Skip to main content

hermes_core/query/
scoring.rs

1//! Shared scoring abstractions for text and sparse vector search
2//!
3//! Provides common types and executors for efficient top-k retrieval:
4//! - `TermCursor`: Unified cursor for both BM25 text and sparse vector posting lists
5//! - `ScoreCollector`: Efficient min-heap for maintaining top-k results
6//! - `MaxScoreExecutor`: Unified Block-Max MaxScore with conjunction optimization
7//! - `ScoredDoc`: Result type with doc_id, score, and ordinal
8
9use std::cmp::Ordering;
10use std::collections::BinaryHeap;
11
12use log::{debug, warn};
13
14use crate::DocId;
15
16/// Entry for top-k min-heap
17#[derive(Clone, Copy)]
18pub struct HeapEntry {
19    pub doc_id: DocId,
20    pub score: f32,
21    pub ordinal: u16,
22}
23
24impl PartialEq for HeapEntry {
25    fn eq(&self, other: &Self) -> bool {
26        self.score.to_bits() == other.score.to_bits()
27            && self.doc_id == other.doc_id
28            && self.ordinal == other.ordinal
29    }
30}
31
32impl Eq for HeapEntry {}
33
34impl Ord for HeapEntry {
35    fn cmp(&self, other: &Self) -> Ordering {
36        // Min-heap: lower scores come first (to be evicted).
37        // total_cmp is branchless (compiles to a single comparison instruction).
38        other
39            .score
40            .total_cmp(&self.score)
41            .then_with(|| self.doc_id.cmp(&other.doc_id))
42            .then_with(|| self.ordinal.cmp(&other.ordinal))
43    }
44}
45
46impl PartialOrd for HeapEntry {
47    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
48        Some(self.cmp(other))
49    }
50}
51
52/// Efficient top-k collector using min-heap (internal, scoring-layer)
53///
54/// Maintains the k highest-scoring documents using a min-heap where the
55/// lowest score is at the top for O(1) threshold lookup and O(log k) eviction.
56/// No deduplication — caller must ensure each doc_id is inserted only once.
57///
58/// This is intentionally separate from `TopKCollector` in `collector.rs`:
59/// `ScoreCollector` is used inside `MaxScoreExecutor` where only `(doc_id,
60/// score, ordinal)` tuples exist — no `Scorer` trait, no position tracking,
61/// and the threshold must be inlined for tight block-max loops.
62/// `TopKCollector` wraps a `Scorer` and drives the full `DocSet`/`Scorer`
63/// protocol, collecting positions on demand.
64pub struct ScoreCollector {
65    /// Min-heap of top-k entries (lowest score at top for eviction)
66    heap: BinaryHeap<HeapEntry>,
67    pub k: usize,
68    /// Cached threshold: avoids repeated heap.peek() in hot loops.
69    /// Updated only when the heap changes (insert/pop).
70    cached_threshold: f32,
71    /// Number of real entries in the heap. Threshold seeding fills unused
72    /// slots with sentinels, so `heap.len()` alone cannot answer this.
73    real_len: usize,
74}
75
76impl ScoreCollector {
77    /// Create a new collector for top-k results
78    pub fn new(k: usize) -> Self {
79        // Cap capacity to avoid allocation overflow for very large k
80        let capacity = k.saturating_add(1).min(1_000_000);
81        Self {
82            heap: BinaryHeap::with_capacity(capacity),
83            k,
84            cached_threshold: 0.0,
85            real_len: 0,
86        }
87    }
88
89    /// Current score threshold (minimum score to enter top-k)
90    #[inline]
91    pub fn threshold(&self) -> f32 {
92        self.cached_threshold
93    }
94
95    /// Recompute cached threshold from heap state
96    #[inline]
97    fn update_threshold(&mut self) {
98        self.cached_threshold = if self.heap.len() >= self.k {
99            self.heap.peek().map(|e| e.score).unwrap_or(0.0)
100        } else {
101            0.0
102        };
103    }
104
105    /// Insert a document score. Returns true if inserted in top-k.
106    /// Caller must ensure each doc_id is inserted only once.
107    #[inline]
108    pub fn insert(&mut self, doc_id: DocId, score: f32) -> bool {
109        self.insert_with_ordinal(doc_id, score, 0)
110    }
111
112    /// Insert a document score with ordinal. Returns true if inserted in top-k.
113    /// Caller must ensure each doc_id is inserted only once.
114    #[inline]
115    pub fn insert_with_ordinal(&mut self, doc_id: DocId, score: f32, ordinal: u16) -> bool {
116        if self.k == 0 {
117            return false;
118        }
119        let entry = HeapEntry {
120            doc_id,
121            score,
122            ordinal,
123        };
124        if self.heap.len() < self.k {
125            self.heap.push(entry);
126            self.real_len += 1;
127            // Only recompute threshold when heap just became full
128            if self.heap.len() == self.k {
129                self.update_threshold();
130            }
131            true
132        } else if self.heap.peek().is_some_and(|worst| entry < *worst) {
133            self.heap.push(entry);
134            if self
135                .heap
136                .pop()
137                .is_some_and(|evicted| evicted.doc_id == u32::MAX)
138            {
139                self.real_len += 1;
140            }
141            self.update_threshold();
142            true
143        } else {
144            false
145        }
146    }
147
148    /// Check if a score could potentially enter top-k
149    #[inline]
150    pub fn would_enter(&self, score: f32) -> bool {
151        self.heap.len() < self.k || score > self.cached_threshold
152    }
153
154    /// Check whether this fully identified candidate ranks ahead of the current
155    /// worst retained entry, including deterministic tie breaks.
156    #[inline]
157    pub fn would_enter_candidate(&self, doc_id: DocId, score: f32, ordinal: u16) -> bool {
158        if self.k == 0 {
159            return false;
160        }
161        let entry = HeapEntry {
162            doc_id,
163            score,
164            ordinal,
165        };
166        self.heap.len() < self.k || self.heap.peek().is_some_and(|worst| entry < *worst)
167    }
168
169    /// Get number of documents collected so far
170    #[inline]
171    pub fn len(&self) -> usize {
172        self.heap.len()
173    }
174
175    /// Number of real results retained, excluding threshold sentinels.
176    #[inline]
177    pub fn real_len(&self) -> usize {
178        self.real_len
179    }
180
181    /// Check if collector is empty
182    #[inline]
183    pub fn is_empty(&self) -> bool {
184        self.heap.is_empty()
185    }
186
187    /// Seed the threshold from a cross-segment shared value.
188    ///
189    /// Fills unused slots and replaces retained entries below the new floor
190    /// with dummy entries. This can be called repeatedly while another
191    /// segment raises the shared threshold; equal-scoring real candidates win
192    /// the deterministic doc-id tie break over sentinels.
193    pub fn seed_threshold(&mut self, initial_threshold: f32) {
194        if initial_threshold <= 0.0
195            || self.k == 0
196            || (self.heap.len() >= self.k && initial_threshold <= self.cached_threshold)
197        {
198            return;
199        }
200
201        let sentinel = HeapEntry {
202            doc_id: u32::MAX,
203            score: initial_threshold,
204            ordinal: 0,
205        };
206        while self.heap.len() < self.k {
207            self.heap.push(sentinel);
208        }
209        while self.heap.peek().is_some_and(|worst| sentinel < *worst) {
210            self.heap.push(sentinel);
211            if self
212                .heap
213                .pop()
214                .is_some_and(|evicted| evicted.doc_id != u32::MAX)
215            {
216                self.real_len -= 1;
217            }
218        }
219        self.update_threshold();
220    }
221
222    /// Convert to sorted top-k results (descending by score).
223    /// Filters out sentinel entries (doc_id == u32::MAX) from threshold seeding.
224    pub fn into_sorted_results(self) -> Vec<(DocId, f32, u16)> {
225        let mut results: Vec<(DocId, f32, u16)> = self
226            .heap
227            .into_vec()
228            .into_iter()
229            .filter(|e| e.doc_id != u32::MAX)
230            .map(|e| (e.doc_id, e.score, e.ordinal))
231            .collect();
232
233        // Sort by score descending, then doc_id ascending
234        results.sort_unstable_by(|a, b| {
235            b.1.total_cmp(&a.1)
236                .then_with(|| a.0.cmp(&b.0))
237                .then_with(|| a.2.cmp(&b.2))
238        });
239
240        results
241    }
242}
243
244/// Cross-segment top-k score floor, shared across the parallel/concurrent
245/// per-segment searches of a single query.
246///
247/// Stores an `f32` as raw bits in an atomic so it can be read and monotonically
248/// raised from many threads without a lock. Each segment reads the current
249/// floor as its initial pruning threshold (`ScorerOptions::initial_threshold`)
250/// and, once it has collected a *full* top-k of its own, raises the floor to
251/// its k-th score.
252///
253/// Safety of seeding: a segment only raises the floor after filling its own
254/// heap, so a floor value `v` is always backed by at least `k` real documents
255/// scoring `>= v`. The final merged k-th score is therefore `>= v`, and seeding
256/// any other segment with `v` can never drop a document that belongs in the
257/// final top-k. Completion order is arbitrary, so the floor is best-effort — it
258/// only changes how aggressively later segments prune, never correctness.
259#[derive(Clone, Debug, Default)]
260pub struct SharedThreshold(std::sync::Arc<std::sync::atomic::AtomicU32>);
261
262impl SharedThreshold {
263    /// A fresh floor of 0.0 (no pruning seed).
264    pub fn new() -> Self {
265        // 0.0_f32.to_bits() == 0, matching AtomicU32::default().
266        Self(std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)))
267    }
268
269    /// Current floor.
270    #[inline]
271    pub fn get(&self) -> f32 {
272        f32::from_bits(self.0.load(std::sync::atomic::Ordering::Relaxed))
273    }
274
275    /// Raise the floor to `score` if it is strictly higher. Monotonic; a lower
276    /// or non-positive `score` is ignored. Scores here are BM25/sparse and thus
277    /// non-negative, but the comparison is done on `f32` values (not raw bits)
278    /// so it stays correct regardless.
279    pub fn raise(&self, score: f32) {
280        // Ignore non-positive scores; a NaN falls through harmlessly (the CAS
281        // loop condition below is false for NaN, so nothing is stored).
282        if score <= 0.0 {
283            return;
284        }
285        use std::sync::atomic::Ordering::Relaxed;
286        let bits = score.to_bits();
287        let mut cur = self.0.load(Relaxed);
288        while f32::from_bits(cur) < score {
289            match self.0.compare_exchange_weak(cur, bits, Relaxed, Relaxed) {
290                Ok(_) => break,
291                Err(actual) => cur = actual,
292            }
293        }
294    }
295}
296
297/// Search result from MaxScore execution
298#[derive(Debug, Clone, Copy)]
299pub struct ScoredDoc {
300    pub doc_id: DocId,
301    pub score: f32,
302    /// Ordinal for multi-valued fields (which vector in the field matched)
303    pub ordinal: u16,
304}
305
306/// Unified Block-Max MaxScore executor for top-k retrieval
307///
308/// Works with both full-text (BM25) and sparse vector (dot product) queries
309/// through the polymorphic `TermCursor`. Combines three optimizations:
310/// 1. **MaxScore partitioning** (Turtle & Flood 1995): terms split into essential
311///    (must check) and non-essential (only scored if candidate is promising)
312/// 2. **Block-max pruning** (Ding & Suel 2011): skip blocks where per-block
313///    upper bounds can't beat the current threshold
314/// 3. **Conjunction optimization** (Lucene/Grand 2023): progressively intersect
315///    essential terms as threshold rises, skipping docs that lack enough terms
316pub struct MaxScoreExecutor<'a> {
317    /// Metric labels (index, field) — set via `with_metric_labels`; empty
318    /// strings render as "unknown"/"?" is avoided by callers passing real
319    /// names from the schema.
320    metric_index: &'a str,
321    metric_field: &'a str,
322    cursors: Vec<TermCursor<'a>>,
323    prefix_sums: Vec<f32>,
324    collector: ScoreCollector,
325    inv_heap_factor: f32,
326    predicate: Option<super::DocPredicate<'a>>,
327}
328
329/// Unified term cursor for Block-Max MaxScore execution.
330///
331/// All per-position decode buffers (`doc_ids`, `scores`, `ordinals`) live in
332/// the struct directly and are filled by `ensure_block_loaded`.
333///
334/// Skip-list metadata is **not** materialized — it is read lazily from the
335/// underlying source (`BlockPostingList` for text, `SparseIndex` for sparse),
336/// both backed by zero-copy mmap'd `OwnedBytes`.
337pub(crate) struct TermCursor<'a> {
338    pub max_score: f32,
339    num_blocks: usize,
340    // ── Per-position state (filled by ensure_block_loaded) ──────────
341    block_idx: usize,
342    doc_ids: Vec<u32>,
343    scores: Vec<f32>,
344    ordinals: Vec<u16>,
345    pos: usize,
346    block_loaded: bool,
347    exhausted: bool,
348    // ── Lazy ordinal decode (sparse only) ───────────────────────────
349    /// When true, ordinal decode is deferred until ordinal_mut() is called.
350    /// Set to true for MaxScoreExecutor cursors (most blocks never need ordinals).
351    lazy_ordinals: bool,
352    /// Whether ordinals have been decoded for the current block.
353    ordinals_loaded: bool,
354    /// Stored sparse block for deferred ordinal decode (cheap Arc clone of mmap data).
355    current_sparse_block: Option<crate::structures::SparseBlock>,
356    // ── Block decode + skip access source ───────────────────────────
357    variant: CursorVariant<'a>,
358}
359
360enum CursorVariant<'a> {
361    /// Full-text BM25 — in-memory BlockPostingList (skip list + block data)
362    Text {
363        list: crate::structures::BlockPostingList,
364        idf: f32,
365        /// Precomputed: idf * (BM25_K1 + 1.0) — numerator scale factor
366        idf_times_k1_plus_1: f32,
367        /// Precomputed: 1.0 + BM25_K1 * (BM25_B / avg_field_len) — denominator tf coefficient
368        denom_tf_coeff: f32,
369        /// Precomputed: BM25_K1 * (1.0 - BM25_B) — denominator constant
370        denom_const: f32,
371        tfs: Vec<u32>,
372        /// Deferred TF decode state: (block_offset, tf_start, count).
373        /// Set when doc_ids are decoded but TFs/scores are not yet computed.
374        deferred_tf: Option<(usize, usize, usize)>,
375    },
376    /// Sparse vector — mmap'd SparseIndex (skip entries + block data)
377    Sparse {
378        si: &'a crate::segment::SparseIndex,
379        query_weight: f32,
380        skip_start: usize,
381        block_data_offset: u64,
382    },
383}
384
385// ── TermCursor async/sync macros ──────────────────────────────────────────
386//
387// Parameterised on:
388//   $load_block_fn – load_block_direct | load_block_direct_sync  (sparse I/O)
389//   $ensure_fn     – ensure_block_loaded | ensure_block_loaded_sync
390//   $($aw)*        – .await  (present for async, absent for sync)
391
392macro_rules! cursor_ensure_block {
393    ($self:ident, $load_block_fn:ident, $($aw:tt)*) => {{
394        if $self.exhausted || $self.block_loaded {
395            return Ok(!$self.exhausted);
396        }
397        match &mut $self.variant {
398            CursorVariant::Text {
399                list,
400                deferred_tf,
401                ..
402            } => {
403                if let Some(state) = list.decode_block_doc_ids_only($self.block_idx, &mut $self.doc_ids) {
404                    *deferred_tf = Some(state);
405                    $self.scores.clear();
406                    $self.pos = 0;
407                    $self.block_loaded = true;
408                    Ok(true)
409                } else {
410                    $self.exhausted = true;
411                    Ok(false)
412                }
413            }
414            CursorVariant::Sparse {
415                si,
416                query_weight,
417                skip_start,
418                block_data_offset,
419                ..
420            } => {
421                let block = si
422                    .$load_block_fn(*skip_start, *block_data_offset, $self.block_idx)
423                    $($aw)* ?;
424                match block {
425                    Some(b) => {
426                        b.decode_doc_ids_into(&mut $self.doc_ids);
427                        b.decode_scored_weights_into(*query_weight, &mut $self.scores);
428                        if $self.lazy_ordinals {
429                            // Defer ordinal decode until ordinal_mut() is called.
430                            // Stores cheap Arc-backed mmap slice, no copy.
431                            $self.current_sparse_block = Some(b);
432                            $self.ordinals_loaded = false;
433                        } else {
434                            b.decode_ordinals_into(&mut $self.ordinals);
435                            $self.ordinals_loaded = true;
436                            $self.current_sparse_block = None;
437                        }
438                        $self.pos = 0;
439                        $self.block_loaded = true;
440                        Ok(true)
441                    }
442                    None => {
443                        $self.exhausted = true;
444                        Ok(false)
445                    }
446                }
447            }
448        }
449    }};
450}
451
452macro_rules! cursor_advance {
453    ($self:ident, $ensure_fn:ident, $($aw:tt)*) => {{
454        if $self.exhausted {
455            return Ok(u32::MAX);
456        }
457        $self.$ensure_fn() $($aw)* ?;
458        if $self.exhausted {
459            return Ok(u32::MAX);
460        }
461        Ok($self.advance_pos())
462    }};
463}
464
465macro_rules! cursor_seek {
466    ($self:ident, $ensure_fn:ident, $target:expr, $($aw:tt)*) => {{
467        if let Some(doc) = $self.seek_prepare($target) {
468            return Ok(doc);
469        }
470        $self.$ensure_fn() $($aw)* ?;
471        if $self.seek_finish($target) {
472            $self.$ensure_fn() $($aw)* ?;
473        }
474        Ok($self.doc())
475    }};
476}
477
478impl<'a> TermCursor<'a> {
479    /// Create a full-text BM25 cursor (lazy — no blocks decoded yet).
480    pub fn text(
481        posting_list: crate::structures::BlockPostingList,
482        idf: f32,
483        avg_field_len: f32,
484    ) -> Self {
485        let max_tf = posting_list.max_tf() as f32;
486        let max_score = super::bm25_upper_bound(max_tf.max(1.0), idf);
487        let num_blocks = posting_list.num_blocks();
488        let safe_avg = avg_field_len.max(1.0);
489        Self {
490            max_score,
491            num_blocks,
492            block_idx: 0,
493            doc_ids: Vec::with_capacity(128),
494            scores: Vec::with_capacity(128),
495            ordinals: Vec::new(),
496            pos: 0,
497            block_loaded: false,
498            exhausted: num_blocks == 0,
499            lazy_ordinals: false,
500            ordinals_loaded: true, // text cursors never have ordinals
501            current_sparse_block: None,
502            variant: CursorVariant::Text {
503                list: posting_list,
504                idf,
505                idf_times_k1_plus_1: idf * (super::BM25_K1 + 1.0),
506                denom_tf_coeff: 1.0 + super::BM25_K1 * (super::BM25_B / safe_avg),
507                denom_const: super::BM25_K1 * (1.0 - super::BM25_B),
508                tfs: Vec::with_capacity(128),
509                deferred_tf: None,
510            },
511        }
512    }
513
514    /// Create a sparse vector cursor with lazy block loading.
515    /// Skip entries are **not** copied — they are read from `SparseIndex` mmap on demand.
516    pub fn sparse(
517        si: &'a crate::segment::SparseIndex,
518        query_weight: f32,
519        skip_start: usize,
520        skip_count: usize,
521        global_max_weight: f32,
522        block_data_offset: u64,
523    ) -> Self {
524        Self {
525            max_score: query_weight.abs() * global_max_weight,
526            num_blocks: skip_count,
527            block_idx: 0,
528            doc_ids: Vec::with_capacity(256),
529            scores: Vec::with_capacity(256),
530            ordinals: Vec::with_capacity(256),
531            pos: 0,
532            block_loaded: false,
533            exhausted: skip_count == 0,
534            lazy_ordinals: false,
535            ordinals_loaded: true,
536            current_sparse_block: None,
537            variant: CursorVariant::Sparse {
538                si,
539                query_weight,
540                skip_start,
541                block_data_offset,
542            },
543        }
544    }
545
546    // ── Skip-entry access (lazy, zero-copy for sparse) ──────────────────
547
548    #[inline]
549    fn block_first_doc(&self, idx: usize) -> DocId {
550        match &self.variant {
551            CursorVariant::Text { list, .. } => list.block_first_doc(idx).unwrap_or(u32::MAX),
552            CursorVariant::Sparse { si, skip_start, .. } => {
553                si.read_skip_entry(*skip_start + idx).first_doc
554            }
555        }
556    }
557
558    #[inline]
559    fn block_last_doc(&self, idx: usize) -> DocId {
560        match &self.variant {
561            CursorVariant::Text { list, .. } => list.block_last_doc(idx).unwrap_or(0),
562            CursorVariant::Sparse { si, skip_start, .. } => {
563                si.read_skip_entry(*skip_start + idx).last_doc
564            }
565        }
566    }
567
568    // ── Read-only accessors ─────────────────────────────────────────────
569
570    #[inline]
571    pub fn doc(&self) -> DocId {
572        if self.exhausted {
573            return u32::MAX;
574        }
575        if self.block_loaded {
576            debug_assert!(self.pos < self.doc_ids.len());
577            // SAFETY: pos < doc_ids.len() is maintained by advance_pos/ensure_block_loaded.
578            unsafe { *self.doc_ids.get_unchecked(self.pos) }
579        } else {
580            self.block_first_doc(self.block_idx)
581        }
582    }
583
584    #[inline]
585    pub fn ordinal(&self) -> u16 {
586        if !self.block_loaded || self.ordinals.is_empty() {
587            return 0;
588        }
589        debug_assert!(self.pos < self.ordinals.len());
590        // SAFETY: pos < ordinals.len() is maintained by advance_pos/ensure_block_loaded.
591        unsafe { *self.ordinals.get_unchecked(self.pos) }
592    }
593
594    /// Lazily-decoded ordinal accessor for MaxScore executor.
595    ///
596    /// When `lazy_ordinals=true`, ordinals are not decoded during block loading.
597    /// This method triggers the deferred decode on first access, amortized over
598    /// the block. Subsequent calls within the same block are free.
599    #[inline]
600    pub fn ordinal_mut(&mut self) -> u16 {
601        if !self.block_loaded {
602            return 0;
603        }
604        if !self.ordinals_loaded {
605            if let Some(ref block) = self.current_sparse_block {
606                block.decode_ordinals_into(&mut self.ordinals);
607            }
608            self.ordinals_loaded = true;
609        }
610        if self.ordinals.is_empty() {
611            return 0;
612        }
613        debug_assert!(self.pos < self.ordinals.len());
614        unsafe { *self.ordinals.get_unchecked(self.pos) }
615    }
616
617    #[inline]
618    pub fn score(&self) -> f32 {
619        if !self.block_loaded {
620            return 0.0;
621        }
622        debug_assert!(self.pos < self.scores.len());
623        // SAFETY: pos < scores.len() is maintained by advance_pos/ensure_block_loaded.
624        unsafe { *self.scores.get_unchecked(self.pos) }
625    }
626
627    /// Ensure BM25 scores are computed for the current block (lazy TF decode).
628    ///
629    /// For text cursors, TF unpacking and BM25 scoring are deferred from block
630    /// loading until this method is called, saving work for blocks skipped by
631    /// block-max or conjunction pruning. No-op for sparse cursors.
632    #[inline]
633    pub fn ensure_scores(&mut self) {
634        if self.block_loaded && self.scores.is_empty() {
635            self.compute_deferred_scores();
636        }
637    }
638
639    #[inline]
640    pub fn current_block_max_score(&self) -> f32 {
641        if self.exhausted {
642            return 0.0;
643        }
644        match &self.variant {
645            CursorVariant::Text { list, idf, .. } => {
646                let block_max_tf = list.block_max_tf(self.block_idx).unwrap_or(0) as f32;
647                super::bm25_upper_bound(block_max_tf.max(1.0), *idf)
648            }
649            CursorVariant::Sparse {
650                si,
651                query_weight,
652                skip_start,
653                ..
654            } => query_weight.abs() * si.read_skip_entry(*skip_start + self.block_idx).max_weight,
655        }
656    }
657
658    // ── Block navigation ────────────────────────────────────────────────
659
660    pub fn skip_to_next_block(&mut self) -> DocId {
661        if self.exhausted {
662            return u32::MAX;
663        }
664        self.block_idx += 1;
665        self.block_loaded = false;
666        if self.block_idx >= self.num_blocks {
667            self.exhausted = true;
668            return u32::MAX;
669        }
670        self.block_first_doc(self.block_idx)
671    }
672
673    #[inline]
674    fn advance_pos(&mut self) -> DocId {
675        self.pos += 1;
676        if self.pos >= self.doc_ids.len() {
677            self.block_idx += 1;
678            self.block_loaded = false;
679            if self.block_idx >= self.num_blocks {
680                self.exhausted = true;
681                return u32::MAX;
682            }
683        }
684        self.doc()
685    }
686
687    /// Compute BM25 scores from deferred TF data (lazy decode for text cursors).
688    #[inline(never)]
689    fn compute_deferred_scores(&mut self) {
690        if let CursorVariant::Text {
691            list,
692            idf_times_k1_plus_1,
693            denom_tf_coeff,
694            denom_const,
695            tfs,
696            deferred_tf,
697            ..
698        } = &mut self.variant
699            && let Some((block_offset, tf_start, count)) = deferred_tf.take()
700        {
701            list.decode_block_tfs_deferred(block_offset, tf_start, count, tfs);
702            let num_scale = *idf_times_k1_plus_1;
703            let d_tf = *denom_tf_coeff;
704            let d_const = *denom_const;
705            self.scores.clear();
706            self.scores.resize(count, 0.0);
707            for i in 0..count {
708                let tf = unsafe { *tfs.get_unchecked(i) } as f32;
709                let score = (num_scale * tf) / (d_tf * tf + d_const);
710                unsafe {
711                    *self.scores.get_unchecked_mut(i) = score;
712                }
713            }
714        }
715    }
716
717    // ── Block loading / advance / seek ─────────────────────────────────
718    //
719    // Macros parameterised on sparse I/O method + optional .await to
720    // stamp out both async and sync variants without duplication.
721
722    pub async fn ensure_block_loaded(&mut self) -> crate::Result<bool> {
723        cursor_ensure_block!(self, load_block_direct, .await)
724    }
725
726    pub fn ensure_block_loaded_sync(&mut self) -> crate::Result<bool> {
727        cursor_ensure_block!(self, load_block_direct_sync,)
728    }
729
730    pub async fn advance(&mut self) -> crate::Result<DocId> {
731        cursor_advance!(self, ensure_block_loaded, .await)
732    }
733
734    pub fn advance_sync(&mut self) -> crate::Result<DocId> {
735        cursor_advance!(self, ensure_block_loaded_sync,)
736    }
737
738    pub async fn seek(&mut self, target: DocId) -> crate::Result<DocId> {
739        cursor_seek!(self, ensure_block_loaded, target, .await)
740    }
741
742    pub fn seek_sync(&mut self, target: DocId) -> crate::Result<DocId> {
743        cursor_seek!(self, ensure_block_loaded_sync, target,)
744    }
745
746    fn seek_prepare(&mut self, target: DocId) -> Option<DocId> {
747        if self.exhausted {
748            return Some(u32::MAX);
749        }
750
751        // Fast path: target is within the currently loaded block
752        if self.block_loaded
753            && let Some(&last) = self.doc_ids.last()
754        {
755            if last >= target && self.doc_ids[self.pos] < target {
756                let remaining = &self.doc_ids[self.pos..];
757                self.pos += crate::structures::simd::find_first_ge_u32(remaining, target);
758                if self.pos >= self.doc_ids.len() {
759                    self.block_idx += 1;
760                    self.block_loaded = false;
761                    if self.block_idx >= self.num_blocks {
762                        self.exhausted = true;
763                        return Some(u32::MAX);
764                    }
765                }
766                return Some(self.doc());
767            }
768            if self.doc_ids[self.pos] >= target {
769                return Some(self.doc());
770            }
771        }
772
773        // Seek to the block containing target
774        let lo = match &self.variant {
775            // Text: SIMD-accelerated 2-level seek (L1 + L0)
776            CursorVariant::Text { list, .. } => match list.seek_block(target, self.block_idx) {
777                Some(idx) => idx,
778                None => {
779                    self.exhausted = true;
780                    return Some(u32::MAX);
781                }
782            },
783            // Sparse: binary search on skip entries (lazy mmap reads)
784            CursorVariant::Sparse { .. } => {
785                let mut lo = self.block_idx;
786                let mut hi = self.num_blocks;
787                while lo < hi {
788                    let mid = lo + (hi - lo) / 2;
789                    if self.block_last_doc(mid) < target {
790                        lo = mid + 1;
791                    } else {
792                        hi = mid;
793                    }
794                }
795                lo
796            }
797        };
798        if lo >= self.num_blocks {
799            self.exhausted = true;
800            return Some(u32::MAX);
801        }
802        if lo != self.block_idx || !self.block_loaded {
803            self.block_idx = lo;
804            self.block_loaded = false;
805        }
806        None
807    }
808
809    #[inline]
810    fn seek_finish(&mut self, target: DocId) -> bool {
811        if self.exhausted {
812            return false;
813        }
814        self.pos = crate::structures::simd::find_first_ge_u32(&self.doc_ids, target);
815        if self.pos >= self.doc_ids.len() {
816            self.block_idx += 1;
817            self.block_loaded = false;
818            if self.block_idx >= self.num_blocks {
819                self.exhausted = true;
820                return false;
821            }
822            return true;
823        }
824        false
825    }
826}
827
828/// Macro to stamp out the Block-Max MaxScore loop for both async and sync paths.
829///
830/// `$ensure`, `$advance`, `$seek` are cursor method idents (async or _sync variants).
831/// `$($aw:tt)*` captures `.await` for async or nothing for sync.
832macro_rules! bms_execute_loop {
833    ($self:ident, $ensure:ident, $advance:ident, $seek:ident, $($aw:tt)*) => {{
834        let n = $self.cursors.len();
835
836        // Load first block for each cursor (ensures doc() returns real values)
837        for cursor in &mut $self.cursors {
838            cursor.$ensure() $($aw)* ?;
839        }
840
841        let mut docs_scored = 0u64;
842        let mut docs_skipped = 0u64;
843        let mut blocks_skipped = 0u64;
844        let mut conjunction_skipped = 0u64;
845        let mut ordinal_scores: Vec<(u16, f32)> = Vec::with_capacity(n * 2);
846        let _bms_start = std::time::Instant::now();
847
848        let inv_heap_factor = $self.inv_heap_factor;
849        let mut adjusted_threshold = $self.collector.threshold() * inv_heap_factor - 1e-6;
850
851        loop {
852            let partition = $self.find_partition();
853            if partition >= n {
854                break;
855            }
856
857            // Find minimum doc_id across essential cursors and collect
858            // which cursors are at min_doc (avoids redundant re-checks in
859            // conjunction, block-max, predicate, and scoring passes).
860            let mut min_doc = u32::MAX;
861            let mut at_min_mask = 0u64; // bitset of cursor indices at min_doc
862            for i in partition..n {
863                let doc = $self.cursors[i].doc();
864                match doc.cmp(&min_doc) {
865                    std::cmp::Ordering::Less => {
866                        min_doc = doc;
867                        at_min_mask = 1u64 << (i as u32);
868                    }
869                    std::cmp::Ordering::Equal => {
870                        at_min_mask |= 1u64 << (i as u32);
871                    }
872                    _ => {}
873                }
874            }
875            if min_doc == u32::MAX {
876                break;
877            }
878
879            let non_essential_upper = if partition > 0 {
880                $self.prefix_sums[partition - 1]
881            } else {
882                0.0
883            };
884
885            // --- Conjunction optimization ---
886            if $self.collector.len() >= $self.collector.k {
887                let mut present_upper: f32 = 0.0;
888                let mut mask = at_min_mask;
889                while mask != 0 {
890                    let i = mask.trailing_zeros() as usize;
891                    present_upper += $self.cursors[i].max_score;
892                    mask &= mask - 1;
893                }
894
895                if present_upper + non_essential_upper < adjusted_threshold {
896                    let mut mask = at_min_mask;
897                    while mask != 0 {
898                        let i = mask.trailing_zeros() as usize;
899                        $self.cursors[i].$ensure() $($aw)* ?;
900                        $self.cursors[i].$advance() $($aw)* ?;
901                        mask &= mask - 1;
902                    }
903                    conjunction_skipped += 1;
904                    continue;
905                }
906            }
907
908            // --- Block-max pruning ---
909            if $self.collector.len() >= $self.collector.k {
910                let mut block_max_sum: f32 = 0.0;
911                let mut mask = at_min_mask;
912                while mask != 0 {
913                    let i = mask.trailing_zeros() as usize;
914                    block_max_sum += $self.cursors[i].current_block_max_score();
915                    mask &= mask - 1;
916                }
917
918                if block_max_sum + non_essential_upper < adjusted_threshold {
919                    let mut mask = at_min_mask;
920                    while mask != 0 {
921                        let i = mask.trailing_zeros() as usize;
922                        $self.cursors[i].skip_to_next_block();
923                        $self.cursors[i].$ensure() $($aw)* ?;
924                        mask &= mask - 1;
925                    }
926                    blocks_skipped += 1;
927                    continue;
928                }
929            }
930
931            // --- Predicate filter (after block-max, before scoring) ---
932            if let Some(ref pred) = $self.predicate {
933                if !pred(min_doc) {
934                    let mut mask = at_min_mask;
935                    while mask != 0 {
936                        let i = mask.trailing_zeros() as usize;
937                        $self.cursors[i].$ensure() $($aw)* ?;
938                        $self.cursors[i].$advance() $($aw)* ?;
939                        mask &= mask - 1;
940                    }
941                    continue;
942                }
943            }
944
945            // --- Score essential cursors ---
946            ordinal_scores.clear();
947            {
948                let mut mask = at_min_mask;
949                while mask != 0 {
950                    let i = mask.trailing_zeros() as usize;
951                    $self.cursors[i].$ensure() $($aw)* ?;
952                    $self.cursors[i].ensure_scores();
953                    while $self.cursors[i].doc() == min_doc {
954                        let ord = $self.cursors[i].ordinal_mut();
955                        let sc = $self.cursors[i].score();
956                        ordinal_scores.push((ord, sc));
957                        $self.cursors[i].$advance() $($aw)* ?;
958                    }
959                    mask &= mask - 1;
960                }
961            }
962
963            let essential_total: f32 = ordinal_scores.iter().map(|(_, s)| *s).sum();
964            if $self.collector.len() >= $self.collector.k
965                && essential_total + non_essential_upper < adjusted_threshold
966            {
967                docs_skipped += 1;
968                continue;
969            }
970
971            // --- Score non-essential cursors (highest max_score first for early exit) ---
972            let mut running_total = essential_total;
973            for i in (0..partition).rev() {
974                if $self.collector.len() >= $self.collector.k
975                    && running_total + $self.prefix_sums[i] < adjusted_threshold
976                {
977                    break;
978                }
979
980                let doc = $self.cursors[i].$seek(min_doc) $($aw)* ?;
981                if doc == min_doc {
982                    $self.cursors[i].ensure_scores();
983                    while $self.cursors[i].doc() == min_doc {
984                        let s = $self.cursors[i].score();
985                        running_total += s;
986                        let ord = $self.cursors[i].ordinal_mut();
987                        ordinal_scores.push((ord, s));
988                        $self.cursors[i].$advance() $($aw)* ?;
989                    }
990                }
991            }
992
993            // --- Group by ordinal and insert ---
994            // Fast path: single entry (common for single-valued fields) — skip sort + grouping
995            if ordinal_scores.len() == 1 {
996                let (ord, score) = ordinal_scores[0];
997                if $self.collector.insert_with_ordinal(min_doc, score, ord) {
998                    docs_scored += 1;
999                    adjusted_threshold = $self.collector.threshold() * inv_heap_factor - 1e-6;
1000                } else {
1001                    docs_skipped += 1;
1002                }
1003            } else if !ordinal_scores.is_empty() {
1004                if ordinal_scores.len() > 2 {
1005                    ordinal_scores.sort_unstable_by_key(|(ord, _)| *ord);
1006                } else if ordinal_scores.len() == 2 && ordinal_scores[0].0 > ordinal_scores[1].0 {
1007                    ordinal_scores.swap(0, 1);
1008                }
1009                let mut j = 0;
1010                while j < ordinal_scores.len() {
1011                    let current_ord = ordinal_scores[j].0;
1012                    let mut score = 0.0f32;
1013                    while j < ordinal_scores.len() && ordinal_scores[j].0 == current_ord {
1014                        score += ordinal_scores[j].1;
1015                        j += 1;
1016                    }
1017                    if $self
1018                        .collector
1019                        .insert_with_ordinal(min_doc, score, current_ord)
1020                    {
1021                        docs_scored += 1;
1022                        adjusted_threshold = $self.collector.threshold() * inv_heap_factor - 1e-6;
1023                    } else {
1024                        docs_skipped += 1;
1025                    }
1026                }
1027            }
1028        }
1029
1030        let results: Vec<ScoredDoc> = $self
1031            .collector
1032            .into_sorted_results()
1033            .into_iter()
1034            .map(|(doc_id, score, ordinal)| ScoredDoc {
1035                doc_id,
1036                score,
1037                ordinal,
1038            })
1039            .collect();
1040
1041        let _bms_elapsed_ms = _bms_start.elapsed().as_millis() as u64;
1042        if _bms_elapsed_ms > 500 {
1043            warn!(
1044                "slow MaxScore: {}ms, cursors={}, scored={}, skipped={}, blocks_skipped={}, conjunction_skipped={}, returned={}, top_score={:.4}",
1045                _bms_elapsed_ms,
1046                n,
1047                docs_scored,
1048                docs_skipped,
1049                blocks_skipped,
1050                conjunction_skipped,
1051                results.len(),
1052                results.first().map(|r| r.score).unwrap_or(0.0)
1053            );
1054        } else {
1055            debug!(
1056                "MaxScoreExecutor: {}ms, scored={}, skipped={}, blocks_skipped={}, conjunction_skipped={}, returned={}, top_score={:.4}",
1057                _bms_elapsed_ms,
1058                docs_scored,
1059                docs_skipped,
1060                blocks_skipped,
1061                conjunction_skipped,
1062                results.len(),
1063                results.first().map(|r| r.score).unwrap_or(0.0)
1064            );
1065        }
1066
1067        Ok(results)
1068    }};
1069}
1070
1071impl<'a> MaxScoreExecutor<'a> {
1072    /// Create a new executor from pre-built cursors.
1073    ///
1074    /// Cursors are sorted by max_score ascending (non-essential first) and
1075    /// prefix sums are computed for the MaxScore partitioning.
1076    pub(crate) fn new(mut cursors: Vec<TermCursor<'a>>, k: usize, heap_factor: f32) -> Self {
1077        // The execution loop tracks cursors at the current document in a u64.
1078        // Query construction normally enforces this bound, but keep this
1079        // boundary defensive for direct/internal executor users as well.
1080        if cursors.len() > super::MAX_QUERY_TERMS {
1081            cursors.sort_unstable_by(|a, b| b.max_score.total_cmp(&a.max_score));
1082            cursors.truncate(super::MAX_QUERY_TERMS);
1083            log::warn!(
1084                "MaxScore cursor count exceeded {}; retaining the strongest cursors",
1085                super::MAX_QUERY_TERMS
1086            );
1087        }
1088
1089        // Enable lazy ordinal decode — ordinals are only decoded when a doc
1090        // actually reaches the scoring phase (saves ~100ns per skipped block).
1091        for c in &mut cursors {
1092            c.lazy_ordinals = true;
1093        }
1094
1095        // Sort by max_score ascending (non-essential first)
1096        cursors.sort_by(|a, b| {
1097            a.max_score
1098                .partial_cmp(&b.max_score)
1099                .unwrap_or(Ordering::Equal)
1100        });
1101
1102        let mut prefix_sums = Vec::with_capacity(cursors.len());
1103        let mut cumsum = 0.0f32;
1104        for c in &cursors {
1105            cumsum += c.max_score;
1106            prefix_sums.push(cumsum);
1107        }
1108
1109        let clamped_heap_factor = heap_factor.clamp(0.01, 1.0);
1110
1111        debug!(
1112            "Creating MaxScoreExecutor: num_cursors={}, k={}, total_upper={:.4}, heap_factor={:.2}",
1113            cursors.len(),
1114            k,
1115            cumsum,
1116            clamped_heap_factor
1117        );
1118
1119        Self {
1120            cursors,
1121            prefix_sums,
1122            collector: ScoreCollector::new(k),
1123            inv_heap_factor: 1.0 / clamped_heap_factor,
1124            predicate: None,
1125            metric_index: "unknown",
1126            metric_field: "unknown",
1127        }
1128    }
1129
1130    /// Attach (index, field) labels for the metrics this executor emits.
1131    pub fn with_metric_labels(mut self, index: &'a str, field: &'a str) -> Self {
1132        self.metric_index = index;
1133        self.metric_field = field;
1134        self
1135    }
1136
1137    /// Create an executor for sparse vector queries.
1138    ///
1139    /// Builds `TermCursor::Sparse` for each matched dimension.
1140    pub fn sparse(
1141        sparse_index: &'a crate::segment::SparseIndex,
1142        query_terms: Vec<(u32, f32)>,
1143        k: usize,
1144        heap_factor: f32,
1145    ) -> Self {
1146        let cursors: Vec<TermCursor<'a>> = query_terms
1147            .iter()
1148            .filter_map(|&(dim_id, qw)| {
1149                let (skip_start, skip_count, global_max, block_data_offset) =
1150                    sparse_index.get_skip_range_full(dim_id)?;
1151                Some(TermCursor::sparse(
1152                    sparse_index,
1153                    qw,
1154                    skip_start,
1155                    skip_count,
1156                    global_max,
1157                    block_data_offset,
1158                ))
1159            })
1160            .collect();
1161        Self::new(cursors, k, heap_factor)
1162    }
1163
1164    /// Create an executor for full-text BM25 queries.
1165    ///
1166    /// Builds `TermCursor::Text` for each posting list.
1167    pub fn text(
1168        posting_lists: Vec<(crate::structures::BlockPostingList, f32)>,
1169        avg_field_len: f32,
1170        k: usize,
1171    ) -> Self {
1172        let cursors: Vec<TermCursor<'a>> = posting_lists
1173            .into_iter()
1174            .map(|(pl, idf)| TermCursor::text(pl, idf, avg_field_len))
1175            .collect();
1176        Self::new(cursors, k, 1.0)
1177    }
1178
1179    #[inline]
1180    fn find_partition(&self) -> usize {
1181        // Alpha < 1.0 raises the effective threshold → more terms become
1182        // non-essential → more aggressive pruning (approximate retrieval).
1183        // Use multiplication by reciprocal (cheaper than division).
1184        let threshold = self.collector.threshold() * self.inv_heap_factor;
1185        // Keep an equal-score candidate essential: it can still displace the
1186        // current worst hit through the deterministic doc/ordinal tie-break.
1187        self.prefix_sums.partition_point(|&sum| sum < threshold)
1188    }
1189
1190    /// Attach a per-doc predicate filter to this executor.
1191    ///
1192    /// Docs failing the predicate are skipped after block-max pruning but
1193    /// before scoring. The predicate does not affect thresholds or block-max
1194    /// comparisons — the heap stores pure sparse/text scores.
1195    pub fn with_predicate(mut self, predicate: super::DocPredicate<'a>) -> Self {
1196        self.predicate = Some(predicate);
1197        self
1198    }
1199
1200    /// Seed the collector with an initial threshold for tighter early pruning.
1201    pub fn seed_threshold(&mut self, initial_threshold: f32) {
1202        self.collector.seed_threshold(initial_threshold);
1203    }
1204
1205    /// Execute Block-Max MaxScore and return top-k results (async).
1206    pub async fn execute(mut self) -> crate::Result<Vec<ScoredDoc>> {
1207        if self.cursors.is_empty() {
1208            return Ok(Vec::new());
1209        }
1210        let t = crate::observe::Timer::start();
1211        let results = bms_execute_loop!(self, ensure_block_loaded, advance, seek, .await);
1212        if let Ok(r) = &results {
1213            crate::observe::maxscore_query(self.metric_index, self.metric_field, t.secs(), r.len());
1214        }
1215        results
1216    }
1217
1218    /// Synchronous execution — works when all cursors are text or mmap-backed sparse.
1219    pub fn execute_sync(mut self) -> crate::Result<Vec<ScoredDoc>> {
1220        if self.cursors.is_empty() {
1221            return Ok(Vec::new());
1222        }
1223        let t = crate::observe::Timer::start();
1224        let results = bms_execute_loop!(self, ensure_block_loaded_sync, advance_sync, seek_sync,);
1225        if let Ok(r) = &results {
1226            crate::observe::maxscore_query(self.metric_index, self.metric_field, t.secs(), r.len());
1227        }
1228        results
1229    }
1230}
1231
1232#[cfg(test)]
1233mod tests {
1234    use super::*;
1235
1236    #[test]
1237    fn test_shared_threshold_monotonic_raise() {
1238        let shared = SharedThreshold::new();
1239        assert_eq!(shared.get(), 0.0);
1240
1241        shared.raise(2.5);
1242        assert_eq!(shared.get(), 2.5);
1243
1244        // Lower values never lower the floor.
1245        shared.raise(1.0);
1246        assert_eq!(shared.get(), 2.5);
1247
1248        // Higher values raise it.
1249        shared.raise(4.0);
1250        assert_eq!(shared.get(), 4.0);
1251
1252        // Non-positive and NaN are ignored.
1253        shared.raise(0.0);
1254        shared.raise(-3.0);
1255        shared.raise(f32::NAN);
1256        assert_eq!(shared.get(), 4.0);
1257
1258        // Clones share the same atomic cell.
1259        let clone = shared.clone();
1260        clone.raise(9.0);
1261        assert_eq!(shared.get(), 9.0);
1262    }
1263
1264    #[test]
1265    fn test_shared_threshold_seed_matches_manual() {
1266        // A collector seeded with a floor prunes anything at/below it, matching
1267        // the threshold a fully-populated heap would have produced.
1268        let mut seeded = ScoreCollector::new(2);
1269        seeded.seed_threshold(3.0);
1270        assert_eq!(seeded.threshold(), 3.0);
1271        // A score at/below the floor cannot enter.
1272        assert!(!seeded.would_enter(3.0));
1273        assert!(seeded.would_enter(3.5));
1274        // Real inserts above the floor evict the sentinels; results contain no
1275        // sentinel (doc_id == u32::MAX) entries.
1276        seeded.insert(1, 5.0);
1277        seeded.insert(2, 4.0);
1278        let results = seeded.into_sorted_results();
1279        assert_eq!(results.len(), 2);
1280        assert_eq!(results[0].0, 1);
1281        assert_eq!(results[1].0, 2);
1282    }
1283
1284    #[test]
1285    fn test_shared_threshold_can_raise_after_real_inserts() {
1286        let mut collector = ScoreCollector::new(3);
1287        collector.insert(1, 10.0);
1288        collector.insert(2, 4.0);
1289        assert_eq!(collector.real_len(), 2);
1290
1291        // Raising the floor after traversal has started removes retained work
1292        // that can no longer reach the global top-k.
1293        collector.seed_threshold(6.0);
1294        assert_eq!(collector.threshold(), 6.0);
1295        assert_eq!(collector.real_len(), 1);
1296
1297        // A real candidate tied with the floor displaces the sentinel because
1298        // its doc id wins the canonical tie break.
1299        assert!(collector.would_enter_candidate(3, 6.0, 0));
1300        assert!(collector.insert(3, 6.0));
1301        assert_eq!(collector.real_len(), 2);
1302        let results = collector.into_sorted_results();
1303        assert_eq!(results, vec![(1, 10.0, 0), (3, 6.0, 0)]);
1304    }
1305
1306    #[test]
1307    fn test_score_collector_basic() {
1308        let mut collector = ScoreCollector::new(3);
1309
1310        collector.insert(1, 1.0);
1311        collector.insert(2, 2.0);
1312        collector.insert(3, 3.0);
1313        assert_eq!(collector.threshold(), 1.0);
1314
1315        collector.insert(4, 4.0);
1316        assert_eq!(collector.threshold(), 2.0);
1317
1318        let results = collector.into_sorted_results();
1319        assert_eq!(results.len(), 3);
1320        assert_eq!(results[0].0, 4); // Highest score
1321        assert_eq!(results[1].0, 3);
1322        assert_eq!(results[2].0, 2);
1323    }
1324
1325    #[test]
1326    fn test_score_collector_threshold() {
1327        let mut collector = ScoreCollector::new(2);
1328
1329        collector.insert(1, 5.0);
1330        collector.insert(2, 3.0);
1331        assert_eq!(collector.threshold(), 3.0);
1332
1333        // Should not enter (score too low)
1334        assert!(!collector.would_enter(2.0));
1335        assert!(!collector.insert(3, 2.0));
1336
1337        // Should enter (score high enough)
1338        assert!(collector.would_enter(4.0));
1339        assert!(collector.insert(4, 4.0));
1340        assert_eq!(collector.threshold(), 4.0);
1341    }
1342
1343    #[test]
1344    fn test_heap_entry_ordering() {
1345        let mut heap = BinaryHeap::new();
1346        heap.push(HeapEntry {
1347            doc_id: 1,
1348            score: 3.0,
1349            ordinal: 0,
1350        });
1351        heap.push(HeapEntry {
1352            doc_id: 2,
1353            score: 1.0,
1354            ordinal: 0,
1355        });
1356        heap.push(HeapEntry {
1357            doc_id: 3,
1358            score: 2.0,
1359            ordinal: 0,
1360        });
1361
1362        // Min-heap: lowest score should come out first
1363        assert_eq!(heap.pop().unwrap().score, 1.0);
1364        assert_eq!(heap.pop().unwrap().score, 2.0);
1365        assert_eq!(heap.pop().unwrap().score, 3.0);
1366    }
1367}