Skip to main content

chronix_engine/index/
zone_map.rs

1//! Zone map evaluator — predicate pushdown using per-row-group column stats.
2//!
3//! Each row group stores `ColumnStats { min_value, max_value, null_count }`.
4//! The zone map evaluator checks predicates against these stats to decide
5//! whether a row group can be entirely skipped.
6
7/// Comparison operators supported by zone map evaluation.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ZoneMapOp {
10    /// `column == value`
11    Eq,
12    /// `column != value`
13    Ne,
14    /// `column > value`
15    Gt,
16    /// `column >= value`
17    Ge,
18    /// `column < value`
19    Lt,
20    /// `column <= value`
21    Le,
22}
23
24/// A numeric predicate to evaluate against column zone maps.
25#[derive(Debug, Clone, Copy)]
26pub struct ZoneMapPredicate {
27    /// The comparison operator.
28    pub op: ZoneMapOp,
29    /// The value to compare against (as f64 bits — supports i64 and f64).
30    pub value: f64,
31}
32
33/// Result of evaluating a predicate against zone map stats.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum ZoneMapResult {
36    /// All rows in the zone satisfy the predicate (can skip decoding values).
37    AllMatch,
38    /// No rows in the zone can satisfy the predicate (skip the zone entirely).
39    NoneMatch,
40    /// Some rows may match — must decode and evaluate per row.
41    MaybeMatch,
42}
43
44impl ZoneMapPredicate {
45    /// Evaluate this predicate against a row group's column min/max stats.
46    ///
47    /// `min` and `max` are the column's zone map boundaries for a row group.
48    ///
49    /// # Returns
50    ///
51    /// - `NoneMatch` if no row in the group can satisfy the predicate
52    /// - `AllMatch` if every row must satisfy the predicate
53    /// - `MaybeMatch` if some rows might match
54    #[must_use]
55    pub fn evaluate(&self, min: f64, max: f64) -> ZoneMapResult {
56        // NaN guard: if any operand is NaN, we cannot safely prune or
57        // claim all-match — fall back to MaybeMatch (scan required).
58        if self.value.is_nan() || min.is_nan() || max.is_nan() {
59            return ZoneMapResult::MaybeMatch;
60        }
61
62        match self.op {
63            ZoneMapOp::Eq => {
64                if self.value < min || self.value > max {
65                    ZoneMapResult::NoneMatch
66                } else if min.to_bits() == max.to_bits() && min.to_bits() == self.value.to_bits() {
67                    ZoneMapResult::AllMatch
68                } else {
69                    ZoneMapResult::MaybeMatch
70                }
71            }
72            ZoneMapOp::Ne => {
73                if min.to_bits() == max.to_bits() && min.to_bits() == self.value.to_bits() {
74                    ZoneMapResult::NoneMatch
75                } else if self.value < min || self.value > max {
76                    ZoneMapResult::AllMatch
77                } else {
78                    ZoneMapResult::MaybeMatch
79                }
80            }
81            ZoneMapOp::Gt => {
82                if min > self.value {
83                    ZoneMapResult::AllMatch
84                } else if max <= self.value {
85                    ZoneMapResult::NoneMatch
86                } else {
87                    ZoneMapResult::MaybeMatch
88                }
89            }
90            ZoneMapOp::Ge => {
91                if min >= self.value {
92                    ZoneMapResult::AllMatch
93                } else if max < self.value {
94                    ZoneMapResult::NoneMatch
95                } else {
96                    ZoneMapResult::MaybeMatch
97                }
98            }
99            ZoneMapOp::Lt => {
100                if max < self.value {
101                    ZoneMapResult::AllMatch
102                } else if min >= self.value {
103                    ZoneMapResult::NoneMatch
104                } else {
105                    ZoneMapResult::MaybeMatch
106                }
107            }
108            ZoneMapOp::Le => {
109                if max <= self.value {
110                    ZoneMapResult::AllMatch
111                } else if min > self.value {
112                    ZoneMapResult::NoneMatch
113                } else {
114                    ZoneMapResult::MaybeMatch
115                }
116            }
117        }
118    }
119}
120
121/// Evaluate a time range predicate against a segment's timestamp bounds.
122///
123/// Returns `true` if the segment may contain data in `[range_start, range_end]`.
124#[must_use]
125pub fn segment_overlaps_range(
126    seg_min_ts: i64,
127    seg_max_ts: i64,
128    range_start: i64,
129    range_end: i64,
130) -> bool {
131    seg_max_ts >= range_start && seg_min_ts <= range_end
132}
133
134/// Evaluate row-group-level column stats against a predicate to determine
135/// which row groups can be pruned.
136///
137/// Returns a `Vec<bool>` of length `row_group_count` indicating which row
138/// groups should be read.
139#[must_use]
140pub fn prune_row_groups(
141    row_group_stats: &[(f64, f64)], // (min, max) per row group
142    predicate: &ZoneMapPredicate,
143) -> Vec<bool> {
144    row_group_stats
145        .iter()
146        .map(|&(min, max)| predicate.evaluate(min, max) != ZoneMapResult::NoneMatch)
147        .collect()
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn eq_inside_range() {
156        let pred = ZoneMapPredicate {
157            op: ZoneMapOp::Eq,
158            value: 50.0,
159        };
160        assert_eq!(pred.evaluate(0.0, 100.0), ZoneMapResult::MaybeMatch);
161    }
162
163    #[test]
164    fn eq_outside_range() {
165        let pred = ZoneMapPredicate {
166            op: ZoneMapOp::Eq,
167            value: 200.0,
168        };
169        assert_eq!(pred.evaluate(0.0, 100.0), ZoneMapResult::NoneMatch);
170    }
171
172    #[test]
173    fn eq_constant_column() {
174        let pred = ZoneMapPredicate {
175            op: ZoneMapOp::Eq,
176            value: 42.0,
177        };
178        assert_eq!(pred.evaluate(42.0, 42.0), ZoneMapResult::AllMatch);
179    }
180
181    #[test]
182    fn gt_entire_range_above() {
183        let pred = ZoneMapPredicate {
184            op: ZoneMapOp::Gt,
185            value: 10.0,
186        };
187        assert_eq!(pred.evaluate(50.0, 100.0), ZoneMapResult::AllMatch);
188    }
189
190    #[test]
191    fn gt_entire_range_below() {
192        let pred = ZoneMapPredicate {
193            op: ZoneMapOp::Gt,
194            value: 100.0,
195        };
196        assert_eq!(pred.evaluate(50.0, 100.0), ZoneMapResult::NoneMatch);
197    }
198
199    #[test]
200    fn lt_entire_range_below() {
201        let pred = ZoneMapPredicate {
202            op: ZoneMapOp::Lt,
203            value: 200.0,
204        };
205        assert_eq!(pred.evaluate(50.0, 100.0), ZoneMapResult::AllMatch);
206    }
207
208    #[test]
209    fn lt_entire_range_above() {
210        let pred = ZoneMapPredicate {
211            op: ZoneMapOp::Lt,
212            value: 10.0,
213        };
214        assert_eq!(pred.evaluate(50.0, 100.0), ZoneMapResult::NoneMatch);
215    }
216
217    #[test]
218    fn ne_constant_column_matches_value() {
219        let pred = ZoneMapPredicate {
220            op: ZoneMapOp::Ne,
221            value: 42.0,
222        };
223        assert_eq!(pred.evaluate(42.0, 42.0), ZoneMapResult::NoneMatch);
224    }
225
226    #[test]
227    fn ne_value_outside_range() {
228        let pred = ZoneMapPredicate {
229            op: ZoneMapOp::Ne,
230            value: 200.0,
231        };
232        assert_eq!(pred.evaluate(0.0, 100.0), ZoneMapResult::AllMatch);
233    }
234
235    #[test]
236    fn ge_at_boundary() {
237        let pred = ZoneMapPredicate {
238            op: ZoneMapOp::Ge,
239            value: 50.0,
240        };
241        assert_eq!(pred.evaluate(50.0, 100.0), ZoneMapResult::AllMatch);
242    }
243
244    #[test]
245    fn le_at_boundary() {
246        let pred = ZoneMapPredicate {
247            op: ZoneMapOp::Le,
248            value: 100.0,
249        };
250        assert_eq!(pred.evaluate(50.0, 100.0), ZoneMapResult::AllMatch);
251    }
252
253    #[test]
254    fn prune_row_groups_mixed() {
255        // 5 row groups with known ranges
256        let stats = vec![
257            (0.0, 10.0),    // should match
258            (20.0, 30.0),   // should match
259            (50.0, 60.0),   // should NOT match
260            (5.0, 15.0),    // should match (maybe)
261            (100.0, 200.0), // should NOT match
262        ];
263        let pred = ZoneMapPredicate {
264            op: ZoneMapOp::Lt,
265            value: 40.0,
266        };
267        let result = prune_row_groups(&stats, &pred);
268        assert_eq!(result, vec![true, true, false, true, false]);
269    }
270
271    #[test]
272    fn segment_overlap_basic() {
273        assert!(segment_overlaps_range(100, 200, 150, 250));
274        assert!(segment_overlaps_range(100, 200, 50, 150));
275        assert!(!segment_overlaps_range(100, 200, 300, 400));
276        assert!(!segment_overlaps_range(100, 200, 0, 50));
277        assert!(segment_overlaps_range(100, 200, 100, 200)); // exact
278    }
279
280    // ── NaN safety tests ────────────────────────────────────────────
281
282    #[test]
283    fn nan_value_returns_maybe_match() {
284        let pred = ZoneMapPredicate {
285            op: ZoneMapOp::Eq,
286            value: f64::NAN,
287        };
288        assert_eq!(pred.evaluate(0.0, 100.0), ZoneMapResult::MaybeMatch);
289    }
290
291    #[test]
292    fn nan_min_returns_maybe_match() {
293        let pred = ZoneMapPredicate {
294            op: ZoneMapOp::Gt,
295            value: 50.0,
296        };
297        assert_eq!(pred.evaluate(f64::NAN, 100.0), ZoneMapResult::MaybeMatch);
298    }
299
300    #[test]
301    fn nan_max_returns_maybe_match() {
302        let pred = ZoneMapPredicate {
303            op: ZoneMapOp::Lt,
304            value: 50.0,
305        };
306        assert_eq!(pred.evaluate(0.0, f64::NAN), ZoneMapResult::MaybeMatch);
307    }
308
309    #[test]
310    fn nan_all_returns_maybe_match() {
311        for op in [
312            ZoneMapOp::Eq,
313            ZoneMapOp::Ne,
314            ZoneMapOp::Gt,
315            ZoneMapOp::Ge,
316            ZoneMapOp::Lt,
317            ZoneMapOp::Le,
318        ] {
319            let pred = ZoneMapPredicate {
320                op,
321                value: f64::NAN,
322            };
323            assert_eq!(
324                pred.evaluate(f64::NAN, f64::NAN),
325                ZoneMapResult::MaybeMatch,
326                "op={op:?} with all-NaN should be MaybeMatch"
327            );
328        }
329    }
330
331    #[test]
332    fn nan_in_prune_row_groups_keeps_nan_groups() {
333        let stats = vec![
334            (0.0, 100.0),     // normal
335            (f64::NAN, 50.0), // NaN min → MaybeMatch → kept
336            (10.0, f64::NAN), // NaN max → MaybeMatch → kept
337        ];
338        let pred = ZoneMapPredicate {
339            op: ZoneMapOp::Gt,
340            value: 200.0,
341        };
342        let result = prune_row_groups(&stats, &pred);
343        // Normal group: NoneMatch (max=100 <= 200). NaN groups: MaybeMatch → kept.
344        assert_eq!(result, vec![false, true, true]);
345    }
346}