use std::cmp::Reverse;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::io::Read;
use std::ops::Range;
use std::path::Path;
use std::sync::Arc;
use fst::Map;
use crate::Error;
use crate::alphabet::{Alphabet, Key, yo_order};
use crate::format::{self, Layout, MAX_SPELLINGS, Slice, Storage, Strings, WordValue};
mod guess;
mod parts;
mod rules;
mod ud;
#[cfg(any(test, feature = "build"))]
pub use guess::GuessIndex;
use parts::with_particle;
pub use ud::UdTag;
use ud::UdTags;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Source {
Dictionary,
Rule,
Guess,
}
impl Source {
pub fn as_str(self) -> &'static str {
match self {
Source::Dictionary => "dictionary",
Source::Rule => "rule",
Source::Guess => "guess",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Entry {
pub table: u16,
pub form: u16,
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct Analysis {
pub word: String,
pub lemma: String,
pub ud: u16,
pub tag: Option<u16>,
pub prob: f64,
pub source: Source,
pub entry: Option<Entry>,
}
struct Codes<'a> {
pos: u16,
values: &'a [u16],
labels: &'a [u16],
}
struct Lexeme<'a> {
table: u16,
forms: &'a [u16],
cells: &'a [u32],
stem: &'a str,
}
impl Lexeme<'_> {
fn lemma_form(&self, form: u16) -> u16 {
(self.cells[usize::from(form)] >> 16) as u16
}
fn ud(&self, form: u16) -> u16 {
self.cells[usize::from(form)] as u16
}
fn tag(&self, form: u16) -> u16 {
self.forms[self.cells.len() + usize::from(form)]
}
fn affixes(&self, form: u16) -> (usize, usize) {
let form = usize::from(form);
(
usize::from(self.forms[2 * self.cells.len() + form]),
usize::from(self.forms[form]),
)
}
}
pub struct Dictionary {
storage: Arc<Storage>,
layout: Layout,
meta: String,
tags: Strings,
ud: UdTags,
ud_core: Vec<u16>,
ud_values: Vec<u16>,
rule_tags: rules::Tags,
preferences: Preferences,
combined_numeral: Option<(usize, u16)>,
features: BTreeMap<String, BTreeSet<String>>,
alphabet: Alphabet,
prefixes: Strings,
suffixes: Strings,
table_offsets: Vec<u32>,
words: Map<Slice>,
word_count: usize,
guesses: Map<Slice>,
}
impl std::fmt::Debug for Dictionary {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Dictionary")
.field("meta", &self.meta)
.field("word_count", &self.word_count)
.field("ud_tags", &self.ud.len())
.finish_non_exhaustive()
}
}
enum Spellings<'a> {
Single {
yo: u64,
records: u32,
probabilities: Option<u32>,
},
Many {
variants: &'a [[u32; 3]],
probabilities: &'a [[u32; 3]],
},
}
impl Spellings<'_> {
fn variants(&self) -> impl Iterator<Item = (u64, u32)> {
let (single, many) = match *self {
Spellings::Single { yo, records, .. } => (Some((yo, records)), &[][..]),
Spellings::Many { variants, .. } => (None, variants),
};
single
.into_iter()
.chain(many.iter().map(|&triple| unpack(triple)))
}
fn probabilities(&self, yo: u64) -> Option<u32> {
match *self {
Spellings::Single {
yo: own,
probabilities,
..
} => probabilities.filter(|_| own == yo),
Spellings::Many { probabilities, .. } => probabilities
.iter()
.map(|&triple| unpack(triple))
.find(|&(own, _)| own == yo)
.map(|(_, offset)| offset),
}
}
}
fn unpack([low, high, offset]: [u32; 3]) -> (u64, u32) {
(u64::from(low) | u64::from(high) << 32, offset)
}
struct Matches {
items: [(u64, u32); MAX_SPELLINGS],
len: usize,
}
impl Matches {
fn as_slice(&self) -> &[(u64, u32)] {
&self.items[..self.len]
}
}
impl Dictionary {
pub fn open(path: impl AsRef<Path>) -> Result<Self, Error> {
let mut file = std::fs::File::open(path)?;
let metadata = file.metadata()?;
if metadata.is_file() && metadata.len() > 0 {
let map = unsafe { memmap2::Mmap::map(&file)? };
return Self::from_storage(Storage::Mapped(map));
}
let mut data = Vec::new();
file.read_to_end(&mut data)?;
Self::from_bytes(&data)
}
pub fn from_bytes(data: &[u8]) -> Result<Self, Error> {
Self::from_storage(Storage::copy(data))
}
pub fn from_static(data: &'static [u8]) -> Result<Self, Error> {
Self::from_storage(Storage::of_static(data))
}
#[cfg(feature = "bundled")]
#[cfg_attr(docsrs, doc(cfg(feature = "bundled")))]
pub fn bundled() -> Result<Self, Error> {
if rumorph_dict_ru::DICTIONARY.is_empty() {
return Err(Error::Format(
"крейт rumorph-dict-ru собран без файла словаря".into(),
));
}
Self::from_static(rumorph_dict_ru::DICTIONARY)
}
fn from_storage(storage: Storage) -> Result<Self, Error> {
let layout = format::layout(storage.bytes())?;
let storage = Arc::new(storage.native(&layout));
let bytes = |range: &Range<usize>| &storage.bytes()[range.clone()];
let meta = std::str::from_utf8(bytes(&layout.meta))
.map_err(|_| Error::Format("строка не в UTF-8".into()))?
.to_owned();
let alphabet = std::str::from_utf8(bytes(&layout.alphabet))
.map_err(|_| Error::Format("строка не в UTF-8".into()))
.and_then(Alphabet::new)?;
let mut ud = UdTags::read(
&Strings::read(bytes(&layout.ud_strings))?,
&bytes(&layout.ud_tags)
.as_chunks::<2>()
.0
.iter()
.map(|&pair| u16::from_le_bytes(pair))
.collect::<Vec<_>>(),
)?;
let ud_count = ud.len();
let mut dictionary = Dictionary {
tags: Strings::read(bytes(&layout.tags))?,
prefixes: Strings::read(bytes(&layout.prefixes))?,
suffixes: Strings::read(bytes(&layout.suffixes))?,
words: automaton(&storage, &layout.words)?,
guesses: automaton(&storage, &layout.guesses)?,
word_count: 0,
table_offsets: Vec::new(),
rule_tags: rules::Tags::add(&mut ud),
ud,
ud_core: Vec::new(),
ud_values: Vec::new(),
features: BTreeMap::new(),
preferences: Preferences::default(),
combined_numeral: None,
meta,
alphabet,
layout,
storage: Arc::clone(&storage),
};
if dictionary.ud.len() > usize::from(u16::MAX) + 1 {
return Err(Error::Format("больше 65 536 тегов UD".into()));
}
dictionary.check_paradigms()?;
dictionary.table_offsets = dictionary.check_tables(ud_count)?;
dictionary.check_lists()?;
dictionary.word_count = dictionary.words.len() + dictionary.check_variants()?;
dictionary.index_ud_tags();
Ok(dictionary)
}
fn index_ud_tags(&mut self) {
let numbers = |number: usize| self.ud.numbers(number as u16).unwrap_or_default();
let mut cores: HashMap<(u16, &[u16]), u16> = HashMap::new();
let mut pairs: HashSet<(u16, u16)> = HashSet::new();
for number in 0..self.ud.len() {
let (pos, feats, _) = numbers(number);
pairs.extend(
feats
.as_chunks::<2>()
.0
.iter()
.map(|&[name, value]| (name, value)),
);
let next = cores.len() as u16;
self.ud_core
.push(*cores.entry((pos, feats)).or_insert(next));
}
let string = |number: u16| self.ud.string(number);
for &(name, value) in &pairs {
self.features
.entry(string(name).to_owned())
.or_default()
.insert(string(value).to_owned());
}
let mut codes: HashMap<(u16, u16), (usize, u16)> = HashMap::new();
for (name, value) in pairs {
codes.insert(
(name, value),
feature_code(&self.features, string(name), string(value)).unwrap_or_default(),
);
}
let count = self.features.len();
self.ud_values = vec![0; self.ud.len() * count];
for number in 0..self.ud.len() {
for &[name, value] in numbers(number).1.as_chunks::<2>().0 {
let (index, code) = codes[&(name, value)];
self.ud_values[number * count + index] = code;
}
}
let code = |name: &str, value: &str| feature_code(&self.features, name, value);
self.preferences = Preferences {
infinitive: code("VerbForm", "Inf"),
finite: code("VerbForm", "Fin"),
short: code("Variant", "Short"),
animate: code("Animacy", "Anim"),
first_person: code("Person", "1"),
inclusive: self.ud.string_number("incl"),
counting: self.ud.string_number("Coun"),
};
self.combined_numeral = code("NumForm", "Combi");
}
pub fn meta(&self) -> &str {
&self.meta
}
pub fn tag(&self, number: u16) -> Option<&str> {
self.tags.get(usize::from(number))
}
pub fn tags(&self) -> impl ExactSizeIterator<Item = &str> {
self.tags.iter()
}
pub fn ud_tag(&self, number: u16) -> Option<UdTag<'_>> {
self.ud.get(number)
}
pub fn ud_tags(&self) -> impl ExactSizeIterator<Item = UdTag<'_>> {
self.ud.iter()
}
pub fn features(&self) -> &BTreeMap<String, BTreeSet<String>> {
&self.features
}
pub fn word_count(&self) -> usize {
self.word_count
}
pub fn analyze(&self, word: &str) -> Vec<Analysis> {
if let Some(normal) = normalized_input(word) {
return self.analyze(&normal);
}
let mut analyses = self.merge(self.candidates(word));
if !analyses.is_empty() {
self.reweight_numerals(&mut analyses);
return analyses;
}
let analyses = self.rules(word);
if !analyses.is_empty() {
return analyses;
}
let analyses = self.parts(word);
if !analyses.is_empty() {
return analyses;
}
let analyses = self.guess(word);
if !analyses.is_empty() {
return analyses;
}
self.fallback(word)
}
fn candidates(&self, word: &str) -> Vec<Analysis> {
let Some(key) = self.alphabet.input_key(word) else {
return Vec::new();
};
let Some(spellings) = self.spellings(&key) else {
return Vec::new();
};
let matches = self.matches(&spellings, key.yo);
let mut analyses = Vec::new();
let mut groups = [(0, 0, None); MAX_SPELLINGS];
let input = spellings
.probabilities(key.yo)
.and_then(|offset| self.probabilities_at(offset));
for (group, &(yo, records)) in groups.iter_mut().zip(matches.as_slice()) {
let spelling = self.alphabet.spelling(&key, yo);
let start = analyses.len();
for &entry in self.records_at(records).unwrap_or_default() {
let (table, form) = ((entry >> 16) as u16, entry as u16);
let Some(lexeme) = self.lexeme(table, form, &spelling) else {
continue;
};
analyses.push(Analysis {
lemma: self.spell(&lexeme, lexeme.lemma_form(form)),
ud: lexeme.ud(form),
tag: Some(lexeme.tag(form)),
word: String::new(),
prob: 0.0,
source: Source::Dictionary,
entry: Some(Entry { table, form }),
});
}
if let Some((last, rest)) = analyses[start..].split_last_mut() {
for analysis in rest {
analysis.word.clone_from(&spelling);
}
last.word = spelling;
}
let table = match input {
Some(table) => Some(table),
None if yo == key.yo => None,
None => spellings
.probabilities(yo)
.and_then(|offset| self.probabilities_at(offset)),
};
*group = (start, analyses.len(), table);
}
self.assign_probabilities(&groups[..matches.len], input.is_some(), &mut analyses);
analyses
}
#[doc(hidden)]
pub fn opencorpora(&self, word: &str) -> Vec<(String, &str, String, f64)> {
let analyses = self.candidates(word);
let uniform = analyses.iter().all(|analysis| analysis.prob == 0.0);
let share = 1.0 / analyses.len() as f64;
analyses
.into_iter()
.filter_map(|analysis| {
let entry = analysis.entry?;
let lexeme = self.lexeme(entry.table, entry.form, &analysis.word)?;
let lemma = self.spell(&lexeme, 0);
let prob = if uniform { share } else { analysis.prob };
Some((
analysis.word,
self.tags.get(usize::from(analysis.tag?))?,
lemma,
prob,
))
})
.collect()
}
fn merge(&self, mut merged: Vec<Analysis>) -> Vec<Analysis> {
let yo_count = |word: &str| word.chars().filter(|&ch| ch == 'ё').count();
let core = |analysis: &Analysis| self.ud_core.get(usize::from(analysis.ud));
let mut kept = 0;
for i in 0..merged.len() {
let analysis = &merged[i];
let same = (0..kept).find(|&j| {
let known = &merged[j];
core(known) == core(analysis)
&& known.lemma == analysis.lemma
&& same_up_to_yo(&known.word, &analysis.word)
});
match same {
Some(j) => {
let prob = merged[j].prob + merged[i].prob;
if yo_count(&merged[i].word) < yo_count(&merged[j].word) {
merged.swap(i, j);
}
merged[j].prob = prob;
}
None => {
merged.swap(kept, i);
kept += 1;
}
}
}
merged.truncate(kept);
if merged.iter().all(|analysis| analysis.prob == 0.0) {
let prob = 1.0 / merged.len() as f64;
for analysis in &mut merged {
analysis.prob = prob;
}
} else {
merged.sort_by(|a, b| b.prob.total_cmp(&a.prob));
}
merged
}
pub fn analysis(&self, word: &str, entry: Entry, source: Source) -> Option<Analysis> {
if source == Source::Guess
&& let Some((base, particle)) = parts::split_particle(word)
&& self
.lexeme(entry.table, entry.form, &base.to_lowercase())
.is_some()
{
return self
.analysis(base, entry, source)
.map(|analysis| with_particle(analysis, particle));
}
let lower = word.to_lowercase();
let lexeme = self.lexeme(entry.table, entry.form, &lower)?;
if source == Source::Dictionary {
let same = |packed: u32| {
let form = packed as u16;
(packed >> 16) as u16 == entry.table
&& (form == entry.form
|| (lexeme.affixes(form) == lexeme.affixes(entry.form)
&& lexeme.tag(form) == lexeme.tag(entry.form)))
};
let key = self.alphabet.spelling_key(word)?;
let (_, offset) = self
.spellings(&key)?
.variants()
.find(|&(yo, _)| yo == key.yo)?;
if !self.records_at(offset)?.iter().any(|&packed| same(packed)) {
return None;
}
}
Some(Analysis {
word: word.to_owned(),
lemma: self.spell(&lexeme, lexeme.lemma_form(entry.form)),
ud: lexeme.ud(entry.form),
tag: Some(lexeme.tag(entry.form)),
prob: 1.0,
source,
entry: Some(entry),
})
}
pub fn forms(&self, analysis: &Analysis) -> Vec<Analysis> {
if let Some((base, particle)) = self.without_particle(analysis) {
return self
.forms(&base)
.into_iter()
.map(|form| with_particle(form, &particle))
.collect();
}
if let Some(lower) = self.lowercased(analysis) {
return self
.forms(&lower)
.into_iter()
.map(|form| capitalized_like(&analysis.word, form))
.collect();
}
if analysis.entry.is_none() {
return vec![Analysis {
prob: 1.0,
..analysis.clone()
}];
}
let Some((lexeme, forms)) = self.lexeme_forms(analysis) else {
return Vec::new();
};
let mut seen = HashSet::new();
forms
.into_iter()
.filter(|&form| {
seen.insert((
lexeme.affixes(form),
self.ud_core[usize::from(lexeme.ud(form))],
))
})
.map(|form| self.form(analysis, &lexeme, form))
.collect()
}
pub fn inflect(&self, analysis: &Analysis, feats: &[(&str, &str)]) -> Option<Analysis> {
if let Some((base, particle)) = self.without_particle(analysis) {
return self
.inflect(&base, feats)
.map(|form| with_particle(form, &particle));
}
if let Some(lower) = self.lowercased(analysis) {
return self
.inflect(&lower, feats)
.map(|form| capitalized_like(&analysis.word, form));
}
if analysis.entry.is_none() {
let tag = self.ud.get(analysis.ud)?;
let own = feats
.iter()
.all(|&(name, value)| tag.feat(name) == Some(value));
return own.then(|| Analysis {
prob: 1.0,
..analysis.clone()
});
}
let (lexeme, forms) = self.lexeme_forms(analysis)?;
let form = self.closest(analysis, &lexeme, &forms, feats, &[])?;
Some(self.form(analysis, &lexeme, form))
}
pub fn agree(&self, analysis: &Analysis, number: u64) -> Option<Analysis> {
self.agree_with(analysis, Some(number))
}
pub fn agree_fraction(&self, analysis: &Analysis) -> Option<Analysis> {
self.agree_with(analysis, None)
}
fn agree_with(&self, analysis: &Analysis, number: Option<u64>) -> Option<Analysis> {
if let Some((base, particle)) = self.without_particle(analysis) {
return self
.agree_with(&base, number)
.map(|form| with_particle(form, &particle));
}
if let Some(lower) = self.lowercased(analysis) {
return self
.agree_with(&lower, number)
.map(|form| capitalized_like(&analysis.word, form));
}
let tag = self.ud.get(analysis.ud)?;
let feat = |name: &str| tag.feat(name);
let case = match feat("Case")? {
"Par" => "Gen",
case => case,
};
let (lexeme, forms) = self.lexeme_forms(analysis)?;
let nominal = matches!(tag.pos(), "NOUN" | "PROPN");
let female_surname = feat("NameType") == Some("Sur")
&& feat("Gender") == Some("Fem")
&& feat("InflClass").is_none()
&& ["ова", "ева", "ёва", "ина", "ына"]
.iter()
.any(|ending| analysis.lemma.ends_with(ending));
let adjectival = matches!(tag.pos(), "ADJ" | "DET")
|| feat("VerbForm") == Some("Part")
|| (nominal && (female_surname || self.adjectival_declension(&lexeme, &forms)));
if !adjectival && !nominal {
return None;
}
let gen_sing = [("Case", "Gen"), ("Number", "Sing")];
let gen_plur = [("Case", "Gen"), ("Number", "Plur")];
let counting = self.preferences.counting.as_slice();
let Some(number) = number else {
let form = [&gen_sing[..], &gen_plur[..]]
.into_iter()
.find_map(|feats| self.closest(analysis, &lexeme, &forms, feats, &[]))?;
return Some(self.form(analysis, &lexeme, form));
};
let one = number % 10 == 1 && number % 100 != 11;
let few = (2..=4).contains(&(number % 10)) && !(12..=14).contains(&(number % 100));
let animate = feat("Animacy") == Some("Anim");
let sing = [("Case", case), ("Number", "Sing")];
let plur = [("Case", case), ("Number", "Plur")];
let plur_inanimate = [("Case", case), ("Number", "Plur"), ("Animacy", "Inan")];
let targets: Vec<&[(&str, &str)]> = if one {
vec![&sing, &plur]
} else if !matches!(case, "Nom" | "Acc") {
vec![&plur]
} else if case == "Acc" && animate && (adjectival || (few && number < 5)) {
vec![&gen_plur]
} else if adjectival && few && feat("Gender") == Some("Fem") {
vec![&plur_inanimate, &plur]
} else if few && !adjectival {
vec![&gen_sing, &gen_plur]
} else {
vec![&gen_plur]
};
let form = targets
.into_iter()
.find_map(|feats| self.closest(analysis, &lexeme, &forms, feats, counting))?;
Some(self.form(analysis, &lexeme, form))
}
fn adjectival_declension(&self, lexeme: &Lexeme, forms: &[u16]) -> bool {
let ends = |case: &str, endings: &[&str]| {
let Some(required) = [("Case", case), ("Number", "Sing")]
.into_iter()
.map(|(name, value)| self.code(name, value))
.collect::<Option<Vec<_>>>()
else {
return false;
};
forms
.iter()
.find(|&&form| {
self.codes(lexeme.ud(form)).is_some_and(|codes| {
required
.iter()
.all(|&(name, value)| codes.values[name] == value)
})
})
.is_some_and(|&form| {
let word = self.spell(lexeme, form);
endings.iter().any(|ending| word.ends_with(ending))
})
};
ends("Nom", &["ый", "ий", "ой", "ое", "ее", "ая", "яя"])
&& ends("Gen", &["ого", "его", "ой", "ей"])
}
fn closest(
&self,
analysis: &Analysis,
lexeme: &Lexeme,
forms: &[u16],
feats: &[(&str, &str)],
prefer: &[u16],
) -> Option<u16> {
let required = feats
.iter()
.map(|&(name, value)| self.code(name, value))
.collect::<Option<Vec<_>>>()?;
let current = self.codes(analysis.ud)?;
let mut wanted = current.values.to_vec();
let preferences = &self.preferences;
if let (Some((form_name, infinitive)), Some((_, finite))) =
(preferences.infinitive, preferences.finite)
&& wanted[form_name] == infinitive
&& required.iter().all(|&(name, _)| name != form_name)
&& required
.iter()
.any(|&(name, value)| current.values[name] != value)
{
wanted[form_name] = finite;
}
for &(name, value) in &required {
wanted[name] = value;
}
let mut labels = current.labels.to_vec();
for &label in prefer {
if !labels.contains(&label) {
labels.push(label);
}
}
let unwanted = |feature: Option<(usize, u16)>, codes: &Codes| {
feature
.is_some_and(|(index, code)| codes.values[index] == code && wanted[index] != code)
};
let own = lexeme.affixes(analysis.entry.map_or(0, |entry| entry.form));
let twin = forms
.iter()
.any(|&form| lexeme.ud(form) == analysis.ud && lexeme.affixes(form) != own);
type Score = (usize, Reverse<usize>, usize);
let mut best: Option<(u16, Score)> = None;
for &form in forms {
let Some(codes) = self.codes(lexeme.ud(form)) else {
continue;
};
if required
.iter()
.any(|&(name, value)| codes.values[name] != value)
{
continue;
}
let same = wanted
.iter()
.zip(codes.values)
.filter(|&(wanted, value)| *wanted != 0 && wanted == value)
.count()
+ labels
.iter()
.filter(|label| codes.labels.contains(label))
.count()
+ usize::from(codes.pos == current.pos);
let extra = codes
.labels
.iter()
.filter(|label| !labels.contains(label))
.count()
+ usize::from(unwanted(preferences.short, &codes))
+ usize::from(unwanted(preferences.animate, &codes))
+ usize::from(preferences.inclusive.is_some_and(|label| {
codes.labels.contains(&label)
&& preferences
.first_person
.is_none_or(|(index, code)| wanted[index] != code)
}));
let common = if twin {
self.spell(lexeme, form)
.chars()
.zip(analysis.word.chars())
.take_while(|(a, b)| a == b)
.count()
} else {
0
};
let score = (same, Reverse(extra), common);
if best.is_none_or(|(_, top)| score > top) {
best = Some((form, score));
}
}
best.map(|(form, _)| form)
}
fn code(&self, name: &str, value: &str) -> Option<(usize, u16)> {
feature_code(&self.features, name, value)
}
fn codes(&self, number: u16) -> Option<Codes<'_>> {
let (pos, _, labels) = self.ud.numbers(number)?;
let count = self.features.len();
let start = usize::from(number) * count;
Some(Codes {
pos,
values: self.ud_values.get(start..start + count)?,
labels,
})
}
fn pos(&self, number: u16) -> &str {
self.ud.get(number).map_or("", UdTag::pos)
}
fn feat(&self, number: u16, name: &str) -> Option<&str> {
self.ud.get(number)?.feat(name)
}
fn has_code(&self, number: u16, code: Option<(usize, u16)>) -> bool {
code.is_some_and(|(index, code)| {
let start = usize::from(number) * self.features.len();
self.ud_values.get(start + index) == Some(&code)
})
}
fn lexeme_forms<'a>(&'a self, analysis: &'a Analysis) -> Option<(Lexeme<'a>, Vec<u16>)> {
let entry = analysis.entry?;
let lexeme = self.lexeme(entry.table, entry.form, &analysis.word)?;
let own = lexeme.lemma_form(entry.form);
let (prefix, suffix) = lexeme.affixes(own);
let (prefix, suffix) = (self.prefixes.at(prefix), self.suffixes.at(suffix));
let forms = (0..lexeme.cells.len() as u16)
.filter(|&form| {
let lemma_form = lexeme.lemma_form(form);
if lemma_form == own {
return true;
}
let (other_prefix, other_suffix) = lexeme.affixes(lemma_form);
self.prefixes.at(other_prefix) == prefix && self.suffixes.at(other_suffix) == suffix
})
.collect();
Some((lexeme, forms))
}
fn lowercased(&self, analysis: &Analysis) -> Option<Analysis> {
let entry = analysis.entry?;
if self
.lexeme(entry.table, entry.form, &analysis.word)
.is_some()
{
return None;
}
let word = analysis.word.to_lowercase();
(word != analysis.word && self.lexeme(entry.table, entry.form, &word).is_some()).then(
|| Analysis {
word,
..analysis.clone()
},
)
}
fn lexeme<'a>(&'a self, table: u16, form: u16, word: &'a str) -> Option<Lexeme<'a>> {
let start = *self.table_offsets.get(usize::from(table))? as usize;
let tables = self.tables();
let forms = self.paradigm(*tables.get(start)? as u16)?;
let cells = tables.get(start + 1..start + 1 + forms.len() / 3)?;
if usize::from(form) >= cells.len() {
return None;
}
let mut lexeme = Lexeme {
table,
forms,
cells,
stem: "",
};
let (prefix, suffix) = lexeme.affixes(form);
lexeme.stem = word
.strip_prefix(self.prefixes.at(prefix))?
.strip_suffix(self.suffixes.at(suffix))?;
Some(lexeme)
}
fn spell(&self, lexeme: &Lexeme, form: u16) -> String {
let (prefix, suffix) = lexeme.affixes(form);
[
self.prefixes.at(prefix),
lexeme.stem,
self.suffixes.at(suffix),
]
.concat()
}
fn form(&self, analysis: &Analysis, lexeme: &Lexeme, form: u16) -> Analysis {
let (prefix, suffix) = lexeme.affixes(form);
let prefix = self.prefixes.at(prefix);
let mut stem = lexeme.stem.chars();
let word = match (prefix.chars().next(), stem.next()) {
(Some(head), Some(first)) if first.is_uppercase() => [
head.to_uppercase().to_string().as_str(),
&prefix[head.len_utf8()..],
first.to_lowercase().to_string().as_str(),
stem.as_str(),
self.suffixes.at(suffix),
]
.concat(),
_ => self.spell(lexeme, form),
};
Analysis {
word,
lemma: analysis.lemma.clone(),
ud: lexeme.ud(form),
tag: Some(lexeme.tag(form)),
prob: 1.0,
source: analysis.source,
entry: Some(Entry {
table: lexeme.table,
form,
}),
}
}
pub fn is_known(&self, word: &str) -> bool {
let normal = normalized_input(word);
let Some(key) = self.alphabet.input_key(normal.as_deref().unwrap_or(word)) else {
return false;
};
self.spellings(&key)
.is_some_and(|spellings| spellings.variants().any(|(yo, _)| yo & key.yo == key.yo))
}
fn paradigm(&self, paradigm: u16) -> Option<&[u16]> {
let i = paradigm as usize;
let offsets = self.paradigm_offsets();
let (start, end) = (*offsets.get(i)?, *offsets.get(i + 1)?);
self.paradigms().get(start as usize..end as usize)
}
fn records_at(&self, offset: u32) -> Option<&[u32]> {
list(self.records(), offset, 1)
}
fn probabilities_at(&self, offset: u32) -> Option<&[u32]> {
list(self.probability_records(), offset, 2)
}
fn assign_probabilities(
&self,
groups: &[(usize, usize, Option<&[u32]>)],
input: bool,
analyses: &mut [Analysis],
) {
let table = |i: usize| {
groups
.iter()
.find(|&&(start, end, _)| (start..end).contains(&i))
.and_then(|&(_, _, table)| table)
};
if groups.iter().all(|&(_, _, table)| table.is_none()) {
return;
}
let mut total = 0.0;
let (mut known_total, mut known_count) = (0.0, 0);
for i in 0..analyses.len() {
let Some(table) = table(i) else {
continue;
};
let (tag, spelling) = (analyses[i].tag, analyses[i].word.as_str());
let share = analyses
.iter()
.filter(|other| other.tag == tag && (input || other.word == spelling))
.count() as f64;
let prob = table
.as_chunks::<2>()
.0
.iter()
.find(|[known, _]| Some(*known) == tag.map(u32::from))
.map_or(0.0, |[_, millionths]| f64::from(*millionths));
analyses[i].prob = prob / share;
total += analyses[i].prob;
known_total += analyses[i].prob;
known_count += 1;
}
let mean = known_total / f64::from(known_count);
for (i, analysis) in analyses.iter_mut().enumerate() {
if table(i).is_none() {
analysis.prob = mean;
total += mean;
}
}
if total > 0.0 {
for analysis in analyses.iter_mut() {
analysis.prob /= total;
}
analyses.sort_by(|a, b| b.prob.total_cmp(&a.prob));
}
}
fn spellings(&self, key: &Key) -> Option<Spellings<'_>> {
Some(match WordValue::unpack(self.words.get(key.bytes())?) {
WordValue::Single {
yo,
records,
probabilities,
} => Spellings::Single {
yo,
records,
probabilities,
},
WordValue::Many(offset) => {
let lists = self.variants().get(offset as usize..)?;
let (variants, rest) = triples(lists)?;
let (probabilities, _) = triples(rest)?;
Spellings::Many {
variants,
probabilities,
}
}
})
}
fn matches(&self, spellings: &Spellings, yo: u64) -> Matches {
let mut matches = Matches {
items: [(0, 0); MAX_SPELLINGS],
len: 0,
};
for (own, records) in spellings.variants() {
if own & yo == yo && matches.len < MAX_SPELLINGS {
matches.items[matches.len] = (own, records);
matches.len += 1;
}
}
if yo != 0 {
matches.items[..matches.len].sort_by(|a, b| yo_order(a.0 & !yo, b.0 & !yo));
}
matches
}
fn check_variants(&self) -> Result<usize, Error> {
let invalid = |msg: &str| Err(Error::Format(msg.into()));
let (records, probabilities) = (self.records().len(), self.probability_records().len());
let mut extra = 0;
let mut rest = self.variants();
while !rest.is_empty() {
let Some((variants, after)) = triples(rest) else {
return invalid("список написаний обрезан");
};
let Some((frequencies, after)) = triples(after) else {
return invalid("список написаний обрезан");
};
if variants.is_empty()
|| variants.len() > MAX_SPELLINGS
|| variants
.iter()
.any(|&[_, _, offset]| offset as usize >= records)
|| frequencies
.iter()
.any(|&[_, _, offset]| offset as usize >= probabilities)
{
return invalid("неверный список написаний");
}
extra += variants.len() - 1;
rest = after;
}
Ok(extra)
}
fn numbers<T: bytemuck::Pod>(&self, range: &Range<usize>) -> &[T] {
format::numbers(&self.storage.bytes()[range.clone()])
}
fn paradigm_offsets(&self) -> &[u32] {
self.numbers(&self.layout.paradigm_offsets)
}
fn paradigms(&self) -> &[u16] {
self.numbers(&self.layout.paradigms)
}
fn tables(&self) -> &[u32] {
self.numbers(&self.layout.tables)
}
fn records(&self) -> &[u32] {
self.numbers(&self.layout.records)
}
fn variants(&self) -> &[u32] {
self.numbers(&self.layout.variants)
}
fn probability_records(&self) -> &[u32] {
self.numbers(&self.layout.probability_records)
}
fn guess_records(&self) -> &[u32] {
self.numbers(&self.layout.guess_records)
}
}
fn list(lists: &[u32], offset: u32, width: usize) -> Option<&[u32]> {
let start = offset as usize + 1;
let count = *lists.get(start - 1)? as usize;
if count > MAX_LIST {
return None;
}
lists.get(start..start + count * width)
}
fn triples(lists: &[u32]) -> Option<(&[[u32; 3]], &[u32])> {
let (&count, rest) = lists.split_first()?;
let count = usize::try_from(count)
.ok()
.filter(|&count| count <= MAX_LIST)?;
let (list, rest) = rest.split_at_checked(3 * count)?;
Some((list.as_chunks::<3>().0, rest))
}
fn automaton(storage: &Arc<Storage>, range: &Range<usize>) -> Result<Map<Slice>, Error> {
let slice = Slice {
storage: Arc::clone(storage),
range: range.clone(),
};
let map = Map::new(slice).map_err(|err| Error::Format(err.to_string()))?;
let bytes = map.as_fst().as_bytes();
let end = bytes.len() - 4;
let root = u64::from_le_bytes(bytes[end - 8..end].try_into().unwrap_or_default());
let empty = root == 0 && bytes.len() == 36;
if !empty && root.checked_add(21) != Some(bytes.len() as u64) {
return Err(Error::Format("неверный адрес корня автомата".into()));
}
Ok(map)
}
fn feature_code(
features: &BTreeMap<String, BTreeSet<String>>,
name: &str,
value: &str,
) -> Option<(usize, u16)> {
let (index, (_, values)) = features
.iter()
.enumerate()
.find(|(_, (known, _))| *known == name)?;
let position = values.iter().position(|known| known == value)?;
Some((index, position as u16 + 1))
}
#[derive(Default)]
struct Preferences {
infinitive: Option<(usize, u16)>,
finite: Option<(usize, u16)>,
short: Option<(usize, u16)>,
animate: Option<(usize, u16)>,
first_person: Option<(usize, u16)>,
inclusive: Option<u16>,
counting: Option<u16>,
}
fn normalized_input(word: &str) -> Option<String> {
if !word.contains(['\u{306}', '\u{308}', '\u{2010}', '\u{2011}']) {
return None;
}
let mut normal = String::with_capacity(word.len());
for c in word.chars() {
let composed = match (normal.chars().last(), c) {
(Some('и'), '\u{306}') => Some('й'),
(Some('И'), '\u{306}') => Some('Й'),
(Some('е'), '\u{308}') => Some('ё'),
(Some('Е'), '\u{308}') => Some('Ё'),
_ => None,
};
match (composed, c) {
(Some(letter), _) => {
normal.pop();
normal.push(letter);
}
(None, '\u{2010}' | '\u{2011}') => normal.push('-'),
(None, c) => normal.push(c),
}
}
(normal != word).then_some(normal)
}
fn same_up_to_yo(a: &str, b: &str) -> bool {
a == b
|| (a.len() == b.len()
&& a.chars()
.zip(b.chars())
.all(|(x, y)| x == y || matches!((x, y), ('е', 'ё') | ('ё', 'е'))))
}
fn capitalized_like(original: &str, form: Analysis) -> Analysis {
if original.chars().any(char::is_alphabetic) && !original.chars().any(char::is_lowercase) {
return Analysis {
word: form.word.to_uppercase(),
..form
};
}
let upper = original.chars().next().is_some_and(char::is_uppercase);
let mut letters = form.word.chars();
match letters.next() {
Some(first) if upper && first.is_lowercase() => Analysis {
word: first.to_uppercase().chain(letters).collect(),
..form
},
_ => form,
}
}
const MAX_LIST: usize = 255;
impl Dictionary {
fn check_paradigms(&self) -> Result<(), Error> {
let (offsets, paradigms) = (self.paradigm_offsets(), self.paradigms());
let invalid = |msg: &str| Err(Error::Format(msg.into()));
if offsets.first() != Some(&0)
|| offsets.last().map(|&end| end as usize) != Some(paradigms.len())
{
return invalid("границы парадигм не совпадают с таблицей парадигм");
}
if offsets.len() - 1 > usize::from(u16::MAX) + 1 {
return invalid("больше 65 536 парадигм");
}
let limits = [self.suffixes.len(), self.tags.len(), self.prefixes.len()];
for bounds in offsets.windows(2) {
let (start, end) = (bounds[0] as usize, bounds[1] as usize);
if end <= start || (end - start) % 3 != 0 {
return invalid("парадигма неверной длины");
}
let Some(forms) = paradigms.get(start..end) else {
return invalid("парадигма за пределами таблицы парадигм");
};
for (block, limit) in forms.chunks_exact(forms.len() / 3).zip(limits) {
if block.iter().any(|&id| usize::from(id) >= limit) {
return invalid("парадигма ссылается за пределы таблицы");
}
}
}
Ok(())
}
fn check_tables(&self, ud_count: usize) -> Result<Vec<u32>, Error> {
let invalid = |msg: &str| Err(Error::Format(msg.into()));
let (tables, paradigm_offsets) = (self.tables(), self.paradigm_offsets());
let mut offsets = Vec::new();
let mut start = 0;
while start < tables.len() {
if offsets.len() > usize::from(u16::MAX) {
return invalid("больше 65 536 таблиц словоизменения");
}
let paradigm = tables[start] as usize;
let Some(&[begin, end]) = paradigm
.checked_add(2)
.and_then(|stop| paradigm_offsets.get(paradigm..stop))
else {
return invalid("таблица ссылается на несуществующую парадигму");
};
let count = (end - begin) as usize / 3;
let Some(cells) = tables.get(start + 1..start + 1 + count) else {
return invalid("таблица словоизменения обрезана");
};
if cells
.iter()
.any(|&cell| (cell >> 16) as usize >= count || (cell & 0xFFFF) as usize >= ud_count)
{
return invalid("таблица ссылается на несуществующую форму или тег UD");
}
offsets.push(start as u32);
start += 1 + count;
}
Ok(offsets)
}
fn check_lists(&self) -> Result<(), Error> {
let (tables, offsets) = (self.tables(), self.paradigm_offsets());
let form_counts: Vec<usize> = self
.table_offsets
.iter()
.map(|&start| {
let paradigm = tables[start as usize] as usize;
(offsets[paradigm + 1] - offsets[paradigm]) as usize / 3
})
.collect();
let entry = |packed: u32| {
form_counts
.get((packed >> 16) as usize)
.is_some_and(|&count| ((packed & 0xFFFF) as usize) < count)
};
walk_lists(self.records(), 1, |items| {
items.iter().all(|&packed| entry(packed))
})?;
walk_lists(self.probability_records(), 2, |items| {
items
.chunks(2)
.all(|pair| (pair[0] as usize) < self.tags.len())
})?;
walk_lists(self.guess_records(), 2, |items| {
items.chunks(2).all(|pair| entry(pair[0]))
})
}
}
fn walk_lists(lists: &[u32], width: usize, valid: impl Fn(&[u32]) -> bool) -> Result<(), Error> {
let invalid = |msg: &str| Err(Error::Format(msg.into()));
let mut start = 0;
while start < lists.len() {
let count = lists[start] as usize;
if count > MAX_LIST {
return invalid("список длиннее 255 элементов");
}
let end = start + 1 + count * width;
let Some(items) = lists.get(start + 1..end) else {
return invalid("список обрезан");
};
if !valid(items) {
return invalid("список ссылается на несуществующую таблицу, форму или тег");
}
start = end;
}
Ok(())
}
#[cfg(test)]
pub(super) mod tests {
use super::*;
use crate::builder::Builder;
use crate::format::Sections;
const TAGS: [&str; 6] = [
"NOUN,anim,masc sing,nomn",
"NOUN,anim,masc sing,gent",
"VERB,perf,intr plur,past,indc",
"NOUN,inan,femn sing,gent",
"NOUN,inan,femn plur,nomn",
"ADJF,Supr,Qual masc,sing,nomn",
];
fn entry(table: u16, form: u16) -> Entry {
Entry { table, form }
}
fn lexeme_table(builder: &mut Builder, forms: &[(u16, u16, u16)]) -> u16 {
let paradigm = builder.add_paradigm(forms).unwrap();
let cells: Vec<(u16, u16)> = forms.iter().map(|&(_, tag, _)| (0, tag)).collect();
builder.add_table(paradigm, &cells).unwrap()
}
fn builder(tags: &[&str], prefixes: &[&str], suffixes: &[&str]) -> Builder {
let owned = |items: &[&str]| items.iter().map(|item| item.to_string()).collect();
let mut builder = Builder::new(owned(tags), owned(prefixes), owned(suffixes));
for number in 0..tags.len() {
builder
.add_ud_tag("X", &format!("Tag={number}"), &[])
.unwrap();
}
builder
}
fn dictionary_bytes() -> Vec<u8> {
let mut builder = builder(
&TAGS,
&["", "наи"],
&["", "ёж", "ежа", "ть", "ли", "ь", "и", "ой", "ий"],
);
builder.meta("тест");
let hedgehog = lexeme_table(&mut builder, &[(1, 0, 0), (2, 1, 0)]);
let become_ = lexeme_table(&mut builder, &[(3, 2, 0), (4, 2, 0)]);
let steel = lexeme_table(&mut builder, &[(5, 3, 0), (6, 3, 0), (6, 4, 0)]);
let big = lexeme_table(&mut builder, &[(7, 5, 0), (8, 5, 1)]);
let plain = lexeme_table(&mut builder, &[(0, 0, 0)]);
builder.add_word("ёж", &[entry(hedgehog, 0)]).unwrap();
builder.add_word("ежа", &[entry(hedgehog, 1)]).unwrap();
builder
.add_word(
"стали",
&[entry(steel, 1), entry(steel, 2), entry(become_, 1)],
)
.unwrap();
builder.add_word("наибольший", &[entry(big, 1)]).unwrap();
builder.add_word("все", &[entry(plain, 0)]).unwrap();
builder.add_word("всё", &[entry(plain, 0)]).unwrap();
builder.add_probability("стали", 2, 970_000).unwrap();
builder.add_probability("стали", 3, 20_000).unwrap();
builder.add_probability("стали", 4, 10_000).unwrap();
builder.build().unwrap()
}
fn dictionary() -> Dictionary {
Dictionary::from_bytes(&dictionary_bytes()).unwrap()
}
fn summary(analyses: &[Analysis]) -> Vec<(&str, &str, u16)> {
analyses
.iter()
.map(|a| (a.word.as_str(), a.lemma.as_str(), a.tag.unwrap()))
.collect()
}
#[test]
fn lemma_from_paradigm() {
let dict = dictionary();
assert_eq!(summary(&dict.analyze("ежа")), [("ежа", "ёж", 1)]);
assert_eq!(
summary(&dict.analyze("наибольший")),
[("наибольший", "большой", 5)]
);
}
#[test]
fn lemma_from_another_form() {
let mut builder = builder(&["NOUN,masc", "NOUN,femn"], &[""], &["", "а", "ой"]);
let paradigm = builder
.add_paradigm(&[(0, 0, 0), (1, 1, 0), (2, 1, 0)])
.unwrap();
let surname = builder
.add_table(paradigm, &[(0, 0), (1, 1), (1, 1)])
.unwrap();
builder.add_word("петровой", &[entry(surname, 2)]).unwrap();
builder.add_word("петрова", &[entry(surname, 1)]).unwrap();
builder.add_word("петров", &[entry(surname, 0)]).unwrap();
let dict = Dictionary::from_bytes(&builder.build().unwrap()).unwrap();
assert_eq!(dict.analyze("петровой")[0].lemma, "петрова");
let words = |word: &str| -> Vec<String> {
let analysis = &dict.analyze(word)[0];
dict.forms(analysis)
.into_iter()
.map(|form| form.word)
.collect()
};
assert_eq!(words("петровой"), ["петрова", "петровой"]);
assert_eq!(words("петров"), ["петров"]);
assert_eq!(
dict.opencorpora("петровой"),
[(
"петровой".to_string(),
"NOUN,femn",
"петров".to_string(),
1.0
)]
);
}
pub(super) type Forms = Vec<(&'static str, String)>;
fn lexicon() -> Dictionary {
const NOUN: [&str; 12] = [
"Case=Nom|Number=Sing",
"Case=Gen|Number=Sing",
"Case=Dat|Number=Sing",
"Case=Acc|Number=Sing",
"Case=Ins|Number=Sing",
"Case=Loc|Number=Sing",
"Case=Nom|Number=Plur",
"Case=Gen|Number=Plur",
"Case=Dat|Number=Plur",
"Case=Acc|Number=Plur",
"Case=Ins|Number=Plur",
"Case=Loc|Number=Plur",
];
let noun = |animacy: &str, endings: [&'static str; 12]| -> Forms {
endings
.into_iter()
.zip(NOUN)
.filter(|(ending, _)| *ending != "-")
.map(|(ending, feats)| (ending, format!("Animacy={animacy}|{feats}")))
.collect()
};
let lexemes: Vec<(&str, &str, Forms)> = vec![
(
"NOUN",
"кот",
noun(
"Anim",
[
"", "а", "у", "а", "ом", "е", "ы", "ов", "ам", "ов", "ами", "ах",
],
),
),
(
"NOUN",
"учён",
noun(
"Anim",
[
"ый", "ого", "ому", "ого", "ым", "ом", "ые", "ых", "ым", "ых", "ыми", "ых",
],
),
),
(
"NOUN",
"сут",
noun(
"Inan",
[
"-", "-", "-", "-", "-", "-", "ки", "ок", "кам", "ки", "ками", "ках",
],
),
),
(
"NOUN",
"молок",
noun(
"Inan",
["о", "а", "у", "о", "ом", "е", "-", "-", "-", "-", "-", "-"],
),
),
(
"ADJ",
"нов",
[
("ый", "Case=Nom|Gender=Masc|Number=Sing"),
("ая", "Case=Nom|Gender=Fem|Number=Sing"),
("ую", "Case=Acc|Gender=Fem|Number=Sing"),
("ые", "Case=Nom|Number=Plur"),
("ых", "Case=Gen|Number=Plur"),
("ых", "Animacy=Anim|Case=Acc|Number=Plur"),
("ые", "Animacy=Inan|Case=Acc|Number=Plur"),
]
.map(|(ending, feats)| (ending, feats.to_string()))
.to_vec(),
),
(
"VERB",
"нес",
vec![
("ти", "VerbForm=Inf".into()),
("ла", "Gender=Fem|Tense=Past".into()),
],
),
];
dictionary_of(&lexemes, &[])
}
pub(super) fn dictionary_of(
lexemes: &[(&str, &str, Forms)],
probabilities: &[(&str, &str, u32)],
) -> Dictionary {
let mut tags: Vec<String> = Vec::new();
let mut suffixes: Vec<String> = Vec::new();
for (pos, _, forms) in lexemes {
for (ending, feats) in forms {
let tag = format!("{pos} {feats}");
if !tags.contains(&tag) {
tags.push(tag);
}
if !suffixes.iter().any(|suffix| suffix == ending) {
suffixes.push(ending.to_string());
}
}
}
let mut builder = Builder::new(tags.clone(), vec![String::new()], suffixes.clone());
for tag in &tags {
let (pos, feats) = tag.split_once(' ').unwrap();
builder.add_ud_tag(pos, feats, &[]).unwrap();
}
let id = |items: &[String], item: &str| {
items.iter().position(|known| known == item).unwrap() as u16
};
for (pos, stem, forms) in lexemes {
let paradigm: Vec<(u16, u16, u16)> = forms
.iter()
.map(|(ending, feats)| {
(
id(&suffixes, ending),
id(&tags, &format!("{pos} {feats}")),
0,
)
})
.collect();
let table = lexeme_table(&mut builder, ¶digm);
for (form, (ending, _)) in forms.iter().enumerate() {
builder
.add_word(&format!("{stem}{ending}"), &[entry(table, form as u16)])
.unwrap();
}
}
for &(word, tag, millionths) in probabilities {
builder
.add_probability(word, id(&tags, tag), millionths)
.unwrap();
}
Dictionary::from_bytes(&builder.build().unwrap()).unwrap()
}
fn find(dict: &Dictionary, word: &str, feats: &str) -> Analysis {
dict.analyze(word)
.into_iter()
.find(|analysis| {
dict.ud_tag(analysis.ud)
.unwrap()
.feats_string()
.ends_with(feats)
})
.unwrap()
}
#[test]
fn forms_of_lexeme() {
let dict = lexicon();
let forms: Vec<String> = dict
.forms(&dict.analyze("коту")[0])
.into_iter()
.map(|form| form.word)
.collect();
assert_eq!(
forms,
[
"кот",
"кота",
"коту",
"кота",
"котом",
"коте",
"коты",
"котов",
"котам",
"котов",
"котами",
"котах"
]
);
assert!(
dict.forms(&dict.analyze("коту")[0])
.iter()
.all(|form| form.prob == 1.0 && form.lemma == "кот")
);
}
#[test]
fn inflect_keeps_other_features() {
let dict = lexicon();
let inflect = |word: &str, feats: &[(&str, &str)]| {
dict.inflect(&dict.analyze(word)[0], feats)
.map(|form| form.word)
};
assert_eq!(inflect("кот", &[("Case", "Gen")]).as_deref(), Some("кота"));
assert_eq!(
inflect("кот", &[("Case", "Gen"), ("Number", "Plur")]).as_deref(),
Some("котов")
);
assert_eq!(
inflect("котам", &[("Number", "Sing")]).as_deref(),
Some("коту")
);
assert_eq!(inflect("кот", &[]).as_deref(), Some("кот"));
assert_eq!(
inflect("новые", &[("Case", "Acc")]).as_deref(),
Some("новые")
);
assert_eq!(inflect("кот", &[("Tense", "Past")]), None);
assert_eq!(inflect("сутки", &[("Number", "Sing")]), None);
assert_eq!(
inflect("нести", &[("Tense", "Past")]).as_deref(),
Some("несла")
);
}
#[test]
fn agree_with_number() {
let dict = lexicon();
let agree = |analysis: &Analysis, numbers: &[u64]| -> Vec<Option<String>> {
numbers
.iter()
.map(|&number| dict.agree(analysis, number).map(|form| form.word))
.collect()
};
let words = |words: &[&str]| -> Vec<Option<String>> {
words
.iter()
.map(|word| (*word != "-").then(|| word.to_string()))
.collect()
};
let numbers = [0, 1, 2, 5, 11, 12, 21, 22, 25, 101, 112, 1_000_002];
assert_eq!(
agree(&dict.analyze("кот")[0], &numbers),
words(&[
"котов",
"кот",
"кота",
"котов",
"котов",
"котов",
"кот",
"кота",
"котов",
"кот",
"котов",
"кота"
])
);
assert_eq!(
agree(&find(&dict, "кота", "Case=Acc|Number=Sing"), &[1, 2, 5, 22]),
words(&["кота", "котов", "котов", "кота"])
);
assert_eq!(
agree(&dict.analyze("коту")[0], &[1, 2, 21]),
words(&["коту", "котам", "коту"])
);
assert_eq!(
agree(&dict.analyze("сутки")[0], &[1, 2, 5]),
words(&["сутки", "суток", "суток"])
);
assert_eq!(
agree(&dict.analyze("молоко")[0], &[1, 2, 5]),
words(&["молоко", "молока", "-"])
);
assert_eq!(
agree(&dict.analyze("новый")[0], &[1, 2, 5]),
words(&["новый", "новых", "новых"])
);
assert_eq!(
agree(&dict.analyze("новая")[0], &[1, 2, 5]),
words(&["новая", "новые", "новых"])
);
assert_eq!(agree(&dict.analyze("новую")[0], &[2]), words(&["новые"]));
assert_eq!(agree(&dict.analyze("нести")[0], &[2]), words(&["-"]));
assert_eq!(
agree(&dict.analyze("учёный")[0], &[1, 2, 5, 22]),
words(&["учёный", "учёных", "учёных", "учёных"])
);
assert_eq!(
agree(&find(&dict, "учёного", "Case=Acc|Number=Sing"), &[2, 22]),
words(&["учёных", "учёных"])
);
assert_eq!(agree(&dict.analyze("кот")[0], &[22]), words(&["кота"]));
}
#[test]
fn merged_analyses_share_forms() {
let mut builder = Builder::new(
vec!["INFN".into(), "VERB".into(), "PRTF".into()],
vec![String::new()],
vec!["ть".into(), "л".into(), "емый".into()],
);
let tags = [
("VerbForm=Inf", "intr"),
("Tense=Past|VerbForm=Fin", "intr"),
("VerbForm=Inf", "tran"),
("Tense=Past|VerbForm=Fin", "tran"),
("VerbForm=Part|Voice=Pass", "tran"),
];
let cells: Vec<(u16, u16)> = tags
.iter()
.map(|&(feats, label)| (0, builder.add_ud_tag("VERB", feats, &[label]).unwrap()))
.collect();
let paradigm = builder
.add_paradigm(&[(0, 0, 0), (1, 1, 0), (0, 0, 0), (1, 1, 0), (2, 2, 0)])
.unwrap();
let table = builder.add_table(paradigm, &cells).unwrap();
builder
.add_word("мешать", &[entry(table, 0), entry(table, 2)])
.unwrap();
builder
.add_word("мешал", &[entry(table, 1), entry(table, 3)])
.unwrap();
builder.add_word("мешаемый", &[entry(table, 4)]).unwrap();
let dict = Dictionary::from_bytes(&builder.build().unwrap()).unwrap();
let analyses = dict.analyze("мешать");
assert_eq!(analyses.len(), 1);
for word in ["мешать", "мешаемый"] {
let words: Vec<String> = dict
.forms(&dict.analyze(word)[0])
.into_iter()
.map(|form| form.word)
.collect();
assert_eq!(words, ["мешать", "мешал", "мешаемый"], "{word}");
}
let found = dict.inflect(&analyses[0], &[("Voice", "Pass")]).unwrap();
assert_eq!(found.word, "мешаемый");
let analysis = |word: &str, form: u16, source: Source| {
dict.analysis(word, Entry { table, form }, source)
};
assert_eq!(analysis("мешаемый", 4, Source::Dictionary), Some(found));
assert_eq!(analysis("мешаемый", 1, Source::Dictionary), None);
assert_eq!(analysis("мешал", 4, Source::Dictionary), None);
let guessed = analysis("Зашаемый", 4, Source::Guess).unwrap();
assert_eq!(
(guessed.lemma.as_str(), guessed.source),
("зашать", Source::Guess)
);
assert_eq!(analysis("Зашаемый", 4, Source::Dictionary), None);
}
fn guessing() -> Dictionary {
let mut builder = Builder::new(
vec!["NOUN,nomn".into(), "NOUN,gent".into()],
vec![String::new()],
vec![String::new(), "а".into()],
);
let nominative = builder.add_ud_tag("NOUN", "Case=Nom", &[]).unwrap();
let genitive = builder.add_ud_tag("NOUN", "Case=Gen", &[]).unwrap();
let surname = builder
.add_ud_tag("PROPN", "Case=Gen|NameType=Sur", &[])
.unwrap();
let surname_nominative = builder
.add_ud_tag("PROPN", "Case=Nom|NameType=Sur", &[])
.unwrap();
let paradigm = builder.add_paradigm(&[(0, 0, 0), (1, 1, 0)]).unwrap();
let common = builder
.add_table(paradigm, &[(0, nominative), (0, genitive)])
.unwrap();
let proper = builder
.add_table(paradigm, &[(0, surname_nominative), (0, surname)])
.unwrap();
builder.add_word("кот", &[entry(common, 0)]).unwrap();
builder.add_word("кота", &[entry(common, 1)]).unwrap();
builder.add_word("петров", &[entry(proper, 0)]).unwrap();
builder.add_word("петрова", &[entry(proper, 1)]).unwrap();
builder
.add_guesses(GuessIndex::Common, "а", &[(entry(common, 1), 5)])
.unwrap();
builder
.add_guesses(GuessIndex::Common, "ова", &[(entry(common, 1), 1)])
.unwrap();
builder
.add_guesses(GuessIndex::Proper, "ова", &[(entry(proper, 1), 3)])
.unwrap();
Dictionary::from_bytes(&builder.build().unwrap()).unwrap()
}
#[test]
fn guess_by_longest_ending() {
let dict = guessing();
let analyses = dict.analyze("зумова");
let summary: Vec<(&str, &str, Source)> = analyses
.iter()
.map(|a| (a.word.as_str(), a.lemma.as_str(), a.source))
.collect();
assert_eq!(summary, [("зумова", "зумов", Source::Guess)]);
assert_eq!(dict.pos(analyses[0].ud), "NOUN");
assert_eq!(dict.analyze("зума")[0].lemma, "зум");
assert_eq!(dict.analyze("зумы"), dict.fallback("зумы"));
assert_eq!(dict.analyze("зумова1"), dict.fallback("зумова1"));
}
#[test]
fn capitalized_guess_prefers_proper_nouns() {
let dict = guessing();
let analyses = dict.analyze("Зумова");
let summary: Vec<(&str, &str, &str)> = analyses
.iter()
.map(|a| (a.word.as_str(), a.lemma.as_str(), dict.pos(a.ud)))
.collect();
assert_eq!(
summary,
[("Зумова", "зумов", "PROPN"), ("Зумова", "зумов", "NOUN")]
);
assert!((analyses[0].prob - 0.89).abs() < 1e-12);
let forms: Vec<String> = dict
.forms(&analyses[0])
.into_iter()
.map(|form| form.word)
.collect();
assert_eq!(forms, ["Зумов", "Зумова"]);
assert_eq!(dict.analyze("ЗуМова"), dict.fallback("ЗуМова"));
}
#[test]
fn guess_with_yo_in_suffix() {
let mut builder = Builder::new(
vec!["NOUN,nomn".into(), "NOUN,gent".into()],
vec![String::new()],
vec!["ёнок".into(), "ёнка".into(), "енка".into()],
);
let nominative = builder.add_ud_tag("NOUN", "Case=Nom", &[]).unwrap();
let genitive = builder.add_ud_tag("NOUN", "Case=Gen", &[]).unwrap();
let young = builder.add_paradigm(&[(0, 0, 0), (1, 1, 0)]).unwrap();
let young = builder
.add_table(young, &[(0, nominative), (0, genitive)])
.unwrap();
let plain = builder.add_paradigm(&[(2, 0, 0)]).unwrap();
let plain = builder.add_table(plain, &[(0, nominative)]).unwrap();
builder.add_word("котёнок", &[entry(young, 0)]).unwrap();
builder.add_word("котёнка", &[entry(young, 1)]).unwrap();
builder.add_word("баренка", &[entry(plain, 0)]).unwrap();
builder
.add_guesses(
GuessIndex::Common,
"енка",
&[(entry(plain, 0), 5), (entry(young, 1), 3)],
)
.unwrap();
builder
.add_guesses(GuessIndex::Common, "ёнка", &[(entry(young, 1), 3)])
.unwrap();
builder
.add_guesses(GuessIndex::Common, "нка", &[(entry(young, 1), 3)])
.unwrap();
let dict = Dictionary::from_bytes(&builder.build().unwrap()).unwrap();
let summary = |word: &str| -> Vec<(String, String)> {
dict.analyze(word)
.into_iter()
.map(|a| (a.word, a.lemma))
.collect()
};
assert_eq!(
summary("слонёнка"),
[("слонёнка".to_string(), "слонёнок".to_string())]
);
let written_with_e = summary("слоненка");
assert_eq!(
written_with_e[1],
("слонёнка".to_string(), "слонёнок".to_string())
);
let forms: Vec<String> = dict
.forms(&dict.analyze("слонёнка")[0])
.into_iter()
.map(|form| form.word)
.collect();
assert_eq!(forms, ["слонёнок", "слонёнка"]);
let whole = dict.analyze("Енка");
assert_eq!(
(whole[0].word.as_str(), whole[0].lemma.as_str()),
("Ёнка", "ёнок")
);
let forms: Vec<String> = dict
.forms(&whole[0])
.into_iter()
.map(|form| form.word)
.collect();
assert_eq!(forms, ["Ёнок", "Ёнка"]);
}
#[test]
fn capitalized_guess_with_prefixed_form() {
let mut builder = Builder::new(
vec!["ADJF".into(), "COMP".into()],
vec![String::new(), "по".into()],
vec!["ый".into(), "ее".into()],
);
let positive = builder.add_ud_tag("ADJ", "Degree=Pos", &[]).unwrap();
let comparative = builder.add_ud_tag("ADJ", "Degree=Cmp", &[]).unwrap();
let paradigm = builder.add_paradigm(&[(0, 0, 0), (1, 1, 1)]).unwrap();
let table = builder
.add_table(paradigm, &[(0, positive), (0, comparative)])
.unwrap();
builder.add_word("новый", &[entry(table, 0)]).unwrap();
builder.add_word("поновее", &[entry(table, 1)]).unwrap();
builder
.add_guesses(GuessIndex::Common, "ый", &[(entry(table, 0), 3)])
.unwrap();
let dict = Dictionary::from_bytes(&builder.build().unwrap()).unwrap();
let analysis = &dict.analyze("Кринжовый")[0];
let forms: Vec<String> = dict
.forms(analysis)
.into_iter()
.map(|form| form.word)
.collect();
assert_eq!(forms, ["Кринжовый", "Покринжовее"]);
let comparative = dict.inflect(analysis, &[("Degree", "Cmp")]).unwrap();
assert_eq!(comparative.word, "Покринжовее");
let entry = comparative.entry.unwrap();
let restored = dict.analysis("Покринжовее", entry, Source::Guess).unwrap();
assert_eq!(restored.lemma, "кринжовый");
let forms: Vec<String> = dict
.forms(&restored)
.into_iter()
.map(|form| form.word)
.collect();
assert_eq!(forms, ["Кринжовый", "Покринжовее"]);
let positive = dict.inflect(&restored, &[("Degree", "Pos")]).unwrap();
assert_eq!(positive.word, "Кринжовый");
}
#[test]
fn broken_guess_records_are_rejected() {
let data = guessing_bytes();
assert_eq!(
Dictionary::from_bytes(&data).unwrap().analyze("зума")[0].lemma,
"зума"
);
for records in [vec![1, 0, 0], vec![]] {
let mut sections = format::tests::read(&data);
sections.guess_records = records;
let dict = Dictionary::from_bytes(&format::write(§ions)).unwrap();
assert_eq!(dict.analyze("зума"), dict.fallback("зума"));
}
for records in [vec![9, 0], vec![1, 0x0007_0000, 2], vec![300]] {
let mut sections = format::tests::read(&data);
sections.guess_records = records;
assert!(Dictionary::from_bytes(&format::write(§ions)).is_err());
}
}
fn guessing_bytes() -> Vec<u8> {
let mut builder = Builder::new(
vec!["NOUN".into()],
vec![String::new()],
vec![String::new(), "а".into()],
);
builder.add_ud_tag("NOUN", "", &[]).unwrap();
let table = lexeme_table(&mut builder, &[(1, 0, 0)]);
builder.add_word("кота", &[entry(table, 0)]).unwrap();
builder
.add_guesses(GuessIndex::Common, "а", &[(entry(table, 0), 2)])
.unwrap();
builder.build().unwrap()
}
#[test]
fn yo_spelled_as_e_and_case() {
let dict = dictionary();
assert_eq!(summary(&dict.analyze("еж")), [("ёж", "ёж", 0)]);
assert_eq!(summary(&dict.analyze("ЁЖ")), [("ёж", "ёж", 0)]);
let words: Vec<_> = dict.analyze("все").iter().map(|a| a.word.clone()).collect();
assert_eq!(words, ["все", "всё"]);
assert_eq!(summary(&dict.analyze("всё")), [("всё", "всё", 0)]);
}
#[test]
fn probabilities_order_analyses() {
let dict = dictionary();
let analyses = dict.analyze("Стали");
assert_eq!(
summary(&analyses),
[
("стали", "стать", 2),
("стали", "сталь", 3),
("стали", "сталь", 4)
]
);
let probs: Vec<f64> = analyses.iter().map(|a| a.prob).collect();
assert!((probs[0] - 0.97).abs() < 1e-12 && (probs[2] - 0.01).abs() < 1e-12);
assert!((probs.iter().sum::<f64>() - 1.0).abs() < 1e-12);
}
#[test]
fn uniform_without_probabilities() {
let dict = dictionary();
let probs: Vec<f64> = dict.analyze("все").iter().map(|a| a.prob).collect();
assert_eq!(probs, [0.5, 0.5]);
}
#[test]
fn same_tag_shares_probability() {
let mut builder = builder(&["NOUN", "VERB"], &[""], &["", "а", "о"]);
let first = lexeme_table(&mut builder, &[(0, 0, 0)]);
let second = lexeme_table(&mut builder, &[(1, 1, 0), (0, 0, 0)]);
let third = lexeme_table(&mut builder, &[(2, 1, 0), (0, 0, 0)]);
builder
.add_word("лук", &[entry(first, 0), entry(second, 1), entry(third, 1)])
.unwrap();
builder.add_probability("лук", 0, 600_000).unwrap();
let dict = Dictionary::from_bytes(&builder.build().unwrap()).unwrap();
let probs: Vec<f64> = dict.analyze("лук").iter().map(|a| a.prob).collect();
assert_eq!(probs, [1.0 / 3.0; 3]);
}
#[test]
fn same_ud_analyses_merge() {
let mut builder = Builder::new(
vec!["CONJ".into(), "ADVB".into()],
vec![String::new()],
vec![String::new()],
);
let plain = builder.add_ud_tag("ADV", "Degree=Pos", &[]).unwrap();
let labelled = builder.add_ud_tag("ADV", "Degree=Pos", &["Dmns"]).unwrap();
let conjunction = builder.add_paradigm(&[(0, 0, 0)]).unwrap();
let conjunction = builder.add_table(conjunction, &[(0, plain)]).unwrap();
let adverb = builder.add_paradigm(&[(0, 1, 0)]).unwrap();
let adverb = builder.add_table(adverb, &[(0, labelled)]).unwrap();
builder
.add_word("так", &[entry(conjunction, 0), entry(adverb, 0)])
.unwrap();
builder.add_probability("так", 1, 750_000).unwrap();
builder.add_probability("так", 0, 250_000).unwrap();
let dict = Dictionary::from_bytes(&builder.build().unwrap()).unwrap();
let analyses = dict.analyze("так");
assert_eq!(analyses.len(), 1);
let labels: Vec<&str> = dict.ud_tag(analyses[0].ud).unwrap().labels().collect();
assert_eq!(labels, ["Dmns"]);
assert!((analyses[0].prob - 1.0).abs() < 1e-12);
let probs: Vec<f64> = dict.opencorpora("так").iter().map(|a| a.3).collect();
assert_eq!(probs, [0.75, 0.25]);
}
#[test]
fn uniform_probabilities_after_merge() {
let mut builder = Builder::new(
vec!["ADJF,gent".into(), "ADJF,datv".into(), "NPRO".into()],
vec![String::new()],
vec![String::new()],
);
let abbreviation = builder.add_ud_tag("ADJ", "Abbr=Yes", &[]).unwrap();
let pronoun = builder.add_ud_tag("PRON", "Case=Loc", &[]).unwrap();
let adjective = builder.add_paradigm(&[(0, 0, 0), (0, 1, 0)]).unwrap();
let adjective = builder
.add_table(adjective, &[(0, abbreviation), (0, abbreviation)])
.unwrap();
let personal = builder.add_paradigm(&[(0, 2, 0)]).unwrap();
let personal = builder.add_table(personal, &[(0, pronoun)]).unwrap();
builder
.add_word(
"нем",
&[entry(adjective, 0), entry(adjective, 1), entry(personal, 0)],
)
.unwrap();
let dict = Dictionary::from_bytes(&builder.build().unwrap()).unwrap();
let probs: Vec<f64> = dict.analyze("нем").iter().map(|a| a.prob).collect();
assert_eq!(probs, [0.5, 0.5]);
let probs: Vec<f64> = dict.opencorpora("нем").iter().map(|a| a.3).collect();
assert_eq!(probs, [1.0 / 3.0; 3]);
}
#[test]
fn ud_tags() {
let mut builder = Builder::new(
vec!["NOUN".into()],
vec![String::new()],
vec![String::new()],
);
builder
.add_ud_tag("NOUN", "Case=Gen|Number=Sing", &["Infr", "Slng"])
.unwrap();
builder.add_ud_tag("PART", "", &[]).unwrap();
let dict = Dictionary::from_bytes(&builder.build().unwrap()).unwrap();
let noun = dict.ud_tag(0).unwrap();
assert_eq!(noun.pos(), "NOUN");
assert_eq!(noun.feats_string(), "Case=Gen|Number=Sing");
assert_eq!(noun.labels().collect::<Vec<_>>(), ["Infr", "Slng"]);
let particle = dict.ud_tag(1).unwrap();
assert!(particle.feats().len() == 0 && particle.labels().len() == 0);
}
#[test]
fn tag_probability_shared_within_spelling() {
let mut builder = builder(&["NOUN,datv", "NOUN,loct"], &[""], &[""]);
let dative = lexeme_table(&mut builder, &[(0, 0, 0)]);
let locative = lexeme_table(&mut builder, &[(0, 1, 0)]);
builder.add_word("алеше", &[entry(dative, 0)]).unwrap();
builder
.add_word("алёше", &[entry(dative, 0), entry(locative, 0)])
.unwrap();
builder.add_probability("алёше", 0, 666_667).unwrap();
builder.add_probability("алёше", 1, 333_333).unwrap();
let dict = Dictionary::from_bytes(&builder.build().unwrap()).unwrap();
let summary: Vec<(String, u16, f64)> = dict
.analyze("алеше")
.into_iter()
.map(|a| (a.word, a.tag.unwrap(), (a.prob * 1000.0).round() / 1000.0))
.collect();
assert_eq!(
summary,
[
("алёше".to_string(), 0, 0.444),
("алеше".to_string(), 0, 0.333),
("алёше".to_string(), 1, 0.222),
]
);
}
#[test]
fn probabilities_of_dictionary_spelling() {
let mut builder = builder(&["ADVB", "PRCL"], &[""], &[""]);
let adverb = lexeme_table(&mut builder, &[(0, 0, 0)]);
let particle = lexeme_table(&mut builder, &[(0, 1, 0)]);
builder
.add_word("ещё", &[entry(adverb, 0), entry(particle, 0)])
.unwrap();
builder.add_probability("ещё", 1, 750_000).unwrap();
builder.add_probability("ещё", 0, 250_000).unwrap();
let dict = Dictionary::from_bytes(&builder.build().unwrap()).unwrap();
for word in ["ещё", "еще"] {
let analyses = dict.analyze(word);
let tags: Vec<u16> = analyses.iter().map(|a| a.tag.unwrap()).collect();
assert_eq!(tags, [1, 0], "{word}");
assert!((analyses[0].prob - 0.75).abs() < 1e-12, "{word}");
}
}
#[test]
fn unknown_words() {
let dict = dictionary();
assert_eq!(dict.analyze("вайб"), dict.fallback("вайб"));
assert!(dict.analyze("").is_empty());
assert_eq!(dict.analyze("ежаа"), dict.fallback("ежаа"));
assert!(!dict.is_known("вайб"));
assert!(dict.is_known("Еж"));
}
#[test]
fn open_reads_file() {
let path = std::env::temp_dir().join(format!("rumorph-{}.dict", std::process::id()));
std::fs::write(&path, dictionary_bytes()).unwrap();
let dict = Dictionary::open(&path).unwrap();
std::fs::remove_file(&path).unwrap();
assert_eq!(dict.word_count(), dictionary().word_count());
assert_eq!(
summary(&dict.analyze("ежа")),
summary(&dictionary().analyze("ежа"))
);
}
#[test]
fn open_reports_directory_as_io_error() {
let path = std::env::temp_dir().join(format!("rumorph-dir-{}", std::process::id()));
std::fs::create_dir(&path).unwrap();
let result = Dictionary::open(&path);
std::fs::remove_dir(&path).unwrap();
assert!(matches!(result, Err(Error::Io(_))));
}
#[test]
fn metadata() {
let dict = dictionary();
assert_eq!(dict.meta(), "тест");
assert_eq!(dict.word_count(), 6);
assert_eq!(dict.tag(5), Some(TAGS[5]));
assert_eq!(dict.tags().len(), TAGS.len());
assert_eq!(dict.ud_tag(5).unwrap().feats_string(), "Tag=5");
}
fn sections() -> Sections {
Sections {
tags: vec!["NOUN".into()],
ud_strings: vec!["NOUN".into()],
ud_tags: vec![0, 0, 0],
prefixes: vec![String::new()],
suffixes: vec![String::new()],
paradigm_offsets: vec![0, 3],
paradigms: vec![0, 0, 0],
tables: vec![0, 0],
words: fst::MapBuilder::memory().into_inner().unwrap(),
guesses: fst::MapBuilder::memory().into_inner().unwrap(),
..Sections::default()
}
}
#[test]
fn rejects_broken_paradigms_and_ud_tags() {
assert!(Dictionary::from_bytes(&format::write(§ions())).is_ok());
let broken: [fn(&mut Sections); 14] = [
|s| s.paradigms = vec![0, 1, 0],
|s| s.paradigms = vec![0, 0, 1],
|s| s.paradigm_offsets = vec![0, 2],
|s| s.paradigm_offsets = vec![1, 3],
|s| s.paradigm_offsets = vec![0, 9, 3],
|s| s.ud_tags = vec![0],
|s| s.ud_tags = vec![1, 0, 0],
|s| s.ud_strings = vec![String::new()],
|s| s.tables = vec![1, 0],
|s| s.tables = vec![0],
|s| s.tables = vec![0, 0x0001_0000],
|s| s.tables = vec![0, 1],
|s| s.alphabet = "аа".into(),
|s| s.prefixes = vec![],
];
for (number, breaker) in broken.into_iter().enumerate() {
let mut sections = sections();
breaker(&mut sections);
let result = Dictionary::from_bytes(&format::write(§ions));
assert!(matches!(result, Err(Error::Format(_))), "{number}");
}
}
#[test]
fn broken_records_are_rejected() {
let mut builder = builder(&["NOUN"], &[""], &[""]);
let table = lexeme_table(&mut builder, &[(0, 0, 0)]);
builder.add_word("кот", &[entry(table, 0)]).unwrap();
builder.add_word("еж", &[entry(table, 0)]).unwrap();
builder.add_word("ёж", &[entry(table, 0)]).unwrap();
let data = builder.build().unwrap();
assert_eq!(
format::tests::read(&data).variants,
[2, 0, 0, 0, 1, 0, 0, 0]
);
for records in [vec![5, 0], vec![1, 7], vec![1, 0x0005_0000], vec![256]] {
let mut sections = format::tests::read(&data);
sections.records = records;
assert!(Dictionary::from_bytes(&format::write(§ions)).is_err());
}
let mut sections = format::tests::read(&data);
sections.probability_records = vec![1, 99, 5];
assert!(Dictionary::from_bytes(&format::write(§ions)).is_err());
let many = [vec![9], [0, 0, 0].repeat(9), vec![0]].concat();
for variants in [
vec![2, 0, 0, 0, 1, 0, 0],
vec![0, 0],
many,
vec![1, 0, 0, 9, 0],
vec![1, 0, 0, 0, 1, 0, 0, 0],
vec![256],
] {
let mut sections = format::tests::read(&data);
sections.variants = variants;
assert!(Dictionary::from_bytes(&format::write(§ions)).is_err());
}
}
#[test]
fn rejects_damaged_file() {
let data = dictionary_bytes();
let middle = data.len() / 2;
let mut damaged = data.clone();
damaged[middle] ^= 1;
let err = Dictionary::from_bytes(&damaged).err().unwrap();
assert!(err.to_string().contains("контрольная сумма"), "{err}");
}
fn masked_crc32c(data: &[u8]) -> u32 {
let mut crc = !0u32;
for &byte in data {
crc ^= u32::from(byte);
for _ in 0..8 {
crc = if crc & 1 == 1 {
(crc >> 1) ^ 0x82F6_3B78
} else {
crc >> 1
};
}
}
let sum = !crc;
(sum.rotate_right(15)).wrapping_add(0xA282_EAD8)
}
#[test]
fn rejects_automaton_with_moved_root() {
let mut sections = format::tests::read(&dictionary_bytes());
let words = &mut sections.words;
let end = words.len() - 4;
let root = u64::from_le_bytes(words[end - 8..end].try_into().unwrap());
assert_eq!(masked_crc32c(&words[..end]).to_le_bytes(), words[end..]);
words[end - 8..end].copy_from_slice(&(root - 1).to_le_bytes());
let crc = masked_crc32c(&words[..end]);
words[end..].copy_from_slice(&crc.to_le_bytes());
let err = Dictionary::from_bytes(&format::write(§ions))
.err()
.unwrap();
assert!(err.to_string().contains("адрес корня"), "{err}");
}
#[test]
fn rejects_too_many_ud_tags() {
let mut sections = sections();
sections.ud_tags = [0, 0, 0].repeat(usize::from(u16::MAX) + 2);
let err = Dictionary::from_bytes(&format::write(§ions))
.err()
.unwrap();
assert!(err.to_string().contains("65 536 тегов"), "{err}");
sections.ud_tags = [0, 0, 0].repeat(usize::from(u16::MAX) + 1);
let err = Dictionary::from_bytes(&format::write(§ions))
.err()
.unwrap();
assert!(err.to_string().contains("65 536 тегов"), "{err}");
}
fn exercise(dict: &Dictionary) {
let words = [
"ёж",
"ежа",
"еж",
"стали",
"наибольший",
"все",
"всё",
"кота",
"зума",
"Зума",
"XIX-го",
"кот-то",
"Д-д-да",
];
for word in words {
dict.is_known(word);
dict.opencorpora(word);
for analysis in dict.analyze(word) {
dict.forms(&analysis);
dict.inflect(&analysis, &[("Case", "Gen")]);
dict.agree(&analysis, 5);
dict.agree_fraction(&analysis);
}
}
for (table, form) in (0..8).flat_map(|table| (0..4).map(move |form| (table, form))) {
for source in [Source::Dictionary, Source::Rule, Source::Guess] {
if let Some(analysis) = dict.analysis("стали", entry(table, form), source) {
dict.forms(&analysis);
}
}
}
}
#[test]
fn corrupted_dictionary_never_panics() {
for data in [dictionary_bytes(), guessing_bytes()] {
exercise(&Dictionary::from_bytes(&data).unwrap());
for len in 0..data.len() {
assert!(Dictionary::from_bytes(&data[..len]).is_err(), "{len}");
}
let layout = format::layout(&data).unwrap();
let automata = [layout.words.clone(), layout.guesses.clone()];
for at in 0..data.len() {
let mut corrupted = data.clone();
corrupted[at] ^= 0xFF;
assert!(Dictionary::from_bytes(&corrupted).is_err(), "{at}");
if automata.iter().any(|range| range.contains(&at)) {
continue;
}
if let Ok(dict) = Dictionary::from_bytes(&format::tests::with_checksum(corrupted)) {
exercise(&dict);
}
}
let numbers: [fn(&mut Sections) -> &mut Vec<u32>; 6] = [
|s| &mut s.paradigm_offsets,
|s| &mut s.tables,
|s| &mut s.records,
|s| &mut s.variants,
|s| &mut s.probability_records,
|s| &mut s.guess_records,
];
let mut original = format::tests::read(&data);
for section in numbers {
for at in 0..section(&mut original).len() {
let number = section(&mut original)[at];
for value in [
0,
1,
number.wrapping_add(1),
number.wrapping_sub(1),
0xFFFF,
0x1_0000,
u32::MAX,
] {
let mut sections = format::tests::read(&data);
section(&mut sections)[at] = value;
if let Ok(dict) = Dictionary::from_bytes(&format::write(§ions)) {
exercise(&dict);
}
}
}
}
let numbers: [fn(&mut Sections) -> &mut Vec<u16>; 2] =
[|s| &mut s.paradigms, |s| &mut s.ud_tags];
for section in numbers {
for at in 0..section(&mut original).len() {
for value in [0, 1, 2, u16::MAX] {
let mut sections = format::tests::read(&data);
section(&mut sections)[at] = value;
if let Ok(dict) = Dictionary::from_bytes(&format::write(§ions)) {
exercise(&dict);
}
}
}
}
let swaps: [fn(&mut Sections); 10] = [
|s| std::mem::swap(&mut s.words, &mut s.guesses),
|s| std::mem::swap(&mut s.prefixes, &mut s.suffixes),
|s| std::mem::swap(&mut s.tags, &mut s.ud_strings),
|s| std::mem::swap(&mut s.tags, &mut s.suffixes),
|s| std::mem::swap(&mut s.records, &mut s.guess_records),
|s| std::mem::swap(&mut s.records, &mut s.probability_records),
|s| std::mem::swap(&mut s.records, &mut s.variants),
|s| std::mem::swap(&mut s.tables, &mut s.records),
|s| std::mem::swap(&mut s.paradigm_offsets, &mut s.tables),
|s| std::mem::swap(&mut s.paradigms, &mut s.ud_tags),
];
for swap in swaps {
let mut sections = format::tests::read(&data);
swap(&mut sections);
if let Ok(dict) = Dictionary::from_bytes(&format::write(§ions)) {
exercise(&dict);
}
}
}
}
#[test]
fn broken_probability_offset_gives_uniform_probabilities() {
let data = dictionary_bytes();
let dict = Dictionary::from_bytes(&data).unwrap();
let key = dict.alphabet.input_key("стали").unwrap();
let mut sections = format::tests::read(&data);
let mut words = fst::MapBuilder::memory();
let map = fst::Map::new(sections.words.clone()).unwrap();
let mut stream = fst::IntoStreamer::into_stream(&map);
while let Some((word, value)) = fst::Streamer::next(&mut stream) {
let value = match WordValue::unpack(value) {
WordValue::Single { yo, records, .. } if word == key.bytes() => WordValue::Single {
yo,
records,
probabilities: Some(77_777),
}
.pack()
.unwrap(),
_ => value,
};
words.insert(word, value).unwrap();
}
sections.words = words.into_inner().unwrap();
let dict = Dictionary::from_bytes(&format::write(§ions)).unwrap();
let probs: Vec<f64> = dict.analyze("стали").iter().map(|a| a.prob).collect();
assert_eq!(probs, [1.0 / 3.0; 3]);
}
#[test]
fn yo_spellings_of_one_key() {
let mut builder = builder(&["ADJF"], &[""], &[""]);
let table = lexeme_table(&mut builder, &[(0, 0, 0)]);
for word in ["трёхзвёздный", "трехзвездный", "трехзвёздный"]
{
builder.add_word(word, &[entry(table, 0)]).unwrap();
}
let dict = Dictionary::from_bytes(&builder.build().unwrap()).unwrap();
assert_eq!(dict.word_count(), 3);
let words = |word: &str| -> Vec<String> {
dict.analyze(word).into_iter().map(|a| a.word).collect()
};
assert_eq!(
words("трехзвездный"),
["трехзвездный", "трёхзвёздный", "трехзвёздный"]
);
assert_eq!(words("трёхзвездный"), ["трёхзвёздный"]);
assert_eq!(words("трехзвёздный"), ["трехзвёздный", "трёхзвёздный"]);
assert_eq!(words("ТРЁХЗВЁЗДНЫЙ"), ["трёхзвёздный"]);
assert!(dict.is_known("трёхзвездный") && dict.is_known("Трехзвездный"));
assert!(!dict.is_known("трехзвездныи"));
for word in ["трёхзвёздный", "трехзвездный", "трехзвёздный"]
{
assert!(
dict.analysis(word, entry(table, 0), Source::Dictionary)
.is_some()
);
}
assert!(
dict.analysis("трёхзвездный", entry(table, 0), Source::Dictionary)
.is_none()
);
}
#[test]
fn probabilities_of_spelling_without_yo() {
let mut builder = builder(&["NOUN,gent", "NOUN,nomn"], &[""], &[""]);
let genitive = lexeme_table(&mut builder, &[(0, 0, 0)]);
let nominative = lexeme_table(&mut builder, &[(0, 1, 0)]);
builder
.add_word("тётки", &[entry(genitive, 0), entry(nominative, 0)])
.unwrap();
builder.add_probability("тетки", 1, 800_000).unwrap();
builder.add_probability("тетки", 0, 200_000).unwrap();
builder.add_probability("тётки", 0, 900_000).unwrap();
builder.add_probability("тётки", 1, 100_000).unwrap();
builder.add_probability("вайб", 0, 1_000_000).unwrap();
let data = builder.build().unwrap();
assert_eq!(format::tests::read(&data).probability_records.len(), 10);
let dict = Dictionary::from_bytes(&data).unwrap();
let top = |word: &str| {
let analyses = dict.analyze(word);
(analyses[0].word.clone(), analyses[0].tag, analyses[0].prob)
};
assert_eq!(top("тетки"), ("тётки".to_string(), Some(1), 0.8));
assert_eq!(top("тётки"), ("тётки".to_string(), Some(0), 0.9));
}
#[test]
fn mapped_file_outlives_dictionary_parts() {
let path = std::env::temp_dir().join(format!("rumorph-map-{}.dict", std::process::id()));
std::fs::write(&path, dictionary_bytes()).unwrap();
let dict = Dictionary::open(&path).unwrap();
assert!(matches!(*dict.storage, Storage::Mapped(_)));
let analyses = dict.analyze("стали");
drop(dict);
std::fs::remove_file(&path).unwrap();
assert_eq!(analyses.len(), 3);
std::fs::write(&path, b"").unwrap();
let result = Dictionary::open(&path);
std::fs::remove_file(&path).unwrap();
assert!(matches!(result, Err(Error::Format(_))));
}
}