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