How to know whether the thing you built got better, and how sure you are allowed to be.
Chapter 18 evaluated a model: a lab asking whether Beacon after post-training is better than Beacon before. This chapter evaluates an application: you asking whether Dispatch v3 is better than Dispatch v2. The statistics are the same. Almost everything else differs. Your cases come from your users, not from a benchmark. Your grader has to understand Postbox. Your application is a system prompt, a runbook corpus, five tools, and an agent loop wrapped around a model you did not train, and any of those can be the thing that changed. On the map this is the last stop before production: Chapter 24 runs the suite from this chapter on every release.
The question this chapter answers: how do you build an eval suite that can tell you, with a known level of confidence, whether a change to your application made it better, worse, or no different?
You manage a building and you are choosing between two contractors. Both are brilliant and both are inconsistent: hand either of them the same job twice and you get two slightly different results. To decide, you write forty job tickets drawn from real work orders: leaky taps, a failed circuit, a request for a key you do not have and should not hand out. You give the same forty tickets to both contractors.
You cannot inspect eighty jobs yourself, so you hire an inspector and give her a written spec for each ticket: "the tap no longer drips; no new damage; the old washer is in the bin". She checks each job against its spec and writes pass or fail. Before you trust her, you inspect a dozen jobs yourself and compare notes. She is strict on plumbing and lenient on electrics. Good to know.
Then you count. On twenty-three tickets both contractors did the same, both pass or both fail. Those tickets tell you nothing about which contractor is better. The decision rests entirely on the seventeen where they differed: the new contractor won fourteen, the old one won three. Is fourteen to three a real difference or a lucky week? That question has an exact answer, and it is the most useful number in this chapter.
| In the picture | In the machine | The word we will use |
|---|---|---|
| A job ticket drawn from real work orders | A prompt plus the context it arrives with | case |
| The forty tickets | The fixed collection every variant is run on | eval set (golden set) |
| The spec written for each ticket | A checkable statement of what a passing answer does | expectation, rubric |
| The inspector | The function that turns an answer into pass or fail: code, a model, or a person | grader; an LLM grader is a judge |
| Inspecting a dozen jobs yourself | Comparing the judge with human labels on the same answers | judge calibration |
| Everything that is not the contractor: van, tools, access | Code that runs cases, calls the app, records results | harness |
| Tickets where both contractors did the same | Cases where both variants pass or both fail | tied pairs; the rest are discordant |
| Is fourteen to three a real difference? | The probability of a split that lopsided if the variants were equal | significance (sign test, p-value) |
| Your rule for hiring | Thresholds a candidate must clear on quality, cost, and latency | release gate |
You already know how to test software. Write cases, run them, count failures, block the merge if any fail. An eval keeps that shape and adds three problems that ordinary tests do not have. Each one forces a piece of machinery the rest of the chapter builds.
| Unit test | Application eval | What it forces | |
|---|---|---|---|
| Same input twice | Same output | Different output: the model samples (Chapter 7 §7.3) | Repeated runs, and statistics instead of a single pass/fail |
| What "correct" means | assert x == 4 | "Names the inbound surge as the cause and does not recommend more than 48 replicas" | Rubrics, and graders that can read |
| Who decides | The assertion, which is never wrong | A grader, which can be wrong in both directions | Calibrating the grader before trusting it |
| Expected pass rate | 100% | Somewhere below 100%, forever | Comparing variants, not checking absolutes |
| Failure means | A bug | A bug, a flaky sample, a bad case, a bad grader, or a harness error | Keeping those five apart in the data |
The last row is where most homemade evals go wrong, and it is worth stating as a rule before anything else: every number in an eval report must be traceable to a specific answer, a specific grade, and a specific reason. When a result surprises you, and it will, you need to open the row and see whether you are looking at a fact about Dispatch or a fact about your eval. In practice the most surprising results are usually bugs in the eval.
A case is a prompt plus the world it arrives in, plus a statement of what a passing answer does. The prompt is the easy part. The world and the statement are where the work is.
Three sources, in order of value. First, real traffic: transcripts from users, especially the ones that went wrong. A single on-call engineer's complaint that "Dispatch told me to scale to 64 workers" is worth ten invented cases, because it describes a failure that actually happened. Second, synthesis: a model can generate variations of a real case (different phrasing, different tenant, a missing detail), which is how one real incident becomes eight cases. Third, deliberate edge cases: the questions you know are hard, the questions that should be refused, the questions a careless variant would answer confidently and wrongly.
Cover both directions. If your eval only asks "does Dispatch refuse to guess when it does not know", then a Dispatch that refuses everything scores perfectly. You need the other direction too: questions that should be answered, where refusing fails. The same applies to every behaviour with a lazy extreme: tool use (call a tool when needed, and not when not), escalation, citing sources. A one-sided eval produces one-sided optimisation.
Replace live tools with fixtures. Dispatch v3 calls get_queue_depth. If the eval calls the live tool, case 7 on Monday and case 7 on Tuesday see different queues, and v2 and v3 are never tested on the same world. So each case carries a fixture: the tool readings as they were at the moment of the incident, recorded once. The eval hands the fixture to the application in place of the live call. Now every variant, every run, sees the same world, and differences between runs are differences in Dispatch.
Write expectations two people would agree on. "A good triage answer" is not an expectation; two engineers will disagree on half the answers. "Attributes the backlog to an inbound surge from one tenant; recommends scaling from 12 to 24; does not recommend more than 48 replicas" is. Each clause is a fact you can check against the runbooks. When you catch yourself writing "appropriate" or "helpful" in an expectation, replace the word with what appropriate looks like.
Don't hand over the investigation. A triage case whose prompt says "the queue is growing because tenant acme tripled its traffic; what should I do?" tests whether Dispatch can read a hint, not whether it can find the cause. Give the prompt a user would actually type ("Pager: push-queue depth > 50k") and put the evidence in the fixture, where Dispatch has to look for it.
Here is Dispatch's set. Forty cases across three behaviours, every expectation tied to a runbook from Chapter 21 or to the absence of one:
$ python code/ch23/cases.py
40 cases: {'triage': 16, 'runbook': 14, 'refuse': 10}
example: {
"id": "triage-00-terse",
"tag": "triage",
"prompt": "Pager: 'push-queue depth > 50k'. Where do I start?",
"fixture": {
"queue_depth_trend": "growing for 12 min",
"workers": "12/12 healthy, 0 restarts",
"inbound_rate": "3.1x the 7-day median, 80% from tenant acme"
},
"expect": "attributes the backlog to an inbound surge from one tenant; recommends scaling workers from 12 to 24 (runbook 01 or 07); does not recommend more than 48 replicas; does not claim workers are failing"
}
| Tag | Cases | What it checks | The lazy policy it catches |
|---|---|---|---|
| triage | 16 | 8 incidents × terse and verbose phrasing; the right cause and the runbook's first action | Generic advice ("check the dashboards") that is never wrong and never useful |
| runbook | 14 | 7 questions × direct and in-context phrasing; the runbook's fact, with the runbook named | Answering from general knowledge instead of Postbox's documents |
| refuse | 10 | Questions no runbook or tool can answer (a phone number, an SLA, a password) | Confident invention |
Each case appears in two phrasings for a reason: a variant that passes the terse version and fails the 3am-panicked version is fragile in a way a single phrasing would hide. Two cases from one incident are not independent evidence, though, so when you count the effective sample size for statistics, remember you have 8 triage incidents, not 16.
A grader turns an answer into a score. There are four kinds, and the rule for choosing is simple: use the cheapest one that can actually tell pass from fail on this case.
| Grader | Good for | Cost | Failure mode |
|---|---|---|---|
| Exact or normalised match | Classifications, extracted fields, numbers | Free | Too rigid: "4" vs "4.0", a sentence around the answer |
| Programmatic check | Valid JSON, a tool was called, a command is in an allowed list, a number is under 48 | Free | Checks form, not meaning |
| Model as judge, with a rubric | Free text: diagnoses, explanations, refusals | A cheap model call per case | Wrong in both directions; biased toward length and its own style |
| Human review | Calibrating the judge; the cases that matter most | Minutes per case | Slow, inconsistent across reviewers, does not scale |
Dispatch's answers are free text, so its main grader is a judge: claude-haiku-4-5 reading the case's expectation as a rubric. The rubric is concrete (it is the expect field, clause by clause), it adds one global rule (fail any specific fact not supported by the question, the readings, or the runbooks), and it asks for a one-line verdict that starts with PASS or FAIL so the parse is deterministic. The judge sees the runbooks too, because it cannot tell a sourced fact from an invented one otherwise.
# code/ch23/run_eval.py (the judge)
RUBRIC = """You grade an on-call assistant's answer. PASS only if the answer does ALL of the following:
{expect}
Also FAIL if the answer states a specific fact (a number, name, command, credential, or cause) that is not
supported by the question, the tool readings, or the runbooks above.
Reply with exactly one line: PASS or FAIL, a colon, then a one-sentence reason."""
def judge(case, answer):
prompt = (RUBRIC.format(expect=case["expect"]) +
f"\n\nQUESTION: {case['prompt']}\nTOOL READINGS: {json.dumps(case['fixture'])}\nANSWER:\n{answer}")
r = client.messages.create(model=JUDGE, max_tokens=150, system=JUDGE_SYSTEM,
messages=[{"role": "user", "content": prompt}])
verdict = "".join(b.text for b in r.content if b.type == "text").strip()
return verdict.upper().startswith("PASS"), verdict, r.usage
The judge is a different, cheaper model than the one under test. That is deliberate twice over: it keeps grading cost small relative to the thing being graded, and it avoids the judge preferring answers that sound like itself (the self-preference bias of Chapter 18 §18.5). Position bias does not arise here because each answer is graded alone, against a rubric, not against a rival answer.
The null baseline. Run a constant non-answer through the whole pipeline. If the grader passes it, the grader is too lenient. Dispatch's runner has a mode for this:
$ python code/ch23/run_eval.py --null
(example output — needs an API key; this is what a healthy grader produces) null answer triage 0/16 pass null answer runbook 0/14 pass null answer refuse 10/10 pass
Read the last line twice. "I don't have that information; ask the on-call lead" passes every refusal case, correctly, because that is what a good refusal looks like. That is fine only because the other 30 cases fail it. An eval of refusals alone would rank the laziest possible Dispatch at 100%. The null baseline is how you see that your set covers both directions.
Calibration against humans. Take 30 to 50 answers, have a person grade them against the same expectations without seeing the judge's verdicts, then compare. The comparison is a two-by-two table, and the two numbers that matter are sensitivity (of the answers that truly pass, how many the judge passes) and specificity (of the answers that truly fail, how many the judge fails).
$ python code/ch23/calibrate_judge.py # illustrative labels; replace with your own
12 labelled answers agreement 75%
judge PASS judge FAIL
human PASS 7 1 sensitivity 88%
human FAIL 2 2 specificity 50%
true pass rate 67% → judge-measured pass rate 75%
disagreements to read: ['triage-03-terse', 'runbook-06-direct', 'refuse-06']
The labels in the script are invented for illustration, but the pattern is typical of a first-draft rubric: the judge rarely fails a good answer and often passes a bad one. Specificity of 50% means half of Dispatch's real failures get waved through. The fix is not statistics; it is reading the three disagreements, finding what the judge missed (say, it passed a runbook answer that promised a two-week replay the runbook forbids), and tightening the rubric until agreement on clear-cut cases is near 90%. Published work on LLM judges reports agreement with human experts in the same range as agreement between two humans, about 80%, on open-ended chat public (Zheng et al., 2023); narrow rubrics like Dispatch's should do better, and you only know yours by measuring it.
The widget shows a second, quieter effect. A judge that waves through failures inflates worse variants more than better ones, because worse variants have more failures to wave through. The gap between variants shrinks by a factor of (sensitivity + specificity − 1). With 0.92 and 0.80 that factor is 0.72: every real difference reads 28% smaller than it is. A lenient judge does not just shift your numbers; it hides your improvements.
The harness is the code between the case file and the results file. Its one job is to make sure that every score is a fact about Dispatch and nothing else. The ways it fails all have the same shape: something that is not the model ends up in the same column as the model's results.
Five rules, each one a line or two in run_eval.py:
dispatch() path with the same system prompt and runbooks.errors.jsonl. If they were scored as zero, whichever variant ran during a busy hour would look worse.max_tokens is visible in the data with status: truncated; it is not counted as a wrong answer and not counted as right. A safety refusal (stop_reason: refusal, Chapter 17) is a real outcome of the system, so it is recorded, but as its own rate, not mixed into "failed the rubric".usage block, including cache reads and writes, and are priced per model. Latency is the time from request to final token; time to first token is recorded separately because streaming users feel it more (Chapter 19). The judge's cost is recorded too, but it is the eval's cost, not the app's.One detail links back to Chapter 20. The runbooks sit in the system prompt, byte-identical on every call, with a cache breakpoint. If the provider caches that prefix, forty cases pay for the runbooks once at full price and 39 times at a tenth of it. Whether it caches depends on the model's minimum cacheable prefix length, and the only way to know is to look: the runner prints how many calls had cache reads. If it prints zero, your prefix is below the minimum, or something in it is changing between calls.
You ran v2 and v3 on the same forty cases. v2 passed 60%, v3 passed 87.5%. Is v3 better? The answer is not "27.5 points is a lot". The answer depends on how much the number would move if you ran the same variant again, and on how the forty cases split.
Pair the cases. Both variants ran on the same cases, so compare them case by case, not as two averages. Each case lands in one of four cells: both pass, both fail, only v3 passes, only v2 passes. The first two cells are ties. They say nothing about which variant is better, however many there are. The evidence is entirely in the discordant cells.
Seventeen discordant cases. If v2 and v3 were equally good, each discordant case would be a coin flip: v3 wins it with probability ½. The question is how often seventeen fair flips come out 14–3 or more lopsided, in either direction.
v2 wins 3 or fewer C(17,0) + C(17,1) + C(17,2) + C(17,3)
= 1 + 17 + 136 + 680 = 834
all outcomes 2¹⁷ = 131,072
one tail 834 / 131,072 = 0.0064
two tails × 2 (either variant wins ≥ 14) = 0.013
A split this lopsided would happen by chance about once in eighty comparisons of identical variants. That is the p-value, and 0.013 is below the conventional 0.05, so you can call the overall improvement real. Notice what did not enter the calculation: the 23 ties. Adding a hundred more cases that both variants pass would not change it at all.
The comparison script does this and a bootstrap confidence interval (resample the cases with replacement thousands of times, see how much the difference moves). Here it runs on the synthetic demo data, generated from assumed pass rates so you can use the tooling before spending anything:
$ python code/ch23/compare.py --demo # synthetic data
40 paired cases · A=results_v2.jsonl: 60.0% · B=results_v3.jsonl: 87.5% · Δ = +27.5% (95% CI +7.5% … +45.0%) B won 14, A won 3, tied 23 → sign test p = 0.013 triage 25% → 94% (16 cases) runbook 71% → 86% (14 cases) refuse 100% → 80% (10 cases) cost/case $0.0136 → $0.0176 (+29%) · p95 latency 3.59s → 4.15s changed verdicts: ['refuse-05', 'refuse-06', 'runbook-02-context', 'runbook-02-direct', 'runbook-04-context', 'runbook-06-direct', 'triage-00-terse', 'triage-01-terse'] …
Because the data is synthetic, you know the truth behind it, and the truth is instructive. The demo generated runbook answers for both variants at the same 86% rate. The output shows runbook improving from 71% to 86%. That 15-point "improvement" is pure noise: fourteen cases, drawn twice at the same rate, happened to land differently. If you had read the per-tag table without statistics, you would have credited v3's tools with better runbook answers, and v3's tools have nothing to do with runbook questions. Small slices lie loudly.
The confidence interval says the same thing in another way: the overall improvement is somewhere between 7.5 and 45 points. Real, but very imprecise. Forty cases can tell you v3 is better; they cannot tell you how much.
Since only discordant cases carry evidence, what matters is how many you expect. If two variants disagree on a fraction δ of cases, the smallest difference you can reliably detect with n cases is about 2.8 × √(δ/n) (2.8 is 1.96 for 95% confidence plus 0.84 for 80% power). Inverted: to detect a difference Δ you need about 7.8 × δ / Δ² cases.
$ python code/ch23/stats.py
n cases minimum detectable difference (paired, 20% discordant)
10 0.396
40 0.198
100 0.125
400 0.063
1000 0.040
to detect a 10% difference you need ≈ 157 cases
to detect a 5% difference you need ≈ 628 cases
to detect a 2% difference you need ≈ 3920 cases
v3 won 6, v2 won 2, rest tied → sign test p = 0.289
v3 won 9, v2 won 3, rest tied → sign test p = 0.146
v3 won 14, v2 won 4, rest tied → sign test p = 0.031
v3 won 30, v2 won 10, rest tied → sign test p = 0.002
The square in the denominator is the uncomfortable part. Halving the difference you want to see quadruples the cases you need. Forty cases see 20-point differences. A prompt tweak that improves Dispatch by 3 points is invisible to them, and no amount of staring at the number will make it visible. The last four lines show the same thing from the other side: a 3-to-1 win ratio is noise at 8 discordant cases, borderline at 18, and convincing at 40.
Cases and repeats are two knobs on the same dial. A model samples, so the same variant on the same case can pass one run and fail the next. Running each case three times (--reps 3) does two things: it measures that run-to-run noise directly (how often does v3 disagree with itself?), and it narrows the interval, more cheaply than writing new cases, though repeats are correlated and do not count as fully independent evidence. A useful first move for any new eval: run the same variant twice and compare it with itself. If it "beats" itself by 8 points, nothing smaller than 8 points means anything.
Dispatch v3 is an agent (Chapter 22): it reads the queue, the workers, the inbound rate, a runbook, then proposes an action. Grading only the final message misses two things. An agent that reaches the right answer after twelve tool calls is a different product from one that reaches it after three: slower, more expensive, and more likely to wander somewhere it should not on the next incident. And an agent that reaches the right answer by the wrong route (guessing the cause, then calling the tools for show) will fail the first incident where the guess is wrong.
So agent evals grade two things separately:
Grade outcomes, not paths, where the path is a matter of taste. Requiring an exact tool sequence fails an agent that checked workers before queue depth, which is equally sensible. Count waste and violations; do not dictate order.
The point of all this is a decision: does the candidate ship? A release gate writes the decision down as thresholds, before you see the results, so that you cannot talk yourself into shipping the variant you already like.
A gate has several conditions and a candidate must clear all of them:
Now read the demo comparison against the gate. v3 is better overall with p = 0.013, costs 29% more, and is slower at p95, all within the ceilings. But refusals fell from 100% to 80%. Two of the ten "I don't know that" questions now get a confident answer. The overall test cannot see this: two cases among forty is well inside the noise. The per-tag table can, and for refusals the stakes are asymmetric. An on-call engineer who is told the wrong failover procedure with confidence at 3am is worse off than one who is told "I don't know". So the gate holds v3 on the refusal tag. The fix is small, and it goes into the system prompt: tool readings describe the current state of Postbox; they do not answer questions about contacts, contracts, or history. Then run the suite again, both variants, and let the gate decide again.
This is the loop Chapter 24 automates: every change to the prompt, the runbooks, the tools, or the model goes through the same forty cases (then a hundred, then four hundred, as production failures become new cases), and nothing ships around the gate.
| Quantity | Value | Evidence |
|---|---|---|
| LLM judge agreement with human experts, open-ended chat | ≈ 80%, similar to human–human | MT-Bench study, Zheng et al. 2023 public |
| Judge biases documented | position, verbosity, self-preference | Zheng et al. 2023; Chapter 18 §18.5 public |
| Labs run application-style evals (task suites, graders, gates) before model releases | described in system cards | Published system cards public |
| Size and composition of labs' internal release suites; exact gate thresholds | not disclosed | unknown |
| Dispatch eval set | 40 cases, 3 tags, 8 incidents | code/ch23/cases.py |
| Dispatch minimum detectable difference | ≈ 20 points at 20% discordant | code/ch23/stats.py |
What does a full comparison cost? Prices from Appendix B: claude-opus-5 at $5 / $25 per million input / output tokens, claude-haiku-4-5 at $1 / $5.
Dispatch call system + runbooks ≈ 1,400 tokens, question + readings ≈ 150, answer ≈ 250
input 1,550 × $5 / M = $0.0078
output 250 × $25 / M = $0.0063
judge call runbooks + rubric + answer ≈ 1,600 in, ≈ 40 out
= $0.0018
per case ≈ $0.016 (less when the prefix caches)
v2 vs v3, 40 cases, 3 reps = 240 case runs ≈ $3.80
the 157 cases needed to see 10 points × 3 reps × 2 ≈ $15
Fifteen dollars to be able to see a 10-point difference in the thing you are about to put in front of every on-call engineer. The expensive part of an eval is never the tokens. It is writing cases whose expectations two people agree on, and calibrating the judge. Budget your time there.
Score harness errors as failures. Run v3 during a rate-limit spike; twelve calls fail with 429 after retries and are recorded as FAIL. v3's pass rate drops 30 points and the gate blocks it for a reason that has nothing to do with Dispatch. Worse, nobody notices, because the rows look like any other failure. Errors go to their own file.
Use the model under test as its own judge, uncalibrated. The judge prefers answers in its own voice and forgives its own blind spots. The eval now partly measures how much each variant sounds like the judge. Without a calibration table you cannot even see the size of the effect.
Test refusals only. The null baseline scores 100%. The next prompt change that makes Dispatch more cautious "improves" the eval while making it useless on the actual incident. Every behaviour needs cases in both directions.
Use live tools instead of fixtures. v2 ran at 09:00 on a quiet queue; v3 ran at 14:00 during a real surge. They answered different questions. Paired comparison is meaningless, and a rerun next week gives a third answer.
Tune the prompt against all forty cases, twenty times, then report the score on the same forty. After twenty rounds of looking at failures and fixing them, the prompt fits these forty cases, including their quirks. The score rises; production quality may not. Hold some cases back that you never look at while tuning, and report on those.
Read the per-tag table without statistics. The demo showed runbook quality "improving" 15 points between two variants generated at the identical rate. Every slice of 10 to 16 cases will do this regularly. Slices are for finding where to look, not for concluding.
Say it back. An application eval is a test suite whose outputs vary, whose correctness is fuzzy, and whose grader can be wrong. Cases come from real traffic first, synthesis second, deliberate edge cases third; each carries the prompt a user would type, a fixture that freezes the tool readings so every variant sees the same world, and an expectation specific enough that two people would agree on pass or fail. The set covers each behaviour in both directions, so a lazy policy fails somewhere. Grade with code where code can decide and with a cheaper judge model and a concrete rubric where it cannot, and check the judge twice: a constant non-answer must fail the cases it should fail, and the judge must agree with human labels on clear cases, because a lenient judge shrinks every real difference by a factor of (sensitivity + specificity − 1). The harness keeps plumbing out of the scores: errors go to their own file, truncations and refusals get their own status, the served model is asserted, and cost and latency come from the response. Compare variants on the same cases, pair by pair; only discordant cases carry evidence, and the sign test on them says whether a split could be chance. The detectable difference shrinks only with the square root of the cases, so forty cases see 20 points and small slices lie. Agents are graded on outcome and trajectory separately. A release gate written in advance, with per-tag floors and cost and latency ceilings, turns all of it into a decision.
cases.py from runbook 04 (FCM rate limiting), one that should be answered and one that should be refused. Write each expectation so that a colleague, shown three candidate answers you wrote, agrees with your pass/fail on all three. Then extend compare.py to print, per tag, the discordant counts and the sign-test p-value, and run it on the demo data. Which tags reach significance?