Skip to main content

moine_core/
lib.rs

1//! Language-independent Lattice Path Edit Distance core.
2//!
3//! `moine-core` provides the [`Lattice`] DAG representation and exact edit
4//! distance algorithms used by the language adapters. It intentionally has no
5//! Japanese, Chinese, Unicode-normalization, dictionary, CLI, or artifact
6//! loading logic.
7//!
8//! Use [`try_distance`], [`try_distance_with_cutoff`],
9//! [`try_damerau_distance`], [`try_damerau_distance_with_cutoff`],
10//! [`try_distance_with_trace`], [`try_within_distance`], and
11//! [`try_within_damerau_distance`] when lattices come from external input. The
12//! infallible lattice convenience functions keep examples short, but panic if
13//! the configured matrix limits would be exceeded. Trace reconstruction stores
14//! more per cell than the plain distance path, so it uses the lower
15//! [`MAX_TRACE_MATRIX_CELLS`] limit.
16//!
17//! ```
18//! use moine_core::{distance, try_distance, Lattice};
19//!
20//! let left = Lattice::from_paths(["moine"]);
21//! let right = Lattice::from_paths(["moinya"]);
22//!
23//! assert_eq!(distance(&left, &right), 2);
24//! assert_eq!(try_distance(&left, &right).unwrap(), 2);
25//! ```
26//!
27#![deny(missing_docs)]
28
29use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
30use std::error::Error;
31use std::fmt;
32
33pub mod dot;
34
35/// Integer symbol stored on lattice arcs.
36///
37/// String constructors encode each Unicode scalar value as one `Symbol`.
38pub type Symbol = u32;
39
40/// Directed arc between two lattice nodes.
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42pub struct Arc {
43    /// Source node index.
44    pub src: usize,
45    /// Destination node index.
46    pub dst: usize,
47    /// Symbol consumed when traversing the arc.
48    pub symbol: Symbol,
49}
50
51impl Arc {
52    /// Creates an arc from `src` to `dst` carrying `symbol`.
53    pub fn new(src: usize, dst: usize, symbol: Symbol) -> Self {
54        Self { src, dst, symbol }
55    }
56}
57
58/// A directed acyclic lattice with one start node and one end node.
59///
60/// Nodes are addressed by zero-based indices. Arcs must move from lower to
61/// higher node indices so distance algorithms can process the lattice in
62/// topological order.
63#[derive(Clone, Debug, Eq, PartialEq)]
64pub struct Lattice {
65    node_count: usize,
66    start: usize,
67    end: usize,
68    arcs: Vec<Arc>,
69    incoming: Vec<Vec<usize>>,
70    outgoing: Vec<Vec<usize>>,
71}
72
73/// Errors returned when constructing an invalid lattice.
74#[derive(Clone, Debug, Eq, PartialEq)]
75pub enum LatticeError {
76    /// The lattice has no nodes.
77    Empty,
78    /// Empty paths were mixed with non-empty paths.
79    MixedEmptyAndNonEmptyPaths,
80    /// An arc endpoint is outside the node range.
81    InvalidNode {
82        /// Invalid node index.
83        node: usize,
84        /// Number of nodes in the lattice.
85        node_count: usize,
86    },
87    /// An arc does not respect topological node order.
88    InvalidArcOrder {
89        /// Source node index.
90        src: usize,
91        /// Destination node index.
92        dst: usize,
93    },
94    /// Start or end node indices are outside the valid range.
95    InvalidEndpoint {
96        /// Start node index.
97        start: usize,
98        /// End node index.
99        end: usize,
100    },
101}
102
103impl fmt::Display for LatticeError {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        match self {
106            Self::Empty => write!(f, "lattice must contain at least one node"),
107            Self::MixedEmptyAndNonEmptyPaths => write!(
108                f,
109                "mixed empty and non-empty paths need epsilon arcs, which moine-core does not model yet"
110            ),
111            Self::InvalidNode { node, node_count } => {
112                write!(f, "node {node} is outside 0..{node_count}")
113            }
114            Self::InvalidArcOrder { src, dst } => {
115                write!(f, "arc {src}->{dst} violates topological node order")
116            }
117            Self::InvalidEndpoint { start, end } => {
118                write!(f, "invalid endpoints start={start}, end={end}")
119            }
120        }
121    }
122}
123
124impl Error for LatticeError {}
125
126impl Lattice {
127    /// Builds a lattice from explicit nodes and arcs.
128    ///
129    /// `start` and `end` must be valid node indices with `start <= end`.
130    /// Every arc must reference valid nodes and satisfy `src < dst`.
131    pub fn from_edges(
132        node_count: usize,
133        start: usize,
134        end: usize,
135        arcs: Vec<Arc>,
136    ) -> Result<Self, LatticeError> {
137        if node_count == 0 {
138            return Err(LatticeError::Empty);
139        }
140        if start >= node_count || end >= node_count || start > end {
141            return Err(LatticeError::InvalidEndpoint { start, end });
142        }
143
144        let mut incoming = vec![Vec::new(); node_count];
145        let mut outgoing = vec![Vec::new(); node_count];
146        for (idx, arc) in arcs.iter().enumerate() {
147            if arc.src >= node_count {
148                return Err(LatticeError::InvalidNode {
149                    node: arc.src,
150                    node_count,
151                });
152            }
153            if arc.dst >= node_count {
154                return Err(LatticeError::InvalidNode {
155                    node: arc.dst,
156                    node_count,
157                });
158            }
159            if arc.src >= arc.dst {
160                return Err(LatticeError::InvalidArcOrder {
161                    src: arc.src,
162                    dst: arc.dst,
163                });
164            }
165            incoming[arc.dst].push(idx);
166            outgoing[arc.src].push(idx);
167        }
168
169        Ok(Self {
170            node_count,
171            start,
172            end,
173            arcs,
174            incoming,
175            outgoing,
176        })
177    }
178
179    /// Builds a lattice from UTF-8 string paths.
180    ///
181    /// # Panics
182    ///
183    /// Panics when `paths` is empty or when empty and non-empty paths are
184    /// mixed. Use [`Lattice::try_from_paths`] to handle those cases as input
185    /// errors.
186    pub fn from_paths<I, S>(paths: I) -> Self
187    where
188        I: IntoIterator<Item = S>,
189        S: AsRef<str>,
190    {
191        Self::try_from_paths(paths).expect("valid string path lattice")
192    }
193
194    /// Builds a lattice from UTF-8 string paths.
195    pub fn try_from_paths<I, S>(paths: I) -> Result<Self, LatticeError>
196    where
197        I: IntoIterator<Item = S>,
198        S: AsRef<str>,
199    {
200        let symbol_paths = paths.into_iter().map(|path| {
201            path.as_ref()
202                .chars()
203                .map(|ch| ch as Symbol)
204                .collect::<Vec<_>>()
205        });
206        Self::try_from_symbol_paths(symbol_paths)
207    }
208
209    /// Builds a lattice from symbol paths.
210    ///
211    /// # Panics
212    ///
213    /// Panics when `paths` is empty or when empty and non-empty paths are
214    /// mixed. Use [`Lattice::try_from_symbol_paths`] to handle those cases as
215    /// input errors.
216    pub fn from_symbol_paths<I, P>(paths: I) -> Self
217    where
218        I: IntoIterator<Item = P>,
219        P: IntoIterator<Item = Symbol>,
220    {
221        Self::try_from_symbol_paths(paths).expect("valid symbol path lattice")
222    }
223
224    /// Builds a lattice from symbol paths.
225    pub fn try_from_symbol_paths<I, P>(paths: I) -> Result<Self, LatticeError>
226    where
227        I: IntoIterator<Item = P>,
228        P: IntoIterator<Item = Symbol>,
229    {
230        let paths = paths
231            .into_iter()
232            .map(|path| path.into_iter().collect::<Vec<_>>())
233            .collect::<Vec<_>>();
234        if paths.is_empty() {
235            return Err(LatticeError::Empty);
236        }
237
238        if paths.len() == 1 && paths[0].is_empty() {
239            return Self::from_edges(1, 0, 0, Vec::new());
240        }
241        if !paths.iter().all(|path| !path.is_empty()) {
242            return Err(LatticeError::MixedEmptyAndNonEmptyPaths);
243        }
244
245        let start = 0;
246        let node_count = 2 + paths
247            .iter()
248            .map(|path| path.len().saturating_sub(1))
249            .sum::<usize>();
250        let end = node_count - 1;
251        let mut next_node = 1;
252        let mut arcs = Vec::new();
253
254        for path in paths {
255            let mut current = start;
256            for (idx, symbol) in path.iter().copied().enumerate() {
257                let dst = if idx + 1 == path.len() {
258                    end
259                } else {
260                    let node = next_node;
261                    next_node += 1;
262                    node
263                };
264                arcs.push(Arc::new(current, dst, symbol));
265                current = dst;
266            }
267        }
268
269        debug_assert_eq!(next_node, end);
270        Self::from_edges(node_count, start, end, arcs)
271    }
272
273    /// Builds a compact lattice from symbol paths by sharing common suffixes.
274    ///
275    /// # Panics
276    ///
277    /// Panics when `paths` is empty or when empty and non-empty paths are
278    /// mixed. Use [`Lattice::try_from_symbol_paths_compact`] to handle those
279    /// cases as input errors.
280    pub fn from_symbol_paths_compact<I, P>(paths: I) -> Self
281    where
282        I: IntoIterator<Item = P>,
283        P: IntoIterator<Item = Symbol>,
284    {
285        Self::try_from_symbol_paths_compact(paths).expect("valid compact symbol path lattice")
286    }
287
288    /// Builds a compact lattice from symbol paths by sharing common suffixes.
289    pub fn try_from_symbol_paths_compact<I, P>(paths: I) -> Result<Self, LatticeError>
290    where
291        I: IntoIterator<Item = P>,
292        P: IntoIterator<Item = Symbol>,
293    {
294        let paths = paths
295            .into_iter()
296            .map(|path| path.into_iter().collect::<Vec<_>>())
297            .collect::<Vec<_>>();
298        if paths.is_empty() {
299            return Err(LatticeError::Empty);
300        }
301
302        if paths.len() == 1 && paths[0].is_empty() {
303            return Self::from_edges(1, 0, 0, Vec::new());
304        }
305        if !paths.iter().all(|path| !path.is_empty()) {
306            return Err(LatticeError::MixedEmptyAndNonEmptyPaths);
307        }
308
309        let mut builder = CompactPathBuilder::default();
310        for path in paths {
311            builder.insert(&path);
312        }
313        Ok(builder.into_lattice())
314    }
315
316    /// Returns the number of nodes in the lattice.
317    pub fn node_count(&self) -> usize {
318        self.node_count
319    }
320
321    /// Returns the start node index.
322    pub fn start(&self) -> usize {
323        self.start
324    }
325
326    /// Returns the end node index.
327    pub fn end(&self) -> usize {
328        self.end
329    }
330
331    /// Returns all arcs in insertion order.
332    pub fn arcs(&self) -> &[Arc] {
333        &self.arcs
334    }
335
336    /// Returns incoming arcs for `node`, or `None` when the node index is out
337    /// of range.
338    pub fn try_incoming_arcs(&self, node: usize) -> Option<impl Iterator<Item = &Arc>> {
339        self.incoming
340            .get(node)
341            .map(|indices| indices.iter().map(|&idx| &self.arcs[idx]))
342    }
343
344    /// Returns incoming arcs for `node`.
345    ///
346    /// # Panics
347    ///
348    /// Panics when `node >= self.node_count()`.
349    pub fn incoming_arcs(&self, node: usize) -> impl Iterator<Item = &Arc> {
350        self.try_incoming_arcs(node)
351            .expect("node should be inside lattice")
352    }
353
354    /// Returns outgoing arcs for `node`, or `None` when the node index is out
355    /// of range.
356    pub fn try_outgoing_arcs(&self, node: usize) -> Option<impl Iterator<Item = &Arc>> {
357        self.outgoing
358            .get(node)
359            .map(|indices| indices.iter().map(|&idx| &self.arcs[idx]))
360    }
361
362    /// Returns outgoing arcs for `node`.
363    ///
364    /// # Panics
365    ///
366    /// Panics when `node >= self.node_count()`.
367    pub fn outgoing_arcs(&self, node: usize) -> impl Iterator<Item = &Arc> {
368        self.try_outgoing_arcs(node)
369            .expect("node should be inside lattice")
370    }
371}
372
373#[derive(Clone, Debug, Default)]
374struct TrieNode {
375    children: BTreeMap<Symbol, usize>,
376    terminal_symbols: BTreeSet<Symbol>,
377}
378
379#[derive(Clone, Debug, Default)]
380struct CompactPathBuilder {
381    nodes: Vec<TrieNode>,
382}
383
384#[derive(Clone, Debug, Eq, Hash, PartialEq)]
385struct CompactSignature {
386    terminal_symbols: Vec<Symbol>,
387    children: Vec<(Symbol, usize)>,
388}
389
390#[derive(Clone, Debug)]
391struct CompactNode {
392    terminal_symbols: Vec<Symbol>,
393    children: Vec<(Symbol, usize)>,
394}
395
396impl CompactPathBuilder {
397    fn insert(&mut self, path: &[Symbol]) {
398        if self.nodes.is_empty() {
399            self.nodes.push(TrieNode::default());
400        }
401
402        let mut current = 0;
403        for (idx, symbol) in path.iter().copied().enumerate() {
404            if idx + 1 == path.len() {
405                self.nodes[current].terminal_symbols.insert(symbol);
406                break;
407            }
408
409            let next = if let Some(&next) = self.nodes[current].children.get(&symbol) {
410                next
411            } else {
412                let next = self.nodes.len();
413                self.nodes.push(TrieNode::default());
414                self.nodes[current].children.insert(symbol, next);
415                next
416            };
417            current = next;
418        }
419    }
420
421    fn into_lattice(self) -> Lattice {
422        let mut minimizer = CompactMinimizer {
423            trie_nodes: self.nodes,
424            memo: HashMap::new(),
425            interned: HashMap::new(),
426            compact_nodes: Vec::new(),
427        };
428        let root = minimizer.compact_trie_node(0);
429        build_compact_lattice(root, minimizer.compact_nodes)
430    }
431}
432
433struct CompactMinimizer {
434    trie_nodes: Vec<TrieNode>,
435    memo: HashMap<usize, usize>,
436    interned: HashMap<CompactSignature, usize>,
437    compact_nodes: Vec<CompactNode>,
438}
439
440impl CompactMinimizer {
441    fn compact_trie_node(&mut self, trie_id: usize) -> usize {
442        if let Some(&compact_id) = self.memo.get(&trie_id) {
443            return compact_id;
444        }
445
446        let children = self.trie_nodes[trie_id]
447            .children
448            .clone()
449            .into_iter()
450            .map(|(symbol, child)| (symbol, self.compact_trie_node(child)))
451            .collect::<Vec<_>>();
452        let terminal_symbols = self.trie_nodes[trie_id]
453            .terminal_symbols
454            .iter()
455            .copied()
456            .collect::<Vec<_>>();
457        let signature = CompactSignature {
458            terminal_symbols,
459            children,
460        };
461
462        let compact_id = if let Some(&existing) = self.interned.get(&signature) {
463            existing
464        } else {
465            let compact_id = self.compact_nodes.len();
466            self.compact_nodes.push(CompactNode {
467                terminal_symbols: signature.terminal_symbols.clone(),
468                children: signature.children.clone(),
469            });
470            self.interned.insert(signature, compact_id);
471            compact_id
472        };
473
474        self.memo.insert(trie_id, compact_id);
475        compact_id
476    }
477}
478
479fn build_compact_lattice(root: usize, compact_nodes: Vec<CompactNode>) -> Lattice {
480    let mut reachable = Vec::new();
481    collect_reachable_compact_nodes(root, &compact_nodes, &mut BTreeSet::new(), &mut reachable);
482
483    let mut heights = HashMap::new();
484    for &node in &reachable {
485        compact_height(node, &compact_nodes, &mut heights);
486    }
487
488    reachable.sort_by_key(|node| {
489        (
490            usize::from(*node != root),
491            std::cmp::Reverse(*heights.get(node).expect("height should be known")),
492            *node,
493        )
494    });
495
496    let node_ids = reachable
497        .iter()
498        .enumerate()
499        .map(|(node_id, &compact_id)| (compact_id, node_id))
500        .collect::<HashMap<_, _>>();
501    let end = reachable.len();
502    let mut arcs = Vec::new();
503
504    for &compact_id in &reachable {
505        let src = node_ids[&compact_id];
506        let node = &compact_nodes[compact_id];
507        for &symbol in &node.terminal_symbols {
508            arcs.push(Arc::new(src, end, symbol));
509        }
510        for &(symbol, child) in &node.children {
511            arcs.push(Arc::new(src, node_ids[&child], symbol));
512        }
513    }
514
515    Lattice::from_edges(end + 1, 0, end, arcs).expect("valid compact path lattice")
516}
517
518fn collect_reachable_compact_nodes(
519    node: usize,
520    compact_nodes: &[CompactNode],
521    seen: &mut BTreeSet<usize>,
522    output: &mut Vec<usize>,
523) {
524    if !seen.insert(node) {
525        return;
526    }
527    output.push(node);
528    for &(_, child) in &compact_nodes[node].children {
529        collect_reachable_compact_nodes(child, compact_nodes, seen, output);
530    }
531}
532
533fn compact_height(
534    node: usize,
535    compact_nodes: &[CompactNode],
536    memo: &mut HashMap<usize, usize>,
537) -> usize {
538    if let Some(&height) = memo.get(&node) {
539        return height;
540    }
541
542    let child_height = compact_nodes[node]
543        .children
544        .iter()
545        .map(|&(_, child)| compact_height(child, compact_nodes, memo) + 1)
546        .max()
547        .unwrap_or(0);
548    let terminal_height = usize::from(!compact_nodes[node].terminal_symbols.is_empty());
549    let height = child_height.max(terminal_height);
550    memo.insert(node, height);
551    height
552}
553
554/// Edit operation represented in a trace step.
555#[derive(Clone, Copy, Debug, Eq, PartialEq)]
556pub enum EditOp {
557    /// The left and right symbols matched exactly.
558    Match,
559    /// A left symbol was substituted for a right symbol.
560    Substitute,
561    /// A left symbol was deleted.
562    Delete,
563    /// A right symbol was inserted.
564    Insert,
565}
566
567/// One operation in a reconstructed edit-distance trace.
568#[derive(Clone, Debug, Eq, PartialEq)]
569pub struct TraceStep {
570    /// Operation selected for this step.
571    pub op: EditOp,
572    /// Symbol consumed from the left lattice, if any.
573    pub left: Option<Symbol>,
574    /// Symbol consumed from the right lattice, if any.
575    pub right: Option<Symbol>,
576}
577
578/// Edit distance plus one best sequence of edit operations.
579#[derive(Clone, Debug, Eq, PartialEq)]
580pub struct DistanceTrace {
581    /// Final edit distance.
582    pub distance: usize,
583    /// Reconstructed steps from start to end.
584    pub steps: Vec<TraceStep>,
585}
586
587impl DistanceTrace {
588    /// Returns the left-side symbols consumed by the trace.
589    pub fn left_symbols(&self) -> Vec<Symbol> {
590        self.steps.iter().filter_map(|step| step.left).collect()
591    }
592
593    /// Returns the right-side symbols consumed by the trace.
594    pub fn right_symbols(&self) -> Vec<Symbol> {
595        self.steps.iter().filter_map(|step| step.right).collect()
596    }
597}
598
599/// Maximum DP matrix size accepted by non-trace distance functions.
600pub const MAX_DISTANCE_MATRIX_CELLS: usize = 16_000_000;
601/// Maximum DP matrix size accepted by trace reconstruction.
602pub const MAX_TRACE_MATRIX_CELLS: usize = 2_000_000;
603
604/// Errors returned by fallible distance functions.
605#[derive(Clone, Debug, Eq, PartialEq)]
606pub enum DistanceError {
607    /// The requested dynamic-programming matrix exceeds the configured limit.
608    MatrixTooLarge {
609        /// Number of matrix rows.
610        rows: usize,
611        /// Number of matrix columns.
612        cols: usize,
613        /// Maximum allowed cell count.
614        max_cells: usize,
615    },
616}
617
618impl fmt::Display for DistanceError {
619    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
620        match self {
621            Self::MatrixTooLarge {
622                rows,
623                cols,
624                max_cells,
625            } => write!(
626                f,
627                "distance matrix {rows}x{cols} exceeds the maximum of {max_cells} cells"
628            ),
629        }
630    }
631}
632
633impl Error for DistanceError {}
634
635#[derive(Clone, Debug, Eq, PartialEq)]
636struct Backpointer {
637    prev_i: usize,
638    prev_j: usize,
639    step: TraceStep,
640}
641
642const INF: usize = usize::MAX / 4;
643
644/// Reusable buffers for repeated lattice distance computations.
645///
646/// The plain convenience functions allocate temporary buffers for each call.
647/// Use this workspace when scoring many lattice pairs, such as inside a
648/// candidate matrix, to reuse the DP and threshold-search allocations while
649/// preserving the same exact results.
650#[derive(Debug, Default)]
651pub struct DistanceWorkspace {
652    dp: Vec<usize>,
653    queued: Vec<bool>,
654    queue: VecDeque<(usize, usize)>,
655}
656
657impl DistanceWorkspace {
658    /// Creates an empty distance workspace.
659    pub fn new() -> Self {
660        Self::default()
661    }
662
663    /// Computes lattice edit distance using this workspace.
664    pub fn try_distance(
665        &mut self,
666        left: &Lattice,
667        right: &Lattice,
668    ) -> Result<usize, DistanceError> {
669        let rows = left.node_count();
670        let cols = right.node_count();
671        let cells = checked_distance_matrix_cells(rows, cols)?;
672        Ok(distance_impl(left, right, cells, self))
673    }
674
675    /// Computes lattice edit distance up to `threshold` using this workspace.
676    pub fn try_distance_with_cutoff(
677        &mut self,
678        left: &Lattice,
679        right: &Lattice,
680        threshold: usize,
681    ) -> Result<Option<usize>, DistanceError> {
682        let rows = left.node_count();
683        let cols = right.node_count();
684        let cells = checked_distance_matrix_cells(rows, cols)?;
685        Ok(cutoff_distance_impl(left, right, threshold, cells, self))
686    }
687
688    /// Computes lattice Damerau-Levenshtein distance using this workspace.
689    pub fn try_damerau_distance(
690        &mut self,
691        left: &Lattice,
692        right: &Lattice,
693    ) -> Result<usize, DistanceError> {
694        let rows = left.node_count();
695        let cols = right.node_count();
696        let cells = checked_distance_matrix_cells(rows, cols)?;
697        Ok(damerau_distance_impl(left, right, cells, self))
698    }
699
700    /// Computes lattice Damerau-Levenshtein distance up to `threshold` using
701    /// this workspace.
702    pub fn try_damerau_distance_with_cutoff(
703        &mut self,
704        left: &Lattice,
705        right: &Lattice,
706        threshold: usize,
707    ) -> Result<Option<usize>, DistanceError> {
708        let rows = left.node_count();
709        let cols = right.node_count();
710        let cells = checked_distance_matrix_cells(rows, cols)?;
711        Ok(cutoff_damerau_distance_impl(
712            left, right, threshold, cells, self,
713        ))
714    }
715
716    fn reset_dp(&mut self, cells: usize) {
717        self.dp.clear();
718        self.dp.resize(cells, INF);
719    }
720
721    fn reset_threshold_search(&mut self, cells: usize) {
722        self.reset_dp(cells);
723        self.queued.clear();
724        self.queued.resize(cells, false);
725        self.queue.clear();
726    }
727}
728
729/// Reusable buffers for plain Unicode-scalar string distance computations.
730///
731/// The `*_str` helpers tokenize inputs and allocate temporary DP rows on each
732/// call. Use this workspace with pre-tokenized `char` slices when scoring many
733/// plain string pairs, such as a candidate matrix.
734#[derive(Debug, Default)]
735pub struct StringDistanceWorkspace {
736    previous2: Vec<usize>,
737    previous: Vec<usize>,
738    current: Vec<usize>,
739}
740
741impl StringDistanceWorkspace {
742    /// Creates an empty plain-string distance workspace.
743    pub fn new() -> Self {
744        Self::default()
745    }
746
747    /// Computes Levenshtein distance over Unicode scalar value slices.
748    pub fn levenshtein(&mut self, left: &[char], right: &[char]) -> usize {
749        let (left, right) = trim_common_affixes(left, right);
750        levenshtein_chars_impl(left, right, self)
751    }
752
753    /// Computes Levenshtein distance up to `threshold`.
754    ///
755    /// Returns `Some(distance)` when the exact distance is at most
756    /// `threshold`, and `None` when the distance is greater than `threshold`.
757    pub fn levenshtein_with_cutoff(
758        &mut self,
759        left: &[char],
760        right: &[char],
761        threshold: usize,
762    ) -> Option<usize> {
763        let (left, right) = trim_common_affixes(left, right);
764        levenshtein_chars_with_cutoff_impl(left, right, threshold, self)
765    }
766
767    /// Computes optimal string alignment Damerau-Levenshtein distance over
768    /// Unicode scalar value slices.
769    pub fn damerau_levenshtein(&mut self, left: &[char], right: &[char]) -> usize {
770        damerau_levenshtein_chars_impl(left, right, self)
771    }
772
773    /// Computes optimal string alignment Damerau-Levenshtein distance up to
774    /// `threshold`.
775    ///
776    /// Returns `Some(distance)` when the exact distance is at most
777    /// `threshold`, and `None` when the distance is greater than `threshold`.
778    pub fn damerau_levenshtein_with_cutoff(
779        &mut self,
780        left: &[char],
781        right: &[char],
782        threshold: usize,
783    ) -> Option<usize> {
784        damerau_levenshtein_chars_with_cutoff_impl(left, right, threshold, self)
785    }
786}
787
788/// Computes lattice edit distance.
789///
790/// # Panics
791///
792/// Panics when the DP matrix would exceed [`MAX_DISTANCE_MATRIX_CELLS`].
793/// Use [`try_distance`] for external or otherwise untrusted lattices.
794pub fn distance(left: &Lattice, right: &Lattice) -> usize {
795    try_distance(left, right).expect("distance matrix should fit")
796}
797
798/// Computes lattice edit distance with explicit matrix-size validation.
799pub fn try_distance(left: &Lattice, right: &Lattice) -> Result<usize, DistanceError> {
800    DistanceWorkspace::new().try_distance(left, right)
801}
802
803/// Computes lattice edit distance up to `threshold`.
804///
805/// Returns `Ok(Some(distance))` when the exact distance is at most
806/// `threshold`, and `Ok(None)` when the distance is greater than `threshold`.
807/// This is useful for cutoff-style scoring because callers can avoid a separate
808/// [`try_within_distance`] pass followed by a full [`try_distance`] pass.
809pub fn try_distance_with_cutoff(
810    left: &Lattice,
811    right: &Lattice,
812    threshold: usize,
813) -> Result<Option<usize>, DistanceError> {
814    DistanceWorkspace::new().try_distance_with_cutoff(left, right, threshold)
815}
816
817fn distance_impl(
818    left: &Lattice,
819    right: &Lattice,
820    cells: usize,
821    workspace: &mut DistanceWorkspace,
822) -> usize {
823    let cols = right.node_count();
824    workspace.reset_dp(cells);
825    let dp = workspace.dp.as_mut_slice();
826    dp[distance_index(left.start(), right.start(), cols)] = 0;
827
828    for i in left.start()..=left.end() {
829        for j in right.start()..=right.end() {
830            if i == left.start() && j == right.start() {
831                continue;
832            }
833
834            let mut best = dp[distance_index(i, j, cols)];
835
836            for left_arc in left.incoming_arcs(i) {
837                for right_arc in right.incoming_arcs(j) {
838                    let cost = usize::from(left_arc.symbol != right_arc.symbol);
839                    let candidate =
840                        dp[distance_index(left_arc.src, right_arc.src, cols)].saturating_add(cost);
841                    best = best.min(candidate);
842                }
843            }
844
845            for left_arc in left.incoming_arcs(i) {
846                let candidate = dp[distance_index(left_arc.src, j, cols)].saturating_add(1);
847                best = best.min(candidate);
848            }
849
850            for right_arc in right.incoming_arcs(j) {
851                let candidate = dp[distance_index(i, right_arc.src, cols)].saturating_add(1);
852                best = best.min(candidate);
853            }
854
855            dp[distance_index(i, j, cols)] = best;
856        }
857    }
858
859    dp[distance_index(left.end(), right.end(), cols)]
860}
861
862/// Computes lattice edit distance with adjacent transpositions.
863///
864/// # Panics
865///
866/// Panics when the DP matrix would exceed [`MAX_DISTANCE_MATRIX_CELLS`].
867/// Use [`try_damerau_distance`] for external or otherwise untrusted lattices.
868pub fn damerau_distance(left: &Lattice, right: &Lattice) -> usize {
869    try_damerau_distance(left, right).expect("distance matrix should fit")
870}
871
872/// Computes lattice edit distance with adjacent transpositions and explicit
873/// matrix-size validation.
874pub fn try_damerau_distance(left: &Lattice, right: &Lattice) -> Result<usize, DistanceError> {
875    DistanceWorkspace::new().try_damerau_distance(left, right)
876}
877
878/// Computes lattice Damerau-Levenshtein distance up to `threshold`.
879///
880/// Returns `Ok(Some(distance))` when the exact distance is at most
881/// `threshold`, and `Ok(None)` when the distance is greater than `threshold`.
882/// This is useful for cutoff-style scoring because callers can avoid a separate
883/// [`try_within_damerau_distance`] pass followed by a full
884/// [`try_damerau_distance`] pass.
885pub fn try_damerau_distance_with_cutoff(
886    left: &Lattice,
887    right: &Lattice,
888    threshold: usize,
889) -> Result<Option<usize>, DistanceError> {
890    DistanceWorkspace::new().try_damerau_distance_with_cutoff(left, right, threshold)
891}
892
893fn damerau_distance_impl(
894    left: &Lattice,
895    right: &Lattice,
896    cells: usize,
897    workspace: &mut DistanceWorkspace,
898) -> usize {
899    let cols = right.node_count();
900    workspace.reset_dp(cells);
901    let dp = workspace.dp.as_mut_slice();
902    dp[distance_index(left.start(), right.start(), cols)] = 0;
903
904    for i in left.start()..=left.end() {
905        for j in right.start()..=right.end() {
906            if i == left.start() && j == right.start() {
907                continue;
908            }
909
910            let mut best = dp[distance_index(i, j, cols)];
911
912            for left_arc in left.incoming_arcs(i) {
913                for right_arc in right.incoming_arcs(j) {
914                    let cost = usize::from(left_arc.symbol != right_arc.symbol);
915                    let candidate =
916                        dp[distance_index(left_arc.src, right_arc.src, cols)].saturating_add(cost);
917                    best = best.min(candidate);
918                }
919            }
920
921            for left_arc in left.incoming_arcs(i) {
922                let candidate = dp[distance_index(left_arc.src, j, cols)].saturating_add(1);
923                best = best.min(candidate);
924            }
925
926            for right_arc in right.incoming_arcs(j) {
927                let candidate = dp[distance_index(i, right_arc.src, cols)].saturating_add(1);
928                best = best.min(candidate);
929            }
930
931            for left_second in left.incoming_arcs(i) {
932                for right_second in right.incoming_arcs(j) {
933                    for left_first in left.incoming_arcs(left_second.src) {
934                        for right_first in right.incoming_arcs(right_second.src) {
935                            if left_first.symbol == right_second.symbol
936                                && left_second.symbol == right_first.symbol
937                            {
938                                let candidate = dp
939                                    [distance_index(left_first.src, right_first.src, cols)]
940                                .saturating_add(1);
941                                best = best.min(candidate);
942                            }
943                        }
944                    }
945                }
946            }
947
948            dp[distance_index(i, j, cols)] = best;
949        }
950    }
951
952    dp[distance_index(left.end(), right.end(), cols)]
953}
954
955/// Computes edit distance and one best trace.
956///
957/// The trace is meaningful for lattices whose `start` and `end` states are
958/// reachable through modeled arcs. `Lattice::from_paths` and
959/// `Lattice::from_symbol_paths_compact` construct that shape; arbitrary
960/// `from_edges` DAGs can represent unreachable endpoints, where the distance
961/// remains effectively infinite and the returned trace can be empty.
962///
963/// # Panics
964///
965/// Panics when the trace DP matrix would exceed [`MAX_TRACE_MATRIX_CELLS`].
966/// Use [`try_distance_with_trace`] for external or otherwise untrusted
967/// lattices.
968pub fn distance_with_trace(left: &Lattice, right: &Lattice) -> DistanceTrace {
969    try_distance_with_trace(left, right).expect("distance matrix should fit")
970}
971
972/// Computes edit distance and one best trace with explicit matrix-size
973/// validation.
974pub fn try_distance_with_trace(
975    left: &Lattice,
976    right: &Lattice,
977) -> Result<DistanceTrace, DistanceError> {
978    let rows = left.node_count();
979    let cols = right.node_count();
980    let cells = checked_trace_matrix_cells(rows, cols)?;
981    Ok(distance_with_trace_impl(left, right, cells))
982}
983
984fn distance_with_trace_impl(left: &Lattice, right: &Lattice, cells: usize) -> DistanceTrace {
985    let cols = right.node_count();
986    let mut dp = vec![INF; cells];
987    let mut back = vec![None; cells];
988    dp[distance_index(left.start(), right.start(), cols)] = 0;
989
990    for i in left.start()..=left.end() {
991        for j in right.start()..=right.end() {
992            if i == left.start() && j == right.start() {
993                continue;
994            }
995
996            let index = distance_index(i, j, cols);
997            let mut best = dp[index];
998            let mut best_back = back[index].clone();
999
1000            for left_arc in left.incoming_arcs(i) {
1001                for right_arc in right.incoming_arcs(j) {
1002                    let cost = usize::from(left_arc.symbol != right_arc.symbol);
1003                    let candidate =
1004                        dp[distance_index(left_arc.src, right_arc.src, cols)].saturating_add(cost);
1005                    if candidate < best {
1006                        best = candidate;
1007                        best_back = Some(Backpointer {
1008                            prev_i: left_arc.src,
1009                            prev_j: right_arc.src,
1010                            step: TraceStep {
1011                                op: if cost == 0 {
1012                                    EditOp::Match
1013                                } else {
1014                                    EditOp::Substitute
1015                                },
1016                                left: Some(left_arc.symbol),
1017                                right: Some(right_arc.symbol),
1018                            },
1019                        });
1020                    }
1021                }
1022            }
1023
1024            for left_arc in left.incoming_arcs(i) {
1025                let candidate = dp[distance_index(left_arc.src, j, cols)].saturating_add(1);
1026                if candidate < best {
1027                    best = candidate;
1028                    best_back = Some(Backpointer {
1029                        prev_i: left_arc.src,
1030                        prev_j: j,
1031                        step: TraceStep {
1032                            op: EditOp::Delete,
1033                            left: Some(left_arc.symbol),
1034                            right: None,
1035                        },
1036                    });
1037                }
1038            }
1039
1040            for right_arc in right.incoming_arcs(j) {
1041                let candidate = dp[distance_index(i, right_arc.src, cols)].saturating_add(1);
1042                if candidate < best {
1043                    best = candidate;
1044                    best_back = Some(Backpointer {
1045                        prev_i: i,
1046                        prev_j: right_arc.src,
1047                        step: TraceStep {
1048                            op: EditOp::Insert,
1049                            left: None,
1050                            right: Some(right_arc.symbol),
1051                        },
1052                    });
1053                }
1054            }
1055
1056            dp[index] = best;
1057            back[index] = best_back;
1058        }
1059    }
1060
1061    let mut steps = Vec::new();
1062    let mut i = left.end();
1063    let mut j = right.end();
1064    while i != left.start() || j != right.start() {
1065        let Some(prev) = &back[distance_index(i, j, cols)] else {
1066            break;
1067        };
1068        steps.push(prev.step.clone());
1069        i = prev.prev_i;
1070        j = prev.prev_j;
1071    }
1072    steps.reverse();
1073
1074    DistanceTrace {
1075        distance: dp[distance_index(left.end(), right.end(), cols)],
1076        steps,
1077    }
1078}
1079
1080/// Returns whether lattice edit distance is at most `threshold`.
1081///
1082/// # Panics
1083///
1084/// Panics when the DP matrix would exceed [`MAX_DISTANCE_MATRIX_CELLS`].
1085/// Use [`try_within_distance`] for external or otherwise untrusted lattices.
1086pub fn within_distance(left: &Lattice, right: &Lattice, threshold: usize) -> bool {
1087    try_within_distance(left, right, threshold).expect("distance matrix should fit")
1088}
1089
1090/// Returns whether lattice edit distance is at most `threshold`, with explicit
1091/// matrix-size validation.
1092pub fn try_within_distance(
1093    left: &Lattice,
1094    right: &Lattice,
1095    threshold: usize,
1096) -> Result<bool, DistanceError> {
1097    let rows = left.node_count();
1098    let cols = right.node_count();
1099    let cells = checked_distance_matrix_cells(rows, cols)?;
1100    Ok(within_distance_impl(left, right, threshold, cells))
1101}
1102
1103fn within_distance_impl(left: &Lattice, right: &Lattice, threshold: usize, cells: usize) -> bool {
1104    let cols = right.node_count();
1105    let mut workspace = DistanceWorkspace::new();
1106    workspace.reset_threshold_search(cells);
1107    let start = distance_index(left.start(), right.start(), cols);
1108    workspace.dp[start] = 0;
1109    workspace.queued[start] = true;
1110    workspace.queue.push_back((left.start(), right.start()));
1111    let mut search = ThresholdSearch {
1112        threshold,
1113        cols,
1114        dp: &mut workspace.dp,
1115        queued: &mut workspace.queued,
1116        queue: &mut workspace.queue,
1117    };
1118
1119    while let Some((i, j)) = search.queue.pop_front() {
1120        let index = distance_index(i, j, cols);
1121        search.queued[index] = false;
1122        let current = search.dp[index];
1123        if current > threshold {
1124            continue;
1125        }
1126        if i == left.end() && j == right.end() {
1127            return true;
1128        }
1129
1130        for right_arc in right.outgoing_arcs(j) {
1131            search.relax(i, right_arc.dst, current.saturating_add(1));
1132        }
1133
1134        for left_arc in left.outgoing_arcs(i) {
1135            search.relax(left_arc.dst, j, current.saturating_add(1));
1136        }
1137
1138        for left_arc in left.outgoing_arcs(i) {
1139            for right_arc in right.outgoing_arcs(j) {
1140                let cost = usize::from(left_arc.symbol != right_arc.symbol);
1141                search.relax(left_arc.dst, right_arc.dst, current.saturating_add(cost));
1142            }
1143        }
1144    }
1145
1146    false
1147}
1148
1149fn cutoff_distance_impl(
1150    left: &Lattice,
1151    right: &Lattice,
1152    threshold: usize,
1153    cells: usize,
1154    workspace: &mut DistanceWorkspace,
1155) -> Option<usize> {
1156    let cols = right.node_count();
1157    workspace.reset_threshold_search(cells);
1158    let start = distance_index(left.start(), right.start(), cols);
1159    workspace.dp[start] = 0;
1160    workspace.queued[start] = true;
1161    workspace.queue.push_back((left.start(), right.start()));
1162    let mut search = ThresholdSearch {
1163        threshold,
1164        cols,
1165        dp: &mut workspace.dp,
1166        queued: &mut workspace.queued,
1167        queue: &mut workspace.queue,
1168    };
1169
1170    while let Some((i, j)) = search.queue.pop_front() {
1171        let index = distance_index(i, j, cols);
1172        search.queued[index] = false;
1173        let current = search.dp[index];
1174        if current > threshold {
1175            continue;
1176        }
1177
1178        for right_arc in right.outgoing_arcs(j) {
1179            search.relax(i, right_arc.dst, current.saturating_add(1));
1180        }
1181
1182        for left_arc in left.outgoing_arcs(i) {
1183            search.relax(left_arc.dst, j, current.saturating_add(1));
1184        }
1185
1186        for left_arc in left.outgoing_arcs(i) {
1187            for right_arc in right.outgoing_arcs(j) {
1188                let cost = usize::from(left_arc.symbol != right_arc.symbol);
1189                search.relax(left_arc.dst, right_arc.dst, current.saturating_add(cost));
1190            }
1191        }
1192    }
1193
1194    let distance = search.dp[distance_index(left.end(), right.end(), cols)];
1195    (distance <= threshold).then_some(distance)
1196}
1197
1198/// Returns whether lattice Damerau-Levenshtein distance is at most
1199/// `threshold`.
1200///
1201/// # Panics
1202///
1203/// Panics when the DP matrix would exceed [`MAX_DISTANCE_MATRIX_CELLS`].
1204/// Use [`try_within_damerau_distance`] for external or otherwise untrusted
1205/// lattices.
1206pub fn within_damerau_distance(left: &Lattice, right: &Lattice, threshold: usize) -> bool {
1207    try_within_damerau_distance(left, right, threshold).expect("distance matrix should fit")
1208}
1209
1210/// Returns whether lattice Damerau-Levenshtein distance is at most
1211/// `threshold`, with explicit matrix-size validation.
1212pub fn try_within_damerau_distance(
1213    left: &Lattice,
1214    right: &Lattice,
1215    threshold: usize,
1216) -> Result<bool, DistanceError> {
1217    let rows = left.node_count();
1218    let cols = right.node_count();
1219    let cells = checked_distance_matrix_cells(rows, cols)?;
1220    Ok(within_damerau_distance_impl(left, right, threshold, cells))
1221}
1222
1223fn within_damerau_distance_impl(
1224    left: &Lattice,
1225    right: &Lattice,
1226    threshold: usize,
1227    cells: usize,
1228) -> bool {
1229    let cols = right.node_count();
1230    let mut workspace = DistanceWorkspace::new();
1231    workspace.reset_threshold_search(cells);
1232    let start = distance_index(left.start(), right.start(), cols);
1233    workspace.dp[start] = 0;
1234    workspace.queued[start] = true;
1235    workspace.queue.push_back((left.start(), right.start()));
1236    let mut search = ThresholdSearch {
1237        threshold,
1238        cols,
1239        dp: &mut workspace.dp,
1240        queued: &mut workspace.queued,
1241        queue: &mut workspace.queue,
1242    };
1243
1244    while let Some((i, j)) = search.queue.pop_front() {
1245        let index = distance_index(i, j, cols);
1246        search.queued[index] = false;
1247        let current = search.dp[index];
1248        if current > threshold {
1249            continue;
1250        }
1251        if i == left.end() && j == right.end() {
1252            return true;
1253        }
1254
1255        for right_arc in right.outgoing_arcs(j) {
1256            search.relax(i, right_arc.dst, current.saturating_add(1));
1257        }
1258
1259        for left_arc in left.outgoing_arcs(i) {
1260            search.relax(left_arc.dst, j, current.saturating_add(1));
1261        }
1262
1263        for left_arc in left.outgoing_arcs(i) {
1264            for right_arc in right.outgoing_arcs(j) {
1265                let cost = usize::from(left_arc.symbol != right_arc.symbol);
1266                search.relax(left_arc.dst, right_arc.dst, current.saturating_add(cost));
1267            }
1268        }
1269
1270        for left_first in left.outgoing_arcs(i) {
1271            for right_first in right.outgoing_arcs(j) {
1272                for left_second in left.outgoing_arcs(left_first.dst) {
1273                    for right_second in right.outgoing_arcs(right_first.dst) {
1274                        if left_first.symbol == right_second.symbol
1275                            && left_second.symbol == right_first.symbol
1276                        {
1277                            search.relax(
1278                                left_second.dst,
1279                                right_second.dst,
1280                                current.saturating_add(1),
1281                            );
1282                        }
1283                    }
1284                }
1285            }
1286        }
1287    }
1288
1289    false
1290}
1291
1292fn cutoff_damerau_distance_impl(
1293    left: &Lattice,
1294    right: &Lattice,
1295    threshold: usize,
1296    cells: usize,
1297    workspace: &mut DistanceWorkspace,
1298) -> Option<usize> {
1299    let cols = right.node_count();
1300    workspace.reset_threshold_search(cells);
1301    let start = distance_index(left.start(), right.start(), cols);
1302    workspace.dp[start] = 0;
1303    workspace.queued[start] = true;
1304    workspace.queue.push_back((left.start(), right.start()));
1305    let mut search = ThresholdSearch {
1306        threshold,
1307        cols,
1308        dp: &mut workspace.dp,
1309        queued: &mut workspace.queued,
1310        queue: &mut workspace.queue,
1311    };
1312
1313    while let Some((i, j)) = search.queue.pop_front() {
1314        let index = distance_index(i, j, cols);
1315        search.queued[index] = false;
1316        let current = search.dp[index];
1317        if current > threshold {
1318            continue;
1319        }
1320
1321        for right_arc in right.outgoing_arcs(j) {
1322            search.relax(i, right_arc.dst, current.saturating_add(1));
1323        }
1324
1325        for left_arc in left.outgoing_arcs(i) {
1326            search.relax(left_arc.dst, j, current.saturating_add(1));
1327        }
1328
1329        for left_arc in left.outgoing_arcs(i) {
1330            for right_arc in right.outgoing_arcs(j) {
1331                let cost = usize::from(left_arc.symbol != right_arc.symbol);
1332                search.relax(left_arc.dst, right_arc.dst, current.saturating_add(cost));
1333            }
1334        }
1335
1336        for left_first in left.outgoing_arcs(i) {
1337            for right_first in right.outgoing_arcs(j) {
1338                for left_second in left.outgoing_arcs(left_first.dst) {
1339                    for right_second in right.outgoing_arcs(right_first.dst) {
1340                        if left_first.symbol == right_second.symbol
1341                            && left_second.symbol == right_first.symbol
1342                        {
1343                            search.relax(
1344                                left_second.dst,
1345                                right_second.dst,
1346                                current.saturating_add(1),
1347                            );
1348                        }
1349                    }
1350                }
1351            }
1352        }
1353    }
1354
1355    let distance = search.dp[distance_index(left.end(), right.end(), cols)];
1356    (distance <= threshold).then_some(distance)
1357}
1358
1359fn checked_distance_matrix_cells(rows: usize, cols: usize) -> Result<usize, DistanceError> {
1360    checked_matrix_cells(rows, cols, MAX_DISTANCE_MATRIX_CELLS)
1361}
1362
1363fn checked_trace_matrix_cells(rows: usize, cols: usize) -> Result<usize, DistanceError> {
1364    checked_matrix_cells(rows, cols, MAX_TRACE_MATRIX_CELLS)
1365}
1366
1367fn checked_matrix_cells(
1368    rows: usize,
1369    cols: usize,
1370    max_cells: usize,
1371) -> Result<usize, DistanceError> {
1372    let cells = rows
1373        .checked_mul(cols)
1374        .ok_or(DistanceError::MatrixTooLarge {
1375            rows,
1376            cols,
1377            max_cells,
1378        })?;
1379    if cells > max_cells {
1380        return Err(DistanceError::MatrixTooLarge {
1381            rows,
1382            cols,
1383            max_cells,
1384        });
1385    }
1386    Ok(cells)
1387}
1388
1389fn distance_index(i: usize, j: usize, cols: usize) -> usize {
1390    i * cols + j
1391}
1392
1393struct ThresholdSearch<'a> {
1394    threshold: usize,
1395    cols: usize,
1396    dp: &'a mut [usize],
1397    queued: &'a mut [bool],
1398    queue: &'a mut VecDeque<(usize, usize)>,
1399}
1400
1401impl ThresholdSearch<'_> {
1402    fn relax(&mut self, i: usize, j: usize, candidate: usize) {
1403        if candidate > self.threshold {
1404            return;
1405        }
1406        let index = distance_index(i, j, self.cols);
1407        if candidate >= self.dp[index] {
1408            return;
1409        }
1410        self.dp[index] = candidate;
1411        if !self.queued[index] {
1412            self.queued[index] = true;
1413            self.queue.push_back((i, j));
1414        }
1415    }
1416}
1417
1418/// Converts an edit distance and sequence lengths to a similarity score.
1419///
1420/// The result is clamped to `0.0..=1.0`; equal empty inputs return `1.0`.
1421pub fn normalized_similarity_from_distance(
1422    distance: usize,
1423    left_len: usize,
1424    right_len: usize,
1425) -> f64 {
1426    let max_len = left_len.max(right_len);
1427    if max_len == 0 {
1428        return 1.0;
1429    }
1430
1431    (1.0 - distance as f64 / max_len as f64).clamp(0.0, 1.0)
1432}
1433
1434/// Computes normalized Levenshtein similarity for two strings.
1435pub fn normalized_similarity_str(left: &str, right: &str) -> f64 {
1436    let left = left.chars().collect::<Vec<_>>();
1437    let right = right.chars().collect::<Vec<_>>();
1438    let mut workspace = StringDistanceWorkspace::new();
1439    normalized_similarity_chars(&left, &right, &mut workspace)
1440}
1441
1442/// Computes Levenshtein distance over Unicode scalar values.
1443pub fn levenshtein_str(left: &str, right: &str) -> usize {
1444    let left = left.chars().collect::<Vec<_>>();
1445    let right = right.chars().collect::<Vec<_>>();
1446    StringDistanceWorkspace::new().levenshtein(&left, &right)
1447}
1448
1449/// Computes Levenshtein distance over Unicode scalar values up to `threshold`.
1450///
1451/// Returns `Some(distance)` when the exact distance is at most `threshold`,
1452/// and `None` when the distance is greater than `threshold`.
1453pub fn levenshtein_str_with_cutoff(left: &str, right: &str, threshold: usize) -> Option<usize> {
1454    let left = left.chars().collect::<Vec<_>>();
1455    let right = right.chars().collect::<Vec<_>>();
1456    StringDistanceWorkspace::new().levenshtein_with_cutoff(&left, &right, threshold)
1457}
1458
1459/// Computes optimal string alignment Damerau-Levenshtein distance.
1460pub fn damerau_levenshtein_str(left: &str, right: &str) -> usize {
1461    try_damerau_levenshtein_str(left, right).expect("plain string distance should fit")
1462}
1463
1464/// Computes optimal string alignment Damerau-Levenshtein distance with
1465/// the fallible API shape used by lattice distances.
1466pub fn try_damerau_levenshtein_str(left: &str, right: &str) -> Result<usize, DistanceError> {
1467    let left = left.chars().collect::<Vec<_>>();
1468    let right = right.chars().collect::<Vec<_>>();
1469    Ok(StringDistanceWorkspace::new().damerau_levenshtein(&left, &right))
1470}
1471
1472/// Computes optimal string alignment Damerau-Levenshtein distance up to
1473/// `threshold`.
1474///
1475/// Returns `Ok(Some(distance))` when the exact distance is at most
1476/// `threshold`, and `Ok(None)` when the distance is greater than `threshold`.
1477pub fn try_damerau_levenshtein_str_with_cutoff(
1478    left: &str,
1479    right: &str,
1480    threshold: usize,
1481) -> Result<Option<usize>, DistanceError> {
1482    let left = left.chars().collect::<Vec<_>>();
1483    let right = right.chars().collect::<Vec<_>>();
1484    Ok(StringDistanceWorkspace::new().damerau_levenshtein_with_cutoff(&left, &right, threshold))
1485}
1486
1487fn normalized_similarity_chars(
1488    left: &[char],
1489    right: &[char],
1490    workspace: &mut StringDistanceWorkspace,
1491) -> f64 {
1492    normalized_similarity_from_distance(workspace.levenshtein(left, right), left.len(), right.len())
1493}
1494
1495fn levenshtein_chars_impl(
1496    left: &[char],
1497    right: &[char],
1498    workspace: &mut StringDistanceWorkspace,
1499) -> usize {
1500    let (shorter, longer) = if left.len() <= right.len() {
1501        (left, right)
1502    } else {
1503        (right, left)
1504    };
1505    workspace.previous.clear();
1506    workspace.previous.extend(0..=shorter.len());
1507    workspace.current.clear();
1508    workspace.current.resize(shorter.len() + 1, 0);
1509
1510    for (i, &longer_ch) in longer.iter().enumerate() {
1511        workspace.current[0] = i + 1;
1512        for (j, &shorter_ch) in shorter.iter().enumerate() {
1513            let substitution_cost = usize::from(longer_ch != shorter_ch);
1514            workspace.current[j + 1] = (workspace.previous[j + 1] + 1)
1515                .min(workspace.current[j] + 1)
1516                .min(workspace.previous[j] + substitution_cost);
1517        }
1518        std::mem::swap(&mut workspace.previous, &mut workspace.current);
1519    }
1520
1521    workspace.previous[shorter.len()]
1522}
1523
1524fn levenshtein_chars_with_cutoff_impl(
1525    left: &[char],
1526    right: &[char],
1527    threshold: usize,
1528    workspace: &mut StringDistanceWorkspace,
1529) -> Option<usize> {
1530    if left.len().abs_diff(right.len()) > threshold {
1531        return None;
1532    }
1533
1534    let (shorter, longer) = if left.len() <= right.len() {
1535        (left, right)
1536    } else {
1537        (right, left)
1538    };
1539    workspace.previous.clear();
1540    workspace.previous.extend(0..=shorter.len());
1541    workspace.current.clear();
1542    workspace.current.resize(shorter.len() + 1, 0);
1543
1544    for (i, &longer_ch) in longer.iter().enumerate() {
1545        workspace.current[0] = i + 1;
1546        let mut row_min = workspace.current[0];
1547        for (j, &shorter_ch) in shorter.iter().enumerate() {
1548            let substitution_cost = usize::from(longer_ch != shorter_ch);
1549            let distance = (workspace.previous[j + 1] + 1)
1550                .min(workspace.current[j] + 1)
1551                .min(workspace.previous[j] + substitution_cost);
1552            workspace.current[j + 1] = distance;
1553            row_min = row_min.min(distance);
1554        }
1555        if row_min > threshold {
1556            return None;
1557        }
1558        std::mem::swap(&mut workspace.previous, &mut workspace.current);
1559    }
1560
1561    let distance = workspace.previous[shorter.len()];
1562    (distance <= threshold).then_some(distance)
1563}
1564
1565fn damerau_levenshtein_chars_impl(
1566    left: &[char],
1567    right: &[char],
1568    workspace: &mut StringDistanceWorkspace,
1569) -> usize {
1570    if left.is_empty() {
1571        return right.len();
1572    }
1573    if right.is_empty() {
1574        return left.len();
1575    }
1576
1577    workspace.previous2.clear();
1578    workspace.previous2.resize(right.len() + 1, 0);
1579    workspace.previous.clear();
1580    workspace.previous.extend(0..=right.len());
1581    workspace.current.clear();
1582    workspace.current.resize(right.len() + 1, 0);
1583
1584    for i in 1..=left.len() {
1585        workspace.current[0] = i;
1586        for j in 1..=right.len() {
1587            let substitution_cost = usize::from(left[i - 1] != right[j - 1]);
1588            let mut best = (workspace.previous[j] + 1)
1589                .min(workspace.current[j - 1] + 1)
1590                .min(workspace.previous[j - 1] + substitution_cost);
1591
1592            if i > 1 && j > 1 && left[i - 1] == right[j - 2] && left[i - 2] == right[j - 1] {
1593                best = best.min(workspace.previous2[j - 2] + 1);
1594            }
1595
1596            workspace.current[j] = best;
1597        }
1598        std::mem::swap(&mut workspace.previous2, &mut workspace.previous);
1599        std::mem::swap(&mut workspace.previous, &mut workspace.current);
1600    }
1601
1602    workspace.previous[right.len()]
1603}
1604
1605fn damerau_levenshtein_chars_with_cutoff_impl(
1606    left: &[char],
1607    right: &[char],
1608    threshold: usize,
1609    workspace: &mut StringDistanceWorkspace,
1610) -> Option<usize> {
1611    if left.len().abs_diff(right.len()) > threshold {
1612        return None;
1613    }
1614
1615    if left.is_empty() {
1616        return (right.len() <= threshold).then_some(right.len());
1617    }
1618    if right.is_empty() {
1619        return (left.len() <= threshold).then_some(left.len());
1620    }
1621
1622    workspace.previous2.clear();
1623    workspace.previous2.resize(right.len() + 1, 0);
1624    workspace.previous.clear();
1625    workspace.previous.extend(0..=right.len());
1626    workspace.current.clear();
1627    workspace.current.resize(right.len() + 1, 0);
1628
1629    for i in 1..=left.len() {
1630        workspace.current[0] = i;
1631        let mut row_min = workspace.current[0];
1632        for j in 1..=right.len() {
1633            let substitution_cost = usize::from(left[i - 1] != right[j - 1]);
1634            let mut best = (workspace.previous[j] + 1)
1635                .min(workspace.current[j - 1] + 1)
1636                .min(workspace.previous[j - 1] + substitution_cost);
1637
1638            if i > 1 && j > 1 && left[i - 1] == right[j - 2] && left[i - 2] == right[j - 1] {
1639                best = best.min(workspace.previous2[j - 2] + 1);
1640            }
1641
1642            workspace.current[j] = best;
1643            row_min = row_min.min(best);
1644        }
1645        if row_min > threshold {
1646            return None;
1647        }
1648        std::mem::swap(&mut workspace.previous2, &mut workspace.previous);
1649        std::mem::swap(&mut workspace.previous, &mut workspace.current);
1650    }
1651
1652    let distance = workspace.previous[right.len()];
1653    (distance <= threshold).then_some(distance)
1654}
1655
1656fn trim_common_affixes<'a>(left: &'a [char], right: &'a [char]) -> (&'a [char], &'a [char]) {
1657    let common_prefix_len = left
1658        .iter()
1659        .zip(right.iter())
1660        .take_while(|(left_ch, right_ch)| left_ch == right_ch)
1661        .count();
1662    let mut left_end = left.len();
1663    let mut right_end = right.len();
1664    while left_end > common_prefix_len
1665        && right_end > common_prefix_len
1666        && left[left_end - 1] == right[right_end - 1]
1667    {
1668        left_end -= 1;
1669        right_end -= 1;
1670    }
1671    (
1672        &left[common_prefix_len..left_end],
1673        &right[common_prefix_len..right_end],
1674    )
1675}
1676
1677#[cfg(test)]
1678mod tests {
1679    use super::*;
1680
1681    fn symbols_to_string(symbols: &[Symbol]) -> String {
1682        symbols
1683            .iter()
1684            .map(|&symbol| char::from_u32(symbol).expect("test symbol should be a char"))
1685            .collect()
1686    }
1687
1688    fn string_distance(left: &str, right: &str) -> usize {
1689        distance(&Lattice::from_paths([left]), &Lattice::from_paths([right]))
1690    }
1691
1692    fn string_damerau_distance(left: &str, right: &str) -> usize {
1693        damerau_distance(&Lattice::from_paths([left]), &Lattice::from_paths([right]))
1694    }
1695
1696    fn assert_close(left: f64, right: f64) {
1697        assert!((left - right).abs() < f64::EPSILON);
1698    }
1699
1700    #[test]
1701    fn try_path_constructors_report_invalid_inputs() {
1702        assert!(matches!(
1703            Lattice::try_from_paths(std::iter::empty::<&str>()),
1704            Err(LatticeError::Empty)
1705        ));
1706        assert!(matches!(
1707            Lattice::try_from_paths(["", "a"]),
1708            Err(LatticeError::MixedEmptyAndNonEmptyPaths)
1709        ));
1710        assert!(matches!(
1711            Lattice::try_from_symbol_paths_compact([Vec::<Symbol>::new(), vec![1]]),
1712            Err(LatticeError::MixedEmptyAndNonEmptyPaths)
1713        ));
1714    }
1715
1716    #[test]
1717    fn linear_lattice_matches_levenshtein_distance() {
1718        assert_eq!(string_distance("kitten", "sitting"), 3);
1719        assert_eq!(string_distance("insat", "insatu"), 1);
1720        assert_eq!(string_distance("abc", "abc"), 0);
1721        assert_eq!(string_distance("abc", "axc"), 1);
1722    }
1723
1724    #[test]
1725    fn normalized_similarity_uses_max_length() {
1726        assert_close(
1727            normalized_similarity_from_distance(1, "abc".chars().count(), "adc".chars().count()),
1728            2.0 / 3.0,
1729        );
1730        assert_close(normalized_similarity_str("abc", "adc"), 2.0 / 3.0);
1731        assert_eq!(normalized_similarity_str("", ""), 1.0);
1732        assert_eq!(normalized_similarity_from_distance(4, 1, 2), 0.0);
1733    }
1734
1735    #[test]
1736    fn parallel_paths_take_minimum_distance() {
1737        let left = Lattice::from_paths(["insat"]);
1738        let right = Lattice::from_paths(["insatu", "insat", "zzzzz"]);
1739
1740        assert_eq!(distance(&left, &right), 0);
1741    }
1742
1743    #[test]
1744    fn fallible_distance_apis_match_convenience_apis() {
1745        let left = Lattice::from_paths(["abcd"]);
1746        let right = Lattice::from_paths(["acbd"]);
1747
1748        assert_eq!(
1749            try_distance(&left, &right).unwrap(),
1750            distance(&left, &right)
1751        );
1752        assert_eq!(
1753            try_damerau_distance(&left, &right).unwrap(),
1754            damerau_distance(&left, &right)
1755        );
1756        assert_eq!(
1757            try_distance_with_trace(&left, &right).unwrap(),
1758            distance_with_trace(&left, &right)
1759        );
1760        assert_eq!(
1761            try_within_distance(&left, &right, 2).unwrap(),
1762            within_distance(&left, &right, 2)
1763        );
1764        assert_eq!(
1765            try_within_damerau_distance(&left, &right, 1).unwrap(),
1766            within_damerau_distance(&left, &right, 1)
1767        );
1768    }
1769
1770    #[test]
1771    fn fallible_distance_apis_reject_large_matrices() {
1772        let node_count = 4001;
1773        let lattice = Lattice::from_edges(node_count, 0, node_count - 1, Vec::new()).unwrap();
1774
1775        assert!(matches!(
1776            try_distance(&lattice, &lattice),
1777            Err(DistanceError::MatrixTooLarge {
1778                rows: 4001,
1779                cols: 4001,
1780                max_cells: MAX_DISTANCE_MATRIX_CELLS,
1781            })
1782        ));
1783        assert!(matches!(
1784            try_damerau_distance(&lattice, &lattice),
1785            Err(DistanceError::MatrixTooLarge { .. })
1786        ));
1787        assert!(matches!(
1788            try_distance_with_trace(&lattice, &lattice),
1789            Err(DistanceError::MatrixTooLarge { .. })
1790        ));
1791        assert!(matches!(
1792            try_within_distance(&lattice, &lattice, 1),
1793            Err(DistanceError::MatrixTooLarge { .. })
1794        ));
1795        assert!(matches!(
1796            try_within_damerau_distance(&lattice, &lattice, 1),
1797            Err(DistanceError::MatrixTooLarge { .. })
1798        ));
1799        assert!(matches!(
1800            try_distance_with_cutoff(&lattice, &lattice, 1),
1801            Err(DistanceError::MatrixTooLarge { .. })
1802        ));
1803        assert!(matches!(
1804            try_damerau_distance_with_cutoff(&lattice, &lattice, 1),
1805            Err(DistanceError::MatrixTooLarge { .. })
1806        ));
1807    }
1808
1809    #[test]
1810    fn trace_uses_lower_matrix_limit_than_plain_distance() {
1811        let node_count = 1415;
1812        let lattice = Lattice::from_edges(node_count, 0, node_count - 1, Vec::new()).unwrap();
1813
1814        assert!(try_distance(&lattice, &lattice).is_ok());
1815        assert!(matches!(
1816            try_distance_with_trace(&lattice, &lattice),
1817            Err(DistanceError::MatrixTooLarge {
1818                rows: 1415,
1819                cols: 1415,
1820                max_cells: MAX_TRACE_MATRIX_CELLS,
1821            })
1822        ));
1823    }
1824
1825    #[test]
1826    fn fallible_arc_accessors_report_out_of_range_nodes() {
1827        let lattice = Lattice::from_paths(["ab"]);
1828
1829        assert_eq!(
1830            lattice.try_incoming_arcs(999).map(|arcs| arcs.count()),
1831            None
1832        );
1833        assert_eq!(
1834            lattice.try_outgoing_arcs(999).map(|arcs| arcs.count()),
1835            None
1836        );
1837        assert_eq!(
1838            lattice.try_outgoing_arcs(0).map(|arcs| arcs.count()),
1839            Some(1)
1840        );
1841    }
1842
1843    #[test]
1844    fn trace_free_distance_matches_trace_distance() {
1845        let left = Lattice::from_paths(["insat", "insatu"]);
1846        let right = Lattice::from_paths(["inzatu", "insatsu"]);
1847
1848        assert_eq!(
1849            distance(&left, &right),
1850            distance_with_trace(&left, &right).distance
1851        );
1852    }
1853
1854    #[test]
1855    fn compact_paths_share_prefix_nodes() {
1856        let lattice = Lattice::from_symbol_paths_compact([
1857            "chadougu"
1858                .chars()
1859                .map(|ch| ch as Symbol)
1860                .collect::<Vec<_>>(),
1861            "chadoogu"
1862                .chars()
1863                .map(|ch| ch as Symbol)
1864                .collect::<Vec<_>>(),
1865        ]);
1866
1867        assert_eq!(distance(&lattice, &Lattice::from_paths(["chadougu"])), 0);
1868        assert_eq!(distance(&lattice, &Lattice::from_paths(["chadoogu"])), 0);
1869        assert!(lattice.node_count() < Lattice::from_paths(["chadougu", "chadoogu"]).node_count());
1870    }
1871
1872    #[test]
1873    fn compact_paths_share_equivalent_suffix_nodes() {
1874        let lattice = Lattice::from_symbol_paths_compact([
1875            "xab".chars().map(|ch| ch as Symbol).collect::<Vec<_>>(),
1876            "yab".chars().map(|ch| ch as Symbol).collect::<Vec<_>>(),
1877        ]);
1878
1879        assert_eq!(distance(&lattice, &Lattice::from_paths(["xab"])), 0);
1880        assert_eq!(distance(&lattice, &Lattice::from_paths(["yab"])), 0);
1881        assert_eq!(distance(&lattice, &Lattice::from_paths(["zab"])), 1);
1882        assert!(lattice.node_count() < Lattice::from_paths(["xab", "yab"]).node_count());
1883    }
1884
1885    #[test]
1886    fn compact_paths_preserve_prefix_words() {
1887        let lattice = Lattice::from_symbol_paths_compact([
1888            "a".chars().map(|ch| ch as Symbol).collect::<Vec<_>>(),
1889            "ab".chars().map(|ch| ch as Symbol).collect::<Vec<_>>(),
1890        ]);
1891
1892        assert_eq!(distance(&lattice, &Lattice::from_paths(["a"])), 0);
1893        assert_eq!(distance(&lattice, &Lattice::from_paths(["ab"])), 0);
1894    }
1895
1896    #[test]
1897    fn distance_with_trace_returns_best_path_pair() {
1898        let left = Lattice::from_paths(["chadougu"]);
1899        let right = Lattice::from_paths(["tyadougu", "chadougu"]);
1900
1901        let trace = distance_with_trace(&left, &right);
1902
1903        assert_eq!(trace.distance, 0);
1904        assert_eq!(symbols_to_string(&trace.left_symbols()), "chadougu");
1905        assert_eq!(symbols_to_string(&trace.right_symbols()), "chadougu");
1906        assert!(trace.steps.iter().all(|step| step.op == EditOp::Match));
1907    }
1908
1909    #[test]
1910    fn trace_includes_insertions_and_deletions() {
1911        let trace = distance_with_trace(
1912            &Lattice::from_paths(["insat"]),
1913            &Lattice::from_paths(["insatu"]),
1914        );
1915
1916        assert_eq!(trace.distance, 1);
1917        assert_eq!(symbols_to_string(&trace.left_symbols()), "insat");
1918        assert_eq!(symbols_to_string(&trace.right_symbols()), "insatu");
1919        assert_eq!(trace.steps.last().map(|step| step.op), Some(EditOp::Insert));
1920    }
1921
1922    #[test]
1923    fn threshold_check_uses_distance() {
1924        let left = Lattice::from_paths(["insat"]);
1925        let right = Lattice::from_paths(["insatu"]);
1926
1927        assert!(within_distance(&left, &right, 1));
1928        assert!(!within_distance(&left, &right, 0));
1929        assert_eq!(try_distance_with_cutoff(&left, &right, 1).unwrap(), Some(1));
1930        assert_eq!(try_distance_with_cutoff(&left, &right, 0).unwrap(), None);
1931    }
1932
1933    #[test]
1934    fn threshold_check_prunes_but_preserves_lattice_paths() {
1935        let left = Lattice::from_paths(["chadougu", "tyadougu"]);
1936        let right = Lattice::from_paths(["chadoogu", "zzzzzzzz"]);
1937
1938        assert_eq!(distance(&left, &right), 1);
1939        assert!(within_distance(&left, &right, 1));
1940        assert!(!within_distance(&left, &right, 0));
1941        assert_eq!(try_distance_with_cutoff(&left, &right, 1).unwrap(), Some(1));
1942        assert_eq!(try_distance_with_cutoff(&left, &right, 0).unwrap(), None);
1943    }
1944
1945    #[test]
1946    fn linear_lattice_damerau_matches_string_damerau_distance() {
1947        for (left, right) in [
1948            ("ca", "ac"),
1949            ("abc", "acb"),
1950            ("abcdef", "abcedf"),
1951            ("moine", "mione"),
1952            ("マトリッツォ", "マリトッツォ"),
1953        ] {
1954            assert_eq!(
1955                string_damerau_distance(left, right),
1956                damerau_levenshtein_str(left, right),
1957                "{left:?} / {right:?}"
1958            );
1959        }
1960    }
1961
1962    #[test]
1963    fn lattice_damerau_takes_transposition_across_candidate_paths() {
1964        let left = Lattice::from_paths(["abc", "axc"]);
1965        let right = Lattice::from_paths(["acb"]);
1966
1967        assert_eq!(distance(&left, &right), 2);
1968        assert_eq!(damerau_distance(&left, &right), 1);
1969    }
1970
1971    #[test]
1972    fn lattice_damerau_supports_branched_two_arc_transposition() {
1973        let left = Lattice::from_edges(
1974            4,
1975            0,
1976            3,
1977            vec![
1978                Arc::new(0, 1, 'a' as Symbol),
1979                Arc::new(0, 1, 'x' as Symbol),
1980                Arc::new(1, 3, 'b' as Symbol),
1981                Arc::new(1, 3, 'y' as Symbol),
1982            ],
1983        )
1984        .unwrap();
1985        let right = Lattice::from_paths(["ba"]);
1986
1987        assert_eq!(damerau_distance(&left, &right), 1);
1988    }
1989
1990    #[test]
1991    fn threshold_damerau_check_uses_lattice_damerau_distance() {
1992        let left = Lattice::from_paths(["abc", "axc"]);
1993        let right = Lattice::from_paths(["acb"]);
1994
1995        assert!(within_damerau_distance(&left, &right, 1));
1996        assert!(!within_damerau_distance(&left, &right, 0));
1997        assert_eq!(
1998            try_damerau_distance_with_cutoff(&left, &right, 1).unwrap(),
1999            Some(1)
2000        );
2001        assert_eq!(
2002            try_damerau_distance_with_cutoff(&left, &right, 0).unwrap(),
2003            None
2004        );
2005    }
2006
2007    #[test]
2008    fn from_edges_rejects_non_topological_arcs() {
2009        let result = Lattice::from_edges(2, 0, 1, vec![Arc::new(1, 0, 'a' as Symbol)]);
2010
2011        assert!(matches!(
2012            result,
2013            Err(LatticeError::InvalidArcOrder { src: 1, dst: 0 })
2014        ));
2015    }
2016
2017    #[test]
2018    fn surface_levenshtein_counts_unicode_chars() {
2019        assert_eq!(levenshtein_str("kitten", "sitting"), 3);
2020        assert_eq!(levenshtein_str("いんさt", "印刷"), 4);
2021        assert_eq!(levenshtein_str("マトリッツォ", "マリトッツォ"), 2);
2022        assert_eq!(levenshtein_str("prefix-abc-suffix", "prefix-axc-suffix"), 1);
2023        assert_eq!(levenshtein_str_with_cutoff("kitten", "sitting", 3), Some(3));
2024        assert_eq!(levenshtein_str_with_cutoff("kitten", "sitting", 2), None);
2025        assert_eq!(
2026            levenshtein_str_with_cutoff("prefix-abc-suffix", "prefix-axc-suffix", 1),
2027            Some(1)
2028        );
2029        assert_eq!(
2030            levenshtein_str_with_cutoff("蒸留所", "ジョウリュウショ", 1),
2031            None
2032        );
2033    }
2034
2035    #[test]
2036    fn surface_damerau_counts_adjacent_transposition() {
2037        assert_eq!(damerau_levenshtein_str("ca", "ac"), 1);
2038        assert_eq!(try_damerau_levenshtein_str("ca", "ac").unwrap(), 1);
2039        assert_eq!(damerau_levenshtein_str("マトリッツォ", "マリトッツォ"), 1);
2040        assert_eq!(damerau_levenshtein_str("いんさt", "印刷"), 4);
2041        assert_eq!(
2042            try_damerau_levenshtein_str_with_cutoff("abc", "acb", 1).unwrap(),
2043            Some(1)
2044        );
2045        assert_eq!(
2046            try_damerau_levenshtein_str_with_cutoff("abc", "acb", 0).unwrap(),
2047            None
2048        );
2049        assert_eq!(
2050            try_damerau_levenshtein_str_with_cutoff("蒸留所", "ジョウリュウショ", 1).unwrap(),
2051            None
2052        );
2053    }
2054}