TAI BUI
Back to curriculum
CapstonePython

Language Model Evaluation Harness

A model that does well on a task you cannot define is a model that does well by accident. The harness is the task definition, the metric, the runner, and the leaderboard, kept short and swappable.

TIME: ~90 minutesPREREQUISITES: Phase 19 lessons 42 to 45

Learning Objectives

  • Define a task as a JSONL file with `prompt`, `targets`, `metric`, and optional `extras` per example.
  • Implement five metrics: exact match, rouge-l F1, executable check, multiple choice, and substring contains.
  • Build a runner that batches examples per task and dispatches to a swappable model adapter.
  • Emit a leaderboard JSON with per-task scores, latency, and an overall average that is reproducible.

The Problem

An eval harness is a small program that runs a model against a fixed set of tasks, scores each answer with a fixed metric, and writes the scores to a JSON file you can diff. It has four parts: the task definition, the metric, the runner, and the leaderboard.

A new language model lands every week, and the claim is always that it does well. Well at what? The honest answer is the leaderboard you wrote yourself, because the vendor's leaderboard is the one they tuned to.

Without a harness in your repo you compare two models by vibes. With one, you compare them by score on a fixed task set with a fixed metric, in an output you can diff. The harness is the contract between yesterday's run and today's run, and without it regressions ship.

Over-fitting the harness to a single model is the trap, and the fix is to keep the harness small. You can read it in fifteen minutes. The tasks are small enough to ship in the repo. The metrics are written from scratch so a colleague can audit them, and model-specific code lives in the adapter. Swap the adapter and the leaderboard moves; swap the tasks and the leaderboard moves. Nothing else should move.

Warm-up0/3
1. You want to know whether a new model version beats the old one. Which comparison can you trust?Warm-up
2. A scorer gives each of 10 test examples a number between 0 and 1. How do you get one score for the whole set?Warm-up
3. A dataset is stored as a JSONL file. How is the data laid out?Warm-up
Check: The Problem0/2
1. What is an eval harness?Define
2. Why does the lesson write the metrics from scratch and keep the whole harness short?Explain

The Concept

Mermaid diagram

Task spec

Every example is one JSONL line:

json
{"id": "arith-00", "prompt": "compute: 2 + 2", "targets": ["4"], "metric": "exact_match"}

For metrics that need scoring helpers, extras carries the side payload:

json
{
  "id": "code-00",
  "prompt": "python: write a function f that doubles its input",
  "targets": ["ok"],
  "metric": "code_exec",
  "extras": {"io_pairs": [[1, 2], [3, 6]]}
}

A task is a .jsonl file under outputs/tasks/. The file name is the task name. All examples in a file share a metric.

The five fixture tasks

TaskMetricWhat it tests
arithmeticexact_matchToken-level correctness on a deterministic answer
summaryrouge_lLongest common subsequence F1 against a one-line reference summary
code-execcode_execExecutable test: the predicted function must satisfy a list of input-output pairs
multiple-choicemultiple_choiceFirst letter of the prediction must match an allowed letter
generationsubstring_containsFree-form text must contain at least one target substring

The metric contract

Every metric maps a prediction and its targets to a float in [0.0, 1.0]. Only code_exec reads extras; METRIC_FNS wraps the other four in three-argument lambdas so the runner can call every metric as (prediction, targets, extras). The harness averages the per-example scores to get a task score, then averages task scores to get the overall. The metric functions are tiny:

  • exact_match: lowercase, collapse whitespace, equality.
  • substring_contains: same normalization, substring test.
  • multiple_choice: first character uppercased.
  • rouge_l: LCS length divided by lengths of prediction and reference, F1 of precision and recall. With several targets it keeps the best F1.
  • code_exec: execute the prediction in a restricted namespace, call f(x) on every pair in extras.io_pairs, and score the fraction that match. It ignores targets.

The code_exec metric runs the prediction in a stripped builtins namespace. The test asserts that a prediction containing import os scores 0.0: the stripped __builtins__ has no __import__, so exec raises, the metric catches the exception and returns 0.0. That keeps a code prediction away from the filesystem.

