LLM Evaluation & Regression Harness
A statistically honest A/B gate for LLM systems, and a report that measures how often the usual approach gets the answer wrong.
The library is small: a paired bootstrap, a permutation test, a power
calculation, judge-agreement diagnostics, a content-addressed dataset, and a
compare() that turns all of it into one of five verdicts. The interesting
part is docs/results.md, which is generated by running the library and which
contains 17 written-in-advance predictions, 3 of which the measurements
contradicted.
python run_eval.py --out docs/results.md # the full report (~5 minutes)
python run_eval.py --quick --section 7 # one section, fast, to stdout
python -m pytest tests -q # 299 tests
./test.ps1 # six-stage verification
The problem
Teams shipping LLM features run an eval set before and after a prompt change, compare two averages, and ship if the number went up. Every part of that is defensible in isolation and the combination is unsound:
- The eval set is too small to see the effect being claimed. Detecting a +0.01 quality improvement at 80% power needs 2,309 items on the paired variance measured here. Teams use 50 and report three-decimal deltas.
- A "no significant difference" result is reported as "no regression." These are different claims and the second one does not follow.
- The judge is validated with a statistic that cannot detect the failure it actually has. Cohen's kappa collapses under skew; Gwet's AC1 was built not to, which means it also does not collapse under degeneracy. A judge that answers "pass" to everything scores AC1 above 0.94 on 95%-pass data.
- The winner is chosen from many candidates and its measured gain is reported as its true gain. The selection is the bias.
docs/results.md puts a number on each of these.
Selected findings
| A 50-item eval set has 50.9% power to detect a +0.05 improvement -- and the exaggeration factor is 1.39x, so the changes that do reach significance overstate themselves by 39% | §1 |
| Holding the eval set fixed and varying only how much two systems have in common moves power from 79.0% to 28.5% | §1 |
| Going from +0.05 to +0.01 detectable costs 25x the items (93 to 2,309) | §3 |
| Twenty uncorrected slice comparisons produce a familywise error rate of 41.8% -- not the textbook 64.2%, because a shared baseline correlates the tests. Re-run with independent baselines: 68.8% | §5 |
| The apparent gain of the best of eight candidates is 107.1% winner's curse: all of it evaporates on held-out data | §6 |
| At 92% pass rate, a judge with 93.0% agreement and AC1 0.885 beats a hard-coded string by +0.0040 [-0.0073, +0.0145] -- indistinguishable from zero on 4,000 items. At 97% it is measurably worse than not having one | §7 |
| A judge with a length preference reports +0.0711, significant, for a true effect of +0.0021. Growing the eval set from 25 to 800 narrows the interval 5.1x and buys only confidence in a false conclusion | §9 |
| 8.5% of individual 50-item runs measure a real +0.04 improvement as a decline | §11 |
| BCa coverage at n=20 is 91.0% against the percentile bootstrap's 86.2% | §13 |
The gate
result = compare(baseline_run, candidate_run, dataset)
if result.blocked:
raise SystemExit(result.summary())
Five verdicts, and the fourth is the one that does not usually exist:
| Verdict | Meaning |
|---|---|
IMPROVED |
The interval excludes zero on the upside. |
REGRESSED |
Credible harm, overall or in any single slice. |
INDISTINGUISHABLE |
The effect is above what this eval set could detect, and it was not there. A real null result. |
UNDERPOWERED |
The effect is below what this eval set could detect. Nothing was learned. Reported separately because merging it into the row above is how "no significant difference" becomes "no regression". |
INCOMPARABLE |
The two runs do not describe the same measurement. Fails closed. |
Calibration on 200 trials per scenario at n=120: a genuinely harmful change
(-0.06) is caught 97% of the time and never called an improvement; a
genuine +0.06 is found 92% of the time; a no-op change is called an
improvement 1.5% of the time and UNDERPOWERED 92% of the time.
Why the systems are simulated, and why that is the stronger choice
No LLM is called anywhere in this repository. That is not a limitation worked around; it is what makes the report mean anything.
Every claim here is of the form "the standard method reports X when the truth is Y." That sentence requires knowing Y. With a real model behind an API there is no ground truth to compare against -- you would get a table of measurements and no way to say which were wrong, which is precisely the epistemic position this repository is arguing is untenable. §9 states that a judge reported +0.0711 for a true effect of +0.0021. That claim is only available because the +0.0021 was set, not estimated.
The simulation is also the only way to answer how often. "A 50-item eval set can mislead you" is a truism. "It reports a real +0.04 improvement as a decline in 12% of runs" required 10,000 evaluations under a known effect. At real API prices and latencies that experiment does not get run, which is a large part of why these numbers are not already common knowledge.
The design decision that carries the weight is the shared/private variance
decomposition in SimulatedModel. An item's quality splits into an effect
belonging to the item -- this question is hard, and it is hard for every model
-- and an effect belonging to the model on that item. Only the second differs
between two systems being compared. That ratio is the intraclass correlation,
and it is the entire reason paired analysis works. A simulation with
independent models would make paired statistics look worthless; one with
perfectly correlated models would make them look magical. Both would be
useless. Here it is a parameter, and §1 varies it deliberately to show that
eval set adequacy is a property of how similar your two systems are, not a
property of the number 50.
The statistics are not simulated. The bootstrap, the BCa correction, the permutation test, the Student-t quantiles and the exact sign test are the real implementations, written from primitives (no scipy), validated against published tables, and exercised by 299 tests.
What this does not establish is anything about a specific real model's
quality. See docs/known-limitations.md, which is deliberately blunt.
What is in here
evalharness/
stats.py bootstrap, BCa, permutation, BH, power, MDE, required-n,
Student-t via a continued-fraction incomplete beta,
exact sign test
agreement.py kappa, AC1, prevalence/bias indices, the baseline-margin
check that neither statistic performs
dataset.py content-addressed eval sets; order-independent fingerprints
systems.py simulated models and judges with known ground truth
compare.py the gate
corpus.py dataset generation
report.py the expect/found DSL that makes predictions falsifiable
run_eval.py 14 experiments -> docs/results.md
report.py enforces the discipline the report depends on: a prediction must be
written before the measurement is taken, exactly one finding may answer it, and
render() refuses to produce a document with an open prediction. It is not
possible to quietly drop a prediction that turned out wrong.
Documentation
docs/results.md-- the report. Start at §1 and §7.docs/known-limitations.md-- what this does not establish.docs/adr/-- five decisions and what they cost.docs/portfolio/-- four essays, including04-bugs-the-experiment-found.md, which catalogues seven defects the measurements found in code that already had passing tests -- including one in the mutation suite that verifies the tests.
Requirements
Python 3.12+, numpy. No scipy: every statistical function is implemented from primitives, which was originally a constraint and became the most useful part of the exercise.