Skip to main content

ffai_bench/
normalize.rs

1//! Text normalization for error-rate scoring — a port of `OpenAI` Whisper's
2//! `whisper/normalizers/{basic,english}.py`.
3//!
4//! # Why this exists
5//!
6//! Raw WER between an ASR hypothesis and a `LibriSpeech` reference is mostly
7//! noise about formatting. The reference says `MISTER QUILTER`, Whisper
8//! writes `Mr. Quilter`; the reference says `TWENTY THREE`, Whisper writes
9//! `23`. Scoring those as errors would make every implementation — ours and
10//! the references alike — look far worse than it is, and would put our
11//! published numbers nowhere near the world's.
12//!
13//! Normalization is applied identically to reference and hypothesis, and
14//! identically to every implementation under test, so it cannot advantage
15//! anyone.
16//!
17//! # Parity status (honest accounting)
18//!
19//! Implemented faithfully: lowercasing, bracket/parenthesis stripping, filler
20//! removal, the contraction and title replacer table, comma-in-digit and
21//! period handling, symbol stripping with the numeric keep-set, and
22//! spelled-number → digit conversion.
23//!
24//! **Not yet at bit-parity with openai-whisper**, tracked as a Mercury M1
25//! exit item:
26//!
27//! - the ~1,700-entry British→American spelling map (`english.json`),
28//! - Unicode NFKD decomposition and general-category-based diacritic removal
29//!   (we approximate: non-alphanumeric, non-keep characters become spaces),
30//! - fractions, currency-suffix forms, and the year-pair heuristics in
31//!   `EnglishNumberNormalizer`.
32//!
33//! Until those land, treat cross-implementation comparisons produced here as
34//! sound (same normalizer for everyone) and absolute agreement with published
35//! WER figures as approximate.
36
37use regex::Regex;
38use std::sync::OnceLock;
39
40/// Which normalizer to score with.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42pub enum Mode {
43    /// No normalization — raw string comparison.
44    None,
45    /// Whisper's `BasicTextNormalizer`: language-agnostic.
46    Basic,
47    /// Whisper's `EnglishTextNormalizer`: the default for English corpora.
48    #[default]
49    English,
50    /// OCR scoring: whitespace runs (including line breaks) collapse to one
51    /// space, everything else is preserved.
52    ///
53    /// Deliberately NOT the ASR normalizer: reading case, punctuation, and
54    /// digits correctly is OCR's job, so folding them away would score the
55    /// task's hardest parts as free. Whitespace is collapsed because engines
56    /// legitimately disagree about line-break placement, and the corpus keeps
57    /// reading order unambiguous (single-column, top-to-bottom) so a flat
58    /// comparison is fair.
59    Ocr,
60}
61
62/// Normalize `text` under `mode`.
63#[must_use]
64pub fn normalize(text: &str, mode: Mode) -> String {
65    match mode {
66        Mode::None => text.to_string(),
67        Mode::Basic => basic(text),
68        Mode::English => english(text),
69        Mode::Ocr => collapse_whitespace(text),
70    }
71}
72
73/// Whisper's `BasicTextNormalizer`.
74fn basic(text: &str) -> String {
75    let s = text.to_lowercase();
76    let s = re(r"[<\[][^>\]]*[>\]]").replace_all(&s, "");
77    let s = re(r"\(([^)]+?)\)").replace_all(&s, "");
78    let s = strip_symbols(&s, "");
79    collapse_whitespace(&s)
80}
81
82/// Whisper's `EnglishTextNormalizer`.
83fn english(text: &str) -> String {
84    let s = text.to_lowercase();
85    let s = re(r"[<\[][^>\]]*[>\]]").replace_all(&s, "").into_owned();
86    let s = re(r"\(([^)]+?)\)").replace_all(&s, "").into_owned();
87    // Fillers Whisper drops outright.
88    let s = re(r"\b(hmm|mm|mhm|mmm|uh|um)\b")
89        .replace_all(&s, "")
90        .into_owned();
91    // Standardize a space before an apostrophe ("it 's" -> "it's").
92    let s = re(r"\s+'").replace_all(&s, "'").into_owned();
93
94    let mut s = s;
95    for (pattern, replacement) in replacers() {
96        s = pattern.replace_all(&s, *replacement).into_owned();
97    }
98
99    // Commas inside numbers: "1,234" -> "1234".
100    let s = re(r"(\d),(\d)").replace_all(&s, "${1}${2}").into_owned();
101    // Periods not followed by a digit are punctuation, not decimal points.
102    let s = re(r"\.([^0-9]|$)").replace_all(&s, " ${1}").into_owned();
103    // Keep the symbols that carry numeric meaning.
104    let s = strip_symbols(&s, ".%$¢€£");
105    let s = words_to_digits(&s);
106    // Now drop numeric symbols that turned out not to be attached to digits.
107    let s = re(r"[.$¢€£]([^0-9])").replace_all(&s, " ${1}").into_owned();
108    let s = re(r"([^0-9])%").replace_all(&s, "${1} ").into_owned();
109    collapse_whitespace(&s)
110}
111
112/// Replace symbol/punctuation characters with spaces, except `keep`.
113/// (Approximates Whisper's Unicode-category pass — see module docs.)
114fn strip_symbols(text: &str, keep: &str) -> String {
115    text.chars()
116        .map(|c| {
117            if c.is_alphanumeric() || c.is_whitespace() || keep.contains(c) {
118                c
119            } else {
120                ' '
121            }
122        })
123        .collect()
124}
125
126fn collapse_whitespace(text: &str) -> String {
127    text.split_whitespace().collect::<Vec<_>>().join(" ")
128}
129
130fn re(pattern: &str) -> Regex {
131    // Small, fixed set of patterns; compiled per call is fine at corpus
132    // scale (thousands of strings, not millions). Hoist into a OnceLock table
133    // if this ever shows up in a profile.
134    Regex::new(pattern).expect("static pattern")
135}
136
137/// Whisper's contraction / title replacer table, applied in order.
138fn replacers() -> &'static [(Regex, &'static str)] {
139    static TABLE: OnceLock<Vec<(Regex, &'static str)>> = OnceLock::new();
140    TABLE.get_or_init(|| {
141        [
142            // common contractions
143            (r"\bwon't\b", "will not"),
144            (r"\bcan't\b", "can not"),
145            (r"\blet's\b", "let us"),
146            (r"\bain't\b", "aint"),
147            (r"\by'all\b", "you all"),
148            (r"\bwanna\b", "want to"),
149            (r"\bgotta\b", "got to"),
150            (r"\bgonna\b", "going to"),
151            (r"\bi'ma\b", "i am going to"),
152            (r"\bimma\b", "i am going to"),
153            (r"\bwoulda\b", "would have"),
154            (r"\bcoulda\b", "could have"),
155            (r"\bshoulda\b", "should have"),
156            (r"\bma'am\b", "madam"),
157            // titles and abbreviations — the big win on read-speech corpora
158            (r"\bmr\b", "mister "),
159            (r"\bmrs\b", "missus "),
160            (r"\bst\b", "saint "),
161            (r"\bdr\b", "doctor "),
162            (r"\bprof\b", "professor "),
163            (r"\bcapt\b", "captain "),
164            (r"\bgov\b", "governor "),
165            (r"\bald\b", "alderman "),
166            (r"\bgen\b", "general "),
167            (r"\bsen\b", "senator "),
168            (r"\brep\b", "representative "),
169            (r"\bpres\b", "president "),
170            (r"\brev\b", "reverend "),
171            (r"\bhon\b", "honorable "),
172            (r"\basst\b", "assistant "),
173            (r"\bassoc\b", "associate "),
174            (r"\blt\b", "lieutenant "),
175            (r"\bcol\b", "colonel "),
176            (r"\bjr\b", "junior "),
177            (r"\bsr\b", "senior "),
178            (r"\besq\b", "esquire "),
179            // perfect tenses
180            (r"'d been\b", " had been"),
181            (r"'s been\b", " has been"),
182            (r"'d gone\b", " had gone"),
183            (r"'s gone\b", " has gone"),
184            (r"'d done\b", " had done"),
185            (r"'s got\b", " has got"),
186            // general contractions
187            (r"n't\b", " not"),
188            (r"'re\b", " are"),
189            (r"'s\b", " is"),
190            (r"'d\b", " would"),
191            (r"'ll\b", " will"),
192            (r"'t\b", " not"),
193            (r"'ve\b", " have"),
194            (r"'m\b", " am"),
195        ]
196        .into_iter()
197        .map(|(p, r)| (Regex::new(p).expect("static pattern"), r))
198        .collect()
199    })
200}
201
202fn unit_value(word: &str) -> Option<u64> {
203    Some(match word {
204        "zero" => 0,
205        "one" => 1,
206        "two" => 2,
207        "three" => 3,
208        "four" => 4,
209        "five" => 5,
210        "six" => 6,
211        "seven" => 7,
212        "eight" => 8,
213        "nine" => 9,
214        "ten" => 10,
215        "eleven" => 11,
216        "twelve" => 12,
217        "thirteen" => 13,
218        "fourteen" => 14,
219        "fifteen" => 15,
220        "sixteen" => 16,
221        "seventeen" => 17,
222        "eighteen" => 18,
223        "nineteen" => 19,
224        _ => return None,
225    })
226}
227
228fn tens_value(word: &str) -> Option<u64> {
229    Some(match word {
230        "twenty" => 20,
231        "thirty" => 30,
232        "forty" => 40,
233        "fourty" => 40, // common misspelling, as Whisper tolerates
234        "fifty" => 50,
235        "sixty" => 60,
236        "seventy" => 70,
237        "eighty" => 80,
238        "ninety" => 90,
239        _ => return None,
240    })
241}
242
243fn scale_value(word: &str) -> Option<u64> {
244    Some(match word {
245        "thousand" => 1_000,
246        "million" => 1_000_000,
247        "billion" => 1_000_000_000,
248        "trillion" => 1_000_000_000_000,
249        _ => return None,
250    })
251}
252
253/// Ordinal word → (value, suffix), e.g. "third" → (3, "rd").
254fn ordinal_value(word: &str) -> Option<(u64, &'static str)> {
255    let v = match word {
256        "first" => 1,
257        "second" => 2,
258        "third" => 3,
259        "fourth" => 4,
260        "fifth" => 5,
261        "sixth" => 6,
262        "seventh" => 7,
263        "eighth" => 8,
264        "ninth" => 9,
265        "tenth" => 10,
266        "eleventh" => 11,
267        "twelfth" => 12,
268        "thirteenth" => 13,
269        "fourteenth" => 14,
270        "fifteenth" => 15,
271        "sixteenth" => 16,
272        "seventeenth" => 17,
273        "eighteenth" => 18,
274        "nineteenth" => 19,
275        "twentieth" => 20,
276        "thirtieth" => 30,
277        "fortieth" => 40,
278        "fiftieth" => 50,
279        "sixtieth" => 60,
280        "seventieth" => 70,
281        "eightieth" => 80,
282        "ninetieth" => 90,
283        "hundredth" => 100,
284        "thousandth" => 1000,
285        _ => return None,
286    };
287    Some((v, ordinal_suffix(v)))
288}
289
290fn ordinal_suffix(v: u64) -> &'static str {
291    match (v % 100, v % 10) {
292        (11..=13, _) => "th",
293        (_, 1) => "st",
294        (_, 2) => "nd",
295        (_, 3) => "rd",
296        _ => "th",
297    }
298}
299
300/// Convert spelled-out cardinals and ordinals to digits.
301///
302/// Composition rules mirror Whisper's behaviour on the cases that matter for
303/// read speech: a unit after a tens word combines (`twenty three` → `23`), a
304/// tens word after a partial number starts a new one (`eighteen seventy six`
305/// → `18 76`, the year form), `hundred` multiplies, and `thousand`/`million`
306/// scale. `and` is absorbed only when it sits inside a number.
307fn words_to_digits(text: &str) -> String {
308    /// The number being assembled: `total` holds completed scale groups
309    /// ("two thousand"), `part` the 0–999 group under construction.
310    #[derive(Default)]
311    struct Acc {
312        total: u64,
313        part: u64,
314        active: bool,
315    }
316
317    impl Acc {
318        fn value(&self) -> u64 {
319            self.total + self.part
320        }
321
322        /// Emit the pending number, if any, and reset.
323        fn flush(&mut self, out: &mut Vec<String>) {
324            if self.active {
325                out.push(self.value().to_string());
326                *self = Self::default();
327            }
328        }
329    }
330
331    let tokens: Vec<&str> = text.split_whitespace().collect();
332    let mut out: Vec<String> = Vec::with_capacity(tokens.len());
333    let mut acc = Acc::default();
334
335    for (i, token) in tokens.iter().enumerate() {
336        let token = *token;
337        if let Some(v) = unit_value(token) {
338            // A unit after another unit/teen starts a new number rather than
339            // summing: "six six" is two numbers, "twenty six" is one.
340            if acc.active && acc.part % 10 != 0 {
341                acc.flush(&mut out);
342            }
343            acc.part += v;
344            acc.active = true;
345        } else if let Some(v) = tens_value(token) {
346            if acc.active && acc.part != 0 {
347                acc.flush(&mut out);
348            }
349            acc.part += v;
350            acc.active = true;
351        } else if token == "hundred" && acc.active {
352            acc.part = acc.part.max(1) * 100;
353        } else if let Some(scale) = scale_value(token) {
354            if acc.active {
355                acc.total += acc.part.max(1) * scale;
356                acc.part = 0;
357            } else {
358                out.push(token.to_string());
359            }
360        } else if token == "and"
361            && acc.active
362            && tokens
363                .get(i + 1)
364                .is_some_and(|n| unit_value(n).is_some() || tens_value(n).is_some())
365        {
366            // absorbed: "two hundred and five"
367        } else if let Some((v, _)) = ordinal_value(token) {
368            // An ordinal terminates the number it completes: "twenty third"
369            // is 23rd, not 20 followed by 3rd.
370            let value = if !acc.active {
371                v
372            } else if v >= 100 {
373                // scale ordinal: "two hundredth" -> 200th
374                acc.total + acc.part.max(1) * v
375            } else if acc.part % 10 == 0 {
376                acc.value() + v
377            } else {
378                // no valid composition ("six third"): emit the pending
379                // number on its own, then the ordinal.
380                out.push(acc.value().to_string());
381                v
382            };
383            acc = Acc::default();
384            out.push(format!("{value}{}", ordinal_suffix(value)));
385        } else {
386            acc.flush(&mut out);
387            out.push(token.to_string());
388        }
389    }
390    acc.flush(&mut out);
391    out.join(" ")
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    fn en(s: &str) -> String {
399        english(s)
400    }
401
402    #[test]
403    fn librispeech_reference_and_whisper_output_converge() {
404        // The case that motivates the whole module: same sentence, one from a
405        // LibriSpeech .trans.txt, one as Whisper would emit it.
406        let reference = "MISTER QUILTER IS THE APOSTLE OF THE MIDDLE CLASSES AND WE ARE GLAD TO WELCOME HIS GOSPEL";
407        let hypothesis = "Mr. Quilter is the apostle of the middle classes, and we are glad to welcome his gospel.";
408        assert_eq!(en(reference), en(hypothesis));
409    }
410
411    #[test]
412    fn titles_expand() {
413        assert_eq!(
414            en("Dr. Smith and Mrs. Jones"),
415            "doctor smith and missus jones"
416        );
417    }
418
419    #[test]
420    fn contractions_expand_consistently() {
421        assert_eq!(en("it's"), en("it is"));
422        assert_eq!(en("won't"), "will not");
423        assert_eq!(en("they've"), "they have");
424    }
425
426    #[test]
427    fn fillers_are_dropped() {
428        assert_eq!(en("um so uh yes"), "so yes");
429    }
430
431    #[test]
432    fn spelled_numbers_match_digits() {
433        assert_eq!(en("twenty three"), en("23"));
434        assert_eq!(en("one hundred and five"), en("105"));
435        assert_eq!(en("two thousand"), en("2000"));
436        assert_eq!(en("twenty three thousand four hundred"), en("23400"));
437    }
438
439    #[test]
440    fn year_pairs_split_rather_than_summing() {
441        // "eighteen seventy six" must not become 94.
442        assert_eq!(en("eighteen seventy six"), "18 76");
443        assert_eq!(en("six six"), "6 6");
444    }
445
446    #[test]
447    fn ordinals_become_suffixed_digits() {
448        assert_eq!(en("the first day"), "the 1st day");
449        assert_eq!(en("the twentieth"), "the 20th");
450        // The composition case: an ordinal completes the pending number.
451        assert_eq!(en("the twenty third"), "the 23rd");
452        assert_eq!(en("one hundredth"), "100th");
453        assert_eq!(en("the twenty third of may"), en("the 23rd of may"));
454    }
455
456    #[test]
457    fn numbers_survive_surrounding_words() {
458        assert_eq!(
459            en("he had twenty three apples and left"),
460            "he had 23 apples and left"
461        );
462    }
463
464    #[test]
465    fn scale_word_alone_is_left_as_a_word() {
466        assert_eq!(en("thousands of people"), "thousands of people");
467        assert_eq!(en("million"), "million");
468    }
469
470    #[test]
471    fn digit_commas_and_trailing_periods() {
472        assert_eq!(en("1,234 apples."), "1234 apples");
473    }
474
475    #[test]
476    fn basic_mode_is_language_agnostic() {
477        assert_eq!(basic("Hello, [noise] World! (aside)"), "hello world");
478    }
479
480    #[test]
481    fn normalization_is_idempotent() {
482        let once = en("Mr. Smith had twenty three apples, and he won't share.");
483        assert_eq!(en(&once), once);
484    }
485}