Module 10 · Safety, Evaluation & Capstone ⏱ 19 min

Evaluation: If You Can't Measure It, You Can't Ship It

By the end of this lesson you will be able to:
  • Explain why vibes-based testing hides regressions and why an eval harness replaces it with a number
  • Build a golden dataset and a checker that scores a pipeline over fixed expected behaviour
  • Compute accuracy and break it down so a weak category cannot hide behind a strong one

You changed the prompt, tried three examples, liked how they looked, and shipped. A week later a user reports the model now gives wrong answers on inputs none of you tried. That is vibes-based testing — eyeballing a few outputs and trusting a feeling — and it is exactly how regressions reach production undetected.

The fix is an evaluation harness: a fixed set of test cases with known-expected behaviour that you run the whole pipeline over to produce a number. 'Does it work?' becomes 'it passes 47 of 50 cases', and when you change the prompt, the model, or a guardrail, you re-run and compare. If the number drops, you broke something. If it holds, you ship with evidence. Measurement replaces vibes, and regressions stop being something a user has to report.

The golden dataset

An eval starts with a golden dataset: a list of cases, each pairing an input with the behaviour you expect. The expectation need not be an exact string. It can be a category ('this review is positive'), a property ('the answer cites a source'), or a constraint ('no secrets in the output'). What matters is that each case is fixed, written down, and agreed to represent correct behaviour — the 'golden trace' you measure against.

Building this dataset is real work, and it is the work that pays off forever after. Every tricky input a user has ever reported becomes a case; every edge case you argued about becomes a case. The dataset grows as you learn, and it never forgets a bug you have already fixed.

flowchart LR
  C["golden cases"] --> R["run pipeline<br/>on each case"]
  R --> K["checker compares<br/>actual vs expected"]
  K --> S["score passed / total"]
  S -.->|"on every change"| C
  style S fill:#1e7f3d,color:#fff
The eval loop: run the pipeline over every golden case, check each result against its expectation, and report a score. Re-run on every change and watch for the score to drop.
A golden case plus a checker: run the pipeline on one input, compare to the expected value. Press Run.
function extractSentiment(text) {
  const t = text.toLowerCase();
  if (t.indexOf('great') !== -1 || t.indexOf('love') !== -1) return 'positive';
  if (t.indexOf('bad') !== -1 || t.indexOf('hate') !== -1) return 'negative';
  return 'neutral';
}

const golden = [
  { input: 'I love this product', expected: 'positive' },
  { input: 'This is bad', expected: 'negative' },
  { input: 'It is a table', expected: 'neutral' }
];

const actual = extractSentiment(golden[0].input);
console.log(actual === golden[0].expected);

Checkers and the harness

For each case you need a checker: a function that takes the pipeline's actual output and the expected value and returns true or false. A checker can be as strict as exact-string equality or as loose as 'the output contains the expected substring' — choose the strictness that matches what correct means for that case.

The harness is the loop that ties it together: for every case, run the pipeline, call the checker, count the passes. It is a dozen lines of plain code, and it is the single most valuable test infrastructure an LLM application has, because it turns 'is it good?' into a number you can track over time.

The full harness: run every case, count the passes. Run it.
function evaluate(cases, fn, checker) {
  let passed = 0;
  for (const c of cases) {
    if (checker(fn(c.input), c.expected)) passed += 1;
  }
  return { passed: passed, total: cases.length };
}

function extractSentiment(text) {
  const t = text.toLowerCase();
  if (t.indexOf('great') !== -1 || t.indexOf('love') !== -1) return 'positive';
  if (t.indexOf('bad') !== -1 || t.indexOf('hate') !== -1) return 'negative';
  return 'neutral';
}

const golden = [
  { input: 'I love this', expected: 'positive' },
  { input: 'This is bad', expected: 'negative' },
  { input: 'It is a table', expected: 'neutral' }
];

const score = evaluate(golden, extractSentiment, (a, e) => a === e);
console.log(score.passed + ' of ' + score.total);

The pipeline under test can be a stub

Notice that nothing about the harness requires a real model. The function under test can be a live model call in production and a tiny deterministic stub in your tests — a function that returns canned answers from a lookup. That lets you verify the harness itself: feed it a stub you control, confirm the score is what you expect, and only then trust the number it reports for the real pipeline.

Testing the test infrastructure is not paranoia. A harness that silently miscounts gives you a confidently wrong score, which is worse than no score at all, because you stop looking for the bugs it is hiding.

From counts to a number

