Skip to main content

hermes_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        use rustc_hash::FxHashSet;
132
133        self.positions
134            .iter()
135            .map(|(field_id, scored_positions)| {
136                let mut ordinals: FxHashSet<u32> = FxHashSet::default();
137                for sp in scored_positions {
138                    // For text fields with encoded positions, extract ordinal
139                    // For vector fields, position IS the ordinal
140                    // We use a heuristic: if position > 0xFFFFF (20 bits), it's encoded
141                    let ordinal = if sp.position > 0xFFFFF {
142                        sp.position >> 20
143                    } else {
144                        sp.position
145                    };
146                    ordinals.insert(ordinal);
147                }
148                let mut ordinals: Vec<u32> = ordinals.into_iter().collect();
149                ordinals.sort_unstable();
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    /// Whether this score can enter the collector's retained result set.
221    ///
222    /// The scorer still calls `collect` when this returns false so counters and
223    /// other side effects remain exact; it only skips materializing positions.
224    fn would_collect(&self, _doc_id: DocId, _score: Score) -> bool {
225        true
226    }
227
228    /// Collect already-owned positions. Position-aware collectors can override
229    /// this to move the nested vectors instead of cloning them.
230    fn collect_owned(&mut self, doc_id: DocId, score: Score, positions: super::MatchedPositions) {
231        self.collect(doc_id, score, &positions);
232    }
233
234    /// Whether this collector needs position information
235    fn needs_positions(&self) -> bool {
236        false
237    }
238}
239
240/// Collector for top-k results
241pub struct TopKCollector {
242    heap: BinaryHeap<SearchResult>,
243    k: usize,
244    collect_positions: bool,
245    /// Total documents seen by this collector
246    total_seen: u32,
247}
248
249// Avoid trusting a caller-controlled `k` as an up-front allocation size. The
250// heap still grows to the number of results actually retained, but malformed
251// or overly broad requests cannot reserve gigabytes once per segment before
252// any document has been scored.
253const MAX_INITIAL_TOP_K_CAPACITY: usize = 8 * 1024;
254
255impl TopKCollector {
256    pub fn new(k: usize) -> Self {
257        Self {
258            heap: BinaryHeap::with_capacity(k.min(MAX_INITIAL_TOP_K_CAPACITY)),
259            k,
260            collect_positions: false,
261            total_seen: 0,
262        }
263    }
264
265    /// Create a collector that also collects positions
266    pub fn with_positions(k: usize) -> Self {
267        Self {
268            heap: BinaryHeap::with_capacity(k.min(MAX_INITIAL_TOP_K_CAPACITY)),
269            k,
270            collect_positions: true,
271            total_seen: 0,
272        }
273    }
274
275    /// Get the total number of documents seen (scored) by this collector
276    pub fn total_seen(&self) -> u32 {
277        self.total_seen
278    }
279
280    pub fn into_sorted_results(self) -> Vec<SearchResult> {
281        let mut results: Vec<_> = self.heap.into_vec();
282        results.sort_unstable_by(compare_search_results_desc);
283        results
284    }
285
286    /// Consume collector and return (sorted_results, total_seen)
287    pub fn into_results_with_count(self) -> (Vec<SearchResult>, u32) {
288        let total = self.total_seen;
289        (self.into_sorted_results(), total)
290    }
291}
292
293impl Collector for TopKCollector {
294    fn collect(&mut self, doc_id: DocId, score: Score, positions: &[(u32, Vec<ScoredPosition>)]) {
295        self.total_seen = self.total_seen.saturating_add(1);
296
297        // Only clone positions when the document will actually be kept in the heap.
298        // This avoids deep-cloning Vec<ScoredPosition> for documents that are
299        // immediately discarded (the common case for large result sets).
300        if !self.would_collect(doc_id, score) {
301            return;
302        }
303
304        let positions = if self.collect_positions {
305            positions.to_vec()
306        } else {
307            Vec::new()
308        };
309
310        if self.heap.len() >= self.k {
311            self.heap.pop();
312        }
313        self.heap.push(SearchResult {
314            doc_id,
315            score,
316            segment_id: 0,
317            positions,
318        });
319    }
320
321    fn would_collect(&self, doc_id: DocId, score: Score) -> bool {
322        self.k > 0
323            && (self.heap.len() < self.k
324                || self.heap.peek().is_some_and(|min| {
325                    score.total_cmp(&min.score).is_gt()
326                        || (score.total_cmp(&min.score).is_eq() && doc_id < min.doc_id)
327                }))
328    }
329
330    fn collect_owned(&mut self, doc_id: DocId, score: Score, positions: super::MatchedPositions) {
331        self.total_seen = self.total_seen.saturating_add(1);
332
333        if !self.would_collect(doc_id, score) {
334            return;
335        }
336
337        if self.heap.len() >= self.k {
338            self.heap.pop();
339        }
340        self.heap.push(SearchResult {
341            doc_id,
342            score,
343            segment_id: 0,
344            positions: if self.collect_positions {
345                positions
346            } else {
347                Vec::new()
348            },
349        });
350    }
351
352    fn needs_positions(&self) -> bool {
353        self.collect_positions
354    }
355}
356
357/// Collector that counts all matching documents
358#[derive(Default)]
359pub struct CountCollector {
360    count: u64,
361}
362
363impl CountCollector {
364    pub fn new() -> Self {
365        Self { count: 0 }
366    }
367
368    /// Get the total count
369    pub fn count(&self) -> u64 {
370        self.count
371    }
372}
373
374impl Collector for CountCollector {
375    #[inline]
376    fn collect(
377        &mut self,
378        _doc_id: DocId,
379        _score: Score,
380        _positions: &[(u32, Vec<ScoredPosition>)],
381    ) {
382        self.count += 1;
383    }
384}
385
386/// Execute a search query on a single segment and return (results, total_seen) (async)
387pub async fn search_segment_with_count(
388    reader: &SegmentReader,
389    query: &dyn Query,
390    limit: usize,
391) -> Result<(Vec<SearchResult>, u32)> {
392    let segment_limit = limit.min(reader.num_docs() as usize);
393    let mut collector = TopKCollector::new(segment_limit);
394    collect_segment_with_limit(reader, query, &mut collector, segment_limit).await?;
395    Ok(collector.into_results_with_count())
396}
397
398/// Execute a search query on a single segment with positions and return (results, total_seen)
399pub async fn search_segment_with_positions_and_count(
400    reader: &SegmentReader,
401    query: &dyn Query,
402    limit: usize,
403) -> Result<(Vec<SearchResult>, u32)> {
404    let segment_limit = limit.min(reader.num_docs() as usize);
405    let mut collector = TopKCollector::with_positions(segment_limit);
406    collect_segment_with_limit(reader, query, &mut collector, segment_limit).await?;
407    Ok(collector.into_results_with_count())
408}
409
410/// Return positions for the next collector that can retain them. All but the
411/// final consumer receive a clone; the final consumer takes the original
412/// allocation. Tuple collectors use this to avoid a deep clone when only one
413/// child actually needs positions (the common top-k + count case).
414fn positions_for_next_collector(
415    positions: &mut Option<super::MatchedPositions>,
416    remaining_consumers: &mut usize,
417) -> super::MatchedPositions {
418    assert!(
419        *remaining_consumers > 0,
420        "position consumer count underflow"
421    );
422    *remaining_consumers -= 1;
423    if *remaining_consumers == 0 {
424        positions
425            .take()
426            .expect("owned positions must remain for the final collector")
427    } else {
428        positions
429            .as_ref()
430            .cloned()
431            .expect("owned positions must remain while collectors are pending")
432    }
433}
434
435// Implement Collector for tuple of 2 collectors
436impl<A: Collector, B: Collector> Collector for (&mut A, &mut B) {
437    fn collect(&mut self, doc_id: DocId, score: Score, positions: &[(u32, Vec<ScoredPosition>)]) {
438        self.0.collect(doc_id, score, positions);
439        self.1.collect(doc_id, score, positions);
440    }
441    fn needs_positions(&self) -> bool {
442        self.0.needs_positions() || self.1.needs_positions()
443    }
444    fn would_collect(&self, doc_id: DocId, score: Score) -> bool {
445        (self.0.needs_positions() && self.0.would_collect(doc_id, score))
446            || (self.1.needs_positions() && self.1.would_collect(doc_id, score))
447    }
448    fn collect_owned(&mut self, doc_id: DocId, score: Score, positions: super::MatchedPositions) {
449        let wants = [
450            self.0.needs_positions() && self.0.would_collect(doc_id, score),
451            self.1.needs_positions() && self.1.would_collect(doc_id, score),
452        ];
453        let mut remaining = wants.iter().filter(|&&want| want).count();
454        let mut positions = Some(positions);
455
456        if wants[0] {
457            self.0.collect_owned(
458                doc_id,
459                score,
460                positions_for_next_collector(&mut positions, &mut remaining),
461            );
462        } else {
463            self.0.collect(doc_id, score, &[]);
464        }
465        if wants[1] {
466            self.1.collect_owned(
467                doc_id,
468                score,
469                positions_for_next_collector(&mut positions, &mut remaining),
470            );
471        } else {
472            self.1.collect(doc_id, score, &[]);
473        }
474    }
475}
476
477// Implement Collector for tuple of 3 collectors
478impl<A: Collector, B: Collector, C: Collector> Collector for (&mut A, &mut B, &mut C) {
479    fn collect(&mut self, doc_id: DocId, score: Score, positions: &[(u32, Vec<ScoredPosition>)]) {
480        self.0.collect(doc_id, score, positions);
481        self.1.collect(doc_id, score, positions);
482        self.2.collect(doc_id, score, positions);
483    }
484    fn needs_positions(&self) -> bool {
485        self.0.needs_positions() || self.1.needs_positions() || self.2.needs_positions()
486    }
487    fn would_collect(&self, doc_id: DocId, score: Score) -> bool {
488        (self.0.needs_positions() && self.0.would_collect(doc_id, score))
489            || (self.1.needs_positions() && self.1.would_collect(doc_id, score))
490            || (self.2.needs_positions() && self.2.would_collect(doc_id, score))
491    }
492    fn collect_owned(&mut self, doc_id: DocId, score: Score, positions: super::MatchedPositions) {
493        let wants = [
494            self.0.needs_positions() && self.0.would_collect(doc_id, score),
495            self.1.needs_positions() && self.1.would_collect(doc_id, score),
496            self.2.needs_positions() && self.2.would_collect(doc_id, score),
497        ];
498        let mut remaining = wants.iter().filter(|&&want| want).count();
499        let mut positions = Some(positions);
500
501        if wants[0] {
502            self.0.collect_owned(
503                doc_id,
504                score,
505                positions_for_next_collector(&mut positions, &mut remaining),
506            );
507        } else {
508            self.0.collect(doc_id, score, &[]);
509        }
510        if wants[1] {
511            self.1.collect_owned(
512                doc_id,
513                score,
514                positions_for_next_collector(&mut positions, &mut remaining),
515            );
516        } else {
517            self.1.collect(doc_id, score, &[]);
518        }
519        if wants[2] {
520            self.2.collect_owned(
521                doc_id,
522                score,
523                positions_for_next_collector(&mut positions, &mut remaining),
524            );
525        } else {
526            self.2.collect(doc_id, score, &[]);
527        }
528    }
529}
530
531/// Execute a query with one or more collectors (async)
532///
533/// Uses a large limit for the scorer to disable MaxScore pruning.
534/// For queries that benefit from MaxScore pruning (e.g., sparse vector search),
535/// use `collect_segment_with_limit` instead.
536///
537/// # Examples
538/// ```ignore
539/// // Single collector
540/// let mut top_k = TopKCollector::new(10);
541/// collect_segment(reader, query, &mut top_k).await?;
542///
543/// // Multiple collectors (tuple)
544/// let mut top_k = TopKCollector::new(10);
545/// let mut count = CountCollector::new();
546/// collect_segment(reader, query, &mut (&mut top_k, &mut count)).await?;
547/// ```
548pub async fn collect_segment<C: Collector>(
549    reader: &SegmentReader,
550    query: &dyn Query,
551    collector: &mut C,
552) -> Result<()> {
553    // Use large limit to disable MaxScore skipping for exhaustive collection
554    collect_segment_with_limit(reader, query, collector, usize::MAX / 2).await
555}
556
557/// Execute a query with one or more collectors and a specific limit (async)
558///
559/// The limit is passed to the scorer to enable MaxScore pruning for queries
560/// that support it (e.g., sparse vector search). This significantly improves
561/// performance when only the top-k results are needed.
562///
563/// Doc IDs in the collector are segment-local. The searcher stamps each result
564/// with its segment_id, making (segment_id, doc_id) the unique document key.
565pub async fn collect_segment_with_limit<C: Collector>(
566    reader: &SegmentReader,
567    query: &dyn Query,
568    collector: &mut C,
569    limit: usize,
570) -> Result<()> {
571    collect_segment_with_limit_seeded(reader, query, collector, limit, 0.0).await
572}
573
574/// Async `collect_segment_with_limit` with a cross-segment threshold seed.
575///
576/// `initial_threshold` is passed to the scorer so exact MaxScore/BMP paths can
577/// start pruning from a nonzero floor carried over from earlier segments.
578pub async fn collect_segment_with_limit_seeded<C: Collector>(
579    reader: &SegmentReader,
580    query: &dyn Query,
581    collector: &mut C,
582    limit: usize,
583    initial_threshold: f32,
584) -> Result<()> {
585    let options = super::ScorerOptions {
586        collect_positions: collector.needs_positions(),
587        initial_threshold,
588        shared_threshold: None,
589        lsp_plan: None,
590    };
591    let mut scorer = query.scorer_with_options(reader, limit, options).await?;
592    drive_scorer(scorer.as_mut(), collector);
593    Ok(())
594}
595
596/// Drive a scorer through a collector (shared by async and sync paths).
597fn drive_scorer<C: Collector>(scorer: &mut dyn super::Scorer, collector: &mut C) {
598    let needs_positions = collector.needs_positions();
599    let mut doc = scorer.doc();
600    while doc != TERMINATED {
601        let score = scorer.score();
602        if needs_positions && collector.would_collect(doc, score) {
603            let positions = scorer.matched_positions().unwrap_or_default();
604            collector.collect_owned(doc, score, positions);
605        } else {
606            collector.collect(doc, score, &[]);
607        }
608        doc = scorer.advance();
609    }
610}
611
612// ── Synchronous collector functions (mmap/RAM only) ─────────────────────────
613
614/// Synchronous segment search — returns (results, total_seen).
615#[cfg(feature = "sync")]
616pub fn search_segment_with_count_sync(
617    reader: &SegmentReader,
618    query: &dyn Query,
619    limit: usize,
620) -> Result<(Vec<SearchResult>, u32)> {
621    let segment_limit = limit.min(reader.num_docs() as usize);
622    let mut collector = TopKCollector::new(segment_limit);
623    collect_segment_with_limit_sync(reader, query, &mut collector, segment_limit)?;
624    Ok(collector.into_results_with_count())
625}
626
627/// Synchronous segment search with positions — returns (results, total_seen).
628#[cfg(feature = "sync")]
629pub fn search_segment_with_positions_and_count_sync(
630    reader: &SegmentReader,
631    query: &dyn Query,
632    limit: usize,
633) -> Result<(Vec<SearchResult>, u32)> {
634    let segment_limit = limit.min(reader.num_docs() as usize);
635    let mut collector = TopKCollector::with_positions(segment_limit);
636    collect_segment_with_limit_sync(reader, query, &mut collector, segment_limit)?;
637    Ok(collector.into_results_with_count())
638}
639
640/// Synchronous collect with limit — uses `scorer_sync`.
641#[cfg(feature = "sync")]
642pub fn collect_segment_with_limit_sync<C: Collector>(
643    reader: &SegmentReader,
644    query: &dyn Query,
645    collector: &mut C,
646    limit: usize,
647) -> Result<()> {
648    collect_segment_with_limit_seeded_sync(reader, query, collector, limit, 0.0)
649}
650
651/// Synchronous `collect_segment_with_limit_sync` with a cross-segment threshold
652/// seed (see `collect_segment_with_limit_seeded`).
653#[cfg(feature = "sync")]
654pub fn collect_segment_with_limit_seeded_sync<C: Collector>(
655    reader: &SegmentReader,
656    query: &dyn Query,
657    collector: &mut C,
658    limit: usize,
659    initial_threshold: f32,
660) -> Result<()> {
661    let options = super::ScorerOptions {
662        collect_positions: collector.needs_positions(),
663        initial_threshold,
664        shared_threshold: None,
665        lsp_plan: None,
666    };
667    let mut scorer = query.scorer_sync_with_options(reader, limit, options)?;
668    drive_scorer(scorer.as_mut(), collector);
669    Ok(())
670}
671
672/// Per-segment search seeded with a cross-segment top-k floor (sync).
673///
674/// Behaves like `search_segment_with_count_sync` / its positions variant, but
675/// threads `initial_threshold` into the scorer so exact MaxScore/BMP paths
676/// prune from the running global k-th score. Used by the multi-segment
677/// searcher to propagate the threshold across segments.
678#[cfg(feature = "sync")]
679pub fn search_segment_seeded_sync(
680    reader: &SegmentReader,
681    query: &dyn Query,
682    limit: usize,
683    collect_positions: bool,
684    initial_threshold: f32,
685) -> Result<(Vec<SearchResult>, u32)> {
686    let segment_limit = limit.min(reader.num_docs() as usize);
687    let mut collector = if collect_positions {
688        TopKCollector::with_positions(segment_limit)
689    } else {
690        TopKCollector::new(segment_limit)
691    };
692    collect_segment_with_limit_seeded_sync(
693        reader,
694        query,
695        &mut collector,
696        segment_limit,
697        initial_threshold,
698    )?;
699    Ok(collector.into_results_with_count())
700}
701
702/// Per-segment search with a live cross-segment top-k floor (sync).
703#[cfg(feature = "sync")]
704pub fn search_segment_shared_sync(
705    reader: &SegmentReader,
706    query: &dyn Query,
707    limit: usize,
708    collect_positions: bool,
709    shared_threshold: super::SharedThreshold,
710) -> Result<(Vec<SearchResult>, u32)> {
711    search_segment_shared_sync_planned(
712        reader,
713        query,
714        limit,
715        collect_positions,
716        shared_threshold,
717        None,
718    )
719}
720
721/// Per-segment search with a live threshold and a query-global LSP/0 plan.
722#[cfg(feature = "sync")]
723pub(crate) fn search_segment_shared_sync_planned(
724    reader: &SegmentReader,
725    query: &dyn Query,
726    limit: usize,
727    collect_positions: bool,
728    shared_threshold: super::SharedThreshold,
729    lsp_plan: Option<std::sync::Arc<super::bmp::LspSegmentPlan>>,
730) -> Result<(Vec<SearchResult>, u32)> {
731    let segment_limit = limit.min(reader.num_docs() as usize);
732    let mut collector = if collect_positions {
733        TopKCollector::with_positions(segment_limit)
734    } else {
735        TopKCollector::new(segment_limit)
736    };
737    let options = super::ScorerOptions {
738        collect_positions,
739        initial_threshold: shared_threshold.get(),
740        shared_threshold: Some(shared_threshold),
741        lsp_plan,
742    };
743    let mut scorer = query.scorer_sync_with_options(reader, segment_limit, options)?;
744    drive_scorer(scorer.as_mut(), &mut collector);
745    Ok(collector.into_results_with_count())
746}
747
748/// Per-segment search seeded with a cross-segment top-k floor (async).
749pub async fn search_segment_seeded(
750    reader: &SegmentReader,
751    query: &dyn Query,
752    limit: usize,
753    collect_positions: bool,
754    initial_threshold: f32,
755) -> Result<(Vec<SearchResult>, u32)> {
756    let segment_limit = limit.min(reader.num_docs() as usize);
757    let mut collector = if collect_positions {
758        TopKCollector::with_positions(segment_limit)
759    } else {
760        TopKCollector::new(segment_limit)
761    };
762    collect_segment_with_limit_seeded(
763        reader,
764        query,
765        &mut collector,
766        segment_limit,
767        initial_threshold,
768    )
769    .await?;
770    Ok(collector.into_results_with_count())
771}
772
773/// Per-segment search with a live cross-segment top-k floor (async).
774pub async fn search_segment_shared(
775    reader: &SegmentReader,
776    query: &dyn Query,
777    limit: usize,
778    collect_positions: bool,
779    shared_threshold: super::SharedThreshold,
780) -> Result<(Vec<SearchResult>, u32)> {
781    search_segment_shared_planned(
782        reader,
783        query,
784        limit,
785        collect_positions,
786        shared_threshold,
787        None,
788    )
789    .await
790}
791
792/// Async per-segment search with a query-global LSP/0 plan.
793pub(crate) async fn search_segment_shared_planned(
794    reader: &SegmentReader,
795    query: &dyn Query,
796    limit: usize,
797    collect_positions: bool,
798    shared_threshold: super::SharedThreshold,
799    lsp_plan: Option<std::sync::Arc<super::bmp::LspSegmentPlan>>,
800) -> Result<(Vec<SearchResult>, u32)> {
801    let segment_limit = limit.min(reader.num_docs() as usize);
802    let mut collector = if collect_positions {
803        TopKCollector::with_positions(segment_limit)
804    } else {
805        TopKCollector::new(segment_limit)
806    };
807    let options = super::ScorerOptions {
808        collect_positions,
809        initial_threshold: shared_threshold.get(),
810        shared_threshold: Some(shared_threshold),
811        lsp_plan,
812    };
813    let mut scorer = query
814        .scorer_with_options(reader, segment_limit, options)
815        .await?;
816    drive_scorer(scorer.as_mut(), &mut collector);
817    Ok(collector.into_results_with_count())
818}
819
820#[cfg(test)]
821mod tests {
822    use super::*;
823    use std::sync::Arc;
824    use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
825
826    #[derive(Default)]
827    struct OwnedPositionCollector {
828        owned_calls: usize,
829        borrowed_calls: usize,
830        positions: super::super::MatchedPositions,
831    }
832
833    impl Collector for OwnedPositionCollector {
834        fn collect(
835            &mut self,
836            _doc_id: DocId,
837            _score: Score,
838            positions: &[(u32, Vec<ScoredPosition>)],
839        ) {
840            self.borrowed_calls += 1;
841            self.positions = positions.to_vec();
842        }
843
844        fn collect_owned(
845            &mut self,
846            _doc_id: DocId,
847            _score: Score,
848            positions: super::super::MatchedPositions,
849        ) {
850            self.owned_calls += 1;
851            self.positions = positions;
852        }
853
854        fn needs_positions(&self) -> bool {
855            true
856        }
857    }
858
859    struct PositionCountingScorer {
860        index: usize,
861        position_calls: Arc<AtomicUsize>,
862    }
863
864    impl super::super::DocSet for PositionCountingScorer {
865        fn doc(&self) -> DocId {
866            if self.index < 3 {
867                self.index as DocId
868            } else {
869                TERMINATED
870            }
871        }
872
873        fn advance(&mut self) -> DocId {
874            self.index += 1;
875            self.doc()
876        }
877
878        fn seek(&mut self, target: DocId) -> DocId {
879            self.index = target.min(3) as usize;
880            self.doc()
881        }
882
883        fn size_hint(&self) -> u32 {
884            3u32.saturating_sub(self.index as u32)
885        }
886    }
887
888    impl super::super::Scorer for PositionCountingScorer {
889        fn score(&self) -> Score {
890            [10.0, 1.0, 2.0][self.index]
891        }
892
893        fn matched_positions(&self) -> Option<super::super::MatchedPositions> {
894            self.position_calls.fetch_add(1, AtomicOrdering::Relaxed);
895            Some(vec![(7, vec![ScoredPosition::new(self.index as u32, 1.0)])])
896        }
897    }
898
899    #[test]
900    fn test_top_k_collector() {
901        let mut collector = TopKCollector::new(3);
902
903        collector.collect(0, 1.0, &[]);
904        collector.collect(1, 3.0, &[]);
905        collector.collect(2, 2.0, &[]);
906        collector.collect(3, 4.0, &[]);
907        collector.collect(4, 0.5, &[]);
908
909        let results = collector.into_sorted_results();
910
911        assert_eq!(results.len(), 3);
912        assert_eq!(results[0].doc_id, 3); // score 4.0
913        assert_eq!(results[1].doc_id, 1); // score 3.0
914        assert_eq!(results[2].doc_id, 2); // score 2.0
915    }
916
917    #[test]
918    fn top_k_zero_retains_no_results() {
919        let mut collector = TopKCollector::new(0);
920        collector.collect(1, 1.0, &[]);
921
922        assert!(collector.into_sorted_results().is_empty());
923    }
924
925    #[test]
926    fn huge_top_k_does_not_trigger_a_huge_initial_allocation() {
927        let collector = TopKCollector::new(usize::MAX);
928
929        assert!(collector.heap.capacity() <= MAX_INITIAL_TOP_K_CAPACITY);
930    }
931
932    #[test]
933    fn positions_are_only_materialized_for_competitive_hits() {
934        let calls = Arc::new(AtomicUsize::new(0));
935        let mut scorer = PositionCountingScorer {
936            index: 0,
937            position_calls: Arc::clone(&calls),
938        };
939        let mut collector = TopKCollector::with_positions(1);
940
941        drive_scorer(&mut scorer, &mut collector);
942
943        assert_eq!(calls.load(AtomicOrdering::Relaxed), 1);
944        assert_eq!(collector.total_seen(), 3);
945        let results = collector.into_sorted_results();
946        assert_eq!(results.len(), 1);
947        assert_eq!(results[0].doc_id, 0);
948        assert_eq!(results[0].positions[0].0, 7);
949    }
950
951    #[test]
952    fn tuple_moves_owned_positions_to_single_position_collector() {
953        let mut positions = OwnedPositionCollector::default();
954        let mut count = CountCollector::new();
955        let input = vec![(7, vec![ScoredPosition::new(3, 1.0)])];
956        let input_ptr = input[0].1.as_ptr();
957
958        (&mut positions, &mut count).collect_owned(11, 2.0, input);
959
960        assert_eq!(positions.owned_calls, 1);
961        assert_eq!(positions.borrowed_calls, 0);
962        assert_eq!(positions.positions[0].1.as_ptr(), input_ptr);
963        assert_eq!(count.count(), 1);
964    }
965
966    #[test]
967    fn tuple_clones_for_all_but_final_position_collector() {
968        let mut first = OwnedPositionCollector::default();
969        let mut second = OwnedPositionCollector::default();
970        let mut count = CountCollector::new();
971        let input = vec![(7, vec![ScoredPosition::new(3, 1.0)])];
972        let input_ptr = input[0].1.as_ptr();
973
974        (&mut first, &mut count, &mut second).collect_owned(11, 2.0, input);
975
976        assert_eq!((first.owned_calls, first.borrowed_calls), (1, 0));
977        assert_eq!((second.owned_calls, second.borrowed_calls), (1, 0));
978        assert_ne!(first.positions[0].1.as_ptr(), input_ptr);
979        assert_eq!(second.positions[0].1.as_ptr(), input_ptr);
980        assert_eq!(count.count(), 1);
981    }
982
983    #[test]
984    fn test_count_collector() {
985        let mut collector = CountCollector::new();
986
987        collector.collect(0, 1.0, &[]);
988        collector.collect(1, 2.0, &[]);
989        collector.collect(2, 3.0, &[]);
990
991        assert_eq!(collector.count(), 3);
992    }
993
994    #[test]
995    fn test_multi_collector() {
996        let mut top_k = TopKCollector::new(2);
997        let mut count = CountCollector::new();
998
999        // Simulate what collect_segment_multi does
1000        for (doc_id, score) in [(0, 1.0), (1, 3.0), (2, 2.0), (3, 4.0), (4, 0.5)] {
1001            top_k.collect(doc_id, score, &[]);
1002            count.collect(doc_id, score, &[]);
1003        }
1004
1005        // Count should have all 5 documents
1006        assert_eq!(count.count(), 5);
1007
1008        // TopK should only have top 2 results
1009        let results = top_k.into_sorted_results();
1010        assert_eq!(results.len(), 2);
1011        assert_eq!(results[0].doc_id, 3); // score 4.0
1012        assert_eq!(results[1].doc_id, 1); // score 3.0
1013    }
1014}