The model adapter

python
class ModelAdapter(Protocol):
    def generate(self, prompts: Sequence[str]) -> List[str]: ...
    @property
    def name(self) -> str: ...

The adapter is the seam between the harness and the model. This build ships ToyAdapter, a deterministic pattern matcher that returns the right answer for every prompt in the five fixture tasks. A real adapter calls the model and returns its output. The harness treats both the same.

The runner

run_task batches batch_size prompts at a time and dispatches to the metric function. run_leaderboard walks every task and averages. write_leaderboard emits JSON with a schema string, so a future format change does not silently break dashboards.

Mermaid diagram

Check: The Concept0/20
1. In this harness, what is a task spec?Define
2. In this harness, what is a metric?Define
3. In this harness, what is an adapter?Define
4. In this harness, what is a leaderboard?Define
5. What does the code exec metric do?Define
6. What does the adapter abstraction buy you in this harness?Explain
7. Why must every example in one task file share the same metric?Explain
8. The runner calls every metric with `(prediction, targets, extras)`, yet most metrics ignore extras. How does that work?Explain
9. Why does code_exec run the prediction with stripped builtins instead of normal Python?Explain
10. Why does the leaderboard JSON carry a schema string such as leaderboard.v1?Explain
11. A code_exec prediction begins with `import os` and then defines a correct `f`. What score does the metric give it?Apply
12. A summary example has two reference targets. How does rouge_l turn them into one score?Apply
13. The prediction is `the river flows east` and the only target is `the river bends slowly`. What does rouge_l return?Apply
14. A multiple_choice example has target `B`, and the model replies `The answer is B`. What score results?Apply
15. The prediction is `the answer is 4` and the target is `4`. What do exact_match and substring_contains score?Apply
16. A code_exec example has `targets: ["ok"]` and io_pairs `[[1, 2], [3, 6]]`. The model returns `def f(x): return x + 1`. What score does it get?Apply
17. One task has 5 examples and scores 0.2. Another has 100 examples and scores 0.8. What overall_score does run_leaderboard report?Apply
18. A model answers `100 / 4` with `25` and scores 0.0 on an exact_match example whose target is `25.0`. What explains this?Analyze
19. A model writes a correct doubling function but names it `solve` instead of `f`. The code_exec example scores 0.0. Why?Analyze
20. You want to score translation quality with a BLEU-like overlap score. What is the smallest change that fits the harness design?Design

Build It

code/main.py is the runnable artifact. It is built in five steps.

Step 1: seed fixture tasks

seed_fixture_tasks(target_dir) writes the five .jsonl files. The first run of main.py seeds them when the directory is empty.

Step 2: load tasks

load_all_tasks(task_dir) reads every .jsonl and returns a dict from task name to a list of Example records. Comment lines starting with # and blank lines are skipped so contributors can annotate the files.

Step 3: implement metrics

Each metric is a small function with a unit test. The test suite includes 13 cases covering normalization, partial overlap, code execution, and unsafe code rejection.

Step 4: write the runner

run_task iterates batches and produces a TaskResult with score, correct count, total count, and latency. run_leaderboard walks all tasks and produces a Leaderboard with the overall average.

Step 5: emit JSON

write_leaderboard serializes the board. The --include-per-example flag dumps the per-example records so you can diff predictions against the previous run when scores move.

Run it:

bash
python3 code/main.py

On first run the script seeds the fixtures, scores them with the toy adapter (which gets every fixture right), and writes outputs/leaderboard.json. The overall score is 1.0 with the toy adapter. The stub adapter test in test_main.py shows the same harness producing 0.0 when the adapter cannot answer.

Check: Build It0/7
1. Why does the build keep a toy adapter that scores 1.0 and a stub adapter that scores 0.0?Explain
2. The toy adapter gets `compute: 100 / 4` and the fixture target is `25.0`. What score does the example get?Apply
3. Five examples run with `batch_size=2`. How many times does the runner call the adapter's generate, and with how many prompts each time?Apply
4. A task file holds a `# note` line, a blank line and one valid example line. How many examples does the loader return?Apply
5. `write_leaderboard` is called for a finished run without the `--include-per-example` flag. What does each task entry in the JSON contain?Apply
6. After you swap in a real adapter, overall_score drops from 0.92 to 0.85. What is the best first step?Analyze
7. A new adapter returns 3 outputs for a batch of 4 prompts, and the run stops with an error. Where is the bug?Analyze

