Skip to content

Feat/bench core abstraction - #7

Open
Abhinavexist wants to merge 38 commits into
mainfrom
feat/bench-core-abstraction
Open

Feat/bench core abstraction#7
Abhinavexist wants to merge 38 commits into
mainfrom
feat/bench-core-abstraction

Conversation

@Abhinavexist

@Abhinavexist Abhinavexist commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Every new model used to mean copy-pasting a benchmark runner and patching it per provider — an N×M fork explosion (~40 near-identical files) where copied parts silently drifted and produced wrong scores: dropped RefCOCO runners, a live wrong Gemini thinking budget, and audio_url silently dropped to OpenRouter causing WER ≈ 1.0.

This replaces all of that with a single abstraction: benchmark logic says what to measure, adapters say how to talk to a model, and config says which models to run and when.

Adding a model is now just a YAML entry in src/targets.yaml — no code changes. Everything runs through one CLI:

python -m src run --target X --benchmark Y

The core lives in src/. Capabilities are represented as data, with declared reasoning style, media shape, and response shape per target. At runtime, execute() has a fallback ladder that self-heals declared capability gaps such as thinking-floor and temperature coupling, then emits a hint so the working behavior can be promoted into YAML.

Provider adapters live in src/providers/: openai_compat, anthropic, and gemini, plus reducto for document parsing. There is one client builder and a uniform encode/decode/classify interface.

There is now one runner handling token-bucket rate limiting, a bounded worker pool for memory safety, retry with backoff, resume from per-row append+fsync checkpoints, and ordered cross-provider failover with the actual host recorded for each row.

All results follow one contract:

results/<benchmark>/<target>/{responses,metrics,run}.json

Result discovery is content-based.

All eight benchmarks have been collapsed to one bench.py each under benchmarks/<name>/: GPQA, MMMLU, MMMU-Pro, OCRBench v2, olmOCR, RefCOCO, VoxPopuli ASR, and Spider2-Lite. Each has a parity test proving there is no score drift from the previous implementation.

For heavy benchmarks, --sample N streams only N rows instead of downloading the whole split. Smoke runs are isolated under results/_smoke/, so they cannot overwrite a full score and are hidden from the report.

Full image runs are also RAM-bounded using lazy-by-index loading. RefCOCO val, for example, stays around 1.7 GB flat instead of heading toward ~14 GB and OOMing CI.

CI under .github/workflows/ now includes an offline test + lint gate on every push and PR, plus a benchmark runner supporting manual dispatch, a weekly regression smoke, and a full run when a targets.yaml change is merged. There is also a UI form for adding a model via PR.

There is deliberately no default target, so nothing runs unless a target is explicitly selected.

This removes ~40 per-provider forks, the commons_{gemini,openai,anthropic,fireworks,openrouter} shims, BFCL, JigsawStack, the demo entrypoint, and the one-time results backfill. results/ is no longer tracked because run artifacts are generated output.

185 tests pass, covering parity replays plus capability, adapter, runner, and failover units. ruff check . and ty check src are clean.

All three adapter paths have also been live-verified against real APIs, including audio transport and failover host recording.

The package is currently named src, so the CLI is python -m src. That's unconventional, and it can be moved to a src/bench_core/ layout if preferred.

olmocr and spider2 are excluded from CI because they require poppler/chromium and roughly 4 GB of data. Vendored scorers are excluded from ruff.

reducto lives in src/providers/, but it is a function module rather than a ProviderAdapter, since it represents a document-parsing API rather than a chat provider.

Per-provider runners (Fireworks/OpenRouter/native Gemini) and 9 correctness
fixes carried over from the benchmarking work: Spider2 query-timeout guard +
unclosed-fence SQL extraction, MAX_IN_FLIGHT concurrency caps, OpenRouter
input_audio (vs silently-dropped audio_url), OpenRouter client timeout,
lowercase API-key fallbacks, and per-model checkpoint/slug tagging. Adds
scripts/report_scores.py, BFCL setup, chart/sheet tools, and dev/test deps.

