duallity 0.2.0

Levenshtein automata as lling-llang WFSTs: composition adapters bridging liblevenshtein fuzzy matching and the lling-llang weighted-transducer framework.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
//! StateSource implementation for Phonetic WFST composition.
//!
//! This module provides [`PhoneticStateSource`], which implements lling-llang's
//! [`StateSource`] trait for phonetic fuzzy matching. It composes a phonetic
//! NFA with a Levenshtein automaton for combined phonetic + edit distance matching.
//!
//! # Architecture
//!
//! The phonetic state source computes product states:
//! - **NFA states**: Active states in the phonetic NFA (after epsilon closure)
//! - **Edit distance**: Current edit distance consumed
//! - **Dictionary node**: Position in the dictionary trie
//!
//! The product state space is encoded into a single `StateId` for WFST composition.

use std::sync::Arc;

use lling_llang::prelude::{
    LazyState, Semiring, StateId, StateSource, TropicalWeight, WeightedTransition,
};
use rustc_hash::FxHashMap;
use smallvec::SmallVec;

#[cfg(feature = "phonetic-rules")]
use liblevenshtein::phonetic::nfa::{NFAChar, ProductAutomatonChar, ProductStateChar};
use libdictenstein::{Dictionary, DictionaryNode};

use crate::state_encoding;

/// State source for phonetic WFST composition.
///
/// This implements lling-llang's [`StateSource`] trait using the phonetic
/// product automaton (NFA × Levenshtein). States are computed on-demand as
/// the composed transducer is traversed.
///
/// # Product State Representation
///
/// Each WFST state represents a tuple:
/// - `dictionary_node_id`: Position in the dictionary trie
/// - `product_state_id`: ID of the phonetic product state (NFA × Levenshtein)
///
/// # Example
///
/// ```rust,ignore
/// use liblevenshtein::wfst::PhoneticStateSource;
/// use liblevenshtein::dictionary::dynamic_dawg_char::DynamicDawgChar;
/// use liblevenshtein::phonetic::nfa::compile;
/// use liblevenshtein::phonetic::regex::parse;
/// use lling_llang::prelude::*;
///
/// let dict = DynamicDawgChar::from_terms(vec!["phone", "fone", "phon"]);
/// let nfa = compile(&parse("(ph|f)one").unwrap()).unwrap();
/// let source = PhoneticStateSource::new(&dict, nfa, 2);
///
/// // Use with LazyWfstWrapper for composition
/// let lazy_wfst = LazyWfstWrapper::new(source);
/// ```
#[cfg(feature = "phonetic-rules")]
#[derive(Clone)]
pub struct PhoneticStateSource<D>
where
    D: Dictionary + Clone + Send + Sync,
    D::Node: Send + Sync,
    <D::Node as DictionaryNode>::Unit: Into<char> + TryFrom<char> + Copy + Send + Sync,
{
    /// The dictionary to search
    dictionary: D,
    /// Product automaton (NFA × Levenshtein)
    product: Arc<ProductAutomatonChar>,
    /// Maximum edit distance
    max_distance: u8,
    /// Phonetic weight (cost for phonetic transformations)
    phonetic_weight: f64,
    /// Maximum product states for encoding
    max_product_states: u32,
    /// Node registry: maps path hash to node ID
    node_registry: Arc<std::sync::RwLock<NodeRegistry<D::Node>>>,
    /// Product state registry: maps product state to ID
    product_state_registry: Arc<std::sync::RwLock<ProductStateRegistry>>,
}

/// Registry for assigning stable IDs to dictionary nodes.
struct NodeRegistry<N: DictionaryNode> {
    /// Map from path hash to assigned ID
    node_to_id: FxHashMap<u64, u32>,
    /// Map from ID back to node
    id_to_node: Vec<N>,
}

impl<N: DictionaryNode> NodeRegistry<N> {
    fn new(root: N) -> Self {
        let mut registry = Self {
            node_to_id: FxHashMap::default(),
            id_to_node: Vec::new(),
        };
        registry.register_node(root, 0);
        registry
    }

    fn register_node(&mut self, node: N, path_hash: u64) -> u32 {
        if let Some(&id) = self.node_to_id.get(&path_hash) {
            return id;
        }

        let id = self.id_to_node.len() as u32;
        self.node_to_id.insert(path_hash, id);
        self.id_to_node.push(node);
        id
    }

    fn get_node(&self, id: u32) -> Option<&N> {
        self.id_to_node.get(id as usize)
    }
}

/// Registry for assigning stable IDs to product states.
#[cfg(feature = "phonetic-rules")]
struct ProductStateRegistry {
    /// Map from product state to assigned ID
    state_to_id: FxHashMap<ProductStateKey, u32>,
    /// Map from ID back to state
    id_to_state: Vec<ProductStateChar>,
}

/// Key for hashing ProductStateChar.
#[cfg(feature = "phonetic-rules")]
#[derive(Clone, PartialEq, Eq, Hash)]
struct ProductStateKey(Vec<u8>);

