summa_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/// Exact scores corresponding to one compact posting batch.
18pub type ScoreBatch = [Score; super::docset::DOC_BATCH_SIZE];
19pub type ScoreBatchMask = [u64; super::docset::DOC_BATCH_SIZE / 64];
20
21pub(super) fn fill_score_batch_scalar<S: Scorer + ?Sized>(
22 scorer: &mut S,
23 docs: &mut super::docset::DocBatch,
24 scores: &mut ScoreBatch,
25) -> usize {
26 let mut count = 0;
27 let mut doc = scorer.doc();
28 while count < docs.len() && doc != crate::structures::TERMINATED {
29 docs[count] = doc;
30 scores[count] = scorer.score();
31 count += 1;
32 doc = scorer.advance();
33 }
34 count
35}
36
37pub(super) fn score_batch_matches_scalar<S: Scorer + ?Sized>(
38 scorer: &mut S,
39 docs: &super::docset::DocBatch,
40 len: usize,
41 scores: &mut ScoreBatch,
42 matches: &mut ScoreBatchMask,
43) {
44 assert!(len <= docs.len());
45 matches.fill(0);
46 for i in 0..len {
47 if scorer.seek(docs[i]) == docs[i] {
48 scores[i] = scorer.score();
49 matches[i / 64] |= 1 << (i % 64);
50 }
51 }
52}
53
54/// Options that affect scorer construction rather than scoring semantics.
55///
56/// Position postings can be much larger than the top-k result itself. Keeping
57/// this explicit lets ID/score-only collectors avoid loading them while query
58/// types that need positions for matching (for example phrases) remain free to
59/// load their own internal data.
60#[derive(Debug, Clone, Default)]
61pub struct ScorerOptions {
62 /// Internal collection scope: children traverse this field's physical IDs.
63 /// Only an opted-in query tree may receive this; collection owns translation.
64 pub(crate) physical_text_field: Option<crate::Field>,
65 /// Required text clauses must expose membership before any top-k cutoff.
66 pub(crate) complete_text_matches: bool,
67 /// A top-level collector may accept bounded ranked hits plus exact count.
68 /// Nested clauses must still expose complete membership.
69 pub(crate) ranked_count_limit: Option<usize>,
70 /// Membership-only collection can skip optional scoring setup. Scoring
71 /// remains valid if a nested consumer requests it (canonical fallback).
72 pub(crate) skip_scoring_setup: bool,
73 /// Eligibility pushed into candidate collectors; never a scoring feature.
74 pub(crate) eligibility: Option<std::sync::Arc<DocBitset>>,
75 pub collect_positions: bool,
76 /// Initial top-k score floor to seed into MaxScore/BMP pruning. Used to
77 /// carry the running k-th score across the segments of one query so later
78 /// segments prune from a nonzero threshold (see `SharedThreshold`). 0.0 =
79 /// no seed. Only honored on exact, final-score executor paths.
80 pub initial_threshold: f32,
81 /// Live form of `initial_threshold`. Exact final-score executors may read
82 /// it during traversal so concurrently searched segments benefit as soon
83 /// as another segment establishes a stronger global floor.
84 pub shared_threshold: Option<super::scoring::SharedThreshold>,
85 /// Query-global LSP/0 selection projected onto this segment.
86 pub(crate) lsp_plan: Option<std::sync::Arc<super::bmp::LspSegmentPlan>>,
87 /// Query-global text statistics (document frequencies, corpus sizes,
88 /// average lengths aggregated over every segment of the searcher, or
89 /// supplied by a broker across shards). Text scorers use them for IDF
90 /// and length normalisation so a term scores the same in every segment;
91 /// a query's own `with_global_stats` takes precedence.
92 pub global_stats: Option<std::sync::Arc<super::GlobalStats>>,
93}
94
95impl ScorerOptions {
96 pub const fn with_positions() -> Self {
97 Self {
98 physical_text_field: None,
99 complete_text_matches: false,
100 ranked_count_limit: None,
101 skip_scoring_setup: false,
102 eligibility: None,
103 collect_positions: true,
104 initial_threshold: 0.0,
105 shared_threshold: None,
106 lsp_plan: None,
107 global_stats: None,
108 }
109 }
110
111 /// Preserve collection behavior while preventing a nested/component
112 /// scorer from applying a floor expressed in the outer query's score
113 /// space.
114 pub fn without_threshold(&self) -> Self {
115 Self {
116 physical_text_field: self.physical_text_field,
117 complete_text_matches: self.complete_text_matches,
118 ranked_count_limit: None,
119 skip_scoring_setup: self.skip_scoring_setup,
120 eligibility: self.eligibility.clone(),
121 collect_positions: self.collect_positions,
122 initial_threshold: 0.0,
123 shared_threshold: self
124 .shared_threshold
125 .as_ref()
126 .filter(|shared| shared.deadline().is_some())
127 .map(super::SharedThreshold::budget_only),
128 lsp_plan: None,
129 global_stats: self.global_stats.clone(),
130 }
131 }
132
133 pub(crate) fn for_required_clause(&self) -> Self {
134 Self {
135 complete_text_matches: true,
136 ..self.without_threshold()
137 }
138 }
139
140 pub(crate) fn stop_if_expired(&self) -> bool {
141 self.shared_threshold
142 .as_ref()
143 .is_some_and(super::SharedThreshold::stop_if_expired)
144 }
145
146 /// Materialization uses the same budget as scoring. Implementations must
147 /// never return a partial bitset (especially for MUST_NOT).
148 pub(crate) fn doc_bitset(
149 &self,
150 query: &dyn Query,
151 reader: &SegmentReader,
152 ) -> Option<DocBitset> {
153 query.as_doc_bitset_with_options(reader, self)
154 }
155}
156
157/// Future type for count estimation
158#[cfg(not(target_arch = "wasm32"))]
159pub type CountFuture<'a> = Pin<Box<dyn Future<Output = Result<u32>> + Send + 'a>>;
160#[cfg(target_arch = "wasm32")]
161pub type CountFuture<'a> = Pin<Box<dyn Future<Output = Result<u32>> + 'a>>;
162
163/// Per-document predicate closure type (platform-aware Send+Sync bounds)
164#[cfg(not(target_arch = "wasm32"))]
165pub type DocPredicate<'a> = Box<dyn Fn(DocId) -> bool + Send + Sync + 'a>;
166#[cfg(target_arch = "wasm32")]
167pub type DocPredicate<'a> = Box<dyn Fn(DocId) -> bool + 'a>;
168
169/// Compact bitset indexed by doc_id. O(1) lookup, ~2.25 MB for 18M docs.
170///
171/// Built from posting lists or predicate scans. Used by BMP filtered queries
172/// to avoid repeated fast-field decoding during per-slot predicate evaluation.
173/// Lookup cost depends on residency and the caller's dispatch, not just this type.
174#[derive(Debug, Clone)]
175pub struct DocBitset {
176 pub(crate) bits: Vec<u64>,
177}
178
179impl DocBitset {
180 /// Clear one document from this set.
181 #[cfg(any(feature = "native", feature = "wasm"))]
182 pub(crate) fn clear(&mut self, doc_id: u32) {
183 if let Some(word) = self.bits.get_mut(doc_id as usize / 64) {
184 *word &= !(1u64 << (doc_id % 64));
185 }
186 }
187 /// Create an empty bitset for `num_docs` documents.
188 pub fn new(num_docs: u32) -> Self {
189 let num_words = (num_docs as usize).div_ceil(64);
190 Self {
191 bits: vec![0u64; num_words],
192 }
193 }
194
195 /// The segment's document universe, with padding bits left clear.
196 pub(crate) fn all(num_docs: u32) -> Self {
197 let mut result = Self::new(num_docs);
198 result.bits.fill(u64::MAX);
199 if !num_docs.is_multiple_of(64)
200 && let Some(last) = result.bits.last_mut()
201 {
202 *last = (1u64 << (num_docs % 64)) - 1;
203 }
204 result
205 }
206
207 /// Set bit for `doc_id`.
208 #[inline]
209 pub fn set(&mut self, doc_id: u32) {
210 let word = doc_id as usize / 64;
211 let bit = doc_id as usize % 64;
212 if word < self.bits.len() {
213 self.bits[word] |= 1u64 << bit;
214 }
215 }
216
217 /// Add matches from consecutive values without a bitset read/modify/write
218 /// per hit. The caller supplies a range inside the document universe.
219 pub(super) fn insert_matching_values(
220 &mut self,
221 start: u32,
222 values: &[u64],
223 predicate: impl Fn(u64) -> bool,
224 ) {
225 for (chunk_index, chunk) in values.chunks(64).enumerate() {
226 let mut matches = [0u8; 64];
227 for (matched, &value) in matches.iter_mut().zip(chunk) {
228 *matched = u8::from(predicate(value));
229 }
230 let mask = matches
231 .iter()
232 .enumerate()
233 .fold(0u64, |mask, (bit, &matched)| {
234 mask | (u64::from(matched) << bit)
235 });
236 let doc = start as usize + chunk_index * 64;
237 let word = doc / 64;
238 let shift = doc % 64;
239 self.bits[word] |= mask << shift;
240 // Short copied blocks can share words. OR preserves their earlier
241 // matches, and zero-filled comparison tails preserve padding.
242 if shift + chunk.len() > 64 {
243 self.bits[word + 1] |= mask >> (64 - shift);
244 }
245 }
246 }
247
248 /// First set bit at or after `from`, if any.
249 pub fn next_set_bit(&self, from: DocId) -> Option<DocId> {
250 let mut word = from as usize / 64;
251 if word >= self.bits.len() {
252 return None;
253 }
254 let mut bits = self.bits[word] & (u64::MAX << (from % 64));
255 loop {
256 if bits != 0 {
257 return Some((word * 64 + bits.trailing_zeros() as usize) as DocId);
258 }
259 word += 1;
260 if word >= self.bits.len() {
261 return None;
262 }
263 bits = self.bits[word];
264 }
265 }
266
267 /// Test if `doc_id` is in the bitset.
268 #[inline(always)]
269 pub fn contains(&self, doc_id: u32) -> bool {
270 let word = doc_id as usize / 64;
271 let bit = doc_id as usize % 64;
272 word < self.bits.len() && self.bits[word] & (1u64 << bit) != 0
273 }
274
275 /// Number of set bits (matching docs).
276 pub fn count(&self) -> u32 {
277 self.bits.iter().map(|w| w.count_ones()).sum()
278 }
279
280 /// Build bitset from a predicate by scanning all docs. O(N).
281 pub fn from_predicate(num_docs: u32, pred: &dyn Fn(DocId) -> bool) -> Self {
282 let mut bs = Self::new(num_docs);
283 for doc_id in 0..num_docs {
284 if pred(doc_id) {
285 bs.set(doc_id);
286 }
287 }
288 bs
289 }
290
291 /// In-place OR (union): `self |= other`.
292 pub fn union_with(&mut self, other: &DocBitset) {
293 for (a, b) in self.bits.iter_mut().zip(other.bits.iter()) {
294 *a |= *b;
295 }
296 }
297
298 /// In-place AND (intersection): `self &= other`.
299 pub fn intersect_with(&mut self, other: &DocBitset) {
300 for (a, b) in self.bits.iter_mut().zip(other.bits.iter()) {
301 *a &= *b;
302 }
303 // Zero out any words beyond `other`'s length
304 for a in self.bits.iter_mut().skip(other.bits.len()) {
305 *a = 0;
306 }
307 }
308
309 /// In-place ANDNOT (subtract): `self &= !other`.
310 pub fn subtract(&mut self, other: &DocBitset) {
311 for (a, b) in self.bits.iter_mut().zip(other.bits.iter()) {
312 *a &= !*b;
313 }
314 }
315
316 /// Keep only the set docs for which `pred` returns true. O(count) probes —
317 /// the planner uses this to refine a small accumulator against a wide
318 /// clause instead of materializing that clause's full bitset.
319 pub fn retain(&mut self, pred: &dyn Fn(DocId) -> bool) {
320 for (w, word) in self.bits.iter_mut().enumerate() {
321 let mut bits = *word;
322 while bits != 0 {
323 let b = bits.trailing_zeros();
324 let doc = (w * 64) as u32 + b;
325 if !pred(doc) {
326 *word &= !(1u64 << b);
327 }
328 bits &= bits - 1;
329 }
330 }
331 }
332}
333
334/// Info for MaxScore-optimizable term queries
335#[derive(Debug, Clone)]
336pub struct TermQueryInfo {
337 /// Field being searched
338 pub field: crate::dsl::Field,
339 /// Term bytes (lowercase)
340 pub term: Vec<u8>,
341 /// Query-side weight of the term (a boost, or the query term frequency
342 /// of a de-duplicated match); scales the term's idf, hence its scores
343 /// and bounds alike. 1.0 = plain.
344 pub weight: f32,
345 /// Query-owned statistics override the parent's IDF and average length.
346 /// Grouping that cannot preserve them must retain the original scorer.
347 pub global_stats: Option<std::sync::Arc<super::GlobalStats>>,
348}
349
350/// Info for MaxScore-optimizable sparse term queries
351#[derive(Debug, Clone, Copy)]
352pub struct SparseTermQueryInfo {
353 /// Sparse vector field
354 pub field: crate::dsl::Field,
355 /// Dimension ID in the sparse vector
356 pub dim_id: u32,
357 /// Query weight for this dimension
358 pub weight: f32,
359 /// Whether this term participates in candidate generation. BMP/LSP uses
360 /// the pruned subset for maximum-grid traversal, then scores candidates
361 /// with every term retained in this decomposition.
362 pub candidate: bool,
363 /// MaxScore heap factor (1.0 = exact, lower = approximate)
364 pub heap_factor: f32,
365 /// Multi-value combiner for ordinal deduplication
366 pub combiner: super::MultiValueCombiner,
367 /// Multiplier on executor limit to compensate for ordinal deduplication
368 /// (1.0 = exact, 2.0 = fetch 2x then combine down)
369 pub over_fetch_factor: f32,
370 /// LSP/0 γ. None is depth-derived; Some(0) is exhaustive.
371 pub lsp_gamma: Option<usize>,
372 pub seismic_cut: usize,
373 pub seismic_factor: f32,
374 pub exhaustive: bool,
375}
376
377/// Decomposition of a query for MaxScore optimization.
378///
379/// The planner inspects this to decide whether to use text MaxScore,
380/// sparse MaxScore, or standard BooleanScorer execution.
381#[derive(Debug, Clone)]
382pub enum QueryDecomposition {
383 /// Single text term — eligible for text MaxScore grouping
384 TextTerm(TermQueryInfo),
385 /// One or more sparse dimensions — eligible for sparse MaxScore
386 SparseTerms(Vec<SparseTermQueryInfo>),
387 /// Not decomposable — falls back to standard execution
388 Opaque,
389}
390
391/// Matched positions for a field (field_id, list of scored positions)
392/// Each position includes its individual score contribution
393pub type MatchedPositions = Vec<(u32, Vec<super::ScoredPosition>)>;
394
395macro_rules! define_query_traits {
396 ($($send_bounds:tt)*) => {
397 /// A search query (async)
398 ///
399 /// Note: `scorer` takes `&self` (not `&'a self`) so that scorers don't borrow the query.
400 /// This enables query composition - queries can create sub-queries locally and get their scorers.
401 /// Implementations must clone/capture any data they need during scorer creation.
402 pub trait Query: std::fmt::Display + $($send_bounds)* {
403 /// Create a scorer for this query against a single segment (async)
404 ///
405 /// The `limit` parameter specifies the maximum number of results to return.
406 /// This is passed from the top-level search limit.
407 ///
408 /// Note: The scorer borrows only the reader, not the query. Implementations
409 /// should capture any needed query data (field, terms, etc.) during creation.
410 fn scorer<'a>(
411 &self,
412 reader: &'a SegmentReader,
413 limit: usize,
414 ) -> ScorerFuture<'a>;
415
416 /// Create a scorer with collector-specific construction options.
417 /// Query implementations that can avoid optional position data
418 /// should override this; the default preserves existing behavior.
419 fn scorer_with_options<'a>(
420 &self,
421 reader: &'a SegmentReader,
422 limit: usize,
423 options: ScorerOptions,
424 ) -> ScorerFuture<'a> {
425 let _ = options;
426 self.scorer(reader, limit)
427 }
428
429 /// Estimated number of matching documents in a segment (async)
430 fn count_estimate<'a>(&self, reader: &'a SegmentReader) -> CountFuture<'a>;
431
432 /// Create a scorer synchronously (mmap/RAM only).
433 ///
434 /// Available when the `sync` feature is enabled.
435 /// Default implementation returns an error.
436 #[cfg(feature = "sync")]
437 fn scorer_sync<'a>(
438 &self,
439 reader: &'a SegmentReader,
440 limit: usize,
441 ) -> Result<Box<dyn Scorer + 'a>> {
442 let _ = (reader, limit);
443 Err(crate::error::Error::Query(
444 "sync scorer not supported for this query type".into(),
445 ))
446 }
447
448 /// Synchronous counterpart to [`Query::scorer_with_options`].
449 #[cfg(feature = "sync")]
450 fn scorer_sync_with_options<'a>(
451 &self,
452 reader: &'a SegmentReader,
453 limit: usize,
454 options: ScorerOptions,
455 ) -> Result<Box<dyn Scorer + 'a>> {
456 let _ = options;
457 self.scorer_sync(reader, limit)
458 }
459
460 /// Decompose this query for MaxScore optimization.
461 ///
462 /// Returns `TextTerm` for simple term queries, `SparseTerms` for
463 /// sparse vector queries (single or multi-dim), or `Opaque` if
464 /// the query cannot be decomposed.
465 fn decompose(&self) -> QueryDecomposition {
466 QueryDecomposition::Opaque
467 }
468
469 /// Opt into one field-local physical address space for collection.
470 /// Every child must honor the internal scorer scope. Unknown/custom
471 /// queries retain logical IDs. Ranked term/union executors may keep
472 /// their existing mapping by opting in only for complete streams.
473 fn physical_text_field(&self, _reader: &SegmentReader, _complete: bool) -> Option<crate::Field> {
474 None
475 }
476
477 /// Sparse terms for query-global BMP superblock planning only.
478 /// Unlike scoring decomposition, this never replaces a query's
479 /// scorer. A filter wrapper may expose its inner sparse query here
480 /// while remaining opaque to Boolean scoring optimizations.
481 fn sparse_decomposition(&self) -> QueryDecomposition {
482 self.decompose()
483 }
484
485 /// Exact scoring plan for a named L1 branch. Unsupported queries
486 /// reject explicitly rather than returning truncated retrieval scores.
487 fn candidate_query(&self) -> Result<super::CandidateQuery> {
488 super::CandidateQuery::from_decomposition(self.decompose())
489 }
490
491 /// Append every `(field, term)` this query scores with BM25 to
492 /// `out`. The searcher aggregates their document frequencies
493 /// across segments before scoring (see `ScorerOptions::global_stats`).
494 fn text_terms(&self, out: &mut Vec<(crate::dsl::Field, Vec<u8>)>) {
495 let _ = out;
496 }
497
498 /// True if this query is a pure filter (always scores 1.0, no positions).
499 /// Used by the planner to convert non-selective MUST filters into predicates.
500 fn is_filter(&self) -> bool {
501 false
502 }
503
504 /// For filter queries: return a cheap per-doc predicate against a segment.
505 /// The predicate does O(1) work per doc (e.g., fast-field lookup).
506 fn as_doc_predicate<'a>(
507 &self,
508 _reader: &'a SegmentReader,
509 ) -> Option<DocPredicate<'a>> {
510 None
511 }
512
513 /// Build a compact bitset of matching doc_ids for this query.
514 ///
515 /// Preferred over `as_doc_predicate` for BMP filtered queries because
516 /// bitset lookup is ~2ns vs ~30-40ns for a fast-field closure.
517 /// Default returns None; TermQuery overrides this to build from its
518 /// posting list in O(M) time.
519 fn as_doc_bitset(
520 &self,
521 _reader: &SegmentReader,
522 ) -> Option<DocBitset> {
523 None
524 }
525
526 /// Budget-aware materialization. `None` means unsupported or
527 /// cancelled; cancellation must flag the shared budget, and a
528 /// partial bitset must never escape as a complete filter.
529 fn as_doc_bitset_with_options(&self, reader: &SegmentReader, options: &ScorerOptions) -> Option<DocBitset> {
530 if options.stop_if_expired() { return None; }
531 let bitset = self.as_doc_bitset(reader);
532 if options.stop_if_expired() { None } else { bitset }
533 }
534
535 /// Cheap estimate of how many docs this filter clause matches in
536 /// the segment. Used by the boolean planner to order MUST/MUST_NOT
537 /// evaluation: the narrowest clause is materialized first and wider
538 /// clauses refine it with per-doc probes instead of being fully
539 /// materialized. `None` = unknown (treated as matching everything).
540 fn bitset_cardinality_estimate(&self, _reader: &SegmentReader) -> Option<u64> {
541 None
542 }
543
544 /// A term with identical membership for count-only collection.
545 /// This does not change scoring decomposition. The collector must
546 /// still account for deleted rows, chunks and missing metadata.
547 fn count_equivalent_term(&self) -> Option<super::TermQueryInfo> {
548 match self.decompose() {
549 QueryDecomposition::TextTerm(info) => Some(info),
550 _ => None,
551 }
552 }
553
554 /// A term-equivalent cardinality alongside an exact score-only
555 /// text rank plan. Unlike the count-only hint, opaque/custom plans
556 /// do not opt in automatically. The collector still validates the
557 /// indexed document space, deletions, mappings and cardinality gate.
558 fn ranked_count_equivalent_term(&self) -> Option<super::TermQueryInfo> {
559 match self.decompose() {
560 QueryDecomposition::TextTerm(info) => Some(info),
561 _ => None,
562 }
563 }
564
565 /// Opt into top-level bounded conjunction results with an exact
566 /// count. Wrappers must not inherit this automatically: they may
567 /// consume or hide a child's cardinality. Defaults to streaming.
568 fn supports_ranked_conjunction_count(&self) -> bool { false }
569
570 /// For a query that is a pure disjunction of sub-queries (a Boolean
571 /// query with only SHOULD clauses and no boost), the clauses.
572 ///
573 /// The boolean planner flattens these into the enclosing SHOULD
574 /// list: `OR(OR(a, b), c)` scores exactly like `OR(a, b, c)`, and
575 /// the flat form is eligible for MaxScore and filter push-down
576 /// where the nested form would be an opaque, top-k-truncated
577 /// sub-scorer.
578 fn should_children(&self) -> Option<&[std::sync::Arc<dyn Query>]> {
579 None
580 }
581 }
582
583 /// Scored document stream: a DocSet that also provides scores.
584 pub trait Scorer: super::docset::DocSet + $($send_bounds)* {
585 /// Score for current document
586 fn score(&self) -> Score;
587
588 /// Opt into final-score bounds before candidate confirmation.
589 /// Only a top-level ranked collector may use this to omit matches;
590 /// complete collectors and enclosing scorers retain exact traversal.
591 fn supports_candidate_score_bounds(&self) -> bool { false }
592
593 /// Conservative upper bound on `score()` if the current candidate
594 /// confirms. Must include floating-point rounding and use this
595 /// scorer's final score space. The default never excludes a score.
596 fn candidate_score_upper_bound(&self) -> Score { Score::INFINITY }
597
598 /// Conservative final-score bound through an inclusive physical doc ID.
599 /// The interval starts at the current candidate. Only a top-level ranked
600 /// collector may skip it; exact/nested traversal stays unchanged.
601 /// Returning None opts out for the remaining traversal. A skipped
602 /// interval is left through seek_candidate, including deadline checks.
603 fn candidate_block_upper_bound(&mut self) -> Option<(DocId, Score)> { None }
604
605 /// Advance while optionally omitting candidates whose final-score bound
606 /// cannot compete with `minimum`. Only top-level ranked collection
607 /// may call this. `allow_equal = false` certifies that every remaining
608 /// stable ID loses an equal-score tie against the full local heap.
609 fn advance_competitive_candidate(&mut self, _minimum: Score, _allow_equal: bool) -> DocId {
610 self.advance_candidate()
611 }
612
613 /// Optionally prove a lower bound on the kth best score using
614 /// distinct real matches. Restore the current candidate unless
615 /// cancelled. Emit no sampled hits; normal traversal still owns them.
616 /// Only top-level ranked collection may use this hint. Equality
617 /// remains competitive until its own heap resolves stable-ID ties.
618 fn seed_ranked_score(&mut self, _limit: usize) -> Option<Score> { None }
619
620
621 /// Whether this scorer's batches remain useful when every match
622 /// needs a predicate check. Composite scorers can amortize child
623 /// traversal; leaf bitmap production alone may cost more than a
624 /// scalar pass once filtering visits every set bit again.
625 fn supports_filtered_windows(&self) -> bool { false }
626
627 /// Whether compact exact-score batches amortize this scorer's work.
628 fn supports_score_batches(&self) -> bool { false }
629
630 /// Consume a sorted exact prefix, leaving the first unconsumed match.
631 /// Scores correspond to docs[..returned_len]; zero means exhausted.
632 fn fill_score_batch(&mut self, docs: &mut super::docset::DocBatch, scores: &mut ScoreBatch) -> usize {
633 fill_score_batch_scalar(self, docs, scores)
634 }
635
636 /// Probe sorted unique docs[..len] without changing their order.
637 /// Set membership bits index the input and its exact final scores.
638 /// The cursor remains at or beyond the last probe.
639 fn score_batch_matches(&mut self, docs: &super::docset::DocBatch, len: usize,
640 scores: &mut ScoreBatch, matches: &mut ScoreBatchMask) {
641 score_batch_matches_scalar(self, docs, len, scores, matches)
642 }
643
644 /// Whether score-only collection benefits from bounded score windows.
645 /// Positions still require ordinary per-document collection.
646 fn supports_score_windows(&self) -> bool { false }
647
648 /// Add each exact match's final score in a forward-only document window.
649 /// Existing values and membership bits are retained. A nested scorer
650 /// contributes its complete score once, preserving its summation order.
651 /// The cursor ends at the first match after the interval. Previously
652 /// consumed matches stay consumed, just as with `fill_doc_window`.
653 fn accumulate_score_window(
654 &mut self,
655 base: DocId,
656 scores: &mut [Score; super::docset::DOC_WINDOW_SIZE as usize],
657 bits: &mut super::docset::DocWindow,
658 ) {
659 let end = base.saturating_add(super::docset::DOC_WINDOW_SIZE);
660 let mut doc = self.seek(base);
661 while doc < end {
662 let offset = (doc - base) as usize;
663 scores[offset] += self.score();
664 bits[offset / 64] |= 1u64 << (offset % 64);
665 doc = self.advance();
666 }
667 }
668
669 /// Replace a bounded window of exact scores and membership. Only
670 /// advertised through `supports_score_windows` when it is beneficial.
671 fn fill_score_window(
672 &mut self,
673 base: DocId,
674 scores: &mut [Score; super::docset::DOC_WINDOW_SIZE as usize],
675 bits: &mut super::docset::DocWindow,
676 ) {
677 scores.fill(0.0);
678 bits.fill(0);
679 let end = base.saturating_add(super::docset::DOC_WINDOW_SIZE);
680 let mut doc = self.seek(base);
681 while doc < end {
682 let offset = (doc - base) as usize;
683 scores[offset] = self.score();
684 bits[offset / 64] |= 1u64 << (offset % 64);
685 doc = self.advance();
686 }
687 }
688
689 /// Move to the next candidate for a two-phase conjunction. Candidates
690 /// may be false positives: the caller must call `confirm_candidate`
691 /// before consuming scores or positions. Ordinary DocSet traversal
692 /// remains exact, including after candidate traversal. The default
693 /// simply advances the exact stream.
694 fn advance_candidate(&mut self) -> DocId {
695 self.advance()
696 }
697
698 /// Seek a candidate at or beyond `target`, without skipping any
699 /// possible exact match. See `advance_candidate` for the protocol.
700 fn seek_candidate(&mut self, target: DocId) -> DocId {
701 self.seek(target)
702 }
703
704 /// Exactly verify the current candidate without advancing it.
705 /// Repeated calls must agree unless cancellation ends the stream.
706 /// Only a true result permits consuming its score/positions.
707 fn confirm_candidate(&mut self) -> bool {
708 self.doc() != crate::structures::TERMINATED
709 }
710
711 /// Get matched positions for the current document (if available)
712 /// Returns (field_id, positions) pairs where positions are encoded as per PositionMode
713 fn matched_positions(&self) -> Option<MatchedPositions> {
714 None
715 }
716
717 /// Exact cardinality supplied only for an explicit top-level ranked
718 /// count request. Ordinary streams and ranked scorers return None.
719 fn exact_ranked_count(&self) -> Option<u64> { None }
720
721 /// Standalone fast path for scorers that wrap an already ranked
722 /// top-k list (text and vector executors). When this query is the top-level
723 /// query of a segment search, the caller may take the ranked list
724 /// directly instead of walking the DocSet and re-collecting it:
725 /// the result must be exactly what a `TopKCollector` of size
726 /// `limit` would produce (score desc, doc id asc, `total_seen`).
727 ///
728 /// Only valid before the first `advance`/`seek`. Default: `None`
729 /// (the scorer must be driven).
730 fn precomputed_top_k(
731 &mut self,
732 limit: usize,
733 collect_positions: bool,
734 ) -> Option<(Vec<super::SearchResult>, u32)> {
735 let _ = (limit, collect_positions);
736 None
737 }
738 }
739 };
740}
741
742#[cfg(not(target_arch = "wasm32"))]
743define_query_traits!(Send + Sync);
744
745#[cfg(target_arch = "wasm32")]
746define_query_traits!();
747
748impl Query for Box<dyn Query> {
749 fn as_doc_bitset_with_options(
750 &self,
751 reader: &SegmentReader,
752 options: &ScorerOptions,
753 ) -> Option<DocBitset> {
754 (**self).as_doc_bitset_with_options(reader, options)
755 }
756 fn scorer<'a>(&self, reader: &'a SegmentReader, limit: usize) -> ScorerFuture<'a> {
757 (**self).scorer(reader, limit)
758 }
759
760 fn count_estimate<'a>(&self, reader: &'a SegmentReader) -> CountFuture<'a> {
761 (**self).count_estimate(reader)
762 }
763
764 fn scorer_with_options<'a>(
765 &self,
766 reader: &'a SegmentReader,
767 limit: usize,
768 options: ScorerOptions,
769 ) -> ScorerFuture<'a> {
770 (**self).scorer_with_options(reader, limit, options)
771 }
772
773 fn candidate_query(&self) -> Result<super::CandidateQuery> {
774 (**self).candidate_query()
775 }
776
777 fn text_terms(&self, out: &mut Vec<(crate::dsl::Field, Vec<u8>)>) {
778 (**self).text_terms(out)
779 }
780
781 fn physical_text_field(&self, reader: &SegmentReader, complete: bool) -> Option<crate::Field> {
782 (**self).physical_text_field(reader, complete)
783 }
784
785 fn decompose(&self) -> QueryDecomposition {
786 (**self).decompose()
787 }
788
789 fn sparse_decomposition(&self) -> QueryDecomposition {
790 (**self).sparse_decomposition()
791 }
792
793 fn is_filter(&self) -> bool {
794 (**self).is_filter()
795 }
796
797 fn as_doc_predicate<'a>(&self, reader: &'a SegmentReader) -> Option<DocPredicate<'a>> {
798 (**self).as_doc_predicate(reader)
799 }
800
801 fn as_doc_bitset(&self, reader: &SegmentReader) -> Option<DocBitset> {
802 (**self).as_doc_bitset(reader)
803 }
804
805 fn should_children(&self) -> Option<&[std::sync::Arc<dyn Query>]> {
806 (**self).should_children()
807 }
808
809 fn count_equivalent_term(&self) -> Option<super::TermQueryInfo> {
810 (**self).count_equivalent_term()
811 }
812
813 fn ranked_count_equivalent_term(&self) -> Option<super::TermQueryInfo> {
814 (**self).ranked_count_equivalent_term()
815 }
816
817 fn supports_ranked_conjunction_count(&self) -> bool {
818 (**self).supports_ranked_conjunction_count()
819 }
820
821 fn bitset_cardinality_estimate(&self, reader: &SegmentReader) -> Option<u64> {
822 (**self).bitset_cardinality_estimate(reader)
823 }
824
825 #[cfg(feature = "sync")]
826 fn scorer_sync<'a>(
827 &self,
828 reader: &'a SegmentReader,
829 limit: usize,
830 ) -> Result<Box<dyn Scorer + 'a>> {
831 (**self).scorer_sync(reader, limit)
832 }
833
834 #[cfg(feature = "sync")]
835 fn scorer_sync_with_options<'a>(
836 &self,
837 reader: &'a SegmentReader,
838 limit: usize,
839 options: ScorerOptions,
840 ) -> Result<Box<dyn Scorer + 'a>> {
841 (**self).scorer_sync_with_options(reader, limit, options)
842 }
843}
844
845/// Empty scorer for terms that don't exist
846pub struct EmptyScorer;
847
848// A document universe is a neutral required cursor for exclusion-only Boolean
849// queries. It adds neither a relevance score nor matched positions.
850impl Scorer for super::AllDocSet {
851 fn score(&self) -> Score {
852 0.0
853 }
854}
855
856impl super::docset::DocSet for EmptyScorer {
857 fn doc(&self) -> DocId {
858 crate::structures::TERMINATED
859 }
860
861 fn advance(&mut self) -> DocId {
862 crate::structures::TERMINATED
863 }
864
865 fn seek(&mut self, _target: DocId) -> DocId {
866 crate::structures::TERMINATED
867 }
868
869 fn size_hint(&self) -> u32 {
870 0
871 }
872}
873
874impl Scorer for EmptyScorer {
875 fn score(&self) -> Score {
876 0.0
877 }
878}
879
880#[cfg(test)]
881mod bitset_tests {
882 use super::DocBitset;
883
884 #[test]
885 fn batched_matches_preserve_neighbours_and_padding_at_every_bit_offset() {
886 for start in 0..64 {
887 for len in 0..=257 {
888 let end = start + len;
889 let mut bits = DocBitset::new(end + 1);
890 if start != 0 {
891 bits.set(start - 1);
892 }
893 bits.set(end);
894 let values: Vec<_> = (0..len).map(|i| u64::from(i % 3)).collect();
895 bits.insert_matching_values(start, &values, |v| v == 1);
896 for doc in 0..=end {
897 let expected = (start != 0 && doc == start - 1)
898 || doc == end
899 || (doc >= start && doc < end && (doc - start) % 3 == 1);
900 assert_eq!(bits.contains(doc), expected, "start={start}, len={len}");
901 }
902 assert_eq!(bits.count(), u32::from(start != 0) + 1 + (len + 1) / 3);
903 }
904 }
905 }
906}