Downloaded datasets (spider2_lite/data ~4GB, olmocr full_data) and eval
scratch zips excluded via .gitignore.
Stage 1 of the harness redesign. Collapses the N×M per-provider runner forks
into one tested core: declared capabilities + merge chain, a single reasoning
translation (the six divergent Gemini-floor encodings + OpenAI/Fireworks/
Anthropic/OpenRouter), media block builders (fixes the audio_url silent-drop
and the RGBA→JPEG crash centrally), response decode, error classification, the
openai-compat/anthropic/gemini adapters, a self-healing fallback ladder that
emits capability hints, the shared async harness (rate-limit/semaphore/retry/
resume/incremental checkpoint), and one result contract with content-based
discovery. 8 target YAMLs across all providers; 106 offline tests.

Schema note: reasoning keys are off_value/on_value, not off/on — bare off:/on:
are YAML 1.1 booleans (off: low -> {False: low}); guarded by a test.
…gets.yaml

One file with a targets: map (key = target name) instead of a file per model.
load_all_targets()/load_target(name) read it; tests updated. Same schema, less
directory clutter, easier to eyeball.
…ai_compat

anthropic>=0.122.0 is required by AnthropicAdapter.build_client (was
uncommitted). Also drops an unreachable duplicate return an automated pass left
in openai_compat._content.
Keep only load-bearing comments; drop multi-paragraph module narration.
_probe_voxpopuli_missing.py and _probe_grok_reasoning.py were diagnostic
one-offs (unreferenced). Unrelated to the runners.
…vider forks

Adds benchmarks/gpqa/bench.py (dataset + prompt + parse + score), the bench
CLI (run/list-targets) + python -m bench_core entrypoint, and an offline
scoring-parity test that replays all 4 archived GPQA runs through the new
parse/score and asserts bit-identical scores (inkling 87.37, deepseek 54.55,
gemini 90.40, inkling-small 85.86).

Deletes gpqa_{openai,openrouter,fireworks,gemini}.py (4 forks -> 1 bench.py).
Updates run_fireworks_suite.sh (gpqa via CLI, TARGET env) and README.
…e 2 forks

benchmarks/asr/bench.py (audio fetch + WER/CER scoring) replaces
voxpopuli_aa.py and voxpopuli_aa_multi.py; the per-provider audio wire-shape
(input_audio / audio_url / gemini_part / file_block) is now handled by the
adapters, fixing the silent audio_url->OpenRouter drop by construction.

Offline parity test replays the 4 archived ASR runs through the new score and
matches corpus/time-weighted WER + CER (inkling 0.0461, gemini 0.0358,
inkling-small 0.0409). CLI gains --benchmark asr and a generic primary-metric
summary. Suite + README updated.

125 tests pass.
benchmarks/mmmlu/bench.py (14-language lite MCQ, per-language macro accuracy)
replaces mmmlu.py + mmmlu_multi.py. Offline parity replays the 4 archived runs
(inkling macro 0.8846, gemini 0.9268, deepseek 0.7961, inkling-small 0.8737).
CLI --benchmark mmmlu; suite + README updated.
…+ modal app

benchmarks/mmmu_pro/bench.py (standard + vision, A-J, per-subject/difficulty)
replaces mmmu_pro_multi.py. CLI gains a generic --variant (standard|vision).
Offline parity replays the 6 archived runs (standard inkling 0.7329, gemini
0.8486; vision inkling 0.6809, gemini 0.8324).

Also deletes modal_app.py — it wrapped mmmu_pro_multi's internals for a Modal
deployment and dies with the fork; Modal deployment, if wanted, should target
the new CLI.

139 tests pass.
…s + oracle re-scorer

benchmarks/obj_detection/bench.py ports the coordinate parser, IoU scoring,
and the format-tolerant oracle (now a score-time submetric, not a separate
pass). Retires refcoco_{multi,openai,gemini,grok,kimi}.py and
reeval_format_tolerant.py. Offline parity replays strict Acc@0.5 (inkling val
0.3237, inkling-small 0.5987, gemini testA 0.7970) and the oracle (0.8119).

Kept: refcoco.py (shared helper lib) and ob_det_api.py — the JigsawStack
object-detection benchmark is a separate target, not a per-provider fork, and
still imports refcoco.py's helpers. CLI --variant selects the split.

146 tests pass.
… forks

benchmarks/ocrbench_v2/bench.py routes inference through bench_core and reuses
the existing eval_scripts scorer verbatim (file-based, chdir, RRC shell); only
the macro-of-category aggregation is ported. Retires the 10 per-provider/per-task
runners (ocrbench_v2.py + 9 variants); eval_scripts/ kept as the scorer.

