1mod 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#[derive(Clone)]
22pub struct PhraseQuery {
23 pub field: Field,
24 pub terms: Vec<Vec<u8>>,
26 pub offsets: Vec<u32>,
32 pub slop: u32,
34 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 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 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 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 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 fn is_adjacent(&self) -> bool {
144 self.offsets.windows(2).all(|pair| pair[1] == pair[0] + 1)
145 }
146
147 pub fn with_slop(mut self, slop: u32) -> Self {
149 self.slop = slop;
150 self
151 }
152
153 pub fn with_global_stats(mut self, stats: Arc<GlobalStats>) -> Self {
155 self.global_stats = Some(stats);
156 self
157 }
158}
159
160pub(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 return Box::new(EmptyScorer);
199 }
200 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 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#[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
347fn 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
448macro_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 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 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 #[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 if reader
643 .schema()
644 .get_field_entry(self.field)
645 .is_some_and(|entry| !entry.indexed)
646 {
647 return None;
648 }
649 let Some(list) = reader.get_postings_sync(self.field, &self.terms[0]).ok()? else {
652 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 #[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 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 Ok((min_count / 10).max(1))
718 })
719 }
720}
721
722enum 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
738fn 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
765fn 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
791struct PhraseScorer {
793 cost: u32,
795 lead: usize,
797 bound_term: usize,
799 exact_term_bounds: bool,
801 scan_bound_term: bool,
802 intersection: crate::structures::postings::PostingIntersection,
803 budget: Option<super::SharedThreshold>,
804 posting_iters: Vec<BlockPostingIterator<'static>>,
806 position_lists: Vec<crate::structures::postings::TermPositionCursor>,
808 position_indices: Vec<usize>,
810 term_order: smallvec::SmallVec<[usize; 8]>,
813 deltas: Vec<u32>,
816 slop: u32,
818 current_doc: DocId,
820 first_match: bool,
823 frequency: std::sync::OnceLock<u32>,
824 next_start: usize,
825 confirmed: Option<bool>,
827 idf: f32,
829 params: super::Bm25Params,
831 avg_field_len: f32,
833 lengths: Option<Lengths>,
836 prepared_bounds: Option<super::bm25::PreparedBounds>,
839 singleton_bounds: Option<Box<[Score; 1024]>>,
840 competitive_lengths: Option<CompetitiveLengths>,
841 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 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 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 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 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 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 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 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 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 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 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 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 fn check_phrase_positions(&mut self) -> bool {
1088 crate::observe::search_work!(phrase_confirmations += 1);
1089 self.first_match = false;
1093 self.frequency.take();
1094 if self.slop == 0 {
1095 return self.check_exact_phrase_positions();
1096 }
1097 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 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 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 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 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#[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 starts[retained] = starts[matched];
1363 retained += 1;
1364 }
1365 starts.truncate(retained);
1366}
1367
1368fn 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 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 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 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 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 let tf = self.exact_phrase_frequency().max(1) as f32;
1697 crate::observe::search_work!(phrase_score_units += 1);
1698
1699 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 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}