//! Strict loader and semantic validator for Nedo morphology reference bundles. #![forbid(unsafe_code)] 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. #[derive(Debug)] pub struct MorphBundle { manifest: BundleManifest, morphemes: Vec, dictionary: Vec, stems: Vec, states: Vec, edges: Vec, } 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 { 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. #[must_use] 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. #[must_use] 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. #[derive(Clone, Copy, Debug, Eq, PartialEq)] 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. #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] 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, } impl BundleManifest { fn condition_count_map(&self) -> Result, 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) } } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct ConditionKindCount { kind: String, count: usize, } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct MorphemeRecord { id: String, name: String, pos: Option, derivational: bool, informal: bool, mapped_id: Option, } 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(()) } } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct DictionaryRecord { id: String, lemma: String, root: String, pronunciation: String, primary_pos: String, secondary_pos: String, attributes: Vec, reference_id: Option, 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(()) } } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct StemRecord { surface: String, source_order: usize, dictionary_id: String, target_state: String, target_zemberek_id: String, phonetic_bits: i32, phonetic_attributes: Vec, } 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(()) } } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] 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, } 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(()) } } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] 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, condition_count: usize, condition: Option, } 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(()) } } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct TemplateToken { #[serde(rename = "type")] token_type: TemplateTokenType, letter: String, append: bool, } #[derive(Clone, Copy, Debug, Deserialize)] enum TemplateTokenType { #[serde(rename = "I_WOVEL")] IVowel, #[serde(rename = "A_WOVEL")] AVowel, #[serde(rename = "DEVOICE_FIRST")] DevoiceFirst, #[serde(rename = "LAST_VOICED")] LastVoiced, #[serde(rename = "LAST_NOT_VOICED")] LastNotVoiced, #[serde(rename = "APPEND")] Append, #[serde(rename = "LETTER")] Letter, } #[derive(Debug, Deserialize)] #[serde(tag = "kind", deny_unknown_fields)] enum Condition { #[serde(rename = "AND")] And { children: Vec, }, #[serde(rename = "OR")] Or { children: Vec, }, #[serde(rename = "NOT")] Not { child: Box, }, HasRootAttribute { attribute: String, }, HasAnyRootAttribute { attributes: Vec, }, HasPhoneticAttribute { attribute: String, }, DictionaryItemIs { item: String, }, RootPrimaryPosIs { pos: String, }, SecondaryPosIs { pos: String, }, DictionaryItemIsAny { items: Vec>, }, DictionaryItemIsNone { items: Vec>, }, HasAnySuffixSurface, HasTail, HasNoTail, HasTailSequence { morphemes: Vec, }, ContainsMorphemeSequence { morphemes: Vec, }, CurrentMorphemeIs { morpheme: String, }, PreviousMorphemeIs { morpheme: String, }, PreviousStateIs { state: String, }, PreviousStateIsNot { state: String, }, RootSurfaceIs { surface: String, }, RootSurfaceIsAny { surfaces: Vec, }, CurrentStateIs { state: String, }, CurrentStateIsNot { state: String, }, LastDerivationIs { state: String, }, HasDerivation, LastDerivationIsAny { states: Vec, }, CurrentGroupContainsAny { states: Vec, }, PreviousGroupContains { states: Vec, }, PreviousGroupContainsMorpheme { morphemes: Vec, }, NoSurfaceAfterDerivation, ContainsMorpheme { morphemes: Vec, }, PreviousMorphemeIsAny { morphemes: Vec, }, CurrentMorphemeIsAny { morphemes: Vec, }, PreviousStateIsAny { states: Vec, }, } 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 { 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 { 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(path: &Path) -> Result { 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(path: &Path) -> Result, 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, 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( values: &[Option], 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( 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) -> Result { 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 { 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) -> BundleError { BundleError::Invalid { message: message.into(), } } /// Bundle load or semantic validation error. #[derive(Debug)] 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", ]; #[cfg(test)] mod tests { use std::collections::HashMap; use super::{ reconstruct_template, validate_nullable_dictionary_set, zemberek_condition_count, Condition, TemplateToken, TemplateTokenType, }; #[test] 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(()) } #[test] 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(()) } #[test] 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()); } #[test] fn rejects_unknown_condition_kind() { let value = r#"{"kind":"UnknownCondition"}"#; assert!(serde_json::from_str::(value).is_err()); } #[test] 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(()) } }