Offline parity replays the archived scored.json through the ported aggregate and
matches en_overall/cn_overall + every category (inkling 0.5785, inkling-small
0.5659, gemini 0.7134). Images decode lazily per request (no 10k-image OOM).

150 tests pass.
…rs + 6 runners

benchmarks/olmocr/harness.py routes per-page inference through bench_core and
reuses the external olmocr.bench scorer: score() materializes .md files from the
run records, invokes the scorer, tees stdout to logs/olmocr_<target>.log (what
report_scores reads), and parses the overall out. Named harness.py to avoid the
vendored benchmarks/olmocr/bench/ package. CLI passes target to score() when its
signature accepts it.

Retires olmocr_bench{,_fireworks,_gemini,_gemini_pro_31,_grok,_openai_mini}.py
and the matching run_*.py. Kept: olmocr_bench_reducto.py + run_reducto.py
(Reducto is a bespoke document API, not a chat model — like ob_det_api).

No offline score-replay (the scorer needs the dataset + prints to a log);
parity is by-construction (identical prompt/PNG render/null-normalization) plus
a log-parser test against the archived gemini run.

153 tests pass.
…he fork

benchmarks/spider2_lite/bench.py routes text-to-SQL through bench_core and reuses
the SQL extraction, in-memory SQLite execution (120s timeout guard), and result-set
comparison. Retires spider2_lite.py; fetch_data.py (data downloader) kept.

Offline unit tests cover extract_sql (incl. the unterminated-fence fallback) and
the result comparator. Full score parity re-executes SQL against the ~4GB
gitignored data/, so it is gated behind data/ + BENCH_SPIDER2_PARITY=1; run
locally it reproduces inkling within execution noise (65 vs archived 64 — a heavy
query flips on the 120s timeout boundary, inherent to Spider2, not a port bug).

158 tests pass, 1 gated skip.
…ontract (Steps 2+5)

