Skip to main content

hermes_core/query/
scoring.rs

1//! Shared scoring abstractions for text and sparse vector search
2//!
3//! Provides common types and executors for efficient top-k retrieval:
4//! - `TermCursor`: Unified cursor for both BM25 text and sparse vector posting lists
5//! - `ScoreCollector`: Efficient min-heap for maintaining top-k results
6//! - `MaxScoreExecutor`: Unified Block-Max MaxScore with conjunction optimization
7//! - `ScoredDoc`: Result type with doc_id, score, and ordinal
8
9use std::cmp::Ordering;
10use std::collections::BinaryHeap;
11
12use log::{debug, warn};
13
14use crate::DocId;
15
16/// Avoid eagerly reserving an arbitrarily large top-k heap. Most searches
17/// return far fewer hits than a very large requested limit, so let the heap
18/// grow on demand beyond this point.
19const MAX_INITIAL_SCORE_COLLECTOR_CAPACITY: usize = 8 * 1024;
20
21/// Entry for top-k min-heap
22#[derive(Clone, Copy)]
23pub struct HeapEntry {
24    pub doc_id: DocId,
25    pub score: f32,
26    pub ordinal: u16,
27}
28
29impl PartialEq for HeapEntry {
30    fn eq(&self, other: &Self) -> bool {
31        self.score.to_bits() == other.score.to_bits()
32            && self.doc_id == other.doc_id
33            && self.ordinal == other.ordinal
34    }
35}
36
37impl Eq for HeapEntry {}
38
39impl Ord for HeapEntry {
40    fn cmp(&self, other: &Self) -> Ordering {
41        // Min-heap: lower scores come first (to be evicted).
42        // total_cmp is branchless (compiles to a single comparison instruction).
43        other
44            .score
45            .total_cmp(&self.score)
46            .then_with(|| self.doc_id.cmp(&other.doc_id))
47            .then_with(|| self.ordinal.cmp(&other.ordinal))
48    }
49}
50
51impl PartialOrd for HeapEntry {
52    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
53        Some(self.cmp(other))
54    }
55}
56
57/// Efficient top-k collector using min-heap (internal, scoring-layer)
58///
59/// Maintains the k highest-scoring documents using a min-heap where the
60/// lowest score is at the top for O(1) threshold lookup and O(log k) eviction.
61/// No deduplication — caller must ensure each doc_id is inserted only once.
62///
63/// This is intentionally separate from `TopKCollector` in `collector.rs`:
64/// `ScoreCollector` is used inside `MaxScoreExecutor` where only `(doc_id,
65/// score, ordinal)` tuples exist — no `Scorer` trait, no position tracking,
66/// and the threshold must be inlined for tight block-max loops.
67/// `TopKCollector` wraps a `Scorer` and drives the full `DocSet`/`Scorer`
68/// protocol, collecting positions on demand.
69pub struct ScoreCollector {
70    /// Min-heap of top-k entries (lowest score at top for eviction)
71    heap: BinaryHeap<HeapEntry>,
72    pub k: usize,
73    /// Cached threshold: avoids repeated heap.peek() in hot loops.
74    /// Updated only when the heap changes (insert/pop).
75    cached_threshold: f32,
76    /// Score of the logical sentinel filling every unused top-k slot after
77    /// threshold seeding. Keeping one score here instead of `k - heap.len()`
78    /// entries makes filling those unused slots O(1) time and memory.
79    virtual_threshold: Option<f32>,
80}
81
82impl ScoreCollector {
83    /// Create a new collector for top-k results
84    pub fn new(k: usize) -> Self {
85        Self {
86            heap: BinaryHeap::with_capacity(k.min(MAX_INITIAL_SCORE_COLLECTOR_CAPACITY)),
87            k,
88            cached_threshold: 0.0,
89            virtual_threshold: None,
90        }
91    }
92
93    /// Current score threshold (minimum score to enter top-k)
94    #[inline]
95    pub fn threshold(&self) -> f32 {
96        self.cached_threshold
97    }
98
99    /// Recompute cached threshold from heap state
100    #[inline]
101    fn update_threshold(&mut self) {
102        self.cached_threshold = if let Some(threshold) = self.virtual_threshold {
103            threshold
104        } else if self.heap.len() >= self.k {
105            self.heap.peek().map(|e| e.score).unwrap_or(0.0)
106        } else {
107            0.0
108        };
109    }
110
111    /// Insert a document score. Returns true if inserted in top-k.
112    /// Caller must ensure each doc_id is inserted only once.
113    #[inline]
114    pub fn insert(&mut self, doc_id: DocId, score: f32) -> bool {
115        self.insert_with_ordinal(doc_id, score, 0)
116    }
117
118    /// Insert a document score with ordinal. Returns true if inserted in top-k.
119    /// Caller must ensure each doc_id is inserted only once.
120    #[inline]
121    pub fn insert_with_ordinal(&mut self, doc_id: DocId, score: f32, ordinal: u16) -> bool {
122        if self.k == 0 {
123            return false;
124        }
125        let entry = HeapEntry {
126            doc_id,
127            score,
128            ordinal,
129        };
130        if self.heap.len() < self.k {
131            if let Some(threshold) = self.virtual_threshold {
132                let sentinel = HeapEntry {
133                    doc_id: u32::MAX,
134                    score: threshold,
135                    ordinal: 0,
136                };
137                if entry >= sentinel {
138                    return false;
139                }
140            }
141
142            self.heap.push(entry);
143            // The final real entry displaces the last virtual sentinel.
144            if self.heap.len() == self.k {
145                self.virtual_threshold = None;
146                self.update_threshold();
147            }
148            true
149        } else if self.heap.peek().is_some_and(|worst| entry < *worst) {
150            {
151                let mut worst = self.heap.peek_mut().expect("full heap has a root");
152                *worst = entry;
153            }
154            self.update_threshold();
155            true
156        } else {
157            false
158        }
159    }
160
161    /// Check if a score could potentially enter top-k
162    #[inline]
163    pub fn would_enter(&self, score: f32) -> bool {
164        self.len() < self.k || score > self.cached_threshold
165    }
166
167    /// Check whether this fully identified candidate ranks ahead of the current
168    /// worst retained entry, including deterministic tie breaks.
169    #[inline]
170    pub fn would_enter_candidate(&self, doc_id: DocId, score: f32, ordinal: u16) -> bool {
171        if self.k == 0 {
172            return false;
173        }
174        let entry = HeapEntry {
175            doc_id,
176            score,
177            ordinal,
178        };
179        if let Some(threshold) = self.virtual_threshold {
180            let sentinel = HeapEntry {
181                doc_id: u32::MAX,
182                score: threshold,
183                ordinal: 0,
184            };
185            entry < sentinel
186        } else {
187            self.heap.len() < self.k || self.heap.peek().is_some_and(|worst| entry < *worst)
188        }
189    }
190
191    /// Get the conceptual heap length, including virtual threshold sentinels.
192    #[inline]
193    pub fn len(&self) -> usize {
194        if self.virtual_threshold.is_some() {
195            self.k
196        } else {
197            self.heap.len()
198        }
199    }
200
201    /// Number of real results retained, excluding threshold sentinels.
202    #[inline]
203    pub fn real_len(&self) -> usize {
204        self.heap.len()
205    }
206
207    /// Check if collector is empty
208    #[inline]
209    pub fn is_empty(&self) -> bool {
210        self.len() == 0
211    }
212
213    /// Seed the threshold from a cross-segment shared value.
214    ///
215    /// Logically fills unused slots and replaces retained entries below the new
216    /// floor with virtual dummy entries. This can be called repeatedly while
217    /// another segment raises the shared threshold; equal-scoring real
218    /// candidates win the deterministic doc-id tie break over sentinels.
219    pub fn seed_threshold(&mut self, initial_threshold: f32) {
220        if initial_threshold <= 0.0
221            || self.k == 0
222            || (self.len() >= self.k && initial_threshold <= self.cached_threshold)
223        {
224            return;
225        }
226
227        let sentinel = HeapEntry {
228            doc_id: u32::MAX,
229            score: initial_threshold,
230            ordinal: 0,
231        };
232
233        // When unused slots are already represented by a virtual sentinel, a
234        // new seed only changes the heap if it outranks the old floor. With an
235        // all-real full heap, it must similarly outrank the current root.
236        if let Some(current_threshold) = self.virtual_threshold {
237            let current = HeapEntry {
238                doc_id: u32::MAX,
239                score: current_threshold,
240                ordinal: 0,
241            };
242            if sentinel >= current {
243                return;
244            }
245        } else if self.heap.len() >= self.k
246            && !self.heap.peek().is_some_and(|worst| sentinel < *worst)
247        {
248            return;
249        }
250
251        self.virtual_threshold = Some(initial_threshold);
252        while self.heap.peek().is_some_and(|worst| sentinel < *worst) {
253            self.heap.pop();
254        }
255        self.update_threshold();
256    }
257
258    /// Convert to sorted top-k results (descending by score).
259    /// Filters out sentinel entries (doc_id == u32::MAX) from threshold seeding.
260    pub fn into_sorted_results(self) -> Vec<(DocId, f32, u16)> {
261        let mut results: Vec<(DocId, f32, u16)> = self
262            .heap
263            .into_vec()
264            .into_iter()
265            .filter(|e| e.doc_id != u32::MAX)
266            .map(|e| (e.doc_id, e.score, e.ordinal))
267            .collect();
268
269        // Sort by score descending, then doc_id ascending
270        results.sort_unstable_by(|a, b| {
271            b.1.total_cmp(&a.1)
272                .then_with(|| a.0.cmp(&b.0))
273                .then_with(|| a.2.cmp(&b.2))
274        });
275
276        results
277    }
278}
279
280/// Cross-segment top-k score floor, shared across the parallel/concurrent
281/// per-segment searches of a single query.
282///
283/// Stores an `f32` as raw bits in an atomic so it can be read and monotonically
284/// raised from many threads without a lock. Each segment reads the current
285/// floor as its initial pruning threshold (`ScorerOptions::initial_threshold`)
286/// and, once it has collected a *full* top-k of its own, raises the floor to
287/// its k-th score.
288///
289/// Safety of seeding: a segment only raises the floor after filling its own
290/// heap, so a floor value `v` is always backed by at least `k` real documents
291/// scoring `>= v`. The final merged k-th score is therefore `>= v`, and seeding
292/// any other segment with `v` can never drop a document that belongs in the
293/// final top-k. Completion order is arbitrary, so the floor is best-effort — it
294/// only changes how aggressively later segments prune, never correctness.
295///
296/// The floor carries the query's result-window depth `k` (`for_limit`).
297/// Publishing from a heap shallower than `k` is invalid — a segment with
298/// fewer documents than the window fills its clamped heap early, and its
299/// heap threshold says nothing about the query-global k-th score. Executors
300/// must check `SharedThreshold::covers` before raising the floor with a
301/// full-heap threshold.
302#[derive(Clone, Debug)]
303pub struct SharedThreshold {
304    floor: std::sync::Arc<std::sync::atomic::AtomicU32>,
305    /// Result-window depth the floor is valid for. `usize::MAX` means the
306    /// depth is unknown; reading stays safe, publishing is disabled.
307    k: usize,
308    /// Wall-clock budget of the whole query (anytime mode): executors that
309    /// honour it stop scoring once it passes and flag the result truncated.
310    deadline: Option<std::time::Instant>,
311    /// Set by any executor that stopped early because of `deadline`.
312    truncated: std::sync::Arc<std::sync::atomic::AtomicBool>,
313}
314
315impl Default for SharedThreshold {
316    fn default() -> Self {
317        Self::new()
318    }
319}
320
321impl SharedThreshold {
322    /// A fresh floor of 0.0 (no pruning seed) with an unknown window depth.
323    /// Executors can read and manually raise it, but never publish their own
324    /// full-heap thresholds into it.
325    pub fn new() -> Self {
326        Self::with_depth(usize::MAX)
327    }
328
329    /// A fresh floor valid for a query fetching `limit` results.
330    pub fn for_limit(limit: usize) -> Self {
331        Self::with_depth(limit)
332    }
333
334    fn with_depth(k: usize) -> Self {
335        Self {
336            // 0.0_f32.to_bits() == 0, matching AtomicU32::default().
337            floor: std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)),
338            k,
339            deadline: None,
340            truncated: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
341        }
342    }
343
344    /// Attach a wall-clock budget (`None` = unbounded).
345    pub fn with_deadline(mut self, deadline: Option<std::time::Instant>) -> Self {
346        self.deadline = deadline;
347        self
348    }
349
350    /// The query's deadline, if any.
351    pub fn deadline(&self) -> Option<std::time::Instant> {
352        self.deadline
353    }
354
355    /// Whether the deadline has passed.
356    #[inline]
357    pub fn expired(&self) -> bool {
358        self.deadline
359            .is_some_and(|deadline| std::time::Instant::now() >= deadline)
360    }
361
362    /// Record that an executor stopped early because the deadline passed.
363    pub fn mark_truncated(&self) {
364        self.truncated
365            .store(true, std::sync::atomic::Ordering::Relaxed);
366    }
367
368    /// Whether any executor of this query stopped early.
369    pub fn truncated(&self) -> bool {
370        self.truncated.load(std::sync::atomic::Ordering::Relaxed)
371    }
372
373    /// True when a full heap of `heap_depth` distinct documents backs a valid
374    /// query-global floor for this threshold's result window.
375    #[inline]
376    pub(crate) fn covers(&self, heap_depth: usize) -> bool {
377        heap_depth >= self.k
378    }
379
380    /// Current floor.
381    #[inline]
382    pub fn get(&self) -> f32 {
383        f32::from_bits(self.floor.load(std::sync::atomic::Ordering::Relaxed))
384    }
385
386    /// Raise the floor to `score` if it is strictly higher. Monotonic; a lower
387    /// or non-positive `score` is ignored. Scores here are BM25/sparse and thus
388    /// non-negative, but the comparison is done on `f32` values (not raw bits)
389    /// so it stays correct regardless.
390    pub fn raise(&self, score: f32) {
391        // Ignore non-positive scores; a NaN falls through harmlessly (the CAS
392        // loop condition below is false for NaN, so nothing is stored).
393        if score <= 0.0 {
394            return;
395        }
396        use std::sync::atomic::Ordering::Relaxed;
397        let bits = score.to_bits();
398        let mut cur = self.floor.load(Relaxed);
399        while f32::from_bits(cur) < score {
400            match self
401                .floor
402                .compare_exchange_weak(cur, bits, Relaxed, Relaxed)
403            {
404                Ok(_) => break,
405                Err(actual) => cur = actual,
406            }
407        }
408    }
409}
410
411/// Search result from MaxScore execution
412#[derive(Debug, Clone, Copy)]
413pub struct ScoredDoc {
414    pub doc_id: DocId,
415    pub score: f32,
416    /// Ordinal for multi-valued fields (which vector in the field matched)
417    pub ordinal: u16,
418}
419
420/// Unified Block-Max MaxScore executor for top-k retrieval
421///
422/// Works with both full-text (BM25) and sparse vector (dot product) queries
423/// through the polymorphic `TermCursor`. Combines three optimizations:
424/// 1. **MaxScore partitioning** (Turtle & Flood 1995): terms split into essential
425///    (must check) and non-essential (only scored if candidate is promising)
426/// 2. **Block-max pruning** (Ding & Suel 2011): skip blocks where per-block
427///    upper bounds can't beat the current threshold
428/// 3. **Conjunction optimization** (Lucene/Grand 2023): progressively intersect
429///    essential terms as threshold rises, skipping docs that lack enough terms
430pub struct MaxScoreExecutor<'a> {
431    /// Metric labels (index, field) — set via `with_metric_labels`; empty
432    /// strings render as "unknown"/"?" is avoided by callers passing real
433    /// names from the schema.
434    metric_index: &'a str,
435    metric_field: &'a str,
436    cursors: Vec<TermCursor<'a>>,
437    prefix_sums: Vec<f32>,
438    collector: ScoreCollector,
439    inv_heap_factor: f32,
440    predicate: Option<super::DocPredicate<'a>>,
441    /// Query-global budget: checked every few thousand loop iterations;
442    /// an expired deadline ends traversal with the results so far.
443    budget: Option<SharedThreshold>,
444}
445
446/// Where a text cursor reads the length of a scoring unit: chunk lengths of a
447/// chunked field, or the persisted per-document field lengths (norms) of a
448/// plain field. Without either, `tf` stands in for the length.
449#[derive(Clone, Copy)]
450pub enum LengthSource<'a> {
451    Chunks(&'a crate::segment::chunk_map::ChunkMap),
452    Docs(&'a crate::segment::chunk_map::DocLengths),
453}
454
455impl LengthSource<'_> {
456    #[inline]
457    pub fn length(&self, id: u32) -> u32 {
458        match self {
459            LengthSource::Chunks(map) => map.length(id),
460            LengthSource::Docs(lengths) => lengths.length(id),
461        }
462    }
463}
464
465/// Unified term cursor for Block-Max MaxScore execution.
466///
467/// All per-position decode buffers (`doc_ids`, `scores`, `ordinals`) live in
468/// the struct directly and are filled by `ensure_block_loaded`.
469///
470/// Skip-list metadata is **not** materialized — it is read lazily from the
471/// underlying source (`BlockPostingList` for text, `SparseIndex` for sparse),
472/// both backed by zero-copy mmap'd `OwnedBytes`.
473pub(crate) struct TermCursor<'a> {
474    pub max_score: f32,
475    num_blocks: usize,
476    // ── Per-position state (filled by ensure_block_loaded) ──────────
477    block_idx: usize,
478    doc_ids: Vec<u32>,
479    scores: Vec<f32>,
480    ordinals: Vec<u16>,
481    pos: usize,
482    block_loaded: bool,
483    exhausted: bool,
484    // ── Lazy ordinal decode (sparse only) ───────────────────────────
485    /// When true, ordinal decode is deferred until ordinal_mut() is called.
486    /// Set to true for MaxScoreExecutor cursors (most blocks never need ordinals).
487    lazy_ordinals: bool,
488    /// Whether ordinals have been decoded for the current block.
489    ordinals_loaded: bool,
490    /// Stored sparse block for deferred ordinal decode (cheap Arc clone of mmap data).
491    current_sparse_block: Option<crate::structures::SparseBlock>,
492    // ── Block decode + skip access source ───────────────────────────
493    variant: CursorVariant<'a>,
494}
495
496// One cursor per query term; the text variant carries the decoded-block
497// state inline on purpose (no indirection on the scoring path).
498#[allow(clippy::large_enum_variant)]
499enum CursorVariant<'a> {
500    /// Full-text BM25 — in-memory BlockPostingList (skip list + block data)
501    Text {
502        list: crate::structures::BlockPostingList,
503        idf: f32,
504        /// Precomputed: idf * (BM25_K1 + 1.0) — numerator scale factor
505        idf_times_k1_plus_1: f32,
506        /// Precomputed: 1.0 + BM25_K1 * (BM25_B / avg_field_len) — denominator tf coefficient
507        denom_tf_coeff: f32,
508        /// Precomputed: BM25_K1 * (1.0 - BM25_B) — denominator constant
509        denom_const: f32,
510        /// Precomputed: BM25_K1 * BM25_B / avg_len — per-token length
511        /// coefficient, used when `lengths` supplies real chunk lengths.
512        denom_len_coeff: f32,
513        /// Real per-posting lengths (chunk lengths or document norms).
514        /// `None` keeps the historic `tf`-as-length approximation.
515        lengths: Option<LengthSource<'a>>,
516        /// Block bounds may use the block's minimum length: only when the
517        /// list stores one and scoring uses real lengths (a `tf`-as-length
518        /// score is not bounded by a real-length bound).
519        length_bounds: bool,
520        /// Average length used by the bounds (matches the scoring average).
521        avg_len: f32,
522        /// Per-field k1/b, used by the block and group bounds.
523        params: super::Bm25Params,
524        tfs: Vec<u32>,
525        /// Deferred TF decode state: (block_offset, tf_start, count).
526        /// Set when doc_ids are decoded but TFs/scores are not yet computed.
527        deferred_tf: Option<(usize, usize, usize)>,
528    },
529    /// Sparse vector — mmap'd SparseIndex (skip entries + block data)
530    Sparse {
531        si: &'a crate::segment::SparseIndex,
532        query_weight: f32,
533        skip_start: usize,
534        block_data_offset: u64,
535    },
536}
537
538// ── TermCursor async/sync macros ──────────────────────────────────────────
539//
540// Parameterised on:
541//   $load_block_fn – load_block_direct | load_block_direct_sync  (sparse I/O)
542//   $ensure_fn     – ensure_block_loaded | ensure_block_loaded_sync
543//   $($aw)*        – .await  (present for async, absent for sync)
544
545macro_rules! cursor_ensure_block {
546    ($self:ident, $load_block_fn:ident, $($aw:tt)*) => {{
547        if $self.exhausted || $self.block_loaded {
548            return Ok(!$self.exhausted);
549        }
550        match &mut $self.variant {
551            CursorVariant::Text {
552                list,
553                deferred_tf,
554                ..
555            } => {
556                if let Some(state) = list.decode_block_doc_ids_only($self.block_idx, &mut $self.doc_ids) {
557                    *deferred_tf = Some(state);
558                    $self.scores.clear();
559                    $self.pos = 0;
560                    $self.block_loaded = true;
561                    Ok(true)
562                } else {
563                    $self.exhausted = true;
564                    Ok(false)
565                }
566            }
567            CursorVariant::Sparse {
568                si,
569                query_weight,
570                skip_start,
571                block_data_offset,
572                ..
573            } => {
574                let block = si
575                    .$load_block_fn(*skip_start, *block_data_offset, $self.block_idx)
576                    $($aw)* ?;
577                match block {
578                    Some(b) => {
579                        b.decode_doc_ids_into(&mut $self.doc_ids);
580                        b.decode_scored_weights_into(*query_weight, &mut $self.scores);
581                        if $self.lazy_ordinals {
582                            // Defer ordinal decode until ordinal_mut() is called.
583                            // Stores cheap Arc-backed mmap slice, no copy.
584                            $self.current_sparse_block = Some(b);
585                            $self.ordinals_loaded = false;
586                        } else {
587                            b.decode_ordinals_into(&mut $self.ordinals);
588                            $self.ordinals_loaded = true;
589                            $self.current_sparse_block = None;
590                        }
591                        $self.pos = 0;
592                        $self.block_loaded = true;
593                        Ok(true)
594                    }
595                    None => {
596                        $self.exhausted = true;
597                        Ok(false)
598                    }
599                }
600            }
601        }
602    }};
603}
604
605macro_rules! cursor_advance {
606    ($self:ident, $ensure_fn:ident, $($aw:tt)*) => {{
607        if $self.exhausted {
608            return Ok(u32::MAX);
609        }
610        $self.$ensure_fn() $($aw)* ?;
611        if $self.exhausted {
612            return Ok(u32::MAX);
613        }
614        Ok($self.advance_pos())
615    }};
616}
617
618macro_rules! cursor_seek {
619    ($self:ident, $ensure_fn:ident, $target:expr, $($aw:tt)*) => {{
620        if let Some(doc) = $self.seek_prepare($target) {
621            return Ok(doc);
622        }
623        $self.$ensure_fn() $($aw)* ?;
624        if $self.seek_finish($target) {
625            $self.$ensure_fn() $($aw)* ?;
626        }
627        Ok($self.doc())
628    }};
629}
630
631impl<'a> TermCursor<'a> {
632    /// Full-text BM25 cursor with explicit per-field parameters.
633    pub fn text_with_params(
634        posting_list: crate::structures::BlockPostingList,
635        idf: f32,
636        avg_field_len: f32,
637        lengths: Option<LengthSource<'a>>,
638        params: super::Bm25Params,
639    ) -> Self {
640        Self::text_with_lengths(posting_list, idf, avg_field_len, lengths, params)
641    }
642
643    fn text_with_lengths(
644        posting_list: crate::structures::BlockPostingList,
645        idf: f32,
646        avg_field_len: f32,
647        lengths: Option<LengthSource<'a>>,
648        params: super::Bm25Params,
649    ) -> Self {
650        let max_tf = posting_list.max_tf() as f32;
651        let safe_avg = avg_field_len.max(1.0);
652        let length_bounds = lengths.is_some() && posting_list.min_len().is_some();
653        let max_score = match posting_list.min_len() {
654            Some(min_len) if length_bounds => {
655                params.upper_bound_with_len(max_tf.max(1.0), idf, min_len as f32, safe_avg)
656            }
657            _ => params.upper_bound(max_tf.max(1.0), idf),
658        };
659        let num_blocks = posting_list.num_blocks();
660        Self {
661            max_score,
662            num_blocks,
663            block_idx: 0,
664            doc_ids: Vec::with_capacity(128),
665            scores: Vec::with_capacity(128),
666            ordinals: Vec::new(),
667            pos: 0,
668            block_loaded: false,
669            exhausted: num_blocks == 0,
670            lazy_ordinals: false,
671            ordinals_loaded: true, // text cursors never have ordinals
672            current_sparse_block: None,
673            variant: CursorVariant::Text {
674                list: posting_list,
675                idf,
676                idf_times_k1_plus_1: idf * (params.k1 + 1.0),
677                denom_tf_coeff: 1.0 + params.k1 * (params.b / safe_avg),
678                denom_const: params.k1 * (1.0 - params.b),
679                denom_len_coeff: params.k1 * params.b / safe_avg,
680                lengths,
681                length_bounds,
682                avg_len: safe_avg,
683                params,
684                tfs: Vec::with_capacity(128),
685                deferred_tf: None,
686            },
687        }
688    }
689
690    /// Create a sparse vector cursor with lazy block loading.
691    /// Skip entries are **not** copied — they are read from `SparseIndex` mmap on demand.
692    pub fn sparse(
693        si: &'a crate::segment::SparseIndex,
694        query_weight: f32,
695        skip_start: usize,
696        skip_count: usize,
697        global_max_weight: f32,
698        block_data_offset: u64,
699    ) -> Self {
700        Self {
701            max_score: query_weight.abs() * global_max_weight,
702            num_blocks: skip_count,
703            block_idx: 0,
704            doc_ids: Vec::with_capacity(256),
705            scores: Vec::with_capacity(256),
706            ordinals: Vec::with_capacity(256),
707            pos: 0,
708            block_loaded: false,
709            exhausted: skip_count == 0,
710            lazy_ordinals: false,
711            ordinals_loaded: true,
712            current_sparse_block: None,
713            variant: CursorVariant::Sparse {
714                si,
715                query_weight,
716                skip_start,
717                block_data_offset,
718            },
719        }
720    }
721
722    // ── Skip-entry access (lazy, zero-copy for sparse) ──────────────────
723
724    #[inline]
725    fn block_first_doc(&self, idx: usize) -> DocId {
726        match &self.variant {
727            CursorVariant::Text { list, .. } => list.block_first_doc(idx).unwrap_or(u32::MAX),
728            CursorVariant::Sparse { si, skip_start, .. } => {
729                si.read_skip_entry(*skip_start + idx).first_doc
730            }
731        }
732    }
733
734    #[inline]
735    fn block_last_doc(&self, idx: usize) -> DocId {
736        match &self.variant {
737            CursorVariant::Text { list, .. } => list.block_last_doc(idx).unwrap_or(0),
738            CursorVariant::Sparse { si, skip_start, .. } => {
739                si.read_skip_entry(*skip_start + idx).last_doc
740            }
741        }
742    }
743
744    // ── Read-only accessors ─────────────────────────────────────────────
745
746    #[inline]
747    pub fn doc(&self) -> DocId {
748        if self.exhausted {
749            return u32::MAX;
750        }
751        if self.block_loaded {
752            debug_assert!(self.pos < self.doc_ids.len());
753            // SAFETY: pos < doc_ids.len() is maintained by advance_pos/ensure_block_loaded.
754            unsafe { *self.doc_ids.get_unchecked(self.pos) }
755        } else {
756            self.block_first_doc(self.block_idx)
757        }
758    }
759
760    #[inline]
761    pub fn ordinal(&self) -> u16 {
762        if !self.block_loaded || self.ordinals.is_empty() {
763            return 0;
764        }
765        debug_assert!(self.pos < self.ordinals.len());
766        // SAFETY: pos < ordinals.len() is maintained by advance_pos/ensure_block_loaded.
767        unsafe { *self.ordinals.get_unchecked(self.pos) }
768    }
769
770    /// Lazily-decoded ordinal accessor for MaxScore executor.
771    ///
772    /// When `lazy_ordinals=true`, ordinals are not decoded during block loading.
773    /// This method triggers the deferred decode on first access, amortized over
774    /// the block. Subsequent calls within the same block are free.
775    #[inline]
776    pub fn ordinal_mut(&mut self) -> u16 {
777        if !self.block_loaded {
778            return 0;
779        }
780        if !self.ordinals_loaded {
781            if let Some(ref block) = self.current_sparse_block {
782                block.decode_ordinals_into(&mut self.ordinals);
783            }
784            self.ordinals_loaded = true;
785        }
786        if self.ordinals.is_empty() {
787            return 0;
788        }
789        debug_assert!(self.pos < self.ordinals.len());
790        unsafe { *self.ordinals.get_unchecked(self.pos) }
791    }
792
793    #[inline]
794    pub fn score(&self) -> f32 {
795        if !self.block_loaded {
796            return 0.0;
797        }
798        debug_assert!(self.pos < self.scores.len());
799        // SAFETY: pos < scores.len() is maintained by advance_pos/ensure_block_loaded.
800        unsafe { *self.scores.get_unchecked(self.pos) }
801    }
802
803    /// Ensure BM25 scores are computed for the current block (lazy TF decode).
804    ///
805    /// For text cursors, TF unpacking and BM25 scoring are deferred from block
806    /// loading until this method is called, saving work for blocks skipped by
807    /// block-max or conjunction pruning. No-op for sparse cursors.
808    #[inline]
809    pub fn ensure_scores(&mut self) {
810        if self.block_loaded && self.scores.is_empty() {
811            self.compute_deferred_scores();
812        }
813    }
814
815    #[inline]
816    pub fn current_block_max_score(&self) -> f32 {
817        if self.exhausted {
818            return 0.0;
819        }
820        match &self.variant {
821            CursorVariant::Text { .. } => self.text_block_bound(self.block_idx),
822            CursorVariant::Sparse {
823                si,
824                query_weight,
825                skip_start,
826                ..
827            } => query_weight.abs() * si.read_skip_entry(*skip_start + self.block_idx).max_weight,
828        }
829    }
830
831    /// Upper bound over the L1 group (eight blocks) containing the current
832    /// block, for text lists that store superblock bounds; `None` when the
833    /// cursor cannot bound a whole group (sparse, legacy lists).
834    #[inline]
835    pub fn current_group_max_score(&self) -> Option<f32> {
836        if self.exhausted {
837            return Some(0.0);
838        }
839        match &self.variant {
840            CursorVariant::Text { .. } => self.text_group_bound(self.block_idx),
841            CursorVariant::Sparse { .. } => None,
842        }
843    }
844
845    /// Whether this cursor reads an in-memory text posting list (all of its
846    /// I/O is synchronous, so the windowed executor can drive it).
847    #[inline]
848    pub(crate) fn is_text(&self) -> bool {
849        matches!(self.variant, CursorVariant::Text { .. })
850    }
851
852    /// Upper bound of text block `idx` from its `(max_tf, min_len)` word.
853    fn text_block_bound(&self, idx: usize) -> f32 {
854        match &self.variant {
855            CursorVariant::Text {
856                list,
857                idf,
858                length_bounds,
859                avg_len,
860                params,
861                ..
862            } => {
863                let (max_tf, min_len) = list.block_bounds(idx).unwrap_or((0, None));
864                match min_len {
865                    Some(min_len) if *length_bounds => params.upper_bound_with_len(
866                        (max_tf as f32).max(1.0),
867                        *idf,
868                        min_len as f32,
869                        *avg_len,
870                    ),
871                    _ => params.upper_bound((max_tf as f32).max(1.0), *idf),
872                }
873            }
874            CursorVariant::Sparse { .. } => self.max_score,
875        }
876    }
877
878    /// Upper bound of the L1 group containing text block `idx`.
879    fn text_group_bound(&self, idx: usize) -> Option<f32> {
880        match &self.variant {
881            CursorVariant::Text {
882                list,
883                idf,
884                length_bounds,
885                avg_len,
886                params,
887                ..
888            } => {
889                let (max_tf, min_len) = list.group_bounds(idx)?;
890                Some(if *length_bounds {
891                    params.upper_bound_with_len(
892                        (max_tf as f32).max(1.0),
893                        *idf,
894                        min_len as f32,
895                        *avg_len,
896                    )
897                } else {
898                    params.upper_bound((max_tf as f32).max(1.0), *idf)
899                })
900            }
901            CursorVariant::Sparse { .. } => None,
902        }
903    }
904
905    /// Upper bound of this cursor's contribution to any id in `[from, to]`:
906    /// the largest block bound over the blocks intersecting the range, with
907    /// one L1 word standing in for a group that lies inside it. Reads skip
908    /// entries only; no block is decoded (Lucene `advanceShallow` +
909    /// `getMaxScore(upTo)`).
910    pub(crate) fn window_upper_bound(&self, from: DocId, to: DocId) -> f32 {
911        if self.exhausted {
912            return 0.0;
913        }
914        let CursorVariant::Text { list, .. } = &self.variant else {
915            return self.max_score;
916        };
917        // Postings the cursor has already passed cannot score again: the
918        // bound starts at its current id, not at the window start.
919        let start = from.max(self.doc());
920        if start > to {
921            return 0.0;
922        }
923        let Some(mut idx) = list.seek_block(start, self.block_idx) else {
924            return 0.0;
925        };
926        let mut bound = 0.0f32;
927        while idx < self.num_blocks {
928            if list.block_first_doc(idx).unwrap_or(u32::MAX) > to {
929                break;
930            }
931            if list.is_group_start(idx)
932                && list.group_last_doc(idx).is_some_and(|last| last <= to)
933                && let Some(group_bound) = self.text_group_bound(idx)
934            {
935                bound = bound.max(group_bound);
936                idx = list.next_group_block(idx);
937                continue;
938            }
939            bound = bound.max(self.text_block_bound(idx));
940            idx += 1;
941        }
942        bound
943    }
944
945    /// Add this cursor's scores for every id in `[from, to]` to the window
946    /// buffers (`scores[id - from]`, bit `id - from` of `mask`) and leave the
947    /// cursor on its first id after `to`. Whole runs of a block are
948    /// processed in one pass over its decoded arrays. Text cursors only.
949    pub(crate) fn score_window_sync(
950        &mut self,
951        from: DocId,
952        to: DocId,
953        scores: &mut [f32],
954        mask: &mut [u64],
955    ) -> crate::Result<u32> {
956        let mut matched = 0u32;
957        loop {
958            if self.exhausted {
959                return Ok(matched);
960            }
961            if !self.block_loaded {
962                if self.block_first_doc(self.block_idx) > to {
963                    return Ok(matched);
964                }
965                self.ensure_block_loaded_sync()?;
966                if self.exhausted {
967                    return Ok(matched);
968                }
969            }
970            if self.doc_ids[self.pos] > to {
971                return Ok(matched);
972            }
973            self.ensure_scores();
974            let remaining = &self.doc_ids[self.pos..];
975            let end = if to == u32::MAX {
976                remaining.len()
977            } else {
978                crate::structures::simd::find_first_ge_u32(remaining, to + 1)
979            };
980            let block_scores = &self.scores[self.pos..self.pos + end];
981            for (doc, score) in remaining[..end].iter().zip(block_scores) {
982                let slot = (doc - from) as usize;
983                scores[slot] += score;
984                mask[slot >> 6] |= 1u64 << (slot & 63);
985            }
986            matched += end as u32;
987            self.pos += end;
988            if self.pos >= self.doc_ids.len() {
989                self.block_idx += 1;
990                self.block_loaded = false;
991                if self.block_idx >= self.num_blocks {
992                    self.exhausted = true;
993                    return Ok(matched);
994                }
995            } else {
996                return Ok(matched);
997            }
998        }
999    }
1000
1001    /// Move past every id `<= to`, skipping whole blocks that end before it
1002    /// without decoding them.
1003    pub(crate) fn skip_past_sync(&mut self, to: DocId) -> crate::Result<()> {
1004        if to == u32::MAX {
1005            self.exhausted = true;
1006            return Ok(());
1007        }
1008        while !self.exhausted && self.block_last_doc(self.block_idx) <= to {
1009            self.skip_to_next_block();
1010        }
1011        if !self.exhausted && self.doc() <= to {
1012            self.seek_sync(to + 1)?;
1013        }
1014        Ok(())
1015    }
1016
1017    /// Last doc of the L1 group containing the current block (text only).
1018    #[inline]
1019    pub fn current_group_last_doc(&self) -> DocId {
1020        match &self.variant {
1021            CursorVariant::Text { list, .. } => list.group_last_doc(self.block_idx).unwrap_or(0),
1022            CursorVariant::Sparse { .. } => self.block_last_doc(self.block_idx),
1023        }
1024    }
1025
1026    /// Jump past the current L1 group (text) or block (sparse).
1027    pub fn skip_to_next_group(&mut self) -> DocId {
1028        if self.exhausted {
1029            return u32::MAX;
1030        }
1031        let next = match &self.variant {
1032            CursorVariant::Text { list, .. } => list.next_group_block(self.block_idx),
1033            CursorVariant::Sparse { .. } => self.block_idx + 1,
1034        };
1035        self.block_idx = next;
1036        self.block_loaded = false;
1037        if self.block_idx >= self.num_blocks {
1038            self.exhausted = true;
1039            return u32::MAX;
1040        }
1041        self.block_first_doc(self.block_idx)
1042    }
1043
1044    // ── Block navigation ────────────────────────────────────────────────
1045
1046    pub fn skip_to_next_block(&mut self) -> DocId {
1047        if self.exhausted {
1048            return u32::MAX;
1049        }
1050        self.block_idx += 1;
1051        self.block_loaded = false;
1052        if self.block_idx >= self.num_blocks {
1053            self.exhausted = true;
1054            return u32::MAX;
1055        }
1056        self.block_first_doc(self.block_idx)
1057    }
1058
1059    #[inline]
1060    fn advance_pos(&mut self) -> DocId {
1061        self.pos += 1;
1062        if self.pos >= self.doc_ids.len() {
1063            self.block_idx += 1;
1064            self.block_loaded = false;
1065            if self.block_idx >= self.num_blocks {
1066                self.exhausted = true;
1067                return u32::MAX;
1068            }
1069        }
1070        self.doc()
1071    }
1072
1073    /// Compute BM25 scores from deferred TF data (lazy decode for text cursors).
1074    #[inline(never)]
1075    fn compute_deferred_scores(&mut self) {
1076        if let CursorVariant::Text {
1077            list,
1078            idf_times_k1_plus_1,
1079            denom_tf_coeff,
1080            denom_const,
1081            denom_len_coeff,
1082            lengths,
1083            tfs,
1084            deferred_tf,
1085            ..
1086        } = &mut self.variant
1087            && let Some((block_offset, tf_start, count)) = deferred_tf.take()
1088        {
1089            list.decode_block_tfs_deferred(block_offset, tf_start, count, tfs);
1090            let num_scale = *idf_times_k1_plus_1;
1091            let d_tf = *denom_tf_coeff;
1092            let d_const = *denom_const;
1093            let d_len = *denom_len_coeff;
1094            self.scores.clear();
1095            self.scores.resize(count, 0.0);
1096            match lengths {
1097                // Real BM25 length normalisation per chunk or document.
1098                Some(source) => {
1099                    for i in 0..count {
1100                        let tf = unsafe { *tfs.get_unchecked(i) } as f32;
1101                        let vid = unsafe { *self.doc_ids.get_unchecked(i) };
1102                        let len = source.length(vid) as f32;
1103                        let score = (num_scale * tf) / (tf + d_const + d_len * len);
1104                        unsafe {
1105                            *self.scores.get_unchecked_mut(i) = score;
1106                        }
1107                    }
1108                }
1109                None => {
1110                    for i in 0..count {
1111                        let tf = unsafe { *tfs.get_unchecked(i) } as f32;
1112                        let score = (num_scale * tf) / (d_tf * tf + d_const);
1113                        unsafe {
1114                            *self.scores.get_unchecked_mut(i) = score;
1115                        }
1116                    }
1117                }
1118            }
1119        }
1120    }
1121
1122    // ── Block loading / advance / seek ─────────────────────────────────
1123    //
1124    // Macros parameterised on sparse I/O method + optional .await to
1125    // stamp out both async and sync variants without duplication.
1126
1127    pub async fn ensure_block_loaded(&mut self) -> crate::Result<bool> {
1128        cursor_ensure_block!(self, load_block_direct, .await)
1129    }
1130
1131    pub fn ensure_block_loaded_sync(&mut self) -> crate::Result<bool> {
1132        cursor_ensure_block!(self, load_block_direct_sync,)
1133    }
1134
1135    pub async fn advance(&mut self) -> crate::Result<DocId> {
1136        cursor_advance!(self, ensure_block_loaded, .await)
1137    }
1138
1139    pub fn advance_sync(&mut self) -> crate::Result<DocId> {
1140        cursor_advance!(self, ensure_block_loaded_sync,)
1141    }
1142
1143    pub async fn seek(&mut self, target: DocId) -> crate::Result<DocId> {
1144        cursor_seek!(self, ensure_block_loaded, target, .await)
1145    }
1146
1147    pub fn seek_sync(&mut self, target: DocId) -> crate::Result<DocId> {
1148        cursor_seek!(self, ensure_block_loaded_sync, target,)
1149    }
1150
1151    fn seek_prepare(&mut self, target: DocId) -> Option<DocId> {
1152        if self.exhausted {
1153            return Some(u32::MAX);
1154        }
1155
1156        // Fast path: target is within the currently loaded block
1157        if self.block_loaded
1158            && let Some(&last) = self.doc_ids.last()
1159        {
1160            if last >= target && self.doc_ids[self.pos] < target {
1161                let remaining = &self.doc_ids[self.pos..];
1162                self.pos += crate::structures::simd::find_first_ge_u32(remaining, target);
1163                if self.pos >= self.doc_ids.len() {
1164                    self.block_idx += 1;
1165                    self.block_loaded = false;
1166                    if self.block_idx >= self.num_blocks {
1167                        self.exhausted = true;
1168                        return Some(u32::MAX);
1169                    }
1170                }
1171                return Some(self.doc());
1172            }
1173            if self.doc_ids[self.pos] >= target {
1174                return Some(self.doc());
1175            }
1176        }
1177
1178        // Seek to the block containing target
1179        let lo = match &self.variant {
1180            // Text: SIMD-accelerated 2-level seek (L1 + L0)
1181            CursorVariant::Text { list, .. } => match list.seek_block(target, self.block_idx) {
1182                Some(idx) => idx,
1183                None => {
1184                    self.exhausted = true;
1185                    return Some(u32::MAX);
1186                }
1187            },
1188            // Sparse: binary search on skip entries (lazy mmap reads)
1189            CursorVariant::Sparse { .. } => {
1190                let mut lo = self.block_idx;
1191                let mut hi = self.num_blocks;
1192                while lo < hi {
1193                    let mid = lo + (hi - lo) / 2;
1194                    if self.block_last_doc(mid) < target {
1195                        lo = mid + 1;
1196                    } else {
1197                        hi = mid;
1198                    }
1199                }
1200                lo
1201            }
1202        };
1203        if lo >= self.num_blocks {
1204            self.exhausted = true;
1205            return Some(u32::MAX);
1206        }
1207        if lo != self.block_idx || !self.block_loaded {
1208            self.block_idx = lo;
1209            self.block_loaded = false;
1210        }
1211        None
1212    }
1213
1214    #[inline]
1215    fn seek_finish(&mut self, target: DocId) -> bool {
1216        if self.exhausted {
1217            return false;
1218        }
1219        self.pos = crate::structures::simd::find_first_ge_u32(&self.doc_ids, target);
1220        if self.pos >= self.doc_ids.len() {
1221            self.block_idx += 1;
1222            self.block_loaded = false;
1223            if self.block_idx >= self.num_blocks {
1224                self.exhausted = true;
1225                return false;
1226            }
1227            return true;
1228        }
1229        false
1230    }
1231}
1232
1233/// Macro to stamp out the Block-Max MaxScore loop for both async and sync paths.
1234///
1235/// `$ensure`, `$advance`, `$seek` are cursor method idents (async or _sync variants).
1236/// `$($aw:tt)*` captures `.await` for async or nothing for sync.
1237macro_rules! bms_execute_loop {
1238    ($self:ident, $ensure:ident, $advance:ident, $seek:ident, $($aw:tt)*) => {{
1239        let n = $self.cursors.len();
1240
1241        // Load first block for each cursor (ensures doc() returns real values)
1242        for cursor in &mut $self.cursors {
1243            cursor.$ensure() $($aw)* ?;
1244        }
1245
1246        let mut docs_scored = 0u64;
1247        let mut docs_skipped = 0u64;
1248        let mut blocks_skipped = 0u64;
1249        let mut groups_skipped = 0u64;
1250        let mut conjunction_skipped = 0u64;
1251        let mut ordinal_scores: Vec<(u16, f32)> = Vec::with_capacity(n * 2);
1252        let _bms_start = std::time::Instant::now();
1253
1254        let inv_heap_factor = $self.inv_heap_factor;
1255        let mut adjusted_threshold = $self.collector.threshold() * inv_heap_factor - 1e-6;
1256        let mut iterations: u64 = 0;
1257
1258        loop {
1259            // Anytime budget: a coarse deadline check (one clock read per
1260            // 4096 iterations); the results collected so far are returned
1261            // and the query is flagged truncated.
1262            iterations += 1;
1263            if iterations & 0xFFF == 0
1264                && let Some(budget) = &$self.budget
1265                && budget.expired()
1266            {
1267                budget.mark_truncated();
1268                log::debug!(
1269                    "MaxScoreExecutor: deadline reached after {} iterations, {} scored",
1270                    iterations,
1271                    docs_scored
1272                );
1273                break;
1274            }
1275            let partition = $self.find_partition();
1276            if partition >= n {
1277                break;
1278            }
1279
1280            // Find minimum doc_id across essential cursors and collect
1281            // which cursors are at min_doc (avoids redundant re-checks in
1282            // conjunction, block-max, predicate, and scoring passes).
1283            let mut min_doc = u32::MAX;
1284            // Smallest essential doc after min_doc: the first doc where a
1285            // cursor not at min_doc can contribute, hence the farthest a
1286            // block skip may safely go.
1287            let mut next_other = u32::MAX;
1288            let mut at_min_mask = 0u64; // bitset of cursor indices at min_doc
1289            for i in partition..n {
1290                let doc = $self.cursors[i].doc();
1291                match doc.cmp(&min_doc) {
1292                    std::cmp::Ordering::Less => {
1293                        next_other = min_doc;
1294                        min_doc = doc;
1295                        at_min_mask = 1u64 << (i as u32);
1296                    }
1297                    std::cmp::Ordering::Equal => {
1298                        at_min_mask |= 1u64 << (i as u32);
1299                    }
1300                    std::cmp::Ordering::Greater => {
1301                        if doc < next_other {
1302                            next_other = doc;
1303                        }
1304                    }
1305                }
1306            }
1307            if min_doc == u32::MAX {
1308                break;
1309            }
1310
1311            let non_essential_upper = if partition > 0 {
1312                $self.prefix_sums[partition - 1]
1313            } else {
1314                0.0
1315            };
1316
1317            // --- Conjunction optimization ---
1318            if $self.collector.len() >= $self.collector.k {
1319                let mut present_upper: f32 = 0.0;
1320                let mut mask = at_min_mask;
1321                while mask != 0 {
1322                    let i = mask.trailing_zeros() as usize;
1323                    present_upper += $self.cursors[i].max_score;
1324                    mask &= mask - 1;
1325                }
1326
1327                if present_upper + non_essential_upper < adjusted_threshold {
1328                    let mut mask = at_min_mask;
1329                    while mask != 0 {
1330                        let i = mask.trailing_zeros() as usize;
1331                        $self.cursors[i].$ensure() $($aw)* ?;
1332                        $self.cursors[i].$advance() $($aw)* ?;
1333                        mask &= mask - 1;
1334                    }
1335                    conjunction_skipped += 1;
1336                    continue;
1337                }
1338            }
1339
1340            // --- Block-max pruning ---
1341            if $self.collector.len() >= $self.collector.k {
1342                let mut block_max_sum: f32 = 0.0;
1343                let mut mask = at_min_mask;
1344                while mask != 0 {
1345                    let i = mask.trailing_zeros() as usize;
1346                    block_max_sum += $self.cursors[i].current_block_max_score();
1347                    mask &= mask - 1;
1348                }
1349
1350                if block_max_sum + non_essential_upper < adjusted_threshold {
1351                    // Block-Max MaxScore skip: every document before
1352                    // `next_other` is covered only by the cursors at min_doc
1353                    // (plus non-essential ones), whose block bounds cannot
1354                    // reach the threshold. A document at or after
1355                    // `next_other` may also receive another essential
1356                    // cursor's score, so no cursor jumps past it: skip the
1357                    // block when it ends before `next_other`, otherwise seek
1358                    // to `next_other` inside the block.
1359                    //
1360                    // Superblocks: when the cursors' L1 group bounds cannot
1361                    // reach the threshold either, the same argument covers
1362                    // the whole group of eight blocks, so a cursor may jump
1363                    // to its next group instead (bounded by `next_other` in
1364                    // the same way). A cursor without group bounds counts
1365                    // with its block bound and still skips one block.
1366                    let mut group_sum: f32 = 0.0;
1367                    let mut mask = at_min_mask;
1368                    while mask != 0 {
1369                        let i = mask.trailing_zeros() as usize;
1370                        group_sum += $self.cursors[i]
1371                            .current_group_max_score()
1372                            .unwrap_or_else(|| $self.cursors[i].current_block_max_score());
1373                        mask &= mask - 1;
1374                    }
1375                    let group_prunable = group_sum + non_essential_upper < adjusted_threshold;
1376                    let mut mask = at_min_mask;
1377                    while mask != 0 {
1378                        let i = mask.trailing_zeros() as usize;
1379                        let by_group =
1380                            group_prunable && $self.cursors[i].current_group_max_score().is_some();
1381                        let boundary = if by_group {
1382                            $self.cursors[i].current_group_last_doc()
1383                        } else {
1384                            $self.cursors[i].block_last_doc($self.cursors[i].block_idx)
1385                        };
1386                        if next_other > boundary {
1387                            if by_group {
1388                                $self.cursors[i].skip_to_next_group();
1389                                groups_skipped += 1;
1390                            } else {
1391                                $self.cursors[i].skip_to_next_block();
1392                            }
1393                            $self.cursors[i].$ensure() $($aw)* ?;
1394                        } else {
1395                            $self.cursors[i].$seek(next_other) $($aw)* ?;
1396                        }
1397                        mask &= mask - 1;
1398                    }
1399                    blocks_skipped += 1;
1400                    continue;
1401                }
1402            }
1403
1404            // --- Predicate filter (after block-max, before scoring) ---
1405            if let Some(ref pred) = $self.predicate {
1406                if !pred(min_doc) {
1407                    let mut mask = at_min_mask;
1408                    while mask != 0 {
1409                        let i = mask.trailing_zeros() as usize;
1410                        $self.cursors[i].$ensure() $($aw)* ?;
1411                        $self.cursors[i].$advance() $($aw)* ?;
1412                        mask &= mask - 1;
1413                    }
1414                    continue;
1415                }
1416            }
1417
1418            // --- Score essential cursors ---
1419            ordinal_scores.clear();
1420            {
1421                let mut mask = at_min_mask;
1422                while mask != 0 {
1423                    let i = mask.trailing_zeros() as usize;
1424                    $self.cursors[i].$ensure() $($aw)* ?;
1425                    $self.cursors[i].ensure_scores();
1426                    while $self.cursors[i].doc() == min_doc {
1427                        let ord = $self.cursors[i].ordinal_mut();
1428                        let sc = $self.cursors[i].score();
1429                        ordinal_scores.push((ord, sc));
1430                        $self.cursors[i].$advance() $($aw)* ?;
1431                    }
1432                    mask &= mask - 1;
1433                }
1434            }
1435
1436            let essential_total: f32 = ordinal_scores.iter().map(|(_, s)| *s).sum();
1437            if $self.collector.len() >= $self.collector.k
1438                && essential_total + non_essential_upper < adjusted_threshold
1439            {
1440                docs_skipped += 1;
1441                continue;
1442            }
1443
1444            // --- Score non-essential cursors (highest max_score first for early exit) ---
1445            let mut running_total = essential_total;
1446            for i in (0..partition).rev() {
1447                if $self.collector.len() >= $self.collector.k
1448                    && running_total + $self.prefix_sums[i] < adjusted_threshold
1449                {
1450                    break;
1451                }
1452
1453                let doc = $self.cursors[i].$seek(min_doc) $($aw)* ?;
1454                if doc == min_doc {
1455                    $self.cursors[i].ensure_scores();
1456                    while $self.cursors[i].doc() == min_doc {
1457                        let s = $self.cursors[i].score();
1458                        running_total += s;
1459                        let ord = $self.cursors[i].ordinal_mut();
1460                        ordinal_scores.push((ord, s));
1461                        $self.cursors[i].$advance() $($aw)* ?;
1462                    }
1463                }
1464            }
1465
1466            // --- Group by ordinal and insert ---
1467            // Fast path: single entry (common for single-valued fields) — skip sort + grouping
1468            if ordinal_scores.len() == 1 {
1469                let (ord, score) = ordinal_scores[0];
1470                if $self.collector.insert_with_ordinal(min_doc, score, ord) {
1471                    docs_scored += 1;
1472                    adjusted_threshold = $self.collector.threshold() * inv_heap_factor - 1e-6;
1473                } else {
1474                    docs_skipped += 1;
1475                }
1476            } else if !ordinal_scores.is_empty() {
1477                if ordinal_scores.len() > 2 {
1478                    ordinal_scores.sort_unstable_by_key(|(ord, _)| *ord);
1479                } else if ordinal_scores.len() == 2 && ordinal_scores[0].0 > ordinal_scores[1].0 {
1480                    ordinal_scores.swap(0, 1);
1481                }
1482                let mut j = 0;
1483                while j < ordinal_scores.len() {
1484                    let current_ord = ordinal_scores[j].0;
1485                    let mut score = 0.0f32;
1486                    while j < ordinal_scores.len() && ordinal_scores[j].0 == current_ord {
1487                        score += ordinal_scores[j].1;
1488                        j += 1;
1489                    }
1490                    if $self
1491                        .collector
1492                        .insert_with_ordinal(min_doc, score, current_ord)
1493                    {
1494                        docs_scored += 1;
1495                        adjusted_threshold = $self.collector.threshold() * inv_heap_factor - 1e-6;
1496                    } else {
1497                        docs_skipped += 1;
1498                    }
1499                }
1500            }
1501        }
1502
1503        let results: Vec<ScoredDoc> = $self
1504            .collector
1505            .into_sorted_results()
1506            .into_iter()
1507            .map(|(doc_id, score, ordinal)| ScoredDoc {
1508                doc_id,
1509                score,
1510                ordinal,
1511            })
1512            .collect();
1513
1514        let _bms_elapsed_ms = _bms_start.elapsed().as_millis() as u64;
1515        if _bms_elapsed_ms > 500 {
1516            warn!(
1517                "slow MaxScore: {}ms, cursors={}, scored={}, skipped={}, blocks_skipped={}, groups_skipped={}, conjunction_skipped={}, returned={}, top_score={:.4}",
1518                _bms_elapsed_ms,
1519                n,
1520                docs_scored,
1521                docs_skipped,
1522                blocks_skipped,
1523                groups_skipped,
1524                conjunction_skipped,
1525                results.len(),
1526                results.first().map(|r| r.score).unwrap_or(0.0)
1527            );
1528        } else {
1529            debug!(
1530                "MaxScoreExecutor: {}ms, scored={}, skipped={}, blocks_skipped={}, groups_skipped={}, conjunction_skipped={}, returned={}, top_score={:.4}",
1531                _bms_elapsed_ms,
1532                docs_scored,
1533                docs_skipped,
1534                blocks_skipped,
1535                groups_skipped,
1536                conjunction_skipped,
1537                results.len(),
1538                results.first().map(|r| r.score).unwrap_or(0.0)
1539            );
1540        }
1541
1542        Ok(results)
1543    }};
1544}
1545
1546impl<'a> MaxScoreExecutor<'a> {
1547    /// Create a new executor from pre-built cursors.
1548    ///
1549    /// Cursors are sorted by max_score ascending (non-essential first) and
1550    /// prefix sums are computed for the MaxScore partitioning.
1551    pub(crate) fn new(mut cursors: Vec<TermCursor<'a>>, k: usize, heap_factor: f32) -> Self {
1552        // The execution loop tracks cursors at the current document in a u64.
1553        // Query construction normally enforces this bound, but keep this
1554        // boundary defensive for direct/internal executor users as well.
1555        if cursors.len() > super::MAX_QUERY_TERMS {
1556            cursors.sort_unstable_by(|a, b| b.max_score.total_cmp(&a.max_score));
1557            cursors.truncate(super::MAX_QUERY_TERMS);
1558            log::warn!(
1559                "MaxScore cursor count exceeded {}; retaining the strongest cursors",
1560                super::MAX_QUERY_TERMS
1561            );
1562        }
1563
1564        // Enable lazy ordinal decode — ordinals are only decoded when a doc
1565        // actually reaches the scoring phase (saves ~100ns per skipped block).
1566        for c in &mut cursors {
1567            c.lazy_ordinals = true;
1568        }
1569
1570        // Sort by max_score ascending (non-essential first)
1571        cursors.sort_by(|a, b| {
1572            a.max_score
1573                .partial_cmp(&b.max_score)
1574                .unwrap_or(Ordering::Equal)
1575        });
1576
1577        let mut prefix_sums = Vec::with_capacity(cursors.len());
1578        let mut cumsum = 0.0f32;
1579        for c in &cursors {
1580            cumsum += c.max_score;
1581            prefix_sums.push(cumsum);
1582        }
1583
1584        let clamped_heap_factor = heap_factor.clamp(0.01, 1.0);
1585
1586        debug!(
1587            "Creating MaxScoreExecutor: num_cursors={}, k={}, total_upper={:.4}, heap_factor={:.2}",
1588            cursors.len(),
1589            k,
1590            cumsum,
1591            clamped_heap_factor
1592        );
1593
1594        Self {
1595            cursors,
1596            prefix_sums,
1597            collector: ScoreCollector::new(k),
1598            inv_heap_factor: 1.0 / clamped_heap_factor,
1599            predicate: None,
1600            budget: None,
1601            metric_index: "unknown",
1602            metric_field: "unknown",
1603        }
1604    }
1605
1606    /// Attach the query's wall-clock budget (anytime mode).
1607    pub fn with_budget(mut self, budget: Option<SharedThreshold>) -> Self {
1608        self.budget = budget.filter(|b| b.deadline().is_some());
1609        self
1610    }
1611
1612    /// Attach (index, field) labels for the metrics this executor emits.
1613    pub fn with_metric_labels(mut self, index: &'a str, field: &'a str) -> Self {
1614        self.metric_index = index;
1615        self.metric_field = field;
1616        self
1617    }
1618
1619    /// Create an executor for sparse vector queries.
1620    ///
1621    /// Builds `TermCursor::Sparse` for each matched dimension.
1622    pub fn sparse(
1623        sparse_index: &'a crate::segment::SparseIndex,
1624        query_terms: Vec<(u32, f32)>,
1625        k: usize,
1626        heap_factor: f32,
1627    ) -> Self {
1628        let cursors: Vec<TermCursor<'a>> = query_terms
1629            .iter()
1630            .filter_map(|&(dim_id, qw)| {
1631                let (skip_start, skip_count, global_max, block_data_offset) =
1632                    sparse_index.get_skip_range_full(dim_id)?;
1633                Some(TermCursor::sparse(
1634                    sparse_index,
1635                    qw,
1636                    skip_start,
1637                    skip_count,
1638                    global_max,
1639                    block_data_offset,
1640                ))
1641            })
1642            .collect();
1643        Self::new(cursors, k, heap_factor)
1644    }
1645
1646    /// Create an executor for full-text BM25 queries.
1647    ///
1648    /// Builds `TermCursor::Text` for each posting list.
1649    pub fn text(
1650        posting_lists: Vec<(crate::structures::BlockPostingList, f32)>,
1651        avg_field_len: f32,
1652        k: usize,
1653        lengths: Option<&'a crate::segment::chunk_map::DocLengths>,
1654        params: super::Bm25Params,
1655        heap_factor: f32,
1656    ) -> Self {
1657        let cursors: Vec<TermCursor<'a>> = posting_lists
1658            .into_iter()
1659            .map(|(pl, idf)| {
1660                TermCursor::text_with_params(
1661                    pl,
1662                    idf,
1663                    avg_field_len,
1664                    lengths.map(LengthSource::Docs),
1665                    params,
1666                )
1667            })
1668            .collect();
1669        Self::new(cursors, k, heap_factor)
1670    }
1671
1672    /// Executor for BM25 over a chunked text field: posting ids are virtual
1673    /// chunk ids, scored with each chunk's real length. Results carry the
1674    /// virtual id in `doc_id`; the caller resolves it through `lengths`.
1675    pub fn text_chunked(
1676        posting_lists: Vec<(crate::structures::BlockPostingList, f32)>,
1677        avg_chunk_len: f32,
1678        k: usize,
1679        lengths: &'a crate::segment::chunk_map::ChunkMap,
1680        params: super::Bm25Params,
1681        heap_factor: f32,
1682    ) -> Self {
1683        let cursors: Vec<TermCursor<'a>> = posting_lists
1684            .into_iter()
1685            .map(|(pl, idf)| {
1686                TermCursor::text_with_params(
1687                    pl,
1688                    idf,
1689                    avg_chunk_len,
1690                    Some(LengthSource::Chunks(lengths)),
1691                    params,
1692                )
1693            })
1694            .collect();
1695        Self::new(cursors, k, heap_factor)
1696    }
1697
1698    #[inline]
1699    fn find_partition(&self) -> usize {
1700        // Alpha < 1.0 raises the effective threshold → more terms become
1701        // non-essential → more aggressive pruning (approximate retrieval).
1702        // Use multiplication by reciprocal (cheaper than division).
1703        let threshold = self.collector.threshold() * self.inv_heap_factor;
1704        // Keep an equal-score candidate essential: it can still displace the
1705        // current worst hit through the deterministic doc/ordinal tie-break.
1706        self.prefix_sums.partition_point(|&sum| sum < threshold)
1707    }
1708
1709    /// Attach a per-doc predicate filter to this executor.
1710    ///
1711    /// Docs failing the predicate are skipped after block-max pruning but
1712    /// before scoring. The predicate does not affect thresholds or block-max
1713    /// comparisons — the heap stores pure sparse/text scores.
1714    pub fn with_predicate(mut self, predicate: super::DocPredicate<'a>) -> Self {
1715        self.predicate = Some(predicate);
1716        self
1717    }
1718
1719    /// Seed the collector with an initial threshold for tighter early pruning.
1720    pub fn seed_threshold(&mut self, initial_threshold: f32) {
1721        self.collector.seed_threshold(initial_threshold);
1722    }
1723
1724    /// Execute Block-Max MaxScore and return top-k results (async).
1725    ///
1726    /// Text cursors (in-memory posting lists) run the windowed executor;
1727    /// sparse cursors, whose blocks may need asynchronous I/O, run the
1728    /// document-at-a-time loop.
1729    pub async fn execute(mut self) -> crate::Result<Vec<ScoredDoc>> {
1730        if self.cursors.is_empty() {
1731            return Ok(Vec::new());
1732        }
1733        let t = crate::observe::Timer::start();
1734        let results = if self.all_text() {
1735            self.execute_windowed()
1736        } else {
1737            bms_execute_loop!(self, ensure_block_loaded, advance, seek, .await)
1738        };
1739        if let Ok(r) = &results {
1740            crate::observe::maxscore_query(self.metric_index, self.metric_field, t.secs(), r.len());
1741        }
1742        results
1743    }
1744
1745    /// Synchronous execution — works when all cursors are text or mmap-backed sparse.
1746    pub fn execute_sync(mut self) -> crate::Result<Vec<ScoredDoc>> {
1747        if self.cursors.is_empty() {
1748            return Ok(Vec::new());
1749        }
1750        let t = crate::observe::Timer::start();
1751        let results = if self.all_text() {
1752            self.execute_windowed()
1753        } else {
1754            bms_execute_loop!(self, ensure_block_loaded_sync, advance_sync, seek_sync,)
1755        };
1756        if let Ok(r) = &results {
1757            crate::observe::maxscore_query(self.metric_index, self.metric_field, t.secs(), r.len());
1758        }
1759        results
1760    }
1761
1762    /// The document-at-a-time loop on any cursors (the reference the
1763    /// windowed executor is checked against in tests).
1764    #[cfg(test)]
1765    pub(crate) fn execute_doc_at_a_time_sync(mut self) -> crate::Result<Vec<ScoredDoc>> {
1766        if self.cursors.is_empty() {
1767            return Ok(Vec::new());
1768        }
1769        bms_execute_loop!(self, ensure_block_loaded_sync, advance_sync, seek_sync,)
1770    }
1771
1772    fn all_text(&self) -> bool {
1773        self.cursors.iter().all(TermCursor::is_text)
1774    }
1775
1776    /// Window-at-a-time Block-Max MaxScore for text cursors.
1777    ///
1778    /// The id space is walked in windows of at most [`WINDOW_IDS`] ids that
1779    /// start at the first id a globally essential cursor can still reach and
1780    /// end at the smallest current block end among those cursors. Per window
1781    /// (Lucene `MaxScoreBulkScorer`; turbopuffer "batched iterator
1782    /// advancement"):
1783    ///
1784    /// 1. every cursor's bound over the window is read from its skip entries
1785    ///    (`window_upper_bound`), and the cursors are re-partitioned into
1786    ///    essential and non-essential by those bounds, so the partition is
1787    ///    the block-max one, not the list-max one;
1788    /// 2. a window whose summed bounds cannot reach the threshold is skipped
1789    ///    by every cursor without decoding a block;
1790    /// 3. each essential cursor scores all of its postings in the window in
1791    ///    one pass over its decoded block (dense `scores[id - from]` buffer
1792    ///    plus a match bitset), so the same iterator advances many times in
1793    ///    a row instead of alternating with the others;
1794    /// 4. the candidates are filtered branch-free against the threshold
1795    ///    minus what the remaining cursors could still add, and each
1796    ///    non-essential cursor is then sought to the survivors in id order
1797    ///    (again one iterator at a time), strongest bound first.
1798    ///
1799    /// Rank-safe: only documents whose window-essential score plus the
1800    /// non-essential bounds cannot reach the threshold are dropped. The
1801    /// approximate `heap_factor` mode scales the threshold as in the
1802    /// document-at-a-time loop.
1803    pub(crate) fn execute_windowed(&mut self) -> crate::Result<Vec<ScoredDoc>> {
1804        let n = self.cursors.len();
1805        for cursor in &mut self.cursors {
1806            cursor.ensure_block_loaded_sync()?;
1807        }
1808        let inv_heap_factor = self.inv_heap_factor;
1809        let mut window_scores = vec![0.0f32; WINDOW_IDS];
1810        let mut window_mask = vec![0u64; WINDOW_IDS / 64];
1811        let mut cand_docs: Vec<u32> = Vec::with_capacity(WINDOW_IDS);
1812        let mut cand_scores: Vec<f32> = Vec::with_capacity(WINDOW_IDS);
1813        let mut wmax = vec![0.0f32; n];
1814        let mut order: Vec<usize> = (0..n).collect();
1815        let mut wprefix = vec![0.0f32; n];
1816        let mut windows = 0u64;
1817        let mut windows_skipped = 0u64;
1818        let mut candidates = 0u64;
1819        let mut docs_scored = 0u64;
1820        let started = std::time::Instant::now();
1821
1822        loop {
1823            windows += 1;
1824            if windows & 0x3F == 0
1825                && let Some(budget) = &self.budget
1826                && budget.expired()
1827            {
1828                budget.mark_truncated();
1829                log::debug!(
1830                    "MaxScoreExecutor(windowed): deadline reached after {} windows, {} scored",
1831                    windows,
1832                    docs_scored
1833                );
1834                break;
1835            }
1836            let partition = self.find_partition();
1837            if partition >= n {
1838                break;
1839            }
1840            // Window: from the first id a globally essential cursor can
1841            // still reach to the smallest current block end among them.
1842            let mut from = u32::MAX;
1843            let mut to = u32::MAX;
1844            for cursor in &self.cursors[partition..] {
1845                if cursor.exhausted {
1846                    continue;
1847                }
1848                from = from.min(cursor.doc());
1849                to = to.min(cursor.block_last_doc(cursor.block_idx));
1850            }
1851            if from == u32::MAX {
1852                break;
1853            }
1854            let to = to.max(from).min(from.saturating_add(WINDOW_IDS as u32 - 1));
1855            let width = (to - from) as usize + 1;
1856            let words = width.div_ceil(64);
1857
1858            // Block-max partition over the window.
1859            let heap_full = self.collector.len() >= self.collector.k;
1860            let threshold = if heap_full {
1861                self.collector.threshold() * inv_heap_factor - 1e-6
1862            } else {
1863                0.0
1864            };
1865            for (i, bound) in wmax.iter_mut().enumerate() {
1866                *bound = self.cursors[i].window_upper_bound(from, to);
1867            }
1868            order.sort_unstable_by(|&a, &b| wmax[a].total_cmp(&wmax[b]));
1869            let mut sum = 0.0f32;
1870            for (rank, &i) in order.iter().enumerate() {
1871                sum += wmax[i];
1872                wprefix[rank] = sum;
1873            }
1874            let wpartition = if heap_full {
1875                wprefix.partition_point(|&s| s < threshold)
1876            } else {
1877                0
1878            };
1879            if wpartition >= n {
1880                // Nothing in the window can compete: every cursor jumps past it.
1881                for cursor in &mut self.cursors {
1882                    if !cursor.exhausted && cursor.doc() <= to {
1883                        cursor.skip_past_sync(to)?;
1884                    }
1885                }
1886                windows_skipped += 1;
1887                continue;
1888            }
1889
1890            // Essential cursors: bulk-score into the window buffers.
1891            window_scores[..width].fill(0.0);
1892            window_mask[..words].fill(0);
1893            for &i in &order[wpartition..] {
1894                let cursor = &mut self.cursors[i];
1895                if cursor.exhausted {
1896                    continue;
1897                }
1898                if cursor.doc() < from {
1899                    cursor.seek_sync(from)?;
1900                }
1901                if cursor.exhausted || cursor.doc() > to {
1902                    continue;
1903                }
1904                cursor.score_window_sync(
1905                    from,
1906                    to,
1907                    &mut window_scores[..width],
1908                    &mut window_mask[..words],
1909                )?;
1910            }
1911
1912            // Candidates in id order.
1913            cand_docs.clear();
1914            cand_scores.clear();
1915            for (word_idx, word) in window_mask[..words].iter().enumerate() {
1916                let mut bits = *word;
1917                while bits != 0 {
1918                    let slot = (word_idx << 6) | bits.trailing_zeros() as usize;
1919                    bits &= bits - 1;
1920                    cand_docs.push(from + slot as u32);
1921                    cand_scores.push(window_scores[slot]);
1922                }
1923            }
1924            if let Some(pred) = &self.predicate {
1925                let mut kept = 0usize;
1926                for j in 0..cand_docs.len() {
1927                    let doc = cand_docs[j];
1928                    cand_docs[kept] = doc;
1929                    cand_scores[kept] = cand_scores[j];
1930                    kept += pred(doc) as usize;
1931                }
1932                cand_docs.truncate(kept);
1933                cand_scores.truncate(kept);
1934            }
1935
1936            // Non-essential cursors on the survivors, strongest bound first.
1937            let mut remaining = if wpartition > 0 {
1938                wprefix[wpartition - 1]
1939            } else {
1940                0.0
1941            };
1942            for rank in (0..wpartition).rev() {
1943                let i = order[rank];
1944                if heap_full {
1945                    filter_competitive(&mut cand_docs, &mut cand_scores, remaining, threshold);
1946                }
1947                if cand_docs.is_empty() {
1948                    break;
1949                }
1950                if wmax[i] > 0.0 {
1951                    let cursor = &mut self.cursors[i];
1952                    for (doc, score) in cand_docs.iter().zip(cand_scores.iter_mut()) {
1953                        if cursor.seek_sync(*doc)? == *doc {
1954                            cursor.ensure_scores();
1955                            *score += cursor.score();
1956                        }
1957                    }
1958                }
1959                remaining -= wmax[i];
1960            }
1961            if heap_full {
1962                filter_competitive(&mut cand_docs, &mut cand_scores, 0.0, threshold);
1963            }
1964            candidates += cand_docs.len() as u64;
1965            for (doc, score) in cand_docs.iter().zip(&cand_scores) {
1966                if self.collector.insert_with_ordinal(*doc, *score, 0) {
1967                    docs_scored += 1;
1968                }
1969            }
1970        }
1971
1972        let collector = std::mem::replace(&mut self.collector, ScoreCollector::new(0));
1973        let results: Vec<ScoredDoc> = collector
1974            .into_sorted_results()
1975            .into_iter()
1976            .map(|(doc_id, score, ordinal)| ScoredDoc {
1977                doc_id,
1978                score,
1979                ordinal,
1980            })
1981            .collect();
1982        let elapsed_ms = started.elapsed().as_millis() as u64;
1983        if elapsed_ms > 500 {
1984            warn!(
1985                "slow windowed MaxScore: {}ms, cursors={}, windows={}, windows_skipped={}, candidates={}, scored={}, returned={}, top_score={:.4}",
1986                elapsed_ms,
1987                n,
1988                windows,
1989                windows_skipped,
1990                candidates,
1991                docs_scored,
1992                results.len(),
1993                results.first().map(|r| r.score).unwrap_or(0.0)
1994            );
1995        } else {
1996            debug!(
1997                "MaxScoreExecutor(windowed): {}ms, cursors={}, windows={}, windows_skipped={}, candidates={}, scored={}, returned={}, top_score={:.4}",
1998                elapsed_ms,
1999                n,
2000                windows,
2001                windows_skipped,
2002                candidates,
2003                docs_scored,
2004                results.len(),
2005                results.first().map(|r| r.score).unwrap_or(0.0)
2006            );
2007        }
2008        Ok(results)
2009    }
2010}
2011
2012/// Ids per window of the windowed executor (Lucene's `INNER_WINDOW_SIZE`).
2013const WINDOW_IDS: usize = 4096;
2014
2015/// Keep the candidates that can still reach `threshold` once `remaining`
2016/// (the bounds of the cursors not yet applied) is added. Written without a
2017/// data-dependent branch, like Lucene's `VectorUtil.filterByScore`.
2018fn filter_competitive(docs: &mut Vec<u32>, scores: &mut Vec<f32>, remaining: f32, threshold: f32) {
2019    let mut kept = 0usize;
2020    for j in 0..docs.len() {
2021        let doc = docs[j];
2022        let score = scores[j];
2023        docs[kept] = doc;
2024        scores[kept] = score;
2025        kept += (score + remaining >= threshold) as usize;
2026    }
2027    docs.truncate(kept);
2028    scores.truncate(kept);
2029}
2030
2031#[cfg(test)]
2032mod tests {
2033    use super::*;
2034
2035    // ── Windowed executor parity ─────────────────────────────────────────
2036
2037    struct Corpus {
2038        /// Per term: sorted `(doc, tf)` postings.
2039        postings: Vec<Vec<(u32, u32)>>,
2040        lengths: Vec<u16>,
2041        n_docs: u32,
2042    }
2043
2044    fn xorshift(state: &mut u64) -> u64 {
2045        *state ^= *state << 13;
2046        *state ^= *state >> 7;
2047        *state ^= *state << 17;
2048        *state
2049    }
2050
2051    /// Terms with very different densities (from 0.5% to 60% of the
2052    /// documents), skewed term frequencies, and pseudo-random lengths.
2053    fn random_corpus(seed: u64, n_docs: u32, n_terms: usize) -> Corpus {
2054        let mut state = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
2055        let lengths: Vec<u16> = (0..n_docs)
2056            .map(|_| 1 + (xorshift(&mut state) % 400) as u16)
2057            .collect();
2058        let densities = [0.6, 0.25, 0.1, 0.03, 0.005];
2059        let postings = (0..n_terms)
2060            .map(|t| {
2061                let density = densities[t % densities.len()];
2062                let cutoff = (density * u32::MAX as f64) as u64;
2063                let mut postings = Vec::new();
2064                for doc in 0..n_docs {
2065                    if (xorshift(&mut state) & 0xFFFF_FFFF) >= cutoff {
2066                        continue;
2067                    }
2068                    let r = xorshift(&mut state) % 100;
2069                    let tf = if r < 70 {
2070                        1
2071                    } else if r < 90 {
2072                        2
2073                    } else {
2074                        3 + (r % 6) as u32
2075                    };
2076                    postings.push((doc, tf));
2077                }
2078                postings
2079            })
2080            .collect();
2081        Corpus {
2082            postings,
2083            lengths,
2084            n_docs,
2085        }
2086    }
2087
2088    fn build_lists(
2089        corpus: &Corpus,
2090        lengths: Option<&crate::segment::chunk_map::DocLengths>,
2091    ) -> Vec<(crate::structures::BlockPostingList, f32)> {
2092        corpus
2093            .postings
2094            .iter()
2095            .map(|postings| {
2096                let mut list = crate::structures::PostingList::new();
2097                for &(doc, tf) in postings {
2098                    list.push(doc, tf);
2099                }
2100                let length_of = lengths.map(|l| move |doc: DocId| l.length(doc));
2101                let block_list = crate::structures::BlockPostingList::from_posting_list_with(
2102                    &list,
2103                    false,
2104                    length_of.as_ref().map(|f| f as &dyn Fn(DocId) -> u32),
2105                )
2106                .unwrap();
2107                let idf = super::super::bm25_idf(postings.len() as f32, corpus.n_docs as f32);
2108                (block_list, idf)
2109            })
2110            .collect()
2111    }
2112
2113    /// Exhaustive per-document scores with the same formula the cursors use.
2114    fn exhaustive(
2115        corpus: &Corpus,
2116        lists: &[(crate::structures::BlockPostingList, f32)],
2117        real_lengths: bool,
2118        avg: f32,
2119        params: super::super::Bm25Params,
2120    ) -> std::collections::HashMap<u32, f32> {
2121        let mut scores: std::collections::HashMap<u32, f32> = std::collections::HashMap::new();
2122        for (postings, (_, idf)) in corpus.postings.iter().zip(lists) {
2123            for &(doc, tf) in postings {
2124                let len = if real_lengths {
2125                    corpus.lengths[doc as usize] as f32
2126                } else {
2127                    tf as f32
2128                };
2129                *scores.entry(doc).or_insert(0.0) += params.score(tf as f32, *idf, len, avg);
2130            }
2131        }
2132        scores
2133    }
2134
2135    fn check_top_k(
2136        label: &str,
2137        results: &[ScoredDoc],
2138        exhaustive: &std::collections::HashMap<u32, f32>,
2139        k: usize,
2140        predicate: Option<&dyn Fn(u32) -> bool>,
2141    ) {
2142        let mut expected: Vec<(u32, f32)> = exhaustive
2143            .iter()
2144            .filter(|(doc, _)| predicate.is_none_or(|p| p(**doc)))
2145            .map(|(doc, score)| (*doc, *score))
2146            .collect();
2147        expected.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
2148        let want = k.min(expected.len());
2149        assert_eq!(results.len(), want, "{label}: result count");
2150        for (rank, (got, exp)) in results.iter().zip(&expected).enumerate() {
2151            let tolerance = 1e-4 * exp.1.abs().max(1.0);
2152            assert!(
2153                (got.score - exp.1).abs() <= tolerance,
2154                "{label}: rank {rank} score {} vs exhaustive {} (doc {} vs {})",
2155                got.score,
2156                exp.1,
2157                got.doc_id,
2158                exp.0
2159            );
2160            let own = exhaustive[&got.doc_id];
2161            assert!(
2162                (got.score - own).abs() <= tolerance,
2163                "{label}: doc {} scored {} but exhaustive says {}",
2164                got.doc_id,
2165                got.score,
2166                own
2167            );
2168            if let Some(p) = predicate {
2169                assert!(
2170                    p(got.doc_id),
2171                    "{label}: doc {} fails the predicate",
2172                    got.doc_id
2173                );
2174            }
2175        }
2176        for pair in results.windows(2) {
2177            assert!(
2178                pair[0].score >= pair[1].score,
2179                "{label}: results not sorted"
2180            );
2181        }
2182    }
2183
2184    /// The windowed executor returns the exact top-k (against an exhaustive
2185    /// scorer and against the document-at-a-time loop) over corpora of
2186    /// different sizes and term mixes, with and without real lengths,
2187    /// predicates, and a seeded threshold.
2188    #[test]
2189    fn windowed_text_maxscore_matches_exhaustive_and_doc_at_a_time() {
2190        let params = super::super::Bm25Params::default();
2191        let predicate_fn = |doc: u32| !doc.is_multiple_of(3);
2192        let mut cases = 0usize;
2193        for seed in 1..=6u64 {
2194            for &n_docs in &[300u32, 2_500, 12_000] {
2195                for &n_terms in &[1usize, 2, 4, 9] {
2196                    let corpus = random_corpus(seed, n_docs, n_terms);
2197                    let doc_lengths =
2198                        crate::segment::chunk_map::DocLengths::from_lengths(&corpus.lengths);
2199                    for real_lengths in [true, false] {
2200                        let lengths = real_lengths.then_some(&doc_lengths);
2201                        let lists = build_lists(&corpus, lengths);
2202                        let avg = if real_lengths {
2203                            doc_lengths.avg_len()
2204                        } else {
2205                            1.0
2206                        };
2207                        let truth = exhaustive(&corpus, &lists, real_lengths, avg, params);
2208                        for &k in &[1usize, 10, 100] {
2209                            for with_predicate in [false, true] {
2210                                let label = format!(
2211                                    "seed={seed} docs={n_docs} terms={n_terms} lengths={real_lengths} k={k} pred={with_predicate}"
2212                                );
2213                                let pred: Option<&dyn Fn(u32) -> bool> =
2214                                    with_predicate.then_some(&predicate_fn);
2215                                let make = |seeded: f32| {
2216                                    let mut executor = MaxScoreExecutor::text(
2217                                        lists.clone(),
2218                                        avg,
2219                                        k,
2220                                        lengths,
2221                                        params,
2222                                        1.0,
2223                                    );
2224                                    if with_predicate {
2225                                        executor = executor.with_predicate(Box::new(predicate_fn));
2226                                    }
2227                                    if seeded > 0.0 {
2228                                        executor.seed_threshold(seeded);
2229                                    }
2230                                    executor
2231                                };
2232                                let windowed = make(0.0).execute_windowed().unwrap();
2233                                check_top_k(
2234                                    &format!("windowed {label}"),
2235                                    &windowed,
2236                                    &truth,
2237                                    k,
2238                                    pred,
2239                                );
2240                                let reference = make(0.0).execute_doc_at_a_time_sync().unwrap();
2241                                check_top_k(
2242                                    &format!("reference {label}"),
2243                                    &reference,
2244                                    &truth,
2245                                    k,
2246                                    pred,
2247                                );
2248                                // A floor below the k-th score keeps the exact top-k.
2249                                if let Some(kth) = windowed.last().map(|r| r.score)
2250                                    && windowed.len() == k
2251                                {
2252                                    let seeded = make(kth * 0.9).execute_windowed().unwrap();
2253                                    check_top_k(
2254                                        &format!("seeded {label}"),
2255                                        &seeded,
2256                                        &truth,
2257                                        k,
2258                                        pred,
2259                                    );
2260                                    // A floor above every score returns nothing.
2261                                    let above =
2262                                        make(windowed[0].score * 1.5).execute_windowed().unwrap();
2263                                    assert!(above.is_empty(), "{label}: floor above all scores");
2264                                }
2265                                cases += 1;
2266                            }
2267                        }
2268                    }
2269                }
2270            }
2271        }
2272        assert!(cases > 400);
2273    }
2274
2275    /// The approximate mode returns a subset of the exact top-k with exact
2276    /// scores.
2277    #[test]
2278    fn windowed_text_maxscore_heap_factor_is_a_subset_with_exact_scores() {
2279        let params = super::super::Bm25Params::default();
2280        let corpus = random_corpus(7, 20_000, 6);
2281        let doc_lengths = crate::segment::chunk_map::DocLengths::from_lengths(&corpus.lengths);
2282        let lists = build_lists(&corpus, Some(&doc_lengths));
2283        let avg = doc_lengths.avg_len();
2284        let truth = exhaustive(&corpus, &lists, true, avg, params);
2285        let exact = MaxScoreExecutor::text(lists.clone(), avg, 50, Some(&doc_lengths), params, 1.0)
2286            .execute_windowed()
2287            .unwrap();
2288        check_top_k("exact", &exact, &truth, 50, None);
2289        let approx = MaxScoreExecutor::text(lists, avg, 50, Some(&doc_lengths), params, 0.6)
2290            .execute_windowed()
2291            .unwrap();
2292        assert_eq!(approx.len(), 50);
2293        for hit in &approx {
2294            let own = truth[&hit.doc_id];
2295            assert!((hit.score - own).abs() <= 1e-4 * own.max(1.0));
2296        }
2297        // The usual heap-factor guarantee: every returned score is within the
2298        // factor of the exact k-th score, and the best document is exact.
2299        let exact_kth = exact.last().unwrap().score;
2300        assert!(approx.iter().all(|hit| hit.score >= exact_kth * 0.6 - 1e-4));
2301        assert_eq!(approx[0].doc_id, exact[0].doc_id);
2302        let overlap = approx
2303            .iter()
2304            .filter(|hit| exact.iter().any(|e| e.doc_id == hit.doc_id))
2305            .count();
2306        assert!(overlap >= 25, "overlap {overlap} of 50");
2307    }
2308
2309    #[test]
2310    fn test_shared_threshold_monotonic_raise() {
2311        let shared = SharedThreshold::new();
2312        assert_eq!(shared.get(), 0.0);
2313
2314        shared.raise(2.5);
2315        assert_eq!(shared.get(), 2.5);
2316
2317        // Lower values never lower the floor.
2318        shared.raise(1.0);
2319        assert_eq!(shared.get(), 2.5);
2320
2321        // Higher values raise it.
2322        shared.raise(4.0);
2323        assert_eq!(shared.get(), 4.0);
2324
2325        // Non-positive and NaN are ignored.
2326        shared.raise(0.0);
2327        shared.raise(-3.0);
2328        shared.raise(f32::NAN);
2329        assert_eq!(shared.get(), 4.0);
2330
2331        // Clones share the same atomic cell.
2332        let clone = shared.clone();
2333        clone.raise(9.0);
2334        assert_eq!(shared.get(), 9.0);
2335    }
2336
2337    #[test]
2338    fn test_shared_threshold_seed_matches_manual() {
2339        // A collector seeded with a floor prunes anything at/below it, matching
2340        // the threshold a fully-populated heap would have produced.
2341        let mut seeded = ScoreCollector::new(2);
2342        seeded.seed_threshold(3.0);
2343        assert_eq!(seeded.threshold(), 3.0);
2344        // A score at/below the floor cannot enter.
2345        assert!(!seeded.would_enter(3.0));
2346        assert!(seeded.would_enter(3.5));
2347        // Real inserts above the floor evict the sentinels; results contain no
2348        // sentinel (doc_id == u32::MAX) entries.
2349        seeded.insert(1, 5.0);
2350        seeded.insert(2, 4.0);
2351        let results = seeded.into_sorted_results();
2352        assert_eq!(results.len(), 2);
2353        assert_eq!(results[0].0, 1);
2354        assert_eq!(results[1].0, 2);
2355    }
2356
2357    #[test]
2358    fn test_shared_threshold_can_raise_after_real_inserts() {
2359        let mut collector = ScoreCollector::new(3);
2360        collector.insert(1, 10.0);
2361        collector.insert(2, 4.0);
2362        assert_eq!(collector.real_len(), 2);
2363
2364        // Raising the floor after traversal has started removes retained work
2365        // that can no longer reach the global top-k.
2366        collector.seed_threshold(6.0);
2367        assert_eq!(collector.threshold(), 6.0);
2368        assert_eq!(collector.real_len(), 1);
2369
2370        // A real candidate tied with the floor displaces the sentinel because
2371        // its doc id wins the canonical tie break.
2372        assert!(collector.would_enter_candidate(3, 6.0, 0));
2373        assert!(collector.insert(3, 6.0));
2374        assert_eq!(collector.real_len(), 2);
2375        let results = collector.into_sorted_results();
2376        assert_eq!(results, vec![(1, 10.0, 0), (3, 6.0, 0)]);
2377    }
2378
2379    #[test]
2380    fn test_large_seed_uses_virtual_sentinels() {
2381        let k = 1_000_000_000;
2382        let mut collector = ScoreCollector::new(k);
2383        assert!(collector.heap.capacity() <= MAX_INITIAL_SCORE_COLLECTOR_CAPACITY);
2384
2385        collector.seed_threshold(42.0);
2386
2387        // Seeding a huge top-k is constant-time and does not materialize any
2388        // of its conceptual sentinel entries.
2389        assert_eq!(collector.heap.len(), 0);
2390        assert!(collector.heap.capacity() <= MAX_INITIAL_SCORE_COLLECTOR_CAPACITY);
2391        assert_eq!(collector.len(), k);
2392        assert_eq!(collector.real_len(), 0);
2393        assert_eq!(collector.threshold(), 42.0);
2394        assert!(!collector.is_empty());
2395
2396        // A real result tied with the floor beats the sentinel by doc-id, while
2397        // a lower score remains below the conceptual threshold.
2398        assert!(collector.insert_with_ordinal(9, 42.0, 7));
2399        assert!(!collector.insert(10, 41.0));
2400        assert!(collector.insert(11, 43.0));
2401        assert_eq!(collector.len(), k);
2402        assert_eq!(collector.real_len(), 2);
2403        assert_eq!(
2404            collector.into_sorted_results(),
2405            vec![(11, 43.0, 0), (9, 42.0, 7)]
2406        );
2407    }
2408
2409    #[test]
2410    fn test_virtual_sentinels_preserve_tie_order_when_filled() {
2411        let mut collector = ScoreCollector::new(3);
2412        collector.seed_threshold(5.0);
2413
2414        assert!(collector.insert_with_ordinal(3, 5.0, 2));
2415        assert!(collector.insert_with_ordinal(2, 5.0, 8));
2416        assert!(collector.insert_with_ordinal(1, 5.0, 4));
2417        assert_eq!(collector.real_len(), 3);
2418        assert!(collector.virtual_threshold.is_none());
2419
2420        // Once all virtual slots have been displaced, canonical doc/ordinal
2421        // ordering still controls root replacement at an equal score.
2422        assert!(collector.insert_with_ordinal(2, 5.0, 1));
2423        assert!(!collector.insert_with_ordinal(4, 5.0, 0));
2424        assert_eq!(
2425            collector.into_sorted_results(),
2426            vec![(1, 5.0, 4), (2, 5.0, 1), (2, 5.0, 8)]
2427        );
2428    }
2429
2430    #[test]
2431    fn test_score_collector_basic() {
2432        let mut collector = ScoreCollector::new(3);
2433
2434        collector.insert(1, 1.0);
2435        collector.insert(2, 2.0);
2436        collector.insert(3, 3.0);
2437        assert_eq!(collector.threshold(), 1.0);
2438
2439        collector.insert(4, 4.0);
2440        assert_eq!(collector.threshold(), 2.0);
2441
2442        let results = collector.into_sorted_results();
2443        assert_eq!(results.len(), 3);
2444        assert_eq!(results[0].0, 4); // Highest score
2445        assert_eq!(results[1].0, 3);
2446        assert_eq!(results[2].0, 2);
2447    }
2448
2449    #[test]
2450    fn test_score_collector_threshold() {
2451        let mut collector = ScoreCollector::new(2);
2452
2453        collector.insert(1, 5.0);
2454        collector.insert(2, 3.0);
2455        assert_eq!(collector.threshold(), 3.0);
2456
2457        // Should not enter (score too low)
2458        assert!(!collector.would_enter(2.0));
2459        assert!(!collector.insert(3, 2.0));
2460
2461        // Should enter (score high enough)
2462        assert!(collector.would_enter(4.0));
2463        assert!(collector.insert(4, 4.0));
2464        assert_eq!(collector.threshold(), 4.0);
2465    }
2466
2467    #[test]
2468    fn test_heap_entry_ordering() {
2469        let mut heap = BinaryHeap::new();
2470        heap.push(HeapEntry {
2471            doc_id: 1,
2472            score: 3.0,
2473            ordinal: 0,
2474        });
2475        heap.push(HeapEntry {
2476            doc_id: 2,
2477            score: 1.0,
2478            ordinal: 0,
2479        });
2480        heap.push(HeapEntry {
2481            doc_id: 3,
2482            score: 2.0,
2483            ordinal: 0,
2484        });
2485
2486        // Min-heap: lowest score should come out first
2487        assert_eq!(heap.pop().unwrap().score, 1.0);
2488        assert_eq!(heap.pop().unwrap().score, 2.0);
2489        assert_eq!(heap.pop().unwrap().score, 3.0);
2490    }
2491}