Part 4 · Chapter 23

Evaluating an application

How to know whether the thing you built got better, and how sure you are allowed to be.

Where we are

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?

Picture this

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.

Map it
In the pictureIn the machineThe word we will use
A job ticket drawn from real work ordersA prompt plus the context it arrives withcase
The forty ticketsThe fixed collection every variant is run oneval set (golden set)
The spec written for each ticketA checkable statement of what a passing answer doesexpectation, rubric
The inspectorThe function that turns an answer into pass or fail: code, a model, or a persongrader; an LLM grader is a judge
Inspecting a dozen jobs yourselfComparing the judge with human labels on the same answersjudge calibration
Everything that is not the contractor: van, tools, accessCode that runs cases, calls the app, records resultsharness
Tickets where both contractors did the sameCases where both variants pass or both failtied pairs; the rest are discordant
Is fourteen to three a real difference?The probability of a split that lopsided if the variants were equalsignificance (sign test, p-value)
Your rule for hiringThresholds a candidate must clear on quality, cost, and latencyrelease gate

23.1An eval is a test suite with three extra problems

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 testApplication evalWhat it forces
Same input twiceSame outputDifferent output: the model samples (Chapter 7 §7.3)Repeated runs, and statistics instead of a single pass/fail
What "correct" meansassert x == 4"Names the inbound surge as the cause and does not recommend more than 48 replicas"Rubrics, and graders that can read
Who decidesThe assertion, which is never wrongA grader, which can be wrong in both directionsCalibrating the grader before trusting it
Expected pass rate100%Somewhere below 100%, foreverComparing variants, not checking absolutes
Failure meansA bugA bug, a flaky sample, a bad case, a bad grader, or a harness errorKeeping 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.

Chapter 18 §18.7 covered measuring a behaviour change in a model. Everything statistical there carries over. What this chapter adds is the application layer: cases from your own traffic, graders that know your domain, fixtures that stand in for your tools, and a gate that decides releases.

23.2Where cases come from

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.

case triage-00-terse tagtriage promptPager: 'push-queue depth > 50k'. Where do I start? fixture (recorded tool readings) queue_depth_trend: growing for 12 min workers 12/12 healthy · inbound 3.1×, 80% acme expect (what the grader checks) inbound surge from one tenant · scale 12 → 24 · never > 48 · does not claim workers are failing v2 never sees the fixture: runbooks only. v3 receives it where its tools would answer. Same world for every variant and every run. Every clause is checkable against runbooks 01 and 07 of Chapter 21's corpus.
What is inside one case? A tag for slicing results, the prompt as a user would type it, a fixture that freezes the world, and an expectation written so two careful people would agree on pass or fail. The expectation names facts the runbooks contain, so the grader can check them.

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"
}
TagCasesWhat it checksThe lazy policy it catches
triage168 incidents × terse and verbose phrasing; the right cause and the runbook's first actionGeneric advice ("check the dashboards") that is never wrong and never useful
runbook147 questions × direct and in-context phrasing; the runbook's fact, with the runbook namedAnswering from general knowledge instead of Postbox's documents
refuse10Questions 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.

23.3Grading: code where you can, a judge where you must

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.

GraderGood forCostFailure mode
Exact or normalised matchClassifications, extracted fields, numbersFreeToo rigid: "4" vs "4.0", a sentence around the answer
Programmatic checkValid JSON, a tool was called, a command is in an allowed list, a number is under 48FreeChecks form, not meaning
Model as judge, with a rubricFree text: diagnoses, explanations, refusalsA cheap model call per caseWrong in both directions; biased toward length and its own style
Human reviewCalibrating the judge; the cases that matter mostMinutes per caseSlow, 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.

Two smoke tests before you trust any grader

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.

Static view of the widget. True pass rate 70%, judge sensitivity 0.92, specificity 0.80. The judge reports 70%: 64 points of real passes it caught plus 6 points of failures it waved through. Two variants truly at 50% and 70% read as 56% and 70%, a 20-point gap shrunk to 14.

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.

23.4The harness: keep the plumbing out of the scores

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.

