general-eval-card / scripts /dump-adapter-outputs.mts
Jenny Chim
Sort eval-detail filenames by codepoint for parity
819e7c9
Raw
History Blame
15.6 kB
// @ts-nocheck
// Dump current TS-adapter outputs for the cross-repo parity verifier.
//
// The `server-only` import on every lib file at line 1 throws under Node;
// the wrapper Python verifier preloads `scripts/server_only_hook.cjs`
// via NODE_OPTIONS before invoking this script.
//
// Reads a pipeline output directory (`output/`), runs each TS adapter
// against the corresponding JSON, and writes the expected payload set
// as JSON to stdout (or a file).
//
// Usage (from `general-eval-card/`):
// pnpm tsx scripts/dump-adapter-outputs.mts \
// --pipeline-output /Users/jchim/projects/evaleval/eval_cards_backend_pipeline/output \
// --out /tmp/parity-expected.json
//
// Surfaces dumped: model_cards, model_cards_lite, eval_list, eval_list_lite,
// eval_summaries (per detail), model_summaries (per family),
// aggregate_eval_summaries (per suite), matrix_eval_summaries (per suite),
// developer_summaries, developers.
import { createWriteStream, readFileSync, readdirSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import * as ModelDataMod from "@/lib/model-data"
import * as HfDataMod from "@/lib/hf-data"
import * as EvalProcessingMod from "@/lib/eval-processing"
import * as BenchmarkMetadataUtilsMod from "@/lib/benchmark-metadata-utils"
// Under `tsx`, the libs are CommonJS modules; ESM `import *` lifts the
// real exports onto the synthetic `default`. Pull them off there.
const ModelData: any = (ModelDataMod as any).default ?? ModelDataMod
const HfData: any = (HfDataMod as any).default ?? HfDataMod
const EvalProcessing: any =
(EvalProcessingMod as any).default ?? EvalProcessingMod
const BenchmarkMetadataUtils: any =
(BenchmarkMetadataUtilsMod as any).default ?? BenchmarkMetadataUtilsMod
const candidateBenchmarkKeys = BenchmarkMetadataUtils.candidateBenchmarkKeys
const hfModelCardToEvaluationCardData = ModelData.hfModelCardToEvaluationCardData
const hfEvalEntryToListItem = ModelData.hfEvalEntryToListItem
const hfEvalDetailToSummary = ModelData.hfEvalDetailToSummary
const hfDeveloperDetailToSummary = ModelData.hfDeveloperDetailToSummary
const aggregateBenchmarkSummaries = ModelData.aggregateBenchmarkSummaries
const buildSingleMetricSuiteMatrixSummary = ModelData.buildSingleMetricSuiteMatrixSummary
const flattenModelEvaluations = HfData.flattenModelEvaluations
// Mirror the request-time normalizer in `fetchModelCardsList` /
// `fetchModelCardsListLite` (lib/hf-data.ts:822, 827): every fetch runs
// `normalizeSingleModelCardEntry` to merge setup-alias variants
// ("prompt"/"fc"/"thinking") under one variant_key. Without this the
// dump's variant_count is the raw 6-variant count from disk, but the
// user-facing API returns the post-merge count (~2-3).
const normalizeSingleModelCardEntry = HfData.normalizeSingleModelCardEntry
const createModelFamilySummary = EvalProcessing.createModelFamilySummary
function parseArgs() {
const args: Record<string, string> = {}
for (let i = 2; i < process.argv.length; i++) {
const arg = process.argv[i]
if (arg.startsWith("--")) {
const key = arg.slice(2)
const next = process.argv[i + 1]
if (next && !next.startsWith("--")) {
args[key] = next
i++
} else {
args[key] = "true"
}
}
}
return args
}
function readJSON<T>(path: string): T {
return JSON.parse(readFileSync(path, "utf-8")) as T
}
function listJSONFiles(dir: string): string[] {
try {
return readdirSync(dir).filter((p) => p.endsWith(".json"))
} catch {
return []
}
}
interface DumpedSurface {
surface: string
by_id: Record<string, unknown>
}
const args = parseArgs()
const pipelineRoot = args["pipeline-output"]
const outPath = args["out"]
if (!pipelineRoot) {
console.error("Missing --pipeline-output <dir>")
process.exit(2)
}
const modelCards = readJSON<Array<Record<string, any>>>(
join(pipelineRoot, "model-cards.json")
)
const modelCardsLite = readJSON<Array<Record<string, any>>>(
join(pipelineRoot, "model-cards-lite.json")
)
const evalList = readJSON<{ evals: Array<Record<string, any>> }>(
join(pipelineRoot, "eval-list.json")
)
const evalListLite = readJSON<{ evals: Array<Record<string, any>> }>(
join(pipelineRoot, "eval-list-lite.json")
)
const evalsDir = join(pipelineRoot, "evals")
const modelsDir = join(pipelineRoot, "models")
// Build a sync benchmark card lookup mirror of `lib/benchmark-metadata.ts`'s
// `readPipelineBenchmarkCards` — used for aggregate/matrix surfaces, which
// in the request-time TS path call the async `attachBenchmarkCardToSummary`.
// The pipeline parity layer (`scripts/parity_outputs.py:build_aggregate_eval_summaries`)
// uses a sync card_map; mirror that approach so the dump runs without
// network/async machinery.
const benchmarkMetadata = readJSON<Record<string, any>>(
join(pipelineRoot, "benchmark-metadata.json")
)
const benchmarkCardMap = new Map<string, any>()
for (const card of Object.values(benchmarkMetadata)) {
const cardObj = card as Record<string, any>
const name = cardObj?.benchmark_details?.name
if (!name) continue
for (const key of candidateBenchmarkKeys(name)) {
if (!benchmarkCardMap.has(key)) {
benchmarkCardMap.set(key, cardObj)
}
}
}
function syncGetBenchmarkCard(name: string | undefined | null): any | null {
if (!name) return null
for (const key of candidateBenchmarkKeys(name)) {
const card = benchmarkCardMap.get(key)
if (card) return card
}
return null
}
// Sync mirror of `attachBenchmarkCardToSummary` (lib/model-data.ts). The
// async upstream awaits `getBenchmarkCard`; here we use the sync card map
// loaded from `benchmark-metadata.json` so dump-adapter-outputs stays
// fully sync (no fetch / `server-only` boundary).
function syncAttachBenchmarkCardToSummary(summary: any): any {
if (summary?.benchmark_card) return summary
const candidates = [
summary?.evaluation_name,
summary?.composite_benchmark_name,
summary?.composite_benchmark_key,
]
for (const candidate of candidates) {
const card = syncGetBenchmarkCard(candidate)
if (card) return { ...summary, benchmark_card: card }
}
return summary
}
const dumped: DumpedSurface[] = []
dumped.push({
surface: "model_cards",
by_id: Object.fromEntries(
modelCards.map((card) => {
const normalized = normalizeSingleModelCardEntry(card as any)
const payload = hfModelCardToEvaluationCardData(normalized as any)
// Key by the adapter-canonical `route_id` so it matches parquet's
// scalar `model_route_id` column.
return [payload.route_id, payload]
})
),
})
// model_cards_lite — same adapter pipeline as model_cards, but reads the
// `-lite.json` source. Mirrors `getModelCardsLite()` in lib/model-data.ts.
dumped.push({
surface: "model_cards_lite",
by_id: Object.fromEntries(
modelCardsLite.map((card) => {
const normalized = normalizeSingleModelCardEntry(card as any)
const payload = hfModelCardToEvaluationCardData(normalized as any)
return [payload.route_id, payload]
})
),
})
// Mirror the request-time filter in `getEvalListData` /
// `getEvalListLiteData` (lib/model-data.ts:1251, 1291): drop entries
// whose `source_data.hf_repo` starts with `example://`. Applied here so
// the parity verifier sees the same user-facing shape the parity emitter
// produces (`scripts/parity_outputs.py:_is_example_eval_entry`).
function isExampleEntry(entry: Record<string, any>): boolean {
const repo = entry?.source_data?.hf_repo
return typeof repo === "string" && repo.startsWith("example://")
}
dumped.push({
surface: "eval_list",
by_id: Object.fromEntries(
(evalList.evals ?? [])
.filter((entry) => !isExampleEntry(entry))
.map((entry) => {
const payload = hfEvalEntryToListItem(entry as any)
return [payload.evaluation_id, payload]
})
),
})
// eval_list_lite — same adapter as eval_list but reads from
// `eval-list-lite.json`; mirrors `getEvalListLiteData` in lib/model-data.ts
// (which also strips example entries).
dumped.push({
surface: "eval_list_lite",
by_id: Object.fromEntries(
(evalListLite.evals ?? [])
.filter((entry) => !isExampleEntry(entry))
.map((entry) => {
const payload = hfEvalEntryToListItem(entry as any)
return [payload.evaluation_id, payload]
})
),
})
// Sort eval-detail filenames by codepoint so iteration order matches
// Python's default string comparison on the parity side (which sorts
// `parity_outputs.build_aggregate_eval_summaries` inputs by
// `eval_summary_id`). `localeCompare` treats `_` as collation-ignorable
// at primary level, putting `foo_25.json` before `foo.json` — Python's
// codepoint sort orders them the other way. Plain `.sort()` (no
// compareFn) does the same codepoint comparison Python does.
const evalDetails: Array<Record<string, any>> = listJSONFiles(evalsDir)
.slice()
.sort()
.map((file) => readJSON<Record<string, any>>(join(evalsDir, file)))
dumped.push({
surface: "eval_summaries",
by_id: Object.fromEntries(
evalDetails.map((detail) => [
detail.eval_summary_id,
hfEvalDetailToSummary(detail as any),
])
),
})
// aggregate_eval_summaries — port of the `aggregate__<suite_key>` branch in
// `getEvalSummaryById` (lib/model-data.ts). The TS path runs each sub-eval
// through `hfEvalDetailToSummary`, attaches a benchmark card, then calls
// `aggregateBenchmarkSummaries(summaries, suiteKey)`. We mirror parity
// emitter `build_aggregate_eval_summaries` (parity_outputs.py:212-258):
// - group by `benchmark_family_key || benchmark_parent_key`
// - skip groups with fewer than 2 distinct sub-evals
// Keyed by `payload.evaluation_id` (= `aggregate__<suite_key>`).
{
const aggregateGroups = new Map<string, Record<string, any>[]>()
for (const detail of evalDetails) {
const suiteKey =
detail.benchmark_family_key || detail.benchmark_parent_key
if (!suiteKey) continue
if (!detail.eval_summary_id) continue
const list = aggregateGroups.get(String(suiteKey)) ?? []
list.push(detail)
aggregateGroups.set(String(suiteKey), list)
}
const aggregateById: Record<string, any> = {}
for (const [suiteKey, details] of aggregateGroups.entries()) {
// De-dupe by eval_summary_id (parity also dedupes — first-write-wins).
const seenIds = new Set<string>()
const uniqueDetails: Record<string, any>[] = []
for (const detail of details) {
const id = detail.eval_summary_id
if (seenIds.has(id)) continue
seenIds.add(id)
uniqueDetails.push(detail)
}
if (uniqueDetails.length < 2) continue
const summaries = uniqueDetails.map((detail) => {
const summary = hfEvalDetailToSummary(detail as any)
return syncAttachBenchmarkCardToSummary(summary)
})
const aggregated = aggregateBenchmarkSummaries(summaries as any, suiteKey)
if (!aggregated) continue
aggregateById[aggregated.evaluation_id] = aggregated
}
dumped.push({
surface: "aggregate_eval_summaries",
by_id: aggregateById,
})
}
// matrix_eval_summaries — port of the `matrix__<suite_key>` branch in
// `getEvalSummaryById`. Mirrors parity emitter `build_matrix_eval_summaries`
// (parity_outputs.py:261-281):
// - skip details where `is_summary_score` is true
// - group by `benchmark_family_key || benchmark_parent_key`
// - call `buildSingleMetricSuiteMatrixSummary(details, suiteKey)`
// Keyed by `payload.evaluation_id` (= `matrix__<suite_key>`).
{
const matrixGroups = new Map<string, Record<string, any>[]>()
for (const detail of evalDetails) {
if (detail.is_summary_score) continue
const suiteKey =
detail.benchmark_family_key || detail.benchmark_parent_key
if (!suiteKey) continue
const list = matrixGroups.get(String(suiteKey)) ?? []
list.push(detail)
matrixGroups.set(String(suiteKey), list)
}
const matrixById: Record<string, any> = {}
for (const [suiteKey, details] of matrixGroups.entries()) {
const result = buildSingleMetricSuiteMatrixSummary(details as any, suiteKey)
if (!result) continue
const attached = syncAttachBenchmarkCardToSummary(result)
matrixById[attached.evaluation_id] = attached
}
dumped.push({
surface: "matrix_eval_summaries",
by_id: matrixById,
})
}
const modelDetails: Array<Record<string, any>> = listJSONFiles(modelsDir).map((file) =>
readJSON<Record<string, any>>(join(modelsDir, file))
)
dumped.push({
surface: "model_summaries",
by_id: Object.fromEntries(
modelDetails.flatMap((detail) => {
try {
const evaluations = flattenModelEvaluations(detail as any)
if (evaluations.length === 0) return []
const payload = createModelFamilySummary(evaluations as any)
return [[payload.model_route_id, payload]]
} catch (error) {
// The TS guard `assertSourceMetadata` throws when source_metadata
// is missing on any model_result; surface as a parity-comparable
// sentinel keyed by the canonical route id (lookup against the
// canonical model_family_id avoids drift when the input file's
// route_id was generated pre-canonicalization).
const fallbackRoute = (detail.model_family_id ?? "").replace(/\//g, "__")
return [[fallbackRoute || detail.model_route_id, { _adapter_error: String(error) }]]
}
})
),
})
// Developer surfaces — port of getDeveloperList / getDeveloperSummaryById.
// Both run hfDeveloperDetailToSummary against pipeline `developers/*.json`;
// the list endpoint then strips `models[]` to keep the index lightweight.
const developersDir = join(pipelineRoot, "developers")
const developerDetails: Array<Record<string, any>> = listJSONFiles(developersDir)
.map((file) => readJSON<Record<string, any>>(join(developersDir, file)))
.filter((detail) => detail && detail.developer && Array.isArray(detail.models))
const developerSummaries = developerDetails.map((detail) => hfDeveloperDetailToSummary(detail as any))
dumped.push({
surface: "developer_summaries",
by_id: Object.fromEntries(developerSummaries.map((s) => [s.route_id, s])),
})
dumped.push({
surface: "developers",
by_id: Object.fromEntries(
developerSummaries.map((s) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { models, ...listEntry } = s
return [listEntry.route_id, listEntry]
})
),
})
// Stream surfaces individually — `JSON.stringify` of the full dump can
// exceed Node's max string length on production-scale corpora (~5.8k
// model_summaries with nested `evaluations_by_category`).
function streamSurface(handle: NodeJS.WritableStream, surface: DumpedSurface, isFirst: boolean) {
if (!isFirst) handle.write(",")
handle.write(`{"surface":${JSON.stringify(surface.surface)},"by_id":{`)
let first = true
for (const [key, value] of Object.entries(surface.by_id)) {
if (!first) handle.write(",")
first = false
handle.write(JSON.stringify(key))
handle.write(":")
handle.write(JSON.stringify(value))
}
handle.write("}}")
}
async function emit(): Promise<void> {
if (outPath) {
const stream = createWriteStream(outPath)
stream.write("[")
dumped.forEach((surface, idx) => streamSurface(stream as any, surface, idx === 0))
stream.write("]")
await new Promise<void>((resolve) => {
stream.end(() => resolve())
})
console.log(`Wrote ${dumped.length} surfaces to ${outPath}`)
} else {
dumped.forEach((surface, idx) =>
streamSurface(process.stdout, surface, idx === 0)
)
}
}
await emit()