Skip to main content

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