Download crates/nedo-morph-bundle/src/lib.rs from Ethosoft/NedoTokenizer: direct link, hf CLI and curl.
- Browser
- Download file 46.3 kB
-
https://huggingface.co/Ethosoft/NedoTokenizer/resolve/main/crates/nedo-morph-bundle/src/lib.rs
- Command line
-
hf download hf://Ethosoft/NedoTokenizer/crates/nedo-morph-bundle/src/lib.rs
-
curl -L -o lib.rs https://huggingface.co/Ethosoft/NedoTokenizer/resolve/main/crates/nedo-morph-bundle/src/lib.rs
46.3 kB
| //! Strict loader and semantic validator for Nedo morphology reference bundles. | |
| use core::fmt; | |
| use std::collections::{BTreeMap, HashMap, HashSet}; | |
| use std::fs::File; | |
| use std::io::{BufRead, BufReader}; | |
| use std::path::{Path, PathBuf}; | |
| use serde::de::DeserializeOwned; | |
| use serde::Deserialize; | |
| mod binary; | |
| mod disambiguation; | |
| pub use disambiguation::{ | |
| ambiguity_word_data, AmbiguityScoringCode, AmbiguityWordData, DisambiguationError, | |
| DisambiguationScoreCache, NativeDisambiguation, NativeDisambiguator, PerceptronModel, | |
| SharedPreparedWordCache, | |
| }; | |
| pub use binary::{ | |
| compile_binary, AnalysisLimits, BinaryBundleView, BinaryError, BinarySummary, NativeAnalysis, | |
| NativeGenerator, NativeMorpheme, NativeMorphology, NativeStem, StemMatches, | |
| }; | |
| /// Current supported graph-bundle schema. | |
| pub const BUNDLE_SCHEMA_VERSION: u32 = 1; | |
| /// Exact upstream source identity accepted by schema v1. | |
| pub const ZEMBEREK_COMMIT: &str = "ae2fbe31438dda4dddc674a2a8991d518984d392"; | |
| /// A fully parsed and cross-reference-validated morphology bundle. | |
| pub struct MorphBundle { | |
| manifest: BundleManifest, | |
| morphemes: Vec<MorphemeRecord>, | |
| dictionary: Vec<DictionaryRecord>, | |
| stems: Vec<StemRecord>, | |
| states: Vec<StateRecord>, | |
| edges: Vec<EdgeRecord>, | |
| } | |
| impl MorphBundle { | |
| /// Loads all bundle files and validates every schema and graph invariant. | |
| /// | |
| /// # Errors | |
| /// | |
| /// Returns an error for I/O failure, malformed or unknown JSON fields, | |
| /// unsupported schema identity, count mismatch, invalid template/condition, | |
| /// duplicate identity, or a broken cross-reference. | |
| pub fn load_directory(path: &Path) -> Result<Self, BundleError> { | |
| let manifest: BundleManifest = read_json(&path.join("manifest.json"))?; | |
| let morphemes = read_jsonl(&path.join("morphemes.jsonl"))?; | |
| let dictionary = read_jsonl(&path.join("dictionary.jsonl"))?; | |
| let stems = read_jsonl(&path.join("stems.jsonl"))?; | |
| let states = read_jsonl(&path.join("states.jsonl"))?; | |
| let edges = read_jsonl(&path.join("edges.jsonl"))?; | |
| let bundle = Self { | |
| manifest, | |
| morphemes, | |
| dictionary, | |
| stems, | |
| states, | |
| edges, | |
| }; | |
| bundle.validate()?; | |
| Ok(bundle) | |
| } | |
| /// Returns a compact validated bundle summary. | |
| pub const fn summary(&self) -> BundleSummary { | |
| BundleSummary { | |
| morphemes: self.morphemes.len(), | |
| dictionary: self.dictionary.len(), | |
| stems: self.stems.len(), | |
| states: self.states.len(), | |
| edges: self.edges.len(), | |
| owner_mismatches: self.manifest.owner_declared_from_mismatch_count, | |
| duplicate_state_ids: self.manifest.duplicate_zemberek_state_id_count, | |
| condition_kinds: self.manifest.condition_kind_counts.len(), | |
| } | |
| } | |
| /// Returns the validated manifest. | |
| pub const fn manifest(&self) -> &BundleManifest { | |
| &self.manifest | |
| } | |
| fn validate(&self) -> Result<(), BundleError> { | |
| self.validate_manifest_identity()?; | |
| self.validate_manifest_counts()?; | |
| let morpheme_ids = | |
| validate_sorted_unique(&self.morphemes, |record| record.id.as_str(), "morpheme id")?; | |
| let dictionary_ids = validate_sorted_unique( | |
| &self.dictionary, | |
| |record| record.id.as_str(), | |
| "dictionary id", | |
| )?; | |
| validate_sorted_unique(&self.states, |record| record.key.as_str(), "state key")?; | |
| validate_sequential_edge_ids(&self.edges)?; | |
| let morpheme_by_id: HashMap<&str, &MorphemeRecord> = self | |
| .morphemes | |
| .iter() | |
| .map(|record| (record.id.as_str(), record)) | |
| .collect(); | |
| let dictionary_by_id: HashMap<&str, &DictionaryRecord> = self | |
| .dictionary | |
| .iter() | |
| .map(|record| (record.id.as_str(), record)) | |
| .collect(); | |
| let state_by_key: HashMap<&str, &StateRecord> = self | |
| .states | |
| .iter() | |
| .map(|record| (record.key.as_str(), record)) | |
| .collect(); | |
| for record in &self.morphemes { | |
| record.validate(&morpheme_ids)?; | |
| } | |
| for record in &self.dictionary { | |
| record.validate(&dictionary_ids)?; | |
| } | |
| for record in &self.states { | |
| record.validate(&morpheme_ids)?; | |
| } | |
| for record in &self.stems { | |
| record.validate(&dictionary_by_id, &state_by_key)?; | |
| } | |
| self.validate_graph_aggregates(&morpheme_by_id, &dictionary_by_id, &state_by_key)?; | |
| Ok(()) | |
| } | |
| fn validate_graph_aggregates( | |
| &self, | |
| morpheme_by_id: &HashMap<&str, &MorphemeRecord>, | |
| dictionary_by_id: &HashMap<&str, &DictionaryRecord>, | |
| state_by_key: &HashMap<&str, &StateRecord>, | |
| ) -> Result<(), BundleError> { | |
| let mut owner_edge_counts: HashMap<&str, usize> = HashMap::new(); | |
| let mut owner_mismatches = 0_usize; | |
| let mut condition_counts: BTreeMap<&'static str, usize> = BTreeMap::new(); | |
| for edge in &self.edges { | |
| edge.validate( | |
| morpheme_by_id, | |
| dictionary_by_id, | |
| state_by_key, | |
| &mut condition_counts, | |
| )?; | |
| let count = owner_edge_counts | |
| .entry(edge.owner_state.as_str()) | |
| .or_insert(0); | |
| *count = count | |
| .checked_add(1) | |
| .ok_or_else(|| invalid("owner edge count overflow"))?; | |
| if !edge.owner_matches_declared_from { | |
| owner_mismatches = owner_mismatches | |
| .checked_add(1) | |
| .ok_or_else(|| invalid("owner mismatch count overflow"))?; | |
| } | |
| } | |
| for state in &self.states { | |
| let actual = owner_edge_counts | |
| .get(state.key.as_str()) | |
| .copied() | |
| .unwrap_or(0); | |
| if state.outgoing_count != actual { | |
| return Err(invalid(format!( | |
| "state {} outgoing_count {} != actual {actual}", | |
| state.key, state.outgoing_count | |
| ))); | |
| } | |
| } | |
| if owner_mismatches != self.manifest.owner_declared_from_mismatch_count { | |
| return Err(invalid(format!( | |
| "owner mismatch count {} != manifest {}", | |
| owner_mismatches, self.manifest.owner_declared_from_mismatch_count | |
| ))); | |
| } | |
| let duplicate_state_ids = | |
| duplicate_excess(self.states.iter().map(|state| state.zemberek_id.as_str()))?; | |
| if duplicate_state_ids != self.manifest.duplicate_zemberek_state_id_count { | |
| return Err(invalid(format!( | |
| "duplicate state id count {duplicate_state_ids} != manifest {}", | |
| self.manifest.duplicate_zemberek_state_id_count | |
| ))); | |
| } | |
| let aliasless_states = self | |
| .states | |
| .iter() | |
| .filter(|state| state.declared_fields.is_empty()) | |
| .count(); | |
| if aliasless_states != self.manifest.aliasless_state_count { | |
| return Err(invalid(format!( | |
| "aliasless state count {aliasless_states} != manifest {}", | |
| self.manifest.aliasless_state_count | |
| ))); | |
| } | |
| let reflected_field_count = self.states.iter().try_fold(0_usize, |total, state| { | |
| total | |
| .checked_add(state.declared_fields.len()) | |
| .ok_or_else(|| invalid("reflected field count overflow")) | |
| })?; | |
| if reflected_field_count != self.manifest.reflected_state_field_count { | |
| return Err(invalid(format!( | |
| "reflected state field count {reflected_field_count} != manifest {}", | |
| self.manifest.reflected_state_field_count | |
| ))); | |
| } | |
| let manifest_condition_counts = self.manifest.condition_count_map()?; | |
| if condition_counts != manifest_condition_counts { | |
| return Err(invalid(format!( | |
| "condition inventory mismatch: actual={condition_counts:?} manifest={manifest_condition_counts:?}" | |
| ))); | |
| } | |
| Ok(()) | |
| } | |
| fn validate_manifest_identity(&self) -> Result<(), BundleError> { | |
| if self.manifest.schema_version != BUNDLE_SCHEMA_VERSION { | |
| return Err(invalid(format!( | |
| "unsupported bundle schema {}", | |
| self.manifest.schema_version | |
| ))); | |
| } | |
| if self.manifest.zemberek_commit != ZEMBEREK_COMMIT { | |
| return Err(invalid(format!( | |
| "unsupported Zemberek commit {}", | |
| self.manifest.zemberek_commit | |
| ))); | |
| } | |
| if self.manifest.zemberek_version != "0.17.2" { | |
| return Err(invalid(format!( | |
| "unsupported Zemberek version {}", | |
| self.manifest.zemberek_version | |
| ))); | |
| } | |
| if self.manifest.exporter != "nedo-zemberek-graph-bundle-v1" { | |
| return Err(invalid(format!( | |
| "unsupported exporter {}", | |
| self.manifest.exporter | |
| ))); | |
| } | |
| Ok(()) | |
| } | |
| fn validate_manifest_counts(&self) -> Result<(), BundleError> { | |
| let pairs = [ | |
| ( | |
| "morpheme_count", | |
| self.manifest.morpheme_count, | |
| self.morphemes.len(), | |
| ), | |
| ( | |
| "dictionary_count", | |
| self.manifest.dictionary_count, | |
| self.dictionary.len(), | |
| ), | |
| ("stem_count", self.manifest.stem_count, self.stems.len()), | |
| ("state_count", self.manifest.state_count, self.states.len()), | |
| ("edge_count", self.manifest.edge_count, self.edges.len()), | |
| ]; | |
| for (name, declared, actual) in pairs { | |
| if declared != actual { | |
| return Err(invalid(format!( | |
| "manifest {name} {declared} != actual {actual}" | |
| ))); | |
| } | |
| } | |
| Ok(()) | |
| } | |
| } | |
| /// Validated high-level bundle counts. | |
| pub struct BundleSummary { | |
| /// Number of morphemes. | |
| pub morphemes: usize, | |
| /// Number of dictionary entries. | |
| pub dictionary: usize, | |
| /// Number of generated stem transitions. | |
| pub stems: usize, | |
| /// Number of distinct state objects. | |
| pub states: usize, | |
| /// Number of outgoing-owner graph edges. | |
| pub edges: usize, | |
| /// Number of edges whose owner differs from the Java object's `from` field. | |
| pub owner_mismatches: usize, | |
| /// Number of duplicate upstream textual state IDs beyond the first occurrence. | |
| pub duplicate_state_ids: usize, | |
| /// Number of distinct source condition implementation kinds. | |
| pub condition_kinds: usize, | |
| } | |
| /// Strict graph bundle manifest. | |
| pub struct BundleManifest { | |
| schema_version: u32, | |
| zemberek_commit: String, | |
| zemberek_version: String, | |
| exporter: String, | |
| morpheme_count: usize, | |
| dictionary_count: usize, | |
| stem_count: usize, | |
| state_count: usize, | |
| edge_count: usize, | |
| owner_declared_from_mismatch_count: usize, | |
| reflected_state_field_count: usize, | |
| duplicate_zemberek_state_id_count: usize, | |
| aliasless_state_count: usize, | |
| condition_kind_counts: Vec<ConditionKindCount>, | |
| } | |
| impl BundleManifest { | |
| fn condition_count_map(&self) -> Result<BTreeMap<&'static str, usize>, BundleError> { | |
| let mut result = BTreeMap::new(); | |
| let mut previous = None; | |
| for entry in &self.condition_kind_counts { | |
| let kind = known_condition_manifest_kind(&entry.kind).ok_or_else(|| { | |
| invalid(format!("unknown manifest condition kind {}", entry.kind)) | |
| })?; | |
| if previous.is_some_and(|value: &str| value >= entry.kind.as_str()) { | |
| return Err(invalid("manifest condition kinds are not strictly sorted")); | |
| } | |
| previous = Some(entry.kind.as_str()); | |
| if result.insert(kind, entry.count).is_some() { | |
| return Err(invalid(format!( | |
| "duplicate manifest condition kind {}", | |
| entry.kind | |
| ))); | |
| } | |
| } | |
| Ok(result) | |
| } | |
| } | |
| struct ConditionKindCount { | |
| kind: String, | |
| count: usize, | |
| } | |
| struct MorphemeRecord { | |
| id: String, | |
| name: String, | |
| pos: Option<String>, | |
| derivational: bool, | |
| informal: bool, | |
| mapped_id: Option<String>, | |
| } | |
| impl MorphemeRecord { | |
| fn validate(&self, morpheme_ids: &HashSet<&str>) -> Result<(), BundleError> { | |
| if self.id.is_empty() || self.name.is_empty() { | |
| return Err(invalid("empty morpheme id or name")); | |
| } | |
| if let Some(pos) = &self.pos { | |
| if !PRIMARY_POS_SHORT.contains(&pos.as_str()) { | |
| return Err(invalid(format!("unknown morpheme POS {pos}"))); | |
| } | |
| } | |
| if let Some(mapped_id) = &self.mapped_id { | |
| require_reference(morpheme_ids, mapped_id, "mapped morpheme")?; | |
| } | |
| let _ = (self.derivational, self.informal); | |
| Ok(()) | |
| } | |
| } | |
| struct DictionaryRecord { | |
| id: String, | |
| lemma: String, | |
| root: String, | |
| pronunciation: String, | |
| primary_pos: String, | |
| secondary_pos: String, | |
| attributes: Vec<String>, | |
| reference_id: Option<String>, | |
| index: i32, | |
| } | |
| impl DictionaryRecord { | |
| fn validate(&self, dictionary_ids: &HashSet<&str>) -> Result<(), BundleError> { | |
| if self.id.is_empty() { | |
| return Err(invalid("empty dictionary id")); | |
| } | |
| if !PRIMARY_POS_SHORT.contains(&self.primary_pos.as_str()) { | |
| return Err(invalid(format!( | |
| "unknown dictionary primary POS {} for {}", | |
| self.primary_pos, self.id | |
| ))); | |
| } | |
| if !SECONDARY_POS_SHORT.contains(&self.secondary_pos.as_str()) { | |
| return Err(invalid(format!( | |
| "unknown dictionary secondary POS {} for {}", | |
| self.secondary_pos, self.id | |
| ))); | |
| } | |
| validate_sorted_known(&self.attributes, ROOT_ATTRIBUTES, "root attribute")?; | |
| if let Some(reference_id) = &self.reference_id { | |
| require_reference(dictionary_ids, reference_id, "dictionary reference")?; | |
| } | |
| let _ = ( | |
| self.lemma.as_str(), | |
| self.root.as_str(), | |
| self.pronunciation.as_str(), | |
| self.index, | |
| ); | |
| Ok(()) | |
| } | |
| } | |
| struct StemRecord { | |
| surface: String, | |
| source_order: usize, | |
| dictionary_id: String, | |
| target_state: String, | |
| target_zemberek_id: String, | |
| phonetic_bits: i32, | |
| phonetic_attributes: Vec<String>, | |
| } | |
| impl StemRecord { | |
| fn validate( | |
| &self, | |
| dictionary: &HashMap<&str, &DictionaryRecord>, | |
| states: &HashMap<&str, &StateRecord>, | |
| ) -> Result<(), BundleError> { | |
| if !dictionary.contains_key(self.dictionary_id.as_str()) { | |
| return Err(invalid(format!( | |
| "stem references missing dictionary item {}", | |
| self.dictionary_id | |
| ))); | |
| } | |
| let state = states.get(self.target_state.as_str()).ok_or_else(|| { | |
| invalid(format!( | |
| "stem references missing state {}", | |
| self.target_state | |
| )) | |
| })?; | |
| if state.zemberek_id != self.target_zemberek_id { | |
| return Err(invalid(format!( | |
| "stem target textual id mismatch for {}", | |
| self.target_state | |
| ))); | |
| } | |
| validate_unique(&self.phonetic_attributes, "phonetic attributes")?; | |
| let mut bits = 0_i32; | |
| for attribute in &self.phonetic_attributes { | |
| let ordinal = phonetic_ordinal(attribute) | |
| .ok_or_else(|| invalid(format!("unknown phonetic attribute {attribute}")))?; | |
| bits |= 1_i32 | |
| .checked_shl(ordinal) | |
| .ok_or_else(|| invalid("phonetic bit shift overflow"))?; | |
| } | |
| if bits != self.phonetic_bits { | |
| return Err(invalid(format!( | |
| "stem phonetic bits {bits} != declared {} for {}", | |
| self.phonetic_bits, self.dictionary_id | |
| ))); | |
| } | |
| let _ = (self.surface.as_str(), self.source_order); | |
| Ok(()) | |
| } | |
| } | |
| struct StateRecord { | |
| key: String, | |
| zemberek_id: String, | |
| morpheme_id: String, | |
| terminal: bool, | |
| derivative: bool, | |
| pos_root: bool, | |
| outgoing_count: usize, | |
| incoming_count: usize, | |
| declared_fields: Vec<String>, | |
| } | |
| impl StateRecord { | |
| fn validate(&self, morpheme_ids: &HashSet<&str>) -> Result<(), BundleError> { | |
| require_reference(morpheme_ids, &self.morpheme_id, "state morpheme")?; | |
| validate_sorted_unique_strings(&self.declared_fields, "state declared fields")?; | |
| if let Some(first) = self.declared_fields.first() { | |
| if first != &self.key { | |
| return Err(invalid(format!( | |
| "state key {} differs from canonical field {first}", | |
| self.key | |
| ))); | |
| } | |
| } else if !self.key.starts_with("unbound:") { | |
| return Err(invalid(format!( | |
| "aliasless state has non-unbound key {}", | |
| self.key | |
| ))); | |
| } | |
| let _ = ( | |
| self.zemberek_id.as_str(), | |
| self.terminal, | |
| self.derivative, | |
| self.pos_root, | |
| self.incoming_count, | |
| ); | |
| Ok(()) | |
| } | |
| } | |
| struct EdgeRecord { | |
| id: String, | |
| source_order: usize, | |
| owner_state: String, | |
| owner_zemberek_id: String, | |
| declared_from: String, | |
| declared_from_zemberek_id: String, | |
| owner_matches_declared_from: bool, | |
| to_state: String, | |
| to_zemberek_id: String, | |
| morpheme_id: String, | |
| surface_template: String, | |
| template_tokens: Vec<TemplateToken>, | |
| condition_count: usize, | |
| condition: Option<Condition>, | |
| } | |
| impl EdgeRecord { | |
| fn validate( | |
| &self, | |
| morphemes: &HashMap<&str, &MorphemeRecord>, | |
| dictionary: &HashMap<&str, &DictionaryRecord>, | |
| states: &HashMap<&str, &StateRecord>, | |
| condition_counts: &mut BTreeMap<&'static str, usize>, | |
| ) -> Result<(), BundleError> { | |
| let owner = require_state(states, &self.owner_state, "edge owner")?; | |
| let declared_from = require_state(states, &self.declared_from, "edge declared_from")?; | |
| let target = require_state(states, &self.to_state, "edge target")?; | |
| if owner.zemberek_id != self.owner_zemberek_id | |
| || declared_from.zemberek_id != self.declared_from_zemberek_id | |
| || target.zemberek_id != self.to_zemberek_id | |
| { | |
| return Err(invalid(format!( | |
| "edge {} textual state ID mismatch", | |
| self.id | |
| ))); | |
| } | |
| if self.owner_matches_declared_from != (self.owner_state == self.declared_from) { | |
| return Err(invalid(format!( | |
| "edge {} owner match flag is inconsistent", | |
| self.id | |
| ))); | |
| } | |
| if target.morpheme_id != self.morpheme_id { | |
| return Err(invalid(format!( | |
| "edge {} target morpheme mismatch", | |
| self.id | |
| ))); | |
| } | |
| if !morphemes.contains_key(self.morpheme_id.as_str()) { | |
| return Err(invalid(format!( | |
| "edge {} references missing morpheme {}", | |
| self.id, self.morpheme_id | |
| ))); | |
| } | |
| let _ = self.source_order; | |
| let reconstructed = reconstruct_template(&self.template_tokens)?; | |
| if reconstructed != self.surface_template { | |
| return Err(invalid(format!( | |
| "edge {} template {:?} != reconstructed {:?}", | |
| self.id, self.surface_template, reconstructed | |
| ))); | |
| } | |
| let actual_condition_count = zemberek_condition_count(self.condition.as_ref()); | |
| if actual_condition_count != self.condition_count { | |
| return Err(invalid(format!( | |
| "edge {} condition_count {} != recomputed {actual_condition_count}", | |
| self.id, self.condition_count | |
| ))); | |
| } | |
| if let Some(condition) = &self.condition { | |
| condition.validate(morphemes, dictionary, states, condition_counts)?; | |
| } | |
| Ok(()) | |
| } | |
| } | |
| struct TemplateToken { | |
| token_type: TemplateTokenType, | |
| letter: String, | |
| append: bool, | |
| } | |
| enum TemplateTokenType { | |
| IVowel, | |
| AVowel, | |
| DevoiceFirst, | |
| LastVoiced, | |
| LastNotVoiced, | |
| Append, | |
| Letter, | |
| } | |
| enum Condition { | |
| And { | |
| children: Vec<Self>, | |
| }, | |
| Or { | |
| children: Vec<Self>, | |
| }, | |
| Not { | |
| child: Box<Self>, | |
| }, | |
| HasRootAttribute { | |
| attribute: String, | |
| }, | |
| HasAnyRootAttribute { | |
| attributes: Vec<String>, | |
| }, | |
| HasPhoneticAttribute { | |
| attribute: String, | |
| }, | |
| DictionaryItemIs { | |
| item: String, | |
| }, | |
| RootPrimaryPosIs { | |
| pos: String, | |
| }, | |
| SecondaryPosIs { | |
| pos: String, | |
| }, | |
| DictionaryItemIsAny { | |
| items: Vec<Option<String>>, | |
| }, | |
| DictionaryItemIsNone { | |
| items: Vec<Option<String>>, | |
| }, | |
| HasAnySuffixSurface, | |
| HasTail, | |
| HasNoTail, | |
| HasTailSequence { | |
| morphemes: Vec<String>, | |
| }, | |
| ContainsMorphemeSequence { | |
| morphemes: Vec<String>, | |
| }, | |
| CurrentMorphemeIs { | |
| morpheme: String, | |
| }, | |
| PreviousMorphemeIs { | |
| morpheme: String, | |
| }, | |
| PreviousStateIs { | |
| state: String, | |
| }, | |
| PreviousStateIsNot { | |
| state: String, | |
| }, | |
| RootSurfaceIs { | |
| surface: String, | |
| }, | |
| RootSurfaceIsAny { | |
| surfaces: Vec<String>, | |
| }, | |
| CurrentStateIs { | |
| state: String, | |
| }, | |
| CurrentStateIsNot { | |
| state: String, | |
| }, | |
| LastDerivationIs { | |
| state: String, | |
| }, | |
| HasDerivation, | |
| LastDerivationIsAny { | |
| states: Vec<String>, | |
| }, | |
| CurrentGroupContainsAny { | |
| states: Vec<String>, | |
| }, | |
| PreviousGroupContains { | |
| states: Vec<String>, | |
| }, | |
| PreviousGroupContainsMorpheme { | |
| morphemes: Vec<String>, | |
| }, | |
| NoSurfaceAfterDerivation, | |
| ContainsMorpheme { | |
| morphemes: Vec<String>, | |
| }, | |
| PreviousMorphemeIsAny { | |
| morphemes: Vec<String>, | |
| }, | |
| CurrentMorphemeIsAny { | |
| morphemes: Vec<String>, | |
| }, | |
| PreviousStateIsAny { | |
| states: Vec<String>, | |
| }, | |
| } | |
| impl Condition { | |
| fn validate( | |
| &self, | |
| morphemes: &HashMap<&str, &MorphemeRecord>, | |
| dictionary: &HashMap<&str, &DictionaryRecord>, | |
| states: &HashMap<&str, &StateRecord>, | |
| counts: &mut BTreeMap<&'static str, usize>, | |
| ) -> Result<(), BundleError> { | |
| increment(counts, self.manifest_kind())?; | |
| match self { | |
| Self::And { children } | Self::Or { children } => { | |
| if children.len() < 2 { | |
| return Err(invalid("combined condition has fewer than two children")); | |
| } | |
| for child in children { | |
| child.validate(morphemes, dictionary, states, counts)?; | |
| } | |
| } | |
| Self::Not { child } => child.validate(morphemes, dictionary, states, counts)?, | |
| Self::HasRootAttribute { attribute } => { | |
| require_known(ROOT_ATTRIBUTES, attribute, "root attribute")?; | |
| } | |
| Self::HasAnyRootAttribute { attributes } => { | |
| for attribute in attributes { | |
| require_known(ROOT_ATTRIBUTES, attribute, "root attribute")?; | |
| } | |
| } | |
| Self::HasPhoneticAttribute { attribute } => { | |
| if phonetic_ordinal(attribute).is_none() { | |
| return Err(invalid(format!("unknown phonetic attribute {attribute}"))); | |
| } | |
| } | |
| Self::DictionaryItemIs { item } => { | |
| require_map_reference(dictionary, item, "condition dictionary item")?; | |
| } | |
| Self::RootPrimaryPosIs { pos } => { | |
| require_known(PRIMARY_POS_NAMES, pos, "primary POS")?; | |
| } | |
| Self::SecondaryPosIs { pos } => { | |
| require_known(SECONDARY_POS_NAMES, pos, "secondary POS")?; | |
| } | |
| Self::DictionaryItemIsAny { items } | Self::DictionaryItemIsNone { items } => { | |
| validate_nullable_dictionary_set(items, dictionary)?; | |
| } | |
| Self::HasTailSequence { morphemes: values } | |
| | Self::ContainsMorphemeSequence { morphemes: values } => { | |
| for value in values { | |
| require_map_reference(morphemes, value, "condition morpheme")?; | |
| } | |
| } | |
| Self::CurrentMorphemeIs { morpheme } | Self::PreviousMorphemeIs { morpheme } => { | |
| require_map_reference(morphemes, morpheme, "condition morpheme")?; | |
| } | |
| Self::PreviousStateIs { state } | |
| | Self::PreviousStateIsNot { state } | |
| | Self::CurrentStateIs { state } | |
| | Self::CurrentStateIsNot { state } | |
| | Self::LastDerivationIs { state } => { | |
| require_map_reference(states, state, "condition state")?; | |
| } | |
| Self::RootSurfaceIs { surface } => { | |
| let _ = surface.as_str(); | |
| } | |
| Self::RootSurfaceIsAny { surfaces } => { | |
| let _ = surfaces.as_slice(); | |
| } | |
| Self::LastDerivationIsAny { states: values } | |
| | Self::CurrentGroupContainsAny { states: values } | |
| | Self::PreviousGroupContains { states: values } | |
| | Self::PreviousStateIsAny { states: values } => { | |
| validate_sorted_unique_strings(values, "condition state set")?; | |
| for value in values { | |
| require_map_reference(states, value, "condition state")?; | |
| } | |
| } | |
| Self::PreviousGroupContainsMorpheme { morphemes: values } | |
| | Self::ContainsMorpheme { morphemes: values } | |
| | Self::PreviousMorphemeIsAny { morphemes: values } | |
| | Self::CurrentMorphemeIsAny { morphemes: values } => { | |
| validate_sorted_unique_strings(values, "condition morpheme set")?; | |
| for value in values { | |
| require_map_reference(morphemes, value, "condition morpheme")?; | |
| } | |
| } | |
| Self::HasAnySuffixSurface | |
| | Self::HasTail | |
| | Self::HasNoTail | |
| | Self::HasDerivation | |
| | Self::NoSurfaceAfterDerivation => {} | |
| } | |
| Ok(()) | |
| } | |
| const fn manifest_kind(&self) -> &'static str { | |
| match self { | |
| Self::And { .. } | Self::Or { .. } => "CombinedCondition", | |
| Self::Not { .. } => "NotCondition", | |
| Self::HasRootAttribute { .. } => "HasRootAttribute", | |
| Self::HasAnyRootAttribute { .. } => "HasAnyRootAttribute", | |
| Self::HasPhoneticAttribute { .. } => "HasPhoneticAttribute", | |
| Self::DictionaryItemIs { .. } => "DictionaryItemIs", | |
| Self::RootPrimaryPosIs { .. } => "RootPrimaryPosIs", | |
| Self::SecondaryPosIs { .. } => "SecondaryPosIs", | |
| Self::DictionaryItemIsAny { .. } => "DictionaryItemIsAny", | |
| Self::DictionaryItemIsNone { .. } => "DictionaryItemIsNone", | |
| Self::HasAnySuffixSurface => "HasAnySuffixSurface", | |
| Self::HasTail => "HasTail", | |
| Self::HasNoTail => "HasNoTail", | |
| Self::HasTailSequence { .. } => "HasTailSequence", | |
| Self::ContainsMorphemeSequence { .. } => "ContainsMorphemeSequence", | |
| Self::CurrentMorphemeIs { .. } => "CurrentMorphemeIs", | |
| Self::PreviousMorphemeIs { .. } => "PreviousMorphemeIs", | |
| Self::PreviousStateIs { .. } => "PreviousStateIs", | |
| Self::PreviousStateIsNot { .. } => "PreviousStateIsNot", | |
| Self::RootSurfaceIs { .. } => "RootSurfaceIs", | |
| Self::RootSurfaceIsAny { .. } => "RootSurfaceIsAny", | |
| Self::CurrentStateIs { .. } => "CurrentStateIs", | |
| Self::CurrentStateIsNot { .. } => "CurrentStateIsNot", | |
| Self::LastDerivationIs { .. } => "LastDerivationIs", | |
| Self::HasDerivation => "HasDerivation", | |
| Self::LastDerivationIsAny { .. } => "LastDerivationIsAny", | |
| Self::CurrentGroupContainsAny { .. } => "CurrentGroupContainsAny", | |
| Self::PreviousGroupContains { .. } => "PreviousGroupContains", | |
| Self::PreviousGroupContainsMorpheme { .. } => "PreviousGroupContainsMorpheme", | |
| Self::NoSurfaceAfterDerivation => "NoSurfaceAfterDerivation", | |
| Self::ContainsMorpheme { .. } => "ContainsMorpheme", | |
| Self::PreviousMorphemeIsAny { .. } => "PreviousMorphemeIsAny", | |
| Self::CurrentMorphemeIsAny { .. } => "CurrentMorphemeIsAny", | |
| Self::PreviousStateIsAny { .. } => "PreviousStateIsAny", | |
| } | |
| } | |
| } | |
| fn zemberek_condition_count(condition: Option<&Condition>) -> usize { | |
| match condition { | |
| None => 0, | |
| Some(Condition::And { children } | Condition::Or { children }) => children | |
| .iter() | |
| .map(|child| match child { | |
| Condition::And { .. } | Condition::Or { .. } => { | |
| zemberek_condition_count(Some(child)) | |
| } | |
| _ => 1, | |
| }) | |
| .sum(), | |
| Some(_) => 1, | |
| } | |
| } | |
| fn reconstruct_template(tokens: &[TemplateToken]) -> Result<String, BundleError> { | |
| let mut output = String::new(); | |
| for token in tokens { | |
| match token.token_type { | |
| TemplateTokenType::IVowel => { | |
| require_empty_letter(token)?; | |
| output.push_str(if token.append { "+I" } else { "I" }); | |
| } | |
| TemplateTokenType::AVowel => { | |
| require_empty_letter(token)?; | |
| output.push_str(if token.append { "+A" } else { "A" }); | |
| } | |
| TemplateTokenType::Append => { | |
| require_not_append_flag(token)?; | |
| output.push('+'); | |
| output.push(single_letter(token)?); | |
| } | |
| TemplateTokenType::DevoiceFirst => { | |
| require_not_append_flag(token)?; | |
| output.push('>'); | |
| output.push(single_letter(token)?); | |
| } | |
| TemplateTokenType::LastVoiced => { | |
| require_not_append_flag(token)?; | |
| output.push('~'); | |
| output.push(single_letter(token)?); | |
| } | |
| TemplateTokenType::LastNotVoiced => { | |
| require_not_append_flag(token)?; | |
| output.push('!'); | |
| output.push(single_letter(token)?); | |
| } | |
| TemplateTokenType::Letter => { | |
| require_not_append_flag(token)?; | |
| output.push(single_letter(token)?); | |
| } | |
| } | |
| } | |
| Ok(output) | |
| } | |
| fn single_letter(token: &TemplateToken) -> Result<char, BundleError> { | |
| let mut characters = token.letter.chars(); | |
| let first = characters | |
| .next() | |
| .ok_or_else(|| invalid("template token requires one letter"))?; | |
| if characters.next().is_some() { | |
| return Err(invalid("template token contains multiple letters")); | |
| } | |
| Ok(first) | |
| } | |
| fn require_empty_letter(token: &TemplateToken) -> Result<(), BundleError> { | |
| if token.letter.is_empty() { | |
| Ok(()) | |
| } else { | |
| Err(invalid("vowel template token contains a literal letter")) | |
| } | |
| } | |
| fn require_not_append_flag(token: &TemplateToken) -> Result<(), BundleError> { | |
| if token.append { | |
| Err(invalid("non-vowel template token has append=true")) | |
| } else { | |
| Ok(()) | |
| } | |
| } | |
| fn read_json<T: DeserializeOwned>(path: &Path) -> Result<T, BundleError> { | |
| let file = File::open(path).map_err(|error| io_error(path, &error))?; | |
| serde_json::from_reader(BufReader::new(file)).map_err(|error| BundleError::Json { | |
| path: path.to_path_buf(), | |
| line: error.line(), | |
| message: error.to_string(), | |
| }) | |
| } | |
| fn read_jsonl<T: DeserializeOwned>(path: &Path) -> Result<Vec<T>, BundleError> { | |
| let file = File::open(path).map_err(|error| io_error(path, &error))?; | |
| let mut records = Vec::new(); | |
| for (line_index, line) in BufReader::new(file).lines().enumerate() { | |
| let line_number = line_index + 1; | |
| let line = line.map_err(|error| io_error(path, &error))?; | |
| if line.is_empty() { | |
| return Err(BundleError::Json { | |
| path: path.to_path_buf(), | |
| line: line_number, | |
| message: "empty JSONL line".to_owned(), | |
| }); | |
| } | |
| let record = serde_json::from_str(&line).map_err(|error| BundleError::Json { | |
| path: path.to_path_buf(), | |
| line: line_number, | |
| message: error.to_string(), | |
| })?; | |
| records.push(record); | |
| } | |
| Ok(records) | |
| } | |
| fn validate_sorted_unique<'a, T, F>( | |
| records: &'a [T], | |
| key: F, | |
| label: &str, | |
| ) -> Result<HashSet<&'a str>, BundleError> | |
| where | |
| F: Fn(&'a T) -> &'a str, | |
| { | |
| let mut result = HashSet::with_capacity(records.len()); | |
| let mut previous = None; | |
| for record in records { | |
| let value = key(record); | |
| if previous.is_some_and(|prior| prior >= value) { | |
| return Err(invalid(format!( | |
| "{label} is not strictly sorted at {value}" | |
| ))); | |
| } | |
| previous = Some(value); | |
| if !result.insert(value) { | |
| return Err(invalid(format!("duplicate {label}: {value}"))); | |
| } | |
| } | |
| Ok(result) | |
| } | |
| fn validate_sequential_edge_ids(edges: &[EdgeRecord]) -> Result<(), BundleError> { | |
| for (index, edge) in edges.iter().enumerate() { | |
| let expected = format!("e{index:06}"); | |
| if edge.id != expected { | |
| return Err(invalid(format!( | |
| "edge id {} != expected {expected}", | |
| edge.id | |
| ))); | |
| } | |
| } | |
| Ok(()) | |
| } | |
| fn validate_nullable_dictionary_set<T>( | |
| values: &[Option<String>], | |
| dictionary: &HashMap<&str, T>, | |
| ) -> Result<(), BundleError> { | |
| let mut previous: Option<&str> = None; | |
| let mut saw_null = false; | |
| for value in values { | |
| if let Some(item) = value { | |
| if saw_null { | |
| return Err(invalid( | |
| "condition dictionary null sentinel must be the final element", | |
| )); | |
| } | |
| if previous.is_some_and(|prior| prior >= item.as_str()) { | |
| return Err(invalid( | |
| "condition dictionary item set is not strictly sorted", | |
| )); | |
| } | |
| require_map_reference(dictionary, item, "condition dictionary item")?; | |
| previous = Some(item); | |
| } else { | |
| if saw_null { | |
| return Err(invalid( | |
| "condition dictionary item set contains multiple null sentinels", | |
| )); | |
| } | |
| saw_null = true; | |
| } | |
| } | |
| Ok(()) | |
| } | |
| fn validate_sorted_unique_strings(values: &[String], label: &str) -> Result<(), BundleError> { | |
| for pair in values.windows(2) { | |
| if pair[0] >= pair[1] { | |
| return Err(invalid(format!("{label} is not strictly sorted"))); | |
| } | |
| } | |
| Ok(()) | |
| } | |
| fn validate_unique(values: &[String], label: &str) -> Result<(), BundleError> { | |
| let mut seen = HashSet::with_capacity(values.len()); | |
| for value in values { | |
| if !seen.insert(value) { | |
| return Err(invalid(format!("duplicate {label}: {value}"))); | |
| } | |
| } | |
| Ok(()) | |
| } | |
| fn validate_sorted_known( | |
| values: &[String], | |
| known: &[&str], | |
| label: &str, | |
| ) -> Result<(), BundleError> { | |
| validate_sorted_unique_strings(values, label)?; | |
| for value in values { | |
| require_known(known, value, label)?; | |
| } | |
| Ok(()) | |
| } | |
| fn require_known(known: &[&str], value: &str, label: &str) -> Result<(), BundleError> { | |
| if known.contains(&value) { | |
| Ok(()) | |
| } else { | |
| Err(invalid(format!("unknown {label}: {value}"))) | |
| } | |
| } | |
| fn require_reference(known: &HashSet<&str>, value: &str, label: &str) -> Result<(), BundleError> { | |
| if known.contains(value) { | |
| Ok(()) | |
| } else { | |
| Err(invalid(format!("missing {label}: {value}"))) | |
| } | |
| } | |
| fn require_map_reference<T>( | |
| known: &HashMap<&str, T>, | |
| value: &str, | |
| label: &str, | |
| ) -> Result<(), BundleError> { | |
| if known.contains_key(value) { | |
| Ok(()) | |
| } else { | |
| Err(invalid(format!("missing {label}: {value}"))) | |
| } | |
| } | |
| fn require_state<'a>( | |
| states: &'a HashMap<&str, &StateRecord>, | |
| key: &str, | |
| label: &str, | |
| ) -> Result<&'a StateRecord, BundleError> { | |
| states | |
| .get(key) | |
| .copied() | |
| .ok_or_else(|| invalid(format!("missing {label}: {key}"))) | |
| } | |
| fn duplicate_excess<'a>(values: impl Iterator<Item = &'a str>) -> Result<usize, BundleError> { | |
| let mut counts: HashMap<&str, usize> = HashMap::new(); | |
| for value in values { | |
| let count = counts.entry(value).or_insert(0); | |
| *count = count | |
| .checked_add(1) | |
| .ok_or_else(|| invalid("duplicate count overflow"))?; | |
| } | |
| counts.values().try_fold(0_usize, |total, count| { | |
| total | |
| .checked_add(count.saturating_sub(1)) | |
| .ok_or_else(|| invalid("duplicate excess overflow")) | |
| }) | |
| } | |
| fn increment( | |
| counts: &mut BTreeMap<&'static str, usize>, | |
| kind: &'static str, | |
| ) -> Result<(), BundleError> { | |
| let count = counts.entry(kind).or_insert(0); | |
| *count = count | |
| .checked_add(1) | |
| .ok_or_else(|| invalid("condition count overflow"))?; | |
| Ok(()) | |
| } | |
| fn known_condition_manifest_kind(value: &str) -> Option<&'static str> { | |
| CONDITION_MANIFEST_KINDS | |
| .iter() | |
| .copied() | |
| .find(|known| *known == value) | |
| } | |
| fn phonetic_ordinal(value: &str) -> Option<u32> { | |
| PHONETIC_ATTRIBUTES | |
| .iter() | |
| .position(|known| *known == value) | |
| .and_then(|index| u32::try_from(index).ok()) | |
| } | |
| fn io_error(path: &Path, error: &std::io::Error) -> BundleError { | |
| BundleError::Io { | |
| path: path.to_path_buf(), | |
| message: error.to_string(), | |
| } | |
| } | |
| fn invalid(message: impl Into<String>) -> BundleError { | |
| BundleError::Invalid { | |
| message: message.into(), | |
| } | |
| } | |
| /// Bundle load or semantic validation error. | |
| pub enum BundleError { | |
| /// File I/O failed. | |
| Io { path: PathBuf, message: String }, | |
| /// JSON parsing or strict schema validation failed. | |
| Json { | |
| path: PathBuf, | |
| line: usize, | |
| message: String, | |
| }, | |
| /// Parsed data violated a bundle invariant. | |
| Invalid { message: String }, | |
| } | |
| impl fmt::Display for BundleError { | |
| fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| write!(formatter, "{self:?}") | |
| } | |
| } | |
| impl std::error::Error for BundleError {} | |
| const ROOT_ATTRIBUTES: &[&str] = &[ | |
| "Aorist_I", | |
| "Aorist_A", | |
| "ProgressiveVowelDrop", | |
| "Passive_In", | |
| "Causative_t", | |
| "Voicing", | |
| "NoVoicing", | |
| "InverseHarmony", | |
| "Doubling", | |
| "LastVowelDrop", | |
| "CompoundP3sg", | |
| "NoSuffix", | |
| "NounConsInsert_n", | |
| "NoQuote", | |
| "CompoundP3sgRoot", | |
| "Reflexive", | |
| "Reciprocal", | |
| "NonReciprocal", | |
| "Ext", | |
| "Runtime", | |
| "Dummy", | |
| "ImplicitDative", | |
| "ImplicitPlural", | |
| "ImplicitP1sg", | |
| "ImplicitP2sg", | |
| "FamilyMember", | |
| "PronunciationGuessed", | |
| "Informal", | |
| "LocaleEn", | |
| ]; | |
| const PHONETIC_ATTRIBUTES: &[&str] = &[ | |
| "LastLetterVowel", | |
| "LastLetterConsonant", | |
| "LastVowelFrontal", | |
| "LastVowelBack", | |
| "LastVowelRounded", | |
| "LastVowelUnrounded", | |
| "LastLetterVoiceless", | |
| "LastLetterVoiced", | |
| "LastLetterVoicelessStop", | |
| "FirstLetterVowel", | |
| "FirstLetterConsonant", | |
| "HasNoVowel", | |
| "ExpectsVowel", | |
| "ExpectsConsonant", | |
| "ModifiedPronoun", | |
| "UnModifiedPronoun", | |
| "LastLetterDropped", | |
| "CannotTerminate", | |
| ]; | |
| const PRIMARY_POS_SHORT: &[&str] = &[ | |
| "Noun", "Adj", "Adv", "Conj", "Interj", "Verb", "Pron", "Num", "Det", "Postp", "Ques", "Dup", | |
| "Punc", "Unk", | |
| ]; | |
| const PRIMARY_POS_NAMES: &[&str] = &[ | |
| "Noun", | |
| "Adjective", | |
| "Adverb", | |
| "Conjunction", | |
| "Interjection", | |
| "Verb", | |
| "Pronoun", | |
| "Numeral", | |
| "Determiner", | |
| "PostPositive", | |
| "Question", | |
| "Duplicator", | |
| "Punctuation", | |
| "Unknown", | |
| ]; | |
| const SECONDARY_POS_SHORT: &[&str] = &[ | |
| "Unk", | |
| "Demons", | |
| "Time", | |
| "Quant", | |
| "Ques", | |
| "Prop", | |
| "Pers", | |
| "Reflex", | |
| "None", | |
| "Ord", | |
| "Card", | |
| "Percent", | |
| "Ratio", | |
| "Range", | |
| "Real", | |
| "Dist", | |
| "Clock", | |
| "Date", | |
| "Email", | |
| "Url", | |
| "Mention", | |
| "HashTag", | |
| "Emoticon", | |
| "RomanNumeral", | |
| "RegAbbrv", | |
| "Abbrv", | |
| "PCDat", | |
| "PCAcc", | |
| "PCIns", | |
| "PCNom", | |
| "PCGen", | |
| "PCAbl", | |
| ]; | |
| const SECONDARY_POS_NAMES: &[&str] = &[ | |
| "UnknownSec", | |
| "DemonstrativePron", | |
| "Time", | |
| "QuantitivePron", | |
| "QuestionPron", | |
| "ProperNoun", | |
| "PersonalPron", | |
| "ReflexivePron", | |
| "None", | |
| "Ordinal", | |
| "Cardinal", | |
| "Percentage", | |
| "Ratio", | |
| "Range", | |
| "Real", | |
| "Distribution", | |
| "Clock", | |
| "Date", | |
| "Email", | |
| "Url", | |
| "Mention", | |
| "HashTag", | |
| "Emoticon", | |
| "RomanNumeral", | |
| "RegularAbbreviation", | |
| "Abbreviation", | |
| "PCDat", | |
| "PCAcc", | |
| "PCIns", | |
| "PCNom", | |
| "PCGen", | |
| "PCAbl", | |
| ]; | |
| const CONDITION_MANIFEST_KINDS: &[&str] = &[ | |
| "CombinedCondition", | |
| "ContainsMorpheme", | |
| "ContainsMorphemeSequence", | |
| "CurrentGroupContainsAny", | |
| "CurrentMorphemeIs", | |
| "CurrentMorphemeIsAny", | |
| "CurrentStateIs", | |
| "CurrentStateIsNot", | |
| "DictionaryItemIs", | |
| "DictionaryItemIsAny", | |
| "DictionaryItemIsNone", | |
| "HasAnyRootAttribute", | |
| "HasAnySuffixSurface", | |
| "HasDerivation", | |
| "HasNoTail", | |
| "HasPhoneticAttribute", | |
| "HasRootAttribute", | |
| "HasTail", | |
| "HasTailSequence", | |
| "LastDerivationIs", | |
| "LastDerivationIsAny", | |
| "NoSurfaceAfterDerivation", | |
| "NotCondition", | |
| "PreviousGroupContains", | |
| "PreviousGroupContainsMorpheme", | |
| "PreviousMorphemeIs", | |
| "PreviousMorphemeIsAny", | |
| "PreviousStateIs", | |
| "PreviousStateIsAny", | |
| "PreviousStateIsNot", | |
| "RootPrimaryPosIs", | |
| "RootSurfaceIs", | |
| "RootSurfaceIsAny", | |
| "SecondaryPosIs", | |
| ]; | |
| mod tests { | |
| use std::collections::HashMap; | |
| use super::{ | |
| reconstruct_template, validate_nullable_dictionary_set, zemberek_condition_count, | |
| Condition, TemplateToken, TemplateTokenType, | |
| }; | |
| fn reconstructs_suffix_template_tokens() -> Result<(), super::BundleError> { | |
| let tokens = vec![ | |
| TemplateToken { | |
| token_type: TemplateTokenType::Append, | |
| letter: "y".to_owned(), | |
| append: false, | |
| }, | |
| TemplateToken { | |
| token_type: TemplateTokenType::DevoiceFirst, | |
| letter: "d".to_owned(), | |
| append: false, | |
| }, | |
| TemplateToken { | |
| token_type: TemplateTokenType::IVowel, | |
| letter: String::new(), | |
| append: false, | |
| }, | |
| ]; | |
| let reconstructed = reconstruct_template(&tokens)?; | |
| assert_eq!(reconstructed, "+y>dI"); | |
| Ok(()) | |
| } | |
| fn accepts_one_trailing_dictionary_null_sentinel() -> Result<(), serde_json::Error> { | |
| let condition: Condition = | |
| serde_json::from_str(r#"{"kind":"DictionaryItemIsAny","items":["a_Noun",null]}"#)?; | |
| assert!(matches!( | |
| condition, | |
| Condition::DictionaryItemIsAny { ref items } | |
| if items == &vec![Some("a_Noun".to_owned()), None] | |
| )); | |
| Ok(()) | |
| } | |
| fn rejects_non_trailing_or_duplicate_null_sentinels() { | |
| let dictionary = HashMap::from([("a_Noun", 1_u8)]); | |
| let non_trailing = vec![None, Some("a_Noun".to_owned())]; | |
| let duplicate = vec![Some("a_Noun".to_owned()), None, None]; | |
| assert!(validate_nullable_dictionary_set(&non_trailing, &dictionary).is_err()); | |
| assert!(validate_nullable_dictionary_set(&duplicate, &dictionary).is_err()); | |
| } | |
| fn rejects_unknown_condition_kind() { | |
| let value = r#"{"kind":"UnknownCondition"}"#; | |
| assert!(serde_json::from_str::<Condition>(value).is_err()); | |
| } | |
| fn matches_zemberek_top_level_condition_count_semantics() -> Result<(), serde_json::Error> { | |
| let condition: Condition = serde_json::from_str( | |
| r#"{"kind":"NOT","child":{"kind":"AND","children":[{"kind":"HasTail"},{"kind":"HasNoTail"}]}}"#, | |
| )?; | |
| assert_eq!(zemberek_condition_count(Some(&condition)), 1); | |
| let combined: Condition = serde_json::from_str( | |
| r#"{"kind":"AND","children":[{"kind":"HasTail"},{"kind":"OR","children":[{"kind":"HasNoTail"},{"kind":"HasDerivation"}]}]}"#, | |
| )?; | |
| assert_eq!(zemberek_condition_count(Some(&combined)), 3); | |
| Ok(()) | |
| } | |
| } | |