Skip to main content

zeppelin_embed/fts/
phrase.rs

1//! Phrase matching over the positions task 13 stored.
2//!
3//! # Slop, pinned (task 15 D1)
4//!
5//! Slop is famously underspecified: Lucene, tantivy, and FTS5 `NEAR` all
6//! differ. This engine's definition, fixed here and depended on by the
7//! property tests:
8//!
9//! > A phrase `t0..t(n-1)` matches a document when there exist positions
10//! > `q0 <= q1 <= ... <= q(n-1)`, with `qi` an occurrence of `ti`, whose
11//! > total displacement `SUM |(qi - q0) - i|` is at most the slop.
12//!
13//! Consequences, each pinned by a fixture:
14//!
15//! - **Slop 0 is exact adjacency in order**: `qi = q0 + i`.
16//! - **Slop 1 admits one single-position gap** (`the quick fox` matches
17//!   `quick ... fox` with one word between) **or one adjacent
18//!   transposition** (`fox quick` matches the query `quick fox` at cost 1
19//!   for each of the two displaced terms — so a transposition needs slop 2
20//!   under this metric, which is stated rather than hidden).
21//! - **Positions are non-decreasing, not strictly increasing.** That is what
22//!   lets a phrase cross a synonym stack for free: a stacked variant
23//!   occupies the same position as the token that produced it, so
24//!   `put if match` phrase-matches `put_if_match`.
25//!
26//! # Cost
27//!
28//! Matching is exact rather than greedy: a greedy walk picks a locally
29//! closest occurrence and misses matches a later term would have made
30//! cheaper. The search below fixes `q0` and runs a small dynamic program
31//! over the remaining terms, which is `O(|p0| * n * P)` for `P` occurrences
32//! per term. Documents have few occurrences of any one term, and
33//! `phrase_matches_iff_naive_token_stream_scan_matches` checks the result
34//! against brute force rather than trusting the shortcut.
35
36use super::index::{FieldId, SegmentIndex};
37
38pub(crate) enum PhraseReadError<Control> {
39    Storage(super::sealed::SealedSegmentError),
40    Control(Control),
41}
42
43impl<Control> From<super::postings::PostingsError> for PhraseReadError<Control> {
44    fn from(error: super::postings::PostingsError) -> Self {
45        Self::Storage(error.into())
46    }
47}
48
49impl<Control> From<super::sealed::SealedSegmentError> for PhraseReadError<Control> {
50    fn from(error: super::sealed::SealedSegmentError) -> Self {
51        Self::Storage(error)
52    }
53}
54
55struct SegmentPhrase<'index> {
56    readers: Vec<Option<super::postings::PostingsReader<'index>>>,
57    order: Vec<usize>,
58    rows: u32,
59}
60
61/// Prepared metadata is query-owned; packed payloads remain borrowed from the
62/// pinned immutable index. Only rows surviving term intersection read positions.
63pub(crate) struct PreparedPhrase<'index> {
64    segments: Vec<SegmentPhrase<'index>>,
65    slop: u32,
66    bytes: usize,
67}
68
69impl<'index> PreparedPhrase<'index> {
70    pub(crate) fn allocation_bytes(&self) -> usize {
71        self.bytes
72    }
73
74    pub(crate) fn new<Control>(
75        index: &'index super::index::LexicalIndex,
76        query: &super::query::LexicalQuery,
77        mut reserve: impl FnMut(Option<usize>) -> Result<(), Control>,
78        mut checkpoint: impl FnMut() -> Result<(), Control>,
79    ) -> Result<Option<Self>, PhraseReadError<Control>> {
80        let super::query::LexicalQuery::Phrase { terms, slop, field } = query else {
81            return Ok(None);
82        };
83        let mut work =
84            super::control::WorkCheck::new(|| checkpoint().map_err(PhraseReadError::Control));
85        work.check_now()?;
86        let mut bytes = index
87            .segments()
88            .len()
89            .checked_mul(std::mem::size_of::<SegmentPhrase<'_>>());
90        for segment in index.segments() {
91            work.step()?;
92            bytes = bytes.and_then(|bytes| {
93                terms
94                    .len()
95                    .checked_mul(
96                        std::mem::size_of::<Option<super::postings::PostingsReader<'_>>>()
97                            + std::mem::size_of::<usize>(),
98                    )
99                    .and_then(|extra| bytes.checked_add(extra))
100            });
101            for term in terms {
102                work.step()?;
103                let extra = segment.position_reader_bytes_controlled(term, *field, &mut work)?;
104                bytes = bytes.and_then(|bytes| extra.and_then(|extra| bytes.checked_add(extra)));
105            }
106        }
107        reserve(bytes).map_err(PhraseReadError::Control)?;
108        let bytes = bytes.ok_or(PhraseReadError::Storage(
109            super::sealed::SealedSegmentError::Geometry("phrase allocation size overflow"),
110        ))?;
111        let mut segments = Vec::with_capacity(index.segments().len());
112        for segment in index.segments() {
113            work.step()?;
114            let mut readers = Vec::with_capacity(terms.len());
115            for term in terms {
116                work.step()?;
117                readers.push(segment.position_reader_controlled(term, *field, &mut work)?);
118            }
119            let mut order = Vec::with_capacity(terms.len());
120            for slot in 0..terms.len() {
121                work.step()?;
122                order.push(slot);
123            }
124            let frequency = |slot: &usize| {
125                readers
126                    .get(*slot)
127                    .and_then(Option::as_ref)
128                    .map_or(0, |reader| reader.document_frequency())
129            };
130            super::control::sort_by(
131                &mut order,
132                |left, right| frequency(left).cmp(&frequency(right)),
133                &mut work,
134            )?;
135            segments.push(SegmentPhrase {
136                readers,
137                order,
138                rows: segment.row_count(),
139            });
140        }
141        work.check_now()?;
142        Ok(Some(Self {
143            segments,
144            slop: *slop,
145            bytes,
146        }))
147    }
148
149    pub(crate) fn matches<Control>(
150        &self,
151        doc: super::search::GlobalDocId,
152        mut reserve: impl FnMut(Option<usize>) -> Result<(), Control>,
153        mut checkpoint: impl FnMut() -> Result<(), Control>,
154    ) -> Result<bool, PhraseReadError<Control>> {
155        let invalid = || {
156            PhraseReadError::Storage(super::sealed::SealedSegmentError::Geometry(
157                "phrase candidate is outside its pinned segment",
158            ))
159        };
160        let mut work =
161            super::control::WorkCheck::new(|| checkpoint().map_err(PhraseReadError::Control));
162        work.check_now()?;
163        let segment = self
164            .segments
165            .get(doc.segment as usize)
166            .ok_or_else(invalid)?;
167        if doc.row >= segment.rows {
168            return Err(invalid());
169        }
170        let views_bytes = segment
171            .readers
172            .len()
173            .checked_mul(std::mem::size_of::<Option<super::postings::RowPositions<'_>>>());
174        let scratch = views_bytes.and_then(|bytes| self.bytes.checked_add(bytes));
175        reserve(scratch).map_err(PhraseReadError::Control)?;
176        let mut views = vec![None; segment.readers.len()];
177        let mut positions = 0_usize;
178        let mut maximum = 1_usize;
179        // Check membership from the rarest term first; do not decode any
180        // positions until the row is in every required term/field stream.
181        for slot in &segment.order {
182            work.step()?;
183            let Some(reader) = segment.readers.get(*slot).and_then(Option::as_ref) else {
184                return Ok(false);
185            };
186            let Some(view) = reader.positions_controlled(doc.row, &mut work)? else {
187                return Ok(false);
188            };
189            positions = positions.checked_add(view.len()).ok_or_else(invalid)?;
190            maximum = maximum.max(view.len());
191            *views.get_mut(*slot).ok_or_else(invalid)? = Some(view);
192        }
193        // Original-order position vectors plus the existing alignment DP's
194        // two simultaneous arrays. Its arrays have exact reserved capacities.
195        let bytes = scratch
196            .and_then(|bytes| {
197                segment
198                    .readers
199                    .len()
200                    .checked_mul(std::mem::size_of::<Vec<u32>>())
201                    .and_then(|extra| bytes.checked_add(extra))
202            })
203            .and_then(|bytes| {
204                positions
205                    .checked_mul(std::mem::size_of::<u32>())
206                    .and_then(|extra| bytes.checked_add(extra))
207            })
208            .and_then(|bytes| {
209                maximum
210                    .checked_mul(2 * std::mem::size_of::<(u32, u32)>())
211                    .and_then(|extra| bytes.checked_add(extra))
212            });
213        reserve(bytes).map_err(PhraseReadError::Control)?;
214        let mut streams = Vec::with_capacity(views.len());
215        for view in views {
216            work.step()?;
217            streams.push(view.ok_or_else(invalid)?.decode_controlled(&mut work)?);
218        }
219        Ok(minimum_displacement_controlled(&streams, &mut work)?
220            .is_some_and(|cost| cost <= self.slop))
221    }
222}
223
224/// A phrase query over already-analyzed terms.
225#[derive(Clone, Debug, Eq, PartialEq)]
226pub struct PhraseQuery {
227    /// The analyzed terms, in order.
228    pub terms: Vec<Vec<u8>>,
229    /// Maximum total positional displacement.
230    pub slop: u32,
231    /// The field to match within.
232    pub field: FieldId,
233}
234
235/// A rejected phrase query.
236#[derive(Clone, Copy, Debug, Eq, PartialEq)]
237pub enum PhraseError {
238    /// The phrase had no terms.
239    Empty,
240}
241
242impl std::fmt::Display for PhraseError {
243    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        formatter.write_str("a phrase query needs at least one term")
245    }
246}
247
248impl std::error::Error for PhraseError {}
249
250/// Returns the smallest total displacement of any alignment, if one exists.
251///
252/// `streams[i]` is the ascending list of positions at which term `i` occurs.
253/// Returns `None` when no non-decreasing alignment exists at all.
254#[must_use]
255pub fn minimum_displacement(streams: &[Vec<u32>]) -> Option<u32> {
256    let mut work = super::control::WorkCheck::new(|| Ok::<(), std::convert::Infallible>(()));
257    match minimum_displacement_controlled(streams, &mut work) {
258        Ok(displacement) => displacement,
259        Err(never) => match never {},
260    }
261}
262
263pub(crate) fn minimum_displacement_controlled<E>(
264    streams: &[Vec<u32>],
265    work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
266) -> Result<Option<u32>, E> {
267    work.check_now()?;
268    let Some(first) = streams.first() else {
269        return Ok(None);
270    };
271    if streams.len() == 1 {
272        return Ok((!first.is_empty()).then_some(0));
273    }
274    for stream in streams {
275        work.step()?;
276        if stream.is_empty() {
277            return Ok(None);
278        }
279    }
280
281    let mut best: Option<u32> = None;
282    for anchor in first {
283        work.step()?;
284        // Retain the exact non-decreasing alignment DP and saturated costs.
285        let mut previous: Vec<(u32, u32)> = vec![(*anchor, 0)];
286        for (offset, stream) in streams.iter().enumerate().skip(1) {
287            #[cfg(test)]
288            DP_TERM_OFFSET.with(|value| value.set(offset));
289            work.step()?;
290            let expected = u32::try_from(offset).unwrap_or(u32::MAX);
291            let mut current: Vec<(u32, u32)> = Vec::with_capacity(stream.len());
292            for candidate in stream {
293                work.step()?;
294                let mut best_previous: Option<u32> = None;
295                for (position, cost) in &previous {
296                    work.step()?;
297                    #[cfg(test)]
298                    DP_PREDECESSOR_PROBES.with(|value| value.set(value.get() + 1));
299                    if position <= candidate {
300                        best_previous = Some(best_previous.map_or(*cost, |best| best.min(*cost)));
301                    }
302                }
303                let Some(best_previous) = best_previous else {
304                    continue;
305                };
306                let relative = candidate.saturating_sub(*anchor);
307                let displacement = relative.abs_diff(expected);
308                current.push((*candidate, best_previous.saturating_add(displacement)));
309            }
310            if current.is_empty() {
311                previous.clear();
312                break;
313            }
314            previous = current;
315        }
316        for (_, cost) in previous {
317            work.step()?;
318            best = Some(best.map_or(cost, |current| current.min(cost)));
319        }
320    }
321    work.check_now()?;
322    Ok(best)
323}
324
325#[cfg(test)]
326thread_local! {
327    static DP_PREDECESSOR_PROBES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
328    static DP_TERM_OFFSET: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
329}
330
331/// Returns true when the streams admit an alignment within `slop`.
332#[must_use]
333pub fn streams_match(streams: &[Vec<u32>], slop: u32) -> bool {
334    minimum_displacement(streams).is_some_and(|cost| cost <= slop)
335}
336
337/// Returns the rows of one segment matching the phrase.
338///
339/// # Errors
340///
341/// Returns [`PhraseError::Empty`] for a phrase with no terms. An empty
342/// phrase is not "match everything"; it is a caller mistake.
343pub fn search_segment(
344    segment: &SegmentIndex,
345    query: &PhraseQuery,
346) -> Result<Vec<u32>, PhraseError> {
347    if query.terms.is_empty() {
348        return Err(PhraseError::Empty);
349    }
350    // Candidate rows are those carrying the first term; every match must
351    // contain every term, so intersecting from the rarest would be faster
352    // but never changes the answer.
353    let Some(first) = query.terms.first() else {
354        return Err(PhraseError::Empty);
355    };
356    let Some(anchor_list) = segment.posting_list(first, query.field) else {
357        return Ok(Vec::new());
358    };
359
360    let mut matches = Vec::new();
361    for posting in anchor_list.postings() {
362        let mut streams: Vec<Vec<u32>> = Vec::with_capacity(query.terms.len());
363        let mut complete = true;
364        for term in &query.terms {
365            let Some(list) = segment.posting_list(term, query.field) else {
366                complete = false;
367                break;
368            };
369            let Some(entry) = list
370                .postings()
371                .iter()
372                .find(|candidate| candidate.docid == posting.docid)
373            else {
374                complete = false;
375                break;
376            };
377            streams.push(entry.positions.clone());
378        }
379        if complete && streams_match(&streams, query.slop) {
380            matches.push(posting.docid);
381        }
382    }
383    Ok(matches)
384}
385
386#[cfg(test)]
387#[allow(
388    clippy::expect_used,
389    clippy::indexing_slicing,
390    clippy::panic,
391    clippy::unwrap_used
392)]
393mod tests {
394    use super::*;
395    use crate::fts::index::{DEFAULT_FIELD, Document};
396    use crate::fts::tokenizer::{Analyzer, Profile, TokenizerConfig, Vocabulary};
397
398    #[test]
399    fn astra_18_prepared_phrase_cancels_during_position_decode() {
400        use crate::fts::index::LexicalIndex;
401        use crate::fts::postings::POSITION_DECODE_PROBES;
402        use crate::fts::query::LexicalQuery;
403        use crate::fts::sealed::SealedSegment;
404        use crate::fts::search::GlobalDocId;
405        let analyzer = Analyzer::new(TokenizerConfig::text_default()).expect("analyzer");
406        let mut active = SegmentIndex::new();
407        let text = format!("{}beta", "alpha ".repeat(4_096));
408        active
409            .push_document(&analyzer, &Document::with_text(&text))
410            .expect("long row");
411        let bytes = SealedSegment::seal(&active)
412            .expect("seal")
413            .encode_region()
414            .expect("persist");
415        let mut index = LexicalIndex::new();
416        index.push_sealed(SealedSegment::decode_region(&bytes).expect("reopen"));
417        let query =
418            LexicalQuery::phrase(vec![b"alpha".to_vec(), b"beta".to_vec()], 0, DEFAULT_FIELD);
419        let prepared = PreparedPhrase::new(&index, &query, |_| Ok::<_, &str>(()), || Ok(()))
420            .unwrap_or_else(|_| panic!("prepare"))
421            .expect("phrase");
422        POSITION_DECODE_PROBES.with(|value| value.set(0));
423        crate::fts::preparation_observer::begin();
424        let result = prepared.matches(
425            GlobalDocId { segment: 0, row: 0 },
426            |_| Ok(()),
427            || {
428                if POSITION_DECODE_PROBES.with(std::cell::Cell::get) >= 64 {
429                    Err("cancelled")
430                } else {
431                    Ok(())
432                }
433            },
434        );
435        let probes = POSITION_DECODE_PROBES.with(std::cell::Cell::get);
436        let observed = crate::fts::preparation_observer::phrase_position_work();
437        crate::fts::preparation_observer::take();
438        println!("prepared_decode_probes={probes}, position_work={observed:?}");
439        assert!(matches!(result, Err(PhraseReadError::Control("cancelled"))));
440        assert!(
441            (64..=128).contains(&probes),
442            "prepared phrase must check inside row decode"
443        );
444        assert_eq!(
445            observed.0, probes,
446            "partial work survives the cancellation error"
447        );
448        assert!(
449            prepared
450                .matches(
451                    GlobalDocId { segment: 0, row: 0 },
452                    |_| Ok::<_, ()>(()),
453                    || Ok(())
454                )
455                .unwrap_or_else(|_| panic!("clean phrase")),
456            "last alpha and beta are adjacent"
457        );
458    }
459
460    #[test]
461    fn astra_18_phrase_cancels_inside_predecessor_scan() {
462        let streams = vec![vec![0], (1..=4_096).collect(), vec![4_097]];
463        assert_eq!(minimum_displacement(&streams), Some(4_095));
464        DP_PREDECESSOR_PROBES.with(|value| value.set(0));
465        DP_TERM_OFFSET.with(|value| value.set(0));
466        let mut checks = 0;
467        let mut work = super::super::control::WorkCheck::new(|| {
468            checks += 1;
469            if DP_TERM_OFFSET.with(std::cell::Cell::get) == 2
470                && DP_PREDECESSOR_PROBES.with(std::cell::Cell::get) > 4_096
471            {
472                Err("cancelled")
473            } else {
474                Ok(())
475            }
476        });
477        let result = minimum_displacement_controlled(&streams, &mut work);
478        let probes = DP_PREDECESSOR_PROBES.with(std::cell::Cell::get);
479        println!("checks={checks}, predecessor_probes={probes}");
480        assert_eq!(result, Err("cancelled"));
481        assert!(
482            probes > 4_096 && probes <= 4_096 + 128,
483            "must stop within the final predecessor scan"
484        );
485        let mut work = super::super::control::WorkCheck::new(|| Ok::<(), ()>(()));
486        assert_eq!(
487            minimum_displacement_controlled(&streams, &mut work),
488            Ok(Some(4_095))
489        );
490    }
491
492    #[test]
493    fn astra_11_phrase_slop_repetition_and_field_boundaries_match_oracle() {
494        use crate::fts::index::LexicalIndex;
495        use crate::fts::query::LexicalQuery;
496        use crate::fts::sealed::SealedSegment;
497        use crate::fts::search::GlobalDocId;
498        let texts = [
499            "alpha beta alpha",
500            "alpha filler beta alpha",
501            "beta alpha",
502            "alpha alpha beta",
503            "alpha the beta",
504        ];
505        // Literal positions include the stop-word gap in row four. A second
506        // field reverses order, and must never contribute to field zero.
507        let literal = [
508            [vec![0, 2], vec![1]],
509            [vec![0, 3], vec![2]],
510            [vec![1], vec![0]],
511            [vec![0, 1], vec![2]],
512            [vec![0], vec![2]],
513        ];
514        let analyzer = Analyzer::new(TokenizerConfig::text_default()).expect("analyzer");
515        let mut active = SegmentIndex::new();
516        for text in texts {
517            let mut doc = Document::with_text(text);
518            doc.set(FieldId(1), "beta alpha");
519            active.push_document(&analyzer, &doc).expect("document");
520        }
521        let bytes = SealedSegment::seal(&active)
522            .expect("seal")
523            .encode_region()
524            .expect("persist");
525        let mut index = LexicalIndex::new();
526        index
527            .push_sealed(SealedSegment::decode_region(&bytes).expect("reopen persisted positions"));
528        fn enumerate(streams: &[Vec<u32>], chosen: &mut Vec<u32>, slop: u32) -> bool {
529            if chosen.len() == streams.len() {
530                let first = chosen[0];
531                return chosen
532                    .iter()
533                    .enumerate()
534                    .map(|(i, value)| value.saturating_sub(first).abs_diff(i as u32))
535                    .sum::<u32>()
536                    <= slop;
537            }
538            for value in &streams[chosen.len()] {
539                if chosen.last().is_none_or(|last| value >= last) {
540                    chosen.push(*value);
541                    if enumerate(streams, chosen, slop) {
542                        return true;
543                    }
544                    chosen.pop();
545                }
546            }
547            false
548        }
549        for field in [DEFAULT_FIELD, FieldId(1), FieldId(2)] {
550            for words in [vec![0, 1], vec![0, 0], vec![1, 0], vec![0, 1, 0]] {
551                let terms = words
552                    .iter()
553                    .map(|word| {
554                        if *word == 0 {
555                            b"alpha".to_vec()
556                        } else {
557                            b"beta".to_vec()
558                        }
559                    })
560                    .collect::<Vec<_>>();
561                for slop in [0, 1, 2, 6] {
562                    let query = LexicalQuery::phrase(terms.clone(), slop, field);
563                    let prepared =
564                        PreparedPhrase::new(&index, &query, |_| Ok::<_, ()>(()), || Ok(()))
565                            .unwrap_or_else(|_| panic!("prepare"))
566                            .expect("phrase");
567                    for (row, source) in literal.iter().enumerate() {
568                        let streams = words
569                            .iter()
570                            .map(|word| match field.0 {
571                                0 => source[*word].clone(),
572                                1 => vec![if *word == 0 { 1 } else { 0 }],
573                                _ => vec![],
574                            })
575                            .collect::<Vec<_>>();
576                        let expected = enumerate(&streams, &mut Vec::new(), slop);
577                        let actual = prepared
578                            .matches(
579                                GlobalDocId {
580                                    segment: 0,
581                                    row: row as u32,
582                                },
583                                |_| Ok::<_, ()>(()),
584                                || Ok(()),
585                            )
586                            .unwrap_or_else(|_| panic!("positions"));
587                        assert_eq!(
588                            actual, expected,
589                            "row={row} field={field:?} words={words:?} slop={slop}"
590                        );
591                        if field.0 < 2 {
592                            assert_eq!(
593                                actual,
594                                crate::fts::query::phrase_matches(
595                                    &analyzer,
596                                    if field.0 == 0 {
597                                        texts[row]
598                                    } else {
599                                        "beta alpha"
600                                    },
601                                    &terms,
602                                    slop
603                                ),
604                                "old text matcher secondary control"
605                            );
606                        }
607                    }
608                }
609            }
610        }
611    }
612
613    fn segment_of(analyzer: &Analyzer, texts: &[&str]) -> SegmentIndex {
614        let mut segment = SegmentIndex::new();
615        for text in texts {
616            segment
617                .push_document(analyzer, &Document::with_text(text))
618                .expect("indexable");
619        }
620        segment
621    }
622
623    fn code_analyzer() -> Analyzer {
624        Analyzer::new(Profile::Code.config()).expect("valid config")
625    }
626
627    fn phrase(terms: &[&str], slop: u32) -> PhraseQuery {
628        PhraseQuery {
629            terms: terms.iter().map(|term| term.as_bytes().to_vec()).collect(),
630            slop,
631            field: DEFAULT_FIELD,
632        }
633    }
634
635    #[test]
636    fn slop_zero_and_slop_one_semantics_match_the_pinned_fixtures() {
637        let analyzer = code_analyzer();
638        let segment = segment_of(
639            &analyzer,
640            &[
641                "quick brown fox", // row 0: adjacent, in order
642                "quick red brown", // row 1: one word between quick and brown
643                "brown quick",     // row 2: reversed
644                "quick",           // row 3: missing the second term
645            ],
646        );
647
648        // Slop 0 is exact adjacency in order.
649        assert_eq!(
650            search_segment(&segment, &phrase(&["quick", "brown"], 0)).expect("valid"),
651            vec![0]
652        );
653        // Slop 1 admits the single-position gap in row 1.
654        assert_eq!(
655            search_segment(&segment, &phrase(&["quick", "brown"], 1)).expect("valid"),
656            vec![0, 1]
657        );
658        // Row 2 is a transposition: brown at 0, quick at 1. Aligning
659        // (quick, brown) needs q0=1, q1=0, which is not non-decreasing, so
660        // the only legal alignment uses a later brown — there is none.
661        assert!(
662            !search_segment(&segment, &phrase(&["quick", "brown"], 1))
663                .expect("valid")
664                .contains(&2)
665        );
666    }
667
668    #[test]
669    fn a_single_term_phrase_is_just_a_term_query() {
670        let analyzer = code_analyzer();
671        let segment = segment_of(&analyzer, &["alpha beta", "gamma"]);
672        assert_eq!(
673            search_segment(&segment, &phrase(&["alpha"], 0)).expect("valid"),
674            vec![0]
675        );
676    }
677
678    #[test]
679    fn an_empty_phrase_is_a_typed_error() {
680        let analyzer = code_analyzer();
681        let segment = segment_of(&analyzer, &["alpha"]);
682        assert_eq!(
683            search_segment(&segment, &phrase(&[], 0)),
684            Err(PhraseError::Empty)
685        );
686    }
687
688    #[test]
689    fn synonym_stacked_phrase_matches_the_underscored_identifier() {
690        // The task-12 investment paying out: a vocabulary entry makes
691        // "put if match" and put_if_match the same thing, and the phrase
692        // crosses the stack because stacked variants share a position.
693        let mut vocabulary = Vocabulary::new();
694        vocabulary
695            .declare(
696                "put_if_match",
697                &[&["put", "if", "match"][..], &["putifmatch"][..]],
698            )
699            .expect("valid declaration");
700        let analyzer = Analyzer::new(Profile::Code.config().with_vocabulary(vocabulary))
701            .expect("valid config");
702        let segment = segment_of(&analyzer, &["call put_if_match now"]);
703
704        // The decomposed parts sit at consecutive positions.
705        assert_eq!(
706            search_segment(&segment, &phrase(&["put", "if", "match"], 0)).expect("valid"),
707            vec![0]
708        );
709        // And the canonical term is stacked at the run's first position.
710        assert_eq!(
711            search_segment(&segment, &phrase(&["put_if_match"], 0)).expect("valid"),
712            vec![0]
713        );
714    }
715
716    #[test]
717    fn a_phrase_crossing_a_stopword_gap_needs_the_slop_the_gap_costs() {
718        // "state of the art" analyzes to state@0, art@3 under the default
719        // profile: "of" and "the" are stopwords whose positions remain
720        // spent. The phrase therefore needs slop 2, and the fixture pins it.
721        let analyzer = Analyzer::new(TokenizerConfig::text_default()).expect("valid config");
722        let segment = segment_of(&analyzer, &["state of the art"]);
723        assert!(
724            search_segment(&segment, &phrase(&["state", "art"], 1))
725                .expect("valid")
726                .is_empty()
727        );
728        assert_eq!(
729            search_segment(&segment, &phrase(&["state", "art"], 2)).expect("valid"),
730            vec![0]
731        );
732    }
733
734    #[test]
735    fn displacement_is_exact_not_greedy() {
736        // Term A at 0 and 5; term B at 6. A greedy walk anchors on A=0 and
737        // reports displacement 5; the true minimum anchors on A=5 for
738        // displacement 0.
739        let streams = vec![vec![0_u32, 5], vec![6]];
740        assert_eq!(minimum_displacement(&streams), Some(0));
741        assert!(streams_match(&streams, 0));
742    }
743
744    #[test]
745    fn a_missing_term_never_matches() {
746        assert_eq!(minimum_displacement(&[vec![1_u32], Vec::new()]), None);
747        assert!(!streams_match(&[vec![1_u32], Vec::new()], 100));
748    }
749
750    #[test]
751    fn stacked_positions_cost_nothing_to_cross() {
752        // Two terms sharing position 0, then a third at 1: a stack.
753        let streams = vec![vec![0_u32], vec![0], vec![1]];
754        // Expected offsets are 0,1,2 but actual are 0,0,1, so the
755        // displacement is |0-1| + |1-2| = 2 under the pinned metric.
756        assert_eq!(minimum_displacement(&streams), Some(2));
757    }
758
759    #[test]
760    fn an_empty_stream_list_yields_no_displacement() {
761        assert_eq!(minimum_displacement(&[]), None);
762        assert_eq!(minimum_displacement(&[Vec::new()]), None);
763    }
764}