Back to Projects
Personal Project · 2026

The Eval Gate — Regression Testing for LLM Prompts

A CI gate for prompt changes. Every pull request that touches a prompt runs both versions on the same cases, several times each, and gets PROMOTE, HOLD or ROLLBACK from a paired statistical test, with the evidence in a PR comment.

AI EvaluationCI/CDStatisticsLLM JudgesPython
Organization
Personal Project
Timeline
2026
Promoted through CI
Two prompt changes, +6.4 and +6.3 points

The Challenge

Prompt changes ship on vibes. Someone tries a handful of examples, the new version looks better, and it goes out. But a language model gives a different answer every time you ask, so a handful of examples can make a worse prompt look better, and a regression in one behaviour hides inside an average that went up. I wanted the same discipline a code change gets: a test that runs on every pull request, compares against what is in production, and says whether the change is safe, with a number I could defend.

My Approach

I built a pipeline that runs the base branch's prompt and the PR's prompt on the same synthetic cases, several samples each, and takes the paired difference in pass rate with a bootstrap interval. The cases are built from known answers, so the checks can be exact: the ticket's Gherkin parses, every requirement is covered, gaps are flagged, the research agent cites what it fetched and refuses to pick a side when sources conflict. Before trusting it, I tried to break it: planted regressions to prove it catches them, no-change runs to prove it stays quiet, and a measured noise floor to set the tolerance. LLM judges are scored on every run but may not gate until they agree with my own blind labels.

What it caught, measured

5 of 6
planted regressions rolled back
0
false critical rollbacks in 33 no-change runs
+6.4%, +6.3%
two prompt changes promoted through CI

Each number below comes from a real run on synthetic data: tickets written from invented product asks, and answers researched from a fictional document collection. Nothing here is a projection unless it says so.

How a pull request gets gated

  1. A PR that changes a prompt, model or parameters runs both versions, the base branch’s and the PR’s, on every case, several times each. A PR that changes nothing the model sees skips the paid run.
  2. Each case gets a pass rate per version, and the gate takes the paired difference across cases with a bootstrap 95% interval. Pairing cancels out how hard each case is.
  3. PROMOTE if the whole interval sits above minus the tolerance (5 points); ROLLBACK if it sits below, if any single check regresses on its own, or if a critical check newly fails on every sample and reruns confirm it; HOLD when the run can’t tell.
  4. The result lands as one comment per suite on the PR, edited in place on every push.

It catches what it should

I planted regressions on purpose: prompts that stop splitting bundled requests, stop flagging gaps, drop citations, never abstain, or ignore the documents. Each one ran through the same gate as a real change.

Planted regressionSuiteChangeDecisionCheck that caught it
No split bundlesSpec writing v2−36.7%ROLLBACKBundled asks split
No assumption flagsSpec writing v2−58.9%ROLLBACKGaps flagged
Ignore corpusResearch agent v1−4.3%HOLD—
No abstainResearch agent v1−5.7%ROLLBACKAbstains when the answer is missing
Drop citationsResearch agent v1−83.3%ROLLBACKNo uncited claims
Ignore corpus · known-facts casesResearch agent v2−63.5%ROLLBACKAnswer correct, Follows the documents, not memory, No uncited claims

The one miss is the instructive one. A prompt that ignores the documents looks fine in a fictional world, because the model has no memory of it to fall back on. It only shows once the documents contradict facts the model already knows. So I added known-facts cases, and on those the same regression is a clear rollback.

It doesn't cry wolf

A gate that fires on noise trains people to ignore it. So I ran the same prompt against itself, 33 times across both suites, and looked at every difference it reported.

−10%−8%−6%−4%−2%+0%+2%+4%+6%+8%+10%
Research agent, 132 cases: 13 runs with the same prompt on both arms. Filled dots came back PROMOTE, hollow ones did not; dashed lines are ±5%.

