Halmurat T.
Halmurat T.

Senior SDET

Home Blog Books ask About

The Dispatch

Weekly QA notes from the trenches.

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

© 2026 Halmurat T.

Automation 28
  • Selenium
  • Playwright
  • Appium
  • Cypress
AI Testing 67
CI/CD 12
  • GitHub Actions
  • Slack Reporting
QA Strategy 10
Case Studies 8
Blog/AI Testing
AI TestingHalmurat T./October 1, 2026/13 min

Your 30-Case AI Eval Can't Tell If the Model Got Worse

Filed underai-testing/llm/playwright/cicd
Your 30-Case AI Eval Can't Tell If the Model Got Worse

Table of Contents
  • Why can’t you compare two models by their eval pass rates?
  • How big does your golden set actually need to be?
  • Use McNemar’s test, not two confidence intervals
  • Implement the exact McNemar test
  • Wire it into a Playwright promotion gate
  • What decision rule should the gate enforce?
  • The one thing to do today

On this page

  • Why can’t you compare two models by their eval pass rates?
  • How big does your golden set actually need to be?
  • Use McNemar’s test, not two confidence intervals
  • Implement the exact McNemar test
  • Wire it into a Playwright promotion gate
  • What decision rule should the gate enforce?
  • The one thing to do today

You bumped the model behind your support assistant from one snapshot to the next, reran your 30-case golden eval, saw 28 green, and shipped. Here’s the uncomfortable part: that 28/30 is not evidence the new model is as good as the old one. It’s not evidence it’s worse, either. With a set that small, a real quality regression and pure sampling noise produce the same number — and you just promoted a model on a coin flip you mistook for a measurement.

This is the failure mode that replaced flaky selectors. Vendors reissue model snapshots on their own schedule, your prompt engineers tweak a system message, someone nudges temperature — and the only thing standing between that change and production is an eval set that was sized to “feel thorough,” not to detect anything. Comparing two models by their aggregate pass rates is the green check that hides a real bug, dressed up in the language of evaluation. Below is why the comparison fails, how big the set actually needs to be, and the paired test that squeezes real signal out of the examples you already have.

Why can’t you compare two models by their eval pass rates?

Because “29/30 vs 28/30” throws away the only information that matters: which specific examples changed. When you run the same golden set through both models, the results are paired — every example was answered by model A and model B. The right question isn’t “did the aggregate drop by a point?” It’s “how many examples did the new model break that the old one got right, versus how many did it newly fix?”

Anthropic’s own guidance on evaluation statistics is blunt about this. Their 2024 paper Adding Error Bars to Evals recommends that “when two models are being compared, conduct statistical inference on the question-level paired differences, rather than the population-level summary statistics.” Aggregate pass rates discard the pairing and inflate your variance for no reason. The examples where both models agree — the vast majority — tell you nothing about which is better; only the discordant pairs do.

§ 28/30 vs 29/30, decomposed

2

Regressions

Old passed, new failed

1

Improvements

Old failed, new passed

p = 0.50

Significance

Two coin flips of signal

Two new failures and one new fix out of thirty is a net of minus one — the aggregate you’d report. Run the paired test on those three discordant cases and the one-sided p-value is 0.50. You cannot call the model regressed. You also cannot call it safe. You have three data points of actual signal, and no amount of staring at “93%” changes that. The same non-determinism that makes a single LLM assertion flaky means a 30-case aggregate is a low-resolution instrument pointed at a small effect.

How big does your golden set actually need to be?

Bigger than you think, and it scales with the inverse square of the regression you want to catch. Detecting a large, obvious collapse is cheap; detecting the 2-to-5-point slide that actually reaches customers is expensive. Here’s what it takes to detect a drop from a 90% baseline at 80% power and a 5% significance level, comparing two models as independent samples:

§ Examples per model version (90% baseline, 80% power)

~200

To catch a 10-pt drop

90% → 80%

~690

To catch a 5-pt drop

90% → 85%

~3,800

To catch a 2-pt drop

90% → 88%

A two-point regression from a 90%-reliable feature — the kind a vendor snapshot ships without a changelog — needs on the order of 3,800 examples to detect reliably if you compare the two runs independently. Thirty examples can only detect a catastrophe: something like a 25-plus-point cliff. Everything subtler slips through with a green eval, which is precisely how model drift ships to production while your mocked tests stay green.

[ WARNING ]

The comfortable size of your eval set is not evidence of anything. A 30-case set that passes 28/30 has roughly the statistical power of no test at all against a realistic 3-to-5-point regression. “It felt thorough” is not a power analysis.

The good news is that pairing buys back a lot of that cost. Because both models see identical inputs, the paired analysis cancels out per-example difficulty — the biggest source of variance — so you need far fewer examples than the independent-sample numbers above. The Anthropic paper reports large variance reductions from paired analysis and answer resampling. You still won’t get there on 30 cases, but a frozen set in the low hundreds, analyzed correctly, becomes a real gate instead of a decoration.

Use McNemar’s test, not two confidence intervals

The correct tool for paired binary outcomes is McNemar’s test. It ignores the examples both models agree on and asks a single question of the discordant pairs: if the two models were truly equal, how surprising is it to see this many new failures versus new fixes? For the small counts typical of a golden set, use the exact binomial form — no chi-square approximation, no minimum-cell-count caveats.

Implement the exact McNemar test

tests/ai/mcnemar.ts
// Exact McNemar. Only discordant pairs carry signal:
// b = regressions (old passed, new failed); c = improvements (old failed, new passed).
function binomCdf(k: number, n: number, p = 0.5): number {
let sum = 0;
for (let i = 0; i <= k; i++) {
let coef = 1;
for (let j = 0; j < i; j++) coef = (coef * (n - j)) / (j + 1);
sum += coef * p ** i * (1 - p) ** (n - i);
}
return sum;
}
// One-sided p: chance of >= b regressions if the models were equal.
export function mcnemarRegressionP(b: number, c: number): number {
const n = b + c;
if (n === 0) return 1; // no disagreements -> no evidence of change
return 1 - binomCdf(b - 1, n); // P(X >= b), X ~ Binomial(n, 0.5)
}

Wire it into a Playwright promotion gate

Wire it into a Playwright project so the promotion gate lives next to the rest of your suite. The eval runs both the incumbent and the candidate model over a frozen set — freeze it the same way you’d freeze AI-generated tests once they’re reviewed, because a set that drifts can’t be a baseline.

tests/ai/model-upgrade.spec.ts
import { test, expect } from '@playwright/test';
import { mcnemarRegressionP } from './mcnemar';
import goldenSet from './golden-set.json'; // frozen inputs + pinned graders
test('candidate model is not a regression on the golden set', async ({ request }) => {
let b = 0, c = 0; // b: regressions, c: improvements
for (const ex of goldenSet) {
const [oldPass, newPass] = await Promise.all([
gradeOn(request, 'model-current', ex),
gradeOn(request, 'model-candidate', ex),
]);
if (oldPass && !newPass) b++;
else if (!oldPass && newPass) c++;
}
const p = mcnemarRegressionP(b, c);
// Fail promotion only when regressions significantly outnumber fixes.
expect(p, `regressions=${b}, improvements=${c}, one-sided p=${p.toFixed(3)}`)
.toBeGreaterThan(0.05);
});
[ TIP ]

Whatever gradeOn uses to decide “pass” has to be pinned first. If your grader is a version-locked, calibrated LLM judge or a deterministic schema check, McNemar tells you something. If the grader itself is noisy, you’re running a significance test on static.

What decision rule should the gate enforce?

Two rules, because significance testing has a known blind spot: on a small set it will almost never fire, and “not significant” is not the same as “safe.” Pair the statistical gate with a hard invariant so an underpowered set can’t rubber-stamp a promotion.

Block on a significant net regression

If mcnemarRegressionP(b, c) <= 0.05, the candidate broke meaningfully more than it fixed. Stop the promotion and route the discordant cases to a human. This catches real slides once your set is large enough to have power.

Block on any new failure in a must-pass slice

Tag a subset of the golden set as non-negotiable — refunds, auth, anything with a compliance blast radius. If the candidate newly fails even one, block regardless of the p-value. This is your safety net while the set is small.

Record the discordant pairs every run

Log which examples flipped, both directions. Over a few upgrades this becomes your real regression corpus — the examples that actually distinguish models — and it’s how you grow the set toward statistical power on purpose instead of by accident.

