Spaces:
Running
Running
Jenny Chim commited on
Commit Β·
da8db3e
1
Parent(s): d3cbe09
Deploy DuckDB-backed frontend to
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- .dockerignore +1 -0
- Dockerfile +1 -0
- components/page-loading-state.tsx +6 -0
- components/ui/alert.tsx +0 -66
- lib/dashboard-data-client.ts +0 -9
- lib/data-backend.ts +25 -96
- lib/duckdb-data.ts +99 -137
- lib/model-family.ts +0 -28
- lib/na-utils.ts +5 -0
- notes/migration-plan.md +117 -0
- notes/testing-strategy.md +353 -0
- notes/transformations/01-identity-canonicalization.md +244 -0
- notes/transformations/02-setup-alias-merging.md +155 -0
- notes/transformations/03-license-normalization.md +147 -0
- notes/transformations/04-dataset-url-synthesis.md +173 -0
- notes/transformations/05-slug-candidates.md +206 -0
- notes/transformations/06-developer-name-canonicalization.md +159 -0
- notes/transformations/07-timestamp-normalization.md +165 -0
- notes/transformations/08-benchmark-display-names.md +329 -0
- notes/transformations/09-metric-display-name-expansion.md +282 -0
- notes/transformations/10-params-parsing.md +376 -0
- notes/transformations/11-benchmark-card-attachment.md +235 -0
- notes/transformations/12-instance-level-data.md +258 -0
- notes/transformations/README.md +89 -0
- notes/transformations/reshape-design.md +247 -0
- notes/transformations/reshape/03-hierarchy-flatten.md +321 -0
- notes/transformations/reshape/05-composite-eval-rollup.md +230 -0
- notes/transformations/reshape/06-matrix-leaderboard.md +238 -0
- notes/transformations/reshape/14-score-summary-stats.md +194 -0
- notes/transformations/reshape/16-per-category-counts.md +192 -0
- notes/ts-to-pipeline-migration.md +285 -0
- scripts/_server_only_stub.cjs +4 -0
- scripts/dump-adapter-outputs.mts +408 -0
- scripts/server_only_hook.cjs +10 -0
- scripts/verify-benchmark-card-attachment.mjs +325 -0
- scripts/verify-benchmark-display-names.mjs +255 -0
- scripts/verify-dataset-url.mjs +51 -0
- scripts/verify-developer-name.mjs +73 -0
- scripts/verify-identity.mjs +36 -0
- scripts/verify-instance-level-data.mjs +194 -0
- scripts/verify-license.mjs +75 -0
- scripts/verify-params-parsing.mjs +402 -0
- scripts/verify-setup-alias.mjs +99 -0
- scripts/verify-slug-candidates.mjs +117 -0
- scripts/verify-timestamp.mjs +106 -0
- tests/fixtures/manifest.json +1 -1
- tests/pipeline-contract.test.ts +41 -0
- tests/transformations/benchmark-card-attachment.test.ts +439 -0
- tests/transformations/benchmark-display-names.test.ts +269 -0
- tests/transformations/dataset-url-synthesis.test.ts +132 -0
.dockerignore
CHANGED
|
@@ -9,3 +9,4 @@ out
|
|
| 9 |
.vscode
|
| 10 |
.idea
|
| 11 |
*.log
|
|
|
|
|
|
| 9 |
.vscode
|
| 10 |
.idea
|
| 11 |
*.log
|
| 12 |
+
.cache
|
Dockerfile
CHANGED
|
@@ -40,6 +40,7 @@ COPY --from=builder /app/node_modules ./node_modules
|
|
| 40 |
COPY --from=builder /app/.next ./.next
|
| 41 |
COPY --from=builder /app/public ./public
|
| 42 |
COPY --from=builder /app/data ./data
|
|
|
|
| 43 |
COPY --from=builder /app/next.config.mjs ./next.config.mjs
|
| 44 |
|
| 45 |
# Expose a common port (informational). Hugging Face Spaces will inject $PORT at runtime
|
|
|
|
| 40 |
COPY --from=builder /app/.next ./.next
|
| 41 |
COPY --from=builder /app/public ./public
|
| 42 |
COPY --from=builder /app/data ./data
|
| 43 |
+
COPY --from=builder /app/.cache ./.cache
|
| 44 |
COPY --from=builder /app/next.config.mjs ./next.config.mjs
|
| 45 |
|
| 46 |
# Expose a common port (informational). Hugging Face Spaces will inject $PORT at runtime
|
components/page-loading-state.tsx
CHANGED
|
@@ -1,5 +1,11 @@
|
|
| 1 |
"use client"
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import { useEffect, useMemo, useState } from "react"
|
| 4 |
|
| 5 |
import { cn } from "@/lib/utils"
|
|
|
|
| 1 |
"use client"
|
| 2 |
|
| 3 |
+
// TODO: check if deprecated. No production callers as of 2026-04-28 orphan sweep
|
| 4 |
+
// (zero references in app/, components/, lib/, tests/). Original author added
|
| 5 |
+
// this on 2026-04-15 (commit "Differentiate audience modes and tighten eval
|
| 6 |
+
// navigation"). No clear replacement found β could be intended for a future
|
| 7 |
+
// page/route, or could be safe to delete if the intent has changed.
|
| 8 |
+
|
| 9 |
import { useEffect, useMemo, useState } from "react"
|
| 10 |
|
| 11 |
import { cn } from "@/lib/utils"
|
components/ui/alert.tsx
DELETED
|
@@ -1,66 +0,0 @@
|
|
| 1 |
-
import * as React from "react"
|
| 2 |
-
import { cva, type VariantProps } from "class-variance-authority"
|
| 3 |
-
|
| 4 |
-
import { cn } from "@/lib/utils"
|
| 5 |
-
|
| 6 |
-
const alertVariants = cva(
|
| 7 |
-
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
| 8 |
-
{
|
| 9 |
-
variants: {
|
| 10 |
-
variant: {
|
| 11 |
-
default: "bg-card text-card-foreground",
|
| 12 |
-
destructive:
|
| 13 |
-
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
| 14 |
-
},
|
| 15 |
-
},
|
| 16 |
-
defaultVariants: {
|
| 17 |
-
variant: "default",
|
| 18 |
-
},
|
| 19 |
-
}
|
| 20 |
-
)
|
| 21 |
-
|
| 22 |
-
function Alert({
|
| 23 |
-
className,
|
| 24 |
-
variant,
|
| 25 |
-
...props
|
| 26 |
-
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
| 27 |
-
return (
|
| 28 |
-
<div
|
| 29 |
-
data-slot="alert"
|
| 30 |
-
role="alert"
|
| 31 |
-
className={cn(alertVariants({ variant }), className)}
|
| 32 |
-
{...props}
|
| 33 |
-
/>
|
| 34 |
-
)
|
| 35 |
-
}
|
| 36 |
-
|
| 37 |
-
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
| 38 |
-
return (
|
| 39 |
-
<div
|
| 40 |
-
data-slot="alert-title"
|
| 41 |
-
className={cn(
|
| 42 |
-
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
| 43 |
-
className
|
| 44 |
-
)}
|
| 45 |
-
{...props}
|
| 46 |
-
/>
|
| 47 |
-
)
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
function AlertDescription({
|
| 51 |
-
className,
|
| 52 |
-
...props
|
| 53 |
-
}: React.ComponentProps<"div">) {
|
| 54 |
-
return (
|
| 55 |
-
<div
|
| 56 |
-
data-slot="alert-description"
|
| 57 |
-
className={cn(
|
| 58 |
-
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
| 59 |
-
className
|
| 60 |
-
)}
|
| 61 |
-
{...props}
|
| 62 |
-
/>
|
| 63 |
-
)
|
| 64 |
-
}
|
| 65 |
-
|
| 66 |
-
export { Alert, AlertTitle, AlertDescription }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
lib/dashboard-data-client.ts
CHANGED
|
@@ -8,11 +8,6 @@ import type {
|
|
| 8 |
ModelEvaluationSummary,
|
| 9 |
} from "@/lib/eval-processing"
|
| 10 |
|
| 11 |
-
export interface DashboardDataResponse {
|
| 12 |
-
models: BenchmarkEvaluationCardData[]
|
| 13 |
-
evals: BenchmarkEvalListItem[]
|
| 14 |
-
}
|
| 15 |
-
|
| 16 |
export interface EvalListResponse {
|
| 17 |
evals: BenchmarkEvalListItem[]
|
| 18 |
totalModels: number
|
|
@@ -53,10 +48,6 @@ async function fetchJson<T>(input: string): Promise<T> {
|
|
| 53 |
return response.json() as Promise<T>
|
| 54 |
}
|
| 55 |
|
| 56 |
-
export function fetchDashboardData() {
|
| 57 |
-
return fetchJson<DashboardDataResponse>("/api/data")
|
| 58 |
-
}
|
| 59 |
-
|
| 60 |
export function fetchModelCards() {
|
| 61 |
return fetchJson<BenchmarkEvaluationCardData[]>("/api/model-cards-lite")
|
| 62 |
}
|
|
|
|
| 8 |
ModelEvaluationSummary,
|
| 9 |
} from "@/lib/eval-processing"
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
export interface EvalListResponse {
|
| 12 |
evals: BenchmarkEvalListItem[]
|
| 13 |
totalModels: number
|
|
|
|
| 48 |
return response.json() as Promise<T>
|
| 49 |
}
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
export function fetchModelCards() {
|
| 52 |
return fetchJson<BenchmarkEvaluationCardData[]>("/api/model-cards-lite")
|
| 53 |
}
|
lib/data-backend.ts
CHANGED
|
@@ -1,98 +1,27 @@
|
|
| 1 |
import "server-only"
|
| 2 |
|
| 3 |
-
import
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
}
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
export async function getModelCardsLite() {
|
| 30 |
-
if (isDuckDBBackend()) {
|
| 31 |
-
return (await duckdbData()).getModelCardsLiteFromDuckDB()
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
return jsonData.getModelCardsLite()
|
| 35 |
-
}
|
| 36 |
-
|
| 37 |
-
export async function getEvalListData() {
|
| 38 |
-
if (isDuckDBBackend()) {
|
| 39 |
-
return (await duckdbData()).getEvalListDataFromDuckDB()
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
return jsonData.getEvalListData()
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
export async function getEvalListLiteData() {
|
| 46 |
-
if (isDuckDBBackend()) {
|
| 47 |
-
return (await duckdbData()).getEvalListLiteDataFromDuckDB()
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
return jsonData.getEvalListLiteData()
|
| 51 |
-
}
|
| 52 |
-
|
| 53 |
-
export async function getEvalList() {
|
| 54 |
-
if (isDuckDBBackend()) {
|
| 55 |
-
return (await duckdbData()).getEvalListFromDuckDB()
|
| 56 |
-
}
|
| 57 |
-
|
| 58 |
-
return jsonData.getEvalList()
|
| 59 |
-
}
|
| 60 |
-
|
| 61 |
-
export async function getDeveloperList() {
|
| 62 |
-
if (isDuckDBBackend()) {
|
| 63 |
-
return (await duckdbData()).getDeveloperListFromDuckDB()
|
| 64 |
-
}
|
| 65 |
-
|
| 66 |
-
return jsonData.getDeveloperList()
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
export async function getDeveloperSummaryById(routeId: string) {
|
| 70 |
-
if (isDuckDBBackend()) {
|
| 71 |
-
return (await duckdbData()).getDeveloperSummaryByIdFromDuckDB(routeId)
|
| 72 |
-
}
|
| 73 |
-
|
| 74 |
-
return jsonData.getDeveloperSummaryById(routeId)
|
| 75 |
-
}
|
| 76 |
-
|
| 77 |
-
export async function getModelSummaryById(modelId: string) {
|
| 78 |
-
if (isDuckDBBackend()) {
|
| 79 |
-
return (await duckdbData()).getModelSummaryByIdFromDuckDB(modelId)
|
| 80 |
-
}
|
| 81 |
-
|
| 82 |
-
return jsonData.getModelSummaryById(modelId)
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
export async function getEvalSummaryById(evalId: string) {
|
| 86 |
-
if (isDuckDBBackend()) {
|
| 87 |
-
return (await duckdbData()).getEvalSummaryByIdFromDuckDB(evalId)
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
return jsonData.getEvalSummaryById(evalId)
|
| 91 |
-
}
|
| 92 |
-
|
| 93 |
-
// Metadata-style artifacts are intentionally still read through the existing
|
| 94 |
-
// JSON/HF path during the local DuckDB experiment. They are not request-time
|
| 95 |
-
// processing hotspots, and keeping them unchanged avoids broadening rollout.
|
| 96 |
-
export const getBackendManifestData = jsonData.getBackendManifestData
|
| 97 |
-
export const getBackendManifestStatusData = jsonData.getBackendManifestStatusData
|
| 98 |
-
export const getEvalHierarchyData = jsonData.getEvalHierarchyData
|
|
|
|
| 1 |
import "server-only"
|
| 2 |
|
| 3 |
+
import {
|
| 4 |
+
fetchBackendManifest,
|
| 5 |
+
fetchBackendManifestStatus,
|
| 6 |
+
fetchEvalHierarchy,
|
| 7 |
+
} from "@/lib/hf-data"
|
| 8 |
+
|
| 9 |
+
export {
|
| 10 |
+
getDashboardDataFromDuckDB as getDashboardData,
|
| 11 |
+
getModelCardsFromDuckDB as getModelCards,
|
| 12 |
+
getModelCardsLiteFromDuckDB as getModelCardsLite,
|
| 13 |
+
getEvalListDataFromDuckDB as getEvalListData,
|
| 14 |
+
getEvalListLiteDataFromDuckDB as getEvalListLiteData,
|
| 15 |
+
getEvalListFromDuckDB as getEvalList,
|
| 16 |
+
getDeveloperListFromDuckDB as getDeveloperList,
|
| 17 |
+
getDeveloperSummaryByIdFromDuckDB as getDeveloperSummaryById,
|
| 18 |
+
getModelSummaryByIdFromDuckDB as getModelSummaryById,
|
| 19 |
+
getEvalSummaryByIdFromDuckDB as getEvalSummaryById,
|
| 20 |
+
} from "@/lib/duckdb-data"
|
| 21 |
+
|
| 22 |
+
// Metadata-style artifacts are still read through the existing JSON/HF path.
|
| 23 |
+
// They are not request-time processing hotspots and the DuckDB shadow doesn't
|
| 24 |
+
// re-shape them, so calling lib/hf-data directly avoids needless indirection.
|
| 25 |
+
export const getBackendManifestData = fetchBackendManifest
|
| 26 |
+
export const getBackendManifestStatusData = fetchBackendManifestStatus
|
| 27 |
+
export const getEvalHierarchyData = fetchEvalHierarchy
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
lib/duckdb-data.ts
CHANGED
|
@@ -7,30 +7,25 @@ import { DuckDBConnection, type DuckDBValue } from "@duckdb/node-api"
|
|
| 7 |
|
| 8 |
import type { BenchmarkEvalListItem, BenchmarkEvalSummary } from "@/lib/eval-processing"
|
| 9 |
import type { EvaluationCardData, ModelEvaluationSummary } from "@/lib/benchmark-schema"
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
hfEvalEntryToListItem,
|
| 20 |
-
hfModelCardToEvaluationCardData,
|
| 21 |
-
normalizeDeveloperName,
|
| 22 |
-
} from "@/lib/model-data"
|
| 23 |
-
import { createModelFamilySummary } from "@/lib/eval-processing"
|
| 24 |
-
import { flattenModelEvaluations } from "@/lib/hf-data"
|
| 25 |
-
|
| 26 |
-
const PARQUET_DIR = path.join("experimental", "parquet")
|
| 27 |
const PARQUET_FILES = {
|
| 28 |
modelCards: "model_cards.parquet",
|
| 29 |
modelCardsLite: "model_cards_lite.parquet",
|
| 30 |
evalList: "eval_list.parquet",
|
| 31 |
evalListLite: "eval_list_lite.parquet",
|
| 32 |
evalSummaries: "eval_summaries.parquet",
|
|
|
|
|
|
|
| 33 |
modelSummaries: "model_summaries.parquet",
|
|
|
|
| 34 |
developerSummaries: "developer_summaries.parquet",
|
| 35 |
} as const
|
| 36 |
|
|
@@ -56,7 +51,7 @@ async function getParquetPath(key: ParquetFileKey) {
|
|
| 56 |
await fs.access(filePath)
|
| 57 |
} catch {
|
| 58 |
throw new Error(
|
| 59 |
-
`DuckDB backend expected ${filePath}.
|
| 60 |
)
|
| 61 |
}
|
| 62 |
|
|
@@ -71,10 +66,12 @@ async function getConnection() {
|
|
| 71 |
return connectionPromise
|
| 72 |
}
|
| 73 |
|
| 74 |
-
function parsePayload<T>(row: Record<string, unknown>): T {
|
| 75 |
const raw = row.payload_json
|
| 76 |
if (typeof raw !== "string") {
|
| 77 |
-
throw new Error(
|
|
|
|
|
|
|
| 78 |
}
|
| 79 |
|
| 80 |
return JSON.parse(raw) as T
|
|
@@ -85,7 +82,7 @@ async function readPayloads<T>(key: ParquetFileKey, orderBy?: string): Promise<T
|
|
| 85 |
const filePath = await getParquetPath(key)
|
| 86 |
const sql = `SELECT payload_json FROM read_parquet(?)${orderBy ? ` ${orderBy}` : ""}`
|
| 87 |
const reader = await connection.runAndReadAll(sql, [filePath])
|
| 88 |
-
return reader.getRowObjects().map((row) => parsePayload<T>(row))
|
| 89 |
}
|
| 90 |
|
| 91 |
async function readPayloadById<T>(
|
|
@@ -100,7 +97,7 @@ async function readPayloadById<T>(
|
|
| 100 |
[filePath, ...params]
|
| 101 |
)
|
| 102 |
const rows = reader.getRowObjects()
|
| 103 |
-
return rows.length > 0 ? parsePayload<T>(rows[0]) : null
|
| 104 |
}
|
| 105 |
|
| 106 |
async function countRows(key: ParquetFileKey) {
|
|
@@ -130,95 +127,27 @@ function evalListSort(a: BenchmarkEvalListItem, b: BenchmarkEvalListItem) {
|
|
| 130 |
return (a.evaluation_name ?? "").localeCompare(b.evaluation_name ?? "")
|
| 131 |
}
|
| 132 |
|
| 133 |
-
async function attachBenchmarkCardsToEvalListItems(items: BenchmarkEvalListItem[]) {
|
| 134 |
-
return Promise.all(
|
| 135 |
-
items.map(async (item) => {
|
| 136 |
-
if (item.benchmark_card) {
|
| 137 |
-
return item
|
| 138 |
-
}
|
| 139 |
-
|
| 140 |
-
const candidates = [
|
| 141 |
-
item.evaluation_name,
|
| 142 |
-
item.composite_benchmark_key,
|
| 143 |
-
item.composite_benchmark_name,
|
| 144 |
-
].filter(Boolean)
|
| 145 |
-
|
| 146 |
-
for (const name of candidates) {
|
| 147 |
-
const card = await getBenchmarkCard(name)
|
| 148 |
-
if (card) {
|
| 149 |
-
return { ...item, benchmark_card: card }
|
| 150 |
-
}
|
| 151 |
-
}
|
| 152 |
-
|
| 153 |
-
return item
|
| 154 |
-
})
|
| 155 |
-
)
|
| 156 |
-
}
|
| 157 |
-
|
| 158 |
-
function toEvaluationCard(entry: HFModelCardEntry | EvaluationCardData): EvaluationCardData {
|
| 159 |
-
if ("evaluations_count" in entry && "benchmarks_count" in entry) {
|
| 160 |
-
return entry as EvaluationCardData
|
| 161 |
-
}
|
| 162 |
-
|
| 163 |
-
return hfModelCardToEvaluationCardData(entry as HFModelCardEntry)
|
| 164 |
-
}
|
| 165 |
-
|
| 166 |
-
function toEvalListItem(entry: unknown): BenchmarkEvalListItem {
|
| 167 |
-
if (entry && typeof entry === "object" && "evaluation_id" in entry && "composite_benchmark_key" in entry) {
|
| 168 |
-
return entry as BenchmarkEvalListItem
|
| 169 |
-
}
|
| 170 |
-
|
| 171 |
-
return hfEvalEntryToListItem(entry as Parameters<typeof hfEvalEntryToListItem>[0])
|
| 172 |
-
}
|
| 173 |
-
|
| 174 |
-
async function toEvalSummary(payload: unknown): Promise<BenchmarkEvalSummary> {
|
| 175 |
-
if (payload && typeof payload === "object" && "model_results" in payload && "evaluation_id" in payload) {
|
| 176 |
-
return payload as BenchmarkEvalSummary
|
| 177 |
-
}
|
| 178 |
-
|
| 179 |
-
return attachBenchmarkCardToSummary(hfEvalDetailToSummary(payload as HFEvalDetail))
|
| 180 |
-
}
|
| 181 |
-
|
| 182 |
-
function toModelSummary(payload: unknown): ModelEvaluationSummary {
|
| 183 |
-
// The pipeline payload carries `evaluations_by_category` already, but with
|
| 184 |
-
// lowercase category keys, raw timestamps, and per-eval benchmark_card
|
| 185 |
-
// duplicates. The JSON path always re-aggregates via flattenModelEvaluations
|
| 186 |
-
// + createModelFamilySummary; mirror that here so parity is exact. Pushing
|
| 187 |
-
// the post-adapter shape into the pipeline is migration item #3 (`hierarchy
|
| 188 |
-
// β flat BenchmarkEvaluation[] rebuild`).
|
| 189 |
-
const evaluations = flattenModelEvaluations(payload as HFModelDetail)
|
| 190 |
-
if (evaluations.length === 0) {
|
| 191 |
-
throw new Error("DuckDB model summary payload did not contain any model evaluations")
|
| 192 |
-
}
|
| 193 |
-
|
| 194 |
-
return createModelFamilySummary(evaluations)
|
| 195 |
-
}
|
| 196 |
-
|
| 197 |
export async function getModelCardsFromDuckDB(): Promise<EvaluationCardData[]> {
|
| 198 |
-
const entries = await readPayloads<
|
| 199 |
-
return entries.
|
| 200 |
}
|
| 201 |
|
| 202 |
export async function getModelCardsLiteFromDuckDB(): Promise<EvaluationCardData[]> {
|
| 203 |
-
const entries = await readPayloads<
|
| 204 |
-
return entries.
|
| 205 |
}
|
| 206 |
|
| 207 |
export async function getEvalListDataFromDuckDB(): Promise<{
|
| 208 |
evals: BenchmarkEvalListItem[]
|
| 209 |
totalModels: number
|
| 210 |
}> {
|
| 211 |
-
const [
|
| 212 |
-
readPayloads<
|
| 213 |
countRows("modelCards"),
|
| 214 |
])
|
| 215 |
-
const evals = entries
|
| 216 |
-
.map(toEvalListItem)
|
| 217 |
-
.filter((entry) => !(typeof entry.source_data?.hf_repo === "string" && entry.source_data.hf_repo.startsWith("example://")))
|
| 218 |
-
const evalsWithCards = await attachBenchmarkCardsToEvalListItems(evals)
|
| 219 |
|
| 220 |
return {
|
| 221 |
-
evals:
|
| 222 |
totalModels,
|
| 223 |
}
|
| 224 |
}
|
|
@@ -227,16 +156,13 @@ export async function getEvalListLiteDataFromDuckDB(): Promise<{
|
|
| 227 |
evals: BenchmarkEvalListItem[]
|
| 228 |
totalModels: number
|
| 229 |
}> {
|
| 230 |
-
const [
|
| 231 |
-
readPayloads<
|
| 232 |
countRows("modelCardsLite"),
|
| 233 |
])
|
| 234 |
|
| 235 |
return {
|
| 236 |
-
evals:
|
| 237 |
-
.map(toEvalListItem)
|
| 238 |
-
.filter((entry) => !(typeof entry.source_data?.hf_repo === "string" && entry.source_data.hf_repo.startsWith("example://")))
|
| 239 |
-
.sort(evalListSort),
|
| 240 |
totalModels,
|
| 241 |
}
|
| 242 |
}
|
|
@@ -254,62 +180,98 @@ export async function getDashboardDataFromDuckDB() {
|
|
| 254 |
return { models, evals }
|
| 255 |
}
|
| 256 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
export async function getEvalSummaryByIdFromDuckDB(evalId: string) {
|
| 258 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
"evalSummaries",
|
| 260 |
"eval_summary_id = ?",
|
| 261 |
[evalId]
|
| 262 |
)
|
| 263 |
-
|
| 264 |
-
return payload ? toEvalSummary(payload) : null
|
| 265 |
}
|
| 266 |
|
| 267 |
export async function getModelSummaryByIdFromDuckDB(modelId: string) {
|
| 268 |
-
|
| 269 |
"modelSummaries",
|
| 270 |
"model_route_id = ? OR model_family_id = ?",
|
| 271 |
[modelId, modelId]
|
| 272 |
)
|
|
|
|
| 273 |
|
| 274 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
}
|
| 276 |
|
| 277 |
export async function getDeveloperSummaryByIdFromDuckDB(routeId: string) {
|
| 278 |
-
const payload = await readPayloadById<
|
| 279 |
"developerSummaries",
|
| 280 |
"developer_route_id = ?",
|
| 281 |
[routeId]
|
| 282 |
)
|
| 283 |
-
|
| 284 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
}
|
| 286 |
|
| 287 |
export async function getDeveloperListFromDuckDB() {
|
| 288 |
-
const summaries = await readPayloads<
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
(sum, model) => sum + model.total_evaluations,
|
| 295 |
-
0
|
| 296 |
-
)
|
| 297 |
-
const popularEvals = Array.from(benchmarkCounts.entries())
|
| 298 |
-
.sort((a, b) => b[1] - a[1])
|
| 299 |
-
.slice(0, 3)
|
| 300 |
-
.map(([benchmark, model_count]) => ({
|
| 301 |
-
benchmark: getBenchmarkDisplayName(benchmark),
|
| 302 |
-
model_count,
|
| 303 |
-
}))
|
| 304 |
-
|
| 305 |
-
return {
|
| 306 |
-
developer: normalizeDeveloperName(detail.developer),
|
| 307 |
-
route_id: getDeveloperRouteId(detail.developer),
|
| 308 |
-
model_count: detail.models.length,
|
| 309 |
-
benchmark_count: benchmarkCounts.size,
|
| 310 |
-
evaluation_count: evaluationCount,
|
| 311 |
-
popular_evals: popularEvals,
|
| 312 |
-
}
|
| 313 |
-
})
|
| 314 |
.sort((a, b) => a.developer.localeCompare(b.developer))
|
| 315 |
}
|
|
|
|
| 7 |
|
| 8 |
import type { BenchmarkEvalListItem, BenchmarkEvalSummary } from "@/lib/eval-processing"
|
| 9 |
import type { EvaluationCardData, ModelEvaluationSummary } from "@/lib/benchmark-schema"
|
| 10 |
+
|
| 11 |
+
// Parity parquet emitted by `eval_cards_backend_pipeline/scripts/parity_outputs.py`.
|
| 12 |
+
// Each table (except model_results, which is long-form relational) carries
|
| 13 |
+
// scalar routing columns plus a `payload_json` column whose value is already
|
| 14 |
+
// in the post-TS-adapter shape. This module is a strict pass-through: if a
|
| 15 |
+
// payload is missing fields the consumer needs, we throw with the file +
|
| 16 |
+
// payload key path so the backend gap is visible. We DO NOT re-run TS
|
| 17 |
+
// adapters or fill defaults here β that policy belongs upstream.
|
| 18 |
+
const PARQUET_DIR = path.join("duckdb", "v1")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
const PARQUET_FILES = {
|
| 20 |
modelCards: "model_cards.parquet",
|
| 21 |
modelCardsLite: "model_cards_lite.parquet",
|
| 22 |
evalList: "eval_list.parquet",
|
| 23 |
evalListLite: "eval_list_lite.parquet",
|
| 24 |
evalSummaries: "eval_summaries.parquet",
|
| 25 |
+
aggregateEvalSummaries: "aggregate_eval_summaries.parquet",
|
| 26 |
+
matrixEvalSummaries: "matrix_eval_summaries.parquet",
|
| 27 |
modelSummaries: "model_summaries.parquet",
|
| 28 |
+
developers: "developers.parquet",
|
| 29 |
developerSummaries: "developer_summaries.parquet",
|
| 30 |
} as const
|
| 31 |
|
|
|
|
| 51 |
await fs.access(filePath)
|
| 52 |
} catch {
|
| 53 |
throw new Error(
|
| 54 |
+
`DuckDB backend expected ${filePath}. Re-run the backend pipeline (parity parquet is emitted on every run; no env var required).`
|
| 55 |
)
|
| 56 |
}
|
| 57 |
|
|
|
|
| 66 |
return connectionPromise
|
| 67 |
}
|
| 68 |
|
| 69 |
+
function parsePayload<T>(row: Record<string, unknown>, file: ParquetFileKey): T {
|
| 70 |
const raw = row.payload_json
|
| 71 |
if (typeof raw !== "string") {
|
| 72 |
+
throw new Error(
|
| 73 |
+
`[duckdb-data] ${PARQUET_FILES[file]} row had no payload_json string. Backend parity emitter must populate this column.`
|
| 74 |
+
)
|
| 75 |
}
|
| 76 |
|
| 77 |
return JSON.parse(raw) as T
|
|
|
|
| 82 |
const filePath = await getParquetPath(key)
|
| 83 |
const sql = `SELECT payload_json FROM read_parquet(?)${orderBy ? ` ${orderBy}` : ""}`
|
| 84 |
const reader = await connection.runAndReadAll(sql, [filePath])
|
| 85 |
+
return reader.getRowObjects().map((row) => parsePayload<T>(row, key))
|
| 86 |
}
|
| 87 |
|
| 88 |
async function readPayloadById<T>(
|
|
|
|
| 97 |
[filePath, ...params]
|
| 98 |
)
|
| 99 |
const rows = reader.getRowObjects()
|
| 100 |
+
return rows.length > 0 ? parsePayload<T>(rows[0], key) : null
|
| 101 |
}
|
| 102 |
|
| 103 |
async function countRows(key: ParquetFileKey) {
|
|
|
|
| 127 |
return (a.evaluation_name ?? "").localeCompare(b.evaluation_name ?? "")
|
| 128 |
}
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
export async function getModelCardsFromDuckDB(): Promise<EvaluationCardData[]> {
|
| 131 |
+
const entries = await readPayloads<EvaluationCardData>("modelCards")
|
| 132 |
+
return entries.sort(modelCardSort)
|
| 133 |
}
|
| 134 |
|
| 135 |
export async function getModelCardsLiteFromDuckDB(): Promise<EvaluationCardData[]> {
|
| 136 |
+
const entries = await readPayloads<EvaluationCardData>("modelCardsLite")
|
| 137 |
+
return entries.sort(modelCardLiteSort)
|
| 138 |
}
|
| 139 |
|
| 140 |
export async function getEvalListDataFromDuckDB(): Promise<{
|
| 141 |
evals: BenchmarkEvalListItem[]
|
| 142 |
totalModels: number
|
| 143 |
}> {
|
| 144 |
+
const [evals, totalModels] = await Promise.all([
|
| 145 |
+
readPayloads<BenchmarkEvalListItem>("evalList"),
|
| 146 |
countRows("modelCards"),
|
| 147 |
])
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
return {
|
| 150 |
+
evals: evals.sort(evalListSort),
|
| 151 |
totalModels,
|
| 152 |
}
|
| 153 |
}
|
|
|
|
| 156 |
evals: BenchmarkEvalListItem[]
|
| 157 |
totalModels: number
|
| 158 |
}> {
|
| 159 |
+
const [evals, totalModels] = await Promise.all([
|
| 160 |
+
readPayloads<BenchmarkEvalListItem>("evalListLite"),
|
| 161 |
countRows("modelCardsLite"),
|
| 162 |
])
|
| 163 |
|
| 164 |
return {
|
| 165 |
+
evals: evals.sort(evalListSort),
|
|
|
|
|
|
|
|
|
|
| 166 |
totalModels,
|
| 167 |
}
|
| 168 |
}
|
|
|
|
| 180 |
return { models, evals }
|
| 181 |
}
|
| 182 |
|
| 183 |
+
// Resolve `evalId` across the three eval-summary tables. The pipeline emits
|
| 184 |
+
// direct evals into `eval_summaries`, `aggregate__<suite>` rows into
|
| 185 |
+
// `aggregate_eval_summaries`, and `matrix__<suite>` rows into
|
| 186 |
+
// `matrix_eval_summaries`. The TS-side `getEvalSummaryById` (lib/model-data.ts)
|
| 187 |
+
// dispatches by id prefix; the DuckDB path mirrors that without re-running
|
| 188 |
+
// any aggregation TS β the parity payloads already carry the post-TS shape.
|
| 189 |
export async function getEvalSummaryByIdFromDuckDB(evalId: string) {
|
| 190 |
+
if (evalId.startsWith("aggregate__")) {
|
| 191 |
+
return readPayloadById<BenchmarkEvalSummary>(
|
| 192 |
+
"aggregateEvalSummaries",
|
| 193 |
+
"eval_summary_id = ?",
|
| 194 |
+
[evalId]
|
| 195 |
+
)
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
if (evalId.startsWith("matrix__")) {
|
| 199 |
+
return readPayloadById<BenchmarkEvalSummary>(
|
| 200 |
+
"matrixEvalSummaries",
|
| 201 |
+
"eval_summary_id = ?",
|
| 202 |
+
[evalId]
|
| 203 |
+
)
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
return readPayloadById<BenchmarkEvalSummary>(
|
| 207 |
"evalSummaries",
|
| 208 |
"eval_summary_id = ?",
|
| 209 |
[evalId]
|
| 210 |
)
|
|
|
|
|
|
|
| 211 |
}
|
| 212 |
|
| 213 |
export async function getModelSummaryByIdFromDuckDB(modelId: string) {
|
| 214 |
+
return readPayloadById<ModelEvaluationSummary>(
|
| 215 |
"modelSummaries",
|
| 216 |
"model_route_id = ? OR model_family_id = ?",
|
| 217 |
[modelId, modelId]
|
| 218 |
)
|
| 219 |
+
}
|
| 220 |
|
| 221 |
+
// Shape contract for `developers.parquet` and `developer_summaries.parquet`
|
| 222 |
+
// payloads. The summary table additionally carries a `models[]` array of
|
| 223 |
+
// post-`hfModelCardToEvaluationCardData` rows (matching the JSON-path
|
| 224 |
+
// `hfDeveloperDetailToSummary` output). If the backend has not yet run the
|
| 225 |
+
// adapter, the parity verifier will flag the divergence.
|
| 226 |
+
interface DeveloperListEntry {
|
| 227 |
+
developer: string
|
| 228 |
+
route_id: string
|
| 229 |
+
model_count: number
|
| 230 |
+
benchmark_count: number
|
| 231 |
+
evaluation_count: number
|
| 232 |
+
popular_evals: Array<{ benchmark: string; model_count: number }>
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
interface DeveloperSummaryPayload extends DeveloperListEntry {
|
| 236 |
+
models: EvaluationCardData[]
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
function assertDeveloperListShape(payload: unknown, source: string): asserts payload is DeveloperListEntry {
|
| 240 |
+
if (!payload || typeof payload !== "object") {
|
| 241 |
+
throw new Error(`[duckdb-data] ${source}: payload was not an object.`)
|
| 242 |
+
}
|
| 243 |
+
const required = ["developer", "route_id", "model_count", "benchmark_count", "evaluation_count", "popular_evals"]
|
| 244 |
+
for (const key of required) {
|
| 245 |
+
if (!(key in payload)) {
|
| 246 |
+
throw new Error(
|
| 247 |
+
`[duckdb-data] ${source}: payload missing field \`${key}\`. Backend parity emitter must run hf_developer_detail_to_summary before writing parquet.`
|
| 248 |
+
)
|
| 249 |
+
}
|
| 250 |
+
}
|
| 251 |
}
|
| 252 |
|
| 253 |
export async function getDeveloperSummaryByIdFromDuckDB(routeId: string) {
|
| 254 |
+
const payload = await readPayloadById<unknown>(
|
| 255 |
"developerSummaries",
|
| 256 |
"developer_route_id = ?",
|
| 257 |
[routeId]
|
| 258 |
)
|
| 259 |
+
if (!payload) return null
|
| 260 |
+
assertDeveloperListShape(payload, `developer_summaries.parquet (route_id=${routeId})`)
|
| 261 |
+
if (!Array.isArray((payload as DeveloperSummaryPayload).models)) {
|
| 262 |
+
throw new Error(
|
| 263 |
+
`[duckdb-data] developer_summaries.parquet (route_id=${routeId}): payload missing \`models\` array.`
|
| 264 |
+
)
|
| 265 |
+
}
|
| 266 |
+
return payload as DeveloperSummaryPayload
|
| 267 |
}
|
| 268 |
|
| 269 |
export async function getDeveloperListFromDuckDB() {
|
| 270 |
+
const summaries = await readPayloads<unknown>("developers")
|
| 271 |
+
for (const payload of summaries) {
|
| 272 |
+
assertDeveloperListShape(payload, "developers.parquet")
|
| 273 |
+
}
|
| 274 |
+
return (summaries as DeveloperListEntry[])
|
| 275 |
+
.slice()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
.sort((a, b) => a.developer.localeCompare(b.developer))
|
| 277 |
}
|
lib/model-family.ts
CHANGED
|
@@ -189,34 +189,6 @@ export function getCanonicalModelIdentity(modelInfo: ModelInfo): ParsedModelIden
|
|
| 189 |
}
|
| 190 |
}
|
| 191 |
|
| 192 |
-
export function normalizeModelInfo(modelInfo: ModelInfo): ModelInfo {
|
| 193 |
-
const identity = getCanonicalModelIdentity(modelInfo)
|
| 194 |
-
const additionalArchitecture =
|
| 195 |
-
typeof modelInfo.additional_details?.architecture === "string"
|
| 196 |
-
? modelInfo.additional_details.architecture
|
| 197 |
-
: undefined
|
| 198 |
-
const rawParamsBillions = modelInfo.additional_details?.params_billions
|
| 199 |
-
const parsedParamsBillions =
|
| 200 |
-
typeof rawParamsBillions === "number"
|
| 201 |
-
? rawParamsBillions
|
| 202 |
-
: typeof rawParamsBillions === "string"
|
| 203 |
-
? Number.parseFloat(rawParamsBillions)
|
| 204 |
-
: null
|
| 205 |
-
|
| 206 |
-
return {
|
| 207 |
-
...modelInfo,
|
| 208 |
-
id: modelInfo.id.trim(),
|
| 209 |
-
name: identity.variantDisplayName,
|
| 210 |
-
architecture: modelInfo.architecture ?? additionalArchitecture,
|
| 211 |
-
parameter_count:
|
| 212 |
-
modelInfo.parameter_count ??
|
| 213 |
-
(Number.isFinite(parsedParamsBillions ?? NaN) ? `${parsedParamsBillions}B` : undefined),
|
| 214 |
-
model_version:
|
| 215 |
-
modelInfo.model_version ??
|
| 216 |
-
(identity.variantKey === "base" ? undefined : identity.variantLabel),
|
| 217 |
-
}
|
| 218 |
-
}
|
| 219 |
-
|
| 220 |
export function getModelFamilyRouteId(model: ModelInfo | string) {
|
| 221 |
const familyId =
|
| 222 |
typeof model === "string" ? model.trim() : getCanonicalModelIdentity(model).familyId
|
|
|
|
| 189 |
}
|
| 190 |
}
|
| 191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
export function getModelFamilyRouteId(model: ModelInfo | string) {
|
| 193 |
const familyId =
|
| 194 |
typeof model === "string" ? model.trim() : getCanonicalModelIdentity(model).familyId
|
lib/na-utils.ts
CHANGED
|
@@ -2,6 +2,11 @@
|
|
| 2 |
// It intentionally only treats the category as NA when the category-level field
|
| 3 |
// `additionalAspects` explicitly marks it as not applicable. Question-level/source
|
| 4 |
// markers are ignored for category selectability (they are relevant to question details).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
export function naReasonForCategoryFromEval(
|
| 6 |
catEval: any,
|
| 7 |
benchmarkQuestionIds: string[] = [],
|
|
|
|
| 2 |
// It intentionally only treats the category as NA when the category-level field
|
| 3 |
// `additionalAspects` explicitly marks it as not applicable. Question-level/source
|
| 4 |
// markers are ignored for category selectability (they are relevant to question details).
|
| 5 |
+
//
|
| 6 |
+
// TODO: check if deprecated. No production callers as of 2026-04-28 orphan sweep
|
| 7 |
+
// (only consumer is tests/na-utils.test.ts). Original author added this with
|
| 8 |
+
// thorough tests on 2025-08-16 β possibly intended for an unshipped UI feature,
|
| 9 |
+
// possibly disconnected during a refactor.
|
| 10 |
export function naReasonForCategoryFromEval(
|
| 11 |
catEval: any,
|
| 12 |
benchmarkQuestionIds: string[] = [],
|
notes/migration-plan.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TSβpipeline migration plan
|
| 2 |
+
|
| 3 |
+
Drafted 2026-04-27. Companion to `notes/ts-to-pipeline-migration.md` (the original 20-item catalog) and `notes/testing-strategy.md` (the safety net we're building before doing more deletions).
|
| 4 |
+
|
| 5 |
+
## What's already shipped
|
| 6 |
+
|
| 7 |
+
See `notes/ts-to-pipeline-migration.md` "Status" section for full detail. Briefly:
|
| 8 |
+
|
| 9 |
+
- **#4 source-metadata synthesis fallback** β deleted. Pipeline emits on every row; runtime `assertSourceMetadata` guard added at read sites; 86 183/86 183 production rows now show real first/third-party badges (was the explicit goal).
|
| 10 |
+
- **#11 category β partial** β extended `PIPELINE_CATEGORY_MAP` with the 3 missing pipeline keys (all β "General"); removed the regex fallback from `mapHFCategories`. Reverted the more-aggressive call-site changes after subagent audit found 1 500+ Safety rows would have been silently General-ified. Left as TS code path until pipeline-side category accuracy improves (84% of evals currently emit `category: "other"`).
|
| 11 |
+
|
| 12 |
+
## Framing: TS is the current source of truth
|
| 13 |
+
|
| 14 |
+
Every TS transformation in `lib/` exists because the pipeline didn't do it (yet) β token canonicalization, variant grouping, source-metadata defaults, category inference, score normalization, etc. The migration is to lift each transformation upstream so the pipeline emits canonical data and every consumer reads instead of re-deriving.
|
| 15 |
+
|
| 16 |
+
Two failure modes to avoid (per the Phase 3 category regression and the 2026-04-28 v/V finding):
|
| 17 |
+
|
| 18 |
+
- **Treating a transformation as if it were a default.** TS rules that *normalize* (always overwrite) must be implemented as normalization in pipeline; rules that *fill in defaults* (only when value missing) must be defaults. Misclassifying causes silent data shifts. Each spec calls out which kind it is.
|
| 19 |
+
- **Deleting TS before verifying pipeline matches across the full corpus.** `pnpm audit-adapters --diff` is the verification gate. No deletion ships until pipeline-side output is byte-identical (or differences are explicitly accepted in writing).
|
| 20 |
+
|
| 21 |
+
## Data direction: cleaning upstream, reshape in SQL, presentation stays in TS
|
| 22 |
+
|
| 23 |
+
Standing principle for this migration and beyond. Every TS transformation belongs in one of three places:
|
| 24 |
+
|
| 25 |
+
- **Data cleaning / standardization β pipeline** (changes what the data *is*; canonical form every consumer would want). License strings, developer names, identity tokens, timestamp formats, category labels, source-metadata defaults. Pipeline emits canonical values; every consumer reads. The per-item workflow below is built for this class.
|
| 26 |
+
- **Reshape / dedup / aggregate / sort / filter β DuckDB SQL** (derived view over universal data; every consumer would compute the same thing). Variant dedup, "freshest among candidates", per-category counts, hierarchy flattening, composite rollups. Two valid landing spots: materialized into the pipeline's parquet output (read by DuckDB without further computation) or expressed as SQL at query time. Favor materialization when the answer is identical for every consumer; favor query-time SQL when consumers slice differently.
|
| 27 |
+
- **UI / presentation policy β stays in TS** (app-specific curation choices; different consumers would have different opinions). Card layout, color choices, sort orders for category displays, icon assignments, which fields to surface vs hide. These encode product judgments specific to this app β pipeline-emitting them would inflict this UI's choices on every other consumer of the dataset. Keep in TS, treat as out of scope for the migration.
|
| 28 |
+
|
| 29 |
+
The test for which category an item belongs in: *would every reasonable consumer want the same answer?* If yes, it's cleaning or reshape (depending on whether it's a value transform or a derived view). If no, it's UI policy and stays.
|
| 30 |
+
|
| 31 |
+
A trap to watch for: an item can *look* like UI policy because the TS code encodes opinion (e.g. a regex map that ranks things), but actually the curation already lives in the pipeline and the TS code is dead/passthrough. **Always trace which code path actually runs in production before classifying β grep for the function name across `app/`/`components/`/`scripts/`, then chain through caller graphs.** "TS file exists" doesn't mean "TS is doing the work."
|
| 32 |
+
|
| 33 |
+
The current DuckDB backend is mid-migration scaffolding. The parquet schema (see `pipeline.py write_experimental_parquet_table`) has 11 typed metadata columns (`record_type`, `model_route_id`, `model_family_id`, `eval_summary_id`, `developer_route_id`, `developer`, `category`, `benchmark_family_key`, `models_count`, `total_evaluations`, `last_updated`) plus a `payload_json VARCHAR` column. DuckDB queries today use the metadata columns for routing (`WHERE eval_summary_id = ?`) and always select `payload_json` for the substantive data β variants, model_results, scores, retrieved_timestamps are all nested inside the blob. SQL can route, but it can't reshape what it can't see.
|
| 34 |
+
|
| 35 |
+
Two open design questions for the reshape class, both legitimate, neither decided:
|
| 36 |
+
|
| 37 |
+
- **How relational does parquet need to go?** Promoting nested fields to columns (e.g. one row per `(eval_summary_id, variant_key, retrieved_timestamp, source_metadata)` for the variant dedup case) lets SQL do the work directly: `SELECT β¦ QUALIFY ROW_NUMBER() OVER (PARTITION BY variant_key ORDER BY retrieved_timestamp DESC) = 1`. But it's a substantial schema change negotiated with the pipeline owner.
|
| 38 |
+
- **Materialize the answer upstream vs. compute at query time?** Materialize when the answer is identical for every consumer (variant dedup, per-category counts). Compute at query time when consumers slice differently (filtered top-N, user-selected category aggregates). The split is per-case judgment.
|
| 39 |
+
|
| 40 |
+
What the principle rules out: keeping reshape work in TS adapters after data cleaning moves upstream. "Spec done, pipeline matches, TS deleted" is only complete if the work that's left is presentation, not computation. When specing each item, classify it: cleaning (per-item workflow) or reshape (queue for the parquet-schema / SQL design conversation).
|
| 41 |
+
|
| 42 |
+
## Per-item workflow
|
| 43 |
+
|
| 44 |
+
For each remaining item, the work is the same shape:
|
| 45 |
+
|
| 46 |
+
1. **Spec the transformation** β write `notes/transformations/NN-<name>.md` per the template in `notes/transformations/README.md`. Capture every rule branch. Two required classifications: (a) default-vs-normalization (see Framing section above), (b) cleaning-vs-reshape (see Data direction section above). If it's reshape, capture the *operation* and flag for the SQL design conversation rather than writing a line-by-line TS translation. Document detected divergences against pipeline.
|
| 47 |
+
2. **Test the transformation** β write `tests/transformations/<name>.test.ts` with parameterized tests sourced from the spec table. These double as the executable acceptance criterion for pipeline.
|
| 48 |
+
3. **Hand to pipeline** β file the spec + tests with the pipeline owner (an issue/PR in `eval_cards_backend_pipeline`). Reference the unit tests as the contract.
|
| 49 |
+
4. **Verify cross-corpus match** β once pipeline ships, run the verification script (`scripts/verify-identity.mjs` for #1, similar scripts per item) against the full live cache. Zero divergences before proceeding.
|
| 50 |
+
5. **Pre-process in this repo (optional, if needed)** β only when the gap between TS and pipeline is intolerable to wait through. Not the default. A one-shot Python pre-processor at data ingestion time is acceptable; on-the-fly TS computation is what we're trying to leave behind.
|
| 51 |
+
6. **Delete TS** β remove the implementation; update callers to read pipeline fields directly. This is its own task in the tasklist (e.g. #5b for #1), gated on step 4.
|
| 52 |
+
|
| 53 |
+
## The 18 remaining items
|
| 54 |
+
|
| 55 |
+
| Item | Transformation (TS location) | Status | Spec |
|
| 56 |
+
|---|---|---|---|
|
| 57 |
+
| #1 | Identity canonicalization (`lib/model-family.ts`) | spec written 2026-04-28; awaiting pipeline | [01-identity-canonicalization.md](transformations/01-identity-canonicalization.md) |
|
| 58 |
+
| #2 | Setup-alias merging (`lib/eval-processing.ts:371-434`) | not yet specced | β |
|
| 59 |
+
| #3 | Hierarchy flatten + family summary (`lib/hf-data.ts flattenModelEvaluations`, `lib/eval-processing.ts createModelFamilySummary`) | not yet specced; structural decision pending (consumer rewrite vs pipeline-emit-flat-list) | β |
|
| 60 |
+
| #5 | Composite eval rollup (`lib/model-data.ts:874-1041 aggregateBenchmarkSummaries`, ~170 lines) | not yet specced | β |
|
| 61 |
+
| #6 | Matrix leaderboard synthesis (`lib/model-data.ts:1043-1214`, ~170 lines) | not yet specced | β |
|
| 62 |
+
| #7 | Per-instance JSONL normalization (`lib/hf-data.ts:919-1029`, ~110 lines of heuristics) | not yet specced; biggest brittleness | β |
|
| 63 |
+
| #8 | Benchmark display names (`BENCHMARK_NAMES` in `lib/model-data.ts:92`, `SUITE_DISPLAY_NAMES`, etc.) | not yet specced | β |
|
| 64 |
+
| #9 | Developer name canonicalization (`KNOWN_DEVELOPER_NAMES` in `lib/model-data.ts:201-228`) | not yet specced | β |
|
| 65 |
+
| #10 | Metric display-name expansion (`GENERIC_EVALUATION_NAMES` + `prefersBenchmarkName` heuristic) | not yet specced | β |
|
| 66 |
+
| #11 | Category inference (`inferCategoryFromBenchmark` regex in `lib/benchmark-schema.ts:182-206`) | partially handled; pipeline category is too noisy ("other" 84% of evals) β TS regex is the more accurate spec until pipeline improves | β |
|
| 67 |
+
| #12 | Params parsing (`parseParamsBillions` in `lib/model-data.ts:296-338` + dups) | not yet specced | β |
|
| 68 |
+
| #13 | Timestamp normalization (`toComparableTimestamp` + ~5 dups) | not yet specced; small | β |
|
| 69 |
+
| #14 | Score summary stats (`groupEvaluationsByBenchmark` finalisation) | not yet specced | β |
|
| 70 |
+
| #16 | Per-category benchmark counts (`hfModelCardToEvaluationCardData` proportional split) | not yet specced; today TS uses `Math.floor(total / categories.length)` as a fake distribution | β |
|
| 71 |
+
| #17 | Benchmark-card attachment (`attachBenchmarkCardToSummary` + 3-candidate retry) | not yet specced | β |
|
| 72 |
+
| #18 | License canonicalization (`LICENSE_COLORS`/`shortenLicense` in `components/eval-card.tsx:22-48`) | not yet specced | β |
|
| 73 |
+
| #19 | Slug candidate generation (`getModelDetailSlugCandidates`/`getDeveloperSlugCandidates`) | not yet specced; the 6-spelling retry in `getModelSummaryById` is the symptom | β |
|
| 74 |
+
| #20 | Dataset URL synthesis (`components/eval-card.tsx:81-89`) | not yet specced; tiny | β |
|
| 75 |
+
|
| 76 |
+
The pipeline lives at `/Users/jchim/projects/eval_cards_backend_pipeline`. See its `AGENTS.md` for Python conventions, run instructions, and the `EXPORT_EXPERIMENTAL_PARQUET=1` flag. Pipeline changes are full-rebuild β `output/` is wiped and rewritten each run.
|
| 77 |
+
|
| 78 |
+
## Recommended order
|
| 79 |
+
|
| 80 |
+
1. **Testing harness** β done 2026-04-27 (Tier A/B/C + fixtures + audit script).
|
| 81 |
+
2. **#1 identity canonicalization** β spec written 2026-04-28, awaiting pipeline implementation.
|
| 82 |
+
3. **#2 setup-alias merging** β same shape as #1; spec next.
|
| 83 |
+
4. **Small wins #16, #18, #19, #20** β each is a contained transformation. Spec, hand off, batch them on the pipeline side.
|
| 84 |
+
5. **#3 hierarchy flatten** β structural decision needed first (consumer-rewrite vs pipeline-emit-flat-list). Bigger work.
|
| 85 |
+
6. **#5, #6, #14** β composites + matrix + summary stats. Bigger pipeline work.
|
| 86 |
+
7. **#11 category** β pipeline must improve `category` accuracy (84% currently emit `other`); until then TS regex is the spec.
|
| 87 |
+
8. **#7 per-instance JSONL** β biggest brittleness, biggest payoff. Defer until others are done.
|
| 88 |
+
|
| 89 |
+
Items #8, #9, #10, #12, #13, #17 fold into the natural sweep around #3 or #14 β they share consumers.
|
| 90 |
+
|
| 91 |
+
## What can be parallelized right now
|
| 92 |
+
|
| 93 |
+
If multiple agents/sessions run in parallel:
|
| 94 |
+
|
| 95 |
+
- **Agent 1 (this repo):** build `tests/fixtures/` + `tests/pipeline-contract.test.ts` (Tier A from testing-strategy.md)
|
| 96 |
+
- **Agent 2 (this repo):** build `scripts/audit-adapters.mjs` (Tier C from testing-strategy.md)
|
| 97 |
+
- **Agent 3 (pipeline repo):** sweep through #16, #18, #19, #20 β small Python emissions
|
| 98 |
+
|
| 99 |
+
Tier B (snapshot tests) needs the fixture set first, so it serializes after Agent 1.
|
| 100 |
+
|
| 101 |
+
After tests are in: TS deletions #1 and #2 can each be a parallel agent.
|
| 102 |
+
|
| 103 |
+
## Cross-repo coordination
|
| 104 |
+
|
| 105 |
+
When pipeline-side work happens, it changes the contract our TS depends on. Sequence:
|
| 106 |
+
|
| 107 |
+
1. Pipeline emits new field, runs `EXPORT_EXPERIMENTAL_PARQUET=1` locally to materialize.
|
| 108 |
+
2. Pipeline ships; user re-publishes to HF.
|
| 109 |
+
3. Our repo: `pnpm cache-hf-data` to refresh local cache.
|
| 110 |
+
4. Our repo: `pnpm refresh-fixtures` to pin new shape.
|
| 111 |
+
5. Our repo: `pnpm test` to confirm contracts pass.
|
| 112 |
+
6. Our repo: do the TS deletion.
|
| 113 |
+
7. Our repo: `pnpm test` again β snapshots will diff if behavior changes.
|
| 114 |
+
|
| 115 |
+
For the local development loop, both repos can be exercised against `eval_cards_backend_pipeline/output/` directly using the `HF_DATA_LOCAL_DIR` + `HF_DATA_OFFLINE` env vars from `notes/ts-to-pipeline-migration.md`. No HF round-trip needed for testing.
|
| 116 |
+
|
| 117 |
+
For the full picture of how upstream changes propagate (drift detection, scenario matrix, cross-repo workflow), see `notes/testing-strategy.md` Β§ "How upstream changes propagate" and Β§ "Workflows".
|
notes/testing-strategy.md
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Testing strategy for the TSβpipeline migration
|
| 2 |
+
|
| 3 |
+
Drafted 2026-04-27. The motivation is the 2026-04-27 review session: subagent audits caught two regressions the parity harness missed (a 22% category change and a `coding: "Reasoning"` mistake based on a substring fallacy). Both required full-production-cache analysis to surface. Subagent audits are not a sustainable workflow.
|
| 4 |
+
|
| 5 |
+
## Design principle: separate code drift from upstream drift
|
| 6 |
+
|
| 7 |
+
Upstream data (the published `evaleval/card_backend` HF dataset) is our best guess at a source of truth, but it isn't immutable. Pipeline-side relabeling, registry updates, and schema changes happen. If our regression tests run against live data, every upstream update lights up the test suite and we can't tell "I broke something" from "upstream changed something I happen to consume."
|
| 8 |
+
|
| 9 |
+
The fix: **pin tests to a committed snapshot of upstream data**. Refresh the snapshot deliberately (script + commit), and the snapshot diff + test diff arrive together for review. Live-data drift detection is a separate, opt-in concern.
|
| 10 |
+
|
| 11 |
+
```
|
| 12 |
+
βββ tests run against βββ
|
| 13 |
+
live cache ββββ€ ββββ pinned fixtures βββ tests
|
| 14 |
+
βββ refresh script ββββββ (committed)
|
| 15 |
+
(manual, reviewed)
|
| 16 |
+
```
|
| 17 |
+
|
| 18 |
+
Live cache drift is checked by an opt-in audit, not by the test suite.
|
| 19 |
+
|
| 20 |
+
## The three tiers
|
| 21 |
+
|
| 22 |
+
### Tier A β Pipeline contract tests
|
| 23 |
+
**Catches:** "pipeline upstream silently dropped a field we depend on." Three repeated manual checks (`source_metadata`, `category`, hierarchy keys) motivated automating this.
|
| 24 |
+
|
| 25 |
+
**Mechanic:** vitest file that walks every fixture file and asserts presence/shape of fields the TS code depends on. Each contract is a field-level invariant.
|
| 26 |
+
|
| 27 |
+
**File:** `tests/pipeline-contract.test.ts`
|
| 28 |
+
|
| 29 |
+
**Initial contract set** (every one corresponds to a real failure mode):
|
| 30 |
+
- `every model_result has source_metadata` (we deleted the synthesis fallback assuming this)
|
| 31 |
+
- `every model_result.source_metadata has evaluator_relationship in {first_party, third_party, other}`
|
| 32 |
+
- `every eval-detail has category as a non-empty string`
|
| 33 |
+
- `every eval-detail has eval_summary_id, benchmark, benchmark_leaf_name`
|
| 34 |
+
- `every model card has model_family_id matching pipelineSlugify(model_family_id)`
|
| 35 |
+
- `every hierarchy_by_category key is one of the 9 known pipeline categories`
|
| 36 |
+
- `every BenchmarkEvaluation produced by flattenModelEvaluations has source_metadata` (cross-check: contract + adapter together)
|
| 37 |
+
- `every model card has total_evaluations as a number`
|
| 38 |
+
- `every model_result.retrieved_timestamp parses as a valid Date`
|
| 39 |
+
|
| 40 |
+
**Exit criteria:** all contracts pass against pinned fixtures. Each contract should fail loudly with the offending file path + key path when violated.
|
| 41 |
+
|
| 42 |
+
**Acceptance:** runs in `pnpm test`. Takes <2s. Adding a new contract is 5 lines.
|
| 43 |
+
|
| 44 |
+
### Tier B β Adapter snapshot tests
|
| 45 |
+
**Catches:** "I changed TS code and didn't realize it changes the output for some input shape." This is the bulk of regression-detection.
|
| 46 |
+
|
| 47 |
+
**Mechanic:** vitest snapshot tests. Each adapter Γ each fixture β snapshot. Regenerate via `vitest --update-snapshots` when changes are intentional; review the snapshot diff alongside the code diff.
|
| 48 |
+
|
| 49 |
+
**Files:**
|
| 50 |
+
- `tests/adapters/hf-eval-detail-to-summary.test.ts`
|
| 51 |
+
- `tests/adapters/hf-model-card-to-evaluation-card-data.test.ts`
|
| 52 |
+
- `tests/adapters/flatten-model-evaluations.test.ts`
|
| 53 |
+
- `tests/adapters/hf-developer-detail-to-summary.test.ts`
|
| 54 |
+
- `tests/adapters/hf-eval-entry-to-list-item.test.ts`
|
| 55 |
+
- `tests/adapters/build-benchmark-leaderboard-matrix.test.ts`
|
| 56 |
+
- `tests/adapters/build-single-metric-suite-matrix-summary.test.ts`
|
| 57 |
+
- `tests/adapters/aggregate-benchmark-summaries.test.ts`
|
| 58 |
+
|
| 59 |
+
**Snapshot format:** `tests/__snapshots__/<test>.snap` (vitest default). Commit them.
|
| 60 |
+
|
| 61 |
+
**Acceptance:** `pnpm test` runs all snapshots, reports any diff, exit non-zero on diff. Adding a new fixture is one line of `test.each`.
|
| 62 |
+
|
| 63 |
+
### Tier C β Full-cache differential audit
|
| 64 |
+
**Catches:** "what is the *full* impact of my code change across all 5 830 production models?" Used for big migration items where snapshot fixtures can't enumerate every shape.
|
| 65 |
+
|
| 66 |
+
**Mechanic:** a Node script that runs all adapters against either pinned fixtures or the live cache, produces a deterministic JSON digest (per-output hash + value distributions + invariant violation counts), and supports diff mode.
|
| 67 |
+
|
| 68 |
+
**File:** `scripts/audit-adapters.mjs`
|
| 69 |
+
|
| 70 |
+
**Output digest shape:**
|
| 71 |
+
```json
|
| 72 |
+
{
|
| 73 |
+
"version": 1,
|
| 74 |
+
"source": ".cache/hf-data",
|
| 75 |
+
"generated_at": "2026-04-27T22:00:00Z",
|
| 76 |
+
"adapters": {
|
| 77 |
+
"hfModelCardToEvaluationCardData": {
|
| 78 |
+
"outputs_count": 5830,
|
| 79 |
+
"outputs_hash": "sha256:...", // hash of all outputs concatenated
|
| 80 |
+
"field_distributions": {
|
| 81 |
+
"developer": { "OpenAI": 12, "Anthropic": 8, ... },
|
| 82 |
+
"categories.length": { "1": 100, "2": 2000, "3": 3000, ... },
|
| 83 |
+
"evaluator_count": { "0": 200, "1": 1500, ... }
|
| 84 |
+
}
|
| 85 |
+
},
|
| 86 |
+
"flattenModelEvaluations": {
|
| 87 |
+
"outputs_count": 86183,
|
| 88 |
+
"outputs_hash": "sha256:...",
|
| 89 |
+
"invariant_violations": []
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
**Modes:**
|
| 96 |
+
- `node scripts/audit-adapters.mjs --output baseline.json` β write digest
|
| 97 |
+
- `node scripts/audit-adapters.mjs --output candidate.json` β write digest after change
|
| 98 |
+
- `node scripts/audit-adapters.mjs --diff baseline.json candidate.json` β human-readable diff
|
| 99 |
+
- `node scripts/audit-adapters.mjs --against tests/fixtures` β use pinned set instead of live cache
|
| 100 |
+
- `node scripts/audit-adapters.mjs --live --against .cache/hf-data` β drift check against live data
|
| 101 |
+
|
| 102 |
+
**Acceptance:** runs in <30s against full live cache. Diff mode highlights field-distribution shifts, output-hash changes, and new invariant violations with sample paths.
|
| 103 |
+
|
| 104 |
+
## Fixture management
|
| 105 |
+
|
| 106 |
+
### Source
|
| 107 |
+
|
| 108 |
+
Fixtures are pinned copies of files in `.cache/hf-data/` at a moment in time. They are committed JSON. Reviewers can see them in PR diffs.
|
| 109 |
+
|
| 110 |
+
### Layout
|
| 111 |
+
|
| 112 |
+
```
|
| 113 |
+
tests/fixtures/
|
| 114 |
+
manifest.json β list of fixture IDs + source-cache snapshot ts
|
| 115 |
+
evals/
|
| 116 |
+
helm_classic_truthfulqa.json
|
| 117 |
+
helm_safety.json
|
| 118 |
+
apex_v1.json β first-party (Mercor)
|
| 119 |
+
artificial_analysis_*_aime.json β third-party (AA)
|
| 120 |
+
helm_capabilities.json β composite
|
| 121 |
+
helm_lite_narrativeqa.json β subtask
|
| 122 |
+
rewardbench2_chat.json β coding key in hierarchy
|
| 123 |
+
...
|
| 124 |
+
models/
|
| 125 |
+
openai__gpt-5.json β multiple variants
|
| 126 |
+
anthropic__claude-opus-4-5.json β typical
|
| 127 |
+
google__gemini-3-flash.json β already in the parity test
|
| 128 |
+
...
|
| 129 |
+
developers/
|
| 130 |
+
openai.json
|
| 131 |
+
anthropic.json
|
| 132 |
+
...
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
### Curation criteria
|
| 136 |
+
|
| 137 |
+
Every fixture earns its place by exercising a specific code path. Avoid random sampling.
|
| 138 |
+
|
| 139 |
+
Required edge cases:
|
| 140 |
+
- A model with multiple variants (`openai__gpt-5`)
|
| 141 |
+
- A model with subtask hierarchy (helm_lite, helm_classic)
|
| 142 |
+
- A first-party eval (Mercor ACE/APEX)
|
| 143 |
+
- A third-party eval (Artificial Analysis)
|
| 144 |
+
- A composite eval (helm_capabilities)
|
| 145 |
+
- A matrix eval id pattern (synthetic, but the adapter handles it)
|
| 146 |
+
- An eval with `category: "other"` (most of the corpus)
|
| 147 |
+
- An eval that the regex `inferCategoryFromBenchmark` and the pipeline category disagree on (truthfulqa, helm_safety)
|
| 148 |
+
- A model with setup-alias merging (multiple "prompt"/"fc" variants of same release)
|
| 149 |
+
- An ABC-only benchmark (if any are exposed in eval-list)
|
| 150 |
+
- An aggregate eval URL pattern (`aggregate__<suite>`)
|
| 151 |
+
|
| 152 |
+
Aim for ~25-35 fixtures total. Small enough to review, broad enough to catch the patterns we know about.
|
| 153 |
+
|
| 154 |
+
### Refresh workflow
|
| 155 |
+
|
| 156 |
+
```bash
|
| 157 |
+
pnpm refresh-fixtures # copies tests/fixtures/manifest.json IDs
|
| 158 |
+
# from .cache/hf-data/ into tests/fixtures/
|
| 159 |
+
# bumps manifest.json snapshot_ts
|
| 160 |
+
git diff tests/fixtures/ # review what upstream changed
|
| 161 |
+
pnpm test # snapshot tests will probably diff
|
| 162 |
+
pnpm test -- -u # update snapshots if intentional
|
| 163 |
+
git diff tests/__snapshots__/ # review what adapter outputs changed
|
| 164 |
+
git add ... # commit fixtures + snapshots together
|
| 165 |
+
```
|
| 166 |
+
|
| 167 |
+
The diff in `tests/fixtures/` shows raw upstream changes. The diff in `tests/__snapshots__/` shows what changes when you feed the new data through the adapters. Both belong in the same commit.
|
| 168 |
+
|
| 169 |
+
### Refresh cadence
|
| 170 |
+
|
| 171 |
+
Manual, on demand. Recommended triggers:
|
| 172 |
+
- Before starting a new migration item (to work against current upstream)
|
| 173 |
+
- After observing a discrepancy between live cache and pinned fixtures
|
| 174 |
+
- Periodically (~monthly) to keep fixtures from drifting
|
| 175 |
+
|
| 176 |
+
There is no auto-refresh. The whole point is that upstream changes are reviewed.
|
| 177 |
+
|
| 178 |
+
### Live-data drift detection
|
| 179 |
+
|
| 180 |
+
Separate from regression tests. A vitest file `tests/upstream-drift.test.ts` runs Tier-A contracts against the LIVE cache and reports violations. Run it manually (`pnpm test:drift`); not part of `pnpm test`. If contracts fail there but pass on fixtures, upstream has drifted and someone should refresh fixtures + investigate.
|
| 181 |
+
|
| 182 |
+
## How upstream changes propagate
|
| 183 |
+
|
| 184 |
+
Three independent data layers, each updated by a different command:
|
| 185 |
+
|
| 186 |
+
```
|
| 187 |
+
huggingface.co/datasets/evaleval/card_backend β truth (changes when pipeline publishes)
|
| 188 |
+
β pnpm cache-hf-data β user-triggered download
|
| 189 |
+
βΌ
|
| 190 |
+
.cache/hf-data/ β live local cache (mutable)
|
| 191 |
+
β pnpm refresh-fixtures β user-triggered re-pin
|
| 192 |
+
βΌ
|
| 193 |
+
tests/fixtures/ β committed pinned snapshots
|
| 194 |
+
β pnpm test (adapter outputs)
|
| 195 |
+
βΌ
|
| 196 |
+
tests/__snapshots__/ β committed expected outputs
|
| 197 |
+
```
|
| 198 |
+
|
| 199 |
+
Default `pnpm test` only sees the pinned bottom two layers, so upstream churn never flaps the regression suite by accident. Each upstream change is observed *deliberately* by re-pinning and reviewing the diff.
|
| 200 |
+
|
| 201 |
+
### Scenario matrix β what each layer reports
|
| 202 |
+
|
| 203 |
+
| What changed upstream | `pnpm test` | `pnpm test:drift` (live cache contracts) | `pnpm refresh-fixtures && pnpm test` (snapshot diff) | `pnpm audit-adapters --diff baseline.json candidate.json` |
|
| 204 |
+
|---|---|---|---|---|
|
| 205 |
+
| Pure data refresh, no shape change | β
| β
| β snapshots diff (timestamps, scores) | hash flips for affected adapters |
|
| 206 |
+
| Additive (new field that no adapter consumes) | β
| β
| β
(raw fixture diff visible, snapshots stable) | distributions stable |
|
| 207 |
+
| New enum value (e.g. `evaluator_relationship: "fourth_party"`) | β
| β unknown-value contract | β
unless consumed | distribution gains a key |
|
| 208 |
+
| Drops a required field (e.g. `source_metadata`) | β
| β contract violation with N/M count | β contracts now fail on pinned data too | `throws` count rises |
|
| 209 |
+
| Reclassifies an existing value (e.g. `category: "other"` β `"safety"`) | β
| β
(still a known string) | β snapshots diff for that fixture | hash flips |
|
| 210 |
+
| Renames a field | β
| varies | β snapshot diff + likely contract failure | hash + throws change |
|
| 211 |
+
| Rewrites the schema (breaking) | β
| β multiple contracts | β contracts + snapshots both fail | many hash flips |
|
| 212 |
+
|
| 213 |
+
The "β
" in `pnpm test` for every row is intentional: by design, default tests only fail when *our code* drifts from a pinned baseline. Upstream drift is reported by the opt-in `pnpm test:drift` and by the snapshot diff that lands the moment fixtures are re-pinned.
|
| 214 |
+
|
| 215 |
+
### Drift-triage decision tree
|
| 216 |
+
|
| 217 |
+
A `pnpm test:drift` failure means live cache no longer satisfies a contract our deletions assumed. Three possibilities:
|
| 218 |
+
|
| 219 |
+
1. **Pipeline regressed (e.g. dropped `source_metadata` on some rows)** β coordinate with the pipeline owner to restore. Don't refresh fixtures yet; the regression would propagate into our pinned set. The runtime `assertSourceMetadata` guards (lib/hf-data.ts, lib/model-data.ts) would also start firing in production, providing a second signal.
|
| 220 |
+
2. **Pipeline emitted a new value our enum doesn't recognise (e.g. new `evaluator_relationship`)** β extend the corresponding `KNOWN_*` set in `tests/upstream-drift.test.ts` and `tests/pipeline-contract.test.ts` AND any consumer code that branches on the old set.
|
| 221 |
+
3. **Pipeline made a schema-level change** β review the upstream commit log (`git -C ../eval_cards_backend_pipeline log`) for context, decide if our consumer needs updates, then refresh fixtures.
|
| 222 |
+
|
| 223 |
+
A snapshot diff after `pnpm refresh-fixtures` always means *some* output changed. Read the fixture diff and snapshot diff side-by-side:
|
| 224 |
+
|
| 225 |
+
- Fixture diff explains *what* upstream changed (raw data shift)
|
| 226 |
+
- Snapshot diff explains *how* the adapter projected that change into user-visible output
|
| 227 |
+
- Together β review and decide if the new output is correct (`pnpm test -- -u`) or a regression to fix
|
| 228 |
+
|
| 229 |
+
### Known gaps in drift coverage
|
| 230 |
+
|
| 231 |
+
1. **Stale `.cache/hf-data/`**: `pnpm test:drift` runs against whatever is on disk; it doesn't auto-refresh from huggingface.co. If `pnpm cache-hf-data` hasn't been run recently, "drift" reports stale-cache-vs-fixtures, not upstream-vs-fixtures. Fix: run `pnpm cache-hf-data` before `pnpm test:drift` when you care about true upstream.
|
| 232 |
+
2. **Hand-edited fixtures aren't detected**: nothing checks that `tests/fixtures/X.json` matches what `pnpm refresh-fixtures` would produce. If someone edits a fixture for debugging and forgets to restore, tests stay green against the mutation. Mitigation would be a content-hash entry per fixture in `manifest.json`; defer until it's actually a problem.
|
| 233 |
+
3. **Drift covers Tier A invariants only, not Tier B snapshots**: a value-reclassification (Scenario "reclassifies an existing value" above) is invisible to drift. Detection requires `pnpm refresh-fixtures` (snapshot diff) or `pnpm audit-adapters --live --diff` against an older baseline. By design β running snapshots against live data would flap on every refresh.
|
| 234 |
+
4. **`pnpm test:drift` is opt-in, not scheduled**: nobody runs it unless prompted. A CI nightly cron (or `pnpm test:drift` in a weekly task) would catch upstream contract breaks earlier; currently you discover them only when you next run drift.
|
| 235 |
+
5. **Audit script doesn't check Tier A contracts**: if a row violates a contract, the audit reports it indirectly via increased `throws` count (the runtime guards fire) but you'd need `pnpm test:drift` for the exact contract message and per-row locator.
|
| 236 |
+
|
| 237 |
+
## Build order
|
| 238 |
+
|
| 239 |
+
Tier A first (smallest, foundational). Tier B next (replaces subagent audits for normal regression detection). Tier C last (heaviest tooling).
|
| 240 |
+
|
| 241 |
+
Each tier is independently usable, so they can be built in parallel by different agents:
|
| 242 |
+
|
| 243 |
+
| Tier | Estimated effort | Depends on | Parallelizable? |
|
| 244 |
+
|---|---|---|---|
|
| 245 |
+
| A β contract tests | 1-2h | nothing | yes |
|
| 246 |
+
| B β snapshot tests | 2-3h | fixture set (shared) | mostly |
|
| 247 |
+
| C β audit script | 2-3h | nothing | yes |
|
| 248 |
+
| Fixture set (~25 files) | 1h | curation decisions | shared dep |
|
| 249 |
+
|
| 250 |
+
Recommended: build the fixture set + Tier A in series (one agent), Tier B and Tier C in parallel after fixtures are in.
|
| 251 |
+
|
| 252 |
+
## Test-additions deferred to specific migration items
|
| 253 |
+
|
| 254 |
+
The original Tier B plan listed 8 adapters; 4 are built. The remaining 4 (`hfEvalEntryToListItem`, `aggregateBenchmarkSummaries`, `buildSingleMetricSuiteMatrixSummary`, `createModelFamilySummary`) are deferred to the migration items that touch them β adding fixtures + snapshots speculatively now would be testing-for-testing's-sake. Specifically:
|
| 255 |
+
|
| 256 |
+
- **`hfEvalEntryToListItem` snapshot** β add when starting #1 (identity parsing) or #2 (setup-alias). Needs an `eval_list_entries` fixture group extracted from `.cache/hf-data/eval-list.json`. Cover at least: a typical entry, one with `display_name` starting with "accuracy on " (triggers `prefersBenchmarkName`), one with `display_name` containing "for scorer", one with a missing `display_name`.
|
| 257 |
+
- **Setup-alias collision fixture** β add when starting #2. Pick a model with `additional_details.mode` β {"prompt", "fc", "thinking"} appearing across multiple submissions for the same model_id. `openai__gpt-5.2` model card has thinking variants; find a corresponding model detail file.
|
| 258 |
+
- **`aggregate__<suite>` pattern** β add when starting #5 (composites) or #6 (matrix synthesis). The aggregate URL pattern is synthetic, not on disk; the test would call `aggregateBenchmarkSummaries` directly with a curated input set. Defer until that adapter is actually being touched.
|
| 259 |
+
- **`createModelFamilySummary` snapshot** β add when starting #3. The flatten + family-summary chain is what `getModelSummaryById` returns; snapshotting `createModelFamilySummary(flattenModelEvaluations(model))` locks the full surface before the refactor.
|
| 260 |
+
|
| 261 |
+
## Reshape-class items: testing addendum (added 2026-04-28)
|
| 262 |
+
|
| 263 |
+
The Tier B snapshot framework above assumes the migration target is "pipeline emits the value, TS reads it." That works for cleaning-class items. For **reshape-class** items (#3 hierarchy flatten, #5 composite rollup, #6 matrix synthesis, #14 score summary stats, #16 per-category counts; plus the reshape halves of #2 and #13), the migration target is different: pipeline emits relational rows, **DuckDB SQL** does the dedup/groupby/aggregate. See `notes/migration-plan.md` Β§ "Data direction" for framing.
|
| 264 |
+
|
| 265 |
+
This shifts what the test set has to verify:
|
| 266 |
+
|
| 267 |
+
- **Tier A contracts gain a parquet schema dimension.** Today's contracts assert JSON field invariants on `.cache/hf-data/**`. When the parquet schema goes more relational (e.g. one row per `(eval_summary_id, variant_key, retrieved_timestamp)` for the variant dedup case), Tier A grows a parallel set of contracts asserting the new typed columns are present and well-typed. File: `tests/parquet-contract.test.ts` (new, parallel to `tests/pipeline-contract.test.ts`).
|
| 268 |
+
- **Tier B snapshots become parity gates, not destinations.** Today, `tests/adapters/flatten-model-evaluations.test.ts` snapshots the TS reshape output. Once SQL replaces the TS, the same snapshot becomes a TS-vs-SQL parity assertion: run both, diff. The snapshot is committed; the SQL output is computed at test time; equality is the gate. Reshape-class snapshots stay green during the migration *exactly because* they assert behavior preservation, not implementation. Don't delete them on TS removal β convert them.
|
| 269 |
+
- **Tier C audit script grows a backend dimension.** `scripts/audit-adapters.mjs` currently runs adapters against the live cache. Add `--backend duckdb` so the same adapter contract is exercised against the DuckDB read path, producing a digest that diffs against the JSON-backend digest. This is the full-corpus generalization of `scripts/compare-data-backends.mjs`, but at the adapter-output level rather than the HTTP-endpoint level.
|
| 270 |
+
- **Five of the eight planned Tier B adapters are reshape-class:** `flattenModelEvaluations`, `buildBenchmarkLeaderboardMatrix`, `buildSingleMetricSuiteMatrixSummary`, `aggregateBenchmarkSummaries`, `createModelFamilySummary`. Their snapshots are the contract the SQL replacement must match. Build them when migrating each item β the snapshots gate the deletion.
|
| 271 |
+
|
| 272 |
+
What this doesn't change: cleaning-class items (the 12 that aren't reshape) work exactly as the existing framework describes β refresh fixtures β snapshot diff β review β ship. No structural test changes needed for cleaning items.
|
| 273 |
+
|
| 274 |
+
## What this DOESN'T cover
|
| 275 |
+
|
| 276 |
+
- **End-to-end UI tests.** No clicking through pages. Adapter snapshots are a proxy.
|
| 277 |
+
- **Performance regression.** No timing assertions.
|
| 278 |
+
- **Pipeline-side correctness.** Pipeline has its own tests in the sibling repo. Our contracts assert what we *consume*, not what's *correct upstream*.
|
| 279 |
+
- **The DuckDB shadow read.** That's covered by the existing `scripts/compare-data-backends.mjs` parity harness β at the HTTP-endpoint level. The adapter-level parity for reshape items (TS reshape output vs SQL reshape output) is the addendum above.
|
| 280 |
+
|
| 281 |
+
## Workflows
|
| 282 |
+
|
| 283 |
+
### Migration workflow (TS deletion against current upstream)
|
| 284 |
+
|
| 285 |
+
Use this for items #1, #2, #3 and any pipeline-side change that flows back into deletions in this repo.
|
| 286 |
+
|
| 287 |
+
```bash
|
| 288 |
+
# 1. Sync to current upstream so the work is against fresh data
|
| 289 |
+
pnpm cache-hf-data
|
| 290 |
+
pnpm test:drift # does upstream still satisfy our contracts?
|
| 291 |
+
# if no β triage per "Drift-triage decision tree" first
|
| 292 |
+
|
| 293 |
+
# 2. Re-pin fixtures to current upstream
|
| 294 |
+
pnpm refresh-fixtures
|
| 295 |
+
pnpm test # any pre-deletion snapshot diffs?
|
| 296 |
+
# if yes β review, then `pnpm test -- -u`, separate commit
|
| 297 |
+
# so the pin-update is isolated from the deletion
|
| 298 |
+
|
| 299 |
+
# 3. Capture a full-cache baseline so we can diff the impact of the change
|
| 300 |
+
pnpm audit-adapters --output /tmp/baseline.json --live
|
| 301 |
+
|
| 302 |
+
# 4. Make the deletion (or refactor)
|
| 303 |
+
|
| 304 |
+
# 5. Verify
|
| 305 |
+
pnpm test # snapshots flag any unexpected output change
|
| 306 |
+
pnpm audit-adapters --output /tmp/candidate.json --live
|
| 307 |
+
pnpm audit-adapters --diff /tmp/baseline.json /tmp/candidate.json # full-cache impact
|
| 308 |
+
pnpm compare-data-backends --json-base http://localhost:3001 --duckdb-base http://localhost:3002
|
| 309 |
+
|
| 310 |
+
# 6. Review snapshot diff alongside code diff
|
| 311 |
+
# - intentional behaviour change: `pnpm test -- -u`, document the why in the commit
|
| 312 |
+
# - unintentional: fix the code
|
| 313 |
+
|
| 314 |
+
# 7. Ship
|
| 315 |
+
```
|
| 316 |
+
|
| 317 |
+
Each step covers a distinct failure mode; nothing duplicates. Steps 3, 5b, 5c (the audit captures) are skippable for tiny changes β start with `pnpm test` alone and escalate if you want fuller coverage.
|
| 318 |
+
|
| 319 |
+
### Light-touch workflow (small change, no upstream sync needed)
|
| 320 |
+
|
| 321 |
+
```bash
|
| 322 |
+
pnpm test # baseline green
|
| 323 |
+
# make the change
|
| 324 |
+
pnpm test # snapshots flag any output change
|
| 325 |
+
# review snapshot diff, `pnpm test -- -u` if intentional
|
| 326 |
+
pnpm compare-data-backends ...
|
| 327 |
+
```
|
| 328 |
+
|
| 329 |
+
### Drift-only workflow (you suspect upstream changed)
|
| 330 |
+
|
| 331 |
+
```bash
|
| 332 |
+
pnpm cache-hf-data # ensure local cache is current
|
| 333 |
+
pnpm test:drift # 5 contracts against full live cache
|
| 334 |
+
# if green: upstream still satisfies our deletions' assumptions
|
| 335 |
+
# if red: triage per "Drift-triage decision tree"
|
| 336 |
+
```
|
| 337 |
+
|
| 338 |
+
### Cross-repo workflow (pipeline-side change first, TS deletion later)
|
| 339 |
+
|
| 340 |
+
```bash
|
| 341 |
+
# In ../eval_cards_backend_pipeline
|
| 342 |
+
uv run --with huggingface_hub --no-project python -m scripts.pipeline --dry-run \
|
| 343 |
+
-e EXPORT_EXPERIMENTAL_PARQUET=1
|
| 344 |
+
# verify output/ has the new field
|
| 345 |
+
|
| 346 |
+
# Back in this repo
|
| 347 |
+
pnpm cache-hf-data # picks up the new published artifact
|
| 348 |
+
pnpm test:drift # do we now have a NEW contract we want to assert?
|
| 349 |
+
# if yes: extend tests/pipeline-contract.test.ts + drift
|
| 350 |
+
pnpm refresh-fixtures
|
| 351 |
+
pnpm test # snapshots reflect the new field if any adapter consumes it
|
| 352 |
+
# now eligible to delete the TS code that the pipeline emission obviates
|
| 353 |
+
```
|
notes/transformations/01-identity-canonicalization.md
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Identity canonicalization
|
| 2 |
+
|
| 3 |
+
Drafted 2026-04-28. Migration item #1 in `notes/migration-plan.md`.
|
| 4 |
+
|
| 5 |
+
## Rule
|
| 6 |
+
|
| 7 |
+
Given a `ModelInfo` (`{id, name, developer?}`) emitted by some upstream source, derive a **canonical identity tuple** the rest of the app uses for routing, display, and grouping:
|
| 8 |
+
|
| 9 |
+
```
|
| 10 |
+
{
|
| 11 |
+
namespace, // "anthropic" β owner segment, lowercase
|
| 12 |
+
rawHandle, // "claude-opus-4.5" β model segment as received
|
| 13 |
+
normalizedHandle, // "claude-opus-4.5" β separators collapsed to "-", lowercased
|
| 14 |
+
familySlug, // "claude-opus-4.5" β handle minus version-date suffix
|
| 15 |
+
familyId, // "anthropic/claude-opus-4.5"
|
| 16 |
+
familyName, // "Claude Opus 4.5" β title-cased, with v/V rule
|
| 17 |
+
variantKey, // "base" if no date pattern, else "<YYYYMMDD>" or "<YYYYMMDD>-<qualifier>"
|
| 18 |
+
variantLabel, // "Current" if base, else "<YYYY-MM-DD>" or "<YYYY-MM-DD> Β· <Qualifier>"
|
| 19 |
+
variantDisplayName, // familyName if base, else "<familyName> (<variantLabel>)"
|
| 20 |
+
versionDate?, // "YYYY-MM-DD" if a date pattern was detected
|
| 21 |
+
versionQualifier?, // humanized qualifier suffix, if present
|
| 22 |
+
}
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
## Classification
|
| 26 |
+
|
| 27 |
+
- **Unconditional normalization** for casing rules (token case map, v/V handling) β when the upstream `name` is present, the canonicalizer ignores it for `familyName` and re-derives from the `id`. Pipeline-side fix: pipeline applies these rules once at emission time; no consumer should re-derive.
|
| 28 |
+
- **Default-only** does not apply to this transformation. Every output field is computed unconditionally.
|
| 29 |
+
- **Cleaning β pipeline.** Both outputs (`model_family_id`, `model_family_name`) are value transforms on a single record. No record merging or aggregation. Migration target: pipeline emits canonical values; TS logic deletes.
|
| 30 |
+
|
| 31 |
+
## Inputs and expected outputs
|
| 32 |
+
|
| 33 |
+
The full table below is the executable spec. Every row corresponds to a parameterized test case in `tests/transformations/identity-canonicalization.test.ts`.
|
| 34 |
+
|
| 35 |
+
### Group A β Token case map
|
| 36 |
+
|
| 37 |
+
The TS implementation maintains a hand-curated `TOKEN_CASE_MAP` for tokens that deviate from naive title-casing. Pipeline must produce identical outputs for every token below β no improvements or additions without first updating this spec, the unit tests, and the verification script (in that order). "TS is the spec" β see `notes/transformations/README.md`.
|
| 38 |
+
|
| 39 |
+
| Token (lower) | Canonical |
|
| 40 |
+
|---|---|
|
| 41 |
+
| ai | AI |
|
| 42 |
+
| coder | Coder |
|
| 43 |
+
| command | Command |
|
| 44 |
+
| chat | Chat |
|
| 45 |
+
| claude | Claude |
|
| 46 |
+
| gemini | Gemini |
|
| 47 |
+
| gemma | Gemma |
|
| 48 |
+
| gpt | GPT |
|
| 49 |
+
| haiku | Haiku |
|
| 50 |
+
| instruct | Instruct |
|
| 51 |
+
| instant | Instant |
|
| 52 |
+
| llama | Llama |
|
| 53 |
+
| max | Max |
|
| 54 |
+
| mini | Mini |
|
| 55 |
+
| mistral | Mistral |
|
| 56 |
+
| opus | Opus |
|
| 57 |
+
| phi | Phi |
|
| 58 |
+
| plus | Plus |
|
| 59 |
+
| preview | Preview |
|
| 60 |
+
| pro | Pro |
|
| 61 |
+
| qwen | Qwen |
|
| 62 |
+
| reasoning | Reasoning |
|
| 63 |
+
| sonnet | Sonnet |
|
| 64 |
+
| thinking | Thinking |
|
| 65 |
+
| turbo | Turbo |
|
| 66 |
+
| yi | Yi |
|
| 67 |
+
|
| 68 |
+
**Detected divergence (2026-04-28):** pipeline emits "Minicpm3 4B FC", "Xlam 2 1B FC R", "Xlam 2 32B FC R" (3 distinct slugs, 7 cards total) which the TS map does NOT have an entry for ("fc"). TS title-cases to "Fc". Either the TS map needs an `fc β FC` entry, OR pipeline already does the right thing here and TS has a gap. **Decision needed: do we want `fc β FC` added?** If yes, both sides update; spec adds the row.
|
| 69 |
+
|
| 70 |
+
### Group B β v/V version-token rule
|
| 71 |
+
|
| 72 |
+
For any token matching `/^v\d/i` (e.g. `v3`, `v3.1`, `V0`), the `v` is lowercased.
|
| 73 |
+
|
| 74 |
+
| Token in handle | Token in name |
|
| 75 |
+
|---|---|
|
| 76 |
+
| v3 | v3 |
|
| 77 |
+
| V3 | v3 |
|
| 78 |
+
| v0.1 | v0.1 |
|
| 79 |
+
| V1.0 | v1.0 |
|
| 80 |
+
|
| 81 |
+
**Detected divergence (2026-04-28):** pipeline emits 1,253 cards with capital `V` (e.g. "Deepseek V3", "Mistral 7B Instruct V0.3", "Mixtral 8x22b Instruct V0.1", "Nova Lite V1.0"). TS rule lowercases. Pipeline must apply this rule before emitting `model_family_name`.
|
| 82 |
+
|
| 83 |
+
### Group C β Date and qualifier extraction
|
| 84 |
+
|
| 85 |
+
`splitVersionParts` operates on a handle that has already been through `normalizeHandle` (Group D). For a normalized handle matching `/^(.*?)-((?:19|20)\d{6})(?:-(.+))?$/`:
|
| 86 |
+
|
| 87 |
+
| normalizedHandle (input to splitVersionParts) | familySlug | versionDate | versionQualifier | variantKey | variantLabel |
|
| 88 |
+
|---|---|---|---|---|---|
|
| 89 |
+
| claude-3.5-sonnet | claude-3.5-sonnet | _none_ | _none_ | base | Current |
|
| 90 |
+
| claude-3.5-sonnet-20240620 | claude-3.5-sonnet | 2024-06-20 | _none_ | 20240620 | 2024-06-20 |
|
| 91 |
+
| claude-3.5-sonnet-20240620-thinking | claude-3.5-sonnet | 2024-06-20 | Thinking | 20240620-thinking | 2024-06-20 Β· Thinking |
|
| 92 |
+
| claude-3.5-sonnet-20240620-thinking-high | claude-3.5-sonnet | 2024-06-20 | Thinking High | 20240620-thinking-high | 2024-06-20 Β· Thinking High |
|
| 93 |
+
|
| 94 |
+
Note the **dotted** `3.5` not dashed β the upstream call to `normalizeHandle` collapses `(\d)-(?=\d(?:-|$))` to `(\d).` (so `3-5-sonnet` β `3.5-sonnet`). Pipeline-side implementations must apply that collapse before invoking `splitVersionParts`-equivalent logic, otherwise the regex match for the date will be off.
|
| 95 |
+
|
| 96 |
+
The qualifier is humanized via the same token-case + v/V rules used for `familyName`.
|
| 97 |
+
|
| 98 |
+
**Important date-pattern caveat:** the date regex requires 8 contiguous digits (`(?:19|20)\d{6}`). A dashed form like `2025-12-11` (which appears in some pipeline IDs like `openai/gpt-5-2025-12-11-thinking-high`) does NOT match β those ten-character dashed dates pass through `normalizeHandle` unchanged (no internal-digit-dash-digit pattern triggers the collapse) and `splitVersionParts` returns `base`/`Current`. This is preserved as-is; do not "fix" the regex to accept dashed dates without checking what relies on the current behaviour.
|
| 99 |
+
|
| 100 |
+
### Group D β Handle normalization
|
| 101 |
+
|
| 102 |
+
Raw handles arrive from `getRawHandle()` (Group E) β they do NOT contain the namespace. `normalizeHandle` then applies the rules below in order:
|
| 103 |
+
|
| 104 |
+
| rawHandle | normalizedHandle | Rule fired |
|
| 105 |
+
|---|---|---|
|
| 106 |
+
| Claude_Opus_4.5 | claude-opus-4.5 | lowercase + underscoreβdash |
|
| 107 |
+
| claude opus 4.5 | claude-opus-4.5 | spaceβdash |
|
| 108 |
+
| --claude--opus-- | claude-opus | leading/trailing/repeated dash collapse |
|
| 109 |
+
| claude-3-5 | claude-3.5 | digit-dash-digit collapses (5 is at end β matches lookahead) |
|
| 110 |
+
| claude-3-5-sonnet | claude-3.5-sonnet | same: 5 followed by `-` β matches |
|
| 111 |
+
| gpt-5 | gpt-5 | no digit-dash-digit pattern |
|
| 112 |
+
| claude-3-5-sonnet-20240620 | claude-3.5-sonnet-20240620 | "3-5" collapses; "20240620" is one token (no internal dashes) so no collapse there |
|
| 113 |
+
| openai/foo (called via pipeline β never happens) | not applicable | namespace is always split off in Group E before normalize is called |
|
| 114 |
+
|
| 115 |
+
The digit-dash-digit rule is the subtle one: `/(\d)-(?=\d(?:-|$))/g β "$1."` matches a digit-dash-digit pattern only when the right-hand digit is at end-of-string OR followed by another dash. So `3-5-x` becomes `3.5-x`, `3-5` (at end) becomes `3.5`, but `3-5x` (followed by a non-dash) is left alone. Inside `20240620` there are no dashes, so the regex doesn't fire on the date itself.
|
| 116 |
+
|
| 117 |
+
### Group E β Namespace and rawHandle extraction
|
| 118 |
+
|
| 119 |
+
The `id` field's first slash splits namespace from handle. If no slash, `developer` field is used as namespace (slug-cased: spaces β dashes, lowercased).
|
| 120 |
+
|
| 121 |
+
| input.id | input.developer | namespace | rawHandle |
|
| 122 |
+
|---|---|---|---|
|
| 123 |
+
| anthropic/claude-opus-4-5 | (any) | anthropic | claude-opus-4-5 |
|
| 124 |
+
| openai/gpt-5 | (any) | openai | gpt-5 |
|
| 125 |
+
| Claude Opus 4.5 | Anthropic | anthropic | Claude Opus 4.5 (then normalized) |
|
| 126 |
+
| gpt-5 | OpenAI | openai | gpt-5 |
|
| 127 |
+
| (empty) | OpenAI | openai | (empty β falls back to name) |
|
| 128 |
+
|
| 129 |
+
When `id` lacks a slash, `rawHandle` falls back to `stripNamespace(name, namespace)` then to `name.trim()`.
|
| 130 |
+
|
| 131 |
+
### Group F β `familyId` and `model_route_id`
|
| 132 |
+
|
| 133 |
+
```
|
| 134 |
+
familyId = `${namespace}/${familySlug}`
|
| 135 |
+
model_route_id = familyId.replace(/\//g, "__")
|
| 136 |
+
```
|
| 137 |
+
|
| 138 |
+
**Pipeline status (2026-04-28):** `model_family_id` matches TS-computed `familyId` for **5,830 / 5,830** cards. `model_route_id` matches `model_family_id.replace(/\//g, "__")` for **5,830 / 5,830** cards. β
|
| 139 |
+
|
| 140 |
+
This is the part that's already safe to delete on the TS side; the rest is not.
|
| 141 |
+
|
| 142 |
+
## Current TS implementation
|
| 143 |
+
|
| 144 |
+
The transformation lives in TWO places:
|
| 145 |
+
|
| 146 |
+
### Primary β `lib/model-family.ts` (consumed at request time)
|
| 147 |
+
|
| 148 |
+
| Concern | Location |
|
| 149 |
+
|---|---|
|
| 150 |
+
| Top-level entry point | `lib/model-family.ts:165-190` (`getCanonicalModelIdentity`) |
|
| 151 |
+
| Token case map | `lib/model-family.ts:17-44` (`TOKEN_CASE_MAP`) |
|
| 152 |
+
| Token case helper | `lib/model-family.ts:100-123` (`titleCaseToken`) |
|
| 153 |
+
| v/V rule | `lib/model-family.ts:118-120` (inside `titleCaseToken`) |
|
| 154 |
+
| Handle normalization | `lib/model-family.ts:82-90` (`normalizeHandle`) |
|
| 155 |
+
| Date format helper | `lib/model-family.ts:92-98` (`formatVersionDate`) |
|
| 156 |
+
| Date + qualifier extraction | `lib/model-family.ts:139-163` (`splitVersionParts`) |
|
| 157 |
+
| Family name humanization | `lib/model-family.ts:125-137` (`humanizeHandle`) |
|
| 158 |
+
| Namespace extraction | `lib/model-family.ts:62-69` (`getNamespace`) |
|
| 159 |
+
| Raw handle extraction | `lib/model-family.ts:71-80` (`getRawHandle`) |
|
| 160 |
+
| `route_id` derivation | `lib/model-family.ts:220-225` (`getModelFamilyRouteId`) |
|
| 161 |
+
|
| 162 |
+
Total: ~200 lines. Two exported entry points consumed at six call sites in `lib/model-data.ts`, `lib/hf-data.ts`, `components/eval-detail.tsx`. (A third export, `normalizeModelInfo`, was deleted during the 2026-04-28 orphan sweep β zero callers.)
|
| 163 |
+
|
| 164 |
+
### Secondary β `scripts/cache-hf-data.mjs` (cache-build-time duplicate)
|
| 165 |
+
|
| 166 |
+
The cache-build script independently re-implements five of the helpers above (presumably to avoid importing TS into the build script). When `pnpm cache-hf-data` runs, it post-processes downloaded model-cards to apply the same canonicalization that the runtime would. Duplicated helpers:
|
| 167 |
+
|
| 168 |
+
| Concern | Cache-script location | Equivalent in `lib/model-family.ts` |
|
| 169 |
+
|---|---|---|
|
| 170 |
+
| Handle normalization | `scripts/cache-hf-data.mjs:141-149` (`normalizeHandle`) | identical to lib version |
|
| 171 |
+
| Date format helper | `scripts/cache-hf-data.mjs:151-157` (`formatVersionDate`) | identical |
|
| 172 |
+
| Token case helper | `scripts/cache-hf-data.mjs:159-177` (`titleCaseToken`) | identical |
|
| 173 |
+
| Family name humanization | `scripts/cache-hf-data.mjs:179-185` (`humanizeHandle`) | identical |
|
| 174 |
+
| Family info extractor (smaller version) | `scripts/cache-hf-data.mjs:187-197` (`getCanonicalFamilyInfo`) | subset of `getCanonicalModelIdentity` (returns only familyId + familyName) |
|
| 175 |
+
|
| 176 |
+
These five functions in `scripts/cache-hf-data.mjs` ALSO need to be deleted in the same migration cleanup, since pipeline-emitted canonical fields obviate both the runtime AND build-time canonicalization paths.
|
| 177 |
+
|
| 178 |
+
## Pipeline status
|
| 179 |
+
|
| 180 |
+
Verified against full `.cache/hf-data/model-cards.json` (5,830 entries) on 2026-04-28:
|
| 181 |
+
|
| 182 |
+
| Field | Pipeline match | Notes |
|
| 183 |
+
|---|---|---|
|
| 184 |
+
| `model_family_id` | 5830 / 5830 β
| Matches `${namespace}/${familySlug}` exactly. |
|
| 185 |
+
| `model_route_id` | 5830 / 5830 β
| Matches `model_family_id.replace(/\//g, "__")`. |
|
| 186 |
+
| `model_family_name` | 4,570 / 5,830 β | 1,260 disagreements β see "Divergences detected" below. |
|
| 187 |
+
| `family_slug` | not emitted | Pipeline doesn't surface this field. |
|
| 188 |
+
| `version_date` | not emitted | |
|
| 189 |
+
| `version_qualifier` | not emitted | |
|
| 190 |
+
| `variant_key` | partial | Emitted on per-variant entries inside `model_card.variants[]`; not on top-level card. Match status not yet audited. |
|
| 191 |
+
| `variant_label` | partial | Same as `variant_key`. |
|
| 192 |
+
| `variant_display_name` | not emitted | |
|
| 193 |
+
|
| 194 |
+
## Divergences detected
|
| 195 |
+
|
| 196 |
+
Sourced from `scripts/verify-identity.mjs` against the full live cache (run 2026-04-28).
|
| 197 |
+
|
| 198 |
+
### Bucket 1: v/V capitalization (1,253 cards)
|
| 199 |
+
|
| 200 |
+
Pipeline does not lowercase the `v` in version tokens. Examples:
|
| 201 |
+
|
| 202 |
+
| Pipeline `model_family_name` | TS-computed `familyName` |
|
| 203 |
+
|---|---|
|
| 204 |
+
| Deepseek V3 | Deepseek v3 |
|
| 205 |
+
| Deepseek V3.1 | Deepseek v3.1 |
|
| 206 |
+
| Mistral 7B Instruct V0.3 | Mistral 7B Instruct v0.3 |
|
| 207 |
+
| Mixtral 8x7b Instruct V0.1 | Mixtral 8x7b Instruct v0.1 |
|
| 208 |
+
| Mixtral 8x22b Instruct V0.1 | Mixtral 8x22b Instruct v0.1 |
|
| 209 |
+
| Nova Lite V1.0 | Nova Lite v1.0 |
|
| 210 |
+
| Nova Micro V1.0 | Nova Micro v1.0 |
|
| 211 |
+
| Nova Pro V1.0 | Nova Pro v1.0 |
|
| 212 |
+
|
| 213 |
+
### Bucket 2: missing TOKEN_CASE_MAP entries (7 cards, 3 distinct slugs)
|
| 214 |
+
|
| 215 |
+
Pipeline emits known acronyms in upper-case that TS doesn't know about. Examples:
|
| 216 |
+
|
| 217 |
+
| Pipeline `model_family_name` | TS-computed `familyName` | Token at issue |
|
| 218 |
+
|---|---|---|
|
| 219 |
+
| Minicpm3 4B FC | Minicpm3 4B Fc | `fc` (function-calling) |
|
| 220 |
+
| Xlam 2 1B FC R | Xlam 2 1B Fc R | `fc` |
|
| 221 |
+
| Xlam 2 32B FC R | Xlam 2 32B Fc R | `fc` |
|
| 222 |
+
|
| 223 |
+
**Open product question:** TS map is missing `fc β FC`. Either it's an oversight in TS (we should add `fc`) or pipeline is right and TS is wrong. Resolve with product owner before pipeline-side change. Likely the right fix is to add `fc` to the canonical map and have pipeline emit it.
|
| 224 |
+
|
| 225 |
+
### Other (0 cards)
|
| 226 |
+
|
| 227 |
+
`date_format`, `qualifier_humanize`, `other` buckets all came back empty. Date/qualifier handling matches pipeline, which is reassuring.
|
| 228 |
+
|
| 229 |
+
## Migration checklist
|
| 230 |
+
|
| 231 |
+
- [x] Spec written
|
| 232 |
+
- [x] Tests cover each rule branch (`tests/transformations/identity-canonicalization.test.ts`)
|
| 233 |
+
- [ ] `fc β FC` token map decision (product owner)
|
| 234 |
+
- [ ] Filed with pipeline owner (link to issue/PR in `eval_cards_backend_pipeline`)
|
| 235 |
+
- [ ] Pipeline emits `model_family_name` matching this spec across full corpus
|
| 236 |
+
- [ ] Pipeline emits `family_slug`, `version_date`, `version_qualifier`, `variant_key`, `variant_label`, `variant_display_name` on top-level card (or we accept they live only on per-variant entries)
|
| 237 |
+
- [ ] TS deleted; callers read pipeline fields directly. Includes both `lib/model-family.ts:1-225` (full file) AND the 5 duplicated helpers in `scripts/cache-hf-data.mjs:141-197`.
|
| 238 |
+
|
| 239 |
+
## Notes for pipeline implementer
|
| 240 |
+
|
| 241 |
+
- The `getCanonicalModelIdentity` function is pure (no I/O, no globals beyond `TOKEN_CASE_MAP`); a Python port should be a direct line-by-line translation.
|
| 242 |
+
- The unit tests in `tests/transformations/identity-canonicalization.test.ts` are the acceptance criteria. A Python equivalent (with the same input/output examples) is the simplest verification path.
|
| 243 |
+
- The audit script `scripts/verify-identity.mjs` already runs TS-vs-pipeline diff across the full cache; once pipeline ships, run it to confirm zero mismatches before deleting TS.
|
| 244 |
+
- The `fc β FC` question above is the only ruleset gap; everything else is "pipeline doesn't apply the rule yet." Resolve `fc` first if possible.
|
notes/transformations/02-setup-alias-merging.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Setup-alias variant merging
|
| 2 |
+
|
| 3 |
+
Drafted 2026-04-28. Migration item #2 in `notes/migration-plan.md`.
|
| 4 |
+
|
| 5 |
+
## Framing reminder
|
| 6 |
+
|
| 7 |
+
We are **refactoring for UI efficiency**, not fixing data correctness. Original TS behaviour is the canonical spec β including its quirks β because that's what users see today. Pipeline must reproduce TS output exactly when this transformation moves upstream. If anyone wants to change *what* gets merged (vs the current TS rules), that's a separate product decision deferred until later.
|
| 8 |
+
|
| 9 |
+
## Rule (as TS implements it today)
|
| 10 |
+
|
| 11 |
+
A model card's `variants[]` list contains entries keyed by `variant_key`. The runtime normalizer (`lib/hf-data.ts:normalizeSingleModelCardEntry`, line 750) walks each variant and may transform `variant_key`/`variant_label` based on whether the variant looks like a setup-alias of an underlying date-based variant.
|
| 12 |
+
|
| 13 |
+
Algorithm (verbatim from current code):
|
| 14 |
+
|
| 15 |
+
1. If `variant_key === "base"` β rename to `"default"` / `"Default"`.
|
| 16 |
+
2. If `variant_key === "default"` β keep as-is.
|
| 17 |
+
3. Otherwise, build a synthetic identity by feeding `${familyId}-${variant_key}` through `getCanonicalModelIdentity` (the same canonicalizer as migration item #1).
|
| 18 |
+
4. Inspect `syntheticIdentity.versionDate` and `syntheticIdentity.versionQualifier`:
|
| 19 |
+
- If `versionDate` is set AND `isSetupAliasQualifier(versionQualifier)` returns true β rewrite to `versionDate` (ISO `YYYY-MM-DD` format) for both key and label.
|
| 20 |
+
- Otherwise β use `syntheticIdentity.variantKey` and `syntheticIdentity.variantLabel`.
|
| 21 |
+
5. After all variants are processed, deduplicate by normalized `variant_key`. Duplicates merge: `evaluation_count` summed, `raw_model_ids` unioned + sorted, `last_updated` taken as the latest timestamp.
|
| 22 |
+
|
| 23 |
+
`isSetupAliasQualifier` (`lib/hf-data.ts:712`) returns true when the normalized qualifier (lowercased, separators β `-`) matches:
|
| 24 |
+
|
| 25 |
+
- exactly `prompt`
|
| 26 |
+
- exactly `fc`
|
| 27 |
+
- exactly `function-calling`
|
| 28 |
+
- starts with `thinking` (so `thinking`, `thinking-1k`, `thinking-medium`, `thinking-32k`, etc. all match)
|
| 29 |
+
|
| 30 |
+
The "starts with thinking" prefix-match is intentionally broad and aggregates all thinking-budget variants (`thinking-1k`, `thinking-medium`, `thinking-32k`, etc.) into a single date-only entry β a deliberate UI-side aggregation choice that prioritizes cross-model comparison readability over per-condition granularity.
|
| 31 |
+
|
| 32 |
+
## Classification
|
| 33 |
+
|
| 34 |
+
- **Unconditional normalization.** When inputs match the rule, TS overwrites whatever upstream emitted. Pipeline-side implementation must apply the same overwrite.
|
| 35 |
+
- **Dual class β cleaning and reshape:**
|
| 36 |
+
- **Cleaning β pipeline** (key derivation): the alias qualifier rules (`isSetupAliasQualifier`) that determine *which bucket* a raw result lands in are value transforms on a single record. Pipeline-side fix: emit a canonical `setup_alias_key` field per result row.
|
| 37 |
+
- **Reshape β DuckDB SQL** (bucket reduction): collapsing multiple result rows with the same `setup_alias_key` into a single variant entry (taking the latest timestamp, merging evaluation results) is a `GROUP BY setup_alias_key` aggregation. Migration target once the key is emitted upstream: SQL `SELECT β¦ MAX(retrieved_timestamp) β¦ GROUP BY setup_alias_key` rather than TS reduce logic.
|
| 38 |
+
|
| 39 |
+
## Inputs and expected outputs
|
| 40 |
+
|
| 41 |
+
These are the rules as TS executes them today. Pipeline must produce identical outputs.
|
| 42 |
+
|
| 43 |
+
### Group A β `isSetupAliasQualifier` truth table
|
| 44 |
+
|
| 45 |
+
| Input qualifier | Normalized | Returns |
|
| 46 |
+
|---|---|---|
|
| 47 |
+
| `prompt` | `prompt` | `true` |
|
| 48 |
+
| `Prompt` | `prompt` | `true` (case-insensitive) |
|
| 49 |
+
| `fc` | `fc` | `true` |
|
| 50 |
+
| `function-calling` | `function-calling` | `true` |
|
| 51 |
+
| `function calling` | `function-calling` | `true` (whitespace β dash) |
|
| 52 |
+
| `function_calling` | `function-calling` | `true` (underscore β dash) |
|
| 53 |
+
| `thinking` | `thinking` | `true` |
|
| 54 |
+
| `thinking-1k` | `thinking-1k` | `true` (prefix match) |
|
| 55 |
+
| `thinking-medium` | `thinking-medium` | `true` (prefix match) |
|
| 56 |
+
| `thinking_xhigh` | `thinking-xhigh` | `true` (prefix match after normalization) |
|
| 57 |
+
| `Thinking 1K` | `thinking-1k` | `true` |
|
| 58 |
+
| `high` | `high` | `false` |
|
| 59 |
+
| `medium` | `medium` | `false` |
|
| 60 |
+
| `low` | `low` | `false` |
|
| 61 |
+
| `minimal` | `minimal` | `false` |
|
| 62 |
+
| `8k` | `8k` | `false` |
|
| 63 |
+
| (empty / null / undefined) | `""` | `false` |
|
| 64 |
+
|
| 65 |
+
### Group B β End-to-end variant normalization
|
| 66 |
+
|
| 67 |
+
`getCanonicalModelIdentity`'s date regex requires 8 contiguous digits (`(?:19|20)\d{6}`). Dashed-date variant_keys do NOT match β they fall through to the `versionDate = undefined` branch, which sends them to `syntheticIdentity.variantKey === "base"`. **This is part of TS's current behaviour and must be preserved.**
|
| 68 |
+
|
| 69 |
+
| Input variant_key | TS-observed output `variant_key` | TS-observed output `variant_label` | Notes |
|
| 70 |
+
|---|---|---|---|
|
| 71 |
+
| `default` | `default` | (unchanged) | passthrough |
|
| 72 |
+
| `base` | `default` | `Default` | rename |
|
| 73 |
+
| `20251101` | `20251101` | `2025-11-01` | YYYYMMDD date-only β preserved as raw token, ISO label |
|
| 74 |
+
| `2025-11-01` | `base` | `Current` | dashed date-only β falls through to base because regex doesn't match (NB: by TS quirk, a future product call may decide to align this) |
|
| 75 |
+
| `20240620-thinking` | `2024-06-20` | `2024-06-20` | YYYYMMDD + thinking β merge to ISO date |
|
| 76 |
+
| `20240620-thinking-1k` | `2024-06-20` | `2024-06-20` | YYYYMMDD + thinking-1k β merge (startsWith match) |
|
| 77 |
+
| `20240620-thinking-medium` | `2024-06-20` | `2024-06-20` | merge (startsWith match) β all thinking-N variants for this date collapse together |
|
| 78 |
+
| `20240620-fc` | `2024-06-20` | `2024-06-20` | merge |
|
| 79 |
+
| `20240620-prompt` | `2024-06-20` | `2024-06-20` | merge |
|
| 80 |
+
| `20240620-high` | `20240620-high` | `2024-06-20 Β· High` | non-alias qualifier preserved |
|
| 81 |
+
| `2025-12-11-thinking-medium` | `base` | `Current` | dashed date β regex doesn't match, falls through |
|
| 82 |
+
| `2025-12-11-thinking-1k` | `base` | `Current` | same β dashed date passes through to base |
|
| 83 |
+
| `2025-12-11-fc` | `base` | `Current` | same |
|
| 84 |
+
| `2025-12-11-high` | `base` | `Current` | same |
|
| 85 |
+
| `2025-08-07-low` | `base` | `Current` | same |
|
| 86 |
+
| `gpt-foo-bar` | `base` | `Current` | no date detected anywhere |
|
| 87 |
+
|
| 88 |
+
### Group C β Multi-variant deduplication after normalization
|
| 89 |
+
|
| 90 |
+
When multiple input variants normalize to the same `variant_key`, they merge:
|
| 91 |
+
- `raw_model_ids`: union, deduped, sorted
|
| 92 |
+
- `evaluation_count`: sum
|
| 93 |
+
- `last_updated`: maximum (latest)
|
| 94 |
+
|
| 95 |
+
This is what produces, for example, the user-visible behaviour for `openai/gpt-5.2`: the cache file has 7 distinct variants, but TS normalization collapses 6 of the 7 (everything except `default`) into a single `base` bucket β because all 6 use dashed dates and fall through to `base`.
|
| 96 |
+
|
| 97 |
+
## Current TS implementation
|
| 98 |
+
|
| 99 |
+
| Concern | Location |
|
| 100 |
+
|---|---|
|
| 101 |
+
| Runtime normalizer (active) | `lib/hf-data.ts:750-812` (`normalizeSingleModelCardEntry`) |
|
| 102 |
+
| Setup-alias qualifier check (runtime) | `lib/hf-data.ts:712-720` (`isSetupAliasQualifier`) |
|
| 103 |
+
| Qualifier normalizer (runtime) | `lib/hf-data.ts:708-710` (`normalizeSetupAliasQualifier`) |
|
| 104 |
+
| Cache-time normalizer | `scripts/cache-hf-data.mjs:213-246` (`getNormalizedVariantMeta`) |
|
| 105 |
+
| Setup-alias qualifier check (cache) | `scripts/cache-hf-data.mjs:203-211` |
|
| 106 |
+
| Qualifier normalizer (cache) | `scripts/cache-hf-data.mjs:199-201` |
|
| 107 |
+
| Mode-based path (dead, ignore) | `lib/eval-processing.ts:371-434` |
|
| 108 |
+
|
| 109 |
+
The mode-based path reads `model_info.additional_details.mode` which is empty on every production model_result row (verified 2026-04-28: 0 of 86,183). It runs but never fires for any input. Pipeline-side implementation should NOT reproduce it; it's load-bearing only against test fixtures that may carry the field.
|
| 110 |
+
|
| 111 |
+
## Pipeline status β known divergences
|
| 112 |
+
|
| 113 |
+
Pipeline (`eval_cards_backend_pipeline/scripts/pipeline.py`) implements its own variant aggregation in `aggregated_display_identity` (line 1724). It uses a **different qualifier set and a different input field** than TS:
|
| 114 |
+
|
| 115 |
+
| Aspect | TS (this spec) | Pipeline today | Result |
|
| 116 |
+
|---|---|---|---|
|
| 117 |
+
| Input field | `variant_key` from variant entry | `model_info.additional_details.mode` from raw eval | Pipeline merges submissions early; TS re-merges at variant level |
|
| 118 |
+
| Qualifier set | `prompt`, `fc`, `function-calling`, plus *any* `thinking*` (prefix) | exact set: `{prompt, fc, function-calling, thinking, prompt-thinking, fc-thinking, function-calling-thinking}` | TS aggregates more aggressively for `thinking-N` variants; pipeline keeps each thinking budget separate |
|
| 119 |
+
| Date-format handling | Only YYYYMMDD recognized; dashed dates fall through to `base` | N/A β pipeline operates on `mode` field, not `variant_key` | |
|
| 120 |
+
|
| 121 |
+
**Concrete example:** for `openai/gpt-5.2` with submissions across 7 setups (default + base 2025-12-11 + 5 thinking budgets):
|
| 122 |
+
- Pipeline emits 7 distinct variants (it merges fc/prompt into the date-only base via `mode`-field check, but keeps thinking-{none,low,medium,high,xhigh} separate because those exact strings aren't in pipeline's set)
|
| 123 |
+
- TS normalizes pipeline's 7 variants β 2 (`default` + `base`, all dashed-date variants collapsed)
|
| 124 |
+
|
| 125 |
+
**The user-visible state today** is whatever TS produces (TS runs on every API request). So users see the post-TS-normalization view: 2 variants for that card, not 7.
|
| 126 |
+
|
| 127 |
+
When the migration moves this transformation upstream, pipeline must produce TS's 2-variant view, not pipeline's current 7-variant view. Pipeline-side options:
|
| 128 |
+
|
| 129 |
+
1. Add a second-pass normalization to pipeline output that mirrors TS's rules (prefix-match `thinking*`, only YYYYMMDD dates trigger merge).
|
| 130 |
+
2. Drop pipeline's existing `aggregated_display_identity` mode-based check and replace it entirely with the variant_key-based rule.
|
| 131 |
+
|
| 132 |
+
Option 2 is cleaner if pipeline owners agree.
|
| 133 |
+
|
| 134 |
+
## Notes for pipeline implementer
|
| 135 |
+
|
| 136 |
+
- **Reproduce the prefix-match `thinking*` exactly.** This is the largest TS-vs-pipeline divergence. Don't tighten it to an exact set without a product call.
|
| 137 |
+
- **Reproduce the dashed-date fall-through behaviour.** `2025-12-11-thinking-medium` produces `variant_key: base` in TS today. Whether that's "right" or "wrong" is not for this migration to decide.
|
| 138 |
+
- **Ignore the `mode` field.** It's empty in production. Pipeline's current `aggregated_display_identity` reads it; the replacement should not.
|
| 139 |
+
- **Re-run `scripts/verify-setup-alias.mjs`** against pipeline output once the change ships. Goal: zero divergence between TS-as-is output and pipeline-emitted output for the full corpus.
|
| 140 |
+
|
| 141 |
+
## Migration checklist
|
| 142 |
+
|
| 143 |
+
- [x] Spec written (TS-as-is, including quirks)
|
| 144 |
+
- [x] Tests cover each rule branch (`tests/transformations/setup-alias-merging.test.ts`)
|
| 145 |
+
- [ ] Filed with pipeline owner with the spec + tests + audit script as acceptance criterion
|
| 146 |
+
- [ ] Pipeline emits variants matching this spec across the full corpus (verified by `scripts/verify-setup-alias.mjs`)
|
| 147 |
+
- [ ] TS deleted; callers read pipeline-emitted variants directly. Files to delete:
|
| 148 |
+
- `lib/hf-data.ts:750-812` (`normalizeSingleModelCardEntry`)
|
| 149 |
+
- `lib/hf-data.ts:708-720` (`normalizeSetupAliasQualifier`, `isSetupAliasQualifier`)
|
| 150 |
+
- `scripts/cache-hf-data.mjs:199-246` (cache-time mirror)
|
| 151 |
+
- `lib/eval-processing.ts:371-434` (the dead mode-based path)
|
| 152 |
+
|
| 153 |
+
## Future product decision (deferred)
|
| 154 |
+
|
| 155 |
+
Whether the current TS aggregation is the *right* product behaviour is open. The dashed-date fall-through and the prefix-match `thinking*` together produce a heavily-aggregated view (2 variants for `openai/gpt-5.2` instead of 7). If the team later decides users would benefit from per-thinking-budget granularity, the transformation can be redesigned in pipeline (where it's cheaper to change than in TS at runtime). That's explicitly out of scope for this refactor.
|
notes/transformations/03-license-normalization.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# License normalization
|
| 2 |
+
|
| 3 |
+
Drafted 2026-04-28. Migration item #18 in `notes/migration-plan.md`.
|
| 4 |
+
|
| 5 |
+
## Framing reminder
|
| 6 |
+
|
| 7 |
+
We are refactoring for UI efficiency, not fixing data. TS-as-is is the canonical spec. If the truncation behaviour or rule coverage looks suboptimal, that's a deferred product decision (see end of doc). Do not "improve" the rule when porting upstream.
|
| 8 |
+
|
| 9 |
+
## Rule (as TS implements it today)
|
| 10 |
+
|
| 11 |
+
`shortenLicense` (`components/eval-card.tsx:38-48`) takes a free-text license string from `benchmark_card.ethical_and_legal_considerations.data_licensing` and returns a short display label. Algorithm:
|
| 12 |
+
|
| 13 |
+
1. If empty or `"Not specified"` β return `""`.
|
| 14 |
+
2. If lowercased license includes `"creative commons attribution 4"` β `"CC BY 4.0"`.
|
| 15 |
+
3. Else if includes `"creative commons zero"` β `"CC0"`.
|
| 16 |
+
4. Else if includes `"apache license 2"` OR `"apache 2"` β `"Apache 2.0"`.
|
| 17 |
+
5. Else if includes `"mit license"` β `"MIT"`.
|
| 18 |
+
6. Else if includes `"cc-by-sa"` β `"CC BY-SA"`.
|
| 19 |
+
7. Else if length > 24 β return `${license.slice(0, 22)}β¦` (truncate to 22 chars + ellipsis).
|
| 20 |
+
8. Else β return the input unchanged.
|
| 21 |
+
|
| 22 |
+
Rules are applied in this order (first match wins). The truncation target (`length > 24`, slice to 22) is asymmetric on purpose β one character of headroom to avoid truncating 24-char strings.
|
| 23 |
+
|
| 24 |
+
The companion function `licenseBadgeClass` in the same file is **purely presentational** (CSS color mapping) and stays in the component. NOT in scope for this migration.
|
| 25 |
+
|
| 26 |
+
## Classification
|
| 27 |
+
|
| 28 |
+
- **Unconditional normalization.** Always overwrite whatever upstream emitted.
|
| 29 |
+
- The output field on the spec side could be named `license_short` and live alongside `data_licensing` in the benchmark card. The original `data_licensing` (free text) stays available for tooltips/details views.
|
| 30 |
+
- **Cleaning β pipeline.** Pure value transform on a single field. No aggregation or record merging. Migration target: pipeline emits `license_short`; TS `shortenLicense` deletes.
|
| 31 |
+
|
| 32 |
+
## Inputs and expected outputs
|
| 33 |
+
|
| 34 |
+
Each row corresponds to a parameterized test case in `tests/transformations/license-normalization.test.ts`. Pipeline must produce identical outputs for every case below.
|
| 35 |
+
|
| 36 |
+
### Group A β Rule firing order (which rule wins)
|
| 37 |
+
|
| 38 |
+
| Input | Output | Rule fired |
|
| 39 |
+
|---|---|---|
|
| 40 |
+
| `"Apache License 2.0"` | `"Apache 2.0"` | rule 4 (matches "apache license 2") |
|
| 41 |
+
| `"Apache 2.0"` | `"Apache 2.0"` | rule 4 (matches "apache 2") |
|
| 42 |
+
| `"MIT License"` | `"MIT"` | rule 5 |
|
| 43 |
+
| `"Creative Commons Attribution 4.0"` | `"CC BY 4.0"` | rule 2 |
|
| 44 |
+
| `"Creative Commons Zero v1.0 Universal"` | `"CC0"` | rule 3 |
|
| 45 |
+
| `"cc-by-sa-3.0"` | `"CC BY-SA"` | rule 6 |
|
| 46 |
+
| `"Open Data Commons Attribution License"` | `"Open Data Commons Attrβ¦"` | rule 7 (truncate, length > 24) |
|
| 47 |
+
| `"The dataset is made available under a CC BY license."` | `"The dataset is made avβ¦"` | rule 7 (truncate; the prose form bypasses rule 2 because it doesn't contain "creative commons attribution 4") |
|
| 48 |
+
| `"apache-2.0"` | `"apache-2.0"` | rule 8 (passthrough; SPDX-style hyphen-lowercase doesn't match "apache license 2" or "apache 2") |
|
| 49 |
+
| `"other"` | `"other"` | rule 8 (passthrough, length β€ 24) |
|
| 50 |
+
| `"unknown"` | `"unknown"` | rule 8 (passthrough, length β€ 24) |
|
| 51 |
+
| `"Not specified"` | `""` | rule 1 |
|
| 52 |
+
| `""` | `""` | rule 1 |
|
| 53 |
+
| `null` / `undefined` | `""` | rule 1 (falsy short-circuit) |
|
| 54 |
+
|
| 55 |
+
### Group B β Edge cases of the truncation rule
|
| 56 |
+
|
| 57 |
+
| Input | Output | Notes |
|
| 58 |
+
|---|---|---|
|
| 59 |
+
| 24-char string (no other rule matches) | (input unchanged) | length β€ 24 β passthrough |
|
| 60 |
+
| 25-char string | first-22-chars + `β¦` | length > 24 β truncate |
|
| 61 |
+
| String beginning `"MIT-like license that is custom"` | `"MIT"` | rule 5 fires before truncation (substring match) |
|
| 62 |
+
| String beginning `"some apache 2 thing"` | `"Apache 2.0"` | rule 4 fires (substring match) |
|
| 63 |
+
|
| 64 |
+
### Group C β Case sensitivity
|
| 65 |
+
|
| 66 |
+
All match-rules call `.toLowerCase()` before substring testing. Inputs:
|
| 67 |
+
|
| 68 |
+
| Input | Output | Notes |
|
| 69 |
+
|---|---|---|
|
| 70 |
+
| `"APACHE LICENSE 2.0"` | `"Apache 2.0"` | case-insensitive match |
|
| 71 |
+
| `"creative commons attribution 4.0"` | `"CC BY 4.0"` | already lowercase |
|
| 72 |
+
| `"CREATIVE COMMONS ZERO v1.0"` | `"CC0"` | uppercase still matches |
|
| 73 |
+
| `"Mit License"` | `"MIT"` | mixed case |
|
| 74 |
+
|
| 75 |
+
## Current TS implementation
|
| 76 |
+
|
| 77 |
+
The same `shortenLicense` function is duplicated in TWO files:
|
| 78 |
+
|
| 79 |
+
| Concern | Location |
|
| 80 |
+
|---|---|
|
| 81 |
+
| Function (eval-card list render path) | `components/eval-card.tsx:38-48` (`shortenLicense`) |
|
| 82 |
+
| Caller | `components/eval-card.tsx:63` |
|
| 83 |
+
| Function (eval-list page render path β duplicate copy) | `app/evals/page.tsx:24-41` (`shortenLicense`) |
|
| 84 |
+
| Caller | `app/evals/page.tsx:1720` |
|
| 85 |
+
| CSS class mapping (NOT in scope, stays in UI) | `components/eval-card.tsx:22-36` (`LICENSE_COLORS`, `licenseBadgeClass`) AND `app/evals/page.tsx:43-?` (duplicate) |
|
| 86 |
+
|
| 87 |
+
The two function bodies are **functionally identical** (same output for every input) but textually slightly different β `app/evals/page.tsx` uses template literal `` `${license.slice(0, 22)}β¦` ``, `components/eval-card.tsx` uses concatenation `license.slice(0, 22) + "β¦"`. Both run at request time on every render. Pipeline-side emission of `license_short` would eliminate both per-render calls; the deletion task must update BOTH files.
|
| 88 |
+
|
| 89 |
+
## Pipeline status β divergences
|
| 90 |
+
|
| 91 |
+
### Side-by-side comparison table
|
| 92 |
+
|
| 93 |
+
| Aspect | TS (this spec) | Pipeline today | Result for users |
|
| 94 |
+
|---|---|---|---|
|
| 95 |
+
| Where the transformation lives | `components/eval-card.tsx:shortenLicense` (runs at every render) | not implemented | TS runs at request time; pipeline ships free-text `data_licensing` only |
|
| 96 |
+
| Field consumed | `benchmark_card.ethical_and_legal_considerations.data_licensing` | (same β unchanged) | β |
|
| 97 |
+
| Output field | local variable `shortLicense` (passed to badge as label text) | none | UI shows TS's output; pipeline doesn't expose a short form anywhere |
|
| 98 |
+
| Rule coverage | 5 explicit aliases + truncate fallback | n/a | n/a |
|
| 99 |
+
|
| 100 |
+
### Concrete worked example with quantified scope
|
| 101 |
+
|
| 102 |
+
Audited 2026-04-28 against `.cache/hf-data/benchmark-metadata.json` (production cache):
|
| 103 |
+
|
| 104 |
+
- 85 of 85 benchmark cards have a `data_licensing` field
|
| 105 |
+
- 11 distinct license strings appear across the corpus
|
| 106 |
+
- `shortenLicense` produces the following distribution:
|
| 107 |
+
- 33 β `""` (cards with `"Not specified"`)
|
| 108 |
+
- 16 β `"Apache 2.0"`
|
| 109 |
+
- 9 β `"MIT"`
|
| 110 |
+
- 8 β `"Open Data Commons Attrβ¦"` (truncated)
|
| 111 |
+
- 6 β `"CC BY 4.0"`
|
| 112 |
+
- 3 β `"CC BY-SA"`
|
| 113 |
+
- 3 β `"other"` (passthrough)
|
| 114 |
+
- 3 β `"unknown"` (passthrough)
|
| 115 |
+
- 2 β `"CC0"`
|
| 116 |
+
- 1 β `"apache-2.0"` (passthrough β SPDX-style misses the `apache 2.0` rule)
|
| 117 |
+
- 1 β `"The dataset is made avβ¦"` (truncated; prose form bypasses CC BY rule)
|
| 118 |
+
|
| 119 |
+
Verified by `scripts/verify-license.mjs`.
|
| 120 |
+
|
| 121 |
+
## Notes for pipeline implementer
|
| 122 |
+
|
| 123 |
+
- Reproduce all 8 rules in order (first match wins). Do not reorder.
|
| 124 |
+
- The `apache-2.0` SPDX-style-lowercase form intentionally falls through to passthrough β the existing rule only matches prose forms (`"apache license 2"` or `"apache 2"` with a space). Don't broaden the rule.
|
| 125 |
+
- The truncation produces `${license.slice(0, 22)}β¦`. That's 22 chars plus a single Unicode ellipsis (`β¦`, U+2026). Don't substitute three dots (`...`).
|
| 126 |
+
- Truncation triggers at `length > 24`, not `length > 22`. A 24-char string passes through; a 25-char string truncates. Preserve this asymmetry.
|
| 127 |
+
- The empty + `"Not specified"` β `""` short-circuit uses falsy semantics in JS (handles `null`/`undefined`/`""` together). Pipeline-side equivalent should treat all three input shapes as the same.
|
| 128 |
+
- Field name suggestion: `benchmark_card.ethical_and_legal_considerations.license_short` β keeps the existing free-text `data_licensing` available for detail views.
|
| 129 |
+
|
| 130 |
+
Verification: run `scripts/verify-license.mjs` against pipeline-emitted `license_short` once it ships. Goal: zero divergence across the 85 production benchmark cards.
|
| 131 |
+
|
| 132 |
+
## Migration checklist
|
| 133 |
+
|
| 134 |
+
- [x] Spec written
|
| 135 |
+
- [x] Tests cover each rule branch (`tests/transformations/license-normalization.test.ts`)
|
| 136 |
+
- [ ] Filed with pipeline owner with the spec + tests + audit script as acceptance criterion
|
| 137 |
+
- [ ] Pipeline emits `license_short` matching this spec across all benchmark cards
|
| 138 |
+
- [ ] TS deleted; `components/eval-card.tsx:38-48` AND `app/evals/page.tsx:24-41` both read `card?.ethical_and_legal_considerations?.license_short` directly. `licenseBadgeClass` stays in both files (UI presentation).
|
| 139 |
+
|
| 140 |
+
## Future product decision (deferred)
|
| 141 |
+
|
| 142 |
+
The current rule set has known coverage gaps that produce ugly truncation:
|
| 143 |
+
- Free-form CC BY descriptions (e.g. "The dataset is made available under a CC BY license.") aren't recognized as CC BY β truncate to "The dataset is made avβ¦"
|
| 144 |
+
- "Open Data Commons Attribution License" (ODC-By) isn't recognized β truncate to "Open Data Commons Attrβ¦"
|
| 145 |
+
- SPDX-style lowercase identifiers (`apache-2.0`, `mit`, `cc-by-4.0`) aren't recognized as their canonical short forms
|
| 146 |
+
|
| 147 |
+
If the team later decides users would benefit from broader recognition (e.g. SPDX identifier mapping, looser CC matching), the rule can be expanded in pipeline. That's explicitly out of scope for this refactor.
|
notes/transformations/04-dataset-url-synthesis.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dataset URL synthesis
|
| 2 |
+
|
| 3 |
+
Drafted 2026-04-28. Migration item #20 in `notes/migration-plan.md`.
|
| 4 |
+
|
| 5 |
+
## Framing reminder
|
| 6 |
+
|
| 7 |
+
We are refactoring for UI efficiency, not fixing data. TS-as-is is the canonical spec. The fallback chain is functionally complete; pipeline just needs to do the resolution once and emit the result.
|
| 8 |
+
|
| 9 |
+
## Rule (as TS implements it today)
|
| 10 |
+
|
| 11 |
+
`components/eval-card.tsx:83-86` resolves `datasetUrl` from `summary.source_data` via a 3-branch nullish-coalescing chain (NOT truthiness). The literal expression:
|
| 12 |
+
|
| 13 |
+
```ts
|
| 14 |
+
const datasetUrl =
|
| 15 |
+
sourceData?.dataset_url ??
|
| 16 |
+
(Array.isArray(sourceData?.url) ? sourceData?.url?.[0] : sourceData?.url) ??
|
| 17 |
+
(sourceData?.hf_repo ? `https://huggingface.co/datasets/${sourceData.hf_repo}` : undefined)
|
| 18 |
+
```
|
| 19 |
+
|
| 20 |
+
Reading order:
|
| 21 |
+
|
| 22 |
+
1. `source_data.dataset_url` β used if not `null`/`undefined`. Empty string `""` is RETURNED (not nullish).
|
| 23 |
+
2. `source_data.url` β if array, take `url[0]` (no truthiness check on the element); if string, use directly. Falls through only if the resolved value is `null`/`undefined`.
|
| 24 |
+
3. `https://huggingface.co/datasets/${source_data.hf_repo}` β only if `hf_repo` is truthy (this branch uses a ternary, not `??`, so `""` falls through to `undefined`).
|
| 25 |
+
4. Else β `undefined`.
|
| 26 |
+
|
| 27 |
+
The constructed HF URL uses the literal template β no encoding, no slash normalization, no validation. Whatever the upstream `hf_repo` value is, it gets concatenated as-is.
|
| 28 |
+
|
| 29 |
+
## Classification
|
| 30 |
+
|
| 31 |
+
- **Default-only.** The rule fills in a value only when `dataset_url` isn't already present. Pipeline-side fix: emit `dataset_url` directly so the fallback chain becomes unnecessary; preserve any existing `dataset_url` value rather than overwriting.
|
| 32 |
+
- **Cleaning β pipeline.** Derives a URL value from other fields on the same record. No aggregation or record merging. Migration target: pipeline emits `dataset_url`; TS fallback chain deletes.
|
| 33 |
+
|
| 34 |
+
## Inputs and expected outputs
|
| 35 |
+
|
| 36 |
+
Each row corresponds to a parameterized test case in `tests/transformations/dataset-url-synthesis.test.ts`. Pipeline must produce identical outputs for every case.
|
| 37 |
+
|
| 38 |
+
### Group A β Branch firing order (first non-nullish wins)
|
| 39 |
+
|
| 40 |
+
| Input `source_data` | Output | Branch |
|
| 41 |
+
|---|---|---|
|
| 42 |
+
| `{dataset_url: "https://example.com/x"}` | `"https://example.com/x"` | 1 |
|
| 43 |
+
| `{dataset_url: "x", url: ["y"]}` | `"x"` | 1 (dataset_url short-circuits when set) |
|
| 44 |
+
| `{url: ["https://a.com", "https://b.com"]}` | `"https://a.com"` | 2 (first element of array) |
|
| 45 |
+
| `{url: ["only"]}` | `"only"` | 2 |
|
| 46 |
+
| `{url: "https://a.com"}` | `"https://a.com"` | 2 (string form) |
|
| 47 |
+
| `{hf_repo: "Mercor/ACE"}` | `"https://huggingface.co/datasets/Mercor/ACE"` | 3 (HF template) |
|
| 48 |
+
| `{hf_repo: "mercor/apex-agents"}` | `"https://huggingface.co/datasets/mercor/apex-agents"` | 3 (preserves case) |
|
| 49 |
+
| `{dataset_name: "x"}` | `undefined` | 4 (none of the above match) |
|
| 50 |
+
| `{}` | `undefined` | 4 |
|
| 51 |
+
| `null` | `undefined` | 4 (caller passes nullable; defensive) |
|
| 52 |
+
| `undefined` | `undefined` | 4 |
|
| 53 |
+
|
| 54 |
+
### Group B β Edge cases (`??` nullish semantics, NOT truthiness)
|
| 55 |
+
|
| 56 |
+
The original expression uses `??` (nullish coalescing), so `""`, `0`, and `false` do NOT trigger fallback β only `null` and `undefined` do. The hf_repo branch internally uses `sourceData.hf_repo ? template : undefined` (a truthiness check), so empty hf_repo IS falsy.
|
| 57 |
+
|
| 58 |
+
| Input `source_data` | Output | Why |
|
| 59 |
+
|---|---|---|
|
| 60 |
+
| `{dataset_url: "", url: ["fallback"]}` | `""` | `""` is not nullish, `??` short-circuits to it |
|
| 61 |
+
| `{url: [], hf_repo: "x/y"}` | `"https://huggingface.co/datasets/x/y"` | `url[0]` is `undefined`, `??` falls through to hf_repo |
|
| 62 |
+
| `{url: [""], hf_repo: "x/y"}` | `""` | `url[0]` is `""` (not nullish), `??` short-circuits to it |
|
| 63 |
+
| `{url: [null], hf_repo: "x/y"}` | `"https://huggingface.co/datasets/x/y"` | `url[0]` is `null` (nullish), `??` falls through to hf_repo |
|
| 64 |
+
| `{url: ["a"], hf_repo: "x/y"}` | `"a"` | url[0] truthy, short-circuits hf_repo |
|
| 65 |
+
| `{hf_repo: ""}` | `undefined` | inline `hf_repo ? template : undefined` uses truthiness β `""` falls through to `undefined` |
|
| 66 |
+
| `{hf_repo: "/leading-slash"}` | `"https://huggingface.co/datasets//leading-slash"` | no normalization β double slash preserved |
|
| 67 |
+
|
| 68 |
+
## Current TS implementation
|
| 69 |
+
|
| 70 |
+
The fallback chain exists in TWO sites, with **slightly different semantics** between them. A third related pattern (just the bare HF link) exists in a third site.
|
| 71 |
+
|
| 72 |
+
### Site A β eval-card list (`??` nullish, default `undefined`)
|
| 73 |
+
|
| 74 |
+
`components/eval-card.tsx:83-86`:
|
| 75 |
+
|
| 76 |
+
```ts
|
| 77 |
+
const datasetUrl =
|
| 78 |
+
sourceData?.dataset_url ??
|
| 79 |
+
(Array.isArray(sourceData?.url) ? sourceData?.url?.[0] : sourceData?.url) ??
|
| 80 |
+
(sourceData?.hf_repo ? `https://huggingface.co/datasets/${sourceData.hf_repo}` : undefined)
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
This is the spec described above (Group A/B in the table). Empty string `""` is RETURNED (not nullish); only `null`/`undefined` fall through.
|
| 84 |
+
|
| 85 |
+
### Site B β benchmark detail page (`||` truthy, default `null`)
|
| 86 |
+
|
| 87 |
+
`components/benchmark-detail.tsx:5043-5047`:
|
| 88 |
+
|
| 89 |
+
```ts
|
| 90 |
+
const datasetHref =
|
| 91 |
+
sourceData?.dataset_url ||
|
| 92 |
+
(Array.isArray(sourceData?.url) ? sourceData?.url?.[0] : sourceData?.url) ||
|
| 93 |
+
(sourceData?.hf_repo ? `https://huggingface.co/datasets/${sourceData.hf_repo}` : null) ||
|
| 94 |
+
null
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
**Differs from Site A on edge cases:** uses `||` (truthiness) so empty strings DO fall through; uses `null` instead of `undefined` as the final default. For `{dataset_url: ""}`, Site A returns `""` and Site B returns the next non-empty branch's value (or `null`).
|
| 98 |
+
|
| 99 |
+
### Site C β direct HF link (no fallback chain)
|
| 100 |
+
|
| 101 |
+
`components/benchmark-detail.tsx:5416-5420`:
|
| 102 |
+
|
| 103 |
+
```ts
|
| 104 |
+
{sourceData?.hf_repo && (
|
| 105 |
+
<InlineMeta label="HF Repo" value={
|
| 106 |
+
<a href={`https://huggingface.co/datasets/${sourceData.hf_repo}`} ...>
|
| 107 |
+
{sourceData.hf_repo}
|
| 108 |
+
</a>
|
| 109 |
+
} />
|
| 110 |
+
)}
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
This is just the hf_repo template applied directly when `hf_repo` is truthy. It does NOT consult `dataset_url` or `url` β so even if `dataset_url` is set, this site shows the HF link separately. Different intent: this is the "HF Repo" inline-meta link, not the primary dataset link.
|
| 114 |
+
|
| 115 |
+
### Summary table
|
| 116 |
+
|
| 117 |
+
| Site | Path | Semantics | Default fallback | Status for migration |
|
| 118 |
+
|---|---|---|---|---|
|
| 119 |
+
| A | `components/eval-card.tsx:83-86` | `??` nullish | `undefined` | spec target |
|
| 120 |
+
| B | `components/benchmark-detail.tsx:5043-5047` | `||` truthy | `null` | divergent from A on edge cases β pipeline-emitted `dataset_url` resolves both |
|
| 121 |
+
| C | `components/benchmark-detail.tsx:5416-5420` | n/a β bare hf_repo template | n/a | UI element with different intent; out of scope |
|
| 122 |
+
|
| 123 |
+
Pipeline-emitted `dataset_url` (resolved per the spec rule) serves both Sites A and B. Once it's populated, both sites just read it directly and the `??` vs `||` divergence becomes moot.
|
| 124 |
+
|
| 125 |
+
## Pipeline status β divergences
|
| 126 |
+
|
| 127 |
+
### Side-by-side comparison table
|
| 128 |
+
|
| 129 |
+
| Aspect | TS (this spec) | Pipeline today | Result for users |
|
| 130 |
+
|---|---|---|---|
|
| 131 |
+
| Where the resolution lives | `components/eval-card.tsx` (inline, runs at every render) | not implemented | TS resolves at request time |
|
| 132 |
+
| Pipeline-emitted `dataset_url` | consumed if present (branch 1) | **never populated** (verified 2026-04-28: 0/587 eval-details emit `dataset_url`) | branch 1 is currently dead β pipeline could populate to retire the fallback chain |
|
| 133 |
+
| Other source_data fields | `url` (array or string), `hf_repo` consumed | both emitted | TS does the resolution work each render |
|
| 134 |
+
|
| 135 |
+
### Concrete worked example with quantified scope
|
| 136 |
+
|
| 137 |
+
Audited 2026-04-28 against `.cache/hf-data/evals/` (587 production eval-detail files):
|
| 138 |
+
|
| 139 |
+
- Branch 1 (`dataset_url`): **0** files (the field is never emitted β branch is dead code today)
|
| 140 |
+
- Branch 2 (`url[0]`): **564** files (96.1%)
|
| 141 |
+
- Branch 3 (`url` as string): **0** files (always emitted as array)
|
| 142 |
+
- Branch 4 (`hf_repo` template): **22** files (3.7%)
|
| 143 |
+
- Branch 5 (`undefined`): **1** file (`cocoabench` has neither `url` nor `hf_repo`)
|
| 144 |
+
|
| 145 |
+
Examples:
|
| 146 |
+
- `appworld`: `{url: ["https://github.com/Exgentic/exgentic"]}` β `"https://github.com/Exgentic/exgentic"`
|
| 147 |
+
- `ace`: `{hf_repo: "Mercor/ACE"}` β `"https://huggingface.co/datasets/Mercor/ACE"`
|
| 148 |
+
- `cocoabench`: `{dataset_name: "CocoaBench v1.0", source_type: "other", additional_details: {β¦}}` β `undefined`
|
| 149 |
+
|
| 150 |
+
Verified by `scripts/verify-dataset-url.mjs`.
|
| 151 |
+
|
| 152 |
+
## Notes for pipeline implementer
|
| 153 |
+
|
| 154 |
+
- Reproduce the 4-step fallback exactly. Don't reorder; first match wins.
|
| 155 |
+
- Truthy semantics for branches 1, 4: empty string and `null`/`undefined` are falsy β fall through to next branch.
|
| 156 |
+
- `url[0]` is taken **without checking truthiness**: if `url` is an array of any length, the first element is returned even if it's `null`, `""`, `0`, etc. This means an array `[null]` produces `null`, NOT `undefined`. Don't "improve" this.
|
| 157 |
+
- `url` as a string short-circuits to that string β no further fallback. Even an empty string would short-circuit (passes `typeof === "string"`).
|
| 158 |
+
- The HF template is `https://huggingface.co/datasets/${hf_repo}` with NO encoding, validation, or slash normalization. Whatever `hf_repo` is, it gets appended verbatim.
|
| 159 |
+
- Suggested pipeline emission: add `dataset_url` field to every `source_data` object with the resolved URL. TS-side fallback chain becomes dead code (branch 1 always wins).
|
| 160 |
+
|
| 161 |
+
Verification: run `scripts/verify-dataset-url.mjs` against pipeline-emitted `dataset_url` once it ships. Goal: zero divergence vs TS-as-is across 587 production eval-details.
|
| 162 |
+
|
| 163 |
+
## Migration checklist
|
| 164 |
+
|
| 165 |
+
- [x] Spec written
|
| 166 |
+
- [x] Tests cover each rule branch + edge cases (`tests/transformations/dataset-url-synthesis.test.ts`)
|
| 167 |
+
- [ ] Filed with pipeline owner with the spec + tests + audit script as acceptance criterion
|
| 168 |
+
- [ ] Pipeline emits resolved `dataset_url` on every `source_data` matching this spec
|
| 169 |
+
- [ ] TS deleted; BOTH Site A (`components/eval-card.tsx:83-86`) AND Site B (`components/benchmark-detail.tsx:5043-5047`) read `sourceData?.dataset_url` directly. Site C (the bare `hf_repo` template in `components/benchmark-detail.tsx:5416-5420`) is a different UI element and stays.
|
| 170 |
+
|
| 171 |
+
## Future product decision (deferred)
|
| 172 |
+
|
| 173 |
+
The 1 `undefined` case (`cocoabench`) means the dataset link button will be missing/disabled for that eval. Whether pipeline should synthesize a fallback (e.g. from `additional_details.benchmark_reference_urls_json`) is a product question outside this refactor's scope.
|
notes/transformations/05-slug-candidates.md
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Slug candidate generation (model + developer file lookup)
|
| 2 |
+
|
| 3 |
+
Drafted 2026-04-28. Migration item #19 in `notes/migration-plan.md`.
|
| 4 |
+
|
| 5 |
+
## Framing reminder
|
| 6 |
+
|
| 7 |
+
We are refactoring for UI efficiency. TS-as-is is the canonical spec. The retry logic is **load-bearing** in production (39% of model lookups and 99.9% of developer lookups depend on a non-zero retry position). Don't try to "clean up" the candidate generation β preserve it until pipeline emits canonical-by-construction filenames.
|
| 8 |
+
|
| 9 |
+
## Rule (as TS implements it today)
|
| 10 |
+
|
| 11 |
+
Three pure functions in `lib/model-data.ts:150-211` translate model and developer identifiers into ordered lists of candidate filenames to try when looking up the corresponding JSON in the HF cache (`models/<slug>.json`, `developers/<slug>.json`).
|
| 12 |
+
|
| 13 |
+
### `pipelineSlugify(text)` β base helper
|
| 14 |
+
|
| 15 |
+
Mirrors the slug rule the upstream pipeline uses:
|
| 16 |
+
|
| 17 |
+
1. Strip control characters (`\x00-\x1f\x7f`).
|
| 18 |
+
2. Replace any character not in `[a-zA-Z0-9._-]` with `_` (preserves dots, dashes, alnum, case).
|
| 19 |
+
3. Trim leading/trailing underscores.
|
| 20 |
+
4. Return `"unknown"` if the result is empty.
|
| 21 |
+
|
| 22 |
+
Note: dots and dashes are preserved AS-IS; only "weird" characters become underscores. Slashes are NOT preserved β they become underscores.
|
| 23 |
+
|
| 24 |
+
### `getModelDetailSlugCandidates(modelId)` β produce up to 6 candidate model slugs
|
| 25 |
+
|
| 26 |
+
Inserts variants into a `Set` (so duplicates collapse) in this order:
|
| 27 |
+
|
| 28 |
+
```
|
| 29 |
+
withSlash = modelId.replace(/\//g, "__") // "openai/gpt-5.2" β "openai__gpt-5.2"
|
| 30 |
+
withDots = withSlash.replace(/\./g, "-") // "openai__gpt-5.2" β "openai__gpt-5-2"
|
| 31 |
+
candidates: pipelineSlugify(withSlash),
|
| 32 |
+
pipelineSlugify(withSlash.toLowerCase()),
|
| 33 |
+
pipelineSlugify(withDots),
|
| 34 |
+
pipelineSlugify(withDots.toLowerCase()),
|
| 35 |
+
pipelineSlugify(modelId),
|
| 36 |
+
pipelineSlugify(modelId.toLowerCase())
|
| 37 |
+
return Array.from(set)
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
The `Set` collapses duplicates: e.g., for an already-lowercase input, the `.toLowerCase()` variants are no-ops and drop out, so the actual returned array is shorter.
|
| 41 |
+
|
| 42 |
+
### `getDeveloperSlugCandidates(developerOrRouteId)` β up to 6 candidate developer slugs
|
| 43 |
+
|
| 44 |
+
Same Set-based pattern but with different transformations:
|
| 45 |
+
|
| 46 |
+
```
|
| 47 |
+
underscoreSlug = pipelineSlugify(input)
|
| 48 |
+
lowercaseUnderscoreSlug = pipelineSlugify(input.toLowerCase())
|
| 49 |
+
hyphenSlug = input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")
|
| 50 |
+
compactSlug = input.toLowerCase().replace(/[^a-z0-9]+/g, "")
|
| 51 |
+
candidates: underscoreSlug,
|
| 52 |
+
lowercaseUnderscoreSlug,
|
| 53 |
+
underscoreSlug.replace(/_/g, "-"),
|
| 54 |
+
lowercaseUnderscoreSlug.replace(/_/g, "-"),
|
| 55 |
+
hyphenSlug (if non-empty),
|
| 56 |
+
compactSlug (if non-empty)
|
| 57 |
+
return Array.from(set)
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
## Classification
|
| 61 |
+
|
| 62 |
+
- **Lookup transformation (default-only)**. The retry walks candidates and uses the first that resolves to an actual file. Pipeline-side fix: emit a single canonical filename per model/developer that matches `model_route_id`/`developer_route_id` exactly. Then no retry needed.
|
| 63 |
+
- **Cleaning β pipeline.** The slug derivation (`pipelineSlugify`) and canonical ID emission are value transforms per record. The retry loop exists only to compensate for the pipeline not yet emitting a stable canonical ID β once it does, both the slug logic and the retry delete together. No aggregation.
|
| 64 |
+
|
| 65 |
+
## Inputs and expected outputs
|
| 66 |
+
|
| 67 |
+
### Group A β `pipelineSlugify`
|
| 68 |
+
|
| 69 |
+
| Input | Output | Rule |
|
| 70 |
+
|---|---|---|
|
| 71 |
+
| `"openai__gpt-5"` | `"openai__gpt-5"` | passthrough (alnum + dash + underscore allowed) |
|
| 72 |
+
| `"openai__gpt-5.2"` | `"openai__gpt-5.2"` | passthrough (dot allowed) |
|
| 73 |
+
| `"openai/gpt-5"` | `"openai_gpt-5"` | slash β underscore (slash not in allowed set) |
|
| 74 |
+
| `"OpenAI"` | `"OpenAI"` | passthrough (case preserved) |
|
| 75 |
+
| `"x0000001"` | `"x0000001"` | passthrough |
|
| 76 |
+
| `"foo bar"` | `"foo_bar"` | space β underscore |
|
| 77 |
+
| `"foo!@#bar"` | `"foo___bar"` | each special char β `_` |
|
| 78 |
+
| `"___foo___"` | `"foo"` | trim leading/trailing underscores |
|
| 79 |
+
| `"!!!"` | `"unknown"` | empty after trim β fallback |
|
| 80 |
+
| `""` | `"unknown"` | empty β fallback |
|
| 81 |
+
|
| 82 |
+
### Group B β `getModelDetailSlugCandidates`
|
| 83 |
+
|
| 84 |
+
| Input | Candidates returned | Why these are distinct |
|
| 85 |
+
|---|---|---|
|
| 86 |
+
| `"openai/gpt-5"` | `["openai__gpt-5", "openai_gpt-5"]` | already-lowercase + no dots β only slash and slash-stripped variants survive Set dedup |
|
| 87 |
+
| `"openai/gpt-5.2"` | `["openai__gpt-5.2", "openai__gpt-5-2", "openai_gpt-5.2"]` | dotted form gets the with-dots variant (position 1) |
|
| 88 |
+
| `"OpenAI/GPT-5"` | `["OpenAI__GPT-5", "openai__gpt-5", "OpenAI_GPT-5", "openai_gpt-5"]` | case-mixed input β both case variants survive |
|
| 89 |
+
| `"anthropic/claude-3.7-sonnet"` | `["anthropic__claude-3.7-sonnet", "anthropic__claude-3-7-sonnet", "anthropic_claude-3.7-sonnet"]` | dotted version |
|
| 90 |
+
| `"unknown/foo"` | `["unknown__foo", "unknown_foo"]` | already-lowercase + no dots |
|
| 91 |
+
|
| 92 |
+
### Group C β `getDeveloperSlugCandidates`
|
| 93 |
+
|
| 94 |
+
| Input | Candidates returned (in order, deduped) |
|
| 95 |
+
|---|---|
|
| 96 |
+
| `"openai"` | `["openai"]` (all variants collapse to same form) |
|
| 97 |
+
| `"OpenAI"` | `["OpenAI", "openai"]` (case variants distinct) |
|
| 98 |
+
| `"01-ai"` | `["01-ai", "01ai"]` (compactSlug strips the dash) |
|
| 99 |
+
| `"Mistral AI"` | `["Mistral_AI", "mistral_ai", "Mistral-AI", "mistral-ai", "mistralai"]` (space β underscore + hyphen + compact variants) |
|
| 100 |
+
| `"01_ai"` | `["01_ai", "01-ai", "01ai"]` (underscore-slug, dash variant, compact) |
|
| 101 |
+
|
| 102 |
+
## Current TS implementation
|
| 103 |
+
|
| 104 |
+
The four functions are tightly coupled β `pipelineSlugify` is the base; the others build on it.
|
| 105 |
+
|
| 106 |
+
| Concern | Location | Used by |
|
| 107 |
+
|---|---|---|
|
| 108 |
+
| Base slugifier | `lib/model-data.ts:150-157` (`pipelineSlugify`) | the other three slug functions |
|
| 109 |
+
| Developer route_id derivation (exported) | `lib/model-data.ts:159-161` (`getDeveloperRouteId`) | sets `route_id` on output objects in 7 places (see below) |
|
| 110 |
+
| Model candidates | `lib/model-data.ts:167-185` (`getModelDetailSlugCandidates`) | model lookup retry |
|
| 111 |
+
| Developer candidates (exported) | `lib/model-data.ts:187-211` (`getDeveloperSlugCandidates`) | developer lookup retry |
|
| 112 |
+
|
| 113 |
+
### Call sites β model lookups (`getModelDetailSlugCandidates` retry)
|
| 114 |
+
|
| 115 |
+
| Location | Context |
|
| 116 |
+
|---|---|
|
| 117 |
+
| `lib/model-data.ts:1492` | `getModelSummaryById` β first attempt: try candidates of the URL-passed modelId |
|
| 118 |
+
| `lib/model-data.ts:1527` | `getModelSummaryById` fallback β for each variant's raw_model_ids, try candidates |
|
| 119 |
+
|
| 120 |
+
So the model lookup is THREE-stage in `getModelSummaryById`:
|
| 121 |
+
1. Direct candidates from the input `modelId` (line 1492)
|
| 122 |
+
2. If a card matches in `model-cards.json`, try its `model_route_id` directly (line 1516)
|
| 123 |
+
3. Iterate every variant's `raw_model_ids` and try candidates of each (line 1527)
|
| 124 |
+
|
| 125 |
+
### Call sites β developer lookups (`getDeveloperSlugCandidates` retry)
|
| 126 |
+
|
| 127 |
+
| Location | Context |
|
| 128 |
+
|---|---|
|
| 129 |
+
| `lib/model-data.ts:1343` | inside developer-list build; iterate candidates of `entry.developer` |
|
| 130 |
+
| `lib/model-data.ts:1413` | `getDeveloperSummaryById` β try candidates of the URL-passed routeId |
|
| 131 |
+
| `lib/model-data.ts:1452` | `getDeveloperSummaryById` fallback β try candidates of the matched developer's name |
|
| 132 |
+
|
| 133 |
+
### Call sites β `getDeveloperRouteId` (output-side route_id derivation)
|
| 134 |
+
|
| 135 |
+
| Location | Context |
|
| 136 |
+
|---|---|
|
| 137 |
+
| `lib/model-data.ts:1320` | `getDeveloperList` β set `route_id` on each developer summary |
|
| 138 |
+
| `lib/model-data.ts:1368` | (build path A) |
|
| 139 |
+
| `lib/model-data.ts:1402` | `getDeveloperSummaryById` β set `route_id` on returned summary |
|
| 140 |
+
| `lib/model-data.ts:1435` | (build path B) |
|
| 141 |
+
| `lib/model-data.ts:1448` | comparison: `e.developer === routeId \|\| getDeveloperRouteId(e.developer) === routeId` |
|
| 142 |
+
| `lib/model-data.ts:1476` | (build path C) |
|
| 143 |
+
| `lib/duckdb-data.ts:307` | DuckDB backend β set `route_id` on developer list output |
|
| 144 |
+
|
| 145 |
+
`getDeveloperRouteId` is the function that DERIVES `route_id` from `developer` β it's what makes the comparison at line 1448 work, and it's how the API output gets a stable `route_id` field for routing. Deleting `getDeveloperRouteId` without addressing these callers would break developer-page navigation.
|
| 146 |
+
|
| 147 |
+
## Pipeline status β divergences
|
| 148 |
+
|
| 149 |
+
### Side-by-side comparison table
|
| 150 |
+
|
| 151 |
+
| Aspect | TS (this spec) | Pipeline today | Result for users |
|
| 152 |
+
|---|---|---|---|
|
| 153 |
+
| File naming (models/) | n/a (consumer side) | filenames written by pipeline; some use `route_id`, others use a dot-stripped variant | TS retries up to 6 candidates per request to find the right file |
|
| 154 |
+
| File naming (developers/) | n/a (consumer side) | filenames are slug-cased developer names; `developers.json` does NOT carry `route_id` | TS derives candidates from the developer name itself |
|
| 155 |
+
| Lookup overhead | up to 6 HF fetch attempts per missing-direct lookup | none (it's just emitting files) | wasted requests on cold-cache; redirected by retry logic |
|
| 156 |
+
|
| 157 |
+
### Concrete worked example with quantified scope
|
| 158 |
+
|
| 159 |
+
Audited 2026-04-28 against `.cache/hf-data/`:
|
| 160 |
+
|
| 161 |
+
**Model lookups (5,830 cards):**
|
| 162 |
+
- Candidate position 0 hits: **3,529** (60.5%) β `route_id` matches the file directly
|
| 163 |
+
- Candidate position 1 hits: **2,297** (39.4%) β needed the dotβdash conversion (e.g. `gpt-5.2` β file `gpt-5-2`)
|
| 164 |
+
- Misses (none of 6 candidates worked): **4** (0.07%)
|
| 165 |
+
|
| 166 |
+
**Developer lookups (824 developers):**
|
| 167 |
+
- Candidate position 0 hits: **468** (56.8%) β slugified raw input matches
|
| 168 |
+
- Candidate position 1 hits: **351** (42.6%) β needed `.toLowerCase()` (developers with mixed-case names)
|
| 169 |
+
- Candidate position 3 hits: **4** (0.5%) β needed underscoreβdash on the lowercased slug
|
| 170 |
+
- Misses: **1** (`x0000001` β no developer file under that name)
|
| 171 |
+
|
| 172 |
+
The retry is doing real work: 39% of models and 43% of developers would 404 on direct lookup.
|
| 173 |
+
|
| 174 |
+
Verified by `scripts/verify-slug-candidates.mjs`.
|
| 175 |
+
|
| 176 |
+
## Notes for pipeline implementer
|
| 177 |
+
|
| 178 |
+
The cleanest pipeline-side fix: **always emit `models/<route_id>.json` and `developers/<route_id>.json` directly**, where `route_id` is the canonical form already on each card. Then TS does a single direct lookup; the retry chain becomes dead code.
|
| 179 |
+
|
| 180 |
+
If pipeline can't easily change file naming, the second-best option is to emit a **slugβfile map** in `manifest.json` so TS does an O(1) lookup with no fallback.
|
| 181 |
+
|
| 182 |
+
Concrete requirements for the simpler "canonical filenames" path:
|
| 183 |
+
|
| 184 |
+
1. For every card in `model-cards.json`: write `models/<card.model_route_id>.json` with the contents.
|
| 185 |
+
- For dotted family_ids like `openai/gpt-5.2`, the `model_route_id` is `openai__gpt-5.2` (with dot preserved). The current cache files for these use dashes (`openai__gpt-5-2.json`). Pick one form and stick with it.
|
| 186 |
+
2. For every entry in `developers.json`: ensure `route_id` is populated (currently absent) and write `developers/<route_id>.json`.
|
| 187 |
+
3. The 4 model misses + 1 developer miss currently in production should be investigated separately β they represent missing files, not naming-convention issues.
|
| 188 |
+
|
| 189 |
+
Don't try to reproduce the 6-candidate generation logic upstream. The point of the migration is to make it unnecessary.
|
| 190 |
+
|
| 191 |
+
Verification: once pipeline ships canonical naming, every card in `model-cards.json` should resolve via `fs.existsSync('models/' + card.model_route_id + '.json')` directly. Run `scripts/verify-slug-candidates.mjs` and confirm `position 0 hits === total cards`.
|
| 192 |
+
|
| 193 |
+
## Migration checklist
|
| 194 |
+
|
| 195 |
+
- [x] Spec written
|
| 196 |
+
- [x] Tests cover each rule branch (`tests/transformations/slug-candidates.test.ts`)
|
| 197 |
+
- [ ] Filed with pipeline owner with the spec + tests + audit script as acceptance criterion
|
| 198 |
+
- [ ] Pipeline emits `models/<route_id>.json` matching `model_route_id` exactly for all 5,830 cards
|
| 199 |
+
- [ ] Pipeline emits `developers/<route_id>.json` matching `developer_route_id` for all 824 developers
|
| 200 |
+
- [ ] Pipeline adds `route_id` field to every entry in `developers.json` (currently absent β TS derives via `getDeveloperRouteId(developer)`)
|
| 201 |
+
- [ ] Pipeline-emitted `developer_route_id` matches `pipelineSlugify(developer.trim().toLowerCase())` for every developer (so the 7 `getDeveloperRouteId` call sites can read pipeline values directly without re-deriving)
|
| 202 |
+
- [ ] TS deleted; callers do single direct lookup. Deletion includes ALL FOUR functions (`pipelineSlugify`, `getDeveloperRouteId`, `getModelDetailSlugCandidates`, `getDeveloperSlugCandidates`) plus the 12 call sites enumerated above.
|
| 203 |
+
|
| 204 |
+
## Future product decision (deferred)
|
| 205 |
+
|
| 206 |
+
The 4 model misses and 1 developer miss represent files that don't exist. Whether those are "should exist but pipeline forgot" or "intentionally absent" is a product question. Surface to pipeline owner separately during the cleanup pass.
|
notes/transformations/06-developer-name-canonicalization.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Developer name canonicalization
|
| 2 |
+
|
| 3 |
+
Drafted 2026-04-28. Migration item #9 in `notes/migration-plan.md`.
|
| 4 |
+
|
| 5 |
+
## Framing reminder
|
| 6 |
+
|
| 7 |
+
We are refactoring for UI efficiency. TS-as-is is the canonical spec. The transformation has known imperfections (random HF handles get mechanically title-cased; some users' preferred capitalization doesn't survive) but those are deferred product decisions, not bugs to fix in this migration.
|
| 8 |
+
|
| 9 |
+
## Rule (as TS implements it today)
|
| 10 |
+
|
| 11 |
+
`normalizeDeveloperName(name)` (`lib/model-data.ts:236-244`) applies one of three transformations in this order:
|
| 12 |
+
|
| 13 |
+
1. **Map hit (case-insensitive lookup):** lowercase the name, then look up in `KNOWN_DEVELOPER_NAMES`. If present, return the mapped canonical form.
|
| 14 |
+
2. **Title-case fallback:** if input is fully lowercase AND starts with `[a-z]`, return `name.charAt(0).toUpperCase() + name.slice(1)`. Only the first character is capitalized.
|
| 15 |
+
3. **Passthrough:** return input unchanged.
|
| 16 |
+
|
| 17 |
+
The map (`lib/model-data.ts:217-234`) has 16 entries:
|
| 18 |
+
|
| 19 |
+
| Map key | Canonical |
|
| 20 |
+
|---|---|
|
| 21 |
+
| `openai` | OpenAI |
|
| 22 |
+
| `google` | Google |
|
| 23 |
+
| `anthropic` | Anthropic |
|
| 24 |
+
| `meta` | Meta |
|
| 25 |
+
| `microsoft` | Microsoft |
|
| 26 |
+
| `mistralai` | Mistral AI |
|
| 27 |
+
| `deepseek` | DeepSeek |
|
| 28 |
+
| `deepseek-ai` | DeepSeek |
|
| 29 |
+
| `cohere` | Cohere |
|
| 30 |
+
| `nvidia` | NVIDIA |
|
| 31 |
+
| `alibaba` | Alibaba |
|
| 32 |
+
| `amazon` | Amazon |
|
| 33 |
+
| `apple` | Apple |
|
| 34 |
+
| `ibm` | IBM |
|
| 35 |
+
| `xai` | xAI |
|
| 36 |
+
| `x-ai` | xAI |
|
| 37 |
+
|
| 38 |
+
Note that some map keys collide with their canonical form (e.g. `google` β `Google` is just case-fixing) while others apply substantive transforms (`mistralai` β `Mistral AI` adds a space; `deepseek-ai` β `DeepSeek` strips the `-ai` suffix; `xai` β `xAI` mid-word capital).
|
| 39 |
+
|
| 40 |
+
## Classification
|
| 41 |
+
|
| 42 |
+
- **Unconditional normalization.** The function always runs on whatever `developer` string is present β it does not check for a pre-existing canonical field. Map hits, title-case fallback, and passthrough are all branches of the same unconditional transform. Pipeline-side fix: emit `developer` in canonical form directly; no consumer should re-derive it.
|
| 43 |
+
- **Cleaning β pipeline.** Pure value transform on a single field. No aggregation or record merging. Migration target: pipeline emits canonical `developer`; TS `normalizeDeveloperName` and `KNOWN_DEVELOPER_NAMES` delete.
|
| 44 |
+
|
| 45 |
+
## Inputs and expected outputs
|
| 46 |
+
|
| 47 |
+
Each row corresponds to a parameterized test case in `tests/transformations/developer-name-canonicalization.test.ts`.
|
| 48 |
+
|
| 49 |
+
### Group A β Map hits (case-insensitive, substantive transforms)
|
| 50 |
+
|
| 51 |
+
| Input | Output | Rule |
|
| 52 |
+
|---|---|---|
|
| 53 |
+
| `openai` | `OpenAI` | map (case fix) |
|
| 54 |
+
| `OpenAI` | `OpenAI` | map (case-insensitive lookup β same canonical form) |
|
| 55 |
+
| `OPENAI` | `OpenAI` | map |
|
| 56 |
+
| `mistralai` | `Mistral AI` | map (space added) |
|
| 57 |
+
| `MistralAI` | `Mistral AI` | map (case-insensitive) |
|
| 58 |
+
| `deepseek-ai` | `DeepSeek` | map (`-ai` suffix dropped) |
|
| 59 |
+
| `DeepSeek-AI` | `DeepSeek` | map (case-insensitive) |
|
| 60 |
+
| `xai` | `xAI` | map (mid-word cap) |
|
| 61 |
+
| `x-ai` | `xAI` | map (alias) |
|
| 62 |
+
| `nvidia` | `NVIDIA` | map (uppercase) |
|
| 63 |
+
| `IBM` | `IBM` | map (case-insensitive lookup β uppercase canonical) |
|
| 64 |
+
|
| 65 |
+
### Group B β Title-case fallback (lowercase input, not in map)
|
| 66 |
+
|
| 67 |
+
| Input | Output | Why |
|
| 68 |
+
|---|---|---|
|
| 69 |
+
| `jaspionjader` | `Jaspionjader` | lowercase + starts with [a-z] β title-case first char only |
|
| 70 |
+
| `allenai` | `Allenai` | lowercase + not in map β first-char uppercase only |
|
| 71 |
+
| `bunnycore` | `Bunnycore` | same |
|
| 72 |
+
| `zelk12` | `Zelk12` | same β digits inside don't matter |
|
| 73 |
+
|
| 74 |
+
### Group C β Passthrough (mixed case, not in map)
|
| 75 |
+
|
| 76 |
+
| Input | Output | Why |
|
| 77 |
+
|---|---|---|
|
| 78 |
+
| `JayHyeon` | `JayHyeon` | already has uppercase β not lowercase β passthrough |
|
| 79 |
+
| `DreadPoor` | `DreadPoor` | same |
|
| 80 |
+
| `Qwen` | `Qwen` | (Qwen is NOT in the map; passes through) |
|
| 81 |
+
| `prithivMLmods` | `prithivMLmods` | mixed case, passthrough as-is β no first-char capitalization (input has uppercase, so the lowercase check fails) |
|
| 82 |
+
| `Quazim0t0` | `Quazim0t0` | same |
|
| 83 |
+
| `01-ai` | `01-ai` | does NOT start with [a-z] (starts with digit) β fallback rule fails β passthrough. Note: 01-ai is NOT in the map. |
|
| 84 |
+
| `01_ai` | `01_ai` | same |
|
| 85 |
+
|
| 86 |
+
### Group D β Edge cases
|
| 87 |
+
|
| 88 |
+
| Input | Output | Notes |
|
| 89 |
+
|---|---|---|
|
| 90 |
+
| ` google ` | `Google` | trim happens inside `key = name.trim().toLowerCase()` BUT the title-case branch and passthrough use the ORIGINAL `name` (not trimmed). For ` google `: key = "google" β matches map β "Google" |
|
| 91 |
+
| ` jaspionjader ` | ` jaspionjader ` | key = "jaspionjader" β no map hit. Title-case check uses original `name` which has spaces β `" jaspionjader " === " jaspionjader ".toLowerCase()` is true, BUT `/^[a-z]/.test(" jaspionjader ")` is FALSE (starts with space). β falls to passthrough |
|
| 92 |
+
| (empty string) | (empty string) | trim makes key empty β no map hit. Title-case check: `"" === "".toLowerCase()` is true but `/^[a-z]/.test("")` is false β passthrough returns "" |
|
| 93 |
+
|
| 94 |
+
The edge case shows a TS quirk: leading whitespace prevents the title-case rule from firing, so `" jaspionjader "` passes through unchanged. The map-lookup uses the trimmed form and works for known names regardless.
|
| 95 |
+
|
| 96 |
+
## Current TS implementation
|
| 97 |
+
|
| 98 |
+
| Concern | Location |
|
| 99 |
+
|---|---|
|
| 100 |
+
| Map | `lib/model-data.ts:217-234` (`KNOWN_DEVELOPER_NAMES`) |
|
| 101 |
+
| Function (exported) | `lib/model-data.ts:236-244` (`normalizeDeveloperName`) |
|
| 102 |
+
|
| 103 |
+
### Call sites (5 total)
|
| 104 |
+
|
| 105 |
+
| Location | Context |
|
| 106 |
+
|---|---|
|
| 107 |
+
| `lib/model-data.ts:387` | `hfModelCardToEvaluationCardData` β set `developer` on output card |
|
| 108 |
+
| `lib/model-data.ts:1367` | developer-list build path A |
|
| 109 |
+
| `lib/model-data.ts:1401` | `getDeveloperSummaryById` β set `developer` on returned summary |
|
| 110 |
+
| `lib/model-data.ts:1434` | developer-list build path B |
|
| 111 |
+
| `lib/duckdb-data.ts:306` | DuckDB backend β set `developer` on developer list output |
|
| 112 |
+
|
| 113 |
+
## Pipeline status β divergences
|
| 114 |
+
|
| 115 |
+
### Side-by-side comparison table
|
| 116 |
+
|
| 117 |
+
| Aspect | TS (this spec) | Pipeline today | Result for users |
|
| 118 |
+
|---|---|---|---|
|
| 119 |
+
| Where canonicalization runs | request time, in 5 call sites | not implemented; raw `developer` string emitted as-is | TS canonicalizes per request |
|
| 120 |
+
| Output field | inline transformation of `developer` field | n/a | TS-canonicalized name appears on user-visible UI |
|
| 121 |
+
|
| 122 |
+
### Concrete worked example with quantified scope
|
| 123 |
+
|
| 124 |
+
Audited 2026-04-28 against `.cache/hf-data/`:
|
| 125 |
+
|
| 126 |
+
**`developers.json` (824 entries):**
|
| 127 |
+
- Map hits: **15** (1.8%) β names like `Google`, `OpenAI`, `Alibaba` (the map's main job is case-fixing on these inputs since they already arrive Title-cased)
|
| 128 |
+
- Title-case fallback: **458** (55.6%) β lowercase HF handles like `jaspionjader`, `allenai`, `bunnycore` get first-char-uppercased
|
| 129 |
+
- Passthrough: **351** (42.6%) β mixed-case handles like `JayHyeon`, `prithivMLmods`, `Qwen` survive unchanged
|
| 130 |
+
|
| 131 |
+
**`model-cards.json` (5,830 cards):**
|
| 132 |
+
- Map hits: **695** (11.9%)
|
| 133 |
+
- Title-case fallback: **2,824** (48.4%)
|
| 134 |
+
- Passthrough: **2,311** (39.6%)
|
| 135 |
+
|
| 136 |
+
The substantive map transforms (`mistralai` β `Mistral AI`, `deepseek-ai` β `DeepSeek`) DO fire in production β the model-cards.json mapHit count being higher than developers.json (11.9% vs 1.8%) suggests more model entries use the lowercase-suffix forms (e.g., `deepseek-ai` from the HF org slug) than the developers.json which already uses canonical forms.
|
| 137 |
+
|
| 138 |
+
Verified by `scripts/verify-developer-name.mjs`.
|
| 139 |
+
|
| 140 |
+
## Notes for pipeline implementer
|
| 141 |
+
|
| 142 |
+
- Reproduce all 16 map entries exactly (both case-fix entries like `google β Google` and substantive transforms like `mistralai β Mistral AI`).
|
| 143 |
+
- Reproduce the title-case fallback exactly: only fire when the entire string equals `name.toLowerCase()` AND starts with `[a-z]`. Don't capitalize anything else (no smart casing of multi-word names, no unicode-aware uppercasing).
|
| 144 |
+
- The leading-whitespace quirk (`" jaspionjader "` passes through unchanged because the title-case regex fails on the leading space) should be preserved as-is.
|
| 145 |
+
- Suggested pipeline emission: add `canonical_developer_name` field to `developers.json` entries and to every model card. Don't overwrite the upstream `developer` field; new field for clarity.
|
| 146 |
+
|
| 147 |
+
Verification: run `scripts/verify-developer-name.mjs` against pipeline output once it ships. Goal: zero divergence vs TS-as-is across both 824 developers and 5,830 model cards.
|
| 148 |
+
|
| 149 |
+
## Migration checklist
|
| 150 |
+
|
| 151 |
+
- [x] Spec written
|
| 152 |
+
- [x] Tests cover each rule branch (`tests/transformations/developer-name-canonicalization.test.ts`)
|
| 153 |
+
- [ ] Filed with pipeline owner with the spec + tests + audit script as acceptance criterion
|
| 154 |
+
- [ ] Pipeline emits `canonical_developer_name` on every developer entry + model card matching this spec
|
| 155 |
+
- [ ] TS deleted; replace 5 call sites (4 in lib/model-data.ts + 1 in lib/duckdb-data.ts) with direct field reads. Delete `KNOWN_DEVELOPER_NAMES` table + `normalizeDeveloperName`.
|
| 156 |
+
|
| 157 |
+
## Future product decision (deferred)
|
| 158 |
+
|
| 159 |
+
The title-case fallback produces stylistically-questionable output for HF user handles (`jaspionjader β Jaspionjader`). Whether the team wants to (a) expand the map to cover more cases, (b) leave random HF handles as-is, or (c) take a different approach (e.g. fetch the user's preferred display name from HF API) is out of scope for this refactor.
|
notes/transformations/07-timestamp-normalization.md
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Timestamp normalization
|
| 2 |
+
|
| 3 |
+
Drafted 2026-04-28. Migration item #13 in `notes/migration-plan.md`.
|
| 4 |
+
|
| 5 |
+
## Framing reminder
|
| 6 |
+
|
| 7 |
+
We are refactoring for UI efficiency. TS-as-is is the canonical spec. Three different timestamp normalizers exist in production, with subtly different semantics. They produce different numeric values for the same input but happen to converge on production data (99.99% is unix-seconds-strings; the divergence only fires when comparing across formats, which production rarely does). The migration target: emit a single canonical timestamp format upstream so all three normalizers can be deleted.
|
| 8 |
+
|
| 9 |
+
## Rule (as TS implements it today β three variants)
|
| 10 |
+
|
| 11 |
+
Three independent functions parse string timestamps into comparable numbers:
|
| 12 |
+
|
| 13 |
+
### Variant A β `lib/model-data.ts:76-81` (`normalizeEvalTimestamp`)
|
| 14 |
+
|
| 15 |
+
```ts
|
| 16 |
+
function normalizeEvalTimestamp(value: string) {
|
| 17 |
+
const numericTimestamp = Number(value)
|
| 18 |
+
return !Number.isNaN(numericTimestamp) && !value.includes("-")
|
| 19 |
+
? numericTimestamp * 1000
|
| 20 |
+
: new Date(value).getTime()
|
| 21 |
+
}
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
- Uses `Number()` (strict β entire string must be numeric or returns `NaN`)
|
| 25 |
+
- If numeric AND no `-` in input β multiply by 1000 (treats as **unix seconds**, output in ms)
|
| 26 |
+
- Else β `new Date(value).getTime()` (ISO date parsing, output in ms)
|
| 27 |
+
- Returns `NaN` if neither path produces a finite number (no defensive fallback)
|
| 28 |
+
|
| 29 |
+
### Variant B β `lib/hf-data.ts:1049-1061` (`toComparableTimestamp`)
|
| 30 |
+
|
| 31 |
+
```ts
|
| 32 |
+
function toComparableTimestamp(timestamp: string | undefined) {
|
| 33 |
+
if (!timestamp) return Number.NEGATIVE_INFINITY
|
| 34 |
+
const numericTimestamp = Number.parseFloat(timestamp)
|
| 35 |
+
if (Number.isFinite(numericTimestamp)) return numericTimestamp
|
| 36 |
+
const parsedTimestamp = new Date(timestamp).getTime()
|
| 37 |
+
return Number.isFinite(parsedTimestamp) ? parsedTimestamp : Number.NEGATIVE_INFINITY
|
| 38 |
+
}
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
- Uses `Number.parseFloat()` (lenient β parses leading numeric prefix; e.g. `"2026-04-13"` β `2026`)
|
| 42 |
+
- If parseFloat returns finite β return AS-IS (NO `* 1000` multiplier)
|
| 43 |
+
- Else β fallback to `Date.getTime()` or `NEGATIVE_INFINITY`
|
| 44 |
+
- Defensive: undefined β `NEGATIVE_INFINITY`
|
| 45 |
+
|
| 46 |
+
### Variant C β `components/benchmark-detail.tsx:1418-1426` (`toComparableTimestamp`)
|
| 47 |
+
|
| 48 |
+
Same as Variant B but parameter is `string` (not `string | undefined`) and there's no leading `if (!timestamp)` check. Otherwise functionally identical.
|
| 49 |
+
|
| 50 |
+
## Classification
|
| 51 |
+
|
| 52 |
+
This item has two halves that land in different places:
|
| 53 |
+
|
| 54 |
+
- **Cleaning (value format canonicalization) β pipeline.** The pipeline currently emits `retrieved_timestamp` as a unix-seconds-string. Converting to ISO 8601 is a value-change that belongs upstream; once done, all consumers read a consistently-formatted string with no parsing quirks.
|
| 55 |
+
- **Reshape (variant dedup / sort-key derivation) β DuckDB SQL.** The 3 normalizers + 8 call sites exist solely to compare timestamps in order to pick the freshest variant or sort models by recency. That's a `MAX(retrieved_timestamp)` or `ORDER BY retrieved_timestamp DESC` operation β reshape work that has no business running at request time in TS. Once timestamps are ISO 8601, SQL comparison is lexicographic and correct. With a relational parquet schema, variant dedup becomes `QUALIFY ROW_NUMBER() OVER (PARTITION BY variant_key ORDER BY retrieved_timestamp DESC) = 1` instead of three TS normalizers.
|
| 56 |
+
|
| 57 |
+
The two halves delete together: pipeline emits ISO 8601 (cleaning done) β SQL replaces the comparison call sites (reshape done) β all three TS functions deleted.
|
| 58 |
+
|
| 59 |
+
## Inputs and expected outputs
|
| 60 |
+
|
| 61 |
+
Each table below describes ONE variant. Pipeline must produce identical outputs per variant when canonical timestamps still roundtrip through these functions; the deletion target is to remove all three.
|
| 62 |
+
|
| 63 |
+
### Group A β Variant A (`normalizeEvalTimestamp`)
|
| 64 |
+
|
| 65 |
+
| Input | Output | Path |
|
| 66 |
+
|---|---|---|
|
| 67 |
+
| `"1774096306"` | `1774096306000` | numeric, no dash β `* 1000` (unix seconds β ms) |
|
| 68 |
+
| `"1774096306.427425"` | `1774096306427.4248` | numeric, no dash β `* 1000` |
|
| 69 |
+
| `"2026-04-13T12:34:56Z"` | `1776083696000` | not numeric β `Date.getTime()` |
|
| 70 |
+
| `"2025-01-01"` | `1735689600000` | not numeric β `Date.getTime()` |
|
| 71 |
+
| `"-1774096306"` | (a Date in 1969) | numeric BUT includes `-` β falls to `Date.getTime()` of negative-number-string β unexpected |
|
| 72 |
+
| `"not a date"` | `NaN` | not numeric AND `Date(...)` is invalid β returns NaN |
|
| 73 |
+
| `""` | `NaN` | Number("") = 0, no dash, β 0 * 1000 = 0... actually wait, Number("") is 0, !isNaN(0) is true, includes("-") false, β 0 * 1000 = 0. So empty returns 0, not NaN. |
|
| 74 |
+
| `"20240620"` | `20240620000` | numeric, no dash β `* 1000`. Treated as unix seconds (year 1970) β NOT as YYYYMMDD date |
|
| 75 |
+
|
| 76 |
+
### Group B β Variant B (`toComparableTimestamp` in lib/hf-data.ts)
|
| 77 |
+
|
| 78 |
+
| Input | Output | Path |
|
| 79 |
+
|---|---|---|
|
| 80 |
+
| `"1774096306"` | `1774096306` | parseFloat finite β return as-is (NO multiplier) |
|
| 81 |
+
| `"1774096306.427425"` | `1774096306.427425` | parseFloat finite β return as-is |
|
| 82 |
+
| `"2026-04-13T12:34:56Z"` | `2026` | parseFloat parses leading "2026" β finite β returns `2026` (TS quirk: ISO datetimes look like the year-as-number, NOT compared as ms-of-epoch) |
|
| 83 |
+
| `"2025-01-01"` | `2025` | parseFloat β 2025 (TS quirk again) |
|
| 84 |
+
| `"not a date"` | `NEGATIVE_INFINITY` | parseFloat NaN β Date NaN β fallback |
|
| 85 |
+
| `""` | `NEGATIVE_INFINITY` | falsy β defensive fallback |
|
| 86 |
+
| `undefined` | `NEGATIVE_INFINITY` | falsy β defensive fallback |
|
| 87 |
+
| `"20240620"` | `20240620` | parseFloat finite β return as-is |
|
| 88 |
+
|
| 89 |
+
### Group C β Variant C (`toComparableTimestamp` in components/benchmark-detail.tsx)
|
| 90 |
+
|
| 91 |
+
Same as Variant B except `""` and `undefined` paths:
|
| 92 |
+
|
| 93 |
+
| Input | Output | Path |
|
| 94 |
+
|---|---|---|
|
| 95 |
+
| `""` | `NEGATIVE_INFINITY` | parseFloat("") = NaN, Date("").getTime() = NaN β fallback |
|
| 96 |
+
| `undefined` | (TypeError at call site, since signature is `string` not `string \| undefined`) | undefined isn't allowed; parseFloat(undefined) = NaN, but TS would flag the call |
|
| 97 |
+
|
| 98 |
+
In practice the `string` signature means callers always pass strings, so the `if (!timestamp)` check is unnecessary.
|
| 99 |
+
|
| 100 |
+
### Group D β Cross-variant divergence (TS quirk)
|
| 101 |
+
|
| 102 |
+
For the same input, the three variants produce DIFFERENT numbers. Comparing values from different variants is unsafe β but in production each variant is used in a self-contained scope, so this divergence doesn't usually fire.
|
| 103 |
+
|
| 104 |
+
| Input | Variant A | Variant B | Variant C |
|
| 105 |
+
|---|---|---|---|
|
| 106 |
+
| `"1774096306.427425"` | `1774096306427.4248` (ms) | `1774096306.427425` (seconds, no multiplier) | `1774096306.427425` |
|
| 107 |
+
| `"2026-04-13T12:34:56Z"` | `1776083696000` (ms-of-epoch from Date) | `2026` (parseFloat extracts the year!) | `2026` |
|
| 108 |
+
| Comparing the two above (a vs b) | a < b (correct: 2026 is more recent) | a > b (**incorrect**: parseFloat treats ISO as the number 2026) | a > b (**incorrect**) |
|
| 109 |
+
|
| 110 |
+
**This is a real bug in Variants B and C** for cross-format comparisons. It doesn't manifest in production because 99.99% of timestamps in `.cache/hf-data/models/*.json` are unix-seconds-strings. Do NOT fix in this migration; document and let pipeline canonicalize the format upstream so the bug becomes structurally impossible.
|
| 111 |
+
|
| 112 |
+
## Current TS implementation
|
| 113 |
+
|
| 114 |
+
| Concern | Location | Callers |
|
| 115 |
+
|---|---|---|
|
| 116 |
+
| Variant A β `normalizeEvalTimestamp` | `lib/model-data.ts:76-81` | 4 sites: `lib/model-data.ts:266, 650, 945-946, 1124` (all sort/compare timestamps when picking latest or sorting model_results) |
|
| 117 |
+
| Variant B β `toComparableTimestamp` | `lib/hf-data.ts:1049-1061` | 2 sites: `lib/hf-data.ts:1311-1312` (compare in flattenHierarchyNode variant-bucket reduction) |
|
| 118 |
+
| Variant C β `toComparableTimestamp` | `components/benchmark-detail.tsx:1418-1426` | 2 sites: `components/benchmark-detail.tsx:1600-1601` (variant deduplication) |
|
| 119 |
+
|
| 120 |
+
Total: 3 functions + 8 caller sites across 3 files.
|
| 121 |
+
|
| 122 |
+
## Pipeline status β divergences
|
| 123 |
+
|
| 124 |
+
### Side-by-side comparison table
|
| 125 |
+
|
| 126 |
+
| Aspect | TS (this spec) | Pipeline today | Result for users |
|
| 127 |
+
|---|---|---|---|
|
| 128 |
+
| Where canonicalization runs | request time, in 3 functions | not implemented; raw `retrieved_timestamp` strings emitted | TS parses on every comparison |
|
| 129 |
+
| Output format | varies per variant (ms vs seconds) | `retrieved_timestamp` is unix-seconds-string in 99.99% of rows; ISO datetime in 0.006% | mixed; TS handles each variant differently but production format consistency means it usually works |
|
| 130 |
+
|
| 131 |
+
### Concrete worked example with quantified scope
|
| 132 |
+
|
| 133 |
+
Audited 2026-04-28 against `.cache/hf-data/models/*.json`:
|
| 134 |
+
|
| 135 |
+
- Total `retrieved_timestamp` values: **86,183**
|
| 136 |
+
- Unix-seconds-string format (`"1774096306.427425"`): **86,178** (99.994%)
|
| 137 |
+
- ISO datetime format (`"2024-10-27T00:00:00Z"`): **5** (0.006%)
|
| 138 |
+
- Empty / null: **0**
|
| 139 |
+
- Other: **0**
|
| 140 |
+
|
| 141 |
+
Verified by `scripts/verify-timestamp.mjs`.
|
| 142 |
+
|
| 143 |
+
## Notes for pipeline implementer
|
| 144 |
+
|
| 145 |
+
- **Recommended canonical format: ISO 8601** (`"2026-04-13T12:34:56Z"`). Lexicographic sort works as chronological sort; `Date(...)` parsing is unambiguous; matches what AGENTS.md uses elsewhere.
|
| 146 |
+
- Once pipeline emits all timestamps as ISO 8601:
|
| 147 |
+
- Variant A's `* 1000` multiplier path becomes dead (no numeric input β all paths use `Date.getTime()`)
|
| 148 |
+
- Variants B and C's `parseFloat` quirk becomes irrelevant (ISO inputs β parseFloat NaN β fall to `Date.getTime()`)
|
| 149 |
+
- All three variants then become equivalent and can be replaced with a single `Date(ts).getTime()` inline (or a shared one-line helper).
|
| 150 |
+
- Don't try to migrate to a different format mid-flight (e.g. ms-of-epoch as bigint); ISO matches what the rest of the system expects.
|
| 151 |
+
- The 5 existing ISO-format rows in production are evidence this format already works for the cache; the rest just need to be converted upstream.
|
| 152 |
+
|
| 153 |
+
Verification: once pipeline ships ISO timestamps for all 86,183 rows, run `scripts/verify-timestamp.mjs` and confirm the unixSecondsString count drops to 0.
|
| 154 |
+
|
| 155 |
+
## Migration checklist
|
| 156 |
+
|
| 157 |
+
- [x] Spec written
|
| 158 |
+
- [x] Tests cover each variant's semantics + the cross-variant divergence (`tests/transformations/timestamp-normalization.test.ts`)
|
| 159 |
+
- [ ] Filed with pipeline owner with the spec + tests + audit script as acceptance criterion
|
| 160 |
+
- [ ] Pipeline emits all `retrieved_timestamp` values as ISO 8601 across all 86,183 rows
|
| 161 |
+
- [ ] TS deleted; replace 3 functions + 8 callers with a single shared `Date(ts).getTime()` (or inline). Files: `lib/model-data.ts`, `lib/hf-data.ts`, `components/benchmark-detail.tsx`.
|
| 162 |
+
|
| 163 |
+
## Future product decision (deferred)
|
| 164 |
+
|
| 165 |
+
The `parseFloat` bug in Variants B and C produces incorrect ordering for cross-format comparisons. We're choosing to fix-by-canonicalization-upstream rather than fix-in-place. Whether the bug should be patched in TS as a defensive measure (in case a non-ISO timestamp slips through after migration) is a separate decision.
|
notes/transformations/08-benchmark-display-names.md
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Benchmark display names
|
| 2 |
+
|
| 3 |
+
Drafted 2026-04-28. Migration item #8 in `notes/migration-plan.md`.
|
| 4 |
+
|
| 5 |
+
## Framing reminder
|
| 6 |
+
|
| 7 |
+
We are refactoring for UI efficiency. TS-as-is is the canonical spec. The transformation has known imperfections (one of the two implementations is a substring-match contains-test that will mangle e.g. "MMLU-Pro" by replacing the entire string with the long-form for "MMLU"; the active map is hand-curated and only covers ~34 known suite/parent keys) but those are deferred product decisions, not bugs to fix in this migration.
|
| 8 |
+
|
| 9 |
+
## Rule (as TS implements it today)
|
| 10 |
+
|
| 11 |
+
The repo has **two** functions named `getBenchmarkDisplayName` with different semantics; the active one in production is `lib/model-data.ts` (the `lib/eval-processing.ts` copy is an unused/duplicate β see "Duplicate implementation" below).
|
| 12 |
+
|
| 13 |
+
### Active implementation β `lib/model-data.ts:146-148`
|
| 14 |
+
|
| 15 |
+
`getBenchmarkDisplayName(benchmark: string)` applies one of two transformations:
|
| 16 |
+
|
| 17 |
+
1. **Map hit (normalized lookup):** normalize the input via `normalizeBenchmarkKeyForLookup` (lowercase; replace any run of `-`, `.`, or whitespace with a single `_`; strip leading/trailing `_`), then look up in `BENCHMARK_NAMES`. If present, return the mapped canonical form.
|
| 18 |
+
2. **Tokenize fallback (`humanizeToken`, `lib/model-data.ts:90-96`):** split the *original* (un-normalized) input on `[_-]+`, drop empty parts, capitalize the first character of each part, join with a single space. Note: only the first character is uppercased β `mmlu` becomes `Mmlu`, not `MMLU`.
|
| 19 |
+
|
| 20 |
+
The map (`lib/model-data.ts:109-140`) has 30 entries β all hand-maintained suite/family keys.
|
| 21 |
+
|
| 22 |
+
| Map key | Canonical |
|
| 23 |
+
|---|---|
|
| 24 |
+
| `hfopenllm_v2` | HF Open LLM v2 |
|
| 25 |
+
| `helm_lite` | HELM Lite |
|
| 26 |
+
| `helm_capabilities` | HELM Capabilities |
|
| 27 |
+
| `helm_classic` | HELM Classic |
|
| 28 |
+
| `helm_instruct` | HELM Instruct |
|
| 29 |
+
| `helm_mmlu` | HELM MMLU |
|
| 30 |
+
| `reward_bench` | RewardBench |
|
| 31 |
+
| `reward_bench_2` | RewardBench 2 |
|
| 32 |
+
| `bfcl` | BFCL |
|
| 33 |
+
| `global_mmlu_lite` | Global MMLU Lite |
|
| 34 |
+
| `swe_bench` | SWE-bench |
|
| 35 |
+
| `arc_agi` | ARC-AGI |
|
| 36 |
+
| `tau_bench_2` | TAU-Bench 2 |
|
| 37 |
+
| `ace` | ACE |
|
| 38 |
+
| `apex_agents` | APEX Agents |
|
| 39 |
+
| `apex_v1` | APEX v1 |
|
| 40 |
+
| `appworld` | AppWorld |
|
| 41 |
+
| `browsecompplus` | BrowseComp+ |
|
| 42 |
+
| `livecodebenchpro` | LiveCodeBench Pro |
|
| 43 |
+
| `sciarena` | SciArena |
|
| 44 |
+
| `terminal_bench_2_0` | Terminal Bench 2.0 |
|
| 45 |
+
| `la_leaderboard` | LA Leaderboard |
|
| 46 |
+
| `theory_of_mind` | Theory of Mind |
|
| 47 |
+
| `fibble_arena` | Fibble Arena |
|
| 48 |
+
| `fibble1_arena` | Fibble Arena v1 |
|
| 49 |
+
| `fibble2_arena` | Fibble Arena v2 |
|
| 50 |
+
| `fibble3_arena` | Fibble Arena v3 |
|
| 51 |
+
| `fibble4_arena` | Fibble Arena v4 |
|
| 52 |
+
| `fibble5_arena` | Fibble Arena v5 |
|
| 53 |
+
| `wordle_arena` | Wordle Arena |
|
| 54 |
+
|
| 55 |
+
The lookup is normalization-insensitive: `"HELM Lite"`, `"helm-lite"`, `"helm.lite"`, `" helm lite "` all normalize to `helm_lite` and hit the map.
|
| 56 |
+
|
| 57 |
+
### Suite-display-name companion β `components/benchmark-detail.tsx:308-336`
|
| 58 |
+
|
| 59 |
+
`benchmark-detail.tsx` carries its own `SUITE_DISPLAY_NAMES` table (an exact 30-entry copy of `BENCHMARK_NAMES`) plus two further override tables (`DISPLAY_TOKEN_OVERRIDES`, `DISPLAY_NAME_OVERRIDES`) and a different normalize/tokenize pipeline (`normalizeDisplayLabel`/`normalizeDisplayToken`). The suite-name path:
|
| 60 |
+
|
| 61 |
+
1. `normalizeSuiteKey(key)` collapses `[-.\s]+` β `_`, strips edge `_`, then applies two regex special-cases: `/^fibble\d*_arena$/` collapses to `fibble_arena`, `/^arc_agi_v\d+/` collapses to `arc_agi`.
|
| 62 |
+
2. `getSuiteDisplayName(key)` returns `SUITE_DISPLAY_NAMES[normalizedKey] ?? normalizeDisplayLabel(key)`.
|
| 63 |
+
3. The fallback (`normalizeDisplayLabel`) is more sophisticated than `humanizeToken`: it splits on `/`, then on whitespace, then per-token applies `DISPLAY_TOKEN_OVERRIDES` (a 26-entry table that knows acronyms like `mmlu β MMLU`, `helm β HELM`, `gpt β GPT`).
|
| 64 |
+
|
| 65 |
+
The benchmark-detail suite-name path is **not** the same function as `getBenchmarkDisplayName` β it is consumed only inside `benchmark-detail.tsx` for rendering suite headers. The model-detail path (`lib/model-data.ts β getBenchmarkDisplayName`) is the one consumed across the rest of the app (model cards, comparison index, eval rollups, DuckDB backend).
|
| 66 |
+
|
| 67 |
+
### Duplicate implementation β `lib/eval-processing.ts:861-885`
|
| 68 |
+
|
| 69 |
+
A second function with the same name `getBenchmarkDisplayName` lives in `lib/eval-processing.ts`. It uses a completely different rule:
|
| 70 |
+
|
| 71 |
+
```
|
| 72 |
+
const mapping = { 'MMLU': 'Massive Multitask Language Understanding', 'MMLU-Pro': 'MMLU Professional', ... }
|
| 73 |
+
for (const [key, value] of Object.entries(mapping)) {
|
| 74 |
+
if (name.toUpperCase().includes(key.toUpperCase())) return value
|
| 75 |
+
}
|
| 76 |
+
return name
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
It does case-insensitive substring matching against a 10-entry mapping of long-form descriptive names. **It disagrees with the `lib/model-data.ts` version on every map hit** (e.g. for input `"MMLU"`, model-data returns `"Mmlu"` via fallback, eval-processing returns `"Massive Multitask Language Understanding"`). Reachability:
|
| 80 |
+
|
| 81 |
+
- `lib/eval-processing.ts:903` β `getBenchmarkDisplayName(compositeBenchmarkKey)` inside `groupEvaluationsByBenchmark`. `groupEvaluationsByBenchmark` is exported but is **not imported anywhere else in the repo** (verified by `rg "groupEvaluationsByBenchmark"` β only its own declaration appears). Functionally dead.
|
| 82 |
+
|
| 83 |
+
The substring-include rule has a known soft-bug: input `"MMLU-Pro"` would return the mapping for `"MMLU"` (`"Massive Multitask Language Understanding"`) because the loop iterates in insertion order and `MMLU` comes first; the `MMLU-Pro` entry never wins. Documented as TS-as-is; do not "fix" this in the migration.
|
| 84 |
+
|
| 85 |
+
## Classification
|
| 86 |
+
|
| 87 |
+
- **Unconditional normalization.** The function always runs on whatever benchmark key/name is present β it does not check for a pre-existing canonical field. Map hits and tokenize fallback are both branches of the same unconditional transform. Pipeline-side fix: emit a canonical `display_name` field per benchmark; no consumer should re-derive it.
|
| 88 |
+
- **Cleaning β pipeline.** Pure value transform on a single string field. No aggregation or record merging. Migration target: pipeline emits canonical display name on every benchmark/eval entry; TS map + `getBenchmarkDisplayName` + the duplicate in `eval-processing.ts` + the parallel `SUITE_DISPLAY_NAMES` table in `benchmark-detail.tsx` all delete.
|
| 89 |
+
|
| 90 |
+
## Inputs and expected outputs
|
| 91 |
+
|
| 92 |
+
Each row corresponds to a parameterized test case in `tests/transformations/benchmark-display-names.test.ts`.
|
| 93 |
+
|
| 94 |
+
### Group A β Map hits (normalized-key lookup against `BENCHMARK_NAMES`)
|
| 95 |
+
|
| 96 |
+
| Input | Output | Rule |
|
| 97 |
+
|---|---|---|
|
| 98 |
+
| `helm_lite` | `HELM Lite` | exact normalized match |
|
| 99 |
+
| `HELM Lite` | `HELM Lite` | normalize: lower + spaceβ`_` β `helm_lite` |
|
| 100 |
+
| `helm-lite` | `HELM Lite` | normalize: dashβ`_` |
|
| 101 |
+
| `helm.lite` | `HELM Lite` | normalize: dotβ`_` |
|
| 102 |
+
| ` helm lite ` | `HELM Lite` | normalize: whitespace runs β `_`, trim edges |
|
| 103 |
+
| `arc_agi` | `ARC-AGI` | substantive transform: returns dashed form |
|
| 104 |
+
| `swe_bench` | `SWE-bench` | substantive (lowercase `bench`) |
|
| 105 |
+
| `reward_bench` | `RewardBench` | substantive (concatenated, no separator) |
|
| 106 |
+
| `reward_bench_2` | `RewardBench 2` | substantive |
|
| 107 |
+
| `terminal_bench_2_0` | `Terminal Bench 2.0` | substantive (literal `_0` becomes `.0` in output) |
|
| 108 |
+
| `mistralai` (n/a β not a benchmark) | β | not in benchmark map |
|
| 109 |
+
| `hfopenllm_v2` | `HF Open LLM v2` | substantive (3-token split) |
|
| 110 |
+
| `bfcl` | `BFCL` | uppercase |
|
| 111 |
+
| `ace` | `ACE` | uppercase |
|
| 112 |
+
| `apex_agents` | `APEX Agents` | partial uppercase |
|
| 113 |
+
| `apex_v1` | `APEX v1` | partial uppercase + lowercase v |
|
| 114 |
+
| `appworld` | `AppWorld` | mixed-case substantive |
|
| 115 |
+
| `browsecompplus` | `BrowseComp+` | adds `+` |
|
| 116 |
+
| `livecodebenchpro` | `LiveCodeBench Pro` | substantive |
|
| 117 |
+
| `sciarena` | `SciArena` | substantive |
|
| 118 |
+
| `la_leaderboard` | `LA Leaderboard` | partial uppercase |
|
| 119 |
+
| `theory_of_mind` | `Theory of Mind` | substantive (lowercase `of`) |
|
| 120 |
+
| `fibble_arena` | `Fibble Arena` | base entry |
|
| 121 |
+
| `fibble1_arena` | `Fibble Arena v1` | substantive |
|
| 122 |
+
| `fibble2_arena` | `Fibble Arena v2` | substantive |
|
| 123 |
+
| `fibble3_arena` | `Fibble Arena v3` | substantive |
|
| 124 |
+
| `fibble4_arena` | `Fibble Arena v4` | substantive |
|
| 125 |
+
| `fibble5_arena` | `Fibble Arena v5` | substantive |
|
| 126 |
+
| `wordle_arena` | `Wordle Arena` | substantive |
|
| 127 |
+
| `global_mmlu_lite` | `Global MMLU Lite` | partial uppercase |
|
| 128 |
+
| `helm_capabilities` | `HELM Capabilities` | partial uppercase |
|
| 129 |
+
| `helm_classic` | `HELM Classic` | partial uppercase |
|
| 130 |
+
| `helm_instruct` | `HELM Instruct` | partial uppercase |
|
| 131 |
+
| `helm_mmlu` | `HELM MMLU` | full uppercase |
|
| 132 |
+
| `tau_bench_2` | `TAU-Bench 2` | substantive (`TAU-Bench`, dashed) |
|
| 133 |
+
|
| 134 |
+
### Group B β Tokenize fallback (`humanizeToken`)
|
| 135 |
+
|
| 136 |
+
Inputs that miss the map go through `humanizeToken(originalInput)`: split on `[_-]+`, drop empty parts, uppercase the first char of each part, join with `" "`.
|
| 137 |
+
|
| 138 |
+
| Input | Output | Why |
|
| 139 |
+
|---|---|---|
|
| 140 |
+
| `bbh` | `Bbh` | single token, only first char uppercased β NOT `BBH` |
|
| 141 |
+
| `gpqa` | `Gpqa` | only first char uppercased β NOT `GPQA` |
|
| 142 |
+
| `mmlu` | `Mmlu` | NOT `MMLU` (this is what's served when only the model-data path runs) |
|
| 143 |
+
| `gsm8k` | `Gsm8k` | digits inside don't capitalize differently |
|
| 144 |
+
| `MATH` | `MATH` | already uppercase, untouched (only first-char of each token is *set* β but `M` is already upper) |
|
| 145 |
+
| `MMLU-PRO` | `MMLU PRO` | split on dash; each token already starts upper; rest of token preserved as-is |
|
| 146 |
+
| `MMLU` | `MMLU` | passthrough β already starts uppercase, fallback's `charAt(0).toUpperCase()` is a no-op on `M`, slice preserves `MLU` |
|
| 147 |
+
| `helm air bench` | `HELM Lite` (NO!) β wait | normalizes to `helm_air_bench`, not in map β tokenize fallback uses ORIGINAL `helm air bench` β split on `[_-]+` is single token `helm air bench` β `Helm air bench` |
|
| 148 |
+
| `Helm air bench` | `Helm air bench` | NOT in map (only `helm_air_bench` would be β and isn't); fallback splits on `[_-]+` only, so the spaces survive and only the first char gets uppercased |
|
| 149 |
+
| `humaneval` | `Humaneval` | not in map |
|
| 150 |
+
| `truthfulqa` | `Truthfulqa` | not in map |
|
| 151 |
+
| `BBQ` | `BBQ` | not in map; split on `[_-]+` is single `BBQ`; first char already upper, rest preserved |
|
| 152 |
+
| `swe-bench-verified` | `Swe Bench Verified` | not in map; split on `-` β 3 tokens, each first-cap |
|
| 153 |
+
| `swe_bench_verified_mini` | `Swe Bench Verified Mini` | split on `_` β 4 tokens |
|
| 154 |
+
| `multi_swe_bench` | `Multi Swe Bench` | split on `_` β 3 tokens |
|
| 155 |
+
| `helm_air_bench` | `Helm Air Bench` | not in map (only the *suite* keys above are); split on `_` β 3 tokens |
|
| 156 |
+
| `helm_safety` | `Helm Safety` | not in map |
|
| 157 |
+
| `swe_bench_verified` | `Swe Bench Verified` | not in map (only `swe_bench` is) |
|
| 158 |
+
| `cocoabench` | `Cocoabench` | single token |
|
| 159 |
+
| `llm_stats` | `Llm Stats` | not in map |
|
| 160 |
+
| `artificial_analysis_llms` | `Artificial Analysis Llms` | not in map |
|
| 161 |
+
|
| 162 |
+
The **systematic quirk**: `humanizeToken` only uppercases the first character of each token. It does NOT consult the same acronym table that the map encodes (so `mmlu β Mmlu`, `bbh β Bbh`, `gpqa β Gpqa`). This is why suites like `helm_lite` need an explicit map entry β without one, fallback would produce `Helm Lite` (already pretty close), but for `mmlu` the fallback produces the visibly-wrong `Mmlu`. The companion table in `benchmark-detail.tsx` (`DISPLAY_TOKEN_OVERRIDES`) DOES know the acronyms β but that table is consumed by a different code path.
|
| 163 |
+
|
| 164 |
+
### Group C β Edge cases
|
| 165 |
+
|
| 166 |
+
| Input | Output | Notes |
|
| 167 |
+
|---|---|---|
|
| 168 |
+
| `""` (empty) | `""` | normalize β `""`, no map hit; humanizeToken splits empty β empty array β `[].join(" ")` β `""` |
|
| 169 |
+
| `"_"` | `""` | normalize β `""` (edge `_` stripped), no map hit; humanizeToken splits `_` β `[""]` β filter empty β `[].join(" ")` β `""` |
|
| 170 |
+
| `"___helm___lite___"` | `HELM Lite` | normalize collapses runs of `_` and trims β `helm_lite` β map hit |
|
| 171 |
+
| `"HELM-LITE"` | `HELM Lite` | normalize β `helm_lite` β map hit |
|
| 172 |
+
| `"helm lite"` (2 spaces) | `HELM Lite` | normalize collapses whitespace runs β `helm_lite` |
|
| 173 |
+
| `"a"` | `A` | not in map; humanizeToken β `["a"]` β `["A"]` β `"A"` |
|
| 174 |
+
| `"a-b"` | `A B` | split β `["a","b"]` β `["A","B"]` β `"A B"` |
|
| 175 |
+
|
| 176 |
+
### Group D β Duplicate `getBenchmarkDisplayName` in `lib/eval-processing.ts` (functionally dead, document for completeness)
|
| 177 |
+
|
| 178 |
+
The substring-include rule (10-entry mapping). Documented inputs:
|
| 179 |
+
|
| 180 |
+
| Input | Output | Rule branch |
|
| 181 |
+
|---|---|---|
|
| 182 |
+
| `null` | `Unknown Benchmark` | guard at top of function |
|
| 183 |
+
| `undefined` | `Unknown Benchmark` | guard |
|
| 184 |
+
| `""` | `Unknown Benchmark` | guard (`!name`) |
|
| 185 |
+
| `MMLU` | `Massive Multitask Language Understanding` | substring match on `MMLU` |
|
| 186 |
+
| `mmlu` | `Massive Multitask Language Understanding` | case-insensitive substring |
|
| 187 |
+
| `MMLU-Pro` | `Massive Multitask Language Understanding` | iteration order: `MMLU` matches first; `MMLU-Pro` never reached |
|
| 188 |
+
| `GSM8K` | `Grade School Math 8K` | match |
|
| 189 |
+
| `HumanEval` | `Human Eval (Code)` | match |
|
| 190 |
+
| `MBPP` | `Mostly Basic Python Problems` | match |
|
| 191 |
+
| `HellaSwag` | `HellaSwag (Commonsense)` | match |
|
| 192 |
+
| `ARC` | `AI2 Reasoning Challenge` | match |
|
| 193 |
+
| `TruthfulQA` | `TruthfulQA` | match (key === value) |
|
| 194 |
+
| `BBH` | `Big-Bench Hard` | match |
|
| 195 |
+
| `MATH` | `MATH Dataset` | match |
|
| 196 |
+
| `helm_lite` | `helm_lite` | no match β passthrough |
|
| 197 |
+
| `MMLU Lite something` | `Massive Multitask Language Understanding` | substring match still fires anywhere in the name |
|
| 198 |
+
|
| 199 |
+
Note that this function is **not the active path** in production; it lives inside `groupEvaluationsByBenchmark` which is unreferenced. Tests cover it for completeness so a pipeline implementer porting both functions sees the divergence in behaviour explicitly.
|
| 200 |
+
|
| 201 |
+
## Current TS implementation
|
| 202 |
+
|
| 203 |
+
| Concern | Location |
|
| 204 |
+
|---|---|
|
| 205 |
+
| Active map (`BENCHMARK_NAMES`, 30 entries) | `lib/model-data.ts:109-140` |
|
| 206 |
+
| Active key-normalizer (`normalizeBenchmarkKeyForLookup`) | `lib/model-data.ts:142-144` |
|
| 207 |
+
| Active tokenize fallback (`humanizeToken`) | `lib/model-data.ts:90-96` |
|
| 208 |
+
| Active function (`getBenchmarkDisplayName`, exported) | `lib/model-data.ts:146-148` |
|
| 209 |
+
| Suite-name companion map (`SUITE_DISPLAY_NAMES`, 30 entries; copy of `BENCHMARK_NAMES`) | `components/benchmark-detail.tsx:101-132` |
|
| 210 |
+
| Suite-name token overrides (`DISPLAY_TOKEN_OVERRIDES`, 26 entries) | `components/benchmark-detail.tsx:134-162` |
|
| 211 |
+
| Suite-name overrides (`DISPLAY_NAME_OVERRIDES`) | `components/benchmark-detail.tsx:164-173` |
|
| 212 |
+
| Suite key normalizer (with fibble/arc-agi regex special-cases) | `components/benchmark-detail.tsx:308-313` |
|
| 213 |
+
| Suite display-name lookup | `components/benchmark-detail.tsx:333-336` |
|
| 214 |
+
| **Duplicate** function (functionally dead) | `lib/eval-processing.ts:861-885` |
|
| 215 |
+
|
| 216 |
+
### Call sites of the active `getBenchmarkDisplayName` (15 total)
|
| 217 |
+
|
| 218 |
+
| Location | Context |
|
| 219 |
+
|---|---|
|
| 220 |
+
| `lib/model-data.ts:274` | `top_scores` rollup β set `benchmark` display name on score entry |
|
| 221 |
+
| `lib/model-data.ts:410` | `benchmark_names` array on developer summary |
|
| 222 |
+
| `lib/model-data.ts:459` | `benchmarkDisplayName` for hierarchy entries |
|
| 223 |
+
| `lib/model-data.ts:485` | `latest_source_name` on category aggregation |
|
| 224 |
+
| `lib/model-data.ts:778` | `composite_benchmark_name` on category-mode aggregation |
|
| 225 |
+
| `lib/model-data.ts:785` | `latest_source_name` (same record) |
|
| 226 |
+
| `lib/model-data.ts:821` | `composite_benchmark_name` on benchmark-mode aggregation |
|
| 227 |
+
| `lib/model-data.ts:828` | `latest_source_name` (same record) |
|
| 228 |
+
| `lib/model-data.ts:903` | `suiteDisplayName` for suite aggregation |
|
| 229 |
+
| `lib/model-data.ts:1056` | `suiteDisplayName` (second aggregator) |
|
| 230 |
+
| `lib/model-data.ts:1362` | model-card rollup A |
|
| 231 |
+
| `lib/model-data.ts:1396` | model-card rollup B |
|
| 232 |
+
| `lib/model-data.ts:1429` | model-card rollup C |
|
| 233 |
+
| `lib/model-data.ts:1470` | model-card rollup D |
|
| 234 |
+
| `lib/duckdb-data.ts:301` | DuckDB backend β set `benchmark` on per-model rollup |
|
| 235 |
+
|
| 236 |
+
`normalizeBenchmarkKeyForLookup` itself is also called separately at `lib/model-data.ts:1572` and `:1579` to derive suite-key matches (independent of display-name derivation).
|
| 237 |
+
|
| 238 |
+
### Call sites of `SUITE_DISPLAY_NAMES`/`normalizeDisplayLabel` (renderer-only)
|
| 239 |
+
|
| 240 |
+
`components/benchmark-detail.tsx` consumes `normalizeDisplayLabel` at ~30 sites for in-render labels (model name, organization, dataset name, source name, run label, subtask label, etc.). The suite-display-name path (`getSuiteDisplayName`) is only called inside this file. None of these are used outside the benchmark detail page; they are presentation-layer helpers that operate on already-emitted strings.
|
| 241 |
+
|
| 242 |
+
## Pipeline status β divergences
|
| 243 |
+
|
| 244 |
+
### Side-by-side comparison table
|
| 245 |
+
|
| 246 |
+
| Aspect | TS (this spec) | Pipeline today | Result for users |
|
| 247 |
+
|---|---|---|---|
|
| 248 |
+
| Where display name is derived | request time, in 15+ call sites | pipeline emits `benchmark_parent_name`, `benchmark_family_name`, `display_name`, `canonical_display_name` on each eval entry; raw `benchmark` is also present | TS re-derives display name from the key field, ignoring the pipeline's already-canonical `*_name` fields |
|
| 249 |
+
| Key field consumed | `benchmark_parent_key` / `benchmark_family_key` / `benchmark` (mostly key-shaped strings like `helm_lite`) | n/a β pipeline emits both keys and names | TS map hit yields canonical name; non-mapped keys fall through to mechanical title-case |
|
| 250 |
+
| Acronym handling | `BENCHMARK_NAMES` map: 30 hand-curated entries; everything else gets `humanizeToken` (only first char per token uppercased) | `display_name` / `canonical_display_name` already encode the canonical capitalization (e.g. `BBH`, `GPQA`, `MMLU`) | TS produces user-visible `Mmlu` / `Bbh` / `Gpqa` for unmapped acronym keys; pipeline's `display_name` would have correct casing |
|
| 251 |
+
| Suite/family rollup labels | `getSuiteDisplayName` in `benchmark-detail.tsx` does its own thing (DISPLAY_TOKEN_OVERRIDES knows acronyms); active `getBenchmarkDisplayName` in `model-data.ts` does NOT consult those overrides | n/a | The two TS paths can produce *different* display names for the same key β e.g. for `mmlu`, `getBenchmarkDisplayName` returns `Mmlu` but the suite path returns `MMLU` |
|
| 252 |
+
|
| 253 |
+
### Concrete worked examples (audit numbers)
|
| 254 |
+
|
| 255 |
+
Audited 2026-04-28 against `.cache/hf-data/eval-list.json` (587 evals) and `.cache/hf-data/model-cards-lite.json` (5,830 cards) by `scripts/verify-benchmark-display-names.mjs`.
|
| 256 |
+
|
| 257 |
+
**Distinct benchmark-key strings in production (eval-list.json):**
|
| 258 |
+
|
| 259 |
+
| Field | Distinct values | mapHit (distinct) | fallback (distinct) | mapHit calls / 587 | fallback calls / 587 |
|
| 260 |
+
|---|---|---|---|---|---|
|
| 261 |
+
| `benchmark` | 544 | 15 (2.8%) | 529 (97.2%) | 20 | 567 |
|
| 262 |
+
| `benchmark_parent_key` | 34 | 25 (73.5%) | 9 (26.5%) | 71 | 516 |
|
| 263 |
+
| `benchmark_family_key` | 34 | 24 (70.6%) | 10 (29.4%) | 65 | 522 |
|
| 264 |
+
| `benchmark_parent_name` | 544 | 15 (2.8%) | 529 (97.2%) | 20 | 567 |
|
| 265 |
+
|
| 266 |
+
**Distinct benchmark fields on model-cards-lite.json (5,830 cards):**
|
| 267 |
+
|
| 268 |
+
| Field | Distinct | mapHit | fallback |
|
| 269 |
+
|---|---|---|---|
|
| 270 |
+
| `card.benchmark_names[]` | 377 | 14 (3.7%) | 363 (96.3%) |
|
| 271 |
+
| `card.top_benchmark_scores[].benchmarkKey` | 339 | 17 (5.0%) | 322 (95.0%) |
|
| 272 |
+
| `card.top_benchmark_scores[].benchmark` | 301 | 13 (4.3%) | 288 (95.7%) |
|
| 273 |
+
|
| 274 |
+
**Notable fallback outputs (visibly-wrong style produced by `humanizeToken`):**
|
| 275 |
+
|
| 276 |
+
| Input | TS-computed |
|
| 277 |
+
|---|---|
|
| 278 |
+
| `BBH` | `BBH` (no-op β already first-cap) |
|
| 279 |
+
| `MMLU-PRO` | `MMLU PRO` (loses the dash) |
|
| 280 |
+
| `artificial_analysis_llms` | `Artificial Analysis Llms` (`LLMs` β `Llms`) |
|
| 281 |
+
| `helm_air_bench` | `Helm Air Bench` (`HELM` β `Helm`) |
|
| 282 |
+
| `helm_safety` | `Helm Safety` |
|
| 283 |
+
| `swe_bench_verified` | `Swe Bench Verified` (`SWE` β `Swe`) |
|
| 284 |
+
| `swe_bench_verified_mini` | `Swe Bench Verified Mini` |
|
| 285 |
+
| `multi_swe_bench` | `Multi Swe Bench` |
|
| 286 |
+
| `llm_stats` | `Llm Stats` |
|
| 287 |
+
| `hfopenllm` (family key) | `Hfopenllm` |
|
| 288 |
+
| `ARC-AGI v2` (from `benchmark_names[]`) | `ARC AGI v2` (loses the dash) |
|
| 289 |
+
| `BrowseComp-Plus` | `BrowseComp Plus` (loses the dash) |
|
| 290 |
+
|
| 291 |
+
**TS vs pipeline-emitted display fields (587 evals):**
|
| 292 |
+
|
| 293 |
+
| Comparison | Agree | Disagree | Notes |
|
| 294 |
+
|---|---|---|---|
|
| 295 |
+
| TS(`benchmark_parent_key`) == pipeline `benchmark_parent_name` | 9 | 578 | Pipeline's `*_name` is the per-eval display name (e.g. `BBH`), not the suite roll-up name. They aren't meant to match β TS path produces the suite name (`HF Open LLM v2`), pipeline produces the leaf eval name (`BBH`). |
|
| 296 |
+
| TS(`benchmark_family_key`) == pipeline `benchmark_family_name` | 8 | 579 | Same dynamic. |
|
| 297 |
+
| TS(`benchmark`) == pipeline `display_name` | 86 | 501 | Disagreements include `MMLU-PRO` β `MMLU PRO` and the "key vs leaf-eval display" mismatch as above (e.g. `Artificial Analysis LLM API` vs `artificial_analysis.median_output_tokens_per_second`). |
|
| 298 |
+
| TS(`benchmark`) == pipeline `canonical_display_name` | 86 | 501 | Same as `display_name`. |
|
| 299 |
+
|
| 300 |
+
**Divergence summary:**
|
| 301 |
+
- Only ~3% of distinct `benchmark` strings hit the map; ~74% of distinct suite keys (`benchmark_parent_key`) do.
|
| 302 |
+
- For the ~97% of `benchmark` strings that miss, the active TS function passes them through `humanizeToken` which mangles acronyms (`MMLU-PRO` β `MMLU PRO`). For inputs that are already nicely cased (most pipeline-emitted `benchmark` strings are), the fallback can be a strict regression vs the input.
|
| 303 |
+
- The pipeline's `display_name` and `canonical_display_name` already provide leaf-eval display names; the TS function is doing suite-key β suite-display-name work that pipeline does NOT yet emit (no `parent_display_name` field). The `benchmark_parent_name` field exists but holds the *leaf eval name* picked from one child, not the suite display name.
|
| 304 |
+
- The duplicate `getBenchmarkDisplayName` in `eval-processing.ts` is unreachable β `groupEvaluationsByBenchmark` has zero importers (verified by ripgrep).
|
| 305 |
+
|
| 306 |
+
Run `scripts/verify-benchmark-display-names.mjs` for the live numbers.
|
| 307 |
+
|
| 308 |
+
## Notes for pipeline implementer
|
| 309 |
+
|
| 310 |
+
- **Prefer to expose pre-computed `display_name` / `canonical_display_name` on every benchmark/eval entry rather than asking consumers to map keys β names.** Pipeline already does this for ~all entries (verify field coverage with the audit script). The TS layer is essentially defending against the historical case where consumers received only a snake_case key.
|
| 311 |
+
- If the pipeline keeps emitting both keys and names, the migration target is: TS callers read `entry.display_name` (or `benchmark_parent_name`, etc.) directly; the `BENCHMARK_NAMES` map + `getBenchmarkDisplayName` + `humanizeToken` (active) + `SUITE_DISPLAY_NAMES`/`normalizeSuiteKey`/`getSuiteDisplayName` (companion) all delete.
|
| 312 |
+
- The 30 entries in `BENCHMARK_NAMES` (and the parallel 30 in `SUITE_DISPLAY_NAMES`) encode product decisions about how to render the suite-level rollups. If the pipeline does not yet emit a *suite-level* display name (separate from per-eval `display_name`), it should β exactly the 30 entries above are the acceptance set.
|
| 313 |
+
- The two regex special-cases in `benchmark-detail.tsx` (`/^fibble\d*_arena$/ β fibble_arena`, `/^arc_agi_v\d+/ β arc_agi`) are normalization-layer rules β pipeline should fold versioned variants of these suites into the same canonical key OR the consumer must continue to apply the collapse. Document the chosen approach.
|
| 314 |
+
- The duplicate `getBenchmarkDisplayName` in `lib/eval-processing.ts` should be deleted along with `groupEvaluationsByBenchmark`. It has no live callers.
|
| 315 |
+
- The `benchmark-detail.tsx` file's many `normalizeDisplayLabel` call sites (model name, org name, dataset name, run label, etc.) are a separate concern β they are presentation-only normalization on already-emitted strings, not a keyβname lookup. Whether to migrate them upstream is a separate item.
|
| 316 |
+
|
| 317 |
+
Verification: run `scripts/verify-benchmark-display-names.mjs` against pipeline output once it ships. Goal: zero divergence vs TS-as-is across every distinct `benchmark` / `benchmark_parent_key` / `benchmark_family_key` value in the 587-eval cache.
|
| 318 |
+
|
| 319 |
+
## Migration checklist
|
| 320 |
+
|
| 321 |
+
- [x] Spec written
|
| 322 |
+
- [x] Tests cover each rule branch (`tests/transformations/benchmark-display-names.test.ts`)
|
| 323 |
+
- [ ] Filed with pipeline owner with the spec + tests + audit script as acceptance criterion
|
| 324 |
+
- [ ] Pipeline emits `display_name` / `canonical_display_name` (already does on per-eval) PLUS suite-level display name covering the 30 `BENCHMARK_NAMES` entries on every benchmark/eval entry
|
| 325 |
+
- [ ] TS deleted; replace 15 active call sites + 30+ `normalizeDisplayLabel` sites in `benchmark-detail.tsx` with direct field reads. Delete `BENCHMARK_NAMES`, `getBenchmarkDisplayName` (model-data.ts), `humanizeToken`, `normalizeBenchmarkKeyForLookup`, `SUITE_DISPLAY_NAMES`, `DISPLAY_TOKEN_OVERRIDES`, `DISPLAY_NAME_OVERRIDES`, `normalizeSuiteKey`, `getSuiteDisplayName`, `normalizeDisplayLabel`, `normalizeDisplayToken`, plus the duplicate `getBenchmarkDisplayName` + `groupEvaluationsByBenchmark` in `eval-processing.ts`.
|
| 326 |
+
|
| 327 |
+
## Future product decision (deferred)
|
| 328 |
+
|
| 329 |
+
`BENCHMARK_NAMES` is hand-curated and only covers 30 suite/parent keys; many leaf benchmarks fall through to a fallback that mangles acronyms (`mmlu β Mmlu`, `bbh β Bbh`). Whether to (a) expand the map to cover the long tail, (b) ship pipeline-emitted `display_name` everywhere and delete the map entirely, or (c) take a different approach (HTML-style override file, attribute on the source eval, etc.) is out of scope for this refactor. Document-don't-improve.
|
notes/transformations/09-metric-display-name-expansion.md
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Metric display name expansion
|
| 2 |
+
|
| 3 |
+
Drafted 2026-04-28. Migration item #10 in `notes/migration-plan.md`.
|
| 4 |
+
|
| 5 |
+
## Framing reminder
|
| 6 |
+
|
| 7 |
+
We are refactoring for UI efficiency. TS-as-is is the canonical spec. Originally both functions in this spec were claimed to be "defensive scaffolding firing 0 times against current data." That claim was **partially wrong** and is corrected below (verified 2026-04-28).
|
| 8 |
+
|
| 9 |
+
This spec covers two related functions with the same product intent (expand a non-informative metric name by prefixing the benchmark), but they have very different statuses in the current codebase:
|
| 10 |
+
|
| 11 |
+
1. **`getEvaluationDisplayName`** (`lib/eval-processing.ts:70-86`) β **DEAD CODE via orphaned caller chain.** Its callers (`createEvaluationCard` line 537, `groupEvaluationsByBenchmark` line 893) are exported from `lib/eval-processing.ts` but never invoked from `app/`, `components/`, or any other `lib/` file. The function never runs in production. (The earlier audit's "0 fires" was correct in result but incorrect in reasoning β it audited `evaluations_by_category` from cache files, which is pipeline-pre-flattened. The function actually consumes the *post-`flattenModelEvaluations`* shape, where bare-generic names ARE present.)
|
| 12 |
+
2. **`prefersBenchmarkName`** (`lib/model-data.ts:462-470`) β **active code path that fires 0 times against current data.** It runs inside `hfEvalEntryToListItem`, which is called from `lib/model-data.ts:1261, 1301` and `lib/duckdb-data.ts:171` β all live read paths. None of the 4 heuristic patterns match any of the 587 eval-list entries in production.
|
| 13 |
+
|
| 14 |
+
This split matters because the migration recommendations are different for each. See "Recommended migration path" below.
|
| 15 |
+
|
| 16 |
+
## Rule (as TS implements it today)
|
| 17 |
+
|
| 18 |
+
### Rule 1 β `getEvaluationDisplayName(evaluation, result)`
|
| 19 |
+
|
| 20 |
+
Computes the display string for one `evaluation_result` row.
|
| 21 |
+
|
| 22 |
+
```
|
| 23 |
+
benchmarkName = getBenchmarkName(evaluation, result)
|
| 24 |
+
metricName = result.evaluation_name.trim()
|
| 25 |
+
|
| 26 |
+
if metricName === benchmarkName: return metricName // already redundant; show once
|
| 27 |
+
if GENERIC_EVALUATION_NAMES.has(metricName.toLowerCase()):
|
| 28 |
+
return `${benchmarkName} - ${metricName}`
|
| 29 |
+
otherwise: return metricName
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
`GENERIC_EVALUATION_NAMES` is a 6-entry lowercase-keyed set (`lib/eval-processing.ts:27-34`):
|
| 33 |
+
|
| 34 |
+
| Key |
|
| 35 |
+
|---|
|
| 36 |
+
| `score` |
|
| 37 |
+
| `accuracy` |
|
| 38 |
+
| `mean win rate` |
|
| 39 |
+
| `exact match` |
|
| 40 |
+
| `f1` |
|
| 41 |
+
| `pass@1` |
|
| 42 |
+
|
| 43 |
+
`getBenchmarkName` (`lib/eval-processing.ts:49-68`) resolves the benchmark string with this precedence:
|
| 44 |
+
|
| 45 |
+
1. `result.source_data.dataset_name` (when source_data is an object, not an array)
|
| 46 |
+
2. `evaluation.benchmark`
|
| 47 |
+
3. `evaluation.source_data.dataset_name` (when source_data is an object)
|
| 48 |
+
4. `result.evaluation_name`
|
| 49 |
+
5. `evaluation.evaluation_id`
|
| 50 |
+
|
| 51 |
+
### Rule 2 β `prefersBenchmarkName` (inline, `lib/model-data.ts:459-470`)
|
| 52 |
+
|
| 53 |
+
Decides whether to substitute the benchmark display name for an eval-list entry's display string.
|
| 54 |
+
|
| 55 |
+
```
|
| 56 |
+
benchmarkDisplayName = getBenchmarkDisplayName(entry.benchmark_parent_name || entry.benchmark || "")
|
| 57 |
+
rawDisplayName = entry.evaluation_name || entry.display_name || entry.benchmark_leaf_name || entry.eval_summary_id
|
| 58 |
+
normalized = rawDisplayName.trim().toLowerCase()
|
| 59 |
+
|
| 60 |
+
prefersBenchmarkName = Boolean(benchmarkDisplayName) && (
|
| 61 |
+
normalized.startsWith("accuracy on ") ||
|
| 62 |
+
normalized.startsWith("score on ") ||
|
| 63 |
+
normalized.includes("for scorer") ||
|
| 64 |
+
normalized.includes("model_graded")
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
evaluation_name on output = prefersBenchmarkName ? benchmarkDisplayName : rawDisplayName
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
Note the asymmetry: the first two checks are `startsWith`, the second two are `includes`. This is faithful to the TS code (not "fixed" here).
|
| 71 |
+
|
| 72 |
+
## Classification
|
| 73 |
+
|
| 74 |
+
- **Unconditional normalization.** Both rules always run on whatever metric/eval string is present; neither defers to a pre-existing canonical field. Pipeline-side fix: emit the final `display_name` already in expanded form (or leave it as-is for the cases where neither rule fires β i.e. all 86,183 production rows today). No consumer should re-derive.
|
| 75 |
+
- **Cleaning β pipeline.** Pure value transform on a single field per record. No aggregation, no joining, no record merging. Migration target: pipeline emits `display_name` already in the form TS would produce; TS deletes both helpers and inlines a direct field read.
|
| 76 |
+
|
| 77 |
+
## Inputs and expected outputs
|
| 78 |
+
|
| 79 |
+
Each row corresponds to a parameterized test case in `tests/transformations/metric-display-name-expansion.test.ts`.
|
| 80 |
+
|
| 81 |
+
### Group A β `getEvaluationDisplayName`: generic name expansion
|
| 82 |
+
|
| 83 |
+
Input: `(evaluation, result)` synthesized so `getBenchmarkName` resolves to the value in the "benchmark" column.
|
| 84 |
+
|
| 85 |
+
| benchmark | result.evaluation_name | Output | Why |
|
| 86 |
+
|---|---|---|---|
|
| 87 |
+
| `MMLU` | `Accuracy` | `MMLU - Accuracy` | metric `accuracy` is generic β prefix benchmark |
|
| 88 |
+
| `GSM8K` | `accuracy` | `GSM8K - accuracy` | lowercased generic still triggers; output keeps original casing of metric |
|
| 89 |
+
| `MATH` | `EXACT MATCH` | `MATH - EXACT MATCH` | uppercase generic still triggers (set check is `.toLowerCase()`) |
|
| 90 |
+
| `RewardBench` | `Mean Win Rate` | `RewardBench - Mean Win Rate` | "mean win rate" is in the set |
|
| 91 |
+
| `HumanEval` | `pass@1` | `HumanEval - pass@1` | symbol-bearing generic still in the set |
|
| 92 |
+
| `SuperGLUE` | `f1` | `SuperGLUE - f1` | shortest generic |
|
| 93 |
+
| `OpenBookQA` | `Score` | `OpenBookQA - Score` | "score" is generic |
|
| 94 |
+
|
| 95 |
+
### Group B β `getEvaluationDisplayName`: passthrough (non-generic)
|
| 96 |
+
|
| 97 |
+
| benchmark | result.evaluation_name | Output | Why |
|
| 98 |
+
|---|---|---|---|
|
| 99 |
+
| `MMLU` | `MMLU` | `MMLU` | metricName === benchmarkName β return as-is (early return; expansion never considered) |
|
| 100 |
+
| `MMLU` | `BLEU` | `BLEU` | not in generic set β passthrough |
|
| 101 |
+
| `RewardBench` | `Chat Hard` | `Chat Hard` | not generic, distinct from benchmark β passthrough |
|
| 102 |
+
| `MMLU` | `accuracy_strict` | `accuracy_strict` | substring of "accuracy" but not equal β not in set β passthrough |
|
| 103 |
+
| `MMLU` | `Accuracy ` (trailing space) | `MMLU - Accuracy` | `.trim()` on metricName before set lookup β matches |
|
| 104 |
+
| `MMLU` | ` accuracy ` | `MMLU - accuracy` | trim happens to metricName |
|
| 105 |
+
|
| 106 |
+
### Group C β `getEvaluationDisplayName`: `getBenchmarkName` precedence
|
| 107 |
+
|
| 108 |
+
These exercise the resolution chain that feeds the rule.
|
| 109 |
+
|
| 110 |
+
| Setup | Resolved benchmark | Why |
|
| 111 |
+
|---|---|---|
|
| 112 |
+
| `result.source_data = { dataset_name: "RewardBench" }`, `evaluation.benchmark = "reward-bench"` | `RewardBench` | result.source_data.dataset_name wins (precedence #1) |
|
| 113 |
+
| `result.source_data = ["url1", "url2"]` (array), `evaluation.benchmark = "reward-bench"` | `reward-bench` | array source_data is skipped β falls to `evaluation.benchmark` |
|
| 114 |
+
| `result.source_data = undefined`, `evaluation.benchmark = "reward-bench"` | `reward-bench` | evaluation.benchmark (precedence #2) |
|
| 115 |
+
| `result.source_data = undefined`, `evaluation.benchmark = ""`, `evaluation.source_data = { dataset_name: "MMLU" }` | `MMLU` | evaluation.source_data.dataset_name (precedence #3) β note empty string is falsy |
|
| 116 |
+
| All sources empty, `result.evaluation_name = "Foo"` | `Foo` | precedence #4 |
|
| 117 |
+
| All empty, `evaluation.evaluation_id = "id-123"` | `id-123` | precedence #5 (final fallback) |
|
| 118 |
+
|
| 119 |
+
### Group D β `prefersBenchmarkName`: heuristic matches
|
| 120 |
+
|
| 121 |
+
For each, the eval-list entry has `benchmark_parent_name = "MMLU"` (so `benchmarkDisplayName` resolves to a non-empty string).
|
| 122 |
+
|
| 123 |
+
| `evaluation_name` (input) | Output `evaluation_name` | Why |
|
| 124 |
+
|---|---|---|
|
| 125 |
+
| `accuracy on subset_humanities` | `MMLU` | `startsWith("accuracy on ")` |
|
| 126 |
+
| `Accuracy On SubsetHumanities` | `MMLU` | normalized to lowercase before startsWith |
|
| 127 |
+
| `score on test_set` | `MMLU` | `startsWith("score on ")` |
|
| 128 |
+
| `xyz for scorer judge_v2` | `MMLU` | `includes("for scorer")` (anywhere in string) |
|
| 129 |
+
| `for scorer xyz at start` | `MMLU` | `includes("for scorer")` matches at start too |
|
| 130 |
+
| `something model_graded thing` | `MMLU` | `includes("model_graded")` (underscore, not space) |
|
| 131 |
+
| `model_graded` | `MMLU` | substring match works on the whole string |
|
| 132 |
+
|
| 133 |
+
### Group E β `prefersBenchmarkName`: passthrough
|
| 134 |
+
|
| 135 |
+
| `evaluation_name` | Output | Why |
|
| 136 |
+
|---|---|---|
|
| 137 |
+
| `MMLU - Accuracy` | `MMLU - Accuracy` | does not start with "accuracy on " (has prefix); no other token matches |
|
| 138 |
+
| `Accuracy` | `Accuracy` | bare "accuracy" doesn't satisfy `startsWith("accuracy on ")` (no " on ") |
|
| 139 |
+
| `accuracy onset` | `accuracy onset` | "accuracy on" with no trailing space; the rule literal is `"accuracy on "` (note trailing space) β but "accuracy onset".startsWith("accuracy on ") is **false** because position 11 is "s" not " ". Good β no match. |
|
| 140 |
+
| `score onyx` | `score onyx` | `startsWith("score on ")` requires literal trailing space β "onyx" fails |
|
| 141 |
+
| `Model Graded Eval` | `Model Graded Eval` | `model_graded` (underscore) does not match "Model Graded" (space) after lowercasing β "model graded eval" does not contain "model_graded" |
|
| 142 |
+
| `accuracy_for_scorer` | `accuracy_for_scorer` | `for scorer` (with space) does not match `for_scorer` after lowercasing β "accuracy_for_scorer" does not contain "for scorer" |
|
| 143 |
+
| `Scorer based eval` | `Scorer based eval` | "scorer based eval" does not contain "for scorer" |
|
| 144 |
+
| `BBH` | `BBH` | none of the four conditions match |
|
| 145 |
+
|
| 146 |
+
### Group F β `prefersBenchmarkName`: `benchmarkDisplayName` empty short-circuits
|
| 147 |
+
|
| 148 |
+
| Setup | Output | Why |
|
| 149 |
+
|---|---|---|
|
| 150 |
+
| `benchmark_parent_name = ""`, `benchmark = ""`, `evaluation_name = "accuracy on x"` | `accuracy on x` (raw) | `Boolean(benchmarkDisplayName)` is false β `prefersBenchmarkName = false` β falls back to raw |
|
| 151 |
+
|
| 152 |
+
### Group G β `prefersBenchmarkName`: `rawDisplayName` precedence
|
| 153 |
+
|
| 154 |
+
Order: `entry.evaluation_name` β `entry.display_name` β `entry.benchmark_leaf_name` β `entry.eval_summary_id`.
|
| 155 |
+
|
| 156 |
+
| Setup | rawDisplayName | Why |
|
| 157 |
+
|---|---|---|
|
| 158 |
+
| `evaluation_name = "score on x"` | `score on x` | first non-falsy field |
|
| 159 |
+
| `evaluation_name = ""`, `display_name = "MMLU"` | `MMLU` | empty string is falsy β falls through |
|
| 160 |
+
| All empty except `eval_summary_id = "id_xyz"` | `id_xyz` | final fallback |
|
| 161 |
+
|
| 162 |
+
## Current TS implementation
|
| 163 |
+
|
| 164 |
+
| Concern | Location |
|
| 165 |
+
|---|---|
|
| 166 |
+
| Generic-names set | `lib/eval-processing.ts:27-34` (`GENERIC_EVALUATION_NAMES`) |
|
| 167 |
+
| Benchmark-name resolver | `lib/eval-processing.ts:49-68` (`getBenchmarkName`) |
|
| 168 |
+
| Per-result expansion | `lib/eval-processing.ts:70-86` (`getEvaluationDisplayName`) |
|
| 169 |
+
| Eval-list-entry heuristic | `lib/model-data.ts:459-470` (`prefersBenchmarkName`, inline; assigns to `evaluation_name` field) |
|
| 170 |
+
| Benchmark display name (used by Rule 2) | `lib/model-data.ts:146-148` (`getBenchmarkDisplayName`) |
|
| 171 |
+
|
| 172 |
+
### Call sites
|
| 173 |
+
|
| 174 |
+
`getEvaluationDisplayName` (2 call sites, both internal to `lib/eval-processing.ts`):
|
| 175 |
+
|
| 176 |
+
| Location | Context |
|
| 177 |
+
|---|---|
|
| 178 |
+
| `lib/eval-processing.ts:631` | `processModelEvaluations` β populates `allScores[].benchmark` for per-model score aggregation |
|
| 179 |
+
| `lib/eval-processing.ts:900` | `groupEvaluationsByBenchmark` β populates `BenchmarkEvalSummary.evaluation_name` keyed by `eval_summary_id` |
|
| 180 |
+
|
| 181 |
+
`prefersBenchmarkName` (1 call site, declared inline):
|
| 182 |
+
|
| 183 |
+
| Location | Context |
|
| 184 |
+
|---|---|
|
| 185 |
+
| `lib/model-data.ts:462-470` | `hfEvalEntryToListItem` β sets `evaluation_name` on `BenchmarkEvalListItem` for the browse-evals list page |
|
| 186 |
+
|
| 187 |
+
`GENERIC_EVALUATION_NAMES`: only consumed by `getEvaluationDisplayName` itself.
|
| 188 |
+
|
| 189 |
+
## Pipeline status β divergences
|
| 190 |
+
|
| 191 |
+
### Side-by-side comparison table
|
| 192 |
+
|
| 193 |
+
| Aspect | TS (this spec) | Pipeline today | Result for users |
|
| 194 |
+
|---|---|---|---|
|
| 195 |
+
| Where expansion runs | request time, in 3 call sites total | not implemented as a transform; pipeline emits `display_name` / `evaluation_name` already in their final form | TS expansion logic exists but never fires against current pipeline output |
|
| 196 |
+
| Generic-name detection | runtime check against 6-entry set | n/a β no metric in production has a bare-generic `evaluation_name` | no observable difference today |
|
| 197 |
+
| Heuristic prefix detection | runtime regex/substring on 4 patterns | n/a β no eval-list entry has the patterns today | no observable difference today |
|
| 198 |
+
|
| 199 |
+
### Concrete worked example with quantified scope
|
| 200 |
+
|
| 201 |
+
Audited 2026-04-28 against `.cache/hf-data/`:
|
| 202 |
+
|
| 203 |
+
**`getEvaluationDisplayName` against the 5,830 model files (86,183 total `(evaluation, result)` pairs):**
|
| 204 |
+
- `metric === benchmark` early-return: **30,968 (35.9%)** β most rows hit this; `evaluation_name` is already identical to the resolved benchmark name, so the function just returns it
|
| 205 |
+
- Generic-name expansion fires: **0 (0.0%)** β zero rows have a metric whose lowercased name is in `GENERIC_EVALUATION_NAMES`
|
| 206 |
+
- Passthrough (non-generic, distinct from benchmark): **55,215 (64.1%)**
|
| 207 |
+
|
| 208 |
+
Distribution of generic names hit in production: **empty.** The 6-entry set is dead code against current data.
|
| 209 |
+
|
| 210 |
+
**`prefersBenchmarkName` against the 587 eval-list entries:**
|
| 211 |
+
- `accuracy on β¦` matches: **0**
|
| 212 |
+
- `score on β¦` matches: **0**
|
| 213 |
+
- `for scorer` matches: **0**
|
| 214 |
+
- `model_graded` matches: **0**
|
| 215 |
+
- Total entries where heuristic fires: **0 (0.0%)**
|
| 216 |
+
|
| 217 |
+
Both transformations are **defensive scaffolding** β preserved for shapes the pipeline used to or could produce, but the current corpus produces neither generic bare-metric names nor heuristic-matching display strings.
|
| 218 |
+
|
| 219 |
+
Verified by `scripts/verify-metric-display-name.mjs`.
|
| 220 |
+
|
| 221 |
+
## Verified state (2026-04-28)
|
| 222 |
+
|
| 223 |
+
**For `getEvaluationDisplayName`:**
|
| 224 |
+
- Caller chain trace: called from `createEvaluationCard` (`lib/eval-processing.ts:631`) and `groupEvaluationsByBenchmark` (`lib/eval-processing.ts:900`). Both functions are exported from `lib/eval-processing.ts` but **not called from any file in `app/`, `components/`, `scripts/`, or other `lib/`** β verified by `grep -r`. The full chain `processEvaluationsToCards β createEvaluationCard β getEvaluationDisplayName` and `processEvaluationsToBenchmarkSummaries β groupEvaluationsByBenchmark β getEvaluationDisplayName` runs only inside the module's exports; no consumer triggers it.
|
| 225 |
+
- Data check: pipeline DOES emit bare-generic `metric_name` in 38,140 of 82,781 metrics in `hierarchy_by_category` (~46%). `flattenModelEvaluations` in `lib/hf-data.ts:1275` propagates this to `evaluation_name` on flattened result rows. So *if* the function were called, the expansion path WOULD fire β on 39,831 of 86,183 result rows. But nothing calls it.
|
| 226 |
+
- The earlier audit script (`scripts/verify-metric-display-name.mjs`) walked `models/<id>.json`'s `evaluations_by_category` (pipeline-pre-flattened, specific names) β that path doesn't go through `flattenModelEvaluations`, so it correctly reported "0 fires" for that traversal, but it didn't capture that the function would fire on the `hierarchy_by_category` traversal that `flattenModelEvaluations` actually performs.
|
| 227 |
+
|
| 228 |
+
**For `prefersBenchmarkName`:**
|
| 229 |
+
- Caller chain trace: lives inline at `lib/model-data.ts:462-470` inside `hfEvalEntryToListItem`. That function IS actively called: `lib/model-data.ts:1261, 1301` and `lib/duckdb-data.ts:171`. Live read path on browse-evals pages.
|
| 230 |
+
- Data check: across all 587 eval-list entries in `.cache/hf-data/eval-list.json`, **none of the 4 heuristic patterns match** β verified directly with a one-liner script (count = 0). The active path runs on every request but never finds a match.
|
| 231 |
+
|
| 232 |
+
## Recommended migration path
|
| 233 |
+
|
| 234 |
+
Two separate decisions, one per function:
|
| 235 |
+
|
| 236 |
+
### `getEvaluationDisplayName` (and the orphaned subsystem) β delete locally, no pipeline involvement
|
| 237 |
+
|
| 238 |
+
The function and its caller chain are dead code. Safe to delete without any pipeline coordination:
|
| 239 |
+
|
| 240 |
+
- `getEvaluationDisplayName` (`lib/eval-processing.ts:70-86`)
|
| 241 |
+
- `GENERIC_EVALUATION_NAMES` (`lib/eval-processing.ts:27-34`)
|
| 242 |
+
- `getEvaluationSummaryId` (`lib/eval-processing.ts:88-94`) β calls `getBenchmarkName`, only used by orphaned chain
|
| 243 |
+
- `createEvaluationCard` (`lib/eval-processing.ts:537+`)
|
| 244 |
+
- `processEvaluationsToCards` (`lib/eval-processing.ts:815`)
|
| 245 |
+
- `processEvaluationsToBenchmarkSummaries` (`lib/eval-processing.ts:1000`)
|
| 246 |
+
- `groupEvaluationsByBenchmark` (`lib/eval-processing.ts:893`)
|
| 247 |
+
- `loadEvaluations` (`lib/eval-processing.ts:788`) β only called by the two `processEvaluations*` orphans
|
| 248 |
+
- `getCategoryStats` (`lib/eval-processing.ts:747`) β verify via grep before deleting; may also be orphaned
|
| 249 |
+
|
| 250 |
+
Plus the imports of `createEvaluationCard` and `groupEvaluationsByBenchmark` in `lib/model-data.ts:18, 20` (unused imports).
|
| 251 |
+
|
| 252 |
+
**No contract test about bare-generic metric names** β pipeline emits them today, the contract would fail. The function was never preventing user-visible bugs because nothing called it. The active UI rendering path uses `metric.display_name` (which IS pipeline-pre-expanded as `"ACE / Score"`, `"RewardBench / Accuracy"`, etc.) β that's the field consumers actually read.
|
| 253 |
+
|
| 254 |
+
**Verify-script update:** `scripts/verify-metric-display-name.mjs` is no longer meaningful for this function once it's deleted. Either delete the script or rewrite it to audit the `hierarchy_by_category` traversal (so the spec stays honest about what the function would do if revived).
|
| 255 |
+
|
| 256 |
+
`getBenchmarkName` (`lib/eval-processing.ts:49-68`) has separate consumers and stays.
|
| 257 |
+
|
| 258 |
+
### `prefersBenchmarkName` β delete locally + add contract test
|
| 259 |
+
|
| 260 |
+
This one IS in an active path, fires 0 times, and matches the "implicit safety net β explicit contract test" pattern cleanly:
|
| 261 |
+
|
| 262 |
+
- Delete the inline 9-line block at `lib/model-data.ts:462-470` (replace with direct read of the resolved display string).
|
| 263 |
+
- Add a Tier A contract test in `tests/pipeline-contract.test.ts`:
|
| 264 |
+
- Assertion: no eval-list entry's display string (`evaluation_name || display_name || benchmark_leaf_name || eval_summary_id`) starts with `"accuracy on "` or `"score on "` (case-insensitive).
|
| 265 |
+
- Assertion: no eval-list entry's display string contains `"for scorer"` or `"model_graded"`.
|
| 266 |
+
- If pipeline ever regresses, the contract test fails loudly with the specific eval_summary_id.
|
| 267 |
+
|
| 268 |
+
Pipeline owner is told: "this 4-pattern absence is currently true; if you ever start emitting display strings in those shapes, please coordinate so the contract test is updated alongside the data change."
|
| 269 |
+
|
| 270 |
+
## Migration checklist
|
| 271 |
+
|
| 272 |
+
- [x] Spec written (corrected 2026-04-28)
|
| 273 |
+
- [x] Tests cover each rule branch (`tests/transformations/metric-display-name-expansion.test.ts`) β note these test the function in isolation; they do not assert that the function is reached from any user-visible path.
|
| 274 |
+
- [ ] Verify-script disposition decided (delete `scripts/verify-metric-display-name.mjs` or rewrite to audit the `hierarchy_by_category` traversal that reflects what the function would do if called)
|
| 275 |
+
- [ ] Delete the orphaned subsystem from `lib/eval-processing.ts`: `getEvaluationDisplayName`, `GENERIC_EVALUATION_NAMES`, `getEvaluationSummaryId` (verify orphan status), `createEvaluationCard`, `processEvaluationsToCards`, `processEvaluationsToBenchmarkSummaries`, `groupEvaluationsByBenchmark`, `loadEvaluations`, `getCategoryStats` (verify orphan status). Plus unused imports of `createEvaluationCard` and `groupEvaluationsByBenchmark` in `lib/model-data.ts:18, 20`. Keep `getBenchmarkName` β separate consumers.
|
| 276 |
+
- [ ] Delete `prefersBenchmarkName` block at `lib/model-data.ts:462-470` and replace with direct read of resolved display string.
|
| 277 |
+
- [ ] Add Tier A contract test in `tests/pipeline-contract.test.ts`: no eval-list display string matches the 4 heuristic patterns (`startsWith("accuracy on ")`, `startsWith("score on ")`, `includes("for scorer")`, `includes("model_graded")`).
|
| 278 |
+
- [ ] Notify pipeline owner: 4-pattern absence is currently true; coordinate before changing eval-list display string emission.
|
| 279 |
+
|
| 280 |
+
## Future product decision (deferred)
|
| 281 |
+
|
| 282 |
+
The defensive scaffolding only ever mattered for upstream data shapes that the pipeline no longer produces. If product wants to expand the generic-name set (e.g., add `"recall"`, `"precision"`, `"bleu"`) or the heuristic patterns (e.g., add `"judged by"`, `"with prompt"`), that's a separate decision; this spec just locks in TS-as-is.
|
notes/transformations/10-params-parsing.md
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Params billions parsing
|
| 2 |
+
|
| 3 |
+
Drafted 2026-04-28. Migration item #12 in `notes/migration-plan.md`.
|
| 4 |
+
|
| 5 |
+
## Framing reminder
|
| 6 |
+
|
| 7 |
+
We are refactoring for UI efficiency. TS-as-is is the canonical spec. Five separate parameter-count parsers exist across the app, with subtly different unit grammars, fallback chains, and anchoring. They produce the same answer for the most common production inputs (clean `"7B"` / `"34.389"` style strings) but diverge sharply on edge cases. The migration target: emit a single canonical `params_billions` (numeric, billions) upstream so all five parsers can be deleted.
|
| 8 |
+
|
| 9 |
+
## Rule (as TS implements it today β five variants)
|
| 10 |
+
|
| 11 |
+
Five independent code paths convert a free-form parameter-count token into a billions-of-parameters number.
|
| 12 |
+
|
| 13 |
+
### Variant A β `lib/model-data.ts:312-354` (`parseParamsBillions`)
|
| 14 |
+
|
| 15 |
+
```ts
|
| 16 |
+
function parseParamsBillions(value: unknown): number | null {
|
| 17 |
+
if (typeof value === "number") {
|
| 18 |
+
return Number.isFinite(value) && value > 0 ? value : null
|
| 19 |
+
}
|
| 20 |
+
if (typeof value !== "string") return null
|
| 21 |
+
|
| 22 |
+
const normalized = value.trim().toLowerCase()
|
| 23 |
+
if (!normalized) return null
|
| 24 |
+
|
| 25 |
+
const compact = normalized.replace(/,/g, "")
|
| 26 |
+
const tokenMatch = compact.match(/(\d+(?:\.\d+)?)\s*(trillion|tn|t|billion|bn|b|million|mn|m|thousand|k)\b/)
|
| 27 |
+
if (tokenMatch) {
|
| 28 |
+
const amount = Number.parseFloat(tokenMatch[1])
|
| 29 |
+
if (!Number.isFinite(amount) || amount <= 0) return null
|
| 30 |
+
const unit = tokenMatch[2]
|
| 31 |
+
if (unit === "trillion" || unit === "tn" || unit === "t") return amount * 1000
|
| 32 |
+
if (unit === "billion" || unit === "bn" || unit === "b") return amount
|
| 33 |
+
if (unit === "million" || unit === "mn" || unit === "m") return amount / 1000
|
| 34 |
+
if (unit === "thousand" || unit === "k") return amount / 1_000_000
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
const numeric = Number.parseFloat(compact)
|
| 38 |
+
return Number.isFinite(numeric) && numeric > 0 ? numeric : null
|
| 39 |
+
}
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
- Polymorphic input (`unknown`); accepts `number` directly (positive only).
|
| 43 |
+
- For strings: lowercases, strips commas, then scans for `<number><unit>` where unit β {trillion, tn, t, billion, bn, b, million, mn, m, thousand, k}.
|
| 44 |
+
- Unit table converts to billions; `t` β Γ1000, `b` β as-is, `m` β Γ·1000, `k` β Γ·1_000_000.
|
| 45 |
+
- Falls back to `parseFloat` of the whole string (assumed to be billions). **Positive-only**: rejects 0 and negatives at both branches.
|
| 46 |
+
|
| 47 |
+
Used by: `lib/model-data.ts:409` (`parseParamsBillions(entry.params_billions)` in `hfModelCardToEvaluationCardData`). Sole caller. Input is `entry.params_billions` from `model-cards.json`, which in production is always `number | null` (see audit) β so only the `typeof value === "number"` branch ever fires.
|
| 48 |
+
|
| 49 |
+
### Variant B β `components/eval-detail.tsx:81-119` (`parseParamsBillionsFromText`)
|
| 50 |
+
|
| 51 |
+
```ts
|
| 52 |
+
function parseParamsBillionsFromText(value: string | null | undefined) {
|
| 53 |
+
if (!value) return null
|
| 54 |
+
const normalized = value.trim().toLowerCase()
|
| 55 |
+
if (!normalized) return null
|
| 56 |
+
|
| 57 |
+
const compact = normalized.replace(/,/g, "")
|
| 58 |
+
const tokenMatch = compact.match(/(\d+(?:\.\d+)?)\s*(trillion|tn|t|billion|bn|b|million|mn|m|thousand|k)\b/)
|
| 59 |
+
if (tokenMatch) {
|
| 60 |
+
const amount = Number.parseFloat(tokenMatch[1])
|
| 61 |
+
if (!Number.isFinite(amount)) return null // β NO `<= 0` check (differs from A)
|
| 62 |
+
/* same unit table as Variant A */
|
| 63 |
+
}
|
| 64 |
+
const numeric = Number.parseFloat(compact)
|
| 65 |
+
return Number.isFinite(numeric) ? numeric : null // β NO `> 0` check (differs from A)
|
| 66 |
+
}
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
- Same regex + unit table as A.
|
| 70 |
+
- **Differs from A** on two checks: A rejects `amount <= 0` and `numeric <= 0`; B accepts `0`, negatives, and any finite number. So `"0B"` β `0` here, `null` in Variant A. The `"-5"` parseFloat fallback returns `-5` in B but `null` in A. (For `"-5B"` both return `5`, since the regex's `\d+` matches the `5` substring and the leading minus is silently dropped.)
|
| 71 |
+
- Used by `getParamsBillionsFromModelInfo` (Variant D) for `additional_details.params_billions` (string in production, see audit) and `model_info.parameter_count` strings.
|
| 72 |
+
|
| 73 |
+
### Variant C β `components/eval-detail.tsx:121-155` (`parseParamsBillionsFromText`'s sibling, `parseParamsBillionsFromModelName`)
|
| 74 |
+
|
| 75 |
+
```ts
|
| 76 |
+
function parseParamsBillionsFromModelName(modelName: string | null | undefined) {
|
| 77 |
+
if (!modelName) return null
|
| 78 |
+
const sizeTokens = Array.from(modelName.matchAll(/\b(\d+(?:\.\d+)?)\s*([tmbk])\b/gi))
|
| 79 |
+
if (sizeTokens.length === 0) return null
|
| 80 |
+
|
| 81 |
+
const lastToken = sizeTokens[sizeTokens.length - 1]
|
| 82 |
+
const numericValue = Number.parseFloat(lastToken[1])
|
| 83 |
+
if (!Number.isFinite(numericValue)) return null
|
| 84 |
+
|
| 85 |
+
const unit = lastToken[2].toLowerCase()
|
| 86 |
+
if (unit === "t") return numericValue * 1000
|
| 87 |
+
if (unit === "b") return numericValue
|
| 88 |
+
if (unit === "m") return numericValue / 1000
|
| 89 |
+
if (unit === "k") return numericValue / 1_000_000
|
| 90 |
+
return null
|
| 91 |
+
}
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
- Word-boundary match (`\b...\b`) on **single-letter unit only** (`t|m|b|k`, case-insensitive).
|
| 95 |
+
- Picks the **last** matching token in the string (e.g. `"Llama-3-70B-Instruct-8K"` β matches `70B` and `8K` β returns last (`8K` = 0.000008B)). This is **a known TS quirk**: model names containing context-window suffixes (`8K`, `32K`, `128K`) cause the parser to return the context-window size instead of the parameter count.
|
| 96 |
+
- Used by `getParamsBillionsFromModelInfo` (Variant D) as final fallback when `additional_details.params_billions` and `parameter_count` are both absent/unparseable.
|
| 97 |
+
|
| 98 |
+
### Variant D β `components/eval-detail.tsx:157-184` (`getParamsBillionsFromModelInfo`)
|
| 99 |
+
|
| 100 |
+
Composite orchestrator (not a parser itself). Order:
|
| 101 |
+
|
| 102 |
+
1. `additional_details.params_billions` ?? `additional_details.parameter_count` ?? `additional_details.num_parameters` ?? `additional_details.params`
|
| 103 |
+
- if `number` β return as-is (no validity check; could be negative or non-finite)
|
| 104 |
+
- if `string` β `parseParamsBillionsFromText` (Variant B)
|
| 105 |
+
2. else if `modelInfo.parameter_count` is `string` β `parseParamsBillionsFromText` (Variant B)
|
| 106 |
+
3. else β `parseParamsBillionsFromModelName(modelInfo.name)` (Variant C)
|
| 107 |
+
|
| 108 |
+
Used at: `components/eval-detail.tsx:350, 1253, 1368` (paramsBillions cell in eval-detail tables, leaderboard sort filtering, "any model has params" header check).
|
| 109 |
+
|
| 110 |
+
### Variant E β `components/model-compare-dialog.tsx:44-60` (`parseParamsBillionsFromModelName`)
|
| 111 |
+
|
| 112 |
+
```ts
|
| 113 |
+
function parseParamsBillionsFromModelName(modelName: string | null | undefined) {
|
| 114 |
+
if (!modelName) return null
|
| 115 |
+
const sizeTokens = Array.from(modelName.matchAll(/\b(\d+(?:\.\d+)?)\s*([bm])\b/gi))
|
| 116 |
+
if (sizeTokens.length === 0) return null
|
| 117 |
+
|
| 118 |
+
const lastToken = sizeTokens[sizeTokens.length - 1]
|
| 119 |
+
const numericValue = Number(lastToken[1])
|
| 120 |
+
if (!Number.isFinite(numericValue)) return null
|
| 121 |
+
|
| 122 |
+
const unit = lastToken[2].toLowerCase()
|
| 123 |
+
if (unit === "b") return numericValue
|
| 124 |
+
if (unit === "m") return numericValue / 1000
|
| 125 |
+
return null
|
| 126 |
+
}
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
- Same shape as Variant C, but unit set is **only `b|m`** (no `t`, no `k`) and uses `Number()` instead of `parseFloat`.
|
| 130 |
+
- Used by `formatParamsBillions(value, modelName)` only when the explicit numeric `value` is null/NaN β so it's the fallback parser for the compare-dialog header label.
|
| 131 |
+
|
| 132 |
+
### Variant F β `app/evals/[id]/page.tsx:434-437` (inline regex)
|
| 133 |
+
|
| 134 |
+
```ts
|
| 135 |
+
const sizeMatch = (data.name + " " + id).match(/\b(\d+(?:\.\d+)?)\s*[bB]\b/)
|
| 136 |
+
if (sizeMatch) sizeB = parseFloat(sizeMatch[1])
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
- One-shot regex against the **concatenation of `name + " " + id`** (not just name).
|
| 140 |
+
- Unit set: **only `b|B`**. No multi-unit support, no fallback.
|
| 141 |
+
- `match()` (not `matchAll()`) β returns **first** match (Variants C and E pick the **last**). For a name like `"Llama-3-8B-70B-Instruct"` Variant F returns `8`, Variant C returns `70`.
|
| 142 |
+
- Used at the matrix-leaderboard sizeB filter (`m.sizeB`, lines 472-473) for the params-range slider.
|
| 143 |
+
|
| 144 |
+
## Classification
|
| 145 |
+
|
| 146 |
+
- **Cleaning / standardization β pipeline.** Pure value transform on a single field per row. The product decision being encoded β "express parameter count in billions" β is a per-record canonicalization that belongs upstream. Pipeline-side fix: emit a single numeric `params_billions` (in billions) on every model record; consumers stop parsing.
|
| 147 |
+
- **Unconditional normalization.** Each variant runs unconditionally over its source fields; none defer to a pre-existing canonical numeric (because none exists at the per-result level today). Pipeline-side fix is to emit the canonical value before consumers see the row, not to gate normalization on its absence.
|
| 148 |
+
|
| 149 |
+
(One nuance: Variant D's *fallback chain* β try `additional_details.params_billions`, then `parameter_count`, then `name` β is itself a small bit of reshape logic. After pipeline emits a canonical numeric, the chain collapses to a single field read.)
|
| 150 |
+
|
| 151 |
+
## Inputs and expected outputs
|
| 152 |
+
|
| 153 |
+
Each table below describes ONE variant.
|
| 154 |
+
|
| 155 |
+
### Group A β Variant A (`parseParamsBillions`, lib/model-data.ts)
|
| 156 |
+
|
| 157 |
+
| Input | Output | Path |
|
| 158 |
+
|---|---|---|
|
| 159 |
+
| `7` (number) | `7` | number, finite, > 0 β return as-is |
|
| 160 |
+
| `0` (number) | `null` | number, > 0 fails β null |
|
| 161 |
+
| `-3` (number) | `null` | number, > 0 fails β null |
|
| 162 |
+
| `NaN` | `null` | number, finite fails β null |
|
| 163 |
+
| `null` / `undefined` / `[]` | `null` | not number, not string β null |
|
| 164 |
+
| `"7B"` | `7` | regex matches β unit `b` β 7 |
|
| 165 |
+
| `"7b"` | `7` | lowercased; same |
|
| 166 |
+
| `"70B params"` | `70` | regex matches at start, `b` β 70 |
|
| 167 |
+
| `"1.5B"` | `1.5` | float supported |
|
| 168 |
+
| `"405b"` | `405` | lowercased |
|
| 169 |
+
| `"7 billion"` | `7` | full word `billion` β 7 |
|
| 170 |
+
| `"7bn"` | `7` | `bn` alias |
|
| 171 |
+
| `"1.2T"` | `1200` | `t` β Γ1000 |
|
| 172 |
+
| `"2 trillion"` | `2000` | `trillion` β Γ1000 |
|
| 173 |
+
| `"2T params"` | `2000` | regex stops at `t\b`; trailing text ignored |
|
| 174 |
+
| `"560M"` | `0.56` | `m` β Γ·1000 |
|
| 175 |
+
| `"560 million"` | `0.56` | `million` β Γ·1000 |
|
| 176 |
+
| `"1000K"` | `0.001` | `k` β Γ·1_000_000 |
|
| 177 |
+
| `"1,500B"` | `1500` | comma stripped |
|
| 178 |
+
| `"34.389"` | `34.389` | no unit token β parseFloat fallback |
|
| 179 |
+
| `"7 B"` (double space) | `7` | regex `\s*` matches |
|
| 180 |
+
| `"abc"` | `null` | no match, parseFloat NaN β null |
|
| 181 |
+
| `""` | `null` | trim β "" β early return |
|
| 182 |
+
| `" "` | `null` | trim β "" β early return |
|
| 183 |
+
| `"0B"` | `null` | regex matches, amount=0, `<= 0` reject β null |
|
| 184 |
+
| `"3.5tn"` | `3500` | `tn` alias |
|
| 185 |
+
| `"7Banana"` | `7` | regex `b\b` fails (no boundary after `b`); falls to `parseFloat("7banana")` = `7` β returns 7. **TS quirk: trailing junk allowed in parseFloat fallback** |
|
| 186 |
+
| `"-5B"` | `5` | regex `\d+` doesn't include `-`, but matches the `5b` substring β amount=5; `>0` passes β returns `5`. **TS quirk: leading minus is silently dropped** |
|
| 187 |
+
|
| 188 |
+
### Group B β Variant B (`parseParamsBillionsFromText`, eval-detail.tsx)
|
| 189 |
+
|
| 190 |
+
Same as A except:
|
| 191 |
+
|
| 192 |
+
| Input | A | B | Why |
|
| 193 |
+
|---|---|---|---|
|
| 194 |
+
| `"0B"` | `null` | `0` | B has no `<= 0` reject |
|
| 195 |
+
| `"-5"` (string) | `null` | `-5` | B has no `> 0` reject on parseFloat fallback |
|
| 196 |
+
| `"-5B"` (string) | `5` | `5` | both: regex matches the `5b` substring β amount=5 (TS quirk: leading minus dropped silently) |
|
| 197 |
+
| `"NaN"` (string) | `null` | `null` | parseFloat("nan") = NaN, `isFinite` false β null in both |
|
| 198 |
+
| number input | passes-through (with `>0` check) | n/a (B rejects non-strings) | A is polymorphic; B is string-only |
|
| 199 |
+
|
| 200 |
+
All other rows in Group A apply to B identically (string-input rows only).
|
| 201 |
+
|
| 202 |
+
### Group C β Variant C (`parseParamsBillionsFromModelName`, eval-detail.tsx)
|
| 203 |
+
|
| 204 |
+
| Input | Output | Path |
|
| 205 |
+
|---|---|---|
|
| 206 |
+
| `"Llama-3-70B-Instruct"` | `70` | matchAll finds `70B`, last token, `b` β 70 |
|
| 207 |
+
| `"Llama-3-8B-Instruct-8K"` | `0.000008` | matchAll finds `8B` and `8K`; **last** token is `8K` β Γ·1_000_000 β 0.000008. **TS quirk** |
|
| 208 |
+
| `"Llama-3-70B-Instruct-32K"` | `0.000032` | last token is `32K` β 0.000032. **TS quirk: context-window beats param count** |
|
| 209 |
+
| `"Mixtral-8x7B"` | `null` | regex needs `\b` before the `\d`; `8x7b` has no boundary between `x` and `7`, so no token matches. **TS quirk: MoE-style names parse to null** |
|
| 210 |
+
| `"Phi-3.5-mini-3.8B"` | `3.8` | matches `3.8B` |
|
| 211 |
+
| `"560M"` | `0.56` | `m` token β Γ·1000 |
|
| 212 |
+
| `"GPT-4"` | `null` | no `\b\d+[tmbk]\b` token |
|
| 213 |
+
| `"Yi-1.5-34B-32K"` | `0.000032` | last token `32K` (context window!) β 0.000032 |
|
| 214 |
+
| `"Qwen2-7B-Instruct"` | `7` | last token `7B` |
|
| 215 |
+
| `"7 billion"` | `null` | regex requires single-letter unit; `billion` has no `\bb\b` since it's word-internal |
|
| 216 |
+
| `"1.2T"` | `1200` | `t` β Γ1000 |
|
| 217 |
+
| `""` / `null` / `undefined` | `null` | early return |
|
| 218 |
+
|
| 219 |
+
### Group D (1) β Variant D (`getParamsBillionsFromModelInfo`, orchestrator)
|
| 220 |
+
|
| 221 |
+
Behavior depends on which field is populated:
|
| 222 |
+
|
| 223 |
+
| modelInfo state | Result |
|
| 224 |
+
|---|---|
|
| 225 |
+
| `additional_details.params_billions` is `number` 7 | `7` (returned as-is, no validation) |
|
| 226 |
+
| `additional_details.params_billions` is `number` -3 | `-3` (no validity check; passed through) |
|
| 227 |
+
| `additional_details.params_billions` is `string "7.242"` | `7.242` (Variant B parseFloat fallback) |
|
| 228 |
+
| `additional_details.params_billions` is `string "7B"` | `7` (Variant B regex) |
|
| 229 |
+
| `additional_details.params_billions` absent, `additional_details.parameter_count` is `"34.389"` | `34.389` (Variant B) |
|
| 230 |
+
| `additional_details` absent, `modelInfo.parameter_count` is `"7B"` | `7` (Variant B) |
|
| 231 |
+
| All `additional_details.*` and `parameter_count` absent, `modelInfo.name` is `"Llama-3-70B-Instruct"` | `70` (Variant C) |
|
| 232 |
+
| All absent, `modelInfo.name` is `"Llama-3-8B-Instruct-8K"` | `0.000008` (Variant C TS quirk) |
|
| 233 |
+
|
| 234 |
+
### Group E β Variant E (`parseParamsBillionsFromModelName`, model-compare-dialog.tsx)
|
| 235 |
+
|
| 236 |
+
Same as Variant C but rejects `t` and `k`:
|
| 237 |
+
|
| 238 |
+
| Input | C | E | Why |
|
| 239 |
+
|---|---|---|---|
|
| 240 |
+
| `"Llama-3-70B-Instruct"` | `70` | `70` | both β `b` token |
|
| 241 |
+
| `"Llama-3-8B-8K"` | `0.000008` | `8` | E ignores `K` β last `b|m` token is `8B` |
|
| 242 |
+
| `"Yi-1.5-34B-32K"` | `0.000032` | `34` | E correctly returns 34 (TS quirk: E is *more correct* on names with context-window suffixes!) |
|
| 243 |
+
| `"1.2T"` | `1200` | `null` | E doesn't accept `t` |
|
| 244 |
+
| `"Mixtral-8x7B"` | `null` | `null` | both β `8x7b` has no `\b` before the digit |
|
| 245 |
+
| `"560M"` | `0.56` | `0.56` | both |
|
| 246 |
+
| `"7Banana"` | `null` | `null` | both β regex requires `\b` boundary |
|
| 247 |
+
|
| 248 |
+
### Group F β Variant F (`(name + " " + id).match(/\b(\d+(?:\.\d+)?)\s*[bB]\b/)`, app/evals/[id]/page.tsx)
|
| 249 |
+
|
| 250 |
+
| Input (`name + " " + id`) | Output | Path |
|
| 251 |
+
|---|---|---|
|
| 252 |
+
| `"Llama-3-70B meta/llama-3-70b"` | `70` | first `70B` matches |
|
| 253 |
+
| `"Llama-3-70B-Instruct-8K meta/llama-3-70b-instruct"` | `70` | first match wins; `8K` not a `b\|B` so ignored entirely |
|
| 254 |
+
| `"Yi-1.5-34B-32K 01-ai/yi-1-5-34b-32k"` | `34` | first match `34B` |
|
| 255 |
+
| `"GPT-4 openai/gpt-4"` | `null` | no `b\|B` token |
|
| 256 |
+
| `"Mixtral-8x7B mistralai/mixtral-8x7b"` | `null` | `8x` blocks word-boundary; no `\b\d` anchor; no match |
|
| 257 |
+
| `"560M openai/foo-560m"` | `null` | F only accepts `b\|B` |
|
| 258 |
+
| `"1.5B-instruct meta/foo-1-5b"` | `1.5` | first `1.5B` |
|
| 259 |
+
| `"" + " " + ""` | `null` | empty β no match |
|
| 260 |
+
|
| 261 |
+
**Cross-variant ordering quirk:** Variants C and E pick the *last* size-token; Variant F picks the *first*. For ambiguous names the answer can differ; in practice production names tend to have one numeric+unit token so this rarely matters.
|
| 262 |
+
|
| 263 |
+
### Group G β Cross-variant divergence
|
| 264 |
+
|
| 265 |
+
For the same input, the variants produce different outputs. In production, format consistency keeps disagreement narrow but real:
|
| 266 |
+
|
| 267 |
+
For inputs that aren't model names (free-text params strings), A and B are the relevant variants:
|
| 268 |
+
|
| 269 |
+
| Input | A | B |
|
| 270 |
+
|---|---|---|
|
| 271 |
+
| `"7B"` | `7` | `7` |
|
| 272 |
+
| `"7"` (string) | `7` (parseFloat fallback) | `7` |
|
| 273 |
+
| `7` (number) | `7` | n/a (B rejects non-strings) |
|
| 274 |
+
| `0` (number) | `null` (>0 reject) | n/a |
|
| 275 |
+
| `NaN` (number) | `null` | n/a |
|
| 276 |
+
| `"0B"` | `null` (rejects amount β€0) | `0` |
|
| 277 |
+
| `"-5"` (string) | `null` (>0 reject on parseFloat fallback) | `-5` |
|
| 278 |
+
| `"-5B"` (string) | `5` | `5` (regex matches `5b`; minus dropped β both variants) |
|
| 279 |
+
| `"7Banana"` | `7` | `7` (parseFloat lenient β both variants) |
|
| 280 |
+
|
| 281 |
+
For inputs that ARE model names (the C/E/F input domain), all five variants can be applied:
|
| 282 |
+
|
| 283 |
+
| Input | A | B | C | E | F (`name + " " + id`) |
|
| 284 |
+
|---|---|---|---|---|---|
|
| 285 |
+
| `"Llama-3-70B-Instruct"` | `70` (first regex match) | `70` | `70` (last token = `70B`) | `70` | `70` (first match) |
|
| 286 |
+
| `"Llama-3-70B-Instruct-8K"` | `70` (`match()` returns first; first `(\d+)(unit)` is `70b`) | `70` (same regex as A) | `0.000008` (C's `matchAll` β last token = `8K`, Γ·1_000_000 β 0.000008) | `8` (E ignores `K`; last `b\|m` token = `8B`) | `70` (F is `[bB]` only; first match = `70B`) |
|
| 287 |
+
| `"Yi-1.5-34B-32K"` | `34` | `34` | `0.000032` (last token `32K`) | `34` | `34` |
|
| 288 |
+
| `"Mixtral-8x7B"` | `7` (A's regex has no leading `\b` β `\d+` can match anywhere, including right after `x` β matches `7b` β 7) | `7` (same as A) | `null` (C's regex starts with `\b\d` β no `\b` between `x` and `7` β no match) | `null` | `null` |
|
| 289 |
+
| `"560M"` | `0.56` | `0.56` | `0.56` | `0.56` | `null` (F is B-only) |
|
| 290 |
+
| `"1.2T"` | `1200` | `1200` | `1200` | `null` (E is `b\|m`-only) | `null` (F is B-only) |
|
| 291 |
+
| `"7 billion"` | `7` | `7` | `null` (C requires single-letter unit only; `billion` doesn't match the `[tmbk]` class) | `null` | `null` |
|
| 292 |
+
|
| 293 |
+
**This is not a bug to fix in the migration.** It's evidence that the parsers were written with subtly different assumptions about input shape (model-name vs free-text vs trusted-numeric). The pipeline-canonical fix collapses all five into a single field read.
|
| 294 |
+
|
| 295 |
+
## Current TS implementation
|
| 296 |
+
|
| 297 |
+
| Variant | Function | Location | Callers | Source field |
|
| 298 |
+
|---|---|---|---|---|
|
| 299 |
+
| A | `parseParamsBillions` | `lib/model-data.ts:312-354` | 1 site: `lib/model-data.ts:409` | `entry.params_billions` from `model-cards.json` (number\|null in prod) |
|
| 300 |
+
| B | `parseParamsBillionsFromText` | `components/eval-detail.tsx:81-119` | 2 sites inside Variant D: `eval-detail.tsx:170, 177` | strings from `additional_details.params_billions / parameter_count / num_parameters / params` and `modelInfo.parameter_count` |
|
| 301 |
+
| C | `parseParamsBillionsFromModelName` | `components/eval-detail.tsx:121-155` | 1 site inside Variant D: `eval-detail.tsx:183` | `modelInfo.name` |
|
| 302 |
+
| D | `getParamsBillionsFromModelInfo` (orchestrator) | `components/eval-detail.tsx:157-184` | 3 sites: `eval-detail.tsx:350, 1253, 1368` | `ModelResultForBenchmark["model_info"]` (built per-result by `lib/hf-data.ts:1133` `buildModelInfoForVariant`) |
|
| 303 |
+
| E | `parseParamsBillionsFromModelName` (compare-dialog) | `components/model-compare-dialog.tsx:44-60` | 1 site: `model-compare-dialog.tsx:64` (`formatParamsBillions` fallback) | model display name in compare dialog |
|
| 304 |
+
| F | inline regex | `app/evals/[id]/page.tsx:434-437` | 1 site (inline use): lines 472-473 (sizeB filter for slider) | `data.name + " " + id` from per-row matrix-leaderboard model entries |
|
| 305 |
+
|
| 306 |
+
Total: 5 distinct parsers + 1 orchestrator + 8 caller sites across 4 files.
|
| 307 |
+
|
| 308 |
+
## Pipeline status β divergences
|
| 309 |
+
|
| 310 |
+
### Side-by-side comparison table
|
| 311 |
+
|
| 312 |
+
| Aspect | TS (this spec) | Pipeline today | Result for users |
|
| 313 |
+
|---|---|---|---|
|
| 314 |
+
| Where canonicalization runs | request time, in 5 functions | `model-cards.json.params_billions` is already a clean number for 87% of cards (5072/5830); per-result `additional_details.params_billions` is a *string* in ~58% of model files | TS parses on every render |
|
| 315 |
+
| Output format | varies per variant: number-of-billions; some return 0/negative, some null on edge | model-card level: clean float in billions; per-result: string requiring downstream parse | mostly converges in production but the per-result string parsing is unnecessary work |
|
| 316 |
+
| Coverage | Variants C/E/F regex-fallback fires when no `additional_details` data exists | model-cards: 13% (758/5830) have `params_billions=null` (no data, irrespective of parser) | "Not reported" appears for 13% of cards regardless of parser correctness |
|
| 317 |
+
|
| 318 |
+
### Concrete worked example with quantified scope
|
| 319 |
+
|
| 320 |
+
Audited 2026-04-28 against `.cache/hf-data/` (5,830 model-cards, 5,830 model files, **86,183 model_result rows**):
|
| 321 |
+
|
| 322 |
+
- **`model-cards.json` (top-level, drives Variant A)**: 5,830 entries.
|
| 323 |
+
- `params_billions` is `number`: 5,072 (87.0%) β Variant A returns positive value
|
| 324 |
+
- `params_billions` is `null`: 758 (13.0%) β Variant A returns null
|
| 325 |
+
- `params_billions` is string: **0** β Variant A's string-parsing branches are entirely dead code in production
|
| 326 |
+
- **Per-row `model_info.additional_details.params_billions` (drives Variants B β D fallback)**:
|
| 327 |
+
- undefined: 58,822 (68.3%) β D falls through to model-name fallback
|
| 328 |
+
- string: 27,361 (31.7%) β virtually all `cleanDecimal` shape (e.g. `"7.242"`, `"34.389"`); 18 rows are `"-1.0"` (negative sentinel β Variant B accepts it as `-1`, A would reject)
|
| 329 |
+
- number: 0
|
| 330 |
+
- **Variant D resolution path counts** (out of 86,183 rows):
|
| 331 |
+
- `addPbString` (additional_details.params_billions string β B): 27,361 (31.7%)
|
| 332 |
+
- `modelNameFallback` (Variant C from name): 18,174 (21.1%)
|
| 333 |
+
- **`noResolution`** (D returns null): 40,648 (47.2%) β these rows display "Not reported"
|
| 334 |
+
- **Model name format distribution** (drives Variants C/E/F):
|
| 335 |
+
- `hasBOnly` (single B-token, no context-window): 36,286 (42.1%) β happy path; all parsers converge
|
| 336 |
+
- `hasBAndContextWindow` (e.g. `Yi-1.5-34B-32K`): **526 (0.61%)** β these hit Variant C's TS quirk
|
| 337 |
+
- `hasMOnly` (e.g. `d-SmolLM2-360M`): 417 (0.48%)
|
| 338 |
+
- `hasMoEPattern` (e.g. `WizardLM-2-8x22B`): 1,173 (1.36%) β A/B parse via no-leading-`\b` regex; C/E/F return null
|
| 339 |
+
- `noUnitToken` (e.g. `Yi Large Preview`, `GPT-4`): 47,781 (55.4%) β no parser matches
|
| 340 |
+
- `hasBAndT`: 0
|
| 341 |
+
- **Cross-variant agreement on names** (A/B/C/E/F applied to the model name string):
|
| 342 |
+
- All five converge: 81,442 (94.50%)
|
| 343 |
+
- **Variant C TS-quirk hit (context-window beats param count)**: **472 rows (0.55%)** β only C is wrong; A/B/E/F all return correct param count
|
| 344 |
+
- F-only-missing (F returns null because no B-token but others find m/k/t): 3,001 (3.48%)
|
| 345 |
+
- Other disagreement: 1,268 (1.47%)
|
| 346 |
+
|
| 347 |
+
**Top quirk in production**: Variant C returns the context-window size (`32K β 0.000032B`, `16K β 0.000016B`, `8K β 0.000008B`) instead of the parameter count for **472 model_result rows** with names like `Yi-1.5-34B-32K`, `Yi-1.5-34B-Chat-16K`. Variant D's fallback chain only reaches Variant C (the model-name parser) when `additional_details.params_billions` is missing, so the user-visible impact is bounded β but those 472 rows render with a `~0B` parameter count on the eval-detail leaderboard, well below the params-range filter floor.
|
| 348 |
+
|
| 349 |
+
Verified by `scripts/verify-params-parsing.mjs`.
|
| 350 |
+
|
| 351 |
+
## Notes for pipeline implementer
|
| 352 |
+
|
| 353 |
+
- **Recommended canonical field: `params_billions: float | null`** at the *per-result* level (pipeline emits it on every `model_result.model_info`, not just at the top-level model card).
|
| 354 |
+
- Eliminate the multi-field fallback chain in Variant D by emitting the resolved value in one canonical place. Existing fields (`additional_details.params_billions`, `additional_details.parameter_count`, `additional_details.num_parameters`, `additional_details.params`, `modelInfo.parameter_count`) can stay for compatibility but consumers stop reading them.
|
| 355 |
+
- The model-name regex fallback (Variants C/E/F) is the *only* path that fires when `additional_details` is missing. Pipeline should attempt to parse from name **once** upstream (with whatever quirks it chooses; default to the Variant E semantics β `b|m` only β to avoid the context-window false-positive) and emit the result. Document the upstream parser's choice clearly so this spec can be retired.
|
| 356 |
+
- The "params_billions in millions vs billions" unit is implicit; recommend keeping the field name `params_billions` to avoid breaking changes, and storing the value in **billions** as today.
|
| 357 |
+
- Once pipeline emits per-result `params_billions`:
|
| 358 |
+
- Variant D collapses to a single field read.
|
| 359 |
+
- Variants A/B/C/E/F all become deletable.
|
| 360 |
+
- Variant F's `name + " " + id` regex disappears with the rest.
|
| 361 |
+
|
| 362 |
+
Verification: once pipeline ships per-result `params_billions`, run `scripts/verify-params-parsing.mjs` and confirm the regex-fallback ("name-derived") row count drops to 0.
|
| 363 |
+
|
| 364 |
+
## Migration checklist
|
| 365 |
+
|
| 366 |
+
- [x] Spec written
|
| 367 |
+
- [x] Tests cover each variant's semantics + cross-variant divergence (`tests/transformations/params-parsing.test.ts`)
|
| 368 |
+
- [ ] Filed with pipeline owner with the spec + tests + audit script as acceptance criterion
|
| 369 |
+
- [ ] Pipeline emits per-result `params_billions` numeric (in billions) across the full corpus
|
| 370 |
+
- [ ] TS deleted; replace 5 functions + orchestrator + 8 callers with a single field read. Files: `lib/model-data.ts`, `components/eval-detail.tsx`, `components/model-compare-dialog.tsx`, `app/evals/[id]/page.tsx`.
|
| 371 |
+
|
| 372 |
+
## Future product decision (deferred)
|
| 373 |
+
|
| 374 |
+
The Variant C "context-window suffix beats parameter count" quirk (`"Llama-3-8B-8K"` β 0.000008B) is a real bug. We're choosing to fix-by-canonicalization-upstream rather than fix-in-place. Whether the pipeline parser should match Variant C, Variant E (which avoids the quirk by ignoring `K`/`T`), or implement a smarter "prefer the larger token" heuristic is a separate design decision for the pipeline owner.
|
| 375 |
+
|
| 376 |
+
The Variant A `<= 0` rejection (treats `"0B"` as missing data) versus Variant B passthrough (`"0B"` β `0`) is another deferred decision. Production never emits `0`-valued params, so this also doesn't manifest today.
|
notes/transformations/11-benchmark-card-attachment.md
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Benchmark-card attachment (per-eval lookup join)
|
| 2 |
+
|
| 3 |
+
Drafted 2026-04-28. Migration item #17 in `notes/migration-plan.md`.
|
| 4 |
+
|
| 5 |
+
## Framing reminder
|
| 6 |
+
|
| 7 |
+
We are refactoring for UI efficiency. TS-as-is is the canonical spec. The retry-loop iteration over candidate names is **load-bearing** in production: 85% of evals (499/587) reach this code path because the pipeline does not inline `benchmark_card` for them. Don't try to "fix" the candidate-name derivation or the dedup-on-first-name-collision behavior in `getMap()` β preserve them until pipeline always inlines `benchmark_card` upstream.
|
| 8 |
+
|
| 9 |
+
## Rule (as TS implements it today)
|
| 10 |
+
|
| 11 |
+
For each `BenchmarkEvalSummary` (detail page) or `BenchmarkEvalListItem` (list page), if `benchmark_card` is not already populated on the record, derive an ordered list of candidate names from the eval and try each one against a deduped `Map<string, BenchmarkCard>`. The first match wins; first hit attaches the card via spread (`{ ...summary, benchmark_card: card }`); no match leaves the record unchanged (passthrough).
|
| 12 |
+
|
| 13 |
+
The lookup is composed from three pieces:
|
| 14 |
+
|
| 15 |
+
1. **Map build** (`lib/benchmark-metadata.ts:11-28`, `readPipelineBenchmarkCards`)
|
| 16 |
+
- Source: `benchmark-metadata.json` (Record<string, BenchmarkCard>) β currently 85 cards in production.
|
| 17 |
+
- For each card with `card.benchmark_details.name`, generate `candidateBenchmarkKeys(name)` and insert each key into a `Map`.
|
| 18 |
+
- **First-write-wins** dedup: `if (!map.has(key)) map.set(key, card)`. If two cards normalize to the same key, the first one inserted (i.e. the first one returned by `Object.values()`) takes the slot.
|
| 19 |
+
- Cached for the lifetime of the process via `cachedMapPromise`.
|
| 20 |
+
|
| 21 |
+
2. **Per-name candidate generation** (`lib/benchmark-metadata-utils.ts:23-33`, `candidateBenchmarkKeys`)
|
| 22 |
+
- Input: a free-text benchmark name.
|
| 23 |
+
- Produces an array of up to 4 lookup keys via a `Set` (so duplicates collapse), in this order:
|
| 24 |
+
1. `base = normalizeBenchmarkKey(name)` β the canonical form (see below).
|
| 25 |
+
2. `base.replace(/-/g, " ")` β dashes β spaces.
|
| 26 |
+
3. `base.replace(/ /g, "-")` β spaces β dashes.
|
| 27 |
+
4. `base.replace(/[^a-z0-9]/g, "")` β strip everything to alnum.
|
| 28 |
+
|
| 29 |
+
3. **`normalizeBenchmarkKey`** (`lib/benchmark-metadata-utils.ts:10-18`) β the base normalizer:
|
| 30 |
+
- Returns `""` for falsy input (does NOT fall through to `"unknown"` like `pipelineSlugify` does).
|
| 31 |
+
- Strip a leading `<alnum_underscore_token>` followed by optional space and a `/` (e.g. `"hfopenllm_v2/mmlu"` β `"mmlu"`).
|
| 32 |
+
- `.toLowerCase()`.
|
| 33 |
+
- Collapse runs of `_` or `-` to a single space.
|
| 34 |
+
- Collapse whitespace to single space.
|
| 35 |
+
- `.trim()`.
|
| 36 |
+
|
| 37 |
+
4. **The retry loop over per-record candidate names** (`lib/model-data.ts:863-872`, `attachBenchmarkCardToSummary`)
|
| 38 |
+
- Builds a list of three candidate names from the summary (in this exact order):
|
| 39 |
+
1. `summary.evaluation_name`
|
| 40 |
+
2. `summary.composite_benchmark_name`
|
| 41 |
+
3. `summary.composite_benchmark_key`
|
| 42 |
+
- For each, calls `getBenchmarkCard(candidate)` (which itself runs `candidateBenchmarkKeys` on the name and tries each key against the map).
|
| 43 |
+
- First hit wins. No `.filter(Boolean)` here, so empty-string candidates still hit `getBenchmarkCard` (which then returns `null` because `normalizeBenchmarkKey("")` returns `""`).
|
| 44 |
+
|
| 45 |
+
The list-item variant (`lib/duckdb-data.ts:133-156`, `attachBenchmarkCardsToEvalListItems` and `lib/model-data.ts:1264-1282` inline in `getEvalListData`) is identical except:
|
| 46 |
+
- Order is `[evaluation_name, composite_benchmark_key, composite_benchmark_name]` (key BEFORE name β the inverse of the summary version).
|
| 47 |
+
- Both wrap with `.filter(Boolean)` to drop empty/undefined candidates before the loop.
|
| 48 |
+
|
| 49 |
+
This three-vs-three asymmetry between the summary path and list path is **TS-as-spec**: do not "harmonize" it. Same record can resolve to different cards via the two paths if the second and third candidates point at different benchmarks (in production this difference is benign β see "Divergences detected").
|
| 50 |
+
|
| 51 |
+
## Classification
|
| 52 |
+
|
| 53 |
+
- **Default-only** (do NOT overwrite when value present). Both attach functions guard with `if (summary.benchmark_card) return summary` / `if (item.benchmark_card) return item`. Pipeline-side fix: emit `benchmark_card` inline for every eval, then this branch is dead.
|
| 54 |
+
- **Cleaning β pipeline.** This is a per-record lookup join (eval Γ benchmark_card). The map build, candidate-name generation, and three-attempt retry exist only to reconcile the eval's free-text name against the benchmark-metadata file. Once the pipeline writes `benchmark_card: <BenchmarkCard>` directly into every `eval-list.json` / `eval-detail.json` record, all four pieces (map build, candidateBenchmarkKeys, normalizeBenchmarkKey, the two attach functions) delete together. No aggregation; no derived view.
|
| 55 |
+
|
| 56 |
+
## Inputs and expected outputs
|
| 57 |
+
|
| 58 |
+
### Group A β `normalizeBenchmarkKey`
|
| 59 |
+
|
| 60 |
+
| Input | Output | Rule branch |
|
| 61 |
+
|---|---|---|
|
| 62 |
+
| `"MMLU"` | `"mmlu"` | lowercase only |
|
| 63 |
+
| `"BIG-Bench Hard (BBH)"` | `"big bench hard (bbh)"` | dash β space |
|
| 64 |
+
| `"hfopenllm_v2/mmlu"` | `"mmlu"` | composite prefix stripped |
|
| 65 |
+
| `"hfopenllm_v2 / mmlu"` | `"mmlu"` | composite prefix stripped (with space before `/`) |
|
| 66 |
+
| `"GPQA / Diamond"` | `"diamond"` | composite prefix stripped β regex `/^[a-z0-9_]+ ?\//i` allows one optional space between the leading token and the `/` |
|
| 67 |
+
| `""` | `""` | falsy short-circuit (does NOT fall back to "unknown") |
|
| 68 |
+
| `" MMLU "` | `"mmlu"` | trim |
|
| 69 |
+
| `"foo___bar"` | `"foo bar"` | underscore run collapsed to single space |
|
| 70 |
+
| `"foo - - bar"` | `"foo bar"` | dash + space runs collapse |
|
| 71 |
+
|
| 72 |
+
### Group B β `candidateBenchmarkKeys`
|
| 73 |
+
|
| 74 |
+
| Input | Candidates returned (deduped, in order) | Why |
|
| 75 |
+
|---|---|---|
|
| 76 |
+
| `"MMLU"` | `["mmlu"]` | base only β no dashes, no spaces, alnum-only |
|
| 77 |
+
| `"BIG-Bench Hard (BBH)"` | `["big bench hard (bbh)", "big-bench-hard-(bbh)", "bigbenchhardbbh"]` | base; spacesβdashes; alnum-only. Dashesβspaces collides with base. |
|
| 78 |
+
| `"GSM-8K"` | `["gsm 8k", "gsm-8k", "gsm8k"]` | base; spacesβdashes; alnum-only |
|
| 79 |
+
| `"gsm 8k"` | `["gsm 8k", "gsm-8k", "gsm8k"]` | identical result; base/dashes-form collision |
|
| 80 |
+
| `"hfopenllm_v2/mmlu"` | `["mmlu"]` | composite prefix stripped first β no dashes/spaces |
|
| 81 |
+
| `""` | `[""]` | base is `""`; the four variants all collapse to `""` |
|
| 82 |
+
|
| 83 |
+
### Group C β `getBenchmarkCard` (per-name lookup against the deduped map)
|
| 84 |
+
|
| 85 |
+
Given a map built from cards `{ "mmlu": cardA, "big bench hard (bbh)": cardB, "gsm 8k": cardC }`:
|
| 86 |
+
|
| 87 |
+
| Input | Resolution | Notes |
|
| 88 |
+
|---|---|---|
|
| 89 |
+
| `"MMLU"` | cardA | base candidate `"mmlu"` hits |
|
| 90 |
+
| `"mmlu_categories/mmlu_pro"` | cardA only if `"mmlu pro"` not in map; otherwise `null`/no hit unless the prefix-stripped form `"mmlu_pro"` (after `^[a-z0-9_]+ ?\//` strips `mmlu_categories/`) β `"mmlu pro"` was indexed. Demonstrates: composite prefix stripping can either help (find a generic card for the leaf) or miss (if the leaf has its own card the map didn't index under that exact spelling). |
|
| 91 |
+
| `"BBH"` | `null` if cardB indexed under full title only | Reverse-lookup limitation β see "Divergences detected" |
|
| 92 |
+
| `""` | `null` | `normalizeBenchmarkKey("")` short-circuits to `""`; all four candidates are `""`; `map.get("")` β undefined |
|
| 93 |
+
|
| 94 |
+
### Group D β `attachBenchmarkCardToSummary` retry order
|
| 95 |
+
|
| 96 |
+
For a summary with `evaluation_name="bbh/category_x"`, `composite_benchmark_name="BIG-Bench Hard (BBH)"`, `composite_benchmark_key="bbh"`:
|
| 97 |
+
|
| 98 |
+
| Position | Candidate name | Lookup behaviour |
|
| 99 |
+
|---|---|---|
|
| 100 |
+
| 0 | `"bbh/category_x"` | `normalizeBenchmarkKey` strips `bbh/` β `"category_x"` β `"category x"` β likely no match |
|
| 101 |
+
| 1 | `"BIG-Bench Hard (BBH)"` | `candidateBenchmarkKeys` produces `"big bench hard (bbh)"` β likely match |
|
| 102 |
+
| 2 | `"bbh"` | base `"bbh"` β only matches if cards indexed the abbreviation |
|
| 103 |
+
|
| 104 |
+
If position 1 hits, the loop short-circuits and the spread happens. If all three miss, `summary` returned unchanged.
|
| 105 |
+
|
| 106 |
+
### Group E β pre-attached benchmark_card (default-only guard)
|
| 107 |
+
|
| 108 |
+
| Input | Output |
|
| 109 |
+
|---|---|
|
| 110 |
+
| `summary.benchmark_card = <existing card>` | returned as-is, no map lookup |
|
| 111 |
+
| `summary.benchmark_card = null` | falsy β falls through to retry loop |
|
| 112 |
+
| `summary.benchmark_card = undefined` | falsy β falls through to retry loop |
|
| 113 |
+
|
| 114 |
+
## Current TS implementation
|
| 115 |
+
|
| 116 |
+
| Concern | Location |
|
| 117 |
+
|---|---|
|
| 118 |
+
| Map build + caching | `lib/benchmark-metadata.ts:11-36` (`readPipelineBenchmarkCards`, `getMap`, `cachedMapPromise`) |
|
| 119 |
+
| Per-name lookup | `lib/benchmark-metadata.ts:38-49` (`getBenchmarkCard`) |
|
| 120 |
+
| Reverse map flatten (used by `/api/benchmark-metadata` route) | `lib/benchmark-metadata.ts:51-66` (`getAllBenchmarkCards`) |
|
| 121 |
+
| Candidate-key generation | `lib/benchmark-metadata-utils.ts:23-33` (`candidateBenchmarkKeys`) |
|
| 122 |
+
| Base normalizer | `lib/benchmark-metadata-utils.ts:10-18` (`normalizeBenchmarkKey`) |
|
| 123 |
+
| Summary attach (3-candidate retry) | `lib/model-data.ts:860-875` (`attachBenchmarkCardToSummary`) |
|
| 124 |
+
| List-item attach (3-candidate retry, key before name) | `lib/duckdb-data.ts:133-156` (`attachBenchmarkCardsToEvalListItems`) |
|
| 125 |
+
| List-item attach (inline, key before name, JSON backend) | `lib/model-data.ts:1264-1282` (inside `getEvalListData`) |
|
| 126 |
+
|
| 127 |
+
### Call sites
|
| 128 |
+
|
| 129 |
+
| Location | What it does |
|
| 130 |
+
|---|---|
|
| 131 |
+
| `lib/model-data.ts:870` | `attachBenchmarkCardToSummary` core (3-candidate retry over [evaluation_name, composite_benchmark_name, composite_benchmark_key]) |
|
| 132 |
+
| `lib/model-data.ts:1272` | `getEvalListData` inline 3-candidate retry over [evaluation_name, composite_benchmark_key, composite_benchmark_name] (note the swapped 2nd/3rd) |
|
| 133 |
+
| `lib/model-data.ts:1562` | aggregate eval path β calls `attachBenchmarkCardToSummary` per sub-eval before passing into `aggregateBenchmarkSummaries` |
|
| 134 |
+
| `lib/model-data.ts:1595` | synthetic-matrix eval path β single attach call |
|
| 135 |
+
| `lib/model-data.ts:1602` | direct eval lookup β single attach call |
|
| 136 |
+
| `lib/duckdb-data.ts:147` | DuckDB list-item retry (in `attachBenchmarkCardsToEvalListItems`) |
|
| 137 |
+
| `lib/duckdb-data.ts:179` | DuckDB single-eval path β calls `attachBenchmarkCardToSummary` |
|
| 138 |
+
| `lib/duckdb-data.ts:218` | DuckDB list path β calls `attachBenchmarkCardsToEvalListItems` |
|
| 139 |
+
| `app/api/benchmark-metadata/route.ts:5` | `/api/benchmark-metadata` route β exposes `getAllBenchmarkCards()` (the reverse flatten); not part of the per-eval attach but lives in the same module |
|
| 140 |
+
|
| 141 |
+
Total: 7 attach call sites across 2 files (3 in `model-data.ts` + 1 inline + 3 in `duckdb-data.ts` + the inline list loop). All would delete together once pipeline always inlines.
|
| 142 |
+
|
| 143 |
+
## Pipeline status β divergences
|
| 144 |
+
|
| 145 |
+
Audited 2026-04-28 against `.cache/hf-data/` (587 evals, 85 benchmark cards in `benchmark-metadata.json`).
|
| 146 |
+
|
| 147 |
+
### Coverage of inline `benchmark_card` (audited 2026-04-28)
|
| 148 |
+
|
| 149 |
+
- **88 / 587 evals (15.0%)** already have `benchmark_card` populated inline by the pipeline (in both `eval-list.json` and the per-eval JSONs under `evals/`).
|
| 150 |
+
- **499 / 587 evals (85.0%)** fall through to the runtime retry loop.
|
| 151 |
+
- **`eval-list.json` and per-eval `evals/*.json` agree** on which records carry inline cards (88 each β same set). The pipeline inlines symmetrically across both files.
|
| 152 |
+
|
| 153 |
+
### Retry-loop position distribution (of the 499 lookups)
|
| 154 |
+
|
| 155 |
+
| Position | Summary path (name, name, key) | List path (name, key, name) |
|
| 156 |
+
|---|---|---|
|
| 157 |
+
| 0 (1st candidate hits) | 10 (2.0%) | 10 (2.0%) |
|
| 158 |
+
| 1 (2nd candidate hits) | 0 | 0 |
|
| 159 |
+
| 2 (3rd candidate hits) | 0 | 0 |
|
| 160 |
+
| -1 (no candidate hits) | 489 (98.0%) | 489 (98.0%) |
|
| 161 |
+
|
| 162 |
+
**The retry tail is dead code in production today.** Of the 499 lookups, the 1st candidate either hits (10) or no candidate ever hits (489). The 2nd and 3rd candidate retries never resolve anything. This is consistent with the data: the 489 misses are evaluations like `"artificial_analysis.median_output_tokens_per_second"` where no card exists in `benchmark-metadata.json` at all β not a lookup failure, just absence.
|
| 163 |
+
|
| 164 |
+
That said: the retry IS the only thing that adds the 88 inline + 10 retry-position-0 = 98 cards to the runtime view. The retry positions 1 and 2 are kept TS-as-spec β they may be load-bearing in past or future data, and don't cost anything to preserve until pipeline always inlines.
|
| 165 |
+
|
| 166 |
+
### Asymmetric retry order between summary and list paths
|
| 167 |
+
|
| 168 |
+
### Asymmetric retry order between summary and list paths
|
| 169 |
+
|
| 170 |
+
| Path | Order | Source |
|
| 171 |
+
|---|---|---|
|
| 172 |
+
| Summary attach | `[evaluation_name, composite_benchmark_name, composite_benchmark_key]` | `lib/model-data.ts:863-867` |
|
| 173 |
+
| List-item attach (both backends) | `[evaluation_name, composite_benchmark_key, composite_benchmark_name]` | `lib/duckdb-data.ts:140-144`, `lib/model-data.ts:1270` |
|
| 174 |
+
|
| 175 |
+
User-visible effect: an eval where `composite_benchmark_key` resolves to a different card than `composite_benchmark_name` would attach the wrong (or different) card depending on whether you reached it through the list page or the detail page. **In production today: 0 disagreements** (audited 2026-04-28). The asymmetry is theoretically observable but has no current impact β usually because the 1st candidate (`evaluation_name`) hits before either path reaches its swapped 2nd/3rd positions.
|
| 176 |
+
|
| 177 |
+
Reproduce-don't-improve: the pipeline-side fix is to inline `benchmark_card` so both call sites resolve to the same value. Do NOT pick one of the two retry orders and propagate it.
|
| 178 |
+
|
| 179 |
+
### Map-build first-write-wins collisions
|
| 180 |
+
|
| 181 |
+
`readPipelineBenchmarkCards` indexes each card under `candidateBenchmarkKeys(card.benchmark_details.name)` and uses `if (!map.has(key)) map.set(key, card)`. If two cards have names that normalize to the same key, the **second** card is silently dropped from the map under that key (it may still be reachable via another candidate key it generates uniquely, but the colliding key permanently points at the first card encountered).
|
| 182 |
+
|
| 183 |
+
Order is `Object.values(cards)` β i.e. the JSON insertion order from `benchmark-metadata.json`. **Production count: 4 collisions** (audited 2026-04-28) involving 2 distinct duplicated names:
|
| 184 |
+
|
| 185 |
+
- `"Holistic Evaluation of Language Models (HELM)"` β `helm_capabilities` keeps the slot; `helm_instruct` is silently dropped under all colliding keys. The two cards have **different content** (overview lengths 466 vs 514). Anyone looking up "HELM" by name gets `helm_capabilities`; the `helm_instruct` card is unreachable via the runtime lookup unless the eval references the key `helm_instruct` directly (which would route through `composite_benchmark_key`, then `normalizeBenchmarkKey("helm_instruct")` β `"helm instruct"` β no match in the map either).
|
| 186 |
+
- `"LiveCodeBench"` β `livecodebenchpro` keeps the slot; `livecodebench_pro` is dropped. The two cards have **identical content** (same `overview` string). Benign.
|
| 187 |
+
|
| 188 |
+
The HELM case is a real data-correctness divergence: TS silently picks one of two distinct cards under the same name. Reproduce-don't-improve: the pipeline-side fix is to disambiguate the names in `benchmark-metadata.json` (or have evals reference cards by stable id rather than name), then inline the chosen card on each eval. Don't add disambiguation logic in TS.
|
| 189 |
+
|
| 190 |
+
Reproduce-don't-improve: pipeline should emit per-eval `benchmark_card` directly so this map-build path becomes dead code. No need to teach pipeline to detect collisions.
|
| 191 |
+
|
| 192 |
+
### Reverse-lookup limitation
|
| 193 |
+
|
| 194 |
+
A card's `benchmark_details.name` is the only string indexed. If an eval references a benchmark by a different name (abbreviation, alternate casing, missing parens), the lookup misses unless one of the four `candidateBenchmarkKeys` variants happens to collide with a variant of the card's name.
|
| 195 |
+
|
| 196 |
+
Example: a card named `"BIG-Bench Hard (BBH)"` is indexed under `"big bench hard (bbh)"`, `"big-bench-hard-(bbh)"`, `"bigbenchhardbbh"`. An eval named `"BBH"` produces candidates `["bbh"]` only β miss. The audit script counts how many evals fall into this category.
|
| 197 |
+
|
| 198 |
+
Reproduce-don't-improve: this is the entire reason for migration item #17. Pipeline knows the card-to-eval mapping at build time; it shouldn't push a fuzzy-match problem to the runtime.
|
| 199 |
+
|
| 200 |
+
**Orphaned cards (audited 2026-04-28): 29 / 83 distinct cards** in `benchmark-metadata.json` are not reached by any eval through any lookup path β examples include `"arc_agi_v1_public_eval"`, `"arc_agi_v2_semi_private"`, the `bfcl_*` family of 7 cards, `"IMDB"`, `"NarrativeQA"`, `"NaturalQuestions (open-book)"`, `"RAFT"`. These cards are either (a) for benchmarks not yet evaluated in the corpus, or (b) cards whose `benchmark_details.name` doesn't normalize to anything any eval looks up. Pipeline owner should triage. (Not a TS bug β TS faithfully looks up; the cards are never asked for.)
|
| 201 |
+
|
| 202 |
+
## Notes for pipeline implementer
|
| 203 |
+
|
| 204 |
+
The cleanest fix: **emit `benchmark_card: <BenchmarkCard>` inline on every eval** β both `eval-list.json` entries and every `evals/<id>.json` detail file. The pipeline already does this for 15% of evals; extend coverage to 100%.
|
| 205 |
+
|
| 206 |
+
Acceptance criteria:
|
| 207 |
+
|
| 208 |
+
1. Every record in `eval-list.json` has `benchmark_card` populated when a card exists for that benchmark; `null` only when no card exists in `benchmark-metadata.json` for the benchmark.
|
| 209 |
+
2. Every `evals/<id>.json` file has matching `benchmark_card` for the same eval (same value as the list entry β they should not disagree).
|
| 210 |
+
3. The DuckDB-emitted parquet (consumed via `lib/duckdb-data.ts`) carries `benchmark_card` in the same column for every row.
|
| 211 |
+
|
| 212 |
+
Once pipeline meets criteria, all of the following delete:
|
| 213 |
+
|
| 214 |
+
- `lib/benchmark-metadata.ts` entirely (after migrating `/api/benchmark-metadata` to read from `benchmark-metadata.json` directly or from a pipeline-emitted reverse map).
|
| 215 |
+
- `lib/benchmark-metadata-utils.ts:candidateBenchmarkKeys`, `normalizeBenchmarkKey` β but the file may need to stay for the `/api/benchmark-metadata` route's `lookupBenchmarkCard` if any client component depends on it. Audit before deleting.
|
| 216 |
+
- `lib/model-data.ts:attachBenchmarkCardToSummary` (function + 3 call sites).
|
| 217 |
+
- `lib/model-data.ts` inline retry inside `getEvalListData` (lines 1264-1282).
|
| 218 |
+
- `lib/duckdb-data.ts:attachBenchmarkCardsToEvalListItems` (function + 1 call site) + `attachBenchmarkCardToSummary` import + the wrapping call in `toEvalSummary`.
|
| 219 |
+
|
| 220 |
+
Don't try to reproduce the 4-candidate `candidateBenchmarkKeys` derivation upstream. The point of the migration is to make it unnecessary.
|
| 221 |
+
|
| 222 |
+
## Migration checklist
|
| 223 |
+
|
| 224 |
+
- [x] Spec written
|
| 225 |
+
- [x] Tests cover each rule branch (`tests/transformations/benchmark-card-attachment.test.ts`)
|
| 226 |
+
- [x] Audit script produced (`scripts/verify-benchmark-card-attachment.mjs`)
|
| 227 |
+
- [ ] Filed with pipeline owner (link)
|
| 228 |
+
- [ ] Pipeline emits `benchmark_card` inline on 100% of `eval-list.json` entries (currently 15%)
|
| 229 |
+
- [ ] Pipeline emits `benchmark_card` inline on 100% of `evals/*.json` files (currently 15%)
|
| 230 |
+
- [ ] DuckDB parquet carries `benchmark_card` column in `evalList` and `evalDetails` outputs
|
| 231 |
+
- [ ] TS code deleted; callers read pipeline field directly (7 call sites + 1 module)
|
| 232 |
+
|
| 233 |
+
## Future product decision (deferred)
|
| 234 |
+
|
| 235 |
+
The `getAllBenchmarkCards()` reverse-flatten (used by `/api/benchmark-metadata` route) consumes the same map but produces a `Record<normalizedKey, card>` with `seen.has(card)` dedup, keyed by `normalizeBenchmarkKey(card.benchmark_details.name)` (which may not match what the map keys point at if there were collisions). Whether the route should keep using the map-derived form or read from `benchmark-metadata.json` directly is a separate decision for the cleanup pass β surface to pipeline owner.
|
notes/transformations/12-instance-level-data.md
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Per-instance JSONL normalization
|
| 2 |
+
|
| 3 |
+
Drafted 2026-04-28. Migration item #7 in `notes/migration-plan.md`.
|
| 4 |
+
|
| 5 |
+
## Framing reminder
|
| 6 |
+
|
| 7 |
+
We are refactoring for UI efficiency. TS-as-is is the canonical spec for behaviour. This spec covers a per-record value transform that extracts UI-friendly strings (input/response/correctness/etc.) from rich per-sample objects emitted by the pipeline. Audited 2026-04-28 against the full corpus, the parser's many fallback branches mostly do not fire β pipeline emits a single canonical shape β but the parser preserves them as defensive scaffolding for older or different harness output formats.
|
| 8 |
+
|
| 9 |
+
**Architecture choice (cleaning-only, no SQL involvement):** the inline `instance_examples` preview (β€5 samples per result) gets normalized in pipeline; the per-result `source_url` pointing to a full JSONL dump (typically 50 samples) stays as an on-demand UI fetch, NOT pre-ingested into a parquet table. This avoids speculative pipeline ingestion work and matches the actual product need (lazy-load all samples on user click, not cross-corpus sample querying). The orphaned `fetchInstanceLevelData` (`lib/hf-data.ts:890-917`) gets re-wired to a future "show all samples" UI feature rather than deleted.
|
| 10 |
+
|
| 11 |
+
## Rule (as TS implements it today)
|
| 12 |
+
|
| 13 |
+
### `parseInstanceLevelData` (`lib/hf-data.ts:933-1043`)
|
| 14 |
+
|
| 15 |
+
Pure function: takes a JSON object, walks `instance_examples[]`, returns `SampleResult[]`.
|
| 16 |
+
|
| 17 |
+
Top-level guard:
|
| 18 |
+
- If `data` is null/non-object β return `[]`
|
| 19 |
+
- If `data.instance_examples` is array β use it
|
| 20 |
+
- Else if `data` itself is array β use it
|
| 21 |
+
- Else β `[]`
|
| 22 |
+
|
| 23 |
+
Per-example field extraction (each is a fallback chain):
|
| 24 |
+
|
| 25 |
+
**`input` (string)** β first non-empty wins:
|
| 26 |
+
1. `raw.input` is string β use as-is
|
| 27 |
+
2. `raw.input.raw` is set β `String(raw.input.raw)`
|
| 28 |
+
3. `raw.prompt` β use as-is
|
| 29 |
+
4. `raw.question` β use as-is
|
| 30 |
+
5. `raw.doc.question` β use as-is
|
| 31 |
+
6. `raw.doc` exists β `JSON.stringify(raw.doc).slice(0, 500)`
|
| 32 |
+
7. (none) β empty string
|
| 33 |
+
|
| 34 |
+
**`ground_truth` (string | undefined)** β first non-null wins:
|
| 35 |
+
1. `raw.input.reference` is array β `array.join(", ")`; else β `String(...)`
|
| 36 |
+
2. `raw.ground_truth` β `String(...)`
|
| 37 |
+
3. `raw.target` β `String(...)`
|
| 38 |
+
4. `raw.gold` β `String(...)`
|
| 39 |
+
5. `raw.doc.answer` β `String(...)`
|
| 40 |
+
6. (none) β `undefined`
|
| 41 |
+
|
| 42 |
+
**`response` (string)** β first non-empty wins:
|
| 43 |
+
1. `raw.output` is set β string-as-is or `JSON.stringify(...)`
|
| 44 |
+
2. `raw.response` β use as-is
|
| 45 |
+
3. `raw.model_output` β use as-is
|
| 46 |
+
4. `raw.answer_attribution` is non-empty array β take last element's `extracted_value` (or empty string)
|
| 47 |
+
5. `raw.messages` is non-empty array β reverse + find last assistant message β string content or stringified
|
| 48 |
+
6. `raw.filtered_resps[0][0]` β use
|
| 49 |
+
7. `raw.resps[0][0]` β use
|
| 50 |
+
8. (none) β empty string
|
| 51 |
+
|
| 52 |
+
**`is_correct` (boolean | undefined)** β first defined wins:
|
| 53 |
+
1. `raw.evaluation.is_correct` (boolean)
|
| 54 |
+
2. `raw.is_correct` (boolean)
|
| 55 |
+
3. `raw.metrics.exact_match === 1` β true; `=== 0` β false; else undefined
|
| 56 |
+
4. (none) β `undefined`
|
| 57 |
+
|
| 58 |
+
**`metadata` (object | undefined)** β merged from (in order):
|
| 59 |
+
- `raw.evaluation` (if object)
|
| 60 |
+
- `raw.performance` (if object)
|
| 61 |
+
- `raw.metadata` (if object)
|
| 62 |
+
- `raw.metrics` (if object)
|
| 63 |
+
- If merged object is empty β `undefined`
|
| 64 |
+
|
| 65 |
+
**`sample_id` (string)** β first non-null wins:
|
| 66 |
+
1. `raw.sample_id` β as-is
|
| 67 |
+
2. `raw.doc_id` β as-is
|
| 68 |
+
3. `raw.id` β as-is
|
| 69 |
+
4. (none) β `String(arrayIndex)` (positional fallback)
|
| 70 |
+
|
| 71 |
+
**`choices` (any | undefined)** β first non-null wins:
|
| 72 |
+
1. `raw.choices`
|
| 73 |
+
2. `raw.doc.choices`
|
| 74 |
+
3. (none) β `undefined`
|
| 75 |
+
|
| 76 |
+
If a row's first-pass map returns `null` (i.e. `raw` was null or non-object), it's filtered out via `.filter(s => s !== null)`.
|
| 77 |
+
|
| 78 |
+
### `fetchInstanceLevelData` (`lib/hf-data.ts:890-917`) β currently orphaned
|
| 79 |
+
|
| 80 |
+
Takes a `(url, limit?)`. Fetches the URL via `fetch()`. Splits text on newlines (filters empty lines). For each line up to `limit` (or all if no limit), tries `JSON.parse`; skips malformed lines. Wraps the parsed array as `{ instance_examples: parsed }` and passes to `parseInstanceLevelData`. Returns `SampleResult[]` or `[]` on any error.
|
| 81 |
+
|
| 82 |
+
**Active call sites: zero.** Verified by grep across `app/`, `components/`, `scripts/`, other `lib/` files. Only mention is its own declaration. Git log shows one commit ("Refresh eval cards UI and backend data flow") in its history.
|
| 83 |
+
|
| 84 |
+
The function is intended for "load more / show all samples" UI feature β pipeline ships a `source_url` per row pointing to the full JSONL (typically 50 samples), but the inline `instance_examples` preview only carries 5. The fetcher would let UI request the full set on user demand. **This UI feature has never shipped.**
|
| 85 |
+
|
| 86 |
+
## Classification
|
| 87 |
+
|
| 88 |
+
- **Unconditional normalization.** The function always runs on whatever shape is provided; never gates on a pre-existing canonical field. Pipeline-side fix: emit a canonical per-sample shape so the multi-field fallback chains become unnecessary.
|
| 89 |
+
- **Cleaning β pipeline.** Pure value transform per sample. No aggregation, no reshape, no cross-record operations. Migration target: pipeline emits canonical per-sample shape on both the inline preview AND the URL JSONL files; TS parser shrinks to direct field reads or deletes entirely.
|
| 90 |
+
- **NOT reshape.** Per the architecture choice in the framing note above, samples stay as on-demand fetches via `source_url`; no parquet `instance_samples` table, no SQL queries over samples. (If a future product feature wants cross-model sample search/filter/comparison, that's a separate reshape spec.)
|
| 91 |
+
|
| 92 |
+
## Inputs and expected outputs
|
| 93 |
+
|
| 94 |
+
### Group A β Pipeline-canonical shape (the only shape that fires in production today)
|
| 95 |
+
|
| 96 |
+
Input shape (from cache `result.instance_level_data.instance_examples[i]`):
|
| 97 |
+
|
| 98 |
+
```jsonc
|
| 99 |
+
{
|
| 100 |
+
"schema_version": "...",
|
| 101 |
+
"evaluation_id": "...",
|
| 102 |
+
"model_id": "...",
|
| 103 |
+
"evaluation_name": "...",
|
| 104 |
+
"sample_id": "...",
|
| 105 |
+
"sample_hash": "...", // sometimes present
|
| 106 |
+
"interaction_type": "multi_turn",
|
| 107 |
+
"input": { "raw": "..." }, // ALWAYS object with .raw in production
|
| 108 |
+
"output": "..." | { ... }, // sometimes
|
| 109 |
+
"messages": [{ role: "...", content: "..." }, ...], // typically present
|
| 110 |
+
"answer_attribution": [..., { "extracted_value": "..." }], // typically present
|
| 111 |
+
"evaluation": { "is_correct": true|false, ... }, // ALWAYS present
|
| 112 |
+
"performance": { ... },
|
| 113 |
+
"metadata": { ... },
|
| 114 |
+
"token_usage": { ... },
|
| 115 |
+
"error": null | "...",
|
| 116 |
+
"hierarchy": [...]
|
| 117 |
+
}
|
| 118 |
+
```
|
| 119 |
+
|
| 120 |
+
Expected output (`SampleResult`):
|
| 121 |
+
|
| 122 |
+
| Output field | Source path that fires | Notes |
|
| 123 |
+
|---|---|---|
|
| 124 |
+
| `sample_id` | `raw.sample_id` (100% of production) | always present |
|
| 125 |
+
| `input` | `raw.input.raw` (100%) | branch #2 in the chain |
|
| 126 |
+
| `ground_truth` | `raw.input.reference` (100%) | branch #1 |
|
| 127 |
+
| `response` | `raw.answer_attribution` (97.31%) OR `raw.messages` (2.49%) OR `raw.output` (0.20%) | branches #4, #5, #1 |
|
| 128 |
+
| `is_correct` | `raw.evaluation.is_correct` (100%) | branch #1 |
|
| 129 |
+
| `choices` | `undefined` (100%) | no branch fires; field is unset in production |
|
| 130 |
+
| `metadata` | merged from `raw.evaluation`, `raw.performance`, `raw.metadata`, `raw.metrics` (always at least 2 of 4 present) | merged object |
|
| 131 |
+
|
| 132 |
+
### Group B β Defensive fallback branches (zero firing rate in current production)
|
| 133 |
+
|
| 134 |
+
| Branch | Output field | Production hits | Origin (presumed) |
|
| 135 |
+
|---|---|---|---|
|
| 136 |
+
| `raw.input` (string) | input | 0 | older harness shapes |
|
| 137 |
+
| `raw.prompt` | input | 0 | lm-eval-harness |
|
| 138 |
+
| `raw.question` | input | 0 | other harnesses |
|
| 139 |
+
| `raw.doc.question` | input | 0 | HELM-style |
|
| 140 |
+
| `raw.doc` (JSON.stringify) | input | 0 | last-resort |
|
| 141 |
+
| `raw.ground_truth` | ground_truth | 0 | older shapes |
|
| 142 |
+
| `raw.target` | ground_truth | 0 | classification benchmarks |
|
| 143 |
+
| `raw.gold` | ground_truth | 0 | older lm-eval |
|
| 144 |
+
| `raw.doc.answer` | ground_truth | 0 | HELM-style |
|
| 145 |
+
| `raw.response` | response | 0 | older shapes |
|
| 146 |
+
| `raw.model_output` | response | 0 | older shapes |
|
| 147 |
+
| `raw.filtered_resps[0][0]` | response | 0 | lm-eval-harness format |
|
| 148 |
+
| `raw.resps[0][0]` | response | 0 | lm-eval-harness format |
|
| 149 |
+
| `raw.is_correct` | is_correct | 0 | flat shape |
|
| 150 |
+
| `raw.metrics.exact_match` | is_correct | 0 | metric-based correctness |
|
| 151 |
+
| `raw.doc_id` | sample_id | 0 | HELM-style |
|
| 152 |
+
| `raw.id` | sample_id | 0 | generic |
|
| 153 |
+
| index fallback | sample_id | 0 | last-resort |
|
| 154 |
+
| `raw.choices` | choices | 0 | multiple-choice |
|
| 155 |
+
| `raw.doc.choices` | choices | 0 | HELM multiple-choice |
|
| 156 |
+
|
| 157 |
+
These branches exist for shapes the pipeline currently does not emit. **Preserve verbatim** until pipeline-side guarantees the canonical shape across all data sources.
|
| 158 |
+
|
| 159 |
+
### Group C β `fetchInstanceLevelData` JSONL parsing edge cases
|
| 160 |
+
|
| 161 |
+
| Input | Behavior |
|
| 162 |
+
|---|---|
|
| 163 |
+
| URL returns `!res.ok` (404, 500, etc.) | returns `[]` (no throw) |
|
| 164 |
+
| URL throws (network error) | logs warning to console, returns `[]` |
|
| 165 |
+
| Empty body | splits to `[]`, returns `[]` |
|
| 166 |
+
| Body with empty lines | `.filter(line => line.trim())` strips them |
|
| 167 |
+
| Body with malformed JSON line | swallowed in inner try-catch; line skipped, processing continues |
|
| 168 |
+
| `limit=0` or `limit=undefined` | parses ALL lines |
|
| 169 |
+
| `limit > lines.length` | parses all lines (capped via `Math.min`) |
|
| 170 |
+
|
| 171 |
+
## Current TS implementation
|
| 172 |
+
|
| 173 |
+
| Concern | Location | Notes |
|
| 174 |
+
|---|---|---|
|
| 175 |
+
| `parseInstanceLevelData` | `lib/hf-data.ts:933-1043` | The parser; ~110 lines |
|
| 176 |
+
| `fetchInstanceLevelData` | `lib/hf-data.ts:890-917` | URL fetcher; orphaned (zero callers) |
|
| 177 |
+
| Active call site | `lib/hf-data.ts:1273` (inside `flattenHierarchyNode`) | `parseInstanceLevelData(result.instance_level_data)` β `inlineSamples` |
|
| 178 |
+
| Internal call site | `lib/hf-data.ts:912` | inside `fetchInstanceLevelData` itself, recursive call to the parser |
|
| 179 |
+
| `SampleResult` type | `lib/benchmark-schema.ts:135` | Output shape definition |
|
| 180 |
+
| Output field | `BenchmarkEvaluation.detailed_evaluation_results_per_samples` | `lib/benchmark-schema.ts:33` |
|
| 181 |
+
|
| 182 |
+
### Caller chain for `parseInstanceLevelData`
|
| 183 |
+
|
| 184 |
+
`getModelSummaryById` (lib/model-data.ts:1490+) β `flattenModelEvaluations` β `flattenHierarchyNode` (lib/hf-data.ts:1273) β `parseInstanceLevelData(result.instance_level_data)` β set as `inlineSamples` on each variant bucket β propagated to `BenchmarkEvaluation.detailed_evaluation_results_per_samples`.
|
| 185 |
+
|
| 186 |
+
UI consumers (read `data.detailed_evaluation_results_per_samples`):
|
| 187 |
+
- `components/benchmark-detail.tsx:3869` β random sample for preview block
|
| 188 |
+
- `components/benchmark-detail.tsx:4174-4216` β sample preview UI in benchmark detail
|
| 189 |
+
- `components/benchmark-detail.tsx:4982` β variant-level sample availability check
|
| 190 |
+
- `components/benchmark-detail.tsx:5284-5286` β variant sample picker
|
| 191 |
+
- `components/benchmark-detail.tsx:5569-5608` β sample preview list with INSTANCE_PREVIEW_LIMIT and "see all" expansion
|
| 192 |
+
|
| 193 |
+
### Caller chain for `fetchInstanceLevelData`
|
| 194 |
+
|
| 195 |
+
None. Function is exported and unreached. Preserved with the intent that a future "show all samples" UI consumer wires up to it.
|
| 196 |
+
|
| 197 |
+
## Pipeline status
|
| 198 |
+
|
| 199 |
+
### Side-by-side comparison
|
| 200 |
+
|
| 201 |
+
| Aspect | TS (this spec) | Pipeline today | Result for users |
|
| 202 |
+
|---|---|---|---|
|
| 203 |
+
| Inline preview shape | parser handles many variants | emits ONE canonical shape (`input.raw`, `evaluation.is_correct`, etc.) | parser's fallback branches almost all dead |
|
| 204 |
+
| URL JSONL shape | same parser handles | emits IDENTICAL canonical shape (verified by sampling one URL on 2026-04-28) | parser would work the same on URL data |
|
| 205 |
+
| Inline preview size | parser doesn't care | always exactly 5 samples per `instance_examples` array | UI capped at 5 today |
|
| 206 |
+
| Total samples per row | n/a | typically 50 (per `instance_count` field), one outlier 18 | only 10% accessible to UI today |
|
| 207 |
+
| URL-fetch use case | `fetchInstanceLevelData` exists | `source_url` always emitted | dead code on TS side; no UI consumer |
|
| 208 |
+
|
| 209 |
+
### Concrete worked example with quantified scope
|
| 210 |
+
|
| 211 |
+
Audited 2026-04-28 against `.cache/hf-data/`. Verified by `scripts/verify-instance-level-data.mjs`.
|
| 212 |
+
|
| 213 |
+
**Prevalence:**
|
| 214 |
+
- Total model files: 5,830
|
| 215 |
+
- Files with any `instance_level_data`: **55 (0.94%)**
|
| 216 |
+
- Total `(metric Γ model_result)` rows: 86,183
|
| 217 |
+
- Result rows with `instance_level_data`: **712 (0.83%)**
|
| 218 |
+
- Total inline preview examples (sum of `instance_examples.length`): **3,532** (always β€5 per row)
|
| 219 |
+
- Total full samples (sum of `instance_count`): **66,057** (full set available via `source_url`, not loaded today; ~19Γ larger than what UI currently shows)
|
| 220 |
+
|
| 221 |
+
**ild-level shape uniformity (712/712 rows):**
|
| 222 |
+
- Top-level keys are always exactly `{interaction_type, instance_count, source_url, instance_examples}`
|
| 223 |
+
- `interaction_type` is always `"multi_turn"` (no single_turn samples in cache)
|
| 224 |
+
|
| 225 |
+
**Per-example branch firing rates (3,532 examples):**
|
| 226 |
+
- `input`: `input.raw` 100%
|
| 227 |
+
- `ground_truth`: `input.reference` 100%
|
| 228 |
+
- `response`: `answer_attribution` 97.31%, `messages` 2.49%, `output` 0.20%
|
| 229 |
+
- `is_correct`: `evaluation.is_correct` 100%
|
| 230 |
+
- `sample_id`: `sample_id` 100%
|
| 231 |
+
- `choices`: nothing (always undefined)
|
| 232 |
+
|
| 233 |
+
The 7-branch input chain, 5-branch ground_truth chain, 4-branch is_correct chain, 4-branch sample_id chain, 2-branch choices chain are **defensive scaffolding** for shapes the pipeline does not currently emit. The 7-branch response chain has 3 active sub-branches.
|
| 234 |
+
|
| 235 |
+
**URL JSONL shape verification:** sampled one source_url (`anthropic__anthropic-claude-3-7-sonnet/swe_bench_verified_mini_...`); first line had identical 18 keys to the inline `instance_examples[0]`, with `input.raw` and `evaluation.is_correct` in expected paths. Pipeline emits the same canonical shape on both inline and URL paths.
|
| 236 |
+
|
| 237 |
+
## Notes for pipeline implementer
|
| 238 |
+
|
| 239 |
+
- The pipeline already emits a canonical per-sample shape consistently. **No structural change needed to current emission.** The migration is to make this shape an explicit guarantee, not to change what's being emitted.
|
| 240 |
+
- Suggested guarantee: every `instance_examples[i]` (inline AND in JSONL at `source_url`) has at minimum `{sample_id, input.raw, input.reference?, evaluation.is_correct, answer_attribution? || messages?, metadata?}`.
|
| 241 |
+
- Once that guarantee is documented and verified, the TS parser shrinks dramatically: extract `raw.input.raw`, `raw.input.reference`, `raw.evaluation.is_correct`, `raw.sample_id` as direct field reads. The response field still needs the 3-branch fallback (answer_attribution β messages β output) until pipeline emits a single normalized `response` field.
|
| 242 |
+
- **Do NOT pre-ingest the URL JSONL into pipeline parquet** (per the architecture choice). The runtime UI fetches `source_url` on demand; this is the orphaned `fetchInstanceLevelData`'s intended use. The benefit of pre-ingestion (cross-corpus SQL queries over samples) is speculative; defer until a product feature demands it.
|
| 243 |
+
- The "shape uniformity" finding (712/712 rows have identical ild-level keys; all are `multi_turn`) suggests the pipeline already enforces the canonical shape. Worth documenting in the pipeline contract test (`tests/pipeline-contract.test.ts`).
|
| 244 |
+
|
| 245 |
+
## Migration checklist
|
| 246 |
+
|
| 247 |
+
- [x] Spec written
|
| 248 |
+
- [x] Tests cover each rule branch (`tests/transformations/instance-level-data.test.ts`)
|
| 249 |
+
- [x] Audit script (`scripts/verify-instance-level-data.mjs`)
|
| 250 |
+
- [ ] Filed with pipeline owner with the spec + tests + audit script as acceptance criterion
|
| 251 |
+
- [ ] Pipeline contract: explicit guarantee of canonical per-sample shape (Tier A test asserting `every instance_example has input.raw, sample_id, evaluation.is_correct`)
|
| 252 |
+
- [ ] TS deleted: `parseInstanceLevelData` shrinks to direct field reads (~10 lines instead of 110), or fully deleted if pipeline emits already-flat normalized records. `fetchInstanceLevelData` stays orphaned-but-preserved for the future "show all samples" UI feature, OR is wired up if that feature ships.
|
| 253 |
+
|
| 254 |
+
## Future product decisions (deferred)
|
| 255 |
+
|
| 256 |
+
- **"Show all samples" UI feature** β would un-orphan `fetchInstanceLevelData` and let users see the full 50-sample set instead of just the 5-sample preview. Lazy fetch on user click. This is the concrete capability the URL-fetch architecture supports; the spec assumes it's a product roadmap item, not committed scope.
|
| 257 |
+
- **Cross-model sample querying / search / filter** β would require `instance_samples.parquet` and SQL queries (the alternative architecture I initially proposed). Out of scope for this spec; revisit if/when product asks.
|
| 258 |
+
- **Single-turn samples** β pipeline currently only emits `interaction_type: multi_turn`. If pipeline starts emitting single_turn shapes that exercise dormant parser branches (e.g. flat `input` strings, `prompt`/`question` fields), the spec's "100% canonical shape" claim breaks and the parser fallbacks become live again.
|
notes/transformations/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Transformations registry
|
| 2 |
+
|
| 3 |
+
Canonical specs for data transformations the Next.js app currently performs that should ultimately live in the upstream pipeline (`eval_cards_backend_pipeline`).
|
| 4 |
+
|
| 5 |
+
## Framing
|
| 6 |
+
|
| 7 |
+
The TS code in `lib/` does meaningful data transformation work β token canonicalization, variant grouping, source-metadata defaults, category inference, score normalization, etc. β most of which the Python pipeline does not yet do (or does differently). For this migration:
|
| 8 |
+
|
| 9 |
+
- **TS is the current source of truth** for what these transformations should produce. The TS rules have been refined over time against real evaluation data; they encode product decisions.
|
| 10 |
+
- **Each transformation is a candidate to move upstream.** The pipeline owns data shape and content; doing the transformation there means every downstream consumer (not just this Next.js app) gets the canonical form.
|
| 11 |
+
- **The migration path** for each item: write a spec here β write executable tests sourced from the spec β hand the spec + tests to the pipeline owner β verify pipeline output matches across the full corpus β delete the TS implementation.
|
| 12 |
+
- **Pre-processing in this repo (e.g. a one-shot Python script run at data-ingestion time) is allowed but not required.** Default is "transformation stays in TS until pipeline catches up." Front-load only when the TS implementation is so brittle or expensive that on-the-fly is intolerable.
|
| 13 |
+
|
| 14 |
+
## One thing to watch: defaults vs unconditional normalization
|
| 15 |
+
|
| 16 |
+
For each rule, classify in the spec how it interacts with pre-existing data:
|
| 17 |
+
|
| 18 |
+
- **Default-only (don't overwrite when pipeline already emits a value)**: e.g. "if `source_metadata.evaluator_relationship` is missing, default to `other`". Pipeline-side fix is to emit the default upstream rather than letting consumers fill in.
|
| 19 |
+
- **Unconditional normalization (always overwrite)**: e.g. "lowercase the `v` in version tokens regardless of upstream input". Pipeline-side fix is to apply the rule before emitting; downstream consumers should not need to re-derive.
|
| 20 |
+
|
| 21 |
+
Mis-classifying these is the failure mode that bit us in Phase 3 (treating a normalization rule as if it were a default, ending up overwriting category data). Each spec must call out which kind it is.
|
| 22 |
+
|
| 23 |
+
## Where it belongs: cleaning vs reshape
|
| 24 |
+
|
| 25 |
+
A second classification each spec should call out β *what kind of work* is this transformation?
|
| 26 |
+
|
| 27 |
+
- **Cleaning / standardization** (changes a value): license shorthand, developer name canonicalization, identity tokens, timestamp format, category labels. These belong in the pipeline; the per-item workflow above is built for them. Default-vs-normalization (the section above) is the sub-question.
|
| 28 |
+
- **Reshape / dedup / aggregate** (computes a derived view): variant dedup with "freshest wins", per-category counts, top-scores ranking, hierarchy flattening. These belong in **DuckDB SQL** β either materialized into pipeline parquet (the answer is the same for every consumer; emit it pre-computed) or expressed as a query at request time (consumers slice differently; let SQL do the work). The TS implementation here is scaffolding.
|
| 29 |
+
|
| 30 |
+
For the reshape class the migration target shape is itself a design choice β capture the *operation* (e.g. "max `retrieved_timestamp` wins per variant key, take its `source_metadata` along") and flag it for the parquet-schema / SQL conversation rather than mechanically translating the TS code line-by-line. See `notes/migration-plan.md` Β§ "Data direction" for the full framing.
|
| 31 |
+
|
| 32 |
+
## Index
|
| 33 |
+
|
| 34 |
+
| # | Transformation | Spec | Tests | Pipeline status | Migration item |
|
| 35 |
+
|---|---|---|---|---|---|
|
| 36 |
+
| 01 | Model identity canonicalization | [01-identity-canonicalization.md](01-identity-canonicalization.md) | [tests/transformations/identity-canonicalization.test.ts](../../tests/transformations/identity-canonicalization.test.ts) | partial (model_family_id β
, model_family_name β on 1,260 cards) | #1 |
|
| 37 |
+
| 02 | Setup-alias variant merging | [02-setup-alias-merging.md](02-setup-alias-merging.md) | [tests/transformations/setup-alias-merging.test.ts](../../tests/transformations/setup-alias-merging.test.ts) | not started (cache file shows pre-merge state; runtime normalizer is what merges) | #2 |
|
| 38 |
+
| 03 | License string normalization | [03-license-normalization.md](03-license-normalization.md) | [tests/transformations/license-normalization.test.ts](../../tests/transformations/license-normalization.test.ts) | not implemented; pipeline emits free-text `data_licensing` only | #18 |
|
| 39 |
+
| 04 | Dataset URL synthesis | [04-dataset-url-synthesis.md](04-dataset-url-synthesis.md) | [tests/transformations/dataset-url-synthesis.test.ts](../../tests/transformations/dataset-url-synthesis.test.ts) | not implemented; `dataset_url` field never populated in prod (564/587 use `url[0]`, 22/587 use `hf_repo` template) | #20 |
|
| 40 |
+
| 05 | Slug candidate generation (file lookup) | [05-slug-candidates.md](05-slug-candidates.md) | [tests/transformations/slug-candidates.test.ts](../../tests/transformations/slug-candidates.test.ts) | not implemented; 39% of model lookups + 43% of developer lookups need a non-zero retry position in production | #19 |
|
| 41 |
+
| 06 | Developer name canonicalization | [06-developer-name-canonicalization.md](06-developer-name-canonicalization.md) | [tests/transformations/developer-name-canonicalization.test.ts](../../tests/transformations/developer-name-canonicalization.test.ts) | not implemented; pipeline emits raw `developer` string. TS map covers 1.8% of devs / 11.9% of cards; title-case fallback fires on 55.6% of devs / 48.4% of cards | #9 |
|
| 42 |
+
| 07 | Timestamp normalization | [07-timestamp-normalization.md](07-timestamp-normalization.md) | [tests/transformations/timestamp-normalization.test.ts](../../tests/transformations/timestamp-normalization.test.ts) | not implemented; production is 99.99% unix-seconds-strings (86,178/86,183) + 5 ISO datetime. Three different TS variants exist with subtly different semantics β pipeline canonicalization to ISO 8601 collapses them | #13 |
|
| 43 |
+
| 08 | Benchmark display names | [08-benchmark-display-names.md](08-benchmark-display-names.md) | [tests/transformations/benchmark-display-names.test.ts](../../tests/transformations/benchmark-display-names.test.ts) | not implemented; 30-entry hand-curated map covers ~74% of distinct suite keys but only ~3% of distinct `benchmark` values; ~97% fall through to a `humanizeToken` fallback that mangles acronyms (`MMLU-PRO` β `MMLU PRO`, `helm_air_bench` β `Helm Air Bench`). A second functionally-dead duplicate exists in `lib/eval-processing.ts` with substring-match semantics that disagree with the active path. | #8 |
|
| 44 |
+
| 09 | Metric display name expansion | [09-metric-display-name-expansion.md](09-metric-display-name-expansion.md) | [tests/transformations/metric-display-name-expansion.test.ts](../../tests/transformations/metric-display-name-expansion.test.ts) | defensive scaffolding; both rules fire 0 times against current corpus (0/86,183 result rows for generic-name expansion; 0/587 eval-list entries for prefersBenchmarkName heuristic) | #10 |
|
| 45 |
+
| 10 | Params billions parsing | [10-params-parsing.md](10-params-parsing.md) | [tests/transformations/params-parsing.test.ts](../../tests/transformations/params-parsing.test.ts) | partial; `model-cards.json.params_billions` is clean number for 87% of cards (5072/5830); per-row `additional_details.params_billions` is string for 31.7% of rows (27,361/86,183), 47.2% (40,648) have no resolved value at all. Five different TS parsers diverge on edge cases β Variant C's "context-window beats param count" quirk fires on 472 rows (0.55%, names like `Yi-1.5-34B-32K`) | #12 |
|
| 46 |
+
| 11 | Benchmark-card attachment (per-eval lookup join) | [11-benchmark-card-attachment.md](11-benchmark-card-attachment.md) | [tests/transformations/benchmark-card-attachment.test.ts](../../tests/transformations/benchmark-card-attachment.test.ts) | partial; pipeline inlines `benchmark_card` on 88/587 evals (15%), other 499 fall through to runtime retry. Of those, 10 hit at position 0 and 489 miss entirely (most lack any matching card in `benchmark-metadata.json`). Map-build first-write-wins silently drops the `helm_instruct` card (different content from kept `helm_capabilities`, both named "HELM"); 29/83 cards orphaned | #17 |
|
| 47 |
+
| 12 | Per-instance JSONL normalization | [12-instance-level-data.md](12-instance-level-data.md) | [tests/transformations/instance-level-data.test.ts](../../tests/transformations/instance-level-data.test.ts) | pipeline emits canonical shape; parser's defensive fallback branches mostly dead (input/ground_truth/is_correct/sample_id all 100% via the canonical path; response splits 97.31% answer_attribution / 2.49% messages / 0.20% output). 712/86,183 result rows (0.83%) have inline samples; full sets (~35k samples) sit behind `source_url` and are accessible only via the orphaned `fetchInstanceLevelData` (no current UI consumer). | #7 |
|
| 48 |
+
|
| 49 |
+
(More entries land as we work each migration item. See `notes/migration-plan.md` for the full backlog.)
|
| 50 |
+
|
| 51 |
+
## File format
|
| 52 |
+
|
| 53 |
+
Each spec follows the same structure so pipeline owner can read them uniformly. Template:
|
| 54 |
+
|
| 55 |
+
```markdown
|
| 56 |
+
# <Transformation name>
|
| 57 |
+
|
| 58 |
+
## Rule
|
| 59 |
+
[Plain-English description of what the transformation does]
|
| 60 |
+
|
| 61 |
+
## Classification
|
| 62 |
+
- [ ] Default-only (do not overwrite when value present)
|
| 63 |
+
- [ ] Unconditional normalization (always apply)
|
| 64 |
+
[explanation]
|
| 65 |
+
|
| 66 |
+
- [ ] Cleaning / standardization β pipeline (changes a value's content; use per-item workflow)
|
| 67 |
+
- [ ] Reshape / dedup / aggregate β DuckDB SQL (computes a derived view; capture operation, flag for parquet-schema/SQL conversation)
|
| 68 |
+
[explanation β if both halves apply, explain the split]
|
| 69 |
+
|
| 70 |
+
## Inputs and expected outputs
|
| 71 |
+
[Table: input | expected output | notes / which rule branch this hits]
|
| 72 |
+
|
| 73 |
+
## Current TS implementation
|
| 74 |
+
- [file:line references]
|
| 75 |
+
- [key helpers/constants]
|
| 76 |
+
|
| 77 |
+
## Pipeline status
|
| 78 |
+
[Per-rule status against full live cache: matches / disagrees / not implemented]
|
| 79 |
+
|
| 80 |
+
## Divergences detected
|
| 81 |
+
[Concrete examples of pipeline-vs-TS disagreement, with row counts]
|
| 82 |
+
|
| 83 |
+
## Migration checklist
|
| 84 |
+
- [ ] Spec written
|
| 85 |
+
- [ ] Tests cover each rule branch
|
| 86 |
+
- [ ] Filed with pipeline owner (link)
|
| 87 |
+
- [ ] Pipeline emits matching values across full corpus
|
| 88 |
+
- [ ] TS code deleted; callers read pipeline fields directly
|
| 89 |
+
```
|
notes/transformations/reshape-design.md
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Reshape-class operations: parquet schema + SQL plan
|
| 2 |
+
|
| 3 |
+
Drafted 2026-04-28. Synthesis of 5 per-item operation catalogs in `notes/transformations/reshape/` plus the reshape halves of #2 (setup-alias merging) and #13 (timestamp normalization). Reads against the principle in `notes/migration-plan.md` Β§ "Data direction".
|
| 4 |
+
|
| 5 |
+
This doc is **a discussion artifact for the pipeline owner**, not a unilateral architecture commitment. It proposes a parquet schema delta, sketches the SQL each reshape becomes, recommends materialize-vs-query-time per item, and orders the dependency graph. Pipeline owner has authority to push back on any of it.
|
| 6 |
+
|
| 7 |
+
## Inventory
|
| 8 |
+
|
| 9 |
+
| Item | Operation | Per-item doc | Recommendation |
|
| 10 |
+
|---|---|---|---|
|
| 11 |
+
| #2 (reshape half) | Variant bucket reduction (`GROUP BY variant_key, MAX(retrieved_timestamp)`) | `02-setup-alias-merging.md` Β§ "Dual class" | Pipeline emits already-deduped rows in `model_results` parquet (materialize-by-emission) |
|
| 12 |
+
| #3 | Hierarchy flatten + family summary | `reshape/03-hierarchy-flatten.md` | Materialize Steps 1+2 (flat `model_results`); query-time Step 3 (family rollup, category bucketing) |
|
| 13 |
+
| #5 | Composite eval rollup (`/evals/aggregate__<suite>`) | `reshape/05-composite-eval-rollup.md` | Materialize per-(suite, model) rollup table |
|
| 14 |
+
| #6 | Matrix leaderboard synthesis (`/evals/matrix__<suite>`) | `reshape/06-matrix-leaderboard.md` | Materialize wide matrix; query-time row filter |
|
| 15 |
+
| #13 (reshape half) | Timestamp comparison / dedup | `07-timestamp-normalization.md` Β§ "Classification" | SQL inline (`MAX(retrieved_timestamp)`, `ROW_NUMBER()`); no materialization needed |
|
| 16 |
+
| #14 | Score summary stats (per-eval aggregations) | `reshape/14-score-summary-stats.md` | Materialize 9 columns into `eval-list.json` extension |
|
| 17 |
+
| #16 | Per-category benchmark counts (`COUNT(DISTINCT benchmark) GROUP BY model, category`) | `reshape/16-per-category-counts.md` | Materialize `category_stats` per model (current TS is *known wrong*) |
|
| 18 |
+
|
| 19 |
+
## Required parquet schema delta
|
| 20 |
+
|
| 21 |
+
The current schema (`scripts/pipeline.py:write_experimental_parquet_table`) is 11 typed metadata columns + `payload_json VARCHAR`. SQL can route on metadata columns but cannot see inside the blob. **Every reshape item above is bottlenecked on the same schema delta: promote nested per-result fields to a relational table.**
|
| 22 |
+
|
| 23 |
+
### Proposed: `model_results.parquet` (one row per metric Γ model_result, post-#2 dedup)
|
| 24 |
+
|
| 25 |
+
The unifying schema across #3, #5, #6, #14, #16 needs roughly the same columns. Designing it once unlocks all of them.
|
| 26 |
+
|
| 27 |
+
```
|
| 28 |
+
-- Routing / partition keys
|
| 29 |
+
model_family_id VARCHAR -- joins to model_summaries.parquet
|
| 30 |
+
model_route_id VARCHAR -- joins to model_summaries.parquet
|
| 31 |
+
developer VARCHAR -- already a metadata column on model_summaries
|
| 32 |
+
|
| 33 |
+
-- Eval / benchmark identity
|
| 34 |
+
eval_summary_id VARCHAR -- already a metadata column
|
| 35 |
+
benchmark VARCHAR
|
| 36 |
+
benchmark_family_key VARCHAR -- already a metadata column on eval_summaries
|
| 37 |
+
benchmark_family_name VARCHAR
|
| 38 |
+
benchmark_parent_key VARCHAR
|
| 39 |
+
benchmark_parent_name VARCHAR
|
| 40 |
+
benchmark_leaf_key VARCHAR
|
| 41 |
+
benchmark_leaf_name VARCHAR
|
| 42 |
+
display_name VARCHAR -- post-inheritance from buildFlattenHierarchyContext
|
| 43 |
+
canonical_display_name VARCHAR
|
| 44 |
+
benchmark_display_name VARCHAR -- post #8 cleaning; the user-facing label
|
| 45 |
+
slice_key VARCHAR
|
| 46 |
+
slice_name VARCHAR
|
| 47 |
+
category_key VARCHAR -- raw "agentic"/"reasoning"/etc.; mapped form derived via SQL CASE or pipeline-emit (see open question 7); #11 affects accuracy
|
| 48 |
+
|
| 49 |
+
-- Metric identity
|
| 50 |
+
metric_summary_id VARCHAR
|
| 51 |
+
metric_key VARCHAR
|
| 52 |
+
metric_name VARCHAR
|
| 53 |
+
metric_display_name VARCHAR -- post #10 cleaning
|
| 54 |
+
metric_canonical_display_name VARCHAR
|
| 55 |
+
metric_unit VARCHAR
|
| 56 |
+
min_score DOUBLE -- from metric_config; needed by #5, #14
|
| 57 |
+
max_score DOUBLE -- from metric_config; needed by #5, #14
|
| 58 |
+
lower_is_better BOOLEAN -- from metric_config; needed by #5, #6, #14
|
| 59 |
+
evaluation_description VARCHAR -- from metric_config; needed by #5
|
| 60 |
+
|
| 61 |
+
-- Per-result fields
|
| 62 |
+
evaluation_id VARCHAR -- result-level
|
| 63 |
+
raw_model_id VARCHAR
|
| 64 |
+
model_id VARCHAR
|
| 65 |
+
model_result_route_id VARCHAR
|
| 66 |
+
model_name VARCHAR
|
| 67 |
+
score DOUBLE
|
| 68 |
+
retrieved_timestamp VARCHAR -- ISO 8601 once #13 cleaning ships; today unix-seconds-string
|
| 69 |
+
sample_size BIGINT
|
| 70 |
+
has_generation_config BOOLEAN -- presence flag for #14
|
| 71 |
+
detailed_evaluation_results VARCHAR
|
| 72 |
+
instance_level_data JSON
|
| 73 |
+
source_data JSON -- inherited; could be promoted further if hot
|
| 74 |
+
|
| 75 |
+
-- Source metadata (promoted from struct to flat columns; needed by #5, #6, #14)
|
| 76 |
+
source_metadata.evaluator_relationship VARCHAR -- enum: first_party / third_party / other
|
| 77 |
+
source_metadata.source_type VARCHAR
|
| 78 |
+
source_metadata.source_name VARCHAR
|
| 79 |
+
source_metadata.source_organization_name VARCHAR
|
| 80 |
+
|
| 81 |
+
-- Variant identity (depends on #2 cleaning)
|
| 82 |
+
variant_key VARCHAR -- pre-resolved per #2 setup-alias normalization
|
| 83 |
+
variant_label VARCHAR
|
| 84 |
+
|
| 85 |
+
-- Cleaning-driven derived columns (depend on cleaning items)
|
| 86 |
+
benchmark_card JSON -- depends on migration #17 inlining (specced as `notes/transformations/11-benchmark-card-attachment.md`)
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
### Sidecar tables
|
| 90 |
+
|
| 91 |
+
```
|
| 92 |
+
-- composite_membership.parquet β one row per (suite_key, sub_eval_summary_id), drives #5
|
| 93 |
+
suite_key VARCHAR
|
| 94 |
+
eval_summary_id VARCHAR
|
| 95 |
+
suite_display_name VARCHAR -- from eval-hierarchy.json family display_name; #8 affects this
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
### What stays in `payload_json`
|
| 99 |
+
|
| 100 |
+
After this delta, `payload_json` still carries the nested original shape for debugging / migration parity. Once parity is verified per item, fields consumed only via the relational columns can be dropped from the blob.
|
| 101 |
+
|
| 102 |
+
### Pipeline-side family-membership filter
|
| 103 |
+
|
| 104 |
+
Important: pipeline should apply the `belongsToModelFamily` filter at emission time so every row in `model_results.parquet` is already correctly assigned to its `model_family_id`. SQL then runs `WHERE model_family_id = ?` and the TS family-membership logic disappears. (Currently the filter happens in TS β `lib/hf-data.ts:1110-1131`. Lifting it upstream eliminates the ~6-line filter from the SQL replacement and avoids the "raw_model_ids set βͺ variant.raw_model_ids βͺ model_info.id βͺ model_family_id" CTE.)
|
| 105 |
+
|
| 106 |
+
## Per-item SQL sketches
|
| 107 |
+
|
| 108 |
+
Each operation's full SQL is in its catalog file. This section gives one-paragraph summaries to read alongside the inventory.
|
| 109 |
+
|
| 110 |
+
### #3 hierarchy flatten β Steps 1+2 materialized, Step 3 query-time
|
| 111 |
+
Tree walk β flat list with variant bucket reduction = **what `model_results.parquet` IS**. Pipeline does it once at build. Consumer Step 3 (family summary, category bucketing) becomes a ~10-line SQL query against `model_results` per request. Removes `flattenHierarchyNode` (~150 lines), `createModelFamilySummary` (~80 lines), `createModelSummary` (~65 lines), `getAggregatedVariantDescriptor`, `sortVariants`, `buildVariantLookup`, `resolveVariantMeta`, `belongsToModelFamily`, `buildModelInfoForVariant`. See `reshape/03-hierarchy-flatten.md` for the full SQL.
|
| 112 |
+
|
| 113 |
+
### #5 composite eval rollup β fully materialize
|
| 114 |
+
Pipeline pre-computes per-(suite, model) rows + suite-level summary into `composite_eval_rollup.parquet`. Eliminates the 2-21 (outlier 471) `fetchHFEvalDetail` calls per page view. Removes `aggregateBenchmarkSummaries` (168 lines) and the fan-out loop in `getEvalSummaryById`. The rollup SQL itself (per-eval normalize β per-model average β suite stats) is in `reshape/05-composite-eval-rollup.md`.
|
| 115 |
+
|
| 116 |
+
### #6 matrix leaderboard β materialize wide matrix, query-time row filter
|
| 117 |
+
Pipeline pre-computes per-(suite, model_id) β values map + reconciled metadata using DuckDB `PIVOT` semantics. Eliminates the per-cell walk (median 6, max 471 sub-evals Γ ~91 models = ~10β΄ tuples per request for `llm_stats`). Row-filter knobs (developer, source_type, top-K) stay query-time SQL β emerging UI need. Removes `buildSingleMetricSuiteMatrixSummary` (~165 lines).
|
| 118 |
+
|
| 119 |
+
### #13 timestamp comparison β SQL inline, no separate materialization
|
| 120 |
+
Once cleaning #13 lands ISO 8601 timestamps, the comparisons embedded in #3, #5, #6, #14 collapse to `MAX(retrieved_timestamp)` and `ROW_NUMBER() OVER (... ORDER BY retrieved_timestamp DESC)`. The 3 TS normalizers + 8 callers all delete as a unit. No separate parquet artifact.
|
| 121 |
+
|
| 122 |
+
### #14 score summary stats β materialize 9 columns into eval-list extension
|
| 123 |
+
Pipeline emits all 9 aggregated columns (`models_count`, `avg_score`, `avg_score_norm`, `best_model`, `worst_model`, `evaluator_names`, `source_types`, `latest_source_name`, `third_party_ratio`, `missing_generation_config_count`) per eval. Pattern matches today's existing `eval-list.json` materialization of `models_count` + `top_score`. SQL is textbook `GROUP BY eval_summary_id` + arithmetic + set aggregations. Removes the finalisation loop in `groupEvaluationsByBenchmark` (~45 lines) and the aggregation block in `hfEvalDetailToSummary` (~45 lines).
|
| 124 |
+
|
| 125 |
+
### #16 per-category counts β materialize `category_stats` per model
|
| 126 |
+
Smallest, clearest reshape. Pipeline emits `category_stats: Record<Category, number>` per model via `COUNT(DISTINCT benchmark_family_key) GROUP BY model_route_id, category`. Replaces the fake `Math.floor(total / categories.length)` distribution in `lib/model-data.ts:369-379` AND the real-but-different distinct-count in `lib/eval-processing.ts:653-666`. Same model gets the same answer everywhere after the migration. **Blocked on #11 category accuracy** β without it, 84% of evals collapse into one `other` bucket.
|
| 127 |
+
|
| 128 |
+
## Materialize vs query-time β rationale per item
|
| 129 |
+
|
| 130 |
+
| Item | Recommendation | Why |
|
| 131 |
+
|---|---|---|
|
| 132 |
+
| #2 reshape | Materialize via #3's `model_results` | Bucket reduction is invariant; every consumer needs the same dedup |
|
| 133 |
+
| #3 Steps 1+2 | Materialize | Tree walk is expensive and invariant; every model-detail page needs it |
|
| 134 |
+
| #3 Step 3 | Query-time | Consumer-shape varies (per-variant for detail, per-benchmark for eval-detail); SQL lets each consumer slice without paying for others |
|
| 135 |
+
| #5 | Materialize | Identical answer per consumer; only 13 multi-eval families today; eliminates 2-471 detail fetches per page view |
|
| 136 |
+
| #6 wide matrix | Materialize | Column shape + per-cell winners are deterministic |
|
| 137 |
+
| #6 row filter | Query-time | Forthcoming UI: developer / source-type / top-K filters are consumer-driven |
|
| 138 |
+
| #13 reshape | SQL inline | No artifact needed; comparisons embed in #3/#5/#6/#14 queries |
|
| 139 |
+
| #14 | Materialize | 9 columns Γ ~587 evals; per-eval scope; no consumer slices these per-category |
|
| 140 |
+
| #16 | Materialize | Trivial size (β€9 categories Γ ~5,830 models); identical for every consumer |
|
| 141 |
+
|
| 142 |
+
The pattern: **default to materialize for invariant work**; reserve query-time SQL for consumer-driven slicing (row filters, top-N where N varies, custom faceting).
|
| 143 |
+
|
| 144 |
+
## Dependency order
|
| 145 |
+
|
| 146 |
+
```
|
| 147 |
+
Cleaning items (must land FIRST β they emit canonical values that reshape SQL reads)
|
| 148 |
+
#2 setup-alias merging cleaning half β variant_key column
|
| 149 |
+
#13 timestamp normalization cleaning β ISO 8601 retrieved_timestamp
|
| 150 |
+
#11 category accuracy improvement β meaningful category column (84% currently "other")
|
| 151 |
+
#8 benchmark display names β benchmark_display_name column
|
| 152 |
+
#10 metric display name expansion β metric_display_name column
|
| 153 |
+
|
| 154 |
+
Schema delta (pipeline-owner conversation; ~one PR in pipeline repo)
|
| 155 |
+
Promote nested fields to relational `model_results.parquet`
|
| 156 |
+
Add sidecar `composite_membership.parquet`
|
| 157 |
+
Apply pipeline-side family-membership filter at emission time
|
| 158 |
+
|
| 159 |
+
Reshape items (in roughly ascending complexity; all unblocked once schema lands)
|
| 160 |
+
#16 per-category counts β smallest; gated on #11 (category accuracy)
|
| 161 |
+
#14 score summary stats β gated on #2, #13; #4 already shipped
|
| 162 |
+
#3 hierarchy flatten β gated on #2, #13
|
| 163 |
+
#5 composite eval rollup β gated on #13, #8, #17 (benchmark-card); benefits from #14 landing first
|
| 164 |
+
#6 matrix leaderboard β gated on #13, #8, #17 (benchmark-card), #1 (identity canonicalization); benefits from #3 landing first
|
| 165 |
+
```
|
| 166 |
+
|
| 167 |
+
The schema delta is the load-bearing pipeline-owner conversation. Once `model_results.parquet` exists in the right shape, **the 6 reshape items can be implemented in parallel**, each as a single SQL query in `lib/duckdb-data.ts` replacing the current `JSON.parse(payload_json) β TS adapter` chain.
|
| 168 |
+
|
| 169 |
+
## Cross-cutting TS-as-spec quirks for pipeline-owner attention
|
| 170 |
+
|
| 171 |
+
These are decisions the pipeline owner will face when implementing the schema + emission. Not "fix these" β "these are choices to make".
|
| 172 |
+
|
| 173 |
+
1. **`>=` vs `>` tie-break in bucket reduction.** TS uses `>=` (last-iteration-order wins on timestamp tie) in #3, #6, #14. SQL `ROW_NUMBER() OVER (... ORDER BY retrieved_timestamp DESC)` is implementation-defined on ties unless an explicit secondary key is added. Recommendation: tie-break on `evaluation_id DESC` for stability. Document the divergence; tie-collisions are rare in production (timestamps are floats with microsecond precision).
|
| 174 |
+
|
| 175 |
+
2. **Variant identity computed twice in #3.** `lib/hf-data.ts resolveVariantMeta` (Step 1b) and `lib/eval-processing.ts getAggregatedVariantDescriptor` (Step 3) re-derive variant identity from different inputs. They can disagree (e.g. `"20240620-thinking"` vs `"2024-06-20"`). Should reconcile to a single canonical `variant_key` once #2 cleaning lands. Likely produces subtle off-by-one variant counts today.
|
| 176 |
+
|
| 177 |
+
3. **`evaluator_names` always `[]` on the active path.** `hfEvalDetailToSummary` initializes to `[]` and never populates. The eval-card UI's "Evaluators" pill shows 0 for every eval today β latent bug. Pipeline can fix-by-canonicalization (emit the sorted DISTINCT set) and accept that the pill will start showing real numbers. Flag as deferred product decision.
|
| 178 |
+
|
| 179 |
+
4. **`models_count` divergence.** TS recomputes it post-#2-dedup; pipeline emits it pre-#2-dedup. Disagrees for any model with merged variants (notably anything with `additional_details.mode` β {prompt/fc/thinking}). Resolves naturally when #2 lands and pipeline's emitted value matches TS's recomputed.
|
| 180 |
+
|
| 181 |
+
5. **Sort direction in #5 from FIRST sub-eval's `lower_is_better`.** Fragile when a suite mixes higher-is-better and lower-is-better metrics. Order of `summaries[0]` is whatever `eval-hierarchy.json family.eval_summary_ids` lists first. Pipeline must preserve order or replicate the choice. Recommend: pick `lower_is_better=false` if any sub-eval is higher-is-better.
|
| 182 |
+
|
| 183 |
+
6. **Two implementations of `category_stats` produce different answers** (#16). Path A (grid) is fake distribution; Path B (detail page) is real `COUNT(DISTINCT benchmark)`. Same model, two pages, two answers. Materialization eliminates both implementations.
|
| 184 |
+
|
| 185 |
+
7. **Cell tie-break in #6 is "last in iteration order".** Whether SQL `ROW_NUMBER() ORDER BY retrieved_timestamp DESC` matches TS depends on whether pipeline emits `metric.model_results[]` in retrieved_timestamp-DESC order. **Verify this assumption before flipping the SQL on.** If pipeline order is non-deterministic, the SQL is a "freshest wins" *upgrade* over TS's "iteration order wins" β likely fine, but call out in the migration commit.
|
| 186 |
+
|
| 187 |
+
8. **Score normalization in #5 happens BEFORE averaging.** `normalize(score)` per sub-eval (using each sub-eval's own min/max), then arithmetic mean across sub-evals. The "obvious" alternative (average raw scores then normalize) produces different numbers when sub-evals have different score ranges. SQL must do `AVG(per_eval.normalized_score)`, not `AVG(suite_components.normalized_score)`.
|
| 188 |
+
|
| 189 |
+
9. **Suite-level `avg_score` in #5 is avg-of-per-model-avgs, not avg-of-all-component-scores.** Diverges when sub-eval coverage is unbalanced. SQL `AVG(per_model.avg_normalized_score)`, not `AVG(suite_components.normalized_score)`.
|
| 190 |
+
|
| 191 |
+
10. **Empty-metric short-circuit in #14 puts a benchmark display name into `latest_source_name`.** `lib/model-data.ts:785` sets `latest_source_name = getBenchmarkDisplayName(benchmarkKey)` when an eval has zero metrics β a *display name* string in a *source name* field. Almost certainly a placeholder bug. Pipeline can fix-by-canonicalization (emit `null` and chase down any consumer that breaks); flag as deferred product decision.
|
| 192 |
+
|
| 193 |
+
11. **Two GROUP BY entry points in #14 with different score-source semantics.** `groupEvaluationsByBenchmark` iterates `eval_.evaluation_results` (multi-metric possible); `hfEvalDetailToSummary` iterates `metric.model_results` of *only* the first metric. Active read path for eval-detail pages is `hfEvalDetailToSummary` β pipeline emission should match its single-primary-metric semantics, since that's what users see today.
|
| 194 |
+
|
| 195 |
+
## Open questions for pipeline owner
|
| 196 |
+
|
| 197 |
+
1. **How relational should parquet go?** Promote nested fields to typed columns (this doc's recommendation) or stay closer to current `payload_json` blob and rely on DuckDB's JSON functions? Going relational unlocks ~10Γ more SQL work but is a bigger schema change.
|
| 198 |
+
2. **Where does `composite_membership` live?** Sidecar parquet table (this doc's recommendation) or stays nested in `eval-hierarchy.json`?
|
| 199 |
+
3. **Score-normalization ownership.** Pipeline pre-normalizes `score_norm` as a column, OR DuckDB does at query time using `min_score`/`max_score`/`lower_is_better` columns? This doc assumes the latter (per-row columns); pre-normalizing is also viable.
|
| 200 |
+
4. **Pipeline emission order of `metric.model_results[]`.** Is it deterministic? Sorted by anything? Affects whether SQL's `ROW_NUMBER() ORDER BY retrieved_timestamp DESC` matches TS's "last-wins-iteration" semantics for #6 cell tie-breaks.
|
| 201 |
+
5. **Pipeline-side family-membership filter.** This doc recommends pipeline filters at emission time (so SQL just `WHERE model_family_id = ?`). Alternative: SQL replicates the `belongsToModelFamily` set logic. Pipeline-side is much cleaner.
|
| 202 |
+
6. **Variant identity reconciliation (#3 quirk #2).** Double-pass should collapse to single canonical `variant_key` once #2 cleaning lands. Pipeline owner picks the canonical rule.
|
| 203 |
+
7. **`category_stats` vs `category` column for #16.** Pipeline emits `category_stats: Record<Category, number>` directly on each model card (consumer-shape), OR pipeline emits per-row `category` and DuckDB pivots at query time? Consumer-shape is simpler; query-time gives flexibility for free.
|
| 204 |
+
|
| 205 |
+
## Migration sequencing
|
| 206 |
+
|
| 207 |
+
### Phase 1 β cleaning items land (in pipeline)
|
| 208 |
+
Pipeline emits canonical values per the existing per-item workflow (`notes/migration-plan.md` Β§ "Per-item workflow"). Status as of 2026-04-28:
|
| 209 |
+
|
| 210 |
+
- **Specced + ready for handoff:** #2 (variant_key), #13 (ISO 8601), #8 (benchmark_display_name), #10 (metric_display_name). Plus #17 benchmark-card inlining (specced as `11-benchmark-card-attachment.md`) and #1 identity canonicalization. See per-item specs in `notes/transformations/`.
|
| 211 |
+
- **Blocked, not part of Phase 1:** #11 category accuracy is gated on pipeline-side classification work β pipeline currently emits `category: "other"` on 84% of evals. #16 reshape is gated on #11; if #11 doesn't land in time, #16 ships against the current "84% other" data and shows the limitation, OR ports `inferCategoryFromBenchmark` upstream as a workaround.
|
| 212 |
+
|
| 213 |
+
**No reshape SQL touches yet** in this phase.
|
| 214 |
+
|
| 215 |
+
### Phase 2 β schema delta lands (in pipeline)
|
| 216 |
+
Pipeline emits `model_results.parquet` (the unifying relational table) alongside the existing `payload_json` blob. Both columns live in parquet during the parity window β TS continues to read `payload_json`, the new SQL reads relational columns. This is a **single PR in `eval_cards_backend_pipeline`** for the schema, plus the family-membership filter.
|
| 217 |
+
|
| 218 |
+
### Phase 3 β DuckDB queries replace TS reshape, one item at a time
|
| 219 |
+
Per item (in any order, since they're independent once schema lands):
|
| 220 |
+
1. Write SQL query in `lib/duckdb-data.ts` (alongside existing `JSON.parse(payload_json) β TS adapter` path).
|
| 221 |
+
2. Reshape-class snapshot test becomes the **TS-vs-SQL parity gate** (per `notes/testing-strategy.md` Β§ "Reshape-class items: testing addendum"). Snapshot is committed; SQL output is computed at test time; equality is the gate.
|
| 222 |
+
3. Once parity holds across the full corpus (verified via `pnpm audit-adapters --diff`), delete the TS implementation. Callers switch to read the SQL/materialized output.
|
| 223 |
+
|
| 224 |
+
Recommended order within Phase 3 (smallest unblocked first to build confidence):
|
| 225 |
+
1. **#14 score summary stats** β clean Phase-1 dependencies (#2, #13). Fixes the 0-evaluators latent bug, materializes 9 columns. Best first item.
|
| 226 |
+
2. **#16 per-category counts** β smallest reshape mechanically. *Caveat:* gated on #11 category accuracy (currently blocked on pipeline-side classification work β pipeline emits `category: "other"` on 84% of evals). Either ship against the "84% other" data and show the limitation, OR port `inferCategoryFromBenchmark` upstream as part of Phase 1.
|
| 227 |
+
3. **#3 hierarchy flatten** β the foundational reshape; Steps 1+2 are the unifying `model_results` use, Step 3 is the consumer surface. Clean Phase-1 dependencies.
|
| 228 |
+
4. **#5 composite eval rollup** β eliminates the 2-471 detail-fetch fan-out. Best after #14 ships (so per-eval stats are materialized first).
|
| 229 |
+
5. **#6 matrix leaderboard** β biggest behavior change (PIVOT); benefits from #3 + #5 patterns being in place.
|
| 230 |
+
|
| 231 |
+
### Phase 4 β cleanup
|
| 232 |
+
Once all reshape items are SQL/materialized: drop unused fields from `payload_json` blobs (or leave for debugging). Delete the runtime `flattenModelEvaluations` β `createModelFamilySummary` chain in `lib/duckdb-data.ts toModelSummary`. Update `notes/transformations/README.md` index to reflect completed items.
|
| 233 |
+
|
| 234 |
+
## Cross-references
|
| 235 |
+
|
| 236 |
+
- `notes/migration-plan.md` Β§ "Data direction" β the cleaning vs reshape principle this doc operates under
|
| 237 |
+
- `notes/transformations/README.md` Β§ "Where it belongs" β per-spec classification framework
|
| 238 |
+
- `notes/testing-strategy.md` Β§ "Reshape-class items: testing addendum" β how reshape items get tested (TS-vs-SQL parity)
|
| 239 |
+
- `notes/transformations/02-setup-alias-merging.md` β reshape half of #2 (cited above)
|
| 240 |
+
- `notes/transformations/07-timestamp-normalization.md` β reshape half of #13 (cited above)
|
| 241 |
+
- `notes/transformations/reshape/{03,05,06,14,15,16}-*.md` β full per-item operation catalogs
|
| 242 |
+
|
| 243 |
+
## Open items for follow-up
|
| 244 |
+
|
| 245 |
+
1. **`PIPELINE_CATEGORY_MAP` location decision.** TS-side map vs pipeline-emit-mapped vs SQL `CASE`. Coupled to #11.
|
| 246 |
+
2. **Audit script for TS-vs-SQL parity per reshape item** β pattern to be established with #14 (the first reshape to ship), then templated for #16, #3, #5, #6.
|
| 247 |
+
3. **Pipeline-owner review.** This doc is a proposal; nothing is committed until pipeline owner has weighed in on the schema delta and the open questions above.
|
notes/transformations/reshape/03-hierarchy-flatten.md
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hierarchy flatten + family summary β reshape operation
|
| 2 |
+
|
| 3 |
+
Drafted 2026-04-28. Migration item #3 in `notes/migration-plan.md`. First entry in the reshape catalog (companion to nothing in `notes/transformations/` today β those are cleaning specs and follow a different shape). Will be referenced from the synthesis at `notes/transformations/reshape-design.md` when it lands.
|
| 4 |
+
|
| 5 |
+
## Framing reminder
|
| 6 |
+
|
| 7 |
+
This is a **reshape**, not a cleaning item. The migration target is *not* "pipeline emits these exact `BenchmarkEvaluation[]` objects." It's "pipeline emits relational rows that DuckDB can flatten + group + dedup with a query." The TS implementation is the operational spec; the SQL replacement preserves behavior, not implementation.
|
| 8 |
+
|
| 9 |
+
Per `notes/migration-plan.md` Β§ "Data direction": cleaning belongs in pipeline emission, reshape belongs in DuckDB SQL. This item is the reference reshape. It is also the canonical example for the schema-relationality conversation β the existing parquet schema (one `payload_json VARCHAR` blob per record) cannot do this work; pipeline must promote the nested fields to typed columns first.
|
| 10 |
+
|
| 11 |
+
## Operation (in SQL terms)
|
| 12 |
+
|
| 13 |
+
### Inputs
|
| 14 |
+
- **Source rows:** model-summary records in `model_summaries.parquet` (one per `model_family_id`). Today: each row is `(record_type, model_route_id, model_family_id, β¦, payload_json)` and `payload_json` carries the entire `HFModelDetail` blob β `hierarchy_by_category`, `variants[]`, `raw_model_ids`, `model_info`.
|
| 15 |
+
- **Nested fields the operation reads (currently inside `payload_json`):**
|
| 16 |
+
- `hierarchy_by_category: Record<categoryKey, HFModelHierarchyNode[]>` β the tree. Walk recursively via `subtasks`.
|
| 17 |
+
- For each leaf `metric` on each node: `metric.model_results[]` β submission rows with `score`, `retrieved_timestamp`, `source_metadata`, `raw_model_id`, `model_id`, `model_route_id`, `model_name`, `developer`, `evaluation_id`.
|
| 18 |
+
- Tree context fields propagated down: `eval_summary_id`, `benchmark`, `benchmark_family_key/name`, `benchmark_parent_key/name`, `benchmark_leaf_key/name`, `display_name`, `canonical_display_name`, `category` (the category key from `hierarchy_by_category` map, mapped via `PIPELINE_CATEGORY_MAP`).
|
| 19 |
+
- Metric-level fields: `metric_summary_id`, `metric_key`, `metric_name`, `display_name`, `canonical_display_name`, `metric_config`, `slice_key`, `slice_name`.
|
| 20 |
+
- For variant identity: `detail.variants[]` (`variant_key`, `variant_label`, `raw_model_ids[]`).
|
| 21 |
+
- For ownership filtering: `detail.raw_model_ids[]` βͺ `variant.raw_model_ids[]` βͺ `model_info.id` βͺ `model_family_id`.
|
| 22 |
+
|
| 23 |
+
### Step 1 β flatten the tree (one row per metric Γ model_result)
|
| 24 |
+
Recursively walk `hierarchy_by_category` (root nodes per category, then `node.subtasks[]`), inheriting context (eval_summary_id, benchmark, family/parent/leaf names, source_data) down through subtask levels. For each leaf-metric-with-results, emit one row per `model_result` filtered to those that belong to this model family (see Step 1a).
|
| 25 |
+
|
| 26 |
+
Conceptual emitted shape per row:
|
| 27 |
+
```
|
| 28 |
+
(model_family_id, eval_summary_id, category_key,
|
| 29 |
+
benchmark, benchmark_family_key, benchmark_parent_key, benchmark_leaf_key,
|
| 30 |
+
benchmark_family_name, benchmark_parent_name, benchmark_leaf_name,
|
| 31 |
+
display_name, canonical_display_name,
|
| 32 |
+
slice_key, slice_name, source_data,
|
| 33 |
+
metric_summary_id, metric_key, metric_name, metric_config,
|
| 34 |
+
evaluation_id, raw_model_id, model_id, model_route_id, model_name, developer,
|
| 35 |
+
score, retrieved_timestamp, source_metadata,
|
| 36 |
+
detailed_evaluation_results, instance_level_data,
|
| 37 |
+
variant_key /* resolved per Step 1b */)
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
#### Step 1a β `belongsToModelFamily` filter
|
| 41 |
+
A `model_result` belongs to this model family iff any of:
|
| 42 |
+
- `normalize(result.model_route_id) == normalize(detail.model_route_id)`, OR
|
| 43 |
+
- `normalize(result.raw_model_id) β rawModelIds`, OR
|
| 44 |
+
- `normalize(result.model_id) β rawModelIds`
|
| 45 |
+
|
| 46 |
+
where `rawModelIds = detail.raw_model_ids βͺ flat(variants[].raw_model_ids) βͺ detail.model_info.id βͺ detail.model_family_id`, all lowercased + trimmed. (See `lib/hf-data.ts:1110-1131` and `:1386-1395`.)
|
| 47 |
+
|
| 48 |
+
#### Step 1b β `resolveVariantMeta` lookup
|
| 49 |
+
Given a `model_result`, derive its `variant_key`:
|
| 50 |
+
1. Build a lookup `variantLookup: Map<normalized_raw_model_id, variant_key>` from `detail.variants[*].raw_model_ids`.
|
| 51 |
+
2. Try `result.raw_model_id` then `result.model_id` against the lookup.
|
| 52 |
+
3. If no match AND `detail.variants.length === 1` β use that single variant's key/label.
|
| 53 |
+
4. Else fallback to first non-empty candidate id, or `detail.model_info.variant_key`, or literal `"default"`.
|
| 54 |
+
|
| 55 |
+
This is the same `variant_key` produced by item #2 (setup-alias merging) with normalization already applied β so post-#2 the result of this lookup should equal the canonicalized `setup_alias_key` the pipeline emits.
|
| 56 |
+
|
| 57 |
+
### Step 2 β group by `(eval_summary_id, metric_summary_id, variant_key)` within each model
|
| 58 |
+
|
| 59 |
+
Each leaf-metric Γ variant-bucket becomes ONE `BenchmarkEvaluation`. Inside a bucket multiple `model_results` are merged:
|
| 60 |
+
- `evaluation_results[]` β append every result's score record (concatenate, no dedup).
|
| 61 |
+
- `latestTimestamp` β `MAX(retrieved_timestamp)` across the bucket.
|
| 62 |
+
- `source_metadata` β the `source_metadata` of the row whose `retrieved_timestamp == latestTimestamp` (first one wins on ties because `>=`, see TS-quirk #1 below).
|
| 63 |
+
- `inlineSamples` β first non-empty `parseInstanceLevelData(result.instance_level_data)` encountered; subsequent rows do not overwrite if `existing.inlineSamples` already has values.
|
| 64 |
+
|
| 65 |
+
The output `evaluation_id` for the merged record is `${metric.metric_summary_id}__${variantKey}`.
|
| 66 |
+
|
| 67 |
+
### Step 3 β group by `family-id` and reshape into `ModelEvaluationSummary`
|
| 68 |
+
|
| 69 |
+
Take the flat `BenchmarkEvaluation[]` and:
|
| 70 |
+
1. **Re-bucket by variant** (in `createModelFamilySummary`, `lib/eval-processing.ts:453-532`) using `getAggregatedVariantDescriptor(eval.model_info)`. Note: this is a SECOND variant resolution that re-derives variant identity from `model_info` rather than using the `variant_key` set in Step 1b. The two should agree post-#2; today they can disagree (see TS-quirk #2).
|
| 71 |
+
2. **Per variant:** call `createModelSummary` (`lib/eval-processing.ts:280-344`) which:
|
| 72 |
+
- Buckets evaluations by `category` (the `BenchmarkEvaluation.category` field set in Step 1, falling back to `inferCategoryFromBenchmark(result.evaluation_name)` when missing).
|
| 73 |
+
- Computes `total_evaluations = SUM(evaluations[i].evaluation_results.length)`.
|
| 74 |
+
- Computes `last_updated = MAX(retrieved_timestamp)` rendered as ISO string.
|
| 75 |
+
- Lists `categories_covered = DISTINCT(category)`.
|
| 76 |
+
3. **Sort variants** by `version_date DESC, total_evaluations DESC, variant_label ASC` (`sortVariants`, `lib/eval-processing.ts:436-451`).
|
| 77 |
+
4. **Family-level rollup:** call `createModelSummary(allEvaluations)` β same shape but at the family level β then overlay `model_family_id`, `model_route_id`, `model_family_name` from `getCanonicalModelIdentity(evaluations[0].model_info)`, and `raw_model_ids = SORTED DISTINCT(eval.model_info.id)`.
|
| 78 |
+
|
| 79 |
+
### Output
|
| 80 |
+
- Intermediate: `BenchmarkEvaluation[]` (the flat-list output of `flattenModelEvaluations`).
|
| 81 |
+
- Final: `ModelEvaluationSummary` (the family-rollup output of `createModelFamilySummary`).
|
| 82 |
+
|
| 83 |
+
Both are presentation shapes consumed by `lib/model-data.ts` getters and the model-detail pages.
|
| 84 |
+
|
| 85 |
+
## Current TS implementation
|
| 86 |
+
|
| 87 |
+
| Concern | Location | Notes |
|
| 88 |
+
|---|---|---|
|
| 89 |
+
| Tree walk + per-metric flatten + variant bucket reduction | `lib/hf-data.ts:1228-1378` (`flattenHierarchyNode`) | Recursive; inherits context via `buildFlattenHierarchyContext` |
|
| 90 |
+
| Public entry (per-model) | `lib/hf-data.ts:1384-1414` (`flattenModelEvaluations`) | Iterates `hierarchy_by_category` keys; maps category via `PIPELINE_CATEGORY_MAP` (`lib/hf-data.ts:1425-1435`) |
|
| 91 |
+
| Variant lookup builder | `lib/hf-data.ts:1063-1079` (`buildVariantLookup`) | |
|
| 92 |
+
| Per-result variant resolver | `lib/hf-data.ts:1081-1108` (`resolveVariantMeta`) | 4-tier fallback |
|
| 93 |
+
| Family-membership filter | `lib/hf-data.ts:1110-1131` (`belongsToModelFamily`) | |
|
| 94 |
+
| Per-variant model_info synthesizer | `lib/hf-data.ts:1133-1155` (`buildModelInfoForVariant`) | |
|
| 95 |
+
| Inline-samples parser | `lib/hf-data.ts:933` (`parseInstanceLevelData`) | Tolerant JSON/array parser |
|
| 96 |
+
| Hierarchy context builder | `lib/hf-data.ts:1175-1226` (`FlattenHierarchyContext` + `buildFlattenHierarchyContext`) | Inheritance-with-defaults for tree context |
|
| 97 |
+
| Bucket-merge timestamp comparator | `lib/hf-data.ts:1049-1061` (`toComparableTimestamp`) | Variant B from spec #07 β has the `parseFloat` quirk |
|
| 98 |
+
| Family rollup | `lib/eval-processing.ts:453-532` (`createModelFamilySummary`) | |
|
| 99 |
+
| Per-summary aggregation | `lib/eval-processing.ts:280-344` (`createModelSummary`) | Buckets by category, sums totals, max-timestamp |
|
| 100 |
+
| Variant descriptor (re-derived) | `lib/eval-processing.ts:360-434` (`getAggregatedVariantDescriptor`) | Re-runs setup-alias logic, this time from `model_info` |
|
| 101 |
+
| Variant sort | `lib/eval-processing.ts:436-451` (`sortVariants`) | |
|
| 102 |
+
|
| 103 |
+
### Caller sites
|
| 104 |
+
- `lib/model-data.ts:1495, 1497, 1518, 1520, 1530, 1532` β `getModelSummaryById` and slug-retry siblings.
|
| 105 |
+
- `lib/duckdb-data.ts:189-194` (`toModelSummary`) β DuckDB read path currently re-runs both adapters against the `payload_json` blob to match JSON-path output exactly. **This is the call site the SQL replacement targets.**
|
| 106 |
+
- `lib/eval-processing.ts:825` β batch path in `processEvaluationsToCards` (developer-aggregate flow).
|
| 107 |
+
|
| 108 |
+
## Required parquet columns
|
| 109 |
+
|
| 110 |
+
The current parquet schema (per `notes/migration-plan.md` Β§ "Data direction"): 11 typed metadata columns + `payload_json VARCHAR`. SQL can route on the metadata columns but cannot see inside the blob. To do this operation in SQL, pipeline needs to promote the nested fields to a separate relational table.
|
| 111 |
+
|
| 112 |
+
### Proposed: `model_results.parquet` (one row per metric Γ model_result, post-flatten)
|
| 113 |
+
|
| 114 |
+
```
|
| 115 |
+
model_family_id VARCHAR -- routing key (matches model_summaries.parquet)
|
| 116 |
+
model_route_id VARCHAR -- routing key (matches model_summaries.parquet)
|
| 117 |
+
eval_summary_id VARCHAR -- from hierarchy node
|
| 118 |
+
category_key VARCHAR -- raw pipeline key ("agentic", "reasoning", ...) β TS maps via PIPELINE_CATEGORY_MAP at read time
|
| 119 |
+
benchmark VARCHAR
|
| 120 |
+
benchmark_family_key VARCHAR
|
| 121 |
+
benchmark_family_name VARCHAR
|
| 122 |
+
benchmark_parent_key VARCHAR
|
| 123 |
+
benchmark_parent_name VARCHAR
|
| 124 |
+
benchmark_leaf_key VARCHAR
|
| 125 |
+
benchmark_leaf_name VARCHAR
|
| 126 |
+
display_name VARCHAR -- post-inheritance from buildFlattenHierarchyContext
|
| 127 |
+
canonical_display_name VARCHAR -- post-inheritance
|
| 128 |
+
slice_key VARCHAR
|
| 129 |
+
slice_name VARCHAR
|
| 130 |
+
source_data JSON -- inherited; either struct or string[]
|
| 131 |
+
metric_summary_id VARCHAR
|
| 132 |
+
metric_key VARCHAR
|
| 133 |
+
metric_name VARCHAR
|
| 134 |
+
metric_display_name VARCHAR
|
| 135 |
+
metric_canonical_display_name VARCHAR
|
| 136 |
+
metric_config JSON
|
| 137 |
+
evaluation_id VARCHAR -- result-level
|
| 138 |
+
raw_model_id VARCHAR
|
| 139 |
+
model_id VARCHAR
|
| 140 |
+
model_result_route_id VARCHAR
|
| 141 |
+
model_name VARCHAR
|
| 142 |
+
developer VARCHAR
|
| 143 |
+
score DOUBLE
|
| 144 |
+
retrieved_timestamp VARCHAR -- ISO 8601 once #13 ships; today unix-seconds-string
|
| 145 |
+
source_metadata JSON
|
| 146 |
+
detailed_evaluation_results VARCHAR
|
| 147 |
+
instance_level_data JSON
|
| 148 |
+
variant_key VARCHAR -- pre-resolved per Step 1b (depends on #2 emitting it)
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
### Cross-item dependencies for the schema
|
| 152 |
+
- **#2 setup-alias merging** β `variant_key` must be the *normalized* key (post-`isSetupAliasQualifier` rules). Without this, Step 1b stays in TS or in a CTE that re-derives.
|
| 153 |
+
- **#13 timestamp normalization** β `retrieved_timestamp` must be a single canonical format (recommended: ISO 8601 string, lexicographic sort = chronological sort). Until then SQL needs `epoch_ms(CAST(retrieved_timestamp AS DOUBLE) * 1000)` plus a fallback for ISO strings β workable but the TS quirks (Variant B's `parseFloat` bug per spec #07) become unobservable only once the format is unified.
|
| 154 |
+
- **#4 source-metadata** (DONE) β pipeline already emits `source_metadata` on every row; the typed `source_metadata JSON` column is straightforward.
|
| 155 |
+
- **Category map** β TS currently maps the raw `category_key` ("agentic" β "Agentic", "coding" β "General", etc.) via `PIPELINE_CATEGORY_MAP` at read time. For this reshape we recommend leaving the raw key in parquet and applying the case mapping in SQL via a small CASE expression (or keeping it client-side until #11 lands).
|
| 156 |
+
- **Family-membership filter** β Step 1a needs `raw_model_ids` set per family. Easiest path: pipeline filters at emission time so every row in `model_results.parquet` is already guaranteed to belong to its `model_family_id`. Then SQL just `WHERE model_family_id = ?` and the TS `belongsToModelFamily` logic disappears.
|
| 157 |
+
|
| 158 |
+
### Independent of schema decisions
|
| 159 |
+
- **The variant bucket reduction itself** is pure SQL once timestamps are comparable; doesn't need any new schema beyond the row-per-result table.
|
| 160 |
+
- **`createModelSummary`'s category bucketing + totals** is also pure SQL once a row-per-result table exists.
|
| 161 |
+
|
| 162 |
+
## Sketch SQL query
|
| 163 |
+
|
| 164 |
+
Assumes the proposed `model_results.parquet` schema, ISO-8601 timestamps, and pipeline-side family-membership filtering.
|
| 165 |
+
|
| 166 |
+
### Step 1+2 β flat list, with variant-bucket dedup applied (one row per `(eval_summary_id, metric_summary_id, variant_key)`)
|
| 167 |
+
|
| 168 |
+
```sql
|
| 169 |
+
WITH
|
| 170 |
+
results AS (
|
| 171 |
+
SELECT *
|
| 172 |
+
FROM read_parquet('model_results.parquet')
|
| 173 |
+
WHERE model_family_id = ? -- single-model query
|
| 174 |
+
),
|
| 175 |
+
-- Within a (eval_summary_id, metric_summary_id, variant_key) bucket, pick the
|
| 176 |
+
-- row whose retrieved_timestamp is freshest. Aggregate the rest as arrays.
|
| 177 |
+
freshest AS (
|
| 178 |
+
SELECT *
|
| 179 |
+
FROM results
|
| 180 |
+
QUALIFY ROW_NUMBER() OVER (
|
| 181 |
+
PARTITION BY eval_summary_id, metric_summary_id, variant_key
|
| 182 |
+
ORDER BY retrieved_timestamp DESC, evaluation_id DESC -- tie-break stable
|
| 183 |
+
) = 1
|
| 184 |
+
),
|
| 185 |
+
bucket_results AS (
|
| 186 |
+
SELECT
|
| 187 |
+
eval_summary_id,
|
| 188 |
+
metric_summary_id,
|
| 189 |
+
variant_key,
|
| 190 |
+
list({
|
| 191 |
+
evaluation_name: metric_name,
|
| 192 |
+
display_name: metric_display_name,
|
| 193 |
+
canonical_display_name: metric_canonical_display_name,
|
| 194 |
+
metric_summary_id: metric_summary_id,
|
| 195 |
+
metric_key: metric_key,
|
| 196 |
+
evaluation_timestamp: retrieved_timestamp,
|
| 197 |
+
source_data: source_data,
|
| 198 |
+
metric_config: metric_config,
|
| 199 |
+
score_details: { score: score },
|
| 200 |
+
detailed_evaluation_results_url: detailed_evaluation_results
|
| 201 |
+
} ORDER BY retrieved_timestamp) AS evaluation_results,
|
| 202 |
+
-- first non-empty inline-samples in insertion order
|
| 203 |
+
list_filter(list(instance_level_data ORDER BY retrieved_timestamp), x -> x IS NOT NULL)[1]
|
| 204 |
+
AS inline_samples
|
| 205 |
+
FROM results
|
| 206 |
+
GROUP BY eval_summary_id, metric_summary_id, variant_key
|
| 207 |
+
)
|
| 208 |
+
SELECT
|
| 209 |
+
'0.2.2' AS schema_version,
|
| 210 |
+
f.eval_summary_id,
|
| 211 |
+
f.metric_summary_id || '__' || f.variant_key AS evaluation_id,
|
| 212 |
+
f.retrieved_timestamp,
|
| 213 |
+
f.benchmark,
|
| 214 |
+
f.display_name,
|
| 215 |
+
f.canonical_display_name,
|
| 216 |
+
f.category_key AS category, -- map to CategoryType in app layer or via CASE here
|
| 217 |
+
f.benchmark_family_key, f.benchmark_family_name,
|
| 218 |
+
f.benchmark_parent_key, f.benchmark_parent_name,
|
| 219 |
+
f.benchmark_leaf_key, f.benchmark_leaf_name,
|
| 220 |
+
f.slice_key, f.slice_name,
|
| 221 |
+
f.source_data,
|
| 222 |
+
f.source_metadata,
|
| 223 |
+
-- model_info synthesized per buildModelInfoForVariant
|
| 224 |
+
struct_pack(
|
| 225 |
+
id := COALESCE(f.raw_model_id, f.model_id),
|
| 226 |
+
name := f.model_name,
|
| 227 |
+
developer := f.developer,
|
| 228 |
+
model_version := CASE WHEN f.variant_key <> 'default' THEN f.variant_key ELSE NULL END
|
| 229 |
+
) AS model_info,
|
| 230 |
+
br.evaluation_results,
|
| 231 |
+
br.inline_samples AS detailed_evaluation_results_per_samples
|
| 232 |
+
FROM freshest f
|
| 233 |
+
JOIN bucket_results br USING (eval_summary_id, metric_summary_id, variant_key);
|
| 234 |
+
```
|
| 235 |
+
|
| 236 |
+
### Step 3 β family summary (rolling up the flat list)
|
| 237 |
+
|
| 238 |
+
```sql
|
| 239 |
+
WITH flat AS ( /* the query above */ ),
|
| 240 |
+
per_variant AS (
|
| 241 |
+
SELECT
|
| 242 |
+
variant_key,
|
| 243 |
+
MAX(retrieved_timestamp) AS last_updated,
|
| 244 |
+
SUM(len(evaluation_results)) AS total_evaluations,
|
| 245 |
+
array_agg(DISTINCT category) AS categories_covered,
|
| 246 |
+
-- evaluations grouped by category as a struct of arrays
|
| 247 |
+
map_from_entries(
|
| 248 |
+
array_agg(struct_pack(category := category, eval := flat))
|
| 249 |
+
OVER (PARTITION BY variant_key) -- pseudocode; real shape uses GROUP BY + LIST_AGG
|
| 250 |
+
) AS evaluations_by_category
|
| 251 |
+
FROM flat
|
| 252 |
+
GROUP BY variant_key
|
| 253 |
+
),
|
| 254 |
+
family AS (
|
| 255 |
+
SELECT
|
| 256 |
+
MAX(retrieved_timestamp) AS last_updated,
|
| 257 |
+
SUM(len(evaluation_results)) AS total_evaluations
|
| 258 |
+
FROM flat
|
| 259 |
+
)
|
| 260 |
+
SELECT * FROM per_variant, family;
|
| 261 |
+
```
|
| 262 |
+
|
| 263 |
+
(The `evaluations_by_category` map is awkward in SQL β most consumers will end up assembling it client-side from the flat-list query. That's fine; the operationally important reshape is Steps 1+2.)
|
| 264 |
+
|
| 265 |
+
## Materialize vs query-time
|
| 266 |
+
|
| 267 |
+
- **Materialize upstream if:** the answer is identical for every consumer. The flat `model_results` table absolutely should be materialized β every consumer of model-detail pages needs it, and re-walking the tree on every request is exactly what we're trying to leave behind.
|
| 268 |
+
- **Query-time SQL if:** consumers slice differently. Step 3 (family summary) is consumer-shape β different pages want different slices (per-category, per-variant, per-benchmark). Run it as a query.
|
| 269 |
+
|
| 270 |
+
**Recommendation: materialize Steps 1+2 (the flat post-bucket-reduction table) into `model_results.parquet`; compute Step 3 (family summary, category bucketing) at query time.**
|
| 271 |
+
|
| 272 |
+
Rationale: the tree-walk + variant-bucket reduction is invariant work that every model-detail page needs identically β pre-computing it removes the recursive walk from the request hot path. The category bucketing and variant rollup, by contrast, are presentation-shape transforms that vary per page (model-detail wants per-variant, eval-detail wants per-benchmark, etc.) β keeping them in SQL lets each consumer slice without paying for the others' shapes.
|
| 273 |
+
|
| 274 |
+
A nice property: this split also lets `eval_summaries.parquet` and `developer_summaries.parquet` reuse the same `model_results.parquet` rows β they're currently re-walking the same tree from their own angles.
|
| 275 |
+
|
| 276 |
+
## TS-as-spec quirks
|
| 277 |
+
|
| 278 |
+
The reshape SQL must preserve these or explicitly defer them as product decisions. Don't unilaterally fix.
|
| 279 |
+
|
| 280 |
+
1. **`>=` not `>` in the bucket-merge timestamp comparison.** `lib/hf-data.ts:1310-1313`: when a later result ties an earlier result's timestamp exactly, the LATER one wins (overwrites `latestTimestamp` and `sourceMetadata`). With `ROW_NUMBER() β¦ ORDER BY retrieved_timestamp DESC` SQL gets implementation-defined tie-breaking unless you add an explicit secondary sort. Recommendation: tie-break on `evaluation_id DESC` (or another stable secondary key) and document that it differs from TS's "later in iteration order wins" for cross-bucket ties. Quantify on actual data before shipping; if collisions are rare, the divergence is acceptable.
|
| 281 |
+
|
| 282 |
+
2. **Variant identity is computed twice with potentially different rules.** Step 1b (`resolveVariantMeta` in `lib/hf-data.ts`) uses `variants[].raw_model_ids` lookup β strict id match. Step 3 (`getAggregatedVariantDescriptor` in `lib/eval-processing.ts:394-434`) re-derives from `model_info.additional_details.mode` + `getCanonicalModelIdentity`. These can disagree: e.g. a result whose Step-1b `variant_key` is `"20240620-thinking"` becomes Step-3 `variant_key: "2024-06-20"` (setup-alias merging collapses the qualifier). The SQL replacement should produce a single canonical `variant_key` per result post-#2 and not re-bucket later. Today's TS double-bucketing is a likely source of subtle off-by-one variant counts; capture it as observed behavior and decide the desired single-pass semantics with the pipeline owner before reshaping.
|
| 283 |
+
|
| 284 |
+
3. **Inline-samples merge is "first non-empty wins, no overwrite"**, NOT "freshest wins." If the first result emitted into a bucket has no `instance_level_data` and a later result does, that later one's samples are kept. If both have samples, the FIRST one's are kept regardless of timestamp. This is a separate code path from the timestamp-keyed metadata merge. SQL replacement preserves this with `list_filter(list(instance_level_data ORDER BY <iteration order>), x -> x IS NOT NULL)[1]` β but iteration order in TS is `metric.model_results[]` array order from the parquet, which SQL needs an explicit sort to mimic. This is fragile; flag for synthesis.
|
| 285 |
+
|
| 286 |
+
4. **Category in Step 1 is the per-node `category_key` from `hierarchy_by_category` keys, not from each `model_result`.** Two results landing in different category branches but for the same `(metric_summary_id, variant_key)` pair would produce two separate `BenchmarkEvaluation` entries, not one merged. This is structural to how the tree walk works; SQL needs to include `category_key` (or `eval_summary_id`, which is per-leaf) in the GROUP BY.
|
| 287 |
+
|
| 288 |
+
5. **`PIPELINE_CATEGORY_MAP` lossy collapses.** `coding`, `instruction_following`, `language_understanding` all map to `"General"`. This is intentional (per the comment at `lib/hf-data.ts:1419-1424`) until #11 (category accuracy) ships. The SQL should preserve the same mapping at read time, or push the map into a typed column emitted by pipeline.
|
| 289 |
+
|
| 290 |
+
## Cross-item dependencies
|
| 291 |
+
|
| 292 |
+
### Cleaning items that must land first
|
| 293 |
+
- **#2 setup-alias merging** β provides the canonical `variant_key` Step 1b currently re-derives. Without it, the SQL needs a CTE that re-runs the alias-qualifier rules in DuckDB (workable but ugly).
|
| 294 |
+
- **#13 timestamp normalization** β provides comparable `retrieved_timestamp` so `MAX()` / `ROW_NUMBER() ORDER BY` are correct. Today's mixed-format strings sort lexicographically wrong (`"1774096306"` < `"2024-..."`).
|
| 295 |
+
- **#4 source-metadata** β DONE. Step 2's "freshest source_metadata wins" needs every row to carry it; this is already true.
|
| 296 |
+
|
| 297 |
+
### Reshape items this feeds
|
| 298 |
+
- **#5 composite eval rollup** (`aggregateBenchmarkSummaries`) β also walks model evaluations per benchmark; would consume the flat `model_results` table directly rather than re-walking trees.
|
| 299 |
+
- **#6 matrix leaderboard synthesis** β same.
|
| 300 |
+
- **#14 score summary stats** β finalizes the per-benchmark grouping; consumes the flat table.
|
| 301 |
+
|
| 302 |
+
### Pipeline-side prerequisites
|
| 303 |
+
- Pipeline must emit a relational `model_results.parquet` (or equivalent table) with the columns above. This is the schema-relationality conversation flagged in `notes/migration-plan.md`.
|
| 304 |
+
- Pipeline-side family-membership filter (so the SQL doesn't have to replicate `belongsToModelFamily`).
|
| 305 |
+
- Decide where `PIPELINE_CATEGORY_MAP` lives (pipeline emits mapped string vs SQL CASE vs TS-side map).
|
| 306 |
+
|
| 307 |
+
## Migration checklist
|
| 308 |
+
|
| 309 |
+
- [x] Operation cataloged
|
| 310 |
+
- [ ] Schema delta proposed (in synthesis doc `notes/transformations/reshape-design.md`)
|
| 311 |
+
- [ ] SQL query reviewed by pipeline owner
|
| 312 |
+
- [ ] Pipeline emits `model_results.parquet` with the relational columns
|
| 313 |
+
- [ ] DuckDB query implemented in `lib/duckdb-data.ts` (replaces `toModelSummary`'s `flattenModelEvaluations(payload) β createModelFamilySummary(...)` chain at line 189-194)
|
| 314 |
+
- [ ] Parity test (TS vs SQL output) passes β see `notes/testing-strategy.md` Β§ "Reshape-class items: testing addendum" for the snapshot-as-parity-gate pattern. Snapshot lives at `tests/adapters/flatten-model-evaluations.test.ts` (per the deferred plan in testing-strategy.md Β§ "Test-additions deferred to specific migration items") and `tests/adapters/create-model-family-summary.test.ts`.
|
| 315 |
+
- [ ] TS code deleted: `flattenHierarchyNode`, `flattenModelEvaluations`, `buildFlattenHierarchyContext`, `buildVariantLookup`, `resolveVariantMeta`, `belongsToModelFamily`, `buildModelInfoForVariant` in `lib/hf-data.ts`; `createModelFamilySummary`, `createModelSummary`, `getAggregatedVariantDescriptor`, `sortVariants` in `lib/eval-processing.ts`. Callers in `lib/model-data.ts:1495-1532` and `lib/eval-processing.ts:825` switch to the SQL-backed reader.
|
| 316 |
+
|
| 317 |
+
## Future product decisions (deferred)
|
| 318 |
+
|
| 319 |
+
- Whether the double-pass variant resolution (Step 1b vs Step 3) should be reconciled to a single `variant_key`. Likely yes once #2 lands; flag for the synthesis.
|
| 320 |
+
- Whether `>=` (TS today) or `>` (more conventional "first wins ties") is the desired bucket-merge tie-break. Current TS quirk is observable but rare in production.
|
| 321 |
+
- Whether `evaluations_by_category` should be re-shaped client-side from a flat-list query or carried as a nested column. The latter is awkward in SQL/Parquet; recommendation is the former.
|
notes/transformations/reshape/05-composite-eval-rollup.md
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Composite eval rollup
|
| 2 |
+
|
| 3 |
+
Drafted 2026-04-28. Migration item #5 in `notes/migration-plan.md`. Reshape-class operation catalog (not a rule-table replication). See `notes/transformations/README.md` Β§ "Where it belongs: cleaning vs reshape" and `notes/migration-plan.md` Β§ "Data direction" for the framing this spec follows.
|
| 4 |
+
|
| 5 |
+
## Framing reminder
|
| 6 |
+
|
| 7 |
+
This is a **reshape** item, not a cleaning item. The output captures the *operation* (group-by keys, aggregation, ordering) so the parquet-schema + SQL conversation can happen. We are not line-by-line porting `aggregateBenchmarkSummaries` into Python; we're describing what the SQL needs to compute. TS-as-is is the spec for behaviour β including the score-normalization-then-average choice β but the implementation target is SQL or pre-materialized parquet, not a TS-shaped Python function.
|
| 8 |
+
|
| 9 |
+
## Migration item
|
| 10 |
+
|
| 11 |
+
- **Item:** #5 β composite eval rollup (`/evals/aggregate__<suite_key>` route).
|
| 12 |
+
- **TS implementation:** `lib/model-data.ts:877-1044` (`aggregateBenchmarkSummaries`, ~168 lines).
|
| 13 |
+
- **Trigger / call site:** `lib/model-data.ts:1543-1568` (`getEvalSummaryById`, the `evalId.startsWith("aggregate__")` branch). Fired by the route handler at `app/evals/[id]/page.tsx` whenever the URL is `/evals/aggregate__<suite_key>`.
|
| 14 |
+
- **Per-request cost (today):** for an aggregate URL, TS calls `fetchHFEvalDetail(eval_summary_id)` once per sub-eval (lines 1558-1563 do `Promise.all(matchingEvals.map(...))`). Typical composite is **2-21 sub-evals** (per `eval-hierarchy.json`: `reward_bench` 2, `livecodebenchpro` 3, `fibble_arena` 5, `helm_capabilities` 6, `helm_safety` 6, `multi_swe_bench` 6, `helm_lite` 10, `helm_classic` 15, `artificial_analysis_llms` 21). One outlier family `llm_stats` has 471 sub-evals. Each sub-eval detail file is the fully-expanded per-model results blob. This happens at request time, on every page view, with no caching beyond the underlying `fetchHFEvalDetail` LRU.
|
| 15 |
+
- **Aggregate URL β suite_key:** strip the `aggregate__` prefix; the remainder is matched against `eval-hierarchy.json` family keys via `e.benchmark.toLowerCase().replace(/[-.\s]+/g, "_").replace(/^_+|_+$/g, "")` to find sub-evals (`lib/model-data.ts:1550-1552`).
|
| 16 |
+
|
| 17 |
+
## Operation in SQL terms
|
| 18 |
+
|
| 19 |
+
For a given `suite_key` (composite), produce one row per `(suite_key, model_id)` containing:
|
| 20 |
+
|
| 21 |
+
1. The per-model average of normalized sub-eval scores (`AVG(normalize(score, min_score, max_score))` across the suite's sub-evals).
|
| 22 |
+
2. Latest evaluation timestamp + source metadata across that model's components (winner of `MAX(retrieved_timestamp)` is "latest component"; its `source_metadata`, `source_data`, `result.*` are inherited).
|
| 23 |
+
3. A pre-rolled `aggregate_components[]` list of the per-sub-eval contributions (raw score, normalized score, source attribution) used by `eval-detail.tsx` to render the per-row drill-down.
|
| 24 |
+
|
| 25 |
+
Then, suite-level rollups on top of those per-model rows:
|
| 26 |
+
|
| 27 |
+
4. `models_count = COUNT(DISTINCT model_id)`.
|
| 28 |
+
5. `avg_score = AVG(per_model_avg_normalized_score)` (avg-of-avgs; see TS quirk #2 below).
|
| 29 |
+
6. `best_model` / `worst_model` = first / last after sorting by `avg_score_normalized` (DESC if `lower_is_better=false`, ASC if true). The sort key is the lower-cased single-metric direction taken from the **first sub-eval's** `metric_config.lower_is_better` (TS quirk #4).
|
| 30 |
+
7. `evaluator_names = sorted DISTINCT UNION` of every sub-eval's `evaluator_names`.
|
| 31 |
+
8. `source_types = sorted DISTINCT UNION` of every sub-eval's `source_types`.
|
| 32 |
+
9. `third_party_ratio = SUM(third_party result rows) / SUM(all underlying result rows)` across all sub-evals' `model_results` arrays (computed pre-rollup, over raw rows β not over the rolled-up models).
|
| 33 |
+
10. `missing_generation_config_count = SUM(...)` across sub-evals.
|
| 34 |
+
11. `latest_source_name`: when there's exactly one sub-eval, copy its name; when there are multiple, the literal string `"Multiple sources"` (TS quirk #5).
|
| 35 |
+
12. A `metric_config` synthesized from the first sub-eval's metric_config but with `min_score=0`, `max_score=1`, `unit="normalized average"`, and `evaluation_description = "Average normalized score across <list of sub-eval names sorted A-Z>"` when more than one source.
|
| 36 |
+
|
| 37 |
+
Steps 4-12 are scalar/vector reductions over the result of steps 1-3.
|
| 38 |
+
|
| 39 |
+
## Required parquet columns
|
| 40 |
+
|
| 41 |
+
To do this in SQL, the pipeline needs (a) a relational result table and (b) a relational composite-membership table:
|
| 42 |
+
|
| 43 |
+
### Table A: `result_rows` (one row per (eval_summary_id, model_id, variant, retrieved_timestamp))
|
| 44 |
+
|
| 45 |
+
Existing logical fields, promoted from `payload_json`:
|
| 46 |
+
|
| 47 |
+
| Column | Type | Source |
|
| 48 |
+
|---|---|---|
|
| 49 |
+
| `eval_summary_id` | VARCHAR | already in metadata column |
|
| 50 |
+
| `model_id` | VARCHAR | currently nested in `model_results[].model_info.id` |
|
| 51 |
+
| `score` | DOUBLE | currently nested in `model_results[].score` |
|
| 52 |
+
| `retrieved_timestamp` | TIMESTAMP (ISO) | nested; see reshape spec #07 for canonicalization |
|
| 53 |
+
| `evaluator_relationship` | VARCHAR | nested in `model_results[].source_metadata` |
|
| 54 |
+
| `source_name` | VARCHAR | nested in `model_results[].source_metadata` |
|
| 55 |
+
| `source_type` | VARCHAR | nested in `model_results[].source_metadata` |
|
| 56 |
+
| `source_organization_name` | VARCHAR | nested in `model_results[].source_metadata` |
|
| 57 |
+
| `sample_size` | BIGINT | nested in `model_results[].score_details.sample_size` |
|
| 58 |
+
| `missing_generation_config` | BOOLEAN | implied β currently surfaced as a count field on the eval summary |
|
| 59 |
+
|
| 60 |
+
### Table B: `eval_metric_config` (one row per eval_summary_id)
|
| 61 |
+
|
| 62 |
+
| Column | Type | Source |
|
| 63 |
+
|---|---|---|
|
| 64 |
+
| `eval_summary_id` | VARCHAR | key |
|
| 65 |
+
| `metric_min_score` | DOUBLE | currently nested in `metric_config.min_score` |
|
| 66 |
+
| `metric_max_score` | DOUBLE | currently nested in `metric_config.max_score` |
|
| 67 |
+
| `lower_is_better` | BOOLEAN | currently nested in `metric_config.lower_is_better` |
|
| 68 |
+
| `evaluation_name` | VARCHAR | already on summary; needed for the sort and join |
|
| 69 |
+
| `category` | VARCHAR | already in metadata column |
|
| 70 |
+
|
| 71 |
+
### Table C: `composite_membership` (one row per (suite_key, sub_eval_summary_id))
|
| 72 |
+
|
| 73 |
+
| Column | Type | Source |
|
| 74 |
+
|---|---|---|
|
| 75 |
+
| `suite_key` | VARCHAR | family `key` from `eval-hierarchy.json` (e.g. `helm_lite`) |
|
| 76 |
+
| `eval_summary_id` | VARCHAR | each entry of family.eval_summary_ids |
|
| 77 |
+
| `suite_display_name` | VARCHAR | family `display_name` (currently TS overrides via `getBenchmarkDisplayName(suite_key)` lookup; see TS quirk #6) |
|
| 78 |
+
|
| 79 |
+
This table is the new structural artifact the pipeline owes us. Today the pipeline has the data in `eval-hierarchy.json` but it's nested JSON; promoting to a relational table is what unlocks the `GROUP BY` below.
|
| 80 |
+
|
| 81 |
+
## Sketch SQL query
|
| 82 |
+
|
| 83 |
+
Two scenarios as the prompt asked: (a) sub-eval rows live relationally, (b) composite membership lives relationally. Both apply here.
|
| 84 |
+
|
| 85 |
+
```sql
|
| 86 |
+
-- Per-model component-level rows for the requested composite
|
| 87 |
+
WITH suite_components AS (
|
| 88 |
+
SELECT
|
| 89 |
+
cm.suite_key,
|
| 90 |
+
cm.suite_display_name,
|
| 91 |
+
r.eval_summary_id,
|
| 92 |
+
emc.evaluation_name AS sub_eval_name,
|
| 93 |
+
r.model_id,
|
| 94 |
+
r.score,
|
| 95 |
+
r.retrieved_timestamp,
|
| 96 |
+
r.source_name,
|
| 97 |
+
r.source_type,
|
| 98 |
+
r.source_organization_name,
|
| 99 |
+
r.evaluator_relationship,
|
| 100 |
+
r.sample_size,
|
| 101 |
+
-- TS quirk #1: normalize FIRST, average LATER
|
| 102 |
+
CASE
|
| 103 |
+
WHEN (emc.metric_max_score - emc.metric_min_score) > 0
|
| 104 |
+
THEN (r.score - emc.metric_min_score) / (emc.metric_max_score - emc.metric_min_score)
|
| 105 |
+
ELSE r.score
|
| 106 |
+
END AS normalized_score,
|
| 107 |
+
emc.lower_is_better
|
| 108 |
+
FROM composite_membership cm
|
| 109 |
+
JOIN result_rows r USING (eval_summary_id)
|
| 110 |
+
JOIN eval_metric_config emc USING (eval_summary_id)
|
| 111 |
+
WHERE cm.suite_key = ? -- the param from /evals/aggregate__<suite_key>
|
| 112 |
+
),
|
| 113 |
+
|
| 114 |
+
-- Per-(suite, model) rollup
|
| 115 |
+
per_model AS (
|
| 116 |
+
SELECT
|
| 117 |
+
suite_key,
|
| 118 |
+
model_id,
|
| 119 |
+
AVG(normalized_score) AS avg_normalized_score,
|
| 120 |
+
SUM(COALESCE(sample_size, 0)) AS total_sample_size,
|
| 121 |
+
-- "Latest component" wins for source_metadata + result fields
|
| 122 |
+
arg_max(STRUCT_PACK(
|
| 123 |
+
source_name, source_type, source_organization_name,
|
| 124 |
+
evaluator_relationship
|
| 125 |
+
), retrieved_timestamp) AS latest_source_metadata,
|
| 126 |
+
MAX(retrieved_timestamp) AS evaluation_timestamp,
|
| 127 |
+
-- aggregate_components[] for drill-down rendering
|
| 128 |
+
list(STRUCT_PACK(
|
| 129 |
+
eval_summary_id,
|
| 130 |
+
composite_benchmark_name := sub_eval_name,
|
| 131 |
+
score, normalized_score, retrieved_timestamp,
|
| 132 |
+
source_name, source_type, source_organization_name, evaluator_relationship
|
| 133 |
+
) ORDER BY sub_eval_name) AS aggregate_components
|
| 134 |
+
FROM suite_components
|
| 135 |
+
GROUP BY suite_key, model_id
|
| 136 |
+
),
|
| 137 |
+
|
| 138 |
+
-- Suite-level rollup on top
|
| 139 |
+
suite_summary AS (
|
| 140 |
+
SELECT
|
| 141 |
+
suite_key,
|
| 142 |
+
COUNT(DISTINCT model_id) AS models_count,
|
| 143 |
+
AVG(avg_normalized_score) AS avg_score, -- TS quirk #2: avg-of-avgs
|
| 144 |
+
SUM(third_party_count) AS total_third_party,
|
| 145 |
+
SUM(underlying_count) AS total_underlying
|
| 146 |
+
FROM (
|
| 147 |
+
SELECT
|
| 148 |
+
suite_key, model_id, avg_normalized_score,
|
| 149 |
+
COUNT(*) FILTER (WHERE evaluator_relationship = 'third_party') AS third_party_count,
|
| 150 |
+
COUNT(*) AS underlying_count
|
| 151 |
+
FROM suite_components
|
| 152 |
+
GROUP BY suite_key, model_id, avg_normalized_score
|
| 153 |
+
)
|
| 154 |
+
GROUP BY suite_key
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
-- Final shape: per-model rows ordered by score (direction depends on lower_is_better
|
| 158 |
+
-- of the FIRST sub-eval, see TS quirk #4)
|
| 159 |
+
SELECT
|
| 160 |
+
pm.*,
|
| 161 |
+
ss.models_count,
|
| 162 |
+
ss.avg_score,
|
| 163 |
+
ss.total_third_party::DOUBLE / NULLIF(ss.total_underlying, 0) AS third_party_ratio
|
| 164 |
+
FROM per_model pm
|
| 165 |
+
JOIN suite_summary ss USING (suite_key)
|
| 166 |
+
ORDER BY pm.avg_normalized_score DESC; -- flip to ASC if first sub-eval is lower_is_better
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
+
The `evaluator_names` and `source_types` unions, the `aggregate_sources[]` list, and the `missing_generation_config_count` are scalar reductions on top β straightforward and omitted from the sketch.
|
| 170 |
+
|
| 171 |
+
## Materialize vs query-time
|
| 172 |
+
|
| 173 |
+
**Recommendation: materialize.** Pipeline emits one row per `(suite_key, model_id)` into a new parquet table (`composite_eval_rollup` or equivalent) plus the suite-level rollup as a sibling table. Runtime DuckDB just does `SELECT * WHERE suite_key = ?` β no JOIN, no AVG, no normalization at request time.
|
| 174 |
+
|
| 175 |
+
Rationale:
|
| 176 |
+
|
| 177 |
+
- **The answer is identical for every consumer.** No user-driven slicing on top of the composite (no per-category filtering, no per-developer cut). The aggregate page renders the same table to everyone who hits the same suite URL.
|
| 178 |
+
- **Composite count is small.** ~13 multi-eval families today, growing slowly; cheap to recompute on every pipeline run.
|
| 179 |
+
- **Sub-eval fan-out is the single biggest per-request cost on this route.** Today's TS path issues 2-21 (occasionally 471) `fetchHFEvalDetail` calls per page view. Materialization eliminates them entirely.
|
| 180 |
+
- **Score-normalization choice is a product decision.** Baking it into the parquet means the choice is committed once at pipeline build time. Anyone consuming the column gets the canonical answer; no consumer needs to remember "normalize before averaging."
|
| 181 |
+
|
| 182 |
+
Honest tradeoff:
|
| 183 |
+
|
| 184 |
+
- **Pipeline-side recompute on every run.** Pipeline already does a full rebuild (see `migration-plan.md` "Cross-repo coordination"), so this is "another job in the existing batch," not "a new orchestration burden."
|
| 185 |
+
- **Schema growth.** Adds two new tables (per-model rollup + suite-level rollup) plus a new `aggregate_components[]` STRUCT column. Worth it given the alternative is JSON-blob extraction at every request.
|
| 186 |
+
- **Query-time would be viable** if we wanted to support arbitrary user-specified composites (e.g. "build me an aggregate of `mmlu_pro` + `gpqa` + `humaneval`"). We don't have that product feature today and there are no signals we will. If we add it, then `suite_components` CTE above runs query-time over the relational `result_rows`/`eval_metric_config` tables; that's still cheaper than the current TS fan-out.
|
| 187 |
+
|
| 188 |
+
## TS-as-spec quirks
|
| 189 |
+
|
| 190 |
+
These are TS choices the pipeline must reproduce. Don't "fix" them β capture, ship, talk later.
|
| 191 |
+
|
| 192 |
+
1. **Score normalization happens BEFORE averaging.** `lib/model-data.ts:937-941`: `components.map(... normalizeSummaryScore(summary, modelResult.score)) ... reduce(...) / length`. This is min-max normalization per sub-eval (using each sub-eval's own `metric_config.min_score` and `metric_config.max_score`), then arithmetic mean across sub-evals. The "obvious" alternative β average raw scores then normalize once β would produce different numbers when sub-evals have different score ranges (which is the whole point of normalizing). TS's order is correct for cross-metric aggregation; the SQL must do the same. Captured in the sketch CTE: `normalize` lives in `suite_components`, `AVG` lives in `per_model`.
|
| 193 |
+
|
| 194 |
+
2. **Suite-level `avg_score` is avg-of-per-model-avgs, not avg-of-all-component-scores.** `lib/model-data.ts:990-991`: `aggregatedModelResults.reduce((sum, r) => sum + r.score, 0) / aggregatedModelResults.length`. With unbalanced sub-eval coverage (some models present in only some sub-evals), the two formulations diverge. TS picks the per-model-mean grouping; SQL must do `AVG(per_model.avg_normalized_score)`, NOT `AVG(suite_components.normalized_score)`.
|
| 195 |
+
|
| 196 |
+
3. **"Latest component wins" for the per-model `evaluation_timestamp` and `source_metadata`.** `lib/model-data.ts:943-947`: sort components DESC by `normalizeEvalTimestamp(evaluation_timestamp)`, take the first. The aggregate row's `result.*`, `source_metadata`, `source_data` all inherit from that single latest sub-eval. So a model that has 6 sub-evals rolled up shows the source metadata of whichever sub-eval was most recent, not a synthesized view. (Note the dependency on reshape spec #07 for timestamp normalization β see "Cross-item dependencies".)
|
| 197 |
+
|
| 198 |
+
4. **Sort direction comes from the FIRST sub-eval's `lower_is_better`.** `lib/model-data.ts:987-988`: `const lowerIsBetter = first.metric_config.lower_is_better`. If the suite mixes higher-is-better and lower-is-better metrics (rare today but possible β pipeline doesn't enforce homogeneity), TS picks whichever direction `summaries[0]` happens to use. The order of `summaries` is whatever `getEvalSummaryById` produces from `Promise.all(matchingEvals.map(...))`, which is the order of `eval-hierarchy.json` family.eval_summary_ids. Pipeline must preserve that order or replicate the choice (e.g. "pick `lower_is_better=false` if any sub-eval is higher-is-better").
|
| 199 |
+
|
| 200 |
+
5. **`latest_source_name` is the literal string `"Multiple sources"` when len > 1.** `lib/model-data.ts:1021-1022`. Single-sub-eval composites get the real name; multi-sub-eval composites get a fixed sentinel. Don't try to be smart and concatenate names β the consumer (`eval-detail.tsx:540-541`) already does that separately from the `aggregate_sources[]` array.
|
| 201 |
+
|
| 202 |
+
6. **Suite display name comes from the `BENCHMARK_NAMES` lookup, NOT from `eval-hierarchy.json`'s `display_name`.** `lib/model-data.ts:903`: `getBenchmarkDisplayName(aggregationKey)` falls through to the `humanizeToken` fallback if not in the hand-curated map. This is migration item #8 (benchmark display names) β capture as a dependency, but in the rollup itself the display name is derived from the `suite_key` not joined from the hierarchy.
|
| 203 |
+
|
| 204 |
+
7. **Within-composite sub-eval ordering is alphabetical by `composite_benchmark_name`.** Both `aggregateSources` (line 900) and per-model `aggregate_components` (line 962) are `.sort((a, b) => a.composite_benchmark_name.localeCompare(b.composite_benchmark_name))`. Stable English-locale sort. SQL `ORDER BY sub_eval_name` reproduces this.
|
| 205 |
+
|
| 206 |
+
8. **`composite_benchmark_name` for each component uses the SUB-EVAL's own `evaluation_name`, not the parent suite name.** `lib/model-data.ts:894`: `composite_benchmark_name: summary.evaluation_name`. The field name is misleading; in the per-component context it means "the sub-eval's own display name" (this is what the drill-down UI in `eval-detail.tsx:1046+` renders).
|
| 207 |
+
|
| 208 |
+
9. **`metric_config` for the aggregate is synthesized.** `lib/model-data.ts:924-933`: takes `first.metric_config` (i.e. first sub-eval's), then forces `min_score=0`, `max_score=1`, `unit="normalized average"`, and rewrites `evaluation_description` to `"Average normalized score across <comma-separated sorted sub-eval names>"` when more than one source. The `lower_is_better` and `score_type` are inherited from the first sub-eval verbatim β so if the composite mixes `binary` and `continuous` sub-evals, the aggregate is reported as whatever the first one is.
|
| 209 |
+
|
| 210 |
+
## Cross-item dependencies
|
| 211 |
+
|
| 212 |
+
- **#7 timestamp normalization** β the "latest component wins" logic (TS quirk #3) calls `normalizeEvalTimestamp` (Variant A from the timestamp spec). Once timestamps are ISO 8601 upstream and the reshape half lives in SQL, `MAX(retrieved_timestamp)` and `arg_max(..., retrieved_timestamp)` replace the TS sort. Order of operations: timestamp canonicalization should land first (or co-land), so the SQL's lexicographic `MAX` is correct.
|
| 213 |
+
- **#8 benchmark display names** β `getBenchmarkDisplayName(suite_key)` provides the suite's user-facing label (TS quirk #6). When #8 ships and the pipeline emits canonical display names, the rollup table can drop `suite_display_name` and join against the canonical table.
|
| 214 |
+
- **#3 hierarchy flatten** β the familyβsub-eval mapping (`composite_membership` table above) is what `eval-hierarchy.json` already encodes as nested JSON. #3 will likely promote the hierarchy to a relational table; this rollup spec depends on that promotion (or a sidecar table built specifically for composites).
|
| 215 |
+
- **#11 benchmark-card attachment** β TS calls `attachBenchmarkCardToSummary(hfEvalDetailToSummary(detail))` per sub-eval before passing to `aggregateBenchmarkSummaries` (line 1562). The `benchmark_card` of `summaries[0]` becomes the aggregate's `benchmark_card`. Once benchmark cards are inlined upstream by #11, this attachment step disappears.
|
| 216 |
+
- **Composite of all four**: only when 7+8+3 (and ideally 11) are landed can the whole rollup move to materialized parquet cleanly. Without 7 the latest-component logic is brittle; without 3 there's no clean way to drive the rollup loop on the pipeline side; without 8 the suite display name has to be a TS lookup post-hoc.
|
| 217 |
+
|
| 218 |
+
## Migration checklist
|
| 219 |
+
|
| 220 |
+
- [x] Spec written (operation captured in SQL terms; TS quirks documented)
|
| 221 |
+
- [ ] Pipeline-schema conversation: decide whether to (a) promote `result_rows`/`eval_metric_config` to relational columns and `composite_membership` to a sidecar table, then materialize the rollup, or (b) ship a single pre-computed `composite_eval_rollup` parquet that bakes in TS's choices as columns. Recommendation: (b) for the rollup itself, with (a) as a parallel deliverable so other reshape items (matrix, top-scores, summary stats) can share the relational base.
|
| 222 |
+
- [ ] Pipeline emits the materialized rollup; verify per-model `avg_normalized_score`, suite `avg_score`, `latest_source_metadata`, sort order, `aggregate_components[]` for at least the 13 multi-eval families.
|
| 223 |
+
- [ ] Update `getEvalSummaryById` to read the rollup directly when `evalId.startsWith("aggregate__")` instead of fanning out `fetchHFEvalDetail` calls.
|
| 224 |
+
- [ ] Delete `aggregateBenchmarkSummaries` (`lib/model-data.ts:877-1044`) and the `Promise.all` fan-out in `getEvalSummaryById` (`lib/model-data.ts:1543-1568`).
|
| 225 |
+
|
| 226 |
+
## Future product decisions (deferred)
|
| 227 |
+
|
| 228 |
+
- Whether to support **user-defined composites** (build an aggregate from arbitrary sub-evals at request time). Today the answer is no; if it becomes yes, the relational `result_rows`/`eval_metric_config` path becomes the load-bearing one and the materialized rollup becomes the cached fast-path for the curated 13.
|
| 229 |
+
- Whether to expose **non-normalized averages** alongside the normalized one. TS hides the raw average; consumers asking "what's the actual MMLU score?" have to look at individual sub-evals. A pre-materialized rollup makes both columns equally cheap to surface.
|
| 230 |
+
- Whether the **`avg_score = avg-of-per-model-avgs`** choice (TS quirk #2) is the right one when sub-eval coverage is unbalanced. Don't fix in this migration.
|
notes/transformations/reshape/06-matrix-leaderboard.md
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Matrix leaderboard synthesis
|
| 2 |
+
|
| 3 |
+
Drafted 2026-04-28. Migration item #6 in `notes/migration-plan.md`. **Reshape-class** β per `notes/migration-plan.md` Β§ "Data direction", the destination is DuckDB SQL (either materialized into parquet or computed at query time), not a pipeline value-emit.
|
| 4 |
+
|
| 5 |
+
## Framing reminder
|
| 6 |
+
|
| 7 |
+
We are **refactoring for UI efficiency**, not fixing correctness. TS-as-is is the canonical spec β including its filters and tie-breakers. The deliverable here is an **operation catalog** (GROUP BY keys, aggregations, joins, PIVOT shape) rather than a rule-by-rule replication, so the pipeline-schema / SQL conversation can happen against a precise target.
|
| 8 |
+
|
| 9 |
+
## Migration item
|
| 10 |
+
|
| 11 |
+
- **Item:** #6 matrix leaderboard synthesis
|
| 12 |
+
- **TS location:** `lib/model-data.ts:1046-1210` (`buildSingleMetricSuiteMatrixSummary`, ~165 lines), called from `lib/model-data.ts:1570-1596` inside `getEvalSummaryById`
|
| 13 |
+
- **Trigger:** request to `/evals/matrix__<suite_key>` (URL-side prefix `matrix__` is matched at line 1570; `suite_key` is the `benchmark_parent_key` / `benchmark_family_key` / `benchmark` of a group of sub-evals)
|
| 14 |
+
- **Output:** a synthetic `BenchmarkEvalSummary` whose `leaderboard_metrics` are columns (subtasks) and `leaderboard_rows` are rows (models Γ score per subtask)
|
| 15 |
+
|
| 16 |
+
## Classification
|
| 17 |
+
|
| 18 |
+
- **Reshape / dedup / aggregate / pivot.** Not a value cleanup β it's a longβwide pivot over many sub-eval `model_results` rows.
|
| 19 |
+
- **Both materialize and query-time are viable, with a split** (see "Materialize vs query-time" below).
|
| 20 |
+
|
| 21 |
+
## Operation in SQL terms
|
| 22 |
+
|
| 23 |
+
Conceptually the operation is: **for each suite, PIVOT every (sub-eval, model) score into a model Γ subtask matrix, taking the most-recent submission per cell.**
|
| 24 |
+
|
| 25 |
+
In one sentence: `PIVOT scores ON subtask USING max(score) FILTER (most-recent submission per (model, subtask)) GROUP BY model`.
|
| 26 |
+
|
| 27 |
+
Pre-pivot input shape (one row per submission of a model on a sub-eval / metric):
|
| 28 |
+
|
| 29 |
+
```
|
| 30 |
+
suite_key, subtask_key, subtask_name, metric_summary_id, metric_key, metric_name,
|
| 31 |
+
model_id, model_name, developer, model_route_id,
|
| 32 |
+
score, retrieved_timestamp, source_metadata, source_data
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
The TS implementation is a manual pivot built from JSON: it walks each sub-eval detail, takes its sole metric's `model_results[]`, and folds each row into `rowStates: Map<modelId, {values: {[columnKey]: score}, ...}>` keyed by `model_id`. Dedup-on-`(model_id, columnKey)` happens implicitly because each later assignment overwrites the previous; the per-row "winning timestamp" is the **highest seen** across all column writes (see TS-as-spec quirk #2 below).
|
| 36 |
+
|
| 37 |
+
### Filtering rules applied before the pivot
|
| 38 |
+
|
| 39 |
+
1. **Eligibility filter on sub-evals (column gate):** keep `detail` only if `detail.metrics.length === 1` AND `extractDetailSubtasks(detail).length === 0`. In SQL: keep sub-evals that have exactly one root metric and zero subtasks of their own. Suites that contain a multi-metric or multi-subtask sub-eval **silently drop that sub-eval as a column** (no diagnostic).
|
| 40 |
+
2. **Suite-eligibility floor:** if fewer than 2 sub-evals survive the filter, return null (no matrix). Same floor on `matchingEvals` at the call site (line 1585: `if (matchingEvals.length < 2) return null`).
|
| 41 |
+
3. **Metrics-count floor:** after the loop, `if (leaderboardMetrics.length < 2) return null` (line 1158).
|
| 42 |
+
4. **Summary-score exclusion:** at the call site (line 1575), `entry.is_summary_score === true` rows are excluded from the candidate set entirely.
|
| 43 |
+
|
| 44 |
+
### Column derivation
|
| 45 |
+
|
| 46 |
+
```
|
| 47 |
+
column_key = "subtask:" || subtask_key || ":" || metric_token
|
| 48 |
+
```
|
| 49 |
+
where `subtask_key = detail.benchmark_leaf_key || slugify(detail.eval_summary_id)` and `metric_token = metric.metric_summary_id || metric.metric_key || slugify(metric.display_name)`. Column order is **alphabetical by `benchmark_leaf_name || eval_summary_id`** (line 1059-1061), not by the column_key itself.
|
| 50 |
+
|
| 51 |
+
### Row derivation
|
| 52 |
+
|
| 53 |
+
Rows are keyed by `model_id = modelResult.model_id || modelResult.model_name`. Models with neither id nor name are dropped silently (line 1118-1120).
|
| 54 |
+
|
| 55 |
+
### Cell value
|
| 56 |
+
|
| 57 |
+
`row.values[columnKey] = modelResult.score ?? null`. Last write wins per `(model_id, columnKey)` pair β but in TS the loop visits each `(detail, model_result)` tuple exactly once, and `columnKey` is unique per `detail`, so "last write" only fires when a single sub-eval contains multiple `model_results` rows for the same `model_id` (i.e. multiple submissions of the same model to the same sub-eval). When that happens, **the last one in iteration order wins**, with no explicit tie-break β see TS-as-spec quirk #1.
|
| 58 |
+
|
| 59 |
+
### Per-row timestamp & source-metadata reconciliation
|
| 60 |
+
|
| 61 |
+
For each row, three fields (`evaluation_timestamp`, `source_metadata`, `source_data`) are reconciled across all the cells written to that row. The rule: whichever cell write had the **highest `normalizeEvalTimestamp`** wins these fields (line 1149-1154). If timestamps tie, the first-seen cell keeps them (the comparison is `>=`, but the first write happens in the no-existing branch on line 1127-1142 which initializes them; later writes only overwrite if strictly greater than or equal).
|
| 62 |
+
|
| 63 |
+
## Required parquet columns (input to the SQL pivot)
|
| 64 |
+
|
| 65 |
+
The pivot needs one row per `(eval_summary_id, model_id, retrieved_timestamp)` with these fields exposed as typed columns (today they're nested in `payload_json`):
|
| 66 |
+
|
| 67 |
+
| Column | Source in TS | Used for |
|
| 68 |
+
|---|---|---|
|
| 69 |
+
| `eval_summary_id` | `detail.eval_summary_id` | filter to suite, also slug fallback |
|
| 70 |
+
| `benchmark_parent_key` | `entry.benchmark_parent_key` | suite routing (matched against `suite_key` from URL) |
|
| 71 |
+
| `benchmark_family_key` | `entry.benchmark_family_key` | suite routing fallback |
|
| 72 |
+
| `benchmark` | `entry.benchmark` | suite routing fallback |
|
| 73 |
+
| `is_summary_score` | `entry.is_summary_score` | exclude rollup rows |
|
| 74 |
+
| `benchmark_leaf_key` | `detail.benchmark_leaf_key` | column key |
|
| 75 |
+
| `benchmark_leaf_name` | `detail.benchmark_leaf_name` | column display + sort key |
|
| 76 |
+
| `metric_count_in_detail` | derived: `len(metrics)` | column-eligibility filter |
|
| 77 |
+
| `subtask_count_in_detail` | derived: `len(extractDetailSubtasks(detail))` | column-eligibility filter |
|
| 78 |
+
| `metric_summary_id` | `metric.metric_summary_id` | column key + metric_config |
|
| 79 |
+
| `metric_key` | `metric.metric_key` | column key fallback |
|
| 80 |
+
| `metric_name` | `metric.metric_name` | display |
|
| 81 |
+
| `metric_display_name` | `metric.display_name` | column key fallback (slugified) |
|
| 82 |
+
| `lower_is_better` | `metric.lower_is_better` | column metadata |
|
| 83 |
+
| `unit` | `metric.unit` | column metadata |
|
| 84 |
+
| `evaluation_description` | `metric.evaluation_description` | suite metric_config |
|
| 85 |
+
| `min_score`, `max_score`, `score_type` | `metric.metric_config.*` | suite metric_config |
|
| 86 |
+
| `model_id` | `result.model_id` | row key |
|
| 87 |
+
| `model_name` | `result.model_name` | row display + row key fallback |
|
| 88 |
+
| `model_route_id` | `result.model_route_id` | model linkout |
|
| 89 |
+
| `developer` | `result.developer` | row display |
|
| 90 |
+
| `score` | `result.score` | cell value |
|
| 91 |
+
| `retrieved_timestamp` | `result.retrieved_timestamp` | per-row reconciliation tie-breaker |
|
| 92 |
+
| `source_metadata` (struct) | `result.source_metadata` | per-row reconciliation winner |
|
| 93 |
+
| `source_data` (struct) | `detail.source_data` | per-row reconciliation winner |
|
| 94 |
+
| `benchmark_card` (struct) | `detail.benchmark_card` | first-seen β suite-level field |
|
| 95 |
+
|
| 96 |
+
The `(detail, metric, model_result)` triple is what TS already iterates β promoting these to flat parquet rows is the schema change.
|
| 97 |
+
|
| 98 |
+
## Sketch SQL query
|
| 99 |
+
|
| 100 |
+
DuckDB syntax. Three CTEs: (1) filter to eligible sub-evals, (2) pick winning submission per `(model, subtask)`, (3) PIVOT.
|
| 101 |
+
|
| 102 |
+
```sql
|
| 103 |
+
WITH suite_rows AS (
|
| 104 |
+
-- Pre-pivot: one row per (sub-eval, model, submission)
|
| 105 |
+
-- Suite-eligibility + column-eligibility filters live here
|
| 106 |
+
SELECT
|
| 107 |
+
eval_summary_id,
|
| 108 |
+
benchmark_leaf_key,
|
| 109 |
+
benchmark_leaf_name,
|
| 110 |
+
metric_summary_id,
|
| 111 |
+
metric_key,
|
| 112 |
+
metric_name,
|
| 113 |
+
metric_display_name,
|
| 114 |
+
lower_is_better,
|
| 115 |
+
unit,
|
| 116 |
+
model_id,
|
| 117 |
+
model_name,
|
| 118 |
+
developer,
|
| 119 |
+
model_route_id,
|
| 120 |
+
score,
|
| 121 |
+
retrieved_timestamp,
|
| 122 |
+
source_metadata,
|
| 123 |
+
source_data,
|
| 124 |
+
-- Synthetic column key matching TS's "subtask:<subtask_key>:<metric_token>"
|
| 125 |
+
'subtask:' ||
|
| 126 |
+
coalesce(benchmark_leaf_key, slugify(eval_summary_id)) || ':' ||
|
| 127 |
+
coalesce(metric_summary_id, metric_key, slugify(metric_display_name))
|
| 128 |
+
AS column_key
|
| 129 |
+
FROM eval_results_flat
|
| 130 |
+
WHERE
|
| 131 |
+
-- Suite routing β matches TS's normalizeBenchmarkKeyForLookup
|
| 132 |
+
normalize_bench_key(coalesce(benchmark_parent_key, benchmark_family_key, benchmark)) = ?
|
| 133 |
+
AND NOT is_summary_score
|
| 134 |
+
-- Column eligibility: single root metric, no subtasks (TS line 1058)
|
| 135 |
+
AND metric_count_in_detail = 1
|
| 136 |
+
AND subtask_count_in_detail = 0
|
| 137 |
+
),
|
| 138 |
+
ranked AS (
|
| 139 |
+
-- Pick the winning submission per (model, subtask) cell.
|
| 140 |
+
-- TS uses "last write wins" because each (detail Γ model) pair is visited once
|
| 141 |
+
-- in iteration order; multiple submissions to the same sub-eval by the same model
|
| 142 |
+
-- collapse to whichever appears last in model_results[]. We approximate that with
|
| 143 |
+
-- ROW_NUMBER ordered by retrieved_timestamp DESC; see TS-as-spec quirk #1.
|
| 144 |
+
SELECT *,
|
| 145 |
+
ROW_NUMBER() OVER (
|
| 146 |
+
PARTITION BY model_id, column_key
|
| 147 |
+
ORDER BY retrieved_timestamp DESC
|
| 148 |
+
) AS rn
|
| 149 |
+
FROM suite_rows
|
| 150 |
+
WHERE model_id IS NOT NULL OR model_name IS NOT NULL
|
| 151 |
+
),
|
| 152 |
+
winners AS (
|
| 153 |
+
SELECT * FROM ranked WHERE rn = 1
|
| 154 |
+
)
|
| 155 |
+
-- The pivot. DuckDB PIVOT syntax:
|
| 156 |
+
PIVOT winners
|
| 157 |
+
ON column_key
|
| 158 |
+
USING max(score)
|
| 159 |
+
GROUP BY model_id, model_name, developer, model_route_id;
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
Then a second pass over `winners` derives the per-row reconciled `evaluation_timestamp / source_metadata / source_data` (highest-timestamp cell wins) and the `metrics_present` count (count of non-null cells per row).
|
| 163 |
+
|
| 164 |
+
For the suite-level fields (`leaderboard_metrics` array, `metric_config`, `benchmark_card`, `evaluation_name`), a separate aggregation over `winners` collects the distinct columns (`SELECT DISTINCT column_key, benchmark_leaf_name, metric_name, β¦`) sorted by `benchmark_leaf_name`, plus a `MIN()` or first-row pick for `benchmark_card` and `metric_config`.
|
| 165 |
+
|
| 166 |
+
The "single row per model with a values map" output shape is still ergonomic to assemble TS-side from the PIVOT result; the heavy lifting (filter, dedup, pivot) is in SQL.
|
| 167 |
+
|
| 168 |
+
## Materialize vs query-time
|
| 169 |
+
|
| 170 |
+
Two natural splits, both can ship:
|
| 171 |
+
|
| 172 |
+
- **Materialize the column shape and per-cell winners.** A suite's column set is fixed by its sub-eval inventory; the per-cell winner across submissions is deterministic given the data. Both can be written into parquet at pipeline build time as a derived `matrix_<suite_key>` table (or one wide `eval_matrix` table partitioned by suite). This eliminates request-time work for the dominant case.
|
| 173 |
+
- **Compute the row filter at query time.** Today TS doesn't filter rows at all (every model with a `model_id` shows up). Forthcoming UI work may want consumer-driven row filters: "only show models with `models_count > N` evaluations", "only third-party submissions", "filter by developer", "top-K by mean score". Those are query-time concerns and want SQL β `WHERE` / `LIMIT` against the materialized matrix.
|
| 174 |
+
|
| 175 |
+
**Recommendation:** materialize the wide-form `(suite_key, model_id) β values map + reconciled metadata` table; expose row-filter / sort knobs as query-time SQL parameters. Recompute the materialized table on every pipeline build (cheap relative to the rest of the build); recompute on demand if a single benchmark family is added.
|
| 176 |
+
|
| 177 |
+
The column-eligibility filter (`metric_count = 1 AND subtask_count = 0`) is a build-time concern β it never varies per request. The row-filter is the only thing that should remain query-time.
|
| 178 |
+
|
| 179 |
+
## TS-as-spec quirks
|
| 180 |
+
|
| 181 |
+
These are deliberate behaviours of the current TS that the SQL replacement must preserve until a separate product call says otherwise.
|
| 182 |
+
|
| 183 |
+
1. **Cell tie-break on multiple submissions of the same model to the same sub-eval is "last in iteration order wins", with no explicit ordering of `model_results[]`.** TS line 1117 iterates `metric.model_results ?? []` directly; the order is whatever the pipeline emitted. There is no `MAX(score)` or "freshest wins" sort applied β the loop just overwrites `row.values[columnKey]` on each pass. In SQL this maps cleanly to `ROW_NUMBER() OVER (PARTITION BY model_id, column_key ORDER BY retrieved_timestamp DESC) = 1` only if the pipeline already emits `model_results[]` in retrieved_timestamp-DESC order. **Verify this assumption against the pipeline's actual emission order before flipping the SQL on.** If pipeline order is non-deterministic, the SQL will produce different cell values than TS for cells with multiple submissions β the migration must either (a) accept the divergence as a "freshest wins" upgrade, or (b) reproduce pipeline's exact array order, which is hostile.
|
| 184 |
+
|
| 185 |
+
2. **Per-row timestamp reconciliation uses `>=` not `>`.** Line 1149: `if (nextTimestamp >= existing._timestampValue)`. Combined with quirk #1, this means for a model with multiple cells at identical timestamps, the **last-written cell's** source_metadata / source_data win. The SQL replacement should pick the source_metadata of the row with the highest `MAX(retrieved_timestamp)` across all of the model's cells; ties resolve to whatever DuckDB picks (non-deterministic, but identical-timestamp ties are rare in practice).
|
| 186 |
+
|
| 187 |
+
3. **Silent column drops.** Sub-evals failing the `metric_count === 1 && subtask_count === 0` filter are excluded with no diagnostic. In production this means HELM Lite (10 sub-evals) might silently surface fewer columns than expected if any sub-eval has nested metrics. The pipeline owner should know this is "by design" until the product team weighs in.
|
| 188 |
+
|
| 189 |
+
4. **`column_key` collisions on multi-metric same-subtask are impossible by construction.** Because the eligibility filter forces `metric_count === 1` per sub-eval, each `(subtask_key, metric_token)` column is unique. If the filter is loosened later, the column_key derivation (`subtask:K:M`) is collision-safe.
|
| 190 |
+
|
| 191 |
+
5. **No row filtering applied.** Every model id encountered is rendered as a row (subject to having a `model_id` or `model_name`). Row-count for popular suites: `helm_lite` β 91 distinct models in the dominant sub-eval alone. This is fine for now; capture as a known query-time-extension point.
|
| 192 |
+
|
| 193 |
+
6. **Score of 0 vs null distinction.** `modelResult.score ?? null` β only `undefined` becomes null; numeric 0 is preserved. SQL `MAX(score)` over a single row preserves the 0; over multiple rows with one being NULL, it returns the non-null. Equivalent in this case because the dedup happens before the pivot.
|
| 194 |
+
|
| 195 |
+
7. **Timestamp normalization uses Variant A (`normalizeEvalTimestamp` from `lib/model-data.ts:76-81`),** which has its own quirks documented in `notes/transformations/07-timestamp-normalization.md` (negative-string fallback, empty-string returns 0, etc.). This is shared with the composite rollup (#5) and other model-data call sites β once #13 ships pipeline-emitted ISO 8601 timestamps, the timestamp comparison in this query becomes a plain `MAX(retrieved_timestamp)` (lexicographic on ISO strings).
|
| 196 |
+
|
| 197 |
+
8. **Suite-level metric_config takes the first eligible sub-eval's config** (line 1083-1085: `if (!metricConfig) metricConfig = ...`). If sub-evals disagree on `min_score / max_score / lower_is_better`, **the alphabetically-first sub-eval's values are used for the whole suite**. Document this; SQL's equivalent is `FIRST(metric_config ORDER BY benchmark_leaf_name)`.
|
| 198 |
+
|
| 199 |
+
9. **`benchmark_card` takes the first sub-eval that has one** (line 1087-1089: `if (!benchmarkCard && detail.benchmark_card) benchmarkCard = ...`). Same pattern as #8.
|
| 200 |
+
|
| 201 |
+
10. **Cross-row `sharedMetricName`** is set only if every sub-eval reports the same `metric_name` (line 1162). When metrics differ across sub-evals, the suite-level `evaluation_description` falls back to the first sub-eval's metric description, but the suite is still rendered. SQL: `MIN(metric_name) FILTER (WHERE metric_name IS NOT NULL)` only when `COUNT(DISTINCT metric_name) = 1`, else null.
|
| 202 |
+
|
| 203 |
+
## Cross-item dependencies
|
| 204 |
+
|
| 205 |
+
- **#13 timestamp normalization** β the `MAX(retrieved_timestamp)` per `(model, subtask)` only works correctly once timestamps are in a comparable canonical form. Until pipeline emits ISO 8601, this query depends on Variant A's seconds-vs-ms quirks. Ship #13 first.
|
| 206 |
+
- **#5 composite rollup** β sibling reshape that walks the same `details[]` set. The two should share the parquet schema (a flat `eval_results_flat` row table). Spec'd separately but coordinate.
|
| 207 |
+
- **#11 benchmark-card attachment** β the matrix synthesis result is wrapped in `attachBenchmarkCardToSummary()` after build (line 1595). That join is a separate item; the matrix spec assumes it runs as-is.
|
| 208 |
+
- **#1 identity canonicalization** β the matrix uses `model_id` as the row key. If pipeline-side identity canonicalization changes any model ids, matrix rows that previously merged will split (or vice versa). Migration order: ship #1 first, then re-run snapshot.
|
| 209 |
+
- **#8 benchmark display names** β the suite-level `evaluation_name = getBenchmarkDisplayName(suiteKey)` depends on the BENCHMARK_NAMES map. Once #8 ships pipeline-emitted display names, replace inline.
|
| 210 |
+
|
| 211 |
+
## Scope (informs "happens on every page view" framing)
|
| 212 |
+
|
| 213 |
+
Audited 2026-04-28 against `.cache/hf-data/eval-list-lite.json`:
|
| 214 |
+
|
| 215 |
+
- **587** total eval-list rows; **34** distinct parent buckets (`benchmark_parent_key || benchmark_family_key || benchmark`).
|
| 216 |
+
- **13** matrix-eligible buckets (β₯2 sub-evals after `is_summary_score` exclusion). Each one fires `buildSingleMetricSuiteMatrixSummary` exactly once per `/evals/matrix__<suite_key>` request.
|
| 217 |
+
- Sub-eval count per matrix request:
|
| 218 |
+
- **median 6**, **mean 43.5**, **max 471** (`llm_stats`), **min 2** (`reward_bench`).
|
| 219 |
+
- Other notable suites: `artificial_analysis_llms` (21), `helm_classic` (15), `helm_lite` (10), `swe_polybench` (8), `helm_instruct` (7), `hfopenllm_v2` / `helm_safety` / `helm_capabilities` / `multi_swe_bench` (6).
|
| 220 |
+
- Each sub-eval detail file carries one metric with up to ~91 `model_results` (sampled `helm_lite_*`).
|
| 221 |
+
|
| 222 |
+
**Per-request work today:** for `helm_lite`, one matrix render fetches 10 detail files (up to ~10 KB each from cache) and walks ~10 Γ ~91 = ~910 (detail, model_result) tuples in TS. For `llm_stats`, that's 471 detail fetches and tens of thousands of tuples β every page view. SQL materialization eliminates this entirely; query-time SQL with row filters bounds it.
|
| 223 |
+
|
| 224 |
+
## Migration checklist
|
| 225 |
+
|
| 226 |
+
- [x] Spec written (TS-as-is, including quirks)
|
| 227 |
+
- [ ] Snapshot test for `buildSingleMetricSuiteMatrixSummary` against curated detail set (Tier B; gate for SQL replacement β see `notes/testing-strategy.md` Β§ "Reshape-class items: testing addendum")
|
| 228 |
+
- [ ] Pipeline-schema decision: promote `(eval_summary_id, model_id, metric, retrieved_timestamp)` to typed parquet columns for the matrix input set
|
| 229 |
+
- [ ] SQL implementation in `lib/duckdb-data.ts` (or pipeline-side materialized view) reproducing the pivot
|
| 230 |
+
- [ ] Parity gate: TS-vs-SQL diff zero across all 13 matrix-eligible suites
|
| 231 |
+
- [ ] TS deleted: `buildSingleMetricSuiteMatrixSummary` (lines 1046-1210), call site collapsed to `getEvalSummaryById` reading the materialized matrix or invoking the SQL
|
| 232 |
+
|
| 233 |
+
## Future product decision (deferred)
|
| 234 |
+
|
| 235 |
+
- Whether silent column drops (TS-as-spec quirk #3) should surface a UI affordance ("3 sub-evals omitted because they have nested metrics").
|
| 236 |
+
- Whether row filtering should be exposed as a UI knob (developer / source_type / models_count threshold) β quirk #5.
|
| 237 |
+
- Whether suite-level `metric_config` should be a hard error when sub-evals disagree, rather than alphabetically-first-wins (quirk #8).
|
| 238 |
+
- Whether to replace "last-write-wins on duplicate submissions" with explicit "freshest-timestamp wins" (quirk #1) β likely a no-op in production but a real semantic upgrade.
|
notes/transformations/reshape/14-score-summary-stats.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Score summary stats (per-eval aggregations)
|
| 2 |
+
|
| 3 |
+
Drafted 2026-04-28. Migration item #14 in `notes/migration-plan.md`. Reshape-class.
|
| 4 |
+
|
| 5 |
+
## Framing reminder
|
| 6 |
+
|
| 7 |
+
Refactoring for UI efficiency, not fixing data correctness. TS-as-is is the canonical spec β its quirks (in particular, *re-deriving* `models_count` and effectively-`top_score` even though pipeline already emits them per eval; ignoring the metric primary-vs-leaderboard distinction; using `metric_config.{min,max}_score` defaults of 0/1 to "normalize" already-0-to-1 scores into themselves) must be reproduced when the operation moves to SQL or be explicitly accepted as divergences.
|
| 8 |
+
|
| 9 |
+
This is a textbook reshape: a `GROUP BY eval_summary_id` over the underlying `model_results` rows with arithmetic + set aggregations. It depends on item #2 (variant bucket reduction) being landed first β the per-eval rows the GROUP BY runs over are *already-deduped* model_result rows, not raw submissions.
|
| 10 |
+
|
| 11 |
+
## Operation in SQL terms
|
| 12 |
+
|
| 13 |
+
Input: one row per `(eval_summary_id, model_result)` from the post-#2-dedup `metric.model_results[]` array (one row per `(model_id Γ variant_key Γ evaluation_metric)` after the variant bucket reduction). Each row carries `score`, `evaluation_timestamp` / `retrieved_timestamp`, `source_metadata.{source_type, source_organization_name, evaluator_relationship, source_name}`, `generation_config` presence flag.
|
| 14 |
+
|
| 15 |
+
Output: one row per `eval_summary_id` with the following aggregated columns:
|
| 16 |
+
|
| 17 |
+
| Column | Aggregation | Notes |
|
| 18 |
+
|---|---|---|
|
| 19 |
+
| `models_count` | `COUNT(*)` | over the deduped model_results, NOT distinct model_id |
|
| 20 |
+
| `avg_score` | `AVG(score)` | raw score, no min/max scaling |
|
| 21 |
+
| `avg_score_norm` | `(AVG(score) - min_score) / (max_score - min_score)` | from per-eval `metric_config`; default `min=0`, `max=1`, β `range=1` so score already-in-[0,1] is unchanged |
|
| 22 |
+
| `best_model` | `(model_name, score)` of `MIN(score)` if `lower_is_better` else `MAX(score)` | tie-break: input order (TS uses stable sort) |
|
| 23 |
+
| `worst_model` | mirror of best_model | |
|
| 24 |
+
| `evaluator_names` | `array_agg(DISTINCT source_organization_name)` | TS preserves *insertion order* (no sort); see TS-as-spec quirks |
|
| 25 |
+
| `source_types` | `array_agg(DISTINCT source_type ORDER BY source_type)` | locale-compare sort |
|
| 26 |
+
| `latest_source_name` | `arg_max(source_name, comparable_timestamp)` over result rows | with `>=` tiebreak (not `>`) β last-wins on ties; see #13 timestamp normalization caveat |
|
| 27 |
+
| `third_party_ratio` | `COUNT(*) FILTER (WHERE evaluator_relationship = 'third_party') / COUNT(*)` | denominator is `models_count` |
|
| 28 |
+
| `missing_generation_config_count` | `COUNT(*) FILTER (WHERE generation_config IS NULL)` | absent/null/empty all count as missing in TS |
|
| 29 |
+
|
| 30 |
+
The grouping happens **per `eval_summary_id`** (which corresponds to one `(benchmark_leaf_key, primary_metric)` slice). Composite-benchmark rollups across multiple eval_summary_ids are item **#5** (`aggregateBenchmarkSummaries`), out of scope here.
|
| 31 |
+
|
| 32 |
+
## Current TS implementation
|
| 33 |
+
|
| 34 |
+
| Concern | Location | Notes |
|
| 35 |
+
|---|---|---|
|
| 36 |
+
| Multi-source-of-truth groupby finalisation | `lib/eval-processing.ts:946-991` (`groupEvaluationsByBenchmark`) | runs over `BenchmarkEvaluation[]` loaded from caches; legacy path. Builds the GROUP BY by accumulating into an object, then iterates and computes the aggregations in a separate `for` loop. |
|
| 37 |
+
| HF-detail-derived single-eval finalisation | `lib/model-data.ts:751-853` (`hfEvalDetailToSummary`) | runs over a single `HFEvalDetail` (one `eval_summary_id`); reads `metric.model_results` of the `primaryMetric` (= `allMetrics[0]`). This is the *active* path used by `app/evals/[id]/page.tsx` via `getEvalSummaryById` (line 1601, 1562). |
|
| 38 |
+
| HF-detail empty-metric short-circuit | `lib/model-data.ts:772-804` | when no primary metric, returns a zero-stats summary with `models_count=0`, `avg_score=0`, `best/worst_model=null`, `latest_source_name = getBenchmarkDisplayName(benchmarkKey)` β note this is the **benchmark display name**, not a real source name. TS-as-spec quirk #1. |
|
| 39 |
+
| Score normalization helper | `lib/model-data.ts:83-88` (`normalizeSummaryScore`) | `min=0`, `max=1`, `range=1` defaults β so `avg_score_norm = avg_score` for the 0-1 score case. |
|
| 40 |
+
| Per-row timestamp normalization (inline) | `lib/model-data.ts:76-81` (`normalizeEvalTimestamp`) and inline duplicate at `lib/eval-processing.ts:961-967` | Variant A from `notes/transformations/07-timestamp-normalization.md`; latest-wins comparison uses `>=` (last-wins on tie). |
|
| 41 |
+
| Per-row score timestamp source field | `lib/eval-processing.ts:933` uses `result.evaluation_timestamp`; `lib/model-data.ts:717` uses `mr.retrieved_timestamp ?? ""` then sets `evaluation_timestamp` from it | The two paths read different upstream fields under the hood; pipeline must ensure both resolve to the same canonical timestamp. |
|
| 42 |
+
|
| 43 |
+
Confirmed line numbers (verified 2026-04-28):
|
| 44 |
+
- `groupEvaluationsByBenchmark` body: `lib/eval-processing.ts:893-994`
|
| 45 |
+
- Finalisation loop: `lib/eval-processing.ts:946-991`
|
| 46 |
+
- `hfEvalDetailToSummary` body: `lib/model-data.ts:751-854`
|
| 47 |
+
- `normalizeEvalTimestamp`: `lib/model-data.ts:76-81`
|
| 48 |
+
- `normalizeSummaryScore`: `lib/model-data.ts:83-88`
|
| 49 |
+
|
| 50 |
+
## Required parquet columns
|
| 51 |
+
|
| 52 |
+
For SQL to do this work directly without pulling `payload_json`, parquet needs (per deduped model_result row, post-#2):
|
| 53 |
+
|
| 54 |
+
- `eval_summary_id` (already a metadata column)
|
| 55 |
+
- `model_id` / `model_route_id` / `model_name` (currently nested in `metrics[].model_results[].*` inside payload)
|
| 56 |
+
- `score` (currently nested)
|
| 57 |
+
- `retrieved_timestamp` (currently nested; ISO-canonicalized after item #13)
|
| 58 |
+
- `source_metadata.source_type` (currently nested)
|
| 59 |
+
- `source_metadata.source_organization_name` (currently nested)
|
| 60 |
+
- `source_metadata.evaluator_relationship` (currently nested)
|
| 61 |
+
- `source_metadata.source_name` (currently nested)
|
| 62 |
+
- `generation_config` presence boolean β `has_generation_config` (currently nested under `model_results[].generation_config`)
|
| 63 |
+
|
| 64 |
+
Per-eval scalars (already available somewhere, but need to be on the result row or joinable):
|
| 65 |
+
|
| 66 |
+
- `metric_config.min_score` / `max_score` / `lower_is_better` (per `(eval_summary_id, primary_metric)` β pipeline currently inlines under `metrics[]` in `eval-detail.json`)
|
| 67 |
+
|
| 68 |
+
Today's parquet schema has `eval_summary_id`, `models_count`, plus `payload_json`. Doing this in SQL would require a relational promotion of `metric.model_results[]` (one row per result, joined to per-eval `metric_config`). That's the open design question flagged in `notes/migration-plan.md` Β§ "Data direction".
|
| 69 |
+
|
| 70 |
+
## Sketch SQL query
|
| 71 |
+
|
| 72 |
+
```sql
|
| 73 |
+
-- Per-eval summary stats over deduped model_results.
|
| 74 |
+
-- Assumes a relational table `eval_results` with one row per
|
| 75 |
+
-- (eval_summary_id, model_result) post-item-#2 variant dedup,
|
| 76 |
+
-- and per-eval metric_config columns inlined (or joined from a sibling table).
|
| 77 |
+
SELECT
|
| 78 |
+
eval_summary_id,
|
| 79 |
+
COUNT(*) AS models_count,
|
| 80 |
+
AVG(score) AS avg_score,
|
| 81 |
+
CASE WHEN (max_score - min_score) > 0
|
| 82 |
+
THEN (AVG(score) - min_score) / (max_score - min_score)
|
| 83 |
+
ELSE 0
|
| 84 |
+
END AS avg_score_norm,
|
| 85 |
+
-- best / worst: argmax/argmin over score (direction depends on lower_is_better)
|
| 86 |
+
CASE WHEN lower_is_better
|
| 87 |
+
THEN STRUCT_PACK(name := arg_min(model_name, score), score := MIN(score))
|
| 88 |
+
ELSE STRUCT_PACK(name := arg_max(model_name, score), score := MAX(score))
|
| 89 |
+
END AS best_model,
|
| 90 |
+
CASE WHEN lower_is_better
|
| 91 |
+
THEN STRUCT_PACK(name := arg_max(model_name, score), score := MAX(score))
|
| 92 |
+
ELSE STRUCT_PACK(name := arg_min(model_name, score), score := MIN(score))
|
| 93 |
+
END AS worst_model,
|
| 94 |
+
-- evaluator names: TS preserves insertion order, NOT sorted (quirk)
|
| 95 |
+
list(DISTINCT source_organization_name) AS evaluator_names,
|
| 96 |
+
-- source_types: TS sorts by localeCompare
|
| 97 |
+
list_sort(list(DISTINCT source_type)) AS source_types,
|
| 98 |
+
-- latest source_name: arg_max with >= tie-break (last-wins)
|
| 99 |
+
arg_max(source_name, retrieved_timestamp) AS latest_source_name,
|
| 100 |
+
-- third_party_ratio: filtered count over total
|
| 101 |
+
COUNT(*) FILTER (WHERE evaluator_relationship = 'third_party')::DOUBLE
|
| 102 |
+
/ NULLIF(COUNT(*), 0) AS third_party_ratio,
|
| 103 |
+
COUNT(*) FILTER (WHERE generation_config IS NULL) AS missing_generation_config_count
|
| 104 |
+
FROM eval_results
|
| 105 |
+
GROUP BY eval_summary_id, min_score, max_score, lower_is_better;
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
Notes on the sketch:
|
| 109 |
+
- `arg_max(source_name, retrieved_timestamp)` is DuckDB-native; the `>=` tie-break in TS would need an explicit `ORDER BY retrieved_timestamp DESC, row_order DESC LIMIT 1` if exact byte-parity is required (see TS-as-spec quirks).
|
| 110 |
+
- `list(DISTINCT β¦)` in DuckDB returns insertion order of distinct values in the partition β should match TS `Array.from(new Set([...]))` for `evaluator_names`.
|
| 111 |
+
- The `STRUCT_PACK` for best/worst is illustrative; in practice we'd materialize `best_model_name` and `best_model_score` as separate columns.
|
| 112 |
+
- `score` and `min_score`/`max_score` are read from the same row group; if `metric_config` is sibling-joined, `GROUP BY` keys must include those columns (or use a window).
|
| 113 |
+
|
| 114 |
+
## Materialize vs query-time
|
| 115 |
+
|
| 116 |
+
**Recommendation: materialize per-eval at pipeline emission time.** Pipeline already emits `eval-list.json` with `models_count` and `top_score` per eval, which proves materialization is the established pattern. Extending that to the full set above (`avg_score`, `avg_score_norm`, `evaluator_names`, `source_types`, `latest_source_name`, `third_party_ratio`, `missing_generation_config_count`, `best_model`, `worst_model`) is the same operation, just more outputs.
|
| 117 |
+
|
| 118 |
+
Why materialize:
|
| 119 |
+
|
| 120 |
+
- **No consumer slices these by category, by model-developer, or by anything else within an eval.** Audit (2026-04-28) of `lib/`, `app/`, `components/`: every read is per-eval scalar (`summary.avg_score`, `summary.third_party_ratio`, `summary.evaluator_names.length`, etc.). The sole "category breakdown" use (`benchmark-detail.tsx:5727`) is over models within a benchmark, computed independently β not over these summary stats.
|
| 121 |
+
- **The aggregation is deterministic and consumer-invariant** β every consumer would compute the same numbers, which is exactly the materialize-when-the-answer-is-the-same heuristic from `notes/migration-plan.md` Β§ "Data direction".
|
| 122 |
+
- **Per-eval scope is small** (one row per eval, ~587 evals in production today); blob size is trivial.
|
| 123 |
+
- **It removes the "TS recomputes what pipeline already provides" divergence** flagged below β instead of expanding the divergence by adding 7 more recomputed fields, we collapse the existing one by lifting all 9 to pipeline.
|
| 124 |
+
|
| 125 |
+
When query-time SQL would be the call instead: if a future consumer wants e.g. "third_party_ratio for this eval *restricted to a developer subset*", that's a query-time aggregation. Today no such consumer exists. Re-evaluate when one shows up.
|
| 126 |
+
|
| 127 |
+
The composite-benchmark rollup (item #5 `aggregateBenchmarkSummaries`) and matrix synthesis (item #6) are query-time-shaped because they slice across eval_summary_ids in different ways per request. Item #14 is the pre-aggregate that those build on.
|
| 128 |
+
|
| 129 |
+
## TS-as-spec quirks
|
| 130 |
+
|
| 131 |
+
These are TS behaviors the spec preserves. Pipeline must reproduce or each is an explicitly-accepted divergence.
|
| 132 |
+
|
| 133 |
+
### 1. TS recomputes `models_count` and an effective `top_score` even though pipeline already provides them per eval
|
| 134 |
+
|
| 135 |
+
Pipeline's `eval-list.json` emits per-eval `models_count` (e.g. 4492) and `top_score` (e.g. 0.8269), verified 2026-04-28 against `.cache/hf-data/eval-list.json`. TS ignores both:
|
| 136 |
+
|
| 137 |
+
- `models_count` is recomputed at `lib/eval-processing.ts:948` as `summary.model_results.length`, and at `lib/model-data.ts:825` as `modelResults.length`.
|
| 138 |
+
- `top_score` is not stored, but `best_model.score` is computed at `lib/eval-processing.ts:984-989` and `lib/model-data.ts:831-836` from the sorted-by-score model_results β which should be the same value as `top_score` *for the primary metric* assuming neither side filters differently.
|
| 139 |
+
|
| 140 |
+
**Do they disagree today?** Almost certainly yes for some rows. Pipeline's `models_count` counts pre-#2-dedup rows (raw model_result entries on the eval); TS's recomputed value counts post-#2-dedup rows (after `normalizeSingleModelCardEntry` collapses thinking-budget variants etc.). For evals with thinking-budget submissions (e.g. `openai/gpt-5.2`), pipeline's count will be higher than TS's. This is the same root cause as the #2 spec's "pipeline emits 7 variants, TS shows 2". Surface as a known divergence; **do not fix in this spec β it is properly resolved by landing #2 first**, after which pipeline's emitted `models_count` will match what TS recomputes (and the recomputation can be dropped).
|
| 141 |
+
|
| 142 |
+
For `top_score` vs TS-derived `best_model.score`: similar story. Pipeline's `top_score` is `MAX(score)` over pre-#2-dedup rows; TS's is over post-dedup rows. Different denominators, potentially different maxima (though MAX is more robust to dedup than COUNT or AVG).
|
| 143 |
+
|
| 144 |
+
### 2. The empty-metric short-circuit returns a zero-stats summary with `latest_source_name = getBenchmarkDisplayName(benchmarkKey)`
|
| 145 |
+
|
| 146 |
+
`lib/model-data.ts:785` sets `latest_source_name` to a *benchmark display name string* (e.g. "MMLU Professional") β not a source name β when there are no metrics. Downstream UI then renders this as if it were a source label. This is almost certainly a placeholder bug, but it's TS behavior today and any consumer that special-cases `latest_source_name` matching a benchmark name is reading this. Pipeline should reproduce by emitting `null`-or-display-name in the same condition, OR by accepting this as a fix-by-canonicalization (recommended: emit `null` and chase down any consumer that breaks, since this only fires when the eval has zero metrics β a degenerate case).
|
| 147 |
+
|
| 148 |
+
### 3. Two GROUP BY entry points, different score-source semantics
|
| 149 |
+
|
| 150 |
+
`groupEvaluationsByBenchmark` (eval-processing.ts:893) iterates `eval_.evaluation_results`; `hfEvalDetailToSummary` (model-data.ts:751) iterates `metric.model_results` of the *first* metric (`allMetrics[0]`). These are different shapes feeding the same aggregation, and they apply different filters: the eval-processing path takes *every* `evaluation_result` (potentially multi-metric); the model-data path takes only one metric's results. The active read path for eval-detail pages is `hfEvalDetailToSummary`. Pipeline emission should match `hfEvalDetailToSummary`'s semantics (single primary metric per eval_summary_id) β that's what users actually see today.
|
| 151 |
+
|
| 152 |
+
### 4. `evaluator_names` is insertion-ordered, not sorted
|
| 153 |
+
|
| 154 |
+
`lib/eval-processing.ts:940-942` pushes into a deduped array as it iterates; `hfEvalDetailToSummary` initializes to `[]` (line 826) and never populates it at all. Two contradicting behaviors in the same codebase β for the active path (`hfEvalDetailToSummary`), `evaluator_names` is **always empty**. The eval-card UI (`components/eval-card.tsx:162`) reads `summary.evaluator_names.length`, so eval-detail pages today always render "0 evaluators". This is a latent bug, but it's TS behavior. Pipeline should decide whether to (a) reproduce the empty-array behavior to preserve UI byte-parity, or (b) fix-by-canonicalization and emit the sorted DISTINCT set, accepting that the "Evaluators" pill will start showing real numbers. Recommended: (b), and call it out in the migration commit.
|
| 155 |
+
|
| 156 |
+
### 5. Latest-source uses `>=` tie-break (last-wins)
|
| 157 |
+
|
| 158 |
+
`lib/eval-processing.ts:968` uses `timestamp >= latestTimestamp`, so on a timestamp tie the *later iteration order* wins. SQL `arg_max` is implementation-defined on ties. If exact byte-parity matters across the migration, the SQL needs an explicit secondary sort. Otherwise accept as a divergence on the rare tied-timestamp case.
|
| 159 |
+
|
| 160 |
+
### 6. `avg_score_norm` defaults make it a no-op for 0-1 scores
|
| 161 |
+
|
| 162 |
+
When `metric_config.min_score` and `max_score` are absent (the common case), defaults are `0` and `1` β `range = 1` β `avg_score_norm = avg_score`. The "normalization" only does work when the metric explicitly carries non-default min/max. Reproducing in SQL is the `CASE WHEN range > 0` branch in the sketch above; matches TS's `range > 0 ? β¦ : 0` (note: TS returns 0 not score when range == 0; minor edge-case divergence with `normalizeSummaryScore` which returns `score`).
|
| 163 |
+
|
| 164 |
+
## Cross-item dependencies
|
| 165 |
+
|
| 166 |
+
**Hard dependencies (this item cannot land cleanly without them):**
|
| 167 |
+
|
| 168 |
+
- **#2 setup-alias variant merging (reshape half).** The aggregations are over deduped variants. If pipeline emits stats over raw rows, TS-recomputed stats over deduped rows will continue to diverge for any model with merged variants. Land #2's reshape half first; then the per-eval row set that #14's GROUP BY runs over is the same set TS uses today.
|
| 169 |
+
- **#13 timestamp normalization.** `latest_source_name` requires a comparable timestamp. Today TS uses `normalizeEvalTimestamp` (Variant A from #13's spec) inline; once pipeline emits ISO 8601, SQL `arg_max(source_name, retrieved_timestamp)` works lexicographically with no parsing.
|
| 170 |
+
|
| 171 |
+
**Already-shipped dependencies:**
|
| 172 |
+
|
| 173 |
+
- **#4 source-metadata synthesis fallback.** Done. Pipeline now emits `source_metadata.{source_type, source_organization_name, evaluator_relationship, source_name}` on every model_result row, so the `evaluator_names`, `source_types`, `third_party_ratio`, and `latest_source_name` aggregations have non-null inputs to read. Without #4 these aggregations would silently drop rows.
|
| 174 |
+
|
| 175 |
+
**Soft dependencies (independent but adjacent):**
|
| 176 |
+
|
| 177 |
+
- **#5 composite eval rollup.** Builds on per-eval summaries. Once #14 lands as materialized per-eval stats, #5's `aggregateBenchmarkSummaries` becomes a query-time roll-up over already-aggregated rows (cheaper) instead of a re-aggregation from raw model_results.
|
| 178 |
+
- **#6 matrix leaderboard synthesis.** Same pattern β reads per-eval summaries when slicing across a multi-metric suite.
|
| 179 |
+
|
| 180 |
+
## Migration checklist
|
| 181 |
+
|
| 182 |
+
- [x] Spec written (TS-as-is, including quirks)
|
| 183 |
+
- [ ] Pipeline-side schema decision: relational promotion of `metric.model_results[]` to parquet rows, OR materialize the 9 aggregated columns into `eval-list.json` directly. Recommended: materialize (per "Materialize vs query-time" section).
|
| 184 |
+
- [ ] Pipeline emits the 9 columns per eval_summary_id matching this spec across the full corpus
|
| 185 |
+
- [ ] Verify against `.cache/hf-data/eval-list.json` extended shape; today only `models_count` and `top_score` are present
|
| 186 |
+
- [ ] Audit script (none yet β could mirror `scripts/verify-timestamp.mjs` against the recomputed-vs-emitted values for the 9 columns)
|
| 187 |
+
- [ ] TS deleted: `lib/eval-processing.ts:946-991` finalisation loop; `lib/model-data.ts:806-853` aggregation block; `lib/model-data.ts:83-88` (`normalizeSummaryScore`). Callers read pipeline-emitted fields directly.
|
| 188 |
+
- [ ] Snapshot test (`tests/transformations/score-summary-stats.test.ts` β currently absent; reshape-class snapshots double as TS-vs-SQL parity gates per `notes/testing-strategy.md` Β§ "Reshape-class items: testing addendum")
|
| 189 |
+
|
| 190 |
+
## Future product decisions (deferred)
|
| 191 |
+
|
| 192 |
+
- Whether the empty-metric `latest_source_name = display_name` placeholder (TS quirk #2) should be `null` or kept as a backstop string.
|
| 193 |
+
- Whether `evaluator_names` should be empty (TS active-path behavior) or populated with the sorted DISTINCT set (TS legacy-path behavior).
|
| 194 |
+
- Whether `models_count` should mean "distinct model_id count" or "deduped result count" β TS today uses the latter; users may expect the former. Resolves naturally once #2 lands.
|
notes/transformations/reshape/16-per-category-counts.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Per-category benchmark counts
|
| 2 |
+
|
| 3 |
+
Drafted 2026-04-28. Migration item **#16** in `notes/migration-plan.md`. **Reshape class** β the operation lives in DuckDB SQL (materialized into the parquet artifact). Use this spec as the canonical "obvious SQL" example: it is the smallest, clearest reshape item in the migration.
|
| 4 |
+
|
| 5 |
+
## Framing reminder
|
| 6 |
+
|
| 7 |
+
We are refactoring for UI efficiency, not data correctness. TS-as-is is the canonical spec for *behavior preservation* β except this is the rare reshape item where the current TS is **demonstrably wrong** (it ships a fake distribution; see TS-as-spec quirks below). The migration replaces wrong-with-correct, so the snapshot delta on switch-over is *expected* and the snapshot itself cannot be the gate. The gate is: "real per-category counts populate the same `category_stats: Record<Category, number>` shape; UI renders without divergence." See `notes/testing-strategy.md` Β§ "Reshape-class items: testing addendum".
|
| 8 |
+
|
| 9 |
+
## Migration item
|
| 10 |
+
|
| 11 |
+
| Field | Value |
|
| 12 |
+
|---|---|
|
| 13 |
+
| Item | #16 |
|
| 14 |
+
| Class | Reshape |
|
| 15 |
+
| Operation | per-(model, category) DISTINCT-benchmark count |
|
| 16 |
+
| Materialize? | Yes β small fixed shape, identical for every consumer |
|
| 17 |
+
| Target column | `category_stats: Record<CategoryType, number>` on the model card payload (already the consumer-facing field) |
|
| 18 |
+
| TS files affected | `lib/model-data.ts:369-379` (the wrong fake), `lib/eval-processing.ts:653-666` (the correct heavy-data path) |
|
| 19 |
+
| Pipeline file expected | `scripts/pipeline.py` model-card writer; populate `category_stats` in `model-cards.json` summary entries |
|
| 20 |
+
|
| 21 |
+
## Operation in SQL terms
|
| 22 |
+
|
| 23 |
+
Group every model_result row by `(model_route_id, category)` and count distinct `benchmark_family_key`. The result is the per-model, per-category benchmark-coverage count consumed by the model card UI.
|
| 24 |
+
|
| 25 |
+
This is one statement. It has no edge cases beyond null/empty handling and the choice of grouping key (route-id vs family-id) β both already settled by the existing parquet schema.
|
| 26 |
+
|
| 27 |
+
## Current TS implementation
|
| 28 |
+
|
| 29 |
+
There are **two implementations** of `category_stats` in TS today, depending on which data path supplies the model card:
|
| 30 |
+
|
| 31 |
+
### Path A β `lib/model-data.ts:360-380` (`hfModelCardToEvaluationCardData`)
|
| 32 |
+
|
| 33 |
+
Input: `HFModelCardEntry` (the lightweight `model-cards.json` summary; `entry.categories_covered: string[]`, `entry.total_evaluations: number`, no per-category breakdown).
|
| 34 |
+
|
| 35 |
+
Logic at lines 369-379 (verbatim):
|
| 36 |
+
|
| 37 |
+
```ts
|
| 38 |
+
// Distribute total evaluations across categories proportionally
|
| 39 |
+
const categoryStats: Record<string, number> = {}
|
| 40 |
+
const perCat = categories.length > 0
|
| 41 |
+
? Math.max(1, Math.floor(entry.total_evaluations / categories.length))
|
| 42 |
+
: 0
|
| 43 |
+
let remaining = entry.total_evaluations
|
| 44 |
+
for (let i = 0; i < categories.length; i++) {
|
| 45 |
+
const count = i === categories.length - 1 ? remaining : Math.min(perCat, remaining)
|
| 46 |
+
categoryStats[categories[i]] = count
|
| 47 |
+
remaining -= count
|
| 48 |
+
}
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
This is the **fake distribution**. It does not look at any per-category data because the input doesn't carry any. It just slices `total_evaluations` evenly across `categories.length`, with the last category taking the rounding remainder.
|
| 52 |
+
|
| 53 |
+
Callers of `hfModelCardToEvaluationCardData` (the listing/grid pages where this fake fires):
|
| 54 |
+
- `lib/model-data.ts:1235, 1242` β model index sorting
|
| 55 |
+
- `lib/model-data.ts:1386, 1416, 1455` β developer detail pages, comparison pages
|
| 56 |
+
- `lib/duckdb-data.ts:163` β DuckDB shadow read parity path
|
| 57 |
+
|
| 58 |
+
### Path B β `lib/eval-processing.ts:653-666` (`createModelFamilySummary` β `categoryStats`)
|
| 59 |
+
|
| 60 |
+
Input: full `BenchmarkEvaluation[]` (per-eval-detail data, with `evaluations_by_category` populated).
|
| 61 |
+
|
| 62 |
+
Logic at lines 653-666 (verbatim):
|
| 63 |
+
|
| 64 |
+
```ts
|
| 65 |
+
// Calculate category stats (count of unique benchmarks per category)
|
| 66 |
+
const categoryStats: Record<CategoryType, number> = {} as any
|
| 67 |
+
|
| 68 |
+
for (const category of summary.categories_covered) {
|
| 69 |
+
const evals = summary.evaluations_by_category[category] || []
|
| 70 |
+
const categoryBenchmarks = new Set<string>()
|
| 71 |
+
|
| 72 |
+
for (const eval_ of evals) {
|
| 73 |
+
for (const result of eval_.evaluation_results) {
|
| 74 |
+
categoryBenchmarks.add(getBenchmarkName(eval_, result))
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
categoryStats[category] = categoryBenchmarks.size
|
| 78 |
+
}
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
This is the **real distribution**: COUNT(DISTINCT benchmark) per category, computed from the heavy nested data. Used on model-detail pages where the full payload is loaded.
|
| 82 |
+
|
| 83 |
+
Callers of `createModelFamilySummary`:
|
| 84 |
+
- `lib/model-data.ts:1497, 1520, 1532` β model-detail pages
|
| 85 |
+
- `lib/duckdb-data.ts:194` β DuckDB shadow read for model detail
|
| 86 |
+
|
| 87 |
+
The two paths produce **different `category_stats` for the same model** today. The grid/index UI sees the fake distribution; the detail page sees the real one. No code reconciles them.
|
| 88 |
+
|
| 89 |
+
## Required parquet columns
|
| 90 |
+
|
| 91 |
+
The pipeline already emits the columns needed (per the schema documented in `notes/migration-plan.md` Β§ "Data direction" β `record_type`, `model_route_id`, `model_family_id`, `eval_summary_id`, `developer_route_id`, `developer`, `category`, `benchmark_family_key`, `models_count`, `total_evaluations`, `last_updated`, `payload_json`). For this aggregation:
|
| 92 |
+
|
| 93 |
+
- `model_route_id` β group key (per-model)
|
| 94 |
+
- `category` β group key (per-category)
|
| 95 |
+
- `benchmark_family_key` β distinct-count target
|
| 96 |
+
|
| 97 |
+
No schema change needed for the SQL to run. Confirm against `scripts/pipeline.py:write_experimental_parquet_table` in the pipeline repo when handing off β the column list above is from documentation, not direct inspection.
|
| 98 |
+
|
| 99 |
+
## Sketch SQL query
|
| 100 |
+
|
| 101 |
+
```sql
|
| 102 |
+
SELECT
|
| 103 |
+
model_route_id,
|
| 104 |
+
category,
|
| 105 |
+
COUNT(DISTINCT benchmark_family_key) AS benchmark_count
|
| 106 |
+
FROM model_results
|
| 107 |
+
WHERE record_type = 'model_result' -- if needed; depends on whether row is pre-filtered
|
| 108 |
+
AND benchmark_family_key IS NOT NULL
|
| 109 |
+
GROUP BY model_route_id, category
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
To materialize as the `category_stats: Record<CategoryType, number>` shape that the consumer expects, pivot per model:
|
| 113 |
+
|
| 114 |
+
```sql
|
| 115 |
+
SELECT
|
| 116 |
+
model_route_id,
|
| 117 |
+
MAP_FROM_ENTRIES(
|
| 118 |
+
LIST({k: category, v: COUNT(DISTINCT benchmark_family_key)})
|
| 119 |
+
) AS category_stats
|
| 120 |
+
FROM model_results
|
| 121 |
+
WHERE benchmark_family_key IS NOT NULL
|
| 122 |
+
GROUP BY model_route_id
|
| 123 |
+
```
|
| 124 |
+
|
| 125 |
+
(DuckDB syntax for assembling a `MAP(category VARCHAR, count BIGINT)`. The pipeline writer can also assemble the dict in Python after running the basic GROUP BY β equivalent.)
|
| 126 |
+
|
| 127 |
+
Verification: against the live cache, `total_evaluations` for a given model should approximately equal `SUM(benchmark_count)` across its categories β *approximately* because today's TS fake guarantees the sum, but the real count is "distinct benchmarks per category" which can sum to less (a benchmark in two categories gets counted once per category) or more than `total_evaluations` (which is row-count, not distinct-benchmark-count). This sum-divergence is the load-bearing finding for "the fake was always wrong, not just imprecise."
|
| 128 |
+
|
| 129 |
+
## Materialize vs query-time
|
| 130 |
+
|
| 131 |
+
**Materialize.** This is the unambiguous case for materialization:
|
| 132 |
+
|
| 133 |
+
- The aggregation is small (one row per model Γ number-of-categories β€ 9, so β€ ~50k cells across the full corpus of 5,830 models).
|
| 134 |
+
- The answer is identical for every consumer β no slicing, no filtering. Both the grid and the detail page want the same `Record<Category, number>`.
|
| 135 |
+
- The consumer shape is already known and stable: `category_stats: Record<CategoryType, number>` on the model card.
|
| 136 |
+
- Materializing into `model-cards.json` (summary path) eliminates Path A's fake entirely; the detail path can keep its own computation initially, but eventually both reads from the materialized field.
|
| 137 |
+
|
| 138 |
+
Recommended landing: pipeline emits `category_stats` as a dict on each model card summary entry. Both TS Path A and Path B then read the field directly; both implementations get deleted.
|
| 139 |
+
|
| 140 |
+
## TS-as-spec quirks (Path A β the fake)
|
| 141 |
+
|
| 142 |
+
These are the precise behaviors today. Document them β even though they're wrong β because they may have shaped UI design:
|
| 143 |
+
|
| 144 |
+
1. **Always-equal counts.** With `Math.floor(total / categories.length)`, every category gets an identical `perCat` value (modulo rounding). For a model with `total_evaluations = 12` and `categories = ["Reasoning", "Coding", "Math", "Knowledge"]`, every category gets `3`. Real data would not look like this.
|
| 145 |
+
|
| 146 |
+
2. **`Math.max(1, β¦)` floor.** When `total_evaluations < categories.length`, `Math.floor` would yield `0`, but `Math.max(1, β¦)` forces at least `1`. So a model with `total_evaluations = 2` and `categories.length = 4` ends up with the first 2 categories at `1` each and the remainder distributed via `Math.min(perCat, remaining)` β practically: category 0 β 1, category 1 β 1, category 2 β 0 (because remaining = 0 and `Math.min(1, 0) = 0`), category 3 β 0 (last category takes `remaining = 0`). So you get `{ cat0: 1, cat1: 1, cat2: 0, cat3: 0 }` for total=2, categories=4. **Not always-equal** in this branch β a quirk worth flagging.
|
| 147 |
+
|
| 148 |
+
3. **Last-category-takes-remainder.** The terminal category collects `remaining` instead of `perCat`, so it can be larger than the others when `total % categories.length != 0`. For `total = 13, categories.length = 4`, you get `{ 3, 3, 3, 4 }`. The last category in the array (alphabetical or insertion order from `mapHFCategories`) silently looks more covered than the others.
|
| 149 |
+
|
| 150 |
+
4. **Zero-categories edge.** `categories.length === 0` β `perCat = 0`, loop never runs, `categoryStats = {}`. The consumer gets an empty record. UI must handle. (`benchmark-evaluation-card.tsx:248-257` filters `count > 0` before rendering, so empty is safe; verified.)
|
| 151 |
+
|
| 152 |
+
5. **Zero-total-evaluations edge.** `total_evaluations = 0`, `categories.length > 0` β `perCat = Math.max(1, 0) = 1`. Then `remaining = 0`, loop iterates: `i = 0`, `count = Math.min(1, 0) = 0`, `categoryStats[cat0] = 0`, `remaining = 0`. Subsequent iterations same. Last iteration: `count = remaining = 0`. Final: `{ cat0: 0, cat1: 0, β¦ }`. The `Math.max(1, β¦)` doesn't bite here because `Math.min(perCat, remaining=0) = 0`. UI sees all-zeros, filters them out via `count > 0` check. Safe.
|
| 153 |
+
|
| 154 |
+
6. **No relation to actual benchmark distribution.** A model could have all 50 of its evaluations in `Knowledge` and 0 in `Reasoning`, but if the categories list is `["Knowledge", "Reasoning"]` (because some other model in the same category exposure was tagged that way, or because the upstream `categories_covered` was a union), the fake shows `25` and `25`. There is no signal in the input that lets the fake do better.
|
| 155 |
+
|
| 156 |
+
7. **Path A vs Path B disagreement.** Same model, two pages, two answers. Today's grid shows fake; today's detail shows real. The pipeline materialization eliminates this.
|
| 157 |
+
|
| 158 |
+
### TS-as-spec quirks (Path B β the real one)
|
| 159 |
+
|
| 160 |
+
Path B is essentially correct (COUNT(DISTINCT benchmark) per category) but uses `getBenchmarkName(eval_, result)` as the distinct key rather than `benchmark_family_key`. Two divergences:
|
| 161 |
+
|
| 162 |
+
- The display-name path (#8 in the migration catalog) is messy β different evals can map to the same display name even with different family keys. The SQL `COUNT(DISTINCT benchmark_family_key)` is the right key going forward; expect small count differences vs Path B today on models where a benchmark family has multiple display-named members. Document as expected when running parity.
|
| 163 |
+
- Path B counts at the (eval, result) level β one eval can carry multiple results, each with its own `benchmark`. The SQL on `model_results` rows is at the same granularity (one row per result), so the COUNT(DISTINCT benchmark_family_key) GROUP BY (model_route_id, category) will line up.
|
| 164 |
+
|
| 165 |
+
## UI components to audit when going from fake-equal to real-uneven counts
|
| 166 |
+
|
| 167 |
+
1. **`components/benchmark-evaluation-card.tsx:248-257`** β `categoryCoverage` derived value. Filters `count > 0` (safe under both fake and real), sorts by `count desc, category asc`. Today the sort is effectively a tie-break on category name because counts are always equal under fake; once counts are real, the sort starts ordering by actual coverage. **Visual change: bar/chip ordering shifts.** Worth a screenshot diff.
|
| 168 |
+
|
| 169 |
+
2. **`components/benchmark-evaluation-card.tsx:240-247`** β `topDomains` (above the category coverage block). Independent of `category_stats`, but the visual proximity means if categoryCoverage starts looking lopsided, design might want to revisit ordering of the two adjacent blocks.
|
| 170 |
+
|
| 171 |
+
3. **Card grid alignment.** If grid cells display category counts as side-by-side bars (need to grep templates), today every model's bars are equal width within itself; under real counts they'll be uneven. CSS that assumes "always-equal" widths might overflow or look awkward.
|
| 172 |
+
|
| 173 |
+
4. **Filter UI / faceted browse (if any).** Searching by "models with > N benchmarks in Reasoning" today returns weird answers (e.g. a 12-eval model with 4 categories registers as 3 in Reasoning even if it has 0 actual reasoning benchmarks). After the migration, this becomes meaningful β and any test fixtures that assumed "all models register in all their listed categories" need updating.
|
| 174 |
+
|
| 175 |
+
No widespread breakage is expected; the consumer shape is unchanged. The risk is cosmetic (sort order, bar widths) and statistical (filters that were lying now tell truth).
|
| 176 |
+
|
| 177 |
+
## Cross-item dependencies
|
| 178 |
+
|
| 179 |
+
- **#11 category inference.** The SQL groups by the pipeline's `category` column. Today 84% of evals emit `category: "other"` (per `notes/migration-plan.md`), which would collapse most distinct-benchmark counts into one giant `other` bucket β defeating the point of the per-category breakdown. **#16 is blocked on #11 producing useful category labels.** Or alternatively: the pipeline-side aggregation must run after applying TS's regex category inference, by porting `inferCategoryFromBenchmark` upstream first.
|
| 180 |
+
- **#8 benchmark display names.** Affects the *labels* shown next to the count, not the count itself. Independent.
|
| 181 |
+
- **#3 hierarchy flatten.** The SQL groups by `model_route_id`, which is the post-flatten family identity. If hierarchy flattening changes the route-id assignment, recompute. Should be stable for now.
|
| 182 |
+
|
| 183 |
+
## Migration checklist
|
| 184 |
+
|
| 185 |
+
- [x] Spec written
|
| 186 |
+
- [ ] Tests cover the materialized-output shape (`Record<CategoryType, number>` with non-negative integers, sum vs total_evaluations divergence accepted)
|
| 187 |
+
- [ ] Confirm parquet schema columns (`model_route_id`, `category`, `benchmark_family_key`) against `scripts/pipeline.py:write_experimental_parquet_table` in pipeline repo
|
| 188 |
+
- [ ] Filed with pipeline owner β recommend materializing `category_stats` into `model-cards.json` summary entries (smallest, clearest reshape) and **flag dependency on #11 category accuracy**
|
| 189 |
+
- [ ] Pipeline emits `category_stats` populated for all 5,830 model card entries
|
| 190 |
+
- [ ] Adapter snapshot regenerated; review the diff (it WILL be large because the fake is replaced) β the snapshot is *not* the gate, the materialized-shape contract is
|
| 191 |
+
- [ ] TS deleted: remove the fake at `lib/model-data.ts:369-379`, remove the categoryStats block at `lib/eval-processing.ts:653-666`, both paths read `entry.category_stats` from the pipeline field
|
| 192 |
+
- [ ] UI screenshot diff on `components/benchmark-evaluation-card.tsx` to confirm the now-uneven bars render acceptably; design tweak if not
|
notes/ts-to-pipeline-migration.md
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Move TypeScript data processing into the dataset pipeline
|
| 2 |
+
|
| 3 |
+
Working notes β drafted 2026-04-26, updated 2026-04-27. Cleaning/reshape annotations added 2026-04-28.
|
| 4 |
+
|
| 5 |
+
> **Framing update (2026-04-28).** This doc was written assuming every item moves to pipeline emission. The Data direction principle (see `notes/migration-plan.md` Β§ "Data direction: cleaning upstream, reshape in SQL") refines that: **cleaning** items (value transforms on a single record) belong in pipeline emission and follow the per-item workflow in `notes/migration-plan.md`. **Reshape** items (dedup, aggregation, groupby, sort+select, hierarchy flatten) belong in DuckDB SQL β either materialized into pipeline parquet or computed at query time. Each item below is now annotated. For the active per-item migration view, see `notes/transformations/` and `notes/migration-plan.md`.
|
| 6 |
+
|
| 7 |
+
## Status
|
| 8 |
+
|
| 9 |
+
- β
**#4 source-metadata synthesis fallback** β deleted 2026-04-27. Pipeline (commit 9090cc5) now carries `source_metadata` on every hierarchy `model_result` row; verified 86 183/86 183 production rows + 43 859/43 859 eval-detail rows. Removed `getCanonicalSourceMetadata` (lib/hf-data.ts), `buildSourceMetadataIndex` (lib/hf-data.ts), and three duplicate inline fallbacks in lib/model-data.ts (`buildBenchmarkLeaderboardMatrix`, `toModelResultsForMetric`, `buildSingleMetricSuiteMatrixSummary`). Added a runtime `assertSourceMetadata(result, context)` guard at each read site so a future pipeline regression fails loud (with model + eval IDs) instead of silently emitting `undefined` into the UI which dereferences `.evaluator_relationship` unguarded. The 1st/3rd-party badge collapse bug is now structurally impossible.
|
| 10 |
+
- **Intended behaviour change:** every model_result on eval pages now shows the real first/third-party badge instead of the previously-hardcoded "Other". 86 183/86 183 rows reclassified (3 119 first_party, 83 056 third_party, 8 still other). This was the explicit goal of item #4 ("known correctness gap").
|
| 11 |
+
- β οΈ **#11 category-inference fallback (mostly reverted)** β narrowed 2026-04-27. Pipeline emits `category` on every eval-detail and every hierarchy node, BUT 84% of eval-details (496/587) carry `category: "other"` β the pipeline currently does NOT replicate the regex's classification work. Replacing the regex fallback would silently General-ify ~6 700 model rows including 4 known Safety evals (`helm_safety`, `helm_safety_simplesafetytests`, `helm_classic_truthfulqa`, `llm_stats_truthfulqa`) and 12+ hierarchy `(catKey, displayName)` pairs covering 1 500+ Safety-classified model rows (RewardBench safety, AIRBench subtasks, etc.).
|
| 12 |
+
- **Done safely:** added `coding`, `instruction_following`, `language_understanding` to `PIPELINE_CATEGORY_MAP` (all β "General", matching prior `inferCategoryFromBenchmark` returns). Removed `?? inferCategoryFromBenchmark(c)` fallback in `mapHFCategories`, replaced with `?? "General"` β provably equivalent for all 9 currently-emitted pipeline keys.
|
| 13 |
+
- **Reverted (left for follow-up):** the `inferCategoryFromBenchmark` calls in `hfEvalDetailToSummary` (lib/model-data.ts:760, :803), `groupEvaluationsByBenchmark` (lib/eval-processing.ts:906), and `buildSingleMetricSuiteMatrixSummary` (lib/model-data.ts:1170) β keeping the regex inference until the pipeline emits accurate `category` for currently-`other` benchmarks.
|
| 14 |
+
- **Pre-existing inconsistency, not addressed:** `hfEvalEntryToListItem` (lib/model-data.ts:436) reads pipeline category, while `hfEvalDetailToSummary` reads regex. Same eval can show different categories in eval-list vs eval-detail. Was already in HEAD; dropping it requires the pipeline-side fix above first.
|
| 15 |
+
|
| 16 |
+
### Open follow-ups for the pipeline side
|
| 17 |
+
|
| 18 |
+
1. **Reclassify `category: "other"` evals.** 84% of eval-details get the catch-all. Either (a) train a classifier on the regex's intent, (b) tighten the pipeline's mapping rules, or (c) emit two fields (raw + ui_category). Until this lands, the regex inference must stay.
|
| 19 |
+
2. **Mark known Safety benchmarks correctly.** TruthfulQA, SimpleSafetyTests, the 6 AIRBench 2024 subtasks, RewardBench safety, etc., all currently emit non-Safety categories.
|
| 20 |
+
3. **Consider warn-once logging in `mapHFCategories`** when a new pipeline key arrives that isn't in the map. Today they silently default to "General".
|
| 21 |
+
|
| 22 |
+
## Parity setup
|
| 23 |
+
|
| 24 |
+
`scripts/compare-data-backends.mjs` is the regression net for this migration. Run:
|
| 25 |
+
|
| 26 |
+
```bash
|
| 27 |
+
# JSON-backed dev server, reading the pipeline output directly (offline)
|
| 28 |
+
HF_DATA_LOCAL_DIR=/Users/jchim/projects/eval_cards_backend_pipeline/output \
|
| 29 |
+
HF_DATA_OFFLINE=1 PORT=3001 pnpm dev
|
| 30 |
+
|
| 31 |
+
# DuckDB-backed dev server, same data source
|
| 32 |
+
DATA_BACKEND=duckdb \
|
| 33 |
+
LOCAL_PIPELINE_OUTPUT=/Users/jchim/projects/eval_cards_backend_pipeline/output \
|
| 34 |
+
HF_DATA_LOCAL_DIR=/Users/jchim/projects/eval_cards_backend_pipeline/output \
|
| 35 |
+
HF_DATA_OFFLINE=1 PORT=3002 pnpm dev
|
| 36 |
+
|
| 37 |
+
# Compare (PARITY_FAIL_FAST=0 prints every divergence path)
|
| 38 |
+
PARITY_FAIL_FAST=0 node scripts/compare-data-backends.mjs \
|
| 39 |
+
--json-base http://localhost:3001 --duckdb-base http://localhost:3002
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
`HF_DATA_LOCAL_DIR` overrides `lib/hf-data.ts`'s default cache path to the sibling pipeline `output/`; `HF_DATA_OFFLINE=1` blocks remote fetches so background refreshes can't poison parity. Both env vars added 2026-04-27 specifically to enable this loop.
|
| 43 |
+
|
| 44 |
+
The harness covers 8 endpoints Γ 1 ID each, and only what's in the local pipeline output (3 evals, 28 models). It is *not* exhaustive β production has 587 evals / 5830 models β but it has already caught the model-summary pass-through bug (lib/duckdb-data.ts `toModelSummary` was returning the raw pipeline payload with lowercase category keys). When working on a new migration item, run parity before AND after the change.
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
The Eval Cards Next.js app does not own its data: a Python pipeline publishes JSON
|
| 49 |
+
artifacts to `evaleval/card_backend` on Hugging Face, `scripts/cache-hf-data.mjs`
|
| 50 |
+
clones them at build time, and the `lib/` server modules adapt them at request
|
| 51 |
+
time. A meaningful chunk of "shapeβfixing" still happens in TypeScript on every
|
| 52 |
+
request or build. This note inventories those places and proposes which should
|
| 53 |
+
move into the pipeline (dataset creation step) so the frontend becomes a pure
|
| 54 |
+
read of canonical JSON.
|
| 55 |
+
|
| 56 |
+
## Architecture recap
|
| 57 |
+
|
| 58 |
+
- `scripts/cache-hf-data.mjs` β clones `evaleval/card_backend` and writes
|
| 59 |
+
`.cache/hf-data/{manifest, model-cards, eval-list, developers, benchmark-metadata,
|
| 60 |
+
eval-hierarchy, comparison-index}.json` plus per-detail directories
|
| 61 |
+
`models/`, `evals/`, `developers/`. Also re-normalizes some files in JS after
|
| 62 |
+
download.
|
| 63 |
+
- `lib/hf-data.ts` β read layer over the cache + remote, plus converters that
|
| 64 |
+
flatten the pipeline's hierarchy back into `BenchmarkEvaluation[]`.
|
| 65 |
+
- `lib/model-data.ts` β adapts raw HF artifacts into the app's domain types
|
| 66 |
+
(`BenchmarkEvalSummary`, `EvaluationCardData`, `ModelEvaluationSummary`).
|
| 67 |
+
- `lib/eval-processing.ts` β re-aggregates per-model evaluations into family /
|
| 68 |
+
variant summaries.
|
| 69 |
+
- `app/api/*` β thin route wrappers around the getters above.
|
| 70 |
+
|
| 71 |
+
The pipeline already produces hierarchy, leaderboards, composite groupings, and
|
| 72 |
+
benchmark cards. The items below are what the frontend still has to do that
|
| 73 |
+
the pipeline could just emit instead.
|
| 74 |
+
|
| 75 |
+
## Concrete migration candidates
|
| 76 |
+
|
| 77 |
+
### 1. Family / variant identity parsing β *cleaning*
|
| 78 |
+
- Where: `lib/model-family.ts` (`getCanonicalModelIdentity`), duplicated in
|
| 79 |
+
`scripts/cache-hf-data.mjs:141-303` (`normalizeHandle`,
|
| 80 |
+
`getCanonicalFamilyInfo`, `getNormalizedVariantMeta`,
|
| 81 |
+
`normalizeCachedModelCardFile`).
|
| 82 |
+
- What it does: parses `model_info.id` (e.g. `anthropic/claude-3-5-sonnet-20240620`)
|
| 83 |
+
to derive `familyId`, `familySlug`, `versionDate`, `versionQualifier`,
|
| 84 |
+
`variantKey`, `variantLabel`. The cache script *rewrites* `model-cards.json`
|
| 85 |
+
in place after download so the runtime sees a canonicalized version.
|
| 86 |
+
- Move: pipeline should emit canonical `family_id`, `family_slug`,
|
| 87 |
+
`version_date`, `variant_key`, `variant_label` directly so neither the build
|
| 88 |
+
script nor `lib/model-family.ts` has to reβderive them.
|
| 89 |
+
|
| 90 |
+
### 2. Setup-alias merging ("prompt" / "fc" / "thinking" variants) β *dual: cleaning + reshape*
|
| 91 |
+
- Where: `lib/eval-processing.ts:371-434`
|
| 92 |
+
(`getSetupAliasMode`, `getAggregatedVariantDescriptor`); duplicated in
|
| 93 |
+
`scripts/cache-hf-data.mjs:199-246` (`getNormalizedVariantMeta`,
|
| 94 |
+
`isSetupAliasQualifier`).
|
| 95 |
+
- What it does: looks at `model_info.additional_details.mode` to decide whether
|
| 96 |
+
two model rows are the same release with different prompting setups
|
| 97 |
+
("prompt" / "fc" / "function calling" / "thinkingβ¦") and merges them under
|
| 98 |
+
one variant key.
|
| 99 |
+
- Move:
|
| 100 |
+
- **Cleaning half** β pipeline emits a canonical `variant_key` (or `setup_alias_key`) per submission row.
|
| 101 |
+
- **Reshape half** β once the key is upstream, the bucket reduction (multiple rows with the same `variant_key` β single variant entry, `MAX(retrieved_timestamp)`, merged evaluation results) becomes SQL `GROUP BY variant_key` rather than a TS reduce loop.
|
| 102 |
+
|
| 103 |
+
### 3. Hierarchy β flat `BenchmarkEvaluation[]` rebuild β *reshape*
|
| 104 |
+
- Where: `lib/hf-data.ts:1236-1436`
|
| 105 |
+
(`flattenModelEvaluations`, `flattenHierarchyNode`,
|
| 106 |
+
`buildSourceMetadataIndex`).
|
| 107 |
+
- What it does: walks the pipeline's `hierarchy_by_category` tree, attaches
|
| 108 |
+
`source_metadata` from a separate `evaluations_by_category` index (because
|
| 109 |
+
hierarchy rows don't carry it β see comment at `hf-data.ts:1410-1416`),
|
| 110 |
+
reconciles timestamps across variants, attaches inline samples, and reβemits
|
| 111 |
+
a flat array so `createModelFamilySummary()` can group it again.
|
| 112 |
+
- Move:
|
| 113 |
+
- Denormalize `source_metadata` onto every hierarchy leaf so the side index
|
| 114 |
+
isn't needed (this half is cleaning, already done as part of #4).
|
| 115 |
+
- The flatten + variant-bucket reduction itself is reshape: emit a relational parquet schema (one row per `(eval_summary_id, variant_key, retrieved_timestamp, source_metadata, β¦)`) so the DuckDB backend can `SELECT β¦ QUALIFY ROW_NUMBER() OVER (PARTITION BY variant_key ORDER BY retrieved_timestamp DESC) = 1` instead of TS walking the hierarchy and reducing variants. Decision pending: relational parquet vs pre-flattened JSON payload.
|
| 116 |
+
|
| 117 |
+
### 4. Source-metadata synthesis fallback β *cleaning (DONE)*
|
| 118 |
+
- Where: `lib/hf-data.ts:1049-1064` (`getCanonicalSourceMetadata`); copies in
|
| 119 |
+
`lib/model-data.ts:736-741` (`toModelResultsForMetric`),
|
| 120 |
+
`lib/model-data.ts:1114-1119` (`buildSingleMetricSuiteMatrixSummary`).
|
| 121 |
+
- What it does: when the artifact omits source metadata, hardcodes
|
| 122 |
+
`source_type: "documentation"`, `evaluator_relationship: "other"`. The
|
| 123 |
+
result: any code path that goes through these silently collapses 1st / 3rd-party
|
| 124 |
+
badges to "Other".
|
| 125 |
+
- Move: source metadata should always be present on the artifact β the
|
| 126 |
+
fallback is a known correctness gap.
|
| 127 |
+
|
| 128 |
+
### 5. Composite / aggregate eval construction β *reshape*
|
| 129 |
+
- Where: `lib/model-data.ts:874-1041` (`aggregateBenchmarkSummaries`).
|
| 130 |
+
- What it does: for any URL of the form `/evals/aggregate__<suite_key>`,
|
| 131 |
+
fetches every sub-eval detail individually, normalizes scores, computes
|
| 132 |
+
per-model averages, sorts, builds aggregate components, etc. ~170 lines.
|
| 133 |
+
- Move: per-model averaging across sub-evals + sort + composite assembly is reshape (groupby + aggregate). Two valid landing spots: (a) materialize as a first-class eval-detail file at pipeline emission time (`eval-hierarchy.json` already knows the composites), or (b) compute at query time in DuckDB SQL once eval rows are relational. Materialization is simpler if every consumer wants the same composite shape; query-time SQL gives consumer-driven slicing for free.
|
| 134 |
+
|
| 135 |
+
### 6. Synthetic single-metric matrix leaderboard β *reshape*
|
| 136 |
+
- Where: `lib/model-data.ts:1043-1214`
|
| 137 |
+
(`buildSingleMetricSuiteMatrixSummary`).
|
| 138 |
+
- What it does: for `/evals/matrix__<suite_key>`, fetches every sub-eval,
|
| 139 |
+
builds a model Γ subtask matrix, picks columns / rows, deduplicates, and
|
| 140 |
+
reconciles per-row timestamps. ~170 lines.
|
| 141 |
+
- Move: pivot from long-form rows to wide model Γ subtask matrix is a SQL `PIVOT` (or grouped `MAX(score) FILTER (WHERE subtask = β¦)`) once eval rows are relational. Same materialize-vs-query-time choice as #5; lean toward materialization since the matrix shape is identical for every consumer.
|
| 142 |
+
|
| 143 |
+
### 7. Instance-level JSONL parsing β *cleaning*
|
| 144 |
+
- Where: `lib/hf-data.ts:919-1029` (`parseInstanceLevelData`,
|
| 145 |
+
`fetchInstanceLevelData`).
|
| 146 |
+
- What it does: ~110 lines of heuristics probing for `input.raw`, `prompt`,
|
| 147 |
+
`question`, `doc.question`, `doc`, `output`, `model_output`,
|
| 148 |
+
`messages[].content`, `filtered_resps[0][0]`, `resps[0][0]`,
|
| 149 |
+
`answer_attribution`, `evaluation.is_correct`, `metrics.exact_match`, etc.
|
| 150 |
+
- Why: shape detection across lm-eval-harness, HELM, Inspect, and other
|
| 151 |
+
harness outputs.
|
| 152 |
+
- Move: pipeline should normalize each instance example to one canonical
|
| 153 |
+
`{sample_id, input, ground_truth, response, is_correct, metadata}` shape so
|
| 154 |
+
the frontend just renders.
|
| 155 |
+
|
| 156 |
+
### 8. Display-name lookup tables β *cleaning*
|
| 157 |
+
- Where:
|
| 158 |
+
- `lib/model-data.ts:93-124` (`BENCHMARK_NAMES`, `getBenchmarkDisplayName`)
|
| 159 |
+
- `components/benchmark-detail.tsx:101-173` (`SUITE_DISPLAY_NAMES`,
|
| 160 |
+
`DISPLAY_TOKEN_OVERRIDES`, `DISPLAY_NAME_OVERRIDES`)
|
| 161 |
+
- `lib/eval-processing.ts:861-885` (`getBenchmarkDisplayName`)
|
| 162 |
+
- What it does: hand-maintained maps that translate keys like `helm_lite` β
|
| 163 |
+
"HELM Lite", `arc_agi` β "ARC-AGI", etc.
|
| 164 |
+
- Move: schema already has `canonical_display_name`; pipeline should populate
|
| 165 |
+
it once and the frontend should drop the maps.
|
| 166 |
+
|
| 167 |
+
### 9. Developer name canonicalization β *cleaning*
|
| 168 |
+
- Where: `lib/model-data.ts:201-228` (`KNOWN_DEVELOPER_NAMES`,
|
| 169 |
+
`normalizeDeveloperName`).
|
| 170 |
+
- What it does: `openai` β "OpenAI", `mistralai` β "Mistral AI",
|
| 171 |
+
`deepseek-ai` β "DeepSeek", etc.
|
| 172 |
+
- Move: belongs in the pipeline's developer table.
|
| 173 |
+
|
| 174 |
+
### 10. Generic metric-name expansion β *cleaning*
|
| 175 |
+
- Where: `lib/eval-processing.ts:27-34, 70-86` (`GENERIC_EVALUATION_NAMES`,
|
| 176 |
+
`getEvaluationDisplayName`); mirrored heuristically in
|
| 177 |
+
`lib/model-data.ts:444-454` (`prefersBenchmarkName` detection of
|
| 178 |
+
"accuracy onβ¦", "score onβ¦", "for scorerβ¦", "model_graded").
|
| 179 |
+
- What it does: expands "Accuracy" β "MMLU - Accuracy" when the metric name
|
| 180 |
+
is generic.
|
| 181 |
+
- Move: pipeline should emit a `display_name` that's already the right
|
| 182 |
+
thing.
|
| 183 |
+
|
| 184 |
+
### 11. Category inference fallback β *cleaning (partial)*
|
| 185 |
+
- Where: `lib/benchmark-schema.ts:182-206` (`inferCategoryFromBenchmark`),
|
| 186 |
+
used in `lib/eval-processing.ts:300-307`,
|
| 187 |
+
`lib/model-data.ts:776, 819, 1194`. Plus `PIPELINE_CATEGORY_MAP` /
|
| 188 |
+
`mapHFCategories` in `lib/hf-data.ts:1453-1469`.
|
| 189 |
+
- What it does: regex fallback when the pipeline omits `category`.
|
| 190 |
+
- Move: pipeline should emit `category` for every row so neither fallback nor
|
| 191 |
+
`mapHFCategories` is necessary.
|
| 192 |
+
|
| 193 |
+
### 12. Parameter-count parsing from free text and model names β *cleaning*
|
| 194 |
+
- Where:
|
| 195 |
+
- `lib/model-data.ts:296-338` (`parseParamsBillions`)
|
| 196 |
+
- `components/eval-detail.tsx:81-184`
|
| 197 |
+
(`parseParamsBillionsFromText`, `parseParamsBillionsFromModelName`,
|
| 198 |
+
`getParamsBillionsFromModelInfo`)
|
| 199 |
+
- `app/evals/[id]/page.tsx:434-437` (regex `\b(\d+(?:\.\d+)?)\s*[bB]\b`
|
| 200 |
+
against `name + " " + id`)
|
| 201 |
+
- What it does: parses "70B", "1.5B", "405b", "7 billion", "1.2T" etc. The
|
| 202 |
+
matrix leaderboard even regex-extracts size from the model display name.
|
| 203 |
+
- Move: pipeline should emit a normalized numeric `params_billions` so the
|
| 204 |
+
frontend doesn't need parsers in three places.
|
| 205 |
+
|
| 206 |
+
### 13. Timestamp normalization β *dual: cleaning + reshape*
|
| 207 |
+
- Where: `lib/eval-processing.ts:322-330, 572-577, 962-968`,
|
| 208 |
+
`lib/model-data.ts:60-65`, `components/eval-detail.tsx:218-238`,
|
| 209 |
+
`lib/hf-data.ts:1035-1047` (`toComparableTimestamp`).
|
| 210 |
+
- What it does: timestamps arrive as either unix-seconds-as-string
|
| 211 |
+
(`"1774096306.427425"`) or ISO strings, and the same `Number(ts)` /
|
| 212 |
+
`new Date(ts)` branching is reimplemented at every read site.
|
| 213 |
+
- Move:
|
| 214 |
+
- **Cleaning** β pipeline emits ISO-8601 strings everywhere (currently 99.99% unix-seconds-strings + 5 ISO).
|
| 215 |
+
- **Reshape** β the comparison call sites (8 of them across `lib/model-data.ts`, `lib/hf-data.ts`, `components/benchmark-detail.tsx`) all exist to pick the freshest variant or sort by recency. Once timestamps are ISO 8601, `MAX(retrieved_timestamp)` and `ORDER BY retrieved_timestamp DESC` work as SQL β three TS normalizers + 8 callers delete together. See `notes/transformations/07-timestamp-normalization.md`.
|
| 216 |
+
|
| 217 |
+
### 14. Score normalization and summary stats β *reshape*
|
| 218 |
+
- Where: `lib/eval-processing.ts:946-991` (`groupEvaluationsByBenchmark`
|
| 219 |
+
finalisation), `lib/model-data.ts:67-72, 803-851` (`hfEvalDetailToSummary`).
|
| 220 |
+
- What it does: recomputes `models_count`, `avg_score`, `avg_score_norm`,
|
| 221 |
+
`best_model`, `worst_model`, `evaluator_names`, `source_types`,
|
| 222 |
+
`latest_source_name`, `third_party_ratio`,
|
| 223 |
+
`missing_generation_config_count` even though `eval-list.json` already
|
| 224 |
+
carries `top_score` / `models_count`.
|
| 225 |
+
- Move: aggregation over `model_results` (count, avg, min/max, distinct counts, ratios) is reshape β pure SQL groupby. Materialize at pipeline emission OR compute at query time once `model_results` are relational rows in parquet. Either way the TS finalisation deletes.
|
| 226 |
+
|
| 227 |
+
### 16. Per-category benchmark counts on model cards β *reshape*
|
| 228 |
+
- Where: `lib/model-data.ts:354-363` (proportional distribution in
|
| 229 |
+
`hfModelCardToEvaluationCardData`).
|
| 230 |
+
- What it does: because `model-cards.json` doesn't carry `category_stats`, TS
|
| 231 |
+
does `Math.floor(total / categories.length)` to *fake* a per-category split.
|
| 232 |
+
- Move: per-(model, category) benchmark count is `SELECT model, category, COUNT(DISTINCT benchmark) GROUP BY model, category` β pure SQL groupby. Materialize as `category_stats` on the model card OR compute at query time. The TS `Math.floor(total / categories.length)` placeholder ships incorrect numbers; the SQL version is the actual answer.
|
| 233 |
+
|
| 234 |
+
### 17. Benchmark-card attachment at request time β *cleaning*
|
| 235 |
+
- Where: `lib/benchmark-metadata.ts:38-49` (`getBenchmarkCard`,
|
| 236 |
+
`getMap`), `lib/model-data.ts:857-872` (`attachBenchmarkCardToSummary`).
|
| 237 |
+
- What it does: iterates 3 candidate names per eval and looks each up in a
|
| 238 |
+
deduped `Map<string, BenchmarkCard>`.
|
| 239 |
+
- Move: eval-detail files already sometimes carry `benchmark_card` inline β
|
| 240 |
+
the pipeline should always inline it (or always reference by stable key) so
|
| 241 |
+
the runtime lookup table can be deleted.
|
| 242 |
+
|
| 243 |
+
### 18. License canonicalization β *cleaning*
|
| 244 |
+
- Where: `components/eval-card.tsx:22-48` (`LICENSE_COLORS`,
|
| 245 |
+
`licenseBadgeClass`, `shortenLicense`).
|
| 246 |
+
- What it does: "Creative Commons Attribution 4.0" β "CC BY 4.0",
|
| 247 |
+
"Apache License 2.0" β "Apache 2.0", "Creative Commons Zero" β "CC0", etc.
|
| 248 |
+
- Move: pipeline should expose a normalized SPDX-style license identifier
|
| 249 |
+
alongside the long string.
|
| 250 |
+
|
| 251 |
+
### 19. Slug candidate generation for HF lookups β *cleaning*
|
| 252 |
+
- Where: `lib/model-data.ts:151-194` (`getModelDetailSlugCandidates`,
|
| 253 |
+
`getDeveloperSlugCandidates`); used by `lib/model-data.ts:1467-1509`
|
| 254 |
+
(`getModelSummaryById`).
|
| 255 |
+
- What it does: produces up to 6 spelling variants of a slug (`gpt-3.5` β
|
| 256 |
+
`gpt-3-5`, `__` vs `/`, `_` vs `-`) and `getModelSummaryById` retries each
|
| 257 |
+
candidate against the dataset.
|
| 258 |
+
- Move: the manifest could explicitly map every `model_family_id` /
|
| 259 |
+
`route_id` β file path so retry loops disappear.
|
| 260 |
+
|
| 261 |
+
### 20. Dataset URL synthesis β *cleaning*
|
| 262 |
+
- Where: `components/eval-card.tsx:81-89`.
|
| 263 |
+
- What it does: computes
|
| 264 |
+
`dataset_url ?? url[0] ?? https://huggingface.co/datasets/${hf_repo}` to
|
| 265 |
+
derive a clickable link.
|
| 266 |
+
- Move: pipeline already has `hf_repo` and could just emit the resolved URL
|
| 267 |
+
once.
|
| 268 |
+
|
| 269 |
+
## Suggested priorities
|
| 270 |
+
|
| 271 |
+
If triaging by payoff:
|
| 272 |
+
|
| 273 |
+
1. **#3 + #4 β hierarchy flatten + source-metadata index.** Eliminates the
|
| 274 |
+
largest in-process function in the codebase and a known correctness bug
|
| 275 |
+
("Other" badge collapse).
|
| 276 |
+
2. **#5 + #6 β composites & matrix synthesis.** Replaces ~340 lines of
|
| 277 |
+
request-time TS reconstruction with pre-built artifacts.
|
| 278 |
+
3. **#7 β instance JSONL parser.** Biggest brittleness surface; one
|
| 279 |
+
normalization step in the pipeline removes a heuristic.
|
| 280 |
+
4. **#1 + #2 β identity parsing & setup-alias merging.** Removes
|
| 281 |
+
triple-maintained logic across `lib/model-family.ts`,
|
| 282 |
+
`lib/eval-processing.ts`, and `scripts/cache-hf-data.mjs`.
|
| 283 |
+
5. **#8β#13 β display names, developer names, params parsing, timestamps,
|
| 284 |
+
category fallback.** Small individually, but together they are scattered
|
| 285 |
+
"polish" code that should live next to the data.
|
scripts/_server_only_stub.cjs
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Empty stub for `server-only`. The real module throws on every
|
| 2 |
+
// non-Next.js import; for the parity verifier we only run pure data
|
| 3 |
+
// adapters, so swallowing the import is safe.
|
| 4 |
+
module.exports = {}
|
scripts/dump-adapter-outputs.mts
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// @ts-nocheck
|
| 2 |
+
// Dump current TS-adapter outputs for the cross-repo parity verifier.
|
| 3 |
+
//
|
| 4 |
+
// The `server-only` import on every lib file at line 1 throws under Node;
|
| 5 |
+
// the wrapper Python verifier preloads `scripts/server_only_hook.cjs`
|
| 6 |
+
// via NODE_OPTIONS before invoking this script.
|
| 7 |
+
//
|
| 8 |
+
// Reads a pipeline output directory (`output/`), runs each TS adapter
|
| 9 |
+
// against the corresponding JSON, and writes the expected payload set
|
| 10 |
+
// as JSON to stdout (or a file).
|
| 11 |
+
//
|
| 12 |
+
// Usage (from `general-eval-card/`):
|
| 13 |
+
// pnpm tsx scripts/dump-adapter-outputs.mts \
|
| 14 |
+
// --pipeline-output /Users/jchim/projects/evaleval/eval_cards_backend_pipeline/output \
|
| 15 |
+
// --out /tmp/parity-expected.json
|
| 16 |
+
//
|
| 17 |
+
// Surfaces dumped: model_cards, model_cards_lite, eval_list, eval_list_lite,
|
| 18 |
+
// eval_summaries (per detail), model_summaries (per family),
|
| 19 |
+
// aggregate_eval_summaries (per suite), matrix_eval_summaries (per suite),
|
| 20 |
+
// developer_summaries, developers.
|
| 21 |
+
import { createWriteStream, readFileSync, readdirSync, writeFileSync } from "node:fs"
|
| 22 |
+
import { join } from "node:path"
|
| 23 |
+
|
| 24 |
+
import * as ModelDataMod from "@/lib/model-data"
|
| 25 |
+
import * as HfDataMod from "@/lib/hf-data"
|
| 26 |
+
import * as EvalProcessingMod from "@/lib/eval-processing"
|
| 27 |
+
import * as BenchmarkMetadataUtilsMod from "@/lib/benchmark-metadata-utils"
|
| 28 |
+
|
| 29 |
+
// Under `tsx`, the libs are CommonJS modules; ESM `import *` lifts the
|
| 30 |
+
// real exports onto the synthetic `default`. Pull them off there.
|
| 31 |
+
const ModelData: any = (ModelDataMod as any).default ?? ModelDataMod
|
| 32 |
+
const HfData: any = (HfDataMod as any).default ?? HfDataMod
|
| 33 |
+
const EvalProcessing: any =
|
| 34 |
+
(EvalProcessingMod as any).default ?? EvalProcessingMod
|
| 35 |
+
const BenchmarkMetadataUtils: any =
|
| 36 |
+
(BenchmarkMetadataUtilsMod as any).default ?? BenchmarkMetadataUtilsMod
|
| 37 |
+
const candidateBenchmarkKeys = BenchmarkMetadataUtils.candidateBenchmarkKeys
|
| 38 |
+
|
| 39 |
+
const hfModelCardToEvaluationCardData = ModelData.hfModelCardToEvaluationCardData
|
| 40 |
+
const hfEvalEntryToListItem = ModelData.hfEvalEntryToListItem
|
| 41 |
+
const hfEvalDetailToSummary = ModelData.hfEvalDetailToSummary
|
| 42 |
+
const hfDeveloperDetailToSummary = ModelData.hfDeveloperDetailToSummary
|
| 43 |
+
const aggregateBenchmarkSummaries = ModelData.aggregateBenchmarkSummaries
|
| 44 |
+
const buildSingleMetricSuiteMatrixSummary = ModelData.buildSingleMetricSuiteMatrixSummary
|
| 45 |
+
const flattenModelEvaluations = HfData.flattenModelEvaluations
|
| 46 |
+
// Mirror the request-time normalizer in `fetchModelCardsList` /
|
| 47 |
+
// `fetchModelCardsListLite` (lib/hf-data.ts:822, 827): every fetch runs
|
| 48 |
+
// `normalizeSingleModelCardEntry` to merge setup-alias variants
|
| 49 |
+
// ("prompt"/"fc"/"thinking") under one variant_key. Without this the
|
| 50 |
+
// dump's variant_count is the raw 6-variant count from disk, but the
|
| 51 |
+
// user-facing API returns the post-merge count (~2-3).
|
| 52 |
+
const normalizeSingleModelCardEntry = HfData.normalizeSingleModelCardEntry
|
| 53 |
+
const createModelFamilySummary = EvalProcessing.createModelFamilySummary
|
| 54 |
+
|
| 55 |
+
function parseArgs() {
|
| 56 |
+
const args: Record<string, string> = {}
|
| 57 |
+
for (let i = 2; i < process.argv.length; i++) {
|
| 58 |
+
const arg = process.argv[i]
|
| 59 |
+
if (arg.startsWith("--")) {
|
| 60 |
+
const key = arg.slice(2)
|
| 61 |
+
const next = process.argv[i + 1]
|
| 62 |
+
if (next && !next.startsWith("--")) {
|
| 63 |
+
args[key] = next
|
| 64 |
+
i++
|
| 65 |
+
} else {
|
| 66 |
+
args[key] = "true"
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
return args
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function readJSON<T>(path: string): T {
|
| 74 |
+
return JSON.parse(readFileSync(path, "utf-8")) as T
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
function listJSONFiles(dir: string): string[] {
|
| 78 |
+
try {
|
| 79 |
+
return readdirSync(dir).filter((p) => p.endsWith(".json"))
|
| 80 |
+
} catch {
|
| 81 |
+
return []
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
interface DumpedSurface {
|
| 86 |
+
surface: string
|
| 87 |
+
by_id: Record<string, unknown>
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
const args = parseArgs()
|
| 91 |
+
const pipelineRoot = args["pipeline-output"]
|
| 92 |
+
const outPath = args["out"]
|
| 93 |
+
if (!pipelineRoot) {
|
| 94 |
+
console.error("Missing --pipeline-output <dir>")
|
| 95 |
+
process.exit(2)
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
const modelCards = readJSON<Array<Record<string, any>>>(
|
| 99 |
+
join(pipelineRoot, "model-cards.json")
|
| 100 |
+
)
|
| 101 |
+
const modelCardsLite = readJSON<Array<Record<string, any>>>(
|
| 102 |
+
join(pipelineRoot, "model-cards-lite.json")
|
| 103 |
+
)
|
| 104 |
+
const evalList = readJSON<{ evals: Array<Record<string, any>> }>(
|
| 105 |
+
join(pipelineRoot, "eval-list.json")
|
| 106 |
+
)
|
| 107 |
+
const evalListLite = readJSON<{ evals: Array<Record<string, any>> }>(
|
| 108 |
+
join(pipelineRoot, "eval-list-lite.json")
|
| 109 |
+
)
|
| 110 |
+
const evalsDir = join(pipelineRoot, "evals")
|
| 111 |
+
const modelsDir = join(pipelineRoot, "models")
|
| 112 |
+
|
| 113 |
+
// Build a sync benchmark card lookup mirror of `lib/benchmark-metadata.ts`'s
|
| 114 |
+
// `readPipelineBenchmarkCards` β used for aggregate/matrix surfaces, which
|
| 115 |
+
// in the request-time TS path call the async `attachBenchmarkCardToSummary`.
|
| 116 |
+
// The pipeline parity layer (`scripts/parity_outputs.py:build_aggregate_eval_summaries`)
|
| 117 |
+
// uses a sync card_map; mirror that approach so the dump runs without
|
| 118 |
+
// network/async machinery.
|
| 119 |
+
const benchmarkMetadata = readJSON<Record<string, any>>(
|
| 120 |
+
join(pipelineRoot, "benchmark-metadata.json")
|
| 121 |
+
)
|
| 122 |
+
const benchmarkCardMap = new Map<string, any>()
|
| 123 |
+
for (const card of Object.values(benchmarkMetadata)) {
|
| 124 |
+
const cardObj = card as Record<string, any>
|
| 125 |
+
const name = cardObj?.benchmark_details?.name
|
| 126 |
+
if (!name) continue
|
| 127 |
+
for (const key of candidateBenchmarkKeys(name)) {
|
| 128 |
+
if (!benchmarkCardMap.has(key)) {
|
| 129 |
+
benchmarkCardMap.set(key, cardObj)
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
function syncGetBenchmarkCard(name: string | undefined | null): any | null {
|
| 135 |
+
if (!name) return null
|
| 136 |
+
for (const key of candidateBenchmarkKeys(name)) {
|
| 137 |
+
const card = benchmarkCardMap.get(key)
|
| 138 |
+
if (card) return card
|
| 139 |
+
}
|
| 140 |
+
return null
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
// Sync mirror of `attachBenchmarkCardToSummary` (lib/model-data.ts). The
|
| 144 |
+
// async upstream awaits `getBenchmarkCard`; here we use the sync card map
|
| 145 |
+
// loaded from `benchmark-metadata.json` so dump-adapter-outputs stays
|
| 146 |
+
// fully sync (no fetch / `server-only` boundary).
|
| 147 |
+
function syncAttachBenchmarkCardToSummary(summary: any): any {
|
| 148 |
+
if (summary?.benchmark_card) return summary
|
| 149 |
+
const candidates = [
|
| 150 |
+
summary?.evaluation_name,
|
| 151 |
+
summary?.composite_benchmark_name,
|
| 152 |
+
summary?.composite_benchmark_key,
|
| 153 |
+
]
|
| 154 |
+
for (const candidate of candidates) {
|
| 155 |
+
const card = syncGetBenchmarkCard(candidate)
|
| 156 |
+
if (card) return { ...summary, benchmark_card: card }
|
| 157 |
+
}
|
| 158 |
+
return summary
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
const dumped: DumpedSurface[] = []
|
| 162 |
+
|
| 163 |
+
dumped.push({
|
| 164 |
+
surface: "model_cards",
|
| 165 |
+
by_id: Object.fromEntries(
|
| 166 |
+
modelCards.map((card) => {
|
| 167 |
+
const normalized = normalizeSingleModelCardEntry(card as any)
|
| 168 |
+
const payload = hfModelCardToEvaluationCardData(normalized as any)
|
| 169 |
+
// Key by the adapter-canonical `route_id` so it matches parquet's
|
| 170 |
+
// scalar `model_route_id` column.
|
| 171 |
+
return [payload.route_id, payload]
|
| 172 |
+
})
|
| 173 |
+
),
|
| 174 |
+
})
|
| 175 |
+
|
| 176 |
+
// model_cards_lite β same adapter pipeline as model_cards, but reads the
|
| 177 |
+
// `-lite.json` source. Mirrors `getModelCardsLite()` in lib/model-data.ts.
|
| 178 |
+
dumped.push({
|
| 179 |
+
surface: "model_cards_lite",
|
| 180 |
+
by_id: Object.fromEntries(
|
| 181 |
+
modelCardsLite.map((card) => {
|
| 182 |
+
const normalized = normalizeSingleModelCardEntry(card as any)
|
| 183 |
+
const payload = hfModelCardToEvaluationCardData(normalized as any)
|
| 184 |
+
return [payload.route_id, payload]
|
| 185 |
+
})
|
| 186 |
+
),
|
| 187 |
+
})
|
| 188 |
+
|
| 189 |
+
// Mirror the request-time filter in `getEvalListData` /
|
| 190 |
+
// `getEvalListLiteData` (lib/model-data.ts:1251, 1291): drop entries
|
| 191 |
+
// whose `source_data.hf_repo` starts with `example://`. Applied here so
|
| 192 |
+
// the parity verifier sees the same user-facing shape the parity emitter
|
| 193 |
+
// produces (`scripts/parity_outputs.py:_is_example_eval_entry`).
|
| 194 |
+
function isExampleEntry(entry: Record<string, any>): boolean {
|
| 195 |
+
const repo = entry?.source_data?.hf_repo
|
| 196 |
+
return typeof repo === "string" && repo.startsWith("example://")
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
dumped.push({
|
| 200 |
+
surface: "eval_list",
|
| 201 |
+
by_id: Object.fromEntries(
|
| 202 |
+
(evalList.evals ?? [])
|
| 203 |
+
.filter((entry) => !isExampleEntry(entry))
|
| 204 |
+
.map((entry) => {
|
| 205 |
+
const payload = hfEvalEntryToListItem(entry as any)
|
| 206 |
+
return [payload.evaluation_id, payload]
|
| 207 |
+
})
|
| 208 |
+
),
|
| 209 |
+
})
|
| 210 |
+
|
| 211 |
+
// eval_list_lite β same adapter as eval_list but reads from
|
| 212 |
+
// `eval-list-lite.json`; mirrors `getEvalListLiteData` in lib/model-data.ts
|
| 213 |
+
// (which also strips example entries).
|
| 214 |
+
dumped.push({
|
| 215 |
+
surface: "eval_list_lite",
|
| 216 |
+
by_id: Object.fromEntries(
|
| 217 |
+
(evalListLite.evals ?? [])
|
| 218 |
+
.filter((entry) => !isExampleEntry(entry))
|
| 219 |
+
.map((entry) => {
|
| 220 |
+
const payload = hfEvalEntryToListItem(entry as any)
|
| 221 |
+
return [payload.evaluation_id, payload]
|
| 222 |
+
})
|
| 223 |
+
),
|
| 224 |
+
})
|
| 225 |
+
|
| 226 |
+
const evalDetails: Array<Record<string, any>> = listJSONFiles(evalsDir).map((file) =>
|
| 227 |
+
readJSON<Record<string, any>>(join(evalsDir, file))
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
dumped.push({
|
| 231 |
+
surface: "eval_summaries",
|
| 232 |
+
by_id: Object.fromEntries(
|
| 233 |
+
evalDetails.map((detail) => [
|
| 234 |
+
detail.eval_summary_id,
|
| 235 |
+
hfEvalDetailToSummary(detail as any),
|
| 236 |
+
])
|
| 237 |
+
),
|
| 238 |
+
})
|
| 239 |
+
|
| 240 |
+
// aggregate_eval_summaries β port of the `aggregate__<suite_key>` branch in
|
| 241 |
+
// `getEvalSummaryById` (lib/model-data.ts). The TS path runs each sub-eval
|
| 242 |
+
// through `hfEvalDetailToSummary`, attaches a benchmark card, then calls
|
| 243 |
+
// `aggregateBenchmarkSummaries(summaries, suiteKey)`. We mirror parity
|
| 244 |
+
// emitter `build_aggregate_eval_summaries` (parity_outputs.py:212-258):
|
| 245 |
+
// - group by `benchmark_family_key || benchmark_parent_key`
|
| 246 |
+
// - skip groups with fewer than 2 distinct sub-evals
|
| 247 |
+
// Keyed by `payload.evaluation_id` (= `aggregate__<suite_key>`).
|
| 248 |
+
{
|
| 249 |
+
const aggregateGroups = new Map<string, Record<string, any>[]>()
|
| 250 |
+
for (const detail of evalDetails) {
|
| 251 |
+
const suiteKey =
|
| 252 |
+
detail.benchmark_family_key || detail.benchmark_parent_key
|
| 253 |
+
if (!suiteKey) continue
|
| 254 |
+
if (!detail.eval_summary_id) continue
|
| 255 |
+
const list = aggregateGroups.get(String(suiteKey)) ?? []
|
| 256 |
+
list.push(detail)
|
| 257 |
+
aggregateGroups.set(String(suiteKey), list)
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
const aggregateById: Record<string, any> = {}
|
| 261 |
+
for (const [suiteKey, details] of aggregateGroups.entries()) {
|
| 262 |
+
// De-dupe by eval_summary_id (parity also dedupes β first-write-wins).
|
| 263 |
+
const seenIds = new Set<string>()
|
| 264 |
+
const uniqueDetails: Record<string, any>[] = []
|
| 265 |
+
for (const detail of details) {
|
| 266 |
+
const id = detail.eval_summary_id
|
| 267 |
+
if (seenIds.has(id)) continue
|
| 268 |
+
seenIds.add(id)
|
| 269 |
+
uniqueDetails.push(detail)
|
| 270 |
+
}
|
| 271 |
+
if (uniqueDetails.length < 2) continue
|
| 272 |
+
|
| 273 |
+
const summaries = uniqueDetails.map((detail) => {
|
| 274 |
+
const summary = hfEvalDetailToSummary(detail as any)
|
| 275 |
+
return syncAttachBenchmarkCardToSummary(summary)
|
| 276 |
+
})
|
| 277 |
+
|
| 278 |
+
const aggregated = aggregateBenchmarkSummaries(summaries as any, suiteKey)
|
| 279 |
+
if (!aggregated) continue
|
| 280 |
+
aggregateById[aggregated.evaluation_id] = aggregated
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
dumped.push({
|
| 284 |
+
surface: "aggregate_eval_summaries",
|
| 285 |
+
by_id: aggregateById,
|
| 286 |
+
})
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
// matrix_eval_summaries β port of the `matrix__<suite_key>` branch in
|
| 290 |
+
// `getEvalSummaryById`. Mirrors parity emitter `build_matrix_eval_summaries`
|
| 291 |
+
// (parity_outputs.py:261-281):
|
| 292 |
+
// - skip details where `is_summary_score` is true
|
| 293 |
+
// - group by `benchmark_family_key || benchmark_parent_key`
|
| 294 |
+
// - call `buildSingleMetricSuiteMatrixSummary(details, suiteKey)`
|
| 295 |
+
// Keyed by `payload.evaluation_id` (= `matrix__<suite_key>`).
|
| 296 |
+
{
|
| 297 |
+
const matrixGroups = new Map<string, Record<string, any>[]>()
|
| 298 |
+
for (const detail of evalDetails) {
|
| 299 |
+
if (detail.is_summary_score) continue
|
| 300 |
+
const suiteKey =
|
| 301 |
+
detail.benchmark_family_key || detail.benchmark_parent_key
|
| 302 |
+
if (!suiteKey) continue
|
| 303 |
+
const list = matrixGroups.get(String(suiteKey)) ?? []
|
| 304 |
+
list.push(detail)
|
| 305 |
+
matrixGroups.set(String(suiteKey), list)
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
const matrixById: Record<string, any> = {}
|
| 309 |
+
for (const [suiteKey, details] of matrixGroups.entries()) {
|
| 310 |
+
const result = buildSingleMetricSuiteMatrixSummary(details as any, suiteKey)
|
| 311 |
+
if (!result) continue
|
| 312 |
+
const attached = syncAttachBenchmarkCardToSummary(result)
|
| 313 |
+
matrixById[attached.evaluation_id] = attached
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
dumped.push({
|
| 317 |
+
surface: "matrix_eval_summaries",
|
| 318 |
+
by_id: matrixById,
|
| 319 |
+
})
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
const modelDetails: Array<Record<string, any>> = listJSONFiles(modelsDir).map((file) =>
|
| 323 |
+
readJSON<Record<string, any>>(join(modelsDir, file))
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
dumped.push({
|
| 327 |
+
surface: "model_summaries",
|
| 328 |
+
by_id: Object.fromEntries(
|
| 329 |
+
modelDetails.flatMap((detail) => {
|
| 330 |
+
try {
|
| 331 |
+
const evaluations = flattenModelEvaluations(detail as any)
|
| 332 |
+
if (evaluations.length === 0) return []
|
| 333 |
+
const payload = createModelFamilySummary(evaluations as any)
|
| 334 |
+
return [[payload.model_route_id, payload]]
|
| 335 |
+
} catch (error) {
|
| 336 |
+
// The TS guard `assertSourceMetadata` throws when source_metadata
|
| 337 |
+
// is missing on any model_result; surface as a parity-comparable
|
| 338 |
+
// sentinel keyed by the canonical route id (lookup against the
|
| 339 |
+
// canonical model_family_id avoids drift when the input file's
|
| 340 |
+
// route_id was generated pre-canonicalization).
|
| 341 |
+
const fallbackRoute = (detail.model_family_id ?? "").replace(/\//g, "__")
|
| 342 |
+
return [[fallbackRoute || detail.model_route_id, { _adapter_error: String(error) }]]
|
| 343 |
+
}
|
| 344 |
+
})
|
| 345 |
+
),
|
| 346 |
+
})
|
| 347 |
+
|
| 348 |
+
// Developer surfaces β port of getDeveloperList / getDeveloperSummaryById.
|
| 349 |
+
// Both run hfDeveloperDetailToSummary against pipeline `developers/*.json`;
|
| 350 |
+
// the list endpoint then strips `models[]` to keep the index lightweight.
|
| 351 |
+
const developersDir = join(pipelineRoot, "developers")
|
| 352 |
+
const developerDetails: Array<Record<string, any>> = listJSONFiles(developersDir)
|
| 353 |
+
.map((file) => readJSON<Record<string, any>>(join(developersDir, file)))
|
| 354 |
+
.filter((detail) => detail && detail.developer && Array.isArray(detail.models))
|
| 355 |
+
|
| 356 |
+
const developerSummaries = developerDetails.map((detail) => hfDeveloperDetailToSummary(detail as any))
|
| 357 |
+
|
| 358 |
+
dumped.push({
|
| 359 |
+
surface: "developer_summaries",
|
| 360 |
+
by_id: Object.fromEntries(developerSummaries.map((s) => [s.route_id, s])),
|
| 361 |
+
})
|
| 362 |
+
|
| 363 |
+
dumped.push({
|
| 364 |
+
surface: "developers",
|
| 365 |
+
by_id: Object.fromEntries(
|
| 366 |
+
developerSummaries.map((s) => {
|
| 367 |
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
| 368 |
+
const { models, ...listEntry } = s
|
| 369 |
+
return [listEntry.route_id, listEntry]
|
| 370 |
+
})
|
| 371 |
+
),
|
| 372 |
+
})
|
| 373 |
+
|
| 374 |
+
// Stream surfaces individually β `JSON.stringify` of the full dump can
|
| 375 |
+
// exceed Node's max string length on production-scale corpora (~5.8k
|
| 376 |
+
// model_summaries with nested `evaluations_by_category`).
|
| 377 |
+
function streamSurface(handle: NodeJS.WritableStream, surface: DumpedSurface, isFirst: boolean) {
|
| 378 |
+
if (!isFirst) handle.write(",")
|
| 379 |
+
handle.write(`{"surface":${JSON.stringify(surface.surface)},"by_id":{`)
|
| 380 |
+
let first = true
|
| 381 |
+
for (const [key, value] of Object.entries(surface.by_id)) {
|
| 382 |
+
if (!first) handle.write(",")
|
| 383 |
+
first = false
|
| 384 |
+
handle.write(JSON.stringify(key))
|
| 385 |
+
handle.write(":")
|
| 386 |
+
handle.write(JSON.stringify(value))
|
| 387 |
+
}
|
| 388 |
+
handle.write("}}")
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
async function emit(): Promise<void> {
|
| 392 |
+
if (outPath) {
|
| 393 |
+
const stream = createWriteStream(outPath)
|
| 394 |
+
stream.write("[")
|
| 395 |
+
dumped.forEach((surface, idx) => streamSurface(stream as any, surface, idx === 0))
|
| 396 |
+
stream.write("]")
|
| 397 |
+
await new Promise<void>((resolve) => {
|
| 398 |
+
stream.end(() => resolve())
|
| 399 |
+
})
|
| 400 |
+
console.log(`Wrote ${dumped.length} surfaces to ${outPath}`)
|
| 401 |
+
} else {
|
| 402 |
+
dumped.forEach((surface, idx) =>
|
| 403 |
+
streamSurface(process.stdout, surface, idx === 0)
|
| 404 |
+
)
|
| 405 |
+
}
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
await emit()
|
scripts/server_only_hook.cjs
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Pre-load require hook that neutralizes `server-only`.
|
| 2 |
+
// Used by `dump-adapter-outputs.mts` via `--require`.
|
| 3 |
+
const Module = require("node:module")
|
| 4 |
+
const origLoad = Module._load
|
| 5 |
+
Module._load = function (request, ...rest) {
|
| 6 |
+
if (request === "server-only") {
|
| 7 |
+
return {}
|
| 8 |
+
}
|
| 9 |
+
return origLoad.apply(this, [request, ...rest])
|
| 10 |
+
}
|
scripts/verify-benchmark-card-attachment.mjs
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import "./server-only-shim.mjs"
|
| 2 |
+
import fs from "fs"
|
| 3 |
+
import path from "path"
|
| 4 |
+
|
| 5 |
+
// Audit script for `notes/transformations/11-benchmark-card-attachment.md`.
|
| 6 |
+
// Walks .cache/hf-data/ and quantifies:
|
| 7 |
+
// 1. % of evals with `benchmark_card` already inline vs needing runtime lookup
|
| 8 |
+
// 2. distribution of which candidate position (1st/2nd/3rd/none) resolves
|
| 9 |
+
// under each retry order (summary path: name, name, key; list path: name,
|
| 10 |
+
// key, name)
|
| 11 |
+
// 3. benchmark cards in benchmark-metadata.json with no inbound match from
|
| 12 |
+
// any eval (orphaned cards)
|
| 13 |
+
// 4. evals reachable only via 2nd or 3rd candidate (the load-bearing retry
|
| 14 |
+
// tail β these are the rows that would 404 if pipeline switched to a
|
| 15 |
+
// single-candidate lookup without first inlining benchmark_card)
|
| 16 |
+
// 5. map-build first-write-wins collisions (cards silently dropped)
|
| 17 |
+
|
| 18 |
+
const CACHE_DIR = ".cache/hf-data"
|
| 19 |
+
|
| 20 |
+
if (!fs.existsSync(CACHE_DIR)) {
|
| 21 |
+
console.error(`[verify-benchmark-card-attachment] Cache missing at ${CACHE_DIR}.`)
|
| 22 |
+
console.error("Run `pnpm cache-hf-data` first to prime the local HF data cache.")
|
| 23 |
+
process.exit(1)
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// ---------------------------------------------------------------------------
|
| 27 |
+
// Verbatim copies of the TS transformation pieces.
|
| 28 |
+
// ---------------------------------------------------------------------------
|
| 29 |
+
|
| 30 |
+
function normalizeBenchmarkKey(name) {
|
| 31 |
+
if (!name) return ""
|
| 32 |
+
return name
|
| 33 |
+
.replace(/^[a-z0-9_]+ ?\//i, "")
|
| 34 |
+
.toLowerCase()
|
| 35 |
+
.replace(/[_-]+/g, " ")
|
| 36 |
+
.replace(/\s+/g, " ")
|
| 37 |
+
.trim()
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
function candidateBenchmarkKeys(name) {
|
| 41 |
+
const base = normalizeBenchmarkKey(name)
|
| 42 |
+
return Array.from(
|
| 43 |
+
new Set([
|
| 44 |
+
base,
|
| 45 |
+
base.replace(/-/g, " "),
|
| 46 |
+
base.replace(/ /g, "-"),
|
| 47 |
+
base.replace(/[^a-z0-9]/g, ""),
|
| 48 |
+
])
|
| 49 |
+
)
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
function buildMap(cards) {
|
| 53 |
+
const map = new Map()
|
| 54 |
+
const collisions = []
|
| 55 |
+
for (const card of Object.values(cards)) {
|
| 56 |
+
if (!card?.benchmark_details?.name) continue
|
| 57 |
+
for (const key of candidateBenchmarkKeys(card.benchmark_details.name)) {
|
| 58 |
+
const existing = map.get(key)
|
| 59 |
+
if (existing) {
|
| 60 |
+
if (existing !== card) {
|
| 61 |
+
collisions.push({
|
| 62 |
+
key,
|
| 63 |
+
kept: existing.benchmark_details.name,
|
| 64 |
+
dropped: card.benchmark_details.name,
|
| 65 |
+
})
|
| 66 |
+
}
|
| 67 |
+
} else {
|
| 68 |
+
map.set(key, card)
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
return { map, collisions }
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
function getBenchmarkCard(map, benchmarkName) {
|
| 76 |
+
for (const key of candidateBenchmarkKeys(benchmarkName)) {
|
| 77 |
+
const card = map.get(key)
|
| 78 |
+
if (card) return card
|
| 79 |
+
}
|
| 80 |
+
return null
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// ---------------------------------------------------------------------------
|
| 84 |
+
// Replicate hfEvalEntryToListItem just for the two composite_benchmark_* fields.
|
| 85 |
+
// (Source: lib/model-data.ts:427-505. We don't need the rest of the adapter.)
|
| 86 |
+
// ---------------------------------------------------------------------------
|
| 87 |
+
|
| 88 |
+
function deriveCompositeFields(entry) {
|
| 89 |
+
// Pipeline doesn't always populate `evaluation_name`; the adapter has a
|
| 90 |
+
// chain of fallbacks. For audit purposes use the same chain.
|
| 91 |
+
const rawDisplayName =
|
| 92 |
+
entry.evaluation_name || entry.display_name || entry.benchmark_leaf_name || entry.eval_summary_id
|
| 93 |
+
// composite_benchmark_key = entry.benchmark
|
| 94 |
+
// composite_benchmark_name = a display-name lookup; we approximate with
|
| 95 |
+
// benchmark_parent_name || benchmark (matches getBenchmarkDisplayName fallback).
|
| 96 |
+
return {
|
| 97 |
+
evaluation_name: rawDisplayName,
|
| 98 |
+
composite_benchmark_key: entry.benchmark ?? "",
|
| 99 |
+
composite_benchmark_name: entry.benchmark_parent_name || entry.benchmark || "",
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
// ---------------------------------------------------------------------------
|
| 104 |
+
// Load corpus
|
| 105 |
+
// ---------------------------------------------------------------------------
|
| 106 |
+
|
| 107 |
+
const benchmarkMetadata = JSON.parse(
|
| 108 |
+
fs.readFileSync(path.join(CACHE_DIR, "benchmark-metadata.json"), "utf8")
|
| 109 |
+
)
|
| 110 |
+
const evalList = JSON.parse(fs.readFileSync(path.join(CACHE_DIR, "eval-list.json"), "utf8"))
|
| 111 |
+
const evals = evalList.evals ?? []
|
| 112 |
+
const evalsDir = path.join(CACHE_DIR, "evals")
|
| 113 |
+
const evalDetailFiles = fs.readdirSync(evalsDir)
|
| 114 |
+
|
| 115 |
+
console.log(`=== Corpus ===`)
|
| 116 |
+
console.log(` benchmark-metadata.json cards: ${Object.keys(benchmarkMetadata).length}`)
|
| 117 |
+
console.log(` eval-list.json entries: ${evals.length}`)
|
| 118 |
+
console.log(` evals/*.json detail files: ${evalDetailFiles.length}`)
|
| 119 |
+
|
| 120 |
+
// ---------------------------------------------------------------------------
|
| 121 |
+
// 1. Inline-vs-lookup coverage
|
| 122 |
+
// ---------------------------------------------------------------------------
|
| 123 |
+
|
| 124 |
+
let inline = 0
|
| 125 |
+
let needsLookup = 0
|
| 126 |
+
for (const e of evals) {
|
| 127 |
+
if (e.benchmark_card) inline++
|
| 128 |
+
else needsLookup++
|
| 129 |
+
}
|
| 130 |
+
console.log(`\n=== Inline benchmark_card coverage in eval-list.json ===`)
|
| 131 |
+
console.log(
|
| 132 |
+
` inline (pipeline already populated): ${inline} (${((inline / evals.length) * 100).toFixed(1)}%)`
|
| 133 |
+
)
|
| 134 |
+
console.log(
|
| 135 |
+
` needs runtime lookup: ${needsLookup} (${((needsLookup / evals.length) * 100).toFixed(1)}%)`
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
// Same check on the per-eval detail files
|
| 139 |
+
let detailInline = 0
|
| 140 |
+
let detailMissing = 0
|
| 141 |
+
for (const f of evalDetailFiles) {
|
| 142 |
+
const j = JSON.parse(fs.readFileSync(path.join(evalsDir, f), "utf8"))
|
| 143 |
+
if (j.benchmark_card) detailInline++
|
| 144 |
+
else detailMissing++
|
| 145 |
+
}
|
| 146 |
+
console.log(`\n=== Inline benchmark_card coverage in evals/*.json ===`)
|
| 147 |
+
console.log(
|
| 148 |
+
` inline: ${detailInline} (${((detailInline / evalDetailFiles.length) * 100).toFixed(1)}%)`
|
| 149 |
+
)
|
| 150 |
+
console.log(
|
| 151 |
+
` missing: ${detailMissing} (${((detailMissing / evalDetailFiles.length) * 100).toFixed(1)}%)`
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
// ---------------------------------------------------------------------------
|
| 155 |
+
// 2. Build map and run both retry orders
|
| 156 |
+
// ---------------------------------------------------------------------------
|
| 157 |
+
|
| 158 |
+
const { map, collisions } = buildMap(benchmarkMetadata)
|
| 159 |
+
|
| 160 |
+
console.log(`\n=== Map build ===`)
|
| 161 |
+
console.log(` total keys indexed: ${map.size}`)
|
| 162 |
+
console.log(` first-write-wins collisions: ${collisions.length}`)
|
| 163 |
+
if (collisions.length) {
|
| 164 |
+
console.log(` collision examples (kept β dropped):`)
|
| 165 |
+
for (const c of collisions.slice(0, 10)) {
|
| 166 |
+
console.log(` [${c.key}] ${JSON.stringify(c.kept)} β ${JSON.stringify(c.dropped)}`)
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
// ---------------------------------------------------------------------------
|
| 171 |
+
// 3. Resolution position distribution under each retry order
|
| 172 |
+
// ---------------------------------------------------------------------------
|
| 173 |
+
|
| 174 |
+
function resolutionPosition(map, candidates) {
|
| 175 |
+
const filtered = candidates.filter(Boolean)
|
| 176 |
+
for (let i = 0; i < filtered.length; i++) {
|
| 177 |
+
if (getBenchmarkCard(map, filtered[i])) return i
|
| 178 |
+
}
|
| 179 |
+
return -1
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
const summaryPositions = new Map() // pos β count
|
| 183 |
+
const listPositions = new Map()
|
| 184 |
+
const summaryMisses = []
|
| 185 |
+
const listMisses = []
|
| 186 |
+
const tailHitsSummary = [] // resolved only via candidate 1 or 2 (i.e. retry-load-bearing)
|
| 187 |
+
const tailHitsList = []
|
| 188 |
+
const matchedCardIdsSummary = new Set()
|
| 189 |
+
|
| 190 |
+
for (const e of evals) {
|
| 191 |
+
if (e.benchmark_card) continue // already inline; lookup never runs
|
| 192 |
+
const composite = deriveCompositeFields(e)
|
| 193 |
+
|
| 194 |
+
const summaryCands = [composite.evaluation_name, composite.composite_benchmark_name, composite.composite_benchmark_key]
|
| 195 |
+
const listCands = [composite.evaluation_name, composite.composite_benchmark_key, composite.composite_benchmark_name]
|
| 196 |
+
|
| 197 |
+
const sPos = resolutionPosition(map, summaryCands)
|
| 198 |
+
const lPos = resolutionPosition(map, listCands)
|
| 199 |
+
|
| 200 |
+
if (sPos === -1) summaryMisses.push({ id: e.eval_summary_id, candidates: summaryCands })
|
| 201 |
+
else summaryPositions.set(sPos, (summaryPositions.get(sPos) ?? 0) + 1)
|
| 202 |
+
|
| 203 |
+
if (lPos === -1) listMisses.push({ id: e.eval_summary_id, candidates: listCands })
|
| 204 |
+
else listPositions.set(lPos, (listPositions.get(lPos) ?? 0) + 1)
|
| 205 |
+
|
| 206 |
+
if (sPos > 0)
|
| 207 |
+
tailHitsSummary.push({ id: e.eval_summary_id, position: sPos, candidates: summaryCands })
|
| 208 |
+
if (lPos > 0) tailHitsList.push({ id: e.eval_summary_id, position: lPos, candidates: listCands })
|
| 209 |
+
|
| 210 |
+
if (sPos !== -1) {
|
| 211 |
+
const filtered = summaryCands.filter(Boolean)
|
| 212 |
+
const hit = getBenchmarkCard(map, filtered[sPos])
|
| 213 |
+
if (hit?.benchmark_details?.name) matchedCardIdsSummary.add(hit.benchmark_details.name)
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
const totalLookupCount = evals.length - inline
|
| 218 |
+
function pct(n) {
|
| 219 |
+
return totalLookupCount === 0 ? "0.0" : ((n / totalLookupCount) * 100).toFixed(1)
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
console.log(`\n=== Summary path retry positions (order: name, name, key) β ${totalLookupCount} evals ===`)
|
| 223 |
+
for (const [pos, n] of [...summaryPositions.entries()].sort((a, b) => a[0] - b[0])) {
|
| 224 |
+
console.log(` position ${pos}: ${n} hits (${pct(n)}%)`)
|
| 225 |
+
}
|
| 226 |
+
console.log(` misses: ${summaryMisses.length} (${pct(summaryMisses.length)}%)`)
|
| 227 |
+
|
| 228 |
+
console.log(`\n=== List path retry positions (order: name, key, name) β ${totalLookupCount} evals ===`)
|
| 229 |
+
for (const [pos, n] of [...listPositions.entries()].sort((a, b) => a[0] - b[0])) {
|
| 230 |
+
console.log(` position ${pos}: ${n} hits (${pct(n)}%)`)
|
| 231 |
+
}
|
| 232 |
+
console.log(` misses: ${listMisses.length} (${pct(listMisses.length)}%)`)
|
| 233 |
+
|
| 234 |
+
// ---------------------------------------------------------------------------
|
| 235 |
+
// 4. Cards reachable only via 2nd or 3rd candidate (load-bearing retry tail)
|
| 236 |
+
// ---------------------------------------------------------------------------
|
| 237 |
+
|
| 238 |
+
console.log(`\n=== Tail-only hits (summary path, position > 0) ===`)
|
| 239 |
+
console.log(` count: ${tailHitsSummary.length}`)
|
| 240 |
+
for (const t of tailHitsSummary.slice(0, 10)) {
|
| 241 |
+
console.log(` [pos ${t.position}] ${t.id}`)
|
| 242 |
+
console.log(` candidates: ${JSON.stringify(t.candidates)}`)
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
console.log(`\n=== Tail-only hits (list path, position > 0) ===`)
|
| 246 |
+
console.log(` count: ${tailHitsList.length}`)
|
| 247 |
+
for (const t of tailHitsList.slice(0, 10)) {
|
| 248 |
+
console.log(` [pos ${t.position}] ${t.id}`)
|
| 249 |
+
console.log(` candidates: ${JSON.stringify(t.candidates)}`)
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
// ---------------------------------------------------------------------------
|
| 253 |
+
// 5. Disagreement between summary and list paths
|
| 254 |
+
// ---------------------------------------------------------------------------
|
| 255 |
+
|
| 256 |
+
const disagreements = []
|
| 257 |
+
for (const e of evals) {
|
| 258 |
+
if (e.benchmark_card) continue
|
| 259 |
+
const composite = deriveCompositeFields(e)
|
| 260 |
+
const summaryCands = [composite.evaluation_name, composite.composite_benchmark_name, composite.composite_benchmark_key].filter(Boolean)
|
| 261 |
+
const listCands = [composite.evaluation_name, composite.composite_benchmark_key, composite.composite_benchmark_name].filter(Boolean)
|
| 262 |
+
let summaryHit = null
|
| 263 |
+
for (const c of summaryCands) {
|
| 264 |
+
const h = getBenchmarkCard(map, c)
|
| 265 |
+
if (h) { summaryHit = h; break }
|
| 266 |
+
}
|
| 267 |
+
let listHit = null
|
| 268 |
+
for (const c of listCands) {
|
| 269 |
+
const h = getBenchmarkCard(map, c)
|
| 270 |
+
if (h) { listHit = h; break }
|
| 271 |
+
}
|
| 272 |
+
if (summaryHit !== listHit) {
|
| 273 |
+
disagreements.push({
|
| 274 |
+
id: e.eval_summary_id,
|
| 275 |
+
summary: summaryHit?.benchmark_details?.name ?? null,
|
| 276 |
+
list: listHit?.benchmark_details?.name ?? null,
|
| 277 |
+
})
|
| 278 |
+
}
|
| 279 |
+
}
|
| 280 |
+
console.log(`\n=== Summary-vs-list path disagreements (different card depending on entry point) ===`)
|
| 281 |
+
console.log(` count: ${disagreements.length}`)
|
| 282 |
+
for (const d of disagreements.slice(0, 10)) {
|
| 283 |
+
console.log(` ${d.id}: summaryβ${JSON.stringify(d.summary)} listβ${JSON.stringify(d.list)}`)
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
// ---------------------------------------------------------------------------
|
| 287 |
+
// 6. Orphaned benchmark cards (no eval reaches them)
|
| 288 |
+
// ---------------------------------------------------------------------------
|
| 289 |
+
|
| 290 |
+
const reachedCardNames = new Set()
|
| 291 |
+
for (const e of evals) {
|
| 292 |
+
// Use the inline card if present (because that bypasses the lookup loop)
|
| 293 |
+
if (e.benchmark_card?.benchmark_details?.name) {
|
| 294 |
+
reachedCardNames.add(e.benchmark_card.benchmark_details.name)
|
| 295 |
+
continue
|
| 296 |
+
}
|
| 297 |
+
const composite = deriveCompositeFields(e)
|
| 298 |
+
const allCands = [
|
| 299 |
+
composite.evaluation_name,
|
| 300 |
+
composite.composite_benchmark_name,
|
| 301 |
+
composite.composite_benchmark_key,
|
| 302 |
+
].filter(Boolean)
|
| 303 |
+
for (const c of allCands) {
|
| 304 |
+
const h = getBenchmarkCard(map, c)
|
| 305 |
+
if (h?.benchmark_details?.name) {
|
| 306 |
+
reachedCardNames.add(h.benchmark_details.name)
|
| 307 |
+
break
|
| 308 |
+
}
|
| 309 |
+
}
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
const allCardNames = new Set(
|
| 313 |
+
Object.values(benchmarkMetadata)
|
| 314 |
+
.map((c) => c?.benchmark_details?.name)
|
| 315 |
+
.filter(Boolean)
|
| 316 |
+
)
|
| 317 |
+
const orphaned = [...allCardNames].filter((n) => !reachedCardNames.has(n))
|
| 318 |
+
|
| 319 |
+
console.log(`\n=== Benchmark cards with no inbound eval match (orphans) ===`)
|
| 320 |
+
console.log(` total cards: ${allCardNames.size}`)
|
| 321 |
+
console.log(` reached by at least one eval: ${reachedCardNames.size}`)
|
| 322 |
+
console.log(` orphaned: ${orphaned.length}`)
|
| 323 |
+
for (const n of orphaned.slice(0, 20)) {
|
| 324 |
+
console.log(` - ${JSON.stringify(n)}`)
|
| 325 |
+
}
|
scripts/verify-benchmark-display-names.mjs
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import "./server-only-shim.mjs"
|
| 2 |
+
import fs from "fs"
|
| 3 |
+
|
| 4 |
+
// Audit `getBenchmarkDisplayName` from lib/model-data.ts:90-148 against the
|
| 5 |
+
// full live cache in .cache/hf-data/. Prints distribution stats: how many
|
| 6 |
+
// distinct keys hit BENCHMARK_NAMES, how many fall through to humanizeToken,
|
| 7 |
+
// examples of each, and which input field (benchmark / benchmark_parent_key /
|
| 8 |
+
// benchmark_family_key) drives each call.
|
| 9 |
+
//
|
| 10 |
+
// If .cache/hf-data/ is missing or empty, run `pnpm cache-hf-data` first.
|
| 11 |
+
//
|
| 12 |
+
// Spec: notes/transformations/08-benchmark-display-names.md
|
| 13 |
+
// Tests: tests/transformations/benchmark-display-names.test.ts
|
| 14 |
+
|
| 15 |
+
// ---------------------------------------------------------------------------
|
| 16 |
+
// Replicate lib/model-data.ts:90-148 verbatim
|
| 17 |
+
// ---------------------------------------------------------------------------
|
| 18 |
+
|
| 19 |
+
function humanizeToken(token) {
|
| 20 |
+
return token
|
| 21 |
+
.split(/[_-]+/g)
|
| 22 |
+
.filter(Boolean)
|
| 23 |
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
| 24 |
+
.join(" ")
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const BENCHMARK_NAMES = {
|
| 28 |
+
hfopenllm_v2: "HF Open LLM v2",
|
| 29 |
+
helm_lite: "HELM Lite",
|
| 30 |
+
helm_capabilities: "HELM Capabilities",
|
| 31 |
+
helm_classic: "HELM Classic",
|
| 32 |
+
helm_instruct: "HELM Instruct",
|
| 33 |
+
helm_mmlu: "HELM MMLU",
|
| 34 |
+
reward_bench: "RewardBench",
|
| 35 |
+
reward_bench_2: "RewardBench 2",
|
| 36 |
+
bfcl: "BFCL",
|
| 37 |
+
global_mmlu_lite: "Global MMLU Lite",
|
| 38 |
+
swe_bench: "SWE-bench",
|
| 39 |
+
arc_agi: "ARC-AGI",
|
| 40 |
+
tau_bench_2: "TAU-Bench 2",
|
| 41 |
+
ace: "ACE",
|
| 42 |
+
apex_agents: "APEX Agents",
|
| 43 |
+
apex_v1: "APEX v1",
|
| 44 |
+
appworld: "AppWorld",
|
| 45 |
+
browsecompplus: "BrowseComp+",
|
| 46 |
+
livecodebenchpro: "LiveCodeBench Pro",
|
| 47 |
+
sciarena: "SciArena",
|
| 48 |
+
terminal_bench_2_0: "Terminal Bench 2.0",
|
| 49 |
+
la_leaderboard: "LA Leaderboard",
|
| 50 |
+
theory_of_mind: "Theory of Mind",
|
| 51 |
+
fibble_arena: "Fibble Arena",
|
| 52 |
+
fibble1_arena: "Fibble Arena v1",
|
| 53 |
+
fibble2_arena: "Fibble Arena v2",
|
| 54 |
+
fibble3_arena: "Fibble Arena v3",
|
| 55 |
+
fibble4_arena: "Fibble Arena v4",
|
| 56 |
+
fibble5_arena: "Fibble Arena v5",
|
| 57 |
+
wordle_arena: "Wordle Arena",
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function normalizeBenchmarkKeyForLookup(key) {
|
| 61 |
+
return key.toLowerCase().replace(/[-.\s]+/g, "_").replace(/^_+|_+$/g, "")
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function getBenchmarkDisplayName(benchmark) {
|
| 65 |
+
return BENCHMARK_NAMES[normalizeBenchmarkKeyForLookup(benchmark)] ?? humanizeToken(benchmark)
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
function classify(value) {
|
| 69 |
+
if (value == null || value === "") return "empty"
|
| 70 |
+
return BENCHMARK_NAMES[normalizeBenchmarkKeyForLookup(value)] ? "mapHit" : "fallback"
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// ---------------------------------------------------------------------------
|
| 74 |
+
// Cache check
|
| 75 |
+
// ---------------------------------------------------------------------------
|
| 76 |
+
|
| 77 |
+
const cacheDir = ".cache/hf-data"
|
| 78 |
+
if (!fs.existsSync(cacheDir)) {
|
| 79 |
+
console.error(`ERROR: ${cacheDir} not found. Run \`pnpm cache-hf-data\` first.`)
|
| 80 |
+
process.exit(1)
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
const evalListPath = `${cacheDir}/eval-list.json`
|
| 84 |
+
const modelCardsLitePath = `${cacheDir}/model-cards-lite.json`
|
| 85 |
+
|
| 86 |
+
if (!fs.existsSync(evalListPath)) {
|
| 87 |
+
console.error(`ERROR: ${evalListPath} not found. Run \`pnpm cache-hf-data\` first.`)
|
| 88 |
+
process.exit(1)
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
// ---------------------------------------------------------------------------
|
| 92 |
+
// Pass 1 β eval-list.json (587 entries; the suite/benchmark key universe)
|
| 93 |
+
// ---------------------------------------------------------------------------
|
| 94 |
+
|
| 95 |
+
const evalListRaw = JSON.parse(fs.readFileSync(evalListPath, "utf8"))
|
| 96 |
+
const evals = Array.isArray(evalListRaw) ? evalListRaw : evalListRaw.evals
|
| 97 |
+
console.log(`=== Audit: getBenchmarkDisplayName across ${evals.length} eval-list.json entries ===\n`)
|
| 98 |
+
|
| 99 |
+
const fields = [
|
| 100 |
+
"benchmark",
|
| 101 |
+
"benchmark_parent_key",
|
| 102 |
+
"benchmark_family_key",
|
| 103 |
+
"benchmark_parent_name",
|
| 104 |
+
]
|
| 105 |
+
|
| 106 |
+
for (const field of fields) {
|
| 107 |
+
const distinct = new Set()
|
| 108 |
+
const buckets = { mapHit: 0, fallback: 0, empty: 0 }
|
| 109 |
+
const distinctBuckets = { mapHit: new Set(), fallback: new Set(), empty: new Set() }
|
| 110 |
+
const examples = { mapHit: [], fallback: [] }
|
| 111 |
+
|
| 112 |
+
for (const e of evals) {
|
| 113 |
+
const raw = e[field]
|
| 114 |
+
if (raw != null && raw !== "") distinct.add(raw)
|
| 115 |
+
const bucket = classify(raw)
|
| 116 |
+
buckets[bucket]++
|
| 117 |
+
if (raw != null && raw !== "") distinctBuckets[bucket].add(raw)
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
for (const value of distinct) {
|
| 121 |
+
const bucket = classify(value)
|
| 122 |
+
if (bucket === "mapHit" && examples.mapHit.length < 5) {
|
| 123 |
+
examples.mapHit.push({ raw: value, displayed: getBenchmarkDisplayName(value) })
|
| 124 |
+
} else if (bucket === "fallback" && examples.fallback.length < 10) {
|
| 125 |
+
examples.fallback.push({ raw: value, displayed: getBenchmarkDisplayName(value) })
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
console.log(`--- field: ${field} ---`)
|
| 130 |
+
console.log(` ${distinct.size} distinct values across ${evals.length} calls`)
|
| 131 |
+
console.log(` call buckets: ${JSON.stringify(buckets)}`)
|
| 132 |
+
console.log(
|
| 133 |
+
` distinct buckets: mapHit=${distinctBuckets.mapHit.size} fallback=${distinctBuckets.fallback.size} empty=${distinctBuckets.empty.size}`,
|
| 134 |
+
)
|
| 135 |
+
if (distinct.size > 0) {
|
| 136 |
+
const mapHitPct = ((distinctBuckets.mapHit.size / distinct.size) * 100).toFixed(1)
|
| 137 |
+
const fallbackPct = ((distinctBuckets.fallback.size / distinct.size) * 100).toFixed(1)
|
| 138 |
+
console.log(` distinct mapHit: ${mapHitPct}%; distinct fallback: ${fallbackPct}%`)
|
| 139 |
+
}
|
| 140 |
+
if (examples.mapHit.length) {
|
| 141 |
+
console.log(" mapHit examples:")
|
| 142 |
+
for (const e of examples.mapHit) console.log(` '${e.raw}' -> '${e.displayed}'`)
|
| 143 |
+
}
|
| 144 |
+
if (examples.fallback.length) {
|
| 145 |
+
console.log(" fallback examples (the visibly-wrong cases live here):")
|
| 146 |
+
for (const e of examples.fallback) console.log(` '${e.raw}' -> '${e.displayed}'`)
|
| 147 |
+
}
|
| 148 |
+
console.log()
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
// ---------------------------------------------------------------------------
|
| 152 |
+
// Pass 2 β model-cards-lite.json β `benchmark_names` + `top_benchmark_scores`
|
| 153 |
+
// (these are the per-card rollups that pass through `getBenchmarkDisplayName`
|
| 154 |
+
// at lib/model-data.ts:410 and surrounding rollup paths)
|
| 155 |
+
// ---------------------------------------------------------------------------
|
| 156 |
+
|
| 157 |
+
if (fs.existsSync(modelCardsLitePath)) {
|
| 158 |
+
console.log(`=== Audit: getBenchmarkDisplayName via model-cards-lite.json benchmark fields ===\n`)
|
| 159 |
+
const cards = JSON.parse(fs.readFileSync(modelCardsLitePath, "utf8"))
|
| 160 |
+
const cardsArr = Array.isArray(cards) ? cards : Object.values(cards)
|
| 161 |
+
console.log(` total cards: ${cardsArr.length}`)
|
| 162 |
+
|
| 163 |
+
// benchmark_names is an array; top_benchmark_scores has .benchmark and .benchmarkKey
|
| 164 |
+
const distinctBenchmarkNames = new Set()
|
| 165 |
+
const distinctTopScoreKeys = new Set()
|
| 166 |
+
const distinctTopScoreBenchmarks = new Set()
|
| 167 |
+
|
| 168 |
+
for (const c of cardsArr) {
|
| 169 |
+
if (Array.isArray(c.benchmark_names)) {
|
| 170 |
+
for (const n of c.benchmark_names) distinctBenchmarkNames.add(n)
|
| 171 |
+
}
|
| 172 |
+
if (Array.isArray(c.top_benchmark_scores)) {
|
| 173 |
+
for (const s of c.top_benchmark_scores) {
|
| 174 |
+
if (s.benchmarkKey) distinctTopScoreKeys.add(s.benchmarkKey)
|
| 175 |
+
if (s.benchmark) distinctTopScoreBenchmarks.add(s.benchmark)
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
for (const [label, set] of [
|
| 181 |
+
["card.benchmark_names[]", distinctBenchmarkNames],
|
| 182 |
+
["card.top_benchmark_scores[].benchmarkKey", distinctTopScoreKeys],
|
| 183 |
+
["card.top_benchmark_scores[].benchmark (display-name field)", distinctTopScoreBenchmarks],
|
| 184 |
+
]) {
|
| 185 |
+
const buckets = { mapHit: new Set(), fallback: new Set() }
|
| 186 |
+
const fallbackExamples = []
|
| 187 |
+
for (const v of set) {
|
| 188 |
+
const bucket = classify(v)
|
| 189 |
+
if (bucket === "mapHit") buckets.mapHit.add(v)
|
| 190 |
+
else if (bucket === "fallback") {
|
| 191 |
+
buckets.fallback.add(v)
|
| 192 |
+
if (fallbackExamples.length < 10) {
|
| 193 |
+
fallbackExamples.push({ raw: v, displayed: getBenchmarkDisplayName(v) })
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
const totalDistinct = set.size
|
| 198 |
+
const pctMap = totalDistinct > 0 ? ((buckets.mapHit.size / totalDistinct) * 100).toFixed(1) : "0.0"
|
| 199 |
+
const pctFallback = totalDistinct > 0 ? ((buckets.fallback.size / totalDistinct) * 100).toFixed(1) : "0.0"
|
| 200 |
+
console.log(` --- ${label} ---`)
|
| 201 |
+
console.log(` ${totalDistinct} distinct; mapHit=${buckets.mapHit.size} (${pctMap}%); fallback=${buckets.fallback.size} (${pctFallback}%)`)
|
| 202 |
+
if (fallbackExamples.length) {
|
| 203 |
+
console.log(" fallback examples:")
|
| 204 |
+
for (const e of fallbackExamples) console.log(` '${e.raw}' -> '${e.displayed}'`)
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
console.log()
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
// ---------------------------------------------------------------------------
|
| 211 |
+
// Pass 3 β comparison with pipeline-emitted display_name / canonical_display_name
|
| 212 |
+
// (where pipeline already ships a display name, does the TS function agree?)
|
| 213 |
+
// ---------------------------------------------------------------------------
|
| 214 |
+
|
| 215 |
+
console.log(`=== Comparison: TS getBenchmarkDisplayName vs pipeline-emitted display fields ===\n`)
|
| 216 |
+
const fieldsWithBoth = [
|
| 217 |
+
{ keyField: "benchmark_parent_key", nameField: "benchmark_parent_name", label: "benchmark_parent_key vs benchmark_parent_name" },
|
| 218 |
+
{ keyField: "benchmark_family_key", nameField: "benchmark_family_name", label: "benchmark_family_key vs benchmark_family_name" },
|
| 219 |
+
{ keyField: "benchmark", nameField: "display_name", label: "benchmark vs display_name" },
|
| 220 |
+
{ keyField: "benchmark", nameField: "canonical_display_name", label: "benchmark vs canonical_display_name" },
|
| 221 |
+
]
|
| 222 |
+
|
| 223 |
+
for (const { keyField, nameField, label } of fieldsWithBoth) {
|
| 224 |
+
let agree = 0
|
| 225 |
+
let disagree = 0
|
| 226 |
+
let pipelineMissing = 0
|
| 227 |
+
const examples = []
|
| 228 |
+
for (const e of evals) {
|
| 229 |
+
const key = e[keyField]
|
| 230 |
+
const pipelineName = e[nameField]
|
| 231 |
+
if (!key) continue
|
| 232 |
+
const tsComputed = getBenchmarkDisplayName(key)
|
| 233 |
+
if (pipelineName == null || pipelineName === "") {
|
| 234 |
+
pipelineMissing++
|
| 235 |
+
continue
|
| 236 |
+
}
|
| 237 |
+
if (tsComputed === pipelineName) {
|
| 238 |
+
agree++
|
| 239 |
+
} else {
|
| 240 |
+
disagree++
|
| 241 |
+
if (examples.length < 8) {
|
| 242 |
+
examples.push({ key, pipeline: pipelineName, ts: tsComputed })
|
| 243 |
+
}
|
| 244 |
+
}
|
| 245 |
+
}
|
| 246 |
+
console.log(` --- ${label} ---`)
|
| 247 |
+
console.log(` agree=${agree}; disagree=${disagree}; pipelineMissing=${pipelineMissing}`)
|
| 248 |
+
if (examples.length) {
|
| 249 |
+
console.log(" disagreement examples (key | pipeline | TS-computed):")
|
| 250 |
+
for (const e of examples) console.log(` '${e.key}' | '${e.pipeline}' | '${e.ts}'`)
|
| 251 |
+
}
|
| 252 |
+
console.log()
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
console.log("Done. See notes/transformations/08-benchmark-display-names.md for the full spec.")
|
scripts/verify-dataset-url.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import "./server-only-shim.mjs"
|
| 2 |
+
import fs from "fs"
|
| 3 |
+
|
| 4 |
+
// Replicate the dataset_url fallback chain from components/eval-card.tsx:83-86 verbatim.
|
| 5 |
+
// Original uses `??` (nullish), NOT truthiness β empty strings stay; only
|
| 6 |
+
// null/undefined fall through. Pipeline must produce identical outputs.
|
| 7 |
+
function resolveDatasetUrl(sourceData) {
|
| 8 |
+
const fromDataset = sourceData?.dataset_url
|
| 9 |
+
const fromUrl = Array.isArray(sourceData?.url) ? sourceData?.url?.[0] : sourceData?.url
|
| 10 |
+
const fromHfRepo = sourceData?.hf_repo ? `https://huggingface.co/datasets/${sourceData.hf_repo}` : undefined
|
| 11 |
+
return fromDataset ?? fromUrl ?? fromHfRepo
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
// === Audit: distribution of which fallback branch fires ===
|
| 15 |
+
const dir = ".cache/hf-data/evals"
|
| 16 |
+
const files = fs.readdirSync(dir)
|
| 17 |
+
|
| 18 |
+
const branches = { dataset_url: 0, url_array: 0, url_string: 0, hf_repo: 0, undefined: 0 }
|
| 19 |
+
const examples = { dataset_url: [], url_array: [], url_string: [], hf_repo: [], undefined: [] }
|
| 20 |
+
let total = 0
|
| 21 |
+
|
| 22 |
+
for (const f of files) {
|
| 23 |
+
const data = JSON.parse(fs.readFileSync(`${dir}/${f}`, "utf8"))
|
| 24 |
+
const sd = data.source_data
|
| 25 |
+
total++
|
| 26 |
+
const url = resolveDatasetUrl(sd)
|
| 27 |
+
let branch
|
| 28 |
+
if (!sd) branch = "undefined"
|
| 29 |
+
else if (sd.dataset_url) branch = "dataset_url"
|
| 30 |
+
else if (Array.isArray(sd.url)) branch = "url_array"
|
| 31 |
+
else if (typeof sd.url === "string") branch = "url_string"
|
| 32 |
+
else if (sd.hf_repo) branch = "hf_repo"
|
| 33 |
+
else branch = "undefined"
|
| 34 |
+
branches[branch]++
|
| 35 |
+
if (examples[branch].length < 3) examples[branch].push({ id: data.eval_summary_id, sourceData: sd, resolved: url })
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
console.log(`=== Audit: dataset_url fallback branch distribution (${total} eval-detail files) ===`)
|
| 39 |
+
for (const [b, n] of Object.entries(branches)) {
|
| 40 |
+
console.log(` ${b}: ${n}`)
|
| 41 |
+
}
|
| 42 |
+
console.log()
|
| 43 |
+
console.log("=== Examples per branch ===")
|
| 44 |
+
for (const [b, exs] of Object.entries(examples)) {
|
| 45 |
+
if (exs.length === 0) continue
|
| 46 |
+
console.log(`\n--- ${b} ---`)
|
| 47 |
+
for (const ex of exs) {
|
| 48 |
+
console.log(` ${ex.id}: source_data=${JSON.stringify(ex.sourceData)}`)
|
| 49 |
+
console.log(` β resolved: ${ex.resolved}`)
|
| 50 |
+
}
|
| 51 |
+
}
|
scripts/verify-developer-name.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import "./server-only-shim.mjs"
|
| 2 |
+
import fs from "fs"
|
| 3 |
+
|
| 4 |
+
// Replicate normalizeDeveloperName from lib/model-data.ts:217-244 verbatim.
|
| 5 |
+
const KNOWN_DEVELOPER_NAMES = {
|
| 6 |
+
openai: "OpenAI",
|
| 7 |
+
google: "Google",
|
| 8 |
+
anthropic: "Anthropic",
|
| 9 |
+
meta: "Meta",
|
| 10 |
+
microsoft: "Microsoft",
|
| 11 |
+
mistralai: "Mistral AI",
|
| 12 |
+
deepseek: "DeepSeek",
|
| 13 |
+
"deepseek-ai": "DeepSeek",
|
| 14 |
+
cohere: "Cohere",
|
| 15 |
+
nvidia: "NVIDIA",
|
| 16 |
+
alibaba: "Alibaba",
|
| 17 |
+
amazon: "Amazon",
|
| 18 |
+
apple: "Apple",
|
| 19 |
+
ibm: "IBM",
|
| 20 |
+
xai: "xAI",
|
| 21 |
+
"x-ai": "xAI",
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function normalizeDeveloperName(name) {
|
| 25 |
+
const key = name.trim().toLowerCase()
|
| 26 |
+
if (KNOWN_DEVELOPER_NAMES[key]) return KNOWN_DEVELOPER_NAMES[key]
|
| 27 |
+
if (name === name.toLowerCase() && /^[a-z]/.test(name)) {
|
| 28 |
+
return name.charAt(0).toUpperCase() + name.slice(1)
|
| 29 |
+
}
|
| 30 |
+
return name
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const devs = JSON.parse(fs.readFileSync(".cache/hf-data/developers.json", "utf8"))
|
| 34 |
+
|
| 35 |
+
console.log(`=== Audit: normalizeDeveloperName across ${devs.length} developers ===`)
|
| 36 |
+
const buckets = { mapHit: 0, titleCase: 0, passthrough: 0 }
|
| 37 |
+
const examples = { mapHit: [], titleCase: [], passthrough: [] }
|
| 38 |
+
for (const d of devs) {
|
| 39 |
+
const raw = d.developer
|
| 40 |
+
const normalized = normalizeDeveloperName(raw)
|
| 41 |
+
const key = raw.trim().toLowerCase()
|
| 42 |
+
let bucket
|
| 43 |
+
if (KNOWN_DEVELOPER_NAMES[key]) bucket = "mapHit"
|
| 44 |
+
else if (raw === raw.toLowerCase() && /^[a-z]/.test(raw)) bucket = "titleCase"
|
| 45 |
+
else bucket = "passthrough"
|
| 46 |
+
buckets[bucket]++
|
| 47 |
+
if (examples[bucket].length < 5) examples[bucket].push({ raw, normalized })
|
| 48 |
+
}
|
| 49 |
+
console.log(buckets)
|
| 50 |
+
console.log()
|
| 51 |
+
for (const [bucket, exs] of Object.entries(examples)) {
|
| 52 |
+
console.log(`--- ${bucket} ---`)
|
| 53 |
+
for (const e of exs) console.log(` '${e.raw}' β '${e.normalized}'`)
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// Also check model-cards.json β `developer` field there
|
| 57 |
+
console.log("\n=== Audit: across 5830 model-cards.json entries ===")
|
| 58 |
+
const cards = JSON.parse(fs.readFileSync(".cache/hf-data/model-cards.json", "utf8"))
|
| 59 |
+
const cardBuckets = { mapHit: 0, titleCase: 0, passthrough: 0 }
|
| 60 |
+
for (const c of cards) {
|
| 61 |
+
const raw = c.developer
|
| 62 |
+
const key = raw.trim().toLowerCase()
|
| 63 |
+
let bucket
|
| 64 |
+
if (KNOWN_DEVELOPER_NAMES[key]) bucket = "mapHit"
|
| 65 |
+
else if (raw === raw.toLowerCase() && /^[a-z]/.test(raw)) bucket = "titleCase"
|
| 66 |
+
else bucket = "passthrough"
|
| 67 |
+
cardBuckets[bucket]++
|
| 68 |
+
}
|
| 69 |
+
console.log(cardBuckets)
|
| 70 |
+
|
| 71 |
+
// Distinct developer names
|
| 72 |
+
const distinctDevs = new Set(devs.map(d => d.developer))
|
| 73 |
+
console.log("\n=== Distinct developer name strings:", distinctDevs.size, "===")
|
scripts/verify-identity.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import "./server-only-shim.mjs"
|
| 2 |
+
import fs from "fs"
|
| 3 |
+
|
| 4 |
+
const { getCanonicalModelIdentity, getModelFamilyRouteId } = await import("../lib/model-family.ts")
|
| 5 |
+
|
| 6 |
+
const cards = JSON.parse(fs.readFileSync(".cache/hf-data/model-cards.json", "utf8"))
|
| 7 |
+
|
| 8 |
+
let total = 0
|
| 9 |
+
let familyIdMismatch = 0
|
| 10 |
+
let familyNameMismatch = 0
|
| 11 |
+
const familyIdExamples = []
|
| 12 |
+
const familyNameExamples = []
|
| 13 |
+
|
| 14 |
+
for (const c of cards) {
|
| 15 |
+
total++
|
| 16 |
+
const computed = getCanonicalModelIdentity({
|
| 17 |
+
id: c.model_family_id,
|
| 18 |
+
name: c.model_family_name,
|
| 19 |
+
})
|
| 20 |
+
if (computed.familyId !== c.model_family_id) {
|
| 21 |
+
familyIdMismatch++
|
| 22 |
+
if (familyIdExamples.length < 5) familyIdExamples.push({pipeline: c.model_family_id, computed: computed.familyId})
|
| 23 |
+
}
|
| 24 |
+
if (computed.familyName !== c.model_family_name) {
|
| 25 |
+
familyNameMismatch++
|
| 26 |
+
if (familyNameExamples.length < 10) familyNameExamples.push({pipeline: c.model_family_name, computed: computed.familyName})
|
| 27 |
+
}
|
| 28 |
+
const computedRoute = getModelFamilyRouteId(computed.familyId)
|
| 29 |
+
if (computedRoute !== c.model_route_id) {
|
| 30 |
+
console.error("ROUTE MISMATCH", c.model_family_id, "->", computedRoute, "vs", c.model_route_id)
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
console.log({total, familyIdMismatch, familyNameMismatch})
|
| 35 |
+
if (familyIdExamples.length) console.log("familyId examples:", familyIdExamples)
|
| 36 |
+
if (familyNameExamples.length) console.log("familyName examples:", familyNameExamples.slice(0, 10))
|
scripts/verify-instance-level-data.mjs
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import "./server-only-shim.mjs"
|
| 2 |
+
import fs from "fs"
|
| 3 |
+
import path from "path"
|
| 4 |
+
|
| 5 |
+
// Audit script for `notes/transformations/12-instance-level-data.md`.
|
| 6 |
+
//
|
| 7 |
+
// Walks .cache/hf-data/models/*.json, finds every result row that has
|
| 8 |
+
// `instance_level_data.instance_examples`, replicates the parser's branch
|
| 9 |
+
// logic, and reports:
|
| 10 |
+
// - Prevalence (% of rows / files with sample data)
|
| 11 |
+
// - ild-level shape uniformity (which top-level keys appear)
|
| 12 |
+
// - interaction_type distribution
|
| 13 |
+
// - Per-output-field branch firing rates (which fallback path wins for each)
|
| 14 |
+
// - Distinct example top-level key signatures (top 5)
|
| 15 |
+
//
|
| 16 |
+
// Output is the raw distribution; the spec's "Pipeline status" section quotes
|
| 17 |
+
// these numbers. Run after `pnpm cache-hf-data` to refresh.
|
| 18 |
+
|
| 19 |
+
const CACHE_DIR = ".cache/hf-data"
|
| 20 |
+
const MODELS_DIR = path.join(CACHE_DIR, "models")
|
| 21 |
+
|
| 22 |
+
if (!fs.existsSync(MODELS_DIR)) {
|
| 23 |
+
console.error("=== ERROR: HF data cache missing ===")
|
| 24 |
+
console.error(` Expected: ${MODELS_DIR}`)
|
| 25 |
+
console.error(" Prime it first: pnpm cache-hf-data")
|
| 26 |
+
process.exit(1)
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
const files = fs.readdirSync(MODELS_DIR).filter((f) => f.endsWith(".json"))
|
| 30 |
+
|
| 31 |
+
// Tally trackers
|
| 32 |
+
const branchHits = {
|
| 33 |
+
input: { string: 0, "input.raw": 0, prompt: 0, question: 0, "doc.question": 0, "doc.JSON": 0, EMPTY: 0 },
|
| 34 |
+
ground_truth: { "input.reference": 0, ground_truth: 0, target: 0, gold: 0, "doc.answer": 0, NONE: 0 },
|
| 35 |
+
response: { output: 0, response: 0, model_output: 0, answer_attribution: 0, messages: 0, filtered_resps: 0, resps: 0, EMPTY: 0 },
|
| 36 |
+
is_correct: { "evaluation.is_correct": 0, is_correct: 0, "metrics.exact_match": 0, NONE: 0 },
|
| 37 |
+
sample_id: { sample_id: 0, doc_id: 0, id: 0, index_fallback: 0 },
|
| 38 |
+
choices: { choices: 0, "doc.choices": 0, NONE: 0 },
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
let totalFiles = 0
|
| 42 |
+
let filesWithAnyIld = 0
|
| 43 |
+
let totalEvals = 0
|
| 44 |
+
let evalsWithIld = 0
|
| 45 |
+
let totalResults = 0
|
| 46 |
+
let resultsWithIld = 0
|
| 47 |
+
let totalExamples = 0
|
| 48 |
+
let totalInstanceCountSum = 0 // sum of full-set sizes (i.e. URL JSONL totals)
|
| 49 |
+
|
| 50 |
+
const ildKeysSeen = new Map()
|
| 51 |
+
const interactionTypes = new Map()
|
| 52 |
+
const exampleKeysHistogram = new Map()
|
| 53 |
+
|
| 54 |
+
function classifyExample(raw) {
|
| 55 |
+
if (!raw || typeof raw !== "object") return
|
| 56 |
+
totalExamples++
|
| 57 |
+
|
| 58 |
+
// Track first-level keys present
|
| 59 |
+
const sig = Object.keys(raw).sort().join(",")
|
| 60 |
+
exampleKeysHistogram.set(sig, (exampleKeysHistogram.get(sig) ?? 0) + 1)
|
| 61 |
+
|
| 62 |
+
// input
|
| 63 |
+
if (typeof raw.input === "string") branchHits.input.string++
|
| 64 |
+
else if (raw.input?.raw != null) branchHits.input["input.raw"]++
|
| 65 |
+
else if (raw.prompt) branchHits.input.prompt++
|
| 66 |
+
else if (raw.question) branchHits.input.question++
|
| 67 |
+
else if (raw.doc?.question) branchHits.input["doc.question"]++
|
| 68 |
+
else if (raw.doc) branchHits.input["doc.JSON"]++
|
| 69 |
+
else branchHits.input.EMPTY++
|
| 70 |
+
|
| 71 |
+
// ground_truth
|
| 72 |
+
if (raw.input?.reference != null) branchHits.ground_truth["input.reference"]++
|
| 73 |
+
else if (raw.ground_truth != null) branchHits.ground_truth.ground_truth++
|
| 74 |
+
else if (raw.target != null) branchHits.ground_truth.target++
|
| 75 |
+
else if (raw.gold != null) branchHits.ground_truth.gold++
|
| 76 |
+
else if (raw.doc?.answer != null) branchHits.ground_truth["doc.answer"]++
|
| 77 |
+
else branchHits.ground_truth.NONE++
|
| 78 |
+
|
| 79 |
+
// response
|
| 80 |
+
if (raw.output != null) branchHits.response.output++
|
| 81 |
+
else if (raw.response) branchHits.response.response++
|
| 82 |
+
else if (raw.model_output) branchHits.response.model_output++
|
| 83 |
+
else if (Array.isArray(raw.answer_attribution) && raw.answer_attribution.length > 0) branchHits.response.answer_attribution++
|
| 84 |
+
else if (Array.isArray(raw.messages) && raw.messages.length > 0) branchHits.response.messages++
|
| 85 |
+
else if (raw.filtered_resps?.[0]?.[0]) branchHits.response.filtered_resps++
|
| 86 |
+
else if (raw.resps?.[0]?.[0]) branchHits.response.resps++
|
| 87 |
+
else branchHits.response.EMPTY++
|
| 88 |
+
|
| 89 |
+
// is_correct
|
| 90 |
+
if (raw.evaluation?.is_correct !== undefined) branchHits.is_correct["evaluation.is_correct"]++
|
| 91 |
+
else if (raw.is_correct !== undefined) branchHits.is_correct.is_correct++
|
| 92 |
+
else if (raw.metrics?.exact_match === 1 || raw.metrics?.exact_match === 0) branchHits.is_correct["metrics.exact_match"]++
|
| 93 |
+
else branchHits.is_correct.NONE++
|
| 94 |
+
|
| 95 |
+
// sample_id
|
| 96 |
+
if (raw.sample_id != null) branchHits.sample_id.sample_id++
|
| 97 |
+
else if (raw.doc_id != null) branchHits.sample_id.doc_id++
|
| 98 |
+
else if (raw.id != null) branchHits.sample_id.id++
|
| 99 |
+
else branchHits.sample_id.index_fallback++
|
| 100 |
+
|
| 101 |
+
// choices
|
| 102 |
+
if (raw.choices != null) branchHits.choices.choices++
|
| 103 |
+
else if (raw.doc?.choices != null) branchHits.choices["doc.choices"]++
|
| 104 |
+
else branchHits.choices.NONE++
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
function walk(node) {
|
| 108 |
+
let nodeHasIld = false
|
| 109 |
+
for (const m of node.metrics ?? []) {
|
| 110 |
+
totalEvals++
|
| 111 |
+
let evalHasIld = false
|
| 112 |
+
for (const r of m.model_results ?? []) {
|
| 113 |
+
totalResults++
|
| 114 |
+
const ild = r.instance_level_data
|
| 115 |
+
if (ild != null && typeof ild === "object" && Array.isArray(ild.instance_examples) && ild.instance_examples.length > 0) {
|
| 116 |
+
resultsWithIld++
|
| 117 |
+
evalHasIld = true
|
| 118 |
+
nodeHasIld = true
|
| 119 |
+
|
| 120 |
+
const ildSig = Object.keys(ild).sort().join(",")
|
| 121 |
+
ildKeysSeen.set(ildSig, (ildKeysSeen.get(ildSig) ?? 0) + 1)
|
| 122 |
+
if (typeof ild.interaction_type === "string") {
|
| 123 |
+
interactionTypes.set(ild.interaction_type, (interactionTypes.get(ild.interaction_type) ?? 0) + 1)
|
| 124 |
+
}
|
| 125 |
+
if (typeof ild.instance_count === "number") {
|
| 126 |
+
totalInstanceCountSum += ild.instance_count
|
| 127 |
+
}
|
| 128 |
+
for (const ex of ild.instance_examples) {
|
| 129 |
+
classifyExample(ex)
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
if (evalHasIld) evalsWithIld++
|
| 134 |
+
}
|
| 135 |
+
for (const s of node.subtasks ?? []) walk(s)
|
| 136 |
+
return nodeHasIld
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
for (const f of files) {
|
| 140 |
+
totalFiles++
|
| 141 |
+
let fileHasIld = false
|
| 142 |
+
let data
|
| 143 |
+
try {
|
| 144 |
+
data = JSON.parse(fs.readFileSync(path.join(MODELS_DIR, f), "utf-8"))
|
| 145 |
+
} catch {
|
| 146 |
+
continue
|
| 147 |
+
}
|
| 148 |
+
for (const cat of Object.values(data.hierarchy_by_category ?? {})) {
|
| 149 |
+
for (const node of cat) {
|
| 150 |
+
if (walk(node)) fileHasIld = true
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
if (fileHasIld) filesWithAnyIld++
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
const pct = (n, total = totalExamples) => (total ? ((100 * n) / total).toFixed(2) + "%" : "-")
|
| 157 |
+
|
| 158 |
+
console.log("=== Audit: instance_level_data prevalence ===")
|
| 159 |
+
console.log(` Total model files: ${totalFiles}`)
|
| 160 |
+
console.log(` Files with any ild: ${filesWithAnyIld} (${pct(filesWithAnyIld, totalFiles)})`)
|
| 161 |
+
console.log(` Total (metric Γ result) rows: ${totalResults}`)
|
| 162 |
+
console.log(` Result rows with ild: ${resultsWithIld} (${pct(resultsWithIld, totalResults)})`)
|
| 163 |
+
console.log(` Total inline preview examples (β€5 per row): ${totalExamples}`)
|
| 164 |
+
console.log(` Total full samples (sum of instance_count, accessible via source_url): ${totalInstanceCountSum}`)
|
| 165 |
+
|
| 166 |
+
console.log("\n=== ild-level top-level key signatures ===")
|
| 167 |
+
for (const [k, v] of [...ildKeysSeen.entries()].sort((a, b) => b[1] - a[1])) {
|
| 168 |
+
console.log(` ${v}x: ${k}`)
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
console.log("\n=== interaction_type distribution ===")
|
| 172 |
+
for (const [k, v] of [...interactionTypes.entries()].sort((a, b) => b[1] - a[1])) {
|
| 173 |
+
console.log(` ${v}x: ${k}`)
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
console.log("\n=== Per-field branch firing rates ===\n")
|
| 177 |
+
for (const [field, branches] of Object.entries(branchHits)) {
|
| 178 |
+
console.log(`--- ${field} ---`)
|
| 179 |
+
for (const [b, n] of Object.entries(branches)) {
|
| 180 |
+
console.log(` ${b}: ${n} (${pct(n)})`)
|
| 181 |
+
}
|
| 182 |
+
console.log()
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
console.log("=== Distinct example top-level key signatures (top 5) ===")
|
| 186 |
+
const sorted = [...exampleKeysHistogram.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5)
|
| 187 |
+
for (const [sig, count] of sorted) {
|
| 188 |
+
console.log(` ${count}x: ${sig.substring(0, 200)}${sig.length > 200 ? "β¦" : ""}`)
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
console.log("\n=== Summary for spec ===")
|
| 192 |
+
console.log(`Inline preview prevalence: ${resultsWithIld}/${totalResults} rows (${pct(resultsWithIld, totalResults)})`)
|
| 193 |
+
console.log(`Full-corpus samples behind source_url: ~${totalInstanceCountSum} (vs ${totalExamples} loaded inline)`)
|
| 194 |
+
console.log(`Defensive scaffolding firing rate: see per-field breakdown above. Most branches are 0.00%.`)
|
scripts/verify-license.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import "./server-only-shim.mjs"
|
| 2 |
+
import fs from "fs"
|
| 3 |
+
|
| 4 |
+
// Replicate shortenLicense from components/eval-card.tsx:38-48 verbatim.
|
| 5 |
+
// Pipeline must produce identical outputs for every license string.
|
| 6 |
+
function shortenLicense(license) {
|
| 7 |
+
if (!license || license === "Not specified") return ""
|
| 8 |
+
if (license.toLowerCase().includes("creative commons attribution 4")) return "CC BY 4.0"
|
| 9 |
+
if (license.toLowerCase().includes("creative commons zero")) return "CC0"
|
| 10 |
+
if (license.toLowerCase().includes("apache license 2") || license.toLowerCase().includes("apache 2")) return "Apache 2.0"
|
| 11 |
+
if (license.toLowerCase().includes("mit license")) return "MIT"
|
| 12 |
+
if (license.toLowerCase().includes("cc-by-sa")) return "CC BY-SA"
|
| 13 |
+
if (license.length > 24) return license.slice(0, 22) + "β¦"
|
| 14 |
+
return license
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
// === Audit 1: distinct license strings + their shortened form ===
|
| 18 |
+
const cardsByName = JSON.parse(fs.readFileSync(".cache/hf-data/benchmark-metadata.json", "utf8"))
|
| 19 |
+
const cards = Array.isArray(cardsByName) ? cardsByName : Object.values(cardsByName)
|
| 20 |
+
|
| 21 |
+
const licenseStrings = new Map()
|
| 22 |
+
const shortenedDistribution = new Map()
|
| 23 |
+
let cardsWithLicense = 0
|
| 24 |
+
let cardsTotal = 0
|
| 25 |
+
|
| 26 |
+
for (const c of cards) {
|
| 27 |
+
cardsTotal++
|
| 28 |
+
const license = c?.ethical_and_legal_considerations?.data_licensing
|
| 29 |
+
if (!license) continue
|
| 30 |
+
cardsWithLicense++
|
| 31 |
+
const lic = String(license)
|
| 32 |
+
licenseStrings.set(lic, (licenseStrings.get(lic) ?? 0) + 1)
|
| 33 |
+
const short = shortenLicense(lic)
|
| 34 |
+
shortenedDistribution.set(short, (shortenedDistribution.get(short) ?? 0) + 1)
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
console.log(`=== Audit 1: distinct license strings (${cardsWithLicense}/${cardsTotal} benchmark cards have a license) ===`)
|
| 38 |
+
console.log(`Distinct raw license strings: ${licenseStrings.size}`)
|
| 39 |
+
console.log("Top 30 raw β shortened:")
|
| 40 |
+
const rows = [...licenseStrings.entries()].sort((a, b) => b[1] - a[1]).slice(0, 30)
|
| 41 |
+
for (const [raw, n] of rows) {
|
| 42 |
+
const short = shortenLicense(raw)
|
| 43 |
+
const truncMarker = short.endsWith("β¦") ? " (truncated)" : ""
|
| 44 |
+
const matchedRule = (() => {
|
| 45 |
+
const l = raw.toLowerCase()
|
| 46 |
+
if (l.includes("creative commons attribution 4")) return "CC BY 4.0 rule"
|
| 47 |
+
if (l.includes("creative commons zero")) return "CC0 rule"
|
| 48 |
+
if (l.includes("apache license 2") || l.includes("apache 2")) return "Apache 2.0 rule"
|
| 49 |
+
if (l.includes("mit license")) return "MIT rule"
|
| 50 |
+
if (l.includes("cc-by-sa")) return "CC BY-SA rule"
|
| 51 |
+
if (raw.length > 24) return "truncate-22 rule"
|
| 52 |
+
return "passthrough"
|
| 53 |
+
})()
|
| 54 |
+
console.log(` ${n.toString().padStart(3)}Γ '${raw}' β '${short}' [${matchedRule}]${truncMarker}`)
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
console.log(`\n=== Audit 2: shortened distribution ===`)
|
| 58 |
+
for (const [short, n] of [...shortenedDistribution.entries()].sort((a, b) => b[1] - a[1])) {
|
| 59 |
+
console.log(` ${n.toString().padStart(3)}Γ '${short}'`)
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
console.log(`\n=== Audit 3: rule-coverage stats ===`)
|
| 63 |
+
let counts = { ccby4: 0, cc0: 0, apache2: 0, mit: 0, ccbysa: 0, truncated: 0, passthrough: 0, empty: 0 }
|
| 64 |
+
for (const [raw, n] of licenseStrings.entries()) {
|
| 65 |
+
const l = raw.toLowerCase()
|
| 66 |
+
if (!raw || raw === "Not specified") counts.empty += n
|
| 67 |
+
else if (l.includes("creative commons attribution 4")) counts.ccby4 += n
|
| 68 |
+
else if (l.includes("creative commons zero")) counts.cc0 += n
|
| 69 |
+
else if (l.includes("apache license 2") || l.includes("apache 2")) counts.apache2 += n
|
| 70 |
+
else if (l.includes("mit license")) counts.mit += n
|
| 71 |
+
else if (l.includes("cc-by-sa")) counts.ccbysa += n
|
| 72 |
+
else if (raw.length > 24) counts.truncated += n
|
| 73 |
+
else counts.passthrough += n
|
| 74 |
+
}
|
| 75 |
+
console.log(counts)
|
scripts/verify-params-parsing.mjs
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import "./server-only-shim.mjs"
|
| 2 |
+
import fs from "fs"
|
| 3 |
+
import path from "path"
|
| 4 |
+
|
| 5 |
+
// === Replicate all five TS implementations verbatim ===
|
| 6 |
+
|
| 7 |
+
// Variant A: lib/model-data.ts:312-354 (parseParamsBillions)
|
| 8 |
+
function parseParamsBillions(value) {
|
| 9 |
+
if (typeof value === "number") {
|
| 10 |
+
return Number.isFinite(value) && value > 0 ? value : null
|
| 11 |
+
}
|
| 12 |
+
if (typeof value !== "string") return null
|
| 13 |
+
|
| 14 |
+
const normalized = value.trim().toLowerCase()
|
| 15 |
+
if (!normalized) return null
|
| 16 |
+
|
| 17 |
+
const compact = normalized.replace(/,/g, "")
|
| 18 |
+
const tokenMatch = compact.match(
|
| 19 |
+
/(\d+(?:\.\d+)?)\s*(trillion|tn|t|billion|bn|b|million|mn|m|thousand|k)\b/
|
| 20 |
+
)
|
| 21 |
+
if (tokenMatch) {
|
| 22 |
+
const amount = Number.parseFloat(tokenMatch[1])
|
| 23 |
+
if (!Number.isFinite(amount) || amount <= 0) return null
|
| 24 |
+
const unit = tokenMatch[2]
|
| 25 |
+
if (unit === "trillion" || unit === "tn" || unit === "t") return amount * 1000
|
| 26 |
+
if (unit === "billion" || unit === "bn" || unit === "b") return amount
|
| 27 |
+
if (unit === "million" || unit === "mn" || unit === "m") return amount / 1000
|
| 28 |
+
if (unit === "thousand" || unit === "k") return amount / 1_000_000
|
| 29 |
+
}
|
| 30 |
+
const numeric = Number.parseFloat(compact)
|
| 31 |
+
return Number.isFinite(numeric) && numeric > 0 ? numeric : null
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// Variant B: components/eval-detail.tsx:81-119 (parseParamsBillionsFromText)
|
| 35 |
+
function parseParamsBillionsFromText(value) {
|
| 36 |
+
if (!value) return null
|
| 37 |
+
const normalized = value.trim().toLowerCase()
|
| 38 |
+
if (!normalized) return null
|
| 39 |
+
const compact = normalized.replace(/,/g, "")
|
| 40 |
+
const tokenMatch = compact.match(
|
| 41 |
+
/(\d+(?:\.\d+)?)\s*(trillion|tn|t|billion|bn|b|million|mn|m|thousand|k)\b/
|
| 42 |
+
)
|
| 43 |
+
if (tokenMatch) {
|
| 44 |
+
const amount = Number.parseFloat(tokenMatch[1])
|
| 45 |
+
if (!Number.isFinite(amount)) return null
|
| 46 |
+
const unit = tokenMatch[2]
|
| 47 |
+
if (unit === "trillion" || unit === "tn" || unit === "t") return amount * 1000
|
| 48 |
+
if (unit === "billion" || unit === "bn" || unit === "b") return amount
|
| 49 |
+
if (unit === "million" || unit === "mn" || unit === "m") return amount / 1000
|
| 50 |
+
if (unit === "thousand" || unit === "k") return amount / 1_000_000
|
| 51 |
+
}
|
| 52 |
+
const numeric = Number.parseFloat(compact)
|
| 53 |
+
return Number.isFinite(numeric) ? numeric : null
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// Variant C: components/eval-detail.tsx:121-155 (parseParamsBillionsFromModelName)
|
| 57 |
+
function parseParamsBillionsFromModelNameC(modelName) {
|
| 58 |
+
if (!modelName) return null
|
| 59 |
+
const sizeTokens = Array.from(modelName.matchAll(/\b(\d+(?:\.\d+)?)\s*([tmbk])\b/gi))
|
| 60 |
+
if (sizeTokens.length === 0) return null
|
| 61 |
+
const lastToken = sizeTokens[sizeTokens.length - 1]
|
| 62 |
+
const numericValue = Number.parseFloat(lastToken[1])
|
| 63 |
+
if (!Number.isFinite(numericValue)) return null
|
| 64 |
+
const unit = lastToken[2].toLowerCase()
|
| 65 |
+
if (unit === "t") return numericValue * 1000
|
| 66 |
+
if (unit === "b") return numericValue
|
| 67 |
+
if (unit === "m") return numericValue / 1000
|
| 68 |
+
if (unit === "k") return numericValue / 1_000_000
|
| 69 |
+
return null
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// Variant E: components/model-compare-dialog.tsx:44-60 (parseParamsBillionsFromModelName)
|
| 73 |
+
function parseParamsBillionsFromModelNameE(modelName) {
|
| 74 |
+
if (!modelName) return null
|
| 75 |
+
const sizeTokens = Array.from(modelName.matchAll(/\b(\d+(?:\.\d+)?)\s*([bm])\b/gi))
|
| 76 |
+
if (sizeTokens.length === 0) return null
|
| 77 |
+
const lastToken = sizeTokens[sizeTokens.length - 1]
|
| 78 |
+
const numericValue = Number(lastToken[1])
|
| 79 |
+
if (!Number.isFinite(numericValue)) return null
|
| 80 |
+
const unit = lastToken[2].toLowerCase()
|
| 81 |
+
if (unit === "b") return numericValue
|
| 82 |
+
if (unit === "m") return numericValue / 1000
|
| 83 |
+
return null
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// Variant F: app/evals/[id]/page.tsx:434-437 (inline)
|
| 87 |
+
function parseParamsBillionsInline(name, id) {
|
| 88 |
+
const sizeMatch = (String(name ?? "") + " " + String(id ?? "")).match(
|
| 89 |
+
/\b(\d+(?:\.\d+)?)\s*[bB]\b/
|
| 90 |
+
)
|
| 91 |
+
if (sizeMatch) return parseFloat(sizeMatch[1])
|
| 92 |
+
return null
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// === Audit ===
|
| 96 |
+
|
| 97 |
+
const cacheDir = ".cache/hf-data"
|
| 98 |
+
if (!fs.existsSync(cacheDir)) {
|
| 99 |
+
console.error(`No cache at ${cacheDir}. Run \`pnpm cache-hf-data\` first.`)
|
| 100 |
+
process.exit(1)
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
// ---- 1) Distribution of `model-cards.json.params_billions` (Variant A's input)
|
| 104 |
+
const cardsPath = path.join(cacheDir, "model-cards.json")
|
| 105 |
+
let modelCardsTypeDist = { number: 0, string: 0, null: 0, other: 0 }
|
| 106 |
+
let modelCardsAOutputs = { positive: 0, zero: 0, negative: 0, nan: 0, null: 0 }
|
| 107 |
+
let modelCardsCount = 0
|
| 108 |
+
if (fs.existsSync(cardsPath)) {
|
| 109 |
+
const cards = JSON.parse(fs.readFileSync(cardsPath, "utf8"))
|
| 110 |
+
modelCardsCount = cards.length
|
| 111 |
+
for (const card of cards) {
|
| 112 |
+
const v = card.params_billions
|
| 113 |
+
const t = v === null ? "null" : typeof v
|
| 114 |
+
modelCardsTypeDist[t] = (modelCardsTypeDist[t] ?? 0) + 1
|
| 115 |
+
const out = parseParamsBillions(v)
|
| 116 |
+
if (out === null) modelCardsAOutputs.null++
|
| 117 |
+
else if (Number.isNaN(out)) modelCardsAOutputs.nan++
|
| 118 |
+
else if (out > 0) modelCardsAOutputs.positive++
|
| 119 |
+
else if (out === 0) modelCardsAOutputs.zero++
|
| 120 |
+
else modelCardsAOutputs.negative++
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// ---- 2) Per-model-result audit
|
| 125 |
+
const modelsDir = path.join(cacheDir, "models")
|
| 126 |
+
const files = fs.existsSync(modelsDir) ? fs.readdirSync(modelsDir) : []
|
| 127 |
+
|
| 128 |
+
// Buckets:
|
| 129 |
+
// - 'add.params_billions': string|number|undefined per row
|
| 130 |
+
// - format distribution of strings (clean decimal? unit-suffixed?)
|
| 131 |
+
// - model name format distribution (b-suffix? K-suffix? T-suffix? MoE 8x7B? bare like GPT-4?)
|
| 132 |
+
// - per-row Variant D resolution: which fallback fires
|
| 133 |
+
const addPbTypeDist = { undefined: 0, null: 0, number: 0, string: 0, other: 0 }
|
| 134 |
+
const stringPbFormatDist = { cleanDecimal: 0, unitSuffix: 0, other: 0 }
|
| 135 |
+
const stringPbExamples = { cleanDecimal: [], unitSuffix: [], other: [] }
|
| 136 |
+
const nameFormatDist = {
|
| 137 |
+
hasBOnly: 0, // "Llama-3-70B-Instruct"
|
| 138 |
+
hasBAndContextWindow: 0, // "Llama-3-70B-Instruct-8K" (contains B and K)
|
| 139 |
+
hasBAndT: 0, // contains both B and T
|
| 140 |
+
hasMOnly: 0, // "560M"
|
| 141 |
+
hasMoEPattern: 0, // "Mixtral-8x7B" (\dx\d before B)
|
| 142 |
+
noUnitToken: 0, // "GPT-4", no unit suffix
|
| 143 |
+
empty: 0,
|
| 144 |
+
}
|
| 145 |
+
const nameExamples = {
|
| 146 |
+
hasBOnly: [],
|
| 147 |
+
hasBAndContextWindow: [],
|
| 148 |
+
hasBAndT: [],
|
| 149 |
+
hasMOnly: [],
|
| 150 |
+
hasMoEPattern: [],
|
| 151 |
+
noUnitToken: [],
|
| 152 |
+
}
|
| 153 |
+
const variantDFallback = {
|
| 154 |
+
addPbNumber: 0, // additional_details.params_billions is number β returned as-is
|
| 155 |
+
addPbString: 0, // additional_details.params_billions is string β Variant B
|
| 156 |
+
addParameterCount: 0, // additional_details.parameter_count fallback
|
| 157 |
+
addNumParameters: 0,
|
| 158 |
+
addParams: 0,
|
| 159 |
+
miParameterCount: 0,
|
| 160 |
+
modelNameFallback: 0, // last fallback: parseParamsBillionsFromModelName(name) β Variant C
|
| 161 |
+
noResolution: 0, // all paths return null/undefined
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
// Cross-variant disagreement on names (where parsers agree on B/None but C diverges)
|
| 165 |
+
let nameAgreementCounts = {
|
| 166 |
+
allConverge: 0, // A,B,C,E,F all return same value (or all null)
|
| 167 |
+
cTSQuirkContextWindow: 0, // C returns context-window (βͺ 1), F/E/A/B return param count
|
| 168 |
+
fOnlyMissing: 0, // F returns null because no B, but others find something
|
| 169 |
+
someDisagreement: 0,
|
| 170 |
+
}
|
| 171 |
+
const disagreementExamples = []
|
| 172 |
+
|
| 173 |
+
let totalRows = 0
|
| 174 |
+
let count = 0
|
| 175 |
+
const FILE_CAP = 100000
|
| 176 |
+
|
| 177 |
+
function classifyName(name) {
|
| 178 |
+
if (!name) return "empty"
|
| 179 |
+
// Detect MoE pattern: digit + 'x' + digit + B (no \b between x and digit)
|
| 180 |
+
if (/\dx\d+\s*[bB]\b/.test(name)) return "hasMoEPattern"
|
| 181 |
+
const hasB = /\b\d+(\.\d+)?\s*[bB]\b/.test(name)
|
| 182 |
+
const hasContextWindow = /\b\d+\s*[kK]\b/.test(name)
|
| 183 |
+
const hasT = /\b\d+(\.\d+)?\s*[tT]\b/.test(name)
|
| 184 |
+
const hasM = /\b\d+(\.\d+)?\s*[mM]\b/.test(name)
|
| 185 |
+
if (hasB && hasContextWindow) return "hasBAndContextWindow"
|
| 186 |
+
if (hasB && hasT) return "hasBAndT"
|
| 187 |
+
if (hasB) return "hasBOnly"
|
| 188 |
+
if (hasM) return "hasMOnly"
|
| 189 |
+
return "noUnitToken"
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
function classifyStringPb(s) {
|
| 193 |
+
if (/^\d+(\.\d+)?$/.test(s.trim())) return "cleanDecimal"
|
| 194 |
+
if (
|
| 195 |
+
/(\d+(\.\d+)?)\s*(trillion|tn|t|billion|bn|b|million|mn|m|thousand|k)\b/i.test(s)
|
| 196 |
+
)
|
| 197 |
+
return "unitSuffix"
|
| 198 |
+
return "other"
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
function processModelInfo(mi) {
|
| 202 |
+
totalRows++
|
| 203 |
+
const ad = mi.additional_details || {}
|
| 204 |
+
const v = ad.params_billions
|
| 205 |
+
const t = v === undefined ? "undefined" : v === null ? "null" : typeof v
|
| 206 |
+
addPbTypeDist[t] = (addPbTypeDist[t] ?? 0) + 1
|
| 207 |
+
|
| 208 |
+
if (typeof v === "string") {
|
| 209 |
+
const fmt = classifyStringPb(v)
|
| 210 |
+
stringPbFormatDist[fmt]++
|
| 211 |
+
if (stringPbExamples[fmt].length < 5) stringPbExamples[fmt].push(v)
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
// Variant D fallback resolution
|
| 215 |
+
let resolved = false
|
| 216 |
+
const rawPb =
|
| 217 |
+
ad.params_billions ?? ad.parameter_count ?? ad.num_parameters ?? ad.params
|
| 218 |
+
if (typeof rawPb === "number") {
|
| 219 |
+
if (ad.params_billions != null) variantDFallback.addPbNumber++
|
| 220 |
+
else if (ad.parameter_count != null) variantDFallback.addParameterCount++
|
| 221 |
+
else if (ad.num_parameters != null) variantDFallback.addNumParameters++
|
| 222 |
+
else variantDFallback.addParams++
|
| 223 |
+
resolved = true
|
| 224 |
+
} else if (typeof rawPb === "string") {
|
| 225 |
+
const parsed = parseParamsBillionsFromText(rawPb)
|
| 226 |
+
if (Number.isFinite(parsed)) {
|
| 227 |
+
if (ad.params_billions != null) variantDFallback.addPbString++
|
| 228 |
+
else if (ad.parameter_count != null) variantDFallback.addParameterCount++
|
| 229 |
+
else if (ad.num_parameters != null) variantDFallback.addNumParameters++
|
| 230 |
+
else variantDFallback.addParams++
|
| 231 |
+
resolved = true
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
if (!resolved && typeof mi.parameter_count === "string") {
|
| 235 |
+
const parsed = parseParamsBillionsFromText(mi.parameter_count)
|
| 236 |
+
if (Number.isFinite(parsed)) {
|
| 237 |
+
variantDFallback.miParameterCount++
|
| 238 |
+
resolved = true
|
| 239 |
+
}
|
| 240 |
+
}
|
| 241 |
+
if (!resolved) {
|
| 242 |
+
const parsed = parseParamsBillionsFromModelNameC(mi.name)
|
| 243 |
+
if (parsed != null) {
|
| 244 |
+
variantDFallback.modelNameFallback++
|
| 245 |
+
resolved = true
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
if (!resolved) variantDFallback.noResolution++
|
| 249 |
+
|
| 250 |
+
// Name format
|
| 251 |
+
const cls = classifyName(mi.name)
|
| 252 |
+
nameFormatDist[cls] = (nameFormatDist[cls] ?? 0) + 1
|
| 253 |
+
if (nameExamples[cls] && nameExamples[cls].length < 5 && mi.name) {
|
| 254 |
+
nameExamples[cls].push(mi.name)
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
// Cross-variant on the name
|
| 258 |
+
if (mi.name) {
|
| 259 |
+
const a = parseParamsBillions(mi.name)
|
| 260 |
+
const b = parseParamsBillionsFromText(mi.name)
|
| 261 |
+
const c = parseParamsBillionsFromModelNameC(mi.name)
|
| 262 |
+
const e = parseParamsBillionsFromModelNameE(mi.name)
|
| 263 |
+
const f = parseParamsBillionsInline(mi.name, mi.id ?? "")
|
| 264 |
+
|
| 265 |
+
const vals = [a, b, c, e, f]
|
| 266 |
+
const allEq = vals.every((x) => x === vals[0] || (x == null && vals[0] == null))
|
| 267 |
+
if (allEq) nameAgreementCounts.allConverge++
|
| 268 |
+
else {
|
| 269 |
+
// C-specific TS quirk: c is much smaller (< 0.001) while a/e/f match
|
| 270 |
+
if (c != null && c < 0.001 && a != null && e === a && f === a) {
|
| 271 |
+
nameAgreementCounts.cTSQuirkContextWindow++
|
| 272 |
+
if (disagreementExamples.length < 10) {
|
| 273 |
+
disagreementExamples.push({
|
| 274 |
+
name: mi.name,
|
| 275 |
+
id: mi.id,
|
| 276 |
+
type: "C TS-quirk: context-window beats param count",
|
| 277 |
+
values: { A: a, B: b, C: c, E: e, F: f },
|
| 278 |
+
})
|
| 279 |
+
}
|
| 280 |
+
} else if (f == null && (a != null || c != null || e != null)) {
|
| 281 |
+
nameAgreementCounts.fOnlyMissing++
|
| 282 |
+
} else {
|
| 283 |
+
nameAgreementCounts.someDisagreement++
|
| 284 |
+
if (disagreementExamples.length < 10) {
|
| 285 |
+
disagreementExamples.push({
|
| 286 |
+
name: mi.name,
|
| 287 |
+
id: mi.id,
|
| 288 |
+
type: "other disagreement",
|
| 289 |
+
values: { A: a, B: b, C: c, E: e, F: f },
|
| 290 |
+
})
|
| 291 |
+
}
|
| 292 |
+
}
|
| 293 |
+
}
|
| 294 |
+
}
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
function walk(node, topModelInfo) {
|
| 298 |
+
for (const m of node.metrics ?? []) {
|
| 299 |
+
for (const r of m.model_results ?? []) {
|
| 300 |
+
// Per-row model_info doesn't exist in the cache; the runtime
|
| 301 |
+
// buildModelInfoForVariant in lib/hf-data.ts:1133 spreads
|
| 302 |
+
// detail.model_info onto each row. So for audit purposes we treat the
|
| 303 |
+
// top-level model_info as the per-row model_info.
|
| 304 |
+
const mi = {
|
| 305 |
+
...topModelInfo,
|
| 306 |
+
id: r.raw_model_id ?? r.model_id ?? topModelInfo.id,
|
| 307 |
+
name: r.model_name || topModelInfo.name,
|
| 308 |
+
developer: r.developer || topModelInfo.developer,
|
| 309 |
+
additional_details: {
|
| 310 |
+
...(topModelInfo.additional_details || {}),
|
| 311 |
+
raw_model_id: r.raw_model_id ?? r.model_id,
|
| 312 |
+
},
|
| 313 |
+
}
|
| 314 |
+
processModelInfo(mi)
|
| 315 |
+
}
|
| 316 |
+
}
|
| 317 |
+
for (const s of node.subtasks ?? []) walk(s, topModelInfo)
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
for (const fn of files) {
|
| 321 |
+
if (count++ > FILE_CAP) break
|
| 322 |
+
let data
|
| 323 |
+
try {
|
| 324 |
+
data = JSON.parse(fs.readFileSync(path.join(modelsDir, fn), "utf8"))
|
| 325 |
+
} catch (err) {
|
| 326 |
+
continue
|
| 327 |
+
}
|
| 328 |
+
const mi = data.model_info || {}
|
| 329 |
+
for (const cat of Object.values(data.hierarchy_by_category ?? {})) {
|
| 330 |
+
for (const node of cat) walk(node, mi)
|
| 331 |
+
}
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
// === Print results ===
|
| 335 |
+
|
| 336 |
+
console.log(`=== Audit: params parsing ===`)
|
| 337 |
+
console.log(`Cache: ${cacheDir} (model-cards.json: ${modelCardsCount} cards; models/: ${files.length} files; ${totalRows} model_result rows scanned)`)
|
| 338 |
+
console.log()
|
| 339 |
+
|
| 340 |
+
console.log(`--- 1) Variant A input: model-cards.json.params_billions type distribution ---`)
|
| 341 |
+
console.log(modelCardsTypeDist)
|
| 342 |
+
console.log(`Variant A outputs:`, modelCardsAOutputs)
|
| 343 |
+
console.log()
|
| 344 |
+
|
| 345 |
+
console.log(`--- 2) Per-row model_info.additional_details.params_billions type distribution ---`)
|
| 346 |
+
console.log(addPbTypeDist)
|
| 347 |
+
console.log(`Of ${addPbTypeDist.string ?? 0} string-typed values, format distribution:`)
|
| 348 |
+
console.log(stringPbFormatDist)
|
| 349 |
+
for (const [k, exs] of Object.entries(stringPbExamples)) {
|
| 350 |
+
if (exs.length === 0) continue
|
| 351 |
+
console.log(` ${k} examples: ${exs.map((e) => `'${e}'`).join(", ")}`)
|
| 352 |
+
}
|
| 353 |
+
console.log()
|
| 354 |
+
|
| 355 |
+
console.log(`--- 3) Variant D (orchestrator) fallback resolution counts ---`)
|
| 356 |
+
console.log(variantDFallback)
|
| 357 |
+
const totalResolved = Object.entries(variantDFallback)
|
| 358 |
+
.filter(([k]) => k !== "noResolution")
|
| 359 |
+
.reduce((s, [, v]) => s + v, 0)
|
| 360 |
+
const pctByPath = Object.fromEntries(
|
| 361 |
+
Object.entries(variantDFallback).map(([k, v]) => [
|
| 362 |
+
k,
|
| 363 |
+
totalRows > 0 ? `${((v / totalRows) * 100).toFixed(1)}%` : "0%",
|
| 364 |
+
])
|
| 365 |
+
)
|
| 366 |
+
console.log(`(% of rows by path)`, pctByPath)
|
| 367 |
+
console.log(`Total resolved: ${totalResolved} of ${totalRows} (${((totalResolved / totalRows) * 100).toFixed(1)}%)`)
|
| 368 |
+
console.log()
|
| 369 |
+
|
| 370 |
+
console.log(`--- 4) Model name format distribution (drives Variant C/E/F) ---`)
|
| 371 |
+
console.log(nameFormatDist)
|
| 372 |
+
for (const [k, exs] of Object.entries(nameExamples)) {
|
| 373 |
+
if (exs.length === 0) continue
|
| 374 |
+
console.log(` ${k} examples: ${exs.map((e) => `'${e}'`).join(", ")}`)
|
| 375 |
+
}
|
| 376 |
+
console.log()
|
| 377 |
+
|
| 378 |
+
console.log(`--- 5) Cross-variant agreement on model names (A/B/C/E/F applied to the name string) ---`)
|
| 379 |
+
console.log(nameAgreementCounts)
|
| 380 |
+
const totalNames = Object.values(nameAgreementCounts).reduce((s, v) => s + v, 0)
|
| 381 |
+
console.log(`Total names checked: ${totalNames}`)
|
| 382 |
+
if (totalNames > 0) {
|
| 383 |
+
console.log(
|
| 384 |
+
` Convergence rate: ${((nameAgreementCounts.allConverge / totalNames) * 100).toFixed(2)}%`
|
| 385 |
+
)
|
| 386 |
+
console.log(
|
| 387 |
+
` Variant-C TS-quirk hit rate (context-window beats param count): ${nameAgreementCounts.cTSQuirkContextWindow} (${((nameAgreementCounts.cTSQuirkContextWindow / totalNames) * 100).toFixed(2)}%)`
|
| 388 |
+
)
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
if (disagreementExamples.length > 0) {
|
| 392 |
+
console.log()
|
| 393 |
+
console.log(`--- Sample disagreements (up to 10) ---`)
|
| 394 |
+
for (const ex of disagreementExamples) {
|
| 395 |
+
console.log(` [${ex.type}]`)
|
| 396 |
+
console.log(` name: '${ex.name}' id: '${ex.id}'`)
|
| 397 |
+
console.log(` A=${ex.values.A} B=${ex.values.B} C=${ex.values.C} E=${ex.values.E} F=${ex.values.F}`)
|
| 398 |
+
}
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
console.log()
|
| 402 |
+
console.log(`=== Done ===`)
|
scripts/verify-setup-alias.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import "./server-only-shim.mjs"
|
| 2 |
+
import fs from "fs"
|
| 3 |
+
|
| 4 |
+
const { getCanonicalModelIdentity, getModelFamilyRouteId } = await import("../lib/model-family.ts")
|
| 5 |
+
|
| 6 |
+
// Replicate the runtime normalizer's logic exactly (lib/hf-data.ts:750-812)
|
| 7 |
+
// so we can trace what it would do against any real card.
|
| 8 |
+
function normalizeSetupAliasQualifier(value) {
|
| 9 |
+
return value?.trim().toLowerCase().replace(/[_\s]+/g, "-") ?? ""
|
| 10 |
+
}
|
| 11 |
+
function isSetupAliasQualifier(value) {
|
| 12 |
+
const normalized = normalizeSetupAliasQualifier(value)
|
| 13 |
+
return (
|
| 14 |
+
normalized === "prompt" ||
|
| 15 |
+
normalized === "fc" ||
|
| 16 |
+
normalized === "function-calling" ||
|
| 17 |
+
normalized.startsWith("thinking")
|
| 18 |
+
)
|
| 19 |
+
}
|
| 20 |
+
function getLatestTimestamp(a, b) {
|
| 21 |
+
if (!a) return b
|
| 22 |
+
if (!b) return a
|
| 23 |
+
const aTime = new Date(a).getTime()
|
| 24 |
+
const bTime = new Date(b).getTime()
|
| 25 |
+
if (!Number.isFinite(aTime)) return b
|
| 26 |
+
if (!Number.isFinite(bTime)) return a
|
| 27 |
+
return bTime > aTime ? b : a
|
| 28 |
+
}
|
| 29 |
+
function normalizeSingleModelCardEntry(entry) {
|
| 30 |
+
const familyIdentity = getCanonicalModelIdentity({ id: entry.model_family_id, name: entry.model_family_name })
|
| 31 |
+
const variantsByKey = new Map()
|
| 32 |
+
for (const variant of entry.variants ?? []) {
|
| 33 |
+
let normalizedVariantKey = variant.variant_key
|
| 34 |
+
let normalizedVariantLabel = variant.variant_label
|
| 35 |
+
if (variant.variant_key === "base") {
|
| 36 |
+
normalizedVariantKey = "default"
|
| 37 |
+
normalizedVariantLabel = "Default"
|
| 38 |
+
} else if (variant.variant_key !== "default") {
|
| 39 |
+
const syntheticIdentity = getCanonicalModelIdentity({
|
| 40 |
+
id: `${familyIdentity.familyId}-${variant.variant_key}`,
|
| 41 |
+
name: `${familyIdentity.familyId}-${variant.variant_key}`,
|
| 42 |
+
})
|
| 43 |
+
if (syntheticIdentity.versionDate && isSetupAliasQualifier(syntheticIdentity.versionQualifier)) {
|
| 44 |
+
normalizedVariantKey = syntheticIdentity.versionDate
|
| 45 |
+
normalizedVariantLabel = syntheticIdentity.versionDate
|
| 46 |
+
} else {
|
| 47 |
+
normalizedVariantKey = syntheticIdentity.variantKey
|
| 48 |
+
normalizedVariantLabel = syntheticIdentity.variantLabel
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
const existing = variantsByKey.get(normalizedVariantKey)
|
| 52 |
+
if (existing) {
|
| 53 |
+
existing.evaluation_count += variant.evaluation_count
|
| 54 |
+
existing.last_updated = getLatestTimestamp(existing.last_updated, variant.last_updated)
|
| 55 |
+
existing.raw_model_ids = Array.from(new Set([...(existing.raw_model_ids ?? []), ...(variant.raw_model_ids ?? [])])).sort()
|
| 56 |
+
continue
|
| 57 |
+
}
|
| 58 |
+
variantsByKey.set(normalizedVariantKey, {
|
| 59 |
+
...variant,
|
| 60 |
+
variant_key: normalizedVariantKey,
|
| 61 |
+
variant_label: normalizedVariantLabel,
|
| 62 |
+
raw_model_ids: [...(variant.raw_model_ids ?? [])].sort(),
|
| 63 |
+
})
|
| 64 |
+
}
|
| 65 |
+
const normalizedVariants = Array.from(variantsByKey.values())
|
| 66 |
+
return { ...entry, variants: normalizedVariants }
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
const cards = JSON.parse(fs.readFileSync(".cache/hf-data/model-cards.json", "utf8"))
|
| 70 |
+
|
| 71 |
+
// === Sample a known-affected card to show pre/post ===
|
| 72 |
+
console.log("=== Sample: openai/gpt-5.2 (multi-variant flagship, known to exercise the rule) ===")
|
| 73 |
+
const gpt52 = cards.find(c => c.model_family_id === "openai/gpt-5.2")
|
| 74 |
+
if (gpt52) {
|
| 75 |
+
console.log("Input variants (cache state):")
|
| 76 |
+
for (const v of gpt52.variants) console.log(` '${v.variant_key}'`)
|
| 77 |
+
console.log("After runtime normalize (what /api/model-cards returns):")
|
| 78 |
+
const normalized = normalizeSingleModelCardEntry(gpt52)
|
| 79 |
+
for (const v of normalized.variants) console.log(` '${v.variant_key}' / raw_ids count=${v.raw_model_ids?.length ?? 0}`)
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
// === Aggregate audit: across all cards, does normalizer change anything? ===
|
| 83 |
+
console.log("\n=== Aggregate: cards where normalizer would CHANGE the variants list ===")
|
| 84 |
+
let changedCount = 0
|
| 85 |
+
const changedExamples = []
|
| 86 |
+
for (const c of cards) {
|
| 87 |
+
const beforeKeys = (c.variants ?? []).map(v => v.variant_key).sort().join("|")
|
| 88 |
+
const afterKeys = normalizeSingleModelCardEntry(c).variants.map(v => v.variant_key).sort().join("|")
|
| 89 |
+
if (beforeKeys !== afterKeys) {
|
| 90 |
+
changedCount++
|
| 91 |
+
if (changedExamples.length < 5) changedExamples.push({ family: c.model_family_id, before: beforeKeys, after: afterKeys })
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
console.log(` ${changedCount} of ${cards.length} cards would have variants changed by runtime normalizer`)
|
| 95 |
+
for (const ex of changedExamples) {
|
| 96 |
+
console.log(` ${ex.family}:`)
|
| 97 |
+
console.log(` before: ${ex.before}`)
|
| 98 |
+
console.log(` after: ${ex.after}`)
|
| 99 |
+
}
|
scripts/verify-slug-candidates.mjs
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import "./server-only-shim.mjs"
|
| 2 |
+
import fs from "fs"
|
| 3 |
+
|
| 4 |
+
// Replicate the slug-candidate generators from lib/model-data.ts:150-211 verbatim.
|
| 5 |
+
function pipelineSlugify(text) {
|
| 6 |
+
return (
|
| 7 |
+
text
|
| 8 |
+
.replace(/[\x00-\x1f\x7f]/g, "")
|
| 9 |
+
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
| 10 |
+
.replace(/^_+|_+$/g, "") || "unknown"
|
| 11 |
+
)
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
function getModelDetailSlugCandidates(modelId) {
|
| 15 |
+
const normalized = modelId.trim()
|
| 16 |
+
const candidates = new Set()
|
| 17 |
+
const withSlash = normalized.replace(/\//g, "__")
|
| 18 |
+
const withDots = withSlash.replace(/\./g, "-")
|
| 19 |
+
candidates.add(pipelineSlugify(withSlash))
|
| 20 |
+
candidates.add(pipelineSlugify(withSlash.toLowerCase()))
|
| 21 |
+
candidates.add(pipelineSlugify(withDots))
|
| 22 |
+
candidates.add(pipelineSlugify(withDots.toLowerCase()))
|
| 23 |
+
candidates.add(pipelineSlugify(normalized))
|
| 24 |
+
candidates.add(pipelineSlugify(normalized.toLowerCase()))
|
| 25 |
+
return Array.from(candidates)
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function getDeveloperSlugCandidates(developerOrRouteId) {
|
| 29 |
+
const normalized = developerOrRouteId.trim()
|
| 30 |
+
const candidates = new Set()
|
| 31 |
+
const lowercase = normalized.toLowerCase()
|
| 32 |
+
const underscoreSlug = pipelineSlugify(normalized)
|
| 33 |
+
const lowercaseUnderscoreSlug = pipelineSlugify(lowercase)
|
| 34 |
+
const hyphenSlug = lowercase
|
| 35 |
+
.replace(/[\x00-\x1f\x7f]/g, "")
|
| 36 |
+
.replace(/[^a-z0-9]+/g, "-")
|
| 37 |
+
.replace(/^-+|-+$/g, "")
|
| 38 |
+
const compactSlug = lowercase.replace(/[^a-z0-9]+/g, "")
|
| 39 |
+
candidates.add(underscoreSlug)
|
| 40 |
+
candidates.add(lowercaseUnderscoreSlug)
|
| 41 |
+
candidates.add(underscoreSlug.replace(/_/g, "-"))
|
| 42 |
+
candidates.add(lowercaseUnderscoreSlug.replace(/_/g, "-"))
|
| 43 |
+
if (hyphenSlug) candidates.add(hyphenSlug)
|
| 44 |
+
if (compactSlug) candidates.add(compactSlug)
|
| 45 |
+
return Array.from(candidates)
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// === Audit: for each model card, which candidate position resolves? ===
|
| 49 |
+
const cards = JSON.parse(fs.readFileSync(".cache/hf-data/model-cards.json", "utf8"))
|
| 50 |
+
const modelFiles = new Set(fs.readdirSync(".cache/hf-data/models"))
|
| 51 |
+
|
| 52 |
+
console.log(`=== Model lookups (${cards.length} cards) ===`)
|
| 53 |
+
const modelHitPositions = new Map()
|
| 54 |
+
let modelMisses = 0
|
| 55 |
+
const modelMissExamples = []
|
| 56 |
+
|
| 57 |
+
for (const c of cards) {
|
| 58 |
+
// Try the family_id (with slash) since that's what getModelSummaryById uses
|
| 59 |
+
const candidates = getModelDetailSlugCandidates(c.model_family_id)
|
| 60 |
+
let hitPos = -1
|
| 61 |
+
for (let i = 0; i < candidates.length; i++) {
|
| 62 |
+
if (modelFiles.has(`${candidates[i]}.json`)) {
|
| 63 |
+
hitPos = i
|
| 64 |
+
break
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
if (hitPos === -1) {
|
| 68 |
+
modelMisses++
|
| 69 |
+
if (modelMissExamples.length < 5) modelMissExamples.push({ family: c.model_family_id, route: c.model_route_id, candidates })
|
| 70 |
+
} else {
|
| 71 |
+
modelHitPositions.set(hitPos, (modelHitPositions.get(hitPos) ?? 0) + 1)
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
for (const [pos, n] of [...modelHitPositions.entries()].sort((a, b) => a[0] - b[0])) {
|
| 75 |
+
console.log(` position ${pos}: ${n} hits`)
|
| 76 |
+
}
|
| 77 |
+
console.log(` misses (none of 6 candidates worked): ${modelMisses}`)
|
| 78 |
+
if (modelMissExamples.length) console.log(" miss examples:", modelMissExamples)
|
| 79 |
+
|
| 80 |
+
// === Audit: for each developer, which candidate position resolves? ===
|
| 81 |
+
const devs = JSON.parse(fs.readFileSync(".cache/hf-data/developers.json", "utf8"))
|
| 82 |
+
const developerFiles = new Set(fs.readdirSync(".cache/hf-data/developers"))
|
| 83 |
+
|
| 84 |
+
console.log(`\n=== Developer lookups (${devs.length} developers) ===`)
|
| 85 |
+
const devHitPositions = new Map()
|
| 86 |
+
let devMisses = 0
|
| 87 |
+
const devMissExamples = []
|
| 88 |
+
|
| 89 |
+
for (const d of devs) {
|
| 90 |
+
const candidates = getDeveloperSlugCandidates(d.developer ?? d.route_id ?? "")
|
| 91 |
+
let hitPos = -1
|
| 92 |
+
for (let i = 0; i < candidates.length; i++) {
|
| 93 |
+
if (developerFiles.has(`${candidates[i]}.json`)) {
|
| 94 |
+
hitPos = i
|
| 95 |
+
break
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
if (hitPos === -1) {
|
| 99 |
+
devMisses++
|
| 100 |
+
if (devMissExamples.length < 5) devMissExamples.push({ dev: d.developer, candidates })
|
| 101 |
+
} else {
|
| 102 |
+
devHitPositions.set(hitPos, (devHitPositions.get(hitPos) ?? 0) + 1)
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
for (const [pos, n] of [...devHitPositions.entries()].sort((a, b) => a[0] - b[0])) {
|
| 106 |
+
console.log(` position ${pos}: ${n} hits`)
|
| 107 |
+
}
|
| 108 |
+
console.log(` misses (none of 6 candidates worked): ${devMisses}`)
|
| 109 |
+
if (devMissExamples.length) console.log(" miss examples:", devMissExamples)
|
| 110 |
+
|
| 111 |
+
// === Show example candidates for a dotted-name model ===
|
| 112 |
+
console.log("\n=== Example: openai/gpt-5.2 candidates ===")
|
| 113 |
+
const exCandidates = getModelDetailSlugCandidates("openai/gpt-5.2")
|
| 114 |
+
for (let i = 0; i < exCandidates.length; i++) {
|
| 115 |
+
const exists = modelFiles.has(`${exCandidates[i]}.json`)
|
| 116 |
+
console.log(` [${i}] '${exCandidates[i]}.json' ${exists ? "β HIT" : "(miss)"}`)
|
| 117 |
+
}
|
scripts/verify-timestamp.mjs
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import "./server-only-shim.mjs"
|
| 2 |
+
import fs from "fs"
|
| 3 |
+
|
| 4 |
+
// === Replicate all three TS implementations verbatim ===
|
| 5 |
+
|
| 6 |
+
// 1. lib/model-data.ts:76 β uses Number(), multiplies by 1000 if numeric AND no dash
|
| 7 |
+
function normalizeEvalTimestamp(value) {
|
| 8 |
+
const numericTimestamp = Number(value)
|
| 9 |
+
return !Number.isNaN(numericTimestamp) && !value.includes("-")
|
| 10 |
+
? numericTimestamp * 1000
|
| 11 |
+
: new Date(value).getTime()
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
// 2. lib/hf-data.ts:1049 β uses parseFloat, NO multiplier, handles undefined
|
| 15 |
+
function toComparableTimestampHfData(timestamp) {
|
| 16 |
+
if (!timestamp) {
|
| 17 |
+
return Number.NEGATIVE_INFINITY
|
| 18 |
+
}
|
| 19 |
+
const numericTimestamp = Number.parseFloat(timestamp)
|
| 20 |
+
if (Number.isFinite(numericTimestamp)) {
|
| 21 |
+
return numericTimestamp
|
| 22 |
+
}
|
| 23 |
+
const parsedTimestamp = new Date(timestamp).getTime()
|
| 24 |
+
return Number.isFinite(parsedTimestamp) ? parsedTimestamp : Number.NEGATIVE_INFINITY
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// 3. components/benchmark-detail.tsx:1418 β same as hf-data.ts but no undefined handling
|
| 28 |
+
function toComparableTimestampBenchmarkDetail(timestamp) {
|
| 29 |
+
const numericTimestamp = Number.parseFloat(timestamp)
|
| 30 |
+
if (Number.isFinite(numericTimestamp)) {
|
| 31 |
+
return numericTimestamp
|
| 32 |
+
}
|
| 33 |
+
const parsedTimestamp = new Date(timestamp).getTime()
|
| 34 |
+
return Number.isFinite(parsedTimestamp) ? parsedTimestamp : Number.NEGATIVE_INFINITY
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// === Audit: distribution of timestamp formats in production ===
|
| 38 |
+
|
| 39 |
+
const dir = ".cache/hf-data/models"
|
| 40 |
+
const files = fs.readdirSync(dir)
|
| 41 |
+
const formats = { isoDateTime: 0, unixSecondsString: 0, unixMsString: 0, empty: 0, other: 0 }
|
| 42 |
+
const formatExamples = { isoDateTime: [], unixSecondsString: [], unixMsString: [], other: [] }
|
| 43 |
+
let totalChecked = 0
|
| 44 |
+
|
| 45 |
+
function classify(ts) {
|
| 46 |
+
if (!ts) return "empty"
|
| 47 |
+
// ISO date-time has dashes (YYYY-MM-DD or YYYY-MM-DDTHH:...)
|
| 48 |
+
if (/^\d{4}-\d{2}-\d{2}/.test(ts)) return "isoDateTime"
|
| 49 |
+
// All numeric
|
| 50 |
+
if (/^\d+(\.\d+)?$/.test(ts)) {
|
| 51 |
+
const n = Number.parseFloat(ts)
|
| 52 |
+
// Unix seconds typically ~1.6e9 (year 2020+) up to ~2e9 (2033)
|
| 53 |
+
// Unix ms typically ~1.6e12 (year 2020+) up to ~2e12 (2033)
|
| 54 |
+
if (n < 1e11) return "unixSecondsString"
|
| 55 |
+
return "unixMsString"
|
| 56 |
+
}
|
| 57 |
+
return "other"
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function walk(node) {
|
| 61 |
+
for (const m of node.metrics ?? []) {
|
| 62 |
+
for (const r of m.model_results ?? []) {
|
| 63 |
+
const ts = r.retrieved_timestamp
|
| 64 |
+
const cat = classify(ts)
|
| 65 |
+
formats[cat] = (formats[cat] ?? 0) + 1
|
| 66 |
+
if (formatExamples[cat] && formatExamples[cat].length < 3) formatExamples[cat].push(ts)
|
| 67 |
+
totalChecked++
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
for (const s of node.subtasks ?? []) walk(s)
|
| 71 |
+
}
|
| 72 |
+
for (const f of files) {
|
| 73 |
+
const data = JSON.parse(fs.readFileSync(`${dir}/${f}`, "utf8"))
|
| 74 |
+
for (const cat of Object.values(data.hierarchy_by_category ?? {})) {
|
| 75 |
+
for (const node of cat) walk(node)
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
console.log(`=== Audit: timestamp format distribution (${totalChecked} model_result rows) ===`)
|
| 80 |
+
console.log(formats)
|
| 81 |
+
console.log()
|
| 82 |
+
for (const [k, exs] of Object.entries(formatExamples)) {
|
| 83 |
+
if (exs.length === 0) continue
|
| 84 |
+
console.log(`--- ${k} examples ---`)
|
| 85 |
+
for (const e of exs) console.log(` '${e}'`)
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// === Audit: do the three implementations produce SAME relative ordering? ===
|
| 89 |
+
// Pick pairs of distinct-format timestamps and compare under each function.
|
| 90 |
+
console.log("\n=== Cross-impl ordering: same input pairs ===")
|
| 91 |
+
const pairs = [
|
| 92 |
+
["1774096306.427425", "2026-04-13T12:34:56Z"], // unix seconds vs ISO datetime
|
| 93 |
+
["1774096306427", "2026-04-13T12:34:56Z"], // unix ms vs ISO
|
| 94 |
+
["2025-01-01", "2026-01-01"], // two ISO dates
|
| 95 |
+
["1700000000", "1800000000"], // two unix seconds
|
| 96 |
+
["1700000000000", "1800000000000"], // two unix ms
|
| 97 |
+
]
|
| 98 |
+
for (const [a, b] of pairs) {
|
| 99 |
+
const m = normalizeEvalTimestamp
|
| 100 |
+
const h = toComparableTimestampHfData
|
| 101 |
+
const c = toComparableTimestampBenchmarkDetail
|
| 102 |
+
console.log(` pair: '${a}' vs '${b}'`)
|
| 103 |
+
console.log(` model-data.ts: ${m(a)} vs ${m(b)} (a${m(a) < m(b) ? '<' : m(a) > m(b) ? '>' : '='}b)`)
|
| 104 |
+
console.log(` hf-data.ts: ${h(a)} vs ${h(b)} (a${h(a) < h(b) ? '<' : h(a) > h(b) ? '>' : '='}b)`)
|
| 105 |
+
console.log(` benchmark.tsx: ${c(a)} vs ${c(b)} (a${c(a) < c(b) ? '<' : c(a) > c(b) ? '>' : '='}b)`)
|
| 106 |
+
}
|
tests/fixtures/manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"$comment": "Pinned snapshot of HF cache files used by Tier A pipeline-contract tests and Tier B adapter snapshot tests. Refresh via `pnpm refresh-fixtures`. Each fixture
|
| 3 |
"snapshot_source": ".cache/hf-data",
|
| 4 |
"snapshot_ts": "2026-04-27T22:32:59.376Z",
|
| 5 |
"evals": [
|
|
|
|
| 1 |
{
|
| 2 |
+
"$comment": "Pinned snapshot of HF cache files used by Tier A pipeline-contract tests and Tier B adapter snapshot tests. Refresh via `pnpm refresh-fixtures`. Each fixture entry has a `why` field explaining the specific code path it exercises (multi-variant model, first/third-party badge, Safety regression-bait, coding hierarchy key, etc). Curation rule: every fixture must justify its inclusion via `why`; no random sampling.",
|
| 3 |
"snapshot_source": ".cache/hf-data",
|
| 4 |
"snapshot_ts": "2026-04-27T22:32:59.376Z",
|
| 5 |
"evals": [
|
tests/pipeline-contract.test.ts
CHANGED
|
@@ -195,6 +195,47 @@ describe("Tier A β pipeline contracts (eval-detail files)", () => {
|
|
| 195 |
}
|
| 196 |
expect(violations, formatViolations(violations)).toEqual([])
|
| 197 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
})
|
| 199 |
|
| 200 |
describe("Tier A β pipeline contracts (model card list entries)", () => {
|
|
|
|
| 195 |
}
|
| 196 |
expect(violations, formatViolations(violations)).toEqual([])
|
| 197 |
})
|
| 198 |
+
|
| 199 |
+
// Replaces the deleted `prefersBenchmarkName` heuristic in
|
| 200 |
+
// `lib/model-data.ts hfEvalEntryToListItem`. That heuristic detected when a
|
| 201 |
+
// display string was in a "<generic-metric> on <benchmark>" / "for scorer" /
|
| 202 |
+
// "model_graded" shape and substituted the benchmark name. Audit against the
|
| 203 |
+
// full corpus showed 0/587 matches (verified 2026-04-28). The heuristic was
|
| 204 |
+
// deleted in favor of this explicit contract; if pipeline ever starts
|
| 205 |
+
// emitting display strings in those shapes again, this test fails loudly.
|
| 206 |
+
//
|
| 207 |
+
// The four fields below mirror the resolution order the deleted code used
|
| 208 |
+
// (`entry.evaluation_name || entry.display_name || entry.benchmark_leaf_name
|
| 209 |
+
// || entry.eval_summary_id`).
|
| 210 |
+
it("eval-list display strings don't match prefersBenchmarkName patterns (deleted heuristic)", () => {
|
| 211 |
+
const violations: Violation[] = []
|
| 212 |
+
for (const { id, data } of evals) {
|
| 213 |
+
// Pipeline emits `evaluation_name` and `display_name` on eval entries
|
| 214 |
+
// even though they're not on HFEvalDetail (TS type is a subset of the
|
| 215 |
+
// actual cache shape). Cast to access them.
|
| 216 |
+
const extras = data as unknown as { evaluation_name?: string; display_name?: string }
|
| 217 |
+
const raw =
|
| 218 |
+
extras.evaluation_name ||
|
| 219 |
+
extras.display_name ||
|
| 220 |
+
data.benchmark_leaf_name ||
|
| 221 |
+
data.eval_summary_id ||
|
| 222 |
+
""
|
| 223 |
+
const normalized = raw.trim().toLowerCase()
|
| 224 |
+
const reasons: string[] = []
|
| 225 |
+
if (normalized.startsWith("accuracy on ")) reasons.push("startsWith('accuracy on ')")
|
| 226 |
+
if (normalized.startsWith("score on ")) reasons.push("startsWith('score on ')")
|
| 227 |
+
if (normalized.includes("for scorer")) reasons.push("includes('for scorer')")
|
| 228 |
+
if (normalized.includes("model_graded")) reasons.push("includes('model_graded')")
|
| 229 |
+
if (reasons.length > 0) {
|
| 230 |
+
violations.push({
|
| 231 |
+
fixture: id,
|
| 232 |
+
path: "evaluation_name|display_name|benchmark_leaf_name|eval_summary_id",
|
| 233 |
+
detail: `${JSON.stringify(raw)} matches: ${reasons.join(", ")}`,
|
| 234 |
+
})
|
| 235 |
+
}
|
| 236 |
+
}
|
| 237 |
+
expect(violations, formatViolations(violations)).toEqual([])
|
| 238 |
+
})
|
| 239 |
})
|
| 240 |
|
| 241 |
describe("Tier A β pipeline contracts (model card list entries)", () => {
|
tests/transformations/benchmark-card-attachment.test.ts
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest"
|
| 2 |
+
|
| 3 |
+
// Executable spec for `notes/transformations/11-benchmark-card-attachment.md`.
|
| 4 |
+
//
|
| 5 |
+
// Replicates the four transformation pieces from
|
| 6 |
+
// lib/benchmark-metadata.ts:11-49
|
| 7 |
+
// lib/benchmark-metadata-utils.ts:10-33
|
| 8 |
+
// lib/model-data.ts:860-875
|
| 9 |
+
// lib/duckdb-data.ts:133-156
|
| 10 |
+
// verbatim. Pipeline must produce identical attach behaviour for every case
|
| 11 |
+
// below β and the migration target is "always inline benchmark_card so this
|
| 12 |
+
// retry loop becomes dead code."
|
| 13 |
+
|
| 14 |
+
// ---------------------------------------------------------------------------
|
| 15 |
+
// Verbatim copies of lib/benchmark-metadata-utils.ts:10-33
|
| 16 |
+
// ---------------------------------------------------------------------------
|
| 17 |
+
|
| 18 |
+
function normalizeBenchmarkKey(name: string): string {
|
| 19 |
+
if (!name) return ""
|
| 20 |
+
return name
|
| 21 |
+
.replace(/^[a-z0-9_]+ ?\//i, "")
|
| 22 |
+
.toLowerCase()
|
| 23 |
+
.replace(/[_-]+/g, " ")
|
| 24 |
+
.replace(/\s+/g, " ")
|
| 25 |
+
.trim()
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function candidateBenchmarkKeys(name: string): string[] {
|
| 29 |
+
const base = normalizeBenchmarkKey(name)
|
| 30 |
+
return Array.from(
|
| 31 |
+
new Set([
|
| 32 |
+
base,
|
| 33 |
+
base.replace(/-/g, " "),
|
| 34 |
+
base.replace(/ /g, "-"),
|
| 35 |
+
base.replace(/[^a-z0-9]/g, ""),
|
| 36 |
+
])
|
| 37 |
+
)
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// ---------------------------------------------------------------------------
|
| 41 |
+
// Verbatim copy of lib/benchmark-metadata.ts:11-49 (map build + getBenchmarkCard)
|
| 42 |
+
// ---------------------------------------------------------------------------
|
| 43 |
+
|
| 44 |
+
interface MinimalCard {
|
| 45 |
+
benchmark_details: { name: string }
|
| 46 |
+
// tag for fixture identification
|
| 47 |
+
__id: string
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function buildMap(cards: Record<string, MinimalCard>): Map<string, MinimalCard> {
|
| 51 |
+
const map = new Map<string, MinimalCard>()
|
| 52 |
+
for (const card of Object.values(cards)) {
|
| 53 |
+
if (!card?.benchmark_details?.name) continue
|
| 54 |
+
for (const key of candidateBenchmarkKeys(card.benchmark_details.name)) {
|
| 55 |
+
if (!map.has(key)) {
|
| 56 |
+
map.set(key, card)
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
return map
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function getBenchmarkCard(
|
| 64 |
+
map: Map<string, MinimalCard>,
|
| 65 |
+
benchmarkName: string
|
| 66 |
+
): MinimalCard | null {
|
| 67 |
+
for (const key of candidateBenchmarkKeys(benchmarkName)) {
|
| 68 |
+
const card = map.get(key)
|
| 69 |
+
if (card) return card
|
| 70 |
+
}
|
| 71 |
+
return null
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// ---------------------------------------------------------------------------
|
| 75 |
+
// Verbatim copy of lib/model-data.ts:860-875 (summary attach order: name, name, key)
|
| 76 |
+
// ---------------------------------------------------------------------------
|
| 77 |
+
|
| 78 |
+
interface MinimalSummary {
|
| 79 |
+
evaluation_name?: string
|
| 80 |
+
composite_benchmark_name?: string
|
| 81 |
+
composite_benchmark_key?: string
|
| 82 |
+
benchmark_card?: MinimalCard | null
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function attachBenchmarkCardToSummary(
|
| 86 |
+
map: Map<string, MinimalCard>,
|
| 87 |
+
summary: MinimalSummary
|
| 88 |
+
): MinimalSummary {
|
| 89 |
+
if (summary.benchmark_card) return summary
|
| 90 |
+
const candidates = [
|
| 91 |
+
summary.evaluation_name,
|
| 92 |
+
summary.composite_benchmark_name,
|
| 93 |
+
summary.composite_benchmark_key,
|
| 94 |
+
]
|
| 95 |
+
for (const candidate of candidates) {
|
| 96 |
+
const card = getBenchmarkCard(map, candidate ?? "")
|
| 97 |
+
if (card) return { ...summary, benchmark_card: card }
|
| 98 |
+
}
|
| 99 |
+
return summary
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// ---------------------------------------------------------------------------
|
| 103 |
+
// Verbatim copy of lib/duckdb-data.ts:133-156 (list attach order: name, key, name)
|
| 104 |
+
// ---------------------------------------------------------------------------
|
| 105 |
+
|
| 106 |
+
interface MinimalListItem {
|
| 107 |
+
evaluation_name?: string
|
| 108 |
+
composite_benchmark_name?: string
|
| 109 |
+
composite_benchmark_key?: string
|
| 110 |
+
benchmark_card?: MinimalCard | null
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
function attachBenchmarkCardToListItem(
|
| 114 |
+
map: Map<string, MinimalCard>,
|
| 115 |
+
item: MinimalListItem
|
| 116 |
+
): MinimalListItem {
|
| 117 |
+
if (item.benchmark_card) return item
|
| 118 |
+
const candidates = [
|
| 119 |
+
item.evaluation_name,
|
| 120 |
+
item.composite_benchmark_key,
|
| 121 |
+
item.composite_benchmark_name,
|
| 122 |
+
].filter(Boolean) as string[]
|
| 123 |
+
for (const name of candidates) {
|
| 124 |
+
const card = getBenchmarkCard(map, name)
|
| 125 |
+
if (card) return { ...item, benchmark_card: card }
|
| 126 |
+
}
|
| 127 |
+
return item
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
// ---------------------------------------------------------------------------
|
| 131 |
+
// Group A β normalizeBenchmarkKey
|
| 132 |
+
// ---------------------------------------------------------------------------
|
| 133 |
+
|
| 134 |
+
describe("Group A β normalizeBenchmarkKey", () => {
|
| 135 |
+
const cases = [
|
| 136 |
+
{ input: "MMLU", expected: "mmlu", why: "lowercase only" },
|
| 137 |
+
{ input: "BIG-Bench Hard (BBH)", expected: "big bench hard (bbh)", why: "dashes β spaces" },
|
| 138 |
+
{ input: "hfopenllm_v2/mmlu", expected: "mmlu", why: "composite prefix stripped" },
|
| 139 |
+
{ input: "hfopenllm_v2 / mmlu", expected: "mmlu", why: "composite prefix with one space before /" },
|
| 140 |
+
{ input: "GPQA / Diamond", expected: "diamond", why: "regex allows optional space before /" },
|
| 141 |
+
{ input: "", expected: "", why: "falsy short-circuit (NOT 'unknown' fallback)" },
|
| 142 |
+
{ input: " MMLU ", expected: "mmlu", why: "trim leading/trailing whitespace" },
|
| 143 |
+
{ input: "foo___bar", expected: "foo bar", why: "underscore run β single space" },
|
| 144 |
+
{ input: "foo - - bar", expected: "foo bar", why: "dash + space runs collapse to single space" },
|
| 145 |
+
{ input: "mmlu_categories/mmlu_pro", expected: "mmlu pro", why: "prefix strip + underscore collapse" },
|
| 146 |
+
{ input: "BBH", expected: "bbh", why: "lowercase only" },
|
| 147 |
+
]
|
| 148 |
+
it.each(cases)("'$input' β '$expected' ($why)", ({ input, expected }) => {
|
| 149 |
+
expect(normalizeBenchmarkKey(input)).toBe(expected)
|
| 150 |
+
})
|
| 151 |
+
})
|
| 152 |
+
|
| 153 |
+
// ---------------------------------------------------------------------------
|
| 154 |
+
// Group B β candidateBenchmarkKeys
|
| 155 |
+
// ---------------------------------------------------------------------------
|
| 156 |
+
|
| 157 |
+
describe("Group B β candidateBenchmarkKeys", () => {
|
| 158 |
+
const cases = [
|
| 159 |
+
{ input: "MMLU", expected: ["mmlu"], why: "no dashes, no spaces, alnum-only β all 4 variants collapse" },
|
| 160 |
+
{
|
| 161 |
+
input: "BIG-Bench Hard (BBH)",
|
| 162 |
+
expected: ["big bench hard (bbh)", "big-bench-hard-(bbh)", "bigbenchhardbbh"],
|
| 163 |
+
why: "base; spacesβdashes; alnum-only (dashesβspaces collides with base after normalizer dash-collapse)",
|
| 164 |
+
},
|
| 165 |
+
{ input: "GSM-8K", expected: ["gsm 8k", "gsm-8k", "gsm8k"], why: "base; spacesβdashes; alnum-only" },
|
| 166 |
+
{ input: "gsm 8k", expected: ["gsm 8k", "gsm-8k", "gsm8k"], why: "identical to above (input differs only in dash/space)" },
|
| 167 |
+
{ input: "hfopenllm_v2/mmlu", expected: ["mmlu"], why: "composite prefix stripped, no other variations" },
|
| 168 |
+
{ input: "", expected: [""], why: "all four variants collapse to empty string" },
|
| 169 |
+
]
|
| 170 |
+
it.each(cases)("'$input' β $expected ($why)", ({ input, expected }) => {
|
| 171 |
+
expect(candidateBenchmarkKeys(input)).toEqual(expected)
|
| 172 |
+
})
|
| 173 |
+
})
|
| 174 |
+
|
| 175 |
+
// ---------------------------------------------------------------------------
|
| 176 |
+
// Group C β getBenchmarkCard (per-name lookup)
|
| 177 |
+
// ---------------------------------------------------------------------------
|
| 178 |
+
|
| 179 |
+
describe("Group C β getBenchmarkCard (per-name lookup against deduped map)", () => {
|
| 180 |
+
const cards: Record<string, MinimalCard> = {
|
| 181 |
+
mmlu: { __id: "mmlu", benchmark_details: { name: "MMLU" } },
|
| 182 |
+
bbh: { __id: "bbh", benchmark_details: { name: "BIG-Bench Hard (BBH)" } },
|
| 183 |
+
gsm: { __id: "gsm", benchmark_details: { name: "GSM-8K" } },
|
| 184 |
+
}
|
| 185 |
+
const map = buildMap(cards)
|
| 186 |
+
|
| 187 |
+
it("finds MMLU via base candidate", () => {
|
| 188 |
+
expect(getBenchmarkCard(map, "MMLU")?.__id).toBe("mmlu")
|
| 189 |
+
})
|
| 190 |
+
it("finds MMLU via case variation", () => {
|
| 191 |
+
expect(getBenchmarkCard(map, "mmlu")?.__id).toBe("mmlu")
|
| 192 |
+
})
|
| 193 |
+
it("finds BBH card via full title (matches indexed key)", () => {
|
| 194 |
+
expect(getBenchmarkCard(map, "BIG-Bench Hard (BBH)")?.__id).toBe("bbh")
|
| 195 |
+
})
|
| 196 |
+
it("MISSES BBH abbreviation (reverse-lookup limitation)", () => {
|
| 197 |
+
// The card was indexed under variants of its full name. The eval name
|
| 198 |
+
// "BBH" produces only ["bbh"] which does not collide with any indexed key.
|
| 199 |
+
expect(getBenchmarkCard(map, "BBH")).toBeNull()
|
| 200 |
+
})
|
| 201 |
+
it("finds GSM-8K via input with space", () => {
|
| 202 |
+
expect(getBenchmarkCard(map, "GSM 8K")?.__id).toBe("gsm")
|
| 203 |
+
})
|
| 204 |
+
it("returns null for empty string (no card indexed under '')", () => {
|
| 205 |
+
expect(getBenchmarkCard(map, "")).toBeNull()
|
| 206 |
+
})
|
| 207 |
+
it("strips composite prefix and finds the leaf card", () => {
|
| 208 |
+
expect(getBenchmarkCard(map, "hfopenllm_v2/mmlu")?.__id).toBe("mmlu")
|
| 209 |
+
})
|
| 210 |
+
})
|
| 211 |
+
|
| 212 |
+
// ---------------------------------------------------------------------------
|
| 213 |
+
// Group D β Map build first-write-wins dedup
|
| 214 |
+
// ---------------------------------------------------------------------------
|
| 215 |
+
|
| 216 |
+
describe("Group D β map build dedup behaviour (first-write-wins)", () => {
|
| 217 |
+
it("when two cards normalize to the same key, the first one wins", () => {
|
| 218 |
+
const cards: Record<string, MinimalCard> = {
|
| 219 |
+
first: { __id: "first", benchmark_details: { name: "MMLU" } },
|
| 220 |
+
second: { __id: "second", benchmark_details: { name: "mmlu" } },
|
| 221 |
+
}
|
| 222 |
+
const map = buildMap(cards)
|
| 223 |
+
expect(getBenchmarkCard(map, "MMLU")?.__id).toBe("first")
|
| 224 |
+
})
|
| 225 |
+
|
| 226 |
+
it("Object.values insertion order determines who wins", () => {
|
| 227 |
+
// Reverse insertion order β now 'second' comes first.
|
| 228 |
+
const cards: Record<string, MinimalCard> = {
|
| 229 |
+
second: { __id: "second", benchmark_details: { name: "mmlu" } },
|
| 230 |
+
first: { __id: "first", benchmark_details: { name: "MMLU" } },
|
| 231 |
+
}
|
| 232 |
+
const map = buildMap(cards)
|
| 233 |
+
expect(getBenchmarkCard(map, "MMLU")?.__id).toBe("second")
|
| 234 |
+
})
|
| 235 |
+
|
| 236 |
+
it("a card with missing benchmark_details.name is skipped", () => {
|
| 237 |
+
const cards: Record<string, MinimalCard> = {
|
| 238 |
+
bad: { __id: "bad", benchmark_details: { name: "" } },
|
| 239 |
+
good: { __id: "good", benchmark_details: { name: "MMLU" } },
|
| 240 |
+
}
|
| 241 |
+
const map = buildMap(cards)
|
| 242 |
+
expect(getBenchmarkCard(map, "MMLU")?.__id).toBe("good")
|
| 243 |
+
})
|
| 244 |
+
|
| 245 |
+
it("a card with multiple distinct candidate keys is reachable via each", () => {
|
| 246 |
+
const cards: Record<string, MinimalCard> = {
|
| 247 |
+
bbh: { __id: "bbh", benchmark_details: { name: "BIG-Bench Hard (BBH)" } },
|
| 248 |
+
}
|
| 249 |
+
const map = buildMap(cards)
|
| 250 |
+
expect(getBenchmarkCard(map, "BIG-Bench Hard (BBH)")?.__id).toBe("bbh")
|
| 251 |
+
expect(getBenchmarkCard(map, "big-bench-hard-(bbh)")?.__id).toBe("bbh")
|
| 252 |
+
expect(getBenchmarkCard(map, "bigbenchhardbbh")?.__id).toBe("bbh")
|
| 253 |
+
})
|
| 254 |
+
})
|
| 255 |
+
|
| 256 |
+
// ---------------------------------------------------------------------------
|
| 257 |
+
// Group E β attachBenchmarkCardToSummary (3-candidate retry, summary order)
|
| 258 |
+
// ---------------------------------------------------------------------------
|
| 259 |
+
|
| 260 |
+
describe("Group E β attachBenchmarkCardToSummary (summary path, order: name, name, key)", () => {
|
| 261 |
+
const cards: Record<string, MinimalCard> = {
|
| 262 |
+
mmlu: { __id: "mmlu", benchmark_details: { name: "MMLU" } },
|
| 263 |
+
bbh: { __id: "bbh", benchmark_details: { name: "BIG-Bench Hard (BBH)" } },
|
| 264 |
+
}
|
| 265 |
+
const map = buildMap(cards)
|
| 266 |
+
|
| 267 |
+
it("hits on 1st candidate (evaluation_name)", () => {
|
| 268 |
+
const result = attachBenchmarkCardToSummary(map, {
|
| 269 |
+
evaluation_name: "MMLU",
|
| 270 |
+
composite_benchmark_name: "Some other thing",
|
| 271 |
+
composite_benchmark_key: "another_thing",
|
| 272 |
+
})
|
| 273 |
+
expect(result.benchmark_card?.__id).toBe("mmlu")
|
| 274 |
+
})
|
| 275 |
+
|
| 276 |
+
it("hits on 2nd candidate (composite_benchmark_name) when 1st misses", () => {
|
| 277 |
+
const result = attachBenchmarkCardToSummary(map, {
|
| 278 |
+
evaluation_name: "Accuracy on multi-choice questions",
|
| 279 |
+
composite_benchmark_name: "MMLU",
|
| 280 |
+
composite_benchmark_key: "mmlu",
|
| 281 |
+
})
|
| 282 |
+
expect(result.benchmark_card?.__id).toBe("mmlu")
|
| 283 |
+
})
|
| 284 |
+
|
| 285 |
+
it("hits on 3rd candidate (composite_benchmark_key) when 1st + 2nd miss", () => {
|
| 286 |
+
const result = attachBenchmarkCardToSummary(map, {
|
| 287 |
+
evaluation_name: "Accuracy on multi-choice questions",
|
| 288 |
+
composite_benchmark_name: "Some unindexed display name",
|
| 289 |
+
composite_benchmark_key: "MMLU",
|
| 290 |
+
})
|
| 291 |
+
expect(result.benchmark_card?.__id).toBe("mmlu")
|
| 292 |
+
})
|
| 293 |
+
|
| 294 |
+
it("no match β returns summary unchanged (passthrough)", () => {
|
| 295 |
+
const summary = {
|
| 296 |
+
evaluation_name: "nothing matches",
|
| 297 |
+
composite_benchmark_name: "still nothing",
|
| 298 |
+
composite_benchmark_key: "zilch",
|
| 299 |
+
}
|
| 300 |
+
const result = attachBenchmarkCardToSummary(map, summary)
|
| 301 |
+
expect(result).toBe(summary) // identity β same object
|
| 302 |
+
expect(result.benchmark_card).toBeUndefined()
|
| 303 |
+
})
|
| 304 |
+
|
| 305 |
+
it("default-only: pre-attached card is preserved (does NOT overwrite)", () => {
|
| 306 |
+
const preExisting: MinimalCard = { __id: "preExisting", benchmark_details: { name: "preExisting" } }
|
| 307 |
+
const summary = {
|
| 308 |
+
evaluation_name: "MMLU",
|
| 309 |
+
composite_benchmark_name: "MMLU",
|
| 310 |
+
composite_benchmark_key: "mmlu",
|
| 311 |
+
benchmark_card: preExisting,
|
| 312 |
+
}
|
| 313 |
+
const result = attachBenchmarkCardToSummary(map, summary)
|
| 314 |
+
expect(result).toBe(summary)
|
| 315 |
+
expect(result.benchmark_card?.__id).toBe("preExisting")
|
| 316 |
+
})
|
| 317 |
+
|
| 318 |
+
it("falsy benchmark_card (null) falls through to retry", () => {
|
| 319 |
+
const result = attachBenchmarkCardToSummary(map, {
|
| 320 |
+
evaluation_name: "MMLU",
|
| 321 |
+
benchmark_card: null,
|
| 322 |
+
})
|
| 323 |
+
expect(result.benchmark_card?.__id).toBe("mmlu")
|
| 324 |
+
})
|
| 325 |
+
|
| 326 |
+
it("undefined candidate strings are passed through to getBenchmarkCard (which returns null on '')", () => {
|
| 327 |
+
const result = attachBenchmarkCardToSummary(map, {
|
| 328 |
+
// all three undefined
|
| 329 |
+
})
|
| 330 |
+
expect(result.benchmark_card).toBeUndefined()
|
| 331 |
+
})
|
| 332 |
+
})
|
| 333 |
+
|
| 334 |
+
// ---------------------------------------------------------------------------
|
| 335 |
+
// Group F β attachBenchmarkCardToListItem (3-candidate retry, list path order)
|
| 336 |
+
// ---------------------------------------------------------------------------
|
| 337 |
+
|
| 338 |
+
describe("Group F β attachBenchmarkCardToListItem (list path, order: name, key, name)", () => {
|
| 339 |
+
const cards: Record<string, MinimalCard> = {
|
| 340 |
+
mmlu: { __id: "mmlu", benchmark_details: { name: "MMLU" } },
|
| 341 |
+
bbh: { __id: "bbh", benchmark_details: { name: "BIG-Bench Hard (BBH)" } },
|
| 342 |
+
}
|
| 343 |
+
const map = buildMap(cards)
|
| 344 |
+
|
| 345 |
+
it("hits on 1st candidate (evaluation_name)", () => {
|
| 346 |
+
const result = attachBenchmarkCardToListItem(map, {
|
| 347 |
+
evaluation_name: "MMLU",
|
| 348 |
+
composite_benchmark_key: "other_key",
|
| 349 |
+
composite_benchmark_name: "Other display name",
|
| 350 |
+
})
|
| 351 |
+
expect(result.benchmark_card?.__id).toBe("mmlu")
|
| 352 |
+
})
|
| 353 |
+
|
| 354 |
+
it("hits on 2nd candidate (composite_benchmark_KEY in this path, not name)", () => {
|
| 355 |
+
const result = attachBenchmarkCardToListItem(map, {
|
| 356 |
+
evaluation_name: "no match",
|
| 357 |
+
composite_benchmark_key: "MMLU",
|
| 358 |
+
composite_benchmark_name: "Other display",
|
| 359 |
+
})
|
| 360 |
+
expect(result.benchmark_card?.__id).toBe("mmlu")
|
| 361 |
+
})
|
| 362 |
+
|
| 363 |
+
it("hits on 3rd candidate (composite_benchmark_NAME in this path) when 1st + 2nd miss", () => {
|
| 364 |
+
const result = attachBenchmarkCardToListItem(map, {
|
| 365 |
+
evaluation_name: "no match",
|
| 366 |
+
composite_benchmark_key: "no_match_either",
|
| 367 |
+
composite_benchmark_name: "MMLU",
|
| 368 |
+
})
|
| 369 |
+
expect(result.benchmark_card?.__id).toBe("mmlu")
|
| 370 |
+
})
|
| 371 |
+
|
| 372 |
+
it(".filter(Boolean) drops empty/undefined candidates before iteration", () => {
|
| 373 |
+
const item = {
|
| 374 |
+
evaluation_name: "",
|
| 375 |
+
composite_benchmark_key: undefined,
|
| 376 |
+
composite_benchmark_name: "MMLU",
|
| 377 |
+
}
|
| 378 |
+
const result = attachBenchmarkCardToListItem(map, item)
|
| 379 |
+
expect(result.benchmark_card?.__id).toBe("mmlu")
|
| 380 |
+
})
|
| 381 |
+
|
| 382 |
+
it("no match β returns item unchanged", () => {
|
| 383 |
+
const item = {
|
| 384 |
+
evaluation_name: "nothing",
|
| 385 |
+
composite_benchmark_key: "nothing",
|
| 386 |
+
composite_benchmark_name: "nothing",
|
| 387 |
+
}
|
| 388 |
+
const result = attachBenchmarkCardToListItem(map, item)
|
| 389 |
+
expect(result).toBe(item)
|
| 390 |
+
expect(result.benchmark_card).toBeUndefined()
|
| 391 |
+
})
|
| 392 |
+
|
| 393 |
+
it("default-only: pre-attached card preserved", () => {
|
| 394 |
+
const preExisting: MinimalCard = { __id: "preExisting", benchmark_details: { name: "preExisting" } }
|
| 395 |
+
const item = {
|
| 396 |
+
evaluation_name: "MMLU",
|
| 397 |
+
benchmark_card: preExisting,
|
| 398 |
+
}
|
| 399 |
+
const result = attachBenchmarkCardToListItem(map, item)
|
| 400 |
+
expect(result).toBe(item)
|
| 401 |
+
expect(result.benchmark_card?.__id).toBe("preExisting")
|
| 402 |
+
})
|
| 403 |
+
})
|
| 404 |
+
|
| 405 |
+
// ---------------------------------------------------------------------------
|
| 406 |
+
// Group G β Asymmetric retry order between summary and list paths
|
| 407 |
+
// ---------------------------------------------------------------------------
|
| 408 |
+
|
| 409 |
+
describe("Group G β summary vs list path can disagree (TS-as-spec, not a bug)", () => {
|
| 410 |
+
// Construct a map where the 2nd-position candidate in the SUMMARY path
|
| 411 |
+
// (composite_benchmark_name) and the 2nd-position candidate in the LIST
|
| 412 |
+
// path (composite_benchmark_key) point at DIFFERENT cards. The two paths
|
| 413 |
+
// would attach different cards to the same underlying record.
|
| 414 |
+
const cards: Record<string, MinimalCard> = {
|
| 415 |
+
cardForName: { __id: "cardForName", benchmark_details: { name: "Display Name" } },
|
| 416 |
+
cardForKey: { __id: "cardForKey", benchmark_details: { name: "raw_key" } },
|
| 417 |
+
}
|
| 418 |
+
const map = buildMap(cards)
|
| 419 |
+
|
| 420 |
+
const record = {
|
| 421 |
+
evaluation_name: "no first-position match",
|
| 422 |
+
composite_benchmark_name: "Display Name",
|
| 423 |
+
composite_benchmark_key: "raw_key",
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
it("summary path (2nd candidate is composite_benchmark_name) β cardForName", () => {
|
| 427 |
+
const result = attachBenchmarkCardToSummary(map, { ...record })
|
| 428 |
+
expect(result.benchmark_card?.__id).toBe("cardForName")
|
| 429 |
+
})
|
| 430 |
+
|
| 431 |
+
it("list path (2nd candidate is composite_benchmark_key) β cardForKey", () => {
|
| 432 |
+
const result = attachBenchmarkCardToListItem(map, { ...record })
|
| 433 |
+
expect(result.benchmark_card?.__id).toBe("cardForKey")
|
| 434 |
+
})
|
| 435 |
+
|
| 436 |
+
// Migration target: pipeline inlines benchmark_card so this asymmetry
|
| 437 |
+
// becomes unobservable. Until then, document which path produced which
|
| 438 |
+
// value if a bug report comes in.
|
| 439 |
+
})
|
tests/transformations/benchmark-display-names.test.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest"
|
| 2 |
+
|
| 3 |
+
// Executable spec for `notes/transformations/08-benchmark-display-names.md`.
|
| 4 |
+
//
|
| 5 |
+
// Replicates BENCHMARK_NAMES + normalizeBenchmarkKeyForLookup + humanizeToken +
|
| 6 |
+
// getBenchmarkDisplayName from lib/model-data.ts:90-148 verbatim.
|
| 7 |
+
//
|
| 8 |
+
// Also replicates the duplicate getBenchmarkDisplayName from
|
| 9 |
+
// lib/eval-processing.ts:861-885 (Group D β functionally dead, tested for
|
| 10 |
+
// completeness so a pipeline implementer porting the rules sees the
|
| 11 |
+
// disagreement explicitly).
|
| 12 |
+
|
| 13 |
+
// ---------------------------------------------------------------------------
|
| 14 |
+
// Active implementation β lib/model-data.ts
|
| 15 |
+
// ---------------------------------------------------------------------------
|
| 16 |
+
|
| 17 |
+
function humanizeToken(token: string): string {
|
| 18 |
+
return token
|
| 19 |
+
.split(/[_-]+/g)
|
| 20 |
+
.filter(Boolean)
|
| 21 |
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
| 22 |
+
.join(" ")
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const BENCHMARK_NAMES: Record<string, string> = {
|
| 26 |
+
hfopenllm_v2: "HF Open LLM v2",
|
| 27 |
+
helm_lite: "HELM Lite",
|
| 28 |
+
helm_capabilities: "HELM Capabilities",
|
| 29 |
+
helm_classic: "HELM Classic",
|
| 30 |
+
helm_instruct: "HELM Instruct",
|
| 31 |
+
helm_mmlu: "HELM MMLU",
|
| 32 |
+
reward_bench: "RewardBench",
|
| 33 |
+
reward_bench_2: "RewardBench 2",
|
| 34 |
+
bfcl: "BFCL",
|
| 35 |
+
global_mmlu_lite: "Global MMLU Lite",
|
| 36 |
+
swe_bench: "SWE-bench",
|
| 37 |
+
arc_agi: "ARC-AGI",
|
| 38 |
+
tau_bench_2: "TAU-Bench 2",
|
| 39 |
+
ace: "ACE",
|
| 40 |
+
apex_agents: "APEX Agents",
|
| 41 |
+
apex_v1: "APEX v1",
|
| 42 |
+
appworld: "AppWorld",
|
| 43 |
+
browsecompplus: "BrowseComp+",
|
| 44 |
+
livecodebenchpro: "LiveCodeBench Pro",
|
| 45 |
+
sciarena: "SciArena",
|
| 46 |
+
terminal_bench_2_0: "Terminal Bench 2.0",
|
| 47 |
+
la_leaderboard: "LA Leaderboard",
|
| 48 |
+
theory_of_mind: "Theory of Mind",
|
| 49 |
+
fibble_arena: "Fibble Arena",
|
| 50 |
+
fibble1_arena: "Fibble Arena v1",
|
| 51 |
+
fibble2_arena: "Fibble Arena v2",
|
| 52 |
+
fibble3_arena: "Fibble Arena v3",
|
| 53 |
+
fibble4_arena: "Fibble Arena v4",
|
| 54 |
+
fibble5_arena: "Fibble Arena v5",
|
| 55 |
+
wordle_arena: "Wordle Arena",
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
function normalizeBenchmarkKeyForLookup(key: string): string {
|
| 59 |
+
return key.toLowerCase().replace(/[-.\s]+/g, "_").replace(/^_+|_+$/g, "")
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
function getBenchmarkDisplayName(benchmark: string): string {
|
| 63 |
+
return BENCHMARK_NAMES[normalizeBenchmarkKeyForLookup(benchmark)] ?? humanizeToken(benchmark)
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// ---------------------------------------------------------------------------
|
| 67 |
+
// Group A β Map hits (normalized-key lookup)
|
| 68 |
+
// ---------------------------------------------------------------------------
|
| 69 |
+
|
| 70 |
+
describe("Group A β BENCHMARK_NAMES map hits", () => {
|
| 71 |
+
const cases = [
|
| 72 |
+
// Exact normalized matches
|
| 73 |
+
{ input: "hfopenllm_v2", expected: "HF Open LLM v2" },
|
| 74 |
+
{ input: "helm_lite", expected: "HELM Lite" },
|
| 75 |
+
{ input: "helm_capabilities", expected: "HELM Capabilities" },
|
| 76 |
+
{ input: "helm_classic", expected: "HELM Classic" },
|
| 77 |
+
{ input: "helm_instruct", expected: "HELM Instruct" },
|
| 78 |
+
{ input: "helm_mmlu", expected: "HELM MMLU" },
|
| 79 |
+
{ input: "reward_bench", expected: "RewardBench" },
|
| 80 |
+
{ input: "reward_bench_2", expected: "RewardBench 2" },
|
| 81 |
+
{ input: "bfcl", expected: "BFCL" },
|
| 82 |
+
{ input: "global_mmlu_lite", expected: "Global MMLU Lite" },
|
| 83 |
+
{ input: "swe_bench", expected: "SWE-bench" },
|
| 84 |
+
{ input: "arc_agi", expected: "ARC-AGI" },
|
| 85 |
+
{ input: "tau_bench_2", expected: "TAU-Bench 2" },
|
| 86 |
+
{ input: "ace", expected: "ACE" },
|
| 87 |
+
{ input: "apex_agents", expected: "APEX Agents" },
|
| 88 |
+
{ input: "apex_v1", expected: "APEX v1" },
|
| 89 |
+
{ input: "appworld", expected: "AppWorld" },
|
| 90 |
+
{ input: "browsecompplus", expected: "BrowseComp+" },
|
| 91 |
+
{ input: "livecodebenchpro", expected: "LiveCodeBench Pro" },
|
| 92 |
+
{ input: "sciarena", expected: "SciArena" },
|
| 93 |
+
{ input: "terminal_bench_2_0", expected: "Terminal Bench 2.0" },
|
| 94 |
+
{ input: "la_leaderboard", expected: "LA Leaderboard" },
|
| 95 |
+
{ input: "theory_of_mind", expected: "Theory of Mind" },
|
| 96 |
+
{ input: "fibble_arena", expected: "Fibble Arena" },
|
| 97 |
+
{ input: "fibble1_arena", expected: "Fibble Arena v1" },
|
| 98 |
+
{ input: "fibble2_arena", expected: "Fibble Arena v2" },
|
| 99 |
+
{ input: "fibble3_arena", expected: "Fibble Arena v3" },
|
| 100 |
+
{ input: "fibble4_arena", expected: "Fibble Arena v4" },
|
| 101 |
+
{ input: "fibble5_arena", expected: "Fibble Arena v5" },
|
| 102 |
+
{ input: "wordle_arena", expected: "Wordle Arena" },
|
| 103 |
+
|
| 104 |
+
// Case- and separator-insensitive lookups (normalize: lower; -/./space -> _; trim _)
|
| 105 |
+
{ input: "HELM Lite", expected: "HELM Lite", why: "space -> _ during normalize" },
|
| 106 |
+
{ input: "helm-lite", expected: "HELM Lite", why: "dash -> _" },
|
| 107 |
+
{ input: "helm.lite", expected: "HELM Lite", why: "dot -> _" },
|
| 108 |
+
{ input: "HELM-LITE", expected: "HELM Lite", why: "lower + dash -> _" },
|
| 109 |
+
{ input: " helm lite ", expected: "HELM Lite", why: "whitespace runs collapse, edges trim" },
|
| 110 |
+
{ input: "ARC.AGI", expected: "ARC-AGI", why: "dot -> _" },
|
| 111 |
+
{ input: "Reward-Bench-2", expected: "RewardBench 2" },
|
| 112 |
+
]
|
| 113 |
+
it.each(cases)("'$input' -> '$expected'", ({ input, expected }) => {
|
| 114 |
+
expect(getBenchmarkDisplayName(input)).toBe(expected)
|
| 115 |
+
})
|
| 116 |
+
})
|
| 117 |
+
|
| 118 |
+
// ---------------------------------------------------------------------------
|
| 119 |
+
// Group B β Tokenize fallback (humanizeToken on the *original* input)
|
| 120 |
+
// ---------------------------------------------------------------------------
|
| 121 |
+
|
| 122 |
+
describe("Group B β humanizeToken fallback for non-map inputs", () => {
|
| 123 |
+
const cases = [
|
| 124 |
+
// Single-token acronyms β only first char gets uppercased (NOT a real acronym map)
|
| 125 |
+
{ input: "bbh", expected: "Bbh", why: "humanizeToken only uppercases first char of each token; map doesn't have 'bbh'" },
|
| 126 |
+
{ input: "gpqa", expected: "Gpqa", why: "same β visibly wrong but TS-as-spec" },
|
| 127 |
+
{ input: "mmlu", expected: "Mmlu", why: "same β visibly wrong; the suite-name companion table in benchmark-detail.tsx fixes this, but the active getBenchmarkDisplayName does NOT" },
|
| 128 |
+
{ input: "gsm8k", expected: "Gsm8k", why: "digits inside don't capitalize differently" },
|
| 129 |
+
{ input: "humaneval", expected: "Humaneval" },
|
| 130 |
+
{ input: "truthfulqa", expected: "Truthfulqa" },
|
| 131 |
+
|
| 132 |
+
// Already-uppercase passthrough (charAt(0).toUpperCase() is a no-op on already-upper char)
|
| 133 |
+
{ input: "MATH", expected: "MATH", why: "M is already upper; ATH preserved by slice(1)" },
|
| 134 |
+
{ input: "MMLU", expected: "MMLU", why: "M upper; MLU preserved" },
|
| 135 |
+
{ input: "BBQ", expected: "BBQ" },
|
| 136 |
+
{ input: "MMLU-PRO", expected: "MMLU PRO", why: "split on - -> ['MMLU','PRO'] -> first-char-upper (no-op) -> join with space" },
|
| 137 |
+
|
| 138 |
+
// Multi-token snake/dash inputs that miss the map
|
| 139 |
+
{ input: "swe-bench-verified", expected: "Swe Bench Verified", why: "split on -, each first-cap" },
|
| 140 |
+
{ input: "swe_bench_verified_mini", expected: "Swe Bench Verified Mini" },
|
| 141 |
+
{ input: "multi_swe_bench", expected: "Multi Swe Bench" },
|
| 142 |
+
{ input: "helm_air_bench", expected: "Helm Air Bench", why: "not in map (only the ~30 listed suite keys are)" },
|
| 143 |
+
{ input: "helm_safety", expected: "Helm Safety" },
|
| 144 |
+
{ input: "swe_bench_verified", expected: "Swe Bench Verified", why: "swe_bench is in map but swe_bench_verified is not" },
|
| 145 |
+
{ input: "cocoabench", expected: "Cocoabench" },
|
| 146 |
+
{ input: "llm_stats", expected: "Llm Stats" },
|
| 147 |
+
{ input: "artificial_analysis_llms", expected: "Artificial Analysis Llms" },
|
| 148 |
+
|
| 149 |
+
// The fallback uses the *original* (unnormalized) input β spaces survive!
|
| 150 |
+
{ input: "Helm air bench", expected: "Helm air bench", why: "fallback splits on [_-]+ ONLY; the spaces don't trigger split; first char of the lone token already upper" },
|
| 151 |
+
{ input: "helm air bench", expected: "Helm air bench", why: "single token (spaces don't split); lowercase 'h' becomes 'H', rest unchanged" },
|
| 152 |
+
]
|
| 153 |
+
it.each(cases)("'$input' -> '$expected' ($why)", ({ input, expected }) => {
|
| 154 |
+
expect(getBenchmarkDisplayName(input)).toBe(expected)
|
| 155 |
+
})
|
| 156 |
+
})
|
| 157 |
+
|
| 158 |
+
// ---------------------------------------------------------------------------
|
| 159 |
+
// Group C β Edge cases
|
| 160 |
+
// ---------------------------------------------------------------------------
|
| 161 |
+
|
| 162 |
+
describe("Group C β edge cases", () => {
|
| 163 |
+
it("empty string -> empty string", () => {
|
| 164 |
+
// normalize -> "" (no map hit). humanizeToken: "".split(/[_-]+/) -> [""] -> filter(Boolean) -> [] -> [].join(" ") -> ""
|
| 165 |
+
expect(getBenchmarkDisplayName("")).toBe("")
|
| 166 |
+
})
|
| 167 |
+
|
| 168 |
+
it("single underscore -> empty string", () => {
|
| 169 |
+
// normalize: "_" -> "" (edges stripped). no map hit. humanizeToken: "_".split(/[_-]+/) -> ["",""] -> filter -> [] -> ""
|
| 170 |
+
expect(getBenchmarkDisplayName("_")).toBe("")
|
| 171 |
+
})
|
| 172 |
+
|
| 173 |
+
it("triple-underscore-padded map key collapses to map hit during normalize", () => {
|
| 174 |
+
// normalize: "___helm___lite___" -> lower (no-op) -> internal runs of _ stay (but [-.\s]+ doesn't include _!) -> wait
|
| 175 |
+
// Let's check carefully: normalizeBenchmarkKeyForLookup uses /[-.\s]+/g (NOT _).
|
| 176 |
+
// So "___helm___lite___".replace(/[-.\s]+/g, "_") is unchanged.
|
| 177 |
+
// Then .replace(/^_+|_+$/g, "") strips edge _ runs. Internal "___" stays as-is.
|
| 178 |
+
// Result: "helm___lite" β NOT "helm_lite". So this misses the map!
|
| 179 |
+
expect(getBenchmarkDisplayName("___helm___lite___")).toBe("Helm Lite")
|
| 180 |
+
// humanizeToken splits on [_-]+ which collapses the runs: "helm___lite".split(/[_-]+/) -> ["helm","lite"] -> ["Helm","Lite"]
|
| 181 |
+
})
|
| 182 |
+
|
| 183 |
+
it("single char -> uppercased single char via fallback", () => {
|
| 184 |
+
expect(getBenchmarkDisplayName("a")).toBe("A")
|
| 185 |
+
})
|
| 186 |
+
|
| 187 |
+
it("a-b -> 'A B' via fallback", () => {
|
| 188 |
+
expect(getBenchmarkDisplayName("a-b")).toBe("A B")
|
| 189 |
+
})
|
| 190 |
+
|
| 191 |
+
it("lookup is case-insensitive even for substantive transforms", () => {
|
| 192 |
+
expect(getBenchmarkDisplayName("APEX_AGENTS")).toBe("APEX Agents")
|
| 193 |
+
expect(getBenchmarkDisplayName("Browsecompplus")).toBe("BrowseComp+")
|
| 194 |
+
expect(getBenchmarkDisplayName("LIVECODEBENCHPRO")).toBe("LiveCodeBench Pro")
|
| 195 |
+
})
|
| 196 |
+
|
| 197 |
+
it("two-space whitespace collapses for normalize (map lookup)", () => {
|
| 198 |
+
expect(getBenchmarkDisplayName("helm lite")).toBe("HELM Lite")
|
| 199 |
+
})
|
| 200 |
+
})
|
| 201 |
+
|
| 202 |
+
// ---------------------------------------------------------------------------
|
| 203 |
+
// Group D β Duplicate getBenchmarkDisplayName in lib/eval-processing.ts
|
| 204 |
+
// (functionally dead β only called by groupEvaluationsByBenchmark which has
|
| 205 |
+
// no importers. Tested for completeness so the divergence in semantics is
|
| 206 |
+
// explicit.)
|
| 207 |
+
// ---------------------------------------------------------------------------
|
| 208 |
+
|
| 209 |
+
function getBenchmarkDisplayNameDuplicate(name: string | undefined | null): string {
|
| 210 |
+
if (!name) return "Unknown Benchmark"
|
| 211 |
+
|
| 212 |
+
const mapping: Record<string, string> = {
|
| 213 |
+
MMLU: "Massive Multitask Language Understanding",
|
| 214 |
+
"MMLU-Pro": "MMLU Professional",
|
| 215 |
+
GSM8K: "Grade School Math 8K",
|
| 216 |
+
HumanEval: "Human Eval (Code)",
|
| 217 |
+
MBPP: "Mostly Basic Python Problems",
|
| 218 |
+
HellaSwag: "HellaSwag (Commonsense)",
|
| 219 |
+
ARC: "AI2 Reasoning Challenge",
|
| 220 |
+
TruthfulQA: "TruthfulQA",
|
| 221 |
+
BBH: "Big-Bench Hard",
|
| 222 |
+
MATH: "MATH Dataset",
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
for (const [key, value] of Object.entries(mapping)) {
|
| 226 |
+
if (name.toUpperCase().includes(key.toUpperCase())) {
|
| 227 |
+
return value
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
return name
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
describe("Group D β duplicate getBenchmarkDisplayName (eval-processing.ts) β substring-include rule", () => {
|
| 235 |
+
const cases: Array<{ input: string | null | undefined; expected: string; why?: string }> = [
|
| 236 |
+
{ input: null, expected: "Unknown Benchmark", why: "guard: !name" },
|
| 237 |
+
{ input: undefined, expected: "Unknown Benchmark", why: "guard" },
|
| 238 |
+
{ input: "", expected: "Unknown Benchmark", why: "guard (empty string is falsy)" },
|
| 239 |
+
{ input: "MMLU", expected: "Massive Multitask Language Understanding", why: "substring match on MMLU" },
|
| 240 |
+
{ input: "mmlu", expected: "Massive Multitask Language Understanding", why: "case-insensitive (toUpperCase)" },
|
| 241 |
+
{
|
| 242 |
+
input: "MMLU-Pro",
|
| 243 |
+
expected: "Massive Multitask Language Understanding",
|
| 244 |
+
why: "iteration order: MMLU is matched first (insertion order); MMLU-Pro entry never reached. KNOWN SOFT-BUG, document don't fix.",
|
| 245 |
+
},
|
| 246 |
+
{ input: "GSM8K", expected: "Grade School Math 8K" },
|
| 247 |
+
{ input: "HumanEval", expected: "Human Eval (Code)" },
|
| 248 |
+
{ input: "MBPP", expected: "Mostly Basic Python Problems" },
|
| 249 |
+
{ input: "HellaSwag", expected: "HellaSwag (Commonsense)" },
|
| 250 |
+
{ input: "ARC", expected: "AI2 Reasoning Challenge" },
|
| 251 |
+
{ input: "TruthfulQA", expected: "TruthfulQA", why: "key === value" },
|
| 252 |
+
{ input: "BBH", expected: "Big-Bench Hard" },
|
| 253 |
+
{ input: "MATH", expected: "MATH Dataset" },
|
| 254 |
+
{ input: "helm_lite", expected: "helm_lite", why: "no substring match -> passthrough" },
|
| 255 |
+
{
|
| 256 |
+
input: "MMLU Lite something",
|
| 257 |
+
expected: "Massive Multitask Language Understanding",
|
| 258 |
+
why: "substring match still fires when the key appears anywhere in the input",
|
| 259 |
+
},
|
| 260 |
+
{
|
| 261 |
+
input: "ARC-AGI",
|
| 262 |
+
expected: "AI2 Reasoning Challenge",
|
| 263 |
+
why: "substring 'ARC' matches; this overwrites the more specific intent of 'ARC-AGI' β soft-bug",
|
| 264 |
+
},
|
| 265 |
+
]
|
| 266 |
+
it.each(cases)("'$input' -> '$expected' ($why)", ({ input, expected }) => {
|
| 267 |
+
expect(getBenchmarkDisplayNameDuplicate(input)).toBe(expected)
|
| 268 |
+
})
|
| 269 |
+
})
|
tests/transformations/dataset-url-synthesis.test.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest"
|
| 2 |
+
|
| 3 |
+
// Executable spec for `notes/transformations/04-dataset-url-synthesis.md`.
|
| 4 |
+
//
|
| 5 |
+
// Replicates the 4-step fallback chain from components/eval-card.tsx:83-86
|
| 6 |
+
// verbatim. Pipeline must produce identical outputs for every case below.
|
| 7 |
+
// Verify cross-corpus equivalence with `scripts/verify-dataset-url.mjs`.
|
| 8 |
+
|
| 9 |
+
interface SourceData {
|
| 10 |
+
dataset_url?: string
|
| 11 |
+
url?: string | string[] | null
|
| 12 |
+
hf_repo?: string
|
| 13 |
+
[key: string]: unknown
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
// Replicates the actual TS expression verbatim. The original uses `??`
|
| 17 |
+
// (nullish coalescing), NOT `||` truthiness β so empty strings stay; only
|
| 18 |
+
// null/undefined fall through. Don't "improve" by switching to truthiness.
|
| 19 |
+
function resolveDatasetUrl(sourceData: SourceData | null | undefined): string | undefined {
|
| 20 |
+
const fromDataset = sourceData?.dataset_url
|
| 21 |
+
const fromUrl = Array.isArray(sourceData?.url) ? sourceData?.url?.[0] : sourceData?.url
|
| 22 |
+
const fromHfRepo = sourceData?.hf_repo ? `https://huggingface.co/datasets/${sourceData.hf_repo}` : undefined
|
| 23 |
+
return fromDataset ?? fromUrl ?? fromHfRepo
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// ---------------------------------------------------------------------------
|
| 27 |
+
// Group A β Branch firing order (first non-nullish wins, NOT truthy)
|
| 28 |
+
// ---------------------------------------------------------------------------
|
| 29 |
+
|
| 30 |
+
describe("Group A β branch firing order", () => {
|
| 31 |
+
const cases = [
|
| 32 |
+
{ input: { dataset_url: "https://example.com/x" }, expected: "https://example.com/x", why: "branch 1" },
|
| 33 |
+
{ input: { dataset_url: "x", url: ["y"] }, expected: "x", why: "branch 1 short-circuits even with url present" },
|
| 34 |
+
{ input: { url: ["https://a.com", "https://b.com"] }, expected: "https://a.com", why: "branch 2 β first array element" },
|
| 35 |
+
{ input: { url: ["only"] }, expected: "only", why: "branch 2 β single-element array" },
|
| 36 |
+
{ input: { url: "https://a.com" }, expected: "https://a.com", why: "branch 3 β string form" },
|
| 37 |
+
{ input: { hf_repo: "Mercor/ACE" }, expected: "https://huggingface.co/datasets/Mercor/ACE", why: "branch 4 β HF template" },
|
| 38 |
+
{
|
| 39 |
+
input: { hf_repo: "mercor/apex-agents" },
|
| 40 |
+
expected: "https://huggingface.co/datasets/mercor/apex-agents",
|
| 41 |
+
why: "branch 4 β preserves case",
|
| 42 |
+
},
|
| 43 |
+
{ input: { dataset_name: "x" }, expected: undefined, why: "branch 5 β none of the above" },
|
| 44 |
+
{ input: {}, expected: undefined, why: "branch 5 β empty object" },
|
| 45 |
+
{ input: null, expected: undefined, why: "branch 5 β null defensive" },
|
| 46 |
+
{ input: undefined, expected: undefined, why: "branch 5 β undefined defensive" },
|
| 47 |
+
]
|
| 48 |
+
it.each(cases)("$why β '$expected'", ({ input, expected }) => {
|
| 49 |
+
expect(resolveDatasetUrl(input as SourceData | null | undefined)).toBe(expected)
|
| 50 |
+
})
|
| 51 |
+
})
|
| 52 |
+
|
| 53 |
+
// ---------------------------------------------------------------------------
|
| 54 |
+
// Group B β Edge cases of the fallback chain
|
| 55 |
+
// ---------------------------------------------------------------------------
|
| 56 |
+
|
| 57 |
+
describe("Group B β fallback chain edge cases (?? nullish semantics)", () => {
|
| 58 |
+
it("empty dataset_url string is RETURNED (not nullish, ?? does NOT fall through)", () => {
|
| 59 |
+
// "" is not nullish β ?? short-circuits to it. TS quirk to preserve.
|
| 60 |
+
expect(resolveDatasetUrl({ dataset_url: "", url: ["fallback"] })).toBe("")
|
| 61 |
+
})
|
| 62 |
+
|
| 63 |
+
it("empty url array β url[0] is undefined, ?? falls through to hf_repo", () => {
|
| 64 |
+
expect(resolveDatasetUrl({ url: [], hf_repo: "x/y" })).toBe("https://huggingface.co/datasets/x/y")
|
| 65 |
+
})
|
| 66 |
+
|
| 67 |
+
it("url array containing only empty string β returns empty string (NO further fallback because '' is not nullish)", () => {
|
| 68 |
+
expect(resolveDatasetUrl({ url: [""], hf_repo: "x/y" })).toBe("")
|
| 69 |
+
})
|
| 70 |
+
|
| 71 |
+
it("url array containing only null β null IS nullish, ?? falls through to hf_repo", () => {
|
| 72 |
+
expect(resolveDatasetUrl({ url: [null as unknown as string], hf_repo: "x/y" })).toBe(
|
| 73 |
+
"https://huggingface.co/datasets/x/y"
|
| 74 |
+
)
|
| 75 |
+
})
|
| 76 |
+
|
| 77 |
+
it("url array short-circuits hf_repo when first element is truthy", () => {
|
| 78 |
+
expect(resolveDatasetUrl({ url: ["a"], hf_repo: "x/y" })).toBe("a")
|
| 79 |
+
})
|
| 80 |
+
|
| 81 |
+
it("empty hf_repo evaluated as falsy by inline ternary, falls through to undefined", () => {
|
| 82 |
+
// The hf_repo branch uses `sourceData.hf_repo ? template : undefined`,
|
| 83 |
+
// a truthiness check (NOT ??), so empty string is treated as falsy.
|
| 84 |
+
expect(resolveDatasetUrl({ hf_repo: "" })).toBe(undefined)
|
| 85 |
+
})
|
| 86 |
+
|
| 87 |
+
it("hf_repo with leading slash produces double-slash URL (no normalization)", () => {
|
| 88 |
+
expect(resolveDatasetUrl({ hf_repo: "/leading-slash" })).toBe(
|
| 89 |
+
"https://huggingface.co/datasets//leading-slash"
|
| 90 |
+
)
|
| 91 |
+
})
|
| 92 |
+
})
|
| 93 |
+
|
| 94 |
+
// ---------------------------------------------------------------------------
|
| 95 |
+
// Group C β Production fixtures (real source_data shapes from prod cache)
|
| 96 |
+
// ---------------------------------------------------------------------------
|
| 97 |
+
|
| 98 |
+
describe("Group C β production fixtures", () => {
|
| 99 |
+
const cases = [
|
| 100 |
+
{
|
| 101 |
+
input: { dataset_name: "appworld/test_normal", source_type: "url", url: ["https://github.com/Exgentic/exgentic"] },
|
| 102 |
+
expected: "https://github.com/Exgentic/exgentic",
|
| 103 |
+
why: "url-array path (564/587 eval-details use this)",
|
| 104 |
+
},
|
| 105 |
+
{
|
| 106 |
+
input: { dataset_name: "ace", source_type: "hf_dataset", hf_repo: "Mercor/ACE" },
|
| 107 |
+
expected: "https://huggingface.co/datasets/Mercor/ACE",
|
| 108 |
+
why: "hf_repo template (22/587)",
|
| 109 |
+
},
|
| 110 |
+
{
|
| 111 |
+
input: {
|
| 112 |
+
dataset_name: "Artificial Analysis LLM API",
|
| 113 |
+
source_type: "url",
|
| 114 |
+
url: ["https://artificialanalysis.ai/api/v2/data/llms/models"],
|
| 115 |
+
},
|
| 116 |
+
expected: "https://artificialanalysis.ai/api/v2/data/llms/models",
|
| 117 |
+
why: "third-party API URL",
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
input: {
|
| 121 |
+
dataset_name: "CocoaBench v1.0",
|
| 122 |
+
source_type: "other",
|
| 123 |
+
additional_details: { samples_number: "153" },
|
| 124 |
+
},
|
| 125 |
+
expected: undefined,
|
| 126 |
+
why: "no url, no hf_repo, no dataset_url β branch 5 (1/587)",
|
| 127 |
+
},
|
| 128 |
+
]
|
| 129 |
+
it.each(cases)("$why β '$expected'", ({ input, expected }) => {
|
| 130 |
+
expect(resolveDatasetUrl(input as SourceData)).toBe(expected)
|
| 131 |
+
})
|
| 132 |
+
})
|