#[cfg(feature = "phonetic-rules")]
impl ProductStateRegistry {
    fn new(initial_state: ProductStateChar) -> Self {
        let mut registry = Self {
            state_to_id: FxHashMap::default(),
            id_to_state: Vec::new(),
        };
        registry.register_state(initial_state);
        registry
    }

    fn register_state(&mut self, state: ProductStateChar) -> u32 {
        let key = Self::state_to_key(&state);
        if let Some(&id) = self.state_to_id.get(&key) {
            return id;
        }

        let id = self.id_to_state.len() as u32;
        self.state_to_id.insert(key, id);
        self.id_to_state.push(state);
        id
    }

    fn get_state(&self, id: u32) -> Option<&ProductStateChar> {
        self.id_to_state.get(id as usize)
    }

    fn state_to_key(state: &ProductStateChar) -> ProductStateKey {
        let mut bytes = Vec::with_capacity(state.nfa_states.len() * 4 + 1);
        for &s in &state.nfa_states {
            bytes.extend_from_slice(&s.to_le_bytes());
        }
        bytes.push(state.edit_distance());
        ProductStateKey(bytes)
    }

    fn len(&self) -> usize {
        self.id_to_state.len()
    }
}

#[cfg(feature = "phonetic-rules")]
impl<D> PhoneticStateSource<D>
where
    D: Dictionary + Clone + Send + Sync,
    D::Node: Send + Sync,
    <D::Node as DictionaryNode>::Unit: Into<char> + TryFrom<char> + Copy + Send + Sync,
{
    /// Create a new phonetic state source.
    ///
    /// # Arguments
    ///
    /// - `dictionary`: The dictionary to search
    /// - `nfa`: The phonetic NFA pattern
    /// - `max_distance`: Maximum edit distance for matches
    pub fn new(dictionary: &D, nfa: NFAChar, max_distance: u8) -> Self {
        Self::with_phonetic_weight(dictionary, nfa, max_distance, 0.0)
    }

    /// Create a new phonetic state source with custom phonetic weight.
    ///
    /// # Arguments
    ///
    /// - `dictionary`: The dictionary to search
    /// - `nfa`: The phonetic NFA pattern
    /// - `max_distance`: Maximum edit distance for matches
    /// - `phonetic_weight`: Cost for phonetic transformations
    pub fn with_phonetic_weight(
        dictionary: &D,
        nfa: NFAChar,
        max_distance: u8,
        phonetic_weight: f64,
    ) -> Self {
        let product =
            ProductAutomatonChar::with_phonetic_weight(nfa, max_distance, phonetic_weight);
        let initial_state = product.initial_state();

        // Estimate max product states based on NFA size and max distance
        let max_product_states = ((max_distance as u32 + 1) * 1000).max(10_000);

        let root = dictionary.root();
        let node_registry = NodeRegistry::new(root);
        let product_state_registry = ProductStateRegistry::new(initial_state);

        Self {
            dictionary: dictionary.clone(),
            product: Arc::new(product),
            max_distance,
            phonetic_weight,
            max_product_states,
            node_registry: Arc::new(std::sync::RwLock::new(node_registry)),
            product_state_registry: Arc::new(std::sync::RwLock::new(product_state_registry)),
        }
    }

    /// Get the maximum edit distance.
    pub fn max_distance(&self) -> u8 {
        self.max_distance
    }

    /// Get the phonetic weight.
    pub fn phonetic_weight(&self) -> f64 {
        self.phonetic_weight
    }

    /// Compute transitions for a product state.
    fn compute_transitions(
        &self,
        dict_node_id: u32,
        product_state_id: u32,
    ) -> (
        bool,
        TropicalWeight,
        SmallVec<[WeightedTransition<char, TropicalWeight>; 4]>,
    ) {
        let node_registry = self.node_registry.read().expect("Lock poisoned");
        let product_registry = self.product_state_registry.read().expect("Lock poisoned");

        let dict_node = match node_registry.get_node(dict_node_id) {
            Some(node) => node.clone(),
            None => {
                return (false, TropicalWeight::zero(), SmallVec::new());
            }
        };

        let product_state = match product_registry.get_state(product_state_id) {
            Some(state) => state.clone(),
            None => {
                return (false, TropicalWeight::zero(), SmallVec::new());
            }
        };

        drop(node_registry);
        drop(product_registry);

        let mut transitions = SmallVec::new();

        // For each dictionary edge, compute product transitions
        for (unit, child_node) in dict_node.edges() {
            let dict_char: char = unit.into();

            // Register the child node
            let child_node_id = {
                let mut registry = self.node_registry.write().expect("Lock poisoned");
                let path_hash = compute_path_hash(dict_node_id, dict_char);
                registry.register_node(child_node.clone(), path_hash)
            };

            // Compute product automaton successors for this character
            let successors = self.product.transition(&product_state, dict_char);

            for successor in successors {
                // Register the successor product state
                let successor_id = {
                    let mut registry = self.product_state_registry.write().expect("Lock poisoned");
                    registry.register_state(successor.clone())
                };

                // Compute transition cost
                let cost = if successor.edit_distance() > product_state.edit_distance() {
                    // Edit operation was used
                    1.0
                } else {
                    // Exact match (may include phonetic transformation)
                    self.phonetic_weight
                };

                let from_state =
                    state_encoding::encode(dict_node_id, product_state_id, self.max_product_states);

                let target_state =
                    state_encoding::encode(child_node_id, successor_id, self.max_product_states);

                transitions.push(WeightedTransition::new(
                    from_state,
                    Some(dict_char),
                    Some(dict_char),
                    target_state,
                    TropicalWeight::new(cost),
                ));
            }
        }

        // Check if this is a final state
        let is_final = dict_node.is_final() && self.product.is_accepting(&product_state);
        let final_weight = if is_final {
            // Final weight is the edit distance consumed
            TropicalWeight::new(product_state.edit_distance() as f64)
        } else {
            TropicalWeight::zero()
        };

        (is_final, final_weight, transitions)
    }
}

