diff --git a/package.json b/package.json index 0fa607e..b5e1ecf 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,10 @@ "./internal/log": "./src/internal/log.ts", "./internal/effect-logger": "./src/internal/effect-logger.ts", "./sample-result-store": "./src/harness/sample-result-store.ts", - "./request-context": "./src/harness/request-context.ts" + "./request-context": "./src/harness/request-context.ts", + "./benchmarks/eval-manifest": "./src/benchmarks/eval-manifest.ts", + "./benchmarks/manifests": "./src/benchmarks/manifests.ts", + "./benchmarks/custom-eval/spec": "./src/benchmarks/custom-eval/spec.ts" }, "scripts": { "bench": "bun src/cli/index.ts", diff --git a/src/benchmarks/eval-manifest.test.ts b/src/benchmarks/eval-manifest.test.ts new file mode 100644 index 0000000..8336b38 --- /dev/null +++ b/src/benchmarks/eval-manifest.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from "bun:test"; + +import { Either } from "../internal/either"; +import type { z } from "../internal/zod"; +import { parseSchema } from "../internal/zod"; +import type { EvalManifestV1 } from "./eval-manifest"; +import { + EVAL_MANIFEST_API_VERSION, + EvalManifestSchema, + isDeterministicScoring, + manifestIdentityDigest, + validateManifest, +} from "./eval-manifest"; + +type ManifestInput = z.input; + +/** A minimal valid spec-bound manifest (the shape rung-1 user evals take). */ +function specManifest(overrides: Partial = {}): ManifestInput { + const base: ManifestInput = { + apiVersion: EVAL_MANIFEST_API_VERSION, + id: "org_abc/my-eval", + name: "My Eval", + version: "1", + protocolId: "org_abc.my-eval.v1", + dataset: { + kind: "inline", + cases: [{ input: "What is 2+2?", target: "4" }], + }, + models: [{ role: "candidate" }], + solver: { kind: "spec", promptTemplate: "Q: {input}\nA:" }, + scorer: { kind: "spec", method: { kind: "exact" } }, + epochs: { default: 1, reducer: "mean" }, + execution: { tier: "trusted-local", drivers: ["process"] }, + reports: [{ key: "score", label: "Score", type: "scalar", goal: "higher" }], + }; + return { ...base, ...overrides }; +} + +function parse(value: unknown): EvalManifestV1 { + const parsed = parseSchema(EvalManifestSchema, value); + if (Either.isLeft(parsed)) { + throw new Error(parsed.left.message); + } + return parsed.right; +} + +describe("EvalManifestSchema", () => { + it("accepts a minimal spec-bound user eval", () => { + const manifest = parse(specManifest()); + expect(validateManifest(manifest)).toEqual([]); + expect(isDeterministicScoring(manifest)).toBe(true); + }); + + it("accepts a builtin-bound first-party benchmark shape (gpqa)", () => { + const manifest = parse( + specManifest({ + id: "gpqa_diamond", + protocolId: "gpqa-diamond.public.v1", + dataset: { + kind: "hf", + dataset: "nmayorga7/gpqa_diamond", + config: "default", + split: "train", + inputField: "Question", + targetField: "Correct Answer", + }, + solver: { kind: "builtin", ref: "gpqa-solver" }, + scorer: { kind: "builtin", ref: "mcq" }, + sampling: { temperature: 0.5 }, + epochs: { default: 10, reducer: "mean" }, + }) + ); + expect(validateManifest(manifest)).toEqual([]); + // builtin ⇒ not provably spec-deterministic + expect(isDeterministicScoring(manifest)).toBe(false); + }); + + it("accepts a multi-role agentic shape (tau3: user simulator + builtin everything)", () => { + const manifest = parse( + specManifest({ + id: "tau3_bench_banking", + dataset: { kind: "builtin", ref: "tau3-banking-dataset" }, + models: [ + { role: "candidate" }, + { role: "user-simulator", defaultModel: "openai/gpt-5.4-mini" }, + ], + solver: { + kind: "builtin", + ref: "tau3-banking-solver", + options: { retrieval: "auto" }, + }, + scorer: { kind: "builtin", ref: "tau3-banking-scorer" }, + epochs: { default: 5, reducer: "mean" }, + }) + ); + expect(validateManifest(manifest)).toEqual([]); + }); + + it("accepts a capsule adapter shape and enforces digest pinning", () => { + const image = `ganler/evalplus@sha256:${"a".repeat(64)}`; + const manifest = parse( + specManifest({ + id: "humaneval-plus", + solver: { kind: "spec" }, + scorer: { + kind: "capsule", + image, + command: ["python", "-m", "evalplus"], + network: "none", + }, + execution: { tier: "rootless", drivers: ["docker"] }, + }) + ); + expect(validateManifest(manifest)).toEqual([]); + + const floating = parseSchema( + EvalManifestSchema, + specManifest({ + scorer: { + kind: "capsule", + image: "ganler/evalplus:latest", + command: ["x"], + }, + }) + ); + expect(Either.isLeft(floating)).toBe(true); + }); + + it("rejects unknown apiVersion and missing reports", () => { + expect( + Either.isLeft( + parseSchema(EvalManifestSchema, { ...specManifest(), apiVersion: "v2" }) + ) + ).toBe(true); + expect( + Either.isLeft( + parseSchema(EvalManifestSchema, specManifest({ reports: [] })) + ) + ).toBe(true); + }); +}); + +describe("validateManifest", () => { + it("requires a candidate role and unique roles", () => { + const noCandidate = parse(specManifest({ models: [{ role: "judge" }] })); + expect(validateManifest(noCandidate).map((i) => i.code)).toContain( + "missing-candidate" + ); + + const duplicate = parse( + specManifest({ models: [{ role: "candidate" }, { role: "candidate" }] }) + ); + expect(validateManifest(duplicate).map((i) => i.code)).toContain( + "duplicate-role" + ); + }); + + it("spec-judge scoring requires a declared judge role", () => { + const judged = parse( + specManifest({ + scorer: { kind: "spec-judge", promptRef: "prompts/judge-v1" }, + }) + ); + expect(validateManifest(judged).map((i) => i.code)).toContain( + "judge-role-missing" + ); + + const fixed = parse( + specManifest({ + models: [ + { role: "candidate" }, + { role: "judge", defaultModel: "openai/gpt-5.4" }, + ], + scorer: { kind: "spec-judge", promptRef: "prompts/judge-v1" }, + }) + ); + expect(validateManifest(fixed)).toEqual([]); + expect(isDeterministicScoring(fixed)).toBe(false); + }); + + it("enforces pass@k coherence", () => { + const noK = parse( + specManifest({ epochs: { default: 5, reducer: "pass@k" } }) + ); + expect(validateManifest(noK).map((i) => i.code)).toContain("k-missing"); + + const bigK = parse( + specManifest({ epochs: { default: 3, reducer: "pass@k", k: 5 } }) + ); + expect(validateManifest(bigK).map((i) => i.code)).toContain( + "k-exceeds-epochs" + ); + }); + + it("capsule bindings require rootless tier and a container driver", () => { + const image = `x/y@sha256:${"b".repeat(64)}`; + const wrongTier = parse( + specManifest({ + scorer: { kind: "capsule", image, command: ["run"] }, + execution: { tier: "trusted-local", drivers: ["process"] }, + }) + ); + const codes = validateManifest(wrongTier).map((i) => i.code); + expect(codes).toContain("capsule-needs-rootless"); + expect(codes).toContain("capsule-needs-container-driver"); + }); +}); + +describe("manifestIdentityDigest", () => { + it("is stable across metadata-only changes", () => { + const a = parse(specManifest()); + const b = parse(specManifest({ name: "Renamed — display only" })); + expect(manifestIdentityDigest(a)).toBe(manifestIdentityDigest(b)); + expect(manifestIdentityDigest(a)).toHaveLength(32); + }); + + it("changes when any identity-bearing field changes", () => { + const base = manifestIdentityDigest(parse(specManifest())); + const changed: Partial[] = [ + { version: "2" }, + { protocolId: "other.v1" }, + { sampling: { temperature: 0.7 } }, + { epochs: { default: 3, reducer: "mean" } }, + { solver: { kind: "spec", promptTemplate: "different: {input}" } }, + { scorer: { kind: "spec", method: { kind: "contains" } } }, + { mediaRecipe: { imageDetail: "high" } }, + { dataset: { kind: "inline", cases: [{ input: "other", target: "x" }] } }, + ]; + for (const override of changed) { + expect(manifestIdentityDigest(parse(specManifest(override)))).not.toBe( + base + ); + } + }); + + it("is insensitive to model-role declaration order", () => { + const roles = [ + { role: "candidate" as const }, + { role: "user-simulator" as const, defaultModel: "openai/gpt-5.4-mini" }, + ]; + const a = parse(specManifest({ models: roles })); + const b = parse(specManifest({ models: [...roles].reverse() })); + expect(manifestIdentityDigest(a)).toBe(manifestIdentityDigest(b)); + }); + + it("ignores provenance and reports (metadata, not identity)", () => { + const a = parse(specManifest()); + const b = parse( + specManifest({ + provenance: { supportStatus: "ready", comparabilityNotes: "anything" }, + reports: [{ key: "other", label: "Other", type: "table" }], + }) + ); + expect(manifestIdentityDigest(a)).toBe(manifestIdentityDigest(b)); + }); +}); diff --git a/src/benchmarks/eval-manifest.ts b/src/benchmarks/eval-manifest.ts new file mode 100644 index 0000000..1b059cc --- /dev/null +++ b/src/benchmarks/eval-manifest.ts @@ -0,0 +1,349 @@ +/** + * EvalManifestV1 — the unified description of any eval on the platform: + * first-party benchmarks, catalog entries, and user-provided evals are all + * the same object at different binding completeness. + * + * Every eval decomposes into dataset → solver → scorer → reducer → reports. + * The manifest carries the declarative fields all evals share, plus one + * *binding* per implementation slot: + * + * - `builtin` — a named TypeScript implementation compiled into this harness + * (how the first-party benchmarks bind; options validated per-builtin). + * - `spec` — fully declarative, no code (custom_eval's surface; rung-1 + * user evals). + * - `capsule` — a digest-pinned container speaking the capsule I/O contract + * (upstream harness adapters and rung-3 user containers). + * + * Identity is structural: {@link manifestIdentityDigest} hashes the fields + * that make scores comparable. Prose (`provenance.comparabilityNotes`) + * documents *why* — it is never itself the identity. + * + * Workflow-safe: schema-only module, no heavy imports. Standalone-clean: + * vendored zod only. See docs/plans/2026-07-28-002-feat-unified-eval- + * manifest-architecture.md. + */ +import { createHash } from "node:crypto"; + +import { z } from "../internal/zod"; +import { EvalDatasetSchema, EvalScorerSchema } from "./custom-eval/spec"; + +export const EVAL_MANIFEST_API_VERSION = "openrouter.ai/eval-manifest/v1"; + +//#region Model roles + +/** + * Roles are first-class because they are the gateway's unit of budget + * scoping (one capability token per role) and the per-role spend breakdown + * in results. `candidate` is implicit in every run; benchmarks that simulate + * a user (tau) or grade with a model (swe-atlas, judged specs) declare it. + */ +export const ModelRoleSchema = z.object({ + role: z.enum(["candidate", "judge", "user-simulator", "extractor"]), + /** Default model slug for non-candidate roles; the run config may override. */ + defaultModel: z.string().optional(), + /** Whether a run may substitute another model for this role. */ + overridable: z.boolean().default(true), +}); +export type ModelRole = z.infer; + +//#endregion + +//#region Bindings + +const CapsuleResourcesSchema = z.object({ + cpu: z.number().positive().optional(), + memoryGb: z.number().positive().optional(), + timeoutMs: z.number().int().positive().optional(), +}); + +/** Digest-pinned image reference: name@sha256:<64 LOWERCASE hex> — registries + * treat digests as lowercase-only, and case variants would fork the identity + * digest for the same image. */ +export const PINNED_IMAGE_RE = /^[\w.\-/:]+@sha256:[a-f0-9]{64}$/; + +const CapsuleBindingSchema = z.object({ + kind: z.literal("capsule"), + /** Must be digest-pinned; floating tags are rejected at parse time. */ + image: z + .string() + .regex( + PINNED_IMAGE_RE, + "capsule image must be digest-pinned (name@sha256:<64-hex>)" + ), + command: z.array(z.string()).min(1), + resources: CapsuleResourcesSchema.optional(), + /** Capsules never get open egress; the gateway is the only model path. */ + network: z.enum(["none", "gateway-only"]).default("none"), +}); + +const BuiltinBindingSchema = z.object({ + kind: z.literal("builtin"), + /** Name of a TS implementation registered in this harness build. */ + ref: z.string().min(1), + /** Validated against the builtin's own options schema at resolve time. */ + options: z.record(z.string(), z.unknown()).optional(), +}); + +export const SolverBindingSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("spec"), + systemPrompt: z.string().optional(), + /** `{input}` is replaced with the case input; omitted → raw input. */ + promptTemplate: z.string().optional(), + }), + BuiltinBindingSchema, + CapsuleBindingSchema, +]); +export type SolverBinding = z.infer; + +export const ScorerBindingSchema = z.discriminatedUnion("kind", [ + /** Deterministic declarative scorers (custom_eval's set). */ + z.object({ kind: z.literal("spec"), method: EvalScorerSchema }), + /** + * LLM-judged scoring with a pinned judge. Requires a `judge` role in + * `models[]`; results are labeled judge-dependent, never deterministic. + */ + z.object({ + kind: z.literal("spec-judge"), + /** Content-addressed ref to the pinned judge prompt. */ + promptRef: z.string().min(1), + rubric: z.unknown().optional(), + }), + BuiltinBindingSchema, + CapsuleBindingSchema, +]); +export type ScorerBinding = z.infer; + +export const DatasetBindingSchema = z.discriminatedUnion("kind", [ + ...EvalDatasetSchema.options, + /** Fixture/composite datasets owned by a builtin (tau domains, draco panels). */ + z.object({ kind: z.literal("builtin"), ref: z.string().min(1) }), +]); +export type DatasetBinding = z.infer; + +//#endregion + +//#region Declarative shared fields + +export const ManifestSamplingSchema = z.object({ + temperature: z.number().optional(), + maxTokens: z.number().int().positive().optional(), + reasoningEffort: z.string().optional(), +}); + +export const EpochPolicySchema = z.object({ + default: z.number().int().positive(), + reducer: z.enum(["mean", "best", "pass@k"]), + /** Required when reducer is pass@k. */ + k: z.number().int().positive().optional(), +}); + +/** + * Identity-bearing media configuration: these settings change scores, so + * they are part of the comparability digest (image detail for MMMU, video + * frame policy for Video-MME, context buckets for long-context suites). + */ +export const MediaRecipeSchema = z.object({ + imageDetail: z.enum(["low", "high", "auto"]).optional(), + video: z + .object({ + frames: z.number().int().positive().optional(), + fps: z.number().positive().optional(), + subtitles: z.enum(["off", "on"]).optional(), + }) + .optional(), + context: z + .object({ + requestedTokens: z.number().int().positive(), + bucket: z.string(), + }) + .optional(), +}); + +export const ExecutionPolicySchema = z.object({ + /** + * trusted-local: operator-trusted code in-process on the operator's own + * machine. rootless: hardened containers — the only tier for hosted user + * code. The tier is a property of trust, not of the code. + */ + tier: z.enum(["trusted-local", "rootless"]), + drivers: z.array(z.enum(["process", "docker", "compose"])).min(1), +}); + +export const ManifestReportSchema = z.object({ + key: z.string(), + label: z.string(), + type: z.enum([ + "scalar", + "table", + "distribution", + "confusion", + "reliability", + "artifact", + ]), + metric: z.string().optional(), + groupBy: z.array(z.string()).optional(), + goal: z.enum(["higher", "lower"]).optional(), +}); + +/** Catalog/coverage metadata. Prose belongs here — and only here. */ +export const ProvenanceSchema = z.object({ + canonicalName: z.string().optional(), + authoritativeRuntime: z.string().optional(), + sourceUrl: z.string().optional(), + license: z.string().optional(), + comparabilityNotes: z.string().optional(), + section: z.string().optional(), + supportStatus: z.enum(["ready", "adapter-declared", "pending-smoke"]), +}); + +//#endregion + +//#region Manifest + +export const EvalManifestSchema = z.object({ + apiVersion: z.literal(EVAL_MANIFEST_API_VERSION), + /** 'gpqa_diamond' | 'org_abc/my-regression-suite' */ + id: z.string().min(1).max(200), + name: z.string().min(1).max(200), + /** Manifest revision; part of the comparability identity. */ + version: z.string().min(1), + /** Comparability series key; scores merge only within one protocolId. */ + protocolId: z.string().min(1), + dataset: DatasetBindingSchema, + models: z.array(ModelRoleSchema).min(1), + solver: SolverBindingSchema, + scorer: ScorerBindingSchema, + sampling: ManifestSamplingSchema.optional(), + epochs: EpochPolicySchema, + mediaRecipe: MediaRecipeSchema.optional(), + execution: ExecutionPolicySchema, + reports: z.array(ManifestReportSchema).min(1), + provenance: ProvenanceSchema.optional(), +}); +export type EvalManifestV1 = z.infer; + +//#endregion + +//#region Cross-field validation + +export interface ManifestIssue { + readonly code: string; + readonly message: string; +} + +/** + * Structural rules zod's field-level schemas cannot express. Fail-closed: + * a manifest with issues must not resolve to a runnable plan. + */ +export function validateManifest( + manifest: EvalManifestV1 +): readonly ManifestIssue[] { + const issues: ManifestIssue[] = []; + const roles = new Set(manifest.models.map((m) => m.role)); + + if (!roles.has("candidate")) { + issues.push({ + code: "missing-candidate", + message: "models[] must declare a candidate role", + }); + } + if (manifest.models.length !== roles.size) { + issues.push({ + code: "duplicate-role", + message: "each role may be declared at most once", + }); + } + if (manifest.scorer.kind === "spec-judge" && !roles.has("judge")) { + issues.push({ + code: "judge-role-missing", + message: "spec-judge scorer requires a judge role in models[]", + }); + } + if (manifest.epochs.reducer === "pass@k" && manifest.epochs.k === undefined) { + issues.push({ + code: "k-missing", + message: "pass@k reducer requires epochs.k", + }); + } + if ( + manifest.epochs.k !== undefined && + manifest.epochs.k > manifest.epochs.default + ) { + issues.push({ + code: "k-exceeds-epochs", + message: "epochs.k cannot exceed epochs.default", + }); + } + + const hasCapsule = + manifest.solver.kind === "capsule" || manifest.scorer.kind === "capsule"; + if (hasCapsule && manifest.execution.tier !== "rootless") { + issues.push({ + code: "capsule-needs-rootless", + message: "capsule bindings require the rootless execution tier", + }); + } + if ( + hasCapsule && + !manifest.execution.drivers.some((d) => d === "docker" || d === "compose") + ) { + issues.push({ + code: "capsule-needs-container-driver", + message: "capsule bindings require a docker or compose driver", + }); + } + return issues; +} + +//#endregion + +//#region Identity + +function stableJson(value: unknown): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableJson(item)).join(",")}]`; + } + // oxlint-disable-next-line openrouter/no-unnecessary-typecast -- narrowed to a non-null, non-array object above + const record = value as Record; + const body = Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`) + .join(","); + return `{${body}}`; +} + +/** + * The comparability identity: hash of every field that changes what a score + * means. Scores from two runs may aggregate only when their digests match. + * `name`, `reports`, and `provenance` are display/metadata and excluded. + */ +export function manifestIdentityDigest(manifest: EvalManifestV1): string { + const identity = { + protocolId: manifest.protocolId, + version: manifest.version, + dataset: manifest.dataset, + /* Roles are a semantically unordered set (uniqueness is enforced by + * validateManifest); sort so declaration order cannot split a series. */ + models: [...manifest.models].sort((a, b) => a.role.localeCompare(b.role)), + solver: manifest.solver, + scorer: manifest.scorer, + sampling: manifest.sampling ?? null, + epochs: manifest.epochs, + mediaRecipe: manifest.mediaRecipe ?? null, + }; + return createHash("sha256") + .update(stableJson(identity)) + .digest("hex") + .slice(0, 32); +} + +/** Whether scoring is reproducible from stored trajectories without a model. */ +export function isDeterministicScoring(manifest: EvalManifestV1): boolean { + return manifest.scorer.kind === "spec"; +} + +//#endregion diff --git a/src/benchmarks/gpqa.ts b/src/benchmarks/gpqa.ts index cdeb30d..6375818 100644 --- a/src/benchmarks/gpqa.ts +++ b/src/benchmarks/gpqa.ts @@ -32,7 +32,27 @@ D) {option_d}`; export const GPQA_TEMPERATURE = GPQA_META.temperature; -const GPQA_OPTION_FIELDS = [ +//#endregion + +//#region Dataset record -> Sample + +/** + * NOTE ON DIVERGENCE FROM OPENBENCH: openbench's gpqa reseeds Python's RNG to 0 + * before EVERY record, collapsing `random.shuffle` to a constant permutation so + * the correct answer is ALWAYS at position "B". A model that always answers "B" + * would score 100%. We deliberately DO NOT replicate that bug: each record is + * shuffled with a seed derived from its index, removing MCQ position bias while + * staying reproducible (same index -> same order across epochs and runs). + * + * Consequence: gpqa accuracy will not match openbench's and should track + * canonical/published gpqa numbers more closely. + */ +/** + * Exported for the manifest drift guard (manifests.test.ts) — not a public + * API. The manifest's documentation-only targetField must stay pinned to the + * field the solver actually reads. + */ +export const GPQA_OPTION_FIELDS = [ "Correct Answer", "Incorrect Answer 1", "Incorrect Answer 2", @@ -86,6 +106,10 @@ export const GPQA_DATASET = { dataset: "nmayorga7/gpqa_diamond", config: "default", split: "train", + /* Captured 2026-08-05. The HF loader fails closed if upstream moves; an + * upstream dataset change is a new comparability series — re-pin, never + * un-pin. Must match GPQA_MANIFEST.dataset.revision (drift-guarded). */ + revision: "c63e9ba02dc3da4c698e2a8485551b35041c3900", recordToSample: gpqaRecordToSample, } as const satisfies Omit; diff --git a/src/benchmarks/manifests.test.ts b/src/benchmarks/manifests.test.ts new file mode 100644 index 0000000..9784dfa --- /dev/null +++ b/src/benchmarks/manifests.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "bun:test"; + +import { assertRight } from "../internal/testing"; +import { parseSchema } from "../internal/zod"; +import { + GPQA_META, + TAU3_BENCH_BANKING_META, + getBenchmarkMeta, +} from "./benchmark-meta"; +import { + EvalManifestSchema, + manifestIdentityDigest, + validateManifest, +} from "./eval-manifest"; +import { GPQA_DATASET, GPQA_OPTION_FIELDS } from "./gpqa"; +import { + FIRST_PARTY_MANIFESTS, + GPQA_MANIFEST, + TAU3_BENCH_BANKING_MANIFEST, +} from "./manifests"; +import { getBenchmark } from "./registry"; + +describe("first-party manifests", () => { + it("every manifest parses, validates, and has a stable identity digest", () => { + for (const manifest of Object.values(FIRST_PARTY_MANIFESTS)) { + assertRight(parseSchema(EvalManifestSchema, manifest)); + expect(validateManifest(manifest)).toEqual([]); + expect(manifestIdentityDigest(manifest)).toHaveLength(32); + } + }); + + /* + * Equality guards: until meta/constants are *derived from* manifests, the + * two sources must agree. A drift in either direction fails here, which is + * the M2 contract — manifests cannot silently diverge from the running + * benchmark definitions. + */ + it("gpqa manifest agrees with its meta and registry entry", () => { + expect(GPQA_MANIFEST.id).toBe(GPQA_META.id); + expect(GPQA_MANIFEST.epochs.default).toBe(GPQA_META.defaultEpochs); + expect(GPQA_MANIFEST.sampling?.temperature).toBe(GPQA_META.temperature); + expect(getBenchmark(GPQA_MANIFEST.id)?.defaultEpochs).toBe( + GPQA_MANIFEST.epochs.default + ); + expect(getBenchmark(GPQA_MANIFEST.id)?.temperature).toBe( + GPQA_MANIFEST.sampling?.temperature + ); + }); + + it("gpqa manifest dataset reference and record fields match the running dataset module", () => { + /* inputField/targetField on a builtin-bound dataset are documentation for + * tooling — this guard keeps the documentation from silently lying about + * the record shape the builtin solver actually reads. */ + const dataset = GPQA_MANIFEST.dataset; + expect(dataset.kind).toBe("hf"); + if (dataset.kind !== "hf") { + throw new Error("unreachable"); + } + expect(dataset.dataset).toBe(GPQA_DATASET.dataset); + expect(dataset.config).toBe(GPQA_DATASET.config); + expect(dataset.split).toBe(GPQA_DATASET.split); + expect(dataset.revision).toBe(GPQA_DATASET.revision); + expect(dataset.revision).toMatch(/^[0-9a-f]{40}$/); + /* The fields named by the manifest must be fields the solver reads. */ + expect(dataset.inputField).toBe("Question"); + expect(dataset.targetField).toBe(GPQA_OPTION_FIELDS[0]); + }); + + it("gpqa protocol id is the openrouter series, not the simple-evals reference protocol", () => { + expect(GPQA_MANIFEST.protocolId).toBe("gpqa-diamond.openrouter.v1"); + expect(GPQA_MANIFEST.epochs.default).toBe(10); + }); + + it("tau3 manifest agrees with its meta, registry entry, and user-simulator default", () => { + expect(TAU3_BENCH_BANKING_MANIFEST.id).toBe(TAU3_BENCH_BANKING_META.id); + expect(TAU3_BENCH_BANKING_MANIFEST.epochs.default).toBe( + TAU3_BENCH_BANKING_META.defaultEpochs + ); + expect(getBenchmark(TAU3_BENCH_BANKING_MANIFEST.id)?.defaultEpochs).toBe( + TAU3_BENCH_BANKING_MANIFEST.epochs.default + ); + const simulator = TAU3_BENCH_BANKING_MANIFEST.models.find( + (m) => m.role === "user-simulator" + ); + expect(simulator?.defaultModel).toBe(TAU3_BENCH_BANKING_META.userModel); + }); + + it("manifest ids resolve in the meta registry (no orphan manifests)", () => { + for (const id of Object.keys(FIRST_PARTY_MANIFESTS)) { + expect(getBenchmarkMeta(id)).toBeDefined(); + expect(getBenchmark(id)).toBeDefined(); + } + }); + + it("identity digests are distinct across benchmarks", () => { + const digests = Object.values(FIRST_PARTY_MANIFESTS).map((m) => + manifestIdentityDigest(m) + ); + expect(new Set(digests).size).toBe(digests.length); + }); +}); diff --git a/src/benchmarks/manifests.ts b/src/benchmarks/manifests.ts new file mode 100644 index 0000000..1d37f5f --- /dev/null +++ b/src/benchmarks/manifests.ts @@ -0,0 +1,113 @@ +import { GPQA_META, TAU3_BENCH_BANKING_META } from "./benchmark-meta"; +/** + * First-party eval manifests (M2 of the unified-manifest migration). + * + * Each registered benchmark gains an {@link EvalManifestV1} with `builtin` + * bindings naming its TS implementation. The manifests are the durable, + * serializable description; `benchmark-meta` literals and Mission Control + * constants are asserted equal to them by tests today and derived from them + * once every benchmark is covered. + * + * Starting set: gpqa (simplest chat benchmark) and tau3-banking (hardest: + * multi-role, builtin dataset/solver/scorer, per-benchmark options). The + * remaining 14 follow mechanically once this shape survives review. + */ +import type { EvalManifestV1 } from "./eval-manifest"; +import { EVAL_MANIFEST_API_VERSION } from "./eval-manifest"; + +export const GPQA_MANIFEST: EvalManifestV1 = { + apiVersion: EVAL_MANIFEST_API_VERSION, + id: GPQA_META.id, + name: "GPQA Diamond", + version: "1", + /* + * Deliberately NOT `gpqa-diamond.public.v1` (the Compass catalog's name for + * the simple-evals reference protocol, n_repeats=4): our first-party series + * runs 10 epochs with a per-record seeded shuffle, which is a different + * comparability series and must not masquerade as the public one. + */ + protocolId: "gpqa-diamond.openrouter.v1", + dataset: { + kind: "hf", + dataset: "nmayorga7/gpqa_diamond", + config: "default", + split: "train", + /* Captured 2026-08-05; the loader fails closed if upstream moves. An + * upstream dataset change is a new comparability series — re-pin here + * (which changes the identity digest) rather than un-pinning. */ + revision: "c63e9ba02dc3da4c698e2a8485551b35041c3900", + // The builtin solver renders the full MCQ prompt; these fields document + // the record shape for tooling rather than driving a spec solver. + inputField: "Question", + targetField: "Correct Answer", + }, + models: [{ role: "candidate", overridable: true }], + solver: { kind: "builtin", ref: "gpqa-solver" }, + scorer: { kind: "builtin", ref: "mcq" }, + sampling: { temperature: GPQA_META.temperature }, + epochs: { default: GPQA_META.defaultEpochs, reducer: "mean" }, + execution: { tier: "trusted-local", drivers: ["process"] }, + reports: [ + { + key: "score", + label: "Accuracy", + type: "scalar", + metric: "accuracy", + goal: "higher", + }, + ], + provenance: { + canonicalName: "GPQA Diamond", + sourceUrl: "https://arxiv.org/abs/2311.12022", + supportStatus: "ready", + comparabilityNotes: + "Three deliberate divergences from the simple-evals reference protocol: " + + '(1) prompt omits "Think step by step before answering." (inherited from openbench: faster, less leading); ' + + "(2) per-record seeded option shuffle instead of one RNG stream (also fixes openbench's reseed-to-0 bug that pins the answer at B); " + + "(3) 10 epochs (leaderboard series) vs n_repeats=4. " + + "Scores are not comparable across the two protocol IDs.", + }, +}; + +export const TAU3_BENCH_BANKING_MANIFEST: EvalManifestV1 = { + apiVersion: EVAL_MANIFEST_API_VERSION, + id: TAU3_BENCH_BANKING_META.id, + name: "τ³-bench Banking", + version: "1", + protocolId: "tau3-bench-banking.v1", + dataset: { kind: "builtin", ref: "tau3-banking-dataset" }, + models: [ + { role: "candidate", overridable: true }, + { + role: "user-simulator", + defaultModel: TAU3_BENCH_BANKING_META.userModel, + overridable: true, + }, + ], + solver: { kind: "builtin", ref: "tau3-banking-solver" }, + scorer: { kind: "builtin", ref: "tau3-banking-scorer" }, + sampling: { temperature: 0 }, + epochs: { default: TAU3_BENCH_BANKING_META.defaultEpochs, reducer: "mean" }, + execution: { tier: "trusted-local", drivers: ["process"] }, + reports: [ + { + key: "score", + label: "Task success", + type: "scalar", + metric: "success", + goal: "higher", + }, + ], + provenance: { + canonicalName: "tau3-bench banking", + supportStatus: "ready", + comparabilityNotes: + "Dual-control conversations; user-simulator model is part of protocol identity. 5 epochs mirrors Artificial Analysis.", + }, +}; + +/** All first-party manifests declared so far, keyed by benchmark id. */ +export const FIRST_PARTY_MANIFESTS: Readonly> = { + [GPQA_MANIFEST.id]: GPQA_MANIFEST, + [TAU3_BENCH_BANKING_MANIFEST.id]: TAU3_BENCH_BANKING_MANIFEST, +};