An eval harness that fails the build
A dataset built from real traces, graders checked against human labels, a runner that scores a change, and a CI job that turns a pull request red when quality drops.
Evaluation has become the system design round. Asked how you would ship a change to an LLM feature, the expected answer is a dataset, a grader, a baseline and a threshold — and "we did not have time for evals" is the fastest way to lose a take-home that otherwise worked. It is also the only way to tell whether your own prompt edits help, which is why the people who build this once keep building it. This project produces the harness and, more usefully, the argument for every decision inside it: why this grader is code rather than a model, how the judge was validated, what the held-out set is for.
Have these behind you first
You can start without them and look things up as you go. It will take longer and you will understand less of what you typed.
Capture what the system did
You build: Tracing on one LLM feature, and a hundred real runs you have read and annotated with their first failure.
If a line here loses you, this is where it is taught
- Tracing & observability: Seeing what the system did
What has to be in a record for a failure to be reproducible later.
- Tracing & observability: Using traces to improve the system
Turning stored runs into decisions rather than into a log nobody opens.
- Evaluation: Error analysis: read traces before you write evals
Open coding: reading traces and naming failure modes before writing any grader.
Pick a feature that already exists and already disappoints someone. If you have no traffic, generate inputs across personas and scenarios, but replace them with real ones the moment you have any — invented cases test your imagination, not your system.
import functools
import json
import time
import uuid
def traced(span: str):
"""Append-only JSONL. A file is enough until it is not, and a file you can
grep beats a dashboard you have to be online to read."""
def decorate(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
run = {
"id": uuid.uuid4().hex[:12],
"span": span,
"ts": time.time(),
"prompt_version": PROMPT_VERSION, # which prompt produced this
"model": MODEL,
"input": {"args": args, "kwargs": kwargs},
}
started = time.perf_counter()
try:
run["output"] = fn(*args, **kwargs)
return run["output"]
except Exception as exc:
run["error"] = repr(exc)
raise
finally:
run["ms"] = int((time.perf_counter() - started) * 1000)
with open("traces.jsonl", "a") as f:
f.write(json.dumps(run, default=str) + "\n")
return wrapper
return decorateThen read a hundred of them and write one line each about the first thing that went wrong. Only the first: later errors are usually consequences, and counting them separately inflates problems that fix themselves. Group the lines into failure modes that someone could act on.
Failure mode Count Share
Quoted a number the source does not give 18 31%
Answered in the wrong language 9 16%
Ignored a constraint stated in the question 8 14%
Promised an action the product cannot do 7 12%
Refused something it could have answered 4 7%
--
46 failing of 100A trace without a prompt version cannot be replayed
The failure you find on Tuesday was produced by a prompt someone edited on Wednesday, and without the version in the record you cannot reproduce it, cannot tell whether it is already fixed, and cannot turn it into a case. Version the prompt file, put the version and the model id in every trace, and treat a record missing either as unusable.
Done when
Any run can be pulled by id with its input, prompt version, output, latency and cost, and you have a table of named failure modes with counts over a hundred traces.
Turn failures into cases
You build: A case file of 60 to 100 cases taken from real traces, each with an input and an expectation something can check.
If a line here loses you, this is where it is taught
- Evaluation: Building the dataset
How many cases, chosen how, and what makes a set representative rather than convenient.
- Data quality & labelling: Where ground truth comes from
Where the expected answer comes from, and who is allowed to decide it.
- Evaluation: Why evals exist
What this set is for, so it is not quietly turned into a benchmark to win.
One line per case, in a file in the repository, reviewed like code. Keep the trace it came from: when a case starts failing in a year, the original run is the only thing that explains what it was meant to catch.
{"id": "c_014",
"mode": "quoted-wrong-number",
"from_trace": "9f2c81ad4410",
"input": {"question": "How long do I have to cancel an annual plan?"},
"expect": {"must_contain": ["14 days"],
"must_not_contain": ["30 days"],
"cites": ["policy-annual-cancellation"]}}Weight the set by what actually happens. If a third of failures are wrong numbers, a third of the set should be about numbers. Include a handful of cases that currently pass, or a change that breaks everything will still look like a small drop.
Sixty real cases beat six hundred invented ones
Synthetic cases cluster around the failures you already thought of, so they pass, and the pass rate tells you nothing. Cases lifted from traces carry the ambiguity, typos, half-questions and missing context that real inputs have. Start from traces and only generate variations of failures you have actually seen.
Done when
The file loads and validates: every case has a unique id, a failure mode, and at least one expectation. The mix of modes in the file matches the counts you measured in stage one, within a few points.
Graders that need no model
You build: A grader registry where each case names the checks that apply to it, all of them plain Python.
If a line here loses you, this is where it is taught
- Evaluation: Grading: code first, judges second
The order to work in: code checks first, model-based grading only for what is left.
- Testing: Testing a system with a non-deterministic part
Finding the deterministic part of a non-deterministic system and pinning it down.
- Python: Testing
Structuring this as tests with fixtures, so it runs the way the rest of the repo runs.
GRADERS: dict[str, callable] = {}
def grader(name: str):
def register(fn):
GRADERS[name] = fn
return fn
return register
@grader("must_contain")
def must_contain(output: str, expected: list[str]) -> bool:
return all(s.lower() in output.lower() for s in expected)
@grader("must_not_contain")
def must_not_contain(output: str, forbidden: list[str]) -> bool:
return not any(s.lower() in output.lower() for s in forbidden)
@grader("cites")
def cites(output: str, expected: list[str]) -> bool:
return set(expected) <= set(CITE.findall(output))
@grader("valid_json")
def valid_json(output: str, _: object) -> bool:
try:
json.loads(output)
return True
except json.JSONDecodeError:
return False
def grade(case: dict, output: str) -> dict[str, bool]:
"""Every expectation on the case, by name. Per-check results, not one
boolean: 'failed' is not actionable, 'failed must_not_contain' is."""
return {name: GRADERS[name](output, arg) for name, arg in case["expect"].items()}Reach for a judge last, not first
Most of what you want to check is a string, a number, a schema or a tool call, and those are `in`, `==`, `json.loads` and a set comparison: free, instant, and identical on every run. Every check you hand to a model instead is a check you must now validate, pay for and rerun when it drifts. Write the code graders first and see how little is genuinely left.
Done when
Running the graders over the case file prints a pass rate, and running them twice over the same outputs prints exactly the same number.
A judge for what is left
You build: An LLM judge for the one or two failure modes code cannot check, returning a label from a fixed set and a short reason.
If a line here loses you, this is where it is taught
- LLM-as-judge: Grading with a model
Rubric design: what makes a judge prompt produce a label that moves with quality.
- Prompting: A prompt is a specification
Writing the failure rule precisely enough that you could apply it yourself.
- Structured output: Designing schemas the model fills well
Getting a parseable verdict back, so an unparseable one is an error rather than a zero.
The judge calls the model through the same ask helper the rest of the repository uses, so the grader and the system under test can be pointed at different models without a second client.
One judge grades one property. The rubric says what fails, in the terms of your corpus, and the output is two fields. Ask for the reason after the label and you get a reason; ask for it before and you get a label that follows the reasoning, which is slower and usually better — pick one and keep it fixed.
JUDGE = """You are grading one customer support reply against the policy text
it was supposed to use.
Policy:
{policy}
Reply:
{reply}
The reply FAILS if it states any number, date, price or entitlement that the
policy does not state. Disagreements of wording, tone or length are not failures.
Otherwise it PASSES.
Reply with JSON only: {{"label": "PASS" or "FAIL", "reason": "<12 words"}}"""
def judge(policy: str, reply: str) -> dict:
raw = ask(JUDGE.format(policy=policy, reply=reply), temperature=0)
verdict = json.loads(raw) # a parse failure is a bug to see, not a FAIL
assert verdict["label"] in ("PASS", "FAIL")
return verdictAsk a yes or no question, not for a score
A judge asked to rate helpfulness from one to five returns four for nearly everything, and the number stops moving when the system changes. A binary label with a written rule for what fails gives a rate that actually moves, and disagreements about it are about the rule rather than about taste. If you need degrees, use several binary judges.
Done when
The judge returns a value from your label set on every case, never free text, and running it twice on the same case agrees with itself on at least 95% of cases. Measure that, do not assume it.
Check the judge before trusting it
You build: Human labels on a sample, and an agreement number between you and the judge, broken into the two kinds of disagreement.
If a line here loses you, this is where it is taught
- LLM-as-judge: Running judges on live traffic
How judges drift and what has to be re-checked when the model or prompt changes.
- Model evaluation & metrics: Choosing the metric that matches the cost of being wrong
Why the two error types are counted separately and which one costs you more here.
- Probability & statistics: Probability, and reading an experiment honestly
What an agreement rate over 50 cases does and does not establish.
Label blind. Write your own PASS or FAIL for each sampled case with the judge's column hidden, then join them. Labelling after seeing the verdict measures how agreeable you are.
import collections
pairs = [(human[cid], machine[cid]) for cid in sample] # (mine, judge's)
counts = collections.Counter(pairs)
agree = sum(n for (h, m), n in counts.items() if h == m) / len(pairs)
print(f"agreement {agree:.0%} over {len(pairs)} cases")
print(f"false passes {counts[('FAIL', 'PASS')]} judge approved what you rejected")
print(f"false fails {counts[('PASS', 'FAIL')]} judge rejected what you accepted")
# Read every disagreement. Each one is either a bad judgement or a rubric that
# did not say what you meant, and the second is far more common.
for cid, (h, m) in zip(sample, pairs):
if h != m:
print(cid, h, "->", m, machine_reason[cid])An unvalidated judge is a confident random number
Until it has been compared with human labels you do not know whether it measures the property you named or the length of the answer. The expensive error is the false pass: the judge approving output you would have rejected, which makes a real regression invisible while the dashboard stays green. Count that cell on its own, and never report a single agreement percentage without it.
Done when
At least 50 cases labelled by hand before seeing the judge's verdict, plus a printed agreement rate and separate counts of false passes and false fails. If agreement missed your bar, the rubric was rewritten and the sample relabelled.
The runner, the split and the report
You build: One command that runs every case against a named configuration, scores it, and writes a report that can be diffed against a previous run.
If a line here loses you, this is where it is taught
- Evaluation: Is the difference real? Statistics for evals
Whether a move in the pass rate is a real difference or the size of your sample.
- Eval-driven development: Improving against a number
Using the report as the thing you iterate against, one change at a time.
- Overfitting & regularisation: Memorising instead of learning
Why the held-out set exists, and what a dev-only gain means when it does not appear there.
Split the cases once, by a hash of the case id, so the split never moves as cases are added and no configuration file can accidentally change it.
def split_of(case_id: str) -> str:
"""Deterministic and stable: adding cases does not reshuffle old ones."""
digest = int(hashlib.sha1(case_id.encode()).hexdigest(), 16)
return "dev" if digest % 10 < 7 else "heldout"
def run(cases: list[dict], config: dict) -> dict:
report = {}
for case in cases:
output = system_under_test(case["input"], config)
checks = grade(case, output)
report[case["id"]] = {"mode": case["mode"], "passed": all(checks.values()),
"checks": checks, "output": output}
return report
def compare(before: dict, after: dict) -> tuple[list[str], list[str]]:
"""Per-case changes, both directions. An unchanged pass rate can hide five
fixes and five new breakages, which is not the same as nothing happening."""
broke = [c for c in after if before.get(c, {}).get("passed") and not after[c]["passed"]]
fixed = [c for c in after if not before.get(c, {}).get("passed", True) and after[c]["passed"]]
return broke, fixedReport per failure mode, not only overall. A change that fixes wrong numbers and breaks language handling shows as a flat total and as two obvious rows.
The set you tune against stops being a measurement
You will read failing dev cases and edit the prompt until they pass, which is the point of a dev set and also how you overfit to forty examples. The held-out set is the check on that: run it rarely, never read its individual failures, and treat a dev improvement that does not appear there as noise. With sixty cases a pass rate has a margin of roughly ten points, so a two-point gain is not a gain.
Done when
`python -m eval run --config prompts/v8.yaml` prints a pass rate per failure mode and writes a per-case report. Passing it an older report prints exactly which cases broke and which were fixed.
Fail the pull request
You build: A CI job that runs the eval when prompts or system code change, compares against the baseline, and fails on a regression.
If a line here loses you, this is where it is taught
- Evaluation: Evals in the loop
Where the gate goes, what it blocks on, and what a sensible threshold looks like.
- Testing: Tests for prompts, tools and agent loops
Making a suite with a non-deterministic component stable enough that people trust it.
- Cost & latency engineering: Find out where the money goes
Knowing what a full run costs before it runs on every push, and caching what repeats.
name: evals
on:
pull_request:
paths: ["prompts/**", "src/answer/**", "evals/**"]
jobs:
eval:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install -r requirements.txt
# The dev set on every pull request; the full set nightly on main.
- run: python -m eval run --split dev --out report.json
env:
MODEL_API_KEY: ${{ secrets.MODEL_API_KEY }}
- run: python -m eval compare evals/baseline.json report.json --max-regressions 0
- uses: actions/upload-artifact@v4
if: always()
with:
name: eval-report
path: report.jsonGate on per-case regressions rather than on the aggregate. A threshold on the total lets a change break three cases and fix three others and pass silently, which is exactly the change you wanted to look at.
broke, fixed = compare(baseline, current)
print(f"fixed {len(fixed)}, broke {len(broke)}")
for cid in broke:
print(f" REGRESSION {cid} {current[cid]['mode']}")
print(f" {current[cid]['output'][:160]}")
sys.exit(1 if len(broke) > args.max_regressions else 0)A flaky eval gets switched off
The first time the job fails on a pull request that changed nothing, people stop believing it. The second time, somebody adds continue-on-error and the harness is dead. Pin temperature to zero, cache model responses by a hash of prompt and input so a rerun of the same commit is free, and if a case is still non-deterministic run it three times and take the majority — or delete it. A case that cannot decide is not measuring anything.
Done when
A pull request that makes the prompt worse gets a red check naming the cases that broke, and rerunning the same commit produces the same verdict and costs nothing the second time.
What you have at the end
A repository with traced runs, a case file drawn from them, code graders, a validated judge, a runner producing a diffable report, and a CI workflow that blocks a regression. You can state your judge's agreement with human labels, your dev and held-out pass rates, and what a two-point move on your set is actually worth — which is the level of answer the evaluation question is looking for.
The other projects