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 `f64` value.
137    ///
138    /// Uses [`f64_to_ordered_i64`] for correct ordering of negative values.
139    /// NaN values are excluded from min/max tracking (they would sort above
140    /// +∞, corrupting predicate pushdown) but are still counted in
141    /// `value_count`. Only finite values contribute to `sum`, so computing
142    /// an average as `sum / value_count` is only correct when the data
143    /// contains no NaN or Infinity values.
144    pub fn update_f64(&mut self, value: f64) {
145        if !value.is_nan() {
146            let ordered = f64_to_ordered_i64(value);
147            self.min_value = self.min_value.min(ordered);
148            self.max_value = self.max_value.max(ordered);
149        }
150        if value.is_finite() {
151            self.sum += value;
152        }
153        self.value_count += 1;
154    }
155
156    /// Update statistics with a new boolean value.
157    pub fn update_bool(&mut self, value: bool) {
158        let v = i64::from(value);
159        self.min_value = self.min_value.min(v);
160        self.max_value = self.max_value.max(v);
161        self.value_count += 1;
162    }
163
164    /// Record a null value.
165    pub fn record_null(&mut self) {
166        self.null_count += 1;
167    }
168
169    /// Serialize stats to bytes (fixed 60-byte layout).
170    #[must_use]
171    pub fn to_bytes(&self) -> Vec<u8> {
172        let mut buf = Vec::with_capacity(76);
173        buf.extend_from_slice(&self.min_value.to_le_bytes());
174        buf.extend_from_slice(&self.max_value.to_le_bytes());
175        buf.extend_from_slice(&self.min_value_u64.to_le_bytes());
176        buf.extend_from_slice(&self.max_value_u64.to_le_bytes());
177        buf.extend_from_slice(&self.null_count.to_le_bytes());
178        buf.extend_from_slice(&self.value_count.to_le_bytes());
179        buf.extend_from_slice(&self.sum.to_le_bytes());
180        buf.extend_from_slice(&self.sum_i128.to_le_bytes());
181        buf.extend_from_slice(&self.distinct_count.to_le_bytes());
182        buf
183    }
184
185    /// Deserialize stats from bytes.
186    ///
187    /// # Errors
188    ///
189    /// Returns an error if `data` is shorter than 60 bytes.
190    pub fn from_bytes(data: &[u8]) -> Result<Self> {
191        if data.len() < 76 {
192            return Err(SegmentError::CorruptFile {
193                detail: format!("ColumnStats requires 76 bytes, got {}", data.len()),
194            });
195        }
196        Ok(Self {
197            min_value: i64::from_le_bytes(to_array!(data[0..8], "stats min_value")?),
198            max_value: i64::from_le_bytes(to_array!(data[8..16], "stats max_value")?),
199            min_value_u64: u64::from_le_bytes(to_array!(data[16..24], "stats min_value_u64")?),
200            max_value_u64: u64::from_le_bytes(to_array!(data[24..32], "stats max_value_u64")?),
201            null_count: u64::from_le_bytes(to_array!(data[32..40], "stats null_count")?),
202            value_count: u64::from_le_bytes(to_array!(data[40..48], "stats value_count")?),
203            sum: f64::from_le_bytes(to_array!(data[48..56], "stats sum")?),
204            sum_i128: i128::from_le_bytes(to_array!(data[56..72], "stats sum_i128")?),
205            distinct_count: u32::from_le_bytes(to_array!(data[72..76], "stats distinct_count")?),
206        })
207    }
208}
209
210/// Size of serialized [`ColumnStats`] in bytes.
211pub const COLUMN_STATS_SIZE: usize = 76;
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn stats_roundtrip() {
219        let mut stats = ColumnStats::empty();
220        stats.update_i64(10);
221        stats.update_i64(20);
222        stats.update_i64(30);
223
224        let bytes = stats.to_bytes();
225        assert_eq!(bytes.len(), COLUMN_STATS_SIZE);
226        let recovered = ColumnStats::from_bytes(&bytes).unwrap();
227        assert_eq!(stats, recovered);
228    }
229
230    #[test]
231    fn stats_i64_tracking() {
232        let mut stats = ColumnStats::empty();
233        stats.update_i64(-5);
234        stats.update_i64(10);
235        stats.update_i64(3);
236
237        assert_eq!(stats.min_value, -5);
238        assert_eq!(stats.max_value, 10);
239        assert_eq!(stats.value_count, 3);
240        assert!((stats.sum - 8.0).abs() < f64::EPSILON);
241        assert_eq!(stats.sum_i128, 8);
242    }
243
244    #[test]
245    fn stats_f64_tracking() {
246        let mut stats = ColumnStats::empty();
247        stats.update_f64(1.5);
248        stats.update_f64(2.5);
249        stats.update_f64(f64::NAN);
250
251        assert_eq!(stats.value_count, 3);
252        // NaN doesn't contribute to sum
253        assert!((stats.sum - 4.0).abs() < f64::EPSILON);
254    }
255
256    #[test]
257    fn stats_f64_negative_ordering() {
258        let mut stats = ColumnStats::empty();
259        stats.update_f64(-10.0);
260        stats.update_f64(-1.0);
261        stats.update_f64(5.0);
262
263        // Recover the actual f64 min/max via the ordered mapping
264        let min_f = ordered_i64_to_f64(stats.min_value);
265        let max_f = ordered_i64_to_f64(stats.max_value);
266        assert!(
267            (min_f - (-10.0)).abs() < f64::EPSILON,
268            "min should be -10.0, got {min_f}"
269        );
270        assert!(
271            (max_f - 5.0).abs() < f64::EPSILON,
272            "max should be 5.0, got {max_f}"
273        );
274    }
275
276    #[test]
277    fn f64_total_ordering() {
278        // Verify the ordered mapping preserves total order
279        let values = [
280            f64::NEG_INFINITY,
281            -1e100,
282            -1.0,
283            -f64::MIN_POSITIVE,
284            -0.0,
285            0.0,
286            f64::MIN_POSITIVE,
287            1.0,
288            1e100,
289            f64::INFINITY,
290        ];
291        for window in values.windows(2) {
292            let a = f64_to_ordered_i64(window[0]);
293            let b = f64_to_ordered_i64(window[1]);
294            assert!(
295                a <= b,
296                "ordering violated: {:?} (={a}) should <= {:?} (={b})",
297                window[0],
298                window[1]
299            );
300        }
301    }
302
303    #[test]
304    fn stats_u64_large_values() {
305        let mut stats = ColumnStats::empty();
306        stats.update_u64(u64::MAX);
307        stats.update_u64(0);
308
309        // u64 values tracked in dedicated fields with full precision
310        assert_eq!(stats.max_value_u64, u64::MAX);
311        assert_eq!(stats.min_value_u64, 0);
312        // sum_i128 preserves full precision even beyond 2^53
313        assert_eq!(stats.sum_i128, u64::MAX as i128);
314    }
315
316    #[test]
317    fn stats_u64_above_i64_max() {
318        let mut stats = ColumnStats::empty();
319        let big = (i64::MAX as u64) + 100;
320        stats.update_u64(big);
321        stats.update_u64(42);
322
323        assert_eq!(stats.max_value_u64, big);
324        assert_eq!(stats.min_value_u64, 42);
325        assert_eq!(stats.value_count, 2);
326        assert_eq!(stats.sum_i128, big as i128 + 42);
327    }
328
329    #[test]
330    fn stats_from_bytes_too_short() {
331        assert!(ColumnStats::from_bytes(&[0u8; 10]).is_err());
332    }
333
334    #[test]
335    fn stats_null_tracking() {
336        let mut stats = ColumnStats::empty();
337        stats.update_i64(42);
338        stats.record_null();
339        stats.record_null();
340
341        assert_eq!(stats.value_count, 1);
342        assert_eq!(stats.null_count, 2);
343    }
344
345    #[test]
346    fn stats_empty() {
347        let stats = ColumnStats::empty();
348        assert_eq!(stats.min_value, i64::MAX);
349        assert_eq!(stats.max_value, i64::MIN);
350        assert_eq!(stats.value_count, 0);
351        assert_eq!(stats.null_count, 0);
352    }
353
354    #[test]
355    fn stats_f64_nan_excluded_from_min_max() {
356        let mut stats = ColumnStats::empty();
357        stats.update_f64(f64::NAN);
358        // NaN should not update min/max — they should remain at sentinel
359        assert_eq!(stats.min_value, i64::MAX, "NaN corrupted min_value");
360        assert_eq!(stats.max_value, i64::MIN, "NaN corrupted max_value");
361        assert_eq!(stats.value_count, 1);
362
363        // After adding a real value, NaN should not affect min/max
364        stats.update_f64(42.0);
365        stats.update_f64(f64::NAN);
366        let max_f = ordered_i64_to_f64(stats.max_value);
367        assert!(
368            (max_f - 42.0).abs() < f64::EPSILON,
369            "max should be 42.0, got {max_f}"
370        );
371    }
372
373    #[test]
374    fn stats_bool_tracking() {
375        let mut stats = ColumnStats::empty();
376        stats.update_bool(false);
377        stats.update_bool(true);
378        stats.update_bool(false);
379
380        assert_eq!(stats.min_value, 0);
381        assert_eq!(stats.max_value, 1);
382        assert_eq!(stats.value_count, 3);
383    }
384
385    #[test]
386    fn stats_f64_infinity() {
387        let mut stats = ColumnStats::empty();
388        stats.update_f64(f64::NEG_INFINITY);
389        stats.update_f64(f64::INFINITY);
390
391        let min_f = ordered_i64_to_f64(stats.min_value);
392        let max_f = ordered_i64_to_f64(stats.max_value);
393        assert!(min_f.is_infinite() && min_f.is_sign_negative());
394        assert!(max_f.is_infinite() && max_f.is_sign_positive());
395        // Infinities are not finite, should not contribute to sum
396        assert!(stats.sum.abs() < f64::EPSILON);
397    }
398
399    #[test]
400    fn stats_i128_precision_beyond_f64() {
401        // i64 values above 2^53 lose precision when cast to f64.
402        // sum_i128 must preserve exact precision.
403        let mut stats = ColumnStats::empty();
404        let big: i64 = (1_i64 << 53) + 1; // 9_007_199_254_740_993
405        stats.update_i64(big);
406        stats.update_i64(big);
407
408        // f64 sum may have lost precision
409        let expected_i128 = 2 * (big as i128);
410        assert_eq!(stats.sum_i128, expected_i128);
411
412        // Roundtrip through serialization
413        let bytes = stats.to_bytes();
414        let recovered = ColumnStats::from_bytes(&bytes).unwrap();
415        assert_eq!(recovered.sum_i128, expected_i128);
416    }
417}