On the research suite the no-change difference has a spread of 1.2%, and 12 of 13 runs came back PROMOTE. My first rule for critical failures would have fired on most no-change runs in simulation, because with three samples a few cases always fail by chance. It now needs a failure on every sample and a confirming rerun, and across all 33 no-change runs it has confirmed 0.

The noise floor has a price

The first run in CI put the same prompt on both arms and said HOLD: a −1.1% difference, but with three samples per case it could only detect a drop of 6.8%, more than the 5-point tolerance. Noise in a paired pass rate falls with the square root of the runs, so doubling the samples should get under it.

0%2%4%6%8%k=3k=6k=9k=12samples per case (runs per arm = 120 × k)k=3: 6.8%k=6: 5.1%
The smallest drop the spec-writing suite can reliably detect, against runs per arm (120 cases × samples per case). The line is projected from the first CI run; dots are measured. The dashed line is the 5% tolerance the gate needs to clear.

At six samples the next run measured 5.1%, close to the projection and just over the line. That doubled the cost of a prompt PR to about $2.05. I took it: a gate that can’t see a 5-point drop isn’t enforcing a 5-point tolerance.

Two prompt changes, promoted

Spec writing v3: strict Gherkin and shorter titles

I looked at why v2 failed before writing v3. Most broken Gherkin was a scenario with no When step, and a third was an uppercase AND, which a strict parser rejects. Titles ran over 80 characters because models count characters badly, so v3 aims for 40 to 65 instead of quoting the limit.

−10%−5%+0%+5%+10%tolerance
Change in case pass rate: +6.4% (95% interval +2.9% to +10.0%). The dashed line at −5% is the tolerance; the whole interval sits above it.
Same ticket, reworded ask · report only76.0% → 84.9%Format rules96.3% → 99.7%Every requirement covered91.7% → 93.5%Gherkin parses96.5% → 98.1%0%50%100%
Pass rate per check. Hollow dashed bars are v2, filled bars are v3. Checks marked “report only” are shown but don’t decide the gate.

Research agent v3: never pick a side in a conflict

When two documents disagreed, v2 usually said so and then answered with one side anyway, mostly on comparison questions where the disputed figure decides the answer. v3 makes the rule cover facts needed along the way and checks its own output before answering.

−10%−5%+0%+5%+10%tolerance
Change in case pass rate: +6.3% (95% interval +2.0% to +10.9%). The dashed line at −5% is the tolerance; the whole interval sits above it.
Flags conflicting sources60.6% → 95.5%Abstains when the answer is missing78.8% → 75.8%Follows the documents, not memory100.0% → 98.7%No uncited claims94.4% → 95.7%0%50%100%
Pass rate per check. Hollow dashed bars are v2, filled bars are v3. Checks marked “report only” are shown but don’t decide the gate.

Before both of these, v2 itself went through the gate against v1 at +28.9% on 30 cases. The interval was wide, because 30 cases is few, but the change was large enough to clear it anyway.

The judge that isn't allowed to gate yet

Keyword checks can tell a requirement is mentioned, not whether a ticket invented one. That needs an LLM judge, and a judge is a measuring instrument: it gates only once it agrees with a human on at least 50 labels with κ of at least 0.6. I labeled tickets blind to the judge’s verdicts, on my phone.

First 50 labelsOriginal prompt · 50 labels0.07Allow sensible elaborationsElaborations allowed · 50 labels0.24After rechecking my failsElaborations allowed · 50 labels-0.02Add 30 tickets likely to failElaborations allowed · 80 labels0.37Strict actor ruleElaborations list + strict actor rule · 78 labels0.060.00.20.40.60.81.0
Cohen’s κ between the faithfulness judge and my labels at each step (0 is chance, 1 is perfect). The dashed line is the 0.6 a judge needs before it may gate.

The first result was barely better than chance: the judge failed tickets for sensible elaborations like error messages and permission checks. Loosening the rule helped. Then my own rechecks left only four fails in fifty, too few to measure agreement at all, so I added tickets from a prompt built to invent requirements. That got to κ 0.37. A stricter actor rule then backfired, failing tickets for not naming a user the request never named. It’s narrowed now, and a held-out batch the prompt was never tuned on will decide whether the judge earns its place. Until then it reports and does not gate.

