Skip to main content

zeppelin_embed/fts/
fuzzy.rs

1//! Bounded fuzzy matching by dictionary-guided Levenshtein pruning.
2//!
3//! # Why not enumerate the dictionary
4//!
5//! The naive answer computes an edit distance against every term. That is
6//! linear in vocabulary size per query term and is exactly what the counter
7//! contract in `tests/lexical_extras.rs` forbids.
8//!
9//! Instead this walks the SORTED dictionary carrying an incremental
10//! Levenshtein row. Because terms are sorted, consecutive terms share a
11//! prefix, and the row for that shared prefix does not need recomputing.
12//! Better: if the minimum value in the row for a prefix already exceeds the
13//! distance budget, then no term starting with that prefix can match, and
14//! the walk skips the whole run in one seek rather than testing each term.
15//! That is the same pruning an FST intersection performs, without an FST.
16//!
17//! # Policy (task 15 D3)
18//!
19//! From the spec, which took it from Meilisearch's tested policy:
20//!
21//! - distance 1 for terms of length >= 5, distance 2 for length >= 9;
22//! - never on `no_fuzzy` tokens — identifiers, numbers, SKUs. A fuzzy match
23//!   on `i-485` or a ticket id is almost always wrong;
24//! - the first character must match, which is what makes the dictionary
25//!   walk cheap and also what users expect;
26//! - candidates enter scoring as low-weight variants and are always
27//!   reported as fuzzy in diagnostics. Fuzzy never silently replaces exact
28//!   semantics.
29//!
30//! # Known limitation: transpositions cost two
31//!
32//! The metric is plain Levenshtein, so swapping two adjacent letters —
33//! `recieve` for `receive`, `teh` for `the` — costs two edits, not one.
34//! Damerau-Levenshtein charges one. Since the policy grants distance 1 to
35//! terms of length 5..8, the single most common English typo class is out
36//! of reach for short words under the shipped policy.
37//!
38//! This is recorded rather than fixed because moving to Damerau changes
39//! which candidates every fuzzy query returns, and that is a retrieval
40//! semantics change that belongs to a measured decision, not to a
41//! convenience edit. The distance table was NOT widened to hide it.
42
43use super::dict::TermDictionary;
44
45/// Largest edit distance this engine will ever consider.
46pub const MAX_EDIT_DISTANCE: u32 = 2;
47
48/// Query-local fixed-width rows for the structured operator's byte distance.
49/// This matcher has no first-character, minimum-length or no-fuzzy policy.
50/// Only cells within the allowed diagonal band can belong to an accepted path.
51pub(crate) struct BoundedDistance {
52    previous: [u32; 5],
53    current: [u32; 5],
54    #[cfg(any(test, feature = "test-seams"))]
55    cells: usize,
56}
57
58impl BoundedDistance {
59    pub(crate) fn new() -> Self {
60        #[cfg(any(test, feature = "test-seams"))]
61        super::preparation_observer::fuzzy_scratch();
62        Self {
63            previous: [0; 5],
64            current: [0; 5],
65            #[cfg(any(test, feature = "test-seams"))]
66            cells: 0,
67        }
68    }
69
70    /// Returns the exact accepted distance, or None when the budget is exceeded.
71    /// The structured query validates the maximum before constructing scratch.
72    #[cfg(test)]
73    pub(crate) fn distance(&mut self, left: &[u8], right: &[u8], maximum: u32) -> Option<u32> {
74        let mut work = super::control::WorkCheck::new(|| Ok::<(), std::convert::Infallible>(()));
75        match self.distance_controlled(left, right, maximum, &mut work) {
76            Ok(distance) => distance,
77            Err(never) => match never {},
78        }
79    }
80
81    pub(crate) fn distance_controlled<E>(
82        &mut self,
83        left: &[u8],
84        right: &[u8],
85        maximum: u32,
86        work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
87    ) -> Result<Option<u32>, E> {
88        work.check_now()?;
89        #[cfg(any(test, feature = "test-seams"))]
90        {
91            self.cells = 0;
92        }
93        let result = self.bounded(left, right, maximum, work);
94        // Record the partial work even when a checkpoint stops the candidate.
95        #[cfg(any(test, feature = "test-seams"))]
96        super::preparation_observer::fuzzy_distance(self.cells, 0);
97        result
98    }
99
100    fn bounded<E>(
101        &mut self,
102        left: &[u8],
103        right: &[u8],
104        maximum: u32,
105        work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
106    ) -> Result<Option<u32>, E> {
107        let band = maximum as usize;
108        if maximum > MAX_EDIT_DISTANCE || left.len().abs_diff(right.len()) > band {
109            return Ok(None);
110        }
111        let infinity = maximum + 1;
112        let mut previous_start = 0;
113        let mut previous_end = band.min(right.len());
114        for (column, cell) in self.previous.iter_mut().enumerate() {
115            *cell = if column <= previous_end {
116                column as u32
117            } else {
118                infinity
119            };
120        }
121        for (row, byte) in left.iter().enumerate() {
122            work.step()?;
123            let row = row + 1;
124            let start = row.saturating_sub(band);
125            let end = row.saturating_add(band).min(right.len());
126            let mut row_minimum = infinity;
127            let mut preceding = infinity;
128            for (column, cell) in (start..=end).zip(self.current.iter_mut()) {
129                work.step()?;
130                let value = if column == 0 {
131                    row as u32 // This column exists only while row <= maximum.
132                } else {
133                    #[cfg(any(test, feature = "test-seams"))]
134                    {
135                        self.cells += 1;
136                    }
137                    let up = band_cell(
138                        &self.previous,
139                        previous_start,
140                        previous_end,
141                        column,
142                        infinity,
143                    );
144                    let diagonal = band_cell(
145                        &self.previous,
146                        previous_start,
147                        previous_end,
148                        column - 1,
149                        infinity,
150                    );
151                    let substitution = u32::from(right.get(column - 1) != Some(byte));
152                    (up + 1)
153                        .min(preceding + 1)
154                        .min(diagonal + substitution)
155                        .min(infinity)
156                };
157                *cell = value;
158                preceding = value;
159                row_minimum = row_minimum.min(value);
160            }
161            if row_minimum > maximum {
162                return Ok(None);
163            }
164            std::mem::swap(&mut self.previous, &mut self.current);
165            previous_start = start;
166            previous_end = end;
167        }
168        let distance = band_cell(
169            &self.previous,
170            previous_start,
171            previous_end,
172            right.len(),
173            infinity,
174        );
175        Ok((distance <= maximum).then_some(distance))
176    }
177}
178
179fn band_cell(row: &[u32; 5], start: usize, end: usize, column: usize, infinity: u32) -> u32 {
180    // Cells outside the active band are deliberately infinite, not missing data.
181    column
182        .checked_sub(start)
183        .filter(|_| column <= end)
184        .and_then(|offset| row.get(offset))
185        .copied()
186        .unwrap_or(infinity)
187}
188
189/// Minimum term length for distance 1.
190pub const MIN_LENGTH_DISTANCE_1: usize = 5;
191
192/// Minimum term length for distance 2.
193pub const MIN_LENGTH_DISTANCE_2: usize = 9;
194
195/// One fuzzy candidate.
196#[derive(Clone, Debug, Eq, PartialEq)]
197pub struct FuzzyCandidate {
198    /// Index of the term in the dictionary's flat order.
199    pub term_index: usize,
200    /// The matched term.
201    pub term: Vec<u8>,
202    /// Edit distance from the query term. Zero means an exact match.
203    pub distance: u32,
204}
205
206/// What a fuzzy walk cost, for the counter contract.
207#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
208pub struct FuzzyCounters {
209    /// Dictionary entries whose distance was actually computed.
210    pub entries_visited: u64,
211    /// Runs skipped wholesale because their prefix already exceeded budget.
212    pub prefix_skips: u64,
213}
214
215/// Returns the edit distance budget the policy allows for a term.
216///
217/// Returns zero when fuzzy matching is not permitted at all, which is the
218/// safe direction: zero distance is an exact match.
219#[must_use]
220pub fn permitted_distance(term: &[u8], no_fuzzy: bool) -> u32 {
221    if no_fuzzy {
222        return 0;
223    }
224    // A term carrying a digit is an identifier or a quantity; both are
225    // wrong to fuzz even when the token flag was not set by the analyzer.
226    if term.iter().any(u8::is_ascii_digit) {
227        return 0;
228    }
229    let length = term.iter().filter(|byte| **byte & 0xC0 != 0x80).count();
230    if length >= MIN_LENGTH_DISTANCE_2 {
231        2
232    } else if length >= MIN_LENGTH_DISTANCE_1 {
233        1
234    } else {
235        0
236    }
237}
238
239/// Computes the DP row after consuming one more byte of the candidate.
240///
241/// `previous` is the row for the candidate prefix one byte shorter, and
242/// `row_index` is that prefix's length. Returning a row rather than a single
243/// distance is what makes prefix pruning possible: if every cell already
244/// exceeds the budget, no extension of this prefix can match.
245fn step_row(query: &[u8], previous: &[u32], row_index: usize, byte: u8) -> Vec<u32> {
246    let width = query.len() + 1;
247    let mut next: Vec<u32> = Vec::with_capacity(width);
248    next.push(u32::try_from(row_index + 1).unwrap_or(u32::MAX));
249    for column in 1..width {
250        let substitution_cost = u32::from(query.get(column - 1).copied() != Some(byte));
251        let deletion = previous
252            .get(column)
253            .copied()
254            .unwrap_or(u32::MAX)
255            .saturating_add(1);
256        let insertion = next
257            .get(column - 1)
258            .copied()
259            .unwrap_or(u32::MAX)
260            .saturating_add(1);
261        let substitution = previous
262            .get(column - 1)
263            .copied()
264            .unwrap_or(u32::MAX)
265            .saturating_add(substitution_cost);
266        next.push(deletion.min(insertion).min(substitution));
267    }
268    next
269}
270
271fn shared_prefix(left: &[u8], right: &[u8]) -> usize {
272    left.iter()
273        .zip(right.iter())
274        .take_while(|(a, b)| a == b)
275        .count()
276}
277
278/// Finds every dictionary term within `max_distance` of `query`.
279///
280/// Walks the sorted dictionary with prefix pruning; never enumerates the
281/// whole vocabulary unless every prefix stays within budget, which only
282/// happens on a pathologically small dictionary.
283#[must_use]
284pub fn search(
285    dictionary: &TermDictionary,
286    query: &[u8],
287    max_distance: u32,
288) -> (Vec<FuzzyCandidate>, FuzzyCounters) {
289    let mut counters = FuzzyCounters::default();
290    let mut candidates = Vec::new();
291    if query.is_empty() || max_distance == 0 {
292        // Distance zero is an exact lookup, which the dictionary answers in
293        // one binary search rather than a walk.
294        if let Some(index) = dictionary.seek_exact(query) {
295            counters.entries_visited = 1;
296            candidates.push(FuzzyCandidate {
297                term_index: index,
298                term: query.to_vec(),
299                distance: 0,
300            });
301        }
302        return (candidates, counters);
303    }
304
305    // The first character must match, so the walk is confined to that
306    // prefix range rather than the whole dictionary.
307    let Some(first) = query.first().copied() else {
308        return (candidates, counters);
309    };
310    let range = match dictionary.prefix_range(&[first]) {
311        Ok(range) => range,
312        Err(_) => return (candidates, counters),
313    };
314
315    // `rows[i]` is the DP row after consuming the first `i` bytes of the
316    // term currently being examined. Moving to the next sorted term keeps
317    // the rows its shared prefix already computed and rebuilds only the
318    // suffix; that is the whole saving, and reusing a row beyond the shared
319    // prefix is exactly the bug this replaced.
320    let identity: Vec<u32> = (0..=query.len())
321        .map(|index| u32::try_from(index).unwrap_or(u32::MAX))
322        .collect();
323    let mut rows: Vec<Vec<u32>> = vec![identity];
324    let mut previous_term: Vec<u8> = Vec::new();
325    let mut index = range.start;
326
327    while index < range.end {
328        let Some(term) = dictionary.term_at(index) else {
329            break;
330        };
331        let prefix = shared_prefix(&previous_term, term).min(rows.len().saturating_sub(1));
332        rows.truncate(prefix + 1);
333        for (offset, byte) in term.iter().enumerate().skip(prefix) {
334            let Some(previous) = rows.get(offset) else {
335                break;
336            };
337            let next = step_row(query, previous, offset, *byte);
338            rows.push(next);
339        }
340        counters.entries_visited = counters.entries_visited.saturating_add(1);
341
342        let Some(final_row) = rows.get(term.len()) else {
343            index += 1;
344            continue;
345        };
346        let distance = final_row.last().copied().unwrap_or(u32::MAX);
347        if distance <= max_distance {
348            candidates.push(FuzzyCandidate {
349                term_index: index,
350                term: term.to_vec(),
351                distance,
352            });
353        }
354
355        // If every cell of this term's row exceeds the budget, no term
356        // extending it can match, so skip the whole run in one seek.
357        let minimum = final_row.iter().copied().min().unwrap_or(u32::MAX);
358        previous_term = term.to_vec();
359        if minimum > max_distance {
360            let skip_to = next_term_outside_prefix(dictionary, term, range.end);
361            if skip_to > index + 1 {
362                counters.prefix_skips = counters.prefix_skips.saturating_add(1);
363            }
364            index = skip_to.max(index + 1);
365            continue;
366        }
367        index += 1;
368    }
369
370    (candidates, counters)
371}
372
373/// Returns the first dictionary index whose term leaves this term's prefix.
374fn next_term_outside_prefix(dictionary: &TermDictionary, term: &[u8], end: usize) -> usize {
375    // Seek past every term sharing the whole of `term` as a prefix.
376    let mut probe = term.to_vec();
377    // Increment the last byte to form the smallest strictly greater prefix.
378    while let Some(last) = probe.pop() {
379        if last < u8::MAX {
380            probe.push(last.saturating_add(1));
381            break;
382        }
383    }
384    if probe.is_empty() {
385        return end;
386    }
387    dictionary.seek_ceiling(&probe).unwrap_or(end).min(end)
388}
389
390#[cfg(test)]
391#[allow(
392    clippy::expect_used,
393    clippy::indexing_slicing,
394    clippy::panic,
395    clippy::unwrap_used
396)]
397mod tests {
398    use super::*;
399    use crate::fts::dict::TermInfo;
400
401    #[test]
402    fn astra_18_fuzzy_distance_cancels_inside_one_long_candidate() {
403        let left = vec![b'a'; 4_096];
404        let mut right = left.clone();
405        *right.last_mut().expect("last byte") = b'b';
406        let mut scratch = BoundedDistance::new();
407        assert_eq!(scratch.distance(&left, &right, 2), Some(1));
408        super::super::preparation_observer::begin();
409        let mut checks = 0;
410        let mut work = super::super::control::WorkCheck::new(|| {
411            checks += 1;
412            if checks == 3 {
413                Err("cancelled")
414            } else {
415                Ok(())
416            }
417        });
418        let result = scratch.distance_controlled(&left, &right, 2, &mut work);
419        let observed = super::super::preparation_observer::fuzzy_work();
420        super::super::preparation_observer::take();
421        println!("checks={checks}, fuzzy={observed:?}");
422        assert_eq!(result, Err("cancelled"));
423        assert!(observed.dp_cells > 0 && observed.dp_cells <= 128);
424        assert_eq!(observed.candidates, 1);
425        let mut work = super::super::control::WorkCheck::new(|| Ok::<(), ()>(()));
426        assert_eq!(
427            scratch.distance_controlled(&left, &right, 2, &mut work),
428            Ok(Some(1))
429        );
430    }
431
432    fn dictionary(terms: &[&str]) -> TermDictionary {
433        let mut sorted: Vec<&str> = terms.to_vec();
434        sorted.sort_unstable();
435        sorted.dedup();
436        let mut dictionary = TermDictionary::default();
437        for term in sorted {
438            dictionary
439                .push(term.as_bytes(), TermInfo::default())
440                .expect("sorted");
441        }
442        dictionary
443    }
444
445    /// The naive model: edit distance against every term.
446    fn brute_force(terms: &[&str], query: &str, max_distance: u32) -> Vec<String> {
447        let mut sorted: Vec<&str> = terms.to_vec();
448        sorted.sort_unstable();
449        sorted.dedup();
450        sorted
451            .into_iter()
452            .filter(|term| {
453                // The policy's first-character rule applies to the model too.
454                term.as_bytes().first() == query.as_bytes().first()
455                    && levenshtein(query.as_bytes(), term.as_bytes()) <= max_distance
456            })
457            .map(str::to_owned)
458            .collect()
459    }
460
461    fn levenshtein(left: &[u8], right: &[u8]) -> u32 {
462        let mut row: Vec<u32> = (0..=right.len()).map(|index| index as u32).collect();
463        for (i, a) in left.iter().enumerate() {
464            let mut next = vec![i as u32 + 1];
465            for (j, b) in right.iter().enumerate() {
466                let cost = u32::from(a != b);
467                next.push((row[j + 1] + 1).min(next[j] + 1).min(row[j] + cost));
468            }
469            row = next;
470        }
471        row.last().copied().unwrap_or(u32::MAX)
472    }
473
474    const VOCABULARY: [&str; 16] = [
475        "receive",
476        "recieve",
477        "receipt",
478        "recipe",
479        "receiver",
480        "rest",
481        "resting",
482        "restore",
483        "retrieve",
484        "retriever",
485        "return",
486        "returns",
487        "read",
488        "ready",
489        "real",
490        "realise",
491    ];
492
493    #[test]
494    fn fuzzy_results_equal_brute_force_edit_distance_over_the_dictionary() {
495        let dictionary = dictionary(&VOCABULARY);
496        for query in [
497            "receive", "recieve", "recipe", "restore", "retreive", "returm", "reeal", "read",
498        ] {
499            for max_distance in 1..=MAX_EDIT_DISTANCE {
500                let (candidates, _) = search(&dictionary, query.as_bytes(), max_distance);
501                let mut actual: Vec<String> = candidates
502                    .into_iter()
503                    .filter_map(|candidate| String::from_utf8(candidate.term).ok())
504                    .collect();
505                actual.sort();
506                let mut expected = brute_force(&VOCABULARY, query, max_distance);
507                expected.sort();
508                assert_eq!(
509                    actual, expected,
510                    "query {query:?} at distance {max_distance}"
511                );
512            }
513        }
514    }
515
516    #[test]
517    fn the_classic_typo_finds_its_correction_at_the_distance_it_actually_costs() {
518        // `recieve` -> `receive` is a TRANSPOSITION. Plain Levenshtein
519        // charges two substitutions for it; only Damerau-Levenshtein counts
520        // it as one. The policy grants distance 1 to a 7-letter term, so
521        // this very common typo is NOT reachable under the shipped policy —
522        // a real limitation, recorded rather than papered over by widening
523        // the distance table. See the module docs.
524        let dictionary = dictionary(&VOCABULARY);
525        let (at_one, _) = search(&dictionary, b"recieve", 1);
526        assert!(
527            !at_one.iter().any(|candidate| candidate.term == b"receive"),
528            "a transposition is distance 2, not 1"
529        );
530        let (at_two, _) = search(&dictionary, b"recieve", 2);
531        assert!(
532            at_two.iter().any(|candidate| candidate.term == b"receive"),
533            "recieve must find receive at distance 2"
534        );
535    }
536
537    #[test]
538    fn fuzzy_policy_matrix_is_enforced() {
539        // Length thresholds.
540        assert_eq!(permitted_distance(b"cat", false), 0);
541        assert_eq!(permitted_distance(b"four", false), 0);
542        assert_eq!(permitted_distance(b"fives", false), 1);
543        assert_eq!(permitted_distance(b"eighteens", false), 2);
544        // The no_fuzzy flag always wins.
545        assert_eq!(permitted_distance(b"eighteens", true), 0);
546        // Anything carrying a digit is never fuzzed.
547        assert_eq!(permitted_distance(b"i-485", false), 0);
548        assert_eq!(permitted_distance(b"gpt-5.6", false), 0);
549        assert_eq!(permitted_distance(b"abc1234567", false), 0);
550        // The distance never exceeds the documented maximum.
551        for term in [b"a".as_slice(), b"averyverylongtermindeed".as_slice()] {
552            assert!(permitted_distance(term, false) <= MAX_EDIT_DISTANCE);
553        }
554    }
555
556    #[test]
557    fn the_first_character_rule_confines_the_walk() {
558        let dictionary = dictionary(&["alpha", "alpha1", "blpha", "clpha"]);
559        let (candidates, _) = search(&dictionary, b"alpha", 2);
560        assert!(
561            candidates
562                .iter()
563                .all(|candidate| candidate.term.first() == Some(&b'a')),
564            "a candidate escaped the first-character rule"
565        );
566    }
567
568    #[test]
569    fn fuzzy_never_visits_more_dictionary_entries_than_the_contract() {
570        // A dictionary where only a small share shares the query's first
571        // byte; the walk must not touch the rest.
572        let mut terms: Vec<String> = Vec::new();
573        for letter in b'a'..=b'z' {
574            for index in 0..40 {
575                terms.push(format!("{}term{index:03}", char::from(letter)));
576            }
577        }
578        let refs: Vec<&str> = terms.iter().map(String::as_str).collect();
579        let dictionary = dictionary(&refs);
580        assert_eq!(dictionary.len(), 26 * 40);
581
582        let (_, counters) = search(&dictionary, b"aterm001", 1);
583        assert!(
584            counters.entries_visited <= 40,
585            "visited {} entries; the first-character rule bounds this at 40",
586            counters.entries_visited
587        );
588        assert!(
589            counters.entries_visited < dictionary.len() as u64,
590            "the walk enumerated the whole dictionary"
591        );
592    }
593
594    #[test]
595    fn distance_zero_is_an_exact_lookup() {
596        let dictionary = dictionary(&VOCABULARY);
597        let (candidates, counters) = search(&dictionary, b"receive", 0);
598        assert_eq!(candidates.len(), 1);
599        assert_eq!(candidates[0].distance, 0);
600        assert_eq!(counters.entries_visited, 1);
601
602        let (missing, _) = search(&dictionary, b"absent", 0);
603        assert!(missing.is_empty());
604    }
605
606    #[test]
607    fn an_empty_query_matches_nothing() {
608        let dictionary = dictionary(&VOCABULARY);
609        let (candidates, _) = search(&dictionary, b"", 2);
610        assert!(candidates.is_empty());
611    }
612
613    #[test]
614    fn an_empty_dictionary_yields_no_candidates() {
615        let dictionary = TermDictionary::default();
616        let (candidates, counters) = search(&dictionary, b"receive", 2);
617        assert!(candidates.is_empty());
618        assert_eq!(counters.entries_visited, 0);
619    }
620
621    #[test]
622    fn every_reported_candidate_carries_its_true_distance() {
623        let dictionary = dictionary(&VOCABULARY);
624        let (candidates, _) = search(&dictionary, b"recieve", 2);
625        for candidate in &candidates {
626            assert_eq!(
627                candidate.distance,
628                levenshtein(b"recieve", &candidate.term),
629                "reported distance for {:?} is wrong",
630                String::from_utf8_lossy(&candidate.term)
631            );
632            assert_eq!(
633                dictionary.term_at(candidate.term_index),
634                Some(candidate.term.as_slice())
635            );
636        }
637    }
638}