chronix_encoding/
dictionary.rs1use 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
25const MAX_DICT_SIZE: usize = u32::MAX as usize;
27
28#[derive(Debug, Clone, Copy)]
30pub struct DictionaryEncoder;
31
32#[derive(Debug, Clone, Copy)]
34pub struct DictionaryDecoder;
35
36const LINEAR_SCAN_THRESHOLD: usize = 16;
39
40impl DictionaryEncoder {
41 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 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 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 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 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 let max_idx = dict.len().saturating_sub(1) as u64;
109 let bit_width = if max_idx == 0 {
110 0_u8 } else {
112 bits_needed(max_idx)
113 };
114
115 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 let count = checked_count(values.len())?;
122 buf.extend_from_slice(&count.to_le_bytes());
123
124 buf.extend_from_slice(&(dict.len() as u32).to_le_bytes());
126
127 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 buf.push(bit_width);
136
137 if bit_width > 0 {
139 pack_bits(&indices, bit_width, &mut buf);
140 }
141
142 Ok(buf)
143 }
144}
145
146impl DictionaryDecoder {
147 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 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 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 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 let mut result = Vec::with_capacity(count);
210 if bit_width == 0 {
211 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 }
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 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(); 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}