Skip to main content

luci/query/
phrase.rs

1//! MatchPhraseQuery: terms must appear in order at consecutive positions.
2//!
3//! Uses position posting lists directly for both conjunction and position
4//! verification, eliminating the overhead of maintaining separate posting
5//! lists. The lead reader (fewest docs) drives iteration; other readers
6//! advance() to each candidate doc, skipping intermediate positions.
7//!
8//! See [[query-dsl#Full-Text Queries]] and [[architecture-query-execution#Step 9]].
9
10use crate::core::{DocId, FieldId, NO_MORE_DOCS, Result, ScoreMode, Scorer, TwoPhaseIterator};
11
12use crate::query::term::TermQuery;
13use crate::query::{BoundQuery, Query, ScorerSupplier};
14use crate::search::bm25::{bm25_idf, bm25_score};
15use crate::search::searcher::Searcher;
16use crate::segment::reader::SegmentReader;
17
18pub struct MatchPhraseQuery {
19    pub field: String,
20    pub query_text: String,
21    pub analyzer: Option<String>,
22}
23
24impl Query for MatchPhraseQuery {
25    fn bind(&self, searcher: &Searcher, score_mode: ScoreMode) -> Result<Box<dyn BoundQuery>> {
26        let analyzer_name = searcher.resolve_search_analyzer(&self.field, self.analyzer.as_deref());
27        let analyzer = searcher.analyzers().get(analyzer_name);
28        let tokens = analyzer.analyze(&self.query_text);
29
30        if tokens.is_empty() {
31            return Ok(Box::new(BoundEmptyQuery));
32        }
33
34        if tokens.len() == 1 {
35            let tq = TermQuery {
36                field: self.field.clone(),
37                value: tokens[0].text.clone(),
38            };
39            return tq.bind(searcher, score_mode);
40        }
41
42        let terms: Vec<String> = tokens.iter().map(|t| t.text.clone()).collect();
43
44        Ok(Box::new(BoundPhraseQuery {
45            field: self.field.clone(),
46            terms,
47            total_docs: searcher.total_docs(),
48            avg_field_length: searcher.avg_field_length(&self.field),
49        }))
50    }
51}
52
53struct BoundPhraseQuery {
54    field: String,
55    terms: Vec<String>,
56    total_docs: u32,
57    avg_field_length: f32,
58}
59
60impl BoundQuery for BoundPhraseQuery {
61    fn scorer_supplier(&self, reader: &SegmentReader) -> Result<Option<Box<dyn ScorerSupplier>>> {
62        let field_id = match reader
63            .header()
64            .fields
65            .iter()
66            .find(|f| f.field_name == self.field)
67            .map(|f| f.field_id)
68        {
69            Some(id) => id,
70            None => return Ok(None),
71        };
72
73        // All terms must exist for the phrase to match
74        let mut term_doc_freqs = Vec::new();
75        for term in &self.terms {
76            let df = reader.doc_freq(field_id, term);
77            if df == 0 {
78                return Ok(None);
79            }
80            term_doc_freqs.push(df);
81        }
82
83        let cost = *term_doc_freqs.iter().min().unwrap() as u64;
84
85        Ok(Some(Box::new(PhraseScorerSupplier {
86            field_id,
87            terms: self.terms.clone(),
88            term_doc_freqs,
89            total_docs: self.total_docs,
90            avg_field_length: self.avg_field_length,
91            cost,
92            segment_data: reader as *const SegmentReader,
93        })))
94    }
95}
96
97struct PhraseScorerSupplier {
98    field_id: FieldId,
99    terms: Vec<String>,
100    term_doc_freqs: Vec<u32>,
101    total_docs: u32,
102    avg_field_length: f32,
103    cost: u64,
104    segment_data: *const SegmentReader,
105}
106
107unsafe impl Send for PhraseScorerSupplier {}
108
109impl ScorerSupplier for PhraseScorerSupplier {
110    fn cost(&self) -> u64 {
111        self.cost
112    }
113
114    fn scorer(self: Box<Self>) -> Result<Box<dyn Scorer>> {
115        let reader = unsafe { &*self.segment_data };
116
117        // Open position posting lists — used for BOTH conjunction and
118        // position verification. This eliminates the separate simple posting
119        // lists that the old approach maintained in parallel.
120        let mut position_readers: Vec<crate::inverted::postings::PositionPostingListReader> =
121            Vec::new();
122        for term in &self.terms {
123            match reader.postings_with_positions(self.field_id, term) {
124                Some(r) => position_readers.push(r),
125                None => return Ok(Box::new(SimpleIterScorer::empty())),
126            }
127        }
128
129        // Sort by doc frequency (cheapest = fewest docs = lead iterator)
130        // Use index mapping to track original term order for position offsets
131        let mut term_order: Vec<usize> = (0..self.terms.len()).collect();
132        term_order.sort_by_key(|&i| self.term_doc_freqs[i]);
133
134        let sorted_readers: Vec<_> = term_order
135            .iter()
136            .map(|&i| {
137                std::mem::replace(
138                    &mut position_readers[i],
139                    crate::inverted::postings::PositionPostingListReader::new(&[]),
140                )
141            })
142            .collect();
143        let sorted_offsets: Vec<u32> = term_order.iter().map(|&i| i as u32).collect();
144
145        // Compute phrase IDF as sum of individual IDFs
146        let idf: f32 = self
147            .term_doc_freqs
148            .iter()
149            .map(|&df| bm25_idf(self.total_docs, df))
150            .sum();
151
152        // Pre-read first entry from lead reader
153        let reader_state: Vec<(u32, Vec<u32>)> = sorted_readers
154            .iter()
155            .map(|_| (u32::MAX, Vec::new()))
156            .collect();
157
158        let mut scorer = PhraseScorer {
159            readers: sorted_readers,
160            term_offsets: sorted_offsets,
161            reader_state,
162            current: NO_MORE_DOCS,
163            idf,
164            avg_field_length: self.avg_field_length,
165            norms: reader.norms(self.field_id),
166            constant_score: reader
167                .norms(self.field_id)
168                .and_then(|n| n.uniform_norm())
169                .map(|dl| bm25_score(idf, 1.0, dl, self.avg_field_length)),
170            ptrs_buf: Vec::new(),
171            phrase_freq: 0,
172        };
173
174        // Position on the first phrase match
175        scorer.advance_to_next_phrase();
176
177        Ok(Box::new(scorer))
178    }
179}
180
181/// Phrase scorer using position posting lists directly for both conjunction
182/// and position verification. Eliminates the separate simple posting lists
183/// that the old two-phase approach maintained in parallel.
184///
185/// The lead reader (fewest docs) drives iteration. For each lead doc, all
186/// other readers advance to that doc. If all terms are present, positions
187/// are checked for consecutive sequence.
188struct PhraseScorer<'a> {
189    /// Position readers sorted by doc frequency (lead = index 0).
190    readers: Vec<crate::inverted::postings::PositionPostingListReader<'a>>,
191    /// Original term index for each reader (for position offset calculation).
192    /// term_offsets[i] = the position offset of the i-th sorted reader's
193    /// term in the original phrase.
194    term_offsets: Vec<u32>,
195    /// Per-reader: (current_doc_id, current_positions). Reused across docs.
196    reader_state: Vec<(u32, Vec<u32>)>,
197    /// Current matched doc.
198    current: DocId,
199    idf: f32,
200    avg_field_length: f32,
201    /// Cached norms reader for scoring (avoids per-doc lookup).
202    norms: Option<crate::inverted::norms::FieldNormsReader<'a>>,
203    /// Precomputed constant score when norms are uniform AND phrase_freq=1.
204    constant_score: Option<f32>,
205    /// Reusable pointer array for two-pointer merge.
206    ptrs_buf: Vec<usize>,
207    /// Phrase frequency (count of phrase occurrences) in the current matched
208    /// document. Used as TF in BM25 scoring.
209    /// See [[investigation-20260405-02-phrase-tf-always-one]].
210    phrase_freq: u32,
211}
212
213// SAFETY: segment_data is only dereferenced within the search call that
214// holds the SegmentReader, so the pointer remains valid.
215unsafe impl Send for PhraseScorer<'_> {}
216
217impl PhraseScorer<'_> {
218    /// Advance lead reader and find the next document where all terms are
219    /// present with consecutive positions.
220    ///
221    /// See [[fix-phrase-nterm-skip-bug]].
222    fn advance_to_next_phrase(&mut self) {
223        let num_readers = self.readers.len();
224        if num_readers == 0 {
225            self.current = NO_MORE_DOCS;
226            return;
227        }
228
229        if num_readers == 2 {
230            self.advance_two_term_phrase();
231            return;
232        }
233
234        // N-term path (3+ terms). Uses advance(current+1) for the lead
235        // because next_doc() is only available on PositionPostingListReader
236        // and the general TF>1 position check needs reader_state anyway.
237
238        // Initial lead advance — advance(current+1) handles both
239        // Scorer::next() and Scorer::advance(target) correctly.
240        let mut lead_doc =
241            match self.readers[0].advance(DocId::new(if self.current == NO_MORE_DOCS {
242                0
243            } else {
244                self.current.as_u32() + 1
245            })) {
246                Some(id) => id,
247                None => {
248                    self.current = NO_MORE_DOCS;
249                    return;
250                }
251            };
252
253        loop {
254            // Converge all readers onto the same document. When any reader
255            // overshoots, the lead catches up and alignment restarts from
256            // reader[1]. lead_doc strictly increases each round.
257            'align: loop {
258                let target = lead_doc.as_u32();
259                let mut aligned = true;
260
261                for i in 1..num_readers {
262                    match self.readers[i].advance(lead_doc) {
263                        Some(id) if id.as_u32() == target => {}
264                        Some(id) => match self.readers[0].advance(id) {
265                            Some(new_lead) => {
266                                lead_doc = new_lead;
267                                aligned = false;
268                                break;
269                            }
270                            None => {
271                                self.current = NO_MORE_DOCS;
272                                return;
273                            }
274                        },
275                        None => {
276                            self.current = NO_MORE_DOCS;
277                            return;
278                        }
279                    }
280                }
281
282                if aligned {
283                    break 'align;
284                }
285            }
286
287            // All readers at lead_doc — check positions and count phrase TF
288            let freq = if num_readers == 1 {
289                1
290            } else {
291                self.count_phrase_positions(lead_doc)
292            };
293            if freq > 0 {
294                self.phrase_freq = freq;
295                self.current = lead_doc;
296                return;
297            }
298
299            // Position mismatch — advance lead to next doc
300            self.current = lead_doc;
301            lead_doc = match self.readers[0].advance(DocId::new(lead_doc.as_u32() + 1)) {
302                Some(id) => id,
303                None => {
304                    self.current = NO_MORE_DOCS;
305                    return;
306                }
307            };
308        }
309    }
310
311    /// 2-term phrase fast path. Uses next_doc() for sequential lead
312    /// iteration and an inner convergence loop for catch-up.
313    ///
314    /// See [[fix-phrase-nterm-skip-bug]].
315    fn advance_two_term_phrase(&mut self) {
316        let off0 = self.term_offsets[0];
317        let off1 = self.term_offsets[1];
318
319        // Initial lead advance uses advance(current+1) to handle
320        // Scorer::advance(target) correctly. Subsequent iterations
321        // use next_doc() (faster — no target comparison).
322        let mut lead_doc =
323            match self.readers[0].advance(DocId::new(if self.current == NO_MORE_DOCS {
324                0
325            } else {
326                self.current.as_u32() + 1
327            })) {
328                Some(id) => id,
329                None => {
330                    self.current = NO_MORE_DOCS;
331                    return;
332                }
333            };
334
335        loop {
336            // Converge both readers onto the same document
337            loop {
338                match self.readers[1].advance(lead_doc) {
339                    Some(id) if id == lead_doc => break,
340                    Some(id) => match self.readers[0].advance(id) {
341                        Some(new_lead) => {
342                            lead_doc = new_lead;
343                        }
344                        None => {
345                            self.current = NO_MORE_DOCS;
346                            return;
347                        }
348                    },
349                    None => {
350                        self.current = NO_MORE_DOCS;
351                        return;
352                    }
353                }
354            }
355
356            // Both readers at lead_doc — check positions
357            let tf0 = self.readers[0].current_tf();
358            let tf1 = self.readers[1].current_tf();
359
360            if tf0 == 1 && tf1 == 1 {
361                // Both terms have TF=1 → at most one phrase occurrence.
362                let pos0 = self.readers[0].first_position();
363                let pos1 = self.readers[1].first_position();
364                if pos0.wrapping_sub(off0) == pos1.wrapping_sub(off1) {
365                    self.phrase_freq = 1;
366                    self.current = lead_doc;
367                    return;
368                }
369            } else {
370                self.reader_state[0].0 = lead_doc.as_u32();
371                self.reader_state[0].1.clear();
372                self.reader_state[0]
373                    .1
374                    .extend_from_slice(self.readers[0].positions());
375                self.reader_state[1].0 = lead_doc.as_u32();
376                self.reader_state[1].1.clear();
377                self.reader_state[1]
378                    .1
379                    .extend_from_slice(self.readers[1].positions());
380
381                let freq = self.count_positions();
382                if freq > 0 {
383                    self.phrase_freq = freq;
384                    self.current = lead_doc;
385                    return;
386                }
387            }
388
389            // Position mismatch — advance to next doc via next_doc()
390            self.current = lead_doc;
391            lead_doc = match self.readers[0].next_doc() {
392                Some(id) => id,
393                None => {
394                    self.current = NO_MORE_DOCS;
395                    return;
396                }
397            };
398        }
399    }
400
401    /// Count phrase occurrences in the current document. Returns 0 if no
402    /// match. Uses a TF=1 fast path (common case: each term appears once)
403    /// without Vec copies, falling back to multi-pointer merge for
404    /// high-TF documents.
405    fn count_phrase_positions(&mut self, lead_doc: DocId) -> u32 {
406        let num_readers = self.readers.len();
407
408        // Fast path: all readers have TF=1 — at most one phrase occurrence.
409        if self.readers.iter().all(|r| r.current_tf() == 1) {
410            let base = self.readers[0]
411                .first_position()
412                .wrapping_sub(self.term_offsets[0]);
413            let aligned = (1..num_readers).all(|i| {
414                self.readers[i]
415                    .first_position()
416                    .wrapping_sub(self.term_offsets[i])
417                    == base
418            });
419            return if aligned { 1 } else { 0 };
420        }
421
422        // General path: copy positions into reader_state, run multi-pointer merge
423        let target = lead_doc.as_u32();
424        for i in 0..num_readers {
425            self.reader_state[i].0 = target;
426            self.reader_state[i].1.clear();
427            self.reader_state[i]
428                .1
429                .extend_from_slice(self.readers[i].positions());
430        }
431        self.count_positions()
432    }
433
434    /// Multi-pointer merge over reader_state positions. Returns the number
435    /// of phrase occurrences (TF) in the current document.
436    /// Uses term_offsets to account for the original phrase order
437    /// (readers are sorted by doc frequency, not phrase order).
438    fn count_positions(&mut self) -> u32 {
439        let num = self.readers.len();
440        self.ptrs_buf.clear();
441        self.ptrs_buf.resize(num, 0);
442
443        // Find the reader with the smallest term_offset to use as anchor
444        let anchor = self
445            .term_offsets
446            .iter()
447            .enumerate()
448            .min_by_key(|(_, off)| *off)
449            .map(|(i, _)| i)
450            .unwrap();
451        let anchor_offset = self.term_offsets[anchor];
452        let anchor_positions = &self.reader_state[anchor].1;
453
454        let mut count: u32 = 0;
455
456        for &anchor_pos in anchor_positions.iter() {
457            let start = anchor_pos - anchor_offset; // phrase start position
458            let mut matched = true;
459
460            for i in 0..num {
461                if i == anchor {
462                    continue;
463                }
464                let expected = start + self.term_offsets[i];
465                let positions = &self.reader_state[i].1;
466
467                while self.ptrs_buf[i] < positions.len() && positions[self.ptrs_buf[i]] < expected {
468                    self.ptrs_buf[i] += 1;
469                }
470
471                if self.ptrs_buf[i] >= positions.len() || positions[self.ptrs_buf[i]] != expected {
472                    matched = false;
473                    break;
474                }
475            }
476
477            if matched {
478                count += 1;
479            }
480        }
481        count
482    }
483}
484
485impl Scorer for PhraseScorer<'_> {
486    fn doc_id(&self) -> DocId {
487        self.current
488    }
489
490    fn next(&mut self) -> DocId {
491        self.advance_to_next_phrase();
492        self.current
493    }
494
495    fn advance(&mut self, target: DocId) -> DocId {
496        if self.current < target {
497            self.current = DocId::new(target.as_u32().saturating_sub(1));
498        }
499        self.advance_to_next_phrase();
500        self.current
501    }
502
503    fn score(&mut self) -> f32 {
504        // The constant_score optimization assumes uniform norms AND TF=1.
505        // Only use it when phrase_freq=1 (the most common case).
506        if self.phrase_freq <= 1 {
507            if let Some(cs) = self.constant_score {
508                return cs;
509            }
510        }
511        let dl = self
512            .norms
513            .as_ref()
514            .map(|n| n.norm(self.doc_id()))
515            .unwrap_or(1.0);
516        bm25_score(self.idf, self.phrase_freq as f32, dl, self.avg_field_length)
517    }
518
519    fn two_phase(&mut self) -> Option<&mut dyn TwoPhaseIterator> {
520        None
521    }
522}
523
524struct SimpleIterScorer<'a> {
525    postings: crate::inverted::postings::PostingListReader<'a>,
526    current: DocId,
527}
528
529impl<'a> SimpleIterScorer<'a> {
530    fn empty() -> Self {
531        Self {
532            postings: crate::inverted::postings::PostingListReader::new(&[0, 0, 0, 0, 0]),
533            current: NO_MORE_DOCS,
534        }
535    }
536}
537
538impl Scorer for SimpleIterScorer<'_> {
539    fn doc_id(&self) -> DocId {
540        self.current
541    }
542    fn next(&mut self) -> DocId {
543        self.current = match self.postings.next() {
544            Some((id, _)) => id,
545            None => NO_MORE_DOCS,
546        };
547        self.current
548    }
549    fn advance(&mut self, target: DocId) -> DocId {
550        while self.current < target && self.current != NO_MORE_DOCS {
551            self.next();
552        }
553        self.current
554    }
555    fn score(&mut self) -> f32 {
556        1.0
557    }
558    fn two_phase(&mut self) -> Option<&mut dyn TwoPhaseIterator> {
559        None
560    }
561}
562
563struct BoundEmptyQuery;
564impl BoundQuery for BoundEmptyQuery {
565    fn scorer_supplier(&self, _: &SegmentReader) -> Result<Option<Box<dyn ScorerSupplier>>> {
566        Ok(None)
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use crate::analysis::{AnalyzerRegistry, Token};
574    use crate::core::SegmentId;
575    use crate::mapping::{FieldType, Mapping};
576    use crate::segment::builder::SegmentBuilder;
577
578    fn make_tokens(terms: &[&str]) -> Vec<Token> {
579        terms
580            .iter()
581            .enumerate()
582            .map(|(i, t)| Token::new(*t, 0, t.len(), i as u32))
583            .collect()
584    }
585
586    fn build_phrase_store() -> crate::search::segment_store::SegmentStore {
587        let schema = Mapping::builder().field("body", FieldType::Text).build();
588        let mut builder = SegmentBuilder::new(SegmentId::new(1), &schema);
589
590        // Doc 0: "the quick brown fox" — positions [0,1,2,3]
591        builder.add_document(
592            &[(
593                FieldId::new(0),
594                make_tokens(&["the", "quick", "brown", "fox"]),
595            )],
596            br#"{"body":"the quick brown fox"}"#,
597        );
598
599        // Doc 1: "the brown quick fox" — positions [0,1,2,3] but different order
600        builder.add_document(
601            &[(
602                FieldId::new(0),
603                make_tokens(&["the", "brown", "quick", "fox"]),
604            )],
605            br#"{"body":"the brown quick fox"}"#,
606        );
607
608        // Doc 2: "quick fox brown" — no "the", different order
609        builder.add_document(
610            &[(FieldId::new(0), make_tokens(&["quick", "fox", "brown"]))],
611            br#"{"body":"quick fox brown"}"#,
612        );
613
614        let reader = SegmentReader::open(builder.build()).unwrap();
615        crate::search::segment_store::SegmentStore::new(
616            vec![reader],
617            AnalyzerRegistry::new(),
618            None,
619            None,
620        )
621    }
622
623    #[test]
624    fn phrase_exact_match() {
625        let store = build_phrase_store();
626        let searcher = Searcher::new(&store);
627        let query = MatchPhraseQuery {
628            field: "body".into(),
629            query_text: "quick brown".into(),
630            analyzer: None,
631        };
632
633        let weight = query.bind(&searcher, ScoreMode::Complete).unwrap();
634        let supplier = weight
635            .scorer_supplier(&searcher.segments()[0])
636            .unwrap()
637            .unwrap();
638        let mut scorer = supplier.scorer().unwrap();
639
640        // "quick brown" at consecutive positions only in doc 0
641        assert_eq!(scorer.doc_id(), DocId::new(0));
642        assert_eq!(scorer.next(), NO_MORE_DOCS);
643    }
644
645    #[test]
646    fn phrase_wrong_order_no_match() {
647        let store = build_phrase_store();
648        let searcher = Searcher::new(&store);
649        let query = MatchPhraseQuery {
650            field: "body".into(),
651            query_text: "brown quick".into(),
652            analyzer: None,
653        };
654
655        let weight = query.bind(&searcher, ScoreMode::Complete).unwrap();
656        let supplier = weight
657            .scorer_supplier(&searcher.segments()[0])
658            .unwrap()
659            .unwrap();
660        let mut scorer = supplier.scorer().unwrap();
661
662        // "brown quick" is consecutive in doc 1 (positions 1,2)
663        assert_eq!(scorer.doc_id(), DocId::new(1));
664        assert_eq!(scorer.next(), NO_MORE_DOCS);
665    }
666
667    #[test]
668    fn phrase_no_match() {
669        let store = build_phrase_store();
670        let searcher = Searcher::new(&store);
671        let query = MatchPhraseQuery {
672            field: "body".into(),
673            query_text: "fox quick".into(), // never consecutive
674            analyzer: None,
675        };
676
677        let weight = query.bind(&searcher, ScoreMode::Complete).unwrap();
678        let supplier = weight
679            .scorer_supplier(&searcher.segments()[0])
680            .unwrap()
681            .unwrap();
682        let scorer = supplier.scorer().unwrap();
683
684        // "fox" is always after "quick" in all docs, never before
685        // Doc 0: fox@3, quick@1 → not consecutive
686        // Doc 1: fox@3, quick@2 → not consecutive
687        // Doc 2: fox@1, quick@0 → consecutive! "quick fox" but query is "fox quick"
688        // Wait — "fox quick" means fox at position X, quick at X+1. Doc 2 has quick@0, fox@1.
689        // So "fox quick" would need fox before quick at consecutive positions.
690        // Doc 2: fox@1, quick@0 → fox@1 then quick@1+1=2, but quick is at 0, not 2 → no match
691
692        assert_eq!(scorer.doc_id(), NO_MORE_DOCS);
693    }
694
695    #[test]
696    fn phrase_single_term_degenerates() {
697        let store = build_phrase_store();
698        let searcher = Searcher::new(&store);
699        let query = MatchPhraseQuery {
700            field: "body".into(),
701            query_text: "quick".into(),
702            analyzer: None,
703        };
704
705        let weight = query.bind(&searcher, ScoreMode::Complete).unwrap();
706        let supplier = weight
707            .scorer_supplier(&searcher.segments()[0])
708            .unwrap()
709            .unwrap();
710        let mut scorer = supplier.scorer().unwrap();
711
712        // Single term → just a term query. "quick" in docs 0, 1, 2
713        let mut ids = Vec::new();
714        while scorer.doc_id() != NO_MORE_DOCS {
715            ids.push(scorer.doc_id().as_u32());
716            scorer.next();
717        }
718        assert_eq!(ids, vec![0, 1, 2]);
719    }
720
721    #[test]
722    fn phrase_three_terms() {
723        let store = build_phrase_store();
724        let searcher = Searcher::new(&store);
725        let query = MatchPhraseQuery {
726            field: "body".into(),
727            query_text: "the quick brown".into(),
728            analyzer: None,
729        };
730
731        let weight = query.bind(&searcher, ScoreMode::Complete).unwrap();
732        let supplier = weight
733            .scorer_supplier(&searcher.segments()[0])
734            .unwrap()
735            .unwrap();
736        let mut scorer = supplier.scorer().unwrap();
737
738        // "the quick brown" at positions 0,1,2 only in doc 0
739        assert_eq!(scorer.doc_id(), DocId::new(0));
740        assert_eq!(scorer.next(), NO_MORE_DOCS);
741    }
742
743    #[test]
744    fn phrase_has_positive_score() {
745        let store = build_phrase_store();
746        let searcher = Searcher::new(&store);
747        let query = MatchPhraseQuery {
748            field: "body".into(),
749            query_text: "quick brown".into(),
750            analyzer: None,
751        };
752
753        let weight = query.bind(&searcher, ScoreMode::Complete).unwrap();
754        let supplier = weight
755            .scorer_supplier(&searcher.segments()[0])
756            .unwrap()
757            .unwrap();
758        let mut scorer = supplier.scorer().unwrap();
759
760        assert_eq!(scorer.doc_id(), DocId::new(0));
761        let score = scorer.score();
762        assert!(score > 0.0, "phrase score should be positive, got {score}");
763    }
764
765    /// Regression test for [[investigation-20260405-01-phrase-2term-skip-bug]].
766    ///
767    /// When reader[1] overshoots and reader[0] catches up via advance(),
768    /// the catch-up document must still be checked for a phrase match.
769    /// The bug: next_doc() after catch-up advances past the catch-up doc.
770    #[test]
771    fn phrase_2term_catchup_not_skipped() {
772        let schema = Mapping::builder().field("body", FieldType::Text).build();
773        let mut builder = SegmentBuilder::new(SegmentId::new(1), &schema);
774
775        // Term "alpha": docs 0, 3, 5 (3 docs — rarer, becomes reader[0])
776        // Term "beta":  docs 1, 2, 3, 4, 5 (5 docs — more common, becomes reader[1])
777        //
778        // Iteration 1: reader[0] at doc 0, reader[1].advance(0) → doc 1 (overshoot).
779        //   reader[0].advance(1) → doc 3. current = 2, continue.
780        // Iteration 2 (BUG): next_doc() on reader[0] at doc 3 → doc 5. Doc 3 skipped!
781        // Iteration 2 (FIX): advance(3) on reader[0] at doc 3 → doc 3 (no-op). Doc 3 checked.
782
783        // Doc 0: has alpha, no beta
784        builder.add_document(
785            &[(FieldId::new(0), make_tokens(&["alpha", "gamma"]))],
786            b"{}",
787        );
788        // Doc 1: has beta, no alpha
789        builder.add_document(&[(FieldId::new(0), make_tokens(&["beta", "delta"]))], b"{}");
790        // Doc 2: has beta, no alpha
791        builder.add_document(
792            &[(FieldId::new(0), make_tokens(&["beta", "epsilon"]))],
793            b"{}",
794        );
795        // Doc 3: "alpha beta" — phrase match (consecutive positions)
796        builder.add_document(&[(FieldId::new(0), make_tokens(&["alpha", "beta"]))], b"{}");
797        // Doc 4: has beta, no alpha
798        builder.add_document(&[(FieldId::new(0), make_tokens(&["beta", "zeta"]))], b"{}");
799        // Doc 5: "alpha beta" — another phrase match
800        builder.add_document(&[(FieldId::new(0), make_tokens(&["alpha", "beta"]))], b"{}");
801
802        let reader = SegmentReader::open(builder.build()).unwrap();
803        let store = crate::search::segment_store::SegmentStore::new(
804            vec![reader],
805            AnalyzerRegistry::new(),
806            None,
807            None,
808        );
809        let searcher = Searcher::new(&store);
810        let query = MatchPhraseQuery {
811            field: "body".into(),
812            query_text: "alpha beta".into(),
813            analyzer: None,
814        };
815
816        let weight = query.bind(&searcher, ScoreMode::Complete).unwrap();
817        let supplier = weight
818            .scorer_supplier(&searcher.segments()[0])
819            .unwrap()
820            .unwrap();
821        let mut scorer = supplier.scorer().unwrap();
822
823        // Both doc 3 and doc 5 must be found
824        assert_eq!(
825            scorer.doc_id(),
826            DocId::new(3),
827            "doc 3 must not be skipped after catch-up"
828        );
829        assert_eq!(scorer.next(), DocId::new(5));
830        assert_eq!(scorer.next(), NO_MORE_DOCS);
831    }
832
833    /// Same catch-up skip bug as above, but for the N-term (3+) general path.
834    ///
835    /// The N-term path uses `advance(current + 1)` after catch-up, but
836    /// `PositionPostingListReader::advance()` always reads the next stream
837    /// entry — it does not check if `current_doc_id >= target` first.
838    /// So `advance(new_lead)` when already at `new_lead` skips past it.
839    #[test]
840    fn phrase_nterm_catchup_not_skipped() {
841        let schema = Mapping::builder().field("body", FieldType::Text).build();
842        let mut builder = SegmentBuilder::new(SegmentId::new(1), &schema);
843
844        // 3 terms forces the N-term path (2-term fast path only for exactly 2).
845        //
846        // Term "alpha": docs 0, 3, 5 (df=3, reader[0] after sort)
847        // Term "gamma": docs 0, 3, 5 (df=3, reader[1] after sort)
848        // Term "beta":  docs 1, 2, 3, 4, 5 (df=5, reader[2] after sort)
849        //
850        // Iteration 1 (current = NO_MORE_DOCS):
851        //   reader[0] (alpha).advance(0) → doc 0
852        //   reader[1] (gamma).advance(0) → doc 0. OK.
853        //   reader[2] (beta).advance(0) → doc 1 (overshoot!)
854        //     reader[0].advance(1) → doc 3. current = 2, continue.
855        //
856        // Iteration 2 (current = 2):
857        //   reader[0].advance(3) — already at doc 3, but advance() reads next → doc 5!
858        //   Doc 3 is SKIPPED.
859
860        // Doc 0: alpha, gamma (no beta)
861        builder.add_document(
862            &[(FieldId::new(0), make_tokens(&["alpha", "gamma", "delta"]))],
863            b"{}",
864        );
865        // Doc 1: beta only
866        builder.add_document(
867            &[(FieldId::new(0), make_tokens(&["beta", "delta", "epsilon"]))],
868            b"{}",
869        );
870        // Doc 2: beta only
871        builder.add_document(
872            &[(FieldId::new(0), make_tokens(&["beta", "epsilon", "zeta"]))],
873            b"{}",
874        );
875        // Doc 3: "alpha beta gamma" — phrase match
876        builder.add_document(
877            &[(FieldId::new(0), make_tokens(&["alpha", "beta", "gamma"]))],
878            b"{}",
879        );
880        // Doc 4: beta only
881        builder.add_document(
882            &[(FieldId::new(0), make_tokens(&["beta", "eta", "theta"]))],
883            b"{}",
884        );
885        // Doc 5: "alpha beta gamma" — another phrase match
886        builder.add_document(
887            &[(FieldId::new(0), make_tokens(&["alpha", "beta", "gamma"]))],
888            b"{}",
889        );
890
891        let reader = SegmentReader::open(builder.build()).unwrap();
892        let store = crate::search::segment_store::SegmentStore::new(
893            vec![reader],
894            AnalyzerRegistry::new(),
895            None,
896            None,
897        );
898        let searcher = Searcher::new(&store);
899        let query = MatchPhraseQuery {
900            field: "body".into(),
901            query_text: "alpha beta gamma".into(),
902            analyzer: None,
903        };
904
905        let weight = query.bind(&searcher, ScoreMode::Complete).unwrap();
906        let supplier = weight
907            .scorer_supplier(&searcher.segments()[0])
908            .unwrap()
909            .unwrap();
910        let mut scorer = supplier.scorer().unwrap();
911
912        // Both doc 3 and doc 5 must be found
913        assert_eq!(
914            scorer.doc_id(),
915            DocId::new(3),
916            "N-term: doc 3 must not be skipped after catch-up"
917        );
918        assert_eq!(scorer.next(), DocId::new(5));
919        assert_eq!(scorer.next(), NO_MORE_DOCS);
920    }
921
922    /// Regression test for bug 3: `Scorer::advance(target)` must return
923    /// a doc `>= target`. The 2-term path used `next_doc()` which iterates
924    /// from the reader's current position, ignoring the target — it could
925    /// return a match before the requested target.
926    #[test]
927    fn phrase_advance_respects_target() {
928        let schema = Mapping::builder().field("body", FieldType::Text).build();
929        let mut builder = SegmentBuilder::new(SegmentId::new(1), &schema);
930
931        // Phrase matches at docs 0, 5, 10. Gaps filled with non-matching docs.
932        // Doc 0: "alpha beta"
933        builder.add_document(&[(FieldId::new(0), make_tokens(&["alpha", "beta"]))], b"{}");
934        // Docs 1-4: filler (no alpha)
935        for _ in 1..5 {
936            builder.add_document(&[(FieldId::new(0), make_tokens(&["gamma"]))], b"{}");
937        }
938        // Doc 5: "alpha beta"
939        builder.add_document(&[(FieldId::new(0), make_tokens(&["alpha", "beta"]))], b"{}");
940        // Docs 6-9: filler
941        for _ in 6..10 {
942            builder.add_document(&[(FieldId::new(0), make_tokens(&["gamma"]))], b"{}");
943        }
944        // Doc 10: "alpha beta"
945        builder.add_document(&[(FieldId::new(0), make_tokens(&["alpha", "beta"]))], b"{}");
946
947        let reader = SegmentReader::open(builder.build()).unwrap();
948        let store = crate::search::segment_store::SegmentStore::new(
949            vec![reader],
950            AnalyzerRegistry::new(),
951            None,
952            None,
953        );
954        let searcher = Searcher::new(&store);
955        let query = MatchPhraseQuery {
956            field: "body".into(),
957            query_text: "alpha beta".into(),
958            analyzer: None,
959        };
960
961        let weight = query.bind(&searcher, ScoreMode::Complete).unwrap();
962        let supplier = weight
963            .scorer_supplier(&searcher.segments()[0])
964            .unwrap()
965            .unwrap();
966        let mut scorer = supplier.scorer().unwrap();
967
968        // Initial match at doc 0
969        assert_eq!(scorer.doc_id(), DocId::new(0));
970
971        // advance(7) must return >= 7 — must NOT return doc 5
972        let result = scorer.advance(DocId::new(7));
973        assert!(
974            result >= DocId::new(7),
975            "advance(7) returned {result:?}, expected >= DocId(7)"
976        );
977        assert_eq!(result, DocId::new(10));
978    }
979
980    /// Regression test for [[investigation-20260405-02-phrase-tf-always-one]].
981    ///
982    /// Phrase scorer hardcoded TF=1.0 in BM25, so a doc with the phrase
983    /// repeated multiple times scored the same as a doc with one
984    /// occurrence (per-token-length terms equal). The fix counts phrase
985    /// occurrences and uses that as TF.
986    #[test]
987    fn phrase_freq_repeated_phrase_scores_higher() {
988        let schema = Mapping::builder().field("body", FieldType::Text).build();
989        let mut builder = SegmentBuilder::new(SegmentId::new(1), &schema);
990
991        // Doc 0: phrase "alpha beta" appears 3 times
992        // body length = 6 tokens
993        builder.add_document(
994            &[(
995                FieldId::new(0),
996                make_tokens(&["alpha", "beta", "alpha", "beta", "alpha", "beta"]),
997            )],
998            b"{}",
999        );
1000        // Doc 1: phrase "alpha beta" appears 1 time
1001        // body length = 6 tokens (same as doc 0 — eliminates dl-norm differences)
1002        builder.add_document(
1003            &[(
1004                FieldId::new(0),
1005                make_tokens(&["alpha", "beta", "gamma", "delta", "epsilon", "zeta"]),
1006            )],
1007            b"{}",
1008        );
1009
1010        let reader = SegmentReader::open(builder.build()).unwrap();
1011        let store = crate::search::segment_store::SegmentStore::new(
1012            vec![reader],
1013            AnalyzerRegistry::new(),
1014            None,
1015            None,
1016        );
1017        let searcher = Searcher::new(&store);
1018        let query = MatchPhraseQuery {
1019            field: "body".into(),
1020            query_text: "alpha beta".into(),
1021            analyzer: None,
1022        };
1023
1024        let weight = query.bind(&searcher, ScoreMode::Complete).unwrap();
1025        let supplier = weight
1026            .scorer_supplier(&searcher.segments()[0])
1027            .unwrap()
1028            .unwrap();
1029        let mut scorer = supplier.scorer().unwrap();
1030
1031        // Doc 0 has phrase 3 times, doc 1 has it 1 time
1032        assert_eq!(scorer.doc_id(), DocId::new(0));
1033        let doc0_score = scorer.score();
1034        scorer.next();
1035        assert_eq!(scorer.doc_id(), DocId::new(1));
1036        let doc1_score = scorer.score();
1037
1038        // BM25 with higher TF should produce a strictly higher score.
1039        // Both docs have the same field length, so the only score
1040        // difference comes from TF.
1041        assert!(
1042            doc0_score > doc1_score,
1043            "doc with 3 phrase occurrences ({doc0_score}) must score higher than \
1044             doc with 1 occurrence ({doc1_score}) — phrase TF must be counted"
1045        );
1046    }
1047}