Skip to main content

lz4rip_encode/
dict.rs

1//! COVER dictionary trainer.
2#![forbid(unsafe_code)]
3
4use alloc::collections::VecDeque;
5use alloc::vec;
6use alloc::vec::Vec;
7
8use lz4rip_core::MAX_DISTANCE;
9use lz4rip_core::MINMATCH;
10
11/// Trains an LZ4 dictionary from sample messages using the COVER
12/// algorithm.
13///
14/// # Example
15/// ```
16/// use lz4rip_encode::DictTrainer;
17///
18/// let mut trainer = DictTrainer::new(2048);
19/// for msg in &[b"hello world" as &[u8], b"hello rust", b"hello lz4"] {
20///     trainer.add_sample(msg);
21/// }
22/// let dict = trainer.train();
23/// let compressor = lz4rip_encode::Compressor::with_dict(&dict);
24/// ```
25pub struct DictTrainer {
26    max_dict_size: usize,
27    samples: VecDeque<Vec<u8>>,
28    total_bytes: usize,
29}
30
31impl DictTrainer {
32    /// Create a trainer targeting `max_dict_size` bytes of output.
33    ///
34    /// Typical values: 2048 for small messages, 4096 for larger ones.
35    /// The dict is capped at 65535 bytes (LZ4 max match distance).
36    pub fn new(max_dict_size: usize) -> Self {
37        let max_dict_size = max_dict_size.min(MAX_DISTANCE);
38        DictTrainer {
39            max_dict_size,
40            samples: VecDeque::new(),
41            total_bytes: 0,
42        }
43    }
44
45    /// Add a training sample.
46    ///
47    /// Samples shorter than 4 bytes or longer than `max_dict_size` are
48    /// silently skipped.
49    pub fn add_sample(&mut self, data: &[u8]) {
50        if data.len() < MINMATCH || data.len() > self.max_dict_size {
51            return;
52        }
53
54        let budget = self.max_dict_size * 8;
55
56        while self.total_bytes + data.len() > budget && !self.samples.is_empty() {
57            self.total_bytes -= self.samples.pop_front().unwrap().len();
58        }
59
60        self.total_bytes += data.len();
61        self.samples.push_back(data.to_vec());
62    }
63
64    /// Number of samples added so far.
65    pub fn sample_count(&self) -> usize {
66        self.samples.len()
67    }
68
69    /// Total bytes of sample data added so far.
70    pub fn total_bytes(&self) -> usize {
71        self.total_bytes
72    }
73
74    /// Train a dictionary from the collected samples. Consumes the
75    /// trainer, freeing all sample data.
76    ///
77    /// Returns a raw byte buffer. If fewer than 2 samples were added,
78    /// returns an empty vec.
79    pub fn train(self) -> Vec<u8> {
80        if self.samples.len() < 2 {
81            return Vec::new();
82        }
83        let sample_refs: Vec<&[u8]> = self.samples.iter().map(|s| s.as_slice()).collect();
84        cover_select(&sample_refs, self.max_dict_size)
85    }
86}
87
88const D: usize = 8;
89const FREQ_BITS: usize = 16;
90const FREQ_SIZE: usize = 1 << FREQ_BITS;
91const FREQ_MASK: usize = FREQ_SIZE - 1;
92
93#[inline(always)]
94fn hash_dmer(data: &[u8]) -> usize {
95    let v = u64::from_le_bytes(data[..8].try_into().unwrap());
96    v.wrapping_mul(0x9E3779B97F4A7C15) as usize
97}
98
99fn cover_select(samples: &[&[u8]], dict_size: usize) -> Vec<u8> {
100    let mut concat = Vec::new();
101    let mut offsets = Vec::new();
102    for &sample in samples {
103        offsets.push(concat.len());
104        concat.extend_from_slice(sample);
105    }
106    offsets.push(concat.len());
107
108    if concat.len() < D {
109        return concat[..dict_size.min(concat.len())].to_vec();
110    }
111
112    let num_dmers = concat.len() - D + 1;
113
114    let mut hashes = vec![0u32; num_dmers];
115    for i in 0..num_dmers {
116        hashes[i] = (hash_dmer(&concat[i..i + D]) & FREQ_MASK) as u32;
117    }
118
119    let mut freqs = vec![0u32; FREQ_SIZE];
120    for s in 0..samples.len() {
121        let start = offsets[s];
122        let end = offsets[s + 1];
123        if end - start < D {
124            continue;
125        }
126        for i in start..end - D + 1 {
127            freqs[hashes[i] as usize] += 1;
128        }
129    }
130
131    let k = dict_size / 4;
132    if concat.len() < k {
133        return concat;
134    }
135
136    let seg_dmers = k - D + 1;
137    let mut used = vec![false; concat.len()];
138    let mut segments: Vec<(usize, u64)> = Vec::new();
139    let mut collected = 0usize;
140
141    while collected < dict_size {
142        let mut prefix = vec![0u64; num_dmers + 1];
143        prefix[0] = 0;
144        for i in 0..num_dmers {
145            prefix[i + 1] = prefix[i] + freqs[hashes[i] as usize] as u64;
146        }
147
148        let mut best_pos = 0;
149        let mut best_score = 0u64;
150        for pos in 0..=concat.len() - k {
151            if !used[pos] {
152                let score = prefix[pos + seg_dmers] - prefix[pos];
153                if score > best_score {
154                    best_score = score;
155                    best_pos = pos;
156                }
157            }
158        }
159
160        if best_score == 0 {
161            break;
162        }
163
164        segments.push((best_pos, best_score));
165
166        for i in best_pos..best_pos + seg_dmers {
167            freqs[hashes[i] as usize] = 0;
168        }
169        used[best_pos..best_pos + k].fill(true);
170
171        collected += k;
172    }
173
174    let mut dict = Vec::with_capacity(dict_size);
175    for &(pos, _) in segments.iter().rev() {
176        let end = (pos + k).min(concat.len());
177        dict.extend_from_slice(&concat[pos..end]);
178        if dict.len() >= dict_size {
179            break;
180        }
181    }
182    dict.truncate(dict_size);
183    dict
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    fn json_msg(i: u32) -> Vec<u8> {
191        alloc::format!(
192            r#"{{"ts":"2026-04-27T12:00:00.{i:04}Z","level":"INFO","service":"api-gw","trace":"{i:08x}","method":"GET","path":"/v1/users/{i:04}","status":200,"latency_ms":{lat},"region":"us-east-1"}}"#,
193            i = i,
194            lat = 10 + i % 490,
195        )
196        .into_bytes()
197    }
198
199    #[test]
200    fn train_produces_nonempty_dict() {
201        let mut trainer = DictTrainer::new(2048);
202        for i in 0..100 {
203            trainer.add_sample(&json_msg(i));
204        }
205        let dict = trainer.train();
206        assert!(!dict.is_empty(), "dict should not be empty");
207        assert!(dict.len() <= 2048, "dict should respect max size");
208    }
209
210    #[test]
211    fn skips_too_short() {
212        let mut trainer = DictTrainer::new(2048);
213        trainer.add_sample(b"hi");
214        assert_eq!(trainer.sample_count(), 0);
215    }
216
217    #[test]
218    fn skips_too_long() {
219        let mut trainer = DictTrainer::new(64);
220        trainer.add_sample(&[0u8; 100]);
221        assert_eq!(trainer.sample_count(), 0);
222    }
223
224    #[test]
225    fn evicts_old_samples() {
226        let mut trainer = DictTrainer::new(2048);
227        for i in 0..200 {
228            trainer.add_sample(&json_msg(i));
229        }
230        assert!(trainer.total_bytes() <= 2048 * 8);
231        assert!(trainer.sample_count() < 200);
232    }
233
234    #[test]
235    fn too_few_samples_returns_empty() {
236        let mut trainer = DictTrainer::new(2048);
237        trainer.add_sample(b"hello world");
238        assert!(trainer.train().is_empty());
239    }
240}