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