cases.jsonl40 cases dispatch()the app's real entry pointfixture in, answer out checksserved model?stop reason? judge()Haiku + rubricPASS / FAIL + reason resultsone row percase × rep API error,timeout served a differentmodel errors.jsonl — never scored, never a zero truncated / refused: kept, labelled,counted separately from pass/fail each row also stores the answer, the verdict, tokens, cost, TTFT, latency, and cache reads
Where does each outcome go? Answers that were graded land in the results file. Truncated or refused answers land there too, labelled, so they are visible but not averaged in as wrong. Anything that never produced a gradable answer (an API error, a different model served) goes to a separate errors file and is not scored at all.

Five rules, each one a line or two in run_eval.py:

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.

23.5Is the difference real?

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.

both pass: 21 F F F F F F F F R R R R R R R R R T T T T both fail: 2 R T only v3 passes: 14 R R R T T T T T T T T T T T only v2 passes: 3 F F R T = triage, R = runbook, F = refuse. Grey cases are ties: the same result twice, no evidence either way. evidence: 14 cases only v3 passed, 3 cases only v2 passed; the 23 ties contribute nothing if v2 and v3 were equally good, a split of 14–3 or more lopsided happens with probability p = 0.013
Which cases carry the evidence? The synthetic demo comparison, case by case. Both variants passed 21 cases and both failed 2; those 23 ties say nothing about which is better. The decision rests on the 17 discordant cases, pulsing: v3 won 14 (green), v2 won 3 (orange). Note that all three v2 wins are refusal or runbook cases.
By hand · the sign test

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.

How many cases you need

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.

Static view of the widget. With 20% of cases discordant, 40 cases detect about a 20-point difference; to see 10 points you need about 157 cases, four times as many. Raising the discordant fraction makes things worse, not better: more disagreement means more noise per case.

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.

23.6Grading agents: outcomes and trajectories

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.

Static view of the widget. A ten-step trajectory for the queue-depth page. The outcome passes: correct cause, runbook 01 cited, approval requested before scaling. The trajectory shows one wasted step, a repeated worker-status call that returned nothing new: four tool calls where three sufficed, about a third more cost and latency than the ideal path.

23.7The release gate

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:

Static view of the widget. Five candidates against a gate of 80% overall, at most 3 points of regression, $0.02 per case, 5 s p95 latency, and 85% for every tag. At these settings nothing ships. v3 with tool readings clears everything except its refusal tag at 80%; v3 at low effort fails the same way; v3 with a longer prompt is blocked on cost, latency, and its runbook tag; both v2 variants fail on pass rate and on triage. Lower the tag floor to 0.80 and v3 ships.

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.

23.8Beacon's numbers, and Dispatch's

QuantityValueEvidence
LLM judge agreement with human experts, open-ended chat≈ 80%, similar to human–humanMT-Bench study, Zheng et al. 2023 public
Judge biases documentedposition, verbosity, self-preferenceZheng et al. 2023; Chapter 18 §18.5 public
Labs run application-style evals (task suites, graders, gates) before model releasesdescribed in system cardsPublished system cards public
Size and composition of labs' internal release suites; exact gate thresholdsnot disclosedunknown
Dispatch eval set40 cases, 3 tags, 8 incidentscode/ch23/cases.py
Dispatch minimum detectable difference≈ 20 points at 20% discordantcode/ch23/stats.py
Back of the envelope

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.

Break it

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.

Rebuild the model

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+ fixtures, expect harnessreal entry point gradercode, or calibrated judge paired statsdiscordant cases only gateper-tag, cost, p95 ship / holdCh 24 errors never become scores · slices find problems, statistics decide them · 40 cases see 20 points
What is the whole chapter in one line? Frozen cases through the real application, graded by something you have checked, compared pair by pair, decided by a gate you wrote before looking.
Exercises
  1. By hand. v4 is compared with v3 on the same 40 cases: both pass 30, both fail 3, only v4 passes 5, only v3 passes 2. Compute the two pass rates, the difference, and the two-sided sign-test p-value from the seven discordant cases. Should the gate treat v4 as better? Then say how many discordant cases, at the same 5-to-2 ratio, you would need before p drops below 0.05.
  2. Calculation. Your judge has sensitivity 0.95 and specificity 0.70. Two prompts truly pass 60% and 75%. What pass rates will the judge report, and what is the measured gap? By what factor is it shrunk? If you need to detect the true 15-point gap with 20% discordant cases, how many cases do you need before the shrinkage, and after it?
  3. Code. Add two cases to 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?
Further reading