Spec-Driven Dev
Prompt Engineering·Module 3 · Prompts as Code·L5·60 minIterate

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

What you'll learn
  • 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.

FailureRight layerWhy
ISBN-13 with a bad check digit gets throughCodeArithmetic is exact in code and approximate in a prompt
Draft quotes a delivery date the record does not holdThe requestFacts come from context, not from instructions about facts
Draft is asked to issue the refundA toolActions belong to tools with their own gates
Intake misreads a scanned image of a price listModel or productA text model cannot read what is not text
Draft is asked to compute a refund amountProduct decisionPriya 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.json and evals/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.ts calls the feature per case, checks or judges, prints a table, and ends with pass rate: 17/20; npm run evals is 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.

1.Omar changes the Draft prompt so DR-2 passes, and DR-1 starts failing. What is the name for what happened to DR-1, and what would have caught it?
2.Where do the cases in evals/intake/cases.json come from?
3.Intake returns a record through record_offers. Draft returns a reply in prose. How should each be checked?
4.Why does the lesson insist on one change per eval run?
5.Omar adds thinking: { type: "adaptive" } to the Intake call. What is the right way to decide whether to keep it?
6.Which of these failures is the prompt the wrong layer for?

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.

Short answerGraded by Eve · 5 criteriaup to ~150 words

Write the binary rubric, three to five criteria, for DR-2: a customer asks to cancel and refund an order that has already shipped.

0 words
Free responseGraded by Eve · 5 criteria

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.

0 words

Homework 2: A ten-case eval set for Intake

Build a ten-case eval set for Intake and the assertion rules that check it.

  1. Write evals/intake/cases.json with ten cases in the lesson's shape: id, tests (one line saying what the case checks), input (the supplier email), and expected (an offers array of records, or an empty array).
  2. 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).
  3. 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.
  4. Use null for any price or identifier the email does not state. Use an empty offers array where nothing is offered. Never expect an ISBN-13 or a price the email did not contain.
  5. Write the assertion rules, five or more, that the runner applies to every case, in the style of checkIntake from the lesson. Each rule is one sentence or one short code check, for example: "an expected isbn13: null must stay null in the output."

Submit the cases.json file and the list of assertion rules.

Deliverables
  • 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
Homework submissionGraded by Eve · 7 criteria

Paste your homework write-up below. Include every deliverable.

0 words
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.