Skip to main content

chronix_encoding/
dictionary.rs

1//! Dictionary encoding for string columns (tags and low-cardinality strings).
2//!
3//! Maps unique string values to integer indices and encodes the indices with
4//! minimal bit-packing. The dictionary is stored inline in the encoded block.
5//!
6//! ## Format
7//!
8//! ```text
9//! [value_count: u32]
10//! [dict_size: u32]
11//! [dict entry 0: len u16, bytes...]
12//! [dict entry 1: len u16, bytes...]
13//! ...
14//! [bit_width: u8]
15//! [packed indices at bit_width bits each]
16//! ```
17
18use std::collections::HashMap;
19
20use crate::coding::{
21    bits_needed, checked_count, checked_decode_count, checked_string_len, pack_bits, unpack_bits,
22};
23use crate::error::{EncodingError, Result};
24
25/// Maximum number of dictionary entries (u32::MAX).
26const MAX_DICT_SIZE: usize = u32::MAX as usize;
27
28/// Dictionary-based string encoder.
29#[derive(Debug, Clone, Copy)]
30pub struct DictionaryEncoder;
31
32/// Dictionary-based string decoder.
33#[derive(Debug, Clone, Copy)]
34pub struct DictionaryDecoder;
35
36/// Threshold below which a linear scan over a Vec is faster than
37/// HashMap lookup due to cache locality.
38const LINEAR_SCAN_THRESHOLD: usize = 16;
39
40impl DictionaryEncoder {
41    /// Encode a sequence of string values.
42    ///
43    /// # Errors
44    ///
45    /// Returns [`EncodingError::EmptyInput`] if `values` is empty, or
46    /// [`EncodingError::DictionaryOverflow`] if there are more than 2^32−1
47    /// unique values.
48    pub fn encode(values: &[&str]) -> Result<Vec<u8>> {
49        if values.is_empty() {
50            return Err(EncodingError::EmptyInput {
51                context: "dictionary encoder",
52            });
53        }
54
55        // Build dictionary: unique value → index.
56        // For low-cardinality columns (< LINEAR_SCAN_THRESHOLD unique values),
57        // use Vec linear scan which is faster due to cache locality.
58        let mut dict: Vec<&str> = Vec::new();
59        let mut index_map: Option<HashMap<&str, usize>> = None;
60        let mut indices = Vec::with_capacity(values.len());
61
62        for &val in values {
63            let idx = if let Some(ref map) = index_map {
64                // HashMap path for high cardinality
65                if let Some(&idx) = map.get(val) {
66                    idx
67                } else {
68                    let idx = dict.len();
69                    if idx >= MAX_DICT_SIZE {
70                        return Err(EncodingError::DictionaryOverflow {
71                            count: idx + 1,
72                            limit: MAX_DICT_SIZE,
73                        });
74                    }
75                    dict.push(val);
76                    index_map
77                        .as_mut()
78                        .expect("inside Some arm")
79                        .insert(val, idx);
80                    idx
81                }
82            } else {
83                // Vec linear scan path for low cardinality
84                if let Some(pos) = dict.iter().position(|&v| v == val) {
85                    pos
86                } else {
87                    let idx = dict.len();
88                    if idx >= MAX_DICT_SIZE {
89                        return Err(EncodingError::DictionaryOverflow {
90                            count: idx + 1,
91                            limit: MAX_DICT_SIZE,
92                        });
93                    }
94                    dict.push(val);
95                    // Promote to HashMap once we exceed the linear scan threshold
96                    if dict.len() >= LINEAR_SCAN_THRESHOLD {
97                        let map: HashMap<&str, usize> =
98                            dict.iter().enumerate().map(|(i, &v)| (v, i)).collect();
99                        index_map = Some(map);
100                    }
101                    idx
102                }
103            };
104            indices.push(idx as u64);
105        }
106
107        // Bit width for indices
108        let max_idx = dict.len().saturating_sub(1) as u64;
109        let bit_width = if max_idx == 0 {
110            0_u8 // single unique value
111        } else {
112            bits_needed(max_idx)
113        };
114
115        // Estimate buffer size
116        let dict_bytes: usize = dict.iter().map(|s| 2 + s.len()).sum();
117        let idx_bytes = (values.len() as u64 * u64::from(bit_width)).div_ceil(8) as usize;
118        let mut buf = Vec::with_capacity(4 + 4 + dict_bytes + 1 + idx_bytes);
119
120        // Value count
121        let count = checked_count(values.len())?;
122        buf.extend_from_slice(&count.to_le_bytes());
123
124        // Dictionary size (u32)
125        buf.extend_from_slice(&(dict.len() as u32).to_le_bytes());
126
127        // Dictionary entries — validate string lengths fit in u16
128        for &s in &dict {
129            let len = checked_string_len(s.len())?;
130            buf.extend_from_slice(&len.to_le_bytes());
131            buf.extend_from_slice(s.as_bytes());
132        }
133
134        // Bit width
135        buf.push(bit_width);
136
137        // Packed indices
138        if bit_width > 0 {
139            pack_bits(&indices, bit_width, &mut buf);
140        }
141
142        Ok(buf)
143    }
144}
145
146impl DictionaryDecoder {
147    /// Decode a dictionary-encoded byte buffer back to owned strings.
148    ///
149    /// # Errors
150    ///
151    /// Returns [`EncodingError::CorruptData`] if the data is malformed.
152    pub fn decode(data: &[u8]) -> Result<Vec<String>> {
153        if data.len() < 8 {
154            return Err(EncodingError::CorruptData {
155                detail: "dictionary data too short for header".to_string(),
156            });
157        }
158
159        let mut pos = 0;
160
161        // Value count
162        let count = checked_decode_count(
163            u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize,
164            "dictionary",
165        )?;
166        pos += 4;
167
168        // Dictionary size (u32)
169        let dict_size = checked_decode_count(
170            u32::from_le_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize,
171            "dictionary",
172        )?;
173        pos += 4;
174
175        // Read dictionary entries
176        let mut dict = Vec::with_capacity(dict_size);
177        for _ in 0..dict_size {
178            if pos + 2 > data.len() {
179                return Err(EncodingError::CorruptData {
180                    detail: "dictionary entry truncated".to_string(),
181                });
182            }
183            let len = u16::from_le_bytes([data[pos], data[pos + 1]]) as usize;
184            pos += 2;
185            if pos + len > data.len() {
186                return Err(EncodingError::CorruptData {
187                    detail: "dictionary string truncated".to_string(),
188                });
189            }
190            let s = std::str::from_utf8(&data[pos..pos + len]).map_err(|_| {
191                EncodingError::CorruptData {
192                    detail: "invalid UTF-8 in dictionary".to_string(),
193                }
194            })?;
195            dict.push(s.to_string());
196            pos += len;
197        }
198
199        if pos >= data.len() {
200            return Err(EncodingError::CorruptData {
201                detail: "missing bit_width byte".to_string(),
202            });
203        }
204
205        let bit_width = data[pos];
206        pos += 1;
207
208        // Unpack indices
209        let mut result = Vec::with_capacity(count);
210        if bit_width == 0 {
211            // All values map to dict[0]
212            let val = dict
213                .first()
214                .ok_or_else(|| EncodingError::CorruptData {
215                    detail: "empty dictionary with zero bit_width".to_string(),
216                })?
217                .clone();
218            for _ in 0..count {
219                result.push(val.clone());
220            }
221        } else {
222            let packed_data = &data[pos..];
223            let indices = unpack_bits(packed_data, count, bit_width)?;
224            for idx in indices {
225                let idx = idx as usize;
226                if idx >= dict.len() {
227                    return Err(EncodingError::CorruptData {
228                        detail: format!("index {idx} exceeds dictionary size {}", dict.len()),
229                    });
230                }
231                result.push(dict[idx].clone());
232            }
233        }
234
235        Ok(result)
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn roundtrip_low_cardinality() {
245        let values = vec!["host1", "host2", "host1", "host3", "host2", "host1"];
246        let encoded = DictionaryEncoder::encode(&values).unwrap();
247        let decoded = DictionaryDecoder::decode(&encoded).unwrap();
248        let expected: Vec<String> = values.into_iter().map(String::from).collect();
249        assert_eq!(expected, decoded);
250    }
251
252    #[test]
253    fn roundtrip_single_repeated() {
254        let values = vec!["constant"; 100];
255        let encoded = DictionaryEncoder::encode(&values).unwrap();
256        let decoded = DictionaryDecoder::decode(&encoded).unwrap();
257        assert!(decoded.iter().all(|s| s == "constant"));
258        // Single unique value → bit_width=0 → very compact
259    }
260
261    #[test]
262    fn roundtrip_all_unique() {
263        let strs: Vec<String> = (0..100).map(|i| format!("val_{i}")).collect();
264        let values: Vec<&str> = strs.iter().map(String::as_str).collect();
265        let encoded = DictionaryEncoder::encode(&values).unwrap();
266        let decoded = DictionaryDecoder::decode(&encoded).unwrap();
267        assert_eq!(strs, decoded);
268    }
269
270    #[test]
271    fn roundtrip_unicode() {
272        let values = vec!["hello", "世界", "🦀", "café", "naïve"];
273        let encoded = DictionaryEncoder::encode(&values).unwrap();
274        let decoded = DictionaryDecoder::decode(&encoded).unwrap();
275        let expected: Vec<String> = values.into_iter().map(String::from).collect();
276        assert_eq!(expected, decoded);
277    }
278
279    #[test]
280    fn roundtrip_empty_strings() {
281        let values = vec!["", "a", "", "b", ""];
282        let encoded = DictionaryEncoder::encode(&values).unwrap();
283        let decoded = DictionaryDecoder::decode(&encoded).unwrap();
284        let expected: Vec<String> = values.into_iter().map(String::from).collect();
285        assert_eq!(expected, decoded);
286    }
287
288    #[test]
289    fn roundtrip_single_value() {
290        let values = vec!["only"];
291        let encoded = DictionaryEncoder::encode(&values).unwrap();
292        let decoded = DictionaryDecoder::decode(&encoded).unwrap();
293        assert_eq!(vec!["only".to_string()], decoded);
294    }
295
296    #[test]
297    fn empty_error() {
298        let values: Vec<&str> = vec![];
299        assert!(DictionaryEncoder::encode(&values).is_err());
300    }
301
302    #[test]
303    fn compression_ratio_tags() {
304        // Simulate 10K tag values from 5 hosts
305        let hosts = ["host1", "host2", "host3", "host4", "host5"];
306        let values: Vec<&str> = (0..10_000).map(|i| hosts[i % 5]).collect();
307        let raw_size: usize = values.iter().map(|s| 8 + s.len()).sum(); // ptr + len + data
308        let encoded = DictionaryEncoder::encode(&values).unwrap();
309        #[allow(clippy::cast_precision_loss)]
310        let ratio = raw_size as f64 / encoded.len() as f64;
311        assert!(
312            ratio > 3.0,
313            "Expected ratio > 3x on tag data, got {ratio:.1}x"
314        );
315    }
316}
317
318#[cfg(test)]
319mod proptests {
320    use super::*;
321    use proptest::prelude::*;
322
323    proptest! {
324        #[test]
325        fn roundtrip_string(values in proptest::collection::vec("[a-z]{0,8}", 1..200)) {
326            let refs: Vec<&str> = values.iter().map(String::as_str).collect();
327            let encoded = DictionaryEncoder::encode(&refs).unwrap();
328            let decoded = DictionaryDecoder::decode(&encoded).unwrap();
329            prop_assert_eq!(&values, &decoded);
330        }
331    }
332}