Prompt Regression Testing in CI: Assert a Rate, Not an Answer

Photo by Christoph Baumgartinger via Wikimedia Commons (CC BY-SA 2.5)
Prompt regression testing is running a fixed set of saved inputs through your prompt on every change and checking the outputs against assertions, the same way a unit test suite guards application code. The cases come from failures that already happened in production, and the assertions state what the reporter of each failure actually complained about. Because a prompt has no compiler, the suite is the only automated signal that a reworded instruction changed the product.
Because inference kernels are not batch-invariant, so a request's result depends on the batch it lands in and therefore on how busy the server happens to be. Thinking Machines Lab sampled one prompt a thousand times at temperature 0 against Qwen3-235B and got eighty unique completions, identical for the first 102 tokens. This is why a single eval run proves very little and each case should be sampled several times and scored as a rate.
Usually not, and never first. Deterministic assertions cover most regressions: does the output parse against the schema, does it contain the required field, does it still refuse the inputs it should refuse, is it inside the length and cost caps. Add a judge only for genuinely subjective properties like faithfulness or tone, and calibrate it against your own labels before you believe any number it produces.
Label a sample of outputs yourself before seeing the judge's answers, run the judge over the same outputs, and compute Cohen's kappa between the two sets of labels. Kappa corrects for agreement that would happen by chance, so a judge that passes everything on a mostly-passing sample scores zero rather than ninety percent. Pick your own acceptable band, write it down next to the rubric, and re-measure whenever the rubric or the judge model changes.
Yes. A suite running against a moving model measures two variables at once, so when it goes red you cannot tell whether your prompt edit or the provider caused it. Pin the exact model identifier in one file, import it into the eval harness too, and add that file to the CI paths filter so a model bump cannot merge without running the suite. Pinning does not prevent retirement, so plan the migration when the provider gives notice.

