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