Skip to main content

nodedb_codec/
alp.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! ALP (Adaptive Lossless floating-Point) codec for f64 columns.
4//!
5//! Most real-world float metrics originate as fixed-point decimals (e.g.,
6//! `23.5`, `99.99`). ALP finds the optimal power-of-10 multiplier that
7//! converts them to integers **losslessly**: `23.5 × 10 = 235` round-trips
8//! exactly via `235 / 10 = 23.5`. The resulting integers have tiny bit-widths
9//! and compress 3-6x better than Gorilla with SIMD-friendly bit-packing.
10//!
11//! Values that don't round-trip exactly (true arbitrary doubles, NaN, Inf)
12//! are stored as exceptions — their original f64 bits are preserved separately.
13//!
14//! Wire format:
15//! ```text
16//! [4 bytes] total value count (LE u32)
17//! [1 byte]  exponent e (power of 10: factor = 10^e, range 0-18)
18//! [1 byte]  factor index f (combined encode/decode factor)
19//! [4 bytes] exception count (LE u32)
20//! [exception_count × 12 bytes] exceptions: (index: u32, value_bits: u64)
21//! [N bytes] FastLanes-packed encoded integers (FOR + bit-pack)
22//! ```
23//!
24//! Reference: "ALP: Adaptive Lossless floating-Point Compression"
25//! (Afroozeh et al., SIGMOD 2023)
26
27use crate::error::CodecError;
28use crate::fastlanes;
29
30/// Maximum exponent (10^18 fits in i64).
31const MAX_EXPONENT: u8 = 18;
32
33/// Powers of 10 as f64 for the encode multiplier.
34const POW10: [f64; 19] = [
35    1.0,
36    10.0,
37    100.0,
38    1_000.0,
39    10_000.0,
40    100_000.0,
41    1_000_000.0,
42    10_000_000.0,
43    100_000_000.0,
44    1_000_000_000.0,
45    10_000_000_000.0,
46    100_000_000_000.0,
47    1_000_000_000_000.0,
48    10_000_000_000_000.0,
49    100_000_000_000_000.0,
50    1_000_000_000_000_000.0,
51    10_000_000_000_000_000.0,
52    100_000_000_000_000_000.0,
53    1_000_000_000_000_000_000.0,
54];
55
56/// Inverse powers of 10 for the decode divisor.
57const INV_POW10: [f64; 19] = [
58    1.0,
59    0.1,
60    0.01,
61    0.001,
62    0.000_1,
63    0.000_01,
64    0.000_001,
65    0.000_000_1,
66    0.000_000_01,
67    0.000_000_001,
68    0.000_000_000_1,
69    0.000_000_000_01,
70    0.000_000_000_001,
71    0.000_000_000_000_1,
72    0.000_000_000_000_01,
73    0.000_000_000_000_001,
74    0.000_000_000_000_000_1,
75    0.000_000_000_000_000_01,
76    0.000_000_000_000_000_001,
77];
78
79use crate::CODEC_SAMPLE_SIZE;
80
81// ---------------------------------------------------------------------------
82// Public encode / decode API
83// ---------------------------------------------------------------------------
84
85/// Encode a slice of f64 values using ALP compression.
86///
87/// Finds the optimal decimal exponent, converts to integers, stores
88/// exceptions for values that don't round-trip, and bit-packs the integers
89/// via FastLanes.
90pub fn encode(values: &[f64]) -> Vec<u8> {
91    let count = values.len() as u32;
92
93    if values.is_empty() {
94        let mut out = Vec::with_capacity(11);
95        out.extend_from_slice(&0u32.to_le_bytes()); // count
96        out.push(0); // encode exponent
97        out.push(0); // decode exponent
98        out.push(0); // mode
99        out.extend_from_slice(&0u32.to_le_bytes()); // exception count
100        return out;
101    }
102
103    // Step 1: Find optimal parameters by sampling.
104    let params = find_best_params(values);
105    let factor = POW10[params.encode_exp as usize];
106
107    // Step 2: Encode values to integers, collect exceptions.
108    let mut encoded_ints = Vec::with_capacity(values.len());
109    let mut exceptions: Vec<(u32, u64)> = Vec::new();
110
111    for (i, &val) in values.iter().enumerate() {
112        let encoded = try_alp_encode(val, factor, params.decode_exp, params.mode);
113        match encoded {
114            Some(int_val) => {
115                encoded_ints.push(int_val);
116            }
117            None => {
118                // Exception: store original bits, use 0 as placeholder in int array.
119                exceptions.push((i as u32, val.to_bits()));
120                encoded_ints.push(0);
121            }
122        }
123    }
124
125    // Step 3: Build output.
126    let exception_count = exceptions.len() as u32;
127    let packed_ints = fastlanes::encode(&encoded_ints);
128
129    let mut out = Vec::with_capacity(10 + exceptions.len() * 12 + packed_ints.len());
130
131    // Header.
132    out.extend_from_slice(&count.to_le_bytes());
133    out.push(params.encode_exp);
134    out.push(params.decode_exp);
135    out.push(match params.mode {
136        DecodeMode::MultiplyInverse => 0,
137        DecodeMode::DivideByFactor => 1,
138    });
139    out.extend_from_slice(&exception_count.to_le_bytes());
140
141    // Exceptions.
142    for &(idx, bits) in &exceptions {
143        out.extend_from_slice(&idx.to_le_bytes());
144        out.extend_from_slice(&bits.to_le_bytes());
145    }
146
147    // Packed integers.
148    out.extend_from_slice(&packed_ints);
149
150    out
151}
152
153/// Decode ALP-compressed bytes back to f64 values.
154pub fn decode(data: &[u8]) -> Result<Vec<f64>, CodecError> {
155    const HEADER_SIZE: usize = 11; // 4 + 1 + 1 + 1 + 4
156
157    if data.len() < HEADER_SIZE {
158        return Err(CodecError::Truncated {
159            expected: HEADER_SIZE,
160            actual: data.len(),
161        });
162    }
163
164    let count = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
165    let _encode_exp = data[4];
166    let decode_exp = data[5];
167    let mode = match data[6] {
168        0 => DecodeMode::MultiplyInverse,
169        1 => DecodeMode::DivideByFactor,
170        m => {
171            return Err(CodecError::Corrupt {
172                detail: format!("invalid ALP decode mode {m}"),
173            });
174        }
175    };
176    let exception_count = u32::from_le_bytes([data[7], data[8], data[9], data[10]]) as usize;
177
178    if count == 0 {
179        return Ok(Vec::new());
180    }
181
182    if decode_exp > MAX_EXPONENT {
183        return Err(CodecError::Corrupt {
184            detail: format!("invalid ALP decode_exp {decode_exp}"),
185        });
186    }
187
188    // Read exceptions.
189    let exceptions_size = exception_count * 12;
190    let exceptions_end = HEADER_SIZE + exceptions_size;
191    if data.len() < exceptions_end {
192        return Err(CodecError::Truncated {
193            expected: exceptions_end,
194            actual: data.len(),
195        });
196    }
197
198    let mut exceptions = Vec::with_capacity(exception_count);
199    let mut pos = HEADER_SIZE;
200    for _ in 0..exception_count {
201        let idx =
202            u32::from_le_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
203        let bits = u64::from_le_bytes([
204            data[pos + 4],
205            data[pos + 5],
206            data[pos + 6],
207            data[pos + 7],
208            data[pos + 8],
209            data[pos + 9],
210            data[pos + 10],
211            data[pos + 11],
212        ]);
213        exceptions.push((idx, bits));
214        pos += 12;
215    }
216
217    // Decode FastLanes-packed integers.
218    let encoded_ints =
219        fastlanes::decode(&data[exceptions_end..]).map_err(|e| CodecError::Corrupt {
220            detail: format!("ALP fastlanes decode: {e}"),
221        })?;
222
223    if encoded_ints.len() != count {
224        return Err(CodecError::Corrupt {
225            detail: format!(
226                "ALP int count mismatch: expected {count}, got {}",
227                encoded_ints.len()
228            ),
229        });
230    }
231
232    // Reconstruct f64 values using the same decode mode that was selected during encode.
233    let mut values = Vec::with_capacity(count);
234    for &int_val in &encoded_ints {
235        values.push(alp_decode_value(int_val, decode_exp, mode));
236    }
237
238    // Patch exceptions.
239    for &(idx, bits) in &exceptions {
240        if idx < values.len() {
241            values[idx] = f64::from_bits(bits);
242        }
243    }
244
245    Ok(values)
246}
247
248/// Check what fraction of values can be ALP-encoded at the best exponent.
249///
250/// Returns a value between 0.0 and 1.0. Values > 0.95 mean ALP is a good
251/// fit. Used by auto-detection to choose between ALP and Gorilla.
252pub fn alp_encodability(values: &[f64]) -> f64 {
253    if values.is_empty() {
254        return 1.0;
255    }
256
257    let sample_end = values.len().min(CODEC_SAMPLE_SIZE);
258    let sample = &values[..sample_end];
259    let params = find_best_params(sample);
260    let factor = POW10[params.encode_exp as usize];
261
262    let encodable = sample
263        .iter()
264        .filter(|&&v| try_alp_encode(v, factor, params.decode_exp, params.mode).is_some())
265        .count();
266
267    encodable as f64 / sample.len() as f64
268}
269
270// ---------------------------------------------------------------------------
271// ALP core: exponent selection + encode/decode
272// ---------------------------------------------------------------------------
273
274/// Decode mode: whether to multiply by inverse or divide by factor.
275///
276/// IEEE 754 multiplication and division produce different bit patterns
277/// for the same logical value (e.g., `3 * 0.1 != 3 / 10.0` at the bit level).
278/// ALP tries both and picks whichever matches the original data.
279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280enum DecodeMode {
281    /// Decode via `encoded * INV_POW10[e]` (matches data computed as `x * 0.1`).
282    MultiplyInverse,
283    /// Decode via `encoded / POW10[e]` (matches data computed as `x / 10`).
284    DivideByFactor,
285}
286
287/// Try to ALP-encode a single f64 value.
288///
289/// Returns `Some(i64)` if the value round-trips exactly, `None` otherwise.
290#[inline]
291fn try_alp_encode(value: f64, factor: f64, decode_exp: u8, mode: DecodeMode) -> Option<i64> {
292    if !value.is_finite() {
293        return None;
294    }
295
296    let scaled = value * factor;
297    if scaled < i64::MIN as f64 || scaled > i64::MAX as f64 {
298        return None;
299    }
300
301    let encoded = scaled.round() as i64;
302
303    let decoded = match mode {
304        DecodeMode::MultiplyInverse => encoded as f64 * INV_POW10[decode_exp as usize],
305        DecodeMode::DivideByFactor => encoded as f64 / POW10[decode_exp as usize],
306    };
307
308    if decoded.to_bits() == value.to_bits() {
309        Some(encoded)
310    } else {
311        None
312    }
313}
314
315/// Reconstruct a single f64 from an ALP-encoded integer.
316#[inline]
317fn alp_decode_value(encoded: i64, decode_exp: u8, mode: DecodeMode) -> f64 {
318    match mode {
319        DecodeMode::MultiplyInverse => encoded as f64 * INV_POW10[decode_exp as usize],
320        DecodeMode::DivideByFactor => encoded as f64 / POW10[decode_exp as usize],
321    }
322}
323
324/// Find the best (exponent, factor_index) pair for a set of values.
325///
326/// Tries all exponents 0-18 and picks the one that encodes the most
327/// values losslessly. The factor_index may differ from exponent when
328/// the decode factor produces better roundtrips.
329/// ALP parameters determined by sampling.
330struct AlpParams {
331    encode_exp: u8,
332    decode_exp: u8,
333    mode: DecodeMode,
334}
335
336/// Find the best ALP parameters by trying all exponents and both decode modes.
337fn find_best_params(values: &[f64]) -> AlpParams {
338    let sample_end = values.len().min(CODEC_SAMPLE_SIZE);
339    let sample = &values[..sample_end];
340
341    let mut best = AlpParams {
342        encode_exp: 0,
343        decode_exp: 0,
344        mode: DecodeMode::MultiplyInverse,
345    };
346    let mut best_count: usize = 0;
347
348    for e in 0..=MAX_EXPONENT {
349        let factor = POW10[e as usize];
350
351        for mode in [DecodeMode::MultiplyInverse, DecodeMode::DivideByFactor] {
352            // Try same decode exponent.
353            let count = sample
354                .iter()
355                .filter(|&&v| try_alp_encode(v, factor, e, mode).is_some())
356                .count();
357
358            if count > best_count {
359                best_count = count;
360                best = AlpParams {
361                    encode_exp: e,
362                    decode_exp: e,
363                    mode,
364                };
365            }
366
367            // Try neighboring decode exponents.
368            if e > 0 {
369                let count_alt = sample
370                    .iter()
371                    .filter(|&&v| try_alp_encode(v, factor, e - 1, mode).is_some())
372                    .count();
373                if count_alt > best_count {
374                    best_count = count_alt;
375                    best = AlpParams {
376                        encode_exp: e,
377                        decode_exp: e - 1,
378                        mode,
379                    };
380                }
381            }
382            if e < MAX_EXPONENT {
383                let count_alt = sample
384                    .iter()
385                    .filter(|&&v| try_alp_encode(v, factor, e + 1, mode).is_some())
386                    .count();
387                if count_alt > best_count {
388                    best_count = count_alt;
389                    best = AlpParams {
390                        encode_exp: e,
391                        decode_exp: e + 1,
392                        mode,
393                    };
394                }
395            }
396        }
397    }
398
399    best
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn empty_roundtrip() {
408        let encoded = encode(&[]);
409        let decoded = decode(&encoded).unwrap();
410        assert!(decoded.is_empty());
411    }
412
413    #[test]
414    fn single_value() {
415        let values = vec![23.5f64];
416        let encoded = encode(&values);
417        let decoded = decode(&encoded).unwrap();
418        assert_eq!(decoded[0].to_bits(), values[0].to_bits());
419    }
420
421    #[test]
422    fn integer_values() {
423        // Integer values: exponent=0, factor=1.
424        let values: Vec<f64> = (0..1000).map(|i| i as f64).collect();
425        let encoded = encode(&values);
426        let decoded = decode(&encoded).unwrap();
427        for (a, b) in values.iter().zip(decoded.iter()) {
428            assert_eq!(a.to_bits(), b.to_bits(), "mismatch: {a} vs {b}");
429        }
430    }
431
432    #[test]
433    fn decimal_values_one_digit() {
434        // Values like 23.5, 99.1 — exponent=1, factor=10.
435        let values: Vec<f64> = (0..1000).map(|i| i as f64 * 0.1).collect();
436        let encoded = encode(&values);
437        let decoded = decode(&encoded).unwrap();
438        for (i, (a, b)) in values.iter().zip(decoded.iter()).enumerate() {
439            assert_eq!(a.to_bits(), b.to_bits(), "mismatch at {i}: {a} vs {b}");
440        }
441    }
442
443    #[test]
444    fn decimal_values_two_digits() {
445        // Values like 99.99 — exponent=2, factor=100.
446        let values: Vec<f64> = (0..1000).map(|i| i as f64 * 0.01).collect();
447        let encoded = encode(&values);
448        let decoded = decode(&encoded).unwrap();
449        for (i, (a, b)) in values.iter().zip(decoded.iter()).enumerate() {
450            assert_eq!(a.to_bits(), b.to_bits(), "mismatch at {i}: {a} vs {b}");
451        }
452    }
453
454    #[test]
455    fn typical_cpu_metrics() {
456        // CPU percentages: 0.0 to 100.0 with 0.1 resolution.
457        let mut values = Vec::with_capacity(10_000);
458        let mut rng: u64 = 42;
459        for _ in 0..10_000 {
460            rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
461            let cpu = ((rng >> 33) as f64 / (u32::MAX as f64)) * 100.0;
462            // Round to 0.1 to simulate typical metric resolution.
463            let cpu = (cpu * 10.0).round() / 10.0;
464            values.push(cpu);
465        }
466        let encoded = encode(&values);
467        let decoded = decode(&encoded).unwrap();
468        for (i, (a, b)) in values.iter().zip(decoded.iter()).enumerate() {
469            assert_eq!(a.to_bits(), b.to_bits(), "mismatch at {i}: {a} vs {b}");
470        }
471
472        // ALP should compress much better than raw.
473        let raw_size = values.len() * 8;
474        let ratio = raw_size as f64 / encoded.len() as f64;
475        assert!(
476            ratio > 3.0,
477            "ALP should compress CPU metrics >3x, got {ratio:.1}x"
478        );
479    }
480
481    #[test]
482    fn temperature_readings() {
483        // Temperatures: -40.0 to 50.0 with 0.01 resolution.
484        let mut values = Vec::with_capacity(10_000);
485        let mut rng: u64 = 99;
486        for _ in 0..10_000 {
487            rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
488            let temp = ((rng >> 33) as f64 / (u32::MAX as f64)) * 90.0 - 40.0;
489            let temp = (temp * 100.0).round() / 100.0;
490            values.push(temp);
491        }
492        let encoded = encode(&values);
493        let decoded = decode(&encoded).unwrap();
494        for (i, (a, b)) in values.iter().zip(decoded.iter()).enumerate() {
495            assert_eq!(a.to_bits(), b.to_bits(), "mismatch at {i}: {a} vs {b}");
496        }
497    }
498
499    #[test]
500    fn exception_handling() {
501        // Mix of ALP-encodable and non-encodable values.
502        let mut values = vec![23.5, 99.9, 100.1, 50.0];
503        values.push(f64::NAN);
504        values.push(f64::INFINITY);
505        values.push(f64::NEG_INFINITY);
506        values.push(std::f64::consts::PI); // Irrational — likely exception.
507
508        let encoded = encode(&values);
509        let decoded = decode(&encoded).unwrap();
510
511        for (i, (a, b)) in values.iter().zip(decoded.iter()).enumerate() {
512            assert_eq!(
513                a.to_bits(),
514                b.to_bits(),
515                "mismatch at {i}: {a} ({:016x}) vs {b} ({:016x})",
516                a.to_bits(),
517                b.to_bits()
518            );
519        }
520    }
521
522    #[test]
523    fn all_exceptions() {
524        // All values are irrational — all exceptions.
525        let values: Vec<f64> = (1..100).map(|i| std::f64::consts::PI * i as f64).collect();
526        let encoded = encode(&values);
527        let decoded = decode(&encoded).unwrap();
528        for (i, (a, b)) in values.iter().zip(decoded.iter()).enumerate() {
529            assert_eq!(a.to_bits(), b.to_bits(), "mismatch at {i}");
530        }
531    }
532
533    #[test]
534    fn encodability_check() {
535        // Decimal values should have high encodability.
536        let decimal_values: Vec<f64> = (0..1000).map(|i| i as f64 * 0.1).collect();
537        assert!(alp_encodability(&decimal_values) > 0.95);
538
539        // PI multiples: may still be encodable at high exponents, but the
540        // resulting integers will have wide bit-widths (poor compression).
541        // The key metric is that decimals have HIGHER encodability than irrationals.
542        let irrational_values: Vec<f64> =
543            (1..1000).map(|i| std::f64::consts::PI * i as f64).collect();
544        let irrational_enc = alp_encodability(&irrational_values);
545        // Decimal values should always be better than irrational ones.
546        assert!(
547            alp_encodability(&decimal_values) >= irrational_enc,
548            "decimal encodability should be >= irrational"
549        );
550    }
551
552    #[test]
553    fn better_than_gorilla_for_decimals() {
554        let values: Vec<f64> = (0..10_000).map(|i| i as f64 * 0.1).collect();
555        let alp_encoded = encode(&values);
556        let gorilla_encoded = crate::gorilla::encode_f64(&values);
557
558        assert!(
559            alp_encoded.len() < gorilla_encoded.len(),
560            "ALP ({} bytes) should beat Gorilla ({} bytes) on decimal data",
561            alp_encoded.len(),
562            gorilla_encoded.len()
563        );
564    }
565
566    #[test]
567    fn zero_values() {
568        let values = vec![0.0f64; 1000];
569        let encoded = encode(&values);
570        let decoded = decode(&encoded).unwrap();
571        for (a, b) in values.iter().zip(decoded.iter()) {
572            assert_eq!(a.to_bits(), b.to_bits());
573        }
574    }
575
576    #[test]
577    fn negative_zero() {
578        let values = vec![-0.0f64, 0.0, -0.0];
579        let encoded = encode(&values);
580        let decoded = decode(&encoded).unwrap();
581        for (i, (a, b)) in values.iter().zip(decoded.iter()).enumerate() {
582            assert_eq!(a.to_bits(), b.to_bits(), "mismatch at {i}");
583        }
584    }
585
586    #[test]
587    fn truncated_input_errors() {
588        assert!(decode(&[]).is_err());
589        assert!(decode(&[1, 0, 0, 0, 0, 0, 0, 0, 0]).is_err()); // too short
590    }
591
592    #[test]
593    fn large_dataset() {
594        let mut values = Vec::with_capacity(100_000);
595        let mut rng: u64 = 12345;
596        for _ in 0..100_000 {
597            rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
598            let val = ((rng >> 33) as f64 / (u32::MAX as f64)) * 1000.0;
599            let val = (val * 100.0).round() / 100.0; // 2 decimal places
600            values.push(val);
601        }
602        let encoded = encode(&values);
603        let decoded = decode(&encoded).unwrap();
604        assert_eq!(decoded.len(), values.len());
605        for (i, (a, b)) in values.iter().zip(decoded.iter()).enumerate() {
606            assert_eq!(a.to_bits(), b.to_bits(), "mismatch at {i}");
607        }
608    }
609}