#[cfg(feature = "phonetic-rules")]
impl<D> StateSource<char, TropicalWeight> for PhoneticStateSource<D>
where
    D: Dictionary + Clone + Send + Sync,
    D::Node: Send + Sync,
    <D::Node as DictionaryNode>::Unit: Into<char> + TryFrom<char> + Copy + Send + Sync,
{
    fn compute_state(&self, state: StateId) -> LazyState<char, TropicalWeight> {
        let (dict_node_id, product_state_id) =
            state_encoding::decode(state, self.max_product_states);

        let (is_final, final_weight, transitions) =
            self.compute_transitions(dict_node_id, product_state_id);

        if is_final {
            LazyState::final_state(final_weight, transitions)
        } else {
            LazyState::non_final(transitions)
        }
    }

    fn start(&self) -> StateId {
        // Start state is (root_node=0, initial_product_state=0)
        state_encoding::encode(0, 0, self.max_product_states)
    }

    fn num_states_hint(&self) -> Option<usize> {
        let dict_size = self.dictionary.len().unwrap_or(1000);
        let product_registry = self.product_state_registry.read().expect("Lock poisoned");
        let product_states = product_registry.len();
        Some((dict_size * product_states).min(1_000_000))
    }
}

/// Compute a path hash for node registration.
#[inline]
fn compute_path_hash(parent_id: u32, edge_label: char) -> u64 {
    use std::hash::{Hash, Hasher};
    let mut hasher = rustc_hash::FxHasher::default();
    parent_id.hash(&mut hasher);
    edge_label.hash(&mut hasher);
    hasher.finish()
}

#[cfg(test)]
#[cfg(feature = "phonetic-rules")]
mod tests {
    use super::*;
    use liblevenshtein::phonetic::nfa::compiler::compile;
    use liblevenshtein::phonetic::regex::parse;
    use libdictenstein::dynamic_dawg::char::DynamicDawgChar;

    #[test]
    fn test_phonetic_state_source_creation() {
        let dict = DynamicDawgChar::<()>::from_terms(vec!["phone", "fone", "help"]);
        let nfa = compile(&parse("(ph|f)one").expect("parse")).expect("compile");
        let source = PhoneticStateSource::new(&dict, nfa, 2);

        assert_eq!(source.max_distance(), 2);
        assert_eq!(source.phonetic_weight(), 0.0);
    }

    #[test]
    fn test_phonetic_state_source_start_state() {
        let dict = DynamicDawgChar::<()>::from_terms(vec!["test"]);
        let nfa = compile(&parse("test").expect("parse")).expect("compile");
        let source = PhoneticStateSource::new(&dict, nfa, 1);

        let start = source.start();
        let (dict_node, product_state) = state_encoding::decode(start, source.max_product_states);
        assert_eq!(dict_node, 0);
        assert_eq!(product_state, 0);
    }

    #[test]
    fn test_phonetic_state_source_with_weight() {
        let dict = DynamicDawgChar::<()>::from_terms(vec!["phone"]);
        let nfa = compile(&parse("(ph|f)one").expect("parse")).expect("compile");
        let source = PhoneticStateSource::with_phonetic_weight(&dict, nfa, 2, 0.5);

        assert_eq!(source.phonetic_weight(), 0.5);
    }

    #[test]
    fn test_phonetic_state_source_compute_state() {
        let dict = DynamicDawgChar::<()>::from_terms(vec!["phone", "help"]);
        let nfa = compile(&parse("phone").expect("parse")).expect("compile");
        let source = PhoneticStateSource::new(&dict, nfa, 1);

        let start = source.start();
        let state = source.compute_state(start);

        // Start state should have transitions
        assert!(state.is_computed());
    }

    #[test]
    fn test_phonetic_state_source_num_states_hint() {
        let dict = DynamicDawgChar::<()>::from_terms(vec!["test", "rest", "best"]);
        let nfa = compile(&parse("test").expect("parse")).expect("compile");
        let source = PhoneticStateSource::new(&dict, nfa, 1);

        let hint = source.num_states_hint();
        assert!(hint.is_some());
        assert!(hint.expect("expected Some hint in test") > 0);
    }
}