Step 2 — bench migrate-results: folds the 32 legacy flat results/*_metrics.json
(+ responses/predictions) into results/<benchmark>/<target>/. The contract keys
by (benchmark, target), so colliding legacy runs (2-sample smokes, reasoning
off/high, native-vs-openrouter) are deduped by most-evaluated-samples and the
rest logged as shadowed (no silent drops). RefCOCO oracle folds in as a
submetric; empty/None responses are left to resume, not counted done.

Step 5 — report_scores.py now reads the new contract via bench_core.results
.discover() (retiring the filename-prefix parsing, canon() shadowing, and smoke
guards — the contract already resolved those). Renders all 8 tables unchanged;
olmOCR still parsed from logs. 26 canonical metrics.json committed; the large
regenerable per-run responses.jsonl are gitignored.

161 tests pass (+1 gated).
… (Stage 6)

.github/workflows/tests.yml: pytest + ruff on every push/PR (test_targets.py
validates new targets). .github/workflows/benchmark.yml: workflow_dispatch (target
/ benchmarks / sample_size / reasoning) + nightly schedule over ci_regression
targets; matrix from scripts/ci_matrix.py; per-run metrics artifact; report job
diffs vs baseline (scripts/ci_compare.py) into the job summary.

Defaults to cheap --sample smokes (full runs opt-in). CI set is the API-only
benchmarks; olmocr (poppler/chromium/full-dataset) and spider2 (4GB data/) are
left to local/self-hosted. Adding a model = a targets.yaml entry + its secret.
inkling + gemini-3.7-flash flagged ci_regression. README documents it all.

165 tests pass (+1 gated).
add-target.yml: a GitHub-UI form (name/provider/model_id/capabilities_json/
ci_regression) -> scripts/add_target.py validates the entry (loads, resolves caps,
builds adapter) and appends it to bench_core/targets.yaml -> opens a PR. Inputs
passed via env (no shell injection through the JSON).

benchmark.yml: new push trigger on main touching targets.yaml runs a FULL
benchmark for the target(s) whose entry changed (ci_matrix.changed_targets diffs
the merge). So both paths — the guided form and a hand-edited YAML PR — run the
benchmark only once the PR is merged. Action versions bumped to match tests.yml.

Adds tests for add_target (append/validate/reject) and changed_targets.

170 tests pass (+1 gated).
benchmark.yml: dispatch 'target' is now required (no inkling default), so an
accidental Run-workflow can't spend on a model you didn't pick. ci_matrix drops
both inkling fallbacks: dispatch with no target and schedule with no
ci_regression target both yield an empty matrix. A 'has_work' plan output gates
the run job so an empty matrix cleanly produces zero jobs.

170 tests pass.
- tests.yml: was on every branch push AND double-ran PRs (push + pull_request).
  Now push only on main (feature branches covered by their PR) + a concurrency
  group with cancel-in-progress so stale commits don't pile up runs.
- changed_targets: an empty diff baseline (first push / force-push / unavailable)
  used to treat EVERY target as changed and benchmark the whole registry — now
  returns nothing. And a ci_regression flag flip used to trigger a full paid run
  though the model is unchanged — now ignored (it only affects nightly membership).
- benchmark.yml: report job no longer spins a runner on a no-op push (gated on
  has_work); a concurrency group queues (doesn't cancel) paid runs per ref so two
  passes never run at once; nightly dropped from daily to weekly (Mon), and still
  only runs ci_regression-flagged targets.

172 tests pass (+1 gated).
…-ify)

The migration had narrowed two benchmarks to their default variant only,
dropping functionality (scores of the defaults were unchanged, but the options
were gone):

- MMMLU: was lite-only. Restores --variant lite|full (full = openai/MMMLU ~196k);
  lite stays the default so the archived leaderboard numbers are unchanged. The
  result dir is now mmmlu_lite (like mmmu_pro_standard); backfill + report updated.
- RefCOCO: was base-only. --variant now also accepts plus-* (RefCOCO+) and g-*
  (RefCOCOg); base splits (val/testA/testB/test) unchanged.

Every other benchmark already runs the full dataset (--sample is only a smoke
flag). Adds unit tests for both variant builders; report re-migrated.

174 tests pass (+1 gated).
Deletes benchmarks/obj_detection/ob_det_api.py (the JigsawStack API benchmark)
and refcoco.py (the shared helper lib that only ob_det_api still imported — the
new bench.py is self-contained). Drops the JIGSAWSTACK_API_KEY from the README
.env example and the ob_det_api run lines. obj_detection now = bench.py only.

174 tests pass.
results/ (benchmark outputs, incl. Spider2/OCRBench responses, ~200MB) is now
gitignored entirely and purged from this branch's history (git filter-branch).
Files stay on disk locally; regenerate via a run or bench migrate-results.

Note: the report + CI baseline previously read committed metrics.json; with
results/ untracked they read local files (report) / show 'new' vs no baseline
(ci_compare) — no committed benchmark data.
@Abhinavexist
Abhinavexist force-pushed the feat/bench-core-abstraction branch from 80468b3 to 4c15a35 Compare August 16, 2026 11:34
Collapse the 10 per-benchmark sections (each repeating the CLI 3-4x) into one
benchmarks table + a single run example. Deep gotchas (per-benchmark one-time
setup, Fireworks reasoning caveat, BFCL sharp edges) moved into <details> so
they're available but not overwhelming. 255 -> 101 lines.
Deletes scripts/setup_bfcl.sh, scripts/bfcl.sh, the README BFCL section, and
the now-unused external/ gitignore entry (external/ was BFCL's checkout + venv;
removed from disk, ~1.2GB). The repo now covers the 8 bench_core benchmarks.

174 tests pass.
The old per-benchmark runners each called load_dotenv(); the new bench_core CLI
did not, so 'python -m bench_core run' failed with 'no API key' despite keys in
.env. main() now loads .env (python-dotenv, already a dep). Found via a live run.
--sample now streams only the first N rows (bench_core/datautil.load_rows)
instead of downloading the whole split, so smoking a ~10k-image benchmark
(ocrbench_v2) is cheap. Wired into mmmu_pro, refcoco, ocrbench_v2 — ocrbench
keeps its lazy-by-index full path (materializing 10k decoded images would OOM);
smokes embed the streamed image via a dual 'image'/'idx' path in build_request.

Sampled runs write under results/_smoke/ so they can't clobber or shadow a full
run's metrics (results/ is gitignored → a clobbered full score is unrecoverable)
and stay out of report_scores (discover() globs depth-2 only).

Verified live: mmmu_pro --sample 3 (gemini-2.5-flash) streamed 3 rows, real API
calls (reasoning_tokens present), landed in results/_smoke/, invisible to
discover(). Note: README also carries an unrelated title/.env-format tweak.
…+ lazy-idx)

The runner did asyncio.gather(process(s) for s in pending) with build_request
above the first await, so every request was built up front — a full image
benchmark decoded/encoded all images at once (RefCOCO val list(ds) est. ~14 GB,
OOMing a 16 GB CI runner). Two changes:

- runner: bounded worker pool (min(max_in_flight, len) workers pulling a queue),
  so build_request runs only when a worker picks up a sample -> at most
  max_in_flight decoded images at once. All 5 existing runner tests pass
  unchanged; new test asserts peak in-flight <= max_in_flight.
- refcoco/mmmu_pro: full runs keep the split memory-mapped and read images lazily
  by idx (samples carry idx, not images); smokes embed the streamed image via a
  dual image/idx path in build_request. Verified RSS flat: RefCOCO val ~1.7 GB,
  MMMU-Pro ~0.9 GB (were climbing to OOM); live smokes pass.
src/commons_{openai,anthropic,fireworks,openrouter,gemini}.py had 0 importers
(superseded by bench_core/providers); main.py + src/commons.py were a hello-world
Interfaze demo referenced nowhere. Kept src/commons_reducto.py (olmocr reducto).
A gemini run finished and wrote all results but the process never exited (the
google-genai SDK leaves non-daemon threads), so a CI job would hang until its
timeout. __main__ now flushes and os._exit(0) after main() returns; a SystemExit
from an error still propagates first with its code. Verified: gemini run now
exits 0 on its own in ~4s instead of hanging.
The backfill has done its job (results now live in the contract). Removes
bench_core/migrate.py, tests/test_migrate.py, the migrate-results CLI subcommand,
and stale doc references.
A target may declare 'fallbacks: [{provider, model_id, capabilities?}]'. The
runner now takes an ordered list of routes (primary + fallbacks); on a terminal
failure it advances to the next route, but ONLY when the host itself won't serve
(FATAL / rate-limit / transient with retries exhausted) — NOT on EMPTY_CONTENT,
since the backup runs the same weights and would just double the bill. The
serving provider is recorded per row ('host') and aggregated into metrics.hosts;
run.json lists the routes actually available. A proprietary target declares no
fallbacks -> single route -> stops on failure (as intended).

Keyless routes are skipped loudly (error only if none remain). Resume accepts
mixed provenance across hosts (the host field is the audit trail). execute()
never mutates the caller's req/caps (dataclasses.replace), so a route inherits
nothing from a prior route. Example: inkling-small = OpenRouter -> Fireworks.
185 tests (7 new); teeth-checked that EMPTY_CONTENT does not fail over.
bench_core + migrated benchmarks + scripts are ruff- and ty-clean. Key fixes:
reasoning.build_reasoning uses assert_never (exhaustive guard, no unreachable
raise, no |None cascade); Route.caps typed Capabilities; lazy _DATASET globals
and mixed part/row lists annotated; reducto client boundary-typed. Vendored
scorers/harnesses excluded from ruff (extend-exclude). Folds in concurrent
working-tree edits.
src/commons_reducto.py folded into the core (bench_core/), then bench_core/
renamed to src/ so there's a single package. All references updated: imports
(from src.X), the CLI entrypoint (python -m src), GitHub Actions (python -m src,
ruff check src tests, src/targets.yaml triggers), and the CI scripts. Reducto
importers (from src.commons_reducto) resolve to src/commons_reducto.py. Verified:
python -m src list-targets, 185 tests, ruff (repo + src/tests), ty check src.
Reducto was left as a stray src/commons_reducto.py — the old commons_* shape the
rest of the refactor eliminated. Moved it into the providers package alongside the
other provider integrations (anthropic/gemini/openai_compat); importers now use
from src.providers.reducto. Also polished the README CLI reference to python -m src.
Verified: ruff (repo + src/tests), ty check src, 185 tests, python -m src.
…4-mini

These were per-runner defaults in main (refcoco_gemini, gpqa_gemini/ocrbench pro,
openai mini) but weren't carried into targets.yaml. Added for full model parity
with main. No ci_regression flag -> available for manual dispatch, not auto-run.
gemini-2.5-pro uses the 2.5 thinking_budget knob; the others use provider defaults.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant