ealeng
Menu

Episode 07 of 10

Quality gates for voice AI

How to write quality gates for voice AI: a bigger model was better on every kind of speech, and the gate was right to block it, because on a dial tone it wrote "You". Gates that catch real regressions without crying wolf.

Lab 07 · Quality Gates in CI12 min read
Video transcript6:01

0:00Cold open

0:01

I opened a pull request to switch my speech model to a bigger one.

0:05

On every kind of speech, it got two to six points fewer words wrong.

0:09

And my CI blocked it.

0:11

Here’s why. This is a dial tone. Nobody is talking.

0:16

🔊 a dial tone

0:19

The new model wrote: You.

0:22

So, was the gate right to block a better model?

0:26Intro

0:28

Hands-on voice AI evals, episode seven.

0:31

Today: turning all these tests into automatic checks that run on every change.

0:3701 · A gate, written down

0:38

A quality gate is an automatic check in CI that can block a change. It starts as a sentence, like: no worse on phone calls. Then it has to be written down precisely.

0:50

Compared with what? The baseline: the results of the last model we accepted, clip by clip.

0:56

On which audio? The phone slice: clips through a phone line, with people talking in the background.

1:01

How much may it move? A point and a half of word error rate.

1:05

And why does this gate exist? The person who sees it fail will ask, so it’s written right there.

1:11

A missing reason, a misspelled kind of gate, or a missing tolerance stops the whole run, instead of quietly skipping a gate.

1:1902 · Nine gates

1:21

I wrote nine gates, of six kinds. Each one catches a different failure.

1:26

A relative gate fails only if two things are true: it got worse by more than the tolerance, and the paired test from episode four says it isn’t luck. The test stops noise. The tolerance stops tiny real changes.

1:40

An absolute ceiling: read speech must never pass ten percent wrong. Otherwise, many small accepted steps could add up there.

1:48

And no new failures: no clip of silence or tones that the baseline left empty may start getting text. That’s the gate that blocked my better model.

1:58

Then there are six must-pass phrases from real calls: numbers and names that must always come out right. Like this one.

2:06

🔊 “…add up to around 10% CapEx to sales ratio…”

2:14

The smaller model wrote: ten percent cabbage to sales ratio.

2:19

One wrong word in a long sentence barely moves an average. A must-pass gate fails it outright.

2:2603 · Seven pull requests

2:28

To check the gates, I opened seven pull requests: other models, and versions I broke on purpose.

2:34

A smaller model: blocked, by five gates, including cabbage.

2:38

One writes Thank you on every clip it left empty. On silence, that’s a made-up sentence.

2:44

Word error rate didn’t move at all. Silence has no words to get wrong. Only the no-new-failures gate caught it.

2:51

And one drops one percent of the words. It passed: read speech got 0.7 points worse, real, but inside the tolerance. That’s right on the line, so on another day it might block.

3:03

A slower version only gets a warning. Speed depends on the machine CI runs on, so it never blocks.

3:1104 · Red, or couldn’t measure

3:13

When CI fails, it should also say what kind of failure it is.

3:17

Exit one means: I measured, and quality got worse.

3:20

Exit two means: I couldn’t measure. The audio changed, an API key is missing, the provider failed.

3:27

If both show the same red, people learn to ignore red, and real regressions get through.

3:3305 · Does it cry wolf?

3:35

Next, I checked the gates don’t fail when nothing changed. Like in episode four: run the model against itself.

3:42

The same model, on the same audio, three times. Two clips came out differently.

3:47

Quiet room noise: once dots, once nothing, and once: so that’s it for us, we’ll see you in the next video.

3:54

Use one run as the baseline and another as the change, and the no-new-failures gate blocked two comparisons out of six. On pure noise.

4:03

The fix: record the baseline from several runs. A clip that failed in any of them already counts as failing.

4:10

With an online model, a one-run baseline blocked four pull requests in twenty, with nothing changed. Merging three runs brought it down to one, or none.

4:2006 · What can it catch?

4:22

And the other direction: what can the gates actually catch? There are two tiers. Forty-five clips on every pull request, and all hundred and twenty nightly.

4:31

I took the baseline’s own transcripts, dropped words on purpose, and counted how often each gate blocked.

4:38

Tiny regressions, well under the tolerance: almost never blocked. That’s by design.

4:43

Bigger ones: the full tier blocks them reliably, about one point past the tolerance.

4:49

The smoke tier needs about two points past. With fewer clips, it’s blurrier: it misses more above the line, and blocks more often below it. Right at the line, both tiers flip a coin.

5:01

So a green check on a small tier means: nothing this tier can see. Not: nothing changed.

5:0807 · Back to the better model

5:09

So, back to the better model. On clips with nobody talking, it fixed four and broke two. Fewer errors, but new made-up text.

5:17

So yes, the gate was right: not to say no forever, but to make a person look. If I accept it, I update the baseline in the same pull request, and write the reason.

5:27

Then the trade-off is in the diff, for reviewers to see. If the gate just subtracted, four fixed minus two new looks fine. But so would one fixed and one brand-new made-up sentence.

5:39

Gates stop bad changes before release. But after release, there’s no answer key at all. Next time: watching a model in production.Yuki: And after release? 🤔

5:49Summary

5:50

Write each gate down with its reason. Mix the kinds. Prove it doesn’t cry wolf, and measure what it can catch.

5:56

Then a red build means something.Yuki: Thanks! 😄

Key takeaways

  • ✓A gate is a sentence with every choice written down: which number, on which audio, compared with what, how much movement is allowed, block or warn, and why it exists.
  • ✓A relative gate fails only if the change is bigger than the tolerance and its 95% interval excludes zero. The tolerance stops tiny real losses from blocking; the interval stops noise.
  • ✓Mix gate kinds: relative gates, an absolute ceiling, budgets, "no new failures" and must-pass phrases each catch something the others can't. "Thank you." on silence moved WER by exactly 0.
  • ✓Three results, not two: regressed (exit 1) and couldn't measure (exit 2) need different people and different fixes.
  • ✓Prove the gate both ways: an A/A test for false alarms (a single-run baseline blocked 1 PR in 5 on noise) and injected regressions for what each tier can actually catch.

A quality gate is an automatic check that runs on every pull request and blocks a change that makes things worse. This chapter turns Chapters 1–6 into gates: paired comparisons (Chapter 4), slices (Chapter 2), rare failures on non-speech (Chapter 3). Then it proves the gates work: they don't fail when nothing changed, and they catch regressions of a known size. The lab's sharpest case is a gate that blocked a bigger model that was better on every kind of speech, because on a dial tone it wrote "You", and the gate was right.

The lab's system under test is local, whisper-base.en, so the gate runs with no API keys. The evaluation set has 120 clips in four slices:

Slice Clips What it is
read 39 LibriSpeech, clean and other
phone 39 The same clips through a phone line with background talkers (Chapter 2)
calls 30 Earnings-22, from 5 calls: spontaneous speech, numbers, names
non_speech 12 Silence, room tone, hold music, keypad tones, dial tone, noise, clicks, distant talkers (Chapter 3). The right transcript is empty

Write the gate down

Research says things like "no worse on phone calls". A gate needs every choice in that sentence written down:

- name: wer_phone
  kind: relative            # compared with the baseline, clip by clip
  metric: wer
  slices: [phone]
  tolerance: 1.5            # points of WER
  severity: block           # or warn
  why: "Phone lines with background voices: the typical contact-centre call (Lab 02)."

Each field answers a question someone will ask when the gate blocks their PR: which number, on which audio, compared with what, how much movement is allowed, does it block, and why does it exist. The why is what they read at 6 pm. Without it, the gate gets loosened instead of respected. The lab's loader refuses a spec with a missing why, a misspelled kind or a relative gate without a tolerance: a typo must stop the run, not quietly skip the gate.

The lab has nine gates of six kinds:

Kind In the lab Catches
Relative wer_read, wer_phone, wer_calls: no worse than the baseline, beyond noise and tolerance Regressions
Absolute wer_read_ceiling: read WER never above 10% Slow drift: each step passes, the total doesn't
Budget empty_transcripts, non_english: at most 0 Total failures, whatever the baseline did
No new failures invented_text: no non-speech clip the baseline left empty may start producing text Rare failures, compared clip by clip
Must-pass 6 phrases from real calls, like "10% capex to sales ratio" and "page 24" Specific high-value errors that averages hide
Ratio latency_p95: at most 1.5× the baseline, warn only Speed regressions

How a relative gate decides. It pairs each clip with its own baseline result and computes the change in corpus WER, with a 95% interval from a speaker-level paired bootstrap (Chapter 4). It fails only if both hold: the change is bigger than the tolerance, and the interval excludes zero. A change that's real but within tolerance is reported ("worse, within tolerance") and doesn't block.

Why "no new failures" and not a budget. The accepted system already writes text on 8 of 12 non-speech clips, mostly "You". A budget would have to allow 8 just to let it pass, and on the smoke tier's 4 non-speech clips, "8" means nothing. Comparing clip by clip works on any tier and ignores failures that were already accepted.

Seven pull requests

The lab opens seven candidate changes against the same baseline: two other Whisper sizes, another provider, and four deliberately broken versions of the baseline. Pick one, and switch between the smoke tier and the full tier:

Try it · Seven pull requests, nine gatesReal results from Lab 07
Tier
systems/bigger_model.yaml vs baseline whisper-base.en
whisper-small.en
Exit code
1
Blocks · warns
1 · 1
GateRuleBaselineThis change (95% CI)Result
wer_readrelative≤ +1.0 points, unless noise6.86%4.97% -1.89 (-3.87 to -0.32)✓ passbetter by 1.89
wer_phonerelative≤ +1.5 points, unless noise23.55%17.87% -5.68 (-10.20 to -1.55)✓ passbetter by 5.68
wer_callsrelative≤ +1.5 points, unless noise14.14%9.54% -4.61 (-5.20 to -4.04)✓ passbetter by 4.61
wer_read_ceilingabsoluteread WER ≤ 10.0%6.86%4.97%✓ passunder the limit
invented_textno new failures0 new non-speech clips with text8 clips6 clips✕ blocks2 new, 4 fixed
empty_transcriptsbudget≤ 0 clips00✓ passwithin budget
non_englishbudget≤ 0 clips00✓ passwithin budget
must_passmust-pass6 of 6 phrases right6 of 66 of 6✓ passall correct
latency_p95ratio · warn only≤ 1.5× the baseline0.64 s1.78 s⚠ warns2.80× the baseline
  • new: non_speech/dial-tone → “You”
  • new: non_speech/room-tone-b → “You”
  • fixed: dtmf, hold-music-b, loud-noise, room-tone-a

Better WER on every slice, and it fixed 4 non-speech clips. But it wrote “You” on 2 clips the baseline never wrote on in 5 runs. A person should decide this trade-off: if it's accepted, update the baseline in the same PR, with the reason.

  • The gate blocked a better model. whisper-small.en improved WER on every slice (−1.9 read, −5.7 phone, −4.6 calls) and fixed 4 non-speech clips. But it wrote "You" on a dial tone and on room tone, which the baseline never did in 5 runs. That's exactly when a person should decide.
  • Nova-3 on calls: "can't tell" isn't "fine". +4.1 points passed, because the interval (−3.3 to +15.2) includes zero: 30 clips from 5 calls can't separate it from noise. The fix is more calls in the set, not a looser rule.
  • Small real regressions pass by design. Dropping 1% of words made read WER 0.71 points worse, and the interval excluded zero, but it's within the 1.0 tolerance. The tolerance is the loss the team accepts per change; the 10% ceiling stops many accepted steps from adding up. whisper-tiny.en hit it at 10.18%.
  • One word, one must-pass failure. whisper-tiny.en wrote "10% cabbage to sales ratio". In WER that's one substitution; for a listener it's the number they needed.
  • Failures WER can't see. "Thank you." written on empty outputs moved WER by exactly 0 on every slice. Only invented_text caught it.
  • Latency only warns. +1 s per clip more than doubled p95, and the gate warned. Latency depends on the machine, and Nova-3 shows why: one run had calls taking up to 110 s, a rerun's worst was 5 s. A blocking latency gate would have failed a PR over a provider's bad minute.

Red, or couldn't measure

A gate has three results, not two:

Exit Meaning Response
0 Measured, and no blocking gate failed Merge
1 Measured, and quality regressed Fix the change, or deliberately accept it
2 Couldn't measure Fix the pipeline. This is not a quality verdict

The lab returns 2, never 1, when the audio isn't what the baseline was measured on (checked on decoded samples), the evaluation set changed (a fingerprint of every clip's id, samples and reference), the normalizer version changed, a must-pass clip is missing, a key is missing, or the provider failed.

If both show the same red X, people learn that red often means "flaky infrastructure", and start re-running real regressions until they pass. And never fill the gaps by scoring failed calls as empty transcripts: Lab 04 once did exactly that, with an out-of-credit account.

Does the gate cry wolf?

Compare a system with itself. Nothing changed, so every block is a false alarm.

Even the local model isn't deterministic. Three runs from scratch gave identical transcripts on every speech clip, but 2 of 12 non-speech clips flipped. Distant talkers came out as "We'll have our coffee.", then nothing, then three sentences. With a single-run baseline, invented_text failed in 2 of 6 orderings, on pure noise.

The fix is in how the baseline is built: it merges several runs, and a clip that had text in any of them doesn't count as a new failure later. The committed baseline merges five.

Try it · The gate's spec sheetReal results from Lab 07

An API model compared with itself: 20 pairs of runs, nothing changed. Every block is a false alarm.

Smoke, 1-run baselineSmoke, 3-runFull, 1-runFull, 3-run
wer_read1/201/200/200/20
invented_text4/200/204/200/20
Any blocking gate4/201/204/200/20

58 of 120 transcripts differed between any two runs. With a single-run baseline, one PR in five would have been blocked by noise, almost all of it on invented_text. Counting a clip as already failing if it failed in any baseline run removes those false alarms.

With an API model (gpt-4o-mini-transcribe), 58 of 120 transcripts differed between any two runs. A single-run baseline would have blocked one PR in five on noise; a 3-run baseline, none on the full tier. Two more things the A/A test showed:

  • Noise can exceed a tolerance. The largest pure-noise change on phone was 1.78 points, above the 1.5 tolerance; the interval kept the gate quiet. The lab's tolerances were set for the local model; the API model would need its own, set from its A/A table.
  • A gate that fails every A/A run isn't noise. non_english failed in 5 of 5 runs, on a clip whose audio starts with an interpreter speaking Korean. The model wrote it down, correctly. The clip was excluded, and the machinery worked: the evaluation set's fingerprint changed, run_eval.py exited 2 until a new baseline was recorded, and the must-pass check refused a case pointing at the removed clip.

What can the gate catch?

The other direction: inject regressions of known size into the baseline's own transcripts (30 repeats each, no model calls), and count how often each tier blocks them. Switch the widget above to "When something did".

  • The tolerance sets where blocking starts; the tier size sets how sharp that line is. Well below the tolerance, rarely blocked; well above, always; near it, a coin flip in both tiers.
  • The full tier reliably blocks a regression about 1 point beyond the tolerance, the smoke tier about 2. Below that, a green check means "nothing this tier can see".
  • The smoke tier is blurrier, not just weaker. It sometimes blocks a change that's really below the tolerance, and misses more above it.
  • Narrow regressions need slice gates. Dropping 5% of words on phone audio only moved overall WER by 1.3–1.4 points, but the phone gate saw +3.5 to +4.1 and blocked it in 97–100% of repeats.

That's the two-tier design: the smoke tier (45 clips, a fixed third of each slice plus the must-pass clips) on every PR, in about a minute; the full tier (all 120) nightly and before a release. The report posted on each PR says which tier ran and what it can see.

The baseline changes only on purpose

baseline.json holds the accepted system's result for every clip, plus the system, the reason it was accepted, the dataset fingerprint, the normalizer, the machine and the number of runs merged. It changes only in a reviewed PR:

python run_eval.py --tier full --update-baseline --reason "Switch to whisper-small.en: -2 to -6 points WER; accepted 2 new 'You' on tones"

Four rules are enforced in code:

  • CI never accepts its own results. The command refuses when CI is set. Otherwise a regression becomes the new normal without anyone deciding it.
  • The baseline merges 5 runs, so rare flips aren't new failures.
  • A baseline must pass its own blocking gates in every run. A must-pass phrase the accepted system only sometimes gets right would fail PRs at random.
  • It must be valid on every machine that runs the gate. That turned out to be the hard one.

Caching follows the same logic. Transcripts are cached by model + settings + language + run + audio hash, and scoring always re-runs from the cached text. A PR that changes a gate or the report re-uses every transcript; a PR that changes the model re-transcribes. The nightly run skips the cache on purpose, so an API model that changes behind the same name gets noticed.

What the first CI runs found

The baseline was recorded on a Mac; the CI runner is Linux on x86. Each problem below was caught by a check built earlier in the lab, and none showed up on the laptop:

  1. The rebuilt audio was different. 9 of 30 call clips came out a step apart in a few samples, because resampling in float32 rounds differently on x86 and arm64. The audio check exited 2 before anything was evaluated. Resampling in float64 fixed it, and changed 23 clips by an inaudible amount, which changed 9 transcripts and moved calls WER by 0.5 points. That's why the gate pins audio by its decoded samples.
  2. The same model gave different transcripts on different CPUs. In int8, parallel runner jobs landed on 3 kinds of CPU and gave 3 sets of transcripts. On the most common one, a must-pass phrase came out as "10% CAVX to sales ratio": a gate failing on the luck of the runner draw. float32 made every machine agree, and a fresh full-tier run on Linux then matched the Mac baseline exactly.

Latency on the runner was 6.5× the laptop's, so latency_p95 warns on every CI run. That's why it only warns.

A better model fails a gate

Back to whisper-small.en. The gate did its job: it found a trade-off and stopped it for a person to decide. Read which gate failed and its why. If the gain is worth the loss, accept it: update the baseline in the same PR, with the reason, so reviewers see "accepted 2 new 'You' on tones" in the diff.

What not to do is loosen the gate or net the results automatically. "4 fixed, 2 new" looks fine; the same rule would accept "1 fixed, 1 new" when the new one is a hallucinated sentence on a customer's hold music. If a policy like "new failures on tones are acceptable" makes sense, decide it in gates.yaml, with a why, not in the moment of a red build.

Practice: the gate's decisions

Implement the decisions inside the lab's gates: the relative verdict (tolerance and interval), "no new failures" against a multi-run baseline, a whole-word must-pass check, and the 0 / 1 / 2 exit code. The tests use the lab's real results: tiny.en's +3.31, Nova-3's "can't tell" on calls, small.en's 2 new and 4 fixed, and "cabbage".

Try it · Practice in PythonRuns in your browser · ⌘/Ctrl + Enter to run
gates_practice.py81 lines

Quiz

Try it · Check your understanding0 of 6 answered
  1. 1.whisper-small.en is 2–6 points better than the baseline on every speech slice and fixes 4 non-speech clips, but writes "You" on a dial tone and on room tone, where the baseline never wrote anything in 5 runs. The invented_text gate blocks it. What should happen?

  2. 2.Nova-3 scored 4.1 points worse than the baseline on the calls slice, with a 95% interval of −3.3 to +15.2. The gate passed. Was that a bug?

  3. 3.A candidate writes "Thank you." wherever the model returned nothing. What does each gate see?

  4. 4.The CI run can't reach the speech API: the key is missing in a fork's pull request. What should the gate return?

  5. 5.An API model compared with itself, 20 pairs of runs. With a baseline from a single run, 4 of 20 pairs failed a blocking gate. With a baseline merged from 3 runs, how many failed on the full tier?

  6. 6.The smoke tier (45 clips, on every PR) shows green. What does that promise?

Check your understanding

Try each question before opening its answer.

How do you turn a research target into a precise gate? What fields does it need?

Write down every choice the sentence leaves open: the metric, the audio slice, the comparison (the baseline clip by clip, or a fixed limit), the allowed movement, the statistical test (a paired speaker bootstrap for WER), block or warn, and why the gate exists. Tolerances come from the A/A test (how big noise gets), the power table (what the set can detect) and a product decision on how much loss per change is acceptable.

Why combine relative gates, absolute limits, must-pass clips and failure budgets?

Each catches something the others can't. Relative gates catch regressions but let small accepted steps add up; the absolute ceiling catches that drift (tiny.en at 10.18%). Must-pass phrases catch high-value errors averages hide ("cabbage"). Budgets and "no new failures" catch rare failures that barely move WER ("Thank you." on silence moved it by 0).

Why separate "quality regressed" from "couldn't measure"?

A regression means the change is bad: fix it or accept it deliberately. Couldn't-measure means the pipeline is broken and nothing is known about the change. If both show the same red X, people learn to re-run red builds, and then they re-run real regressions too.

What should run on every PR, and what nightly? What can each one catch?

A 45-clip smoke tier on every PR catches big breakage in about a minute: regressions about 2 points beyond each tolerance. The 120-clip full tier, nightly and before release, catches about 1 point beyond, and runs without the transcript cache to notice API models that change behind the same name.

How do you key a cache so it re-runs exactly what changed?

Key each transcript by model, settings, language, run number and audio hash, and re-run scoring from the cached text every time. A report or gate change re-uses everything; a model change re-transcribes. Caching is wrong when something outside the key changes the result: in int8, transcripts depended on the runner's CPU.

How do you prove a gate works, both ways?

An A/A test (the system against itself) counts false alarms: a single-run baseline blocked 1 API-model PR in 5 on noise, a 3-run baseline none on the full tier. Injecting regressions of known size counts what each tier catches. Both results go in the README as the gate's spec sheet.

Who may update the baseline, and how? What makes a baseline valid?

A person, in a reviewed PR, with a written reason; CI never updates it. A valid baseline passes its own blocking gates in every recorded run, merges enough runs to cover run-to-run variation (5 here), and reproduces on every machine that runs the gate (here that took float32).

A better model fails a gate. What do you do?

Read which gate failed and why. If the gain is worth the loss, update the baseline in the same PR with the reason. Don't loosen the gate or net results automatically: "4 fixed, 2 new" would also accept "1 fixed, 1 new" with a hallucinated sentence.

Next up: what happens after code merges. Chapter 8 is about monitoring voice AI in production: how long until anyone notices a bad release, with no answer key.

Learning something useful?

New labs, articles, and videos on evaluating and testing AI systems come out regularly. Follow along so you don't miss the next one.