Skip to main content

summa_core/query/
range.rs

1//! Range query for fast-field numeric filtering.
2//!
3//! `RangeQuery` produces a `RangeScorer` that scans a fast-field column and
4//! yields documents whose value falls within the specified bounds. Score is
5//! always 1.0 — this is a pure filter query.
6//!
7//! Supports u64, i64, and f64 fields. Unsigned and sortable-encoded f64 values
8//! compare in their stored domain; zigzag-encoded i64 values must be decoded
9//! before signed comparison.
10//!
11//! When placed in a `BooleanQuery` MUST clause, the `BooleanScorer`'s
12//! seek-based intersection makes this efficient even on large segments.
13
14use crate::dsl::Field;
15use crate::segment::SegmentReader;
16use crate::structures::TERMINATED;
17use crate::structures::fast_field::{
18    FAST_FIELD_MISSING, FastFieldColumnType, SingleValueCursor, f64_to_sortable_u64, zigzag_decode,
19};
20use crate::{DocId, Score};
21
22use super::docset::DocSet;
23use super::traits::{CountFuture, Query, Scorer, ScorerFuture};
24
25// ── Typed range bounds ───────────────────────────────────────────────────
26
27/// Inclusive range bounds in the user's type domain.
28#[derive(Debug, Clone)]
29pub enum RangeBound {
30    /// u64 range — stored raw
31    U64 { min: Option<u64>, max: Option<u64> },
32    /// i64 range — stored values are zigzag-decoded before comparison
33    I64 { min: Option<i64>, max: Option<i64> },
34    /// f64 range — will be sortable-encoded for comparison
35    F64 { min: Option<f64>, max: Option<f64> },
36}
37
38/// One compiled comparison shared by scorer, random probes, and batch scans.
39#[derive(Clone, Copy, Debug, PartialEq)]
40enum CompiledRange {
41    Raw { lo: u64, hi: u64 },
42    Signed { lo: i64, hi: i64 },
43}
44
45impl CompiledRange {
46    fn may_match(self, bounds: Option<(u64, u64)>) -> bool {
47        let Some((min, max)) = bounds else {
48            return true;
49        };
50        if min == max {
51            return self.contains(min);
52        }
53        match self {
54            Self::Raw { lo, hi } => lo <= hi && lo <= max.min(FAST_FIELD_MISSING - 1) && hi >= min,
55            // Zigzag does not preserve order. Only exact constants above can
56            // reject signed blocks using a raw-domain interval.
57            Self::Signed { .. } => true,
58        }
59    }
60
61    #[inline]
62    fn contains(self, raw: u64) -> bool {
63        if raw == FAST_FIELD_MISSING {
64            return false;
65        }
66        match self {
67            Self::Raw { lo, hi } => raw >= lo && raw <= hi,
68            Self::Signed { lo, hi } => {
69                let value = zigzag_decode(raw);
70                value >= lo && value <= hi
71            }
72        }
73    }
74}
75
76impl RangeBound {
77    fn compile(&self) -> CompiledRange {
78        match *self {
79            Self::U64 { min, max } => CompiledRange::Raw {
80                lo: min.unwrap_or(0),
81                hi: max.unwrap_or(u64::MAX - 1),
82            },
83            Self::I64 { min, max } => CompiledRange::Signed {
84                lo: min.unwrap_or(i64::MIN),
85                hi: max.unwrap_or(i64::MAX),
86            },
87            Self::F64 { min, max } => CompiledRange::Raw {
88                lo: min.map(f64_to_sortable_u64).unwrap_or(0),
89                hi: max.map(f64_to_sortable_u64).unwrap_or(u64::MAX - 1),
90            },
91        }
92    }
93}
94
95// ── RangeQuery ───────────────────────────────────────────────────────────
96
97/// Fast-field range query.
98///
99/// Scans all documents in a segment and yields those whose fast-field value
100/// falls within `[min, max]` (inclusive). Score is always 1.0.
101#[derive(Debug, Clone)]
102pub struct RangeQuery {
103    pub field: Field,
104    pub bound: RangeBound,
105}
106
107impl std::fmt::Display for RangeQuery {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        match &self.bound {
110            RangeBound::U64 { min, max } => write!(
111                f,
112                "Range({}:[{} TO {}])",
113                self.field.0,
114                min.map_or("*".to_string(), |v| v.to_string()),
115                max.map_or("*".to_string(), |v| v.to_string()),
116            ),
117            RangeBound::I64 { min, max } => write!(
118                f,
119                "Range({}:[{} TO {}])",
120                self.field.0,
121                min.map_or("*".to_string(), |v| v.to_string()),
122                max.map_or("*".to_string(), |v| v.to_string()),
123            ),
124            RangeBound::F64 { min, max } => write!(
125                f,
126                "Range({}:[{} TO {}])",
127                self.field.0,
128                min.map_or("*".to_string(), |v| v.to_string()),
129                max.map_or("*".to_string(), |v| v.to_string()),
130            ),
131        }
132    }
133}
134
135impl RangeQuery {
136    pub fn new(field: Field, bound: RangeBound) -> Self {
137        Self { field, bound }
138    }
139
140    /// Convenience: u64 range
141    pub fn u64(field: Field, min: Option<u64>, max: Option<u64>) -> Self {
142        Self::new(field, RangeBound::U64 { min, max })
143    }
144
145    /// Convenience: i64 range
146    pub fn i64(field: Field, min: Option<i64>, max: Option<i64>) -> Self {
147        Self::new(field, RangeBound::I64 { min, max })
148    }
149
150    /// Convenience: f64 range
151    pub fn f64(field: Field, min: Option<f64>, max: Option<f64>) -> Self {
152        Self::new(field, RangeBound::F64 { min, max })
153    }
154}
155
156impl Query for RangeQuery {
157    fn scorer<'a>(&self, reader: &'a SegmentReader, _limit: usize) -> ScorerFuture<'a> {
158        let field = self.field;
159        let bound = self.bound.clone();
160        Box::pin(async move {
161            match RangeScorer::new(reader, field, &bound) {
162                Ok(scorer) => Ok(Box::new(scorer) as Box<dyn Scorer>),
163                Err(_) => Ok(Box::new(EmptyRangeScorer) as Box<dyn Scorer>),
164            }
165        })
166    }
167
168    #[cfg(feature = "sync")]
169    fn scorer_sync<'a>(
170        &self,
171        reader: &'a SegmentReader,
172        _limit: usize,
173    ) -> crate::Result<Box<dyn Scorer + 'a>> {
174        match RangeScorer::new(reader, self.field, &self.bound) {
175            Ok(scorer) => Ok(Box::new(scorer) as Box<dyn Scorer + 'a>),
176            Err(_) => Ok(Box::new(EmptyRangeScorer) as Box<dyn Scorer + 'a>),
177        }
178    }
179
180    fn count_estimate<'a>(&self, reader: &'a SegmentReader) -> CountFuture<'a> {
181        let num_docs = reader.num_docs();
182        // Rough estimate: half the segment (we don't know selectivity)
183        Box::pin(async move { Ok(num_docs / 2) })
184    }
185
186    fn is_filter(&self) -> bool {
187        true
188    }
189
190    fn as_doc_predicate<'a>(&self, reader: &'a SegmentReader) -> Option<super::DocPredicate<'a>> {
191        let fast_field = reader.fast_field(self.field.0)?;
192        let bound = self.bound.compile();
193        Some(Box::new(move |doc_id| {
194            bound.contains(fast_field.get_u64(doc_id))
195        }))
196    }
197
198    fn as_doc_bitset(&self, reader: &SegmentReader) -> Option<super::DocBitset> {
199        let fast_field = reader.fast_field(self.field.0)?;
200        if fast_field.multi {
201            // Range predicates inspect the first value, not any value. Keep
202            // this path until the reader owns a batch API with that contract.
203            let pred = self.as_doc_predicate(reader)?;
204            return Some(super::DocBitset::from_predicate(reader.num_docs(), &*pred));
205        }
206        let bound = self.bound.compile();
207        let mut bits = super::DocBitset::new(reader.num_docs());
208        // Dispatch the numeric domain once per batch. The concrete predicates
209        // can vectorize without duplicating the owning reader's decode loop.
210        let _: Result<(), std::convert::Infallible> = fast_field
211            .try_scan_single_value_batches_where(
212                |block| {
213                    fast_field.column_type == FastFieldColumnType::TextOrdinal
214                        || bound.may_match(block.value_bounds())
215                },
216                |start, values| {
217                    match bound {
218                        CompiledRange::Raw { lo, hi } => {
219                            bits.insert_matching_values(start, values, |raw| {
220                                raw != FAST_FIELD_MISSING && raw >= lo && raw <= hi
221                            });
222                        }
223                        CompiledRange::Signed { lo, hi } => {
224                            bits.insert_matching_values(start, values, |raw| {
225                                let value = zigzag_decode(raw);
226                                raw != FAST_FIELD_MISSING && value >= lo && value <= hi
227                            });
228                        }
229                    }
230                    Ok(())
231                },
232            );
233        Some(bits)
234    }
235
236    fn bitset_cardinality_estimate(&self, reader: &SegmentReader) -> Option<u64> {
237        // Sampled: probe ~1k evenly spaced docs with the fast-field predicate.
238        // Works for every encoding (incl. i64 zigzag, where min/max
239        // interpolation would mis-order). Rounded up so a rare-but-present
240        // range never estimates to zero.
241        let pred = self.as_doc_predicate(reader)?;
242        let n = reader.num_docs();
243        if n == 0 {
244            return Some(0);
245        }
246        const SAMPLES: u32 = 1024;
247        if n <= SAMPLES {
248            return Some((0..n).filter(|&d| pred(d)).count() as u64);
249        }
250        let step = n / SAMPLES;
251        let hits = (0..SAMPLES).filter(|&i| pred(i * step)).count() as u64;
252        Some(((hits * n as u64) / SAMPLES as u64).max(1))
253    }
254}
255
256// ── RangeScorer ──────────────────────────────────────────────────────────
257
258/// Scorer that scans a fast-field column and yields matching docs.
259///
260/// For u64 and f64 fields, comparison is done in the raw u64 domain (both
261/// use order-preserving encodings). For i64 fields, zigzag encoding does NOT
262/// preserve order, so we decode each value and compare in i64 domain.
263struct RangeScorer<'a> {
264    /// Cached fast-field reader — avoids HashMap lookup per document
265    fast_field: &'a crate::structures::fast_field::FastFieldReader,
266    bound: CompiledRange,
267    /// Current document position.
268    current: u32,
269    num_docs: u32,
270    cursor: Option<SingleValueCursor<'a>>,
271    /// Membership in the batch ending at `batch_end`, with bit zero at its start.
272    batch_start: u32,
273    batch_end: u32,
274    matches: u64,
275    scalar_probes: u8,
276}
277
278/// Empty scorer returned when the field has no fast-field data.
279struct EmptyRangeScorer;
280
281impl<'a> RangeScorer<'a> {
282    fn new(
283        reader: &'a SegmentReader,
284        field: Field,
285        bound: &RangeBound,
286    ) -> Result<Self, EmptyRangeScorer> {
287        let fast_field = reader.fast_field(field.0).ok_or(EmptyRangeScorer)?;
288        let num_docs = reader.num_docs();
289        let mut scorer = Self {
290            fast_field,
291            bound: bound.compile(),
292            current: 0,
293            num_docs,
294            cursor: (!fast_field.multi).then(|| SingleValueCursor::new(fast_field)),
295            batch_start: 0,
296            batch_end: 0,
297            matches: 0,
298            scalar_probes: 0,
299        };
300
301        scorer.scan_from(0);
302        Ok(scorer)
303    }
304
305    /// Keep isolated probes scalar; amortize sustained scans over one word.
306    #[inline]
307    fn scan_from(&mut self, mut next: DocId) {
308        while next < self.num_docs {
309            if next < self.batch_end {
310                let offset = next - self.batch_start;
311                let remaining = self.matches & (u64::MAX << offset);
312                if remaining != 0 {
313                    self.current = self.batch_start + remaining.trailing_zeros();
314                    return;
315                }
316                next = self.batch_end;
317                continue;
318            }
319            if self.scalar_probes < 8 || self.cursor.is_none() {
320                self.scalar_probes = self.scalar_probes.saturating_add(1);
321                if self.bound.contains(self.fast_field.get_u64(next)) {
322                    self.current = next;
323                    return;
324                }
325                next += 1;
326                continue;
327            }
328            if !self.refill_batch(next) {
329                break;
330            }
331        }
332        self.current = self.num_docs;
333    }
334    /// Keep decoded scratch out of the per-hit advance/seek path.
335    #[inline(never)]
336    fn refill_batch(&mut self, start: DocId) -> bool {
337        let mut values = [0u64; 64];
338        let count = self.cursor.as_mut().unwrap().read_batch(start, &mut values);
339        // Scalar reads treat documents beyond the column as missing too.
340        if count == 0 {
341            return false;
342        }
343        self.batch_start = start;
344        self.batch_end = start + count as u32;
345        self.matches = values[..count]
346            .iter()
347            .enumerate()
348            .fold(0, |mask, (i, &raw)| {
349                mask | (u64::from(self.bound.contains(raw)) << i)
350            });
351        true
352    }
353}
354
355impl DocSet for RangeScorer<'_> {
356    fn doc(&self) -> DocId {
357        if self.current >= self.num_docs {
358            TERMINATED
359        } else {
360            self.current
361        }
362    }
363
364    fn advance(&mut self) -> DocId {
365        if self.current < self.num_docs {
366            self.scan_from(self.current + 1);
367        }
368        self.doc()
369    }
370
371    fn seek(&mut self, target: DocId) -> DocId {
372        if self.current >= self.num_docs {
373            return TERMINATED;
374        }
375        if target <= self.current {
376            return self.current;
377        }
378        if target >= self.batch_end && target > self.current + 1 {
379            self.scalar_probes = 0;
380        }
381        self.scan_from(target);
382        self.doc()
383    }
384
385    fn size_hint(&self) -> u32 {
386        // Upper bound: remaining docs
387        self.num_docs.saturating_sub(self.current)
388    }
389}
390
391impl Scorer for RangeScorer<'_> {
392    fn score(&self) -> Score {
393        1.0
394    }
395}
396
397impl DocSet for EmptyRangeScorer {
398    fn doc(&self) -> DocId {
399        TERMINATED
400    }
401    fn advance(&mut self) -> DocId {
402        TERMINATED
403    }
404    fn seek(&mut self, _target: DocId) -> DocId {
405        TERMINATED
406    }
407    fn size_hint(&self) -> u32 {
408        0
409    }
410}
411
412impl Scorer for EmptyRangeScorer {
413    fn score(&self) -> Score {
414        0.0
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn test_range_bound_u64_compile() {
424        let b = RangeBound::U64 {
425            min: Some(10),
426            max: Some(100),
427        };
428        assert_eq!(b.compile(), CompiledRange::Raw { lo: 10, hi: 100 });
429    }
430
431    #[test]
432    fn test_range_bound_f64_compile_preserves_order() {
433        let b1 = RangeBound::F64 {
434            min: Some(-1.0),
435            max: Some(1.0),
436        };
437        let CompiledRange::Raw { lo, hi } = b1.compile() else {
438            panic!("expected raw bounds")
439        };
440        assert!(lo < hi);
441
442        let b2 = RangeBound::F64 {
443            min: Some(0.0),
444            max: Some(100.0),
445        };
446        let CompiledRange::Raw { lo, hi } = b2.compile() else {
447            panic!("expected raw bounds")
448        };
449        assert!(lo < hi);
450    }
451
452    #[test]
453    fn test_range_bound_open_bounds() {
454        let b = RangeBound::U64 {
455            min: None,
456            max: None,
457        };
458        assert_eq!(
459            b.compile(),
460            CompiledRange::Raw {
461                lo: 0,
462                hi: u64::MAX - 1
463            }
464        );
465    }
466
467    #[test]
468    fn test_range_query_constructors() {
469        let q = RangeQuery::u64(Field(0), Some(10), Some(100));
470        assert_eq!(q.field, Field(0));
471        assert!(matches!(
472            q.bound,
473            RangeBound::U64 {
474                min: Some(10),
475                max: Some(100)
476            }
477        ));
478
479        let q = RangeQuery::i64(Field(1), Some(-50), Some(50));
480        assert!(matches!(
481            q.bound,
482            RangeBound::I64 {
483                min: Some(-50),
484                max: Some(50)
485            }
486        ));
487
488        let q = RangeQuery::f64(Field(2), Some(0.5), Some(9.5));
489        assert!(matches!(q.bound, RangeBound::F64 { .. }));
490    }
491}