Skip to main content

duallity/
universal_state_source.rs

1//! StateSource implementation for Universal Levenshtein WFST.
2//!
3//! This module provides [`UniversalLevenshteinStateSource`], which implements
4//! lling-llang's [`StateSource`] trait using the Universal Levenshtein Automaton.
5//!
6//! # Key Differences from Parameterized Automaton
7//!
8//! The Universal Automaton is query-agnostic and can be precomputed once for a
9//! given maximum edit distance. When bound to a query via [`BoundUniversalWfst`],
10//! it encodes the dictionary terms as bit vectors and processes them through
11//! the automaton.
12
13use std::sync::Arc;
14
15use lling_llang::prelude::{
16    LazyState, Semiring, StateId, StateSource, TropicalWeight, WeightedTransition,
17};
18use rustc_hash::FxHashMap;
19use smallvec::SmallVec;
20
21use liblevenshtein::transducer::universal::{
22    CharacteristicVector, PositionVariant, UniversalAutomaton, UniversalState,
23};
24use libdictenstein::{Dictionary, DictionaryNode};
25
26use crate::state_encoding;
27
28/// State registry for deduplicating Universal Levenshtein states.
29///
30/// Since `UniversalState<V>` is a complex set of positions, we assign each
31/// unique state a sequential ID for efficient WFST state encoding.
32pub struct UniversalStateRegistry<V: PositionVariant> {
33    /// Map from state to assigned ID
34    state_to_id: FxHashMap<UniversalStateKey, u32>,
35    /// Map from ID back to state
36    id_to_state: Vec<UniversalState<V>>,
37}
38
39/// Key for hashing UniversalState.
40#[derive(Clone, PartialEq, Eq, Hash)]
41struct UniversalStateKey(Vec<u8>);
42
43impl<V: PositionVariant> UniversalStateRegistry<V> {
44    /// Create a new registry with the initial state.
45    pub fn new(max_distance: u8) -> Self {
46        let mut registry = Self {
47            state_to_id: FxHashMap::default(),
48            id_to_state: Vec::new(),
49        };
50
51        // Register initial state as ID 0
52        let initial = UniversalState::initial(max_distance);
53        registry.register_state(initial);
54
55        registry
56    }
57
58    /// Register a state and return its ID.
59    pub fn register_state(&mut self, state: UniversalState<V>) -> u32 {
60        let key = self.state_to_key(&state);
61        if let Some(&id) = self.state_to_id.get(&key) {
62            return id;
63        }
64
65        let id = self.id_to_state.len() as u32;
66        self.state_to_id.insert(key, id);
67        self.id_to_state.push(state);
68        id
69    }
70
71    /// Get a state by ID.
72    pub fn get_state(&self, id: u32) -> Option<&UniversalState<V>> {
73        self.id_to_state.get(id as usize)
74    }
75
76    /// Convert a state to a hashable key.
77    fn state_to_key(&self, state: &UniversalState<V>) -> UniversalStateKey {
78        // Serialize positions to bytes for hashing
79        let mut bytes = Vec::new();
80        for pos in state.positions() {
81            // Encode each position as type + offset + errors
82            let (pos_type, offset, errors) = if pos.is_i_type() {
83                (0u8, pos.offset(), pos.errors())
84            } else {
85                (1u8, pos.offset(), pos.errors())
86            };
87            bytes.push(pos_type);
88            bytes.extend_from_slice(&(offset as i16).to_le_bytes());
89            bytes.push(errors);
90        }
91        UniversalStateKey(bytes)
92    }
93
94    /// Number of registered states.
95    pub fn len(&self) -> usize {
96        self.id_to_state.len()
97    }
98
99    /// Check if registry is empty.
100    pub fn is_empty(&self) -> bool {
101        self.id_to_state.is_empty()
102    }
103}
104
105/// State source for Universal Levenshtein WFST computation.
106///
107/// This implements lling-llang's [`StateSource`] trait using the Universal
108/// Levenshtein Automaton. States are computed on-demand as the WFST is traversed.
109///
110/// # Product State Representation
111///
112/// Each WFST state represents a pair `(dictionary_node_id, automaton_state_id)`:
113/// - `dictionary_node_id`: Position in the dictionary trie
114/// - `automaton_state_id`: ID of the universal automaton state in the registry
115#[derive(Clone)]
116pub struct UniversalLevenshteinStateSource<V, D>
117where
118    V: PositionVariant + Clone + Send + Sync,
119    V::State: Send + Sync,
120    D: Dictionary + Clone + Send + Sync,
121    D::Node: Send + Sync,
122    <D::Node as DictionaryNode>::Unit: Into<char> + TryFrom<char> + Copy + Send + Sync,
123{
124    /// The dictionary to search
125    dictionary: D,
126    /// Query as characters (for computing bit vectors)
127    query_chars: Arc<Vec<char>>,
128    /// Universal automaton
129    automaton: UniversalAutomaton<V>,
130    /// State registry for deduplication
131    state_registry: Arc<std::sync::RwLock<UniversalStateRegistry<V>>>,
132    /// Node registry for dictionary nodes
133    node_registry: Arc<std::sync::RwLock<NodeRegistry<D::Node>>>,
134    /// Maximum automaton states for encoding
135    max_automaton_states: u32,
136}
137
138/// Registry for assigning stable IDs to dictionary nodes.
139struct NodeRegistry<N: DictionaryNode> {
140    /// Map from path hash to node ID
141    node_to_id: FxHashMap<u64, u32>,
142    /// Map from ID back to node
143    id_to_node: Vec<N>,
144}
145
146impl<N: DictionaryNode> NodeRegistry<N> {
147    fn new(root: N) -> Self {
148        let mut registry = Self {
149            node_to_id: FxHashMap::default(),
150            id_to_node: Vec::new(),
151        };
152        // Register root as ID 0
153        registry.register_node(root, 0);
154        registry
155    }
156
157    fn register_node(&mut self, node: N, path_hash: u64) -> u32 {
158        if let Some(&id) = self.node_to_id.get(&path_hash) {
159            return id;
160        }
161
162        let id = self.id_to_node.len() as u32;
163        self.node_to_id.insert(path_hash, id);
164        self.id_to_node.push(node);
165        id
166    }
167
168    fn get_node(&self, id: u32) -> Option<&N> {
169        self.id_to_node.get(id as usize)
170    }
171}
172
173impl<V, D> UniversalLevenshteinStateSource<V, D>
174where
175    V: PositionVariant + Clone + Send + Sync,
176    V::State: Send + Sync,
177    D: Dictionary + Clone + Send + Sync,
178    D::Node: Send + Sync,
179    <D::Node as DictionaryNode>::Unit: Into<char> + TryFrom<char> + Copy + Send + Sync,
180{
181    /// Create a new state source for the given dictionary and query.
182    ///
183    /// # Arguments
184    ///
185    /// - `dictionary`: The dictionary to search
186    /// - `query`: The query string to find corrections for
187    /// - `max_distance`: Maximum edit distance for matches
188    pub fn new(dictionary: &D, query: &str, max_distance: u8) -> Self {
189        let query_chars: Vec<char> = query.chars().collect();
190        let max_automaton_states =
191            state_encoding::estimate_automaton_states(query_chars.len(), max_distance as usize);
192
193        let root = dictionary.root();
194        let node_registry = NodeRegistry::new(root);
195        let state_registry = UniversalStateRegistry::new(max_distance);
196
197        Self {
198            dictionary: dictionary.clone(),
199            query_chars: Arc::new(query_chars),
200            automaton: UniversalAutomaton::new(max_distance),
201            state_registry: Arc::new(std::sync::RwLock::new(state_registry)),
202            node_registry: Arc::new(std::sync::RwLock::new(node_registry)),
203            max_automaton_states,
204        }
205    }
206
207    /// Get the query string.
208    pub fn query(&self) -> String {
209        self.query_chars.iter().collect()
210    }
211
212    /// Compute the relevant subword for a dictionary term at a given position.
213    ///
214    /// From thesis page 51: s_n(w, i) = w_{i-n}...w_v where v = min(|w|, i + n + 1)
215    fn relevant_subword(&self, word: &[char], position: usize) -> String {
216        let n = self.automaton.max_distance() as i32;
217        let i = position as i32;
218
219        let start = i - n;
220        let v = std::cmp::min(word.len() as i32, i + n + 1);
221
222        let mut result = String::new();
223        for pos in start..=v {
224            if pos < 1 {
225                result.push('$');
226            } else if pos <= word.len() as i32 {
227                let idx = (pos - 1) as usize;
228                result.push(word[idx]);
229            }
230        }
231        result
232    }
233
234    /// Compute transitions for a product state.
235    fn compute_transitions(
236        &self,
237        dict_node_id: u32,
238        automaton_state_id: u32,
239    ) -> (
240        bool,
241        TropicalWeight,
242        SmallVec<[WeightedTransition<char, TropicalWeight>; 4]>,
243    ) {
244        let node_registry = self.node_registry.read().expect("Lock poisoned");
245        let state_registry = self.state_registry.read().expect("Lock poisoned");
246
247        let dict_node = match node_registry.get_node(dict_node_id) {
248            Some(node) => node.clone(),
249            None => {
250                return (false, TropicalWeight::zero(), SmallVec::new());
251            }
252        };
253
254        let automaton_state = match state_registry.get_state(automaton_state_id) {
255            Some(state) => state.clone(),
256            None => {
257                return (false, TropicalWeight::zero(), SmallVec::new());
258            }
259        };
260
261        drop(node_registry);
262        drop(state_registry);
263
264        let mut transitions = SmallVec::new();
265        let query_len = self.query_chars.len();
266
267        // For each dictionary edge, compute the transition
268        for (unit, child_node) in dict_node.edges() {
269            let dict_char: char = unit.into();
270
271            // Register the child node
272            let child_node_id = {
273                let mut registry = self.node_registry.write().expect("Lock poisoned");
274                let path_hash = compute_path_hash(dict_node_id, dict_char);
275                registry.register_node(child_node.clone(), path_hash)
276            };
277
278            // For Universal Automaton, we need to compute the bit vector
279            // based on the current position in the query
280            // The automaton state tracks the query position implicitly
281
282            // Compute the current query position from the automaton state
283            // For simplicity, we track position based on transitions made
284            let current_pos = self.estimate_query_position(&automaton_state);
285
286            if current_pos <= query_len {
287                // Compute characteristic vector for this character
288                let subword = self.relevant_subword(&self.query_chars, current_pos);
289                let bit_vector = CharacteristicVector::new(dict_char, &subword);
290
291                // Compute next automaton state
292                if let Some(next_auto_state) = automaton_state.transition(&bit_vector, current_pos)
293                {
294                    // Register the new automaton state
295                    let next_auto_id = {
296                        let mut registry = self.state_registry.write().expect("Lock poisoned");
297                        registry.register_state(next_auto_state.clone())
298                    };
299
300                    // Compute the edit cost based on match
301                    let cost = if current_pos > 0
302                        && current_pos <= query_len
303                        && self.query_chars[current_pos - 1] == dict_char
304                    {
305                        0.0 // Match
306                    } else {
307                        1.0 // Edit operation
308                    };
309
310                    let from_state = state_encoding::encode(
311                        dict_node_id,
312                        automaton_state_id,
313                        self.max_automaton_states,
314                    );
315
316                    let target_state = state_encoding::encode(
317                        child_node_id,
318                        next_auto_id,
319                        self.max_automaton_states,
320                    );
321
322                    transitions.push(WeightedTransition::new(
323                        from_state,
324                        Some(dict_char),
325                        Some(dict_char),
326                        target_state,
327                        TropicalWeight::new(cost),
328                    ));
329                }
330            }
331        }
332
333        // Check if this is a final state
334        let is_final = dict_node.is_final() && automaton_state.is_final();
335        let final_weight = if is_final {
336            // Compute minimum errors from accepting positions
337            let min_errors = automaton_state
338                .positions()
339                .filter(|p| p.is_m_type() && p.offset() <= 0)
340                .map(|p| p.errors())
341                .min()
342                .unwrap_or(self.automaton.max_distance() + 1);
343            TropicalWeight::new(min_errors as f64)
344        } else {
345            TropicalWeight::zero()
346        };
347
348        (is_final, final_weight, transitions)
349    }
350
351    /// Estimate the current query position from the automaton state.
352    ///
353    /// This is an approximation based on the positions in the state.
354    fn estimate_query_position(&self, state: &UniversalState<V>) -> usize {
355        // Find the minimum query position from I-type positions
356        state
357            .positions()
358            .filter(|p| p.is_i_type())
359            .map(|p| p.offset())
360            .min()
361            .map(|offset| {
362                // Offset represents deviation from diagonal
363                // Position = errors + offset for I-type
364                if offset >= 0 {
365                    offset as usize
366                } else {
367                    0
368                }
369            })
370            .unwrap_or(0)
371    }
372}
373
374impl<V, D> StateSource<char, TropicalWeight> for UniversalLevenshteinStateSource<V, D>
375where
376    V: PositionVariant + Clone + Send + Sync,
377    V::State: Send + Sync,
378    D: Dictionary + Clone + Send + Sync,
379    D::Node: Send + Sync,
380    <D::Node as DictionaryNode>::Unit: Into<char> + TryFrom<char> + Copy + Send + Sync,
381{
382    fn compute_state(&self, state: StateId) -> LazyState<char, TropicalWeight> {
383        let (dict_node_id, automaton_state_id) =
384            state_encoding::decode(state, self.max_automaton_states);
385
386        let (is_final, final_weight, transitions) =
387            self.compute_transitions(dict_node_id, automaton_state_id);
388
389        if is_final {
390            LazyState::final_state(final_weight, transitions)
391        } else {
392            LazyState::non_final(transitions)
393        }
394    }
395
396    fn start(&self) -> StateId {
397        // Start state is (root_node=0, initial_automaton_state=0)
398        state_encoding::encode(0, 0, self.max_automaton_states)
399    }
400
401    fn num_states_hint(&self) -> Option<usize> {
402        let dict_size = self.dictionary.len().unwrap_or(1000);
403        let state_registry = self.state_registry.read().expect("Lock poisoned");
404        let automaton_states = state_registry.len();
405        Some((dict_size * automaton_states).min(1_000_000))
406    }
407}
408
409/// Compute a path hash for node registration.
410#[inline]
411fn compute_path_hash(parent_id: u32, edge_label: char) -> u64 {
412    use std::hash::{Hash, Hasher};
413    let mut hasher = rustc_hash::FxHasher::default();
414    parent_id.hash(&mut hasher);
415    edge_label.hash(&mut hasher);
416    hasher.finish()
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use liblevenshtein::transducer::universal::Standard;
423    use libdictenstein::dynamic_dawg::char::DynamicDawgChar;
424
425    #[test]
426    fn test_universal_state_registry_creation() {
427        let registry = UniversalStateRegistry::<Standard>::new(2);
428        assert_eq!(registry.len(), 1); // Initial state
429    }
430
431    #[test]
432    fn test_universal_state_registry_register() {
433        let mut registry = UniversalStateRegistry::<Standard>::new(2);
434        let state = UniversalState::initial(2);
435        let id = registry.register_state(state.clone());
436        assert_eq!(id, 0); // Should be same as initial
437
438        // Same state should get same ID
439        let id2 = registry.register_state(state);
440        assert_eq!(id, id2);
441    }
442
443    #[test]
444    fn test_universal_state_source_creation() {
445        let dict = DynamicDawgChar::<()>::from_terms(vec!["hello", "help", "world"]);
446        let source = UniversalLevenshteinStateSource::<Standard, _>::new(&dict, "helo", 2);
447
448        assert_eq!(source.start(), 0);
449        assert!(source.num_states_hint().is_some());
450    }
451
452    #[test]
453    fn test_universal_state_source_query() {
454        let dict = DynamicDawgChar::<()>::from_terms(vec!["hello"]);
455        let source = UniversalLevenshteinStateSource::<Standard, _>::new(&dict, "helo", 2);
456
457        assert_eq!(source.query(), "helo");
458    }
459}