Once the harness counts passes, the headline metric is accuracy: passes divided by total, usually shown as a percentage. A single number is easy to track but easy to hide behind, so break it down. If your cases fall into categories — easy versus hard, short versus long, different topics — score each category separately. A pipeline at 90% overall that scores 100% on easy cases and 40% on hard ones is not really at 90%; it is failing on the cases that matter most, and only the breakdown reveals it.

Accuracy is the start of the conversation, not the end. The failing cases are the lesson: each one is a concrete input where the pipeline misbehaves, and a candidate for the golden dataset so it can never silently fail again.

Accuracy: passes over total, as a percentage. Run it.
function accuracy(passed, total) {
  if (total === 0) return 0;
  return Math.round((passed / total) * 100);
}

function extractSentiment(text) {
  const t = text.toLowerCase();
  if (t.indexOf('love') !== -1) return 'positive';
  if (t.indexOf('bad') !== -1) return 'negative';
  return 'neutral';
}

const golden = [
  { input: 'I love this', expected: 'positive' },
  { input: 'This is bad', expected: 'negative' },
  { input: 'a plain table', expected: 'neutral' },
  { input: 'I love love love it', expected: 'positive' }
];

let passed = 0;
for (const c of golden) {
  if (extractSentiment(c.input) === c.expected) passed += 1;
}
console.log('accuracy: ' + accuracy(passed, golden.length) + '%');
flowchart TD
  A["change prompt / model / guardrail"] --> B["re-run eval suite"]
  B --> C["score held?"]
  C -->|"yes"| D["ship with evidence"]
  C -->|"dropped"| F["investigate failing cases"]
  F --> B
  style D fill:#1e7f3d,color:#fff
  style F fill:#b91c1c,color:#fff
An eval suite is regression insurance. Every change triggers a re-run; a held score ships with evidence, a dropped score sends you straight to the cases that broke.
flowchart LR
  S["overall 90%"] --> E["easy cases 100%"]
  S --> H["hard cases 40%"]
  style S fill:#3776ab,color:#fff
  style E fill:#1e7f3d,color:#fff
  style H fill:#b91c1c,color:#fff
A single score hides the breakdown. Score easy and hard cases separately so a weak category cannot hide behind a strong one.
Exercise

What does this print? The harness counts how many cases pass the checker. Here the pipeline is just string length.

function evaluate(cases, fn, checker) {
  let passed = 0;
  for (const c of cases) {
    if (checker(fn(c.input), c.expected)) passed += 1;
  }
  return passed;
}
const cases = [
  { input: 'hi', expected: 2 },
  { input: 'hello', expected: 5 },
  { input: 'ok!', expected: 3 }
];
console.log(evaluate(cases, (s) => s.length, (a, e) => a === e));
Exercise

Write evaluate(cases, fn, checker) that returns the number of cases where checker(fn(c.input), c.expected) is truthy. cases is an array of { input, expected } objects.

function evaluate(cases, fn, checker) {
  // return the count of cases whose checker(fn(input), expected) is truthy
}
Exercise

This checker is supposed to compare the pipeline's actual output to the expected value, but it returns true no matter what — so every case 'passes' and the eval reports a fake 100%. Fix it to return true only when actual equals expected.

function checker(actual, expected) {
  return true;
}
Exercise

Write failingCases(cases, fn, checker) that returns the indices of cases where checker(fn(c.input), c.expected) is falsy. Return the indices in ascending order.

function failingCases(cases, fn, checker) {
  // return indices of the cases that fail the checker
}
Exercise

Your team shipped a new prompt after trying three examples that looked good. A week later a user reports wrong answers on inputs none of you tried. What was missing?

Recap

  • Vibes-based testing — eyeballing a few outputs — hides regressions; an eval harness replaces it with a number you can track over time.
  • A golden dataset pairs each input with the behaviour you expect; it grows as you learn and never forgets a bug you have already fixed.
  • A checker compares actual to expected; choose the strictness to match what 'correct' means for that case.
  • The harness runs the pipeline over every case, counts passes, and reports accuracy — passes over total, ideally broken down per category.
  • Beware the checker that approves everything: a fake 100% hides every regression, so sanity-check each checker against an input that should fail.
  • Re-run the suite on every change — a held score ships with evidence, a dropped score points straight at the broken case.

Next you will add observability: tracing each request step by step so that when an eval fails in production, you can see exactly where the pipeline went wrong.

Checkpoint quiz

Why does eyeballing a few model outputs let regressions reach production?

Your eval reports 100%, but a user still hits a wrong answer in production. The checker, when inspected, just returns true. What happened, and why is it dangerous?

Go deeper — technical resources