Skip to main content

summa_core/query/
phrase.rs

1//! Phrase query - matches documents containing terms in consecutive positions
2
3mod competitive;
4mod seed;
5use competitive::CompetitiveLengths;
6
7use std::sync::Arc;
8
9use crate::dsl::Field;
10use crate::segment::SegmentReader;
11use crate::structures::{BlockPostingIterator, BlockPostingList, TERMINATED, TermPositions};
12use crate::{DocId, Score};
13
14use super::docset::DocSet;
15use super::{CountFuture, EmptyScorer, GlobalStats, Query, Scorer, ScorerFuture};
16
17/// Phrase query - matches documents containing terms in consecutive positions
18///
19/// Example: "quick brown fox" matches only if all three terms appear
20/// consecutively in the document.
21#[derive(Clone)]
22pub struct PhraseQuery {
23    pub field: Field,
24    /// Terms in the phrase, in order
25    pub terms: Vec<Vec<u8>>,
26    /// Token offset of each term inside the phrase, ascending, one per term.
27    /// `offsets[i + 1] - offsets[i]` is the required distance between two
28    /// consecutive terms: 1 for adjacent words, more when index-time stop
29    /// words were dropped between them (`quantum@0 art@3`). [`PhraseQuery::new`]
30    /// makes every term adjacent.
31    pub offsets: Vec<u32>,
32    /// Optional slop (max distance between terms, 0 = exact phrase)
33    pub slop: u32,
34    /// Optional global statistics for cross-segment IDF
35    global_stats: Option<Arc<GlobalStats>>,
36}
37
38impl std::fmt::Display for PhraseQuery {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        let terms: Vec<String> = self
41            .terms
42            .iter()
43            .zip(&self.offsets)
44            .map(|(term, offset)| {
45                if self.is_adjacent() {
46                    String::from_utf8_lossy(term).into_owned()
47                } else {
48                    format!("{}@{offset}", String::from_utf8_lossy(term))
49                }
50            })
51            .collect();
52        write!(f, "Phrase({}:\"{}\"", self.field.0, terms.join(" "))?;
53        if self.slop > 0 {
54            write!(f, "~{}", self.slop)?;
55        }
56        write!(f, ")")
57    }
58}
59
60impl std::fmt::Debug for PhraseQuery {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        let terms: Vec<_> = self
63            .terms
64            .iter()
65            .map(|t| String::from_utf8_lossy(t).to_string())
66            .collect();
67        f.debug_struct("PhraseQuery")
68            .field("field", &self.field)
69            .field("terms", &terms)
70            .field("offsets", &self.offsets)
71            .field("slop", &self.slop)
72            .finish()
73    }
74}
75
76impl PhraseQuery {
77    /// Multi-token phrases need token positions; element ordinals cannot prove
78    /// adjacency. Shared by schema-aware parsing, both scorers and backfill.
79    pub(crate) fn validate_positions(schema: &crate::Schema, field: Field) -> crate::Result<()> {
80        let entry = schema
81            .get_field_entry(field)
82            .ok_or_else(|| crate::Error::FieldNotFound(field.0.to_string()))?;
83        if entry.indexed
84            && entry.field_type == crate::dsl::FieldType::Text
85            && entry
86                .positions
87                .is_some_and(|mode| mode.tracks_token_position())
88        {
89            return Ok(());
90        }
91        Err(crate::Error::Query(format!(
92            "phrase queries on field {:?} require token positions; rebuild with indexed<token_position> or use AND for unordered terms",
93            entry.name
94        )))
95    }
96
97    /// Create a new exact phrase query of adjacent terms.
98    pub fn new(field: Field, terms: Vec<Vec<u8>>) -> Self {
99        let offsets = (0..terms.len() as u32).collect();
100        Self {
101            field,
102            terms,
103            offsets,
104            slop: 0,
105            global_stats: None,
106        }
107    }
108
109    /// Create a phrase whose terms carry their token offsets, as produced by
110    /// a tokenizer that drops stop words without renumbering (`(0, quantum)`,
111    /// `(3, art)` for "quantum of the art"). Offsets must be ascending.
112    pub fn with_offsets(field: Field, terms: Vec<(u32, Vec<u8>)>) -> Self {
113        debug_assert!(
114            terms.windows(2).all(|pair| pair[0].0 < pair[1].0),
115            "phrase offsets must be strictly ascending"
116        );
117        let (offsets, terms): (Vec<u32>, Vec<Vec<u8>>) = terms.into_iter().unzip();
118        Self {
119            field,
120            terms,
121            offsets,
122            slop: 0,
123            global_stats: None,
124        }
125    }
126
127    /// Create from text using the simple tokenizer (whitespace split,
128    /// punctuation stripped, lowercased). Fields with a stemming tokenizer
129    /// should tokenize the phrase themselves and call
130    /// [`PhraseQuery::with_offsets`] so the query terms match the indexed
131    /// stems and keep the gaps of dropped stop words.
132    pub fn text(field: Field, phrase: &str) -> Self {
133        use crate::tokenizer::Tokenizer;
134        let terms: Vec<(u32, Vec<u8>)> = crate::tokenizer::SimpleTokenizer
135            .tokenize(phrase)
136            .into_iter()
137            .map(|token| (token.position, token.text.into_bytes()))
138            .collect();
139        Self::with_offsets(field, terms)
140    }
141
142    /// Whether every term must directly follow the previous one.
143    fn is_adjacent(&self) -> bool {
144        self.offsets.windows(2).all(|pair| pair[1] == pair[0] + 1)
145    }
146
147    /// Set slop (max distance between terms)
148    pub fn with_slop(mut self, slop: u32) -> Self {
149        self.slop = slop;
150        self
151    }
152
153    /// Set global statistics for cross-segment IDF
154    pub fn with_global_stats(mut self, stats: Arc<GlobalStats>) -> Self {
155        self.global_stats = Some(stats);
156        self
157    }
158}
159
160/// Ordered maps need only one document's ordinals and one matching-chunk
161/// lookahead. Reordered maps retain the stable, all-document aggregation.
162pub(super) fn fold_chunked_phrase_scorer<'a, S: Scorer + 'a>(
163    mut scorer: S,
164    chunk_map: crate::segment::chunk_map::ChunkMap,
165    field_id: u32,
166    budget: Option<super::SharedThreshold>,
167) -> Box<dyn Scorer + 'a> {
168    if chunk_map.is_doc_ordered() {
169        let mut folded = ChunkedPhraseScorer {
170            inner: scorer,
171            chunk_map,
172            field_id,
173            budget,
174            current_doc: TERMINATED,
175            score: 0.0,
176            ordinals: crate::segment::VectorOrdinals::new(),
177        };
178        folded.fold_next_document();
179        return Box::new(folded);
180    }
181    let mut raw: Vec<(u32, u16, f32)> = Vec::new();
182    while scorer.doc() != TERMINATED {
183        if budget
184            .as_ref()
185            .is_some_and(super::SharedThreshold::stop_if_expired)
186        {
187            return Box::new(EmptyScorer);
188        }
189        let (doc_id, ordinal) = chunk_map.resolve(scorer.doc());
190        raw.push((doc_id, ordinal, scorer.score()));
191        scorer.advance();
192    }
193    if budget
194        .as_ref()
195        .is_some_and(super::SharedThreshold::stop_if_expired)
196    {
197        // Do not start an all-hit sort/fold after cancellation.
198        return Box::new(EmptyScorer);
199    }
200    // Every matching document is kept: a phrase is also used as a MUST
201    // constraint (verifier or bitset), where truncating to `limit` would
202    // silently reject documents that do contain the phrase.
203    let combined =
204        crate::segment::combine_ordinal_results(raw, super::MultiValueCombiner::Max, usize::MAX);
205    Box::new(super::vector::VectorResultScorer::new(combined, field_id))
206}
207
208struct ChunkedPhraseScorer<S> {
209    inner: S,
210    chunk_map: crate::segment::chunk_map::ChunkMap,
211    field_id: u32,
212    budget: Option<super::SharedThreshold>,
213    current_doc: DocId,
214    score: Score,
215    ordinals: crate::segment::VectorOrdinals,
216}
217
218impl<S: Scorer> ChunkedPhraseScorer<S> {
219    fn finish(&mut self) -> DocId {
220        self.current_doc = TERMINATED;
221        self.score = 0.0;
222        self.ordinals.clear();
223        TERMINATED
224    }
225
226    fn expired(&self) -> bool {
227        self.budget
228            .as_ref()
229            .is_some_and(super::SharedThreshold::stop_if_expired)
230    }
231
232    fn fold_next_document(&mut self) -> DocId {
233        if self.expired() || self.inner.doc() == TERMINATED {
234            return self.finish();
235        }
236        let doc = self.chunk_map.doc_id(self.inner.doc());
237        self.ordinals.clear();
238        loop {
239            self.ordinals.push((
240                u32::from(self.chunk_map.ordinal(self.inner.doc())),
241                self.inner.score(),
242            ));
243            self.inner.advance();
244            // Never expose a partial document's max score or ordinal list.
245            if self.expired() {
246                return self.finish();
247            }
248            if self.inner.doc() == TERMINATED || self.chunk_map.doc_id(self.inner.doc()) != doc {
249                break;
250            }
251        }
252        self.current_doc = doc;
253        self.score = super::MultiValueCombiner::Max.combine(&self.ordinals);
254        doc
255    }
256}
257
258impl<S: Scorer> DocSet for ChunkedPhraseScorer<S> {
259    fn doc(&self) -> DocId {
260        self.current_doc
261    }
262
263    fn advance(&mut self) -> DocId {
264        if self.current_doc == TERMINATED {
265            return TERMINATED;
266        }
267        self.fold_next_document()
268    }
269
270    fn seek(&mut self, target: DocId) -> DocId {
271        if self.current_doc >= target {
272            return self.current_doc;
273        }
274        if target == TERMINATED || self.expired() {
275            return self.finish();
276        }
277        let vid = self.chunk_map.lower_bound_doc(target);
278        if vid == self.chunk_map.num_chunks() {
279            return self.finish();
280        }
281        self.inner.seek(vid);
282        self.fold_next_document()
283    }
284
285    fn size_hint(&self) -> u32 {
286        if self.current_doc == TERMINATED {
287            0
288        } else {
289            self.inner.size_hint().saturating_add(1)
290        }
291    }
292}
293
294impl<S: Scorer> Scorer for ChunkedPhraseScorer<S> {
295    fn score(&self) -> Score {
296        self.score
297    }
298
299    fn matched_positions(&self) -> Option<super::MatchedPositions> {
300        (self.current_doc != TERMINATED).then(|| {
301            vec![(
302                self.field_id,
303                self.ordinals
304                    .iter()
305                    .map(|&(ordinal, score)| super::ScoredPosition::new(ordinal, score))
306                    .collect(),
307            )]
308        })
309    }
310}
311
312/// Build the shared positional scorer without walking beyond the candidate set.
313#[allow(clippy::too_many_arguments)]
314fn prepare_phrase_scorer(
315    reader: &SegmentReader,
316    field: Field,
317    terms: &[Vec<u8>],
318    term_data: Vec<(BlockPostingList, TermPositions)>,
319    offsets: &[u32],
320    slop: u32,
321    stats: Option<&Arc<GlobalStats>>,
322    budget: Option<super::SharedThreshold>,
323) -> crate::Result<PhraseScorer> {
324    let mut avg_len = reader.avg_field_len(field);
325    let mut idf = 0.0;
326    for ((postings, _), term) in term_data.iter().zip(terms) {
327        let (term_idf, length) =
328            super::term::compute_term_idf(postings, field, reader, stats, term);
329        idf += term_idf;
330        avg_len = length;
331    }
332    let (postings, positions) = term_data.into_iter().unzip();
333    let mut scorer =
334        PhraseScorer::unpositioned(postings, positions, offsets, slop, idf, avg_len, budget)
335            .with_params(super::Bm25Params::for_field(reader.schema(), field));
336    if reader.has_text_mapping(field) {
337        let map = reader.chunk_map(field).ok_or_else(|| {
338            crate::Error::Corruption("chunked phrase has postings without a chunk map".into())
339        })?;
340        scorer = scorer.with_lengths(Lengths::Chunks(map.clone()));
341    } else if let Some(lengths) = reader.doc_lengths(field) {
342        scorer = scorer.with_lengths(Lengths::Docs(lengths.clone()));
343    }
344    Ok(scorer)
345}
346
347/// Ordinary retrieval enumerates phrase matches; point scoring below parks
348/// these same cursors only on nominated targets and shares frequency/scoring.
349fn finish_phrase_scorer<'a>(
350    mut scorer: PhraseScorer,
351    reader: &SegmentReader,
352    field: Field,
353    budget: Option<super::SharedThreshold>,
354    physical: bool,
355) -> crate::Result<Box<dyn Scorer + 'a>> {
356    scorer.find_next_phrase_match();
357    if physical {
358        return Ok(Box::new(scorer));
359    }
360    if let Some(map) = reader.chunk_map(field) {
361        if map.is_document_map() {
362            return super::required_text::mapped_documents(
363                scorer,
364                map.clone(),
365                reader.num_docs(),
366                budget,
367                |scorer, slot| {
368                    scorer.intersection.reset();
369                    for cursor in &mut scorer.posting_iters {
370                        cursor.seek_physical(slot);
371                    }
372                    scorer.park(TERMINATED, None);
373                    scorer.find_next_phrase_match();
374                    scorer.doc()
375                },
376            );
377        }
378        Ok(fold_chunked_phrase_scorer(
379            scorer,
380            map.clone(),
381            field.0,
382            budget,
383        ))
384    } else {
385        Ok(Box::new(scorer))
386    }
387}
388
389pub(super) async fn score_phrase_candidates(
390    reader: &SegmentReader,
391    query: &PhraseQuery,
392    targets: &[u32],
393    stats: Option<&Arc<GlobalStats>>,
394) -> crate::Result<Vec<f32>> {
395    reader.check_posting_integrity()?;
396    let stats = query.global_stats.as_ref().or(stats);
397    if query.terms.len() == 1 {
398        return super::term::score_term_candidates(
399            reader,
400            query.field,
401            &[(query.terms[0].clone(), 1.0)],
402            targets,
403            stats,
404            &mut Default::default(),
405        )
406        .await;
407    }
408    let mut scores = vec![0.0; targets.len()];
409    if query.terms.is_empty() {
410        return Ok(scores);
411    }
412    PhraseQuery::validate_positions(reader.schema(), query.field)?;
413    let mut data = Vec::with_capacity(query.terms.len());
414    for term in &query.terms {
415        let (p, pos) = futures::join!(
416            reader.get_postings(query.field, term),
417            reader.get_positions(query.field, term)
418        );
419        match (p?, pos?) {
420            (Some(p), Some(pos)) => data.push((p, pos)),
421            _ => return Ok(scores),
422        }
423    }
424    let mut scorer = prepare_phrase_scorer(
425        reader,
426        query.field,
427        &query.terms,
428        data,
429        &query.offsets,
430        query.slop,
431        stats,
432        None,
433    )?;
434    for (index, &target) in targets.iter().enumerate() {
435        let mut matches = true;
436        for cursor in &mut scorer.posting_iters {
437            matches &= cursor.seek(target) == target;
438        }
439        if matches && scorer.check_phrase_positions() {
440            scorer.current_doc = target;
441            scores[index] = scorer.score();
442        }
443    }
444    reader.check_posting_integrity()?;
445    Ok(scores)
446}
447
448// ── Shared early-return checks for phrase scorer ─────────────────────────
449//
450// Handles: empty terms, single-term delegation, token-position requirements.
451// Parameterised on the option-aware scorer function plus async/sync awaiting.
452macro_rules! phrase_early_returns {
453    ($field:expr, $terms:expr, $reader:expr, $limit:expr,
454     $scorer_fn:ident, $options:expr $(, $aw:tt)*) => {
455        if $options.stop_if_expired() || $terms.is_empty() {
456            return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>);
457        }
458        if $terms.len() == 1 {
459            let tq = super::TermQuery::new($field, $terms[0].clone());
460            return tq.$scorer_fn($reader, $limit, $options) $(. $aw)* ;
461        }
462        PhraseQuery::validate_positions($reader.schema(), $field)?;
463    };
464}
465
466impl Query for PhraseQuery {
467    fn physical_text_field(&self, reader: &SegmentReader, _complete: bool) -> Option<Field> {
468        let entry = reader.schema().get_field_entry(self.field)?;
469        (entry.indexed
470            && !entry.fast
471            && reader
472                .chunk_map(self.field)
473                .is_some_and(|map| map.is_document_map()))
474        .then_some(self.field)
475    }
476    fn candidate_query(&self) -> crate::Result<crate::query::CandidateQuery> {
477        Ok(super::CandidateQuery::new(
478            self.field,
479            super::candidate_scoring::ScoreComponent::Phrase(self.clone()),
480        ))
481    }
482
483    fn text_terms(&self, out: &mut Vec<(Field, Vec<u8>)>) {
484        for term in &self.terms {
485            out.push((self.field, term.clone()));
486        }
487    }
488
489    fn scorer<'a>(&self, reader: &'a SegmentReader, limit: usize) -> ScorerFuture<'a> {
490        self.scorer_with_options(reader, limit, super::ScorerOptions::with_positions())
491    }
492
493    fn scorer_with_options<'a>(
494        &self,
495        reader: &'a SegmentReader,
496        limit: usize,
497        options: super::ScorerOptions,
498    ) -> ScorerFuture<'a> {
499        let field = self.field;
500        let terms = self.terms.clone();
501        let offsets = self.offsets.clone();
502        let slop = self.slop;
503        let stats = self
504            .global_stats
505            .clone()
506            .or_else(|| options.global_stats.clone());
507
508        Box::pin(async move {
509            phrase_early_returns!(
510                field,
511                terms,
512                reader,
513                limit,
514                scorer_with_options,
515                options,
516                await
517            );
518
519            // Fetch postings + positions in parallel per term via futures::join!
520            let mut term_data = Vec::with_capacity(terms.len());
521            for term in &terms {
522                let (postings, positions) = futures::join!(
523                    reader.get_postings(field, term),
524                    reader.get_positions(field, term)
525                );
526                match (postings?, positions?) {
527                    (Some(p), Some(pos)) => term_data.push((p, pos)),
528                    _ => return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + 'a>),
529                }
530            }
531
532            let scorer = prepare_phrase_scorer(
533                reader,
534                field,
535                &terms,
536                term_data,
537                &offsets,
538                slop,
539                stats.as_ref(),
540                options.shared_threshold.clone(),
541            )?;
542            finish_phrase_scorer(
543                scorer,
544                reader,
545                field,
546                options.shared_threshold,
547                options.physical_text_field == Some(field),
548            )
549        })
550    }
551
552    #[cfg(feature = "sync")]
553    fn scorer_sync<'a>(
554        &self,
555        reader: &'a SegmentReader,
556        limit: usize,
557    ) -> crate::Result<Box<dyn Scorer + 'a>> {
558        self.scorer_sync_with_options(reader, limit, super::ScorerOptions::with_positions())
559    }
560
561    #[cfg(feature = "sync")]
562    fn scorer_sync_with_options<'a>(
563        &self,
564        reader: &'a SegmentReader,
565        limit: usize,
566        options: super::ScorerOptions,
567    ) -> crate::Result<Box<dyn Scorer + 'a>> {
568        phrase_early_returns!(
569            self.field,
570            self.terms,
571            reader,
572            limit,
573            scorer_sync_with_options,
574            options
575        );
576
577        // Two memory-backed term lookups do not need nested worker tasks.
578        // Larger phrases retain parallel fetching on the shared search pool.
579        use rayon::prelude::*;
580        let load = |term: &Vec<u8>| {
581            let postings = reader.get_postings_sync(self.field, term)?;
582            let positions = reader.get_positions_sync(self.field, term)?;
583            Ok(match (postings, positions) {
584                (Some(p), Some(pos)) => Some((p, pos)),
585                _ => None,
586            })
587        };
588        let pairs: crate::Result<Vec<Option<(BlockPostingList, TermPositions)>>> =
589            if self.terms.len() == 2 {
590                self.terms.iter().map(load).collect()
591            } else {
592                self.terms.par_iter().map(load).collect()
593            };
594        let mut term_data = Vec::with_capacity(self.terms.len());
595        for entry in pairs? {
596            match entry {
597                Some(pair) => term_data.push(pair),
598                None => return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + 'a>),
599            }
600        }
601
602        let scorer = prepare_phrase_scorer(
603            reader,
604            self.field,
605            &self.terms,
606            term_data,
607            &self.offsets,
608            self.slop,
609            self.global_stats.as_ref().or(options.global_stats.as_ref()),
610            options.shared_threshold.clone(),
611        )?;
612        finish_phrase_scorer(
613            scorer,
614            reader,
615            self.field,
616            options.shared_threshold,
617            options.physical_text_field == Some(self.field),
618        )
619    }
620
621    /// Every document containing the phrase, as a bitset (documents, also
622    /// for chunked fields). Lets the planner push a quoted span into the
623    /// MaxScore executors as an O(1) predicate instead of a verifier.
624    #[cfg(feature = "sync")]
625    fn as_doc_bitset(&self, reader: &SegmentReader) -> Option<super::DocBitset> {
626        self.as_doc_bitset_with_options(reader, &super::ScorerOptions::default())
627    }
628
629    #[cfg(feature = "sync")]
630    fn as_doc_bitset_with_options(
631        &self,
632        reader: &SegmentReader,
633        options: &super::ScorerOptions,
634    ) -> Option<super::DocBitset> {
635        if options.stop_if_expired() || self.terms.is_empty() {
636            return None;
637        }
638        let mut bitset = super::DocBitset::new(reader.num_docs());
639        if self.terms.len() == 1 {
640            // Non-indexed terms may match through a fast column. Preserve the
641            // scorer fallback; absent postings do not prove an empty filter.
642            if reader
643                .schema()
644                .get_field_entry(self.field)
645                .is_some_and(|entry| !entry.indexed)
646            {
647                return None;
648            }
649            // A one-term phrase is the term itself; walk its postings and
650            // resolve chunk ids to documents where needed.
651            let Some(list) = reader.get_postings_sync(self.field, &self.terms[0]).ok()? else {
652                // A supported filter with no matches is not an unsupported
653                // filter: the latter would invoke the bounded scorer fallback.
654                return Some(bitset);
655            };
656            let chunk_map = reader.chunk_map(self.field);
657            let mut it = list.iterator();
658            while it.doc() != TERMINATED {
659                if options.stop_if_expired() {
660                    return None;
661                }
662                let doc = chunk_map.map_or(it.doc(), |map| map.doc_id(it.doc()));
663                bitset.set(doc);
664                it.advance();
665            }
666            return Some(bitset);
667        }
668        let mut scorer = self
669            .scorer_sync_with_options(reader, usize::MAX, options.without_threshold())
670            .ok()?;
671        while scorer.doc() != TERMINATED {
672            if options.stop_if_expired() {
673                return None;
674            }
675            bitset.set(scorer.doc());
676            scorer.advance();
677        }
678        if options.stop_if_expired() {
679            None
680        } else {
681            Some(bitset)
682        }
683    }
684
685    /// Matches are at most the rarest term's postings; the planner only
686    /// needs the order of magnitude to pick which clause to materialize.
687    #[cfg(feature = "sync")]
688    fn bitset_cardinality_estimate(&self, reader: &SegmentReader) -> Option<u64> {
689        let mut min = u64::MAX;
690        for term in &self.terms {
691            let list = reader.get_postings_sync(self.field, term).ok()??;
692            min = min.min(u64::from(list.doc_count()));
693        }
694        Some((min / 10).max(1))
695    }
696
697    fn count_estimate<'a>(&self, reader: &'a SegmentReader) -> CountFuture<'a> {
698        let field = self.field;
699        let terms = self.terms.clone();
700
701        Box::pin(async move {
702            if terms.is_empty() {
703                return Ok(0);
704            }
705
706            // Estimate based on minimum posting list size
707            let mut min_count = u32::MAX;
708            for term in &terms {
709                match reader.get_postings(field, term).await? {
710                    Some(list) => min_count = min_count.min(list.doc_count()),
711                    None => return Ok(0),
712                }
713            }
714
715            // Phrase matching will typically match fewer docs than the minimum
716            // Estimate ~10% of the smallest posting list
717            Ok((min_count / 10).max(1))
718        })
719    }
720}
721
722/// Real lengths of the scoring units of a phrase: chunk lengths of a chunked
723/// field or persisted document lengths of a plain field.
724enum Lengths {
725    Chunks(crate::segment::chunk_map::ChunkMap),
726    Docs(crate::segment::chunk_map::DocLengths),
727}
728
729impl Lengths {
730    fn length(&self, id: u32) -> u32 {
731        match self {
732            Lengths::Chunks(map) => map.bm25_length(id),
733            Lengths::Docs(lengths) => lengths.length(id),
734        }
735    }
736}
737
738/// Phrase frequency cannot exceed the original-first term's frequency, so its
739/// existing term envelopes also bound the phrase in the phrase's score space.
740fn phrase_block_bound(
741    bounds: &super::bm25::PreparedBounds,
742    list: &BlockPostingList,
743    block: usize,
744    singletons: Option<&[Score; 1024]>,
745) -> Score {
746    let (tf, length) = list.block_bounds(block).unwrap_or((0, None));
747    let length = length.unwrap_or(1).max(1);
748    let mut score = if tf == 1 {
749        singletons
750            .and_then(|table| table.get(length as usize))
751            .copied()
752            .unwrap_or_else(|| bounds.pair(tf, length))
753    } else {
754        bounds.pair(tf, length)
755    };
756    if list.has_ratio_bounds() {
757        score = score.min(bounds.ratio(tf, list.block_length_ratio(block)));
758    }
759    if list.has_impact_bounds() {
760        score = score.min(bounds.impacts(|a, b| list.block_impact_minimum(block, a, b)));
761    }
762    score
763}
764
765/// The same phrase-frequency bound applies across an existing L1 posting group.
766fn phrase_group_bound(
767    bounds: &super::bm25::PreparedBounds,
768    list: &BlockPostingList,
769    block: usize,
770    singletons: Option<&[Score; 1024]>,
771) -> Option<(DocId, Score)> {
772    let (tf, length) = list.group_bounds(block)?;
773    let length = length.max(1);
774    let mut score = if tf == 1 {
775        singletons
776            .and_then(|table| table.get(length as usize))
777            .copied()
778            .unwrap_or_else(|| bounds.pair(tf, length))
779    } else {
780        bounds.pair(tf, length)
781    };
782    if list.has_ratio_bounds() {
783        score = score.min(bounds.ratio(tf, list.group_length_ratio(block)));
784    }
785    if list.has_group_impact_bounds() {
786        score = score.min(bounds.impacts(|a, b| list.group_impact_minimum(block, a, b)));
787    }
788    Some((list.group_last_doc(block)?, score))
789}
790
791/// Scorer that checks phrase positions
792struct PhraseScorer {
793    /// Upper bound on matches, used to choose conjunction drivers.
794    cost: u32,
795    /// Rarest posting list; position arrays retain their original phrase order.
796    lead: usize,
797    /// Exact phrases with unique original-first starts may use any term's TF.
798    bound_term: usize,
799    /// Unique original-first starts certify every exact-phrase term's TF bound.
800    exact_term_bounds: bool,
801    scan_bound_term: bool,
802    intersection: crate::structures::postings::PostingIntersection,
803    budget: Option<super::SharedThreshold>,
804    /// Posting iterators for each term
805    posting_iters: Vec<BlockPostingIterator<'static>>,
806    /// Cursor-addressed position streams for each term
807    position_lists: Vec<crate::structures::postings::TermPositionCursor>,
808    /// Position-list cursors advance monotonically within a matching unit.
809    position_indices: Vec<usize>,
810    /// Traversal order is independent of original phrase coordinates and score
811    /// multiplicity. Every posting probe starts with the most selective terms.
812    term_order: smallvec::SmallVec<[usize; 8]>,
813    /// Required distance of each term from the first one (`offsets[i] -
814    /// offsets[0]`); `deltas[0]` is 0.
815    deltas: Vec<u32>,
816    /// Max slop between terms
817    slop: u32,
818    /// Current matching document
819    current_doc: DocId,
820    /// Membership is confirmed by one occurrence. Scoring finishes the saved
821    /// monotone scan once; immutable score reads remain safe to share.
822    first_match: bool,
823    frequency: std::sync::OnceLock<u32>,
824    next_start: usize,
825    /// Exact confirmation for the current candidate; reset on cursor movement.
826    confirmed: Option<bool>,
827    /// Combined IDF
828    idf: f32,
829    /// Per-field k1/b.
830    params: super::Bm25Params,
831    /// Average field length
832    avg_field_len: f32,
833    /// Real lengths of the scoring units. `None` keeps the historic
834    /// `tf`-as-length approximation.
835    lengths: Option<Lengths>,
836    /// Whether per-candidate score bounds are numerically safe; computed once
837    /// from the lengths and parameters, not per candidate.
838    prepared_bounds: Option<super::bm25::PreparedBounds>,
839    singleton_bounds: Option<Box<[Score; 1024]>>,
840    competitive_lengths: Option<CompetitiveLengths>,
841    /// Reusable position buffers (one per term, avoids per-document allocation)
842    position_bufs: Vec<Vec<u32>>,
843}
844
845impl PhraseScorer {
846    #[allow(clippy::too_many_arguments)]
847    fn unpositioned(
848        posting_lists: Vec<BlockPostingList>,
849        position_lists: Vec<TermPositions>,
850        offsets: &[u32],
851        slop: u32,
852        idf: f32,
853        avg_field_len: f32,
854        budget: Option<super::SharedThreshold>,
855    ) -> Self {
856        let (lead, cost) = posting_lists
857            .iter()
858            .enumerate()
859            .map(|(index, list)| (index, list.doc_count()))
860            .min_by_key(|&(_, count)| count)
861            .unwrap_or((0, 0));
862        let exact_term_bounds = slop == 0
863            && position_lists
864                .first()
865                .is_some_and(TermPositions::has_unique_positions);
866        let bound_term = if exact_term_bounds { lead } else { 0 };
867        let scan_bound_term = posting_lists
868            .get(bound_term)
869            .is_some_and(|list| list.doc_count() <= cost.saturating_mul(4));
870        let mut term_order: smallvec::SmallVec<[usize; 8]> = (0..posting_lists.len()).collect();
871        term_order.sort_unstable_by_key(|&index| (posting_lists[index].doc_count(), index));
872        let intersection = if term_order.len() >= 2 {
873            crate::structures::postings::PostingIntersection::with_costs(
874                posting_lists[term_order[0]].doc_count(),
875                posting_lists[term_order[1]].doc_count(),
876            )
877        } else {
878            Default::default()
879        };
880        let posting_iters: Vec<_> = posting_lists
881            .into_iter()
882            .map(|p| p.into_iterator())
883            .collect();
884
885        let num_terms = position_lists.len();
886        // Offsets are optional for callers that built the query term by
887        // term; missing entries mean adjacency.
888        let first = offsets.first().copied().unwrap_or(0);
889        let deltas: Vec<u32> = (0..num_terms)
890            .map(|i| offsets.get(i).map_or(i as u32, |o| o - first))
891            .collect();
892        Self {
893            cost,
894            lead,
895            bound_term,
896            exact_term_bounds,
897            scan_bound_term,
898            intersection,
899            budget: budget.filter(|b| b.deadline().is_some()),
900            posting_iters,
901            position_lists: position_lists
902                .into_iter()
903                .map(TermPositions::into_cursor)
904                .collect(),
905            position_indices: vec![0; num_terms],
906            term_order,
907            deltas,
908            slop,
909            current_doc: 0,
910            first_match: false,
911            frequency: std::sync::OnceLock::new(),
912            next_start: 0,
913            confirmed: None,
914            params: super::Bm25Params::default(),
915            idf,
916            avg_field_len,
917            lengths: None,
918            prepared_bounds: None,
919            singleton_bounds: None,
920            competitive_lengths: None,
921            position_bufs: (0..num_terms).map(|_| Vec::new()).collect(),
922        }
923    }
924
925    /// Score with the real length of each scoring unit.
926    fn with_lengths(mut self, lengths: Lengths) -> Self {
927        self.lengths = Some(lengths);
928        self.prepared_bounds = self.prepare_bounds();
929        self.competitive_lengths = None;
930        self.singleton_bounds = self.prepare_singleton_bounds();
931        self
932    }
933
934    /// Score with the field's BM25 parameters.
935    fn with_params(mut self, params: super::Bm25Params) -> Self {
936        self.params = params;
937        self.prepared_bounds = self.prepare_bounds();
938        self.competitive_lengths = None;
939        self.singleton_bounds = self.prepare_singleton_bounds();
940        self
941    }
942
943    /// Conservative bounds need document lengths and parameters under which
944    /// the canonical score is finite and monotone in term frequency.
945    fn prepare_bounds(&self) -> Option<super::bm25::PreparedBounds> {
946        match &self.lengths {
947            Some(Lengths::Docs(_)) => {}
948            Some(Lengths::Chunks(map)) if map.is_document_map() => {}
949            _ => return None,
950        }
951        super::bm25::PreparedBounds::new(self.params, u32::MAX, self.idf, self.avg_field_len)
952    }
953
954    /// Cache the common singleton frequency over short scoring lengths. Scratch
955    /// is bounded at 4 KiB per scorer; short lists and longer lengths use the
956    /// same scalar bound without paying the table construction cost.
957    fn prepare_singleton_bounds(&self) -> Option<Box<[Score; 1024]>> {
958        self.prepared_bounds.as_ref()?;
959        if self.cost < 1024 {
960            return None;
961        }
962        Some(Box::new(std::array::from_fn(|length| {
963            self.params
964                .score(1.0, self.idf, (length as f32).max(1.0), self.avg_field_len)
965        })))
966    }
967
968    /// Park on a posting intersection without reading positions.
969    fn find_next_candidate(&mut self) -> DocId {
970        let doc = self.find_next_and_match();
971        if doc != self.current_doc {
972            self.park(doc, None);
973        }
974        doc
975    }
976
977    /// Move to `doc` and invalidate per-document phrase state. `confirmed`
978    /// records whether the candidate's positions were already checked.
979    fn park(&mut self, doc: DocId, confirmed: Option<bool>) {
980        self.current_doc = doc;
981        self.first_match = false;
982        self.frequency.take();
983        self.confirmed = confirmed;
984    }
985
986    /// Both cursors are parked on an already consumed conjunction candidate.
987    /// Moving them together exposes consecutive common matches without another
988    /// intersection probe. Other phrase arities keep the rarest-term driver.
989    fn advance_aligned(&mut self) {
990        if let [first, second] = self.posting_iters.as_mut_slice() {
991            first.advance();
992            second.advance();
993        } else {
994            self.posting_iters[self.lead].advance();
995        }
996    }
997
998    /// Find next document where all terms appear as a phrase.
999    fn find_next_phrase_match(&mut self) {
1000        while self.find_next_candidate() != TERMINATED {
1001            if self.confirm_candidate() {
1002                return;
1003            }
1004            if self.current_doc == TERMINATED {
1005                return;
1006            }
1007            self.advance_aligned();
1008        }
1009    }
1010
1011    /// Find next document where all terms appear
1012    fn find_next_and_match(&mut self) -> DocId {
1013        self.find_next_and_match_through::<false>(TERMINATED)
1014    }
1015
1016    fn find_next_and_match_through<const BOUNDED: bool>(&mut self, last: DocId) -> DocId {
1017        if self.posting_iters.is_empty() {
1018            return TERMINATED;
1019        }
1020
1021        if self.posting_iters.len() == 1 {
1022            return self.posting_iters[0].doc();
1023        }
1024
1025        // Keep the two common cursors borrowed through alignment. Their
1026        // query identities and position streams remain in their original order.
1027        if let [first, second] = self.posting_iters.as_mut_slice() {
1028            let (lead, other) = if self.lead == 0 {
1029                (first, second)
1030            } else {
1031                (second, first)
1032            };
1033            loop {
1034                if BOUNDED && lead.doc() > last {
1035                    return TERMINATED;
1036                }
1037                if self
1038                    .budget
1039                    .as_ref()
1040                    .is_some_and(super::SharedThreshold::stop_if_expired)
1041                {
1042                    return TERMINATED;
1043                }
1044                if let Some(doc) = self.intersection.intersect_block(lead, other) {
1045                    return if BOUNDED && doc > last {
1046                        TERMINATED
1047                    } else {
1048                        doc
1049                    };
1050                }
1051            }
1052        }
1053
1054        'align: loop {
1055            if BOUNDED && self.posting_iters[self.lead].doc() > last {
1056                return TERMINATED;
1057            }
1058            if self
1059                .budget
1060                .as_ref()
1061                .is_some_and(super::SharedThreshold::stop_if_expired)
1062            {
1063                return TERMINATED;
1064            }
1065            let [lead, second] = self
1066                .posting_iters
1067                .get_disjoint_mut([self.lead, self.term_order[1]])
1068                .expect("distinct phrase cursors");
1069            let Some(candidate) = self.intersection.intersect_block(lead, second) else {
1070                continue;
1071            };
1072            if candidate == TERMINATED || (BOUNDED && candidate > last) {
1073                return TERMINATED;
1074            }
1075            for &index in self.term_order.iter().skip(2) {
1076                let doc = self.posting_iters[index].seek(candidate);
1077                if doc != candidate {
1078                    self.posting_iters[self.lead].seek(doc);
1079                    continue 'align;
1080                }
1081            }
1082            return candidate;
1083        }
1084    }
1085
1086    /// Check if positions form a valid phrase for the given document
1087    fn check_phrase_positions(&mut self) -> bool {
1088        crate::observe::search_work!(phrase_confirmations += 1);
1089        // Backfill also calls this method directly after parking term cursors.
1090        // Frequency belongs to these position buffers, so invalidate it at
1091        // their write boundary as well as on ordinary candidate movement.
1092        self.first_match = false;
1093        self.frequency.take();
1094        if self.slop == 0 {
1095            return self.check_exact_phrase_positions();
1096        }
1097        // Get positions for each term into reusable buffers (zero allocation).
1098        // The doc-posting iterator of every term is parked on `doc_id`, so
1099        // its cursor and term frequency address the term's position stream.
1100        for i in 0..self.position_lists.len() {
1101            if !self.read_term_positions(i) {
1102                return false;
1103            }
1104        }
1105
1106        self.position_indices.fill(0);
1107        self.next_start = 0;
1108        self.first_match = scan_phrase_matches(
1109            &self.position_bufs,
1110            &self.deltas,
1111            self.slop,
1112            &mut self.position_indices,
1113            &mut self.next_start,
1114            1,
1115        ) != 0;
1116        self.first_match
1117    }
1118
1119    fn read_term_positions(&mut self, term: usize) -> bool {
1120        let (cursor, tf) = self.posting_iters[term].position_range();
1121        self.position_lists[term].read_into(cursor, tf, &mut self.position_bufs[term])
1122    }
1123
1124    fn check_exact_phrase_positions(&mut self) -> bool {
1125        // A singleton of the certified bounding term fixes the only possible
1126        // original-first start. Probe each required position directly; this
1127        // also avoids materializing intermediate lists for longer phrases.
1128        if self.posting_iters.len() > 2 && self.posting_iters[self.bound_term].term_freq() == 1 {
1129            let anchor = self.bound_term;
1130            let (cursor, _) = self.posting_iters[anchor].position_range();
1131            let Some(start) = self.position_lists[anchor]
1132                .read_one(cursor, &mut self.position_bufs[anchor])
1133                .and_then(|position| position.checked_sub(self.deltas[anchor]))
1134            else {
1135                return false;
1136            };
1137            for index in 0..self.term_order.len() {
1138                let term = self.term_order[index];
1139                if term == anchor {
1140                    continue;
1141                }
1142                let Some(target) = start.checked_add(self.deltas[term]) else {
1143                    return false;
1144                };
1145                let (cursor, tf) = self.posting_iters[term].position_range();
1146                if !self.position_lists[term].contains(
1147                    cursor,
1148                    tf,
1149                    target,
1150                    &mut self.position_bufs[term],
1151                ) {
1152                    return false;
1153                }
1154            }
1155            self.first_match = true;
1156            self.frequency = std::sync::OnceLock::from(1);
1157            return true;
1158        }
1159        if self.posting_iters.len() == 2 {
1160            let (first_cursor, first_tf) = self.posting_iters[0].position_range();
1161            let (second_cursor, second_tf) = self.posting_iters[1].position_range();
1162            if first_tf == 1 || (self.exact_term_bounds && second_tf == 1) {
1163                let (anchor, other, cursor, other_cursor, other_tf) = if first_tf == 1 {
1164                    (0, 1, first_cursor, second_cursor, second_tf)
1165                } else {
1166                    (1, 0, second_cursor, first_cursor, first_tf)
1167                };
1168                let target = self.position_lists[anchor]
1169                    .read_one(cursor, &mut self.position_bufs[anchor])
1170                    .and_then(|position| {
1171                        if anchor == 0 {
1172                            position.checked_add(self.deltas[1])
1173                        } else {
1174                            position.checked_sub(self.deltas[1])
1175                        }
1176                    });
1177                self.first_match = target.is_some_and(|target| {
1178                    self.position_lists[other].contains(
1179                        other_cursor,
1180                        other_tf,
1181                        target,
1182                        &mut self.position_bufs[other],
1183                    )
1184                });
1185                self.frequency = std::sync::OnceLock::from(u32::from(self.first_match));
1186                return self.first_match;
1187            }
1188            if !self.position_lists[0].read_into(first_cursor, first_tf, &mut self.position_bufs[0])
1189                || !self.position_lists[1].read_into(
1190                    second_cursor,
1191                    second_tf,
1192                    &mut self.position_bufs[1],
1193                )
1194            {
1195                return false;
1196            }
1197            self.next_start = 0;
1198            self.position_indices[1] = 0;
1199            self.first_match = next_exact_phrase_match(
1200                &self.position_bufs[0],
1201                &self.position_bufs[1],
1202                self.deltas[1],
1203                &mut self.next_start,
1204                &mut self.position_indices[1],
1205            )
1206            .is_some();
1207            return self.first_match;
1208        }
1209        let anchor = self.lead;
1210        let last = if anchor == 0 {
1211            *self.term_order.last().unwrap()
1212        } else {
1213            0
1214        };
1215        if !self.read_term_positions(anchor) {
1216            return false;
1217        }
1218        if anchor != 0 {
1219            // Filtering coordinates are possible original-first starts. Values
1220            // before the anchor offset cannot participate in an exact phrase.
1221            let delta = self.deltas[anchor];
1222            self.position_bufs[anchor].retain_mut(|position| {
1223                if let Some(start) = position.checked_sub(delta) {
1224                    *position = start;
1225                    true
1226                } else {
1227                    false
1228                }
1229            });
1230        }
1231        if self.position_bufs[anchor].is_empty() {
1232            return false;
1233        }
1234        for rank in 1..self.term_order.len() {
1235            let term = self.term_order[rank];
1236            if term == last && (anchor == 0 || rank + 1 == self.term_order.len()) {
1237                continue;
1238            }
1239            if !self.read_term_positions(term) {
1240                return false;
1241            }
1242            let (starts, positions) = if anchor < term {
1243                let (left, right) = self.position_bufs.split_at_mut(term);
1244                (&mut left[anchor], &right[0])
1245            } else {
1246                let (left, right) = self.position_bufs.split_at_mut(anchor);
1247                (&mut right[0], &left[term])
1248            };
1249            retain_exact_phrase_starts(starts, positions, self.deltas[term]);
1250            if starts.is_empty() {
1251                return false;
1252            }
1253        }
1254        if (anchor == 0 || self.term_order.last() == Some(&0)) && !self.read_term_positions(last) {
1255            return false;
1256        }
1257        // Enumerate the original first term even when a rarer term supplied
1258        // the filter, so duplicate first starts retain their multiplicity.
1259        let (other, delta) = if anchor == 0 {
1260            (last, self.deltas[last])
1261        } else {
1262            (anchor, 0)
1263        };
1264        self.next_start = 0;
1265        self.position_indices[other] = 0;
1266        self.first_match = next_exact_phrase_match(
1267            &self.position_bufs[0],
1268            &self.position_bufs[other],
1269            delta,
1270            &mut self.next_start,
1271            &mut self.position_indices[other],
1272        )
1273        .is_some();
1274        self.first_match
1275    }
1276
1277    /// Resume after the confirmed first occurrence, without scanning its
1278    /// prefix again. Only scoring consumers initialize this per-document value.
1279    fn exact_phrase_frequency(&self) -> u32 {
1280        if !self.first_match {
1281            return 0;
1282        }
1283        *self.frequency.get_or_init(|| {
1284            if self.slop == 0 {
1285                let (last, delta) = if self.posting_iters.len() == 2 {
1286                    (1, self.deltas[1])
1287                } else if self.lead == 0 {
1288                    let last = *self.term_order.last().unwrap();
1289                    (last, self.deltas[last])
1290                } else {
1291                    (self.lead, 0)
1292                };
1293                let mut next_start = self.next_start;
1294                let mut position_index = self.position_indices[last];
1295                let mut matches = 1;
1296                while next_exact_phrase_match(
1297                    &self.position_bufs[0],
1298                    &self.position_bufs[last],
1299                    delta,
1300                    &mut next_start,
1301                    &mut position_index,
1302                )
1303                .is_some()
1304                {
1305                    matches += 1;
1306                }
1307                return matches;
1308            }
1309            let mut indices = smallvec::SmallVec::<[usize; 8]>::from_slice(&self.position_indices);
1310            let mut next_start = self.next_start;
1311            1 + scan_phrase_matches(
1312                &self.position_bufs,
1313                &self.deltas,
1314                self.slop,
1315                &mut indices,
1316                &mut next_start,
1317                u32::MAX,
1318            )
1319        })
1320    }
1321}
1322
1323/// Yield original first-term starts that match an exact offset. Retaining the
1324/// position cursor on equality preserves duplicate first-start multiplicity.
1325#[inline]
1326fn next_exact_phrase_match(
1327    starts: &[u32],
1328    positions: &[u32],
1329    delta: u32,
1330    next_start: &mut usize,
1331    position_index: &mut usize,
1332) -> Option<usize> {
1333    while let (Some(&start), Some(&position)) =
1334        (starts.get(*next_start), positions.get(*position_index))
1335    {
1336        let expected = u64::from(start) + u64::from(delta);
1337        match expected.cmp(&u64::from(position)) {
1338            std::cmp::Ordering::Less => *next_start += 1,
1339            std::cmp::Ordering::Greater => *position_index += 1,
1340            std::cmp::Ordering::Equal => {
1341                let matched = *next_start;
1342                *next_start += 1;
1343                return Some(matched);
1344            }
1345        }
1346    }
1347    None
1348}
1349
1350fn retain_exact_phrase_starts(starts: &mut Vec<u32>, positions: &[u32], delta: u32) {
1351    let mut next_start = 0;
1352    let mut position_index = 0;
1353    let mut retained = 0;
1354    while let Some(matched) = next_exact_phrase_match(
1355        starts,
1356        positions,
1357        delta,
1358        &mut next_start,
1359        &mut position_index,
1360    ) {
1361        // `retained <= matched < next_start`: writes cannot change unread starts.
1362        starts[retained] = starts[matched];
1363        retained += 1;
1364    }
1365    starts.truncate(retained);
1366}
1367
1368/// Count starts with a match in every term's independent slop interval.
1369/// Ascending starts make each interval monotone: O(terms * starts + positions),
1370/// preserving repeated terms and the existing (not edit-distance) slop rule.
1371/// A stopped scan retains its next start and all monotone term cursors.
1372fn scan_phrase_matches(
1373    bufs: &[Vec<u32>],
1374    deltas: &[u32],
1375    slop: u32,
1376    indices: &mut [usize],
1377    next_start: &mut usize,
1378    max_matches: u32,
1379) -> u32 {
1380    if max_matches == 0 {
1381        return 0;
1382    }
1383    let Some(first) = bufs.first() else {
1384        return 0;
1385    };
1386    let mut matches = 0;
1387    'starts: while let Some(&start) = first.get(*next_start) {
1388        *next_start += 1;
1389        for i in 1..bufs.len() {
1390            let expected = u64::from(start) + u64::from(deltas[i]);
1391            let low = expected.saturating_sub(u64::from(slop));
1392            let high = expected + u64::from(slop);
1393            while indices[i] < bufs[i].len() && u64::from(bufs[i][indices[i]]) < low {
1394                indices[i] += 1;
1395            }
1396            let Some(&position) = bufs[i].get(indices[i]) else {
1397                return matches;
1398            };
1399            if u64::from(position) > high {
1400                continue 'starts;
1401            }
1402        }
1403        matches += 1;
1404        if matches == max_matches {
1405            return matches;
1406        }
1407    }
1408    matches
1409}
1410
1411#[cfg(test)]
1412fn count_phrase_matches(
1413    bufs: &[Vec<u32>],
1414    deltas: &[u32],
1415    slop: u32,
1416    indices: &mut [usize],
1417) -> u32 {
1418    indices.fill(0);
1419    scan_phrase_matches(bufs, deltas, slop, indices, &mut 0, u32::MAX)
1420}
1421
1422impl super::docset::DocSet for PhraseScorer {
1423    fn supports_doc_batches(&self) -> bool {
1424        true
1425    }
1426
1427    fn doc(&self) -> DocId {
1428        self.current_doc
1429    }
1430
1431    fn advance(&mut self) -> DocId {
1432        if self.current_doc == TERMINATED {
1433            return TERMINATED;
1434        }
1435
1436        self.advance_aligned();
1437        self.find_next_phrase_match();
1438        self.current_doc
1439    }
1440
1441    fn seek(&mut self, target: DocId) -> DocId {
1442        if target == TERMINATED || self.current_doc == TERMINATED {
1443            self.park(TERMINATED, self.confirmed);
1444            return TERMINATED;
1445        }
1446
1447        self.posting_iters[self.lead].seek(target);
1448        self.find_next_phrase_match();
1449        self.current_doc
1450    }
1451
1452    fn size_hint(&self) -> u32 {
1453        self.cost
1454    }
1455}
1456
1457impl Scorer for PhraseScorer {
1458    fn seed_ranked_score(&mut self, limit: usize) -> Option<Score> {
1459        self.seed_score(limit)
1460    }
1461    fn supports_candidate_score_bounds(&self) -> bool {
1462        self.prepared_bounds.is_some()
1463    }
1464
1465    fn candidate_block_upper_bound(&mut self) -> Option<(DocId, Score)> {
1466        let bounds = self.prepared_bounds.as_ref()?;
1467        if self.current_doc == TERMINATED {
1468            return None;
1469        }
1470        // A certified exact phrase can use its rarest term; otherwise only
1471        // the original-first TF bounds duplicate-start multiplicity.
1472        let first = &self.posting_iters[self.bound_term];
1473        let (list, block) = first.current_block_metadata()?;
1474        let last = list.block_last_doc(block)?;
1475        Some((
1476            last,
1477            phrase_block_bound(bounds, list, block, self.singleton_bounds.as_deref()),
1478        ))
1479    }
1480
1481    fn candidate_score_upper_bound(&self) -> Score {
1482        crate::observe::search_work!(phrase_bound_calls += 1);
1483        if self.current_doc == TERMINATED {
1484            return Score::INFINITY;
1485        }
1486        let Some(bounds) = &self.prepared_bounds else {
1487            return Score::INFINITY;
1488        };
1489        let Some(lengths) = &self.lengths else {
1490            return Score::INFINITY;
1491        };
1492        // The constructor selects a term whose TF bounds phrase frequency.
1493        let max_tf = if self.exact_term_bounds {
1494            self.posting_iters
1495                .iter()
1496                .map(BlockPostingIterator::term_freq)
1497                .min()
1498                .unwrap_or(0)
1499        } else {
1500            self.posting_iters[self.bound_term].term_freq()
1501        };
1502        let length = lengths.length(self.current_doc).max(1);
1503        if max_tf == 1
1504            && let Some(bound) = self
1505                .singleton_bounds
1506                .as_ref()
1507                .and_then(|table| table.get(length as usize))
1508        {
1509            return *bound;
1510        }
1511        if max_tf == 1 {
1512            self.params
1513                .score(1.0, self.idf, length as f32, self.avg_field_len)
1514        } else {
1515            bounds.pair(max_tf, length)
1516        }
1517    }
1518
1519    fn advance_candidate(&mut self) -> DocId {
1520        if self.current_doc == TERMINATED {
1521            return TERMINATED;
1522        }
1523        self.posting_iters[self.lead].advance();
1524        self.find_next_candidate()
1525    }
1526
1527    fn advance_competitive_candidate(&mut self, minimum: Score, allow_equal: bool) -> DocId {
1528        if minimum <= 0.0
1529            || !minimum.is_finite()
1530            || self.prepared_bounds.is_none()
1531            || self.cost < 1024
1532        {
1533            return self.advance_candidate();
1534        }
1535        if self.current_doc == TERMINATED {
1536            return TERMINATED;
1537        }
1538        let bounds = self.prepared_bounds.as_ref().unwrap();
1539        if self
1540            .competitive_lengths
1541            .as_ref()
1542            .is_none_or(|table| table.minimum != minimum || table.allow_equal != allow_equal)
1543        {
1544            self.competitive_lengths = Some(CompetitiveLengths::new(
1545                bounds,
1546                minimum,
1547                allow_equal,
1548                |length| {
1549                    self.params
1550                        .score(1.0, self.idf, length as f32, self.avg_field_len)
1551                },
1552            ));
1553        }
1554        // A score scan of a very common first term loses against probing from
1555        // a much shorter list. Keep rarest-first alignment in that case, while
1556        // retaining the same cheap score admission inside the concrete scorer.
1557        if !self.scan_bound_term {
1558            loop {
1559                self.posting_iters[self.lead].advance();
1560                let doc = self.find_next_and_match();
1561                if doc == TERMINATED {
1562                    self.park(doc, Some(false));
1563                    return doc;
1564                }
1565                let tf = self.posting_iters[self.bound_term].term_freq();
1566                let length = self.lengths.as_ref().unwrap().length(doc).max(1);
1567                if self.competitive_lengths.as_ref().unwrap().accepts(
1568                    self.prepared_bounds.as_ref().unwrap(),
1569                    tf,
1570                    length,
1571                ) {
1572                    self.park(doc, None);
1573                    return doc;
1574                }
1575            }
1576        }
1577        self.posting_iters[self.bound_term].advance();
1578        let bounds = self.prepared_bounds.as_ref().unwrap();
1579        let lengths = self.lengths.as_ref().unwrap();
1580        let competitive = self.competitive_lengths.as_ref().unwrap();
1581        let mut checked_block = usize::MAX;
1582        let mut checked_group_end = None;
1583        'align: loop {
1584            if self
1585                .budget
1586                .as_ref()
1587                .is_some_and(super::SharedThreshold::stop_if_expired)
1588            {
1589                self.park(TERMINATED, Some(false));
1590                return TERMINATED;
1591            }
1592            if let Some((list, block)) =
1593                self.posting_iters[self.bound_term].current_block_metadata()
1594                && block != checked_block
1595            {
1596                checked_block = block;
1597                let group_end = list.group_last_doc(block);
1598                if group_end != checked_group_end {
1599                    checked_group_end = group_end;
1600                    if let Some((last, bound)) =
1601                        phrase_group_bound(bounds, list, block, self.singleton_bounds.as_deref())
1602                        && (bound < minimum || (!allow_equal && bound == minimum))
1603                    {
1604                        self.posting_iters[self.bound_term].seek(last.saturating_add(1));
1605                        continue;
1606                    }
1607                }
1608                let bound =
1609                    phrase_block_bound(bounds, list, block, self.singleton_bounds.as_deref());
1610                if bound < minimum || (!allow_equal && bound == minimum) {
1611                    // Inspect a bounded directory run before decoding the next
1612                    // competitive block. Yield after one L1 group for cancellation.
1613                    let end = list.next_group_block(block);
1614                    let mut next = block + 1;
1615                    while next < end {
1616                        let score = phrase_block_bound(
1617                            bounds,
1618                            list,
1619                            next,
1620                            self.singleton_bounds.as_deref(),
1621                        );
1622                        if score > minimum || (allow_equal && score == minimum) {
1623                            break;
1624                        }
1625                        next += 1;
1626                    }
1627                    let target = list
1628                        .block_last_doc(next - 1)
1629                        .map_or(TERMINATED, |last| last.saturating_add(1));
1630                    self.posting_iters[self.bound_term].seek(target);
1631                    continue;
1632                }
1633            }
1634            let Some(doc) = competitive.find_candidate(
1635                bounds,
1636                lengths,
1637                &mut self.posting_iters[self.bound_term],
1638            ) else {
1639                continue;
1640            };
1641            if doc == TERMINATED {
1642                self.park(doc, Some(false));
1643                return doc;
1644            }
1645            for &index in &self.term_order {
1646                if index == self.bound_term {
1647                    continue;
1648                }
1649                let other = self.posting_iters[index].seek(doc);
1650                if other != doc {
1651                    self.posting_iters[self.bound_term].seek(other);
1652                    continue 'align;
1653                }
1654            }
1655            self.park(doc, None);
1656            return doc;
1657        }
1658    }
1659
1660    fn seek_candidate(&mut self, target: DocId) -> DocId {
1661        if target == TERMINATED || self.current_doc == TERMINATED {
1662            self.park(TERMINATED, Some(false));
1663            return TERMINATED;
1664        }
1665        self.posting_iters[self.lead].seek(target);
1666        self.find_next_candidate()
1667    }
1668
1669    fn confirm_candidate(&mut self) -> bool {
1670        if self
1671            .budget
1672            .as_ref()
1673            .is_some_and(super::SharedThreshold::stop_if_expired)
1674        {
1675            self.park(TERMINATED, Some(false));
1676        }
1677        if self.current_doc == TERMINATED {
1678            return false;
1679        }
1680        if let Some(matched) = self.confirmed {
1681            return matched;
1682        }
1683        let matched = self.check_phrase_positions();
1684        self.confirmed = Some(matched);
1685        matched
1686    }
1687
1688    fn score(&self) -> Score {
1689        if self.current_doc == TERMINATED {
1690            return 0.0;
1691        }
1692
1693        // BM25 over the phrase frequency with the summed idf of the terms
1694        // (Lucene semantics): a document with two occurrences of the phrase
1695        // outranks one with a single occurrence at equal length.
1696        let tf = self.exact_phrase_frequency().max(1) as f32;
1697        crate::observe::search_work!(phrase_score_units += 1);
1698
1699        // Real unit length when the segment has it; otherwise the summed
1700        // term frequency stands in for the length when no lengths were supplied.
1701        let doc_len = match &self.lengths {
1702            Some(lengths) => (lengths.length(self.current_doc) as f32).max(1.0),
1703            None => self
1704                .posting_iters
1705                .iter()
1706                .map(|it| it.term_freq() as f32)
1707                .sum::<f32>()
1708                .max(tf),
1709        };
1710
1711        self.params.score(tf, self.idf, doc_len, self.avg_field_len)
1712    }
1713}
1714
1715#[cfg(test)]
1716mod tests {
1717    use super::*;
1718
1719    #[test]
1720    fn reordered_document_lengths_enable_bounds_without_enabling_chunk_folding() {
1721        use crate::directories::OwnedBytes;
1722        use crate::segment::chunk_map::{ChunkMapBuilder, read_chunk_maps, write_chunk_maps};
1723        use crate::structures::{PositionStreamEncoder, PostingCodec, PostingList};
1724        for document_units in [false, true] {
1725            let mut map = ChunkMapBuilder::default();
1726            map.set_document_units(document_units);
1727            let lengths = [90, 3, 1200, 1];
1728            for (physical, doc) in [3, 2, 0, 1].into_iter().enumerate() {
1729                map.push(doc, 0, lengths[physical]).unwrap();
1730            }
1731            let mut bytes = Vec::new();
1732            write_chunk_maps(&mut bytes, &[(0, &map)], &[]).unwrap();
1733            let map = read_chunk_maps(OwnedBytes::new(bytes))
1734                .unwrap()
1735                .chunk_maps
1736                .remove(&0)
1737                .unwrap();
1738            let mut list = PostingList::new();
1739            let mut bytes = Vec::new();
1740            let mut encoder =
1741                PositionStreamEncoder::with_posting_codec(&mut bytes, PostingCodec::Packed);
1742            for doc in 0..4 {
1743                list.push(doc, 1);
1744                encoder.push_doc(&mut [0]).unwrap();
1745            }
1746            encoder.finish().unwrap();
1747            let postings = BlockPostingList::from_posting_list_with_options(
1748                &list,
1749                true,
1750                None,
1751                PostingCodec::Packed,
1752            )
1753            .unwrap();
1754            let positions = TermPositions::open(OwnedBytes::new(bytes)).unwrap();
1755            let mut scorer = PhraseScorer::unpositioned(
1756                vec![postings],
1757                vec![positions],
1758                &[0],
1759                0,
1760                1.2345,
1761                17.5,
1762                None,
1763            )
1764            .with_lengths(Lengths::Chunks(map));
1765            assert_eq!(scorer.supports_candidate_score_bounds(), document_units);
1766            scorer.find_next_candidate();
1767            while scorer.doc() != TERMINATED {
1768                let bound = scorer.candidate_score_upper_bound();
1769                assert!(scorer.confirm_candidate());
1770                assert!(bound >= scorer.score());
1771                scorer.advance_candidate();
1772            }
1773        }
1774    }
1775
1776    #[test]
1777    fn competitive_length_cutoffs_preserve_scalar_bound_decisions_at_boundaries() {
1778        for params in [
1779            super::super::Bm25Params::default(),
1780            super::super::Bm25Params { k1: 0.0, b: 1.0 },
1781            super::super::Bm25Params { k1: 7e20, b: 0.0 },
1782        ] {
1783            let bounds =
1784                super::super::bm25::PreparedBounds::new(params, u32::MAX, 1.2345, 100.0).unwrap();
1785            for minimum in [f32::MIN_POSITIVE, 0.1, 0.5, 1.0, 2.0, 5.0] {
1786                let table = CompetitiveLengths::new(&bounds, minimum, true, |length| {
1787                    bounds.pair(1, length)
1788                });
1789                for tf in (0..=40).chain([u32::MAX]) {
1790                    let limit = table.limits[tf.saturating_sub(1).min(31) as usize];
1791                    for length in [
1792                        1,
1793                        2,
1794                        1023,
1795                        1024,
1796                        65535,
1797                        65536,
1798                        u32::MAX,
1799                        limit.saturating_sub(1).max(1),
1800                        limit,
1801                        limit + 1,
1802                    ] {
1803                        assert_eq!(
1804                            table.accepts(&bounds, tf, length),
1805                            bounds.pair(tf, length) >= minimum,
1806                            "tf={tf}, len={length}, floor={minimum}"
1807                        );
1808                    }
1809                }
1810            }
1811        }
1812    }
1813
1814    #[test]
1815    fn competitive_scans_preserve_scalar_admission_at_tails_and_after_reverse_probes() {
1816        use crate::structures::{PostingCodec, PostingList};
1817        let bounds = super::super::bm25::PreparedBounds::new(
1818            super::super::Bm25Params::default(),
1819            u32::MAX,
1820            1.5,
1821            120.0,
1822        )
1823        .unwrap();
1824        for codec in [
1825            PostingCodec::Rounded,
1826            PostingCodec::Packed,
1827            PostingCodec::Pfor,
1828            PostingCodec::Simd4x,
1829        ] {
1830            let lengths: Vec<u16> = (0..389)
1831                .map(|i| [0, 1, 17, 255, 4096, 65535][i % 6])
1832                .collect();
1833            let frequencies: Vec<u32> = (0..389)
1834                .map(|i| [1, 1, 1, 2, 32, 33, u32::MAX][i % 7])
1835                .collect();
1836            let source = Lengths::Docs(crate::segment::chunk_map::DocLengths::from_lengths(
1837                &lengths,
1838            ));
1839            let mut input = PostingList::new();
1840            for (doc, &tf) in frequencies.iter().enumerate() {
1841                input.push(doc as u32, tf);
1842            }
1843            let list = BlockPostingList::from_posting_list_with_options(&input, true, None, codec)
1844                .unwrap();
1845            for minimum in [0.1, 0.8, 1.7, 2.5, 10.0] {
1846                let table = CompetitiveLengths::new(&bounds, minimum, true, |length| {
1847                    bounds.pair(1, length)
1848                });
1849                let mut cursor = list.iterator();
1850                for start in [0, 127, 128, 255, 388, 17] {
1851                    cursor.seek_physical(start);
1852                    let mut actual = Vec::new();
1853                    loop {
1854                        match table.find_candidate(&bounds, &source, &mut cursor) {
1855                            Some(TERMINATED) => break,
1856                            Some(doc) => {
1857                                actual.push(doc);
1858                                assert_eq!(cursor.term_freq(), frequencies[doc as usize]);
1859                                assert_eq!(
1860                                    cursor.position_cursor(),
1861                                    frequencies[..doc as usize]
1862                                        .iter()
1863                                        .map(|&tf| u64::from(tf))
1864                                        .sum::<u64>()
1865                                );
1866                                cursor.advance();
1867                            }
1868                            None => {}
1869                        }
1870                    }
1871                    let expected: Vec<_> = (start..389)
1872                        .filter(|&doc| {
1873                            bounds.pair(
1874                                frequencies[doc as usize],
1875                                u32::from(lengths[doc as usize]).max(1),
1876                            ) >= minimum
1877                        })
1878                        .collect();
1879                    assert_eq!(
1880                        actual, expected,
1881                        "{codec:?}, floor={minimum}, start={start}"
1882                    );
1883                }
1884            }
1885        }
1886    }
1887
1888    #[test]
1889    fn competitive_phrase_traversal_preserves_bounds_ties_and_position_cursors() {
1890        use crate::structures::{PositionStreamEncoder, PostingCodec, PostingList};
1891        for codec in [
1892            PostingCodec::Rounded,
1893            PostingCodec::Packed,
1894            PostingCodec::Pfor,
1895            PostingCodec::Simd4x,
1896        ] {
1897            let lengths: Vec<u16> = (0..2053).map(|doc| 3 + (doc % 1030) as u16).collect();
1898            let mut lists = Vec::new();
1899            let mut positions = Vec::new();
1900            for term in 0..3 {
1901                let mut list = PostingList::new();
1902                let mut bytes = Vec::new();
1903                let mut encoder = PositionStreamEncoder::with_posting_codec(&mut bytes, codec);
1904                for doc in 0..2053 {
1905                    if doc % (term + 3) == 1 {
1906                        continue;
1907                    }
1908                    let tf = 1 + doc % 5;
1909                    list.push(doc, tf);
1910                    let mut values: Vec<_> = (0..tf).map(|i| i * 3 + term).collect();
1911                    encoder.push_doc(&mut values).unwrap();
1912                }
1913                encoder.finish().unwrap();
1914                lists.push(
1915                    BlockPostingList::from_posting_list_with_options(&list, true, None, codec)
1916                        .unwrap(),
1917                );
1918                positions
1919                    .push(TermPositions::open(crate::directories::OwnedBytes::new(bytes)).unwrap());
1920            }
1921            let make = || {
1922                let mut scorer = PhraseScorer::unpositioned(
1923                    lists.clone(),
1924                    positions.clone(),
1925                    &[0, 1, 2],
1926                    0,
1927                    1.2345,
1928                    100.0,
1929                    None,
1930                )
1931                .with_lengths(Lengths::Docs(
1932                    crate::segment::chunk_map::DocLengths::from_lengths(&lengths),
1933                ));
1934                scorer.find_next_candidate();
1935                scorer
1936            };
1937            for (floor, scan_bound_term) in [0.0, 0.5, 1.5, 3.0]
1938                .into_iter()
1939                .flat_map(|floor| [true, false].map(|scan| (floor, scan)))
1940            {
1941                let mut expected = make();
1942                let mut actual = make();
1943                actual.scan_bound_term = scan_bound_term;
1944                loop {
1945                    assert_eq!(actual.doc(), expected.doc());
1946                    if actual.doc() == TERMINATED {
1947                        break;
1948                    }
1949                    assert_eq!(actual.confirm_candidate(), expected.confirm_candidate());
1950                    assert_eq!(actual.score().to_bits(), expected.score().to_bits());
1951                    expected.advance_candidate();
1952                    while expected.doc() != TERMINATED
1953                        && expected.candidate_score_upper_bound() < floor
1954                    {
1955                        expected.advance_candidate();
1956                    }
1957                    actual.advance_competitive_candidate(floor, true);
1958                }
1959                assert_eq!(
1960                    actual.advance_competitive_candidate(floor, true),
1961                    TERMINATED
1962                );
1963            }
1964            let mut expected = make();
1965            expected.advance_candidate();
1966            let tied_bound = expected.candidate_score_upper_bound();
1967            let mut actual = make();
1968            assert_eq!(
1969                actual.advance_competitive_candidate(tied_bound, true),
1970                expected.doc()
1971            );
1972            let budget = super::super::SharedThreshold::for_limit(10)
1973                .with_deadline(Some(std::time::Instant::now()));
1974            actual.budget = Some(budget.clone());
1975            assert_eq!(actual.advance_competitive_candidate(0.5, true), TERMINATED);
1976            assert!(budget.truncated());
1977        }
1978    }
1979
1980    #[test]
1981    fn singleton_score_ties_are_skipped_only_when_stable_id_order_allows_it() {
1982        use crate::structures::{PositionStreamEncoder, PostingList};
1983        let mut postings = Vec::new();
1984        let mut positions = Vec::new();
1985        for term in 0..2 {
1986            let mut list = PostingList::new();
1987            let mut bytes = Vec::new();
1988            let mut encoder = PositionStreamEncoder::new(&mut bytes);
1989            for doc in 0..1031 {
1990                list.push(doc, 1);
1991                encoder.push_doc(&mut [term]).unwrap();
1992            }
1993            encoder.finish().unwrap();
1994            postings.push(BlockPostingList::from_posting_list_with(&list, true, None).unwrap());
1995            positions
1996                .push(TermPositions::open(crate::directories::OwnedBytes::new(bytes)).unwrap());
1997        }
1998        let make = || {
1999            PhraseScorer::unpositioned(
2000                postings.clone(),
2001                positions.clone(),
2002                &[0, 1],
2003                0,
2004                1.5,
2005                3.0,
2006                None,
2007            )
2008            .with_lengths(Lengths::Docs(
2009                crate::segment::chunk_map::DocLengths::from_lengths(&[2; 1031]),
2010            ))
2011        };
2012        let mut original = make();
2013        original.find_next_phrase_match();
2014        let score = original.score();
2015        assert_eq!(
2016            original.candidate_score_upper_bound().to_bits(),
2017            score.to_bits()
2018        );
2019        for minimum in [
2020            f32::from_bits(score.to_bits() - 1),
2021            score,
2022            f32::from_bits(score.to_bits() + 1),
2023        ] {
2024            for allow_equal in [false, true] {
2025                let mut scorer = make();
2026                scorer.find_next_candidate();
2027                let expected = if score > minimum || (allow_equal && score == minimum) {
2028                    1
2029                } else {
2030                    TERMINATED
2031                };
2032                assert_eq!(
2033                    scorer.advance_competitive_candidate(minimum, allow_equal),
2034                    expected
2035                );
2036                if expected != TERMINATED {
2037                    assert!(scorer.confirm_candidate());
2038                    assert_eq!(scorer.score().to_bits(), score.to_bits());
2039                }
2040            }
2041        }
2042    }
2043
2044    #[test]
2045    fn singleton_bound_lookup_preserves_scalar_bits_and_long_length_fallback() {
2046        use crate::structures::{PositionStreamEncoder, PostingCodec, PostingList};
2047        let mut list = PostingList::new();
2048        let mut bytes = Vec::new();
2049        let mut encoder =
2050            PositionStreamEncoder::with_posting_codec(&mut bytes, PostingCodec::Packed);
2051        let lengths: Vec<u16> = (0..=1025).chain([u16::MAX]).collect();
2052        for doc in 0..lengths.len() as u32 {
2053            list.push(doc, 1);
2054            encoder.push_doc(&mut [0]).unwrap();
2055        }
2056        encoder.finish().unwrap();
2057        let postings = BlockPostingList::from_posting_list_with_options(
2058            &list,
2059            true,
2060            None,
2061            PostingCodec::Packed,
2062        )
2063        .unwrap();
2064        let positions = TermPositions::open(crate::directories::OwnedBytes::new(bytes)).unwrap();
2065        for params in [
2066            super::super::Bm25Params::default(),
2067            super::super::Bm25Params { k1: 0.0, b: 1.0 },
2068            super::super::Bm25Params { k1: 7e20, b: 0.0 },
2069        ] {
2070            let mut scorer = PhraseScorer::unpositioned(
2071                vec![postings.clone()],
2072                vec![positions.clone()],
2073                &[0],
2074                0,
2075                1.2345,
2076                17.5,
2077                None,
2078            )
2079            .with_params(params)
2080            .with_lengths(Lengths::Docs(
2081                crate::segment::chunk_map::DocLengths::from_lengths(&lengths),
2082            ));
2083            assert!(scorer.singleton_bounds.is_some());
2084            scorer.find_next_candidate();
2085            while scorer.doc() != TERMINATED {
2086                let cached = scorer.candidate_score_upper_bound();
2087                let table = scorer.singleton_bounds.take();
2088                let scalar = scorer.candidate_score_upper_bound();
2089                scorer.singleton_bounds = table;
2090                assert_eq!(cached.to_bits(), scalar.to_bits());
2091                assert!(scorer.confirm_candidate());
2092                assert!(cached >= scorer.score());
2093                scorer.advance_candidate();
2094            }
2095        }
2096    }
2097
2098    #[test]
2099    fn phrase_bounds_preserve_duplicate_start_multiplicity_without_reading_positions() {
2100        use super::super::Bm25Params;
2101        use crate::structures::{PositionStreamEncoder, PostingCodec, PostingList};
2102        for codec in [
2103            PostingCodec::Rounded,
2104            PostingCodec::Packed,
2105            PostingCodec::Pfor,
2106            PostingCodec::Simd4x,
2107        ] {
2108            let mut lists = Vec::new();
2109            let mut positions = Vec::new();
2110            for mut values in [vec![0, 0, 0, 2, 4], vec![1, 3, 5]] {
2111                let mut list = PostingList::new();
2112                list.push(0, values.len() as u32);
2113                let mut bytes = Vec::new();
2114                let mut encoder = PositionStreamEncoder::with_posting_codec(&mut bytes, codec);
2115                encoder.push_doc(&mut values).unwrap();
2116                encoder.finish().unwrap();
2117                lists.push(
2118                    BlockPostingList::from_posting_list_with_options(&list, true, None, codec)
2119                        .unwrap(),
2120                );
2121                positions
2122                    .push(TermPositions::open(crate::directories::OwnedBytes::new(bytes)).unwrap());
2123            }
2124            for length in [0, 1, 9, u16::MAX] {
2125                for avg in [0.0, 1.0, 17.5, 10000.0] {
2126                    for params in [
2127                        Bm25Params::default(),
2128                        Bm25Params { k1: 0.0, b: 1.0 },
2129                        Bm25Params { k1: 7e20, b: 0.0 },
2130                        Bm25Params { k1: 3.0, b: 1.0 },
2131                    ] {
2132                        let mut scorer = PhraseScorer::unpositioned(
2133                            lists.clone(),
2134                            positions.clone(),
2135                            &[0, 1],
2136                            0,
2137                            1.2345,
2138                            avg,
2139                            None,
2140                        )
2141                        .with_params(params)
2142                        .with_lengths(Lengths::Docs(
2143                            crate::segment::chunk_map::DocLengths::from_lengths(&[length]),
2144                        ));
2145                        assert_eq!(scorer.find_next_candidate(), 0);
2146                        assert!(scorer.supports_candidate_score_bounds());
2147                        let bound = scorer.candidate_score_upper_bound();
2148                        let (_, block_bound) = scorer.candidate_block_upper_bound().unwrap();
2149                        let previous = params.upper_bound_with_impacts(5, 1.2345, avg, |a, b| {
2150                            Some((a + b * f64::from(length.max(1))) / 5.0)
2151                        });
2152                        assert_eq!(bound.to_bits(), previous.to_bits());
2153                        assert!(scorer.position_bufs.iter().all(Vec::is_empty));
2154                        assert!(scorer.confirm_candidate());
2155                        assert_eq!(
2156                            scorer.exact_phrase_frequency(),
2157                            5,
2158                            "smaller second-term TF must not cap duplicate starts"
2159                        );
2160                        assert!(
2161                            bound >= scorer.score(),
2162                            "{codec:?} length={length} avg={avg} params={params:?}"
2163                        );
2164                        assert!(
2165                            block_bound >= scorer.score(),
2166                            "block bound must preserve duplicate starts"
2167                        );
2168                    }
2169                }
2170            }
2171            for (params, idf, avg) in [
2172                (Bm25Params { k1: -1.0, b: 0.75 }, 1.0, 10.0),
2173                (
2174                    Bm25Params {
2175                        k1: f32::MAX,
2176                        b: 0.75,
2177                    },
2178                    1.0,
2179                    10.0,
2180                ),
2181                (
2182                    Bm25Params {
2183                        k1: 1.2,
2184                        b: f32::NAN,
2185                    },
2186                    1.0,
2187                    10.0,
2188                ),
2189                (Bm25Params::default(), f32::NAN, 10.0),
2190                (Bm25Params::default(), -1.0, 10.0),
2191                (Bm25Params::default(), 1.0, f32::INFINITY),
2192            ] {
2193                let scorer = PhraseScorer::unpositioned(
2194                    lists.clone(),
2195                    positions.clone(),
2196                    &[0, 1],
2197                    0,
2198                    idf,
2199                    avg,
2200                    None,
2201                )
2202                .with_params(params)
2203                .with_lengths(Lengths::Docs(
2204                    crate::segment::chunk_map::DocLengths::from_lengths(&[10]),
2205                ));
2206                assert!(!scorer.supports_candidate_score_bounds());
2207            }
2208        }
2209    }
2210
2211    #[test]
2212    fn selective_phrase_checks_an_intermediate_first_term_before_frequent_payloads() {
2213        use crate::structures::{PositionStreamEncoder, PostingList};
2214        let mut lists = Vec::new();
2215        let mut positions = Vec::new();
2216        for (term, docs) in [2, 16, 1].into_iter().enumerate() {
2217            let mut list = PostingList::new();
2218            let mut bytes = Vec::new();
2219            let mut encoder = PositionStreamEncoder::new(&mut bytes);
2220            for doc in 0..docs {
2221                let mut values = match term {
2222                    0 => vec![4, 14],
2223                    1 => (0..512).collect::<Vec<u32>>(),
2224                    _ => vec![2, 12],
2225                };
2226                list.push(doc, values.len() as u32);
2227                encoder.push_doc(&mut values).unwrap();
2228            }
2229            encoder.finish().unwrap();
2230            lists.push(BlockPostingList::from_posting_list_with(&list, true, None).unwrap());
2231            positions
2232                .push(TermPositions::open(crate::directories::OwnedBytes::new(bytes)).unwrap());
2233        }
2234        let mut scorer =
2235            PhraseScorer::unpositioned(lists, positions, &[0, 1, 2], 0, 1.0, 10.0, None);
2236        assert!(!scorer.check_phrase_positions());
2237        assert!(
2238            scorer.position_bufs[1].is_empty(),
2239            "the original first term must reject before a less selective middle term is read"
2240        );
2241        assert_eq!(
2242            scorer.position_bufs[0],
2243            [4, 14],
2244            "filtering must not mutate the original first-term occurrences"
2245        );
2246    }
2247
2248    #[test]
2249    fn rare_term_score_bounds_require_unique_first_positions_and_zero_slop() {
2250        use crate::structures::{PositionStreamEncoder, PostingList};
2251        for (duplicate, rare) in [false, true]
2252            .into_iter()
2253            .flat_map(|dup| [0, 1].map(|rare| (dup, rare)))
2254        {
2255            for slop in [0, 4] {
2256                let mut lists = Vec::new();
2257                let mut positions = Vec::new();
2258                for term in 0..2 {
2259                    let mut list = PostingList::new();
2260                    let mut bytes = Vec::new();
2261                    let mut encoder = PositionStreamEncoder::new(&mut bytes);
2262                    for doc in 0..if term == rare { 1 } else { 5 } {
2263                        let mut values = if term == 1 {
2264                            vec![1]
2265                        } else if duplicate {
2266                            vec![0, 0, 2]
2267                        } else {
2268                            vec![0, 2, 4]
2269                        };
2270                        list.push(doc, values.len() as u32);
2271                        encoder.push_doc(&mut values).unwrap();
2272                    }
2273                    encoder.finish().unwrap();
2274                    lists
2275                        .push(BlockPostingList::from_posting_list_with(&list, true, None).unwrap());
2276                    positions.push(
2277                        TermPositions::open(crate::directories::OwnedBytes::new(bytes)).unwrap(),
2278                    );
2279                }
2280                let mut scorer =
2281                    PhraseScorer::unpositioned(lists, positions, &[0, 1], slop, 1.0, 10.0, None)
2282                        .with_lengths(Lengths::Docs(
2283                            crate::segment::chunk_map::DocLengths::from_lengths(&[5; 5]),
2284                        ));
2285                assert_eq!(scorer.lead, rare);
2286                assert_eq!(
2287                    scorer.bound_term,
2288                    if !duplicate && slop == 0 { rare } else { 0 }
2289                );
2290                assert_eq!(scorer.find_next_candidate(), 0);
2291                let bound = scorer.candidate_score_upper_bound();
2292                assert!(scorer.confirm_candidate());
2293                assert!(bound >= scorer.score());
2294                if !duplicate && slop == 0 {
2295                    assert_eq!(
2296                        bound.to_bits(),
2297                        scorer.score().to_bits(),
2298                        "a singleton in any term bounds the exact phrase"
2299                    );
2300                }
2301                assert_eq!(
2302                    scorer.exact_phrase_frequency(),
2303                    if slop != 0 {
2304                        3
2305                    } else if duplicate {
2306                        2
2307                    } else {
2308                        1
2309                    }
2310                );
2311            }
2312        }
2313    }
2314
2315    #[test]
2316    fn two_term_exact_phrases_preserve_duplicate_starts_with_either_term_rarest() {
2317        use crate::structures::{PositionStreamEncoder, PostingCodec, PostingList};
2318        for codec in [
2319            PostingCodec::Rounded,
2320            PostingCodec::Packed,
2321            PostingCodec::Pfor,
2322            PostingCodec::Simd4x,
2323        ] {
2324            for lead in 0..2 {
2325                for offset in [0, 1, 7, u32::MAX] {
2326                    let values = [
2327                        vec![0, 0, 3, u32::MAX - 7, u32::MAX],
2328                        vec![0, 1, 7, 10, u32::MAX],
2329                    ];
2330                    let expected = values[0]
2331                        .iter()
2332                        .filter(|&&start| {
2333                            start
2334                                .checked_add(offset)
2335                                .is_some_and(|end| values[1].contains(&end))
2336                        })
2337                        .count() as u32;
2338                    let mut lists = Vec::new();
2339                    let mut positions = Vec::new();
2340                    for (term, first_values) in values.iter().enumerate() {
2341                        let mut list = PostingList::new();
2342                        let mut bytes = Vec::new();
2343                        let mut encoder =
2344                            PositionStreamEncoder::with_posting_codec(&mut bytes, codec);
2345                        for doc in 0..if term == lead { 1 } else { 3 } {
2346                            let mut positions = first_values.clone();
2347                            list.push(doc, positions.len() as u32);
2348                            encoder.push_doc(&mut positions).unwrap();
2349                        }
2350                        encoder.finish().unwrap();
2351                        lists.push(
2352                            BlockPostingList::from_posting_list_with_options(
2353                                &list, true, None, codec,
2354                            )
2355                            .unwrap(),
2356                        );
2357                        positions.push(
2358                            TermPositions::open(crate::directories::OwnedBytes::new(bytes))
2359                                .unwrap(),
2360                        );
2361                    }
2362                    let mut scorer = PhraseScorer::unpositioned(
2363                        lists,
2364                        positions,
2365                        &[0, offset],
2366                        0,
2367                        1.0,
2368                        10.0,
2369                        None,
2370                    );
2371                    assert_eq!(scorer.lead, lead);
2372                    assert_eq!(scorer.find_next_candidate(), 0);
2373                    assert_eq!(scorer.confirm_candidate(), expected != 0);
2374                    assert!(scorer.frequency.get().is_none());
2375                    assert_eq!(scorer.exact_phrase_frequency(), expected);
2376                    assert_eq!(scorer.exact_phrase_frequency(), expected);
2377                    assert_eq!(scorer.advance_candidate(), TERMINATED);
2378                }
2379            }
2380        }
2381    }
2382
2383    #[test]
2384    fn selective_phrase_anchors_preserve_original_multiplicity_and_extreme_offsets() {
2385        use crate::structures::{PositionStreamEncoder, PostingCodec, PostingList};
2386        let values = [
2387            vec![0, 0, 5, 20, u32::MAX - 8, u32::MAX - 8],
2388            vec![1, 6, 21, u32::MAX - 7],
2389            vec![0, 3, 8, u32::MAX - 5],
2390            vec![0, 8, 13, u32::MAX],
2391        ];
2392        let offsets = [0, 1, 3, 8];
2393        let expected = values[0]
2394            .iter()
2395            .filter(|&&start| {
2396                (1..values.len()).all(|term| {
2397                    values[term]
2398                        .iter()
2399                        .any(|&p| u64::from(p) == u64::from(start) + u64::from(offsets[term]))
2400                })
2401            })
2402            .count() as u32;
2403        assert_eq!(expected, 5);
2404        for codec in [
2405            PostingCodec::Rounded,
2406            PostingCodec::Packed,
2407            PostingCodec::Pfor,
2408            PostingCodec::Simd4x,
2409        ] {
2410            for rare in 0..values.len() {
2411                let mut lists = Vec::new();
2412                let mut positions = Vec::new();
2413                for (term, first_values) in values.iter().enumerate() {
2414                    let mut list = PostingList::new();
2415                    let mut bytes = Vec::new();
2416                    let mut encoder = PositionStreamEncoder::with_posting_codec(&mut bytes, codec);
2417                    let docs = if term == rare { 1 } else { 3 + term as u32 };
2418                    for doc in 0..docs {
2419                        let mut positions = if doc == 0 {
2420                            first_values.clone()
2421                        } else {
2422                            vec![offsets[term]]
2423                        };
2424                        list.push(doc, positions.len() as u32);
2425                        encoder.push_doc(&mut positions).unwrap();
2426                    }
2427                    encoder.finish().unwrap();
2428                    lists.push(
2429                        BlockPostingList::from_posting_list_with_options(&list, true, None, codec)
2430                            .unwrap(),
2431                    );
2432                    positions.push(
2433                        TermPositions::open(crate::directories::OwnedBytes::new(bytes)).unwrap(),
2434                    );
2435                }
2436                let mut scorer =
2437                    PhraseScorer::unpositioned(lists, positions, &offsets, 0, 1.0, 10.0, None);
2438                assert!(
2439                    scorer.check_phrase_positions(),
2440                    "codec {codec:?}, rare {rare}"
2441                );
2442                assert!(scorer.frequency.get().is_none());
2443                assert_eq!(
2444                    scorer.exact_phrase_frequency(),
2445                    expected,
2446                    "codec {codec:?}, rare {rare}"
2447                );
2448                let doc_len = values.iter().map(|p| p.len() as f32).sum::<f32>();
2449                assert_eq!(
2450                    scorer.score().to_bits(),
2451                    super::super::Bm25Params::default()
2452                        .score(expected as f32, 1.0, doc_len, 10.0)
2453                        .to_bits()
2454                );
2455                assert_eq!(scorer.exact_phrase_frequency(), expected);
2456            }
2457        }
2458    }
2459
2460    #[test]
2461    fn selective_phrase_filters_avoid_reading_frequent_first_term_positions() {
2462        use crate::structures::{PositionStreamEncoder, PostingCodec, PostingList};
2463        for codec in [
2464            PostingCodec::Rounded,
2465            PostingCodec::Packed,
2466            PostingCodec::Pfor,
2467            PostingCodec::Simd4x,
2468        ] {
2469            let mut lists = Vec::new();
2470            let mut positions = Vec::new();
2471            for (term, docs) in [16, 2, 1].into_iter().enumerate() {
2472                let mut list = PostingList::new();
2473                let mut bytes = Vec::new();
2474                let mut encoder = PositionStreamEncoder::with_posting_codec(&mut bytes, codec);
2475                for doc in 0..docs {
2476                    let mut values = match term {
2477                        0 => (0..512).collect::<Vec<u32>>(),
2478                        1 => vec![4, 14],
2479                        _ => vec![2, 12],
2480                    };
2481                    list.push(doc, values.len() as u32);
2482                    encoder.push_doc(&mut values).unwrap();
2483                }
2484                encoder.finish().unwrap();
2485                lists.push(
2486                    BlockPostingList::from_posting_list_with_options(&list, true, None, codec)
2487                        .unwrap(),
2488                );
2489                positions
2490                    .push(TermPositions::open(crate::directories::OwnedBytes::new(bytes)).unwrap());
2491            }
2492            let mut scorer =
2493                PhraseScorer::unpositioned(lists, positions, &[0, 1, 2], 0, 1.0, 10.0, None);
2494            assert!(!scorer.check_phrase_positions());
2495            assert!(
2496                scorer.position_bufs[0].is_empty(),
2497                "an impossible rare-term intersection must reject before reading the frequent first term"
2498            );
2499            assert_eq!(scorer.exact_phrase_frequency(), 0);
2500        }
2501    }
2502
2503    #[test]
2504    fn exact_phrase_rejects_empty_prefix_before_reading_later_position_payloads() {
2505        use crate::structures::{PositionStreamEncoder, PostingCodec, PostingList};
2506        for codec in [
2507            PostingCodec::Rounded,
2508            PostingCodec::Packed,
2509            PostingCodec::Pfor,
2510            PostingCodec::Simd4x,
2511        ] {
2512            let values = [
2513                [vec![0, 10], vec![0, 10, 20]],
2514                [vec![4, 14], vec![1, 11, 21]],
2515                [vec![2, 12], vec![2, 12, 22]],
2516            ];
2517            let mut lists = Vec::new();
2518            let mut positions = Vec::new();
2519            for docs in &values {
2520                let mut list = PostingList::new();
2521                let mut bytes = Vec::new();
2522                let mut encoder = PositionStreamEncoder::with_posting_codec(&mut bytes, codec);
2523                for (doc, values) in docs.iter().enumerate() {
2524                    list.push(doc as u32, values.len() as u32);
2525                    encoder.push_doc(&mut values.clone()).unwrap();
2526                }
2527                encoder.finish().unwrap();
2528                lists.push(
2529                    BlockPostingList::from_posting_list_with_options(&list, true, None, codec)
2530                        .unwrap(),
2531                );
2532                positions
2533                    .push(TermPositions::open(crate::directories::OwnedBytes::new(bytes)).unwrap());
2534            }
2535            let mut scorer =
2536                PhraseScorer::unpositioned(lists, positions, &[0, 1, 2], 0, 1.0, 10.0, None);
2537            assert!(!scorer.check_phrase_positions());
2538            assert!(
2539                scorer.position_bufs[2].is_empty(),
2540                "a failed prefix must not read a later term's positions"
2541            );
2542            assert_eq!(scorer.exact_phrase_frequency(), 0);
2543            for cursor in &mut scorer.posting_iters {
2544                assert_eq!(cursor.seek(1), 1);
2545            }
2546            assert!(scorer.check_phrase_positions());
2547            scorer.current_doc = 1;
2548            assert_eq!(scorer.exact_phrase_frequency(), 3);
2549            assert_eq!(
2550                scorer.score().to_bits(),
2551                super::super::Bm25Params::default()
2552                    .score(3.0, 1.0, 9.0, 10.0)
2553                    .to_bits()
2554            );
2555            assert_eq!(scorer.exact_phrase_frequency(), 3);
2556        }
2557    }
2558
2559    #[test]
2560    fn exact_phrase_intersections_preserve_first_start_multiplicity_offsets_and_resume() {
2561        for terms in 2..10 {
2562            for seed in 0..64u32 {
2563                let mut bufs: Vec<Vec<u32>> = (0..terms)
2564                    .map(|term| {
2565                        let mut values: Vec<_> = (0..256u32)
2566                            .filter(|p| (p * (term as u32 + 3) + seed * 17) % (term as u32 + 4) < 2)
2567                            .map(|p| p * 3 + seed % 4)
2568                            .collect();
2569                        values.extend([u32::MAX - 3, u32::MAX, u32::MAX]);
2570                        if term == 0 && seed.is_multiple_of(3) {
2571                            values = values.into_iter().flat_map(|p| [p, p]).collect();
2572                        }
2573                        if seed.is_multiple_of(17) {
2574                            values.clear();
2575                        }
2576                        values
2577                    })
2578                    .collect();
2579                let deltas: Vec<_> = (0..terms)
2580                    .map(|i| match seed % 5 {
2581                        0 => 0,
2582                        1 => u32::MAX,
2583                        _ => i as u32 * 2,
2584                    })
2585                    .collect();
2586                let expected: Vec<_> = bufs[0]
2587                    .iter()
2588                    .copied()
2589                    .filter(|&start| {
2590                        (1..terms).all(|i| {
2591                            bufs[i]
2592                                .iter()
2593                                .any(|&p| u64::from(p) == u64::from(start) + u64::from(deltas[i]))
2594                        })
2595                    })
2596                    .collect();
2597                let mut order: Vec<_> = (1..terms).collect();
2598                if seed.is_multiple_of(2) {
2599                    order.reverse();
2600                }
2601                for &term in &order[..order.len() - 1] {
2602                    let (left, right) = bufs.split_at_mut(term);
2603                    retain_exact_phrase_starts(&mut left[0], &right[0], deltas[term]);
2604                }
2605                let last = *order.last().unwrap();
2606                let mut next = 0;
2607                let mut position = 0;
2608                let first = next_exact_phrase_match(
2609                    &bufs[0],
2610                    &bufs[last],
2611                    deltas[last],
2612                    &mut next,
2613                    &mut position,
2614                );
2615                let mut actual = Vec::new();
2616                if let Some(index) = first {
2617                    actual.push(bufs[0][index]);
2618                }
2619                let mut resumed_next = next;
2620                let mut resumed_position = position;
2621                while let Some(index) = next_exact_phrase_match(
2622                    &bufs[0],
2623                    &bufs[last],
2624                    deltas[last],
2625                    &mut resumed_next,
2626                    &mut resumed_position,
2627                ) {
2628                    actual.push(bufs[0][index]);
2629                }
2630                assert_eq!(actual, expected, "terms={terms},seed={seed}");
2631            }
2632        }
2633    }
2634
2635    #[test]
2636    fn incremental_exact_phrase_scoring_matches_occurrences_across_codecs_and_false_candidates() {
2637        use crate::structures::{PositionStreamEncoder, PostingCodec, PostingList};
2638        for codec in [
2639            PostingCodec::Rounded,
2640            PostingCodec::Packed,
2641            PostingCodec::Pfor,
2642            PostingCodec::Simd4x,
2643        ] {
2644            for terms in [2, 3, 5, 9] {
2645                let offsets: Vec<_> = (0..terms).map(|term| (term / 2) as u32 * 2).collect();
2646                let mut all_values = Vec::new();
2647                let mut lists = Vec::new();
2648                let mut positions = Vec::new();
2649                for (term, &offset) in offsets.iter().enumerate() {
2650                    let mut list = PostingList::new();
2651                    let mut bytes = Vec::new();
2652                    let mut encoder = PositionStreamEncoder::with_posting_codec(&mut bytes, codec);
2653                    let mut docs = Vec::new();
2654                    for doc in 0..260u32 {
2655                        let values: Vec<_> = (0..(129 + doc % 19))
2656                            .filter(|p| term == 0 || (p + doc) % (term as u32 + 2) != 0)
2657                            .map(|p| {
2658                                p * 32
2659                                    + doc % 3
2660                                    + offset
2661                                    + u32::from(doc.is_multiple_of(5) && term == 1)
2662                            })
2663                            .collect();
2664                        list.push(doc * 17, values.len() as u32);
2665                        encoder.push_doc(&mut values.clone()).unwrap();
2666                        docs.push(values);
2667                    }
2668                    encoder.finish().unwrap();
2669                    lists.push(
2670                        BlockPostingList::from_posting_list_with_options(&list, true, None, codec)
2671                            .unwrap(),
2672                    );
2673                    positions.push(
2674                        TermPositions::open(crate::directories::OwnedBytes::new(bytes)).unwrap(),
2675                    );
2676                    all_values.push(docs);
2677                }
2678                let mut scorer =
2679                    PhraseScorer::unpositioned(lists, positions, &offsets, 0, 1.5, 97.0, None);
2680                for doc in 0..260usize {
2681                    let target = doc as u32 * 17;
2682                    for cursor in &mut scorer.posting_iters {
2683                        assert_eq!(cursor.seek(target), target);
2684                    }
2685                    let expected_tf = all_values[0][doc]
2686                        .iter()
2687                        .filter(|&&start| {
2688                            (1..terms).all(|term| {
2689                                all_values[term][doc].contains(&(start + offsets[term]))
2690                            })
2691                        })
2692                        .count() as u32;
2693                    assert_eq!(
2694                        scorer.check_phrase_positions(),
2695                        expected_tf > 0,
2696                        "codec={codec:?},terms={terms},doc={doc}"
2697                    );
2698                    assert_eq!(
2699                        scorer.exact_phrase_frequency(),
2700                        expected_tf,
2701                        "codec={codec:?},terms={terms},doc={doc}"
2702                    );
2703                    scorer.current_doc = target;
2704                    if expected_tf > 0 {
2705                        let length = all_values.iter().map(|docs| docs[doc].len() as f32).sum();
2706                        let expected = super::super::Bm25Params::default().score(
2707                            expected_tf as f32,
2708                            1.5,
2709                            length,
2710                            97.0,
2711                        );
2712                        assert_eq!(scorer.score().to_bits(), expected.to_bits());
2713                        assert_eq!(scorer.score().to_bits(), expected.to_bits());
2714                    }
2715                }
2716            }
2717        }
2718    }
2719
2720    #[test]
2721    fn point_phrase_backfill_invalidates_frequency_when_position_buffers_change() {
2722        use crate::structures::{PositionStreamEncoder, PostingList};
2723        let docs = [(0, 1), (50, 3), (99, 2), (240, 5)];
2724        let mut lists = Vec::new();
2725        let mut positions = Vec::new();
2726        for term in 0..2 {
2727            let mut list = PostingList::new();
2728            let mut bytes = Vec::new();
2729            let mut encoder = PositionStreamEncoder::new(&mut bytes);
2730            for &(doc, tf) in &docs {
2731                list.push(doc, tf);
2732                let mut values: Vec<_> = (0..tf).map(|i| i * 4 + term).collect();
2733                encoder.push_doc(&mut values).unwrap();
2734            }
2735            encoder.finish().unwrap();
2736            lists.push(BlockPostingList::from_posting_list_with(&list, true, None).unwrap());
2737            positions
2738                .push(TermPositions::open(crate::directories::OwnedBytes::new(bytes)).unwrap());
2739        }
2740        let mut scorer = PhraseScorer::unpositioned(lists, positions, &[0, 1], 0, 1.0, 10.0, None);
2741        for target in [0, 10, 50, 99, 99, 240, 999] {
2742            // Point backfill parks each term only on a nominated target; it does
2743            // not expand a missing target into the next posting intersection.
2744            let mut matches = true;
2745            for cursor in &mut scorer.posting_iters {
2746                matches &= cursor.seek(target) == target;
2747            }
2748            if matches {
2749                assert!(scorer.check_phrase_positions());
2750                scorer.current_doc = target;
2751                let tf = docs.iter().find(|e| e.0 == target).unwrap().1 as f32;
2752                let expected = super::super::Bm25Params::default().score(tf, 1.0, 2.0 * tf, 10.0);
2753                assert_eq!(
2754                    scorer.score().to_bits(),
2755                    expected.to_bits(),
2756                    "target={target}"
2757                );
2758            } else {
2759                assert!(!docs.iter().any(|e| e.0 == target));
2760            }
2761        }
2762    }
2763
2764    #[test]
2765    fn phrase_frequency_resumes_after_first_match_without_recounting_prefixes() {
2766        for terms in 0..6 {
2767            for seed in 0..32u32 {
2768                let mut bufs: Vec<Vec<u32>> = (0..terms)
2769                    .map(|term| {
2770                        let mut positions: Vec<_> = (0..128u32)
2771                            .filter(|p| (p * (term as u32 + 3) + seed * 17) % (term as u32 + 4) < 2)
2772                            .map(|p| p * 3 + seed % 4)
2773                            .collect();
2774                        positions.extend([u32::MAX - 3, u32::MAX]);
2775                        positions
2776                    })
2777                    .collect();
2778                if terms > 0 && seed.is_multiple_of(13) {
2779                    bufs[0].clear();
2780                }
2781                if terms > 2 && seed.is_multiple_of(17) {
2782                    bufs[terms - 1].clear();
2783                }
2784                let deltas: Vec<_> = (0..terms)
2785                    .map(|i| {
2786                        if seed.is_multiple_of(5) {
2787                            0
2788                        } else {
2789                            i as u32 * 2
2790                        }
2791                    })
2792                    .collect();
2793                for slop in [0, 1, 5, u32::MAX] {
2794                    let expected = bufs.first().map_or(0, |first| {
2795                        first
2796                            .iter()
2797                            .filter(|&&start| {
2798                                (1..bufs.len()).all(|i| {
2799                                    let target = u64::from(start) + u64::from(deltas[i]);
2800                                    let low = target.saturating_sub(u64::from(slop));
2801                                    let high = target + u64::from(slop);
2802                                    bufs[i]
2803                                        .iter()
2804                                        .any(|&p| (low..=high).contains(&u64::from(p)))
2805                                })
2806                            })
2807                            .count() as u32
2808                    });
2809                    let mut indices = vec![0; terms];
2810                    let mut next = 0;
2811                    assert_eq!(
2812                        scan_phrase_matches(&bufs, &deltas, slop, &mut indices, &mut next, 0),
2813                        0
2814                    );
2815                    assert_eq!(next, 0);
2816                    let first =
2817                        scan_phrase_matches(&bufs, &deltas, slop, &mut indices, &mut next, 1);
2818                    assert_eq!(first, u32::from(expected > 0));
2819                    let mut resumed = indices.clone();
2820                    let mut resumed_start = next;
2821                    let remaining = scan_phrase_matches(
2822                        &bufs,
2823                        &deltas,
2824                        slop,
2825                        &mut resumed,
2826                        &mut resumed_start,
2827                        u32::MAX,
2828                    );
2829                    assert_eq!(
2830                        first + remaining,
2831                        expected,
2832                        "terms={terms},seed={seed},slop={slop}"
2833                    );
2834                    assert_eq!(
2835                        count_phrase_matches(&bufs, &deltas, slop, &mut indices),
2836                        expected
2837                    );
2838                }
2839            }
2840        }
2841    }
2842
2843    struct ChunkHits {
2844        hits: Vec<(u32, f32)>,
2845        at: usize,
2846        advances: Arc<std::sync::atomic::AtomicUsize>,
2847    }
2848
2849    impl DocSet for ChunkHits {
2850        fn doc(&self) -> DocId {
2851            self.hits.get(self.at).map_or(TERMINATED, |h| h.0)
2852        }
2853        fn advance(&mut self) -> DocId {
2854            self.advances
2855                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2856            self.at = (self.at + 1).min(self.hits.len());
2857            self.doc()
2858        }
2859        fn seek(&mut self, target: DocId) -> DocId {
2860            self.at += self.hits[self.at..].partition_point(|h| h.0 < target);
2861            self.doc()
2862        }
2863        fn size_hint(&self) -> u32 {
2864            (self.hits.len() - self.at) as u32
2865        }
2866    }
2867    impl Scorer for ChunkHits {
2868        fn score(&self) -> Score {
2869            self.hits.get(self.at).map_or(0.0, |h| h.1)
2870        }
2871    }
2872
2873    fn test_chunk_map(owners: &[(u32, u16)]) -> crate::segment::chunk_map::ChunkMap {
2874        use crate::segment::chunk_map::{ChunkMapBuilder, read_chunk_maps, write_chunk_maps};
2875        let mut builder = ChunkMapBuilder::default();
2876        for &(doc, ordinal) in owners {
2877            builder.push(doc, ordinal, 10).unwrap();
2878        }
2879        let mut bytes = Vec::new();
2880        write_chunk_maps(&mut bytes, &[(0, &builder)], &[]).unwrap();
2881        read_chunk_maps(crate::directories::OwnedBytes::new(bytes))
2882            .unwrap()
2883            .chunk_maps
2884            .remove(&0)
2885            .unwrap()
2886    }
2887
2888    fn chunk_hits(hits: Vec<(u32, f32)>) -> ChunkHits {
2889        ChunkHits {
2890            hits,
2891            at: 0,
2892            advances: Arc::default(),
2893        }
2894    }
2895
2896    #[test]
2897    fn lazy_phrase_fold_matches_stable_eager_oracle_including_reordered_ordinals() {
2898        for owners in [
2899            vec![],
2900            vec![(0, 0)],
2901            vec![(2, 2), (2, 0), (2, 1), (5, 1), (5, 0), (9, 0)],
2902            vec![(5, 1), (2, 2), (9, 0), (2, 0), (5, 0), (2, 1)],
2903        ] {
2904            let map = test_chunk_map(&owners);
2905            assert_eq!(
2906                map.is_doc_ordered(),
2907                owners.windows(2).all(|p| p[0].0 <= p[1].0)
2908            );
2909            for stride in [1, 2, 3] {
2910                let hits: Vec<_> = (0..owners.len() as u32)
2911                    .step_by(stride)
2912                    .map(|vid| (vid, (vid % 3) as f32 * 0.5))
2913                    .collect();
2914                let raw: Vec<_> = hits
2915                    .iter()
2916                    .map(|&(vid, score)| {
2917                        let (doc, ord) = map.resolve(vid);
2918                        (doc, ord, score)
2919                    })
2920                    .collect();
2921                let expected = crate::segment::combine_ordinal_results(
2922                    raw,
2923                    super::super::MultiValueCombiner::Max,
2924                    usize::MAX,
2925                );
2926                let mut expected = super::super::vector::VectorResultScorer::new(expected, 7);
2927                let mut actual = fold_chunked_phrase_scorer(chunk_hits(hits), map.clone(), 7, None);
2928                while expected.doc() != TERMINATED {
2929                    assert_eq!(actual.doc(), expected.doc());
2930                    assert_eq!(actual.score().to_bits(), expected.score().to_bits());
2931                    let signature = |s: &dyn Scorer| {
2932                        s.matched_positions()
2933                            .unwrap()
2934                            .into_iter()
2935                            .map(|(field, positions)| {
2936                                (
2937                                    field,
2938                                    positions
2939                                        .into_iter()
2940                                        .map(|p| (p.position, p.score.to_bits()))
2941                                        .collect::<Vec<_>>(),
2942                                )
2943                            })
2944                            .collect::<Vec<_>>()
2945                    };
2946                    assert_eq!(signature(actual.as_ref()), signature(&expected));
2947                    actual.advance();
2948                    expected.advance();
2949                }
2950                assert_eq!(actual.doc(), TERMINATED);
2951                assert_eq!(actual.advance(), TERMINATED);
2952                assert_eq!(actual.score(), 0.0);
2953            }
2954        }
2955    }
2956
2957    #[test]
2958    fn lazy_phrase_fold_only_consumes_one_document_and_can_skip_to_late_matches() {
2959        let owners: Vec<_> = (0..100)
2960            .flat_map(|doc| [(doc * 2, 0), (doc * 2, 1)])
2961            .collect();
2962        let inner = chunk_hits((0..200).map(|vid| (vid, vid as f32)).collect());
2963        let advances = inner.advances.clone();
2964        let mut scorer = fold_chunked_phrase_scorer(inner, test_chunk_map(&owners), 0, None);
2965        assert_eq!(advances.load(std::sync::atomic::Ordering::Relaxed), 2);
2966        assert_eq!(scorer.doc(), 0);
2967        assert_eq!(scorer.seek(179), 180);
2968        assert_eq!(advances.load(std::sync::atomic::Ordering::Relaxed), 4);
2969        assert_eq!(scorer.score(), 181.0);
2970        assert_eq!(scorer.seek(179), 180);
2971        assert_eq!(scorer.seek(199), TERMINATED);
2972        assert!(scorer.matched_positions().is_none());
2973        assert_eq!(scorer.advance(), TERMINATED);
2974    }
2975
2976    #[test]
2977    fn lazy_phrase_fold_discards_current_result_at_budget_boundary() {
2978        let inner = chunk_hits(vec![(0, 1.0), (1, 2.0), (2, 3.0)]);
2979        let advances = inner.advances.clone();
2980        let mut scorer = ChunkedPhraseScorer {
2981            inner,
2982            chunk_map: test_chunk_map(&[(0, 0), (1, 0), (1, 1)]),
2983            field_id: 0,
2984            budget: None,
2985            current_doc: TERMINATED,
2986            score: 0.0,
2987            ordinals: crate::segment::VectorOrdinals::new(),
2988        };
2989        assert_eq!(scorer.fold_next_document(), 0);
2990        assert_eq!(scorer.score(), 1.0);
2991        let budget = super::super::SharedThreshold::for_limit(1)
2992            .with_deadline(Some(std::time::Instant::now()));
2993        scorer.budget = Some(budget.clone());
2994        assert_eq!(scorer.advance(), TERMINATED);
2995        assert_eq!(scorer.score(), 0.0);
2996        assert!(scorer.matched_positions().is_none());
2997        assert!(budget.truncated());
2998        assert_eq!(advances.load(std::sync::atomic::Ordering::Relaxed), 1);
2999    }
3000
3001    #[test]
3002    fn phrase_alignment_preserves_offsets_counts_and_seeks_with_each_term_rarest() {
3003        use crate::structures::{PositionStreamEncoder, PostingList};
3004
3005        for rare in 0..3 {
3006            let present = |term, doc: u32| {
3007                if term == rare {
3008                    doc.is_multiple_of(7)
3009                } else {
3010                    !doc.is_multiple_of(5 + term as u32)
3011                }
3012            };
3013            let offsets = [0, 3, 8];
3014            let mut lists = Vec::new();
3015            let mut positions = Vec::new();
3016            for (term, &offset) in offsets.iter().enumerate() {
3017                let mut list = PostingList::new();
3018                let mut bytes = Vec::new();
3019                let mut encoder = PositionStreamEncoder::new(&mut bytes);
3020                for doc in 0..5000 {
3021                    if !present(term, doc) {
3022                        continue;
3023                    }
3024                    list.push(doc, 2);
3025                    let shift = if term == 2 && doc.is_multiple_of(11) {
3026                        100
3027                    } else {
3028                        0
3029                    };
3030                    encoder
3031                        .push_doc(&mut [offset + shift, 20 + offset + shift])
3032                        .unwrap();
3033                }
3034                encoder.finish().unwrap();
3035                lists.push(BlockPostingList::from_posting_list_with(&list, true, None).unwrap());
3036                positions
3037                    .push(TermPositions::open(crate::directories::OwnedBytes::new(bytes)).unwrap());
3038            }
3039            let candidates: Vec<_> = (0..5000)
3040                .filter(|&doc| (0..3).all(|term| present(term, doc)))
3041                .collect();
3042            let expected: Vec<_> = candidates
3043                .iter()
3044                .copied()
3045                .filter(|doc| !doc.is_multiple_of(11))
3046                .collect();
3047            let make = || {
3048                PhraseScorer::unpositioned(
3049                    lists.clone(),
3050                    positions.clone(),
3051                    &offsets,
3052                    0,
3053                    1.0,
3054                    10.0,
3055                    None,
3056                )
3057            };
3058            let score = super::super::Bm25Params::default().score(2.0, 1.0, 6.0, 10.0);
3059            let mut scorer = make();
3060            scorer.find_next_phrase_match();
3061            for &doc in &expected {
3062                assert_eq!(scorer.doc(), doc);
3063                assert!(scorer.frequency.get().is_none());
3064                assert_eq!(
3065                    scorer.next_start, 1,
3066                    "membership stops after the first occurrence"
3067                );
3068                #[cfg(feature = "native")]
3069                if doc == expected[0] {
3070                    std::thread::scope(|scope| {
3071                        for _ in 0..4 {
3072                            let shared = &scorer;
3073                            scope.spawn(move || {
3074                                assert_eq!(shared.score().to_bits(), score.to_bits())
3075                            });
3076                        }
3077                    });
3078                }
3079                assert_eq!(scorer.exact_phrase_frequency(), 2);
3080                assert_eq!(scorer.score().to_bits(), score.to_bits());
3081                scorer.advance();
3082            }
3083            assert_eq!(scorer.doc(), TERMINATED);
3084            assert_eq!(scorer.advance(), TERMINATED);
3085
3086            let mut scorer = make();
3087            scorer.find_next_candidate();
3088            for &doc in &candidates {
3089                assert_eq!(scorer.doc(), doc);
3090                assert_eq!(scorer.confirm_candidate(), !doc.is_multiple_of(11));
3091                assert!(
3092                    scorer.frequency.get().is_none(),
3093                    "membership does not finish phrase frequency"
3094                );
3095                scorer.advance_candidate();
3096            }
3097            assert_eq!(scorer.doc(), TERMINATED);
3098
3099            let mut scorer = make();
3100            scorer.find_next_phrase_match();
3101            for target in [0, 1, 127, 127, 2000, 4096, 4999, TERMINATED] {
3102                let next = expected
3103                    .iter()
3104                    .copied()
3105                    .find(|&doc| doc >= target)
3106                    .unwrap_or(TERMINATED);
3107                assert_eq!(scorer.seek(target), next);
3108            }
3109            assert_eq!(scorer.seek(0), TERMINATED);
3110        }
3111    }
3112
3113    #[test]
3114    fn phrase_candidates_require_confirmation_and_exact_seek_stays_terminal() {
3115        use crate::structures::{PositionStreamEncoder, PostingList};
3116        let mut lists = Vec::new();
3117        let mut positions = Vec::new();
3118        for term in 0..2 {
3119            let mut list = PostingList::new();
3120            let mut bytes = Vec::new();
3121            let mut encoder = PositionStreamEncoder::new(&mut bytes);
3122            for doc in 0..1000 {
3123                list.push(doc, 1);
3124                encoder
3125                    .push_doc(&mut [if [0, 100, 999].contains(&doc) {
3126                        term
3127                    } else {
3128                        term * 10
3129                    }])
3130                    .unwrap();
3131            }
3132            encoder.finish().unwrap();
3133            lists.push(BlockPostingList::from_posting_list_with(&list, true, None).unwrap());
3134            positions
3135                .push(TermPositions::open(crate::directories::OwnedBytes::new(bytes)).unwrap());
3136        }
3137        let mut expired = PhraseScorer::unpositioned(
3138            lists.clone(),
3139            positions.clone(),
3140            &[0, 1],
3141            0,
3142            1.0,
3143            2.0,
3144            None,
3145        );
3146        let mut scorer = PhraseScorer::unpositioned(lists, positions, &[0, 1], 0, 1.0, 2.0, None);
3147        scorer.find_next_phrase_match();
3148        let score = scorer.score();
3149        assert_eq!(scorer.advance_candidate(), 1);
3150        assert_eq!(
3151            scorer.position_bufs,
3152            vec![vec![0], vec![1]],
3153            "candidate traversal does not decode positions"
3154        );
3155        assert!(!scorer.confirm_candidate());
3156        assert!(!scorer.confirm_candidate());
3157        assert_eq!(scorer.exact_phrase_frequency(), 0);
3158        assert_eq!(scorer.seek_candidate(100), 100);
3159        assert!(scorer.confirm_candidate());
3160        assert!(scorer.confirm_candidate());
3161        assert_eq!(scorer.score(), score);
3162        assert_eq!(scorer.seek(101), 999, "ordinary seek skips false positives");
3163        assert_eq!(scorer.score(), score);
3164        assert_eq!(expired.seek_candidate(100), 100);
3165        assert!(expired.confirm_candidate());
3166        let budget = super::super::SharedThreshold::for_limit(10)
3167            .with_deadline(Some(std::time::Instant::now()));
3168        expired.budget = Some(budget.clone());
3169        assert!(
3170            !expired.confirm_candidate(),
3171            "cancellation invalidates cached confirmation"
3172        );
3173        assert!(budget.truncated());
3174        assert_eq!(expired.doc(), TERMINATED);
3175        assert_eq!(expired.score(), 0.0);
3176        assert_eq!(expired.seek(0), TERMINATED);
3177        assert_eq!(scorer.seek(TERMINATED), TERMINATED);
3178        assert_eq!(
3179            scorer.seek(0),
3180            TERMINATED,
3181            "exhausted streams cannot resurrect"
3182        );
3183    }
3184
3185    #[test]
3186    fn phrase_stops_when_budget_expires_after_first_match() {
3187        use super::super::docset::DocSet;
3188        use crate::structures::{PositionStreamEncoder, PostingList};
3189        let mut lists = Vec::new();
3190        let mut positions = Vec::new();
3191        for term in 0..2 {
3192            let mut list = PostingList::new();
3193            let mut bytes = Vec::new();
3194            let mut encoder = PositionStreamEncoder::new(&mut bytes);
3195            for doc in 0..1000 {
3196                list.push(doc, 1);
3197                encoder
3198                    .push_doc(&mut [if doc == 0 { term } else { term * 10 }])
3199                    .unwrap();
3200            }
3201            encoder.finish().unwrap();
3202            lists.push(BlockPostingList::from_posting_list_with(&list, true, None).unwrap());
3203            positions
3204                .push(TermPositions::open(crate::directories::OwnedBytes::new(bytes)).unwrap());
3205        }
3206        let mut scorer = PhraseScorer::unpositioned(lists, positions, &[0, 1], 0, 1.0, 2.0, None);
3207        scorer.find_next_phrase_match();
3208        assert_eq!(scorer.doc(), 0);
3209        let budget = super::super::SharedThreshold::for_limit(10)
3210            .with_deadline(Some(std::time::Instant::now()));
3211        scorer.budget = Some(budget.clone());
3212        assert_eq!(scorer.advance(), TERMINATED);
3213        assert_eq!(scorer.score(), 0.0);
3214        assert!(budget.truncated());
3215        assert_eq!(
3216            scorer.position_bufs,
3217            vec![vec![0], vec![1]],
3218            "no further positions decoded"
3219        );
3220    }
3221
3222    #[test]
3223    fn monotone_phrase_frequency_matches_naive_offsets_slop_and_repeated_terms() {
3224        for seed in 0..100u32 {
3225            let a: Vec<_> = (0..200).filter(|i| (i * 17 + seed) % 11 < 5).collect();
3226            let b: Vec<_> = (0..200).filter(|i| (i * 13 + seed) % 19 < 4).collect();
3227            for bufs in [
3228                vec![a.clone(), b.clone()],
3229                vec![a.clone(), b.clone(), a.clone()],
3230            ] {
3231                for slop in [0, 1, 3, 100] {
3232                    let deltas = [0, 2, 7];
3233                    let expected = bufs[0]
3234                        .iter()
3235                        .filter(|&&start| {
3236                            bufs.iter().enumerate().skip(1).all(|(i, positions)| {
3237                                positions
3238                                    .iter()
3239                                    .any(|&p| p.abs_diff(start + deltas[i]) <= slop)
3240                            })
3241                        })
3242                        .count() as u32;
3243                    assert_eq!(
3244                        count_phrase_matches(&bufs, &deltas, slop, &mut [0; 3]),
3245                        expected
3246                    );
3247                }
3248            }
3249        }
3250        assert_eq!(
3251            count_phrase_matches(&[vec![0, 0, 5], vec![1, 6]], &[0, 1], 0, &mut [0; 2]),
3252            3
3253        );
3254        assert_eq!(
3255            count_phrase_matches(&[vec![u32::MAX], vec![0]], &[0, 1], 0, &mut [0; 2]),
3256            0
3257        );
3258        assert_eq!(
3259            count_phrase_matches(&[vec![1], vec![]], &[0, 1], 3, &mut [0; 2]),
3260            0
3261        );
3262    }
3263}