Skip to main content

pamoja_codec/
delta.rs

1//! Compact batch encoding for metered links: delta plus variable-length integers.
2//!
3//! On a long-range radio or a metered cellular link, every byte costs power or money,
4//! so it pays to send a batch of readings in as few bytes as possible rather than one
5//! full-width value at a time. The functions here encode a sequence of integers as a
6//! starting value followed by the differences between consecutive values, each
7//! written as a variable-length integer. A slowly changing signal - a temperature, a
8//! tank level, a battery voltage - then costs about one byte per sample instead of
9//! eight, with no loss. [`Quantizer`] extends this to `f32` readings by rounding each
10//! to a fixed precision first.
11
12use pamoja_core::{Error, Result};
13
14// Writes an unsigned integer as LEB128: seven bits per byte, high bit as a continue
15// flag.
16fn write_uvarint(mut value: u64, out: &mut Vec<u8>) {
17    loop {
18        let mut byte = (value & 0x7f) as u8;
19        value >>= 7;
20        if value != 0 {
21            byte |= 0x80;
22        }
23        out.push(byte);
24        if value == 0 {
25            break;
26        }
27    }
28}
29
30// Reads a LEB128 unsigned integer, advancing `pos`.
31fn read_uvarint(bytes: &[u8], pos: &mut usize) -> Result<u64> {
32    let mut result = 0u64;
33    let mut shift = 0u32;
34    loop {
35        let byte = *bytes
36            .get(*pos)
37            .ok_or_else(|| Error::Codec("truncated varint".into()))?;
38        *pos += 1;
39        result |= u64::from(byte & 0x7f) << shift;
40        if byte & 0x80 == 0 {
41            break;
42        }
43        shift += 7;
44        if shift >= 64 {
45            return Err(Error::Codec("varint is too long".into()));
46        }
47    }
48    Ok(result)
49}
50
51// Maps a signed integer to an unsigned one whose size grows with magnitude, so small
52// negative deltas stay small (protobuf zigzag).
53fn zigzag(value: i64) -> u64 {
54    ((value << 1) ^ (value >> 63)) as u64
55}
56
57fn unzigzag(value: u64) -> i64 {
58    ((value >> 1) as i64) ^ -((value & 1) as i64)
59}
60
61/// Encodes a batch of integer samples as a starting value plus variable-length deltas.
62///
63/// # Arguments
64///
65/// * `samples` - the integers to encode, in order.
66///
67/// # Returns
68///
69/// The compact encoding. A slowly changing series is far smaller than the eight bytes
70/// per sample a raw encoding would use.
71///
72/// # Examples
73///
74/// ```
75/// use pamoja_codec::{decode_deltas, encode_deltas};
76///
77/// let samples = [1000, 1001, 1003, 1002];
78/// let bytes = encode_deltas(&samples);
79/// assert!(bytes.len() < samples.len() * 8); // far smaller than eight bytes each
80/// assert_eq!(decode_deltas(&bytes).unwrap(), samples);
81/// ```
82pub fn encode_deltas(samples: &[i64]) -> Vec<u8> {
83    let mut out = Vec::new();
84    write_uvarint(samples.len() as u64, &mut out);
85    let mut previous = 0i64;
86    for &sample in samples {
87        let delta = sample.wrapping_sub(previous);
88        write_uvarint(zigzag(delta), &mut out);
89        previous = sample;
90    }
91    out
92}
93
94/// Decodes a batch encoded by [`encode_deltas`].
95///
96/// # Arguments
97///
98/// * `bytes` - the encoded batch.
99///
100/// # Returns
101///
102/// The decoded samples, in order.
103///
104/// # Errors
105///
106/// Returns [`Error::Codec`](pamoja_core::Error::Codec) if `bytes` ends in the middle
107/// of a value or encodes an over-long integer.
108pub fn decode_deltas(bytes: &[u8]) -> Result<Vec<i64>> {
109    let mut pos = 0;
110    let count = read_uvarint(bytes, &mut pos)?;
111    // The count is untrusted, so the vector grows with the data actually present
112    // rather than pre-allocating from a claimed length.
113    let mut samples = Vec::new();
114    let mut previous = 0i64;
115    for _ in 0..count {
116        let delta = unzigzag(read_uvarint(bytes, &mut pos)?);
117        let sample = previous.wrapping_add(delta);
118        samples.push(sample);
119        previous = sample;
120    }
121    Ok(samples)
122}
123
124/// Packs a batch of `f32` readings into a compact byte form for a metered link.
125///
126/// A quantizer rounds each reading to a fixed precision - set by the `scale`, where
127/// `100.0` keeps two decimal places - turns it into an integer, and delta-encodes the
128/// batch with [`encode_deltas`]. This is lossy by exactly the rounding step, which is
129/// the right trade for a cheap sensor on an expensive link: a fridge temperature to
130/// the nearest hundredth of a degree costs a byte or two per sample instead of four.
131/// The same `scale` must be used to encode and decode.
132///
133/// # Examples
134///
135/// ```
136/// use pamoja_codec::Quantizer;
137///
138/// // Quantize to 0.1 precision and pack a slowly-rising series.
139/// let quantizer = Quantizer::new(10.0);
140/// let readings = [20.0, 20.1, 20.2, 20.3];
141/// let packed = quantizer.encode(&readings);
142/// assert!(packed.len() < readings.len() * 4); // smaller than four bytes per reading
143///
144/// let restored = quantizer.decode(&packed).unwrap();
145/// assert!((restored[2] - 20.2).abs() < 0.05);
146/// ```
147#[derive(Clone, Copy, Debug)]
148pub struct Quantizer {
149    scale: f32,
150}
151
152impl Quantizer {
153    /// Creates a quantizer with the given precision scale.
154    ///
155    /// # Arguments
156    ///
157    /// * `scale` - the multiplier applied before rounding; `100.0` keeps two decimal
158    ///   places. Must be positive.
159    ///
160    /// # Returns
161    ///
162    /// The quantizer.
163    pub fn new(scale: f32) -> Self {
164        Self { scale }
165    }
166
167    /// Quantizes and delta-encodes a batch of readings.
168    ///
169    /// # Arguments
170    ///
171    /// * `readings` - the readings to pack, in order.
172    ///
173    /// # Returns
174    ///
175    /// The compact encoding of the batch.
176    pub fn encode(&self, readings: &[f32]) -> Vec<u8> {
177        let samples: Vec<i64> = readings
178            .iter()
179            .map(|&reading| (reading * self.scale).round() as i64)
180            .collect();
181        encode_deltas(&samples)
182    }
183
184    /// Decodes a batch back into readings, to within the quantizer's precision.
185    ///
186    /// # Arguments
187    ///
188    /// * `bytes` - the encoding produced by [`encode`](Quantizer::encode) with the
189    ///   same scale.
190    ///
191    /// # Returns
192    ///
193    /// The decoded readings, in order.
194    ///
195    /// # Errors
196    ///
197    /// Returns [`Error::Codec`](pamoja_core::Error::Codec) if `bytes` is malformed.
198    pub fn decode(&self, bytes: &[u8]) -> Result<Vec<f32>> {
199        let samples = decode_deltas(bytes)?;
200        Ok(samples
201            .iter()
202            .map(|&sample| sample as f32 / self.scale)
203            .collect())
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn an_integer_batch_round_trips() {
213        for samples in [
214            vec![],
215            vec![0],
216            vec![42],
217            vec![-5, -4, -3, -2],
218            vec![1000, 1001, 1003, 1002, 999],
219            vec![i64::MIN, 0, i64::MAX],
220        ] {
221            let bytes = encode_deltas(&samples);
222            assert_eq!(decode_deltas(&bytes).expect("decode"), samples);
223        }
224    }
225
226    #[test]
227    fn a_slow_series_is_far_smaller_than_raw() {
228        let samples: Vec<i64> = (0..100).map(|i| 5000 + i).collect();
229        let bytes = encode_deltas(&samples);
230        // Each delta is one, so a sample costs about a byte instead of eight.
231        assert!(bytes.len() < samples.len() * 2);
232    }
233
234    #[test]
235    fn truncated_bytes_are_a_codec_error() {
236        // Claims three samples but supplies none.
237        let result = decode_deltas(&[3]);
238        assert!(matches!(result, Err(Error::Codec(_))));
239    }
240
241    #[test]
242    fn varints_cover_the_boundaries() {
243        for value in [0u64, 1, 127, 128, 16_383, 16_384, u64::MAX] {
244            let mut out = Vec::new();
245            write_uvarint(value, &mut out);
246            let mut pos = 0;
247            assert_eq!(read_uvarint(&out, &mut pos).expect("read"), value);
248            assert_eq!(pos, out.len());
249        }
250    }
251
252    #[test]
253    fn varints_match_the_canonical_leb128_encodings() {
254        let cases: [(u64, &[u8]); 4] = [
255            (0, &[0x00]),
256            (1, &[0x01]),
257            (128, &[0x80, 0x01]),
258            (300, &[0xAC, 0x02]),
259        ];
260        for (value, encoded) in cases {
261            let mut out = Vec::new();
262            write_uvarint(value, &mut out);
263            assert_eq!(out, encoded, "varint of {value}");
264        }
265    }
266
267    #[test]
268    fn zigzag_matches_the_protobuf_mapping() {
269        let cases: [(i64, u64); 6] = [
270            (0, 0),
271            (-1, 1),
272            (1, 2),
273            (-2, 3),
274            (2, 4),
275            (2_147_483_647, 4_294_967_294),
276        ];
277        for (signed, unsigned) in cases {
278            assert_eq!(zigzag(signed), unsigned, "zigzag of {signed}");
279            assert_eq!(unzigzag(unsigned), signed, "unzigzag of {unsigned}");
280        }
281    }
282
283    #[test]
284    fn a_quantizer_round_trips_within_its_precision() {
285        let quantizer = Quantizer::new(100.0);
286        let readings = [4.0, 4.62, 5.13, 4.77, 3.98];
287        let packed = quantizer.encode(&readings);
288        let restored = quantizer.decode(&packed).expect("decode");
289        for (original, decoded) in readings.iter().zip(&restored) {
290            assert!((original - decoded).abs() <= 0.005 + f32::EPSILON);
291        }
292    }
293}