Photo by Christoph Baumgartinger via Wikimedia Commons (CC BY-SA 2.5)
Key Takeaway
Prompt regression testing means running a fixed set of real production failures against a pinned model version on every prompt change. Assert deterministically first — valid JSON, required fields, refusals — and sample each case several times, because identical calls do not return identical text. Reach for an LLM judge only after calibrating it against your own labels.
The change was one sentence. A supplier-invoice extractor had a system prompt whose last paragraph was about output format, and I moved a line asking for brevity to the top of it, because the responses were padded. Every document I tried by hand came back as clean JSON. Four days later a batch job died on a scanned invoice, and the reason was that the model had taken to writing one courteous sentence before the JSON on inputs it found unusual.
Nothing in that pull request was reviewable. A prompt is code with no type system and no compiler, so the only thing between a reworded instruction and a changed product is a suite that runs the prompt and checks what comes back. This post is how I build that suite and, more importantly, how I keep it honest: where the cases come from, why the cheap assertions do most of the work, how to calibrate a judge before trusting one, and what to do on the morning it is red.
Start the fixture set from the incident queue, not from a whiteboard. A case you invent tests behaviour you already believe works. A case lifted from a support ticket tests behaviour that has failed once, in front of a real user, which is the only hard evidence you have that this prompt can fail at all. It is also what stops the suite becoming a museum of things that were never at risk, and it means the suite grows for a reason instead of on a schedule.
# evals/cases/invoice-extract/0042-amount-suffix.yaml
#
# Provenance: SUP-2411, reported 2026-02-03, closed 2026-02-05.
# A supplier sent an invoice with the total written "Rp 12.500.000,-".
# The extractor got the number right and then wrapped the JSON in a polite
# sentence, so the downstream parser threw. Both halves of that are the case.
#
# Do NOT tidy the fixture. The trailing dash after the amount and the line
# break inside the supplier name are the reason this file exists; normalising
# them turns a regression test into a formatting test.
vars:
document: file://fixtures/SUP-2411.txt
assert:
# The complaint, expressed literally: JSON, and nothing around it.
- type: is-json
value: file://schemas/invoice.schema.json
# The number the reporter actually cared about. Not "roughly right".
- type: javascript
value: JSON.parse(output).total_idr === 12500000
# The regression we shipped WHILE fixing this the first time.
- type: not-contains
value: "Here is"Two habits keep those cases valuable. Copy the offending input verbatim: the stray thousands separator, the trailing dash after an amount, the line break in the middle of a supplier name are the entire reason the case exists, and normalising them quietly deletes the test. And write the assertion as the complaint rather than as your idea of a good answer. The reporter said the parser threw, so the assertion is that the output parses. The reporter said the total was wrong, so the assertion names that total exactly. A provenance comment naming the ticket and the date is what lets a future engineer decide whether the case still matters.
Most prompt regressions are shape regressions, and shape is checkable without a second model. Before any judge gets involved, four questions cover the majority of what actually breaks, and all four are ordinary code:
Anthropic's own guidance on building evaluations argues the same trade from the other side: prioritise volume over quality, because more questions with slightly lower-signal automated grading beat fewer questions graded by hand. Cheap assertions are what let a suite be large enough to be representative. A judged suite of twenty cases is a smaller instrument than a code-graded suite of four hundred, whatever the sophistication of the grading.
promptfoo ships these as named assertion types rather than leaving you to write them: is-json with a schema, contains-all, icontains-any, is-refusal, starts-with, word-count, latency and cost, plus javascript and python escape hatches, and a not- prefix that negates any of them. Its YAML is a sensible place to start even if you eventually run your own harness.
A single run proves almost nothing, and it is worth knowing exactly why. Thinking Machines Lab sampled one prompt a thousand times at temperature 0 against Qwen3-235B and got eighty unique completions, identical for the first 102 tokens and diverging after that. The cause they identify is not the floating-point folklore usually offered but a lack of batch invariance in inference kernels: the result depends on the batch a request lands in, and the batch depends on how busy the server was, which is not yours to control.
// evals/run.ts
//
// Two tiers, because two kinds of rule share one suite:
// contract valid JSON, required field, refusal -> every sample must pass
// quality tone, completeness, judged -> one sample may miss
const SAMPLES = 5;
export async function runCase(c: EvalCase): Promise<CaseResult> {
const outputs: string[] = [];
for (let i = 0; i < SAMPLES; i++) {
outputs.push(
await callModel({
// Imported from src/llm/model.ts. Never an alias, never "latest",
// and never read from an environment variable that CI can drift.
model: PINNED_MODEL,
system: c.prompt,
user: c.input,
}),
);
}
const passes = outputs.filter((o) => c.assertions.every((a) => a(o))).length;
const required = c.tier === "contract" ? SAMPLES : SAMPLES - 1;
return {
id: c.id,
// The result is the count. "Passed" is what you derive from it, and it is
// the count, not the boolean, that a future red run gets read against.
passes,
samples: SAMPLES,
required,
ok: passes >= required,
model: PINNED_MODEL,
};
}So sample. Every case runs several times and the result is a count, not a boolean. I split cases into two tiers, contract rules that must hold on every sample and quality rules that may miss one, because a suite with a single threshold either tolerates broken JSON or fails on a synonym. Turning the temperature down is not the escape it looks like either: Anthropic now deprecates temperature, top_p and top_k on Claude Opus 4.7 and later, where a non-default value returns a 400. The parameter people reach for to make evals reproducible is itself being retired.

