Skip to main content

hermes_core/query/
traits.rs

1//! Query and Scorer traits with async support
2//!
3//! Provides the core abstractions for search queries and document scoring.
4
5use std::future::Future;
6use std::pin::Pin;
7
8use crate::segment::SegmentReader;
9use crate::{DocId, Result, Score};
10
11/// Future type for scorer creation
12#[cfg(not(target_arch = "wasm32"))]
13pub type ScorerFuture<'a> = Pin<Box<dyn Future<Output = Result<Box<dyn Scorer + 'a>>> + Send + 'a>>;
14#[cfg(target_arch = "wasm32")]
15pub type ScorerFuture<'a> = Pin<Box<dyn Future<Output = Result<Box<dyn Scorer + 'a>>> + 'a>>;
16
17/// Options that affect scorer construction rather than scoring semantics.
18///
19/// Position postings can be much larger than the top-k result itself. Keeping
20/// this explicit lets ID/score-only collectors avoid loading them while query
21/// types that need positions for matching (for example phrases) remain free to
22/// load their own internal data.
23#[derive(Debug, Clone, Default)]
24pub struct ScorerOptions {
25    pub collect_positions: bool,
26    /// Initial top-k score floor to seed into MaxScore/BMP pruning. Used to
27    /// carry the running k-th score across the segments of one query so later
28    /// segments prune from a nonzero threshold (see `SharedThreshold`). 0.0 =
29    /// no seed. Only honored on exact, final-score executor paths.
30    pub initial_threshold: f32,
31    /// Live form of `initial_threshold`. Exact final-score executors may read
32    /// it during traversal so concurrently searched segments benefit as soon
33    /// as another segment establishes a stronger global floor.
34    pub shared_threshold: Option<super::scoring::SharedThreshold>,
35    /// Query-global LSP/0 selection projected onto this segment.
36    pub(crate) lsp_plan: Option<std::sync::Arc<super::bmp::LspSegmentPlan>>,
37    /// Query-global text statistics (document frequencies, corpus sizes,
38    /// average lengths aggregated over every segment of the searcher, or
39    /// supplied by a broker across shards). Text scorers use them for IDF
40    /// and length normalisation so a term scores the same in every segment;
41    /// a query's own `with_global_stats` takes precedence.
42    pub global_stats: Option<std::sync::Arc<super::GlobalStats>>,
43}
44
45impl ScorerOptions {
46    pub const fn with_positions() -> Self {
47        Self {
48            collect_positions: true,
49            initial_threshold: 0.0,
50            shared_threshold: None,
51            lsp_plan: None,
52            global_stats: None,
53        }
54    }
55
56    /// Preserve collection behavior while preventing a nested/component
57    /// scorer from applying a floor expressed in the outer query's score
58    /// space.
59    pub fn without_threshold(&self) -> Self {
60        Self {
61            collect_positions: self.collect_positions,
62            initial_threshold: 0.0,
63            shared_threshold: None,
64            lsp_plan: None,
65            global_stats: self.global_stats.clone(),
66        }
67    }
68}
69
70/// Future type for count estimation
71#[cfg(not(target_arch = "wasm32"))]
72pub type CountFuture<'a> = Pin<Box<dyn Future<Output = Result<u32>> + Send + 'a>>;
73#[cfg(target_arch = "wasm32")]
74pub type CountFuture<'a> = Pin<Box<dyn Future<Output = Result<u32>> + 'a>>;
75
76/// Per-document predicate closure type (platform-aware Send+Sync bounds)
77#[cfg(not(target_arch = "wasm32"))]
78pub type DocPredicate<'a> = Box<dyn Fn(DocId) -> bool + Send + Sync + 'a>;
79#[cfg(target_arch = "wasm32")]
80pub type DocPredicate<'a> = Box<dyn Fn(DocId) -> bool + 'a>;
81
82/// Compact bitset indexed by doc_id. O(1) lookup, ~2.25 MB for 18M docs.
83///
84/// Built from posting lists or predicate scans. Used by BMP filtered queries
85/// for fast per-slot predicate evaluation (~2ns per lookup vs ~30-40ns for
86/// a fast-field closure).
87pub struct DocBitset {
88    pub(crate) bits: Vec<u64>,
89}
90
91impl DocBitset {
92    /// Create an empty bitset for `num_docs` documents.
93    pub fn new(num_docs: u32) -> Self {
94        let num_words = (num_docs as usize).div_ceil(64);
95        Self {
96            bits: vec![0u64; num_words],
97        }
98    }
99
100    /// Set bit for `doc_id`.
101    #[inline]
102    pub fn set(&mut self, doc_id: u32) {
103        let word = doc_id as usize / 64;
104        let bit = doc_id as usize % 64;
105        if word < self.bits.len() {
106            self.bits[word] |= 1u64 << bit;
107        }
108    }
109
110    /// First set bit at or after `from`, if any.
111    pub fn next_set_bit(&self, from: DocId) -> Option<DocId> {
112        let mut word = from as usize / 64;
113        if word >= self.bits.len() {
114            return None;
115        }
116        let mut bits = self.bits[word] & (u64::MAX << (from % 64));
117        loop {
118            if bits != 0 {
119                return Some((word * 64 + bits.trailing_zeros() as usize) as DocId);
120            }
121            word += 1;
122            if word >= self.bits.len() {
123                return None;
124            }
125            bits = self.bits[word];
126        }
127    }
128
129    /// Test if `doc_id` is in the bitset.
130    #[inline(always)]
131    pub fn contains(&self, doc_id: u32) -> bool {
132        let word = doc_id as usize / 64;
133        let bit = doc_id as usize % 64;
134        word < self.bits.len() && self.bits[word] & (1u64 << bit) != 0
135    }
136
137    /// Number of set bits (matching docs).
138    pub fn count(&self) -> u32 {
139        self.bits.iter().map(|w| w.count_ones()).sum()
140    }
141
142    /// Build bitset from a predicate by scanning all docs. O(N).
143    pub fn from_predicate(num_docs: u32, pred: &dyn Fn(DocId) -> bool) -> Self {
144        let mut bs = Self::new(num_docs);
145        for doc_id in 0..num_docs {
146            if pred(doc_id) {
147                bs.set(doc_id);
148            }
149        }
150        bs
151    }
152
153    /// In-place OR (union): `self |= other`.
154    pub fn union_with(&mut self, other: &DocBitset) {
155        for (a, b) in self.bits.iter_mut().zip(other.bits.iter()) {
156            *a |= *b;
157        }
158    }
159
160    /// In-place AND (intersection): `self &= other`.
161    pub fn intersect_with(&mut self, other: &DocBitset) {
162        for (a, b) in self.bits.iter_mut().zip(other.bits.iter()) {
163            *a &= *b;
164        }
165        // Zero out any words beyond `other`'s length
166        for a in self.bits.iter_mut().skip(other.bits.len()) {
167            *a = 0;
168        }
169    }
170
171    /// In-place ANDNOT (subtract): `self &= !other`.
172    pub fn subtract(&mut self, other: &DocBitset) {
173        for (a, b) in self.bits.iter_mut().zip(other.bits.iter()) {
174            *a &= !*b;
175        }
176    }
177
178    /// Keep only the set docs for which `pred` returns true. O(count) probes —
179    /// the planner uses this to refine a small accumulator against a wide
180    /// clause instead of materializing that clause's full bitset.
181    pub fn retain(&mut self, pred: &dyn Fn(DocId) -> bool) {
182        for (w, word) in self.bits.iter_mut().enumerate() {
183            let mut bits = *word;
184            while bits != 0 {
185                let b = bits.trailing_zeros();
186                let doc = (w * 64) as u32 + b;
187                if !pred(doc) {
188                    *word &= !(1u64 << b);
189                }
190                bits &= bits - 1;
191            }
192        }
193    }
194}
195
196/// Info for MaxScore-optimizable term queries
197#[derive(Debug, Clone)]
198pub struct TermQueryInfo {
199    /// Field being searched
200    pub field: crate::dsl::Field,
201    /// Term bytes (lowercase)
202    pub term: Vec<u8>,
203    /// Query-side weight of the term (a boost, or the query term frequency
204    /// of a de-duplicated match); scales the term's idf, hence its scores
205    /// and bounds alike. 1.0 = plain.
206    pub weight: f32,
207}
208
209/// Info for MaxScore-optimizable sparse term queries
210#[derive(Debug, Clone, Copy)]
211pub struct SparseTermQueryInfo {
212    /// Sparse vector field
213    pub field: crate::dsl::Field,
214    /// Dimension ID in the sparse vector
215    pub dim_id: u32,
216    /// Query weight for this dimension
217    pub weight: f32,
218    /// Whether this term participates in candidate generation. BMP/LSP uses
219    /// the pruned subset for maximum-grid traversal, then scores candidates
220    /// with every term retained in this decomposition.
221    pub candidate: bool,
222    /// MaxScore heap factor (1.0 = exact, lower = approximate)
223    pub heap_factor: f32,
224    /// Multi-value combiner for ordinal deduplication
225    pub combiner: super::MultiValueCombiner,
226    /// Multiplier on executor limit to compensate for ordinal deduplication
227    /// (1.0 = exact, 2.0 = fetch 2x then combine down)
228    pub over_fetch_factor: f32,
229    /// LSP/0 γ. None is depth-derived; Some(0) is exhaustive.
230    pub lsp_gamma: Option<usize>,
231}
232
233/// Decomposition of a query for MaxScore optimization.
234///
235/// The planner inspects this to decide whether to use text MaxScore,
236/// sparse MaxScore, or standard BooleanScorer execution.
237#[derive(Debug, Clone)]
238pub enum QueryDecomposition {
239    /// Single text term — eligible for text MaxScore grouping
240    TextTerm(TermQueryInfo),
241    /// One or more sparse dimensions — eligible for sparse MaxScore
242    SparseTerms(Vec<SparseTermQueryInfo>),
243    /// Not decomposable — falls back to standard execution
244    Opaque,
245}
246
247/// Matched positions for a field (field_id, list of scored positions)
248/// Each position includes its individual score contribution
249pub type MatchedPositions = Vec<(u32, Vec<super::ScoredPosition>)>;
250
251macro_rules! define_query_traits {
252    ($($send_bounds:tt)*) => {
253        /// A search query (async)
254        ///
255        /// Note: `scorer` takes `&self` (not `&'a self`) so that scorers don't borrow the query.
256        /// This enables query composition - queries can create sub-queries locally and get their scorers.
257        /// Implementations must clone/capture any data they need during scorer creation.
258        pub trait Query: std::fmt::Display + $($send_bounds)* {
259            /// Create a scorer for this query against a single segment (async)
260            ///
261            /// The `limit` parameter specifies the maximum number of results to return.
262            /// This is passed from the top-level search limit.
263            ///
264            /// Note: The scorer borrows only the reader, not the query. Implementations
265            /// should capture any needed query data (field, terms, etc.) during creation.
266            fn scorer<'a>(
267                &self,
268                reader: &'a SegmentReader,
269                limit: usize,
270            ) -> ScorerFuture<'a>;
271
272            /// Create a scorer with collector-specific construction options.
273            /// Query implementations that can avoid optional position data
274            /// should override this; the default preserves existing behavior.
275            fn scorer_with_options<'a>(
276                &self,
277                reader: &'a SegmentReader,
278                limit: usize,
279                options: ScorerOptions,
280            ) -> ScorerFuture<'a> {
281                let _ = options;
282                self.scorer(reader, limit)
283            }
284
285            /// Estimated number of matching documents in a segment (async)
286            fn count_estimate<'a>(&self, reader: &'a SegmentReader) -> CountFuture<'a>;
287
288            /// Create a scorer synchronously (mmap/RAM only).
289            ///
290            /// Available when the `sync` feature is enabled.
291            /// Default implementation returns an error.
292            #[cfg(feature = "sync")]
293            fn scorer_sync<'a>(
294                &self,
295                reader: &'a SegmentReader,
296                limit: usize,
297            ) -> Result<Box<dyn Scorer + 'a>> {
298                let _ = (reader, limit);
299                Err(crate::error::Error::Query(
300                    "sync scorer not supported for this query type".into(),
301                ))
302            }
303
304            /// Synchronous counterpart to [`Query::scorer_with_options`].
305            #[cfg(feature = "sync")]
306            fn scorer_sync_with_options<'a>(
307                &self,
308                reader: &'a SegmentReader,
309                limit: usize,
310                options: ScorerOptions,
311            ) -> Result<Box<dyn Scorer + 'a>> {
312                let _ = options;
313                self.scorer_sync(reader, limit)
314            }
315
316            /// Decompose this query for MaxScore optimization.
317            ///
318            /// Returns `TextTerm` for simple term queries, `SparseTerms` for
319            /// sparse vector queries (single or multi-dim), or `Opaque` if
320            /// the query cannot be decomposed.
321            fn decompose(&self) -> QueryDecomposition {
322                QueryDecomposition::Opaque
323            }
324
325            /// Append every `(field, term)` this query scores with BM25 to
326            /// `out`. The searcher aggregates their document frequencies
327            /// across segments before scoring (see `ScorerOptions::global_stats`).
328            fn text_terms(&self, out: &mut Vec<(crate::dsl::Field, Vec<u8>)>) {
329                let _ = out;
330            }
331
332            /// True if this query is a pure filter (always scores 1.0, no positions).
333            /// Used by the planner to convert non-selective MUST filters into predicates.
334            fn is_filter(&self) -> bool {
335                false
336            }
337
338            /// For filter queries: return a cheap per-doc predicate against a segment.
339            /// The predicate does O(1) work per doc (e.g., fast-field lookup).
340            fn as_doc_predicate<'a>(
341                &self,
342                _reader: &'a SegmentReader,
343            ) -> Option<DocPredicate<'a>> {
344                None
345            }
346
347            /// Build a compact bitset of matching doc_ids for this query.
348            ///
349            /// Preferred over `as_doc_predicate` for BMP filtered queries because
350            /// bitset lookup is ~2ns vs ~30-40ns for a fast-field closure.
351            /// Default returns None; TermQuery overrides this to build from its
352            /// posting list in O(M) time.
353            fn as_doc_bitset(
354                &self,
355                _reader: &SegmentReader,
356            ) -> Option<DocBitset> {
357                None
358            }
359
360            /// Cheap estimate of how many docs this filter clause matches in
361            /// the segment. Used by the boolean planner to order MUST/MUST_NOT
362            /// evaluation: the narrowest clause is materialized first and wider
363            /// clauses refine it with per-doc probes instead of being fully
364            /// materialized. `None` = unknown (treated as matching everything).
365            fn bitset_cardinality_estimate(&self, _reader: &SegmentReader) -> Option<u64> {
366                None
367            }
368        }
369
370        /// Scored document stream: a DocSet that also provides scores.
371        pub trait Scorer: super::docset::DocSet + $($send_bounds)* {
372            /// Score for current document
373            fn score(&self) -> Score;
374
375            /// Get matched positions for the current document (if available)
376            /// Returns (field_id, positions) pairs where positions are encoded as per PositionMode
377            fn matched_positions(&self) -> Option<MatchedPositions> {
378                None
379            }
380
381            /// Standalone fast path for scorers that wrap an already ranked
382            /// top-k list (vector executors). When this query is the top-level
383            /// query of a segment search, the caller may take the ranked list
384            /// directly instead of walking the DocSet and re-collecting it:
385            /// the result must be exactly what a `TopKCollector` of size
386            /// `limit` would produce (score desc, doc id asc, `total_seen`).
387            ///
388            /// Only valid before the first `advance`/`seek`. Default: `None`
389            /// (the scorer must be driven).
390            fn precomputed_top_k(
391                &mut self,
392                limit: usize,
393                collect_positions: bool,
394            ) -> Option<(Vec<super::SearchResult>, u32)> {
395                let _ = (limit, collect_positions);
396                None
397            }
398        }
399    };
400}
401
402#[cfg(not(target_arch = "wasm32"))]
403define_query_traits!(Send + Sync);
404
405#[cfg(target_arch = "wasm32")]
406define_query_traits!();
407
408impl Query for Box<dyn Query> {
409    fn scorer<'a>(&self, reader: &'a SegmentReader, limit: usize) -> ScorerFuture<'a> {
410        (**self).scorer(reader, limit)
411    }
412
413    fn count_estimate<'a>(&self, reader: &'a SegmentReader) -> CountFuture<'a> {
414        (**self).count_estimate(reader)
415    }
416
417    fn scorer_with_options<'a>(
418        &self,
419        reader: &'a SegmentReader,
420        limit: usize,
421        options: ScorerOptions,
422    ) -> ScorerFuture<'a> {
423        (**self).scorer_with_options(reader, limit, options)
424    }
425
426    fn text_terms(&self, out: &mut Vec<(crate::dsl::Field, Vec<u8>)>) {
427        (**self).text_terms(out)
428    }
429
430    fn decompose(&self) -> QueryDecomposition {
431        (**self).decompose()
432    }
433
434    fn is_filter(&self) -> bool {
435        (**self).is_filter()
436    }
437
438    fn as_doc_predicate<'a>(&self, reader: &'a SegmentReader) -> Option<DocPredicate<'a>> {
439        (**self).as_doc_predicate(reader)
440    }
441
442    fn as_doc_bitset(&self, reader: &SegmentReader) -> Option<DocBitset> {
443        (**self).as_doc_bitset(reader)
444    }
445
446    fn bitset_cardinality_estimate(&self, reader: &SegmentReader) -> Option<u64> {
447        (**self).bitset_cardinality_estimate(reader)
448    }
449
450    #[cfg(feature = "sync")]
451    fn scorer_sync<'a>(
452        &self,
453        reader: &'a SegmentReader,
454        limit: usize,
455    ) -> Result<Box<dyn Scorer + 'a>> {
456        (**self).scorer_sync(reader, limit)
457    }
458
459    #[cfg(feature = "sync")]
460    fn scorer_sync_with_options<'a>(
461        &self,
462        reader: &'a SegmentReader,
463        limit: usize,
464        options: ScorerOptions,
465    ) -> Result<Box<dyn Scorer + 'a>> {
466        (**self).scorer_sync_with_options(reader, limit, options)
467    }
468}
469
470/// Empty scorer for terms that don't exist
471pub struct EmptyScorer;
472
473impl super::docset::DocSet for EmptyScorer {
474    fn doc(&self) -> DocId {
475        crate::structures::TERMINATED
476    }
477
478    fn advance(&mut self) -> DocId {
479        crate::structures::TERMINATED
480    }
481
482    fn seek(&mut self, _target: DocId) -> DocId {
483        crate::structures::TERMINATED
484    }
485
486    fn size_hint(&self) -> u32 {
487        0
488    }
489}
490
491impl Scorer for EmptyScorer {
492    fn score(&self) -> Score {
493        0.0
494    }
495}