1use std::collections::HashMap;
5
6#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
12pub enum CompressionCodec {
13 None,
15 Lz4,
17 Zstd,
19 Snappy,
21 Brotli,
23}
24
25impl CompressionCodec {
26 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 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#[derive(Debug, Clone, PartialEq)]
57pub struct BlockProfile {
58 pub cid: String,
60 pub raw_size_bytes: u64,
62 pub compressed_size_bytes: Option<u64>,
64 pub codec: Option<CompressionCodec>,
66 pub access_count: u64,
68}
69
70impl BlockProfile {
71 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 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#[derive(Debug, Clone, PartialEq)]
99pub struct AdvisorRecommendation {
100 pub cid: String,
102 pub recommended_codec: CompressionCodec,
104 pub estimated_savings_bytes: u64,
106 pub reason: String,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct AdvisorConfig {
117 pub min_size_for_compression: u64,
121 pub hot_access_threshold: u64,
126 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
143pub struct BlockCompressionAdvisor {
150 pub profiles: HashMap<String, BlockProfile>,
152 pub config: AdvisorConfig,
154}
155
156impl BlockCompressionAdvisor {
157 pub fn new(config: AdvisorConfig) -> Self {
159 Self {
160 profiles: HashMap::new(),
161 config,
162 }
163 }
164
165 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 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 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 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 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 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 pub fn stats_for(&self, cid: &str) -> Option<&BlockProfile> {
271 self.profiles.get(cid)
272 }
273}
274
275#[cfg(test)]
280mod tests {
281 use super::*;
282
283 fn default_advisor() -> BlockCompressionAdvisor {
284 BlockCompressionAdvisor::new(AdvisorConfig::default())
285 }
286
287 #[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 #[test]
317 fn compression_speed_ordering() {
318 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 #[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 #[test]
410 fn recommend_hot_block_returns_lz4() {
411 let mut adv = default_advisor();
412 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 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 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 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 #[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 adv.record_compression("ghost", 100, CompressionCodec::Lz4);
484 assert!(adv.stats_for("ghost").is_none());
485 }
486
487 #[test]
490 fn recommend_all_sorted_by_savings_desc() {
491 let mut adv = default_advisor();
492 adv.register_block("cid_large".to_string(), 1_000_000, 50);
494 adv.register_block("cid_small".to_string(), 2_048, 50);
496 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 #[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); adv.record_compression("b", 8_000, CompressionCodec::Brotli); 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 #[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 let expected = (raw as f64 * (1.0 - 1.0 / 3.0)) as u64;
540 assert_eq!(rec.estimated_savings_bytes, expected);
541 }
542}