Testing and Iterating a Prompt
Build a twenty-case eval set, check Intake with assertions and Draft with a rubric judge, run npm run evals, change one thing at a time, decide when thinking is worth it, and notice when the prompt is the wrong layer.
Before this: L4 Examples, templates, and caching
- Build a small eval set (about 20 cases) from real inputs and deliberate edge cases.
- Check structured output with assertions and prose with a rubric-driven judge.
- Run the set, read the pass rate, and change one thing at a time.
- Decide when to turn thinking on and what it costs.
- Recognize when the prompt is the wrong layer and name the right one.
Why this matters
Omar spends a morning on DR-2. A customer wants to cancel and refund an order that already shipped, and Draft keeps promising things Bramble cannot do. He rewrites the shipped-order rule in prompts/draft.system.md, runs DR-2 three times, and every reply is right. He ships it. The next day Tessa sends back a DR-1 draft, the torn cover on order #4471, with red pen across the middle. The reply now talks about cancellation on a complaint that never mentioned it. Omar says DR-2 is fixed. Tessa says DR-1 got worse. Both are right, and neither has a number to point at. Every argument about the prompt is two anecdotes until there is a set of cases and a score. Jun's advice from Working with Coding Agents applies here: a prompt is code, and code has tests.
1. A prompt is code, so it has tests
Code that ships has tests, so a prompt that ships has an eval set, a runner, and a number that says whether the last change helped.
A regression is a case that used to pass and now fails because of a change. Omar's DR-2 fix caused a DR-1 regression. Without tests he found out from Tessa a day later. With tests he would have found out in ninety seconds.
2. The eval set
An eval set is a collection of eval cases for one feature. An eval case is one input plus the expected result plus a line saying what it tests. Twenty cases per feature is enough to catch most regressions and small enough to run on every change.
Cases come from three places. First, real inputs, anonymized: the supplier emails and customer emails that already caused trouble. Second, the recurring cases from this course, IN-1 to IN-3 and DR-1 to DR-3. Third, edge cases you write on purpose because nobody has sent them yet: quantities in words, a second currency, an empty email.
The expected result is called a golden output when it is an exact record, as it is for Intake. For Draft the expected result is a list of criteria instead, because two good replies never share the same words.
3. Assertion checks for structured output
Intake returns a record, so you can check it with assertions. An assertion check is a rule in code that compares a field of the actual output with the expected value and reports a mismatch. Field equality, null where null is right, an empty array for IN-3, and no invented identifiers are all assertions.
The runner calls checkIntake per case and collects failures as strings. An empty list is a pass.
// evals/intake/checks.ts: compare one Intake result with its expected records.
const FIELDS = ["title", "isbn13", "unitPrice", "currency", "quantityOffered"] as const;
export function checkIntake(c: IntakeCase, actual: Offers): string[] {
const failures: string[] = [];
if (actual.offers.length !== c.expected.offers.length) {
failures.push(`expected ${c.expected.offers.length} offers, got ${actual.offers.length}`);
}
for (const [i, want] of c.expected.offers.entries()) {
const got = actual.offers[i];
if (!got) break;
for (const f of FIELDS) {
if (got[f] !== want[f]) {
failures.push(`${f}: expected ${JSON.stringify(want[f])}, got ${JSON.stringify(got[f])}`);
}
}
if (want.isbn13 === null && got.isbn13 !== null) failures.push("invented an isbn13");
if (want.unitPrice === null && got.unitPrice !== null) failures.push("invented a price");
}
return failures;
}
4. A judge for prose
Draft returns prose, and prose has no golden output. Two replies to DR-1 can both be right with different sentences. So Draft is scored by an LLM judge: a second model call that reads the reply and a rubric and returns a verdict per criterion.
A rubric is a list of criteria. Each criterion is binary: the reply meets it or does not. "Names the exchange policy" is binary. "Is polite enough" is not, and a judge asked to grade on a scale drifts between runs. Binary criteria give you a number you can compare across changes.
The judge is an ordinary call with the forced-tool shape from L3. The rubric and the reply go in as delimited data, and the model must answer by calling grade.
// evals/draft/judge.ts: one forced tool call per case; returns the ids of unmet criteria.
const response = await client.messages.create({
model: "claude-opus-5",
max_tokens: 1024,
system: "You grade a customer reply against binary criteria. For each one, say whether the reply meets it and quote the words that show it.",
tools: [{
name: "grade",
description: "Record a verdict for every criterion.",
input_schema: {
type: "object",
properties: {
criteria: {
type: "array",
items: {
type: "object",
properties: { id: { type: "string" }, met: { type: "boolean" }, evidence: { type: "string" } },
required: ["id", "met", "evidence"],
},
},
},
required: ["criteria"],
},
}],
tool_choice: { type: "tool", name: "grade" },
messages: [{ role: "user", content: `<criteria>\n${criteriaJson}\n</criteria>\n<reply>\n${reply}\n</reply>` }],
});
const call = response.content.find((b) => b.type === "tool_use");
5. Running it
evals/run.ts is a plain Node script. For each case it calls the feature, checks or judges the result, prints one line, and ends with the pass rate: passing cases over the total. npm run evals runs it.
// evals/run.ts: run every case for one feature, print a table and the pass rate.
import fs from "node:fs";
import { runIntake } from "../src/llm/intake";
import { runDraft } from "../src/llm/draft";
import { checkIntake } from "./intake/checks";
import { judgeDraft } from "./draft/judge";
const feature = process.argv[2] ?? "intake";
const cases = JSON.parse(fs.readFileSync(`evals/${feature}/cases.json`, "utf8"));
let passed = 0;
for (const c of cases) {
const failures =
feature === "intake"
? checkIntake(c, await runIntake(c.input))
: await judgeDraft(c, await runDraft(c.order, c.input));
const ok = failures.length === 0;
if (ok) passed++;
const detail = ok ? "" : ` -> ${failures.join("; ")}`;
console.log(`${ok ? "PASS" : "FAIL"} ${c.id.padEnd(5)} ${c.tests}${detail}`);
}
console.log(`pass rate: ${passed}/${cases.length}`);
6. One change at a time
Change one thing, run the set, write the result down. That discipline is one-change-at-a-time, and it is the only way to know which change caused which number. Two changes in one run give you one pass rate and two possible explanations.
The record lives in prompts/CHANGELOG.md, the same file L4 introduced. Every entry names the version, the one change, and the pass rate before and after.
7. Thinking
Thinking lets the model reason in private before it writes the answer. You turn it on by adding thinking: { type: "adaptive" } to the request, and off by leaving it out. The model decides how much to reason per request, and it costs tokens and time wherever it reasons.
Thinking is a change like any other, so it goes through the eval set. Turn it on for the feature with hard cases, measure, and weigh the pass rate against the cost.
const response = await client.messages.create({
model: "claude-opus-5",
max_tokens: 2048,
thinking: { type: "adaptive" },
system: INTAKE_SYSTEM_PROMPT,
tools: INTAKE_TOOLS,
tool_choice: { type: "tool", name: "record_offers" },
messages: [{ role: "user", content: `<email>\n${supplierEmail}\n</email>` }],
});
8. When the prompt is the wrong layer
Some failures do not belong to the prompt. When a case keeps failing after three honest changes, ask which layer owns the behavior. A wrong-layer failure is one whose fix belongs in code, retrieval, a tool, a different model, or a product decision. No wording will hold it.
| Failure | Right layer | Why |
|---|---|---|
| ISBN-13 with a bad check digit gets through | Code | Arithmetic is exact in code and approximate in a prompt |
| Draft quotes a delivery date the record does not hold | The request | Facts come from context, not from instructions about facts |
| Draft is asked to issue the refund | A tool | Actions belong to tools with their own gates |
| Intake misreads a scanned image of a price list | Model or product | A text model cannot read what is not text |
| Draft is asked to compute a refund amount | Product decision | Priya owns refunds, and a model should not be deciding money |
Summary
- A prompt is code, so it has tests: about twenty cases per feature in
evals/intake/cases.jsonandevals/draft/cases.json, built from real inputs, the recurring cases, and deliberate edge cases. - Structured output is checked with assertions on the fields code depends on; prose is checked by an LLM judge against a binary rubric, with evidence for every verdict.
evals/run.tscalls the feature per case, checks or judges, prints a table, and ends withpass rate: 17/20;npm run evalsis the command.- Change one thing, run the set, and record the version, the change, and the pass rate in
prompts/CHANGELOG.md; thinking is a change like any other and is measured the same way. - When three honest changes will not move a case, the prompt is the wrong layer: arithmetic and validation go to code, facts to the request, actions to tools, and money to a product decision.
Key terms in this lesson
Check your understanding
Pick an answer to see why it is right or wrong. These are graded in code, instantly.
Apply it
Write your answer in your own words. Eve grades it against a rubric and gives you feedback, the same way an LLM judge works in Lesson 5.
Write the binary rubric, three to five criteria, for DR-2: a customer asks to cancel and refund an order that has already shipped.
Omar wants Draft to compute refund amounts so the reply can tell the customer exactly what they will get back. Argue which layer should own that, and describe how the eval set would prove the prompt should not.
Homework 2: A ten-case eval set for Intake
Build a ten-case eval set for Intake and the assertion rules that check it.
- Write
evals/intake/cases.jsonwith ten cases in the lesson's shape:id,tests(one line saying what the case checks),input(the supplier email), andexpected(anoffersarray of records, or an empty array). - Include the three recurring cases IN-1 (two titles, one in GBP, one with no price), IN-2 (an ISBN-10 and an RRP), and IN-3 (a newsletter that offers nothing).
- Include at least two emails that offer more than one title, one email with a currency other than USD, one email with quantities written in words, and one adversarial email that tries to instruct the extractor.
- Use
nullfor any price or identifier the email does not state. Use an emptyoffersarray where nothing is offered. Never expect an ISBN-13 or a price the email did not contain. - Write the assertion rules, five or more, that the runner applies to every case, in the style of
checkIntakefrom the lesson. Each rule is one sentence or one short code check, for example: "an expectedisbn13: nullmust stay null in the output."
Submit the cases.json file and the list of assertion rules.
- evals/intake/cases.json with ten cases, each with id, tests, input, and expected
- A list of five or more assertion rules the runner applies to every case
Paste your homework write-up below. Include every deliverable.
Instructor notes(for whoever is teaching this lesson)
Talking points
- Open with the argument: Omar says DR-2 is fixed, Tessa says DR-1 got worse, both are right. Push until someone says "run all the cases and count."
- Plant the word regression early. Engineers know it from code; it applies unchanged to prompts.
- Cases come from real inputs, the six recurring cases, and edge cases written on purpose. Every bad output Tessa returns becomes a case.
- Assertions for data, a judge for prose. Exact comparison fails good replies; a judge on JSON adds cost to what code does exactly.
- Binary criteria are the whole trick of the judge. "Names the exchange policy" versus "is polite enough."
- One change, one run, one changelog line. v1.5 shows a change with no score movement is still worth recording.
- Thinking is a change like any other; the pass rate, time, and cost table is the shape of the decision.
- Wrong layer: arithmetic to code, facts to the request, actions to tools, money to a product decision.
Live demo idea
With the lesson's evals/run.ts, checks.ts, and a five-case evals/intake/cases.json in a repository, run npm run evals intake live and read the table. Edit prompts/intake.system.md to add "when a price is missing, use the RRP" and run again: IN-2 fails with invented a price. Revert, add thinking: { type: "adaptive" }, run again, and compare the wall-clock time. Close by writing the three changelog lines on screen, one per run.
Common misconceptions
- "Three good runs of the case I changed means the change is good." That is how the DR-1 regression shipped. The set runs the cases you were not looking at.
- "The judge is objective." It is a model. Evidence per criterion and spot-checks against Tessa keep it honest.
- "More cases is always better." Twenty near-copies of IN-1 inflate the pass rate without testing anything new.
- "Thinking on is the safe default." It doubled Draft's wait for no gain. Measure per feature.
- "If the eval fails, rewrite the prompt." After three honest changes with no movement, the prompt is the wrong layer.
Timing
Sixty minutes: story and section 1, six; the eval set, eight; assertions, seven; the judge, ten; running it, six; one change at a time, seven; thinking, six; wrong layer, seven; summary and homework brief, three.
If you only have 20 minutes
Run the five-case Intake set live and break IN-2 with one prompt line (eight minutes). Put DR-1's rubric and the judge verdict on screen and teach binary criteria (six minutes). Finish with the three changelog entries and "which change moved the number?" (six minutes). Assign sections 7 and 8 as reading.