use super::{Analysis, Dictionary, Entry, Source};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[non_exhaustive]
pub enum GuessIndex {
Common,
Proper,
}
impl GuessIndex {
pub const MAX_ENDING: usize = 5;
pub(crate) fn byte(self) -> u8 {
match self {
GuessIndex::Common => 0,
GuessIndex::Proper => 1,
}
}
}
const PROPER_SHARE: f64 = 0.91;
const ADJECTIVAL_PROPER_SHARE: f64 = 0.25;
const PROPER_NUMBER: &[(&str, f64)] = &[("Sing", 57779.0), ("Plur", 613.0)];
const MAX_GUESSED_LENGTH: usize = 100;
const MIN_MIXED_LENGTH: usize = 5;
impl Dictionary {
pub(super) fn guess(&self, word: &str) -> Vec<Analysis> {
let chars: Vec<char> = word.chars().collect();
if chars.is_empty()
|| chars.len() > MAX_GUESSED_LENGTH
|| !chars.iter().all(|&c| super::rules::cyrillic(c))
{
return Vec::new();
}
let lower = word.to_lowercase();
let capitals = chars.iter().take_while(|c| c.is_uppercase()).count();
let abbreviation_with_ending = capitals >= 2
&& (1..=3).contains(&(chars.len() - capitals))
&& chars[capitals..].iter().all(|c| c.is_lowercase());
let proper = if adjectival(&lower) {
ADJECTIVAL_PROPER_SHARE
} else {
PROPER_SHARE
};
let lowercase = chars.iter().all(|c| c.is_lowercase());
let mixed = !lowercase
&& !chars[1..].iter().all(|c| c.is_lowercase())
&& !chars.iter().all(|c| c.is_uppercase());
let lowercase_tail = capitals >= 2 && chars[capitals..].iter().all(|c| c.is_lowercase());
if lowercase || chars[0].is_lowercase() || (lowercase_tail && !abbreviation_with_ending) {
if mixed && chars.len() < MIN_MIXED_LENGTH {
return Vec::new();
}
return self.guess_with(&[(GuessIndex::Common, 1.0)], word, &lower, None);
}
let stem: Option<String> = if abbreviation_with_ending {
Some(lower.chars().take(capitals).collect())
} else {
(mixed && chars[chars.len() - 1].is_uppercase()).then(|| lower.clone())
};
let title: &[(GuessIndex, f64)] = &[
(GuessIndex::Proper, proper),
(GuessIndex::Common, 1.0 - proper),
];
let analyses = self.guess_with(title, word, &lower, stem.as_deref());
if analyses.is_empty() && lowercase_tail && chars.len() >= MIN_MIXED_LENGTH {
return self.guess_with(&[(GuessIndex::Common, 1.0)], word, &lower, None);
}
analyses
}
fn guess_with(
&self,
indexes: &[(GuessIndex, f64)],
word: &str,
lower: &str,
stem: Option<&str>,
) -> Vec<Analysis> {
let mut analyses = Vec::new();
for &(index, share) in indexes {
let found: Vec<(Analysis, f64)> = self
.guess_in(index, word, lower, stem)
.into_iter()
.map(|(analysis, count)| {
let weight = match index {
GuessIndex::Proper => self.proper_weight(analysis.ud),
GuessIndex::Common => 1.0,
};
(analysis, f64::from(count) * weight)
})
.collect();
let total: f64 = found.iter().map(|(_, weight)| weight).sum();
analyses.extend(found.into_iter().map(|(analysis, weight)| Analysis {
prob: share * weight / total,
..analysis
}));
}
let total: f64 = analyses.iter().map(|analysis| analysis.prob).sum();
for analysis in &mut analyses {
analysis.prob /= total;
}
self.merge(analyses)
}
fn guess_in(
&self,
index: GuessIndex,
word: &str,
lower: &str,
stem: Option<&str>,
) -> Vec<(Analysis, u32)> {
let bounds: Vec<usize> = lower.char_indices().map(|(at, _)| at).collect();
for length in (1..=GuessIndex::MAX_ENDING.min(bounds.len().saturating_sub(1))).rev() {
let ending = &lower[bounds[bounds.len() - length]..];
let Some(forms) = self
.alphabet
.key(index.byte(), ending)
.and_then(|key| self.guesses.get(key.bytes()))
.and_then(|offset| self.guess_records_at(offset))
else {
continue;
};
let found: Vec<(Analysis, u32)> = forms
.iter()
.filter(|&&[_, count]| count > 0)
.filter_map(|&[packed, count]| {
let (table, form) = ((packed >> 16) as u16, packed as u16);
let (word, lower) = self.with_suffix(table, form, word, lower)?;
let lexeme = self.lexeme(table, form, &lower)?;
let lemma = self.spell(&lexeme, lexeme.lemma_form(form));
match stem {
Some(stem) if lemma != stem => return None,
None if !lemma.chars().any(vowel) => return None,
_ => {}
}
let analysis = Analysis {
word,
lemma,
ud: lexeme.ud(form),
tag: Some(lexeme.tag(form)),
prob: 0.0,
source: Source::Guess,
entry: Some(Entry { table, form }),
};
Some((analysis, count))
})
.collect();
if !found.is_empty() {
return found;
}
}
Vec::new()
}
fn with_suffix(
&self,
table: u16,
form: u16,
word: &str,
lower: &str,
) -> Option<(String, String)> {
let start = *self.table_offsets.get(usize::from(table))? as usize;
let forms = self.paradigm(*self.tables().get(start)? as u16)?;
if usize::from(form) >= forms.len() / 3 {
return None;
}
let suffix = self.suffixes.at(usize::from(forms[usize::from(form)]));
if lower.ends_with(suffix) {
return Some((word.to_owned(), lower.to_owned()));
}
if !suffix.contains('ё') || !lower.ends_with(&suffix.replace('ё', "е")) {
return None;
}
let replace = |text: &str| {
let start = text.len() - suffix.len();
let tail: String = text[start..]
.chars()
.zip(suffix.chars())
.map(|pair| match pair {
('е', 'ё') => 'ё',
('Е', 'ё') => 'Ё',
(letter, _) => letter,
})
.collect();
[&text[..start], &tail].concat()
};
Some((replace(word), replace(lower)))
}
fn proper_weight(&self, ud: u16) -> f64 {
let number = self.feat(ud, "Number");
PROPER_NUMBER
.iter()
.find(|&&(known, _)| Some(known) == number)
.map_or(1.0, |&(_, count)| count)
}
fn guess_records_at(&self, offset: u64) -> Option<&[[u32; 2]]> {
let pairs = super::list(self.guess_records(), u32::try_from(offset).ok()?, 2)?;
Some(pairs.as_chunks::<2>().0)
}
}
fn vowel(c: char) -> bool {
"аеёиоуыэюя".contains(c)
}
fn adjectival(lower: &str) -> bool {
const ENDINGS: &[&str] = &[
"ий", "ая", "ое", "ие", "ого", "ому", "им", "ом", "ой", "ую", "их", "ими", "ей", "ою",
];
ENDINGS.iter().any(|ending| {
lower
.strip_suffix(ending)
.is_some_and(|stem| stem.ends_with("ск") || stem.ends_with("цк"))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adjectival_endings() {
for word in ["тильзитского", "стэнфордская", "бельгийские"]
{
assert!(adjectival(word), "{word}");
}
for word in ["шатов", "москва", "киска"] {
assert!(!adjectival(word), "{word}");
}
}
}