Turn evals into a merge gate that blocks regressions, controls judge spend, defends against contamination, and pages you when production quality drifts.
An eval harness in CI is the machine that turns "we think this prompt is better" into a number that either lets a PR merge or blocks it. Think of it as a test suite where the assertions are statistical, not deterministic: instead of assertEqual, you have assert ndcg@10 >= baseline - 0.02. The hard part is almost never the metric math — it's the operational scaffolding around it: versioning the dataset so scores are comparable across commits, capping judge spend so a 5,000-row suite doesn't cost $40 per run, keeping the test set out of the training loop, and wiring online monitoring so the silent regressions (a provider model bump, a new user cohort) page you before users churn. The mental model: offline evals are a merge gate for changes you make; online evals are an alarm for changes that happen to you. Everything below is about building both so they're cheap, trustworthy, and hard to game.
This is the question that separates engineers who use evals from engineers who own the eval platform. The signal differs sharply by level:
The tell: weak answers describe a metric. Strong answers describe a system with feedback loops, owners, and known ways it rots.
The words first.
Step by step.
Remember this: the metric is the easy 10%; the dataset versioning, cost control, and feedback loops are the 90% that makes the gate trustworthy.
Eval-driven development (EDD) is test-driven development for probabilistic systems. In TDD, a failing test is the spec; you write code until it passes. In EDD, a failing eval is the spec, but "passing" is a distribution, not a boolean. Every prompt tweak, model swap, retrieval-chunking change, and tool-schema edit becomes a measurable experiment that runs through the same suite before it ships (Braintrust). The reason eyeball testing fails at scale is interaction: a prompt change that fixes three reported bugs silently breaks a fourth behavior nobody spot-checked. The gate's job is to make those interactions visible in the PR, not in an incident channel.
The non-negotiable architectural decision: the gate compares against a baseline, not an absolute floor. Models and data move; a hardcoded faithfulness >= 0.85 either rots into noise or blocks legitimate improvements. Relative thresholds — "must not degrade more than 2 points versus current production" — are the workhorse, with a few absolute floors for safety-critical dimensions (refusal correctness, PII leakage) (Latitude).
A score is only meaningful relative to the exact dataset that produced it. If engineer A adds 40 rows to the golden set in the same PR that changes the prompt, the metric delta now conflates two things and the gate is lying. Three rules make this work:
(commit, dataset_hash, model_id, prompt_hash, metric, value).Symbols in plain words: baseline = the metric value from current production, stored from the last green main build. candidate = the metric value from this PR's run. tol = tolerance, the worst drop we'll allow before failing the build.
Say we gate on retrieval nDCG@10. Baseline from production is 0.812. The PR (a new chunking strategy) scores 0.788 on the same pinned dataset. We set tol = 0.02.
Compute the delta: 0.788 - 0.812 = -0.024. Is -0.024 < -0.02? Yes. So the gate fails — the drop of 2.4 points exceeds the 2-point tolerance. The PR is blocked even though 0.788 "looks fine" in isolation.
Now suppose a second PR scores 0.799: delta -0.013, which is within tolerance, so it passes but logs a warning. What this did to the data: it converted a vague "is this better?" into a deterministic merge decision, but only because the dataset hash was identical across both runs — change the dataset and the comparison is meaningless.
If you grade with an LLM judge, cost and latency become first-class engineering constraints. A 5,000-row suite with a chain-of-thought judge at ~1,500 tokens in / 400 out per row is roughly 9.5M tokens per run; at frontier-judge pricing that's real money per PR, multiplied by every push. Four levers, in order of leverage:
hash(system_output + reference + rubric_version). If the output didn't change, the score is cached — most PRs touch one feature and leave 95% of outputs byte-identical, so cache hit rates of 90%+ are normal. This alone is the biggest win.Budget it explicitly: a gate that costs $40 and 25 minutes per run will get disabled by the team within a month, and a disabled gate catches nothing.
Three forces quietly invalidate eval scores, and staff candidates are expected to name all three.
Test-set leakage into pretraining. Public benchmark items and solutions are all over the internet-scale training corpora, so a model may have memorized the answers. String-match detection fails because paraphrases evade it (arXiv 2505.18102). Defense: prefer production-derived golden sets over public benchmarks for anything gate-worthy, and rephrase items to detect concealed contamination (if a paraphrase tanks the score, the original was memorized).
Leakage via your own feedback loop. The subtler killer: you tune prompts/models iteratively against the offline set, then evaluate on that same set. Even a private test set overfits through this loop — you're doing gradient descent by hand on the eval (arXiv 2311.04850). Defense: keep a locked holdout that's only ever run at release, never during iteration, and rotate fresh items in from production.
Benchmark saturation. As a metric approaches its ceiling, it stops discriminating — the gap between two systems shrinks into the noise floor, and construction artifacts dominate. A saturated eval gives false confidence. Defense: retire saturated metrics, add harder production-grounded cases, and watch for the smell that "everything passes at 0.98."
Offline and online evals are not redundant — they catch disjoint classes of failure:
The handoff is bidirectional: online monitoring surfaces novel failures, which become golden rows, which strengthen the offline gate. A harness without the online half will pass every PR while quality erodes from forces no merge introduced.
A real, runnable CI gate. It loads a pinned, hashed dataset, runs the system, computes a deterministic retrieval metric plus a cached judge metric, compares against a stored baseline, and exits non-zero to block the merge. This is the shape of the thing — swap run_system and judge for your stack.
# eval_gate.py — run in CI; exit 1 blocks the merge.
import hashlib, json, os, sys
from pathlib import Path
TOL = {"ndcg@10": 0.02, "faithfulness": 0.03} # max allowed drop vs baseline
ABS_FLOOR = {"faithfulness": 0.80} # hard safety floor
def load_dataset(path="golden/v7.jsonl"):
rows = [json.loads(l) for l in Path(path).read_text().splitlines()]
h = hashlib.sha256(Path(path).read_bytes()).hexdigest()[:12]
return rows, h
def ndcg_at_10(retrieved_ids, relevant_ids):
import math
dcg = sum(1.0 / math.log2(i + 2)
for i, rid in enumerate(retrieved_ids[:10]) if rid in relevant_ids)
ideal = sum(1.0 / math.log2(i + 2)
for i in range(min(len(relevant_ids), 10)))
return dcg / ideal if ideal else 0.0
# --- judge with a content-addressed cache so unchanged outputs cost $0 ---
CACHE = Path(".eval_cache/judge.json")
_cache = json.loads(CACHE.read_text()) if CACHE.exists() else {}
def judge_faithfulness(answer, context, rubric_version="r3"):
key = hashlib.sha256(f"{answer}||{context}||{rubric_version}".encode()).hexdigest()
if key in _cache:
return _cache[key] # cache hit: no API call
score = call_llm_judge(answer, context) # your judge call (CoT -> 0..1)
_cache[key] = score
return score
def run_eval():
rows, dhash = load_dataset()
ndcgs, faiths = [], []
for r in rows:
out = run_system(r["query"]) # your pipeline under test
ndcgs.append(ndcg_at_10(out["retrieved_ids"], r["relevant_ids"]))
faiths.append(judge_faithfulness(out["answer"], out["context"]))
CACHE.parent.mkdir(exist_ok=True)
CACHE.write_text(json.dumps(_cache))
return {"ndcg@10": sum(ndcgs)/len(ndcgs),
"faithfulness": sum(faiths)/len(faiths)}, dhash
def gate(candidate, dhash):
baseline = json.loads(Path("baselines/main.json").read_text())
assert baseline["dataset_hash"] == dhash, \
"Dataset changed vs baseline — re-baseline in a separate PR before gating."
failed = []
for m, val in candidate.items():
drop = baseline["metrics"][m] - val
if drop > TOL[m]:
failed.append(f"{m}: {val:.3f} regressed {drop:.3f} (> {TOL[m]})")
if m in ABS_FLOOR and val < ABS_FLOOR[m]:
failed.append(f"{m}: {val:.3f} below floor {ABS_FLOOR[m]}")
return failed
if __name__ == "__main__":
cand, dhash = run_eval()
print(json.dumps(cand, indent=2))
fails = gate(cand, dhash)
if fails:
print("EVAL GATE FAILED:\n " + "\n ".join(fails)); sys.exit(1)
print("EVAL GATE PASSED"); sys.exit(0)The load-bearing details: the dataset hash assertion forbids comparing across different datasets — it forces dataset changes into their own re-baselining PR. The judge cache is content-addressed, so a PR that touches one prompt re-grades only the rows whose outputs actually changed. Both relative tolerances and absolute floors apply — faithfulness can't quietly slide and can't drop below the safety floor. In a real setup you'd run this under a framework that gives you tracing and a log viewer (Inspect AI's Dataset → Task → Solver → Scorer primitives, adopted by Anthropic and DeepMind, or DeepEval's pytest-native metrics for CI integration), but the control flow is exactly this (Inspect AI).
| Decision | Cheap / fast option | Quality-max option | What changes at scale |
|---|---|---|---|
| Grading | Deterministic (nDCG, recall, schema) | LLM-as-judge w/ CoT | Judge cost dominates; cache + tier or it gets disabled |
| Threshold | Absolute floor | Relative vs. baseline + floors | Baselines must be versioned & auto-updated on green main |
| Suite size | 200-row smoke per push | 5k full suite on PR/nightly | Sampling variance; need confidence intervals, not point deltas |
| Dataset source | Public benchmark | Production-derived golden set | Public sets saturate/leak; prod sets need labeling pipeline |
| Online evals | None | Sampled judge on live traffic | Sampling rate vs. cost; alert fatigue if thresholds too tight |
Cost/latency. The gate must run in the time and money budget of a normal CI check — single-digit minutes, cents-to-low-dollars per PR — or engineers route around it. Caching and tiering are not optimizations; they're survival.
Quality / statistical honesty. A 1-point metric move on a 200-row suite is often noise. At scale you report confidence intervals and gate on whether the regression is significant, not just negative — otherwise the gate flaps red on random seeds and the team learns to retry until green, which is functionally a disabled gate.
Failure modes that bite in production:
faithfulness >= 0.85 either rots into a no-op or blocks legitimate improvements when the data gets harder. A relative gate ("don't drop more than 2 points vs. current production") tracks the moving baseline and asks the only question that matters for a merge: did this change make things worse? You keep a few absolute floors for safety-critical dimensions (PII, refusal correctness) where any regression is unacceptable regardless of baseline.Flashcard. Offline evals are a merge gate for changes you make; online evals are an alarm for changes that happen to you. A harness needs both, plus a feedback loop that turns production failures into golden rows.
Next: /evals/llm-as-judge — calibrating the judge whose scores this whole gate depends on.