Skip to main content

oxirs_tdb/compression/
zstd_codec.rs

1//! Advanced ZStd codec with dictionary-based compression for large RDF graphs
2//!
3//! This module provides a higher-level ZStd codec that extends the basic ZstdCompressor
4//! with pre-trained dictionary support and dictionary training from RDF data samples.
5//! Dictionary-based compression is especially effective for small RDF triples that
6//! share common URI prefixes and patterns.
7//!
8//! # Performance Characteristics
9//!
10//! With a pre-trained dictionary:
11//! - 2-5x better compression ratio on small blocks (<16KB)
12//! - Near-identical speed to basic Zstd
13//! - Optimal for RDF triple blocks sharing URI namespace patterns
14//!
15//! # Example
16//!
17//! ```rust,no_run
18//! use oxirs_tdb::compression::zstd_codec::ZstdCodec;
19//!
20//! // Basic usage without dictionary
21//! let codec = ZstdCodec::new(3);
22//! let data = b"<http://example.org/s> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://example.org/Class> .";
23//! let compressed = codec.compress(data).unwrap();
24//! let decompressed = codec.decompress(&compressed).unwrap();
25//! assert_eq!(decompressed, data);
26//!
27//! // Train a dictionary from RDF samples
28//! let samples = vec![data.to_vec(); 100];
29//! let dict = ZstdCodec::train_dict(&samples, 1024).unwrap();
30//! let codec_with_dict = ZstdCodec::with_dict(3, dict);
31//! ```
32
33use crate::error::{Result, TdbError};
34use std::io::{Read, Write};
35use std::time::Instant;
36
37/// Minimum data size (bytes) that benefits from dictionary training samples
38const MIN_SAMPLE_SIZE: usize = 8;
39
40/// Maximum total sample data size for dictionary training (1GB safety limit)
41const MAX_TOTAL_SAMPLE_SIZE: usize = 1024 * 1024 * 1024;
42
43/// Zstandard codec with optional pre-trained dictionary support.
44///
45/// `ZstdCodec` provides both basic Zstd compression and dictionary-accelerated
46/// compression. The dictionary approach is particularly effective for RDF triple
47/// blocks where subjects, predicates, and objects share common URI prefixes.
48#[derive(Debug, Clone)]
49pub struct ZstdCodec {
50    /// Compression level (1-22, default 3)
51    pub compression_level: i32,
52    /// Optional pre-trained Zstd dictionary bytes
53    pub dict: Option<Vec<u8>>,
54}
55
56/// Statistics from a ZstdCodec compress/decompress operation
57#[derive(Debug, Clone)]
58pub struct ZstdCodecStats {
59    /// Original (uncompressed) data size in bytes
60    pub original_size: usize,
61    /// Compressed data size in bytes
62    pub compressed_size: usize,
63    /// Compression ratio (compressed / original); lower is better
64    pub compression_ratio: f64,
65    /// Space savings as percentage (0-100)
66    pub space_savings_pct: f64,
67    /// Time taken for the operation in microseconds
68    pub duration_us: u64,
69    /// Whether a dictionary was used
70    pub dictionary_used: bool,
71}
72
73impl ZstdCodec {
74    /// Create a new `ZstdCodec` with the given compression level and no dictionary.
75    ///
76    /// # Arguments
77    ///
78    /// * `level` - Compression level clamped to 1-22. Level 3 is the recommended default.
79    pub fn new(level: i32) -> Self {
80        Self {
81            compression_level: level.clamp(1, 22),
82            dict: None,
83        }
84    }
85
86    /// Create a new `ZstdCodec` with the given compression level and a pre-trained dictionary.
87    ///
88    /// Dictionary-based compression is typically 2-5x more effective on small blocks
89    /// compared to raw Zstd when the data shares structural patterns with the training samples.
90    ///
91    /// # Arguments
92    ///
93    /// * `level` - Compression level clamped to 1-22
94    /// * `dict`  - Pre-trained Zstd dictionary bytes (produced by [`ZstdCodec::train_dict`])
95    pub fn with_dict(level: i32, dict: Vec<u8>) -> Self {
96        Self {
97            compression_level: level.clamp(1, 22),
98            dict: Some(dict),
99        }
100    }
101
102    /// Compress `data` using Zstd, optionally with a pre-trained dictionary.
103    ///
104    /// Returns the compressed bytes. Use [`decompress`](ZstdCodec::decompress) with the
105    /// same dictionary to recover the original data.
106    pub fn compress(&self, data: &[u8]) -> Result<Vec<u8>> {
107        if data.is_empty() {
108            return Ok(Vec::new());
109        }
110
111        let compressed = match &self.dict {
112            None => {
113                // Standard Zstd compression without dictionary
114                let mut encoder =
115                    oxiarc_zstd::ZstdStreamEncoder::new(Vec::new(), self.compression_level);
116                encoder
117                    .write_all(data)
118                    .map_err(|e| TdbError::Other(format!("zstd write failed: {e}")))?;
119                encoder
120                    .finish()
121                    .map_err(|e| TdbError::Other(format!("zstd finish failed: {e}")))?
122            }
123            Some(dict_bytes) => {
124                // Dictionary-based Zstd compression
125                let mut enc = oxiarc_zstd::ZstdEncoder::new();
126                enc.set_level(self.compression_level);
127                enc.set_dictionary(dict_bytes);
128                enc.compress(data)
129                    .map_err(|e| TdbError::Other(format!("zstd dict compress failed: {e}")))?
130            }
131        };
132
133        Ok(compressed)
134    }
135
136    /// Decompress `data` previously compressed with this codec (same dictionary required).
137    ///
138    /// # Errors
139    ///
140    /// Returns [`TdbError::Other`] if decompression fails or dictionary mismatch occurs.
141    pub fn decompress(&self, data: &[u8]) -> Result<Vec<u8>> {
142        if data.is_empty() {
143            return Ok(Vec::new());
144        }
145
146        let decompressed = match &self.dict {
147            None => {
148                let mut decoder = oxiarc_zstd::ZstdStreamDecoder::new(data);
149                let mut out = Vec::new();
150                decoder
151                    .read_to_end(&mut out)
152                    .map_err(|e| TdbError::Other(format!("zstd decompress failed: {e}")))?;
153                out
154            }
155            Some(dict_bytes) => {
156                // Dictionary decompression - must pass the dictionary used during compression
157                oxiarc_zstd::decompress_with_dict(data, dict_bytes)
158                    .map_err(|e| TdbError::Other(format!("zstd dict decompress failed: {e}")))?
159            }
160        };
161
162        Ok(decompressed)
163    }
164
165    /// Compress `data` and return both the compressed bytes and statistics.
166    pub fn compress_with_stats(&self, data: &[u8]) -> Result<(Vec<u8>, ZstdCodecStats)> {
167        let start = Instant::now();
168        let compressed = self.compress(data)?;
169        let duration_us = start.elapsed().as_micros() as u64;
170
171        let original_size = data.len();
172        let compressed_size = compressed.len();
173        let compression_ratio = if original_size == 0 {
174            1.0
175        } else {
176            compressed_size as f64 / original_size as f64
177        };
178        let space_savings_pct = (1.0 - compression_ratio) * 100.0;
179
180        let stats = ZstdCodecStats {
181            original_size,
182            compressed_size,
183            compression_ratio,
184            space_savings_pct,
185            duration_us,
186            dictionary_used: self.dict.is_some(),
187        };
188
189        Ok((compressed, stats))
190    }
191
192    /// Train a Zstd dictionary from a collection of data samples.
193    ///
194    /// This is the key capability for RDF graph compression: by training on a representative
195    /// sample of RDF triples, the resulting dictionary captures common URI prefixes and
196    /// literal patterns, yielding significantly better compression for small triple blocks.
197    ///
198    /// # Arguments
199    ///
200    /// * `samples`   - Slice of byte vectors representing individual data samples (e.g., serialized triples)
201    /// * `dict_size` - Target dictionary size in bytes. Recommended: 1024-65536 bytes.
202    ///   Larger dictionaries capture more patterns but consume more memory.
203    ///
204    /// # Returns
205    ///
206    /// The trained dictionary as a byte vector suitable for passing to [`ZstdCodec::with_dict`].
207    ///
208    /// # Errors
209    ///
210    /// Returns [`TdbError::Other`] if:
211    /// - No samples are provided
212    /// - Samples are too small (< 8 bytes each)
213    /// - Total sample data exceeds 1GB
214    /// - The zstd dictionary trainer fails
215    pub fn train_dict(samples: &[Vec<u8>], dict_size: usize) -> Result<Vec<u8>> {
216        if samples.is_empty() {
217            return Err(TdbError::InvalidInput(
218                "Cannot train dictionary from empty sample set".to_string(),
219            ));
220        }
221
222        // Validate samples and compute total size
223        let mut total_size = 0usize;
224        let mut valid_samples = Vec::new();
225        for (idx, sample) in samples.iter().enumerate() {
226            if sample.len() < MIN_SAMPLE_SIZE {
227                log::debug!(
228                    "Skipping sample {} (size {} < min {})",
229                    idx,
230                    sample.len(),
231                    MIN_SAMPLE_SIZE
232                );
233                continue;
234            }
235            total_size = total_size
236                .checked_add(sample.len())
237                .ok_or_else(|| TdbError::Other("Sample total size overflow".to_string()))?;
238            if total_size > MAX_TOTAL_SAMPLE_SIZE {
239                return Err(TdbError::InvalidInput(format!(
240                    "Total sample size {} exceeds limit of {} bytes",
241                    total_size, MAX_TOTAL_SAMPLE_SIZE
242                )));
243            }
244            valid_samples.push(sample.as_slice());
245        }
246
247        if valid_samples.is_empty() {
248            return Err(TdbError::InvalidInput(format!(
249                "No valid samples found (all samples had fewer than {} bytes)",
250                MIN_SAMPLE_SIZE
251            )));
252        }
253
254        // Use the oxiarc_zstd dictionary trainer
255        let dict_obj = oxiarc_zstd::train_dictionary(&valid_samples, dict_size)
256            .map_err(|e| TdbError::Other(format!("zstd dictionary training failed: {e}")))?;
257        let dict = dict_obj.data().to_vec();
258
259        log::debug!(
260            "Trained Zstd dictionary: {} samples, {} total bytes -> {} byte dictionary",
261            valid_samples.len(),
262            total_size,
263            dict.len()
264        );
265
266        Ok(dict)
267    }
268
269    /// Check whether this codec has a dictionary loaded.
270    pub fn has_dict(&self) -> bool {
271        self.dict.is_some()
272    }
273
274    /// Return the dictionary size in bytes, or 0 if no dictionary is loaded.
275    pub fn dict_size(&self) -> usize {
276        self.dict.as_ref().map(|d| d.len()).unwrap_or(0)
277    }
278}
279
280impl Default for ZstdCodec {
281    fn default() -> Self {
282        Self::new(3)
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    // -- Basic compress/decompress roundtrip tests --
291
292    #[test]
293    fn test_zstd_codec_empty_data() {
294        let codec = ZstdCodec::new(3);
295        let compressed = codec.compress(&[]).expect("compress empty");
296        assert!(compressed.is_empty(), "compressed empty should be empty");
297        let decompressed = codec.decompress(&[]).expect("decompress empty");
298        assert!(
299            decompressed.is_empty(),
300            "decompressed empty should be empty"
301        );
302    }
303
304    #[test]
305    fn test_zstd_codec_roundtrip_basic() {
306        let codec = ZstdCodec::new(3);
307        let original = b"Hello, RDF World! This is a test of ZstdCodec.".repeat(20);
308        let compressed = codec.compress(&original).expect("compress");
309        let decompressed = codec.decompress(&compressed).expect("decompress");
310        assert_eq!(decompressed, original, "roundtrip must be identical");
311    }
312
313    #[test]
314    fn test_zstd_codec_compression_levels() {
315        let data = b"<http://example.org/subject> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://example.org/Type> .\n".repeat(50);
316
317        for level in [1, 3, 9, 19] {
318            let codec = ZstdCodec::new(level);
319            let compressed = codec
320                .compress(&data)
321                .unwrap_or_else(|e| panic!("compress at level {level} failed: {e}"));
322            let decompressed = codec
323                .decompress(&compressed)
324                .unwrap_or_else(|e| panic!("decompress at level {level} failed: {e}"));
325            assert_eq!(
326                decompressed, data,
327                "roundtrip failed at compression level {level}"
328            );
329            assert!(
330                compressed.len() < data.len(),
331                "level {level} should compress repetitive RDF data"
332            );
333        }
334    }
335
336    #[test]
337    fn test_zstd_codec_level_clamping() {
338        let low = ZstdCodec::new(0);
339        let high = ZstdCodec::new(100);
340        assert_eq!(
341            low.compression_level, 1,
342            "level should be clamped to minimum 1"
343        );
344        assert_eq!(
345            high.compression_level, 22,
346            "level should be clamped to maximum 22"
347        );
348    }
349
350    #[test]
351    fn test_zstd_codec_large_data() {
352        let codec = ZstdCodec::new(3);
353        // Generate ~1MB of RDF-like repetitive data
354        let rdf_triple = b"<http://data.example.org/resource/12345678> <http://schema.org/name> \"Example Resource\" .\n";
355        let mut data = Vec::with_capacity(1024 * 1024);
356        while data.len() < 1024 * 1024 {
357            data.extend_from_slice(rdf_triple);
358        }
359
360        let compressed = codec.compress(&data).expect("compress large data");
361        let decompressed = codec
362            .decompress(&compressed)
363            .expect("decompress large data");
364        assert_eq!(decompressed, data, "large data roundtrip must be identical");
365        // RDF-like data should compress well
366        assert!(
367            compressed.len() < data.len() / 10,
368            "repetitive RDF data should compress >10x, got ratio: {:.2}",
369            compressed.len() as f64 / data.len() as f64
370        );
371    }
372
373    // -- Dictionary training tests --
374
375    #[test]
376    fn test_train_dict_basic() {
377        let rdf_triple = b"<http://example.org/subject> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://example.org/Class> .";
378        let samples: Vec<Vec<u8>> = (0..200)
379            .map(|i| {
380                format!(
381                    "<http://example.org/resource/{}> <http://schema.org/name> \"Name {}\" .",
382                    i, i
383                )
384                .into_bytes()
385            })
386            .collect();
387
388        let dict = ZstdCodec::train_dict(&samples, 4096).expect("dictionary training");
389        assert!(!dict.is_empty(), "trained dictionary must not be empty");
390        assert!(
391            dict.len() <= 4096 + 100,
392            "dictionary should not greatly exceed requested size"
393        );
394
395        // Verify the dictionary is valid by using it for compression
396        let codec = ZstdCodec::with_dict(3, dict);
397        let test_data: Vec<u8> = rdf_triple.to_vec();
398        let compressed = codec.compress(&test_data).expect("compress with dict");
399        let decompressed = codec.decompress(&compressed).expect("decompress with dict");
400        assert_eq!(decompressed, test_data, "dict roundtrip must be identical");
401    }
402
403    #[test]
404    fn test_train_dict_empty_samples_error() {
405        let result = ZstdCodec::train_dict(&[], 1024);
406        assert!(result.is_err(), "empty samples should return error");
407    }
408
409    #[test]
410    fn test_train_dict_too_small_samples_error() {
411        // All samples are too small (< 8 bytes)
412        let samples: Vec<Vec<u8>> = vec![b"abc".to_vec(), b"xy".to_vec()];
413        let result = ZstdCodec::train_dict(&samples, 1024);
414        assert!(result.is_err(), "all-too-small samples should return error");
415    }
416
417    // -- Dictionary-based compress/decompress roundtrip tests --
418
419    #[test]
420    fn test_with_dict_roundtrip() {
421        let samples: Vec<Vec<u8>> = (0..300)
422            .map(|i| {
423                format!(
424                    "<http://example.org/entity/{}> <http://purl.org/dc/terms/title> \"Title {}\" .",
425                    i, i
426                )
427                .into_bytes()
428            })
429            .collect();
430
431        let dict = ZstdCodec::train_dict(&samples, 8192).expect("train dict");
432        let codec = ZstdCodec::with_dict(3, dict);
433
434        for i in 0..10 {
435            let triple = format!(
436                "<http://example.org/entity/{}> <http://purl.org/dc/terms/title> \"Title {}\" .",
437                i * 100,
438                i * 100
439            )
440            .into_bytes();
441            let compressed = codec.compress(&triple).expect("compress with dict");
442            let decompressed = codec.decompress(&compressed).expect("decompress with dict");
443            assert_eq!(
444                decompressed, triple,
445                "dict roundtrip must match at index {i}"
446            );
447        }
448    }
449
450    #[test]
451    fn test_dict_compression_better_than_no_dict() {
452        // Train on RDF data with common URI prefixes
453        let samples: Vec<Vec<u8>> = (0..500)
454            .map(|i| {
455                format!(
456                    "<http://dbpedia.org/resource/Resource_{}> <http://dbpedia.org/ontology/type> <http://dbpedia.org/ontology/Class> .",
457                    i
458                )
459                .into_bytes()
460            })
461            .collect();
462
463        let dict = ZstdCodec::train_dict(&samples, 16384).expect("train dict");
464        let codec_dict = ZstdCodec::with_dict(3, dict);
465        let codec_plain = ZstdCodec::new(3);
466
467        let test_triple = b"<http://dbpedia.org/resource/Resource_999> <http://dbpedia.org/ontology/type> <http://dbpedia.org/ontology/Class> .".repeat(5);
468
469        let (compressed_dict, stats_dict) = codec_dict
470            .compress_with_stats(&test_triple)
471            .expect("compress with dict stats");
472        let (compressed_plain, stats_plain) = codec_plain
473            .compress_with_stats(&test_triple)
474            .expect("compress plain stats");
475
476        // Dictionary compressed should generally be smaller for similar data
477        println!(
478            "Dict compression: {} -> {} ({:.2}x ratio)",
479            stats_dict.original_size,
480            stats_dict.compressed_size,
481            1.0 / stats_dict.compression_ratio
482        );
483        println!(
484            "Plain compression: {} -> {} ({:.2}x ratio)",
485            stats_plain.original_size,
486            stats_plain.compressed_size,
487            1.0 / stats_plain.compression_ratio
488        );
489
490        // Both should be able to decompress correctly
491        let dec_dict = codec_dict
492            .decompress(&compressed_dict)
493            .expect("decompress dict");
494        let dec_plain = codec_plain
495            .decompress(&compressed_plain)
496            .expect("decompress plain");
497        assert_eq!(dec_dict, test_triple);
498        assert_eq!(dec_plain, test_triple);
499
500        assert!(
501            stats_dict.dictionary_used,
502            "stats should reflect dictionary usage"
503        );
504        assert!(
505            !stats_plain.dictionary_used,
506            "stats should reflect no dictionary"
507        );
508    }
509
510    // -- Stats tests --
511
512    #[test]
513    fn test_compress_with_stats() {
514        let codec = ZstdCodec::new(3);
515        let data = b"RDF triple data for stats testing".repeat(100);
516        let (compressed, stats) = codec
517            .compress_with_stats(&data)
518            .expect("compress with stats");
519
520        assert_eq!(stats.original_size, data.len());
521        assert_eq!(stats.compressed_size, compressed.len());
522        assert!(stats.compression_ratio > 0.0);
523        assert!(stats.space_savings_pct >= 0.0 && stats.space_savings_pct <= 100.0);
524        assert!(stats.duration_us > 0 || stats.original_size < 1000);
525        assert!(!stats.dictionary_used);
526    }
527
528    // -- has_dict / dict_size tests --
529
530    #[test]
531    fn test_has_dict_no_dict() {
532        let codec = ZstdCodec::new(3);
533        assert!(!codec.has_dict());
534        assert_eq!(codec.dict_size(), 0);
535    }
536
537    #[test]
538    fn test_has_dict_with_dict() {
539        let samples: Vec<Vec<u8>> = (0..200)
540            .map(|i| format!("<http://example.org/{}> <p> <o> .", i).into_bytes())
541            .collect();
542        let dict = ZstdCodec::train_dict(&samples, 2048).expect("train dict");
543        let dict_len = dict.len();
544        let codec = ZstdCodec::with_dict(3, dict);
545        assert!(codec.has_dict());
546        assert_eq!(codec.dict_size(), dict_len);
547    }
548}