Capstone Lesson 27: Eval Harness with Fixture Tasks
A coding agent is only as good as the tasks you measure it against. This lesson builds an eval harness: it runs a folder of fixture tasks through a candidate agent, scores each run pass or fail with a deterministic verifier, and reports pass@1, pass@k, mean latency, and mean cost. With that report in hand, you can tell a regression from a refactor.
Learning Objectives
- Define a fixture task as a triple of goal, setup, and verifier.
- Score multiple sample runs per task and compute pass@1 and pass@k.
- Aggregate latency into mean and p95, and cost into a per-task mean and a total.
- Wire deterministic verifiers (file diff, exit code, regex match) into reusable functions.
- Emit a structured JSON report a regression-tracking script can ingest.
The Problem
An eval harness is a program that runs a fixed set of tasks through an agent and scores every result with a deterministic check. Without one, agent benchmarks tend to fail in three ways.
The first is unverified pass. The agent says it fixed the bug, a human glances at the diff, the suite is marked green, and three weeks later the regression test surfaces the same bug. The agent reasoned plausibly and fixed nothing.
The second is undetected regression. A change to the prompt template makes the agent 4% better on the loud task and 14% worse on the quiet one. With no goldset and no per-task score, the regression rides into main and surfaces only when a customer complains.
The third is per-task drift. You ran the eval on Monday with 100 tasks and on Friday with 95, because somebody renamed five fixtures. The pass rate looks like a 5% improvement. It isn't.
The harness turns each of these into a fact. It runs every fixture, every time, in a reproducible order, against a verifier that returns true or false on a deterministic check.
The Concept
A FixtureTask is a directory. Its task.json declares an id, a goal (the prompt fed to the agent), and a verifier block that names a function in the harness's verifier registry and supplies its args. The setup is the buggy/ directory next to it, whose files get copied into the scratch dir. Tasks that use file_equals also have a sibling expected/ directory.
Three verifier shapes cover most useful tasks.
file_equals compares a named file against expected content after the agent runs. Use it for "fix this bug in this exact way" tasks.
regex_match matches the named file's contents against a regex. Use it for "the function must exist and return X" tasks, where many solutions are acceptable.
shell_exit_zero runs a command in the scratch dir with a timeout (timeout_seconds, default 10) and passes the task only if the command exits zero. The demo uses a plain subprocess.run; production wiring goes through the sandbox from lesson 26. Use it for "the tests must pass" tasks.
The harness runs each task k times. Pass@k is 1 - (1 - p)^k, where p is the empirical pass rate. The harness also reports raw counts so you can spot variance. Latency is wall-clock per sample, reported as a mean and a p95. Cost is whatever the candidate reports in cost_units (tokens, USD, or any unit you pick), reported as mean_cost per task and a total_cost across the run.
Architecture
The candidate is a callable: Callable[[FixtureTask, str], SampleResult]. The harness creates the scratch directory with tempfile.mkdtemp() and passes its path as a plain string. How the candidate works is none of the harness's business. It could be a deterministic patch applier (handy for harness self-tests), a real LLM agent, or a fuzzer. The contract is the SampleResult.
What you will build
main.py ships:
FixtureTaskdataclass.SampleResultdataclass: task_id, sample_index, latency_ms, cost_units, notes.TaskReport,EvalReportdataclasses withto_dict().VERIFIER_REGISTRY, a plain dict mapping verifier name to function. Built-in verifiers: file_equals, regex_match, shell_exit_zero.EvalHarnessclass. Runs a directory of tasks against a candidate. Returns EvalReport.- Five fixture tasks bundled in
tasks/:- off-by-one in
fizzbuzz - missing return in
factorial - typo in error message
- empty function body
- off-by-one in linked-list traversal
- off-by-one in
- A deterministic reference candidate (
apply_known_fixes) the harness uses to demonstrate a clean pass@1 of 1.0. - A demo that runs the reference candidate at k=1 and a noop candidate at k=3, and prints the results.
The fixture tasks are bundled as JSON files in tasks/, with starting files in tasks/<id>/buggy/ and the fixed versions in tasks/<id>/expected/. The harness copies buggy into a scratch dir, hands it to the candidate, and verifies against expected.
Why pass@k and not just pass@1
Pass@k is the probability of at least one pass in k independent samples. Real LLM agents are stochastic, so a single sample hides what the agent can do. A pass@1 of 0.6 looks like a failure. A pass@5 of 0.95 says the agent usually has the right answer and picks wrong on early samples. That points at sampling and ranking as the fix, not always more training. Pass@k makes the difference visible.
The harness reports pass@1 alongside pass@k because pass@k can paper over a real failure. If the model gets the right answer once in twenty tries, you do not have a useful agent.
How this composes with the rest of Track A
Lesson 25 produced the gate chain. Lesson 26 produced the sandbox, which production wiring of shell_exit_zero should go through. Lesson 28 wraps each harness run in an OTel trace. Lesson 29 runs the end-to-end demo against one of the bundled fixtures and asserts pass@1 = 1.0 for the reference candidate.
I'd point the harness at the reference candidate before I pointed it at a real agent. The reference candidate applies known fixes, so a clean pass@1 of 1.0 is the expected result. If the harness can't produce that, I'd fix the harness before trusting any other run.
I'd also keep the raw pass counts in view next to pass@1 and pass@k, since the report gives you all three and a single rate can hide variance.
For verifiers, I'd reach for regex_match when many solutions are acceptable and save file_equals for tasks where I really do mean "fix it in this exact way."
Running it
cd phases/19-capstone-projects/27-eval-harness-fixture-tasks
python3 code/main.py
python3 -m pytest code/tests/ -vThe demo runs the reference candidate at k=1 and prints the EvalReport as JSON, including pass@1, mean latency, and a per-task breakdown. It then runs a noop candidate at k=3 and prints its pass@1 and pass@k. It exits nonzero if the reference candidate misses pass@1 of 1.0 or the noop candidate passes anything, and zero otherwise. The tests cover the verifier functions, the pass@k math, fixture loading, and the harness end-to-end against the bundled reference candidate.
Code
- main.py (python, 510 lines)
- tasks/task_001_fizzbuzz_offbyone/buggy/fizzbuzz.py (python, 11 lines)
- tasks/task_001_fizzbuzz_offbyone/expected/fizzbuzz.py (python, 11 lines)
- tasks/task_001_fizzbuzz_offbyone/task.json (json, 9 lines)
- tasks/task_002_factorial_missing_return/buggy/factorial.py (python, 5 lines)
- tasks/task_002_factorial_missing_return/expected/factorial.py (python, 6 lines)
- tasks/task_002_factorial_missing_return/task.json (json, 12 lines)
- tasks/task_003_error_message_typo/buggy/errors.py (python, 4 lines)
- tasks/task_003_error_message_typo/expected/errors.py (python, 4 lines)
- tasks/task_003_error_message_typo/task.json (json, 9 lines)
- tasks/task_004_empty_reverse/buggy/reverse.py (python, 3 lines)
- tasks/task_004_empty_reverse/expected/reverse.py (python, 3 lines)
- tasks/task_004_empty_reverse/task.json (json, 12 lines)
- tasks/task_005_linked_list_traversal/buggy/linked_list.py (python, 16 lines)
- tasks/task_005_linked_list_traversal/expected/linked_list.py (python, 16 lines)
- tasks/task_005_linked_list_traversal/task.json (json, 9 lines)
- tests/test_main.py (python, 224 lines)