Skip to main content

chronix_encoding/
patas.rs

1//! Patas float encoding (VLDB 2023).
2//!
3//! Patas ("Exploiting Bitwise Structure for Lossless Floating-Point
4//! Compression") is a byte-aligned XOR-based encoder.  Like Gorilla and
5//! Chimp it XORs consecutive values, but instead of bit-packing the XOR
6//! residuals it stores them as **whole bytes**, yielding much faster
7//! decoding while matching or beating Gorilla compression ratios on most
8//! time-series workloads.
9//!
10//! ## Encoding format
11//!
12//! ```text
13//! [count: u32 LE][first_value: 8 bytes]
14//!   for each subsequent value:
15//!     [flag byte: trailing_zeros_bytes(high nibble) | significant_bytes(low nibble)]
16//!     [significant_bytes: 0..=8 bytes]  (the non-zero core of the XOR)
17//! ```
18//!
19//! - `trailing_zeros_bytes` (0–7): number of trailing zero **bytes** in the XOR.
20//! - `significant_bytes` (0–8): number of bytes needed to represent the XOR
21//!   after stripping trailing zero bytes.  0 means the values are identical.
22//! - Together these fully describe the XOR: the encoder writes only the
23//!   `significant_bytes` middle portion; the decoder reconstructs the full
24//!   64-bit XOR by shifting and zero-filling.
25//!
26//! ## Advantages over Gorilla / Chimp
27//!
28//! - **~2× faster decode**: byte-aligned loads avoid bit-manipulation.
29//! - **Competitive compression**: often within 5% of Chimp/Gorilla; wins on
30//!   data with byte-aligned floating-point patterns (e.g. sensor data
31//!   quantised to limited precision).
32//! - **Simpler implementation**: no bit writers, no leading-zero buckets,
33//!   no ring buffers.
34
35use crate::coding::{checked_count, checked_decode_count};
36use crate::error::{EncodingError, Result};
37
38/// Patas float encoder (VLDB 2023).
39#[derive(Debug, Clone, Copy)]
40pub struct PatasEncoder;
41
42/// Patas float decoder.
43#[derive(Debug, Clone, Copy)]
44pub struct PatasDecoder;
45
46impl PatasEncoder {
47    /// Encode a sequence of `f64` values using Patas byte-aligned XOR encoding.
48    ///
49    /// # Errors
50    ///
51    /// Returns [`EncodingError::EmptyInput`] if `values` is empty.
52    pub fn encode(values: &[f64]) -> Result<Vec<u8>> {
53        if values.is_empty() {
54            return Err(EncodingError::EmptyInput {
55                context: "patas encoder",
56            });
57        }
58
59        let count = checked_count(values.len())?;
60
61        // Worst case: 4 (count) + 8 (first) + 9 * (n-1) (flag + 8 bytes each)
62        let mut buf = Vec::with_capacity(12 + values.len() * 9);
63
64        // Header: value count
65        buf.extend_from_slice(&count.to_le_bytes());
66
67        // First value: raw 8 bytes
68        let mut prev_bits = values[0].to_bits();
69        buf.extend_from_slice(&prev_bits.to_le_bytes());
70
71        for &val in &values[1..] {
72            let bits = val.to_bits();
73            let xor = prev_bits ^ bits;
74
75            if xor == 0 {
76                // Identical: flag=0 means sig_bytes=0 → same value
77                buf.push(0);
78            } else {
79                // Count trailing zero bytes and significant byte span
80                let tz_bytes = (xor.trailing_zeros() / 8) as u8; // 0..=7
81                let total_bytes = 8 - (xor.leading_zeros() / 8) as u8; // 1..=8
82                let sig_bytes = total_bytes.saturating_sub(tz_bytes); // 1..=8
83
84                // Flag byte: high nibble = tz_bytes (0-7), low nibble = sig_bytes (1-8)
85                let flag = (tz_bytes << 4) | sig_bytes;
86                buf.push(flag);
87
88                // Write only the significant bytes (shifted right past trailing zeros)
89                let shifted = xor >> (u32::from(tz_bytes) * 8);
90                let sig_slice = shifted.to_le_bytes();
91                buf.extend_from_slice(&sig_slice[..sig_bytes as usize]);
92            }
93
94            prev_bits = bits;
95        }
96
97        Ok(buf)
98    }
99}
100
101impl PatasDecoder {
102    /// Decode a Patas-encoded payload back to `f64` values.
103    ///
104    /// # Errors
105    ///
106    /// Returns [`EncodingError::CorruptData`] if the payload is truncated or malformed.
107    pub fn decode(payload: &[u8]) -> Result<Vec<f64>> {
108        if payload.len() < 12 {
109            return Err(EncodingError::CorruptData {
110                detail: "patas payload too short for header".to_string(),
111            });
112        }
113
114        let count = checked_decode_count(
115            u32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]) as usize,
116            "Patas",
117        )?;
118        if count == 0 {
119            return Err(EncodingError::CorruptData {
120                detail: "patas count is zero".to_string(),
121            });
122        }
123
124        let mut values = Vec::with_capacity(count);
125        let mut pos = 4;
126
127        // First value: raw 8 bytes
128        if pos + 8 > payload.len() {
129            return Err(EncodingError::CorruptData {
130                detail: "patas payload truncated at first value".to_string(),
131            });
132        }
133        let mut prev_bits = u64::from_le_bytes(payload[pos..pos + 8].try_into().expect("8 bytes"));
134        values.push(f64::from_bits(prev_bits));
135        pos += 8;
136
137        for _ in 1..count {
138            if pos >= payload.len() {
139                return Err(EncodingError::CorruptData {
140                    detail: "patas payload truncated".to_string(),
141                });
142            }
143
144            let flag = payload[pos];
145            pos += 1;
146
147            let tz_bytes = (flag >> 4) & 0x0F;
148            let sig_bytes = flag & 0x0F;
149
150            // Both nibbles are byte counts into a `u64`, so the encoder can
151            // only ever write `tz_bytes + sig_bytes <= 8`. A flag byte from
152            // anywhere else breaks that, and each half breaks it differently:
153            // `sig_bytes > 8` indexes past the end of the 8-byte scratch
154            // buffer below, and `tz_bytes > 7` shifts a `u64` by 64 or more,
155            // which panics in debug and silently masks to a wrong value in
156            // release. Gorilla and Chimp both derive their shift through a
157            // `checked_sub` chain that cannot exceed 63; Patas was the sibling
158            // that validated nothing.
159            if tz_bytes + sig_bytes > 8 {
160                return Err(EncodingError::CorruptData {
161                    detail: format!(
162                        "patas flag byte claims {tz_bytes} trailing-zero and {sig_bytes}                          significant bytes, which do not fit in 8 — header is corrupt"
163                    ),
164                });
165            }
166
167            if sig_bytes == 0 {
168                // Identical value
169                values.push(f64::from_bits(prev_bits));
170                continue;
171            }
172
173            if pos + sig_bytes as usize > payload.len() {
174                return Err(EncodingError::CorruptData {
175                    detail: "patas payload truncated at significant bytes".to_string(),
176                });
177            }
178
179            // Read significant bytes into a u64
180            let mut sig_val = [0u8; 8];
181            sig_val[..sig_bytes as usize].copy_from_slice(&payload[pos..pos + sig_bytes as usize]);
182            pos += sig_bytes as usize;
183
184            let shifted = u64::from_le_bytes(sig_val);
185            let xor = shifted << (u32::from(tz_bytes) * 8);
186            prev_bits ^= xor;
187            values.push(f64::from_bits(prev_bits));
188        }
189
190        if values.len() != count {
191            return Err(EncodingError::CorruptData {
192                detail: format!("patas decoded {} values, expected {}", values.len(), count),
193            });
194        }
195
196        Ok(values)
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn roundtrip_basic() {
206        let values = vec![1.0, 1.1, 1.2, 1.3, 1.4, 1.5];
207        let encoded = PatasEncoder::encode(&values).unwrap();
208        let decoded = PatasDecoder::decode(&encoded).unwrap();
209        assert_eq!(values, decoded);
210    }
211
212    #[test]
213    fn roundtrip_identical() {
214        let values = vec![42.0; 100];
215        let encoded = PatasEncoder::encode(&values).unwrap();
216        let decoded = PatasDecoder::decode(&encoded).unwrap();
217        assert_eq!(values, decoded);
218        // Identical values: 4 (count) + 8 (first) + 99 flag bytes (1 each) = 111
219        assert_eq!(encoded.len(), 111);
220    }
221
222    #[test]
223    fn roundtrip_single() {
224        let values = vec![std::f64::consts::PI];
225        let encoded = PatasEncoder::encode(&values).unwrap();
226        let decoded = PatasDecoder::decode(&encoded).unwrap();
227        assert_eq!(values, decoded);
228    }
229
230    #[test]
231    fn roundtrip_special_values() {
232        let values = vec![
233            0.0,
234            f64::INFINITY,
235            f64::NEG_INFINITY,
236            -0.0,
237            f64::MAX,
238            f64::MIN,
239        ];
240        let encoded = PatasEncoder::encode(&values).unwrap();
241        let decoded = PatasDecoder::decode(&encoded).unwrap();
242        // Compare bit patterns (NaN not included since NaN != NaN)
243        for (a, b) in values.iter().zip(decoded.iter()) {
244            assert_eq!(a.to_bits(), b.to_bits());
245        }
246    }
247
248    #[test]
249    fn roundtrip_nan() {
250        let values = vec![1.0, f64::NAN, 2.0];
251        let encoded = PatasEncoder::encode(&values).unwrap();
252        let decoded = PatasDecoder::decode(&encoded).unwrap();
253        assert_eq!(values[0], decoded[0]);
254        assert!(decoded[1].is_nan());
255        assert_eq!(values[2], decoded[2]);
256    }
257
258    #[test]
259    fn empty_input_error() {
260        let result = PatasEncoder::encode(&[]);
261        assert!(result.is_err());
262    }
263
264    #[test]
265    fn truncated_payload_error() {
266        let result = PatasDecoder::decode(&[0; 4]);
267        assert!(result.is_err());
268    }
269
270    #[test]
271    fn compression_ratio_slowly_varying() {
272        // Slowly varying data should compress reasonably well
273        let values: Vec<f64> = (0..1000).map(|i| 20.0 + (i as f64) * 0.001).collect();
274        let encoded = PatasEncoder::encode(&values).unwrap();
275        let raw_size = values.len() * 8;
276        // Patas is byte-aligned so expect at least 20% compression
277        assert!(
278            encoded.len() < raw_size,
279            "Expected compression, got {}/{}",
280            encoded.len(),
281            raw_size
282        );
283        let decoded = PatasDecoder::decode(&encoded).unwrap();
284        assert_eq!(values, decoded);
285    }
286}
287
288#[cfg(test)]
289mod proptests {
290    use super::*;
291    use proptest::prelude::*;
292
293    proptest! {
294        #[test]
295        fn roundtrip_arbitrary(values in proptest::collection::vec(any::<f64>(), 1..500)) {
296            let encoded = PatasEncoder::encode(&values).unwrap();
297            let decoded = PatasDecoder::decode(&encoded).unwrap();
298            prop_assert_eq!(values.len(), decoded.len());
299            for (a, b) in values.iter().zip(decoded.iter()) {
300                prop_assert_eq!(a.to_bits(), b.to_bits(), "bitwise mismatch");
301            }
302        }
303    }
304}
305
306#[cfg(test)]
307mod flag_validation_tests {
308    use super::*;
309
310    /// Build a minimal Patas payload: `[count u32][first value u64][flag]…`.
311    fn payload_with_flag(flag: u8, tail: &[u8]) -> Vec<u8> {
312        let mut p = Vec::new();
313        p.extend_from_slice(&2u32.to_le_bytes());
314        p.extend_from_slice(&1.0f64.to_bits().to_le_bytes());
315        p.push(flag);
316        p.extend_from_slice(tail);
317        p
318    }
319
320    /// A trailing-zero count above 7 must be refused, not shifted with.
321    ///
322    /// `shifted << (tz_bytes * 8)` reaches 120 for `tz_bytes = 15`, and
323    /// shifting a `u64` by 64 or more panics in debug and masks to a wrong
324    /// value in release. This is reachable from one flipped bit in a flag
325    /// byte on the flash the segment is read from, which is what makes it a
326    /// durability property rather than a hardening nicety.
327    #[test]
328    fn a_trailing_zero_count_past_the_width_of_a_u64_is_rejected() {
329        for tz in 8u8..=15 {
330            let flag = (tz << 4) | 1; // one significant byte
331            let err = PatasDecoder::decode(&payload_with_flag(flag, &[0xFF]))
332                .expect_err("tz_bytes {tz} must be refused");
333            assert!(
334                err.to_string().contains("do not fit in 8"),
335                "tz={tz}: expected the flag guard, got: {err}"
336            );
337        }
338    }
339
340    /// A significant-byte count above 8 would index past the scratch buffer.
341    #[test]
342    fn a_significant_byte_count_past_eight_is_rejected() {
343        for sig in 9u8..=15 {
344            let err = PatasDecoder::decode(&payload_with_flag(sig, &[0xFF; 16]))
345                .expect_err("sig_bytes {sig} must be refused");
346            assert!(
347                err.to_string().contains("do not fit in 8"),
348                "sig={sig}: expected the flag guard, got: {err}"
349            );
350        }
351    }
352
353    /// The guard bounds corruption; it does not narrow the format.
354    #[test]
355    fn every_flag_the_encoder_can_produce_still_decodes() {
356        // Values chosen to exercise a spread of trailing-zero and
357        // significant-byte counts.
358        let values = vec![
359            1.0,
360            1.0,
361            1.5,
362            1.5000001,
363            2.0,
364            -2.0,
365            0.0,
366            -0.0,
367            f64::MAX,
368            f64::MIN_POSITIVE,
369            1e300,
370            1e-300,
371        ];
372        let encoded = PatasEncoder::encode(&values).expect("encode");
373        let decoded = PatasDecoder::decode(&encoded).expect("decode");
374        assert_eq!(decoded.len(), values.len());
375        for (a, b) in values.iter().zip(&decoded) {
376            assert_eq!(a.to_bits(), b.to_bits(), "round trip must be bitwise exact");
377        }
378    }
379}