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