Some things genuinely need a second model: faithfulness to a source document, whether an answer is responsive at all, tone against a written rubric. The mistake is to add the judge and then believe its numbers, because at that point you have introduced a measuring device and never checked it against anything. Calibrate it the boring way. Label a sample of outputs yourself, run the judge over the same outputs, and compute the agreement.
// evals/calibrate-judge.ts
//
// Cohen's kappa between MY labels and the judge's, over the same outputs.
//
// kappa = (po - pe) / (1 - pe)
// po = agreement observed, pe = agreement expected by chance
//
// Why not plain percentage agreement: on a sample where 90 percent of
// outputs are good, a judge that answers "good" to everything scores
// 90 percent and has measured nothing at all. Kappa scores it 0.
type Label = "pass" | "fail";
export function cohensKappa(mine: Label[], judge: Label[]): number {
const n = mine.length;
const agreed = mine.filter((m, i) => m === judge[i]).length;
const po = agreed / n;
const rate = (xs: Label[], l: Label) => xs.filter((x) => x === l).length / n;
const pe =
rate(mine, "pass") * rate(judge, "pass") +
rate(mine, "fail") * rate(judge, "fail");
return (po - pe) / (1 - pe);
}
// Label the sample BEFORE you see the judge's answers, and re-run this
// whenever the rubric, the judge prompt or the judge model moves. A judge
// that has never been through it is an instrument with no scale on it.Use Cohen's kappa rather than raw percentage agreement. Kappa is the observed agreement minus the agreement expected by chance, divided by one minus that chance agreement, which is why a judge that answers pass to everything on a sample that is ninety percent pass scores ninety percent agreement and a kappa of zero. The Landis and Koch bands from 1977 are the ones usually quoted — 0.41 to 0.60 moderate, 0.61 to 0.80 substantial — and Wikipedia is right to record that they were personal opinion and that no single kappa value is universally acceptable. Pick your own bar, write it in the repository next to the rubric, and re-measure when either moves.
The judge is a prompt too. It drifts with everything else, so it needs its own pinned model and its own small set of regression cases, including outputs you know are bad and expect it to fail. Judging with the model that produced the output is the specific thing to avoid: independence is the property you are paying for, and you give it away for nothing.
A suite that runs against a moving model measures two variables at once and can attribute neither. Pin the exact model identifier in one file, import it everywhere including the eval harness, and treat a change to that line as a deploy needing the same review as a prompt edit. The value of pinning is not that a pinned model is better. It is that when the suite goes red you already know which of the two things moved.
| What changed | Who initiated it | What the suite has to do |
|---|---|---|
| Prompt wording | You, in a pull request | Run the full suite on the branch, with the unchanged prompt as the control |
| Pinned model identifier | You, deliberately | Run both models over the same cases and read the per-case delta before merging |
| Model retired upstream | The provider, on notice | Migrate before the date, and let the suite choose between the replacements |
| Retrieved context or tool output | Nobody, silently | Only a scheduled run against an unchanged prompt ever sees this one |
Pinning buys determinism of intent, not immortality. Anthropic's deprecation page is blunt that requests to a retired model fail, and it publishes a lifecycle — active, legacy, deprecated, retired — with a commitment of at least sixty days of notice before a publicly released model is retired. claude-3-7-sonnet-20250219 was deprecated on 28 October 2025 and retired on 19 February 2026, and applications pinned to it stopped working on that date rather than degrading. The suite is what turns the forced migration into a measured change: run it against each candidate replacement, read the per-case diff, and decide with evidence instead of a release note.
Splitting the suite by cost is what keeps it running at all. The deterministic tier adds no spend beyond the generation calls themselves, so it runs on every pull request that touches a prompt, the eval directory, or the file holding the pinned model identifier, and its exit code blocks the merge. The judged tier costs a second call per sample per case, so it runs nightly and on demand, and it posts a comment rather than a verdict.
# .github/workflows/prompt-evals.yml
name: prompt-evals
on:
pull_request:
paths:
- "prompts/**"
- "evals/**"
# The pinned model ID lives here. Leave this line out and a model bump
# merges without ever running the suite that exists to guard it.
- "src/llm/model.ts"
schedule:
# Nightly, against an unchanged prompt. This run is the only thing that
# sees drift nobody on the team initiated.
- cron: "0 19 * * *"
workflow_dispatch:
jobs:
contract:
# Deterministic assertions only. Fast, no judge, blocks the merge.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx tsx evals/run.ts --tier contract --samples 5
- uses: actions/upload-artifact@v4
if: always()
with:
name: eval-results-contract
# Per case: model ID, sample count, pass count. A red run is only
# diagnosable against the last green one.
path: evals/out/results.json
judged:
# Costs a second model call per sample. Nightly and on demand only,
# and it comments rather than blocking.
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx tsx evals/run.ts --tier quality --samples 5 --judgeTwo details are cheap now and expensive later. Point the paths filter at the model-identifier file, or a model bump merges without ever running the suite that exists to guard it — this is the single most common hole in an otherwise good setup. And publish per-case results as an artefact carrying the model identifier, the sample count and the pass count for every case, because a red run is only diagnosable against the last green one, and the suite failed is not a diff.
Record the sample count in the artefact next to the pass count. A case reading four of five today and four of five last week is stable. The same case reading four of five against a run that sampled twice is noise you have not measured yet, and the count is the whole difference between a result and an anecdote.

Red is a question, not a verdict, and there are only three answers. Work them in this order, because the cheapest check rules out the most common mistake:
One rule matters more than the three of them: never loosen a threshold in the same commit that changes a prompt. That commit is unreviewable, because it contains both the change and the permission to accept it, and it is exactly how a suite stops meaning anything to the people reading it. Loosening is its own pull request, with its own reason in the message and its own approval.
The suite is not there to prove the prompt is good. It is there so a change to the prompt produces a number two people can argue about, instead of two opinions about tone. Build it from failures that already happened, assert cheaply and often, sample enough that the number means something, calibrate anything that judges, and pin the model, so that on the morning it goes red you already know what moved.