|
| 1 | +# Most of your eval regressions aren't real |
| 2 | + |
| 3 | +You change a prompt. Your eval suite goes from 82% to 78%. |
| 4 | + |
| 5 | +What do you do? |
| 6 | + |
| 7 | +Most people revert. Some ship anyway and feel bad about it. Almost nobody asks the |
| 8 | +only question that matters, which is whether 78% and 82% are different at all. |
| 9 | + |
| 10 | +Here is that exact comparison, run through the tool I will describe below: |
| 11 | + |
| 12 | +``` |
| 13 | +verdict: INCONCLUSIVE |
| 14 | +effect: -0.0400 (0.8200 -> 0.7800) |
| 15 | +95% CI: [-0.1600, +0.0000] |
| 16 | +p: 0.625 (exact McNemar) |
| 17 | +paired: 50 cases |
| 18 | +``` |
| 19 | + |
| 20 | +Four of your fifty cases moved. The interval spans everything from a sixteen point |
| 21 | +regression to no change whatsoever. McNemar puts it at p = 0.63. There is no |
| 22 | +evidence here in either direction. You learned nothing, and if you reverted, you |
| 23 | +reverted on a coin flip. |
| 24 | + |
| 25 | +This is not a contrived example. It is what a 50-case suite looks like, and 50 |
| 26 | +cases is what Anthropic's own guidance suggests you start with. |
| 27 | + |
| 28 | +## Nothing in your toolchain can tell you this |
| 29 | + |
| 30 | +I went looking for a tool that would. promptfoo, DeepEval, Ragas, Braintrust |
| 31 | +autoevals, Langfuse, Phoenix, pydantic-evals. Every one of them computes a mean |
| 32 | +and compares it to a threshold you hardcoded. |
| 33 | + |
| 34 | +`assert score > 0.8` |
| 35 | + |
| 36 | +That comparison is close to meaningless on a suite this size, and it is the actual |
| 37 | +mechanism behind the thing everyone complains about: flaky eval CI. Your suite |
| 38 | +reports 82% today and 78% tomorrow, the tool says REGRESSION with total |
| 39 | +confidence, and you go hunting for a bug that was never there. Then it happens |
| 40 | +often enough that you stop believing the tool, which is worse than not having it. |
| 41 | + |
| 42 | +The field has good data collection. OpenTelemetry tracing is solved, and every |
| 43 | +observability vendor will happily store your spans. It has good scoring |
| 44 | +primitives: judges, assertions, rubrics. What it does not have is any statistics |
| 45 | +between "we ran an eval" and "we know whether this change is real." |
| 46 | + |
| 47 | +## The fix is twenty months old and nobody implemented it |
| 48 | + |
| 49 | +Evan Miller, an econometrician at Anthropic, published |
| 50 | +[*Adding Error Bars to Evals*](https://arxiv.org/abs/2411.00640) in November 2024. |
| 51 | +It is not a research breakthrough. It is a patient explanation that eval scores |
| 52 | +are a *sample*, that samples have standard errors, and that undergraduate |
| 53 | +experimental design applies. Report standard errors. Cluster them when your |
| 54 | +questions come in groups. Compare paired, not unpaired. Do a power analysis. |
| 55 | + |
| 56 | +As of July 2026, essentially zero mainstream eval tools implement any of it. |
| 57 | + |
| 58 | +I do not think that is because it is hard. The methods are standard. I think it is |
| 59 | +because eval tooling is built by product engineers and this is a statistics |
| 60 | +problem, and those are different people. Miller saw it because he is an |
| 61 | +econometrician looking at a field that had reinvented A/B testing without |
| 62 | +noticing. |
| 63 | + |
| 64 | +So I implemented it. The result is [OpenParity](https://github.com/Skits0098/OpenParity), |
| 65 | +which does exactly one thing: you hand it per-case results from two runs, and it |
| 66 | +tells you whether the difference is real. |
| 67 | + |
| 68 | +It does not run your evals. It does not define graders. It never makes a network |
| 69 | +call. That constraint is what lets its entire test suite run in ten seconds with no |
| 70 | +API key, which matters more than it sounds, and I will come back to it. |
| 71 | + |
| 72 | +Three things I learned building it. The second one is the one I did not expect. |
| 73 | + |
| 74 | +## 1. The gate everyone would build rewards you for having a worse eval suite |
| 75 | + |
| 76 | +The obvious upgrade to `assert score > 0.8` is a significance test. Instead of |
| 77 | +comparing to a threshold, ask: is the candidate significantly worse than baseline? |
| 78 | + |
| 79 | +This is better. It is also broken, and the way it is broken is instructive. |
| 80 | + |
| 81 | +A significance test asks "can I reject the hypothesis that nothing changed?" With |
| 82 | +a small suite, you cannot reject anything. There is not enough evidence to reject |
| 83 | +with. So a small suite passes. Always. |
| 84 | + |
| 85 | +Shrink your eval suite and your build goes green. |
| 86 | + |
| 87 | +That is a catastrophic incentive to bake into a regression tool, and it is a |
| 88 | +completely natural thing to build. You would not notice for months, because the |
| 89 | +failure mode is silence: your CI is green, and it is green because you are not |
| 90 | +looking hard enough to see anything. |
| 91 | + |
| 92 | +The fix is to ask the opposite question. Not "is it worse?" but "can I *prove* it |
| 93 | +is close enough?" That is an equivalence test, TOST, and it is standard in |
| 94 | +pharmacology, where "we failed to detect a difference between this generic and the |
| 95 | +real drug" is obviously not the same claim as "this generic works." |
| 96 | + |
| 97 | +Now an underpowered suite *fails*, because it cannot establish anything either. |
| 98 | +The incentive inverts. To get a green build you need a suite capable of supporting |
| 99 | +a claim. You declare a margin, which is your honest statement of what you do not |
| 100 | +care about, and the tool proves the change stays inside it or admits it cannot. |
| 101 | + |
| 102 | +This gives you four verdicts instead of two: |
| 103 | + |
| 104 | +| | | |
| 105 | +|---|---| |
| 106 | +| `equivalent` | Proven within your margin. Ship. | |
| 107 | +| `regressed` | Significantly worse. Block. | |
| 108 | +| `improved` | Significantly better. | |
| 109 | +| `inconclusive` | Your suite cannot answer. **Not a pass.** | |
| 110 | + |
| 111 | +That fourth one is the whole product. Every other tool has two states, so whenever |
| 112 | +the honest answer is "you don't have enough evidence," it has to pick one and lie. |
| 113 | + |
| 114 | +## 2. The obvious way to handle grouped cases is silently wrong |
| 115 | + |
| 116 | +This is the part I got wrong, and I only know I got it wrong because I wrote the |
| 117 | +tests first. |
| 118 | + |
| 119 | +Eval cases are usually not independent. You have five questions about the same |
| 120 | +document, or eight turns in the same scenario, or twelve prompts against the same |
| 121 | +customer record. When cases share a parent, they move together. Treating them as |
| 122 | +independent understates your uncertainty, and the literature puts the |
| 123 | +understatement above 3x. |
| 124 | + |
| 125 | +Everyone who knows this reaches for the same fix: resample whole clusters instead |
| 126 | +of individual cases. The pairs cluster bootstrap. It is the obvious move, it is |
| 127 | +what I planned, and it is what I built. |
| 128 | + |
| 129 | +Then I ran the calibration test. A 95% confidence interval makes a falsifiable |
| 130 | +promise: across repeated experiments, it contains the true value 95% of the time. |
| 131 | +So generate data where you *know* the true answer, build a thousand intervals, and |
| 132 | +count. |
| 133 | + |
| 134 | +Mine contained the truth 89.5% of the time. |
| 135 | + |
| 136 | +Nominal 95%, actual 89.5%. My intervals were too narrow, which means my tool would |
| 137 | +call regressions that were not there, which is the exact failure it exists to |
| 138 | +prevent. If I had shipped on "the method is standard and my code looks right," I |
| 139 | +would have shipped a tool that lies. |
| 140 | + |
| 141 | +So I measured coverage against the number of clusters: |
| 142 | + |
| 143 | +``` |
| 144 | +Continuous data, nominal 95%: |
| 145 | +
|
| 146 | + clusters pairs bootstrap CRVE + t(G-1) |
| 147 | + 6 0.845 0.945 |
| 148 | + 12 0.902 0.945 |
| 149 | + 20 0.917 0.937 |
| 150 | + 30 0.923 0.947 |
| 151 | + 50 0.950 0.960 |
| 152 | +``` |
| 153 | + |
| 154 | +And on binary pass/fail data, which is what most eval suites actually produce: |
| 155 | + |
| 156 | +``` |
| 157 | + clusters pairs bootstrap CRVE + t(G-1) |
| 158 | + 5 0.840 0.953 |
| 159 | + 8 0.890 0.949 |
| 160 | + 12 0.931 0.960 |
| 161 | + 20 0.948 0.958 |
| 162 | +``` |
| 163 | + |
| 164 | +This is the documented "few clusters" problem, and there is the shape of it. The |
| 165 | +pairs cluster bootstrap needs roughly fifty clusters before it delivers the |
| 166 | +coverage it advertises. |
| 167 | + |
| 168 | +**Real eval suites do not have fifty clusters.** Group your cases by source |
| 169 | +document and you have eight. At eight, the method quietly gives you 89% coverage |
| 170 | +and prints "95%" on the label. |
| 171 | + |
| 172 | +The fix is a cluster-robust standard error with a t(G-1) critical value, which |
| 173 | +holds ~95% down to five clusters. What does the work there is not the robust |
| 174 | +variance, it is the t correction: at six clusters, t(5) puts the critical value at |
| 175 | +2.57 instead of 1.96, and that gap is the entire difference between 84% and 95% |
| 176 | +actual coverage. |
| 177 | + |
| 178 | +I also measured a wild cluster bootstrap with Rademacher weights. It is |
| 179 | +statistically indistinguishable from CRVE here. CRVE won on engineering grounds: |
| 180 | +a third of the code, analytic rather than resampled, and deterministic, so CI can |
| 181 | +never see the same data produce different bounds. |
| 182 | + |
| 183 | +The general lesson is not about clusters. It is that statistical code fails |
| 184 | +silently. It does not throw. It does not crash. It returns a plausible number with |
| 185 | +a confidence interval attached, and the interval is a lie, and nothing anywhere |
| 186 | +tells you. The only defense is to generate data where you know the answer and |
| 187 | +check that your tool finds it. |
| 188 | + |
| 189 | +Which is why the "no network calls" constraint earns its keep. Because the core is |
| 190 | +pure functions over arrays, I can run ten thousand simulated comparisons in |
| 191 | +seconds for free, on every commit. An eval tool that needs a model to test itself |
| 192 | +cannot do this, and so mostly does not. |
| 193 | + |
| 194 | +## 3. You cannot compute power from one run |
| 195 | + |
| 196 | +I wanted a command that says "your suite is too small, here is how many cases you |
| 197 | +need." Everyone wants this. |
| 198 | + |
| 199 | +You cannot build it from a baseline run. The power of a paired test depends on the |
| 200 | +standard deviation of the per-case *differences*, and that depends on the |
| 201 | +correlation between two runs. One run does not contain that information. Any tool |
| 202 | +that offers power analysis from a single eval set is either guessing sigma or |
| 203 | +silently assuming your runs are independent, which they are not. |
| 204 | + |
| 205 | +What it needs is a **null pair**: run your baseline twice, changing nothing. Same |
| 206 | +prompt, same model, same config. The only difference is sampling randomness. That |
| 207 | +measures your actual noise floor. |
| 208 | + |
| 209 | +This turns out to be the cheapest useful thing you can do to an eval suite. No |
| 210 | +labelling, no judging, no new cases. Just run it twice. And almost nobody has. |
| 211 | + |
| 212 | +Here is what it said about that 50-case suite: |
| 213 | + |
| 214 | +``` |
| 215 | +suite: 50 cases |
| 216 | +noise: SE of the mean difference = 0.0896 (+/-10%, from your null pair) |
| 217 | +goal: prove equivalence within +/-0.0300 at alpha=0.05 |
| 218 | +power: 0.0% (target 80%) |
| 219 | +
|
| 220 | +NOT adequately powered. You need about 3822 cases (+3772) to reach 80% power. |
| 221 | +``` |
| 222 | + |
| 223 | +Three thousand eight hundred cases. That number is brutal, and it is not a bug. |
| 224 | +Proving a change stays within three points when your run-to-run noise is nine |
| 225 | +points genuinely takes thousands of cases. That is arithmetic, not pessimism. |
| 226 | + |
| 227 | +The useful response is not to go label 3,772 cases. It is one of: |
| 228 | + |
| 229 | +- **Widen the margin.** Three points was probably a number you picked because it |
| 230 | + sounded rigorous, not because you can tell the difference between a 2% and a 4% |
| 231 | + regression in production. Pick the margin you actually care about. |
| 232 | +- **Cut the noise floor.** Sample each case several times and average. This costs |
| 233 | + inference, not labelling, and it is usually far cheaper than new cases. It also |
| 234 | + converts binary outcomes into continuous ones, which carry strictly more |
| 235 | + information per case. |
| 236 | +- **Accept that you are shipping on judgment.** Which is fine. It is what you are |
| 237 | + doing right now. The difference is knowing it. |
| 238 | + |
| 239 | +What you should not do is keep making ship decisions from a 50-case suite and |
| 240 | +believe them. |
| 241 | + |
| 242 | +## The uncomfortable part |
| 243 | + |
| 244 | +If you run this on your own eval suite, the first thing it will tell you is that |
| 245 | +your suite cannot resolve the question you have been asking it for months. Most |
| 246 | +suites return `INCONCLUSIVE` on their first real comparison. That is the tool |
| 247 | +working, and it is not a pleasant experience. |
| 248 | + |
| 249 | +It also means some of the prompt changes you shipped were noise you read as |
| 250 | +signal, and some you reverted were improvements you threw away. There is no way to |
| 251 | +find that out that feels good. |
| 252 | + |
| 253 | +But the alternative is what everyone is doing now: eyeballing a handful of outputs |
| 254 | +and calling it engineering, while a dashboard reports 78% with three significant |
| 255 | +figures and no error bar. The number is not wrong. It is just not evidence, and |
| 256 | +the two are easy to confuse when one of them is rendered in a nice font. |
| 257 | + |
| 258 | +## Limits, honestly |
| 259 | + |
| 260 | +- **It does not run your evals.** It consumes per-case results from whatever |
| 261 | + produced them. That is on purpose, so it composes with your existing tooling |
| 262 | + instead of replacing it, but it means you have to get results out of your runner |
| 263 | + in a format it can read. |
| 264 | +- **It cannot fix a bad eval.** If your judge is miscalibrated, OpenParity will |
| 265 | + give you a beautifully rigorous confidence interval around a meaningless number. |
| 266 | + Statistics do not rescue construct validity. |
| 267 | +- **The power estimate inherits your noise floor's error.** The relative standard |
| 268 | + error of an SE estimate is about `1/sqrt(2n)`, so about 16% from a 20-case null |
| 269 | + pair. "You need 355 cases" from a small null pair means "a few hundred," not |
| 270 | + three significant figures. The tool says so rather than printing false precision. |
| 271 | +- **Below about five clusters, everything is doing heavy lifting.** Coverage held |
| 272 | + in simulation, but the intervals get wide fast and lean hard on the t |
| 273 | + correction. Treat borderline verdicts with suspicion. |
| 274 | + |
| 275 | +## Try it |
| 276 | + |
| 277 | +```bash |
| 278 | +pip install openparity |
| 279 | +openparity compare runs/main.json runs/pr.json --margin 0.03 |
| 280 | +``` |
| 281 | + |
| 282 | +Exit 0 to ship, 1 to block, 2 for "your suite cannot answer this," which also |
| 283 | +blocks, because treating no evidence as success is the failure the whole thing |
| 284 | +exists to prevent. |
| 285 | + |
| 286 | +Source and the full design notes, including the decisions I got wrong first: |
| 287 | +[github.com/Skits0098/OpenParity](https://github.com/Skits0098/OpenParity) |
| 288 | + |
| 289 | +The statistics are verified rather than asserted. Coverage, false alarm rates, |
| 290 | +clustering, pairing, and power are each checked against data whose true answer is |
| 291 | +known by construction, on every commit. If those tests fail, everything above is |
| 292 | +worthless. They are the first thing I would look at, and you should too. |
| 293 | + |
| 294 | +--- |
| 295 | + |
| 296 | +### On how this was built |
| 297 | + |
| 298 | +This was written with heavy AI assistance, in one session, using Claude. Every |
| 299 | +commit carries a `Co-Authored-By` trailer, and you can check that against the |
| 300 | +history rather than take my word for it. |
| 301 | + |
| 302 | +I want to be precise about what that does and does not mean, because "AI wrote it" |
| 303 | +is doing a lot of unearned work in both directions right now. |
| 304 | + |
| 305 | +The code was largely generated. What I chose was the scope, and on this project |
| 306 | +the scope was most of the work. This started as something much larger and much |
| 307 | +worse: an open-source "AI workflow control plane" with a dashboard, a durable |
| 308 | +execution engine, approval gates, and traces. That idea is dead for good reasons. |
| 309 | +The orchestration category is saturated, the human-approval piece is a graveyard |
| 310 | +of near-identical abandoned projects, and a broad platform is now the cheapest |
| 311 | +artifact anyone can generate, which is exactly why it signals nothing. Killing it |
| 312 | +and keeping the one piece nobody had built was the decision that mattered. |
| 313 | + |
| 314 | +The other choice worth naming is writing the tests before the implementation, and |
| 315 | +it paid off in a way I did not predict. The cluster bug in section 2 was not |
| 316 | +caught by review or by reasoning. It was caught because a simulation made a |
| 317 | +falsifiable claim and the claim failed. Reading the code would never have found |
| 318 | +it: the code was *correct* for the method it implemented. The method was wrong for |
| 319 | +the data, and the only thing that can tell you that is measurement. |
| 320 | + |
| 321 | +Which is the honest summary of what AI assistance is and is not good for here. It |
| 322 | +will write you a clean, well-documented, confidently wrong statistical library. |
| 323 | +It will not tell you the method is wrong, because it does not know either. The |
| 324 | +simulation knows. |
| 325 | + |
| 326 | +If you want to evaluate this repo, do not evaluate the prose. Run |
| 327 | +`pytest tests/test_calibration.py`, then go break something in `inference.py` and |
| 328 | +watch what happens. |
0 commit comments