use super::{Analysis, Dictionary, Source, UdTags};
const SYMBOLS: &[char] = &[
'%', '‰', '$', '£', '¥', '°', '+', '=', '±', '×', '÷', '§', '©', '®',
];
const LOWERCASE_PROPN: f64 = 0.001;
pub(super) const TITLECASE_PROPN: f64 = 3.0;
const NUMERAL_FEATS: &[(&str, &str, &str, &str, f64)] = &[
("Gen", "", "Plur", "", 188.0),
("Acc", "", "Plur", "Inan", 98.0),
("Nom", "Masc", "Sing", "", 72.0),
("Loc", "", "Plur", "", 71.0),
("Gen", "Masc", "Sing", "", 64.0),
("Nom", "", "Plur", "", 41.0),
("Nom", "Fem", "Sing", "", 38.0),
("Gen", "Neut", "Sing", "", 37.0),
("Loc", "Masc", "Sing", "", 34.0),
("Gen", "Fem", "Sing", "", 26.0),
("Loc", "Fem", "Sing", "", 25.0),
("Nom", "Neut", "Sing", "", 22.0),
("Acc", "Masc", "Sing", "Inan", 19.0),
("Dat", "Masc", "Sing", "", 7.0),
("Acc", "Neut", "Sing", "", 7.0),
("Ins", "", "Plur", "", 7.0),
("Ins", "Masc", "Sing", "", 6.0),
("Acc", "Fem", "Sing", "", 5.0),
("Loc", "Neut", "Sing", "", 3.0),
("Ins", "Fem", "Sing", "", 2.0),
("Dat", "Fem", "Sing", "", 2.0),
("Dat", "Neut", "Sing", "", 1.0),
("Dat", "", "Plur", "", 2.0),
("Acc", "Masc", "Sing", "Anim", 2.0),
("Acc", "Neut", "Sing", "Inan", 1.0),
("Acc", "", "Plur", "Anim", 1.0),
];
const ABBREVIATION_LEMMAS: &[(&str, &str, &str)] = &[
("в", "masc", "век"),
("вв", "masc", "век"),
("г", "anim", "господин"),
("г", "inan", "год"),
("гг", "masc", "год"),
("гц", "masc", "герц"),
("долл", "masc", "доллар"),
("квт", "masc", "киловатт"),
("кг", "masc", "килограмм"),
("км", "masc", "километр"),
("л", "masc", "литр"),
("м", "masc", "метр"),
("мг", "masc", "миллиграмм"),
("мин", "femn", "минута"),
("мкм", "masc", "микрометр"),
("млн", "masc", "миллион"),
("млрд", "masc", "миллиард"),
("мм", "masc", "миллиметр"),
("нм", "masc", "нанометр"),
("п", "masc", "пункт"),
("прим", "neut", "примечание"),
("проф", "anim", "профессор"),
("проц", "masc", "процент"),
("руб", "masc", "рубль"),
("сек", "femn", "секунда"),
("см", "masc", "сантиметр"),
("ст", "femn", "статья"),
("стр", "femn", "страница"),
("стр", "neut", "строение"),
("т", "femn", "тонна"),
("т", "masc", "том"),
("тов", "anim", "товарищ"),
("трлн", "masc", "триллион"),
("тыс", "femn", "тысяча"),
("ул", "femn", "улица"),
("ч", "femn", "часть"),
("ч", "masc", "час"),
("чел", "anim", "человек"),
("шт", "femn", "штука"),
("э", "femn", "эра"),
];
const ABBREVIATION_GRAMMEMES: [&str; 5] = ["masc", "femn", "neut", "anim", "inan"];
pub(super) fn abbreviation_grammemes(tag: &str) -> u8 {
let grammemes = || tag.split([',', ' ']);
if !tag.starts_with("NOUN,") || !grammemes().any(|grammeme| grammeme == "Abbr") {
return 0;
}
(ABBREVIATION_GRAMMEMES.iter().enumerate())
.filter(|&(_, name)| grammemes().any(|grammeme| grammeme == *name))
.fold(1 << 7, |bits, (bit, _)| bits | 1 << bit)
}
const CARDINAL_ENDINGS: &[&str] = &[
"ти", "ть", "х", "ух", "ех", "ми", "мя", "м", "ем", "ём", "у",
];
const ROMAN_LETTERS: &[(&str, f64, f64)] = &[
("I", 176.0, 6.0),
("V", 75.0, 2.0),
("X", 52.0, 7.0),
("L", 1.0, 5.0),
("C", 1.0, 16.0),
];
#[derive(Clone)]
pub(super) struct Tags {
punct: u16,
symbol: u16,
cardinal: u16,
ordinal: u16,
roman: u16,
foreign: u16,
given: u16,
patronymic: u16,
surname: u16,
organization: u16,
abbreviation: u16,
adverb: u16,
other: u16,
}
impl Tags {
pub(super) fn add(ud_tags: &mut UdTags) -> Self {
let mut tag = |pos: &str, feats: &[(&str, &str)]| ud_tags.find_or_add(pos, feats);
let abbreviated = |name_type| [("Abbr", "Yes"), ("NameType", name_type)];
Tags {
punct: tag("PUNCT", &[]),
symbol: tag("SYM", &[]),
cardinal: tag("NUM", &[("NumForm", "Digit"), ("NumType", "Card")]),
ordinal: tag("ADJ", &[("NumForm", "Digit"), ("NumType", "Ord")]),
roman: tag("ADJ", &[("NumForm", "Roman"), ("NumType", "Ord")]),
foreign: tag("X", &[("Foreign", "Yes")]),
given: tag("PROPN", &abbreviated("Giv")),
patronymic: tag("PROPN", &abbreviated("Pat")),
surname: tag("PROPN", &abbreviated("Sur")),
organization: tag("PROPN", &abbreviated("Com")),
abbreviation: tag("NOUN", &[("Abbr", "Yes")]),
adverb: tag("ADV", &[("Degree", "Pos")]),
other: tag("X", &[]),
}
}
}
impl Dictionary {
pub(super) fn rules(&self, word: &str) -> Vec<Analysis> {
let tags = &self.rule_tags;
let chars: Vec<char> = word.chars().collect();
let lower = word.to_lowercase();
if chars.is_empty() {
return Vec::new();
}
if let Some(digits) = number(word) {
if !digits {
return self.shares(word, &[(word, tags.cardinal, 1.0)]);
}
let (ordinal, cardinal) = match (chars.len(), word.parse::<u32>()) {
(4, Ok(year @ 1000..=2100)) if year % 100 != 0 || year == 1900 || year == 2000 => {
(3776.0, 74.0)
}
(1 | 2, _) => (624.0, 3199.0),
_ => (125.0, 718.0),
};
return self.shares(
word,
&[
(word, tags.cardinal, cardinal),
(word, tags.ordinal, ordinal),
],
);
}
if !token(&chars) {
return Vec::new();
}
if chars.iter().all(|c| !c.is_alphanumeric()) {
let tag = if chars.iter().all(|&c| symbol(c) || joiner(c)) {
tags.symbol
} else {
tags.punct
};
return self.shares(word, &[(word, tag, 1.0)]);
}
if let Some(analyses) = self.numeral_with_ending(word) {
return analyses;
}
if let Some(rest) = lower.strip_prefix("по-")
&& rest.chars().all(cyrillic)
&& ["ски", "цки", "ому", "ему", "ьи"]
.iter()
.any(|ending| rest.ends_with(ending))
{
return self.shares(word, &[(&lower, tags.adverb, 1.0)]);
}
if roman(word) {
let (ordinal, foreign) =
match ROMAN_LETTERS.iter().find(|(letter, _, _)| *letter == word) {
Some(&(_, ordinal, foreign)) => (ordinal, foreign),
None if chars.len() == 2 && word.contains(['C', 'L']) => (1.0, 3.0),
None => (2566.0, 2.0),
};
return self.shares(
word,
&[(word, tags.roman, ordinal), (&lower, tags.foreign, foreign)],
);
}
let upper = word.to_uppercase();
if chars.len() > 1 && chars.iter().all(|c| "ivx".contains(*c)) && roman(&upper) {
return self.shares(
word,
&[(word, tags.roman, 1.0), (&lower, tags.foreign, 1.0)],
);
}
if word.contains(RANGE) && word.split(RANGE).all(roman) {
return self.shares(word, &[(word, tags.roman, 1.0)]);
}
if chars.iter().any(|c| c.is_alphabetic())
&& !chars.iter().any(|&c| cyrillic(c))
&& chars
.iter()
.all(|&c| c.is_alphanumeric() || "'’-.&_".contains(c))
{
return self.shares(word, &[(&lower, tags.foreign, 1.0)]);
}
if let [letter, '.'] | [letter] = chars[..]
&& cyrillic(letter)
&& letter.is_uppercase()
&& !"ЪЫЬ".contains(letter)
{
let lemma = letter.to_lowercase().to_string();
return self.shares(
word,
&[
(&lemma, tags.given, 6781.0),
(&lemma, tags.patronymic, 4706.0),
(&lemma, tags.surname, 9.0),
],
);
}
if let Some(analyses) = self.abbreviation(word) {
return analyses;
}
if chars.len() > 1 && chars.iter().all(|&c| cyrillic(c) && c.is_uppercase()) {
let mut analyses = self.shares(
word,
&[
(&lower, tags.organization, 252.0),
(&lower, tags.abbreviation, 198.0),
],
);
if chars.len() >= LONG_CAPS {
let capitalized: String = chars[..1]
.iter()
.chain(lower.chars().skip(1).collect::<Vec<_>>().iter())
.collect();
let guessed = self.guess(&capitalized);
if !guessed.is_empty() {
let share = if chars.len() > LONG_CAPS { 0.85 } else { 0.5 };
for analysis in &mut analyses {
analysis.prob *= 1.0 - share;
}
analyses.extend(guessed.into_iter().map(|analysis| Analysis {
word: analysis.word.to_uppercase(),
prob: analysis.prob * share,
..analysis
}));
analyses.sort_by(|a, b| b.prob.total_cmp(&a.prob));
}
}
return analyses;
}
Vec::new()
}
pub(super) fn fallback(&self, word: &str) -> Vec<Analysis> {
let chars: Vec<char> = word.chars().collect();
if !token(&chars) {
return Vec::new();
}
self.shares(word, &[(&word.to_lowercase(), self.rule_tags.other, 1.0)])
}
fn shares(&self, word: &str, analyses: &[(&str, u16, f64)]) -> Vec<Analysis> {
let total: f64 = analyses.iter().map(|&(_, _, weight)| weight).sum();
let mut result: Vec<Analysis> = analyses
.iter()
.map(|&(lemma, ud, weight)| Analysis {
word: word.to_owned(),
lemma: lemma.to_owned(),
ud,
tag: None,
prob: weight / total,
source: Source::Rule,
entry: None,
})
.collect();
result.sort_by(|a, b| b.prob.total_cmp(&a.prob));
result
}
fn numeral_with_ending(&self, word: &str) -> Option<Vec<Analysis>> {
let (digits, ending) = word.rsplit_once('-')?;
if ending.is_empty() || !ending.chars().all(cyrillic) {
return None;
}
if roman(digits)
&& !self
.candidates(&format!("1-{}", ending.to_lowercase()))
.is_empty()
{
return Some(self.shares(word, &[(digits, self.rule_tags.roman, 1.0)]));
}
if !digits
.split(RANGE)
.all(|group| !group.is_empty() && group.bytes().all(|b| b.is_ascii_digit()))
{
return None;
}
let lower = word.to_lowercase();
let mut analyses: Vec<Analysis> = self
.merge(self.candidates(&format!("1-{}", ending.to_lowercase())))
.into_iter()
.filter_map(|template| {
let entry = template.entry?;
let lexeme = self.lexeme(entry.table, entry.form, &lower)?;
Some(Analysis {
word: word.to_owned(),
lemma: self.spell(&lexeme, lexeme.lemma_form(entry.form)),
prob: self.numeral_weight(template.ud),
source: Source::Rule,
..template
})
})
.collect();
if !analyses.is_empty() {
let total: f64 = analyses.iter().map(|analysis| analysis.prob).sum();
for analysis in &mut analyses {
analysis.prob /= total;
}
analyses.sort_by(|a, b| b.prob.total_cmp(&a.prob));
return Some(analyses);
}
let ending_lower = ending.to_lowercase();
let cardinal = CARDINAL_ENDINGS.contains(&ending_lower.as_str())
|| (ending.chars().count() <= 3 && ending.chars().all(char::is_lowercase));
cardinal.then(|| self.shares(word, &[(digits, self.rule_tags.cardinal, 1.0)]))
}
pub(super) fn reweight_numerals(&self, analyses: &mut [Analysis]) {
let combi = |analysis: &Analysis| self.has_code(analysis.ud, self.combined_numeral);
if !analyses.iter().all(combi) {
return;
}
for analysis in analyses.iter_mut() {
analysis.prob = self.numeral_weight(analysis.ud);
}
let total: f64 = analyses.iter().map(|analysis| analysis.prob).sum();
for analysis in analyses.iter_mut() {
analysis.prob /= total;
}
analyses.sort_by(|a, b| b.prob.total_cmp(&a.prob));
}
pub(super) fn weigh_by_case(&self, word: &str, analyses: &mut [Analysis]) {
let propn = |analysis: &Analysis| {
self.proper_nouns
.get(usize::from(analysis.ud))
.is_some_and(|&proper| proper)
};
if analyses.iter().all(propn) || !analyses.iter().any(propn) {
return;
}
let (mut letters, mut first_lower, mut rest_lower) = (0, false, true);
for c in word.chars().filter(|&c| cyrillic(c) || c.is_alphabetic()) {
if letters == 0 {
first_lower = c.is_lowercase();
} else {
rest_lower &= c.is_lowercase();
}
letters += 1;
}
let factor = match (first_lower, rest_lower) {
(true, true) => LOWERCASE_PROPN,
(false, true) if letters > 1 => TITLECASE_PROPN,
_ => return,
};
for analysis in analyses.iter_mut().filter(|analysis| propn(analysis)) {
analysis.prob *= factor;
}
let total: f64 = analyses.iter().map(|analysis| analysis.prob).sum();
if total > 0.0 {
for analysis in analyses.iter_mut() {
analysis.prob /= total;
}
}
analyses.sort_by(|a, b| b.prob.total_cmp(&a.prob));
}
pub(super) fn abbreviation_lemma(
&self,
lemma: &str,
tag: u16,
ud: u16,
) -> Option<&'static str> {
let bits = *self.abbreviation_grammemes.get(usize::from(tag))?;
if bits == 0 || self.proper_nouns.get(usize::from(ud)) == Some(&true) {
return None;
}
let start = ABBREVIATION_LEMMAS.partition_point(|&(short, _, _)| short < lemma);
ABBREVIATION_LEMMAS[start..]
.iter()
.take_while(|&&(short, _, _)| short == lemma)
.find(|&&(_, grammeme, _)| {
let bit = ABBREVIATION_GRAMMEMES
.iter()
.position(|&name| name == grammeme);
bit.is_some_and(|bit| bits & 1 << bit != 0)
})
.map(|&(_, _, full)| full)
}
fn numeral_weight(&self, ud: u16) -> f64 {
let feat = |name: &str| self.feat(ud, name).unwrap_or_default();
let key = (
feat("Case"),
feat("Gender"),
feat("Number"),
feat("Animacy"),
);
1.0 + NUMERAL_FEATS
.iter()
.find(|&&(case, gender, number, animacy, _)| (case, gender, number, animacy) == key)
.map_or(0.0, |&(.., count)| count)
}
fn abbreviation(&self, word: &str) -> Option<Vec<Analysis>> {
let lower = word.to_lowercase();
let base = lower.strip_suffix('.')?;
if base.is_empty() || !base.chars().all(cyrillic) {
return None;
}
let abbreviated = |analysis: &Analysis| {
self.ud
.get(analysis.ud)
.is_some_and(|tag| tag.feat("Abbr") == Some("Yes") || tag.has_label("Abbr"))
};
let mut analyses: Vec<Analysis> = self
.merge(self.candidates(base))
.into_iter()
.filter(abbreviated)
.collect();
let total: f64 = analyses.iter().map(|analysis| analysis.prob).sum();
if analyses.is_empty() || total == 0.0 {
return None;
}
for analysis in &mut analyses {
analysis.word = word.to_owned();
analysis.prob /= total;
analysis.source = Source::Rule;
analysis.entry = None;
}
Some(analyses)
}
}
const LONG_CAPS: usize = 7;
const RANGE: [char; 3] = ['-', '–', '—'];
pub(super) fn token(chars: &[char]) -> bool {
!chars.is_empty()
&& !chars
.iter()
.any(|&c| c.is_whitespace() || c.is_control() || invisible(c))
&& !chars.iter().all(|&c| joiner(c))
}
pub(super) fn cyrillic(c: char) -> bool {
matches!(c, 'а'..='я' | 'А'..='Я' | 'ё' | 'Ё')
}
fn symbol(c: char) -> bool {
SYMBOLS.contains(&c)
|| matches!(
c,
'\u{20A0}'..='\u{20CF}'
| '\u{2100}'..='\u{214F}'
| '\u{2190}'..='\u{23FF}'
| '\u{25A0}'..='\u{27BF}'
| '\u{2B00}'..='\u{2BFF}'
| '\u{1F000}'..='\u{1FAFF}'
)
}
fn invisible(c: char) -> bool {
matches!(
c,
'\u{00AD}'
| '\u{0600}'..='\u{0605}'
| '\u{061C}'
| '\u{06DD}'
| '\u{070F}'
| '\u{0890}'..='\u{0891}'
| '\u{08E2}'
| '\u{180E}'
| '\u{200B}'..='\u{200C}'
| '\u{200E}'..='\u{200F}'
| '\u{202A}'..='\u{202E}'
| '\u{2060}'..='\u{2064}'
| '\u{2066}'..='\u{206F}'
| '\u{FEFF}'
| '\u{FFF9}'..='\u{FFFB}'
| '\u{110BD}'
| '\u{110CD}'
| '\u{13430}'..='\u{1343F}'
| '\u{1BCA0}'..='\u{1BCA3}'
| '\u{1D173}'..='\u{1D17A}'
| '\u{E0001}'
)
}
fn joiner(c: char) -> bool {
matches!(
c,
'\u{200D}' | '\u{FE00}'..='\u{FE0F}' | '\u{E0020}'..='\u{E007F}'
)
}
fn number(word: &str) -> Option<bool> {
let unsigned = word.strip_prefix(['+', '-', '−']).unwrap_or(word);
unsigned
.split([
',', '.', ':', '/', ' ', '\u{A0}', '\u{2007}', '\u{2009}', '\u{202F}', '-', '–', '—',
])
.all(|group| !group.is_empty() && group.bytes().all(|b| b.is_ascii_digit()))
.then(|| unsigned == word && unsigned.bytes().all(|b| b.is_ascii_digit()))
}
fn roman(word: &str) -> bool {
const DIGITS: [(&str, u32); 9] = [
("C", 100),
("XC", 90),
("L", 50),
("XL", 40),
("X", 10),
("IX", 9),
("V", 5),
("IV", 4),
("I", 1),
];
if word.is_empty() || !word.chars().all(|c| "IVXLC".contains(c)) {
return false;
}
let mut value = 0;
let mut rest = word;
while let Some(&(digit, amount)) = DIGITS.iter().find(|(digit, _)| rest.starts_with(digit)) {
value += amount;
rest = &rest[digit.len()..];
if value >= 400 {
return false;
}
}
if !rest.is_empty() || value >= 400 {
return false;
}
let mut canonical = String::new();
let mut left = value;
for (digit, amount) in DIGITS {
while left >= amount {
canonical.push_str(digit);
left -= amount;
}
}
canonical == word
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dictionary::tests::dictionary_of;
fn dictionary() -> Dictionary {
let ordinal =
|case| format!("Case={case}|Gender=Masc|Number=Sing|NumForm=Combi|NumType=Ord");
dictionary_of(
&[
(
"ADJ",
"1-",
vec![("й", ordinal("Nom")), ("го", ordinal("Gen"))],
),
(
"NOUN",
"им",
vec![("", "Abbr=Yes|Case=Gen|Number=Sing".to_owned())],
),
],
&[],
)
}
fn rules(dict: &Dictionary, word: &str) -> Vec<(String, String, String)> {
dict.rules(word)
.into_iter()
.map(|analysis| {
assert_eq!(
(analysis.word.as_str(), analysis.source),
(word, Source::Rule)
);
let tag = dict.ud_tag(analysis.ud).unwrap();
(analysis.lemma, tag.pos().to_owned(), tag.feats_string())
})
.collect()
}
fn parts_of_speech(dict: &Dictionary, word: &str) -> Vec<String> {
rules(dict, word)
.into_iter()
.map(|(_, pos, _)| pos)
.collect()
}
fn analysis(lemma: &str, pos: &str, feats: &str) -> (String, String, String) {
(lemma.to_owned(), pos.to_owned(), feats.to_owned())
}
#[test]
fn abbreviation_lemma_table() {
assert!(ABBREVIATION_LEMMAS.is_sorted_by_key(|&(short, _, _)| short));
for &(_, grammeme, _) in ABBREVIATION_LEMMAS {
assert!(ABBREVIATION_GRAMMEMES.contains(&grammeme), "{grammeme}");
}
assert_eq!(
abbreviation_grammemes("NOUN,inan,masc,Fixd,Abbr sing,nomn"),
0b1001_0001
);
assert_eq!(abbreviation_grammemes("ADJF,Fixd,Abbr masc,sing,nomn"), 0);
assert_eq!(abbreviation_grammemes("NOUN,inan,masc sing,nomn"), 0);
}
#[test]
fn proper_nouns_by_letter_case() {
let (noun, propn) = ("Case=Gen|Number=Plur", "Case=Nom|Number=Sing");
let (noun_tag, propn_tag) = (format!("NOUN {noun}"), format!("PROPN {propn}"));
let dict = dictionary_of(
&[
("NOUN", "шар", vec![("ов", noun.to_owned())]),
("PROPN", "шаров", vec![("", propn.to_owned())]),
("PROPN", "москв", vec![("а", propn.to_owned())]),
],
&[
("шаров", &noun_tag, 600_000),
("шаров", &propn_tag, 400_000),
],
);
let first = |word: &str| {
let analyses = dict.analyze(word);
let total: f64 = analyses.iter().map(|analysis| analysis.prob).sum();
assert!((total - 1.0).abs() < 1e-9, "{word}: {total}");
(dict.pos(analyses[0].ud).to_owned(), analyses[0].prob)
};
assert_eq!(first("шаров").0, "NOUN");
let (pos, prob) = first("Шаров");
assert_eq!(pos, "PROPN");
assert!((prob - 2.0 / 3.0).abs() < 1e-9, "{prob}");
assert_eq!(first("ШАРОВ"), ("NOUN".to_owned(), 0.6));
assert_eq!(first("москва"), ("PROPN".to_owned(), 1.0));
}
#[test]
fn numerals_by_template() {
let dict = dictionary();
let genitive = "Case=Gen|Gender=Masc|Number=Sing|NumForm=Combi|NumType=Ord";
assert_eq!(
rules(&dict, "2005-го"),
[analysis("2005-й", "ADJ", genitive)]
);
assert_eq!(
rules(&dict, "10-15-го"),
[analysis("10-15-й", "ADJ", genitive)]
);
let cardinal = "NumForm=Digit|NumType=Card";
assert_eq!(rules(&dict, "5-ти"), [analysis("5", "NUM", cardinal)]);
assert_eq!(rules(&dict, "3,14"), [analysis("3,14", "NUM", cardinal)]);
assert_eq!(parts_of_speech(&dict, "2008"), ["ADJ", "NUM"]);
assert_eq!(parts_of_speech(&dict, "2050-летний"), Vec::<String>::new());
}
#[test]
fn dictionary_numerals_weighted_by_corpus() {
let ordinal = |feats: &str| format!("{feats}|Number=Plur|NumForm=Combi|NumType=Ord");
let dict = dictionary_of(
&[(
"ADJ",
"90-",
vec![
("е", ordinal("Case=Nom")),
("х", ordinal("Animacy=Anim|Case=Acc")),
("х", ordinal("Case=Gen")),
("х", ordinal("Case=Loc")),
],
)],
&[],
);
let weight = |case: &str| {
let analysis = dict
.analyze("90-х")
.into_iter()
.find(|analysis| dict.feat(analysis.ud, "Case") == Some(case))
.unwrap();
dict.numeral_weight(analysis.ud)
};
assert_eq!(
(weight("Gen"), weight("Loc"), weight("Acc")),
(189.0, 72.0, 2.0)
);
let probs: Vec<(Option<&str>, f64)> = dict
.analyze("90-х")
.iter()
.map(|analysis| (dict.feat(analysis.ud, "Case"), analysis.prob))
.collect();
assert_eq!(
probs,
[
(Some("Gen"), 189.0 / 263.0),
(Some("Loc"), 72.0 / 263.0),
(Some("Acc"), 2.0 / 263.0)
]
);
}
#[test]
fn numerals_mixed_with_other_analyses_keep_dictionary_shares() {
let dict = dictionary_of(
&[
(
"ADJ",
"90-",
vec![(
"х",
"Case=Gen|Number=Plur|NumForm=Combi|NumType=Ord".to_owned(),
)],
),
(
"NOUN",
"90-",
vec![("х", "Case=Gen|Number=Plur".to_owned())],
),
],
&[],
);
let probs: Vec<f64> = dict.analyze("90-х").iter().map(|a| a.prob).collect();
assert_eq!(probs, [0.5, 0.5]);
}
#[test]
fn roman_and_latin() {
let dict = dictionary();
let roman = "NumForm=Roman|NumType=Ord";
assert_eq!(rules(&dict, "XIX-го"), [analysis("XIX", "ADJ", roman)]);
assert!(rules(&dict, "X-хромосома").is_empty());
assert_eq!(parts_of_speech(&dict, "xix"), ["ADJ", "X"]);
assert_eq!(parts_of_speech(&dict, "CV"), ["X", "ADJ"]);
assert_eq!(rules(&dict, "ci"), [analysis("ci", "X", "Foreign=Yes")]);
assert_eq!(parts_of_speech(&dict, "II-III"), ["ADJ"]);
}
#[test]
fn initials_abbreviations_and_symbols() {
let dict = dictionary();
let initials: Vec<_> = rules(&dict, "Х")
.into_iter()
.map(|(lemma, _, feats)| (lemma, feats))
.collect();
assert_eq!(
initials,
["Giv", "Pat", "Sur"].map(|name| ("х".to_owned(), format!("Abbr=Yes|NameType={name}")))
);
assert_eq!(
rules(&dict, "им."),
[analysis("им", "NOUN", "Abbr=Yes|Case=Gen|Number=Sing")]
);
assert_eq!(parts_of_speech(&dict, "ГОСТ"), ["PROPN", "NOUN"]);
assert_eq!(
rules(&dict, "по-бурятски"),
[analysis("по-бурятски", "ADV", "Degree=Pos")]
);
assert_eq!(parts_of_speech(&dict, "%"), ["SYM"]);
assert_eq!(parts_of_speech(&dict, "«"), ["PUNCT"]);
assert!(rules(&dict, "км/ч").is_empty());
let fallback = dict.fallback("км/ч").remove(0);
assert_eq!((fallback.lemma.as_str(), fallback.prob), ("км/ч", 1.0));
assert!(dict.fallback("км ч").is_empty());
}
#[test]
fn numbers() {
assert_eq!(number("2026"), Some(true));
assert_eq!(number("3,14"), Some(false));
assert_eq!(number("-5"), Some(false));
assert_eq!(number("1.5"), Some(false));
assert_eq!(number("10 000"), Some(false));
assert_eq!(number("10\u{A0}000"), Some(false));
assert_eq!(number("10\u{2009}000"), Some(false));
assert_eq!(number("10\u{2007}000"), Some(false));
assert_eq!(number("17:04"), Some(false));
assert_eq!(number("2/3"), Some(false));
assert_eq!(number("01.02.2003"), Some(false));
assert_eq!(number("1,"), None);
assert_eq!(number("1..2"), None);
assert_eq!(number("12a"), None);
assert_eq!(number(" "), None);
assert_eq!(number(""), None);
}
#[test]
fn symbols() {
for c in ['%', '№', '€', '−', '♥', '→', '😀'] {
assert!(symbol(c), "{c}");
}
for c in ['.', '«', '<', '-', '…'] {
assert!(!symbol(c), "{c}");
}
}
#[test]
fn ranges() {
assert_eq!(number("1990-2000"), Some(false));
assert_eq!(number("37–38"), Some(false));
assert_eq!(number("3—4"), Some(false));
assert_eq!(number("5-"), None);
assert_eq!(number("5-Б"), None);
}
#[test]
fn tokens() {
assert!(token(&['к', 'м', '/', 'ч']));
assert!(!token(&[]));
assert!(!token(&['к', ' ', 'м']));
assert!(!token(&['к', '\u{200B}']));
assert!(!token(&['\u{200D}']));
}
#[test]
fn roman_numerals() {
for word in ["I", "IV", "XIV", "XX", "XXI", "XLIX", "CCCXC"] {
assert!(roman(word), "{word}");
}
for word in ["", "IIII", "IC", "VX", "CD", "MIX", "ii", "XXL"] {
assert!(!roman(word), "{word}");
}
assert!(!roman(&"C".repeat(100_000)));
}
}