Everything is a dependency that changes under you
The second row is the one that makes this lesson necessary. An application built on a model you do not control has a dependency that can change without a version number, and the only defence is a set of cases you run regularly.
| What changed | How you find out |
|---|---|
| Somebody edited the prompt | A diff — if the prompt is in the repository |
| The provider updated the model behind the name | You do not, unless you are measuring |
| You moved to a cheaper model | Deliberate, and the evaluation says what it cost |
| A new document entered the corpus | Retrieval changes for questions nobody was thinking about |
| A dependency changed the chunking | Every stored vector is now slightly wrong |
The golden set
A small, stable, version-controlled set of cases with known-good answers, living in the repository next to the code it tests. Two properties matter more than size:
- Stable. If the set changes on the same commit as the prompt, the comparison is meaningless. Change one at a time.
- Owned. A case is added when a bug is found and removed only deliberately, with a reason in the commit message.
Thirty to two hundred cases is a working size. Beyond that the run takes long enough that people stop waiting for it, which is worse than having fewer cases.
Gate on the difference, not the absolute
A threshold on the absolute score ("must be above 0.8") fails the day you add a batch of hard cases and tells you nothing about a change. A threshold on the difference from the recorded baseline is what catches a regression — and a per-case check catches the change that improves the mean while breaking four things.
Update the baseline deliberately, in its own commit, when an improvement is real. That commit is then the record of when the behaviour changed and why.
const baseline = JSON.parse(readFileSync("evals/baseline.json"));
const current = evaluate(cases, pipeline, f1);
const regressions = cases.filter((c, i) =>
current.results[i].score < baseline.results[i].score - 0.01);
if (regressions.length > 0) fail(regressions); // any case got worse
if (current.score < baseline.score - 0.02) fail("mean dropped");
Making it run, and keeping it honest
- Temperature 0, and a fixed seed everywhere else. A flaky gate is a disabled gate — the testing course made the same point and it is more acute here.
- Cache by (model, prompt) hash so an unchanged case is free. Most runs change one thing.
- A subset on every pull request, the whole set nightly. Twenty cases in thirty seconds is a gate people keep; four hundred in twenty minutes is one they skip.
- Pin the model version where the provider offers one, and treat an upgrade as a change to be evaluated, not a background event.
- Run it against production traffic too. Replay yesterday’s real questions through the new pipeline and diff the answers. It finds the cases your golden set never imagined.
What to watch in production
A rising refusal rate with no change to the prompt is the clearest early warning there is: something upstream in retrieval stopped working, and it will show up here days before anyone complains.
| Signal | Means |
|---|---|
| Schema-failure rate | The model has drifted, or the prompt is under-specified |
| Refusal / NOT_FOUND rate | Retrieval has quietly broken, or the corpus changed |
| Tokens per request | The context is growing — a leak somewhere in the assembler |
| Retrieval hit rate | How often the top chunk comes from the document a human would pick |