Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/itn/de/measure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const SIMPLE_UNITS: &[(&str, &str)] = &[
("watt", "w"),
("fuß", "ft"),
("fuss", "ft"),
("prozent", "%"),
];

/// Parse spoken German measurement expression to written form.
Expand Down Expand Up @@ -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()));
}
}
20 changes: 18 additions & 2 deletions src/itn/en/cardinal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,13 @@ fn single_digit_char(word: &str) -> Option<char> {
/// `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<i128> {
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")
Expand Down Expand Up @@ -216,7 +222,13 @@ pub fn words_to_number(input: &str) -> Option<i128> {
/// 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<i128> {
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")
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down
32 changes: 31 additions & 1 deletion src/itn/en/fraction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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.
Expand All @@ -30,6 +30,26 @@ pub fn parse(input: &str) -> Option<String> {

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(" "))?;
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion src/itn/en/ordinal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ lazy_static! {

/// Parse spoken ordinal to written form.
pub fn parse(input: &str) -> Option<String> {
let input = input.to_lowercase();
let input = input.to_lowercase().replace('-', " ");
let words: Vec<&str> = input.split_whitespace().collect();

if words.is_empty() {
Expand Down Expand Up @@ -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]
Expand Down
6 changes: 6 additions & 0 deletions src/itn/en/time.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
9 changes: 8 additions & 1 deletion src/itn/fr/fraction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,14 @@ fn parse_simple_fraction(input: &str) -> Option<String> {
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))
}

Expand Down Expand Up @@ -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]
Expand Down
7 changes: 7 additions & 0 deletions src/itn/fr/measure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ fn get_unit_mappings() -> Vec<(&'static str, &'static str)> {
(" watts", "W"),
(" watt", "W"),
// Percentage
(" pour cent", "%"),
(" pourcent", "%"),
]
}
Expand Down Expand Up @@ -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()));
}
}
26 changes: 21 additions & 5 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
///
Expand Down Expand Up @@ -994,9 +994,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));
}
Expand Down Expand Up @@ -1273,6 +1276,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);
Expand Down Expand Up @@ -1323,9 +1327,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;
}
}
Expand Down
60 changes: 57 additions & 3 deletions tests/en_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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_time_stays_a_time() {
assert_eq!(
normalize_sentence("meet me at nine oh five"),
"meet me at 09:05"
);
}

#[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..."
);
}
8 changes: 2 additions & 6 deletions tests/extensive_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}

Expand Down
20 changes: 20 additions & 0 deletions tests/multilang_itn_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 %");
}
2 changes: 1 addition & 1 deletion tests/parity_baseline.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down