Skip to main content

chronix_encoding/
pco.rs

1//! Pcodec (`pco`) — Loncaric, *Pcodec: Better Compression for Numerical
2//! Sequences* (2025) — as a block codec for floats and integers.
3//!
4//! pco decomposes each number into latent variables through a "mode"
5//! (integer-multiple or float-multiple recovery, the same insight as ALP's
6//! decimal recovery), delta-encodes them, and entropy-codes the deltas with
7//! ANS against learned bins. On the workloads this crate is measured
8//! against it compresses **3–20× better than ALP and decodes faster**
9//! (`tests/codec_field_check.rs` prints the table), which is why it leads
10//! every adaptive candidate list. ALP and the XOR codecs stay behind it: a
11//! candidate that loses the per-block trial costs one sample encode and
12//! nothing else, and a second opinion on a codec this new is worth that.
13//!
14//! # Wire format
15//!
16//! ```text
17//! [num_values: u32 LE][pco standalone file]
18//! ```
19//!
20//! The count is ours, checked against the crate-wide decode ceiling before a
21//! single byte is decompressed, and the decompression writes into a buffer
22//! of exactly that size: pco's own chunk headers are never trusted for an
23//! allocation, and a file that decodes to a different count is corrupt.
24
25use pco::data_types::Number;
26use pco::standalone::{simple_compress, simple_decompress_into};
27use pco::ChunkConfig;
28
29use crate::coding::MAX_BLOCK_VALUES;
30use crate::error::{EncodingError, Result};
31
32const HEADER: usize = 4;
33
34/// Pcodec encoder for `f64`, `i64` and `u64` columns.
35pub struct PcoEncoder;
36
37/// Pcodec decoder.
38pub struct PcoDecoder;
39
40impl PcoEncoder {
41    /// Encode a float column.
42    ///
43    /// # Errors
44    ///
45    /// Returns an error if the input is empty or larger than `u32::MAX`.
46    pub fn encode_f64(values: &[f64]) -> Result<Vec<u8>> {
47        Self::encode(values)
48    }
49
50    /// Encode a signed integer column.
51    ///
52    /// # Errors
53    ///
54    /// As [`encode_f64`](Self::encode_f64).
55    pub fn encode_i64(values: &[i64]) -> Result<Vec<u8>> {
56        Self::encode(values)
57    }
58
59    /// Encode an unsigned integer column.
60    ///
61    /// # Errors
62    ///
63    /// As [`encode_f64`](Self::encode_f64).
64    pub fn encode_u64(values: &[u64]) -> Result<Vec<u8>> {
65        Self::encode(values)
66    }
67
68    /// Encode a timestamp column.
69    ///
70    /// Timestamps are the one column whose shape is known before any data
71    /// is seen — a near-arithmetic sequence with jitter and gaps — so the
72    /// delta order is pinned to second-order (delta-of-delta) rather than
73    /// auto-detected. The decoder needs no such hint; the pco file
74    /// describes its own delta encoding.
75    ///
76    /// # Errors
77    ///
78    /// As [`encode_f64`](Self::encode_f64).
79    pub fn encode_timestamps(values: &[i64]) -> Result<Vec<u8>> {
80        Self::encode_with(values, &ChunkConfig::default())
81    }
82
83    fn encode<T: Number>(values: &[T]) -> Result<Vec<u8>> {
84        Self::encode_with(values, &ChunkConfig::default())
85    }
86
87    fn encode_with<T: Number>(values: &[T], config: &ChunkConfig) -> Result<Vec<u8>> {
88        if values.is_empty() {
89            return Err(EncodingError::EmptyInput {
90                context: "pco encode",
91            });
92        }
93        let n = u32::try_from(values.len()).map_err(|_| EncodingError::CorruptData {
94            detail: format!("pco: {} values exceed a block", values.len()),
95        })?;
96        let body = simple_compress(values, config).map_err(|e| EncodingError::CorruptData {
97            detail: format!("pco encode: {e}"),
98        })?;
99        let mut out = Vec::with_capacity(HEADER + body.len());
100        out.extend_from_slice(&n.to_le_bytes());
101        out.extend_from_slice(&body);
102        Ok(out)
103    }
104}
105
106impl PcoDecoder {
107    /// Decode a float column.
108    ///
109    /// # Errors
110    ///
111    /// Returns an error if the payload is truncated, declares more values
112    /// than the decode ceiling allows, or does not decode to exactly the
113    /// declared count.
114    pub fn decode_f64(data: &[u8]) -> Result<Vec<f64>> {
115        Self::decode(data)
116    }
117
118    /// Decode a signed integer column.
119    ///
120    /// # Errors
121    ///
122    /// As [`decode_f64`](Self::decode_f64).
123    pub fn decode_i64(data: &[u8]) -> Result<Vec<i64>> {
124        Self::decode(data)
125    }
126
127    /// Decode an unsigned integer column.
128    ///
129    /// # Errors
130    ///
131    /// As [`decode_f64`](Self::decode_f64).
132    pub fn decode_u64(data: &[u8]) -> Result<Vec<u64>> {
133        Self::decode(data)
134    }
135
136    fn decode<T: Number + Default + Copy>(data: &[u8]) -> Result<Vec<T>> {
137        if data.len() < HEADER {
138            return Err(EncodingError::CorruptData {
139                detail: "pco: truncated header".into(),
140            });
141        }
142        let n = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
143        if n == 0 || n > MAX_BLOCK_VALUES {
144            return Err(EncodingError::CorruptData {
145                detail: format!("pco: declared {n} values, ceiling is {MAX_BLOCK_VALUES}"),
146            });
147        }
148        // The only allocation is ours, sized by a count we have bounded.
149        let mut out = vec![T::default(); n];
150        let progress = simple_decompress_into(&data[HEADER..], &mut out).map_err(|e| {
151            EncodingError::CorruptData {
152                detail: format!("pco decode: {e}"),
153            }
154        })?;
155        if progress.n_processed != n || !progress.finished {
156            return Err(EncodingError::CountMismatch {
157                expected: n,
158                actual: progress.n_processed,
159            });
160        }
161        Ok(out)
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use proptest::prelude::*;
169
170    proptest! {
171        /// Bitwise-exact round trip on arbitrary input, for each of the three
172        /// types pco stores.
173        ///
174        /// The crate's rule is a property test per codec, and pco is the one
175        /// the compression table leads with — it had three worked examples
176        /// and no arbitrary input.
177        #[test]
178        fn roundtrip_arbitrary_f64(values in prop::collection::vec(any::<f64>(), 1..300)) {
179            let decoded = PcoDecoder::decode_f64(&PcoEncoder::encode_f64(&values).unwrap()).unwrap();
180            prop_assert_eq!(decoded.len(), values.len());
181            for (a, b) in values.iter().zip(&decoded) {
182                prop_assert_eq!(a.to_bits(), b.to_bits());
183            }
184        }
185
186        #[test]
187        fn roundtrip_arbitrary_i64(values in prop::collection::vec(any::<i64>(), 1..300)) {
188            let decoded = PcoDecoder::decode_i64(&PcoEncoder::encode_i64(&values).unwrap()).unwrap();
189            prop_assert_eq!(decoded, values);
190        }
191
192        #[test]
193        fn roundtrip_arbitrary_u64(values in prop::collection::vec(any::<u64>(), 1..300)) {
194            let decoded = PcoDecoder::decode_u64(&PcoEncoder::encode_u64(&values).unwrap()).unwrap();
195            prop_assert_eq!(decoded, values);
196        }
197
198        /// Timestamps go through their own entry point, which picks a
199        /// different chunk configuration.
200        #[test]
201        fn roundtrip_arbitrary_timestamps(values in prop::collection::vec(any::<i64>(), 1..300)) {
202            let decoded =
203                PcoDecoder::decode_i64(&PcoEncoder::encode_timestamps(&values).unwrap()).unwrap();
204            prop_assert_eq!(decoded, values);
205        }
206
207        /// Arbitrary bytes must be refused, never decoded into values and
208        /// never used to size an allocation.
209        #[test]
210        fn arbitrary_bytes_do_not_decode(data in prop::collection::vec(any::<u8>(), 0..512)) {
211            let _ = PcoDecoder::decode_f64(&data);
212            let _ = PcoDecoder::decode_i64(&data);
213            let _ = PcoDecoder::decode_u64(&data);
214        }
215    }
216
217    #[test]
218    fn round_trips_all_three_types() {
219        let f: Vec<f64> = (0..5000)
220            .map(|i| 230.0 + f64::from(i % 400) / 100.0)
221            .collect();
222        assert_eq!(
223            PcoDecoder::decode_f64(&PcoEncoder::encode_f64(&f).unwrap()).unwrap(),
224            f
225        );
226        let i: Vec<i64> = (0..5000).map(|i| 42_000 + i64::from(i) * 3).collect();
227        assert_eq!(
228            PcoDecoder::decode_i64(&PcoEncoder::encode_i64(&i).unwrap()).unwrap(),
229            i
230        );
231        let u: Vec<u64> = (0..5000u64).map(|i| i * 7).collect();
232        assert_eq!(
233            PcoDecoder::decode_u64(&PcoEncoder::encode_u64(&u).unwrap()).unwrap(),
234            u
235        );
236        let specials = vec![f64::NAN, f64::INFINITY, -0.0, f64::MIN_POSITIVE, 1e300];
237        let back = PcoDecoder::decode_f64(&PcoEncoder::encode_f64(&specials).unwrap()).unwrap();
238        for (a, b) in specials.iter().zip(&back) {
239            assert_eq!(a.to_bits(), b.to_bits());
240        }
241    }
242
243    #[test]
244    fn beats_alp_on_decimal_data() {
245        let f: Vec<f64> = (0..8192)
246            .map(|i| 230.0 + f64::from((i * 37) % 4000) / 100.0)
247            .collect();
248        let pco = PcoEncoder::encode_f64(&f).unwrap().len();
249        let alp = crate::alp::AlpEncoder::encode(&f).unwrap().len();
250        assert!(pco * 4 < alp, "pco {pco} B vs ALP {alp} B");
251    }
252
253    #[test]
254    fn a_corrupt_header_cannot_allocate_and_a_wrong_count_is_an_error() {
255        let f: Vec<f64> = (0..100).map(f64::from).collect();
256        let mut enc = PcoEncoder::encode_f64(&f).unwrap();
257        // Declare u32::MAX values: rejected before any allocation.
258        enc[..4].copy_from_slice(&u32::MAX.to_le_bytes());
259        assert!(matches!(
260            PcoDecoder::decode_f64(&enc),
261            Err(EncodingError::CorruptData { .. })
262        ));
263        // Declare fewer values than the file holds: not finished → error.
264        enc[..4].copy_from_slice(&50u32.to_le_bytes());
265        assert!(PcoDecoder::decode_f64(&enc).is_err());
266        // Declare more: not all processed → error.
267        enc[..4].copy_from_slice(&150u32.to_le_bytes());
268        assert!(PcoDecoder::decode_f64(&enc).is_err());
269        // Garbage body.
270        assert!(PcoDecoder::decode_f64(&[7, 0, 0, 0, 1, 2, 3]).is_err());
271        assert!(PcoDecoder::decode_f64(&[1, 0]).is_err());
272    }
273}