1use super::dict::TermDictionary;
44
45pub const MAX_EDIT_DISTANCE: u32 = 2;
47
48pub(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 #[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 #[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 } 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 column
182 .checked_sub(start)
183 .filter(|_| column <= end)
184 .and_then(|offset| row.get(offset))
185 .copied()
186 .unwrap_or(infinity)
187}
188
189pub const MIN_LENGTH_DISTANCE_1: usize = 5;
191
192pub const MIN_LENGTH_DISTANCE_2: usize = 9;
194
195#[derive(Clone, Debug, Eq, PartialEq)]
197pub struct FuzzyCandidate {
198 pub term_index: usize,
200 pub term: Vec<u8>,
202 pub distance: u32,
204}
205
206#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
208pub struct FuzzyCounters {
209 pub entries_visited: u64,
211 pub prefix_skips: u64,
213}
214
215#[must_use]
220pub fn permitted_distance(term: &[u8], no_fuzzy: bool) -> u32 {
221 if no_fuzzy {
222 return 0;
223 }
224 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
239fn 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#[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 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 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 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 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
373fn next_term_outside_prefix(dictionary: &TermDictionary, term: &[u8], end: usize) -> usize {
375 let mut probe = term.to_vec();
377 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 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 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 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 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 assert_eq!(permitted_distance(b"eighteens", true), 0);
546 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 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 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}