Skip to main content

zeppelin_embed/fts/
query.rs

1//! Structured lexical queries and their deterministic expansion plan.
2//!
3//! Persisted source text is deliberately not interpreted here. `StoredText`
4//! remains a lossless row-addressed storage primitive; this module compiles
5//! query behavior from the indexed vocabulary. Adding another operator (for
6//! example regex) therefore does not require a segment-format change.
7
8use 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
20/// Expansion order and one set of pinned BM25 statistics, shared by the
21/// combined top-k producer and exact scoring of supplied hybrid candidates.
22pub(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/// A structured lexical query over already-analyzed terms.
150#[derive(Clone, Debug, Eq, PartialEq)]
151pub enum LexicalQuery {
152    /// Existing OR-of-terms semantics.
153    Term(TermQuery),
154    /// Terms must occur in positional phrase order within the declared slop.
155    Phrase {
156        /// Analyzed phrase terms in order.
157        terms: Vec<Vec<u8>>,
158        /// Maximum total positional displacement.
159        slop: u32,
160        /// Field to match.
161        field: FieldId,
162    },
163    /// Every indexed term beginning with this analyzed byte prefix.
164    Prefix {
165        /// Non-empty analyzed prefix.
166        prefix: Vec<u8>,
167        /// Field to match.
168        field: FieldId,
169    },
170    /// Every indexed term within a bounded edit distance.
171    Fuzzy {
172        /// Analyzed source term.
173        term: Vec<u8>,
174        /// Requested edit-distance ceiling, at most two.
175        max_distance: u32,
176        /// Field to match.
177        field: FieldId,
178    },
179    /// Terms sharing the pinned phonetic code.
180    Phonetic {
181        /// UTF-8 term whose primary phonetic code is used.
182        term: Vec<u8>,
183        /// Field to match.
184        field: FieldId,
185    },
186}
187
188impl LexicalQuery {
189    /// Wraps the compatibility term query.
190    #[must_use]
191    pub const fn term(query: TermQuery) -> Self {
192        Self::Term(query)
193    }
194
195    /// Builds a phrase query.
196    #[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    /// Builds a prefix query.
202    #[must_use]
203    pub const fn prefix(prefix: Vec<u8>, field: FieldId) -> Self {
204        Self::Prefix { prefix, field }
205    }
206
207    /// Builds a bounded fuzzy query.
208    #[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    /// Builds a phonetic query.
218    #[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/// Why one analyzed term entered a structured query.
254#[derive(Clone, Copy, Debug, Eq, PartialEq)]
255pub enum LexicalMatchKind {
256    /// The caller supplied this exact term.
257    Term,
258    /// The term is one member of a phrase.
259    Phrase,
260    /// The term expanded from a prefix.
261    Prefix,
262    /// The term expanded by edit distance.
263    Fuzzy {
264        /// Exact Wagner-Fischer edit distance.
265        distance: u32,
266    },
267    /// The term shares the query's pinned phonetic code.
268    Phonetic,
269}
270
271/// One reported scoring expansion.
272#[derive(Clone, Debug, Eq, PartialEq)]
273pub struct LexicalExpansion {
274    /// Indexed analyzed term.
275    pub term: Vec<u8>,
276    /// Multiplicative BM25 boost in thousandths.
277    pub boost_thousandths: u16,
278    /// Expansion provenance.
279    pub kind: LexicalMatchKind,
280}
281
282/// Owned snippet text with absolute source ranges.
283#[derive(Clone, Debug, Eq, PartialEq)]
284pub struct OwnedLexicalSnippet {
285    /// UTF-8-valid text copied from `source`.
286    pub text: String,
287    /// Absolute byte range within the persisted source text.
288    pub source: Highlight,
289    /// Absolute matched byte ranges within the persisted source text.
290    pub highlights: Vec<Highlight>,
291}
292
293/// A malformed structured lexical query.
294#[derive(Clone, Debug, Eq, PartialEq)]
295pub enum LexicalQueryError {
296    /// A term, phrase, or prefix that must be non-empty was empty.
297    Empty,
298    /// Fuzzy edit distance exceeded the pinned maximum.
299    FuzzyDistance {
300        /// Requested edit-distance ceiling.
301        requested: u32,
302        /// Engine maximum.
303        maximum: u32,
304    },
305    /// A phonetic query was not UTF-8.
306    InvalidPhoneticUtf8,
307    /// The phonetic query contained no encodable letters.
308    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            // Preserve occurrence order and the validation of every term.
363            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    // Existing small query tests retain the independent full encoder-scan control.
498    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        // Primary oracle enumerates actual edit operations, with no DP recurrence.
798        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        // Secondary full-distance oracle covers arbitrary bytes, lengths and mutations.
852        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                // Vocabulary visibility is global across fields, just as before;
1119                // field restrictions belong to scoring, not expansion policy.
1120                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}