Applying it to your own LLM feature

Nothing in the gate knows about tickets or research. It compares two versions of anything that takes an input and returns structured output: a classifier, an extractor, a support reply, an agent that calls tools. What changes from feature to feature is what you write down about it, in this order.

  1. Say what a good output must do, as checks. The right category, a citation to something it actually read, under a length, never blaming the customer. Prefer checks with a known answer; they’re cheap, exact and never drift.
  2. Build cases with known answers. A few dozen to a couple of hundred, spread over the ways real inputs vary: tone, format, missing information, requests bundled together. Label each with what varies, so a regression in one kind of input can’t hide in the average.
  3. Version what the model sees. Model, parameters and prompt templates in one file, hashed into an id. Edit a word of the prompt and it’s a new version; change anything else and the paid run is skipped.
  4. Prove the gate before you trust it. Plant a worse prompt and check it rolls back. Run the prompt against itself and check it stays quiet. Read the smallest drop it can detect, and set samples and tolerance from that.
  5. Put it on every pull request. Both versions run, the comment shows the decision and the evidence, and the check fails only on a rollback. A prompt change costs about cases × samples × 2 model calls: here $0.79 for the research agent and $2.05 for the spec writer.
  6. Make LLM judges earn their place. For what no check can see, add a judge with a yes/no rubric, label about 50 of its outputs blind, and let it gate only once it agrees with you.

A whole suite for a support-triage feature fits on one screen:

# suite.yaml
suite: support-triage
runner: {type: prompt, output_schema: schemas/output.schema.json}
baseline: versions/v1.yaml        # model, params and prompt templates
cases:
  - from: data/cases.jsonl        # {"input": {"message": ...}, "expected": {"category": "billing", ...}}
plugins: [plugins/checks.py]      # your own checks and judges
checks:
  - field_matches: {field: category, critical: true}
  - keywords_present: {field: reply}
  - max_length: {field: reply, max_chars: 400}
  - no_blame: {}                  # from plugins/checks.py
  - checklist: {rubric: rubrics/reply_quality.yaml}   # an LLM judge: reports until calibrated
samples: 6
gate: {tolerance: 0.05}

I’ve packaged this so it can drop into another repository: a command that scaffolds a working suite, plug-ins for your own checks and judges, an offline mode for testing the wiring for free, and a GitHub Action for the pull-request gate. It’s open source under the MIT licence at github.com/bwyndham/llm-evals. Three commands take you from nothing to a gated suite, and the first run needs no API key:

uv tool install git+https://github.com/bwyndham/llm-evals@v0.1.0
llm-evals init evals/my-feature --ci      # a starter suite and a pull-request workflow
llm-evals gate evals/my-feature --candidate versions/v2.yaml --provider mock --skip-judges

Trying it from the outside

To see what adopting it is actually like, I set up a separate repository the way someone else would: a small app that pulls package dimensions and weight from messy supplier listings, with llm-evals installed as a tool and its GitHub Action on every pull request. The eval points at the app’s own prompt file, so a PR that edits the prompt is exactly what gets gated. Its 120 listings are generated from known values, with the mess real supplier data has: mixed units and fractions, packed and assembled sizes side by side, prices and model numbers in the way, and values that are missing or contradict each other.

+24.4%
PROMOTE on the first prompt PR (74.2% → 98.6%)
30 fixed
cases flipped to passing; 0 broke
$0.09
per gated PR (120 cases × 3 samples)
Dimensions correct87.5% → 100.0%Leaves unknowns empty50.0% → 94.4%Weight correct98.9% → 100.0%0%50%100%
Pass rate per check. Hollow dashed bars are first prompt, filled bars are rewritten prompt. Checks marked “report only” are shown but don’t decide the gate.

