From 6b97934883ba74c51d07e1e2767faa3ce16b25dd Mon Sep 17 00:00:00 2001 From: Alex-Wengg Date: Thu, 23 Jul 2026 18:58:17 -0400 Subject: [PATCH] fix(itn/en): read spoken fractions instead of misfiring the ordinal tagger (#82) `normalize_sentence("use one third of a cup")` returned "use 4th of a cup": English ITN had no fraction tagger, so "one third" fell to the ordinal tagger, whose compound-ordinal path blindly added prefix + ordinal (1 + 3 = "4th"). - Add `itn::en::fraction`, run before ordinal in both the single-expression and sentence pipelines (priority 78, below decimal/above ordinal). It emits N/D for spoken fractions: "one third" -> 1/3, "two thirds" -> 2/3, "three quarters" -> 3/4, "twenty two thirds" -> 22/3. - Disambiguate via denominator number: a plural denominator ("thirds") is always a fraction; a singular one ("third") only with numerator one, so "twenty third" stays the ordinal 23rd. Exclude "first"/"second" ("one second" is a duration) and keep singular scale words as ordinals ("one hundredth" -> 100th). The article "a" is not a numerator, so "a quarter of the pizza" is left unchanged. - Guard the ordinal compound-addition path: the prefix must be a round multiple of the next power of ten, so it no longer fabricates ordinals from "one" + ones-ordinal ("one second" no longer becomes "3rd"). Adds unit tests for the fraction tagger and issue #82 integration tests. --- src/itn/en/fraction.rs | 167 +++++++++++++++++++++++++++++++++++++++++ src/itn/en/mod.rs | 1 + src/itn/en/ordinal.rs | 10 ++- src/lib.rs | 17 ++++- tests/en_tests.rs | 49 ++++++++++++ 5 files changed, 241 insertions(+), 3 deletions(-) create mode 100644 src/itn/en/fraction.rs diff --git a/src/itn/en/fraction.rs b/src/itn/en/fraction.rs new file mode 100644 index 0000000..2eed1b0 --- /dev/null +++ b/src/itn/en/fraction.rs @@ -0,0 +1,167 @@ +//! Fraction tagger for English (inverse text normalization). +//! +//! Converts spoken English fractions to written form: +//! - "one third" → "1/3" +//! - "two thirds" → "2/3" +//! - "three quarters" → "3/4" +//! - "one half" → "1/2" +//! - "twenty two thirds" → "22/3" +//! +//! Runs *before* the ordinal tagger. Without it, "one third" is read as a +//! compound ordinal (1 + 3 → "4th"); see issue #82. The plural/singular of the +//! denominator disambiguates fractions from compound ordinals: +//! - Plural denominator ("thirds") is always a fraction: "twenty two thirds" → 22/3. +//! - Singular denominator ("third") is a fraction only with numerator one +//! ("one third" → 1/3); otherwise it is a compound ordinal +//! ("twenty third" → 23rd) and this tagger defers to the ordinal tagger. + +use super::cardinal; + +/// Parse a spoken English fraction to written form, or `None` when the input +/// is not an unambiguous fraction (so higher-priority taggers can handle it). +pub fn parse(input: &str) -> Option { + let lower = input.to_lowercase(); + let tokens: Vec<&str> = lower.split_whitespace().collect(); + + // A fraction needs at least a numerator word and a denominator word. + if tokens.len() < 2 { + return None; + } + + let (denominator, denom_is_plural) = parse_denominator(tokens.last()?)?; + + // The numerator is everything before the denominator. `words_to_number` + // rejects the article "a"/"an", so "a quarter of the pizza" is left alone. + let numerator = cardinal::words_to_number(&tokens[..tokens.len() - 1].join(" "))?; + if numerator < 1 { + return None; + } + + // A singular denominator only reads as a fraction with numerator one. + // "twenty third" (numerator 20) is the ordinal 23rd, not 20/3. + if !denom_is_plural && numerator != 1 { + return None; + } + + Some(format!("{}/{}", numerator, denominator)) +} + +/// Map a denominator word to `(value, is_plural)`, or `None` when the word is +/// not a fraction denominator. +/// +/// Excludes "first"/"second" (and their plurals): "one second" is a duration, +/// not 1/2, and English never spells 1/2 as "second". Scale denominators +/// ("hundredth", "thousandth", ...) only count when plural, so singular +/// "one hundredth" stays the 100th ordinal. +fn parse_denominator(word: &str) -> Option<(i128, bool)> { + // Irregular forms that don't follow the "-s" plural rule. + match word { + "half" => return Some((2, false)), + "halves" => return Some((2, true)), + "quarter" => return Some((4, false)), + "quarters" => return Some((4, true)), + _ => {} + } + + let (singular, is_plural) = match word.strip_suffix('s') { + Some(stem) => (stem, true), + None => (word, false), + }; + + let value = match singular { + "third" => 3, + "fourth" => 4, + "fifth" => 5, + "sixth" => 6, + "seventh" => 7, + "eighth" => 8, + "ninth" => 9, + "tenth" => 10, + "eleventh" => 11, + "twelfth" => 12, + "thirteenth" => 13, + "fourteenth" => 14, + "fifteenth" => 15, + "sixteenth" => 16, + "seventeenth" => 17, + "eighteenth" => 18, + "nineteenth" => 19, + "twentieth" => 20, + "thirtieth" => 30, + "fortieth" => 40, + "fiftieth" => 50, + "sixtieth" => 60, + "seventieth" => 70, + "eightieth" => 80, + "ninetieth" => 90, + // Scale denominators are fractions only when plural ("two hundredths"), + // leaving singular "one hundredth" to the ordinal tagger (100th). + "hundredth" if is_plural => 100, + "thousandth" if is_plural => 1000, + "millionth" if is_plural => 1_000_000, + "billionth" if is_plural => 1_000_000_000, + _ => return None, + }; + Some((value, is_plural)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_simple_singular() { + assert_eq!(parse("one third"), Some("1/3".to_string())); + assert_eq!(parse("one half"), Some("1/2".to_string())); + assert_eq!(parse("one quarter"), Some("1/4".to_string())); + assert_eq!(parse("one fourth"), Some("1/4".to_string())); + assert_eq!(parse("one fifth"), Some("1/5".to_string())); + assert_eq!(parse("one tenth"), Some("1/10".to_string())); + } + + #[test] + fn test_plural() { + assert_eq!(parse("two thirds"), Some("2/3".to_string())); + assert_eq!(parse("three quarters"), Some("3/4".to_string())); + assert_eq!(parse("five eighths"), Some("5/8".to_string())); + assert_eq!(parse("twenty two thirds"), Some("22/3".to_string())); + assert_eq!(parse("three halves"), Some("3/2".to_string())); + } + + #[test] + fn test_compound_ordinals_defer() { + // Singular denominator with numerator != 1 is a compound ordinal. + assert_eq!(parse("twenty third"), None); + assert_eq!(parse("thirty first"), None); + assert_eq!(parse("forty second"), None); + assert_eq!(parse("one hundred third"), None); + } + + #[test] + fn test_excluded_denominators() { + // "second"/"first" are never fraction denominators. + assert_eq!(parse("one second"), None); + assert_eq!(parse("two seconds"), None); + assert_eq!(parse("one first"), None); + // Singular scale words stay ordinals (100th, 1000th). + assert_eq!(parse("one hundredth"), None); + assert_eq!(parse("one thousandth"), None); + // ...but plural scale words are fractions. + assert_eq!(parse("two hundredths"), Some("2/100".to_string())); + } + + #[test] + fn test_article_not_a_numerator() { + // "a quarter" must not become 1/4 (would break "a quarter past three" + // and "a quarter of the pizza"). + assert_eq!(parse("a quarter"), None); + assert_eq!(parse("a third"), None); + } + + #[test] + fn test_not_a_fraction() { + assert_eq!(parse("third"), None); + assert_eq!(parse("hello world"), None); + assert_eq!(parse("one"), None); + } +} diff --git a/src/itn/en/mod.rs b/src/itn/en/mod.rs index 370911e..00fd009 100644 --- a/src/itn/en/mod.rs +++ b/src/itn/en/mod.rs @@ -9,6 +9,7 @@ pub mod cardinal; pub mod date; pub mod decimal; pub mod electronic; +pub mod fraction; pub mod measure; pub mod money; pub mod ordinal; diff --git a/src/itn/en/ordinal.rs b/src/itn/en/ordinal.rs index 934a154..06aa185 100644 --- a/src/itn/en/ordinal.rs +++ b/src/itn/en/ordinal.rs @@ -98,7 +98,15 @@ pub fn parse(input: &str) -> Option { return Some(format_ordinal(prefix_value * scale)); } - // Regular ordinal: add prefix + ordinal value + // Regular compound ordinal: the ordinal suffix fills the low-order digits, + // so the cardinal prefix must be a round multiple of the next power of ten + // ("twenty first" = 20 + 1, "one hundred tenth" = 100 + 10). A non-round + // prefix means this is not a compound ordinal at all ("one second", + // "one third") — decline so cardinal/fraction handle it. See issue #82. + let modulus = if ordinal_value < 10 { 10 } else { 100 }; + if prefix_value % modulus != 0 { + return None; + } Some(format_ordinal(prefix_value + ordinal_value)) } diff --git a/src/lib.rs b/src/lib.rs index aa6d489..852ce51 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,8 +32,8 @@ pub mod wasm; pub use options::{NormalizeOptions, DEFAULT_MAX_SPAN_TOKENS}; use itn::en::{ - cardinal, date, decimal, electronic, measure, money, ordinal, punctuation, telephone, time, - whitelist, word, + cardinal, date, decimal, electronic, fraction, measure, money, ordinal, punctuation, telephone, + time, whitelist, word, }; /// Normalize spoken-form text to written form. @@ -110,6 +110,13 @@ fn normalize_inner(input: &str, disable_bare_second: bool) -> String { return result; } + // Fraction before ordinal: "one third" is 1/3, not the compound-ordinal + // reading (1 + 3 → "4th"). Compound ordinals ("twenty third") and bare + // ordinals ("third") return None here and fall through. See issue #82. + if let Some(result) = fraction::parse(input) { + return result; + } + // Try ordinal numbers (issue #22: skip the bare "second" case when opted out). let skip_ordinal = disable_bare_second && input.eq_ignore_ascii_case("second"); if !skip_ordinal { @@ -973,6 +980,12 @@ fn parse_span( // `"give me a second"` stay literal. Compound ordinals // (`"twenty second"`) still flow through this branch because they // span 2+ tokens. + // Fraction (priority 78, above ordinal). "one third" → "1/3", while + // "twenty third" defers to the ordinal tagger below (23rd). See issue #82. + if let Some(result) = fraction::parse(span) { + return Some((result, 78)); + } + let skip_ordinal = disable_bare_second && token_count == 1 && span.trim().eq_ignore_ascii_case("second"); if !skip_ordinal { diff --git a/tests/en_tests.rs b/tests/en_tests.rs index 44aa263..22cbc27 100644 --- a/tests/en_tests.rs +++ b/tests/en_tests.rs @@ -1285,3 +1285,52 @@ fn test_issue_21_other_punctuation() { "don't eat 21 apples" ); } + +/// Issue #82: fraction denominators must not be misread as compound ordinals. +/// "one third" was becoming "4th" (1 + 3) via the ordinal tagger. +#[test] +fn test_issue_82_fraction_not_ordinal() { + // The reported case. + assert_eq!( + normalize_sentence("use one third of a cup"), + "use 1/3 of a cup" + ); + // Other singular denominators (numerator one). + assert_eq!(normalize_sentence("one half of a cup"), "1/2 of a cup"); + assert_eq!( + normalize_sentence("one quarter of the time"), + "1/4 of the time" + ); + assert_eq!(normalize("one fifth"), "1/5"); + assert_eq!(normalize("one tenth"), "1/10"); + // Plural denominators carry an arbitrary numerator. + assert_eq!(normalize_sentence("two thirds of a cup"), "2/3 of a cup"); + assert_eq!( + normalize_sentence("three quarters of the way"), + "3/4 of the way" + ); + assert_eq!(normalize("twenty two thirds"), "22/3"); +} + +/// Issue #82 regression guard: genuine compound ordinals still convert, and +/// the compound-addition path no longer fabricates ordinals for "one ". +#[test] +fn test_issue_82_ordinals_still_work() { + assert_eq!(normalize("twenty third"), "23rd"); + assert_eq!(normalize("thirty first"), "31st"); + assert_eq!(normalize("forty second"), "42nd"); + assert_eq!(normalize("one hundred third"), "103rd"); + assert_eq!(normalize("one hundred tenth"), "110th"); + // Singular scale words stay ordinals, not fractions. + assert_eq!(normalize("one hundredth"), "100th"); + // "a quarter" is not a fraction (article is not a numerator) — unchanged. + assert_eq!( + normalize_sentence("we had a quarter of the pizza left"), + "we had a quarter of the pizza left" + ); + // No fabricated ordinal from cardinal-one + ones-ordinal ("one" + "second"). + assert_ne!( + normalize_sentence("one second of silence"), + "3rd of silence" + ); +}