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}