The first prompt only asked for centimetres and kilograms. The rewrite said what to do with each kind of mess, and to leave a value empty rather than guess. Guessing is the check I made critical: a made-up weight in a warehouse system is worse than a blank, so one confirmed guess rolls a PR back on its own.

Then I measured the noise. 10 runs of the new prompt against itself all came back PROMOTE, never more than 0.8 points apart, and the gate could see drops of 2.2 points or less. That left room under the 5% tolerance, so I tightened it to 3%: at a 98.6% baseline, a 4-point drop would mean about four times as many wrong values.

It also earned its keep on the tool itself. The first run crashed, because CI loaded the base branch’s copy of the suite’s plug-ins next to the PR’s, and the check passed anyway, because a crash and a HOLD shared an exit code. llm-evals’ own repository never hit either bug; the first outside user found both on its first run. Both are fixed, and the check now passes only on PROMOTE, HOLD or a skip. The demo repository itself is private; its results are all here.

Every run, in detail

Pick any run to see what the gate decided, every check on both versions, and the cases that flipped. Open a flipped case to read both outputs side by side. The outputs are synthetic and load only when you open one.

Prompt changes and CI runs
Planted regressions
Spec writing · CI · PR #3 · 2026-10-21

Spec writing v3: strict Gherkin and shorter titles

v3 targets v2's most common gating failures. First run at 6 samples per case.

Decision
PROMOTE
Case pass rate
85.1% → 91.5%
Cases × samples
120 × 6
Detectable drop
5.1%
Baseline
v2
Candidate
v3
Tolerance
5%
Model cost
$2.05
−10%−5%+0%+5%+10%tolerance
Change in case pass rate: +6.4% (95% interval +2.9% to +10.0%). The dashed line at −5% is the tolerance; the whole interval sits above it.

Every check

Every requirement covered91.7% → 93.5%Bundled asks split98.9% → 98.6%Format rules96.3% → 99.7%Gaps flagged99.6% → 99.6%Gherkin parses96.5% → 98.1%Same ticket, reworded ask · report only76.0% → 84.9%0%50%100%
Pass rate per check. Hollow dashed bars are v2, filled bars are v3. Checks marked “report only” are shown but don’t decide the gate.

Cases that flipped

1 broke and 11 fixed. A rate is the share of that case’s samples that passed every check. Open one to read both outputs.

Precomputed from synthetic and public eval data. No live model calls. See also The Eval Stack, the design tool this pipeline puts into practice.

Sources and inspiration

The design started from two papers and took its statistics from a third. I kept what held up and left what didn’t: single-sample labels, failure counts as the score, and thresholds with no baseline to compare against.

Particular choices trace to particular findings:

Every source, what it informed and where it lives in the code, including the ideas not built yet: docs/references.md.

Impact

Two prompt changes promoted through CI: spec writing +6.4 points (titles over the limit nearly gone, Gherkin failures halved) and research +6.3 points (conflict handling 61% → 96%)
Every planted regression rolled back on the cases built to expose it, including one that only shows when the documents contradict what the model already knows
No false critical rollbacks across 33 no-change runs, after replacing a rule that would have fired on most of them
Found that the first CI setup could only detect a 6.8-point drop against a 5-point tolerance, and doubled the samples to fix it, at about $2 per prompt PR
Kept the LLM judge out of the gate: it reached κ 0.37 against 80 blind human labels, short of the 0.6 bar, and reports until a held-out batch says otherwise
Packaged it for other repositories and tried it from the outside: a separate app's first prompt PR came back PROMOTE at +24.4 points for $0.09, and its first run caught two bugs in the tool that its own CI never hit

Technologies

Python 3.11Claude API (Haiku 5.5, Sonnet 5.5)Bootstrap and McNemar testsSQLiteGitHub ActionsTyperPydantic

What's Next

  • →Finish the faithfulness judge's held-out calibration and let it gate if it clears the bar
  • →Broaden the spec-writing suite past one set of requirements
  • →Spend extra samples only on borderline runs instead of every run