1use limnifs_core::codec::zstd_dict::{
33 compress_with_dict, decompress_with_dict, train_dictionary as core_train,
34 train_dictionary_fastcover,
35};
36
37pub const DEFAULT_TARGET_SIZE: usize = 65_536;
39
40pub const DEFAULT_MIN_SAMPLES: usize = 100;
44
45#[derive(Clone, Debug)]
47pub struct TrainedDictionary {
48 pub id: u8,
51 pub codec: u8,
53 pub content: Vec<u8>,
55}
56
57impl TrainedDictionary {
58 pub fn compress(&self, plaintext: &[u8]) -> Result<Vec<u8>, crate::WriteError> {
63 compress_with_dict(plaintext, &self.content).map_err(|e| {
64 crate::WriteError::Io(std::io::Error::other(format!(
65 "dict compress (id {}): {e}",
66 self.id
67 )))
68 })
69 }
70
71 pub fn decompress(
76 &self,
77 compressed: &[u8],
78 expected_len: u32,
79 ) -> Result<Vec<u8>, crate::WriteError> {
80 decompress_with_dict(compressed, expected_len, &self.content).map_err(|e| {
81 crate::WriteError::Io(std::io::Error::other(format!(
82 "dict decompress (id {}): {e}",
83 self.id
84 )))
85 })
86 }
87}
88
89#[must_use]
94pub fn adopt_from_section(
95 section: limnifs_core::dictionary_section::DictionarySection,
96) -> Vec<TrainedDictionary> {
97 section
98 .dicts
99 .into_iter()
100 .filter(|d| d.codec_id == limnifs_core::codec::CODEC_ZSTD && matches!(d.class_id, 0 | 1))
101 .map(|d| TrainedDictionary {
102 id: d.class_id,
103 codec: d.codec_id,
104 content: d.data,
105 })
106 .collect()
107}
108
109#[must_use]
116pub fn train_zstd(id: u8, samples: &[&[u8]], target_size: usize) -> Option<TrainedDictionary> {
117 train_zstd_with_trainer(id, samples, target_size, TrainerKind::Frequency)
118}
119
120#[derive(Clone, Copy, Debug, Eq, PartialEq)]
122pub enum TrainerKind {
123 Frequency,
126 FastCover,
130}
131
132impl TrainerKind {
133 #[must_use]
136 pub fn from_config_str(s: &str) -> Self {
137 match s.to_ascii_lowercase().as_str() {
138 "fastcover" => Self::FastCover,
139 _ => Self::Frequency,
140 }
141 }
142}
143
144#[must_use]
147pub fn train_zstd_with_trainer(
148 id: u8,
149 samples: &[&[u8]],
150 target_size: usize,
151 trainer: TrainerKind,
152) -> Option<TrainedDictionary> {
153 if samples.is_empty() || target_size == 0 {
154 return None;
155 }
156 let content = match trainer {
157 TrainerKind::Frequency => core_train(samples, target_size),
158 TrainerKind::FastCover => train_dictionary_fastcover(samples, target_size),
159 };
160 if content.is_empty() {
161 return None;
162 }
163 Some(TrainedDictionary {
164 id,
165 codec: limnifs_core::codec::CODEC_ZSTD,
166 content,
167 })
168}
169
170pub fn allocate_ids<'a>(class_names: &'a [&'a str]) -> Result<Vec<(&'a str, u8)>, &'static str> {
176 if class_names.len() > 254 {
177 return Err("dictionary id space exhausted (max 254 classes)");
178 }
179 Ok(class_names
180 .iter()
181 .enumerate()
182 .map(|(i, name)| (*name, u8::try_from(i).expect("≤ 254")))
183 .collect())
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 fn synthetic_text_samples(n: usize) -> Vec<Vec<u8>> {
191 (0..n)
194 .map(|i| format!("function test_case_{i}() {{ return {i}; }}\n").into_bytes())
195 .collect()
196 }
197
198 #[test]
199 fn train_zstd_returns_dict_for_repetitive_samples() {
200 let samples_vec = synthetic_text_samples(50);
201 let samples: Vec<&[u8]> = samples_vec.iter().map(Vec::as_slice).collect();
202 let dict = train_zstd(0, &samples, 4096);
203 if let Some(d) = &dict {
206 assert!(!d.content.is_empty(), "trained dict content non-empty");
207 assert_eq!(d.id, 0);
208 assert_eq!(d.codec, limnifs_core::codec::CODEC_ZSTD);
209 }
210 }
211
212 #[test]
213 fn train_zstd_returns_none_for_empty_samples() {
214 assert!(train_zstd(0, &[], 4096).is_none());
215 }
216
217 #[test]
218 fn train_zstd_returns_none_for_zero_target_size() {
219 let samples_vec = synthetic_text_samples(10);
220 let samples: Vec<&[u8]> = samples_vec.iter().map(Vec::as_slice).collect();
221 assert!(train_zstd(0, &samples, 0).is_none());
222 }
223
224 #[test]
225 fn dict_round_trips_when_trained() {
226 let samples_vec = synthetic_text_samples(50);
227 let samples: Vec<&[u8]> = samples_vec.iter().map(Vec::as_slice).collect();
228 let Some(dict) = train_zstd(0, &samples, 4096) else {
229 return; };
231 let plaintext = b"function test_case_99() { return 99; }\n";
232 let compressed = dict.compress(plaintext).expect("compress");
233 let recovered = dict
234 .decompress(&compressed, plaintext.len() as u32)
235 .expect("decompress");
236 assert_eq!(recovered.as_slice(), &plaintext[..]);
237 }
238
239 #[test]
240 fn allocate_ids_assigns_sequential_ids() {
241 let names = vec!["text", "binary", "source"];
242 let allocated = allocate_ids(&names).expect("allocate");
243 assert_eq!(allocated.len(), 3);
244 assert_eq!(allocated[0], ("text", 0));
245 assert_eq!(allocated[1], ("binary", 1));
246 assert_eq!(allocated[2], ("source", 2));
247 }
248
249 #[test]
250 fn allocate_ids_rejects_more_than_254_classes() {
251 let names: Vec<&str> = (0..255).map(|_| "x").collect();
252 assert!(allocate_ids(&names).is_err());
253 }
254 #[test]
255 fn adopt_from_section_maps_ids_and_filters_codecs() {
256 let section = limnifs_core::dictionary_section::DictionarySection {
257 version: limnifs_core::dictionary_section::DICTIONARY_SECTION_VERSION,
258 dicts: vec![
259 limnifs_core::dictionary_section::Dictionary {
260 codec_id: limnifs_core::codec::CODEC_ZSTD,
261 class_id: 0,
262 data: b"text-dict".to_vec(),
263 },
264 limnifs_core::dictionary_section::Dictionary {
265 codec_id: limnifs_core::codec::CODEC_ZSTD,
266 class_id: 1,
267 data: b"binary-dict".to_vec(),
268 },
269 limnifs_core::dictionary_section::Dictionary {
270 codec_id: limnifs_core::codec::CODEC_LZ4,
271 class_id: 0,
272 data: b"wrong-codec".to_vec(),
273 },
274 limnifs_core::dictionary_section::Dictionary {
275 codec_id: limnifs_core::codec::CODEC_ZSTD,
276 class_id: 7,
277 data: b"unknown-class".to_vec(),
278 },
279 ],
280 };
281 let adopted = adopt_from_section(section);
282 assert_eq!(
283 adopted.len(),
284 2,
285 "non-zstd and unknown-class entries dropped"
286 );
287 assert_eq!(adopted[0].id, 0);
288 assert_eq!(adopted[0].content, b"text-dict");
289 assert_eq!(adopted[1].id, 1);
290 assert_eq!(adopted[1].content, b"binary-dict");
291 }
292}