End-to-End Eval Runner
Five lessons of plumbing, one lesson to glue them. The runner reads the task spec from lesson 70, calls a model through an adapter, scores with lessons 71 and 72, attaches the calibration report from lesson 73, and emits the leaderboard from lesson 74. Demo self-terminates.
Lesson 73 of 85Build~90 minPython
Before this: Phase 19 Track B foundations, lessons 70 through 74
On this page
Learning objectives
- Define a
ModelAdapterinterface that any model (mock, local, API) can satisfy with a small method surface. - Run the eval over a fixture JSONL file with parallel task execution across a worker pool.
- Compose the metric layer (exact_match, F1, BLEU-4, ROUGE-L, code_exec) with the calibration layer in one pass.
- Emit per-model
EvalRunrecords and feed them straight into the leaderboard aggregator. - Output both a JSON report and a markdown table; self-terminate with exit zero on a clean run, non-zero on validation or runtime failure.
eval-grid
The pipeline
The runner is the integration point. Each lesson 70 through 74 owns one module that the runner composes. The runner does not duplicate any logic from those modules: it imports them.
The adapter interface
The adapter is the seam between the runner and any model. The interface is intentionally small.
class ModelAdapter:
model_id: str
def generate(self, prompt: str, task: TaskSpec) -> Generation: ...
Generation is a dataclass with:
text: the model's free-form outputconfidence: a float in[0, 1]representing the model's self-reported probability for the answertoken_nll: optional sum of negative log-likelihoods over the generated tokenstoken_count: optional number of generated tokens
Mock adapters in the runner provide three flavours: RuleBasedAdapter (deterministic, near-perfect), NoisyAdapter (overconfident, often wrong), and BiasedAdapter (good at one category, terrible at another). The demo runs all three over the lesson 70 fixture.
Parallel execution
The runner uses concurrent.futures.ThreadPoolExecutor to run tasks in parallel per model. The worker count defaults to the smaller of eight and the task count. Threads are sufficient because the bottleneck for real model calls is network I/O. The code-exec path spawns its own subprocess inside the task and the executor only schedules the wait.
For deterministic tests, the runner exposes run_eval(adapters, tasks, parallel=False) so tests can pin the execution order.
The single-pass scoring loop
For each task:
- Render the prompt (few-shot prefix plus the prompt body).
- Call the adapter and time the call.
- Post-process the generation per the task's rule.
- Dispatch to the metric layer.
- Build an
EvalRunrecord with the score and metric metadata. - Append the
(confidence, correct)pair to the calibration buffer.
The correct signal is score >= 1.0 for exact_match-style metrics (exact_match, accuracy, code_exec) and score >= 0.5 for graded metrics. The threshold lives in _correct_from_score and the runner does not expose a public override.
Aggregation
After every task has a result, the runner calls aggregate and pairwise_diffs from lesson 74 and CalibrationReport.from_predictions from lesson 73. The output is a single JSON envelope:
{
"leaderboard": [...],
"pairwise": [...],
"calibration": {
"model_id_a": {"ece": 0.04, "brier": 0.10, "populated_bins": 8, ...},
...
},
"summary": {
"tasks": 10,
"models": 3,
"wall_seconds": 1.2
}
}
The runner also writes a markdown table to stdout so the user can paste the result into a PR review.
Self-terminating demo
The demo runs three mock adapters over the ten fixture tasks from lesson 70. Wall time should sit under ten seconds. The exit code is zero on a clean run.
The clean-run criteria are:
- Every task validated under lesson 70.
- Every task scored under lessons 71 and 72.
- The calibration report aggregated under lesson 73 without errors.
- The leaderboard ranked the rule-based adapter strictly above the random adapter.
If any of those break, the runner exits non-zero with a structured error in the JSON envelope.
What this lesson does not do
It does not call a real model. It does not implement an API key flow or rate-limit handling. It does not implement streaming or partial generation; the adapter returns one generation per call. It does not do retries or caching. Those concerns live at the adapter layer; the runner is metric-agnostic and provider-agnostic.
How to read the code
main.py is the integration. It imports from the other five lesson modules through a small _load_sibling helper that resolves them by relative path. The dataclasses Generation, EvalReport, and ModelAdapter are defined locally. The mock adapters are at the bottom of the file.
Read main.py top to bottom. Skim the imports, then look at run_eval, then _score_one, then the adapters. The demo at the end is the entry point.
The tests in code/tests/test_runner.py pin the adapter interface, the single-pass loop, the parallel-vs-sequential equivalence, the calibration buffer, and the JSON envelope shape.
Going further
This runner is the floor. A production eval system adds: a results cache keyed by (task_id, model_id, model_version), a cost ledger that tracks dollars and tokens per run, a retry layer that backs off on rate limits, a sampling policy for pass-at-k tasks, and a streaming output format for long suites. Each of those is a single concern that wraps the runner without changing the metric or aggregation layers. That separation is the point of the contract.
Add an adapter for a real provider after you have the mocks working. Pick one with a free tier, write thirty lines of glue, watch the leaderboard light up. Then add the second provider and let the harness do the work.
Check yourself
Quick quiz
What is the smallest possible surface for the ModelAdapter interface?
- complete, embed, classify, summarise
- init, login, generate, close
- generate(prompt, task) returning text plus optional confidence and per-token nll
- load_weights, generate, save_weights
Show the answer
C. generate(prompt, task) returning text plus optional confidence and per-token nll One method covers any adapter. Everything else is optional decoration. The runner does not need a richer surface.
Why does the runner accept a parallel flag rather than always running in parallel?
- Tests need deterministic execution order; the flag lets them switch to sequential
- Parallel execution is unsafe in CPython
- Parallel execution requires GPU
- ThreadPoolExecutor is not stdlib
Show the answer
A. Tests need deterministic execution order; the flag lets them switch to sequential Tests want reproducibility. Production wants throughput. The flag splits those concerns without two code paths.
How does the runner determine the correct flag for the calibration buffer?
- It uses exact_match-style metrics at threshold 1.0 and graded metrics at threshold 0.5 by default
- It calls the adapter twice and compares outputs
- It uses the average bin confidence
- It uses Brier decomposition
Show the answer
A. It uses exact_match-style metrics at threshold 1.0 and graded metrics at threshold 0.5 by default The threshold is metric-aware: exact_match-style metrics use a near-1.0 cutoff, while graded metrics use 0.5 by default.
Why does the runner build EvalRun records and hand them to the aggregator instead of computing the leaderboard inline?
- It keeps the runner agnostic to the aggregator and lets the leaderboard layer evolve without touching the runner
- The aggregator is faster on numpy arrays than dataclasses
- EvalRun is required by JSON Schema
- EvalRun is more memory-efficient
Show the answer
A. It keeps the runner agnostic to the aggregator and lets the leaderboard layer evolve without touching the runner Composition over inlining. The runner produces records; the aggregator owns the leaderboard math. Each lesson owns one concern.
What is the role of the perplexity block in the final JSON envelope?
- It is computed from the bootstrap CI
- It carries the held-out language modelling number per model, independent of task scoring
- It is the input to the calibration report
- It is required for the leaderboard ranking
Show the answer
B. It carries the held-out language modelling number per model, independent of task scoring Perplexity sits next to the leaderboard, not inside it. It uses the adapter's per-call token NLL and token counts.
What is the clean-run exit criterion of the self-terminating demo?
- Every task validated, every task scored, calibration aggregated, and rule-based adapter strictly above the random adapter on the leaderboard
- Every task scored above 0.9
- ECE below 0.05 for every model
- Bootstrap CI excludes zero for every pair
Show the answer
A. Every task validated, every task scored, calibration aggregated, and rule-based adapter strictly above the random adapter on the leaderboard The demo enforces the contract end to end: every layer ran and the most accurate adapter is ranked accordingly.
Questions people ask
What is a model adapter in an evaluation harness?
An adapter is a thin wrapper that gives every model the same interface, such as a function that takes a prompt and returns text. The runner calls the adapter, so new models or vendors can be added without changing the tests.
How often should a business re-run AI evaluations?
Whenever something changes: a new model version, a prompt edit, new documents or new tools. Many teams run a small suite on every change and a larger one on a schedule, so regressions are caught early.
Lessons from AI Engineering from Scratch by Rohit Ghumare and contributors, MIT license. Notes for owners by Lozo. License