Skip to main content

summa_core/query/
term.rs

1//! Term query - matches documents containing a specific term
2
3use std::sync::Arc;
4
5use crate::dsl::Field;
6use crate::segment::SegmentReader;
7use crate::structures::BlockPostingList;
8use crate::structures::TERMINATED;
9use crate::{DocId, Score};
10
11use super::docset::DocSet;
12use super::{CountFuture, EmptyScorer, GlobalStats, Query, Scorer, ScorerFuture, TermQueryInfo};
13
14/// Term query - matches documents containing a specific term
15#[derive(Clone)]
16pub struct TermQuery {
17    pub field: Field,
18    pub term: Vec<u8>,
19    /// Optional global statistics for cross-segment IDF
20    global_stats: Option<Arc<GlobalStats>>,
21}
22
23impl std::fmt::Debug for TermQuery {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        f.debug_struct("TermQuery")
26            .field("field", &self.field)
27            .field("term", &String::from_utf8_lossy(&self.term))
28            .field("has_global_stats", &self.global_stats.is_some())
29            .finish()
30    }
31}
32
33impl std::fmt::Display for TermQuery {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        write!(
36            f,
37            "Term({}:\"{}\")",
38            self.field.0,
39            String::from_utf8_lossy(&self.term)
40        )
41    }
42}
43
44impl TermQuery {
45    pub fn new(field: Field, term: impl Into<Vec<u8>>) -> Self {
46        Self {
47            field,
48            term: term.into(),
49            global_stats: None,
50        }
51    }
52
53    pub fn text(field: Field, text: &str) -> Self {
54        Self {
55            field,
56            term: text.to_lowercase().into_bytes(),
57            global_stats: None,
58        }
59    }
60
61    /// Create with global statistics for cross-segment IDF
62    pub fn with_global_stats(field: Field, text: &str, stats: Arc<GlobalStats>) -> Self {
63        Self {
64            field,
65            term: text.to_lowercase().into_bytes(),
66            global_stats: Some(stats),
67        }
68    }
69
70    /// Set global statistics for cross-segment IDF
71    pub fn set_global_stats(&mut self, stats: Arc<GlobalStats>) {
72        self.global_stats = Some(stats);
73    }
74
75    fn fast_field_bitset(
76        &self,
77        reader: &SegmentReader,
78        options: &super::ScorerOptions,
79    ) -> Option<super::DocBitset> {
80        let mut bits = super::DocBitset::new(reader.num_docs());
81        let Some(fast_field) = reader.fast_field(self.field.0) else {
82            return Some(bits);
83        };
84        let term = String::from_utf8_lossy(&self.term);
85        let Some(target_ordinal) = fast_field.text_ordinal(&term) else {
86            return (!options.stop_if_expired()).then_some(bits);
87        };
88        if !fast_field.multi {
89            // Avoid decoding column codec headers separately for every doc.
90            let scanned = fast_field.try_scan_single_values(|doc, ordinal| {
91                if doc.is_multiple_of(1024) && options.stop_if_expired() {
92                    return Err(());
93                }
94                if ordinal == target_ordinal {
95                    bits.set(doc);
96                }
97                Ok(())
98            });
99            return (scanned.is_ok() && !options.stop_if_expired()).then_some(bits);
100        }
101        // Multi-value fast equality retains the ordinary scorer's first-value
102        // semantics. The single-value batch API cannot represent those offsets.
103        if let Some(mut scorer) = FastFieldTextScorer::try_new(
104            reader,
105            self.field,
106            &term,
107            options.shared_threshold.as_ref(),
108        ) {
109            let mut doc = scorer.doc();
110            while doc != TERMINATED {
111                bits.set(doc);
112                doc = scorer.advance();
113            }
114        }
115        // A cancelled scan is never a complete filter, especially under NOT.
116        (!options.stop_if_expired()).then_some(bits)
117    }
118}
119
120/// Compute (idf, avg_field_len) from a posting list, using global stats when available.
121pub(super) fn compute_term_idf(
122    posting_list: &BlockPostingList,
123    field: Field,
124    reader: &SegmentReader,
125    global_stats: Option<&Arc<GlobalStats>>,
126    term: &[u8],
127) -> (f32, f32) {
128    if let Some(stats) = global_stats {
129        let term_str = String::from_utf8_lossy(term);
130        let global_idf = stats.text_idf(field, &term_str);
131        if global_idf > 0.0 {
132            return (global_idf, stats.avg_field_len(field));
133        }
134    }
135    let num_docs = reader.text_corpus_size(field);
136    let doc_freq = posting_list.doc_count() as f32;
137    (
138        super::bm25_idf(doc_freq, num_docs),
139        reader.avg_field_len(field),
140    )
141}
142
143/// Complete membership and positioned callers need a cursor. Ranked callers
144/// benefit from block traversal only when bounds can avoid part of the list.
145fn can_rank_term(
146    postings: &BlockPostingList,
147    limit: usize,
148    complete: bool,
149    collect_positions: bool,
150) -> bool {
151    if complete || collect_positions {
152        return false;
153    }
154    let has_block_bounds = postings.min_len().is_some() && postings.num_blocks() > 1;
155    let needs_top_k = limit < postings.doc_count() as usize;
156    has_block_bounds && needs_top_k
157}
158
159// ── Unified term scorer macro ────────────────────────────────────────────
160//
161// Parameterised on:
162//   $get_postings_fn – get_postings | get_postings_sync
163//   $get_positions_fn – get_positions | get_positions_sync
164//   $($aw)*          – .await  (present for async, absent for sync)
165macro_rules! term_plan {
166    ($field:expr, $term:expr, $global_stats:expr, $reader:expr, $limit:expr,
167     $load_positions:expr, $eligibility:expr, $budget:expr, $complete:expr, $skip_scoring_setup:expr, $initial_threshold:expr, $physical_field:expr, $get_postings_fn:ident, $get_positions_fn:ident
168     $(, $aw:tt)*) => {{
169        let field: Field = $field;
170        let term: &[u8] = $term;
171        let global_stats: Option<&Arc<GlobalStats>> = $global_stats;
172        let reader: &SegmentReader = $reader;
173        let limit: usize = $limit;
174        let budget: Option<&super::SharedThreshold> = $budget;
175        if budget.is_some_and(super::SharedThreshold::stop_if_expired) {
176            return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>);
177        }
178
179        // Non-indexed fields → fast-field-only path
180        let is_indexed = reader.schema().get_field_entry(field).is_none_or(|e| e.indexed);
181        if !is_indexed {
182            let term_str = String::from_utf8_lossy(term);
183            if let Some(scorer) = FastFieldTextScorer::try_new(reader, field, &term_str, budget) {
184                return Ok(Box::new(scorer) as Box<dyn Scorer + '_>);
185            }
186            return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>);
187        }
188
189        let postings = reader.$get_postings_fn(field, term) $(. $aw)* ?;
190
191        match postings {
192            Some(posting_list) if reader.chunk_map(field).is_some_and(|map| map.is_document_map())
193                && ($complete || $load_positions || $physical_field == Some(field)) => {
194                let map = reader.chunk_map(field).unwrap();
195                let (idf, avg_field_len) = compute_term_idf(&posting_list, field, reader, global_stats, term);
196                let mut scorer = TermScorer::new(posting_list, idf, avg_field_len, 1.0)
197                    .with_params(super::Bm25Params::for_field(reader.schema(), field));
198                scorer.chunk_lengths = Some(map.clone());
199                scorer.budget = budget.cloned();
200                if $load_positions && let Some(positions) = reader.$get_positions_fn(field, term) $(. $aw)* ? {
201                    scorer = scorer.with_positions(field.0, positions);
202                }
203                if $physical_field == Some(field) {
204                    return Ok(Box::new(scorer) as Box<dyn Scorer + '_>);
205                }
206                let scorer = super::required_text::mapped_documents(
207                    scorer, map.clone(), reader.num_docs(), budget.cloned(),
208                    |scorer, slot| scorer.iterator.seek_physical(slot),
209                )?;
210                Ok(super::filtered::filtered(scorer, $eligibility.clone()))
211            }
212            // Chunked field: postings are keyed by virtual chunk id. Score the
213            // chunks, fold them back to documents and report the ordinals.
214            Some(posting_list) if reader.has_text_mapping(field) => {
215                let (idf, avg_field_len) =
216                    compute_term_idf(&posting_list, field, reader, global_stats, term);
217                if $complete {
218                    return complete_text_scorer(vec![(posting_list, idf)], avg_field_len, reader, field, &super::ScorerOptions {
219                        shared_threshold: budget.cloned(), eligibility: $eligibility.clone(),
220                        skip_scoring_setup: $skip_scoring_setup, ..Default::default()
221                    });
222                }
223                super::planner::finish_chunked_text_maxscore(
224                    vec![(posting_list, idf)],
225                    avg_field_len,
226                    limit,
227                    reader,
228                    field,
229                    $eligibility.as_ref().map(|filter| {
230                        let filter = filter.clone();
231                        Box::new(move |doc| filter.contains(doc)) as super::DocPredicate<'_>
232                    }),
233                    None,
234                    1.0,
235                    budget,
236                )
237            }
238            Some(posting_list) => {
239                let (idf, avg_field_len) =
240                    compute_term_idf(&posting_list, field, reader, global_stats, term);
241
242                // Ranked term requests can use the same block bounds as a text
243                // union. Complete membership and positioned callers keep a cursor.
244                if can_rank_term(&posting_list, limit, $complete, $load_positions) {
245                    return super::planner::finish_text_maxscore(
246                        vec![(posting_list, idf)],
247                        avg_field_len,
248                        reader.doc_lengths(field),
249                        limit,
250                        &std::cell::Cell::new($initial_threshold),
251                        reader,
252                        field,
253                        $eligibility.as_ref().map(|filter| {
254                            let filter = filter.clone();
255                            Box::new(move |doc| filter.contains(doc)) as super::DocPredicate<'_>
256                        }),
257                        super::Bm25Params::for_field(reader.schema(), field),
258                        None,
259                        1.0,
260                        budget,
261                    );
262                }
263
264                let positions = if $load_positions {
265                    reader.$get_positions_fn(field, term) $(. $aw)* ?
266                } else {
267                    None
268                };
269
270                let mut scorer = TermScorer::new(posting_list, idf, avg_field_len, 1.0)
271                    .with_params(super::Bm25Params::for_field(reader.schema(), field));
272                scorer.budget = budget.filter(|b| b.deadline().is_some()).cloned();
273                if let Some(lengths) = reader.doc_lengths(field) {
274                    scorer = scorer.with_doc_lengths(lengths.clone(), $skip_scoring_setup);
275                }
276                if let Some(pos) = positions {
277                    scorer = scorer.with_positions(field.0, pos);
278                }
279                Ok(Box::new(scorer) as Box<dyn Scorer + '_>)
280            }
281            None => {
282                let term_str = String::from_utf8_lossy(term);
283                if let Some(scorer) = FastFieldTextScorer::try_new(reader, field, &term_str, budget) {
284                    Ok(Box::new(scorer) as Box<dyn Scorer + '_>)
285                } else {
286                    Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>)
287                }
288            }
289        }
290    }};
291}
292
293impl Query for TermQuery {
294    fn physical_text_field(&self, reader: &SegmentReader, complete: bool) -> Option<Field> {
295        let entry = reader.schema().get_field_entry(self.field)?;
296        (complete
297            && entry.indexed
298            && !entry.fast
299            && reader
300                .chunk_map(self.field)
301                .is_some_and(|map| map.is_document_map()))
302        .then_some(self.field)
303    }
304    fn scorer<'a>(&self, reader: &'a SegmentReader, limit: usize) -> ScorerFuture<'a> {
305        self.scorer_with_options(reader, limit, super::ScorerOptions::with_positions())
306    }
307
308    fn scorer_with_options<'a>(
309        &self,
310        reader: &'a SegmentReader,
311        limit: usize,
312        options: super::ScorerOptions,
313    ) -> ScorerFuture<'a> {
314        let field = self.field;
315        let term = self.term.clone();
316        let global_stats = self
317            .global_stats
318            .clone()
319            .or_else(|| options.global_stats.clone());
320        let load_positions = options.collect_positions;
321        Box::pin(async move {
322            term_plan!(
323                field,
324                &term,
325                global_stats.as_ref(),
326                reader,
327                limit,
328                load_positions,
329                options.eligibility,
330                options.shared_threshold.as_ref(),
331                options.complete_text_matches,
332                options.skip_scoring_setup,
333                options.initial_threshold,
334                options.physical_text_field,
335                get_postings,
336                get_positions,
337                await
338            )
339        })
340    }
341
342    fn count_estimate<'a>(&self, reader: &'a SegmentReader) -> CountFuture<'a> {
343        let field = self.field;
344        let term = self.term.clone();
345        Box::pin(async move { reader.text_doc_freq(field, &term).await })
346    }
347
348    #[cfg(feature = "sync")]
349    fn scorer_sync<'a>(
350        &self,
351        reader: &'a SegmentReader,
352        limit: usize,
353    ) -> crate::Result<Box<dyn Scorer + 'a>> {
354        self.scorer_sync_with_options(reader, limit, super::ScorerOptions::with_positions())
355    }
356
357    #[cfg(feature = "sync")]
358    fn scorer_sync_with_options<'a>(
359        &self,
360        reader: &'a SegmentReader,
361        limit: usize,
362        options: super::ScorerOptions,
363    ) -> crate::Result<Box<dyn Scorer + 'a>> {
364        let global_stats = self
365            .global_stats
366            .clone()
367            .or_else(|| options.global_stats.clone());
368        term_plan!(
369            self.field,
370            &self.term,
371            global_stats.as_ref(),
372            reader,
373            limit,
374            options.collect_positions,
375            options.eligibility,
376            options.shared_threshold.as_ref(),
377            options.complete_text_matches,
378            options.skip_scoring_setup,
379            options.initial_threshold,
380            options.physical_text_field,
381            get_postings_sync,
382            get_positions_sync
383        )
384    }
385
386    fn as_doc_predicate<'a>(&self, reader: &'a SegmentReader) -> Option<super::DocPredicate<'a>> {
387        let entry = reader.schema().get_field_entry(self.field)?;
388        // A fast column exposes the first complete value. Indexed term
389        // membership is equivalent only for single-valued raw text; analyzed
390        // text and later values must keep their posting-list semantics.
391        if entry.indexed
392            && (entry.multi || !matches!(entry.tokenizer.as_deref(), Some("raw" | "raw_ci")))
393        {
394            return None;
395        }
396        let fast_field = reader.fast_field(self.field.0)?;
397        let term_str = String::from_utf8_lossy(&self.term);
398        match fast_field.text_ordinal(&term_str) {
399            Some(target_ordinal) => Some(Box::new(move |doc_id: DocId| -> bool {
400                fast_field.get_u64(doc_id) == target_ordinal
401            })),
402            // Term doesn't exist in this segment — no doc can match.
403            None => Some(Box::new(|_| false)),
404        }
405    }
406
407    #[cfg(feature = "sync")]
408    fn bitset_cardinality_estimate(&self, reader: &SegmentReader) -> Option<u64> {
409        // Chunked postings count chunks, not documents.
410        if reader.has_text_mapping(self.field) {
411            return None;
412        }
413        // Exact: the posting list header carries the doc count.
414        let pl = reader.get_postings_sync(self.field, &self.term).ok()??;
415        Some(pl.doc_count() as u64)
416    }
417
418    fn as_doc_bitset(&self, reader: &SegmentReader) -> Option<super::DocBitset> {
419        self.as_doc_bitset_with_options(reader, &super::ScorerOptions::default())
420    }
421
422    fn as_doc_bitset_with_options(
423        &self,
424        reader: &SegmentReader,
425        options: &super::ScorerOptions,
426    ) -> Option<super::DocBitset> {
427        if options.stop_if_expired() {
428            return None;
429        }
430        if reader
431            .schema()
432            .get_field_entry(self.field)
433            .is_some_and(|entry| !entry.indexed)
434        {
435            // Fast-only text has no posting list. Match exactly as its ordinary
436            // scorer does, without the bounded candidate-heap fallback.
437            return self.fast_field_bitset(reader, options);
438        }
439        #[cfg(feature = "sync")]
440        {
441            // Chunked postings use chunk ids, not document ids.
442            if reader.has_text_mapping(self.field) {
443                return None;
444            }
445            let Some(pl) = reader.get_postings_sync(self.field, &self.term).ok()? else {
446                // Preserve the ordinary term scorer's fast-column fallback.
447                return self.fast_field_bitset(reader, options);
448            };
449            // Indexed membership remains O(matches), not a full-column scan.
450            let mut bitset = super::DocBitset::new(reader.num_docs());
451            let mut iter = pl.iterator();
452            let mut visited = 0usize;
453            while iter.doc() != TERMINATED {
454                if visited.is_multiple_of(1024) && options.stop_if_expired() {
455                    return None;
456                }
457                bitset.set(iter.doc());
458                iter.advance();
459                visited += 1;
460            }
461            (!options.stop_if_expired()).then_some(bitset)
462        }
463        #[cfg(not(feature = "sync"))]
464        {
465            None
466        }
467    }
468
469    fn text_terms(&self, out: &mut Vec<(Field, Vec<u8>)>) {
470        out.push((self.field, self.term.clone()));
471    }
472
473    fn decompose(&self) -> super::QueryDecomposition {
474        super::QueryDecomposition::TextTerm(TermQueryInfo {
475            weight: 1.0,
476            field: self.field,
477            term: self.term.clone(),
478            global_stats: self.global_stats.clone(),
479        })
480    }
481}
482
483struct TermScorer {
484    budget: Option<super::SharedThreshold>,
485    iterator: crate::structures::BlockPostingIterator<'static>,
486    /// Physical posting cardinality for conjunction planning, not a live hit count.
487    doc_count: u32,
488    idf: f32,
489    /// Average field length for this field
490    avg_field_len: f32,
491    /// Field boost/weight for BM25F
492    field_boost: f32,
493    /// Field ID for position reporting
494    field_id: u32,
495    /// Positions of the term (if positions are enabled)
496    positions: Option<crate::structures::TermPositions>,
497    /// Persisted per-document field lengths; `None` keeps `tf` as the length.
498    lengths: Option<crate::segment::chunk_map::DocLengths>,
499    chunk_lengths: Option<crate::segment::chunk_map::ChunkMap>,
500    /// Per-field k1/b.
501    params: super::Bm25Params,
502    normalization: Option<Box<super::bm25::NormTable>>,
503}
504
505impl TermScorer {
506    pub fn new(
507        posting_list: BlockPostingList,
508        idf: f32,
509        avg_field_len: f32,
510        field_boost: f32,
511    ) -> Self {
512        Self {
513            budget: None,
514            doc_count: posting_list.doc_count(),
515            iterator: posting_list.into_iterator(),
516            idf,
517            avg_field_len,
518            field_boost,
519            field_id: 0,
520            positions: None,
521            lengths: None,
522            chunk_lengths: None,
523            params: super::Bm25Params::default(),
524            normalization: None,
525        }
526    }
527
528    /// Score with the field's BM25 parameters.
529    pub fn with_params(mut self, params: super::Bm25Params) -> Self {
530        self.params = params;
531        if self
532            .lengths
533            .as_ref()
534            .is_some_and(|lengths| lengths.is_quantized())
535        {
536            self.normalization = Some(Box::new(super::bm25::NormTable::new(
537                params,
538                self.avg_field_len,
539            )));
540        }
541        self
542    }
543
544    /// Score with the field's persisted per-document lengths.
545    fn with_doc_lengths(
546        mut self,
547        lengths: crate::segment::chunk_map::DocLengths,
548        skip_scoring_setup: bool,
549    ) -> Self {
550        if lengths.is_quantized() && !skip_scoring_setup {
551            self.normalization = Some(Box::new(super::bm25::NormTable::new(
552                self.params,
553                self.avg_field_len,
554            )));
555        } else {
556            self.normalization = None;
557        }
558        self.lengths = Some(lengths);
559        self
560    }
561
562    pub fn with_positions(
563        mut self,
564        field_id: u32,
565        positions: crate::structures::TermPositions,
566    ) -> Self {
567        self.field_id = field_id;
568        self.positions = Some(positions);
569        self
570    }
571
572    fn score_batch_values(&self, docs: &[DocId], tfs: &[u32], scores: &mut [Score]) {
573        let lengths = self
574            .chunk_lengths
575            .as_ref()
576            .map(super::scoring::LengthSource::Chunks)
577            .or_else(|| {
578                self.lengths
579                    .as_ref()
580                    .map(super::scoring::LengthSource::Docs)
581            });
582        super::scoring::score_text_run(
583            self.params,
584            self.idf,
585            self.avg_field_len,
586            lengths,
587            self.normalization.as_deref(),
588            docs,
589            tfs,
590            scores,
591        );
592    }
593
594    fn score_window<const ACCUMULATE: bool>(
595        &mut self,
596        base: DocId,
597        scores: &mut [Score; super::docset::DOC_WINDOW_SIZE as usize],
598        bits: &mut super::docset::DocWindow,
599    ) {
600        use super::docset::DocSet;
601        if self.seek(base) == TERMINATED {
602            return;
603        }
604        let end = base.saturating_add(super::docset::DOC_WINDOW_SIZE);
605        let mut lengths = [0u32; crate::structures::postings::POSTING_BLOCK_SIZE];
606        let mut values = [0.0; crate::structures::postings::POSTING_BLOCK_SIZE];
607        let params = self.params;
608        let idf = self.idf;
609        let avg_len = self.avg_field_len;
610        let boost = self.field_boost;
611        let length_source = self
612            .chunk_lengths
613            .as_ref()
614            .map(super::scoring::LengthSource::Chunks)
615            .or_else(|| {
616                self.lengths
617                    .as_ref()
618                    .map(super::scoring::LengthSource::Docs)
619            });
620        let budget = &self.budget;
621        let normalization = self.normalization.as_deref();
622        self.iterator.visit_postings_until(end, |docs, tfs| {
623            crate::observe::search_work!(score_batches += 1);
624            if budget
625                .as_ref()
626                .is_some_and(super::SharedThreshold::stop_if_expired)
627            {
628                return false;
629            }
630            if let (Some(super::scoring::LengthSource::Docs(norms)), Some(table)) =
631                (length_source, normalization)
632            {
633                crate::observe::search_work!(lookup_score_units += docs.len());
634                table.score_batch(
635                    params,
636                    idf,
637                    avg_len,
638                    boost,
639                    docs.iter().map(|&doc| norms.norm_code(doc)),
640                    tfs,
641                    &mut values[..docs.len()],
642                );
643            } else {
644                crate::observe::search_work!(exact_score_units += docs.len());
645                if let Some(source) = length_source {
646                    source.gather_lengths(docs, &mut lengths[..docs.len()]);
647                } else {
648                    lengths[..docs.len()].copy_from_slice(tfs);
649                }
650                // Independent canonical scores over contiguous inputs. Missing
651                // lengths retain the scalar scorer's TF fallback.
652                for i in 0..docs.len() {
653                    let tf = tfs[i] as f32;
654                    let len = if lengths[i] == 0 {
655                        tf
656                    } else {
657                        lengths[i] as f32
658                    };
659                    values[i] = params.score_boosted(tf, idf, len, avg_len, boost);
660                }
661            }
662            for (i, &doc) in docs.iter().enumerate() {
663                let offset = (doc - base) as usize;
664                if ACCUMULATE {
665                    scores[offset] += values[i];
666                } else {
667                    scores[offset] = values[i];
668                }
669                bits[offset / 64] |= 1u64 << (offset % 64);
670            }
671            true
672        });
673    }
674}
675
676impl super::docset::DocSet for TermScorer {
677    fn supports_doc_batches(&self) -> bool {
678        self.budget.is_none()
679    }
680
681    fn fill_doc_batch(&mut self, docs: &mut super::docset::DocBatch) -> usize {
682        if self.budget.is_some() {
683            return super::docset::fill_batch(self, docs);
684        }
685        self.iterator.fill_doc_batch(docs)
686    }
687
688    fn retain_doc_batch(&mut self, docs: &mut super::docset::DocBatch, len: usize) -> usize {
689        assert!(len <= docs.len());
690        if self.budget.is_some() {
691            return super::docset::retain_batch(self, docs, len);
692        }
693        self.iterator.retain_doc_batch(&mut docs[..len])
694    }
695
696    fn supports_doc_windows(&self) -> bool {
697        true
698    }
699
700    fn fill_doc_window(&mut self, base: DocId, bits: &mut super::docset::DocWindow) {
701        if self.doc() == TERMINATED {
702            bits.fill(0);
703            return;
704        }
705        self.iterator.fill_doc_window(base, bits);
706    }
707
708    fn doc(&self) -> DocId {
709        if self
710            .budget
711            .as_ref()
712            .is_some_and(super::SharedThreshold::stop_if_expired)
713        {
714            return TERMINATED;
715        }
716        self.iterator.doc()
717    }
718
719    fn advance(&mut self) -> DocId {
720        if self.doc() == TERMINATED {
721            return TERMINATED;
722        }
723        let doc = self.iterator.advance();
724        if self.positions.is_some() {
725            // Commit the position prefix now so the immutable
726            // `position_cursor()` used by `matched_positions` is O(1).
727            self.iterator.position_cursor_mut();
728        }
729        doc
730    }
731
732    fn seek(&mut self, target: DocId) -> DocId {
733        if self.doc() == TERMINATED {
734            return TERMINATED;
735        }
736        let doc = self.iterator.seek(target);
737        if self.positions.is_some() {
738            // See `advance`: commit the position prefix eagerly.
739            self.iterator.position_cursor_mut();
740        }
741        doc
742    }
743
744    fn size_hint(&self) -> u32 {
745        self.doc_count
746    }
747}
748
749// ── Fast field text equality scorer ──────────────────────────────────────
750
751/// Scorer that scans a text fast field for exact string equality.
752/// Used as fallback when a TermQuery targets a fast-only text field (no inverted index).
753/// Returns score 1.0 for matching docs (filter-style, like RangeScorer).
754struct FastFieldTextScorer<'a> {
755    fast_field: &'a crate::structures::fast_field::FastFieldReader,
756    target_ordinal: u64,
757    current: u32,
758    num_docs: u32,
759    budget: Option<super::SharedThreshold>,
760}
761
762impl<'a> FastFieldTextScorer<'a> {
763    fn try_new(
764        reader: &'a SegmentReader,
765        field: Field,
766        text: &str,
767        budget: Option<&super::SharedThreshold>,
768    ) -> Option<Self> {
769        let fast_field = reader.fast_field(field.0)?;
770        let target_ordinal = fast_field.text_ordinal(text)?;
771        let num_docs = reader.num_docs();
772        let mut scorer = Self {
773            fast_field,
774            target_ordinal,
775            current: 0,
776            num_docs,
777            budget: budget.filter(|budget| budget.deadline().is_some()).cloned(),
778        };
779        // Position on first matching doc
780        if scorer.doc() != TERMINATED && fast_field.get_u64(0) != target_ordinal {
781            scorer.scan_forward();
782        }
783        Some(scorer)
784    }
785
786    fn scan_forward(&mut self) {
787        loop {
788            self.current += 1;
789            if self.current >= self.num_docs
790                || (self.current.is_multiple_of(1024)
791                    && self
792                        .budget
793                        .as_ref()
794                        .is_some_and(super::SharedThreshold::stop_if_expired))
795            {
796                self.current = self.num_docs;
797                return;
798            }
799            if self.fast_field.get_u64(self.current) == self.target_ordinal {
800                return;
801            }
802        }
803    }
804}
805
806impl super::docset::DocSet for FastFieldTextScorer<'_> {
807    fn doc(&self) -> DocId {
808        if self.current >= self.num_docs
809            || self
810                .budget
811                .as_ref()
812                .is_some_and(super::SharedThreshold::stop_if_expired)
813        {
814            TERMINATED
815        } else {
816            self.current
817        }
818    }
819
820    fn advance(&mut self) -> DocId {
821        if self.doc() == TERMINATED {
822            return TERMINATED;
823        }
824        self.scan_forward();
825        self.doc()
826    }
827
828    fn seek(&mut self, target: DocId) -> DocId {
829        if self.doc() == TERMINATED {
830            return TERMINATED;
831        }
832        if target > self.current {
833            self.current = target;
834            if self.current < self.num_docs
835                && self.fast_field.get_u64(self.current) != self.target_ordinal
836            {
837                self.scan_forward();
838            }
839        }
840        self.doc()
841    }
842
843    fn size_hint(&self) -> u32 {
844        0
845    }
846}
847
848impl Scorer for FastFieldTextScorer<'_> {
849    fn score(&self) -> Score {
850        1.0
851    }
852}
853
854impl Scorer for TermScorer {
855    fn supports_score_batches(&self) -> bool {
856        self.budget.is_none() && self.field_boost == 1.0
857    }
858
859    fn fill_score_batch(
860        &mut self,
861        docs: &mut super::docset::DocBatch,
862        scores: &mut super::ScoreBatch,
863    ) -> usize {
864        if !self.supports_score_batches() {
865            return super::traits::fill_score_batch_scalar(self, docs, scores);
866        }
867        let mut tfs = [0; super::docset::DOC_BATCH_SIZE];
868        let count = self.iterator.fill_scored_doc_batch(docs, &mut tfs);
869        self.score_batch_values(&docs[..count], &tfs[..count], &mut scores[..count]);
870        count
871    }
872
873    fn score_batch_matches(
874        &mut self,
875        docs: &super::docset::DocBatch,
876        len: usize,
877        scores: &mut super::ScoreBatch,
878        matches: &mut super::ScoreBatchMask,
879    ) {
880        assert!(len <= docs.len());
881        if !self.supports_score_batches() {
882            return super::traits::score_batch_matches_scalar(self, docs, len, scores, matches);
883        }
884        matches.fill(0);
885        let mut retained = *docs;
886        let mut tfs = [0; super::docset::DOC_BATCH_SIZE];
887        let count = self
888            .iterator
889            .retain_scored_doc_batch(&mut retained[..len], &mut tfs[..len]);
890        let mut values = [0.0; super::docset::DOC_BATCH_SIZE];
891        self.score_batch_values(&retained[..count], &tfs[..count], &mut values[..count]);
892        let mut input = 0;
893        for i in 0..count {
894            while docs[input] < retained[i] {
895                input += 1;
896            }
897            scores[input] = values[i];
898            matches[input / 64] |= 1 << (input % 64);
899        }
900    }
901
902    fn supports_score_windows(&self) -> bool {
903        self.doc_count > crate::structures::postings::POSTING_BLOCK_SIZE as u32
904    }
905
906    fn fill_score_window(
907        &mut self,
908        base: DocId,
909        scores: &mut [Score; super::docset::DOC_WINDOW_SIZE as usize],
910        bits: &mut super::docset::DocWindow,
911    ) {
912        bits.fill(0);
913        self.score_window::<false>(base, scores, bits);
914    }
915
916    fn accumulate_score_window(
917        &mut self,
918        base: DocId,
919        scores: &mut [Score; super::docset::DOC_WINDOW_SIZE as usize],
920        bits: &mut super::docset::DocWindow,
921    ) {
922        self.score_window::<true>(base, scores, bits);
923    }
924
925    fn score(&self) -> Score {
926        let tf = self.iterator.term_freq() as f32;
927        if let (Some(lengths), Some(table)) = (&self.lengths, &self.normalization)
928            && self.chunk_lengths.is_none()
929        {
930            crate::observe::search_work!(lookup_score_units += 1);
931            return table.score_boosted(
932                self.params,
933                tf,
934                self.idf,
935                lengths.norm_code(self.iterator.doc()),
936                self.avg_field_len,
937                self.field_boost,
938            );
939        }
940        // Persisted field length when the segment has norms; otherwise `tf`
941        crate::observe::search_work!(exact_score_units += 1);
942        // stands in for the length (legacy segments).
943        let doc_len = self
944            .chunk_lengths
945            .as_ref()
946            .map(|map| map.length(self.iterator.doc()).max(map.length_floor()) as f32)
947            .or_else(|| {
948                self.lengths
949                    .as_ref()
950                    .map(|lengths| lengths.length(self.iterator.doc()) as f32)
951            })
952            .filter(|len| *len > 0.0)
953            .unwrap_or(tf);
954        self.params
955            .score_boosted(tf, self.idf, doc_len, self.avg_field_len, self.field_boost)
956    }
957
958    fn matched_positions(&self) -> Option<super::MatchedPositions> {
959        let positions = self.positions.as_ref()?;
960        let pos =
961            positions.positions(self.iterator.position_cursor(), self.iterator.term_freq())?;
962        let score = self.score();
963        // Each position contributes equally to the term score
964        let per_position_score = if pos.is_empty() {
965            0.0
966        } else {
967            score / pos.len() as f32
968        };
969        let scored_positions: Vec<super::ScoredPosition> = pos
970            .iter()
971            .map(|&p| super::ScoredPosition::new(p, per_position_score))
972            .collect();
973        Some(vec![(self.field_id, scored_positions)])
974    }
975}
976
977pub(super) fn complete_text_scorer<'a>(
978    postings: Vec<(BlockPostingList, f32)>,
979    avg_field_len: f32,
980    reader: &'a SegmentReader,
981    field: Field,
982    options: &super::ScorerOptions,
983) -> crate::Result<Box<dyn Scorer + 'a>> {
984    let budget = options.shared_threshold.clone();
985    let eligibility = options.eligibility.clone();
986    let skip_scoring_setup = options.skip_scoring_setup;
987    let physical = options.physical_text_field == Some(field);
988    if postings.is_empty()
989        || budget
990            .as_ref()
991            .is_some_and(super::SharedThreshold::stop_if_expired)
992    {
993        return Ok(Box::new(EmptyScorer));
994    }
995    let map = reader.chunk_map(field);
996    if reader.has_text_mapping(field) && map.is_none() {
997        return Err(crate::Error::Corruption(
998            "chunked text has postings without a chunk map".into(),
999        ));
1000    }
1001    if !physical && map.is_some_and(|map| !map.is_doc_ordered()) {
1002        return super::required_text::scorer(
1003            postings,
1004            avg_field_len,
1005            reader,
1006            field,
1007            budget,
1008            eligibility,
1009        );
1010    }
1011    let mut terms: Vec<Box<dyn Scorer>> = Vec::with_capacity(postings.len());
1012    for (posting, idf) in postings {
1013        let mut scorer = TermScorer::new(posting, idf, avg_field_len, 1.0)
1014            .with_params(super::Bm25Params::for_field(reader.schema(), field));
1015        scorer.chunk_lengths = map.cloned();
1016        if let Some(lengths) = reader.doc_lengths(field) {
1017            scorer = scorer.with_doc_lengths(lengths.clone(), skip_scoring_setup);
1018        }
1019        scorer.budget = budget.clone();
1020        terms.push(Box::new(scorer));
1021    }
1022    let scorer = super::boolean::BooleanScorer::disjunction(terms);
1023    let scorer: Box<dyn Scorer + 'a> = match map {
1024        Some(map) if !map.is_document_map() => {
1025            super::phrase::fold_chunked_phrase_scorer(scorer, map.clone(), field.0, budget)
1026        }
1027        Some(_) => Box::new(scorer),
1028        None => Box::new(scorer),
1029    };
1030    Ok(super::filtered::filtered(scorer, eligibility))
1031}
1032
1033/// Point BM25 probes. Targets are sorted physical IDs, never a retrieval top-k.
1034pub(super) async fn score_term_candidates(
1035    reader: &SegmentReader,
1036    field: Field,
1037    terms: &[(Vec<u8>, f32)],
1038    targets: &[u32],
1039    stats: Option<&Arc<GlobalStats>>,
1040    scratch: &mut crate::structures::postings::PostingDecodeScratch,
1041) -> crate::Result<Vec<f32>> {
1042    reader.check_posting_integrity()?;
1043    let mut scores = vec![0.0; targets.len()];
1044    let Some(&first_target) = targets.first() else {
1045        return Ok(scores);
1046    };
1047    let params = super::Bm25Params::for_field(reader.schema(), field);
1048    for (term, weight) in terms {
1049        let Some(postings) = reader.get_postings(field, term).await? else {
1050            continue;
1051        };
1052        let (idf, avg_len) = compute_term_idf(&postings, field, reader, stats, term);
1053        let mut cursor = postings.into_candidate_iterator(first_target, scratch);
1054        for (index, &target) in targets.iter().enumerate() {
1055            if cursor.seek(target) != target {
1056                continue;
1057            }
1058            let tf = cursor.term_freq() as f32;
1059            let length = if let Some(map) = reader.chunk_map(field) {
1060                map.bm25_length(target) as f32
1061            } else if let Some(lengths) = reader.doc_lengths(field) {
1062                lengths.length(target) as f32
1063            } else {
1064                tf
1065            };
1066            scores[index] += params.score(tf, idf * weight, length, avg_len);
1067        }
1068        cursor.recycle(scratch);
1069    }
1070    reader.check_posting_integrity()?;
1071    Ok(scores)
1072}
1073
1074#[cfg(test)]
1075mod score_window_tests {
1076    use super::*;
1077    use crate::query::DocSet;
1078    use crate::segment::chunk_map::DocLengths;
1079    use crate::structures::postings::{PostingCodec, PostingList};
1080
1081    #[test]
1082    fn compact_term_scores_preserve_scalar_bits_membership_and_resume() {
1083        let mut list = PostingList::new();
1084        for i in 0..701u32 {
1085            list.push(i * 11 + 1, [0, 1, 3, 65536, u32::MAX][i as usize % 5]);
1086        }
1087        let lengths = DocLengths::from_lengths(
1088            &(0..7800)
1089                .map(|i| [0, 1, 97, 65535][i % 4])
1090                .collect::<Vec<_>>(),
1091        );
1092        for codec in [
1093            PostingCodec::Rounded,
1094            PostingCodec::Packed,
1095            PostingCodec::Pfor,
1096            PostingCodec::Simd4x,
1097        ] {
1098            let postings = BlockPostingList::from_posting_list_with_codec(&list, codec).unwrap();
1099            for with_lengths in [false, true] {
1100                for boost in [0.0, 0.5, 1.0, 2.5] {
1101                    for params in [
1102                        super::super::Bm25Params { k1: 0.0, b: 0.0 },
1103                        super::super::Bm25Params::default(),
1104                        super::super::Bm25Params { k1: 14.3, b: 1.0 },
1105                    ] {
1106                        let build = || {
1107                            let mut scorer =
1108                                TermScorer::new(postings.clone(), 2.713, 503.17, boost)
1109                                    .with_params(params);
1110                            if with_lengths {
1111                                scorer.lengths = Some(lengths.clone());
1112                            }
1113                            scorer
1114                        };
1115                        let mut scalar = build();
1116                        let mut batch = build();
1117                        assert_eq!(batch.supports_score_batches(), boost == 1.0);
1118                        let mut docs = [0; super::super::docset::DOC_BATCH_SIZE];
1119                        let mut scores = [f32::NAN; super::super::docset::DOC_BATCH_SIZE];
1120                        scalar.seek(121);
1121                        batch.seek(121);
1122                        while batch.doc() != TERMINATED {
1123                            let len = batch.fill_score_batch(&mut docs, &mut scores);
1124                            assert!(len > 0);
1125                            for i in 0..len {
1126                                assert_eq!(docs[i], scalar.doc());
1127                                assert_eq!(
1128                                    scores[i].to_bits(),
1129                                    scalar.score().to_bits(),
1130                                    "{codec:?} boost={boost} doc={}",
1131                                    docs[i]
1132                                );
1133                                scalar.advance();
1134                            }
1135                            assert_eq!(batch.doc(), scalar.doc());
1136                        }
1137                        let mut scalar = build();
1138                        let mut batch = build();
1139                        for start in (0..8000u32).step_by(128) {
1140                            let docs = std::array::from_fn(|i| start + i as u32);
1141                            let mut bits = [u64::MAX; 2];
1142                            batch.score_batch_matches(&docs, docs.len(), &mut scores, &mut bits);
1143                            for (i, &doc) in docs.iter().enumerate() {
1144                                let found = scalar.seek(doc) == doc;
1145                                assert_eq!(bits[i / 64] & (1 << (i % 64)) != 0, found);
1146                                if found {
1147                                    assert_eq!(scores[i].to_bits(), scalar.score().to_bits());
1148                                }
1149                            }
1150                            assert_eq!(batch.doc(), scalar.doc());
1151                        }
1152                    }
1153                }
1154            }
1155        }
1156        let mut timed = TermScorer::new(
1157            BlockPostingList::from_posting_list(&list).unwrap(),
1158            1.0,
1159            1.0,
1160            1.0,
1161        );
1162        timed.budget = Some(super::super::SharedThreshold::new());
1163        assert!(!timed.supports_score_batches());
1164    }
1165
1166    #[test]
1167    fn replacement_term_windows_clear_stale_hits_and_stop_at_deadlines() {
1168        let mut list = PostingList::new();
1169        for doc in [0, 4095, 4096, 8192, TERMINATED - 1] {
1170            list.push(doc, 1);
1171        }
1172        let postings = BlockPostingList::from_posting_list(&list).unwrap();
1173        let mut scalar = TermScorer::new(postings.clone(), -0.0, 10.0, 1.0);
1174        let mut batched = TermScorer::new(postings.clone(), -0.0, 10.0, 1.0);
1175        let mut scores = Box::new([f32::NAN; super::super::docset::DOC_WINDOW_SIZE as usize]);
1176        let mut bits = [u64::MAX; super::super::docset::DOC_WINDOW_WORDS];
1177        for base in [
1178            0,
1179            4096,
1180            8192,
1181            TERMINATED - super::super::docset::DOC_WINDOW_SIZE,
1182        ] {
1183            bits.fill(u64::MAX);
1184            scores.fill(f32::NAN);
1185            batched.fill_score_window(base, &mut scores, &mut bits);
1186            scalar.seek(base);
1187            let end = base.saturating_add(super::super::docset::DOC_WINDOW_SIZE);
1188            while scalar.doc() < end {
1189                let offset = (scalar.doc() - base) as usize;
1190                assert_ne!(bits[offset / 64] & (1u64 << (offset % 64)), 0);
1191                assert_eq!(scores[offset].to_bits(), scalar.score().to_bits());
1192                bits[offset / 64] &= !(1u64 << (offset % 64));
1193                scalar.advance();
1194            }
1195            assert!(bits.iter().all(|&word| word == 0));
1196            assert_eq!(batched.doc(), scalar.doc());
1197        }
1198        assert_eq!(batched.doc(), TERMINATED);
1199        let mut expired = TermScorer::new(postings, 1.0, 10.0, 1.0);
1200        let budget = super::super::SharedThreshold::for_limit(1)
1201            .with_deadline(Some(std::time::Instant::now()));
1202        expired.budget = Some(budget.clone());
1203        bits.fill(u64::MAX);
1204        expired.fill_score_window(0, &mut scores, &mut bits);
1205        assert!(bits.iter().all(|&word| word == 0));
1206        assert_eq!(expired.doc(), TERMINATED);
1207        assert!(budget.truncated());
1208    }
1209
1210    #[test]
1211    fn term_score_runs_preserve_scalar_bits_with_boosts_lengths_and_legacy_fallback() {
1212        let mut list = PostingList::new();
1213        for i in 0..327u32 {
1214            list.push(i * 11, [1, 3, 127, 65535, 65536, u32::MAX][i as usize % 6]);
1215        }
1216        let lengths = DocLengths::from_lengths(
1217            &(0..3600)
1218                .map(|i| [0, 1, 97, 65535][i % 4])
1219                .collect::<Vec<_>>(),
1220        );
1221        for codec in [
1222            PostingCodec::Rounded,
1223            PostingCodec::Packed,
1224            PostingCodec::Pfor,
1225            PostingCodec::Simd4x,
1226        ] {
1227            let postings = BlockPostingList::from_posting_list_with_codec(&list, codec).unwrap();
1228            for with_lengths in [false, true] {
1229                for params in [
1230                    super::super::Bm25Params { k1: 0.0, b: 0.0 },
1231                    super::super::Bm25Params::default(),
1232                    super::super::Bm25Params { k1: 14.3, b: 1.0 },
1233                ] {
1234                    for boost in [0.0, 0.5, 1.0, 2.5] {
1235                        for avg in [0.0, 1.0, 503.17] {
1236                            for idf in [-0.0, 0.001, 10.0] {
1237                                let build = || {
1238                                    let mut scorer =
1239                                        TermScorer::new(postings.clone(), idf, avg, boost)
1240                                            .with_params(params);
1241                                    if with_lengths {
1242                                        scorer.lengths = Some(lengths.clone());
1243                                    }
1244                                    scorer
1245                                };
1246                                for replace in [false, true] {
1247                                    let mut scalar = build();
1248                                    let mut batched = build();
1249                                    let base = 121;
1250                                    scalar.seek(base);
1251                                    batched.seek(base);
1252                                    let mut scores = Box::new(
1253                                        [0.0; super::super::docset::DOC_WINDOW_SIZE as usize],
1254                                    );
1255                                    let mut bits = [if replace { u64::MAX } else { 0 };
1256                                        super::super::docset::DOC_WINDOW_WORDS];
1257                                    if replace {
1258                                        scores.fill(f32::NAN);
1259                                        batched.fill_score_window(base, &mut scores, &mut bits);
1260                                    } else {
1261                                        batched.accumulate_score_window(
1262                                            base,
1263                                            &mut scores,
1264                                            &mut bits,
1265                                        );
1266                                    }
1267                                    while scalar.doc() != TERMINATED {
1268                                        let offset = (scalar.doc() - base) as usize;
1269                                        assert_ne!(bits[offset / 64] & (1u64 << (offset % 64)), 0);
1270                                        let expected = if replace {
1271                                            scalar.score()
1272                                        } else {
1273                                            0.0 + scalar.score()
1274                                        };
1275                                        assert_eq!(
1276                                            scores[offset].to_bits(),
1277                                            expected.to_bits(),
1278                                            "codec={codec:?} lengths={with_lengths} params={params:?} boost={boost} avg={avg} idf={idf} doc={}",
1279                                            scalar.doc()
1280                                        );
1281                                        bits[offset / 64] &= !(1u64 << (offset % 64));
1282                                        scalar.advance();
1283                                    }
1284                                    assert!(bits.iter().all(|word| *word == 0));
1285                                    assert_eq!(batched.doc(), TERMINATED);
1286                                }
1287                            }
1288                        }
1289                    }
1290                }
1291            }
1292        }
1293    }
1294}