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
167 changes: 167 additions & 0 deletions src/itn/en/fraction.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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);
}
}
1 change: 1 addition & 0 deletions src/itn/en/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 9 additions & 1 deletion src/itn/en/ordinal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,15 @@ pub fn parse(input: &str) -> Option<String> {
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))
}

Expand Down
17 changes: 15 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
49 changes: 49 additions & 0 deletions tests/en_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <word>".
#[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"
);
}
Loading