From dc44e519609be49af4b384779df10e5abc3334bd Mon Sep 17 00:00:00 2001 From: kiranjd Date: Wed, 14 Oct 2026 15:28:51 +0530 Subject: [PATCH 1/2] fix(itn): sentence-mode accuracy fixes for en, fr and de MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handle hyphenated numbers, mixed fractions, "oh" digit sequences and long cardinals; stop fabricating times from ranges and dropping "and" between quantities; avoid a doubled period after p.m.; read "cent onzième" as an ordinal; add "pour cent" and "prozent" percentages. --- src/itn/de/measure.rs | 6 ++++ src/itn/en/cardinal.rs | 20 ++++++++++-- src/itn/en/fraction.rs | 32 ++++++++++++++++++- src/itn/en/ordinal.rs | 3 +- src/itn/en/time.rs | 6 ++++ src/itn/fr/fraction.rs | 9 +++++- src/itn/fr/measure.rs | 7 +++++ src/lib.rs | 38 ++++++++++++++++++++--- tests/en_tests.rs | 60 ++++++++++++++++++++++++++++++++++-- tests/extensive_tests.rs | 8 ++--- tests/multilang_itn_tests.rs | 20 ++++++++++++ 11 files changed, 190 insertions(+), 19 deletions(-) diff --git a/src/itn/de/measure.rs b/src/itn/de/measure.rs index 333149d..078fc5d 100644 --- a/src/itn/de/measure.rs +++ b/src/itn/de/measure.rs @@ -57,6 +57,7 @@ const SIMPLE_UNITS: &[(&str, &str)] = &[ ("watt", "w"), ("fuß", "ft"), ("fuss", "ft"), + ("prozent", "%"), ]; /// Parse spoken German measurement expression to written form. @@ -250,4 +251,9 @@ mod tests { fn test_fraction_measure() { assert_eq!(parse("ein halb fuß"), Some("1/2 ft".to_string())); } + + #[test] + fn test_percentage() { + assert_eq!(parse("vierzig prozent"), Some("40 %".to_string())); + } } diff --git a/src/itn/en/cardinal.rs b/src/itn/en/cardinal.rs index 3bc33c7..7047a87 100644 --- a/src/itn/en/cardinal.rs +++ b/src/itn/en/cardinal.rs @@ -170,7 +170,13 @@ fn single_digit_char(word: &str) -> Option { /// `date::parse_old_year`). Use [`words_to_number_aviation`] for opt-in /// flight-number / call-sign contexts. pub fn words_to_number(input: &str) -> Option { - let input = input.to_lowercase(); + let input = input.to_lowercase().replace('-', " "); + let unfiltered: Vec<&str> = input.split_whitespace().collect(); + if matches!(unfiltered.first(), Some(&"and")) + || matches!(unfiltered.last(), Some(&"and" | &"a")) + { + return None; + } let words: Vec<&str> = input .split_whitespace() .filter(|w| *w != "and" && *w != "a") @@ -216,7 +222,13 @@ pub fn words_to_number(input: &str) -> Option { /// to avoid clobbering date/time/measure semantics (e.g. `"twenty one forty /// two"` as old-year `2042`). pub fn words_to_number_aviation(input: &str) -> Option { - let input = input.to_lowercase(); + let input = input.to_lowercase().replace('-', " "); + let unfiltered: Vec<&str> = input.split_whitespace().collect(); + if matches!(unfiltered.first(), Some(&"and")) + || matches!(unfiltered.last(), Some(&"and" | &"a")) + { + return None; + } let words: Vec<&str> = input .split_whitespace() .filter(|w| *w != "and" && *w != "a") @@ -385,6 +397,7 @@ mod tests { assert_eq!(parse("twenty one"), Some("21".to_string())); assert_eq!(parse("forty two"), Some("42".to_string())); assert_eq!(parse("ninety nine"), Some("99".to_string())); + assert_eq!(parse("forty-six"), Some("46".to_string())); } #[test] @@ -444,6 +457,9 @@ mod tests { fn test_invalid() { assert_eq!(parse("hello"), None); assert_eq!(parse("one hello"), None); + assert_eq!(parse("and thirty two"), None); + assert_eq!(parse("twenty one and"), None); + assert_eq!(parse("twenty one a"), None); } /// Digit-by-digit reading (issue #15). Sequences of single-digit words diff --git a/src/itn/en/fraction.rs b/src/itn/en/fraction.rs index 2eed1b0..1725e72 100644 --- a/src/itn/en/fraction.rs +++ b/src/itn/en/fraction.rs @@ -20,7 +20,7 @@ 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 lower = input.to_lowercase().replace('-', " "); let tokens: Vec<&str> = lower.split_whitespace().collect(); // A fraction needs at least a numerator word and a denominator word. @@ -30,6 +30,26 @@ pub fn parse(input: &str) -> Option { let (denominator, denom_is_plural) = parse_denominator(tokens.last()?)?; + // Mixed fractions: "one and three quarters" -> "1 3/4". Parse this + // before the simple numerator path so the cardinal parser does not + // discard "and" and collapse the phrase into the improper fraction "13/4". + if let Some(and_index) = tokens[..tokens.len() - 1] + .iter() + .rposition(|token| *token == "and") + { + let whole_tokens = &tokens[..and_index]; + let numerator_tokens = &tokens[and_index + 1..tokens.len() - 1]; + if !whole_tokens.is_empty() && !numerator_tokens.is_empty() { + let whole = cardinal::words_to_number(&whole_tokens.join(" "))?; + // "two and a half" is left to read as words: the article is not + // a numerator, and "2 1/2" reads worse than the spoken phrase. + let numerator = cardinal::words_to_number(&numerator_tokens.join(" "))?; + if whole >= 0 && numerator >= 1 && (denom_is_plural || numerator == 1) { + return Some(format!("{} {}/{}", whole, numerator, denominator)); + } + } + } + // 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(" "))?; @@ -128,6 +148,16 @@ mod tests { assert_eq!(parse("three halves"), Some("3/2".to_string())); } + #[test] + fn test_mixed_fraction() { + assert_eq!(parse("one and three quarters"), Some("1 3/4".to_string())); + assert_eq!(parse("two and a half"), None); + assert_eq!( + parse("one hundred and one half"), + Some("100 1/2".to_string()) + ); + } + #[test] fn test_compound_ordinals_defer() { // Singular denominator with numerator != 1 is a compound ordinal. diff --git a/src/itn/en/ordinal.rs b/src/itn/en/ordinal.rs index 06aa185..b40e673 100644 --- a/src/itn/en/ordinal.rs +++ b/src/itn/en/ordinal.rs @@ -64,7 +64,7 @@ lazy_static! { /// Parse spoken ordinal to written form. pub fn parse(input: &str) -> Option { - let input = input.to_lowercase(); + let input = input.to_lowercase().replace('-', " "); let words: Vec<&str> = input.split_whitespace().collect(); if words.is_empty() { @@ -165,6 +165,7 @@ mod tests { assert_eq!(parse("twenty second"), Some("22nd".to_string())); assert_eq!(parse("twenty third"), Some("23rd".to_string())); assert_eq!(parse("forty second"), Some("42nd".to_string())); + assert_eq!(parse("thirty-second"), Some("32nd".to_string())); } #[test] diff --git a/src/itn/en/time.rs b/src/itn/en/time.rs index fe04c36..dc555f7 100644 --- a/src/itn/en/time.rs +++ b/src/itn/en/time.rs @@ -208,10 +208,16 @@ fn parse_to_pattern(original: &str, input: &str, period: &str, timezone: &str) - if let Some(hour_val) = hour { let hour_val = hour_val as i64; + if !(1..=24).contains(&hour_val) { + return None; + } // Try parsing full min_part as a number if let Some(minutes_before) = words_to_number(min_part) { let minutes_before = minutes_before as i64; + if !(1..=59).contains(&minutes_before) { + return None; + } let prev_hour = if hour_val == 1 { 12 } else { hour_val - 1 }; let minute = 60 - minutes_before; return Some(format_time(prev_hour, minute, period, timezone)); diff --git a/src/itn/fr/fraction.rs b/src/itn/fr/fraction.rs index 2658bd2..577da32 100644 --- a/src/itn/fr/fraction.rs +++ b/src/itn/fr/fraction.rs @@ -42,8 +42,14 @@ fn parse_simple_fraction(input: &str) -> Option { return None; } - let denom = parse_denominator(tokens.last()?)?; + let denominator_word = tokens.last()?; + let denom = parse_denominator(denominator_word)?; let numer = word_to_int(&tokens[..tokens.len() - 1].join(" "))?; + // A numerator above one requires a plural denominator. A singular phrase + // such as "cent onzième" is an ordinal (111th), not 100/11. + if numer > 1 && !denominator_word.ends_with('s') { + return None; + } Some(format!("{}/{}", numer, denom)) } @@ -111,6 +117,7 @@ mod tests { assert_eq!(parse("un cinquième"), Some("1/5".to_string())); assert_eq!(parse("un quatrième"), Some("1/4".to_string())); assert_eq!(parse("un centième"), Some("1/100".to_string())); + assert_eq!(parse("cent onzième"), None); } #[test] diff --git a/src/itn/fr/measure.rs b/src/itn/fr/measure.rs index af7cac9..b63594a 100644 --- a/src/itn/fr/measure.rs +++ b/src/itn/fr/measure.rs @@ -245,6 +245,7 @@ fn get_unit_mappings() -> Vec<(&'static str, &'static str)> { (" watts", "W"), (" watt", "W"), // Percentage + (" pour cent", "%"), (" pourcent", "%"), ] } @@ -310,4 +311,10 @@ mod tests { Some("18,5 km".to_string()) ); } + + #[test] + fn test_percentage() { + assert_eq!(parse("quarante pour cent"), Some("40 %".to_string())); + assert_eq!(parse("quarante pourcent"), Some("40 %".to_string())); + } } diff --git a/src/lib.rs b/src/lib.rs index f28c8cc..8894d54 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -917,7 +917,7 @@ fn tn_parse_span_lang(span: &str, lang: &str) -> Option<(String, u8)> { /// /// Returns `(replacement, priority_score)` if a tagger matches. /// Taggers are ordered by precision: high-confidence patterns first, -/// broad patterns (cardinal) last and limited to short spans. +/// broad patterns (cardinal) last. /// /// Excluded in sentence mode: `word` and `telephone` (over-fire on natural language). /// @@ -952,6 +952,18 @@ fn parse_span( return Some((result, 90)); } + // "one oh one" is a digit sequence (101) unless a larger span adds an + // explicit time marker such as a.m./p.m. Treat it before the generic time + // tagger so room numbers and short codes do not become 01:01. + if span + .split_whitespace() + .any(|token| token.eq_ignore_ascii_case("oh")) + { + if let Some(result) = cardinal::parse(span) { + return Some((result, 89)); + } + } + // Concat-compound cardinal opt-in: priority 89, beats date/time. No // short-span gate — this is opt-in, so the caller has accepted // aggressive matching across longer spans like "one thousand two @@ -994,9 +1006,12 @@ fn parse_span( } } - // Default cardinal fallback (priority 70). In concat-compound mode the - // cardinal path is already covered by the priority-89 branch above. - if !concat_compound && token_count <= 4 { + // Default cardinal fallback (priority 70). Let the caller's sentence span + // limit bound the search. Restricting cardinals to four tokens split valid + // numbers such as "three thousand nine hundred nine" into "3900 9". + // In concat-compound mode the cardinal path is already covered by the + // priority-89 branch above. + if !concat_compound { if let Some(result) = cardinal::parse(span) { return Some((result, 70)); } @@ -1273,6 +1288,7 @@ where let mut out = String::new(); let mut i = 0usize; + let mut prev_replacement_ends_with_period = false; while i < pretokens.len() { let max_end = usize::min(pretokens.len(), i + max_span); @@ -1323,9 +1339,21 @@ where if let Some((end, replacement, _)) = best { push_detokenized(&mut out, pretokens[i].sep, &replacement); + prev_replacement_ends_with_period = replacement.ends_with('.'); i = end; } else { - push_detokenized(&mut out, pretokens[i].sep, &pretokens[i].text); + // A tagger may render an abbreviation with its terminal period + // ("three pm." -> "03:00 p.m.") while the sentence period remains + // as the next pretoken. Drop that single period, but keep an + // ellipsis ("p.m...." stays intact) and literal periods elsewhere. + let is_glued_period = |p: &Pretoken| p.sep.is_empty() && p.text == "."; + let duplicate_period = prev_replacement_ends_with_period + && is_glued_period(&pretokens[i]) + && !pretokens.get(i + 1).is_some_and(is_glued_period); + if !duplicate_period { + push_detokenized(&mut out, pretokens[i].sep, &pretokens[i].text); + } + prev_replacement_ends_with_period = false; i += 1; } } diff --git a/tests/en_tests.rs b/tests/en_tests.rs index 22cbc27..833fb6c 100644 --- a/tests/en_tests.rs +++ b/tests/en_tests.rs @@ -481,11 +481,11 @@ fn test_sentence_mixed_punctuation_and_numbers() { normalize_sentence("I bought twenty three items comma and paid five dollars"), "I bought 23 items , and paid $5" ); - // "forty two to thirty seven" is caught by the time tagger (X to Y = time pattern) - // This is expected — the time tagger has higher priority than cardinal in parse_span. + // A range whose right-hand side cannot be an hour must not be fabricated + // into a time. assert_eq!( normalize_sentence("the score was forty two to thirty seven period"), - "the score was 36:18 ." + "the score was 42 to 37 ." ); assert_eq!( normalize_sentence("question mark did you say one hundred"), @@ -1334,3 +1334,57 @@ fn test_issue_82_ordinals_still_work() { "3rd of silence" ); } + +// --- Sentence-mode accuracy regressions --- + +#[test] +fn test_sentence_hyphenated_numbers() { + assert_eq!( + normalize_sentence("she is forty-six years old"), + "she is 46 years old" + ); + assert_eq!( + normalize_sentence("the thirty-second floor"), + "the 32nd floor" + ); +} + +#[test] +fn test_sentence_mixed_fractions() { + assert_eq!( + normalize_sentence("add one and three quarters cups of flour"), + "add 1 3/4 cups of flour" + ); + assert_eq!( + normalize_sentence("wait two and a half hours"), + "wait 2 and a half hours" + ); +} + +#[test] +fn test_sentence_oh_digit_sequence_is_not_a_time() { + assert_eq!( + normalize_sentence("go to room one oh one"), + "go to room 101" + ); +} + +#[test] +fn test_sentence_long_cardinal_is_not_split() { + assert_eq!( + normalize_sentence("the total was three thousand nine hundred nine"), + "the total was 3909" + ); +} + +#[test] +fn test_sentence_no_double_period_after_meridiem() { + assert_eq!( + normalize_sentence("the meeting is at three pm."), + "the meeting is at 03:00 p.m." + ); // Literal periods, including an ellipsis, are still preserved. + assert_eq!( + normalize_sentence("and then twenty one..."), + "and then 21..." + ); +} diff --git a/tests/extensive_tests.rs b/tests/extensive_tests.rs index a50610e..377d3bf 100644 --- a/tests/extensive_tests.rs +++ b/tests/extensive_tests.rs @@ -922,14 +922,10 @@ fn test_sentence_itn_empty() { #[test] fn test_sentence_itn_multiple_numbers() { - // KNOWN_ISSUE: "and" is consumed by the cardinal tagger as a separator word. - // In sentence mode, "thirty two" is parsed, but "and" before it gets eaten - // by a span that tries "and thirty two" as a single cardinal expression. - // The word "and" is dropped because cardinal::words_to_number filters it out. - // Result: "I have 21 apples 32 oranges" (missing "and") + // A coordinating conjunction between separate quantities is preserved. assert_eq!( normalize_sentence("I have twenty one apples and thirty two oranges"), - "I have 21 apples 32 oranges" + "I have 21 apples and 32 oranges" ); } diff --git a/tests/multilang_itn_tests.rs b/tests/multilang_itn_tests.rs index f45e033..e3142a6 100644 --- a/tests/multilang_itn_tests.rs +++ b/tests/multilang_itn_tests.rs @@ -142,3 +142,23 @@ fn test_max_span_zero_uses_single_token_spans() { "我有21个苹果" ); } + +// ── Sentence-mode accuracy regressions ─────────────────────────────── + +#[test] +fn test_fr_sentence_ordinal_is_not_a_fraction() { + assert_eq!( + normalize_sentence_lang("le cent onzième jour", "fr"), + "le 111ᵉ jour" + ); +} + +#[test] +fn test_fr_sentence_pour_cent() { + assert_eq!(normalize_sentence_lang("quarante pour cent", "fr"), "40 %"); +} + +#[test] +fn test_de_sentence_prozent() { + assert_eq!(normalize_sentence_lang("vierzig prozent", "de"), "40 %"); +} From 7bfaee7d082d66f7781ed050c726aa8402bea462 Mon Sep 17 00:00:00 2001 From: kiranjd Date: Wed, 14 Oct 2026 22:01:45 +0530 Subject: [PATCH 2/2] fix(itn): drop sentence-mode "oh" digit branch, refresh parity baseline The branch read bare times as digits ("nine oh five" -> "905"). Room numbers need preceding-token context; leave that for a follow-up. Add a regression test for the time case and record fr ITN ordinal 23/23. --- src/lib.rs | 12 ------------ tests/en_tests.rs | 6 +++--- tests/parity_baseline.tsv | 2 +- 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 8894d54..6a4505b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -952,18 +952,6 @@ fn parse_span( return Some((result, 90)); } - // "one oh one" is a digit sequence (101) unless a larger span adds an - // explicit time marker such as a.m./p.m. Treat it before the generic time - // tagger so room numbers and short codes do not become 01:01. - if span - .split_whitespace() - .any(|token| token.eq_ignore_ascii_case("oh")) - { - if let Some(result) = cardinal::parse(span) { - return Some((result, 89)); - } - } - // Concat-compound cardinal opt-in: priority 89, beats date/time. No // short-span gate — this is opt-in, so the caller has accepted // aggressive matching across longer spans like "one thousand two diff --git a/tests/en_tests.rs b/tests/en_tests.rs index 833fb6c..084a0bc 100644 --- a/tests/en_tests.rs +++ b/tests/en_tests.rs @@ -1362,10 +1362,10 @@ fn test_sentence_mixed_fractions() { } #[test] -fn test_sentence_oh_digit_sequence_is_not_a_time() { +fn test_sentence_oh_time_stays_a_time() { assert_eq!( - normalize_sentence("go to room one oh one"), - "go to room 101" + normalize_sentence("meet me at nine oh five"), + "meet me at 09:05" ); } diff --git a/tests/parity_baseline.tsv b/tests/parity_baseline.tsv index 83bf0c0..0d77a7e 100644 --- a/tests/parity_baseline.tsv +++ b/tests/parity_baseline.tsv @@ -110,7 +110,7 @@ fr itn electronic 10 10 fr itn fraction 30 30 fr itn measure 15 15 fr itn money 22 22 -fr itn ordinal 22 23 +fr itn ordinal 23 23 fr itn telephone 5 5 fr itn time 18 18 fr itn whitelist 8 8