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_damerau_distance`], [`try_distance_with_trace`],
9//! [`try_within_distance`], and [`try_within_damerau_distance`] when lattices
10//! come from external input. The infallible convenience functions keep examples
11//! short, but panic if the configured matrix limits would be exceeded. Trace
12//! reconstruction stores more per cell than the plain distance path, so it uses
13//! the lower [`MAX_TRACE_MATRIX_CELLS`] limit.
14//!
15//! ```
16//! use moine_core::{distance, try_distance, Lattice};
17//!
18//! let left = Lattice::from_paths(["moine"]);
19//! let right = Lattice::from_paths(["moinya"]);
20//!
21//! assert_eq!(distance(&left, &right), 2);
22//! assert_eq!(try_distance(&left, &right).unwrap(), 2);
23//! ```
24//!
25#![deny(missing_docs)]
26
27use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
28use std::error::Error;
29use std::fmt;
30
31/// Integer symbol stored on lattice arcs.
32///
33/// String constructors encode each Unicode scalar value as one `Symbol`.
34pub type Symbol = u32;
35
36/// Directed arc between two lattice nodes.
37#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38pub struct Arc {
39    /// Source node index.
40    pub src: usize,
41    /// Destination node index.
42    pub dst: usize,
43    /// Symbol consumed when traversing the arc.
44    pub symbol: Symbol,
45}
46
47impl Arc {
48    /// Creates an arc from `src` to `dst` carrying `symbol`.
49    pub fn new(src: usize, dst: usize, symbol: Symbol) -> Self {
50        Self { src, dst, symbol }
51    }
52}
53
54/// A directed acyclic lattice with one start node and one end node.
55///
56/// Nodes are addressed by zero-based indices. Arcs must move from lower to
57/// higher node indices so distance algorithms can process the lattice in
58/// topological order.
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub struct Lattice {
61    node_count: usize,
62    start: usize,
63    end: usize,
64    arcs: Vec<Arc>,
65    incoming: Vec<Vec<usize>>,
66    outgoing: Vec<Vec<usize>>,
67}
68
69/// Errors returned when constructing an invalid lattice.
70#[derive(Clone, Debug, Eq, PartialEq)]
71pub enum LatticeError {
72    /// The lattice has no nodes.
73    Empty,
74    /// Empty paths were mixed with non-empty paths.
75    MixedEmptyAndNonEmptyPaths,
76    /// An arc endpoint is outside the node range.
77    InvalidNode {
78        /// Invalid node index.
79        node: usize,
80        /// Number of nodes in the lattice.
81        node_count: usize,
82    },
83    /// An arc does not respect topological node order.
84    InvalidArcOrder {
85        /// Source node index.
86        src: usize,
87        /// Destination node index.
88        dst: usize,
89    },
90    /// Start or end node indices are outside the valid range.
91    InvalidEndpoint {
92        /// Start node index.
93        start: usize,
94        /// End node index.
95        end: usize,
96    },
97}
98
99impl fmt::Display for LatticeError {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        match self {
102            Self::Empty => write!(f, "lattice must contain at least one node"),
103            Self::MixedEmptyAndNonEmptyPaths => write!(
104                f,
105                "mixed empty and non-empty paths need epsilon arcs, which moine-core does not model yet"
106            ),
107            Self::InvalidNode { node, node_count } => {
108                write!(f, "node {node} is outside 0..{node_count}")
109            }
110            Self::InvalidArcOrder { src, dst } => {
111                write!(f, "arc {src}->{dst} violates topological node order")
112            }
113            Self::InvalidEndpoint { start, end } => {
114                write!(f, "invalid endpoints start={start}, end={end}")
115            }
116        }
117    }
118}
119
120impl Error for LatticeError {}
121
122impl Lattice {
123    /// Builds a lattice from explicit nodes and arcs.
124    ///
125    /// `start` and `end` must be valid node indices with `start <= end`.
126    /// Every arc must reference valid nodes and satisfy `src < dst`.
127    pub fn from_edges(
128        node_count: usize,
129        start: usize,
130        end: usize,
131        arcs: Vec<Arc>,
132    ) -> Result<Self, LatticeError> {
133        if node_count == 0 {
134            return Err(LatticeError::Empty);
135        }
136        if start >= node_count || end >= node_count || start > end {
137            return Err(LatticeError::InvalidEndpoint { start, end });
138        }
139
140        let mut incoming = vec![Vec::new(); node_count];
141        let mut outgoing = vec![Vec::new(); node_count];
142        for (idx, arc) in arcs.iter().enumerate() {
143            if arc.src >= node_count {
144                return Err(LatticeError::InvalidNode {
145                    node: arc.src,
146                    node_count,
147                });
148            }
149            if arc.dst >= node_count {
150                return Err(LatticeError::InvalidNode {
151                    node: arc.dst,
152                    node_count,
153                });
154            }
155            if arc.src >= arc.dst {
156                return Err(LatticeError::InvalidArcOrder {
157                    src: arc.src,
158                    dst: arc.dst,
159                });
160            }
161            incoming[arc.dst].push(idx);
162            outgoing[arc.src].push(idx);
163        }
164
165        Ok(Self {
166            node_count,
167            start,
168            end,
169            arcs,
170            incoming,
171            outgoing,
172        })
173    }
174
175    /// Builds a lattice from UTF-8 string paths.
176    ///
177    /// # Panics
178    ///
179    /// Panics when `paths` is empty or when empty and non-empty paths are
180    /// mixed. Use [`Lattice::try_from_paths`] to handle those cases as input
181    /// errors.
182    pub fn from_paths<I, S>(paths: I) -> Self
183    where
184        I: IntoIterator<Item = S>,
185        S: AsRef<str>,
186    {
187        Self::try_from_paths(paths).expect("valid string path lattice")
188    }
189
190    /// Builds a lattice from UTF-8 string paths.
191    pub fn try_from_paths<I, S>(paths: I) -> Result<Self, LatticeError>
192    where
193        I: IntoIterator<Item = S>,
194        S: AsRef<str>,
195    {
196        let symbol_paths = paths.into_iter().map(|path| {
197            path.as_ref()
198                .chars()
199                .map(|ch| ch as Symbol)
200                .collect::<Vec<_>>()
201        });
202        Self::try_from_symbol_paths(symbol_paths)
203    }
204
205    /// Builds a lattice from symbol paths.
206    ///
207    /// # Panics
208    ///
209    /// Panics when `paths` is empty or when empty and non-empty paths are
210    /// mixed. Use [`Lattice::try_from_symbol_paths`] to handle those cases as
211    /// input errors.
212    pub fn from_symbol_paths<I, P>(paths: I) -> Self
213    where
214        I: IntoIterator<Item = P>,
215        P: IntoIterator<Item = Symbol>,
216    {
217        Self::try_from_symbol_paths(paths).expect("valid symbol path lattice")
218    }
219
220    /// Builds a lattice from symbol paths.
221    pub fn try_from_symbol_paths<I, P>(paths: I) -> Result<Self, LatticeError>
222    where
223        I: IntoIterator<Item = P>,
224        P: IntoIterator<Item = Symbol>,
225    {
226        let paths = paths
227            .into_iter()
228            .map(|path| path.into_iter().collect::<Vec<_>>())
229            .collect::<Vec<_>>();
230        if paths.is_empty() {
231            return Err(LatticeError::Empty);
232        }
233
234        if paths.len() == 1 && paths[0].is_empty() {
235            return Self::from_edges(1, 0, 0, Vec::new());
236        }
237        if !paths.iter().all(|path| !path.is_empty()) {
238            return Err(LatticeError::MixedEmptyAndNonEmptyPaths);
239        }
240
241        let start = 0;
242        let node_count = 2 + paths
243            .iter()
244            .map(|path| path.len().saturating_sub(1))
245            .sum::<usize>();
246        let end = node_count - 1;
247        let mut next_node = 1;
248        let mut arcs = Vec::new();
249
250        for path in paths {
251            let mut current = start;
252            for (idx, symbol) in path.iter().copied().enumerate() {
253                let dst = if idx + 1 == path.len() {
254                    end
255                } else {
256                    let node = next_node;
257                    next_node += 1;
258                    node
259                };
260                arcs.push(Arc::new(current, dst, symbol));
261                current = dst;
262            }
263        }
264
265        debug_assert_eq!(next_node, end);
266        Self::from_edges(node_count, start, end, arcs)
267    }
268
269    /// Builds a compact lattice from symbol paths by sharing common suffixes.
270    ///
271    /// # Panics
272    ///
273    /// Panics when `paths` is empty or when empty and non-empty paths are
274    /// mixed. Use [`Lattice::try_from_symbol_paths_compact`] to handle those
275    /// cases as input errors.
276    pub fn from_symbol_paths_compact<I, P>(paths: I) -> Self
277    where
278        I: IntoIterator<Item = P>,
279        P: IntoIterator<Item = Symbol>,
280    {
281        Self::try_from_symbol_paths_compact(paths).expect("valid compact symbol path lattice")
282    }
283
284    /// Builds a compact lattice from symbol paths by sharing common suffixes.
285    pub fn try_from_symbol_paths_compact<I, P>(paths: I) -> Result<Self, LatticeError>
286    where
287        I: IntoIterator<Item = P>,
288        P: IntoIterator<Item = Symbol>,
289    {
290        let paths = paths
291            .into_iter()
292            .map(|path| path.into_iter().collect::<Vec<_>>())
293            .collect::<Vec<_>>();
294        if paths.is_empty() {
295            return Err(LatticeError::Empty);
296        }
297
298        if paths.len() == 1 && paths[0].is_empty() {
299            return Self::from_edges(1, 0, 0, Vec::new());
300        }
301        if !paths.iter().all(|path| !path.is_empty()) {
302            return Err(LatticeError::MixedEmptyAndNonEmptyPaths);
303        }
304
305        let mut builder = CompactPathBuilder::default();
306        for path in paths {
307            builder.insert(&path);
308        }
309        Ok(builder.into_lattice())
310    }
311
312    /// Returns the number of nodes in the lattice.
313    pub fn node_count(&self) -> usize {
314        self.node_count
315    }
316
317    /// Returns the start node index.
318    pub fn start(&self) -> usize {
319        self.start
320    }
321
322    /// Returns the end node index.
323    pub fn end(&self) -> usize {
324        self.end
325    }
326
327    /// Returns all arcs in insertion order.
328    pub fn arcs(&self) -> &[Arc] {
329        &self.arcs
330    }
331
332    /// Returns incoming arcs for `node`, or `None` when the node index is out
333    /// of range.
334    pub fn try_incoming_arcs(&self, node: usize) -> Option<impl Iterator<Item = &Arc>> {
335        self.incoming
336            .get(node)
337            .map(|indices| indices.iter().map(|&idx| &self.arcs[idx]))
338    }
339
340    /// Returns incoming arcs for `node`.
341    ///
342    /// # Panics
343    ///
344    /// Panics when `node >= self.node_count()`.
345    pub fn incoming_arcs(&self, node: usize) -> impl Iterator<Item = &Arc> {
346        self.try_incoming_arcs(node)
347            .expect("node should be inside lattice")
348    }
349
350    /// Returns outgoing arcs for `node`, or `None` when the node index is out
351    /// of range.
352    pub fn try_outgoing_arcs(&self, node: usize) -> Option<impl Iterator<Item = &Arc>> {
353        self.outgoing
354            .get(node)
355            .map(|indices| indices.iter().map(|&idx| &self.arcs[idx]))
356    }
357
358    /// Returns outgoing arcs for `node`.
359    ///
360    /// # Panics
361    ///
362    /// Panics when `node >= self.node_count()`.
363    pub fn outgoing_arcs(&self, node: usize) -> impl Iterator<Item = &Arc> {
364        self.try_outgoing_arcs(node)
365            .expect("node should be inside lattice")
366    }
367}
368
369#[derive(Clone, Debug, Default)]
370struct TrieNode {
371    children: BTreeMap<Symbol, usize>,
372    terminal_symbols: BTreeSet<Symbol>,
373}
374
375#[derive(Clone, Debug, Default)]
376struct CompactPathBuilder {
377    nodes: Vec<TrieNode>,
378}
379
380#[derive(Clone, Debug, Eq, Hash, PartialEq)]
381struct CompactSignature {
382    terminal_symbols: Vec<Symbol>,
383    children: Vec<(Symbol, usize)>,
384}
385
386#[derive(Clone, Debug)]
387struct CompactNode {
388    terminal_symbols: Vec<Symbol>,
389    children: Vec<(Symbol, usize)>,
390}
391
392impl CompactPathBuilder {
393    fn insert(&mut self, path: &[Symbol]) {
394        if self.nodes.is_empty() {
395            self.nodes.push(TrieNode::default());
396        }
397
398        let mut current = 0;
399        for (idx, symbol) in path.iter().copied().enumerate() {
400            if idx + 1 == path.len() {
401                self.nodes[current].terminal_symbols.insert(symbol);
402                break;
403            }
404
405            let next = if let Some(&next) = self.nodes[current].children.get(&symbol) {
406                next
407            } else {
408                let next = self.nodes.len();
409                self.nodes.push(TrieNode::default());
410                self.nodes[current].children.insert(symbol, next);
411                next
412            };
413            current = next;
414        }
415    }
416
417    fn into_lattice(self) -> Lattice {
418        let mut minimizer = CompactMinimizer {
419            trie_nodes: self.nodes,
420            memo: HashMap::new(),
421            interned: HashMap::new(),
422            compact_nodes: Vec::new(),
423        };
424        let root = minimizer.compact_trie_node(0);
425        build_compact_lattice(root, minimizer.compact_nodes)
426    }
427}
428
429struct CompactMinimizer {
430    trie_nodes: Vec<TrieNode>,
431    memo: HashMap<usize, usize>,
432    interned: HashMap<CompactSignature, usize>,
433    compact_nodes: Vec<CompactNode>,
434}
435
436impl CompactMinimizer {
437    fn compact_trie_node(&mut self, trie_id: usize) -> usize {
438        if let Some(&compact_id) = self.memo.get(&trie_id) {
439            return compact_id;
440        }
441
442        let children = self.trie_nodes[trie_id]
443            .children
444            .clone()
445            .into_iter()
446            .map(|(symbol, child)| (symbol, self.compact_trie_node(child)))
447            .collect::<Vec<_>>();
448        let terminal_symbols = self.trie_nodes[trie_id]
449            .terminal_symbols
450            .iter()
451            .copied()
452            .collect::<Vec<_>>();
453        let signature = CompactSignature {
454            terminal_symbols,
455            children,
456        };
457
458        let compact_id = if let Some(&existing) = self.interned.get(&signature) {
459            existing
460        } else {
461            let compact_id = self.compact_nodes.len();
462            self.compact_nodes.push(CompactNode {
463                terminal_symbols: signature.terminal_symbols.clone(),
464                children: signature.children.clone(),
465            });
466            self.interned.insert(signature, compact_id);
467            compact_id
468        };
469
470        self.memo.insert(trie_id, compact_id);
471        compact_id
472    }
473}
474
475fn build_compact_lattice(root: usize, compact_nodes: Vec<CompactNode>) -> Lattice {
476    let mut reachable = Vec::new();
477    collect_reachable_compact_nodes(root, &compact_nodes, &mut BTreeSet::new(), &mut reachable);
478
479    let mut heights = HashMap::new();
480    for &node in &reachable {
481        compact_height(node, &compact_nodes, &mut heights);
482    }
483
484    reachable.sort_by_key(|node| {
485        (
486            usize::from(*node != root),
487            std::cmp::Reverse(*heights.get(node).expect("height should be known")),
488            *node,
489        )
490    });
491
492    let node_ids = reachable
493        .iter()
494        .enumerate()
495        .map(|(node_id, &compact_id)| (compact_id, node_id))
496        .collect::<HashMap<_, _>>();
497    let end = reachable.len();
498    let mut arcs = Vec::new();
499
500    for &compact_id in &reachable {
501        let src = node_ids[&compact_id];
502        let node = &compact_nodes[compact_id];
503        for &symbol in &node.terminal_symbols {
504            arcs.push(Arc::new(src, end, symbol));
505        }
506        for &(symbol, child) in &node.children {
507            arcs.push(Arc::new(src, node_ids[&child], symbol));
508        }
509    }
510
511    Lattice::from_edges(end + 1, 0, end, arcs).expect("valid compact path lattice")
512}
513
514fn collect_reachable_compact_nodes(
515    node: usize,
516    compact_nodes: &[CompactNode],
517    seen: &mut BTreeSet<usize>,
518    output: &mut Vec<usize>,
519) {
520    if !seen.insert(node) {
521        return;
522    }
523    output.push(node);
524    for &(_, child) in &compact_nodes[node].children {
525        collect_reachable_compact_nodes(child, compact_nodes, seen, output);
526    }
527}
528
529fn compact_height(
530    node: usize,
531    compact_nodes: &[CompactNode],
532    memo: &mut HashMap<usize, usize>,
533) -> usize {
534    if let Some(&height) = memo.get(&node) {
535        return height;
536    }
537
538    let child_height = compact_nodes[node]
539        .children
540        .iter()
541        .map(|&(_, child)| compact_height(child, compact_nodes, memo) + 1)
542        .max()
543        .unwrap_or(0);
544    let terminal_height = usize::from(!compact_nodes[node].terminal_symbols.is_empty());
545    let height = child_height.max(terminal_height);
546    memo.insert(node, height);
547    height
548}
549
550/// Edit operation represented in a trace step.
551#[derive(Clone, Copy, Debug, Eq, PartialEq)]
552pub enum EditOp {
553    /// The left and right symbols matched exactly.
554    Match,
555    /// A left symbol was substituted for a right symbol.
556    Substitute,
557    /// A left symbol was deleted.
558    Delete,
559    /// A right symbol was inserted.
560    Insert,
561}
562
563/// One operation in a reconstructed edit-distance trace.
564#[derive(Clone, Debug, Eq, PartialEq)]
565pub struct TraceStep {
566    /// Operation selected for this step.
567    pub op: EditOp,
568    /// Symbol consumed from the left lattice, if any.
569    pub left: Option<Symbol>,
570    /// Symbol consumed from the right lattice, if any.
571    pub right: Option<Symbol>,
572}
573
574/// Edit distance plus one best sequence of edit operations.
575#[derive(Clone, Debug, Eq, PartialEq)]
576pub struct DistanceTrace {
577    /// Final edit distance.
578    pub distance: usize,
579    /// Reconstructed steps from start to end.
580    pub steps: Vec<TraceStep>,
581}
582
583impl DistanceTrace {
584    /// Returns the left-side symbols consumed by the trace.
585    pub fn left_symbols(&self) -> Vec<Symbol> {
586        self.steps.iter().filter_map(|step| step.left).collect()
587    }
588
589    /// Returns the right-side symbols consumed by the trace.
590    pub fn right_symbols(&self) -> Vec<Symbol> {
591        self.steps.iter().filter_map(|step| step.right).collect()
592    }
593}
594
595/// Maximum DP matrix size accepted by non-trace distance functions.
596pub const MAX_DISTANCE_MATRIX_CELLS: usize = 16_000_000;
597/// Maximum DP matrix size accepted by trace reconstruction.
598pub const MAX_TRACE_MATRIX_CELLS: usize = 2_000_000;
599
600/// Errors returned by fallible distance functions.
601#[derive(Clone, Debug, Eq, PartialEq)]
602pub enum DistanceError {
603    /// The requested dynamic-programming matrix exceeds the configured limit.
604    MatrixTooLarge {
605        /// Number of matrix rows.
606        rows: usize,
607        /// Number of matrix columns.
608        cols: usize,
609        /// Maximum allowed cell count.
610        max_cells: usize,
611    },
612}
613
614impl fmt::Display for DistanceError {
615    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
616        match self {
617            Self::MatrixTooLarge {
618                rows,
619                cols,
620                max_cells,
621            } => write!(
622                f,
623                "distance matrix {rows}x{cols} exceeds the maximum of {max_cells} cells"
624            ),
625        }
626    }
627}
628
629impl Error for DistanceError {}
630
631#[derive(Clone, Debug, Eq, PartialEq)]
632struct Backpointer {
633    prev_i: usize,
634    prev_j: usize,
635    step: TraceStep,
636}
637
638const INF: usize = usize::MAX / 4;
639
640/// Computes lattice edit distance.
641///
642/// # Panics
643///
644/// Panics when the DP matrix would exceed [`MAX_DISTANCE_MATRIX_CELLS`].
645/// Use [`try_distance`] for external or otherwise untrusted lattices.
646pub fn distance(left: &Lattice, right: &Lattice) -> usize {
647    try_distance(left, right).expect("distance matrix should fit")
648}
649
650/// Computes lattice edit distance with explicit matrix-size validation.
651pub fn try_distance(left: &Lattice, right: &Lattice) -> Result<usize, DistanceError> {
652    let rows = left.node_count();
653    let cols = right.node_count();
654    let cells = checked_distance_matrix_cells(rows, cols)?;
655    Ok(distance_impl(left, right, cells))
656}
657
658fn distance_impl(left: &Lattice, right: &Lattice, cells: usize) -> usize {
659    let cols = right.node_count();
660    let mut dp = vec![INF; cells];
661    dp[distance_index(left.start(), right.start(), cols)] = 0;
662
663    for i in left.start()..=left.end() {
664        for j in right.start()..=right.end() {
665            if i == left.start() && j == right.start() {
666                continue;
667            }
668
669            let mut best = dp[distance_index(i, j, cols)];
670
671            for left_arc in left.incoming_arcs(i) {
672                for right_arc in right.incoming_arcs(j) {
673                    let cost = usize::from(left_arc.symbol != right_arc.symbol);
674                    let candidate =
675                        dp[distance_index(left_arc.src, right_arc.src, cols)].saturating_add(cost);
676                    best = best.min(candidate);
677                }
678            }
679
680            for left_arc in left.incoming_arcs(i) {
681                let candidate = dp[distance_index(left_arc.src, j, cols)].saturating_add(1);
682                best = best.min(candidate);
683            }
684
685            for right_arc in right.incoming_arcs(j) {
686                let candidate = dp[distance_index(i, right_arc.src, cols)].saturating_add(1);
687                best = best.min(candidate);
688            }
689
690            dp[distance_index(i, j, cols)] = best;
691        }
692    }
693
694    dp[distance_index(left.end(), right.end(), cols)]
695}
696
697/// Computes lattice edit distance with adjacent transpositions.
698///
699/// # Panics
700///
701/// Panics when the DP matrix would exceed [`MAX_DISTANCE_MATRIX_CELLS`].
702/// Use [`try_damerau_distance`] for external or otherwise untrusted lattices.
703pub fn damerau_distance(left: &Lattice, right: &Lattice) -> usize {
704    try_damerau_distance(left, right).expect("distance matrix should fit")
705}
706
707/// Computes lattice edit distance with adjacent transpositions and explicit
708/// matrix-size validation.
709pub fn try_damerau_distance(left: &Lattice, right: &Lattice) -> Result<usize, DistanceError> {
710    let rows = left.node_count();
711    let cols = right.node_count();
712    let cells = checked_distance_matrix_cells(rows, cols)?;
713    Ok(damerau_distance_impl(left, right, cells))
714}
715
716fn damerau_distance_impl(left: &Lattice, right: &Lattice, cells: usize) -> usize {
717    let cols = right.node_count();
718    let mut dp = vec![INF; cells];
719    dp[distance_index(left.start(), right.start(), cols)] = 0;
720
721    for i in left.start()..=left.end() {
722        for j in right.start()..=right.end() {
723            if i == left.start() && j == right.start() {
724                continue;
725            }
726
727            let mut best = dp[distance_index(i, j, cols)];
728
729            for left_arc in left.incoming_arcs(i) {
730                for right_arc in right.incoming_arcs(j) {
731                    let cost = usize::from(left_arc.symbol != right_arc.symbol);
732                    let candidate =
733                        dp[distance_index(left_arc.src, right_arc.src, cols)].saturating_add(cost);
734                    best = best.min(candidate);
735                }
736            }
737
738            for left_arc in left.incoming_arcs(i) {
739                let candidate = dp[distance_index(left_arc.src, j, cols)].saturating_add(1);
740                best = best.min(candidate);
741            }
742
743            for right_arc in right.incoming_arcs(j) {
744                let candidate = dp[distance_index(i, right_arc.src, cols)].saturating_add(1);
745                best = best.min(candidate);
746            }
747
748            for left_second in left.incoming_arcs(i) {
749                for right_second in right.incoming_arcs(j) {
750                    for left_first in left.incoming_arcs(left_second.src) {
751                        for right_first in right.incoming_arcs(right_second.src) {
752                            if left_first.symbol == right_second.symbol
753                                && left_second.symbol == right_first.symbol
754                            {
755                                let candidate = dp
756                                    [distance_index(left_first.src, right_first.src, cols)]
757                                .saturating_add(1);
758                                best = best.min(candidate);
759                            }
760                        }
761                    }
762                }
763            }
764
765            dp[distance_index(i, j, cols)] = best;
766        }
767    }
768
769    dp[distance_index(left.end(), right.end(), cols)]
770}
771
772/// Computes edit distance and one best trace.
773///
774/// The trace is meaningful for lattices whose `start` and `end` states are
775/// reachable through modeled arcs. `Lattice::from_paths` and
776/// `Lattice::from_symbol_paths_compact` construct that shape; arbitrary
777/// `from_edges` DAGs can represent unreachable endpoints, where the distance
778/// remains effectively infinite and the returned trace can be empty.
779///
780/// # Panics
781///
782/// Panics when the trace DP matrix would exceed [`MAX_TRACE_MATRIX_CELLS`].
783/// Use [`try_distance_with_trace`] for external or otherwise untrusted
784/// lattices.
785pub fn distance_with_trace(left: &Lattice, right: &Lattice) -> DistanceTrace {
786    try_distance_with_trace(left, right).expect("distance matrix should fit")
787}
788
789/// Computes edit distance and one best trace with explicit matrix-size
790/// validation.
791pub fn try_distance_with_trace(
792    left: &Lattice,
793    right: &Lattice,
794) -> Result<DistanceTrace, DistanceError> {
795    let rows = left.node_count();
796    let cols = right.node_count();
797    let cells = checked_trace_matrix_cells(rows, cols)?;
798    Ok(distance_with_trace_impl(left, right, cells))
799}
800
801fn distance_with_trace_impl(left: &Lattice, right: &Lattice, cells: usize) -> DistanceTrace {
802    let cols = right.node_count();
803    let mut dp = vec![INF; cells];
804    let mut back = vec![None; cells];
805    dp[distance_index(left.start(), right.start(), cols)] = 0;
806
807    for i in left.start()..=left.end() {
808        for j in right.start()..=right.end() {
809            if i == left.start() && j == right.start() {
810                continue;
811            }
812
813            let index = distance_index(i, j, cols);
814            let mut best = dp[index];
815            let mut best_back = back[index].clone();
816
817            for left_arc in left.incoming_arcs(i) {
818                for right_arc in right.incoming_arcs(j) {
819                    let cost = usize::from(left_arc.symbol != right_arc.symbol);
820                    let candidate =
821                        dp[distance_index(left_arc.src, right_arc.src, cols)].saturating_add(cost);
822                    if candidate < best {
823                        best = candidate;
824                        best_back = Some(Backpointer {
825                            prev_i: left_arc.src,
826                            prev_j: right_arc.src,
827                            step: TraceStep {
828                                op: if cost == 0 {
829                                    EditOp::Match
830                                } else {
831                                    EditOp::Substitute
832                                },
833                                left: Some(left_arc.symbol),
834                                right: Some(right_arc.symbol),
835                            },
836                        });
837                    }
838                }
839            }
840
841            for left_arc in left.incoming_arcs(i) {
842                let candidate = dp[distance_index(left_arc.src, j, cols)].saturating_add(1);
843                if candidate < best {
844                    best = candidate;
845                    best_back = Some(Backpointer {
846                        prev_i: left_arc.src,
847                        prev_j: j,
848                        step: TraceStep {
849                            op: EditOp::Delete,
850                            left: Some(left_arc.symbol),
851                            right: None,
852                        },
853                    });
854                }
855            }
856
857            for right_arc in right.incoming_arcs(j) {
858                let candidate = dp[distance_index(i, right_arc.src, cols)].saturating_add(1);
859                if candidate < best {
860                    best = candidate;
861                    best_back = Some(Backpointer {
862                        prev_i: i,
863                        prev_j: right_arc.src,
864                        step: TraceStep {
865                            op: EditOp::Insert,
866                            left: None,
867                            right: Some(right_arc.symbol),
868                        },
869                    });
870                }
871            }
872
873            dp[index] = best;
874            back[index] = best_back;
875        }
876    }
877
878    let mut steps = Vec::new();
879    let mut i = left.end();
880    let mut j = right.end();
881    while i != left.start() || j != right.start() {
882        let Some(prev) = &back[distance_index(i, j, cols)] else {
883            break;
884        };
885        steps.push(prev.step.clone());
886        i = prev.prev_i;
887        j = prev.prev_j;
888    }
889    steps.reverse();
890
891    DistanceTrace {
892        distance: dp[distance_index(left.end(), right.end(), cols)],
893        steps,
894    }
895}
896
897/// Returns whether lattice edit distance is at most `threshold`.
898///
899/// # Panics
900///
901/// Panics when the DP matrix would exceed [`MAX_DISTANCE_MATRIX_CELLS`].
902/// Use [`try_within_distance`] for external or otherwise untrusted lattices.
903pub fn within_distance(left: &Lattice, right: &Lattice, threshold: usize) -> bool {
904    try_within_distance(left, right, threshold).expect("distance matrix should fit")
905}
906
907/// Returns whether lattice edit distance is at most `threshold`, with explicit
908/// matrix-size validation.
909pub fn try_within_distance(
910    left: &Lattice,
911    right: &Lattice,
912    threshold: usize,
913) -> Result<bool, DistanceError> {
914    let rows = left.node_count();
915    let cols = right.node_count();
916    let cells = checked_distance_matrix_cells(rows, cols)?;
917    Ok(within_distance_impl(left, right, threshold, cells))
918}
919
920fn within_distance_impl(left: &Lattice, right: &Lattice, threshold: usize, cells: usize) -> bool {
921    let cols = right.node_count();
922    let mut dp = vec![INF; cells];
923    let mut queued = vec![false; cells];
924    let mut queue = VecDeque::new();
925    let start = distance_index(left.start(), right.start(), cols);
926    dp[start] = 0;
927    queued[start] = true;
928    queue.push_back((left.start(), right.start()));
929    let mut search = ThresholdSearch {
930        threshold,
931        cols,
932        dp: &mut dp,
933        queued: &mut queued,
934        queue: &mut queue,
935    };
936
937    while let Some((i, j)) = search.queue.pop_front() {
938        let index = distance_index(i, j, cols);
939        search.queued[index] = false;
940        let current = search.dp[index];
941        if current > threshold {
942            continue;
943        }
944        if i == left.end() && j == right.end() {
945            return true;
946        }
947
948        for right_arc in right.outgoing_arcs(j) {
949            search.relax(i, right_arc.dst, current.saturating_add(1));
950        }
951
952        for left_arc in left.outgoing_arcs(i) {
953            search.relax(left_arc.dst, j, current.saturating_add(1));
954        }
955
956        for left_arc in left.outgoing_arcs(i) {
957            for right_arc in right.outgoing_arcs(j) {
958                let cost = usize::from(left_arc.symbol != right_arc.symbol);
959                search.relax(left_arc.dst, right_arc.dst, current.saturating_add(cost));
960            }
961        }
962    }
963
964    false
965}
966
967/// Returns whether lattice Damerau-Levenshtein distance is at most
968/// `threshold`.
969///
970/// # Panics
971///
972/// Panics when the DP matrix would exceed [`MAX_DISTANCE_MATRIX_CELLS`].
973/// Use [`try_within_damerau_distance`] for external or otherwise untrusted
974/// lattices.
975pub fn within_damerau_distance(left: &Lattice, right: &Lattice, threshold: usize) -> bool {
976    try_within_damerau_distance(left, right, threshold).expect("distance matrix should fit")
977}
978
979/// Returns whether lattice Damerau-Levenshtein distance is at most
980/// `threshold`, with explicit matrix-size validation.
981pub fn try_within_damerau_distance(
982    left: &Lattice,
983    right: &Lattice,
984    threshold: usize,
985) -> Result<bool, DistanceError> {
986    let rows = left.node_count();
987    let cols = right.node_count();
988    let cells = checked_distance_matrix_cells(rows, cols)?;
989    Ok(within_damerau_distance_impl(left, right, threshold, cells))
990}
991
992fn within_damerau_distance_impl(
993    left: &Lattice,
994    right: &Lattice,
995    threshold: usize,
996    cells: usize,
997) -> bool {
998    let cols = right.node_count();
999    let mut dp = vec![INF; cells];
1000    let mut queued = vec![false; cells];
1001    let mut queue = VecDeque::new();
1002    let start = distance_index(left.start(), right.start(), cols);
1003    dp[start] = 0;
1004    queued[start] = true;
1005    queue.push_back((left.start(), right.start()));
1006    let mut search = ThresholdSearch {
1007        threshold,
1008        cols,
1009        dp: &mut dp,
1010        queued: &mut queued,
1011        queue: &mut queue,
1012    };
1013
1014    while let Some((i, j)) = search.queue.pop_front() {
1015        let index = distance_index(i, j, cols);
1016        search.queued[index] = false;
1017        let current = search.dp[index];
1018        if current > threshold {
1019            continue;
1020        }
1021        if i == left.end() && j == right.end() {
1022            return true;
1023        }
1024
1025        for right_arc in right.outgoing_arcs(j) {
1026            search.relax(i, right_arc.dst, current.saturating_add(1));
1027        }
1028
1029        for left_arc in left.outgoing_arcs(i) {
1030            search.relax(left_arc.dst, j, current.saturating_add(1));
1031        }
1032
1033        for left_arc in left.outgoing_arcs(i) {
1034            for right_arc in right.outgoing_arcs(j) {
1035                let cost = usize::from(left_arc.symbol != right_arc.symbol);
1036                search.relax(left_arc.dst, right_arc.dst, current.saturating_add(cost));
1037            }
1038        }
1039
1040        for left_first in left.outgoing_arcs(i) {
1041            for right_first in right.outgoing_arcs(j) {
1042                for left_second in left.outgoing_arcs(left_first.dst) {
1043                    for right_second in right.outgoing_arcs(right_first.dst) {
1044                        if left_first.symbol == right_second.symbol
1045                            && left_second.symbol == right_first.symbol
1046                        {
1047                            search.relax(
1048                                left_second.dst,
1049                                right_second.dst,
1050                                current.saturating_add(1),
1051                            );
1052                        }
1053                    }
1054                }
1055            }
1056        }
1057    }
1058
1059    false
1060}
1061
1062fn checked_distance_matrix_cells(rows: usize, cols: usize) -> Result<usize, DistanceError> {
1063    checked_matrix_cells(rows, cols, MAX_DISTANCE_MATRIX_CELLS)
1064}
1065
1066fn checked_trace_matrix_cells(rows: usize, cols: usize) -> Result<usize, DistanceError> {
1067    checked_matrix_cells(rows, cols, MAX_TRACE_MATRIX_CELLS)
1068}
1069
1070fn checked_matrix_cells(
1071    rows: usize,
1072    cols: usize,
1073    max_cells: usize,
1074) -> Result<usize, DistanceError> {
1075    let cells = rows
1076        .checked_mul(cols)
1077        .ok_or(DistanceError::MatrixTooLarge {
1078            rows,
1079            cols,
1080            max_cells,
1081        })?;
1082    if cells > max_cells {
1083        return Err(DistanceError::MatrixTooLarge {
1084            rows,
1085            cols,
1086            max_cells,
1087        });
1088    }
1089    Ok(cells)
1090}
1091
1092fn distance_index(i: usize, j: usize, cols: usize) -> usize {
1093    i * cols + j
1094}
1095
1096struct ThresholdSearch<'a> {
1097    threshold: usize,
1098    cols: usize,
1099    dp: &'a mut [usize],
1100    queued: &'a mut [bool],
1101    queue: &'a mut VecDeque<(usize, usize)>,
1102}
1103
1104impl ThresholdSearch<'_> {
1105    fn relax(&mut self, i: usize, j: usize, candidate: usize) {
1106        if candidate > self.threshold {
1107            return;
1108        }
1109        let index = distance_index(i, j, self.cols);
1110        if candidate >= self.dp[index] {
1111            return;
1112        }
1113        self.dp[index] = candidate;
1114        if !self.queued[index] {
1115            self.queued[index] = true;
1116            self.queue.push_back((i, j));
1117        }
1118    }
1119}
1120
1121/// Converts an edit distance and sequence lengths to a similarity score.
1122///
1123/// The result is clamped to `0.0..=1.0`; equal empty inputs return `1.0`.
1124pub fn normalized_similarity_from_distance(
1125    distance: usize,
1126    left_len: usize,
1127    right_len: usize,
1128) -> f64 {
1129    let max_len = left_len.max(right_len);
1130    if max_len == 0 {
1131        return 1.0;
1132    }
1133
1134    (1.0 - distance as f64 / max_len as f64).clamp(0.0, 1.0)
1135}
1136
1137/// Computes normalized Levenshtein similarity for two strings.
1138pub fn normalized_similarity_str(left: &str, right: &str) -> f64 {
1139    normalized_similarity_from_distance(
1140        levenshtein_str(left, right),
1141        left.chars().count(),
1142        right.chars().count(),
1143    )
1144}
1145
1146/// Computes Levenshtein distance over Unicode scalar values.
1147pub fn levenshtein_str(left: &str, right: &str) -> usize {
1148    let left = left.chars().collect::<Vec<_>>();
1149    let right = right.chars().collect::<Vec<_>>();
1150    levenshtein_chars(&left, &right)
1151}
1152
1153/// Computes optimal string alignment Damerau-Levenshtein distance.
1154///
1155/// # Panics
1156///
1157/// Panics when the DP matrix would exceed [`MAX_DISTANCE_MATRIX_CELLS`].
1158/// Use [`try_damerau_levenshtein_str`] for external or otherwise untrusted
1159/// strings.
1160pub fn damerau_levenshtein_str(left: &str, right: &str) -> usize {
1161    try_damerau_levenshtein_str(left, right).expect("distance matrix should fit")
1162}
1163
1164/// Computes optimal string alignment Damerau-Levenshtein distance with
1165/// explicit matrix-size validation.
1166pub fn try_damerau_levenshtein_str(left: &str, right: &str) -> Result<usize, DistanceError> {
1167    let left = left.chars().collect::<Vec<_>>();
1168    let right = right.chars().collect::<Vec<_>>();
1169    damerau_levenshtein_chars(&left, &right)
1170}
1171
1172fn levenshtein_chars(left: &[char], right: &[char]) -> usize {
1173    let (shorter, longer) = if left.len() <= right.len() {
1174        (left, right)
1175    } else {
1176        (right, left)
1177    };
1178    let mut previous = (0..=shorter.len()).collect::<Vec<_>>();
1179    let mut current = vec![0; shorter.len() + 1];
1180
1181    for (i, &longer_ch) in longer.iter().enumerate() {
1182        current[0] = i + 1;
1183        for (j, &shorter_ch) in shorter.iter().enumerate() {
1184            let substitution_cost = usize::from(longer_ch != shorter_ch);
1185            current[j + 1] = (previous[j + 1] + 1)
1186                .min(current[j] + 1)
1187                .min(previous[j] + substitution_cost);
1188        }
1189        std::mem::swap(&mut previous, &mut current);
1190    }
1191
1192    previous[shorter.len()]
1193}
1194
1195fn damerau_levenshtein_chars(left: &[char], right: &[char]) -> Result<usize, DistanceError> {
1196    let rows = left.len() + 1;
1197    let cols = right.len() + 1;
1198    let cells = checked_distance_matrix_cells(rows, cols)?;
1199    let mut dp = vec![0; cells];
1200
1201    for i in 0..rows {
1202        dp[distance_index(i, 0, cols)] = i;
1203    }
1204    for j in 0..cols {
1205        dp[distance_index(0, j, cols)] = j;
1206    }
1207
1208    for i in 1..rows {
1209        for j in 1..cols {
1210            let substitution_cost = usize::from(left[i - 1] != right[j - 1]);
1211            let mut best = (dp[distance_index(i - 1, j, cols)] + 1)
1212                .min(dp[distance_index(i, j - 1, cols)] + 1)
1213                .min(dp[distance_index(i - 1, j - 1, cols)] + substitution_cost);
1214
1215            if i > 1 && j > 1 && left[i - 1] == right[j - 2] && left[i - 2] == right[j - 1] {
1216                best = best.min(dp[distance_index(i - 2, j - 2, cols)] + 1);
1217            }
1218
1219            dp[distance_index(i, j, cols)] = best;
1220        }
1221    }
1222
1223    Ok(dp[distance_index(left.len(), right.len(), cols)])
1224}
1225
1226#[cfg(test)]
1227mod tests {
1228    use super::*;
1229
1230    fn symbols_to_string(symbols: &[Symbol]) -> String {
1231        symbols
1232            .iter()
1233            .map(|&symbol| char::from_u32(symbol).expect("test symbol should be a char"))
1234            .collect()
1235    }
1236
1237    fn string_distance(left: &str, right: &str) -> usize {
1238        distance(&Lattice::from_paths([left]), &Lattice::from_paths([right]))
1239    }
1240
1241    fn string_damerau_distance(left: &str, right: &str) -> usize {
1242        damerau_distance(&Lattice::from_paths([left]), &Lattice::from_paths([right]))
1243    }
1244
1245    fn assert_close(left: f64, right: f64) {
1246        assert!((left - right).abs() < f64::EPSILON);
1247    }
1248
1249    #[test]
1250    fn try_path_constructors_report_invalid_inputs() {
1251        assert!(matches!(
1252            Lattice::try_from_paths(std::iter::empty::<&str>()),
1253            Err(LatticeError::Empty)
1254        ));
1255        assert!(matches!(
1256            Lattice::try_from_paths(["", "a"]),
1257            Err(LatticeError::MixedEmptyAndNonEmptyPaths)
1258        ));
1259        assert!(matches!(
1260            Lattice::try_from_symbol_paths_compact([Vec::<Symbol>::new(), vec![1]]),
1261            Err(LatticeError::MixedEmptyAndNonEmptyPaths)
1262        ));
1263    }
1264
1265    #[test]
1266    fn linear_lattice_matches_levenshtein_distance() {
1267        assert_eq!(string_distance("kitten", "sitting"), 3);
1268        assert_eq!(string_distance("insat", "insatu"), 1);
1269        assert_eq!(string_distance("abc", "abc"), 0);
1270        assert_eq!(string_distance("abc", "axc"), 1);
1271    }
1272
1273    #[test]
1274    fn normalized_similarity_uses_max_length() {
1275        assert_close(
1276            normalized_similarity_from_distance(1, "abc".chars().count(), "adc".chars().count()),
1277            2.0 / 3.0,
1278        );
1279        assert_close(normalized_similarity_str("abc", "adc"), 2.0 / 3.0);
1280        assert_eq!(normalized_similarity_str("", ""), 1.0);
1281        assert_eq!(normalized_similarity_from_distance(4, 1, 2), 0.0);
1282    }
1283
1284    #[test]
1285    fn parallel_paths_take_minimum_distance() {
1286        let left = Lattice::from_paths(["insat"]);
1287        let right = Lattice::from_paths(["insatu", "insat", "zzzzz"]);
1288
1289        assert_eq!(distance(&left, &right), 0);
1290    }
1291
1292    #[test]
1293    fn fallible_distance_apis_match_convenience_apis() {
1294        let left = Lattice::from_paths(["abcd"]);
1295        let right = Lattice::from_paths(["acbd"]);
1296
1297        assert_eq!(
1298            try_distance(&left, &right).unwrap(),
1299            distance(&left, &right)
1300        );
1301        assert_eq!(
1302            try_damerau_distance(&left, &right).unwrap(),
1303            damerau_distance(&left, &right)
1304        );
1305        assert_eq!(
1306            try_distance_with_trace(&left, &right).unwrap(),
1307            distance_with_trace(&left, &right)
1308        );
1309        assert_eq!(
1310            try_within_distance(&left, &right, 2).unwrap(),
1311            within_distance(&left, &right, 2)
1312        );
1313        assert_eq!(
1314            try_within_damerau_distance(&left, &right, 1).unwrap(),
1315            within_damerau_distance(&left, &right, 1)
1316        );
1317    }
1318
1319    #[test]
1320    fn fallible_distance_apis_reject_large_matrices() {
1321        let node_count = 4001;
1322        let lattice = Lattice::from_edges(node_count, 0, node_count - 1, Vec::new()).unwrap();
1323
1324        assert!(matches!(
1325            try_distance(&lattice, &lattice),
1326            Err(DistanceError::MatrixTooLarge {
1327                rows: 4001,
1328                cols: 4001,
1329                max_cells: MAX_DISTANCE_MATRIX_CELLS,
1330            })
1331        ));
1332        assert!(matches!(
1333            try_damerau_distance(&lattice, &lattice),
1334            Err(DistanceError::MatrixTooLarge { .. })
1335        ));
1336        assert!(matches!(
1337            try_distance_with_trace(&lattice, &lattice),
1338            Err(DistanceError::MatrixTooLarge { .. })
1339        ));
1340        assert!(matches!(
1341            try_within_distance(&lattice, &lattice, 1),
1342            Err(DistanceError::MatrixTooLarge { .. })
1343        ));
1344        assert!(matches!(
1345            try_within_damerau_distance(&lattice, &lattice, 1),
1346            Err(DistanceError::MatrixTooLarge { .. })
1347        ));
1348    }
1349
1350    #[test]
1351    fn trace_uses_lower_matrix_limit_than_plain_distance() {
1352        let node_count = 1415;
1353        let lattice = Lattice::from_edges(node_count, 0, node_count - 1, Vec::new()).unwrap();
1354
1355        assert!(try_distance(&lattice, &lattice).is_ok());
1356        assert!(matches!(
1357            try_distance_with_trace(&lattice, &lattice),
1358            Err(DistanceError::MatrixTooLarge {
1359                rows: 1415,
1360                cols: 1415,
1361                max_cells: MAX_TRACE_MATRIX_CELLS,
1362            })
1363        ));
1364    }
1365
1366    #[test]
1367    fn fallible_arc_accessors_report_out_of_range_nodes() {
1368        let lattice = Lattice::from_paths(["ab"]);
1369
1370        assert_eq!(
1371            lattice.try_incoming_arcs(999).map(|arcs| arcs.count()),
1372            None
1373        );
1374        assert_eq!(
1375            lattice.try_outgoing_arcs(999).map(|arcs| arcs.count()),
1376            None
1377        );
1378        assert_eq!(
1379            lattice.try_outgoing_arcs(0).map(|arcs| arcs.count()),
1380            Some(1)
1381        );
1382    }
1383
1384    #[test]
1385    fn trace_free_distance_matches_trace_distance() {
1386        let left = Lattice::from_paths(["insat", "insatu"]);
1387        let right = Lattice::from_paths(["inzatu", "insatsu"]);
1388
1389        assert_eq!(
1390            distance(&left, &right),
1391            distance_with_trace(&left, &right).distance
1392        );
1393    }
1394
1395    #[test]
1396    fn compact_paths_share_prefix_nodes() {
1397        let lattice = Lattice::from_symbol_paths_compact([
1398            "chadougu"
1399                .chars()
1400                .map(|ch| ch as Symbol)
1401                .collect::<Vec<_>>(),
1402            "chadoogu"
1403                .chars()
1404                .map(|ch| ch as Symbol)
1405                .collect::<Vec<_>>(),
1406        ]);
1407
1408        assert_eq!(distance(&lattice, &Lattice::from_paths(["chadougu"])), 0);
1409        assert_eq!(distance(&lattice, &Lattice::from_paths(["chadoogu"])), 0);
1410        assert!(lattice.node_count() < Lattice::from_paths(["chadougu", "chadoogu"]).node_count());
1411    }
1412
1413    #[test]
1414    fn compact_paths_share_equivalent_suffix_nodes() {
1415        let lattice = Lattice::from_symbol_paths_compact([
1416            "xab".chars().map(|ch| ch as Symbol).collect::<Vec<_>>(),
1417            "yab".chars().map(|ch| ch as Symbol).collect::<Vec<_>>(),
1418        ]);
1419
1420        assert_eq!(distance(&lattice, &Lattice::from_paths(["xab"])), 0);
1421        assert_eq!(distance(&lattice, &Lattice::from_paths(["yab"])), 0);
1422        assert_eq!(distance(&lattice, &Lattice::from_paths(["zab"])), 1);
1423        assert!(lattice.node_count() < Lattice::from_paths(["xab", "yab"]).node_count());
1424    }
1425
1426    #[test]
1427    fn compact_paths_preserve_prefix_words() {
1428        let lattice = Lattice::from_symbol_paths_compact([
1429            "a".chars().map(|ch| ch as Symbol).collect::<Vec<_>>(),
1430            "ab".chars().map(|ch| ch as Symbol).collect::<Vec<_>>(),
1431        ]);
1432
1433        assert_eq!(distance(&lattice, &Lattice::from_paths(["a"])), 0);
1434        assert_eq!(distance(&lattice, &Lattice::from_paths(["ab"])), 0);
1435    }
1436
1437    #[test]
1438    fn distance_with_trace_returns_best_path_pair() {
1439        let left = Lattice::from_paths(["chadougu"]);
1440        let right = Lattice::from_paths(["tyadougu", "chadougu"]);
1441
1442        let trace = distance_with_trace(&left, &right);
1443
1444        assert_eq!(trace.distance, 0);
1445        assert_eq!(symbols_to_string(&trace.left_symbols()), "chadougu");
1446        assert_eq!(symbols_to_string(&trace.right_symbols()), "chadougu");
1447        assert!(trace.steps.iter().all(|step| step.op == EditOp::Match));
1448    }
1449
1450    #[test]
1451    fn trace_includes_insertions_and_deletions() {
1452        let trace = distance_with_trace(
1453            &Lattice::from_paths(["insat"]),
1454            &Lattice::from_paths(["insatu"]),
1455        );
1456
1457        assert_eq!(trace.distance, 1);
1458        assert_eq!(symbols_to_string(&trace.left_symbols()), "insat");
1459        assert_eq!(symbols_to_string(&trace.right_symbols()), "insatu");
1460        assert_eq!(trace.steps.last().map(|step| step.op), Some(EditOp::Insert));
1461    }
1462
1463    #[test]
1464    fn threshold_check_uses_distance() {
1465        let left = Lattice::from_paths(["insat"]);
1466        let right = Lattice::from_paths(["insatu"]);
1467
1468        assert!(within_distance(&left, &right, 1));
1469        assert!(!within_distance(&left, &right, 0));
1470    }
1471
1472    #[test]
1473    fn threshold_check_prunes_but_preserves_lattice_paths() {
1474        let left = Lattice::from_paths(["chadougu", "tyadougu"]);
1475        let right = Lattice::from_paths(["chadoogu", "zzzzzzzz"]);
1476
1477        assert_eq!(distance(&left, &right), 1);
1478        assert!(within_distance(&left, &right, 1));
1479        assert!(!within_distance(&left, &right, 0));
1480    }
1481
1482    #[test]
1483    fn linear_lattice_damerau_matches_string_damerau_distance() {
1484        for (left, right) in [
1485            ("ca", "ac"),
1486            ("abc", "acb"),
1487            ("abcdef", "abcedf"),
1488            ("moine", "mione"),
1489            ("マトリッツォ", "マリトッツォ"),
1490        ] {
1491            assert_eq!(
1492                string_damerau_distance(left, right),
1493                damerau_levenshtein_str(left, right),
1494                "{left:?} / {right:?}"
1495            );
1496        }
1497    }
1498
1499    #[test]
1500    fn lattice_damerau_takes_transposition_across_candidate_paths() {
1501        let left = Lattice::from_paths(["abc", "axc"]);
1502        let right = Lattice::from_paths(["acb"]);
1503
1504        assert_eq!(distance(&left, &right), 2);
1505        assert_eq!(damerau_distance(&left, &right), 1);
1506    }
1507
1508    #[test]
1509    fn lattice_damerau_supports_branched_two_arc_transposition() {
1510        let left = Lattice::from_edges(
1511            4,
1512            0,
1513            3,
1514            vec![
1515                Arc::new(0, 1, 'a' as Symbol),
1516                Arc::new(0, 1, 'x' as Symbol),
1517                Arc::new(1, 3, 'b' as Symbol),
1518                Arc::new(1, 3, 'y' as Symbol),
1519            ],
1520        )
1521        .unwrap();
1522        let right = Lattice::from_paths(["ba"]);
1523
1524        assert_eq!(damerau_distance(&left, &right), 1);
1525    }
1526
1527    #[test]
1528    fn threshold_damerau_check_uses_lattice_damerau_distance() {
1529        let left = Lattice::from_paths(["abc", "axc"]);
1530        let right = Lattice::from_paths(["acb"]);
1531
1532        assert!(within_damerau_distance(&left, &right, 1));
1533        assert!(!within_damerau_distance(&left, &right, 0));
1534    }
1535
1536    #[test]
1537    fn from_edges_rejects_non_topological_arcs() {
1538        let result = Lattice::from_edges(2, 0, 1, vec![Arc::new(1, 0, 'a' as Symbol)]);
1539
1540        assert!(matches!(
1541            result,
1542            Err(LatticeError::InvalidArcOrder { src: 1, dst: 0 })
1543        ));
1544    }
1545
1546    #[test]
1547    fn surface_levenshtein_counts_unicode_chars() {
1548        assert_eq!(levenshtein_str("kitten", "sitting"), 3);
1549        assert_eq!(levenshtein_str("いんさt", "印刷"), 4);
1550        assert_eq!(levenshtein_str("マトリッツォ", "マリトッツォ"), 2);
1551    }
1552
1553    #[test]
1554    fn surface_damerau_counts_adjacent_transposition() {
1555        assert_eq!(damerau_levenshtein_str("ca", "ac"), 1);
1556        assert_eq!(try_damerau_levenshtein_str("ca", "ac").unwrap(), 1);
1557        assert_eq!(damerau_levenshtein_str("マトリッツォ", "マリトッツォ"), 1);
1558        assert_eq!(damerau_levenshtein_str("いんさt", "印刷"), 4);
1559    }
1560}