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}