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}