Skip to main content

summa_core/query/
collector.rs

1//! Search result collection and response types
2
3use std::cmp::Ordering;
4use std::collections::BinaryHeap;
5
6use crate::segment::SegmentReader;
7use crate::structures::TERMINATED;
8use crate::{DocId, Result, Score};
9
10use super::Query;
11
12/// Unique document address: segment_id + local doc_id within segment.
13/// Stores segment_id as u128 internally (16 bytes) but serializes as hex string
14/// for backward compatibility with JSON/gRPC clients.
15#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub struct DocAddress {
17    /// Segment ID as u128 (avoids heap allocation vs String)
18    segment_id_raw: u128,
19    /// Document ID within the segment
20    pub doc_id: DocId,
21}
22
23impl DocAddress {
24    pub fn new(segment_id: u128, doc_id: DocId) -> Self {
25        Self {
26            segment_id_raw: segment_id,
27            doc_id,
28        }
29    }
30
31    /// Get segment_id as hex string (for display/API)
32    pub fn segment_id(&self) -> String {
33        format!("{:032x}", self.segment_id_raw)
34    }
35
36    /// Get segment_id as u128 (zero-cost)
37    pub fn segment_id_u128(&self) -> Option<u128> {
38        Some(self.segment_id_raw)
39    }
40}
41
42impl serde::Serialize for DocAddress {
43    fn serialize<S: serde::Serializer>(
44        &self,
45        serializer: S,
46    ) -> std::result::Result<S::Ok, S::Error> {
47        use serde::ser::SerializeStruct;
48        let mut s = serializer.serialize_struct("DocAddress", 2)?;
49        s.serialize_field("segment_id", &format!("{:032x}", self.segment_id_raw))?;
50        s.serialize_field("doc_id", &self.doc_id)?;
51        s.end()
52    }
53}
54
55impl<'de> serde::Deserialize<'de> for DocAddress {
56    fn deserialize<D: serde::Deserializer<'de>>(
57        deserializer: D,
58    ) -> std::result::Result<Self, D::Error> {
59        #[derive(serde::Deserialize)]
60        struct Helper {
61            segment_id: String,
62            doc_id: DocId,
63        }
64        let h = Helper::deserialize(deserializer)?;
65        let raw = u128::from_str_radix(&h.segment_id, 16).map_err(serde::de::Error::custom)?;
66        Ok(DocAddress {
67            segment_id_raw: raw,
68            doc_id: h.doc_id,
69        })
70    }
71}
72
73/// A scored position/ordinal within a field
74/// For text fields: position is the token position
75/// For vector fields: position is the ordinal (which vector in multi-value)
76#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
77pub struct ScoredPosition {
78    /// Position (text) or ordinal (vector)
79    pub position: u32,
80    /// Individual score contribution from this position/ordinal
81    pub score: f32,
82}
83
84impl ScoredPosition {
85    pub fn new(position: u32, score: f32) -> Self {
86        Self { position, score }
87    }
88}
89
90/// Search result with doc_id and score (internal use)
91#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
92pub struct SearchResult {
93    pub doc_id: DocId,
94    pub score: Score,
95    /// Segment ID (set by searcher after collection)
96    #[serde(default, skip_serializing_if = "is_zero_u128")]
97    pub segment_id: u128,
98    /// Matched positions per field: (field_id, scored_positions)
99    /// Each position includes its individual score contribution
100    #[serde(default, skip_serializing_if = "Vec::is_empty")]
101    pub positions: Vec<(u32, Vec<ScoredPosition>)>,
102}
103
104fn is_zero_u128(v: &u128) -> bool {
105    *v == 0
106}
107
108/// Canonical result order used by search, reranking, fusion, and pagination.
109pub(crate) fn compare_search_results_desc(a: &SearchResult, b: &SearchResult) -> Ordering {
110    b.score
111        .total_cmp(&a.score)
112        .then_with(|| a.segment_id.cmp(&b.segment_id))
113        .then_with(|| a.doc_id.cmp(&b.doc_id))
114}
115
116/// Matched field info with ordinals (for multi-valued fields)
117#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
118pub struct MatchedField {
119    /// Field ID
120    pub field_id: u32,
121    /// Matched element ordinals (for multi-valued fields with position tracking)
122    /// Empty if position tracking is not enabled for this field
123    pub ordinals: Vec<u32>,
124}
125
126impl SearchResult {
127    /// Extract unique ordinals from positions for each field
128    /// For text fields: ordinal = position >> 20 (from encoded position)
129    /// For vector fields: position IS the ordinal directly
130    pub fn extract_ordinals(&self) -> Vec<MatchedField> {
131        self.positions
132            .iter()
133            .map(|(field_id, scored_positions)| {
134                // Position lists are typically short. Collecting into one
135                // compact buffer and deduplicating in place avoids both the
136                // hash-table allocation and the second allocation needed to
137                // turn that table back into a sorted response vector.
138                let mut ordinals = Vec::with_capacity(scored_positions.len());
139                ordinals.extend(scored_positions.iter().map(|sp| {
140                    // For text fields with encoded positions, extract ordinal.
141                    // For vector fields, position IS the ordinal.
142                    if sp.position > 0xFFFFF {
143                        sp.position >> 20
144                    } else {
145                        sp.position
146                    }
147                }));
148                ordinals.sort_unstable();
149                ordinals.dedup();
150                MatchedField {
151                    field_id: *field_id,
152                    ordinals,
153                }
154            })
155            .collect()
156    }
157
158    /// Get all scored positions for a specific field
159    pub fn field_positions(&self, field_id: u32) -> Option<&[ScoredPosition]> {
160        self.positions
161            .iter()
162            .find(|(fid, _)| *fid == field_id)
163            .map(|(_, positions)| positions.as_slice())
164    }
165}
166
167/// Search hit with unique document address and score
168#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
169pub struct SearchHit {
170    /// Unique document address (segment_id + local doc_id)
171    pub address: DocAddress,
172    pub score: Score,
173    /// Matched fields with element ordinals (populated when position tracking is enabled)
174    #[serde(default, skip_serializing_if = "Vec::is_empty")]
175    pub matched_fields: Vec<MatchedField>,
176}
177
178/// Search response with hits (IDs only, no documents)
179#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
180pub struct SearchResponse {
181    pub hits: Vec<SearchHit>,
182    pub total_hits: u32,
183}
184
185impl PartialEq for SearchResult {
186    fn eq(&self, other: &Self) -> bool {
187        self.score.to_bits() == other.score.to_bits()
188            && self.segment_id == other.segment_id
189            && self.doc_id == other.doc_id
190    }
191}
192
193impl Eq for SearchResult {}
194
195impl PartialOrd for SearchResult {
196    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
197        Some(self.cmp(other))
198    }
199}
200
201impl Ord for SearchResult {
202    fn cmp(&self, other: &Self) -> Ordering {
203        other
204            .score
205            .total_cmp(&self.score)
206            .then_with(|| self.segment_id.cmp(&other.segment_id))
207            .then_with(|| self.doc_id.cmp(&other.doc_id))
208    }
209}
210
211/// Trait for search result collectors
212///
213/// Implement this trait to create custom collectors that can be
214/// combined and passed to query execution.
215pub trait Collector {
216    /// Called for each matching document
217    /// positions: Vec of (field_id, scored_positions)
218    fn collect(&mut self, doc_id: DocId, score: Score, positions: &[(u32, Vec<ScoredPosition>)]);
219
220    /// Accept a known exact number of matches without visiting documents.
221    /// Return false without changing state to request ordinary collection.
222    /// This is only offered to score- and position-free collectors.
223    fn collect_count(&mut self, _count: u64) -> bool {
224        false
225    }
226
227    /// Opt into exact counting separately from ranked collection. A finite
228    /// limit promises that retaining this many highest ranked matches is enough.
229    /// Custom collectors keep exhaustive callbacks by default.
230    fn ranked_count_limit(&self) -> Option<usize> {
231        None
232    }
233
234    /// Account for exact matches omitted by the ranked path. Called only after
235    /// ranked_count_limit opts in; must preserve every count/total_seen field.
236    fn collect_omitted_count(&mut self, _count: u64) {
237        unreachable!("collector did not opt into separate exact counting");
238    }
239
240    /// Opt into bounded score blocks when positions and per-hit deadline
241    /// checks are unnecessary. Custom collectors retain per-document calls.
242    /// Tuple children opting in permit calls to be grouped by collector.
243    fn supports_score_blocks(&self) -> bool {
244        false
245    }
246
247    /// Collect set bits in increasing document order, with `scores[i]` for
248    /// document `base + i`. Set bits must address valid document IDs.
249    /// The driver calls this only after `supports_score_blocks` returns true.
250    fn collect_score_block(&mut self, base: DocId, scores: &[Score; 64], mut bits: u64) {
251        while bits != 0 {
252            let offset = bits.trailing_zeros() as usize;
253            self.collect(base + offset as u32, scores[offset], &[]);
254            bits &= bits - 1;
255        }
256    }
257
258    /// Whether this score can enter the collector's retained result set.
259    ///
260    /// The scorer still calls `collect` when this returns false so counters and
261    /// other side effects remain exact; it only skips materializing positions.
262    fn would_collect(&self, _doc_id: DocId, _score: Score) -> bool {
263        true
264    }
265
266    /// Collect already-owned positions. Position-aware collectors can override
267    /// this to move the nested vectors instead of cloning them.
268    fn collect_owned(&mut self, doc_id: DocId, score: Score, positions: super::MatchedPositions) {
269        self.collect(doc_id, score, &positions);
270    }
271
272    /// Whether this collector consumes scores. Returning false permits the
273    /// driver to pass 0.0 without evaluating BM25. Position consumers still
274    /// force scoring; custom collectors retain the previous behavior by default.
275    fn needs_scores(&self) -> bool {
276        true
277    }
278
279    /// Whether this collector needs position information
280    fn needs_positions(&self) -> bool {
281        false
282    }
283}
284
285/// Compact score-only heap entry.
286///
287/// A segment-local collector does not know its segment ID yet and ordinary
288/// searches do not retain positions. Keeping only these two words while the
289/// scorer runs makes the common heap 8 bytes per hit instead of storing a
290/// full `SearchResult` (including an empty `Vec` and a zero `u128`).
291#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
292struct ScoreOnlyResult(u64);
293
294impl ScoreOnlyResult {
295    #[inline]
296    fn new(doc_id: DocId, score: Score) -> Self {
297        let bits = score.to_bits();
298        // Map IEEE sign/magnitude to unsigned total order, then reverse it
299        // so the max-heap root is the worst score / largest document ID.
300        let rank = bits ^ (((bits as i32 >> 31) as u32) | 0x8000_0000);
301        Self((u64::from(!rank) << 32) | u64::from(doc_id))
302    }
303
304    #[inline]
305    fn doc_id(self) -> DocId {
306        self.0 as u32
307    }
308
309    #[inline]
310    fn score(self) -> Score {
311        let rank = !(self.0 >> 32) as u32;
312        let mask = (rank >> 31).wrapping_sub(1) | 0x8000_0000;
313        f32::from_bits(rank ^ mask)
314    }
315}
316
317/// Position-aware heap entry. The segment ID is stamped after collection, so
318/// omitting it here also keeps this variant smaller than `SearchResult`.
319#[derive(Debug, Clone)]
320struct PositionedResult {
321    doc_id: DocId,
322    score: Score,
323    positions: super::MatchedPositions,
324}
325
326impl PartialEq for PositionedResult {
327    fn eq(&self, other: &Self) -> bool {
328        self.score.to_bits() == other.score.to_bits() && self.doc_id == other.doc_id
329    }
330}
331
332impl Eq for PositionedResult {}
333
334impl PartialOrd for PositionedResult {
335    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
336        Some(self.cmp(other))
337    }
338}
339
340impl Ord for PositionedResult {
341    fn cmp(&self, other: &Self) -> Ordering {
342        other
343            .score
344            .total_cmp(&self.score)
345            .then_with(|| self.doc_id.cmp(&other.doc_id))
346    }
347}
348
349enum TopKHeap {
350    Scores(BinaryHeap<ScoreOnlyResult>),
351    Positions(BinaryHeap<PositionedResult>),
352}
353
354#[inline(always)]
355fn ranks_ahead(doc_id: DocId, score: Score, worst_doc_id: DocId, worst_score: Score) -> bool {
356    let order = score.total_cmp(&worst_score);
357    order.is_gt() || (order.is_eq() && doc_id < worst_doc_id)
358}
359
360/// Collector for top-k results
361pub struct TopKCollector {
362    heap: TopKHeap,
363    k: usize,
364    /// Total documents seen by this collector
365    total_seen: u32,
366}
367
368// Avoid trusting a caller-controlled `k` as an up-front allocation size. The
369// heap still grows to the number of results actually retained, but malformed
370// or overly broad requests cannot reserve gigabytes once per segment before
371// any document has been scored.
372const MAX_INITIAL_TOP_K_CAPACITY: usize = 8 * 1024;
373
374impl TopKCollector {
375    pub fn new(k: usize) -> Self {
376        Self {
377            heap: TopKHeap::Scores(BinaryHeap::with_capacity(k.min(MAX_INITIAL_TOP_K_CAPACITY))),
378            k,
379            total_seen: 0,
380        }
381    }
382
383    /// Create a collector that also collects positions
384    pub fn with_positions(k: usize) -> Self {
385        Self {
386            heap: TopKHeap::Positions(BinaryHeap::with_capacity(k.min(MAX_INITIAL_TOP_K_CAPACITY))),
387            k,
388            total_seen: 0,
389        }
390    }
391
392    /// Get the total number of documents seen (scored) by this collector
393    pub fn total_seen(&self) -> u32 {
394        self.total_seen
395    }
396
397    /// Whether the heap already holds `k` results.
398    pub fn is_full(&self) -> bool {
399        match &self.heap {
400            TopKHeap::Scores(heap) => heap.len() >= self.k,
401            TopKHeap::Positions(heap) => heap.len() >= self.k,
402        }
403    }
404
405    fn competitive_score(&self) -> Score {
406        if !self.is_full() {
407            return Score::NEG_INFINITY;
408        }
409        match &self.heap {
410            TopKHeap::Scores(heap) => heap.peek().map_or(Score::NEG_INFINITY, |hit| hit.score()),
411            TopKHeap::Positions(heap) => heap.peek().map_or(Score::NEG_INFINITY, |hit| hit.score),
412        }
413    }
414
415    pub fn into_sorted_results(self) -> Vec<SearchResult> {
416        match self.heap {
417            TopKHeap::Scores(heap) => {
418                let mut compact = heap.into_vec();
419                compact.sort_unstable();
420                compact
421                    .into_iter()
422                    .map(|result| SearchResult {
423                        doc_id: result.doc_id(),
424                        score: result.score(),
425                        segment_id: 0,
426                        positions: Vec::new(),
427                    })
428                    .collect()
429            }
430            TopKHeap::Positions(heap) => {
431                let mut positioned = heap.into_vec();
432                positioned.sort_unstable_by(|a, b| {
433                    b.score
434                        .total_cmp(&a.score)
435                        .then_with(|| a.doc_id.cmp(&b.doc_id))
436                });
437                positioned
438                    .into_iter()
439                    .map(|result| SearchResult {
440                        doc_id: result.doc_id,
441                        score: result.score,
442                        segment_id: 0,
443                        positions: result.positions,
444                    })
445                    .collect()
446            }
447        }
448    }
449
450    /// Consume collector and return (sorted_results, total_seen)
451    pub fn into_results_with_count(self) -> (Vec<SearchResult>, u32) {
452        let total = self.total_seen;
453        (self.into_sorted_results(), total)
454    }
455}
456
457impl Collector for TopKCollector {
458    fn ranked_count_limit(&self) -> Option<usize> {
459        (!self.needs_positions()).then_some(self.k)
460    }
461    fn collect_omitted_count(&mut self, count: u64) {
462        self.total_seen = self
463            .total_seen
464            .saturating_add(count.min(u64::from(u32::MAX)) as u32);
465    }
466
467    fn supports_score_blocks(&self) -> bool {
468        matches!(self.heap, TopKHeap::Scores(_))
469    }
470
471    fn collect_score_block(&mut self, base: DocId, scores: &[Score; 64], mut bits: u64) {
472        let TopKHeap::Scores(heap) = &mut self.heap else {
473            while bits != 0 {
474                let offset = bits.trailing_zeros() as usize;
475                self.collect(base + offset as u32, scores[offset], &[]);
476                bits &= bits - 1;
477            }
478            return;
479        };
480        self.total_seen = self.total_seen.saturating_add(bits.count_ones());
481        if self.k == 0 || bits == 0 {
482            return;
483        }
484        while heap.len() < self.k && bits != 0 {
485            let offset = bits.trailing_zeros() as usize;
486            heap.push(ScoreOnlyResult::new(base + offset as u32, scores[offset]));
487            bits &= bits - 1;
488        }
489        if bits == 0 {
490            return;
491        }
492        let mut worst = *heap.peek().expect("full top-k heap");
493        // Screen independent scores before the serial heap loop. Preserve
494        // ties and unordered values for its canonical total-order admission.
495        // A rising heap threshold only leaves extra candidates in this mask.
496        let mut eligible = 0u64;
497        for (i, &score) in scores.iter().enumerate() {
498            eligible |= (if score < worst.score() { 0 } else { 1 }) << i;
499        }
500        bits &= eligible;
501        while bits != 0 {
502            let offset = bits.trailing_zeros() as usize;
503            let doc_id = base + offset as u32;
504            let score = scores[offset];
505            let result = ScoreOnlyResult::new(doc_id, score);
506            if result < worst {
507                *heap.peek_mut().expect("full top-k heap") = result;
508                worst = *heap.peek().expect("full top-k heap");
509            }
510            bits &= bits - 1;
511        }
512    }
513
514    #[inline]
515    fn collect(&mut self, doc_id: DocId, score: Score, positions: &[(u32, Vec<ScoredPosition>)]) {
516        self.total_seen = self.total_seen.saturating_add(1);
517        if self.k == 0 {
518            return;
519        }
520
521        match &mut self.heap {
522            TopKHeap::Scores(heap) => {
523                let result = ScoreOnlyResult::new(doc_id, score);
524                if heap.len() < self.k {
525                    heap.push(result);
526                } else if heap.peek().is_some_and(|worst| result < *worst) {
527                    *heap.peek_mut().expect("full top-k heap") = result;
528                }
529            }
530            TopKHeap::Positions(heap) => {
531                if heap.len() >= self.k
532                    && !heap
533                        .peek()
534                        .is_some_and(|worst| ranks_ahead(doc_id, score, worst.doc_id, worst.score))
535                {
536                    return;
537                }
538                let result = PositionedResult {
539                    doc_id,
540                    score,
541                    // Only clone positions after the hit is known to be
542                    // competitive. Replacing the root drops its old positions.
543                    positions: positions.to_vec(),
544                };
545                if heap.len() < self.k {
546                    heap.push(result);
547                } else {
548                    *heap.peek_mut().expect("full top-k heap") = result;
549                }
550            }
551        }
552    }
553
554    #[inline]
555    fn would_collect(&self, doc_id: DocId, score: Score) -> bool {
556        if self.k == 0 {
557            return false;
558        }
559        match &self.heap {
560            TopKHeap::Scores(heap) => {
561                heap.len() < self.k
562                    || heap
563                        .peek()
564                        .is_some_and(|min| ScoreOnlyResult::new(doc_id, score) < *min)
565            }
566            TopKHeap::Positions(heap) => {
567                heap.len() < self.k
568                    || heap
569                        .peek()
570                        .is_some_and(|min| ranks_ahead(doc_id, score, min.doc_id, min.score))
571            }
572        }
573    }
574
575    #[inline]
576    fn collect_owned(&mut self, doc_id: DocId, score: Score, positions: super::MatchedPositions) {
577        self.total_seen = self.total_seen.saturating_add(1);
578        if self.k == 0 {
579            return;
580        }
581
582        match &mut self.heap {
583            TopKHeap::Scores(heap) => {
584                let result = ScoreOnlyResult::new(doc_id, score);
585                if heap.len() < self.k {
586                    heap.push(result);
587                } else if heap.peek().is_some_and(|worst| result < *worst) {
588                    *heap.peek_mut().expect("full top-k heap") = result;
589                }
590            }
591            TopKHeap::Positions(heap) => {
592                if heap.len() >= self.k
593                    && !heap
594                        .peek()
595                        .is_some_and(|worst| ranks_ahead(doc_id, score, worst.doc_id, worst.score))
596                {
597                    return;
598                }
599                let result = PositionedResult {
600                    doc_id,
601                    score,
602                    positions,
603                };
604                if heap.len() < self.k {
605                    heap.push(result);
606                } else {
607                    *heap.peek_mut().expect("full top-k heap") = result;
608                }
609            }
610        }
611    }
612
613    #[inline]
614    fn needs_positions(&self) -> bool {
615        matches!(&self.heap, TopKHeap::Positions(_))
616    }
617}
618
619/// Collector that counts all matching documents
620#[derive(Default)]
621pub struct CountCollector {
622    count: u64,
623}
624
625impl CountCollector {
626    pub fn new() -> Self {
627        Self { count: 0 }
628    }
629
630    /// Get the total count
631    pub fn count(&self) -> u64 {
632        self.count
633    }
634}
635
636impl Collector for CountCollector {
637    fn ranked_count_limit(&self) -> Option<usize> {
638        Some(0)
639    }
640    fn collect_omitted_count(&mut self, count: u64) {
641        self.count += count;
642    }
643
644    fn supports_score_blocks(&self) -> bool {
645        true
646    }
647
648    fn collect_score_block(&mut self, _base: DocId, _scores: &[Score; 64], bits: u64) {
649        self.count += u64::from(bits.count_ones());
650    }
651
652    fn collect_count(&mut self, count: u64) -> bool {
653        self.count += count;
654        true
655    }
656
657    fn needs_scores(&self) -> bool {
658        false
659    }
660
661    #[inline]
662    fn collect(
663        &mut self,
664        _doc_id: DocId,
665        _score: Score,
666        _positions: &[(u32, Vec<ScoredPosition>)],
667    ) {
668        self.count += 1;
669    }
670}
671
672/// Execute a search query on a single segment and return (results, total_seen) (async)
673pub async fn search_segment_with_count(
674    reader: &SegmentReader,
675    query: &dyn Query,
676    limit: usize,
677) -> Result<(Vec<SearchResult>, u32)> {
678    let segment_limit = limit.min(reader.num_docs() as usize);
679    let mut collector = TopKCollector::new(segment_limit);
680    collect_segment_with_limit(reader, query, &mut collector, segment_limit).await?;
681    Ok(collector.into_results_with_count())
682}
683
684/// Execute a search query on a single segment with positions and return (results, total_seen)
685pub async fn search_segment_with_positions_and_count(
686    reader: &SegmentReader,
687    query: &dyn Query,
688    limit: usize,
689) -> Result<(Vec<SearchResult>, u32)> {
690    let segment_limit = limit.min(reader.num_docs() as usize);
691    let mut collector = TopKCollector::with_positions(segment_limit);
692    collect_segment_with_limit(reader, query, &mut collector, segment_limit).await?;
693    Ok(collector.into_results_with_count())
694}
695
696/// Return positions for the next collector that can retain them. All but the
697/// final consumer receive a clone; the final consumer takes the original
698/// allocation. Tuple collectors use this to avoid a deep clone when only one
699/// child actually needs positions (the common top-k + count case).
700fn positions_for_next_collector(
701    positions: &mut Option<super::MatchedPositions>,
702    remaining_consumers: &mut usize,
703) -> super::MatchedPositions {
704    assert!(
705        *remaining_consumers > 0,
706        "position consumer count underflow"
707    );
708    *remaining_consumers -= 1;
709    if *remaining_consumers == 0 {
710        positions
711            .take()
712            .expect("owned positions must remain for the final collector")
713    } else {
714        positions
715            .as_ref()
716            .cloned()
717            .expect("owned positions must remain while collectors are pending")
718    }
719}
720
721// Implement Collector for tuple of 2 collectors
722impl<A: Collector, B: Collector> Collector for (&mut A, &mut B) {
723    fn ranked_count_limit(&self) -> Option<usize> {
724        Some(
725            self.0
726                .ranked_count_limit()?
727                .max(self.1.ranked_count_limit()?),
728        )
729    }
730    fn collect_omitted_count(&mut self, count: u64) {
731        self.0.collect_omitted_count(count);
732        self.1.collect_omitted_count(count);
733    }
734
735    fn supports_score_blocks(&self) -> bool {
736        self.0.supports_score_blocks() && self.1.supports_score_blocks()
737    }
738    fn collect_score_block(&mut self, base: DocId, scores: &[Score; 64], bits: u64) {
739        self.0.collect_score_block(base, scores, bits);
740        self.1.collect_score_block(base, scores, bits);
741    }
742    fn needs_scores(&self) -> bool {
743        self.0.needs_scores() || self.1.needs_scores()
744    }
745    fn collect(&mut self, doc_id: DocId, score: Score, positions: &[(u32, Vec<ScoredPosition>)]) {
746        self.0.collect(doc_id, score, positions);
747        self.1.collect(doc_id, score, positions);
748    }
749    fn needs_positions(&self) -> bool {
750        self.0.needs_positions() || self.1.needs_positions()
751    }
752    fn would_collect(&self, doc_id: DocId, score: Score) -> bool {
753        (self.0.needs_positions() && self.0.would_collect(doc_id, score))
754            || (self.1.needs_positions() && self.1.would_collect(doc_id, score))
755    }
756    fn collect_owned(&mut self, doc_id: DocId, score: Score, positions: super::MatchedPositions) {
757        let wants = [
758            self.0.needs_positions() && self.0.would_collect(doc_id, score),
759            self.1.needs_positions() && self.1.would_collect(doc_id, score),
760        ];
761        let mut remaining = wants.iter().filter(|&&want| want).count();
762        let mut positions = Some(positions);
763
764        if wants[0] {
765            self.0.collect_owned(
766                doc_id,
767                score,
768                positions_for_next_collector(&mut positions, &mut remaining),
769            );
770        } else {
771            self.0.collect(doc_id, score, &[]);
772        }
773        if wants[1] {
774            self.1.collect_owned(
775                doc_id,
776                score,
777                positions_for_next_collector(&mut positions, &mut remaining),
778            );
779        } else {
780            self.1.collect(doc_id, score, &[]);
781        }
782    }
783}
784
785// Implement Collector for tuple of 3 collectors
786impl<A: Collector, B: Collector, C: Collector> Collector for (&mut A, &mut B, &mut C) {
787    fn ranked_count_limit(&self) -> Option<usize> {
788        Some(
789            self.0
790                .ranked_count_limit()?
791                .max(self.1.ranked_count_limit()?)
792                .max(self.2.ranked_count_limit()?),
793        )
794    }
795    fn collect_omitted_count(&mut self, count: u64) {
796        self.0.collect_omitted_count(count);
797        self.1.collect_omitted_count(count);
798        self.2.collect_omitted_count(count);
799    }
800
801    fn supports_score_blocks(&self) -> bool {
802        self.0.supports_score_blocks()
803            && self.1.supports_score_blocks()
804            && self.2.supports_score_blocks()
805    }
806    fn collect_score_block(&mut self, base: DocId, scores: &[Score; 64], bits: u64) {
807        self.0.collect_score_block(base, scores, bits);
808        self.1.collect_score_block(base, scores, bits);
809        self.2.collect_score_block(base, scores, bits);
810    }
811    fn needs_scores(&self) -> bool {
812        self.0.needs_scores() || self.1.needs_scores() || self.2.needs_scores()
813    }
814    fn collect(&mut self, doc_id: DocId, score: Score, positions: &[(u32, Vec<ScoredPosition>)]) {
815        self.0.collect(doc_id, score, positions);
816        self.1.collect(doc_id, score, positions);
817        self.2.collect(doc_id, score, positions);
818    }
819    fn needs_positions(&self) -> bool {
820        self.0.needs_positions() || self.1.needs_positions() || self.2.needs_positions()
821    }
822    fn would_collect(&self, doc_id: DocId, score: Score) -> bool {
823        (self.0.needs_positions() && self.0.would_collect(doc_id, score))
824            || (self.1.needs_positions() && self.1.would_collect(doc_id, score))
825            || (self.2.needs_positions() && self.2.would_collect(doc_id, score))
826    }
827    fn collect_owned(&mut self, doc_id: DocId, score: Score, positions: super::MatchedPositions) {
828        let wants = [
829            self.0.needs_positions() && self.0.would_collect(doc_id, score),
830            self.1.needs_positions() && self.1.would_collect(doc_id, score),
831            self.2.needs_positions() && self.2.would_collect(doc_id, score),
832        ];
833        let mut remaining = wants.iter().filter(|&&want| want).count();
834        let mut positions = Some(positions);
835
836        if wants[0] {
837            self.0.collect_owned(
838                doc_id,
839                score,
840                positions_for_next_collector(&mut positions, &mut remaining),
841            );
842        } else {
843            self.0.collect(doc_id, score, &[]);
844        }
845        if wants[1] {
846            self.1.collect_owned(
847                doc_id,
848                score,
849                positions_for_next_collector(&mut positions, &mut remaining),
850            );
851        } else {
852            self.1.collect(doc_id, score, &[]);
853        }
854        if wants[2] {
855            self.2.collect_owned(
856                doc_id,
857                score,
858                positions_for_next_collector(&mut positions, &mut remaining),
859            );
860        } else {
861            self.2.collect(doc_id, score, &[]);
862        }
863    }
864}
865
866fn streams_exhaustive_text(query: &dyn Query) -> bool {
867    matches!(query.decompose(), super::QueryDecomposition::TextTerm(_))
868        || query.should_children().is_some_and(|children| {
869            children
870                .iter()
871                .all(|child| streams_exhaustive_text(child.as_ref()))
872        })
873}
874
875/// Exact inclusion/exclusion is cheaper when only an OR cardinality is needed.
876/// Reuse ordinary conjunction traversal and admit only physical text counts.
877async fn two_term_union_count(
878    reader: &SegmentReader,
879    query: &dyn Query,
880    minimum_count: u64,
881) -> Result<Option<u64>> {
882    if reader.alive_docs().is_some() {
883        return Ok(None);
884    }
885    let Some([left, right]) = query.should_children() else {
886        return Ok(None);
887    };
888    let (Some(a), Some(b)) = (left.count_equivalent_term(), right.count_equivalent_term()) else {
889        return Ok(None);
890    };
891    for info in [&a, &b] {
892        if reader.is_chunked_field(info.field)
893            || !reader
894                .schema()
895                .get_field_entry(info.field)
896                .is_some_and(|field| field.indexed)
897        {
898            return Ok(None);
899        }
900    }
901    let mapped = reader.has_text_mapping(a.field) || reader.has_text_mapping(b.field);
902    if mapped && a.field != b.field {
903        return Ok(None);
904    }
905    let a = reader.text_doc_freq(a.field, &a.term).await?;
906    let b = reader.text_doc_freq(b.field, &b.term).await?;
907    // A zero DF also permits the existing fast-column fallback. Do not replace it.
908    if a == 0 || b == 0 || u64::from(a.max(b)) < minimum_count {
909        return Ok(None);
910    }
911    let mut intersection = super::BooleanQuery::new();
912    intersection.must = vec![left.clone(), right.clone()];
913    let mut options = super::ScorerOptions {
914        physical_text_field: None,
915        complete_text_matches: true,
916        skip_scoring_setup: true,
917        collect_positions: false,
918        ..Default::default()
919    };
920    if mapped && super::text_mapping::prepare(reader, &intersection, &mut options, true).is_none() {
921        return Ok(None);
922    }
923    let mut scorer = intersection
924        .scorer_with_options(reader, usize::MAX / 2, options)
925        .await?;
926    let mut overlap = CountCollector::new();
927    drive_scorer(scorer.as_mut(), &mut overlap);
928    reader.check_posting_integrity()?;
929    let overlap = overlap.count();
930    if overlap > u64::from(a.min(b)) {
931        return Err(crate::Error::Corruption(
932            "term overlap exceeds dictionary cardinality".into(),
933        ));
934    }
935    Ok(Some(u64::from(a) + u64::from(b) - overlap))
936}
937
938/// Restrict split counting to the ordinary exact text ranking plans.
939async fn ranked_exact_count(
940    reader: &SegmentReader,
941    query: &dyn Query,
942    limit: usize,
943) -> Result<Option<u64>> {
944    if reader.alive_docs().is_some() {
945        return Ok(None);
946    }
947    let eligible = |info: super::TermQueryInfo| {
948        (info.weight.is_finite()
949            && info.weight > 0.0
950            && reader
951                .schema()
952                .get_field_entry(info.field)
953                .is_some_and(|entry| entry.indexed)
954            && (!reader.has_text_mapping(info.field)
955                || reader
956                    .chunk_map(info.field)
957                    .is_some_and(|map| map.is_document_map()))
958            && !reader.is_chunked_field(info.field))
959        .then_some(info)
960    };
961    if let Some(info) = query.ranked_count_equivalent_term()
962        && let Some(info) = eligible(info)
963    {
964        let count = reader.text_doc_freq(info.field, &info.term).await?;
965        return Ok(
966            (u64::from(count) >= (limit.max(128) as u64).saturating_mul(16))
967                .then_some(u64::from(count)),
968        );
969    }
970    if let Some([left, right]) = query.should_children()
971        && let (super::QueryDecomposition::TextTerm(a), super::QueryDecomposition::TextTerm(b)) =
972            (left.decompose(), right.decompose())
973        && let (Some(a), Some(b)) = (eligible(a), eligible(b))
974        && a.field == b.field
975    {
976        return two_term_union_count(reader, query, (limit.max(128) as u64).saturating_mul(128))
977            .await;
978    }
979    Ok(None)
980}
981
982/// Execute a query with one or more collectors (async)
983///
984/// Plain text terms and unions stream matches without an intermediate ranked heap.
985/// Other query types retain the large scorer limit used by this API; this does
986/// not turn approximate vector retrieval into an exhaustive distance scan.
987/// Use `collect_segment_with_limit` when only ranked candidates are needed.
988///
989/// # Examples
990/// ```ignore
991/// // Single collector
992/// let mut top_k = TopKCollector::new(10);
993/// collect_segment(reader, query, &mut top_k).await?;
994///
995/// // Multiple collectors (tuple)
996/// let mut top_k = TopKCollector::new(10);
997/// let mut count = CountCollector::new();
998/// collect_segment(reader, query, &mut (&mut top_k, &mut count)).await?;
999/// ```
1000pub async fn collect_segment<C: Collector>(
1001    reader: &SegmentReader,
1002    query: &dyn Query,
1003    collector: &mut C,
1004) -> Result<()> {
1005    reader.check_posting_integrity()?;
1006    // Dictionary document frequency is exact only for physical document ids in
1007    // deletion-free readers. Never substitute count_estimate (chunks/deletes).
1008    if !collector.needs_scores()
1009        && !collector.needs_positions()
1010        && reader.alive_docs().is_none()
1011        && let Some(info) = query.count_equivalent_term()
1012        && !reader.is_chunked_field(info.field)
1013        && reader
1014            .schema()
1015            .get_field_entry(info.field)
1016            .is_some_and(|entry| entry.indexed)
1017    {
1018        let count = reader.text_doc_freq(info.field, &info.term).await?;
1019        // Zero also represents a missing term. Preserve the ordinary scorer's
1020        // fast-column fallback rather than interpreting it as an exact empty set.
1021        if count > 0 && collector.collect_count(u64::from(count)) {
1022            return reader.check_posting_integrity();
1023        }
1024    }
1025    if !collector.needs_scores()
1026        && !collector.needs_positions()
1027        && query
1028            .should_children()
1029            .is_some_and(|children| children.len() == 2)
1030        && collector.collect_count(0)
1031        && let Some(count) = two_term_union_count(reader, query, 0).await?
1032        && collector.collect_count(count)
1033    {
1034        return reader.check_posting_integrity();
1035    }
1036    if !collector.needs_positions()
1037        && let Some(limit) = collector.ranked_count_limit()
1038        && limit > 0
1039        && limit < reader.num_docs() as usize
1040        && let Some(count) = ranked_exact_count(reader, query, limit).await?
1041        && count > limit as u64
1042    {
1043        let mut visited = CountCollector::new();
1044        collect_segment_with_limit(reader, query, &mut (&mut *collector, &mut visited), limit)
1045            .await?;
1046        let omitted = count.checked_sub(visited.count()).ok_or_else(|| {
1047            crate::Error::Corruption("ranked matches exceed exact cardinality".into())
1048        })?;
1049        collector.collect_omitted_count(omitted);
1050        return reader.check_posting_integrity();
1051    }
1052    let ranked_count_limit = collector.ranked_count_limit().filter(|&k| {
1053        query.supports_ranked_conjunction_count()
1054            && k > 0
1055            && k < reader.num_docs() as usize
1056            && !collector.needs_positions()
1057            && reader.alive_docs().is_none()
1058    });
1059    let mut options = super::ScorerOptions {
1060        physical_text_field: None,
1061        // The required-clause scorer deliberately rejects proximity scoring.
1062        // Select it only for ordinary text terms/unions; preserve the existing
1063        // execution semantics of opaque, tuned, and vector query types.
1064        complete_text_matches: streams_exhaustive_text(query),
1065        ranked_count_limit,
1066        skip_scoring_setup: !collector.needs_scores() && !collector.needs_positions(),
1067        eligibility: reader.alive_docs(),
1068        collect_positions: collector.needs_positions(),
1069        ..Default::default()
1070    };
1071    // Keep the existing non-text limit semantics. Complete text scorers use
1072    // posting cursors rather than retaining and sorting every matching hit.
1073    let map = super::text_mapping::prepare(reader, query, &mut options, true);
1074    let scorer = query
1075        .scorer_with_options(reader, usize::MAX / 2, options)
1076        .await?;
1077    let exact_count = ranked_count_limit.and_then(|_| scorer.exact_ranked_count());
1078    let mut scorer = super::text_mapping::filtered(scorer, reader.alive_docs(), map);
1079    if let Some(count) = exact_count {
1080        let mut visited = CountCollector::new();
1081        drive_collected(
1082            scorer.as_mut(),
1083            &mut (&mut *collector, &mut visited),
1084            map,
1085            None,
1086        );
1087        let omitted = count.checked_sub(visited.count()).ok_or_else(|| {
1088            crate::Error::Corruption("ranked matches exceed exact cardinality".into())
1089        })?;
1090        collector.collect_omitted_count(omitted);
1091    } else {
1092        drive_collected(scorer.as_mut(), collector, map, None);
1093    }
1094    reader.check_posting_integrity()
1095}
1096
1097/// Execute a query with one or more collectors and a specific limit (async)
1098///
1099/// The limit is passed to the scorer to enable MaxScore pruning for queries
1100/// that support it (e.g., sparse vector search). This significantly improves
1101/// performance when only the top-k results are needed.
1102///
1103/// Doc IDs in the collector are segment-local. The searcher stamps each result
1104/// with its segment_id, making (segment_id, doc_id) the unique document key.
1105pub async fn collect_segment_with_limit<C: Collector>(
1106    reader: &SegmentReader,
1107    query: &dyn Query,
1108    collector: &mut C,
1109    limit: usize,
1110) -> Result<()> {
1111    collect_segment_with_limit_seeded(reader, query, collector, limit, 0.0).await
1112}
1113
1114/// Async `collect_segment_with_limit` with a cross-segment threshold seed.
1115///
1116/// `initial_threshold` is passed to the scorer so exact MaxScore/BMP paths can
1117/// start pruning from a nonzero floor carried over from earlier segments.
1118pub async fn collect_segment_with_limit_seeded<C: Collector>(
1119    reader: &SegmentReader,
1120    query: &dyn Query,
1121    collector: &mut C,
1122    limit: usize,
1123    initial_threshold: f32,
1124) -> Result<()> {
1125    reader.check_posting_integrity()?;
1126    let mut options = super::ScorerOptions {
1127        physical_text_field: None,
1128        complete_text_matches: false,
1129        ranked_count_limit: None,
1130        skip_scoring_setup: !collector.needs_scores() && !collector.needs_positions(),
1131        eligibility: reader.alive_docs(),
1132        collect_positions: collector.needs_positions(),
1133        initial_threshold,
1134        shared_threshold: None,
1135        lsp_plan: None,
1136        global_stats: None,
1137    };
1138    let map = super::text_mapping::prepare(reader, query, &mut options, false);
1139    let scorer = query.scorer_with_options(reader, limit, options).await?;
1140    let mut scorer = super::text_mapping::filtered(scorer, reader.alive_docs(), map);
1141    drive_collected(scorer.as_mut(), collector, map, None);
1142    reader.check_posting_integrity()
1143}
1144
1145/// Drive a scorer through a collector (shared by async and sync paths).
1146fn drive_scorer<C: Collector>(scorer: &mut dyn super::Scorer, collector: &mut C) {
1147    drive_scorer_budgeted(scorer, collector, None);
1148}
1149
1150/// Scorers remain sorted in their field-local domain; only the collector sees
1151/// stable IDs. Heap ties and position admission therefore use logical IDs.
1152struct MappedCollector<'a, C> {
1153    inner: &'a mut C,
1154    map: &'a crate::segment::chunk_map::ChunkMap,
1155}
1156
1157impl<C: Collector> Collector for MappedCollector<'_, C> {
1158    fn collect(&mut self, doc: DocId, score: Score, positions: &[(u32, Vec<ScoredPosition>)]) {
1159        self.inner.collect(self.map.doc_id(doc), score, positions);
1160    }
1161    fn collect_owned(&mut self, doc: DocId, score: Score, positions: super::MatchedPositions) {
1162        self.inner
1163            .collect_owned(self.map.doc_id(doc), score, positions);
1164    }
1165    fn would_collect(&self, doc: DocId, score: Score) -> bool {
1166        self.inner.would_collect(self.map.doc_id(doc), score)
1167    }
1168    fn collect_count(&mut self, count: u64) -> bool {
1169        self.inner.collect_count(count)
1170    }
1171    fn needs_scores(&self) -> bool {
1172        self.inner.needs_scores()
1173    }
1174    fn needs_positions(&self) -> bool {
1175        self.inner.needs_positions()
1176    }
1177}
1178
1179fn drive_collected<C: Collector>(
1180    scorer: &mut dyn super::Scorer,
1181    collector: &mut C,
1182    map: Option<&crate::segment::chunk_map::ChunkMap>,
1183    budget: Option<&super::SharedThreshold>,
1184) {
1185    if let Some(map) = map {
1186        drive_scorer_budgeted(
1187            scorer,
1188            &mut MappedCollector {
1189                inner: collector,
1190                map,
1191            },
1192            budget,
1193        );
1194    } else {
1195        drive_scorer_budgeted(scorer, collector, budget);
1196    }
1197}
1198
1199fn drive_scorer_budgeted<C: Collector>(
1200    scorer: &mut dyn super::Scorer,
1201    collector: &mut C,
1202    budget: Option<&super::SharedThreshold>,
1203) {
1204    let needs_positions = collector.needs_positions();
1205    let needs_scores = collector.needs_scores() || needs_positions;
1206    let mut doc = scorer.doc();
1207    if needs_scores && !needs_positions && scorer.supports_score_windows() {
1208        let mut scores = Box::new([0.0; super::docset::DOC_WINDOW_SIZE as usize]);
1209        let mut bits = [0; super::docset::DOC_WINDOW_WORDS];
1210        let collect_blocks = budget.and_then(super::SharedThreshold::deadline).is_none()
1211            && collector.supports_score_blocks();
1212        while doc != TERMINATED {
1213            if budget.is_some_and(super::SharedThreshold::stop_if_expired) {
1214                break;
1215            }
1216            scorer.fill_score_window(doc, &mut scores, &mut bits);
1217            if budget.is_some_and(super::SharedThreshold::stop_if_expired) {
1218                break;
1219            }
1220            if collect_blocks {
1221                for (index, (scores, &word)) in
1222                    scores.as_chunks::<64>().0.iter().zip(&bits).enumerate()
1223                {
1224                    if word != 0 {
1225                        collector.collect_score_block(doc + index as u32 * 64, scores, word);
1226                    }
1227                }
1228                doc = scorer.doc();
1229                continue;
1230            }
1231            for (index, &word) in bits.iter().enumerate() {
1232                let mut remaining = word;
1233                while remaining != 0 {
1234                    if budget.is_some_and(super::SharedThreshold::stop_if_expired) {
1235                        return;
1236                    }
1237                    let offset = index * 64 + remaining.trailing_zeros() as usize;
1238                    collector.collect(doc + offset as u32, scores[offset], &[]);
1239                    remaining &= remaining - 1;
1240                }
1241            }
1242            doc = scorer.doc();
1243        }
1244        return;
1245    }
1246    if needs_scores && !needs_positions && scorer.supports_score_batches() {
1247        let mut docs = [0; super::docset::DOC_BATCH_SIZE];
1248        let mut scores = [0.0; super::docset::DOC_BATCH_SIZE];
1249        while doc != TERMINATED {
1250            if budget.is_some_and(super::SharedThreshold::stop_if_expired) {
1251                break;
1252            }
1253            let count = scorer.fill_score_batch(&mut docs, &mut scores);
1254            if budget.is_some_and(super::SharedThreshold::stop_if_expired) {
1255                break;
1256            }
1257            for i in 0..count {
1258                if budget.is_some_and(super::SharedThreshold::stop_if_expired) {
1259                    return;
1260                }
1261                collector.collect(docs[i], scores[i], &[]);
1262            }
1263            doc = scorer.doc();
1264        }
1265        return;
1266    }
1267    if !needs_scores && scorer.supports_doc_windows() {
1268        let mut bits = [0; super::docset::DOC_WINDOW_WORDS];
1269        while doc != TERMINATED {
1270            if budget.is_some_and(super::SharedThreshold::stop_if_expired) {
1271                break;
1272            }
1273            scorer.fill_doc_window(doc, &mut bits);
1274            if budget.is_some_and(super::SharedThreshold::stop_if_expired) {
1275                break;
1276            }
1277            let count = bits.iter().map(|word| u64::from(word.count_ones())).sum();
1278            if !collector.collect_count(count) {
1279                for (index, &word) in bits.iter().enumerate() {
1280                    let mut remaining = word;
1281                    while remaining != 0 {
1282                        if budget.is_some_and(super::SharedThreshold::stop_if_expired) {
1283                            return;
1284                        }
1285                        let offset = index as u32 * 64 + remaining.trailing_zeros();
1286                        collector.collect(doc + offset, 0.0, &[]);
1287                        remaining &= remaining - 1;
1288                    }
1289                }
1290            }
1291            doc = scorer.doc();
1292        }
1293        return;
1294    }
1295    if !needs_scores && !needs_positions && scorer.supports_doc_batches() {
1296        let mut docs = [0; super::docset::DOC_BATCH_SIZE];
1297        while doc != TERMINATED {
1298            if budget.is_some_and(super::SharedThreshold::stop_if_expired) {
1299                break;
1300            }
1301            let count = scorer.fill_doc_batch(&mut docs);
1302            if budget.is_some_and(super::SharedThreshold::stop_if_expired) {
1303                break;
1304            }
1305            if !collector.collect_count(count as u64) {
1306                for &doc in &docs[..count] {
1307                    if budget.is_some_and(super::SharedThreshold::stop_if_expired) {
1308                        return;
1309                    }
1310                    collector.collect(doc, 0.0, &[]);
1311                }
1312            }
1313            doc = scorer.doc();
1314        }
1315        return;
1316    }
1317    while doc != TERMINATED {
1318        // Check after advance/seek too: an expired negative verifier must
1319        // never turn an incomplete exclusion check into a collected hit.
1320        if budget.is_some_and(super::SharedThreshold::stop_if_expired) {
1321            break;
1322        }
1323        let score = if needs_scores { scorer.score() } else { 0.0 };
1324        if budget.is_some_and(super::SharedThreshold::stop_if_expired) {
1325            break;
1326        }
1327        if needs_positions && collector.would_collect(doc, score) {
1328            let positions = scorer.matched_positions().unwrap_or_default();
1329            if budget.is_some_and(super::SharedThreshold::stop_if_expired) {
1330                break;
1331            }
1332            collector.collect_owned(doc, score, positions);
1333        } else {
1334            collector.collect(doc, score, &[]);
1335        }
1336        doc = scorer.advance();
1337    }
1338}
1339
1340// ── Synchronous collector functions (mmap/RAM only) ─────────────────────────
1341
1342/// Synchronous segment search — returns (results, total_seen).
1343#[cfg(feature = "sync")]
1344pub fn search_segment_with_count_sync(
1345    reader: &SegmentReader,
1346    query: &dyn Query,
1347    limit: usize,
1348) -> Result<(Vec<SearchResult>, u32)> {
1349    let segment_limit = limit.min(reader.num_docs() as usize);
1350    let mut collector = TopKCollector::new(segment_limit);
1351    collect_segment_with_limit_sync(reader, query, &mut collector, segment_limit)?;
1352    Ok(collector.into_results_with_count())
1353}
1354
1355/// Synchronous segment search with positions — returns (results, total_seen).
1356#[cfg(feature = "sync")]
1357pub fn search_segment_with_positions_and_count_sync(
1358    reader: &SegmentReader,
1359    query: &dyn Query,
1360    limit: usize,
1361) -> Result<(Vec<SearchResult>, u32)> {
1362    let segment_limit = limit.min(reader.num_docs() as usize);
1363    let mut collector = TopKCollector::with_positions(segment_limit);
1364    collect_segment_with_limit_sync(reader, query, &mut collector, segment_limit)?;
1365    Ok(collector.into_results_with_count())
1366}
1367
1368/// Synchronous collect with limit — uses `scorer_sync`.
1369#[cfg(feature = "sync")]
1370pub fn collect_segment_with_limit_sync<C: Collector>(
1371    reader: &SegmentReader,
1372    query: &dyn Query,
1373    collector: &mut C,
1374    limit: usize,
1375) -> Result<()> {
1376    collect_segment_with_limit_seeded_sync(reader, query, collector, limit, 0.0)
1377}
1378
1379/// Synchronous `collect_segment_with_limit_sync` with a cross-segment threshold
1380/// seed (see `collect_segment_with_limit_seeded`).
1381#[cfg(feature = "sync")]
1382pub fn collect_segment_with_limit_seeded_sync<C: Collector>(
1383    reader: &SegmentReader,
1384    query: &dyn Query,
1385    collector: &mut C,
1386    limit: usize,
1387    initial_threshold: f32,
1388) -> Result<()> {
1389    reader.check_posting_integrity()?;
1390    let mut options = super::ScorerOptions {
1391        physical_text_field: None,
1392        complete_text_matches: false,
1393        ranked_count_limit: None,
1394        skip_scoring_setup: !collector.needs_scores() && !collector.needs_positions(),
1395        eligibility: reader.alive_docs(),
1396        collect_positions: collector.needs_positions(),
1397        initial_threshold,
1398        shared_threshold: None,
1399        lsp_plan: None,
1400        global_stats: None,
1401    };
1402    let map = super::text_mapping::prepare(reader, query, &mut options, false);
1403    let scorer = query.scorer_sync_with_options(reader, limit, options)?;
1404    let mut scorer = super::text_mapping::filtered(scorer, reader.alive_docs(), map);
1405    drive_collected(scorer.as_mut(), collector, map, None);
1406    reader.check_posting_integrity()
1407}
1408
1409/// Per-segment search seeded with a cross-segment top-k floor (sync).
1410///
1411/// Behaves like `search_segment_with_count_sync` / its positions variant, but
1412/// threads `initial_threshold` into the scorer so exact MaxScore/BMP paths
1413/// prune from the running global k-th score. Used by the multi-segment
1414/// searcher to propagate the threshold across segments.
1415#[cfg(feature = "sync")]
1416pub fn search_segment_seeded_sync(
1417    reader: &SegmentReader,
1418    query: &dyn Query,
1419    limit: usize,
1420    collect_positions: bool,
1421    initial_threshold: f32,
1422) -> Result<(Vec<SearchResult>, u32)> {
1423    let segment_limit = limit.min(reader.num_docs() as usize);
1424    let mut collector = if collect_positions {
1425        TopKCollector::with_positions(segment_limit)
1426    } else {
1427        TopKCollector::new(segment_limit)
1428    };
1429    collect_segment_with_limit_seeded_sync(
1430        reader,
1431        query,
1432        &mut collector,
1433        segment_limit,
1434        initial_threshold,
1435    )?;
1436    Ok(collector.into_results_with_count())
1437}
1438
1439/// Per-segment search with a live cross-segment top-k floor (sync).
1440#[cfg(feature = "sync")]
1441pub fn search_segment_shared_sync(
1442    reader: &SegmentReader,
1443    query: &dyn Query,
1444    limit: usize,
1445    collect_positions: bool,
1446    shared_threshold: super::SharedThreshold,
1447) -> Result<(Vec<SearchResult>, u32)> {
1448    search_segment_shared_sync_planned(
1449        reader,
1450        query,
1451        limit,
1452        collect_positions,
1453        shared_threshold,
1454        None,
1455        None,
1456    )
1457}
1458
1459/// Per-segment search with a live threshold and a query-global LSP/0 plan.
1460#[cfg(feature = "sync")]
1461pub(crate) fn search_segment_shared_sync_planned(
1462    reader: &SegmentReader,
1463    query: &dyn Query,
1464    limit: usize,
1465    collect_positions: bool,
1466    shared_threshold: super::SharedThreshold,
1467    lsp_plan: Option<std::sync::Arc<super::bmp::LspSegmentPlan>>,
1468    global_stats: Option<std::sync::Arc<super::GlobalStats>>,
1469) -> Result<(Vec<SearchResult>, u32)> {
1470    reader.check_posting_integrity()?;
1471    let segment_limit = limit.min(reader.num_docs() as usize);
1472    let mut options = super::ScorerOptions {
1473        physical_text_field: None,
1474        complete_text_matches: false,
1475        ranked_count_limit: None,
1476        skip_scoring_setup: false,
1477        eligibility: reader.alive_docs(),
1478        collect_positions,
1479        initial_threshold: shared_threshold.get(),
1480        shared_threshold: Some(shared_threshold.clone()),
1481        lsp_plan,
1482        global_stats,
1483    };
1484    let map = super::text_mapping::prepare(reader, query, &mut options, false);
1485    let scorer = query.scorer_sync_with_options(reader, segment_limit, options)?;
1486    let mut scorer = super::text_mapping::filtered(scorer, reader.alive_docs(), map);
1487    let results = top_k_from_mapped_scorer(
1488        scorer.as_mut(),
1489        segment_limit,
1490        collect_positions,
1491        Some(&shared_threshold),
1492        map,
1493    );
1494    reader.check_posting_integrity()?;
1495    Ok(results)
1496}
1497
1498/// Collect a segment's top-k from a freshly built top-level scorer.
1499///
1500/// Scorers wrapping an already ranked list (text and vector executors) hand it over
1501/// through [`Scorer::precomputed_top_k`]; everything else is driven through a
1502/// `TopKCollector`. Candidate bounds may avoid confirmation of noncompetitive
1503/// matches. `total_seen` counts confirmed, scored matches, not an exact total.
1504#[cfg(test)]
1505fn top_k_from_scorer(
1506    scorer: &mut dyn super::Scorer,
1507    segment_limit: usize,
1508    collect_positions: bool,
1509    budget: Option<&super::SharedThreshold>,
1510) -> (Vec<SearchResult>, u32) {
1511    top_k_from_mapped_scorer(scorer, segment_limit, collect_positions, budget, None)
1512}
1513
1514fn top_k_from_mapped_scorer(
1515    scorer: &mut dyn super::Scorer,
1516    segment_limit: usize,
1517    collect_positions: bool,
1518    budget: Option<&super::SharedThreshold>,
1519    map: Option<&crate::segment::chunk_map::ChunkMap>,
1520) -> (Vec<SearchResult>, u32) {
1521    // A physical-ID cutoff could discard the winner of a stable-ID tie.
1522    // Take the entire bounded retained list before translating and truncating;
1523    // the precomputed scorer owns at most its original query limit in hits.
1524    let handoff_limit = map.map_or(segment_limit, |map| map.num_chunks() as usize);
1525    if let Some((mut results, seen)) = scorer.precomputed_top_k(handoff_limit, collect_positions) {
1526        if let Some(map) = map {
1527            for result in &mut results {
1528                result.doc_id = map.doc_id(result.doc_id);
1529            }
1530            results.sort_unstable_by(|a, b| {
1531                b.score
1532                    .total_cmp(&a.score)
1533                    .then_with(|| a.doc_id.cmp(&b.doc_id))
1534            });
1535            results.truncate(segment_limit);
1536        }
1537        return (results, seen);
1538    }
1539    let mut collector = if collect_positions {
1540        TopKCollector::with_positions(segment_limit)
1541    } else {
1542        TopKCollector::new(segment_limit)
1543    };
1544    if segment_limit > 0 && scorer.supports_candidate_score_bounds() {
1545        drive_ranked_candidates(scorer, &mut collector, budget, map);
1546    } else {
1547        drive_collected(scorer, &mut collector, map, budget);
1548    }
1549    collector.into_results_with_count()
1550}
1551
1552/// The existing top-k heap owns competitive decisions. Complete and arbitrary
1553/// collectors never enter this driver, so bounds cannot truncate their counts.
1554fn drive_ranked_candidates(
1555    scorer: &mut dyn super::Scorer,
1556    collector: &mut TopKCollector,
1557    budget: Option<&super::SharedThreshold>,
1558    map: Option<&crate::segment::chunk_map::ChunkMap>,
1559) {
1560    let mut doc = scorer.doc();
1561    let mut next_block_check = 0;
1562    let mut confirmations = 0usize;
1563    let mut seed_attempted = false;
1564    let mut seeded_floor = Score::NEG_INFINITY;
1565    while doc != TERMINATED {
1566        if budget.is_some_and(super::SharedThreshold::stop_if_expired) {
1567            break;
1568        }
1569        if doc >= next_block_check && (collector.is_full() || seeded_floor.is_finite()) {
1570            if let Some((last, bound)) = scorer.candidate_block_upper_bound() {
1571                next_block_check = last.saturating_add(1);
1572                // ID zero is the best possible stable-ID tie, including mapped
1573                // physical order. Reject only when no document can enter the heap.
1574                if bound < seeded_floor || !collector.would_collect(0, bound) {
1575                    doc = scorer.seek_candidate(next_block_check);
1576                    continue;
1577                }
1578            } else {
1579                next_block_check = TERMINATED;
1580            }
1581        }
1582        let result_doc = map.map_or(doc, |map| map.doc_id(doc));
1583        // Skip the bound computation while the heap is still filling.
1584        let competitive = if collector.is_full() || seeded_floor.is_finite() {
1585            let bound = scorer.candidate_score_upper_bound();
1586            bound >= seeded_floor && collector.would_collect(result_doc, bound)
1587        } else {
1588            true
1589        };
1590        if budget.is_some_and(super::SharedThreshold::stop_if_expired) {
1591            break;
1592        }
1593        confirmations += usize::from(competitive);
1594        if competitive && scorer.confirm_candidate() {
1595            if budget.is_some_and(super::SharedThreshold::stop_if_expired) {
1596                break;
1597            }
1598            let score = scorer.score();
1599            if budget.is_some_and(super::SharedThreshold::stop_if_expired) {
1600                break;
1601            }
1602            if collector.needs_positions() && collector.would_collect(result_doc, score) {
1603                let positions = scorer.matched_positions().unwrap_or_default();
1604                if budget.is_some_and(super::SharedThreshold::stop_if_expired) {
1605                    break;
1606                }
1607                collector.collect_owned(result_doc, score, positions);
1608            } else {
1609                collector.collect(result_doc, score, &[]);
1610            }
1611        }
1612        if map.is_some() && confirmations >= 1024 && !seed_attempted {
1613            seed_attempted = true;
1614            if let Some(score) = scorer.seed_ranked_score(collector.k)
1615                && score.is_finite()
1616            {
1617                seeded_floor = score;
1618            }
1619        }
1620        // Without a mapping, monotone IDs after this candidate cannot replace
1621        // an equal-score ID already in the full local heap. Physical RGB order
1622        // does not establish that relationship, so retain equality there.
1623        doc = scorer.advance_competitive_candidate(
1624            collector.competitive_score().max(seeded_floor),
1625            map.is_some(),
1626        );
1627    }
1628}
1629
1630/// Per-segment search seeded with a cross-segment top-k floor (async).
1631pub async fn search_segment_seeded(
1632    reader: &SegmentReader,
1633    query: &dyn Query,
1634    limit: usize,
1635    collect_positions: bool,
1636    initial_threshold: f32,
1637) -> Result<(Vec<SearchResult>, u32)> {
1638    let segment_limit = limit.min(reader.num_docs() as usize);
1639    let mut collector = if collect_positions {
1640        TopKCollector::with_positions(segment_limit)
1641    } else {
1642        TopKCollector::new(segment_limit)
1643    };
1644    collect_segment_with_limit_seeded(
1645        reader,
1646        query,
1647        &mut collector,
1648        segment_limit,
1649        initial_threshold,
1650    )
1651    .await?;
1652    Ok(collector.into_results_with_count())
1653}
1654
1655/// Per-segment search with a live cross-segment top-k floor (async).
1656pub async fn search_segment_shared(
1657    reader: &SegmentReader,
1658    query: &dyn Query,
1659    limit: usize,
1660    collect_positions: bool,
1661    shared_threshold: super::SharedThreshold,
1662) -> Result<(Vec<SearchResult>, u32)> {
1663    search_segment_shared_planned(
1664        reader,
1665        query,
1666        limit,
1667        collect_positions,
1668        shared_threshold,
1669        None,
1670        None,
1671    )
1672    .await
1673}
1674
1675/// Async per-segment search with a query-global LSP/0 plan.
1676pub(crate) async fn search_segment_shared_planned(
1677    reader: &SegmentReader,
1678    query: &dyn Query,
1679    limit: usize,
1680    collect_positions: bool,
1681    shared_threshold: super::SharedThreshold,
1682    lsp_plan: Option<std::sync::Arc<super::bmp::LspSegmentPlan>>,
1683    global_stats: Option<std::sync::Arc<super::GlobalStats>>,
1684) -> Result<(Vec<SearchResult>, u32)> {
1685    reader.check_posting_integrity()?;
1686    let segment_limit = limit.min(reader.num_docs() as usize);
1687    let mut options = super::ScorerOptions {
1688        physical_text_field: None,
1689        complete_text_matches: false,
1690        ranked_count_limit: None,
1691        skip_scoring_setup: false,
1692        eligibility: reader.alive_docs(),
1693        collect_positions,
1694        initial_threshold: shared_threshold.get(),
1695        shared_threshold: Some(shared_threshold.clone()),
1696        lsp_plan,
1697        global_stats,
1698    };
1699    let map = super::text_mapping::prepare(reader, query, &mut options, false);
1700    let scorer = query
1701        .scorer_with_options(reader, segment_limit, options)
1702        .await?;
1703    let mut scorer = super::text_mapping::filtered(scorer, reader.alive_docs(), map);
1704    let results = top_k_from_mapped_scorer(
1705        scorer.as_mut(),
1706        segment_limit,
1707        collect_positions,
1708        Some(&shared_threshold),
1709        map,
1710    );
1711    reader.check_posting_integrity()?;
1712    Ok(results)
1713}
1714
1715// Borrowed candidate lists clone only after their retained-output budget is checked.
1716impl From<&SearchResult> for SearchResult {
1717    fn from(value: &SearchResult) -> Self {
1718        value.clone()
1719    }
1720}
1721
1722#[cfg(test)]
1723mod tests {
1724    #[test]
1725    fn packed_score_heap_preserves_total_order_and_exact_bits() {
1726        let mut bits = vec![
1727            0, 1, 0x7f800000, 0xff800000, 0x80000000, 0x7fc00000, 0xffc00000, 0x7fffffff,
1728            0xffffffff, 0x7f800001, 0xff800001,
1729        ];
1730        let mut state = 317u32;
1731        for _ in 0..2048 {
1732            state = state.wrapping_mul(1664525).wrapping_add(1013904223);
1733            bits.push(state);
1734        }
1735        let mut actual = Vec::new();
1736        let mut expected = Vec::new();
1737        for &b in &bits {
1738            for doc in [0, 1, 713, u32::MAX] {
1739                let entry = super::ScoreOnlyResult::new(doc, f32::from_bits(b));
1740                assert_eq!((entry.doc_id(), entry.score().to_bits()), (doc, b));
1741                actual.push(entry);
1742                expected.push((doc, f32::from_bits(b)));
1743            }
1744        }
1745        actual.sort_unstable();
1746        expected.sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
1747        assert_eq!(
1748            actual
1749                .iter()
1750                .map(|x| (x.doc_id(), x.score().to_bits()))
1751                .collect::<Vec<_>>(),
1752            expected
1753                .iter()
1754                .map(|x| (x.0, x.1.to_bits()))
1755                .collect::<Vec<_>>()
1756        );
1757        assert_eq!(std::mem::size_of::<super::ScoreOnlyResult>(), 8);
1758    }
1759
1760    use super::*;
1761
1762    struct BoundedCandidates {
1763        hits: Vec<(DocId, Score, Score, bool)>,
1764        at: usize,
1765        confirmations: std::sync::Arc<std::sync::Mutex<Vec<DocId>>>,
1766        scores: std::sync::Arc<std::sync::atomic::AtomicUsize>,
1767        bounds: std::sync::Arc<std::sync::atomic::AtomicUsize>,
1768        pause_confirmation: bool,
1769        seed_calls: usize,
1770    }
1771
1772    impl BoundedCandidates {
1773        fn fixture() -> Self {
1774            Self {
1775                hits: vec![
1776                    (0, 5.0, 5.0, true),
1777                    (1, 0.0, 9.0, false),
1778                    (2, 1.0, 1.0, true),
1779                    (3, 7.0, 7.0, true),
1780                    (4, 7.0, 7.0, true),
1781                    (5, 9.0, 9.0, true),
1782                ],
1783                at: 0,
1784                confirmations: Default::default(),
1785                scores: Default::default(),
1786                bounds: Default::default(),
1787                pause_confirmation: false,
1788                seed_calls: 0,
1789            }
1790        }
1791    }
1792
1793    impl super::super::DocSet for BoundedCandidates {
1794        fn doc(&self) -> DocId {
1795            self.hits.get(self.at).map_or(TERMINATED, |hit| hit.0)
1796        }
1797        fn advance(&mut self) -> DocId {
1798            self.at += 1;
1799            while self.at < self.hits.len() && !super::super::Scorer::confirm_candidate(self) {
1800                self.at += 1;
1801            }
1802            self.doc()
1803        }
1804        fn size_hint(&self) -> u32 {
1805            self.hits.len() as u32
1806        }
1807    }
1808
1809    impl super::super::Scorer for BoundedCandidates {
1810        fn seed_ranked_score(&mut self, limit: usize) -> Option<Score> {
1811            self.seed_calls += 1;
1812            let mut scores: Vec<_> = self.hits[self.at + 1..]
1813                .iter()
1814                .filter(|hit| hit.3)
1815                .map(|hit| hit.1)
1816                .collect();
1817            scores.sort_unstable_by(|a, b| b.total_cmp(a));
1818            scores.get(limit - 1).copied()
1819        }
1820        fn supports_candidate_score_bounds(&self) -> bool {
1821            true
1822        }
1823        fn candidate_score_upper_bound(&self) -> Score {
1824            self.bounds
1825                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1826            self.hits[self.at].2
1827        }
1828        fn advance_candidate(&mut self) -> DocId {
1829            self.at += 1;
1830            super::super::DocSet::doc(self)
1831        }
1832        fn confirm_candidate(&mut self) -> bool {
1833            self.confirmations
1834                .lock()
1835                .unwrap()
1836                .push(self.hits[self.at].0);
1837            if self.pause_confirmation {
1838                std::thread::sleep(std::time::Duration::from_millis(25));
1839            }
1840            self.hits[self.at].3
1841        }
1842        fn score(&self) -> Score {
1843            assert!(self.hits[self.at].3);
1844            self.scores
1845                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1846            self.hits[self.at].1
1847        }
1848        fn matched_positions(&self) -> Option<super::super::MatchedPositions> {
1849            Some(vec![(
1850                7,
1851                vec![ScoredPosition::new(
1852                    self.hits[self.at].0,
1853                    self.hits[self.at].1,
1854                )],
1855            )])
1856        }
1857    }
1858
1859    #[test]
1860    fn mapped_score_seeding_keeps_late_equal_score_winners_and_underfilled_heaps() {
1861        use crate::directories::OwnedBytes;
1862        use crate::segment::chunk_map::{ChunkMapBuilder, read_chunk_maps, write_chunk_maps};
1863        let mut map = ChunkMapBuilder::default();
1864        map.set_document_units(true);
1865        for doc in 0..2048 {
1866            map.push(2047 - doc, 0, 100).unwrap();
1867        }
1868        let mut bytes = Vec::new();
1869        write_chunk_maps(&mut bytes, &[(0, &map)], &[]).unwrap();
1870        let maps = read_chunk_maps(OwnedBytes::new(bytes)).unwrap();
1871        for prefix_matches in [false, true] {
1872            let mut scorer = BoundedCandidates::fixture();
1873            scorer.hits = (0..2048)
1874                .map(|doc| {
1875                    if doc < 2000 {
1876                        (doc, 1.0, 1.5, prefix_matches)
1877                    } else {
1878                        (doc, 2.0, 2.0, true)
1879                    }
1880                })
1881                .collect();
1882            let (hits, _) =
1883                top_k_from_mapped_scorer(&mut scorer, 10, true, None, Some(&maps.chunk_maps[&0]));
1884            assert_eq!(
1885                hits.iter()
1886                    .map(|hit| (hit.doc_id, hit.score))
1887                    .collect::<Vec<_>>(),
1888                (0..10).map(|doc| (doc, 2.0)).collect::<Vec<_>>()
1889            );
1890            assert_eq!(scorer.seed_calls, 1);
1891            assert_eq!(scorer.confirmations.lock().unwrap().len(), 1072);
1892        }
1893        let mut scorer = BoundedCandidates::fixture();
1894        scorer.hits = (0..2048)
1895            .map(|doc| (doc, if doc < 2000 { 1.0 } else { 2.0 }, 2.0, true))
1896            .collect();
1897        let mut filtered = super::super::PredicatedScorer::new(
1898            Box::new(scorer),
1899            vec![Box::new(|doc| doc < 2000)],
1900            Vec::new(),
1901            Vec::new(),
1902        );
1903        // A child proof may contain documents rejected by its wrapper.
1904        assert_eq!(
1905            super::super::Scorer::seed_ranked_score(&mut filtered, 10),
1906            None
1907        );
1908        let (hits, _) =
1909            top_k_from_mapped_scorer(&mut filtered, 10, false, None, Some(&maps.chunk_maps[&0]));
1910        assert_eq!(
1911            hits.iter()
1912                .map(|hit| (hit.doc_id, hit.score))
1913                .collect::<Vec<_>>(),
1914            (48..58).map(|doc| (doc, 1.0)).collect::<Vec<_>>()
1915        );
1916    }
1917
1918    #[tokio::test]
1919    async fn two_term_union_counts_preserve_overlap_duplicates_fields_and_declining_collectors() {
1920        use crate::query::{BooleanQuery, TermQuery};
1921        use crate::segment::{SegmentBuilder, SegmentBuilderConfig, SegmentId};
1922        use crate::structures::PostingCodec;
1923        use std::sync::Arc;
1924        struct Ids(Vec<u32>);
1925        impl Collector for Ids {
1926            fn needs_scores(&self) -> bool {
1927                false
1928            }
1929            fn collect(&mut self, doc: u32, _: f32, _: &[(u32, Vec<ScoredPosition>)]) {
1930                self.0.push(doc);
1931            }
1932        }
1933        for codec in [
1934            PostingCodec::Rounded,
1935            PostingCodec::Packed,
1936            PostingCodec::Pfor,
1937            PostingCodec::Simd4x,
1938        ] {
1939            let dir = crate::RamDirectory::new();
1940            let mut schema = crate::SchemaBuilder::default();
1941            let a = schema.add_text_field("a", true, false);
1942            let b = schema.add_text_field("b", true, false);
1943            let schema = Arc::new(schema.build());
1944            let mut builder = SegmentBuilder::new(
1945                schema.clone(),
1946                SegmentBuilderConfig {
1947                    posting_codec: codec,
1948                    ..Default::default()
1949                },
1950            )
1951            .unwrap();
1952            for i in 0..1701 {
1953                let mut doc = crate::Document::new();
1954                doc.add_text(
1955                    a,
1956                    match (i % 2 == 0, i % 3 == 0) {
1957                        (true, true) => "alpha beta alpha",
1958                        (true, false) => "alpha",
1959                        (false, true) => "beta",
1960                        _ => "padding",
1961                    },
1962                );
1963                doc.add_text(b, if i % 5 == 0 { "alpha" } else { "padding" });
1964                builder.add_document(doc).unwrap();
1965            }
1966            let id = SegmentId::new();
1967            builder.build(&dir, id, None).await.unwrap();
1968            let mut reader = SegmentReader::open(&dir, id, schema, 16).await.unwrap();
1969            let queries = [
1970                (
1971                    BooleanQuery::new()
1972                        .should(TermQuery::text(a, "alpha"))
1973                        .should(TermQuery::text(a, "beta")),
1974                    (0..1701)
1975                        .filter(|i| i % 2 == 0 || i % 3 == 0)
1976                        .collect::<Vec<u32>>(),
1977                    true,
1978                ),
1979                (
1980                    BooleanQuery::new()
1981                        .should(TermQuery::text(a, "alpha"))
1982                        .should(TermQuery::text(a, "alpha")),
1983                    (0..1701).filter(|i| i % 2 == 0).collect(),
1984                    true,
1985                ),
1986                (
1987                    BooleanQuery::new()
1988                        .should(TermQuery::text(a, "alpha"))
1989                        .should(TermQuery::text(b, "alpha")),
1990                    (0..1701).filter(|i| i % 2 == 0 || i % 5 == 0).collect(),
1991                    true,
1992                ),
1993                (
1994                    BooleanQuery::new()
1995                        .should(TermQuery::text(a, "alpha"))
1996                        .should(TermQuery::text(a, "missing")),
1997                    (0..1701).filter(|i| i % 2 == 0).collect(),
1998                    false,
1999                ),
2000                (
2001                    BooleanQuery::new()
2002                        .should(TermQuery::text(a, "missing"))
2003                        .should(TermQuery::text(b, "missing")),
2004                    Vec::new(),
2005                    false,
2006                ),
2007            ];
2008            for (query, expected, admitted) in &queries {
2009                let known = two_term_union_count(&reader, query, 0).await.unwrap();
2010                assert_eq!(known, admitted.then_some(expected.len() as u64));
2011                let mut count = CountCollector::new();
2012                collect_segment(&reader, query, &mut count).await.unwrap();
2013                assert_eq!(count.count(), expected.len() as u64);
2014                let mut ids = Ids(Vec::new());
2015                collect_segment(&reader, query, &mut ids).await.unwrap();
2016                assert_eq!(&ids.0, expected);
2017            }
2018            let mut alive = crate::query::DocBitset::all(1701);
2019            alive.clear(0);
2020            alive.clear(6);
2021            let deletion = crate::segment::deletion::write(&dir, SegmentId::new(), 1701, &alive)
2022                .await
2023                .unwrap();
2024            reader.load_deletions(&dir, deletion).await.unwrap();
2025            for (query, expected, _) in &queries {
2026                assert_eq!(two_term_union_count(&reader, query, 0).await.unwrap(), None);
2027                let expected = expected.iter().filter(|&&d| d != 0 && d != 6).count() as u64;
2028                let mut count = CountCollector::new();
2029                collect_segment(&reader, query, &mut count).await.unwrap();
2030                assert_eq!(count.count(), expected);
2031            }
2032        }
2033    }
2034
2035    #[tokio::test]
2036    async fn union_count_metadata_shortcut_declines_mapped_and_fast_only_fields() {
2037        use crate::query::{BooleanQuery, TermQuery};
2038        use crate::segment::{SegmentBuilder, SegmentBuilderConfig, SegmentId};
2039        use std::sync::Arc;
2040        for chunked in [false, true] {
2041            let dir = crate::RamDirectory::new();
2042            let mut schema = crate::SchemaBuilder::default();
2043            let field = schema.add_text_field("text", chunked, false);
2044            if chunked {
2045                schema.set_chunked(field, true);
2046            } else {
2047                schema.set_fast(field, true);
2048            }
2049            let schema = Arc::new(schema.build());
2050            let mut builder =
2051                SegmentBuilder::new(schema.clone(), SegmentBuilderConfig::default()).unwrap();
2052            for values in [vec!["alpha", "beta", "alpha"], vec!["alpha"], vec!["gamma"]] {
2053                let mut doc = crate::Document::new();
2054                if chunked {
2055                    for value in values {
2056                        doc.add_text(field, value);
2057                    }
2058                } else {
2059                    doc.add_text(field, values[0]);
2060                }
2061                builder.add_document(doc).unwrap();
2062            }
2063            let id = SegmentId::new();
2064            builder.build(&dir, id, None).await.unwrap();
2065            let reader = SegmentReader::open(&dir, id, schema, 16).await.unwrap();
2066            let query = BooleanQuery::new()
2067                .should(TermQuery::text(field, "alpha"))
2068                .should(TermQuery::text(field, "beta"));
2069            assert_eq!(
2070                two_term_union_count(&reader, &query, 0).await.unwrap(),
2071                None
2072            );
2073            let mut count = CountCollector::new();
2074            collect_segment(&reader, &query, &mut count).await.unwrap();
2075            assert_eq!(count.count(), 2);
2076        }
2077    }
2078
2079    #[tokio::test]
2080    async fn fast_only_unions_keep_column_matches_in_count_and_ranked_collection() {
2081        use crate::query::{BooleanQuery, TermQuery};
2082        use crate::segment::{SegmentBuilder, SegmentBuilderConfig, SegmentId};
2083        use std::sync::Arc;
2084        let dir = crate::RamDirectory::new();
2085        let mut schema = crate::SchemaBuilder::default();
2086        let field = schema.add_text_field("text", false, false);
2087        schema.set_fast(field, true);
2088        let schema = Arc::new(schema.build());
2089        let mut builder =
2090            SegmentBuilder::new(schema.clone(), SegmentBuilderConfig::default()).unwrap();
2091        for text in ["alpha", "alpha", "beta", "gamma"] {
2092            let mut doc = crate::Document::new();
2093            doc.add_text(field, text);
2094            builder.add_document(doc).unwrap();
2095        }
2096        let id = SegmentId::new();
2097        builder.build(&dir, id, None).await.unwrap();
2098        let reader = SegmentReader::open(&dir, id, schema, 16).await.unwrap();
2099        let mut single = CountCollector::new();
2100        collect_segment(&reader, &TermQuery::text(field, "alpha"), &mut single)
2101            .await
2102            .unwrap();
2103        assert_eq!(single.count(), 2);
2104        let query = BooleanQuery::new()
2105            .should(TermQuery::text(field, "alpha"))
2106            .should(TermQuery::text(field, "beta"));
2107        let mut count = CountCollector::new();
2108        collect_segment(&reader, &query, &mut count).await.unwrap();
2109        assert_eq!(count.count(), 3);
2110        let mut top = TopKCollector::new(2);
2111        collect_segment_with_limit(&reader, &query, &mut top, 2)
2112            .await
2113            .unwrap();
2114        let hits = top.into_sorted_results();
2115        assert_eq!(
2116            hits.iter().map(|hit| hit.doc_id).collect::<Vec<_>>(),
2117            [0, 1]
2118        );
2119        assert!(hits.iter().all(|hit| hit.score == 1.0));
2120        #[cfg(feature = "sync")]
2121        {
2122            let mut top = TopKCollector::new(2);
2123            collect_segment_with_limit_sync(&reader, &query, &mut top, 2).unwrap();
2124            assert_eq!(top.into_sorted_results(), hits);
2125        }
2126    }
2127
2128    #[tokio::test]
2129    async fn separate_ranked_counts_preserve_ties_nested_collectors_and_existing_hits() {
2130        use crate::query::{BooleanQuery, TermQuery};
2131        use crate::segment::{SegmentBuilder, SegmentBuilderConfig, SegmentId};
2132        use crate::structures::postings::PostingCodec;
2133        use std::sync::Arc;
2134        struct Exhaustive<C>(C);
2135        impl<C: Collector> Collector for Exhaustive<C> {
2136            fn collect(&mut self, doc: u32, score: f32, positions: &[(u32, Vec<ScoredPosition>)]) {
2137                self.0.collect(doc, score, positions);
2138            }
2139            fn needs_scores(&self) -> bool {
2140                self.0.needs_scores()
2141            }
2142            fn needs_positions(&self) -> bool {
2143                self.0.needs_positions()
2144            }
2145        }
2146        fn hits(top: TopKCollector) -> Vec<(u32, u32)> {
2147            top.into_sorted_results()
2148                .into_iter()
2149                .map(|hit| (hit.doc_id, hit.score.to_bits()))
2150                .collect()
2151        }
2152        for codec in [
2153            PostingCodec::Rounded,
2154            PostingCodec::Packed,
2155            PostingCodec::Pfor,
2156            PostingCodec::Simd4x,
2157        ] {
2158            let dir = crate::RamDirectory::new();
2159            let mut schema = crate::SchemaBuilder::default();
2160            let field = schema.add_text_field("text", true, false);
2161            let schema = Arc::new(schema.build());
2162            let mut builder = SegmentBuilder::new(
2163                schema.clone(),
2164                SegmentBuilderConfig {
2165                    posting_codec: codec,
2166                    ..Default::default()
2167                },
2168            )
2169            .unwrap();
2170            for i in 0..24001 {
2171                let text = match i % 4 {
2172                    0 => "alpha beta alpha",
2173                    1 => "alpha",
2174                    2 => "beta filler filler",
2175                    _ => "gamma",
2176                };
2177                let mut doc = crate::Document::new();
2178                doc.add_text(field, text);
2179                builder.add_document(doc).unwrap();
2180            }
2181            let id = SegmentId::new();
2182            builder.build(&dir, id, None).await.unwrap();
2183            let reader = SegmentReader::open(&dir, id, schema, 16).await.unwrap();
2184            let queries: Vec<Box<dyn Query>> = vec![
2185                Box::new(super::super::BoostQuery::new(
2186                    BooleanQuery::new()
2187                        .must(TermQuery::text(field, "alpha"))
2188                        .must(TermQuery::text(field, "beta")),
2189                    1.0,
2190                )),
2191                Box::new(super::super::FilteredQuery::new(
2192                    Arc::new(
2193                        BooleanQuery::new()
2194                            .must(TermQuery::text(field, "alpha"))
2195                            .must(TermQuery::text(field, "beta")),
2196                    ),
2197                    vec![Arc::new(TermQuery::text(field, "beta"))],
2198                )),
2199                Box::new(
2200                    BooleanQuery::new()
2201                        .must(TermQuery::text(field, "alpha"))
2202                        .must(TermQuery::text(field, "beta")),
2203                ),
2204                Box::new(
2205                    BooleanQuery::new()
2206                        .must(TermQuery::text(field, "beta"))
2207                        .must(TermQuery::text(field, "alpha")),
2208                ),
2209                Box::new(
2210                    BooleanQuery::new()
2211                        .must(TermQuery::text(field, "alpha"))
2212                        .must(TermQuery::text(field, "alpha")),
2213                ),
2214                Box::new(
2215                    BooleanQuery::new()
2216                        .must(TermQuery::text(field, "alpha"))
2217                        .must(TermQuery::text(field, "missing")),
2218                ),
2219                Box::new(
2220                    BooleanQuery::new()
2221                        .must(TermQuery::text(field, "alpha"))
2222                        .must(TermQuery::text(field, "beta"))
2223                        .must(TermQuery::text(field, "gamma")),
2224                ),
2225                Box::new(
2226                    BooleanQuery::new()
2227                        .must(
2228                            BooleanQuery::new()
2229                                .must(TermQuery::text(field, "alpha"))
2230                                .must(TermQuery::text(field, "beta")),
2231                        )
2232                        .should(TermQuery::text(field, "gamma")),
2233                ),
2234                Box::new(TermQuery::text(field, "alpha")),
2235                Box::new(
2236                    BooleanQuery::new()
2237                        .should(TermQuery::text(field, "alpha"))
2238                        .should(TermQuery::text(field, "beta")),
2239                ),
2240                Box::new(
2241                    BooleanQuery::new()
2242                        .should(TermQuery::text(field, "alpha"))
2243                        .should(TermQuery::text(field, "alpha")),
2244                ),
2245                Box::new(
2246                    BooleanQuery::new()
2247                        .should(TermQuery::text(field, "alpha"))
2248                        .should(TermQuery::text(field, "missing")),
2249                ),
2250                Box::new(TermQuery::text(field, "missing")),
2251                Box::new(
2252                    BooleanQuery::new()
2253                        .must(TermQuery::text(field, "alpha"))
2254                        .should(TermQuery::text(field, "beta")),
2255                ),
2256                Box::new(
2257                    BooleanQuery::new()
2258                        .must(TermQuery::text(field, "alpha"))
2259                        .should(TermQuery::text(field, "alpha")),
2260                ),
2261                Box::new(
2262                    BooleanQuery::new()
2263                        .must(TermQuery::text(field, "alpha"))
2264                        .should(TermQuery::text(field, "missing")),
2265                ),
2266                Box::new(
2267                    BooleanQuery::new()
2268                        .must(TermQuery::text(field, "alpha"))
2269                        .should(TermQuery::text(field, "beta"))
2270                        .should(TermQuery::text(field, "gamma")),
2271                ),
2272            ];
2273            let required = BooleanQuery::new()
2274                .must(TermQuery::text(field, "alpha"))
2275                .should(TermQuery::text(field, "beta"));
2276            assert_eq!(
2277                ranked_exact_count(&reader, &required, 17).await.unwrap(),
2278                Some(12001)
2279            );
2280            assert_eq!(
2281                ranked_exact_count(&reader, &required, 5000).await.unwrap(),
2282                None
2283            );
2284            let conjunction = BooleanQuery::new()
2285                .must(TermQuery::text(field, "alpha"))
2286                .must(TermQuery::text(field, "beta"));
2287            let counted = conjunction
2288                .scorer_with_options(
2289                    &reader,
2290                    usize::MAX / 2,
2291                    super::super::ScorerOptions {
2292                        complete_text_matches: true,
2293                        ranked_count_limit: Some(17),
2294                        ..Default::default()
2295                    },
2296                )
2297                .await
2298                .unwrap();
2299            assert_eq!(counted.exact_ranked_count(), Some(6001));
2300            for deadline in [
2301                std::time::Instant::now(),
2302                std::time::Instant::now() + std::time::Duration::from_secs(60),
2303            ] {
2304                let bounded = conjunction
2305                    .scorer_with_options(
2306                        &reader,
2307                        usize::MAX / 2,
2308                        super::super::ScorerOptions {
2309                            complete_text_matches: true,
2310                            ranked_count_limit: Some(17),
2311                            shared_threshold: Some(
2312                                super::super::SharedThreshold::for_limit(17)
2313                                    .with_deadline(Some(deadline)),
2314                            ),
2315                            ..Default::default()
2316                        },
2317                    )
2318                    .await
2319                    .unwrap();
2320                assert_eq!(
2321                    bounded.exact_ranked_count(),
2322                    None,
2323                    "a budgeted stream cannot promise a complete count"
2324                );
2325            }
2326
2327            for query in queries {
2328                for k in [0, 1, 17, 5000] {
2329                    let mut actual = TopKCollector::new(k);
2330                    let mut expected = TopKCollector::new(k);
2331                    let mut a_count = CountCollector::new();
2332                    let mut e_count = CountCollector::new();
2333                    actual.collect(90000, 1e6, &[]);
2334                    expected.collect(90000, 1e6, &[]);
2335                    a_count.collect_count(9);
2336                    e_count.collect_count(9);
2337                    collect_segment(&reader, query.as_ref(), &mut (&mut actual, &mut a_count))
2338                        .await
2339                        .unwrap();
2340                    collect_segment(
2341                        &reader,
2342                        query.as_ref(),
2343                        &mut Exhaustive((&mut expected, &mut e_count)),
2344                    )
2345                    .await
2346                    .unwrap();
2347                    assert_eq!(a_count.count(), e_count.count(), "{codec:?} k={k}");
2348                    assert_eq!(actual.total_seen(), expected.total_seen());
2349                    assert_eq!(hits(actual), hits(expected), "{codec:?} k={k}");
2350                }
2351            }
2352            let query = BooleanQuery::new()
2353                .should(TermQuery::text(field, "alpha"))
2354                .should(TermQuery::text(field, "beta"));
2355            let mut small = TopKCollector::new(3);
2356            let mut large = TopKCollector::new(29);
2357            let mut count = CountCollector::new();
2358            let mut nested = (&mut small, &mut count);
2359            let mut tuple = (&mut nested, &mut large);
2360            assert_eq!(tuple.ranked_count_limit(), Some(29));
2361            collect_segment(&reader, &query, &mut tuple).await.unwrap();
2362            let mut expected = TopKCollector::new(29);
2363            let mut ignored_count = CountCollector::new();
2364            collect_segment(
2365                &reader,
2366                &query,
2367                &mut Exhaustive((&mut expected, &mut ignored_count)),
2368            )
2369            .await
2370            .unwrap();
2371            let expected = hits(expected);
2372            assert_eq!(hits(small), expected[..3]);
2373            assert_eq!(hits(large), expected);
2374            assert_eq!(count.count(), 18001);
2375            assert_eq!(TopKCollector::with_positions(3).ranked_count_limit(), None);
2376        }
2377    }
2378
2379    #[test]
2380    fn mapped_ranked_handoff_translates_before_truncating_stable_ties() {
2381        use crate::directories::OwnedBytes;
2382        use crate::query::docset::DocSet;
2383        use crate::segment::chunk_map::{ChunkMapBuilder, read_chunk_maps, write_chunk_maps};
2384        let mut builder = ChunkMapBuilder::default();
2385        builder.set_document_units(true);
2386        for doc in [3, 2, 0, 1] {
2387            builder.push(doc, 0, 1).unwrap();
2388        }
2389        let mut bytes = Vec::new();
2390        write_chunk_maps(&mut bytes, &[(0, &builder)], &[]).unwrap();
2391        let maps = read_chunk_maps(OwnedBytes::new(bytes)).unwrap();
2392        let map = &maps.chunk_maps[&0];
2393        for limit in [0, 1, 2, 4, 8] {
2394            for positions in [false, true] {
2395                let ranked = || {
2396                    super::super::planner::TopKResultScorer::new(
2397                        (0..4)
2398                            .map(|doc_id| super::super::ScoredDoc {
2399                                doc_id,
2400                                score: 1.0,
2401                                ordinal: 0,
2402                            })
2403                            .collect(),
2404                    )
2405                };
2406                let mut scorer = ranked();
2407                let actual =
2408                    top_k_from_mapped_scorer(&mut scorer, limit, positions, None, Some(map));
2409                let mut expected = TopKCollector::new(limit);
2410                drive_collected(&mut ranked(), &mut expected, Some(map), None);
2411                assert_eq!(actual, expected.into_results_with_count());
2412                // The handoff consumed the retained list; no second heap walk.
2413                assert_eq!(scorer.doc(), TERMINATED);
2414            }
2415        }
2416    }
2417
2418    #[tokio::test]
2419    async fn phrase_block_pruning_preserves_late_winners_score_bits_and_exact_counts() {
2420        use crate::query::PhraseQuery;
2421        use crate::segment::{SegmentBuilder, SegmentBuilderConfig, SegmentId};
2422        use crate::structures::PostingCodec;
2423        for (codec, posting_ratio_bounds, posting_impact_bounds) in [
2424            PostingCodec::Rounded,
2425            PostingCodec::Packed,
2426            PostingCodec::Pfor,
2427            PostingCodec::Simd4x,
2428        ]
2429        .into_iter()
2430        .flat_map(|codec| {
2431            [
2432                (codec, false, false),
2433                (codec, true, false),
2434                (codec, true, true),
2435            ]
2436        }) {
2437            let dir = crate::RamDirectory::new();
2438            let mut schema = crate::SchemaBuilder::default();
2439            let field = schema.add_text_field("body", true, false);
2440            schema.set_positions(field, crate::dsl::PositionMode::TokenPosition);
2441            let schema = std::sync::Arc::new(schema.build());
2442            let mut builder = SegmentBuilder::new(
2443                schema.clone(),
2444                SegmentBuilderConfig {
2445                    posting_codec: codec,
2446                    posting_ratio_bounds,
2447                    posting_impact_bounds,
2448                    ..Default::default()
2449                },
2450            )
2451            .unwrap();
2452            for id in 0..2053 {
2453                // Whole weak blocks lie between short documents and later
2454                // higher-frequency winners. Include nonmatching intersections.
2455                let text = match id {
2456                    0..=3 => "alpha beta".to_owned(),
2457                    2048.. => "alpha beta alpha beta".to_owned(),
2458                    _ if id % 7 == 0 => format!("alpha {} beta", "filler ".repeat(80)),
2459                    _ => format!("alpha beta {}", "filler ".repeat(80)),
2460                };
2461                let mut doc = crate::Document::new();
2462                doc.add_text(field, text);
2463                builder.add_document(doc).unwrap();
2464            }
2465            let id = SegmentId::new();
2466            builder.build(&dir, id, None).await.unwrap();
2467            let reader = SegmentReader::open(&dir, id, schema, 16).await.unwrap();
2468            for slop in [0, 4] {
2469                let query = PhraseQuery::new(field, vec![b"alpha".to_vec(), b"beta".to_vec()])
2470                    .with_slop(slop);
2471                for k in [1, 3, 10, 100] {
2472                    let mut complete = query.scorer(&reader, 0).await.unwrap();
2473                    let mut top = TopKCollector::new(k);
2474                    let mut count = CountCollector::new();
2475                    drive_scorer(complete.as_mut(), &mut (&mut top, &mut count));
2476                    let expected = top.into_sorted_results();
2477                    let (actual, _) = search_segment_with_count(&reader, &query, k).await.unwrap();
2478                    assert_eq!(actual, expected, "{codec:?}, slop={slop}, k={k}");
2479                    #[cfg(feature = "sync")]
2480                    assert_eq!(
2481                        search_segment_with_count_sync(&reader, &query, k)
2482                            .unwrap()
2483                            .0,
2484                        expected
2485                    );
2486                    let mut exact = CountCollector::new();
2487                    collect_segment(&reader, &query, &mut exact).await.unwrap();
2488                    assert_eq!(exact.count(), count.count());
2489                    assert!(exact.count() > 1700);
2490                }
2491            }
2492        }
2493    }
2494
2495    #[test]
2496    fn ranked_bounds_skip_confirmation_but_preserve_late_winners_ties_and_complete_counts() {
2497        for positions in [false, true] {
2498            let mut ranked = BoundedCandidates::fixture();
2499            let (actual, seen) = top_k_from_scorer(&mut ranked, 1, positions, None);
2500            assert_eq!(actual[0].doc_id, 5);
2501            assert_eq!(seen, 3);
2502            assert_eq!(*ranked.confirmations.lock().unwrap(), [0, 1, 3, 5]);
2503            assert_eq!(ranked.scores.load(std::sync::atomic::Ordering::Relaxed), 3);
2504            let mut complete = BoundedCandidates::fixture();
2505            let mut top = if positions {
2506                TopKCollector::with_positions(1)
2507            } else {
2508                TopKCollector::new(1)
2509            };
2510            let mut count = CountCollector::new();
2511            drive_scorer(&mut complete, &mut (&mut top, &mut count));
2512            assert_eq!(actual, top.into_sorted_results());
2513            assert_eq!(count.count(), 5);
2514            assert_eq!(
2515                complete.bounds.load(std::sync::atomic::Ordering::Relaxed),
2516                0
2517            );
2518            let mut count_only = BoundedCandidates::fixture();
2519            let mut count = CountCollector::new();
2520            drive_scorer(&mut count_only, &mut count);
2521            assert_eq!(count.count(), 5);
2522            assert_eq!(
2523                count_only.bounds.load(std::sync::atomic::Ordering::Relaxed),
2524                0
2525            );
2526            assert_eq!(
2527                count_only.scores.load(std::sync::atomic::Ordering::Relaxed),
2528                0
2529            );
2530        }
2531    }
2532
2533    #[test]
2534    fn deadline_during_candidate_confirmation_prevents_scoring_and_collection() {
2535        let budget = super::super::SharedThreshold::for_limit(1).with_deadline(Some(
2536            std::time::Instant::now() + std::time::Duration::from_millis(20),
2537        ));
2538        let mut scorer = BoundedCandidates::fixture();
2539        scorer.pause_confirmation = true;
2540        let (hits, seen) = top_k_from_scorer(&mut scorer, 1, true, Some(&budget));
2541        assert!(hits.is_empty());
2542        assert_eq!(seen, 0);
2543        assert_eq!(scorer.scores.load(std::sync::atomic::Ordering::Relaxed), 0);
2544        assert!(budget.truncated());
2545    }
2546
2547    #[test]
2548    fn eligibility_wrappers_keep_exact_confirmation_and_do_not_inherit_leaf_bounds() {
2549        let candidates = BoundedCandidates::fixture();
2550        let bounds = candidates.bounds.clone();
2551        let mut alive = super::super::DocBitset::new(6);
2552        for doc in [2, 3, 4] {
2553            alive.set(doc);
2554        }
2555        let mut filtered = super::super::filtered::filtered(
2556            Box::new(candidates),
2557            Some(std::sync::Arc::new(alive)),
2558        );
2559        assert!(!filtered.supports_candidate_score_bounds());
2560        let (hits, seen) = top_k_from_scorer(filtered.as_mut(), 1, false, None);
2561        assert_eq!(hits[0].doc_id, 3);
2562        assert_eq!(seen, 3);
2563        assert_eq!(bounds.load(std::sync::atomic::Ordering::Relaxed), 0);
2564    }
2565
2566    #[test]
2567    fn scored_blocks_preserve_total_float_order_ties_and_exact_counts() {
2568        let special = [
2569            f32::NEG_INFINITY,
2570            -1.0,
2571            -0.0,
2572            0.0,
2573            1.0,
2574            f32::INFINITY,
2575            f32::from_bits(0xffc00001),
2576            f32::from_bits(0x7fc00001),
2577            f32::from_bits(0x7fc00002),
2578        ];
2579        for k in [0, 1, 10, 63, 64, 65, 127, 1000] {
2580            let mut blocks = TopKCollector::new(k);
2581            let mut scalar = TopKCollector::new(k);
2582            let mut count = CountCollector::new();
2583            let mut count2 = CountCollector::new();
2584            let mut expected = 0;
2585            for (round, base) in [128, 0, 256, 64, 320, u32::MAX - 64]
2586                .into_iter()
2587                .enumerate()
2588            {
2589                let scores = std::array::from_fn(|i| special[(i + round * 3) % special.len()]);
2590                for bits in [0, u64::MAX, 1, 1u64 << 63, 0xaaaaf00f800102a8] {
2591                    let mut tuple = (&mut blocks, &mut count, &mut count2);
2592                    assert!(tuple.supports_score_blocks());
2593                    tuple.collect_score_block(base, &scores, bits);
2594                    for (i, &score) in scores.iter().enumerate() {
2595                        if bits & (1u64 << i) != 0 {
2596                            scalar.collect(base + i as u32, score, &[]);
2597                            expected += 1;
2598                        }
2599                    }
2600                }
2601            }
2602            assert_eq!(count.count(), expected);
2603            assert_eq!(count2.count(), expected);
2604            assert_eq!(blocks.total_seen(), expected as u32);
2605            assert_eq!(blocks.into_sorted_results(), scalar.into_sorted_results());
2606        }
2607        let mut top = TopKCollector::new(0);
2608        top.total_seen = u32::MAX - 5;
2609        top.collect_score_block(0, &[1.0; 64], u64::MAX);
2610        assert_eq!(top.total_seen(), u32::MAX);
2611        assert!(!TopKCollector::with_positions(10).supports_score_blocks());
2612    }
2613
2614    #[test]
2615    fn custom_collectors_keep_interleaved_callbacks_inside_score_windows() {
2616        use super::super::{DocSet, Scorer};
2617        use std::cell::RefCell;
2618        struct Records<'a> {
2619            child: u8,
2620            calls: &'a RefCell<Vec<(DocId, u8, u32)>>,
2621        }
2622        impl Collector for Records<'_> {
2623            fn collect(
2624                &mut self,
2625                doc: DocId,
2626                score: Score,
2627                positions: &[(u32, Vec<ScoredPosition>)],
2628            ) {
2629                assert!(positions.is_empty());
2630                self.calls
2631                    .borrow_mut()
2632                    .push((doc, self.child, score.to_bits()));
2633            }
2634            fn collect_score_block(&mut self, _: DocId, _: &[Score; 64], _: u64) {
2635                panic!("custom collector has not opted in");
2636            }
2637        }
2638        struct Window(bool);
2639        impl DocSet for Window {
2640            fn doc(&self) -> DocId {
2641                if self.0 { TERMINATED } else { 0 }
2642            }
2643            fn advance(&mut self) -> DocId {
2644                panic!("use score window")
2645            }
2646            fn size_hint(&self) -> u32 {
2647                4
2648            }
2649        }
2650        impl Scorer for Window {
2651            fn score(&self) -> Score {
2652                panic!("use score window")
2653            }
2654            fn supports_score_windows(&self) -> bool {
2655                true
2656            }
2657            fn fill_score_window(
2658                &mut self,
2659                _: DocId,
2660                scores: &mut [Score; super::super::docset::DOC_WINDOW_SIZE as usize],
2661                bits: &mut super::super::docset::DocWindow,
2662            ) {
2663                bits.fill(0);
2664                bits[0] = 3;
2665                bits[1] = 1 << 63;
2666                bits[63] = 1 << 63;
2667                for doc in [0, 1, 127, 4095] {
2668                    scores[doc] = doc as f32;
2669                }
2670                self.0 = true;
2671            }
2672        }
2673        let calls = RefCell::new(Vec::new());
2674        let mut a = Records {
2675            child: 0,
2676            calls: &calls,
2677        };
2678        let mut b = Records {
2679            child: 1,
2680            calls: &calls,
2681        };
2682        let mut top = TopKCollector::new(2);
2683        let mut collector = (&mut a, &mut b, &mut top);
2684        assert!(!collector.supports_score_blocks());
2685        drive_scorer(&mut Window(false), &mut collector);
2686        let expected: Vec<_> = [0, 1, 127, 4095]
2687            .into_iter()
2688            .flat_map(|doc| {
2689                [
2690                    (doc, 0, (doc as f32).to_bits()),
2691                    (doc, 1, (doc as f32).to_bits()),
2692                ]
2693            })
2694            .collect();
2695        assert_eq!(*calls.borrow(), expected);
2696        assert_eq!(top.total_seen(), 4);
2697        assert_eq!(
2698            top.into_sorted_results()
2699                .iter()
2700                .map(|x| x.doc_id)
2701                .collect::<Vec<_>>(),
2702            [4095, 127]
2703        );
2704        let mut top = TopKCollector::new(2);
2705        let mut count = CountCollector::new();
2706        drive_scorer(&mut Window(false), &mut (&mut top, &mut count));
2707        assert_eq!(count.count(), 4);
2708        assert_eq!(top.total_seen(), 4);
2709        assert_eq!(
2710            top.into_sorted_results()
2711                .iter()
2712                .map(|x| x.doc_id)
2713                .collect::<Vec<_>>(),
2714            [4095, 127]
2715        );
2716    }
2717    use std::sync::Arc;
2718    use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
2719
2720    #[test]
2721    fn expired_score_batch_is_discarded_before_any_collection() {
2722        use super::super::{DocSet, Scorer, SharedThreshold};
2723        struct ExpiresInScoreBatch {
2724            deadline: std::time::Instant,
2725            visited: bool,
2726        }
2727        impl DocSet for ExpiresInScoreBatch {
2728            fn doc(&self) -> u32 {
2729                if self.visited { TERMINATED } else { 0 }
2730            }
2731            fn advance(&mut self) -> u32 {
2732                panic!("must use batch")
2733            }
2734            fn size_hint(&self) -> u32 {
2735                4096
2736            }
2737        }
2738        impl Scorer for ExpiresInScoreBatch {
2739            fn supports_score_batches(&self) -> bool {
2740                true
2741            }
2742            fn fill_score_batch(
2743                &mut self,
2744                docs: &mut super::super::docset::DocBatch,
2745                scores: &mut super::super::ScoreBatch,
2746            ) -> usize {
2747                docs[0] = 0;
2748                scores[0] = 5.0;
2749                self.visited = true;
2750                std::thread::sleep(
2751                    self.deadline
2752                        .saturating_duration_since(std::time::Instant::now())
2753                        + std::time::Duration::from_millis(1),
2754                );
2755                1
2756            }
2757            fn score(&self) -> f32 {
2758                panic!("count must not score")
2759            }
2760        }
2761        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(100);
2762        let budget = SharedThreshold::new().with_deadline(Some(deadline));
2763        let mut scorer = ExpiresInScoreBatch {
2764            deadline,
2765            visited: false,
2766        };
2767        let mut count = CountCollector::new();
2768        let mut top = TopKCollector::new(10);
2769        drive_scorer_budgeted(&mut scorer, &mut (&mut top, &mut count), Some(&budget));
2770        assert!(scorer.visited);
2771        assert!(budget.truncated());
2772        assert_eq!(count.count(), 0);
2773        assert_eq!(top.total_seen(), 0);
2774        assert!(top.into_sorted_results().is_empty());
2775    }
2776
2777    #[test]
2778    fn expired_document_batch_is_discarded_before_count_collection() {
2779        use super::super::{DocSet, Scorer, SharedThreshold};
2780        struct ExpiresInBatch {
2781            deadline: std::time::Instant,
2782            visited: bool,
2783        }
2784        impl DocSet for ExpiresInBatch {
2785            fn doc(&self) -> u32 {
2786                if self.visited { TERMINATED } else { 0 }
2787            }
2788            fn advance(&mut self) -> u32 {
2789                panic!("must use batch")
2790            }
2791            fn size_hint(&self) -> u32 {
2792                4096
2793            }
2794            fn supports_doc_batches(&self) -> bool {
2795                true
2796            }
2797            fn fill_doc_batch(&mut self, docs: &mut super::super::docset::DocBatch) -> usize {
2798                docs[0] = 0;
2799                self.visited = true;
2800                std::thread::sleep(
2801                    self.deadline
2802                        .saturating_duration_since(std::time::Instant::now())
2803                        + std::time::Duration::from_millis(1),
2804                );
2805                1
2806            }
2807        }
2808        impl Scorer for ExpiresInBatch {
2809            fn score(&self) -> f32 {
2810                panic!("count must not score")
2811            }
2812        }
2813        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(100);
2814        let budget = SharedThreshold::new().with_deadline(Some(deadline));
2815        let mut scorer = ExpiresInBatch {
2816            deadline,
2817            visited: false,
2818        };
2819        let mut count = CountCollector::new();
2820        drive_scorer_budgeted(&mut scorer, &mut count, Some(&budget));
2821        assert!(scorer.visited);
2822        assert!(budget.truncated());
2823        assert_eq!(count.count(), 0);
2824    }
2825
2826    #[test]
2827    fn expired_document_window_is_discarded_before_count_collection() {
2828        use super::super::{DocSet, Scorer, SharedThreshold};
2829        struct ExpiresInWindow {
2830            deadline: std::time::Instant,
2831            visited: bool,
2832        }
2833        impl DocSet for ExpiresInWindow {
2834            fn doc(&self) -> u32 {
2835                if self.visited { TERMINATED } else { 0 }
2836            }
2837            fn advance(&mut self) -> u32 {
2838                panic!("must use window")
2839            }
2840            fn size_hint(&self) -> u32 {
2841                4096
2842            }
2843            fn supports_doc_windows(&self) -> bool {
2844                true
2845            }
2846            fn fill_doc_window(&mut self, _base: u32, bits: &mut super::super::docset::DocWindow) {
2847                bits.fill(u64::MAX);
2848                self.visited = true;
2849                std::thread::sleep(
2850                    self.deadline
2851                        .saturating_duration_since(std::time::Instant::now())
2852                        + std::time::Duration::from_millis(1),
2853                );
2854            }
2855        }
2856        impl Scorer for ExpiresInWindow {
2857            fn score(&self) -> f32 {
2858                panic!("count must not score")
2859            }
2860        }
2861        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(100);
2862        let budget = SharedThreshold::new().with_deadline(Some(deadline));
2863        let mut scorer = ExpiresInWindow {
2864            deadline,
2865            visited: false,
2866        };
2867        let mut count = CountCollector::new();
2868        drive_scorer_budgeted(&mut scorer, &mut count, Some(&budget));
2869        assert!(scorer.visited);
2870        assert!(budget.truncated());
2871        assert_eq!(count.count(), 0);
2872    }
2873
2874    #[test]
2875    fn expired_score_window_is_discarded_before_rank_and_count_collection() {
2876        use super::super::{DocSet, Scorer, SharedThreshold};
2877        struct ExpiresInWindow {
2878            deadline: std::time::Instant,
2879            visited: bool,
2880        }
2881        impl DocSet for ExpiresInWindow {
2882            fn doc(&self) -> u32 {
2883                if self.visited { TERMINATED } else { 0 }
2884            }
2885            fn advance(&mut self) -> u32 {
2886                panic!("must use score window")
2887            }
2888            fn size_hint(&self) -> u32 {
2889                4096
2890            }
2891        }
2892        impl Scorer for ExpiresInWindow {
2893            fn score(&self) -> f32 {
2894                panic!("must use score window")
2895            }
2896            fn supports_score_windows(&self) -> bool {
2897                true
2898            }
2899            fn fill_score_window(
2900                &mut self,
2901                _base: DocId,
2902                scores: &mut [Score; super::super::docset::DOC_WINDOW_SIZE as usize],
2903                bits: &mut super::super::docset::DocWindow,
2904            ) {
2905                bits.fill(u64::MAX);
2906                scores.fill(1.0);
2907                self.visited = true;
2908                std::thread::sleep(
2909                    self.deadline
2910                        .saturating_duration_since(std::time::Instant::now())
2911                        + std::time::Duration::from_millis(1),
2912                );
2913            }
2914        }
2915        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(20);
2916        let budget = SharedThreshold::new().with_deadline(Some(deadline));
2917        let mut scorer = ExpiresInWindow {
2918            deadline,
2919            visited: false,
2920        };
2921        let mut top = TopKCollector::new(10);
2922        let mut count = CountCollector::new();
2923        drive_scorer_budgeted(&mut scorer, &mut (&mut top, &mut count), Some(&budget));
2924        assert!(scorer.visited);
2925        assert!(budget.truncated());
2926        assert_eq!(count.count(), 0);
2927        assert!(top.into_sorted_results().is_empty());
2928    }
2929
2930    #[derive(Default)]
2931    struct OwnedPositionCollector {
2932        owned_calls: usize,
2933        borrowed_calls: usize,
2934        positions: super::super::MatchedPositions,
2935    }
2936
2937    impl Collector for OwnedPositionCollector {
2938        fn collect(
2939            &mut self,
2940            _doc_id: DocId,
2941            _score: Score,
2942            positions: &[(u32, Vec<ScoredPosition>)],
2943        ) {
2944            self.borrowed_calls += 1;
2945            self.positions = positions.to_vec();
2946        }
2947
2948        fn collect_owned(
2949            &mut self,
2950            _doc_id: DocId,
2951            _score: Score,
2952            positions: super::super::MatchedPositions,
2953        ) {
2954            self.owned_calls += 1;
2955            self.positions = positions;
2956        }
2957
2958        fn needs_positions(&self) -> bool {
2959            true
2960        }
2961    }
2962
2963    struct PositionCountingScorer {
2964        index: usize,
2965        position_calls: Arc<AtomicUsize>,
2966    }
2967
2968    impl super::super::DocSet for PositionCountingScorer {
2969        fn doc(&self) -> DocId {
2970            if self.index < 3 {
2971                self.index as DocId
2972            } else {
2973                TERMINATED
2974            }
2975        }
2976
2977        fn advance(&mut self) -> DocId {
2978            self.index += 1;
2979            self.doc()
2980        }
2981
2982        fn seek(&mut self, target: DocId) -> DocId {
2983            self.index = target.min(3) as usize;
2984            self.doc()
2985        }
2986
2987        fn size_hint(&self) -> u32 {
2988            3u32.saturating_sub(self.index as u32)
2989        }
2990    }
2991
2992    impl super::super::Scorer for PositionCountingScorer {
2993        fn score(&self) -> Score {
2994            [10.0, 1.0, 2.0][self.index]
2995        }
2996
2997        fn matched_positions(&self) -> Option<super::super::MatchedPositions> {
2998            self.position_calls.fetch_add(1, AtomicOrdering::Relaxed);
2999            Some(vec![(7, vec![ScoredPosition::new(self.index as u32, 1.0)])])
3000        }
3001    }
3002
3003    struct ScoreCountingScorer {
3004        doc: u32,
3005        calls: std::sync::Arc<AtomicUsize>,
3006    }
3007
3008    impl super::super::DocSet for ScoreCountingScorer {
3009        fn doc(&self) -> DocId {
3010            if self.doc < 3 { self.doc } else { TERMINATED }
3011        }
3012        fn advance(&mut self) -> DocId {
3013            self.doc += 1;
3014            self.doc()
3015        }
3016        fn size_hint(&self) -> u32 {
3017            3u32.saturating_sub(self.doc)
3018        }
3019    }
3020
3021    impl super::super::Scorer for ScoreCountingScorer {
3022        fn score(&self) -> Score {
3023            self.calls.fetch_add(1, AtomicOrdering::Relaxed);
3024            self.doc as f32 + 1.0
3025        }
3026    }
3027
3028    #[test]
3029    fn count_only_collection_skips_scores_but_ranked_tuples_compute_them() {
3030        let calls = std::sync::Arc::new(AtomicUsize::new(0));
3031        let mut scorer = ScoreCountingScorer {
3032            doc: 0,
3033            calls: calls.clone(),
3034        };
3035        let mut count = CountCollector::new();
3036        drive_scorer(&mut scorer, &mut count);
3037        assert_eq!(count.count(), 3);
3038        assert_eq!(calls.load(AtomicOrdering::Relaxed), 0);
3039
3040        let mut scorer = ScoreCountingScorer {
3041            doc: 0,
3042            calls: calls.clone(),
3043        };
3044        let mut count = CountCollector::new();
3045        let mut top = TopKCollector::new(1);
3046        drive_scorer(&mut scorer, &mut (&mut count, &mut top));
3047        assert_eq!(count.count(), 3);
3048        assert_eq!(calls.load(AtomicOrdering::Relaxed), 3);
3049        let hits = top.into_sorted_results();
3050        assert_eq!((hits[0].doc_id, hits[0].score), (2, 3.0));
3051    }
3052
3053    #[test]
3054    fn test_top_k_collector() {
3055        let mut collector = TopKCollector::new(3);
3056
3057        collector.collect(0, 1.0, &[]);
3058        collector.collect(1, 3.0, &[]);
3059        collector.collect(2, 2.0, &[]);
3060        collector.collect(3, 4.0, &[]);
3061        collector.collect(4, 0.5, &[]);
3062
3063        let results = collector.into_sorted_results();
3064
3065        assert_eq!(results.len(), 3);
3066        assert_eq!(results[0].doc_id, 3); // score 4.0
3067        assert_eq!(results[1].doc_id, 1); // score 3.0
3068        assert_eq!(results[2].doc_id, 2); // score 2.0
3069    }
3070
3071    #[test]
3072    fn top_k_zero_retains_no_results() {
3073        let mut collector = TopKCollector::new(0);
3074        collector.collect(1, 1.0, &[]);
3075
3076        assert!(collector.into_sorted_results().is_empty());
3077    }
3078
3079    #[test]
3080    fn huge_top_k_does_not_trigger_a_huge_initial_allocation() {
3081        let collector = TopKCollector::new(usize::MAX);
3082
3083        let TopKHeap::Scores(heap) = collector.heap else {
3084            panic!("score-only constructor selected the position heap");
3085        };
3086        assert!(heap.capacity() <= MAX_INITIAL_TOP_K_CAPACITY);
3087    }
3088
3089    #[test]
3090    fn score_only_heap_entry_stays_compact() {
3091        assert_eq!(std::mem::size_of::<ScoreOnlyResult>(), 8);
3092        assert!(std::mem::size_of::<SearchResult>() >= 4 * std::mem::size_of::<ScoreOnlyResult>());
3093    }
3094
3095    #[test]
3096    fn top_k_replacement_preserves_score_and_doc_ties() {
3097        let mut collector = TopKCollector::new(3);
3098        for (doc_id, score) in [(9, 2.0), (8, 2.0), (7, 2.0), (6, 2.0), (1, 1.0)] {
3099            collector.collect(doc_id, score, &[]);
3100        }
3101
3102        let results = collector.into_sorted_results();
3103        assert_eq!(
3104            results
3105                .iter()
3106                .map(|result| (result.doc_id, result.score))
3107                .collect::<Vec<_>>(),
3108            vec![(6, 2.0), (7, 2.0), (8, 2.0)]
3109        );
3110    }
3111
3112    #[test]
3113    fn extract_ordinals_sorts_and_deduplicates_without_hashing() {
3114        let result = SearchResult {
3115            doc_id: 1,
3116            score: 1.0,
3117            segment_id: 0,
3118            positions: vec![
3119                (
3120                    3,
3121                    vec![
3122                        ScoredPosition::new(5 << 20, 1.0),
3123                        ScoredPosition::new(2 << 20, 1.0),
3124                        ScoredPosition::new(5 << 20, 2.0),
3125                    ],
3126                ),
3127                (
3128                    7,
3129                    vec![
3130                        ScoredPosition::new(4, 1.0),
3131                        ScoredPosition::new(1, 1.0),
3132                        ScoredPosition::new(4, 2.0),
3133                    ],
3134                ),
3135            ],
3136        };
3137
3138        let fields = result.extract_ordinals();
3139        assert_eq!(fields[0].ordinals, vec![2, 5]);
3140        assert_eq!(fields[1].ordinals, vec![1, 4]);
3141    }
3142
3143    #[test]
3144    fn positions_are_only_materialized_for_competitive_hits() {
3145        let calls = Arc::new(AtomicUsize::new(0));
3146        let mut scorer = PositionCountingScorer {
3147            index: 0,
3148            position_calls: Arc::clone(&calls),
3149        };
3150        let mut collector = TopKCollector::with_positions(1);
3151
3152        drive_scorer(&mut scorer, &mut collector);
3153
3154        assert_eq!(calls.load(AtomicOrdering::Relaxed), 1);
3155        assert_eq!(collector.total_seen(), 3);
3156        let results = collector.into_sorted_results();
3157        assert_eq!(results.len(), 1);
3158        assert_eq!(results[0].doc_id, 0);
3159        assert_eq!(results[0].positions[0].0, 7);
3160    }
3161
3162    #[test]
3163    fn tuple_moves_owned_positions_to_single_position_collector() {
3164        let mut positions = OwnedPositionCollector::default();
3165        let mut count = CountCollector::new();
3166        let input = vec![(7, vec![ScoredPosition::new(3, 1.0)])];
3167        let input_ptr = input[0].1.as_ptr();
3168
3169        (&mut positions, &mut count).collect_owned(11, 2.0, input);
3170
3171        assert_eq!(positions.owned_calls, 1);
3172        assert_eq!(positions.borrowed_calls, 0);
3173        assert_eq!(positions.positions[0].1.as_ptr(), input_ptr);
3174        assert_eq!(count.count(), 1);
3175    }
3176
3177    #[test]
3178    fn tuple_clones_for_all_but_final_position_collector() {
3179        let mut first = OwnedPositionCollector::default();
3180        let mut second = OwnedPositionCollector::default();
3181        let mut count = CountCollector::new();
3182        let input = vec![(7, vec![ScoredPosition::new(3, 1.0)])];
3183        let input_ptr = input[0].1.as_ptr();
3184
3185        (&mut first, &mut count, &mut second).collect_owned(11, 2.0, input);
3186
3187        assert_eq!((first.owned_calls, first.borrowed_calls), (1, 0));
3188        assert_eq!((second.owned_calls, second.borrowed_calls), (1, 0));
3189        assert_ne!(first.positions[0].1.as_ptr(), input_ptr);
3190        assert_eq!(second.positions[0].1.as_ptr(), input_ptr);
3191        assert_eq!(count.count(), 1);
3192    }
3193
3194    #[test]
3195    fn test_count_collector() {
3196        let mut collector = CountCollector::new();
3197
3198        collector.collect(0, 1.0, &[]);
3199        collector.collect(1, 2.0, &[]);
3200        collector.collect(2, 3.0, &[]);
3201
3202        assert_eq!(collector.count(), 3);
3203    }
3204
3205    #[test]
3206    fn test_multi_collector() {
3207        let mut top_k = TopKCollector::new(2);
3208        let mut count = CountCollector::new();
3209
3210        // Simulate what collect_segment_multi does
3211        for (doc_id, score) in [(0, 1.0), (1, 3.0), (2, 2.0), (3, 4.0), (4, 0.5)] {
3212            top_k.collect(doc_id, score, &[]);
3213            count.collect(doc_id, score, &[]);
3214        }
3215
3216        // Count should have all 5 documents
3217        assert_eq!(count.count(), 5);
3218
3219        // TopK should only have top 2 results
3220        let results = top_k.into_sorted_results();
3221        assert_eq!(results.len(), 2);
3222        assert_eq!(results[0].doc_id, 3); // score 4.0
3223        assert_eq!(results[1].doc_id, 1); // score 3.0
3224    }
3225}