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
169    #[test]
170    fn round_trips_all_three_types() {
171        let f: Vec<f64> = (0..5000)
172            .map(|i| 230.0 + f64::from(i % 400) / 100.0)
173            .collect();
174        assert_eq!(
175            PcoDecoder::decode_f64(&PcoEncoder::encode_f64(&f).unwrap()).unwrap(),
176            f
177        );
178        let i: Vec<i64> = (0..5000).map(|i| 42_000 + i64::from(i) * 3).collect();
179        assert_eq!(
180            PcoDecoder::decode_i64(&PcoEncoder::encode_i64(&i).unwrap()).unwrap(),
181            i
182        );
183        let u: Vec<u64> = (0..5000u64).map(|i| i * 7).collect();
184        assert_eq!(
185            PcoDecoder::decode_u64(&PcoEncoder::encode_u64(&u).unwrap()).unwrap(),
186            u
187        );
188        let specials = vec![f64::NAN, f64::INFINITY, -0.0, f64::MIN_POSITIVE, 1e300];
189        let back = PcoDecoder::decode_f64(&PcoEncoder::encode_f64(&specials).unwrap()).unwrap();
190        for (a, b) in specials.iter().zip(&back) {
191            assert_eq!(a.to_bits(), b.to_bits());
192        }
193    }
194
195    #[test]
196    fn beats_alp_on_decimal_data() {
197        let f: Vec<f64> = (0..8192)
198            .map(|i| 230.0 + f64::from((i * 37) % 4000) / 100.0)
199            .collect();
200        let pco = PcoEncoder::encode_f64(&f).unwrap().len();
201        let alp = crate::alp::AlpEncoder::encode(&f).unwrap().len();
202        assert!(pco * 4 < alp, "pco {pco} B vs ALP {alp} B");
203    }
204
205    #[test]
206    fn a_corrupt_header_cannot_allocate_and_a_wrong_count_is_an_error() {
207        let f: Vec<f64> = (0..100).map(f64::from).collect();
208        let mut enc = PcoEncoder::encode_f64(&f).unwrap();
209        // Declare u32::MAX values: rejected before any allocation.
210        enc[..4].copy_from_slice(&u32::MAX.to_le_bytes());
211        assert!(matches!(
212            PcoDecoder::decode_f64(&enc),
213            Err(EncodingError::CorruptData { .. })
214        ));
215        // Declare fewer values than the file holds: not finished → error.
216        enc[..4].copy_from_slice(&50u32.to_le_bytes());
217        assert!(PcoDecoder::decode_f64(&enc).is_err());
218        // Declare more: not all processed → error.
219        enc[..4].copy_from_slice(&150u32.to_le_bytes());
220        assert!(PcoDecoder::decode_f64(&enc).is_err());
221        // Garbage body.
222        assert!(PcoDecoder::decode_f64(&[7, 0, 0, 0, 1, 2, 3]).is_err());
223        assert!(PcoDecoder::decode_f64(&[1, 0]).is_err());
224    }
225}