Skip to main content

ipfrs_storage/
compression_advisor.rs

1//! Block compression advisor — analyses access patterns and sizes to recommend
2//! optimal compression codecs and tracks achieved compression ratios.
3
4use std::collections::HashMap;
5
6// ─────────────────────────────────────────────────────────────────────────────
7// CompressionCodec
8// ─────────────────────────────────────────────────────────────────────────────
9
10/// Supported compression codecs that the advisor can recommend.
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
12pub enum CompressionCodec {
13    /// No compression applied.
14    None,
15    /// LZ4 — very fast with moderate ratio.
16    Lz4,
17    /// Zstd — balanced speed and compression ratio.
18    Zstd,
19    /// Snappy — fast with lower compression ratio than Lz4.
20    Snappy,
21    /// Brotli — slow but best compression ratio.
22    Brotli,
23}
24
25impl CompressionCodec {
26    /// Typical compression ratio achieved by this codec (raw / compressed).
27    ///
28    /// A value of `1.0` means no compression gain.
29    pub fn typical_ratio(&self) -> f64 {
30        match self {
31            Self::None => 1.0,
32            Self::Lz4 => 1.5,
33            Self::Zstd => 2.5,
34            Self::Snappy => 1.3,
35            Self::Brotli => 3.0,
36        }
37    }
38
39    /// Relative compression speed on a scale of 0–100 (higher = faster).
40    pub fn compression_speed(&self) -> u32 {
41        match self {
42            Self::None => 100,
43            Self::Lz4 => 80,
44            Self::Zstd => 40,
45            Self::Snappy => 90,
46            Self::Brotli => 10,
47        }
48    }
49}
50
51// ─────────────────────────────────────────────────────────────────────────────
52// BlockProfile
53// ─────────────────────────────────────────────────────────────────────────────
54
55/// Profile tracking the size and access characteristics of a single block.
56#[derive(Debug, Clone, PartialEq)]
57pub struct BlockProfile {
58    /// Content identifier of the block.
59    pub cid: String,
60    /// Size of the block before any compression is applied, in bytes.
61    pub raw_size_bytes: u64,
62    /// Size after compression, if the block has been compressed.
63    pub compressed_size_bytes: Option<u64>,
64    /// Codec used for the current compressed copy, if any.
65    pub codec: Option<CompressionCodec>,
66    /// Number of times this block has been accessed.
67    pub access_count: u64,
68}
69
70impl BlockProfile {
71    /// Returns the achieved compression ratio (`raw / compressed`).
72    ///
73    /// Returns `1.0` when compression data is unavailable.
74    pub fn compression_ratio(&self) -> f64 {
75        match self.compressed_size_bytes {
76            Some(compressed) if compressed > 0 => self.raw_size_bytes as f64 / compressed as f64,
77            _ => 1.0,
78        }
79    }
80
81    /// Returns the number of bytes saved by compression (`raw − compressed`).
82    ///
83    /// Returns `0` when the block has not been compressed or when compression
84    /// actually expanded the block (saturating subtraction).
85    pub fn space_savings_bytes(&self) -> u64 {
86        match self.compressed_size_bytes {
87            Some(compressed) => self.raw_size_bytes.saturating_sub(compressed),
88            None => 0,
89        }
90    }
91}
92
93// ─────────────────────────────────────────────────────────────────────────────
94// AdvisorRecommendation
95// ─────────────────────────────────────────────────────────────────────────────
96
97/// A concrete codec recommendation produced by [`BlockCompressionAdvisor`].
98#[derive(Debug, Clone, PartialEq)]
99pub struct AdvisorRecommendation {
100    /// Content identifier of the block this recommendation applies to.
101    pub cid: String,
102    /// Codec the advisor suggests using.
103    pub recommended_codec: CompressionCodec,
104    /// Estimated bytes that would be saved by applying the recommended codec.
105    pub estimated_savings_bytes: u64,
106    /// Human-readable explanation of why this codec was chosen.
107    pub reason: String,
108}
109
110// ─────────────────────────────────────────────────────────────────────────────
111// AdvisorConfig
112// ─────────────────────────────────────────────────────────────────────────────
113
114/// Configuration knobs for the [`BlockCompressionAdvisor`].
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct AdvisorConfig {
117    /// Blocks smaller than this threshold (in bytes) are not worth compressing.
118    ///
119    /// Default: 1 024 bytes.
120    pub min_size_for_compression: u64,
121    /// Blocks accessed at least this many times are considered *hot* and favour
122    /// speed over compression ratio.
123    ///
124    /// Default: 100 accesses.
125    pub hot_access_threshold: u64,
126    /// Blocks accessed at most this many times are considered *cold* and favour
127    /// the best compression ratio.
128    ///
129    /// Default: 5 accesses.
130    pub cold_access_threshold: u64,
131}
132
133impl Default for AdvisorConfig {
134    fn default() -> Self {
135        Self {
136            min_size_for_compression: 1_024,
137            hot_access_threshold: 100,
138            cold_access_threshold: 5,
139        }
140    }
141}
142
143// ─────────────────────────────────────────────────────────────────────────────
144// BlockCompressionAdvisor
145// ─────────────────────────────────────────────────────────────────────────────
146
147/// Analyses block access patterns and sizes to recommend optimal compression
148/// codecs and tracks achieved compression ratios.
149pub struct BlockCompressionAdvisor {
150    /// Per-block profiles keyed by CID.
151    pub profiles: HashMap<String, BlockProfile>,
152    /// Configuration thresholds used by the recommendation logic.
153    pub config: AdvisorConfig,
154}
155
156impl BlockCompressionAdvisor {
157    /// Creates a new advisor with the supplied configuration.
158    pub fn new(config: AdvisorConfig) -> Self {
159        Self {
160            profiles: HashMap::new(),
161            config,
162        }
163    }
164
165    /// Registers a block (or updates its `raw_size_bytes` and `access_count`
166    /// if it already exists).
167    pub fn register_block(&mut self, cid: String, raw_size_bytes: u64, access_count: u64) {
168        let profile = self
169            .profiles
170            .entry(cid.clone())
171            .or_insert_with(|| BlockProfile {
172                cid: cid.clone(),
173                raw_size_bytes,
174                compressed_size_bytes: None,
175                codec: None,
176                access_count,
177            });
178        profile.raw_size_bytes = raw_size_bytes;
179        profile.access_count = access_count;
180    }
181
182    /// Records the outcome of a compression operation for an existing block.
183    ///
184    /// Has no effect when the CID is unknown.
185    pub fn record_compression(
186        &mut self,
187        cid: &str,
188        compressed_size_bytes: u64,
189        codec: CompressionCodec,
190    ) {
191        if let Some(profile) = self.profiles.get_mut(cid) {
192            profile.compressed_size_bytes = Some(compressed_size_bytes);
193            profile.codec = Some(codec);
194        }
195    }
196
197    /// Returns a codec recommendation for the block identified by `cid`.
198    ///
199    /// Returns `None` when:
200    /// - the block is not registered, or
201    /// - the block's raw size is below [`AdvisorConfig::min_size_for_compression`].
202    pub fn recommend(&self, cid: &str) -> Option<AdvisorRecommendation> {
203        let profile = self.profiles.get(cid)?;
204
205        if profile.raw_size_bytes < self.config.min_size_for_compression {
206            return None;
207        }
208
209        let (codec, reason) = if profile.access_count >= self.config.hot_access_threshold {
210            (
211                CompressionCodec::Lz4,
212                format!(
213                    "Block is hot ({} accesses >= threshold {}): prefer speed with Lz4",
214                    profile.access_count, self.config.hot_access_threshold
215                ),
216            )
217        } else if profile.access_count <= self.config.cold_access_threshold {
218            (
219                CompressionCodec::Brotli,
220                format!(
221                    "Block is cold ({} accesses <= threshold {}): prefer ratio with Brotli",
222                    profile.access_count, self.config.cold_access_threshold
223                ),
224            )
225        } else {
226            (
227                CompressionCodec::Zstd,
228                format!(
229                    "Block is warm ({} accesses): balanced Zstd recommended",
230                    profile.access_count
231                ),
232            )
233        };
234
235        let typical_ratio = codec.typical_ratio();
236        // estimated_savings = raw * (1 − 1 / typical_ratio)
237        let savings_fraction = 1.0 - 1.0 / typical_ratio;
238        let estimated_savings_bytes = (profile.raw_size_bytes as f64 * savings_fraction) as u64;
239
240        Some(AdvisorRecommendation {
241            cid: cid.to_string(),
242            recommended_codec: codec,
243            estimated_savings_bytes,
244            reason,
245        })
246    }
247
248    /// Returns recommendations for every registered block that qualifies for
249    /// compression, sorted by `estimated_savings_bytes` in descending order.
250    pub fn recommend_all(&self) -> Vec<AdvisorRecommendation> {
251        let mut recs: Vec<AdvisorRecommendation> = self
252            .profiles
253            .keys()
254            .filter_map(|cid| self.recommend(cid))
255            .collect();
256        recs.sort_by_key(|r| std::cmp::Reverse(r.estimated_savings_bytes));
257        recs
258    }
259
260    /// Returns the total bytes saved across all profiles that have been
261    /// compressed.
262    pub fn total_space_savings(&self) -> u64 {
263        self.profiles
264            .values()
265            .map(|p| p.space_savings_bytes())
266            .fold(0u64, |acc, s| acc.saturating_add(s))
267    }
268
269    /// Returns an immutable reference to the profile for `cid`, if it exists.
270    pub fn stats_for(&self, cid: &str) -> Option<&BlockProfile> {
271        self.profiles.get(cid)
272    }
273}
274
275// ─────────────────────────────────────────────────────────────────────────────
276// Tests
277// ─────────────────────────────────────────────────────────────────────────────
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    fn default_advisor() -> BlockCompressionAdvisor {
284        BlockCompressionAdvisor::new(AdvisorConfig::default())
285    }
286
287    // ── CompressionCodec::typical_ratio ──────────────────────────────────────
288
289    #[test]
290    fn typical_ratio_none_is_one() {
291        assert!((CompressionCodec::None.typical_ratio() - 1.0).abs() < f64::EPSILON);
292    }
293
294    #[test]
295    fn typical_ratio_lz4() {
296        assert!((CompressionCodec::Lz4.typical_ratio() - 1.5).abs() < f64::EPSILON);
297    }
298
299    #[test]
300    fn typical_ratio_zstd() {
301        assert!((CompressionCodec::Zstd.typical_ratio() - 2.5).abs() < f64::EPSILON);
302    }
303
304    #[test]
305    fn typical_ratio_snappy() {
306        assert!((CompressionCodec::Snappy.typical_ratio() - 1.3).abs() < f64::EPSILON);
307    }
308
309    #[test]
310    fn typical_ratio_brotli() {
311        assert!((CompressionCodec::Brotli.typical_ratio() - 3.0).abs() < f64::EPSILON);
312    }
313
314    // ── CompressionCodec::compression_speed ordering ─────────────────────────
315
316    #[test]
317    fn compression_speed_ordering() {
318        // None > Snappy > Lz4 > Zstd > Brotli
319        assert!(
320            CompressionCodec::None.compression_speed()
321                > CompressionCodec::Snappy.compression_speed()
322        );
323        assert!(
324            CompressionCodec::Snappy.compression_speed()
325                > CompressionCodec::Lz4.compression_speed()
326        );
327        assert!(
328            CompressionCodec::Lz4.compression_speed() > CompressionCodec::Zstd.compression_speed()
329        );
330        assert!(
331            CompressionCodec::Zstd.compression_speed()
332                > CompressionCodec::Brotli.compression_speed()
333        );
334    }
335
336    #[test]
337    fn compression_speed_exact_values() {
338        assert_eq!(CompressionCodec::None.compression_speed(), 100);
339        assert_eq!(CompressionCodec::Lz4.compression_speed(), 80);
340        assert_eq!(CompressionCodec::Zstd.compression_speed(), 40);
341        assert_eq!(CompressionCodec::Snappy.compression_speed(), 90);
342        assert_eq!(CompressionCodec::Brotli.compression_speed(), 10);
343    }
344
345    // ── BlockProfile helpers ──────────────────────────────────────────────────
346
347    #[test]
348    fn compression_ratio_without_compressed_size() {
349        let profile = BlockProfile {
350            cid: "bafy1".to_string(),
351            raw_size_bytes: 4096,
352            compressed_size_bytes: None,
353            codec: None,
354            access_count: 1,
355        };
356        assert!((profile.compression_ratio() - 1.0).abs() < f64::EPSILON);
357    }
358
359    #[test]
360    fn compression_ratio_with_compressed_size() {
361        let profile = BlockProfile {
362            cid: "bafy2".to_string(),
363            raw_size_bytes: 4096,
364            compressed_size_bytes: Some(2048),
365            codec: Some(CompressionCodec::Zstd),
366            access_count: 10,
367        };
368        assert!((profile.compression_ratio() - 2.0).abs() < f64::EPSILON);
369    }
370
371    #[test]
372    fn space_savings_bytes_without_compression() {
373        let profile = BlockProfile {
374            cid: "bafy3".to_string(),
375            raw_size_bytes: 8192,
376            compressed_size_bytes: None,
377            codec: None,
378            access_count: 3,
379        };
380        assert_eq!(profile.space_savings_bytes(), 0);
381    }
382
383    #[test]
384    fn space_savings_bytes_with_compression() {
385        let profile = BlockProfile {
386            cid: "bafy4".to_string(),
387            raw_size_bytes: 8192,
388            compressed_size_bytes: Some(3000),
389            codec: Some(CompressionCodec::Brotli),
390            access_count: 1,
391        };
392        assert_eq!(profile.space_savings_bytes(), 8192 - 3000);
393    }
394
395    #[test]
396    fn space_savings_bytes_saturates_when_compressed_larger() {
397        let profile = BlockProfile {
398            cid: "bafy5".to_string(),
399            raw_size_bytes: 100,
400            compressed_size_bytes: Some(150),
401            codec: Some(CompressionCodec::Lz4),
402            access_count: 200,
403        };
404        assert_eq!(profile.space_savings_bytes(), 0);
405    }
406
407    // ── Advisor recommendation logic ──────────────────────────────────────────
408
409    #[test]
410    fn recommend_hot_block_returns_lz4() {
411        let mut adv = default_advisor();
412        // hot_access_threshold = 100
413        adv.register_block("cid_hot".to_string(), 65_536, 150);
414        let rec = adv.recommend("cid_hot").expect("should recommend");
415        assert_eq!(rec.recommended_codec, CompressionCodec::Lz4);
416    }
417
418    #[test]
419    fn recommend_cold_block_returns_brotli() {
420        let mut adv = default_advisor();
421        // cold_access_threshold = 5
422        adv.register_block("cid_cold".to_string(), 65_536, 2);
423        let rec = adv.recommend("cid_cold").expect("should recommend");
424        assert_eq!(rec.recommended_codec, CompressionCodec::Brotli);
425    }
426
427    #[test]
428    fn recommend_medium_block_returns_zstd() {
429        let mut adv = default_advisor();
430        // 5 < 50 < 100 → warm
431        adv.register_block("cid_medium".to_string(), 65_536, 50);
432        let rec = adv.recommend("cid_medium").expect("should recommend");
433        assert_eq!(rec.recommended_codec, CompressionCodec::Zstd);
434    }
435
436    #[test]
437    fn recommend_too_small_block_returns_none() {
438        let mut adv = default_advisor();
439        // min_size_for_compression = 1024; this block is 512 bytes
440        adv.register_block("cid_tiny".to_string(), 512, 50);
441        assert!(adv.recommend("cid_tiny").is_none());
442    }
443
444    #[test]
445    fn recommend_unknown_cid_returns_none() {
446        let adv = default_advisor();
447        assert!(adv.recommend("does_not_exist").is_none());
448    }
449
450    #[test]
451    fn recommend_exactly_at_hot_threshold() {
452        let mut adv = default_advisor();
453        adv.register_block("cid_exact_hot".to_string(), 8_192, 100);
454        let rec = adv.recommend("cid_exact_hot").expect("should recommend");
455        assert_eq!(rec.recommended_codec, CompressionCodec::Lz4);
456    }
457
458    #[test]
459    fn recommend_exactly_at_cold_threshold() {
460        let mut adv = default_advisor();
461        adv.register_block("cid_exact_cold".to_string(), 8_192, 5);
462        let rec = adv.recommend("cid_exact_cold").expect("should recommend");
463        assert_eq!(rec.recommended_codec, CompressionCodec::Brotli);
464    }
465
466    // ── record_compression updates profile ───────────────────────────────────
467
468    #[test]
469    fn record_compression_updates_profile() {
470        let mut adv = default_advisor();
471        adv.register_block("cid_comp".to_string(), 10_000, 10);
472        adv.record_compression("cid_comp", 4_000, CompressionCodec::Zstd);
473
474        let profile = adv.stats_for("cid_comp").expect("profile should exist");
475        assert_eq!(profile.compressed_size_bytes, Some(4_000));
476        assert_eq!(profile.codec, Some(CompressionCodec::Zstd));
477    }
478
479    #[test]
480    fn record_compression_on_unknown_cid_does_nothing() {
481        let mut adv = default_advisor();
482        // Should not panic; just no-op.
483        adv.record_compression("ghost", 100, CompressionCodec::Lz4);
484        assert!(adv.stats_for("ghost").is_none());
485    }
486
487    // ── recommend_all ─────────────────────────────────────────────────────────
488
489    #[test]
490    fn recommend_all_sorted_by_savings_desc() {
491        let mut adv = default_advisor();
492        // large block (more savings)
493        adv.register_block("cid_large".to_string(), 1_000_000, 50);
494        // small block (less savings)
495        adv.register_block("cid_small".to_string(), 2_048, 50);
496        // too small — should be excluded
497        adv.register_block("cid_micro".to_string(), 256, 50);
498
499        let recs = adv.recommend_all();
500        assert_eq!(recs.len(), 2);
501        assert!(
502            recs[0].estimated_savings_bytes >= recs[1].estimated_savings_bytes,
503            "results must be sorted descending by estimated_savings_bytes"
504        );
505        assert_eq!(recs[0].cid, "cid_large");
506    }
507
508    // ── total_space_savings ───────────────────────────────────────────────────
509
510    #[test]
511    fn total_space_savings_sums_all_profiles() {
512        let mut adv = default_advisor();
513        adv.register_block("a".to_string(), 10_000, 10);
514        adv.register_block("b".to_string(), 20_000, 10);
515        adv.record_compression("a", 4_000, CompressionCodec::Zstd); // saves 6 000
516        adv.record_compression("b", 8_000, CompressionCodec::Brotli); // saves 12 000
517
518        assert_eq!(adv.total_space_savings(), 6_000 + 12_000);
519    }
520
521    #[test]
522    fn total_space_savings_zero_when_no_compressions() {
523        let mut adv = default_advisor();
524        adv.register_block("x".to_string(), 5_000, 1);
525        assert_eq!(adv.total_space_savings(), 0);
526    }
527
528    // ── estimated savings formula ─────────────────────────────────────────────
529
530    #[test]
531    fn estimated_savings_formula_brotli() {
532        let mut adv = default_advisor();
533        let raw = 30_000u64;
534        adv.register_block("cid_brotli".to_string(), raw, 1);
535        let rec = adv.recommend("cid_brotli").expect("should recommend");
536        assert_eq!(rec.recommended_codec, CompressionCodec::Brotli);
537
538        // expected = raw * (1 - 1/3.0) = 30000 * 0.6666... = 20000
539        let expected = (raw as f64 * (1.0 - 1.0 / 3.0)) as u64;
540        assert_eq!(rec.estimated_savings_bytes, expected);
541    }
542}