Skip to main content

chronix_engine/segment/
stats.rs

1//! Per-column statistics for row groups and segments.
2//!
3//! Statistics enable query predicate pushdown: if a row group's min/max
4//! timestamps don't overlap with a query's time range, the entire row group
5//! can be skipped.
6//!
7//! # Float ordering
8//!
9//! Float values are stored as i64 using a total-order mapping that preserves
10//! comparison semantics for negative values. See [`f64_to_ordered_i64`].
11
12use serde::{Deserialize, Serialize};
13
14use crate::segment::error::{Result, SegmentError};
15use crate::segment::to_array;
16
17/// Convert an `f64` to an `i64` that preserves total ordering.
18///
19/// IEEE 754 doubles are sign-magnitude, which means positive values sort
20/// correctly when reinterpreted as i64, but negative values sort backwards.
21/// For negatives (sign bit set), we flip the value bits (XOR with `i64::MAX`)
22/// so that more-negative floats map to more-negative i64 values.
23///
24/// Resulting order: `-∞ < -max < … < -0 < +0 < … < +max < +∞ < NaN`
25#[inline]
26#[must_use]
27pub fn f64_to_ordered_i64(value: f64) -> i64 {
28    let bits = value.to_bits() as i64;
29    if bits < 0 {
30        bits ^ i64::MAX // flip all bits except the sign bit
31    } else {
32        bits
33    }
34}
35
36/// Reverse the transformation done by [`f64_to_ordered_i64`].
37#[inline]
38#[must_use]
39pub fn ordered_i64_to_f64(ordered: i64) -> f64 {
40    let bits = if ordered < 0 {
41        ordered ^ i64::MAX
42    } else {
43        ordered
44    };
45    f64::from_bits(bits as u64)
46}
47
48/// Statistics for a single column within a row group.
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50pub struct ColumnStats {
51    /// Minimum value (as raw i64 bits for i64/f64/bool types).
52    ///
53    /// For f64 columns this uses the total-order mapping from
54    /// [`f64_to_ordered_i64`] so that min/max comparisons are correct
55    /// even for negative values.
56    pub min_value: i64,
57    /// Maximum value (as raw i64 bits for i64/f64/bool types).
58    pub max_value: i64,
59    /// Minimum u64 value — used only for u64 columns.
60    pub min_value_u64: u64,
61    /// Maximum u64 value — used only for u64 columns.
62    pub max_value_u64: u64,
63    /// Number of null values.
64    pub null_count: u64,
65    /// Number of non-null values.
66    pub value_count: u64,
67    /// Sum of all values (for numeric types, 0 for others).
68    ///
69    /// For float columns this is the canonical sum.  For integer columns,
70    /// use `sum_i128` for full precision.
71    pub sum: f64,
72    /// Precise integer sum (i128) for i64/u64 columns.
73    ///
74    /// Avoids the 2^53 precision loss of the f64 `sum` field.  Set to
75    /// zero for float and non-numeric columns.
76    pub sum_i128: i128,
77    /// Number of distinct values (for string/tag columns, 0 for others).
78    pub distinct_count: u32,
79}
80
81impl ColumnStats {
82    /// Create empty statistics.
83    #[must_use]
84    pub fn empty() -> Self {
85        Self {
86            min_value: i64::MAX,
87            max_value: i64::MIN,
88            min_value_u64: u64::MAX,
89            max_value_u64: u64::MIN,
90            null_count: 0,
91            value_count: 0,
92            sum: 0.0,
93            sum_i128: 0,
94            distinct_count: 0,
95        }
96    }
97
98    /// `true` when these statistics say nothing about the data.
99    ///
100    /// A block with no observed value — every row null, or a block whose
101    /// statistics were deliberately suppressed because it is encrypted —
102    /// leaves `min`/`max` at their sentinels. A zone map must read that as
103    /// "unknown", never as an empty range: `min = i64::MAX` and
104    /// `max = i64::MIN` make every comparison false, so a predicate on an
105    /// encrypted column pruned every row group and the query returned no
106    /// rows at all, with the correct key configured.
107    ///
108    /// The distinction from a genuinely all-null block is `null_count`: an
109    /// all-null block *is* impossible to match, and pruning it is correct.
110    #[must_use]
111    pub fn says_nothing(&self) -> bool {
112        self.value_count == 0 && self.null_count == 0
113    }
114
115    /// Update statistics with a new `i64` value.
116    pub fn update_i64(&mut self, value: i64) {
117        self.min_value = self.min_value.min(value);
118        self.max_value = self.max_value.max(value);
119        self.sum += value as f64;
120        self.sum_i128 += i128::from(value);
121        self.value_count += 1;
122    }
123
124    /// Update statistics with a new `u64` value.
125    ///
126    /// Tracks full-precision u64 min/max in dedicated fields. Clamping to
127    /// `i64::MAX` instead breaks predicate pushdown above 2^63.
128    pub fn update_u64(&mut self, value: u64) {
129        self.min_value_u64 = self.min_value_u64.min(value);
130        self.max_value_u64 = self.max_value_u64.max(value);
131        self.sum += value as f64;
132        self.sum_i128 += i128::from(value);
133        self.value_count += 1;
134    }
135
136    /// Update statistics with a new decimal mantissa.
137    ///
138    /// The mantissa is an `i128` and `min_value`/`max_value` are `i64`, so
139    /// the zone map records a *saturated* range. That is deliberate and
140    /// safe in the only direction that matters: a saturated bound is wider
141    /// than the true one, so a row group is never pruned that might have
142    /// matched — the predicate simply stops being selective above 19
143    /// digits. `sum_i128` stays exact, because a sum is an answer rather
144    /// than a hint.
145    pub fn update_decimal(&mut self, mantissa: i128) {
146        let clamped =
147            i64::try_from(mantissa).unwrap_or(if mantissa > 0 { i64::MAX } else { i64::MIN });
148        self.min_value = self.min_value.min(clamped);
149        self.max_value = self.max_value.max(clamped);
150        // Saturating rather than wrapping: an overflowed sum is a wrong
151        // answer, and this field is only ever a hint for the planner.
152        self.sum_i128 = self.sum_i128.saturating_add(mantissa);
153        self.value_count += 1;
154    }
155
156    /// Update statistics with a new `f64` value.
157    ///
158    /// Uses [`f64_to_ordered_i64`] for correct ordering of negative values.
159    /// NaN values are excluded from min/max tracking (they would sort above
160    /// +∞, corrupting predicate pushdown) but are still counted in
161    /// `value_count`. Only finite values contribute to `sum`, so computing
162    /// an average as `sum / value_count` is only correct when the data
163    /// contains no NaN or Infinity values.
164    pub fn update_f64(&mut self, value: f64) {
165        if !value.is_nan() {
166            let ordered = f64_to_ordered_i64(value);
167            self.min_value = self.min_value.min(ordered);
168            self.max_value = self.max_value.max(ordered);
169        }
170        if value.is_finite() {
171            self.sum += value;
172        }
173        self.value_count += 1;
174    }
175
176    /// Update statistics with a new boolean value.
177    pub fn update_bool(&mut self, value: bool) {
178        let v = i64::from(value);
179        self.min_value = self.min_value.min(v);
180        self.max_value = self.max_value.max(v);
181        self.value_count += 1;
182    }
183
184    /// Record a null value.
185    pub fn record_null(&mut self) {
186        self.null_count += 1;
187    }
188
189    /// Serialize stats to bytes (fixed 60-byte layout).
190    #[must_use]
191    pub fn to_bytes(&self) -> Vec<u8> {
192        let mut buf = Vec::with_capacity(76);
193        buf.extend_from_slice(&self.min_value.to_le_bytes());
194        buf.extend_from_slice(&self.max_value.to_le_bytes());
195        buf.extend_from_slice(&self.min_value_u64.to_le_bytes());
196        buf.extend_from_slice(&self.max_value_u64.to_le_bytes());
197        buf.extend_from_slice(&self.null_count.to_le_bytes());
198        buf.extend_from_slice(&self.value_count.to_le_bytes());
199        buf.extend_from_slice(&self.sum.to_le_bytes());
200        buf.extend_from_slice(&self.sum_i128.to_le_bytes());
201        buf.extend_from_slice(&self.distinct_count.to_le_bytes());
202        buf
203    }
204
205    /// Deserialize stats from bytes.
206    ///
207    /// # Errors
208    ///
209    /// Returns an error if `data` is shorter than 60 bytes.
210    pub fn from_bytes(data: &[u8]) -> Result<Self> {
211        if data.len() < 76 {
212            return Err(SegmentError::CorruptFile {
213                detail: format!("ColumnStats requires 76 bytes, got {}", data.len()),
214            });
215        }
216        Ok(Self {
217            min_value: i64::from_le_bytes(to_array!(data[0..8], "stats min_value")?),
218            max_value: i64::from_le_bytes(to_array!(data[8..16], "stats max_value")?),
219            min_value_u64: u64::from_le_bytes(to_array!(data[16..24], "stats min_value_u64")?),
220            max_value_u64: u64::from_le_bytes(to_array!(data[24..32], "stats max_value_u64")?),
221            null_count: u64::from_le_bytes(to_array!(data[32..40], "stats null_count")?),
222            value_count: u64::from_le_bytes(to_array!(data[40..48], "stats value_count")?),
223            sum: f64::from_le_bytes(to_array!(data[48..56], "stats sum")?),
224            sum_i128: i128::from_le_bytes(to_array!(data[56..72], "stats sum_i128")?),
225            distinct_count: u32::from_le_bytes(to_array!(data[72..76], "stats distinct_count")?),
226        })
227    }
228}
229
230/// Size of serialized [`ColumnStats`] in bytes.
231pub const COLUMN_STATS_SIZE: usize = 76;
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn stats_roundtrip() {
239        let mut stats = ColumnStats::empty();
240        stats.update_i64(10);
241        stats.update_i64(20);
242        stats.update_i64(30);
243
244        let bytes = stats.to_bytes();
245        assert_eq!(bytes.len(), COLUMN_STATS_SIZE);
246        let recovered = ColumnStats::from_bytes(&bytes).unwrap();
247        assert_eq!(stats, recovered);
248    }
249
250    #[test]
251    fn stats_i64_tracking() {
252        let mut stats = ColumnStats::empty();
253        stats.update_i64(-5);
254        stats.update_i64(10);
255        stats.update_i64(3);
256
257        assert_eq!(stats.min_value, -5);
258        assert_eq!(stats.max_value, 10);
259        assert_eq!(stats.value_count, 3);
260        assert!((stats.sum - 8.0).abs() < f64::EPSILON);
261        assert_eq!(stats.sum_i128, 8);
262    }
263
264    #[test]
265    fn stats_f64_tracking() {
266        let mut stats = ColumnStats::empty();
267        stats.update_f64(1.5);
268        stats.update_f64(2.5);
269        stats.update_f64(f64::NAN);
270
271        assert_eq!(stats.value_count, 3);
272        // NaN doesn't contribute to sum
273        assert!((stats.sum - 4.0).abs() < f64::EPSILON);
274    }
275
276    #[test]
277    fn stats_f64_negative_ordering() {
278        let mut stats = ColumnStats::empty();
279        stats.update_f64(-10.0);
280        stats.update_f64(-1.0);
281        stats.update_f64(5.0);
282
283        // Recover the actual f64 min/max via the ordered mapping
284        let min_f = ordered_i64_to_f64(stats.min_value);
285        let max_f = ordered_i64_to_f64(stats.max_value);
286        assert!(
287            (min_f - (-10.0)).abs() < f64::EPSILON,
288            "min should be -10.0, got {min_f}"
289        );
290        assert!(
291            (max_f - 5.0).abs() < f64::EPSILON,
292            "max should be 5.0, got {max_f}"
293        );
294    }
295
296    #[test]
297    fn f64_total_ordering() {
298        // Verify the ordered mapping preserves total order
299        let values = [
300            f64::NEG_INFINITY,
301            -1e100,
302            -1.0,
303            -f64::MIN_POSITIVE,
304            -0.0,
305            0.0,
306            f64::MIN_POSITIVE,
307            1.0,
308            1e100,
309            f64::INFINITY,
310        ];
311        for window in values.windows(2) {
312            let a = f64_to_ordered_i64(window[0]);
313            let b = f64_to_ordered_i64(window[1]);
314            assert!(
315                a <= b,
316                "ordering violated: {:?} (={a}) should <= {:?} (={b})",
317                window[0],
318                window[1]
319            );
320        }
321    }
322
323    #[test]
324    fn stats_u64_large_values() {
325        let mut stats = ColumnStats::empty();
326        stats.update_u64(u64::MAX);
327        stats.update_u64(0);
328
329        // u64 values tracked in dedicated fields with full precision
330        assert_eq!(stats.max_value_u64, u64::MAX);
331        assert_eq!(stats.min_value_u64, 0);
332        // sum_i128 preserves full precision even beyond 2^53
333        assert_eq!(stats.sum_i128, u64::MAX as i128);
334    }
335
336    #[test]
337    fn stats_u64_above_i64_max() {
338        let mut stats = ColumnStats::empty();
339        let big = (i64::MAX as u64) + 100;
340        stats.update_u64(big);
341        stats.update_u64(42);
342
343        assert_eq!(stats.max_value_u64, big);
344        assert_eq!(stats.min_value_u64, 42);
345        assert_eq!(stats.value_count, 2);
346        assert_eq!(stats.sum_i128, big as i128 + 42);
347    }
348
349    #[test]
350    fn stats_from_bytes_too_short() {
351        assert!(ColumnStats::from_bytes(&[0u8; 10]).is_err());
352    }
353
354    #[test]
355    fn stats_null_tracking() {
356        let mut stats = ColumnStats::empty();
357        stats.update_i64(42);
358        stats.record_null();
359        stats.record_null();
360
361        assert_eq!(stats.value_count, 1);
362        assert_eq!(stats.null_count, 2);
363    }
364
365    #[test]
366    fn stats_empty() {
367        let stats = ColumnStats::empty();
368        assert_eq!(stats.min_value, i64::MAX);
369        assert_eq!(stats.max_value, i64::MIN);
370        assert_eq!(stats.value_count, 0);
371        assert_eq!(stats.null_count, 0);
372    }
373
374    #[test]
375    fn stats_f64_nan_excluded_from_min_max() {
376        let mut stats = ColumnStats::empty();
377        stats.update_f64(f64::NAN);
378        // NaN should not update min/max — they should remain at sentinel
379        assert_eq!(stats.min_value, i64::MAX, "NaN corrupted min_value");
380        assert_eq!(stats.max_value, i64::MIN, "NaN corrupted max_value");
381        assert_eq!(stats.value_count, 1);
382
383        // After adding a real value, NaN should not affect min/max
384        stats.update_f64(42.0);
385        stats.update_f64(f64::NAN);
386        let max_f = ordered_i64_to_f64(stats.max_value);
387        assert!(
388            (max_f - 42.0).abs() < f64::EPSILON,
389            "max should be 42.0, got {max_f}"
390        );
391    }
392
393    #[test]
394    fn stats_bool_tracking() {
395        let mut stats = ColumnStats::empty();
396        stats.update_bool(false);
397        stats.update_bool(true);
398        stats.update_bool(false);
399
400        assert_eq!(stats.min_value, 0);
401        assert_eq!(stats.max_value, 1);
402        assert_eq!(stats.value_count, 3);
403    }
404
405    #[test]
406    fn stats_f64_infinity() {
407        let mut stats = ColumnStats::empty();
408        stats.update_f64(f64::NEG_INFINITY);
409        stats.update_f64(f64::INFINITY);
410
411        let min_f = ordered_i64_to_f64(stats.min_value);
412        let max_f = ordered_i64_to_f64(stats.max_value);
413        assert!(min_f.is_infinite() && min_f.is_sign_negative());
414        assert!(max_f.is_infinite() && max_f.is_sign_positive());
415        // Infinities are not finite, should not contribute to sum
416        assert!(stats.sum.abs() < f64::EPSILON);
417    }
418
419    #[test]
420    fn stats_i128_precision_beyond_f64() {
421        // i64 values above 2^53 lose precision when cast to f64.
422        // sum_i128 must preserve exact precision.
423        let mut stats = ColumnStats::empty();
424        let big: i64 = (1_i64 << 53) + 1; // 9_007_199_254_740_993
425        stats.update_i64(big);
426        stats.update_i64(big);
427
428        // f64 sum may have lost precision
429        let expected_i128 = 2 * (big as i128);
430        assert_eq!(stats.sum_i128, expected_i128);
431
432        // Roundtrip through serialization
433        let bytes = stats.to_bytes();
434        let recovered = ColumnStats::from_bytes(&bytes).unwrap();
435        assert_eq!(recovered.sum_i128, expected_i128);
436    }
437}