This gate does not belong on every pull request. Running two model versions across a few hundred graded examples is slow and it costs real money, the same way naive AI-in-CI integrations quietly run up six-figure bills. Put deterministic structure and schema checks on the PR path with a mocked model, and run the paired promotion gate only when a model, prompt, or parameter actually changes — which, given that providers change model behavior over time without warning, means on every vendor snapshot too, not just your own edits.

§ Delta · Model-upgrade gate Real regressions surface

Before

Rerun 30-case eval, compare aggregate pass % — ship on 28/30

→ ↓

After

Paired McNemar on a frozen set + hard must-pass invariant

The one thing to do today

Stop reporting your eval as a single percentage. Change your harness to record a per-example pass/fail vector for the model currently in production and commit it next to the golden set. That one change means the next time a snapshot lands or someone rewrites a prompt, you can run a paired diff — how many examples broke, how many got fixed, and the McNemar p-value — instead of squinting at “93% vs 96%” and guessing. You will not have enough power on day one. But you’ll be measuring the right quantity, on frozen inputs, with a rule a human agreed to — and that’s the difference between a promotion gate and a comforting green number that ships regressions with confidence.

§ Frequently Asked FAQ
+ Why is McNemar's test better than comparing two pass rates?

Because the same golden set run through both models produces paired data, and the examples where both models agree carry no information about which is better. McNemar’s test looks only at the discordant pairs, which removes per-example difficulty as a source of variance. Anthropic’s Adding Error Bars to Evals recommends exactly this — inference on question-level paired differences, not population-level summary statistics. Comparing aggregate pass rates discards the pairing and needs far more examples for the same power.

+ How many examples does an AI eval set need?

It depends on the smallest regression you need to catch, and it scales with the inverse square of that effect. From a 90% baseline at 80% power: catching a 10-point drop needs ~200 examples per version, a 5-point drop ~690, and a 2-point drop ~3,800 when the runs are compared independently. Paired analysis reduces these because it cancels per-example difficulty. A 30-case set only detects a 25-plus-point collapse — which is why it complements, not replaces, a pass@k reliability budget for a single feature.

+ Is a 28/30 eval result good enough to ship a model upgrade?

On its own, no. With 30 examples, a genuine regression and pure sampling noise produce indistinguishable numbers. If 28/30 decomposes into two new failures and one new fix, the paired one-sided p-value is about 0.50 — no evidence either way. Promote only if the candidate clears a paired significance test on a set with real power and passes a hard invariant on your must-pass slice.

+ Where should the paired model-comparison gate run?

Not on every pull request — two model versions over hundreds of graded examples is slow and costly. Keep deterministic structure and schema checks on the PR path with a mocked model, and trigger the paired promotion gate only when a model, prompt, or parameter changes. Since providers change model behavior over time without warning, treat every new vendor snapshot as a change that must clear the gate.

§ Further Reading 03 of 03
01AI Testing

Your AI Agent Is Looping, and No Test Caught It

A max-iterations cap hides livelock instead of catching it. Here's the Playwright test a senior SDET writes to detect cyclic tool calls before they hit prod.

Read →
02AI Testing

Your AI Agent Retries and Charges the Card Twice

A tool call commits but the response is lost, so your AI agent retries and double-charges. Here's the Playwright test a senior SDET writes for exactly-once.

Read →
03AI Testing

Your AI Feature Has Three Clocks. They Disagree.

Your browser clock, your server clock, and the model's idea of now rarely agree. Pin all three in Playwright before a date bug quietly reaches production.

Read →

Don't miss a thing

Subscribe to get updates straight to your inbox.

HT

No spam · Unsubscribe anytime

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

§ Colophon

Halmurat T. — Senior SDET writing about test automation, CI/CD, and QA strategy from 10+ years in the enterprise trenches.

Set in
IBM Plex Sans, Lora, and IBM Plex Mono.
Built with
Astro, MDX, Tailwind CSS & Expressive Code. Served by Vercel.
Privacy
No cookies. No tracking scripts on the main thread — analytics run sandboxed via Partytown.
Source
github.com/Halmurat-Uyghur
Terminal
Try /ask to query Halmurat's notes in a shell prompt.

© 2026 Halmurat T. · Written in plain text, shipped in plain time.

Search
Esc

Search is not available in dev mode.

Run npm run build then npm run preview:local to test search locally.