Skip to main content

chronix_encoding/
alp.rs

1//! ALP — Adaptive Lossless floating-Point compression (SIGMOD 2024).
2//!
3//! Afroozeh, Kuffo & Boncz, *"ALP: Adaptive Lossless floating-Point
4//! Compression"*, Proc. ACM Manag. Data 2(1), SIGMOD 2024. The scheme DuckDB
5//! adopted, and the current state of the art for lossless `f64` columns —
6//! better ratios than Chimp/Patas/Gorilla on real data and an order of
7//! magnitude faster to decode.
8//!
9//! # The idea
10//!
11//! XOR-based codecs (Gorilla, Chimp, Patas) treat a double as an opaque bit
12//! pattern and hope consecutive values share a prefix. But most doubles in a
13//! time-series database never had 15 digits of precision to begin with: they
14//! are *decimals* that a sensor, meter or price feed produced with two or
15//! three fractional digits and that IEEE-754 then stored approximately.
16//! `231.45` is not a random 64-bit pattern — it is the integer `23145` with a
17//! known scale.
18//!
19//! ALP recovers that integer. For a block it picks one exponent pair
20//! `(e, f)` and encodes each value as
21//!
22//! ```text
23//! i = round(v · 10^e · 10^-f)     decode: v = i · 10^f · 10^-e
24//! ```
25//!
26//! The integers are then frame-of-reference coded and bit-packed, which is
27//! where the compression comes from: a power meter reporting 200–250 W to two
28//! decimals yields integers spanning 5000 values — 13 bits instead of 64.
29//!
30//! # Exactness
31//!
32//! The transform is a *guess*, so every value is verified by decoding it
33//! again and comparing bit patterns. Values that do not round-trip — genuine
34//! high-precision doubles, `NaN`, `±inf`, `-0.0`, anything outside `i64` —
35//! are stored verbatim as **exceptions** alongside their positions. Both
36//! sides call the same [`decode_value`], so encoder and decoder cannot
37//! disagree about what a given integer means, and the codec is bitwise
38//! lossless like every other encoder in this crate.
39//!
40//! # Scope
41//!
42//! Only the decimal scheme is implemented. The paper's second scheme,
43//! `ALP_RD`, targets columns of genuinely non-decimal doubles (scientific
44//! measurements, hashes) by splitting the bit pattern into a dictionary-coded
45//! left part and a packed right part. Chronix already has three XOR codecs
46//! that cover exactly that shape, and [`AdaptiveSelector`](crate::AdaptiveSelector)
47//! picks between them by trial encoding — so ALP joins the competition and
48//! loses gracefully on data it is not for, rather than duplicating a fallback
49//! that already exists.
50//!
51//! # Wire format
52//!
53//! ```text
54//! [count: u32 LE][e: u8][f: u8][bit_width: u8][reference: i64 LE]
55//! [exception_count: u32 LE]
56//! [packed offsets: count × bit_width bits, MSB-first]
57//! [exceptions: exception_count × ([position: u32 LE][value: 8 bytes LE])]
58//! ```
59
60use crate::coding::{bits_needed, checked_count, checked_decode_count, pack_bits, unpack_bits};
61use crate::error::{EncodingError, Result};
62
63/// Header size: `count(4) + e(1) + f(1) + bit_width(1) + reference(8) +
64/// exception_count(4)`.
65const HEADER_SIZE: usize = 19;
66
67/// Bytes per stored exception: position + raw value.
68const EXCEPTION_SIZE: usize = 12;
69
70/// Largest usable exponent: `10^18` is the biggest power of ten in `i64`.
71const MAX_EXPONENT: u8 = 18;
72
73/// Exact powers of ten. Every entry is exactly representable in `f64`
74/// (integers below `2^53`), so `F10[i] == 10^i` with no rounding.
75const F10: [f64; 19] = [
76    1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10, 1e11, 1e12, 1e13, 1e14, 1e15, 1e16,
77    1e17, 1e18,
78];
79
80/// Reciprocal powers of ten. These are *not* exact — `1e-1` is the nearest
81/// double to 0.1 — which is fine because correctness comes from verifying the
82/// round trip, not from the arithmetic being exact. What matters is that
83/// encoder and decoder use the identical constants and the identical
84/// multiplication order; [`decode_value`] is the single place that does.
85const IF10: [f64; 19] = [
86    1e-0, 1e-1, 1e-2, 1e-3, 1e-4, 1e-5, 1e-6, 1e-7, 1e-8, 1e-9, 1e-10, 1e-11, 1e-12, 1e-13, 1e-14,
87    1e-15, 1e-16, 1e-17, 1e-18,
88];
89
90/// Values sampled per block when searching for the exponent pair.
91const EXPONENT_SAMPLE: usize = 64;
92
93/// Bits an exception costs relative to a packed value: 32-bit position plus
94/// the 64-bit value. Used only to score candidate exponents.
95const EXCEPTION_BITS: usize = 96;
96
97/// ALP float encoder (SIGMOD 2024).
98#[derive(Debug, Clone, Copy)]
99pub struct AlpEncoder;
100
101/// ALP float decoder.
102#[derive(Debug, Clone, Copy)]
103pub struct AlpDecoder;
104
105/// Reconstruct the original value from its integer encoding.
106///
107/// The single definition of what `(i, e, f)` means. The encoder calls it to
108/// verify each value round-trips and the decoder calls it to reconstruct, so
109/// the two cannot drift apart.
110#[inline]
111#[must_use]
112pub fn decode_value(encoded: i64, e: u8, f: u8) -> f64 {
113    encoded as f64 * F10[f as usize] * IF10[e as usize]
114}
115
116/// Try to encode one value with the given exponents.
117///
118/// Returns `None` when the value cannot be recovered exactly, which makes it
119/// an exception.
120#[inline]
121fn encode_value(v: f64, e: u8, f: u8) -> Option<i64> {
122    let scaled = v * F10[e as usize] * IF10[f as usize];
123    if !scaled.is_finite() {
124        return None;
125    }
126    let rounded = scaled.round_ties_even();
127    // `as` saturates rather than wrapping, so the range must be checked
128    // before the cast or distinct values would collapse onto i64::MAX.
129    if !(rounded >= -(2f64.powi(62)) && rounded <= 2f64.powi(62)) {
130        return None;
131    }
132    let encoded = rounded as i64;
133    // Bit comparison, not `==`: it is what distinguishes `-0.0` from `0.0`
134    // and what makes the guarantee bitwise rather than numeric.
135    (decode_value(encoded, e, f).to_bits() == v.to_bits()).then_some(encoded)
136}
137
138/// Pick the `(e, f)` pair that should compress this block best.
139///
140/// Scores every `0 <= f <= e <= 18` combination against a stratified sample by
141/// the size it would produce — packed width for the values that round-trip,
142/// plus a fixed cost for the ones that do not — and takes the cheapest. This
143/// is the paper's two-level sampling reduced to one level: at Chronix's block
144/// sizes the search is a few thousand multiplications and runs once per block,
145/// so the second level buys nothing.
146fn choose_exponents(values: &[f64]) -> (u8, u8) {
147    let step = (values.len() / EXPONENT_SAMPLE).max(1);
148    let sample: Vec<f64> = values.iter().copied().step_by(step).collect();
149    let n = sample.len();
150
151    let mut best = (0u8, 0u8);
152    let mut best_bits = usize::MAX;
153
154    for e in 0..=MAX_EXPONENT {
155        for f in 0..=e {
156            let mut min = i64::MAX;
157            let mut max = i64::MIN;
158            let mut ok = 0usize;
159            for &v in &sample {
160                if let Some(i) = encode_value(v, e, f) {
161                    min = min.min(i);
162                    max = max.max(i);
163                    ok += 1;
164                }
165            }
166            if ok == 0 {
167                continue;
168            }
169            let width = usize::from(bits_needed(max.wrapping_sub(min) as u64));
170            let bits = n * width + (n - ok) * EXCEPTION_BITS;
171            // Ties go to the smaller exponent: it keeps the integers smaller,
172            // which keeps the frame-of-reference offsets smaller on the parts
173            // of the block the sample did not see.
174            if bits < best_bits {
175                best_bits = bits;
176                best = (e, f);
177            }
178        }
179    }
180
181    best
182}
183
184impl AlpEncoder {
185    /// Encode a sequence of `f64` values with ALP.
186    ///
187    /// # Errors
188    ///
189    /// Returns [`EncodingError::EmptyInput`] if `values` is empty, or
190    /// [`EncodingError::CorruptData`] if the block is longer than `u32::MAX`.
191    pub fn encode(values: &[f64]) -> Result<Vec<u8>> {
192        if values.is_empty() {
193            return Err(EncodingError::EmptyInput {
194                context: "ALP encoder",
195            });
196        }
197        let count = checked_count(values.len())?;
198        let (e, f) = choose_exponents(values);
199
200        let mut encoded: Vec<i64> = Vec::with_capacity(values.len());
201        let mut exceptions: Vec<(u32, f64)> = Vec::new();
202        let mut filler: Option<i64> = None;
203
204        for (idx, &v) in values.iter().enumerate() {
205            match encode_value(v, e, f) {
206                Some(i) => {
207                    filler.get_or_insert(i);
208                    encoded.push(i);
209                }
210                None => {
211                    // Exception slots still occupy a packed position. Filling
212                    // them with a value that actually occurs keeps them inside
213                    // the frame of reference, so a single outlier cannot widen
214                    // the whole block.
215                    exceptions.push(u32::try_from(idx).map_or((u32::MAX, v), |i| (i, v)));
216                    encoded.push(i64::MIN); // patched below
217                }
218            }
219        }
220
221        let filler = filler.unwrap_or(0);
222        for (idx, _) in &exceptions {
223            if let Some(slot) = encoded.get_mut(*idx as usize) {
224                *slot = filler;
225            }
226        }
227
228        let reference = encoded.iter().copied().min().unwrap_or(0);
229        let offsets: Vec<u64> = encoded
230            .iter()
231            .map(|&v| v.wrapping_sub(reference) as u64)
232            .collect();
233        let bit_width = bits_needed(offsets.iter().copied().max().unwrap_or(0));
234
235        let data_bytes = (offsets.len() as u64 * u64::from(bit_width)).div_ceil(8) as usize;
236        let mut buf =
237            Vec::with_capacity(HEADER_SIZE + data_bytes + exceptions.len() * EXCEPTION_SIZE);
238        buf.extend_from_slice(&count.to_le_bytes());
239        buf.push(e);
240        buf.push(f);
241        buf.push(bit_width);
242        buf.extend_from_slice(&reference.to_le_bytes());
243        buf.extend_from_slice(&checked_count(exceptions.len())?.to_le_bytes());
244
245        pack_bits(&offsets, bit_width, &mut buf);
246
247        for (idx, value) in &exceptions {
248            buf.extend_from_slice(&idx.to_le_bytes());
249            buf.extend_from_slice(&value.to_bits().to_le_bytes());
250        }
251
252        Ok(buf)
253    }
254}
255
256impl AlpDecoder {
257    /// Decode an ALP-encoded block.
258    ///
259    /// # Errors
260    ///
261    /// Returns [`EncodingError::CorruptData`] if the buffer is truncated, the
262    /// exponents are out of range, or an exception position is out of bounds.
263    pub fn decode(data: &[u8]) -> Result<Vec<f64>> {
264        if data.len() < HEADER_SIZE {
265            return Err(EncodingError::CorruptData {
266                detail: format!(
267                    "ALP block is {} bytes, need at least {HEADER_SIZE}",
268                    data.len()
269                ),
270            });
271        }
272
273        let count = checked_decode_count(
274            u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize,
275            "ALP",
276        )?;
277        let e = data[4];
278        let f = data[5];
279        let bit_width = data[6];
280        let reference = i64::from_le_bytes([
281            data[7], data[8], data[9], data[10], data[11], data[12], data[13], data[14],
282        ]);
283        let exception_count = checked_decode_count(
284            u32::from_le_bytes([data[15], data[16], data[17], data[18]]) as usize,
285            "ALP exception",
286        )?;
287
288        if e > MAX_EXPONENT || f > e {
289            return Err(EncodingError::CorruptData {
290                detail: format!("ALP exponents out of range: e={e}, f={f}"),
291            });
292        }
293
294        let data_bytes = (count as u64 * u64::from(bit_width)).div_ceil(8) as usize;
295        let body = &data[HEADER_SIZE..];
296        if body.len() < data_bytes + exception_count * EXCEPTION_SIZE {
297            return Err(EncodingError::CorruptData {
298                detail: "ALP block is truncated".to_string(),
299            });
300        }
301
302        let offsets = unpack_bits(&body[..data_bytes], count, bit_width)?;
303        let mut values: Vec<f64> = offsets
304            .into_iter()
305            .map(|o| decode_value(reference.wrapping_add(o as i64), e, f))
306            .collect();
307
308        let mut pos = data_bytes;
309        for _ in 0..exception_count {
310            let idx = u32::from_le_bytes([body[pos], body[pos + 1], body[pos + 2], body[pos + 3]])
311                as usize;
312            let bits = u64::from_le_bytes([
313                body[pos + 4],
314                body[pos + 5],
315                body[pos + 6],
316                body[pos + 7],
317                body[pos + 8],
318                body[pos + 9],
319                body[pos + 10],
320                body[pos + 11],
321            ]);
322            let slot = values
323                .get_mut(idx)
324                .ok_or_else(|| EncodingError::CorruptData {
325                    detail: format!(
326                        "ALP exception position {idx} is outside a {count}-value block"
327                    ),
328                })?;
329            *slot = f64::from_bits(bits);
330            pos += EXCEPTION_SIZE;
331        }
332
333        Ok(values)
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    fn roundtrip(values: &[f64]) -> Vec<f64> {
342        let encoded = AlpEncoder::encode(values).expect("encode");
343        AlpDecoder::decode(&encoded).expect("decode")
344    }
345
346    fn assert_bitwise(values: &[f64]) {
347        let decoded = roundtrip(values);
348        assert_eq!(decoded.len(), values.len());
349        for (i, (a, b)) in values.iter().zip(&decoded).enumerate() {
350            assert_eq!(
351                a.to_bits(),
352                b.to_bits(),
353                "value {i} changed: {a} ({:#x}) -> {b} ({:#x})",
354                a.to_bits(),
355                b.to_bits()
356            );
357        }
358    }
359
360    #[test]
361    fn two_decimal_sensor_readings_round_trip() {
362        let values: Vec<f64> = (0..1024)
363            .map(|i| 200.0 + f64::from(i % 500) / 100.0)
364            .collect();
365        assert_bitwise(&values);
366    }
367
368    /// The point of the codec: decimal data must beat the raw 8 bytes/value
369    /// by a wide margin, and beat the XOR codecs it is meant to replace.
370    #[test]
371    fn decimal_data_compresses_far_better_than_xor_codecs() {
372        let values: Vec<f64> = (0..4096)
373            .map(|i| 230.0 + f64::from(i % 2000) / 100.0)
374            .collect();
375        let raw = values.len() * 8;
376        let alp = AlpEncoder::encode(&values).unwrap().len();
377        let chimp = crate::chimp::ChimpEncoder::encode(&values).unwrap().len();
378        let gorilla = crate::gorilla::GorillaEncoder::encode(&values)
379            .unwrap()
380            .len();
381        let patas = crate::patas::PatasEncoder::encode(&values).unwrap().len();
382
383        assert!(
384            alp * 4 < raw,
385            "ALP should compress decimal data at least 4x: {alp} vs {raw}"
386        );
387        assert!(
388            alp < chimp && alp < gorilla && alp < patas,
389            "ALP {alp} did not beat chimp {chimp} / gorilla {gorilla} / patas {patas}"
390        );
391    }
392
393    #[test]
394    fn integers_round_trip() {
395        let values: Vec<f64> = (0..500).map(f64::from).collect();
396        assert_bitwise(&values);
397    }
398
399    #[test]
400    fn constant_column_round_trips() {
401        assert_bitwise(&[42.5; 300]);
402    }
403
404    #[test]
405    fn single_value() {
406        assert_bitwise(&[1.5]);
407    }
408
409    #[test]
410    fn special_values_become_exceptions_and_survive() {
411        let values = vec![
412            0.0,
413            -0.0,
414            f64::NAN,
415            f64::INFINITY,
416            f64::NEG_INFINITY,
417            f64::MIN,
418            f64::MAX,
419            f64::MIN_POSITIVE,
420            1.5,
421            -2.25,
422        ];
423        let decoded = roundtrip(&values);
424        for (i, (a, b)) in values.iter().zip(&decoded).enumerate() {
425            assert_eq!(a.to_bits(), b.to_bits(), "special value {i} changed");
426        }
427    }
428
429    /// High-precision doubles cannot be expressed as scaled integers, so they
430    /// all become exceptions. The result must still be correct — just bigger,
431    /// which is what makes the adaptive selector pick an XOR codec instead.
432    #[test]
433    fn non_decimal_doubles_still_round_trip() {
434        let values: Vec<f64> = (0..256)
435            .map(|i| f64::from(i).mul_add(0.000_000_000_123_456_789, std::f64::consts::PI))
436            .collect();
437        assert_bitwise(&values);
438    }
439
440    #[test]
441    fn negative_and_mixed_signs() {
442        let values: Vec<f64> = (0..1000).map(|i| (f64::from(i) - 500.0) / 8.0).collect();
443        assert_bitwise(&values);
444    }
445
446    #[test]
447    fn empty_input_is_rejected() {
448        assert!(AlpEncoder::encode(&[]).is_err());
449    }
450
451    #[test]
452    fn truncated_block_is_rejected() {
453        let encoded = AlpEncoder::encode(&[1.0, 2.0, 3.0]).unwrap();
454        assert!(AlpDecoder::decode(&encoded[..HEADER_SIZE - 1]).is_err());
455        assert!(AlpDecoder::decode(&encoded[..encoded.len() - 1]).is_err());
456    }
457
458    #[test]
459    fn corrupt_exponents_are_rejected() {
460        let mut encoded = AlpEncoder::encode(&[1.0, 2.0, 3.0]).unwrap();
461        encoded[4] = 99; // e out of range
462        assert!(AlpDecoder::decode(&encoded).is_err());
463    }
464
465    /// The reciprocal table must match the division it stands in for,
466    /// otherwise encoder-side verification and decoder-side reconstruction
467    /// would be computing different things.
468    #[test]
469    fn reciprocal_table_matches_division() {
470        for i in 0..=MAX_EXPONENT as usize {
471            assert_eq!(
472                IF10[i].to_bits(),
473                (1.0 / F10[i]).to_bits(),
474                "IF10[{i}] does not match 1/F10[{i}]"
475            );
476        }
477    }
478}
479
480#[cfg(test)]
481mod proptests {
482    use super::*;
483    use proptest::prelude::*;
484
485    proptest! {
486        /// Bitwise-exact round trip is the crate's contract (design principle
487        /// 1), and ALP is the one codec that reaches its answer by guessing —
488        /// so it is the one that most needs an arbitrary-input check.
489        #[test]
490        fn roundtrip_arbitrary_f64(values in prop::collection::vec(any::<f64>(), 1..300)) {
491            let encoded = AlpEncoder::encode(&values).unwrap();
492            let decoded = AlpDecoder::decode(&encoded).unwrap();
493            prop_assert_eq!(decoded.len(), values.len());
494            for (a, b) in values.iter().zip(&decoded) {
495                prop_assert_eq!(a.to_bits(), b.to_bits());
496            }
497        }
498
499        /// Decimal values with a bounded number of fractional digits are the
500        /// shape ALP is for; these must round-trip too, and mostly avoid the
501        /// exception path.
502        #[test]
503        fn roundtrip_decimals(
504            scaled in prop::collection::vec(-1_000_000i64..1_000_000, 1..300),
505            digits in 0u32..6,
506        ) {
507            let scale = 10f64.powi(digits as i32);
508            let values: Vec<f64> = scaled.iter().map(|&v| v as f64 / scale).collect();
509            let encoded = AlpEncoder::encode(&values).unwrap();
510            let decoded = AlpDecoder::decode(&encoded).unwrap();
511            for (a, b) in values.iter().zip(&decoded) {
512                prop_assert_eq!(a.to_bits(), b.to_bits());
513            }
514        }
515
516        /// A decoder must never panic on adversarial bytes, only error.
517        #[test]
518        fn decode_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..512)) {
519            let _ = AlpDecoder::decode(&bytes);
520        }
521    }
522}