Use It

Plugging in a real model means writing an adapter, a class with a name and a generate method. The shape:

python
class HttpAdapter:
    name = "vendor.v1"
 
    def __init__(self, endpoint, api_key):
        self.endpoint = endpoint
        self.api_key = api_key
 
    def generate(self, prompts):
        out = []
        for prompt in prompts:
            response = http_post(self.endpoint, prompt, self.api_key)
            out.append(response["text"])
        return out

Swap ToyAdapter for HttpAdapter at the top of main(). The harness, the tasks, the metrics, and the leaderboard stay the same.

Three habits to build in when you ship the harness in a real project:

  • Pin the task files. The leaderboard.json this build writes carries neither task hashes nor copies of the JSONLs, so add one of them (Exercise 5 does the hash). Otherwise the score moves when the task file does, and you cannot tell which.
  • Diff predictions, not just scores. The --include-per-example flag shows you what the model said the day the score dropped.
  • Cap the batch size. Real adapters have rate limits, and a small batch size keeps the harness compatible across vendors.
Check: Use It0/4
1. Why does the lesson tell you to cap the batch size when you run the harness against a real adapter?Explain
2. Two leaderboard.json files from different days differ, and the task JSONLs were edited in between. Why can't you attribute the change?Analyze
3. Your team will compare leaderboard files months apart, while task files and checkpoints may change. Which addition lets a reader trust the comparison?Design
4. You must score a hosted model that rate-limits requests, and the vendor may change next quarter. Which design fits the harness?Design

Ship It

outputs/skill-lm-eval-harness.md carries the recipe: JSONL task spec, five metrics, swappable adapter, batched runner, and a leaderboard JSON with a schema string. The task files in outputs/tasks/ are the fixtures. Copy them into a real project as starters.

Exercises

  1. Add a sixth task with a custom metric you write from scratch (BLEU-like overlap, BLEURT-like reference scoring, anything with a clear contract).
  2. Extend code_exec to capture stdout and accept a list of expected stdouts as targets.
  3. Add a leaderboard diff command: given two leaderboard.json files, print which tasks moved and by how much.
  4. Cap latency per example. Wrap the adapter call in a timeout; surface a separate timeouts column in the leaderboard.
  5. Pin task content with a sha256 in the leaderboard so a future reader can verify they scored the same tasks.

Key Terms

TermWhat people sayWhat it actually means
Task spec"The eval format"JSONL file with prompt, targets, metric, optional extras per example
Metric"How you score"Function from a prediction and its targets (plus extras, for code_exec) to a float in [0, 1]
Adapter"The model client"Object with a generate(prompts) -> list[str] method; the only model-specific code
Leaderboard"The scoreboard"JSON with per-task scores, total counts, latency, and an overall average
Code exec metric"Run it and check"Execute the prediction in a restricted namespace, compare against input-output pairs
Author's Note

My bet is that the adapter deserves the most scrutiny when you wire this into a real project. It is where model-specific code lives, so it is the first place I'd look when a number surprises me. I'd keep the toy adapter in the repo as a control, since it scores 1.0 on the fixtures and a stub that cannot answer scores 0.0. If a real run lands somewhere strange, I'd rerun both controls before reading anything into the number. I'd also turn on --include-per-example from the first run, so I have the predictions from before the score moved.

Further Reading

  • The original lm-evaluation-harness for the production reference, much larger but the same shape.
  • HuggingFace's lighteval for an alternative implementation of the same contract.
  • Phase 19 lesson 46 covers the gradient accumulation patterns used in the training stack the harness scores.
  • Phase 19 lesson 47 covers the checkpoint format you score against; pin the checkpoint hash in the leaderboard.
  • Phase 19 lesson 48 covers the distributed training stack that produced the model under test.

Code

Ships With