Skip to main content

hermes_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::{FAST_FIELD_MISSING, f64_to_sortable_u64, zigzag_decode};
18use crate::{DocId, Score};
19
20use super::docset::DocSet;
21use super::traits::{CountFuture, Query, Scorer, ScorerFuture};
22
23// ── Typed range bounds ───────────────────────────────────────────────────
24
25/// Inclusive range bounds in the user's type domain.
26#[derive(Debug, Clone)]
27pub enum RangeBound {
28    /// u64 range — stored raw
29    U64 { min: Option<u64>, max: Option<u64> },
30    /// i64 range — stored values are zigzag-decoded before comparison
31    I64 { min: Option<i64>, max: Option<i64> },
32    /// f64 range — will be sortable-encoded for comparison
33    F64 { min: Option<f64>, max: Option<f64> },
34}
35
36/// One compiled comparison shared by scorer, random probes, and batch scans.
37#[derive(Clone, Copy, Debug, PartialEq)]
38enum CompiledRange {
39    Raw { lo: u64, hi: u64 },
40    Signed { lo: i64, hi: i64 },
41}
42
43impl CompiledRange {
44    #[inline]
45    fn contains(self, raw: u64) -> bool {
46        if raw == FAST_FIELD_MISSING {
47            return false;
48        }
49        match self {
50            Self::Raw { lo, hi } => raw >= lo && raw <= hi,
51            Self::Signed { lo, hi } => {
52                let value = zigzag_decode(raw);
53                value >= lo && value <= hi
54            }
55        }
56    }
57}
58
59impl RangeBound {
60    fn compile(&self) -> CompiledRange {
61        match *self {
62            Self::U64 { min, max } => CompiledRange::Raw {
63                lo: min.unwrap_or(0),
64                hi: max.unwrap_or(u64::MAX - 1),
65            },
66            Self::I64 { min, max } => CompiledRange::Signed {
67                lo: min.unwrap_or(i64::MIN),
68                hi: max.unwrap_or(i64::MAX),
69            },
70            Self::F64 { min, max } => CompiledRange::Raw {
71                lo: min.map(f64_to_sortable_u64).unwrap_or(0),
72                hi: max.map(f64_to_sortable_u64).unwrap_or(u64::MAX - 1),
73            },
74        }
75    }
76}
77
78// ── RangeQuery ───────────────────────────────────────────────────────────
79
80/// Fast-field range query.
81///
82/// Scans all documents in a segment and yields those whose fast-field value
83/// falls within `[min, max]` (inclusive). Score is always 1.0.
84#[derive(Debug, Clone)]
85pub struct RangeQuery {
86    pub field: Field,
87    pub bound: RangeBound,
88}
89
90impl std::fmt::Display for RangeQuery {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        match &self.bound {
93            RangeBound::U64 { min, max } => write!(
94                f,
95                "Range({}:[{} TO {}])",
96                self.field.0,
97                min.map_or("*".to_string(), |v| v.to_string()),
98                max.map_or("*".to_string(), |v| v.to_string()),
99            ),
100            RangeBound::I64 { min, max } => write!(
101                f,
102                "Range({}:[{} TO {}])",
103                self.field.0,
104                min.map_or("*".to_string(), |v| v.to_string()),
105                max.map_or("*".to_string(), |v| v.to_string()),
106            ),
107            RangeBound::F64 { min, max } => write!(
108                f,
109                "Range({}:[{} TO {}])",
110                self.field.0,
111                min.map_or("*".to_string(), |v| v.to_string()),
112                max.map_or("*".to_string(), |v| v.to_string()),
113            ),
114        }
115    }
116}
117
118impl RangeQuery {
119    pub fn new(field: Field, bound: RangeBound) -> Self {
120        Self { field, bound }
121    }
122
123    /// Convenience: u64 range
124    pub fn u64(field: Field, min: Option<u64>, max: Option<u64>) -> Self {
125        Self::new(field, RangeBound::U64 { min, max })
126    }
127
128    /// Convenience: i64 range
129    pub fn i64(field: Field, min: Option<i64>, max: Option<i64>) -> Self {
130        Self::new(field, RangeBound::I64 { min, max })
131    }
132
133    /// Convenience: f64 range
134    pub fn f64(field: Field, min: Option<f64>, max: Option<f64>) -> Self {
135        Self::new(field, RangeBound::F64 { min, max })
136    }
137}
138
139impl Query for RangeQuery {
140    fn scorer<'a>(&self, reader: &'a SegmentReader, _limit: usize) -> ScorerFuture<'a> {
141        let field = self.field;
142        let bound = self.bound.clone();
143        Box::pin(async move {
144            match RangeScorer::new(reader, field, &bound) {
145                Ok(scorer) => Ok(Box::new(scorer) as Box<dyn Scorer>),
146                Err(_) => Ok(Box::new(EmptyRangeScorer) as Box<dyn Scorer>),
147            }
148        })
149    }
150
151    #[cfg(feature = "sync")]
152    fn scorer_sync<'a>(
153        &self,
154        reader: &'a SegmentReader,
155        _limit: usize,
156    ) -> crate::Result<Box<dyn Scorer + 'a>> {
157        match RangeScorer::new(reader, self.field, &self.bound) {
158            Ok(scorer) => Ok(Box::new(scorer) as Box<dyn Scorer + 'a>),
159            Err(_) => Ok(Box::new(EmptyRangeScorer) as Box<dyn Scorer + 'a>),
160        }
161    }
162
163    fn count_estimate<'a>(&self, reader: &'a SegmentReader) -> CountFuture<'a> {
164        let num_docs = reader.num_docs();
165        // Rough estimate: half the segment (we don't know selectivity)
166        Box::pin(async move { Ok(num_docs / 2) })
167    }
168
169    fn is_filter(&self) -> bool {
170        true
171    }
172
173    fn as_doc_predicate<'a>(&self, reader: &'a SegmentReader) -> Option<super::DocPredicate<'a>> {
174        let fast_field = reader.fast_field(self.field.0)?;
175        let bound = self.bound.compile();
176        Some(Box::new(move |doc_id| {
177            bound.contains(fast_field.get_u64(doc_id))
178        }))
179    }
180
181    fn as_doc_bitset(&self, reader: &SegmentReader) -> Option<super::DocBitset> {
182        let fast_field = reader.fast_field(self.field.0)?;
183        if fast_field.multi {
184            // Range predicates inspect the first value, not any value. Keep
185            // this path until the reader owns a batch API with that contract.
186            let pred = self.as_doc_predicate(reader)?;
187            return Some(super::DocBitset::from_predicate(reader.num_docs(), &*pred));
188        }
189        let bound = self.bound.compile();
190        let mut bits = super::DocBitset::new(reader.num_docs());
191        // The generic callback can inline. Traverse blocks once and dispatch
192        // the codec per batch instead of doing random access for every doc.
193        fast_field.scan_single_values(|doc_id, raw| {
194            if bound.contains(raw) {
195                bits.set(doc_id);
196            }
197        });
198        Some(bits)
199    }
200
201    fn bitset_cardinality_estimate(&self, reader: &SegmentReader) -> Option<u64> {
202        // Sampled: probe ~1k evenly spaced docs with the fast-field predicate.
203        // Works for every encoding (incl. i64 zigzag, where min/max
204        // interpolation would mis-order). Rounded up so a rare-but-present
205        // range never estimates to zero.
206        let pred = self.as_doc_predicate(reader)?;
207        let n = reader.num_docs();
208        if n == 0 {
209            return Some(0);
210        }
211        const SAMPLES: u32 = 1024;
212        if n <= SAMPLES {
213            return Some((0..n).filter(|&d| pred(d)).count() as u64);
214        }
215        let step = n / SAMPLES;
216        let hits = (0..SAMPLES).filter(|&i| pred(i * step)).count() as u64;
217        Some(((hits * n as u64) / SAMPLES as u64).max(1))
218    }
219}
220
221// ── RangeScorer ──────────────────────────────────────────────────────────
222
223/// Scorer that scans a fast-field column and yields matching docs.
224///
225/// For u64 and f64 fields, comparison is done in the raw u64 domain (both
226/// use order-preserving encodings). For i64 fields, zigzag encoding does NOT
227/// preserve order, so we decode each value and compare in i64 domain.
228struct RangeScorer<'a> {
229    /// Cached fast-field reader — avoids HashMap lookup per doc in matches()
230    fast_field: &'a crate::structures::fast_field::FastFieldReader,
231    bound: CompiledRange,
232    /// Current document position.
233    current: u32,
234    num_docs: u32,
235}
236
237/// Empty scorer returned when the field has no fast-field data.
238struct EmptyRangeScorer;
239
240impl<'a> RangeScorer<'a> {
241    fn new(
242        reader: &'a SegmentReader,
243        field: Field,
244        bound: &RangeBound,
245    ) -> Result<Self, EmptyRangeScorer> {
246        let fast_field = reader.fast_field(field.0).ok_or(EmptyRangeScorer)?;
247        let num_docs = reader.num_docs();
248        let mut scorer = Self {
249            fast_field,
250            bound: bound.compile(),
251            current: 0,
252            num_docs,
253        };
254
255        // Position on first matching doc
256        if num_docs > 0 && !scorer.matches(0) {
257            scorer.scan_forward();
258        }
259        Ok(scorer)
260    }
261
262    #[inline]
263    fn matches(&self, doc_id: DocId) -> bool {
264        self.bound.contains(self.fast_field.get_u64(doc_id))
265    }
266
267    /// Advance current past non-matching docs.
268    fn scan_forward(&mut self) {
269        loop {
270            self.current += 1;
271            if self.current >= self.num_docs {
272                self.current = self.num_docs;
273                return;
274            }
275            if self.matches(self.current) {
276                return;
277            }
278        }
279    }
280}
281
282impl DocSet for RangeScorer<'_> {
283    fn doc(&self) -> DocId {
284        if self.current >= self.num_docs {
285            TERMINATED
286        } else {
287            self.current
288        }
289    }
290
291    fn advance(&mut self) -> DocId {
292        self.scan_forward();
293        self.doc()
294    }
295
296    fn seek(&mut self, target: DocId) -> DocId {
297        if self.current >= self.num_docs {
298            return TERMINATED;
299        }
300        if target <= self.current {
301            return self.current;
302        }
303        // Position just before target so scan_forward starts at target
304        self.current = target - 1;
305        self.scan_forward();
306        self.doc()
307    }
308
309    fn size_hint(&self) -> u32 {
310        // Upper bound: remaining docs
311        self.num_docs.saturating_sub(self.current)
312    }
313}
314
315impl Scorer for RangeScorer<'_> {
316    fn score(&self) -> Score {
317        1.0
318    }
319}
320
321impl DocSet for EmptyRangeScorer {
322    fn doc(&self) -> DocId {
323        TERMINATED
324    }
325    fn advance(&mut self) -> DocId {
326        TERMINATED
327    }
328    fn seek(&mut self, _target: DocId) -> DocId {
329        TERMINATED
330    }
331    fn size_hint(&self) -> u32 {
332        0
333    }
334}
335
336impl Scorer for EmptyRangeScorer {
337    fn score(&self) -> Score {
338        0.0
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn test_range_bound_u64_compile() {
348        let b = RangeBound::U64 {
349            min: Some(10),
350            max: Some(100),
351        };
352        assert_eq!(b.compile(), CompiledRange::Raw { lo: 10, hi: 100 });
353    }
354
355    #[test]
356    fn test_range_bound_f64_compile_preserves_order() {
357        let b1 = RangeBound::F64 {
358            min: Some(-1.0),
359            max: Some(1.0),
360        };
361        let CompiledRange::Raw { lo, hi } = b1.compile() else {
362            panic!("expected raw bounds")
363        };
364        assert!(lo < hi);
365
366        let b2 = RangeBound::F64 {
367            min: Some(0.0),
368            max: Some(100.0),
369        };
370        let CompiledRange::Raw { lo, hi } = b2.compile() else {
371            panic!("expected raw bounds")
372        };
373        assert!(lo < hi);
374    }
375
376    #[test]
377    fn test_range_bound_open_bounds() {
378        let b = RangeBound::U64 {
379            min: None,
380            max: None,
381        };
382        assert_eq!(
383            b.compile(),
384            CompiledRange::Raw {
385                lo: 0,
386                hi: u64::MAX - 1
387            }
388        );
389    }
390
391    #[test]
392    fn test_range_query_constructors() {
393        let q = RangeQuery::u64(Field(0), Some(10), Some(100));
394        assert_eq!(q.field, Field(0));
395        assert!(matches!(
396            q.bound,
397            RangeBound::U64 {
398                min: Some(10),
399                max: Some(100)
400            }
401        ));
402
403        let q = RangeQuery::i64(Field(1), Some(-50), Some(50));
404        assert!(matches!(
405            q.bound,
406            RangeBound::I64 {
407                min: Some(-50),
408                max: Some(50)
409            }
410        ));
411
412        let q = RangeQuery::f64(Field(2), Some(0.5), Some(9.5));
413        assert!(matches!(q.bound, RangeBound::F64 { .. }));
414    }
415}