1use super::vocabulary::Vocabulary;
9
10use super::fuzzy;
11use super::index::FieldId;
12use super::phonetic;
13#[cfg(test)]
14use super::phrase;
15use super::search::{FieldWeights, TermQuery};
16use super::snippet::Highlight;
17#[cfg(test)]
18use super::tokenizer::Analyzer;
19
20pub(crate) struct PreparedWeightedQuery {
23 expansions: Vec<LexicalExpansion>,
24 scoring: super::search::PreparedTermQuery,
25}
26
27#[cfg(test)]
28std::thread_local! {
29 static WEIGHTED_SIZE_VISITS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
30}
31
32impl PreparedWeightedQuery {
33 pub(crate) fn new_controlled<E>(
34 index: &super::index::LexicalIndex,
35 expansions: Vec<LexicalExpansion>,
36 fields: FieldWeights,
37 mut check: impl FnMut() -> Result<(), E>,
38 ) -> Result<Self, super::search::ControlledSearchError<E>> {
39 use super::search::ControlledSearchError;
40 let mut work =
41 super::control::WorkCheck::new(|| check().map_err(ControlledSearchError::Control));
42 work.check_now()?;
43 let mut terms = Vec::with_capacity(expansions.len());
44 for entry in &expansions {
45 work.step()?;
46 terms.push(entry.term.clone());
47 }
48 let query = TermQuery { terms, fields };
49 Ok(Self {
50 scoring: super::search::PreparedTermQuery::from_owned_controlled(
51 index,
52 query,
53 super::bm25::Bm25Params::beir(),
54 check,
55 )?,
56 expansions,
57 })
58 }
59
60 #[cfg(test)]
61 pub(crate) fn new(
62 index: &super::index::LexicalIndex,
63 expansions: Vec<LexicalExpansion>,
64 fields: FieldWeights,
65 ) -> Result<Self, super::index::IndexError> {
66 let query = TermQuery {
67 terms: expansions.iter().map(|entry| entry.term.clone()).collect(),
68 fields,
69 };
70 Ok(Self {
71 scoring: super::search::PreparedTermQuery::from_owned(
72 index,
73 query,
74 super::bm25::Bm25Params::beir(),
75 )?,
76 expansions,
77 })
78 }
79
80 #[cfg(test)]
81 pub(crate) fn allocation_bytes(
82 expansions: &Vec<LexicalExpansion>,
83 fields: &FieldWeights,
84 ) -> Option<usize> {
85 let mut bytes = expansions
86 .capacity()
87 .checked_mul(std::mem::size_of::<LexicalExpansion>())?;
88 for entry in expansions {
89 #[cfg(test)]
90 WEIGHTED_SIZE_VISITS.with(|count| count.set(count.get() + 1));
91 bytes = bytes.checked_add(entry.term.capacity())?.checked_add(
92 super::search::PreparedTermQuery::single_allocation_bytes(
93 &FieldWeights::flat(&[]),
94 entry.term.len(),
95 )?,
96 )?;
97 }
98 bytes.checked_add(
99 fields
100 .iter()
101 .count()
102 .checked_mul(std::mem::size_of::<(FieldId, u32)>())?,
103 )
104 }
105
106 pub(crate) fn allocation_bytes_controlled<E>(
107 expansions: &Vec<LexicalExpansion>,
108 fields: &FieldWeights,
109 work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
110 ) -> Result<Option<usize>, E> {
111 work.check_now()?;
112 let Some(mut bytes) = expansions
113 .capacity()
114 .checked_mul(std::mem::size_of::<LexicalExpansion>())
115 else {
116 return Ok(None);
117 };
118 for entry in expansions {
119 work.step()?;
120 #[cfg(test)]
121 WEIGHTED_SIZE_VISITS.with(|count| count.set(count.get() + 1));
122 let Some(next) = bytes.checked_add(entry.term.capacity()).and_then(|bytes| {
123 bytes.checked_add(super::search::PreparedTermQuery::single_allocation_bytes(
124 &FieldWeights::flat(&[]),
125 entry.term.len(),
126 )?)
127 }) else {
128 return Ok(None);
129 };
130 bytes = next;
131 }
132 work.check_now()?;
133 Ok(fields
134 .allocation_bytes()
135 .and_then(|field_bytes| bytes.checked_add(field_bytes)))
136 }
137
138 pub(crate) fn expansions(&self) -> &[LexicalExpansion] {
139 &self.expansions
140 }
141 pub(crate) fn take_expansions(&mut self) -> Vec<LexicalExpansion> {
142 std::mem::take(&mut self.expansions)
143 }
144 pub(crate) fn scoring(&self) -> &super::search::PreparedTermQuery {
145 &self.scoring
146 }
147}
148
149#[derive(Clone, Debug, Eq, PartialEq)]
151pub enum LexicalQuery {
152 Term(TermQuery),
154 Phrase {
156 terms: Vec<Vec<u8>>,
158 slop: u32,
160 field: FieldId,
162 },
163 Prefix {
165 prefix: Vec<u8>,
167 field: FieldId,
169 },
170 Fuzzy {
172 term: Vec<u8>,
174 max_distance: u32,
176 field: FieldId,
178 },
179 Phonetic {
181 term: Vec<u8>,
183 field: FieldId,
185 },
186}
187
188impl LexicalQuery {
189 #[must_use]
191 pub const fn term(query: TermQuery) -> Self {
192 Self::Term(query)
193 }
194
195 #[must_use]
197 pub const fn phrase(terms: Vec<Vec<u8>>, slop: u32, field: FieldId) -> Self {
198 Self::Phrase { terms, slop, field }
199 }
200
201 #[must_use]
203 pub const fn prefix(prefix: Vec<u8>, field: FieldId) -> Self {
204 Self::Prefix { prefix, field }
205 }
206
207 #[must_use]
209 pub const fn fuzzy(term: Vec<u8>, max_distance: u32, field: FieldId) -> Self {
210 Self::Fuzzy {
211 term,
212 max_distance,
213 field,
214 }
215 }
216
217 #[must_use]
219 pub const fn phonetic(term: Vec<u8>, field: FieldId) -> Self {
220 Self::Phonetic { term, field }
221 }
222
223 pub(crate) fn needs_vocabulary(&self) -> bool {
224 matches!(
225 self,
226 Self::Prefix { .. } | Self::Fuzzy { .. } | Self::Phonetic { .. }
227 )
228 }
229
230 pub(crate) fn fields_controlled<E>(
231 &self,
232 work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
233 ) -> Result<FieldWeights, E> {
234 work.check_now()?;
235 Ok(match self {
236 Self::Term(query) => return query.fields.clone_controlled(work),
237 Self::Phrase { field, .. }
238 | Self::Prefix { field, .. }
239 | Self::Fuzzy { field, .. }
240 | Self::Phonetic { field, .. } => FieldWeights::flat(&[*field]),
241 })
242 }
243
244 #[cfg(test)]
245 pub(crate) fn phrase_constraint(&self) -> Option<(&[Vec<u8>], u32)> {
246 match self {
247 Self::Phrase { terms, slop, .. } => Some((terms, *slop)),
248 _ => None,
249 }
250 }
251}
252
253#[derive(Clone, Copy, Debug, Eq, PartialEq)]
255pub enum LexicalMatchKind {
256 Term,
258 Phrase,
260 Prefix,
262 Fuzzy {
264 distance: u32,
266 },
267 Phonetic,
269}
270
271#[derive(Clone, Debug, Eq, PartialEq)]
273pub struct LexicalExpansion {
274 pub term: Vec<u8>,
276 pub boost_thousandths: u16,
278 pub kind: LexicalMatchKind,
280}
281
282#[derive(Clone, Debug, Eq, PartialEq)]
284pub struct OwnedLexicalSnippet {
285 pub text: String,
287 pub source: Highlight,
289 pub highlights: Vec<Highlight>,
291}
292
293#[derive(Clone, Debug, Eq, PartialEq)]
295pub enum LexicalQueryError {
296 Empty,
298 FuzzyDistance {
300 requested: u32,
302 maximum: u32,
304 },
305 InvalidPhoneticUtf8,
307 EmptyPhoneticCode,
309}
310
311impl std::fmt::Display for LexicalQueryError {
312 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
313 match self {
314 Self::Empty => formatter.write_str("structured lexical query must not be empty"),
315 Self::FuzzyDistance { requested, maximum } => write!(
316 formatter,
317 "fuzzy distance {requested} exceeds maximum {maximum}"
318 ),
319 Self::InvalidPhoneticUtf8 => formatter.write_str("phonetic query must be UTF-8"),
320 Self::EmptyPhoneticCode => {
321 formatter.write_str("phonetic query has no encodable letters")
322 }
323 }
324 }
325}
326
327impl std::error::Error for LexicalQueryError {}
328
329#[cfg(test)]
330fn vocabulary<'a>(query: &LexicalQuery, terms: impl Iterator<Item = &'a [u8]>) -> Vocabulary {
331 if query.needs_vocabulary() {
332 terms.map(<[u8]>::to_vec).collect()
333 } else {
334 Vocabulary::empty()
335 }
336}
337
338#[cfg(test)]
339pub(crate) fn expand_with_phonetic<E: From<LexicalQueryError>>(
340 query: &LexicalQuery,
341 vocabulary: &Vocabulary,
342 phonetic_lookup: impl FnOnce(&str) -> Result<Vec<LexicalExpansion>, E>,
343) -> Result<Vec<LexicalExpansion>, E> {
344 let mut work = super::control::WorkCheck::new(|| Ok(()));
345 expand_with_phonetic_controlled(query, vocabulary, &mut work, phonetic_lookup)
346}
347
348pub(crate) fn expand_with_phonetic_controlled<E: From<LexicalQueryError>>(
349 query: &LexicalQuery,
350 vocabulary: &Vocabulary,
351 work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
352 phonetic_lookup: impl FnOnce(&str) -> Result<Vec<LexicalExpansion>, E>,
353) -> Result<Vec<LexicalExpansion>, E> {
354 work.check_now()?;
355 #[cfg(any(test, feature = "test-support"))]
356 super::preparation_observer::expansion();
357 let expansions = match query {
358 LexicalQuery::Term(TermQuery { terms, .. }) | LexicalQuery::Phrase { terms, .. } => {
359 if terms.is_empty() {
360 return Err(LexicalQueryError::Empty.into());
361 }
362 for term in terms {
364 work.step()?;
365 if term.is_empty() {
366 return Err(LexicalQueryError::Empty.into());
367 }
368 }
369 let kind = if matches!(query, LexicalQuery::Term(_)) {
370 LexicalMatchKind::Term
371 } else {
372 LexicalMatchKind::Phrase
373 };
374 terms
375 .iter()
376 .map(|term| {
377 work.step()?;
378 Ok(LexicalExpansion {
379 term: term.clone(),
380 boost_thousandths: 1_000,
381 kind,
382 })
383 })
384 .collect::<Result<Vec<_>, E>>()?
385 }
386 LexicalQuery::Prefix { prefix, .. } => {
387 if prefix.is_empty() {
388 return Err(LexicalQueryError::Empty.into());
389 }
390 vocabulary
391 .prefix(prefix)
392 .map(|term| {
393 work.step()?;
394 Ok(LexicalExpansion {
395 term: term.to_vec(),
396 boost_thousandths: 1_000,
397 kind: LexicalMatchKind::Prefix,
398 })
399 })
400 .collect::<Result<Vec<_>, E>>()?
401 }
402 LexicalQuery::Fuzzy {
403 term, max_distance, ..
404 } => {
405 if term.is_empty() {
406 return Err(LexicalQueryError::Empty.into());
407 }
408 if *max_distance > fuzzy::MAX_EDIT_DISTANCE {
409 return Err(LexicalQueryError::FuzzyDistance {
410 requested: *max_distance,
411 maximum: fuzzy::MAX_EDIT_DISTANCE,
412 }
413 .into());
414 }
415 let mut scratch = fuzzy::BoundedDistance::new();
416 let mut matches = Vec::new();
417 for candidate in vocabulary.fuzzy_candidates(term.len(), *max_distance) {
418 work.step()?;
419 if let Some(distance) =
420 scratch.distance_controlled(term, candidate, *max_distance, work)?
421 {
422 matches.push(LexicalExpansion {
423 term: candidate.to_vec(),
424 boost_thousandths: match distance {
425 0 => 1_000,
426 1 => 500,
427 _ => 250,
428 },
429 kind: LexicalMatchKind::Fuzzy { distance },
430 });
431 }
432 }
433 super::control::sort_by(&mut matches, |left, right| left.term.cmp(&right.term), work)?;
434 matches
435 }
436 LexicalQuery::Phonetic { term, .. } => {
437 let term =
438 std::str::from_utf8(term).map_err(|_| LexicalQueryError::InvalidPhoneticUtf8)?;
439 let code = phonetic::encode_controlled(term, work)?;
440 if code.is_empty() {
441 return Err(LexicalQueryError::EmptyPhoneticCode.into());
442 }
443 phonetic_lookup(&code)?
444 }
445 };
446 work.check_now()?;
447 Ok(expansions)
448}
449
450#[cfg(test)]
451pub(crate) fn phonetic_expansions(
452 vocabulary: &Vocabulary,
453 index: &super::phonetic_index::PhoneticIndex,
454 code: &str,
455) -> Vec<LexicalExpansion> {
456 let mut work = super::control::WorkCheck::new(|| Ok::<(), std::convert::Infallible>(()));
457 match phonetic_expansions_controlled(vocabulary, index, code, &mut work) {
458 Ok(expansions) => expansions,
459 Err(never) => match never {},
460 }
461}
462
463pub(crate) fn phonetic_expansions_controlled<E>(
464 vocabulary: &Vocabulary,
465 index: &super::phonetic_index::PhoneticIndex,
466 code: &str,
467 work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
468) -> Result<Vec<LexicalExpansion>, E> {
469 work.check_now()?;
470 let expansions = vocabulary
471 .select(index.terms(code))
472 .map(|term| {
473 work.step()?;
474 Ok(LexicalExpansion {
475 term: {
476 #[cfg(test)]
477 PHONETIC_COPIES.with(|count| count.set(count.get() + 1));
478 term.to_vec()
479 },
480 boost_thousandths: 250,
481 kind: LexicalMatchKind::Phonetic,
482 })
483 })
484 .collect::<Result<Vec<_>, E>>()?;
485 work.check_now()?;
486 Ok(expansions)
487}
488
489#[cfg(test)]
490thread_local! { static PHONETIC_COPIES:std::cell::Cell<usize>=const {std::cell::Cell::new(0)}; }
491
492#[cfg(test)]
493fn expand(
494 query: &LexicalQuery,
495 vocabulary: &Vocabulary,
496) -> Result<Vec<LexicalExpansion>, LexicalQueryError> {
497 expand_with_phonetic(query, vocabulary, |code| {
499 Ok(vocabulary
500 .iter()
501 .filter(|term| {
502 std::str::from_utf8(term).is_ok_and(|term| phonetic::encode(term) == code)
503 })
504 .map(|term| LexicalExpansion {
505 term: term.to_vec(),
506 boost_thousandths: 250,
507 kind: LexicalMatchKind::Phonetic,
508 })
509 .collect())
510 })
511}
512
513#[cfg(test)]
514pub(crate) fn phrase_matches(
515 analyzer: &Analyzer,
516 text: &str,
517 terms: &[Vec<u8>],
518 slop: u32,
519) -> bool {
520 #[cfg(any(test, feature = "test-support"))]
521 super::preparation_observer::phrase_reanalysis(text.len());
522 let analyzed = analyzer.analyze(text);
523 let streams = terms
524 .iter()
525 .map(|term| {
526 analyzed
527 .iter()
528 .filter(|token| token.term.as_bytes() == term)
529 .map(|token| token.position)
530 .collect::<Vec<_>>()
531 })
532 .collect::<Vec<_>>();
533 phrase::streams_match(&streams, slop)
534}
535
536#[cfg(test)]
537fn wagner_fischer(left: &[u8], right: &[u8]) -> u32 {
538 #[cfg(any(test, feature = "test-support"))]
539 let (mut cells, mut allocations) = (0, 1);
540 let mut previous = (0..=right.len())
541 .map(|value| u32::try_from(value).unwrap_or(u32::MAX))
542 .collect::<Vec<_>>();
543 for (row, left_byte) in left.iter().enumerate() {
544 let mut current = Vec::with_capacity(right.len() + 1);
545 #[cfg(any(test, feature = "test-support"))]
546 {
547 allocations += 1;
548 }
549 current.push(u32::try_from(row + 1).unwrap_or(u32::MAX));
550 for (column, right_byte) in right.iter().enumerate() {
551 #[cfg(any(test, feature = "test-support"))]
552 {
553 cells += 1;
554 }
555 let (Some(up), Some(left), Some(diagonal)) = (
556 previous.get(column + 1).copied(),
557 current.get(column).copied(),
558 previous.get(column).copied(),
559 ) else {
560 return u32::MAX;
561 };
562 let deletion = up.saturating_add(1);
563 let insertion = left.saturating_add(1);
564 let substitution = diagonal.saturating_add(u32::from(left_byte != right_byte));
565 current.push(deletion.min(insertion).min(substitution));
566 }
567 previous = current;
568 }
569 #[cfg(any(test, feature = "test-support"))]
570 super::preparation_observer::fuzzy_distance(cells, allocations);
571 previous.last().copied().unwrap_or(u32::MAX)
572}
573
574#[cfg(test)]
575#[allow(clippy::expect_used)]
576mod tests {
577 use super::*;
578
579 #[test]
580 fn astra_18_weighted_query_size_cancels_inside_walk() {
581 let expansions = (0..4_096)
582 .map(|_| LexicalExpansion {
583 term: b"alpha".to_vec(),
584 boost_thousandths: 1_000,
585 kind: LexicalMatchKind::Term,
586 })
587 .collect::<Vec<_>>();
588 let fields = FieldWeights::flat(&[field()]);
589 let expected = PreparedWeightedQuery::allocation_bytes(&expansions, &fields)
590 .expect("fixed allocation fits");
591 WEIGHTED_SIZE_VISITS.with(|count| count.set(0));
592 let mut work = super::super::control::WorkCheck::new(|| {
593 if WEIGHTED_SIZE_VISITS.with(std::cell::Cell::get) >= 64 {
594 Err("cancelled")
595 } else {
596 Ok(())
597 }
598 });
599 let result =
600 PreparedWeightedQuery::allocation_bytes_controlled(&expansions, &fields, &mut work);
601 let visited = WEIGHTED_SIZE_VISITS.with(std::cell::Cell::get);
602 println!("weighted size visits={visited}, clean bytes={expected}");
603 assert_eq!(result, Err("cancelled"));
604 assert!((64..=128).contains(&visited));
605 let mut clean = super::super::control::WorkCheck::new(|| Ok::<(), ()>(()));
606 assert_eq!(
607 PreparedWeightedQuery::allocation_bytes_controlled(&expansions, &fields, &mut clean),
608 Ok(Some(expected))
609 );
610 }
611
612 #[test]
613 fn astra_18_phonetic_selection_cancels_inside_one_bucket() {
614 let dictionary = (0..4_096)
615 .map(|i| format!("night{i:04}").into_bytes())
616 .collect::<Vocabulary>();
617 let index =
618 super::super::phonetic_index::PhoneticIndex::build(
619 &dictionary,
620 |_, _| Ok::<(), ()>(()),
621 )
622 .expect("index");
623 PHONETIC_COPIES.with(|count| count.set(0));
624 let mut work = super::super::control::WorkCheck::new(|| {
625 if PHONETIC_COPIES.with(std::cell::Cell::get) >= 64 {
626 Err("cancelled")
627 } else {
628 Ok(())
629 }
630 });
631 let result = phonetic_expansions_controlled(&dictionary, &index, "NT", &mut work);
632 let copies = PHONETIC_COPIES.with(std::cell::Cell::get);
633 println!("copied_terms={copies}");
634 assert!(matches!(result, Err("cancelled")));
635 assert!(
636 copies <= 128,
637 "selection must not copy the full bucket after cancellation"
638 );
639 let mut work = super::super::control::WorkCheck::new(|| Ok::<(), ()>(()));
640 let clean = phonetic_expansions_controlled(&dictionary, &index, "NT", &mut work)
641 .expect("clean selection");
642 assert_eq!(
643 clean.iter().map(|e| e.term.as_slice()).collect::<Vec<_>>(),
644 dictionary.iter().collect::<Vec<_>>()
645 );
646 }
647
648 #[test]
649 fn astra_18_expansion_cancels_inside_prefix_and_rejected_fuzzy_candidates() {
650 #[derive(Debug, PartialEq)]
651 enum Error {
652 Shape(LexicalQueryError),
653 Cancelled,
654 }
655 impl From<LexicalQueryError> for Error {
656 fn from(error: LexicalQueryError) -> Self {
657 Self::Shape(error)
658 }
659 }
660 let dictionary = (0..4_096)
661 .map(|i| format!("zz{i:06}").into_bytes())
662 .collect::<Vocabulary>();
663 let queries = [
664 LexicalQuery::prefix(b"zz".to_vec(), field()),
665 LexicalQuery::fuzzy(b"aaaaaaaa".to_vec(), 1, field()),
666 ];
667 let mut results = Vec::new();
668 for (slot, query) in queries.iter().enumerate() {
669 let clean = expand(query, &dictionary).expect("clean expansion");
670 assert_eq!(clean.len(), if slot == 0 { 4_096 } else { 0 });
671 super::super::preparation_observer::begin();
672 let mut checks = 0;
673 let mut work = super::super::control::WorkCheck::new(|| {
674 checks += 1;
675 if checks == 3 {
676 Err(Error::Cancelled)
677 } else {
678 Ok(())
679 }
680 });
681 let result =
682 expand_with_phonetic_controlled(query, &dictionary, &mut work, |_| Ok(vec![]));
683 let terms = if slot == 0 {
684 super::super::preparation_observer::vocabulary_work().terms_visited
685 } else {
686 super::super::preparation_observer::fuzzy_work().candidates
687 };
688 super::super::preparation_observer::take();
689 println!(
690 "operator={slot}, checks={checks}, examined={terms}, cancelled={}",
691 matches!(result, Err(Error::Cancelled))
692 );
693 results.push((matches!(result, Err(Error::Cancelled)), terms));
694 let mut work = super::super::control::WorkCheck::new(|| Ok::<(), Error>(()));
695 assert_eq!(
696 expand_with_phonetic_controlled(query, &dictionary, &mut work, |_| Ok(vec![]))
697 .expect("controlled clean"),
698 clean
699 );
700 }
701 for (cancelled, terms) in results {
702 assert!(cancelled, "must stop even when no candidate matches");
703 assert!(terms > 0 && terms <= 128);
704 }
705 }
706
707 #[test]
708 fn astra_14_phonetic_expansion_cost_screen() {
709 use std::time::Instant;
710 let mut terms = vec![b"night".to_vec(), b"knight".to_vec()];
711 for mut n in 0..4096 {
712 let mut term = if n < 2048 {
713 b"smithson".to_vec()
714 } else {
715 b"robert".to_vec()
716 };
717 for _ in 0..4 {
718 term.push(b'a' + (n % 26) as u8);
719 n /= 26;
720 }
721 terms.push(term);
722 }
723 let dictionary = terms.into_iter().collect::<Vocabulary>();
724 let index = super::super::phonetic_index::PhoneticIndex::build(&dictionary, |_, _| {
725 Ok::<_, std::convert::Infallible>(())
726 })
727 .expect("cached map");
728 let cached_expand = |query: &LexicalQuery| {
729 expand_with_phonetic::<LexicalQueryError>(query, &dictionary, |code| {
730 Ok(phonetic_expansions(&dictionary, &index, code))
731 })
732 };
733 for (name, term, count) in [
734 ("rare", b"night".as_slice(), 2),
735 ("collision", b"smithson".as_slice(), 2048),
736 ] {
737 let query = LexicalQuery::phonetic(term.to_vec(), field());
738 for _ in 0..2 {
739 drop(cached_expand(&query).expect("warm"));
740 }
741 super::super::preparation_observer::begin();
742 let expected = cached_expand(&query).expect("probe");
743 let encodings = super::super::preparation_observer::phonetic_encoding_calls();
744 super::super::preparation_observer::take();
745 assert_eq!(expected.len(), count);
746 let mut samples = Vec::new();
747 for _ in 0..8 {
748 let start = Instant::now();
749 let actual = cached_expand(&query).expect("measure");
750 samples.push(start.elapsed().as_secs_f64() * 1e6);
751 assert_eq!(actual, expected);
752 }
753 samples.sort_by(f64::total_cmp);
754 println!(
755 "PHONETIC_SCREEN {name} vocabulary=4098 warmups=2 samples=8 p50_us={} p95_us={} expansions={} encodings={encodings}",
756 (samples.get(3).expect("sample") + samples.get(4).expect("sample")) / 2.0,
757 samples.last().expect("sample"),
758 expected.len()
759 );
760 }
761 }
762
763 #[test]
764 fn astra_13_fuzzy_scratch_is_reused_per_query() {
765 let query = LexicalQuery::fuzzy(b"abcdefgh".to_vec(), 2, field());
766 let terms = (b'a'..=b'z')
767 .map(|last| {
768 let mut term = b"abcdefg".to_vec();
769 term.push(last);
770 term
771 })
772 .collect::<Vec<_>>();
773 let dictionary = vocabulary(&query, terms.iter().map(Vec::as_slice));
774 for _ in 0..2 {
775 super::super::preparation_observer::begin();
776 assert_eq!(expand(&query, &dictionary).expect("query").len(), 26);
777 let work = super::super::preparation_observer::fuzzy_work();
778 super::super::preparation_observer::take();
779 assert_eq!(work.scratch_constructions, 1, "one per query: {work:?}");
780 assert_eq!(work.row_allocations, 0, "stack rows: {work:?}");
781 assert_eq!(work.candidates, 26);
782 assert!(work.dp_cells < 26 * 8 * 8, "banded cells: {work:?}");
783 }
784 }
785
786 #[test]
787 fn astra_13_fuzzy_bounded_matches_full_distance_expansions() {
788 use rand::RngCore;
789 use std::collections::{BTreeMap, BTreeSet};
790 let mut tiny = vec![Vec::new()];
791 for len in 1..=4 {
792 for bits in 0..(1 << len) {
793 tiny.push((0..len).map(|bit| b'a' + ((bits >> bit) & 1)).collect());
794 }
795 }
796 let dictionary = tiny.iter().cloned().collect::<Vocabulary>();
797 for term in tiny.iter().filter(|term| !term.is_empty()) {
799 let mut distances = BTreeMap::from([(term.clone(), 0_u32)]);
800 let mut frontier = BTreeSet::from([term.clone()]);
801 for distance in 0..=2 {
802 let query = LexicalQuery::fuzzy(term.clone(), distance, field());
803 let expected = dictionary
804 .iter()
805 .filter_map(|candidate| {
806 distances.get(candidate).map(|&distance| LexicalExpansion {
807 term: candidate.to_vec(),
808 boost_thousandths: match distance {
809 0 => 1000,
810 1 => 500,
811 _ => 250,
812 },
813 kind: LexicalMatchKind::Fuzzy { distance },
814 })
815 })
816 .collect::<Vec<_>>();
817 assert_eq!(
818 expand(&query, &dictionary).expect("tiny query"),
819 expected,
820 "{query:?}"
821 );
822 if distance == 2 {
823 break;
824 }
825 let mut next = BTreeSet::new();
826 for word in frontier {
827 for at in 0..=word.len() {
828 for byte in b"ab" {
829 let mut inserted = word.clone();
830 inserted.insert(at, *byte);
831 next.insert(inserted);
832 if at < word.len() {
833 let mut replaced = word.clone();
834 *replaced.get_mut(at).expect("within word") = *byte;
835 next.insert(replaced);
836 }
837 }
838 if at < word.len() {
839 let mut removed = word.clone();
840 removed.remove(at);
841 next.insert(removed);
842 }
843 }
844 }
845 for word in &next {
846 distances.entry(word.clone()).or_insert(distance + 1);
847 }
848 frontier = next;
849 }
850 }
851 let mut rng = crate::test_support::seeded_rng(
853 "astra_13_fuzzy_bounded_matches_full_distance_expansions",
854 );
855 for _ in 0..256 {
856 let len = 1 + (rng.next_u32() % 80) as usize;
857 let term = (0..len)
858 .map(|_| (rng.next_u32() % 256) as u8)
859 .collect::<Vec<_>>();
860 let mut terms = vec![term.clone(), Vec::new()];
861 for _ in 0..12 {
862 let mut candidate = term.clone();
863 for _ in 0..rng.next_u32() % 5 {
864 let at = (rng.next_u32() as usize) % (candidate.len() + 1);
865 match rng.next_u32() % 3 {
866 0 => candidate.insert(at, rng.next_u32() as u8),
867 1 if at < candidate.len() => {
868 candidate.remove(at);
869 }
870 _ => {
871 if let Some(byte) = candidate.get_mut(at) {
872 *byte = rng.next_u32() as u8;
873 }
874 }
875 }
876 }
877 terms.push(candidate);
878 }
879 let dictionary = terms.into_iter().collect::<Vocabulary>();
880 for maximum in 0..=2 {
881 let query = LexicalQuery::fuzzy(term.clone(), maximum, field());
882 let expected = dictionary
883 .iter()
884 .filter_map(|candidate| {
885 let distance = wagner_fischer(&term, candidate);
886 (distance <= maximum).then(|| LexicalExpansion {
887 term: candidate.to_vec(),
888 boost_thousandths: match distance {
889 0 => 1000,
890 1 => 500,
891 _ => 250,
892 },
893 kind: LexicalMatchKind::Fuzzy { distance },
894 })
895 })
896 .collect::<Vec<_>>();
897 assert_eq!(expand(&query, &dictionary).expect("seeded query"), expected);
898 }
899 }
900 }
901
902 #[test]
903 fn astra_13_fuzzy_distance_boundaries_preserve_boosts() {
904 let dictionary = [
905 b"ab".to_vec(),
906 b"ba".to_vec(),
907 b"xb".to_vec(),
908 b"zzab".to_vec(),
909 b"zzzab".to_vec(),
910 ]
911 .into_iter()
912 .collect::<Vocabulary>();
913 let query = LexicalQuery::fuzzy(b"ab".to_vec(), 2, field());
914 let actual = expand(&query, &dictionary).expect("boundary query");
915 assert_eq!(
916 actual
917 .iter()
918 .map(|e| (e.term.as_slice(), e.boost_thousandths))
919 .collect::<Vec<_>>(),
920 vec![
921 (b"ab".as_slice(), 1000),
922 (b"ba".as_slice(), 250),
923 (b"xb".as_slice(), 500),
924 (b"zzab".as_slice(), 250)
925 ]
926 );
927 let utf8 = [
928 b"e".to_vec(),
929 "é".as_bytes().to_vec(),
930 "ê".as_bytes().to_vec(),
931 ]
932 .into_iter()
933 .collect::<Vocabulary>();
934 let query = LexicalQuery::fuzzy("é".as_bytes().to_vec(), 1, FieldId(99));
935 let actual = expand(&query, &utf8).expect("byte distance, global fields");
936 assert_eq!(
937 actual
938 .iter()
939 .map(|e| (e.term.as_slice(), e.boost_thousandths))
940 .collect::<Vec<_>>(),
941 vec![("é".as_bytes(), 1000), ("ê".as_bytes(), 500)]
942 );
943 assert_eq!(
944 expand(&LexicalQuery::fuzzy(Vec::new(), 2, field()), &utf8),
945 Err(LexicalQueryError::Empty)
946 );
947 assert_eq!(
948 expand(&LexicalQuery::fuzzy(b"ab".to_vec(), 3, field()), &utf8),
949 Err(LexicalQueryError::FuzzyDistance {
950 requested: 3,
951 maximum: 2
952 })
953 );
954 }
955
956 #[test]
957 fn astra_13_fuzzy_length_filter_avoids_impossible_dp_work() {
958 let query = LexicalQuery::fuzzy(b"ab".to_vec(), 2, field());
959 let terms = [vec![b'x'; 64], vec![b'y'; 128]];
960 let dictionary = vocabulary(&query, terms.iter().map(Vec::as_slice));
961 super::super::preparation_observer::begin();
962 let actual = expand(&query, &dictionary).expect("valid fuzzy query");
963 let work = super::super::preparation_observer::fuzzy_work();
964 super::super::preparation_observer::take();
965 assert!(actual.is_empty());
966 assert_eq!(
967 work.dp_cells, 0,
968 "impossible byte lengths performed DP: {work:?}"
969 );
970 assert_eq!(work.row_allocations, 0);
971 assert_eq!(work.candidates, 0);
972 }
973
974 #[test]
975 fn astra_13_fuzzy_expansion_cost_screen() {
976 use std::time::Instant;
977 let terms = (0..4096)
978 .map(|mut n| {
979 let mut term = vec![b'a'; 60];
980 for _ in 0..4 {
981 term.push(b'a' + (n % 26) as u8);
982 n /= 26;
983 }
984 term
985 })
986 .collect::<Vec<_>>();
987 let seed_query = LexicalQuery::fuzzy(vec![b'a'; 64], 2, field());
988 let dictionary = vocabulary(&seed_query, terms.iter().map(Vec::as_slice));
989 for (name, term) in [
990 ("length_mismatch", vec![b'z'; 4]),
991 ("same_length_far", vec![b'z'; 64]),
992 ("same_length_near", vec![b'a'; 64]),
993 ] {
994 let query = LexicalQuery::fuzzy(term, 2, field());
995 for _ in 0..2 {
996 drop(expand(&query, &dictionary).expect("warm"));
997 }
998 super::super::preparation_observer::begin();
999 let expected = expand(&query, &dictionary).expect("work probe");
1000 let work = super::super::preparation_observer::fuzzy_work();
1001 super::super::preparation_observer::take();
1002 if name != "same_length_near" {
1003 assert!(expected.is_empty());
1004 }
1005 let mut samples = Vec::new();
1006 for _ in 0..8 {
1007 let start = Instant::now();
1008 let actual = expand(&query, &dictionary).expect("timed expansion");
1009 samples.push(start.elapsed().as_secs_f64() * 1e6);
1010 assert_eq!(actual, expected);
1011 }
1012 samples.sort_by(f64::total_cmp);
1013 println!(
1014 "FUZZY_SCREEN {name} vocabulary=4096 warmups=2 samples=8 p50_us={} p95_us={} expansions={} work={work:?}",
1015 (samples.get(3).expect("sample") + samples.get(4).expect("sample")) / 2.0,
1016 samples.last().expect("sample"),
1017 expected.len()
1018 );
1019 }
1020 }
1021
1022 #[test]
1023 fn astra_12_vocabulary_groups_memberships_with_linear_work() {
1024 let terms = (0..10_000)
1025 .map(|n| format!("unique{n:05}").into_bytes())
1026 .collect::<Vec<_>>();
1027 super::super::preparation_observer::begin();
1028 let dictionary = vocabulary(
1029 &LexicalQuery::prefix(b"unique".to_vec(), field()),
1030 terms.iter().map(Vec::as_slice),
1031 );
1032 let checks = super::super::preparation_observer::vocabulary_group_checks();
1033 super::super::preparation_observer::take();
1034 assert_eq!(dictionary.iter().count(), terms.len());
1035 assert!(
1036 checks <= 2 * terms.len(),
1037 "group boundary comparisons: {checks}"
1038 );
1039 }
1040
1041 #[test]
1042 fn astra_12_prefix_visits_only_matching_vocabulary_range() {
1043 let mut terms = (0..20_000)
1044 .map(|n| format!("{}{n:05}", if n < 10_000 { "aaa" } else { "zzz" }).into_bytes())
1045 .collect::<Vec<_>>();
1046 terms.extend([
1047 b"middlea".to_vec(),
1048 b"middleb".to_vec(),
1049 b"middlec".to_vec(),
1050 ]);
1051 let query = LexicalQuery::prefix(b"middle".to_vec(), field());
1052 let dictionary = vocabulary(&query, terms.iter().map(Vec::as_slice));
1053 super::super::preparation_observer::begin();
1054 let actual = expand(&query, &dictionary).expect("expand");
1055 let work = super::super::preparation_observer::vocabulary_work();
1056 super::super::preparation_observer::take();
1057 let expected = [b"middlea", b"middleb", b"middlec"]
1058 .into_iter()
1059 .map(|term| LexicalExpansion {
1060 term: term.to_vec(),
1061 boost_thousandths: 1000,
1062 kind: LexicalMatchKind::Prefix,
1063 })
1064 .collect::<Vec<_>>();
1065 assert_eq!(actual, expected);
1066 assert!(
1067 work.terms_visited <= 4,
1068 "prefix walked unrelated vocabulary: {work:?}"
1069 );
1070 assert!(work.seek_steps <= 16, "lower-bound work: {work:?}");
1071 }
1072
1073 fn field() -> FieldId {
1074 FieldId(7)
1075 }
1076
1077 #[test]
1078 fn astra_12_prefix_field_membership_and_byte_edges_preserve_expansion_order() {
1079 use std::collections::{BTreeMap, BTreeSet};
1080 let source = [
1081 (b"alpha".as_slice(), FieldId(7)),
1082 (b"alpine", FieldId(8)),
1083 (b"alpha", FieldId(8)),
1084 (b"alpha", FieldId(7)),
1085 (&[0], FieldId(3)),
1086 (&[0, 255], FieldId(4)),
1087 (&[255], FieldId(7)),
1088 (&[255, 255], FieldId(8)),
1089 (&[255, 255, 0], FieldId(7)),
1090 ("éclair".as_bytes(), FieldId(8)),
1091 ];
1092 let mut oracle = BTreeMap::<Vec<u8>, BTreeSet<FieldId>>::new();
1093 for (term, field) in source {
1094 oracle.entry(term.to_vec()).or_default().insert(field);
1095 }
1096 let dictionary =
1097 Vocabulary::build(source.into_iter(), |_, _| Ok::<_, ()>(())).expect("dictionary");
1098 assert_eq!(
1099 dictionary
1100 .entries()
1101 .map(|(term, fields)| (term.to_vec(), fields.to_vec()))
1102 .collect::<Vec<_>>(),
1103 oracle
1104 .iter()
1105 .map(|(term, fields)| (term.clone(), fields.iter().copied().collect()))
1106 .collect::<Vec<_>>()
1107 );
1108 for prefix in [
1109 b"al".as_slice(),
1110 &[0],
1111 &[255],
1112 &[255, 255],
1113 &[255, 255, 255],
1114 &[0xc3],
1115 b"missing",
1116 ] {
1117 for field in [FieldId(7), FieldId(8), FieldId(99)] {
1118 let query = LexicalQuery::prefix(prefix.to_vec(), field);
1121 let expected = oracle
1122 .keys()
1123 .filter(|term| term.starts_with(prefix))
1124 .map(|term| LexicalExpansion {
1125 term: term.clone(),
1126 boost_thousandths: 1000,
1127 kind: LexicalMatchKind::Prefix,
1128 })
1129 .collect::<Vec<_>>();
1130 assert_eq!(
1131 expand(&query, &dictionary).expect("expand arbitrary bytes"),
1132 expected
1133 );
1134 }
1135 }
1136 }
1137
1138 #[cfg(feature = "allocation-audit")]
1139 #[test]
1140 fn term_query_expansion_builds_no_vocabulary() {
1141 let source_terms = [
1142 b"cat".to_vec(),
1143 b"cats".to_vec(),
1144 b"cut".to_vec(),
1145 b"dog".to_vec(),
1146 b"night".to_vec(),
1147 b"nite".to_vec(),
1148 ];
1149 let query = LexicalQuery::term(TermQuery::flat(vec![b"cat".to_vec()], &[field()]));
1150 let empty_vocabulary = Vocabulary::empty();
1151 drop(expand(&query, &empty_vocabulary).expect("warm term expansion"));
1152 let (_, baseline) =
1153 crate::allocation_audit::audit_engine_path(|| expand(&query, &empty_vocabulary));
1154
1155 let (expansions, report) = crate::allocation_audit::audit_engine_path(|| {
1156 let vocabulary = vocabulary(&query, source_terms.iter().map(Vec::as_slice));
1157 expand(&query, &vocabulary)
1158 });
1159
1160 assert_eq!(
1161 expansions.expect("term expansion"),
1162 vec![LexicalExpansion {
1163 term: b"cat".to_vec(),
1164 boost_thousandths: 1_000,
1165 kind: LexicalMatchKind::Term,
1166 }]
1167 );
1168 assert_eq!(
1169 report.allocations, baseline.allocations,
1170 "Term expansion built the index vocabulary"
1171 );
1172 }
1173
1174 #[test]
1175 fn vocabulary_expansions_preserve_terms_and_order() {
1176 let terms = [
1177 b"cat".as_slice(),
1178 b"cats",
1179 b"cut",
1180 b"dog",
1181 b"nite",
1182 b"night",
1183 ];
1184 let prefix = LexicalQuery::prefix(b"ca".to_vec(), field());
1185 let prefix_vocabulary = vocabulary(&prefix, terms.iter().copied());
1186
1187 assert_eq!(
1188 expand(&prefix, &prefix_vocabulary).expect("prefix expansion"),
1189 vec![
1190 LexicalExpansion {
1191 term: b"cat".to_vec(),
1192 boost_thousandths: 1_000,
1193 kind: LexicalMatchKind::Prefix,
1194 },
1195 LexicalExpansion {
1196 term: b"cats".to_vec(),
1197 boost_thousandths: 1_000,
1198 kind: LexicalMatchKind::Prefix,
1199 },
1200 ]
1201 );
1202 let fuzzy = LexicalQuery::fuzzy(b"cat".to_vec(), 1, field());
1203 let fuzzy_vocabulary = vocabulary(&fuzzy, terms.iter().copied());
1204 assert_eq!(
1205 expand(&fuzzy, &fuzzy_vocabulary).expect("fuzzy expansion"),
1206 vec![
1207 LexicalExpansion {
1208 term: b"cat".to_vec(),
1209 boost_thousandths: 1_000,
1210 kind: LexicalMatchKind::Fuzzy { distance: 0 },
1211 },
1212 LexicalExpansion {
1213 term: b"cats".to_vec(),
1214 boost_thousandths: 500,
1215 kind: LexicalMatchKind::Fuzzy { distance: 1 },
1216 },
1217 LexicalExpansion {
1218 term: b"cut".to_vec(),
1219 boost_thousandths: 500,
1220 kind: LexicalMatchKind::Fuzzy { distance: 1 },
1221 },
1222 ]
1223 );
1224 let phonetic = LexicalQuery::phonetic(b"night".to_vec(), field());
1225 let phonetic_vocabulary = vocabulary(&phonetic, terms.iter().copied());
1226 assert_eq!(
1227 expand(&phonetic, &phonetic_vocabulary).expect("phonetic expansion"),
1228 vec![
1229 LexicalExpansion {
1230 term: b"night".to_vec(),
1231 boost_thousandths: 250,
1232 kind: LexicalMatchKind::Phonetic,
1233 },
1234 LexicalExpansion {
1235 term: b"nite".to_vec(),
1236 boost_thousandths: 250,
1237 kind: LexicalMatchKind::Phonetic,
1238 },
1239 ]
1240 );
1241 }
1242
1243 #[test]
1244 fn structured_query_validation_and_expansion_are_exhaustive() {
1245 let vocabulary = [
1246 b"cat".as_slice(),
1247 b"cats",
1248 b"cut",
1249 b"dog",
1250 b"nite",
1251 b"night",
1252 ]
1253 .into_iter()
1254 .map(<[u8]>::to_vec)
1255 .collect();
1256 let term = LexicalQuery::term(TermQuery::flat(vec![b"cat".to_vec()], &[field()]));
1257 assert_eq!(
1258 term.fields_controlled(&mut super::super::control::WorkCheck::new(|| Ok::<(), ()>(
1259 ()
1260 ))),
1261 Ok(FieldWeights::flat(&[field()]))
1262 );
1263 assert!(term.phrase_constraint().is_none());
1264 assert_eq!(expand(&term, &vocabulary).expect("term expansion").len(), 1);
1265
1266 let phrase = LexicalQuery::phrase(vec![b"cat".to_vec(), b"dog".to_vec()], 1, field());
1267 assert_eq!(
1268 phrase.phrase_constraint(),
1269 Some((&[b"cat".to_vec(), b"dog".to_vec()][..], 1))
1270 );
1271 assert_eq!(
1272 expand(&phrase, &vocabulary)
1273 .expect("phrase expansion")
1274 .len(),
1275 2
1276 );
1277 assert_eq!(
1278 expand(&LexicalQuery::prefix(b"ca".to_vec(), field()), &vocabulary)
1279 .expect("prefix expansion")
1280 .len(),
1281 2
1282 );
1283 let fuzzy = expand(
1284 &LexicalQuery::fuzzy(b"cat".to_vec(), 1, field()),
1285 &vocabulary,
1286 )
1287 .expect("fuzzy expansion");
1288 assert!(fuzzy.iter().any(|entry| entry.boost_thousandths == 1_000));
1289 assert!(fuzzy.iter().any(|entry| entry.boost_thousandths == 500));
1290 assert!(
1291 !expand(
1292 &LexicalQuery::phonetic(b"night".to_vec(), field()),
1293 &vocabulary,
1294 )
1295 .expect("phonetic expansion")
1296 .is_empty()
1297 );
1298
1299 for query in [
1300 LexicalQuery::term(TermQuery::flat(Vec::new(), &[field()])),
1301 LexicalQuery::phrase(vec![Vec::new()], 0, field()),
1302 LexicalQuery::prefix(Vec::new(), field()),
1303 LexicalQuery::fuzzy(Vec::new(), 1, field()),
1304 ] {
1305 assert_eq!(expand(&query, &vocabulary), Err(LexicalQueryError::Empty));
1306 }
1307 let distance = expand(
1308 &LexicalQuery::fuzzy(b"cat".to_vec(), 3, field()),
1309 &vocabulary,
1310 )
1311 .expect_err("distance ceiling");
1312 assert!(distance.to_string().contains("exceeds maximum"));
1313 let invalid = expand(&LexicalQuery::phonetic(vec![0xff], field()), &vocabulary)
1314 .expect_err("invalid phonetic UTF-8");
1315 assert_eq!(invalid, LexicalQueryError::InvalidPhoneticUtf8);
1316 assert_eq!(invalid.to_string(), "phonetic query must be UTF-8");
1317 let empty_code = expand(
1318 &LexicalQuery::phonetic(b"123".to_vec(), field()),
1319 &vocabulary,
1320 )
1321 .expect_err("empty phonetic code");
1322 assert_eq!(empty_code, LexicalQueryError::EmptyPhoneticCode);
1323 assert_eq!(
1324 LexicalQueryError::Empty.to_string(),
1325 "structured lexical query must not be empty"
1326 );
1327 assert_eq!(wagner_fischer(b"kitten", b"sitting"), 3);
1328 }
1329}