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