Golden Set Regression Gate
Also known as: eval suite in CI, regression testing for prompts, quality gate
Block any prompt, model, or pipeline change that regresses a curated set of known-correct cases.
The problem
Prompts are edited constantly and their effects are non-local: a clarifying sentence added for one edge case silently breaks three behaviours nobody thought to check. Without an automated gate the feedback loop is a user complaint days later, and by then several changes have shipped and nobody can say which one caused it. Manual spot-checking does not scale and does not catch what it does not think to look at.
The pattern
Maintain a curated set of inputs with known-good outputs or checkable assertions, and run it on every change to a prompt, model, or pipeline component. Score with a mix of deterministic checks and judge models, compare against the current baseline, and fail the build on regression. Every production bug found becomes a new case in the set.
Use when
- More than one person edits prompts, or prompts change more than occasionally
- You are changing models and need to know what the swap actually costs
- Regressions have reached production and been discovered by users
- You want to refactor a prompt and need a safety net to do it confidently
Avoid when
- Pre-product-market-fit prototyping where the target behaviour changes daily
- Purely open-ended creative output with no checkable notion of correct
- The suite would be so small it gives false confidence — under about thirty cases
Tradeoffs
GainRegressions are caught before merge instead of by users days later
CostThe set must be built and then maintained as intended behaviour legitimately changes
GainPrompt refactoring becomes safe, so prompts stop calcifying out of fear
CostEvery CI run costs model calls; a large suite is a real recurring bill
GainModel migrations become a measurement rather than a leap of faith
CostJudge-scored cases are noisy; thresholds must tolerate variance without hiding regressions
This is unit testing for non-deterministic systems. The mechanics differ — assertions are fuzzier and scores are distributions — but the purpose is identical: change code without silently breaking behaviour.
Cases carry their own assertions
Exact-match expectations are too brittle for generated text. Attach assertions instead, and prefer deterministic ones — they are free, fast, and never flaky. Reserve judge-based scoring for the genuinely subjective qualities that nothing else can check.
class Case(BaseModel):
id: str
input: str
context: dict = {}
tags: list[str] = [] # "refund", "multi-hop", "adversarial", "regression"
assertions: list[Assertion]
CASES = [
Case(
id="refund-outside-window",
input="I want to return something I bought 45 days ago.",
tags=["refund", "policy", "regression"],
assertions=[
Contains(any_of=["30 day", "30-day"]), # cites the actual policy
NotContains(["I can process that refund"]), # must not promise it
CitesSource("returns-policy"),
JudgeRubric("Declines clearly but offers the exchange alternative.", min_score=4),
],
),
]Gate on the delta, not the absolute
An absolute pass-rate threshold is the wrong gate: it blocks work when the suite gets harder and permits slow decay when it does not. Compare against the committed baseline instead, allow a small tolerance for judge noise, and always block on a case that flips from pass to fail — a specific regression matters even when the aggregate looks fine.
TOLERANCE = 0.02 # absorbs judge variance, not real regressions
def gate(current: RunResult, baseline: RunResult) -> GateResult:
newly_failing = baseline.passing_ids - current.passing_ids
if newly_failing:
return GateResult(False, f"regressed: {sorted(newly_failing)}")
if current.score < baseline.score - TOLERANCE:
return GateResult(False, f"score {current.score:.3f} < baseline {baseline.score:.3f}")
for tag in TRACKED_TAGS: # aggregate can hide a broken subgroup
if current.score_for(tag) < baseline.score_for(tag) - TOLERANCE:
return GateResult(False, f"tag '{tag}' regressed")
return GateResult(True, f"{current.score:.3f} vs baseline {baseline.score:.3f}")Check per-tag scores, not just the total. A change that improves twenty easy cases while breaking three adversarial ones shows up as a net gain and ships a security regression.
Grow the set from production
The best cases are the ones that already broke. Make adding a case part of fixing every production bug — the same discipline as writing a regression test — and the suite becomes an accumulating record of everything your system has learned not to do.
def case_from_incident(trace_id: str, expected: str, tags: list[str]) -> Case:
trace = traces.load(trace_id)
return Case(
id=f"regression-{trace_id[:8]}",
input=trace.user_input,
context=trace.retrieval_context,
tags=[*tags, "regression", "from-production"],
assertions=derive_assertions(expected),
)- Prefer deterministic assertions; use judges only for genuinely subjective qualities
- Gate on the delta against a committed baseline, never on an absolute threshold
- Block on any pass-to-fail flip, even when the aggregate improves
- Track per-tag scores so a broken subgroup cannot hide inside the average
- Add a case for every production bug, as a matter of routine
Run judge-scored cases three times and take the median. Single-run judge scores are noisy enough to produce flaky gates, and a flaky gate is one people learn to override.