use super::{Phoneme, PronunciationProvider};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Prefix {
Be,
Con,
Dis,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
Use,
SpellOut,
Unknown,
}
pub fn classify(word: &[char], prefix: Prefix, provider: &dyn PronunciationProvider) -> Decision {
match prefix {
Prefix::Be => classify_be(word, provider),
Prefix::Con => classify_con(word, provider),
Prefix::Dis => classify_dis(word, provider),
}
}
fn word_string(word: &[char]) -> String {
word.iter().collect()
}
fn is_vowel_char(c: char) -> bool {
matches!(c.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u')
}
const TENSE_VOWELS: &[&str] = &["IY", "EY", "AY", "OW", "UW", "OY", "AW"];
fn classify_be(word: &[char], provider: &dyn PronunciationProvider) -> Decision {
if word.len() >= 4 && word[2] == word[3] && !is_vowel_char(word[2]) {
return Decision::SpellOut;
}
if word.get(2..).is_some_and(is_apostrophe_dropped_ing_tail) {
return Decision::Use;
}
let pronunciations = provider.pronunciations(&word_string(word));
if !pronunciations.is_empty()
&& pronunciations.iter().all(|p| {
matches!(
(p.first(), p.get(1), p.get(2)),
(Some(b), Some(v), Some(n))
if b.base == "B"
&& v.base == "EH"
&& v.stress != Some(2)
&& n.base == "N"
)
})
{
return Decision::SpellOut;
}
if word.get(2).is_some_and(|c| !is_vowel_char(*c))
&& word.len() > 4
&& !provider.pronunciations(&word_string(&word[2..])).is_empty()
{
return Decision::Use;
}
if matches!(
word,
['b', 'e', 'a', 't', 'i', 't', 'u', 'd', 'e']
| ['b', 'e', 'a', 't', 'r', 'i', 'c', 'e']
| ['b', 'e', 'a', 't', 'r', 'i', 'x']
) {
return Decision::Use;
}
let consonant_follows = word.get(2).is_some_and(|c| !is_vowel_char(*c));
decide_all(&pronunciations, |p| be_pron_uses(p, consonant_follows))
}
fn is_apostrophe_dropped_ing_tail(tail: &[char]) -> bool {
tail == ['i', 'n']
}
fn be_pron_uses(p: &[Phoneme], consonant_follows: bool) -> bool {
if p.first().map(|ph| ph.base.as_str()) != Some("B") {
return false;
}
let Some(idx) = p.iter().position(|ph| ph.is_vowel()) else {
return false;
};
if p.iter().skip(idx + 1).filter(|ph| ph.is_vowel()).count() < 1 {
return false;
}
match p[idx].stress {
Some(0 | 2) => true,
Some(1) if TENSE_VOWELS.contains(&p[idx].base.as_str()) => {
p.get(idx + 1).is_some_and(|n| n.is_vowel()) || consonant_follows
}
_ => false,
}
}
fn classify_con(word: &[char], provider: &dyn PronunciationProvider) -> Decision {
if matches!(word, ['c', 'o', 'n', 'n'] | ['c', 'o', 'n', 's']) {
return Decision::Use;
}
if matches!(word.get(3), Some('t' | 'g')) {
return Decision::Use;
}
decide_all(&provider.pronunciations(&word_string(word)), con_pron_uses)
}
fn con_pron_uses(p: &[Phoneme]) -> bool {
let multisyllable = p.iter().filter(|ph| ph.is_vowel()).count() >= 2;
let n_closes_prefix = p.get(3).is_some_and(|ph| {
!ph.is_vowel()
|| (ph.is_vowel() && matches!(p.get(1).and_then(|v| v.stress), Some(0 | 2)))
});
p.len() >= 4
&& p[0].base == "K"
&& p[1].is_vowel()
&& matches!(p[2].base.as_str(), "N" | "NG")
&& n_closes_prefix
&& multisyllable
}
fn classify_dis(word: &[char], provider: &dyn PronunciationProvider) -> Decision {
if word.len() > 4 && !provider.pronunciations(&word_string(&word[3..])).is_empty() {
return Decision::Use;
}
if word.get(3) == Some(&'t') {
return Decision::Use;
}
decide_all(&provider.pronunciations(&word_string(word)), dis_pron_uses)
}
fn dis_pron_uses(p: &[Phoneme]) -> bool {
if !(p.len() >= 4 && p[0].base == "D" && p[1].base == "IH" && p[2].base == "S") {
return false;
}
let multisyllable = p.iter().filter(|ph| ph.is_vowel()).count() >= 2;
p[3].is_vowel() || (matches!(p[1].stress, Some(1 | 2)) && multisyllable)
}
fn decide_all<F: Fn(&[Phoneme]) -> bool>(prons: &[Vec<Phoneme>], uses: F) -> Decision {
let Some((first, rest)) = prons.split_first() else {
return Decision::Unknown;
};
let verdict = uses(first);
if rest.iter().all(|p| uses(p) == verdict) {
if verdict {
Decision::Use
} else {
Decision::SpellOut
}
} else {
Decision::Unknown
}
}
#[cfg(test)]
mod tests {
use super::super::{NoPronunciationProvider, Phoneme, PronunciationProvider, parse_phoneme};
use super::*;
use std::collections::HashMap;
struct Mock(HashMap<&'static str, Vec<&'static str>>);
impl PronunciationProvider for Mock {
fn pronunciations(&self, word: &str) -> Vec<Vec<Phoneme>> {
self.0
.get(word)
.map(|v| {
v.iter()
.map(|s| s.split_whitespace().map(parse_phoneme).collect())
.collect()
})
.unwrap_or_default()
}
}
fn mock() -> Mock {
Mock(HashMap::from([
("become", vec!["B IH0 K AH1 M"]),
("begin", vec!["B IH0 G IH1 N"]),
("beckon", vec!["B EH1 K AH0 N"]),
("benefit", vec!["B EH1 N AH0 F IH0 T"]),
("beneficent", vec!["B AH0 N EH1 F AH0 S AH0 N T"]),
("being", vec!["B IY1 IH0 NG"]),
("bed", vec!["B EH1 D"]),
("bedazzle", vec!["B IH0 D AE1 Z AH0 L"]),
("dazzle", vec!["D AE1 Z AH0 L"]),
("benumb", vec!["B IH0 N AH1 M"]),
("numb", vec!["N AH1 M"]),
("beat", vec!["B IY1 T"]),
("benzene", vec!["B EH0 N Z IY1 N"]),
("benedict", vec!["B EH1 N AH0 D IH2 K T"]),
("been", vec!["B IH1 N"]),
("belligerent", vec!["B AH0 L IH1 JH ER0 AH0 N T"]),
("concept", vec!["K AA1 N S EH0 P T"]),
("control", vec!["K AH0 N T R OW1 L"]),
("connect", vec!["K AH0 N EH1 K T"]),
("connection", vec!["K AH0 N EH1 K SH AH0 N"]),
("conestoga", vec!["K AA2 N AH0 S T OW1 G AH0"]),
("cone", vec!["K OW1 N"]),
("coney", vec!["K OW1 N IY0"]),
("dislike", vec!["D IH0 S L AY1 K"]),
("like", vec!["L AY1 K"]),
("discipline", vec!["D IH1 S AH0 P L IH0 N"]),
("dispirited", vec!["D IH0 S P IH1 R IH0 T IH0 D"]),
("disulphide", vec!["D AY0 S AH1 L F AY2 D"]),
("disc", vec!["D IH1 S K"]),
("congress", vec!["K AA1 NG G R AH0 S"]),
("conch", vec!["K AA1 NG K"]),
("congo", vec!["K AA1 NG G OW0"]),
("connor", vec!["K AA1 N ER0"]),
("distance", vec!["D IH1 S T AH0 N S"]),
("beta", vec!["B EY1 T AH0"]),
("beacon", vec!["B IY1 K AH0 N"]),
]))
}
fn chars(w: &str) -> Vec<char> {
w.chars().collect()
}
#[rstest::rstest]
#[case::become_word("become", Prefix::Be, Decision::Use)]
#[case::begin("begin", Prefix::Be, Decision::Use)]
#[case::beckon("beckon", Prefix::Be, Decision::SpellOut)]
#[case::benefit("benefit", Prefix::Be, Decision::SpellOut)]
#[case::benzene("benzene", Prefix::Be, Decision::SpellOut)]
#[case::benedict("benedict", Prefix::Be, Decision::SpellOut)]
#[case::beneficent_secondary("beneficent", Prefix::Be, Decision::Use)]
#[case::being_tense_hiatus("being", Prefix::Be, Decision::Use)]
#[case::bed_short_root("bed", Prefix::Be, Decision::SpellOut)]
#[case::bedazzle_bound_prefix("bedazzle", Prefix::Be, Decision::Use)]
#[case::benumb_bound_prefix("benumb", Prefix::Be, Decision::Use)]
#[case::beat_tense_coda("beat", Prefix::Be, Decision::SpellOut)]
#[case::been_monosyllable("been", Prefix::Be, Decision::SpellOut)]
#[case::belligerent_doubled("belligerent", Prefix::Be, Decision::SpellOut)]
#[case::concept("concept", Prefix::Con, Decision::Use)]
#[case::control("control", Prefix::Con, Decision::Use)]
#[case::connect_unstressed_coda_n("connect", Prefix::Con, Decision::Use)]
#[case::connection_unstressed_coda_n("connection", Prefix::Con, Decision::Use)]
#[case::conestoga_secondary_coda_n("conestoga", Prefix::Con, Decision::Use)]
#[case::cone("cone", Prefix::Con, Decision::SpellOut)]
#[case::coney("coney", Prefix::Con, Decision::SpellOut)]
#[case::dislike_rest_word("dislike", Prefix::Dis, Decision::Use)]
#[case::discipline_pron("discipline", Prefix::Dis, Decision::Use)]
#[case::dispirited("dispirited", Prefix::Dis, Decision::SpellOut)]
#[case::disulphide("disulphide", Prefix::Dis, Decision::SpellOut)]
#[case::disc_monosyllable("disc", Prefix::Dis, Decision::SpellOut)]
#[case::congress_ng("congress", Prefix::Con, Decision::Use)] #[case::conch_monosyllable("conch", Prefix::Con, Decision::SpellOut)] #[case::congo_ng("congo", Prefix::Con, Decision::Use)]
#[case::connor_vowel_after_n("connor", Prefix::Con, Decision::SpellOut)] #[case::distance_stressed("distance", Prefix::Dis, Decision::Use)] #[case::distinct_dis_t("distinct", Prefix::Dis, Decision::Use)] #[case::disturb_dis_t("disturb", Prefix::Dis, Decision::Use)] #[case::cont_con_t("cont", Prefix::Con, Decision::Use)] #[case::congee_con_g("congee", Prefix::Con, Decision::Use)] #[case::dispirited_not_dis_t("dispirited", Prefix::Dis, Decision::SpellOut)] #[case::beta_open_consonant("beta", Prefix::Be, Decision::Use)] #[case::beacon_digraph("beacon", Prefix::Be, Decision::SpellOut)] fn classifies_restricted_prefixes(
#[case] word: &str,
#[case] prefix: Prefix,
#[case] expected: Decision,
) {
assert_eq!(classify(&chars(word), prefix, &mock()), expected);
}
#[test]
fn unknown_without_pronunciation() {
assert_eq!(
classify(&chars("become"), Prefix::Be, &NoPronunciationProvider),
Decision::Unknown
);
}
#[test]
fn be_pronunciation_rejects_non_b_or_monosyllables() {
assert!(!be_pron_uses(
&[
parse_phoneme("P"),
parse_phoneme("IH0"),
parse_phoneme("AH1")
],
true
));
assert!(!be_pron_uses(
&[parse_phoneme("B"), parse_phoneme("EH1"), parse_phoneme("D")],
true
));
assert!(!be_pron_uses(
&[parse_phoneme("B"), parse_phoneme("R")],
true
));
}
#[test]
fn disagreement_between_pronunciations_is_unknown() {
let provider = Mock(HashMap::from([(
"ambiguous",
vec!["K AA1 N S EH0 P T", "K OW1 N"],
)]));
assert_eq!(
classify(&chars("ambiguous"), Prefix::Con, &provider),
Decision::Unknown
);
}
}