1use std::collections::HashMap;
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
10pub enum HeatBucket {
11 Cold = 0,
13 Warm = 1,
15 Hot = 2,
17 Scorching = 3,
19}
20
21impl HeatBucket {
22 pub fn from_score(score: f64) -> HeatBucket {
24 if score >= 100.0 {
25 HeatBucket::Scorching
26 } else if score >= 10.0 {
27 HeatBucket::Hot
28 } else if score >= 1.0 {
29 HeatBucket::Warm
30 } else {
31 HeatBucket::Cold
32 }
33 }
34}
35
36#[derive(Clone, Debug)]
38pub struct HeatEntry {
39 pub cid: String,
41 pub score: f64,
43 pub access_count: u64,
45 pub last_accessed_tick: u64,
47}
48
49impl HeatEntry {
50 pub fn bucket(&self) -> HeatBucket {
52 HeatBucket::from_score(self.score)
53 }
54
55 pub fn decay(&mut self, ticks_elapsed: u64, decay_factor: f64) {
59 self.score *= decay_factor.powf(ticks_elapsed as f64);
60 }
61}
62
63#[derive(Clone, Debug, PartialEq)]
65pub struct HeatmapStats {
66 pub total_entries: usize,
68 pub scorching: usize,
70 pub hot: usize,
72 pub warm: usize,
74 pub cold: usize,
76 pub total_accesses: u64,
78}
79
80impl HeatmapStats {
81 pub fn coverage_ratio(&self, top_k: usize) -> f64 {
85 top_k as f64 / self.total_entries.max(1) as f64
86 }
87}
88
89pub struct StorageHeatmapTracker {
91 pub entries: HashMap<String, HeatEntry>,
93 pub decay_factor: f64,
95 pub current_tick: u64,
97}
98
99impl StorageHeatmapTracker {
100 pub fn new(decay_factor: f64) -> Self {
102 Self {
103 entries: HashMap::new(),
104 decay_factor,
105 current_tick: 0,
106 }
107 }
108
109 pub fn record_access(&mut self, cid: &str, heat_gain: f64) {
115 let current_tick = self.current_tick;
116 let decay_factor = self.decay_factor;
117
118 if let Some(entry) = self.entries.get_mut(cid) {
119 let ticks_elapsed = current_tick.saturating_sub(entry.last_accessed_tick);
120 entry.decay(ticks_elapsed, decay_factor);
121 entry.score += heat_gain;
122 entry.access_count += 1;
123 entry.last_accessed_tick = current_tick;
124 } else {
125 self.entries.insert(
126 cid.to_string(),
127 HeatEntry {
128 cid: cid.to_string(),
129 score: heat_gain,
130 access_count: 1,
131 last_accessed_tick: current_tick,
132 },
133 );
134 }
135 }
136
137 pub fn advance_tick(&mut self) {
142 self.current_tick += 1;
143 }
144
145 pub fn top_k(&self, k: usize) -> Vec<&HeatEntry> {
147 let mut entries: Vec<&HeatEntry> = self.entries.values().collect();
148 entries.sort_by(|a, b| {
149 b.score
150 .partial_cmp(&a.score)
151 .unwrap_or(std::cmp::Ordering::Equal)
152 });
153 entries.truncate(k);
154 entries
155 }
156
157 pub fn evict_cold(&mut self, min_score: f64) -> usize {
161 let before = self.entries.len();
162 self.entries.retain(|_, entry| entry.score >= min_score);
163 before - self.entries.len()
164 }
165
166 pub fn stats(&self) -> HeatmapStats {
168 let mut scorching = 0usize;
169 let mut hot = 0usize;
170 let mut warm = 0usize;
171 let mut cold = 0usize;
172 let mut total_accesses = 0u64;
173
174 for entry in self.entries.values() {
175 total_accesses += entry.access_count;
176 match entry.bucket() {
177 HeatBucket::Scorching => scorching += 1,
178 HeatBucket::Hot => hot += 1,
179 HeatBucket::Warm => warm += 1,
180 HeatBucket::Cold => cold += 1,
181 }
182 }
183
184 HeatmapStats {
185 total_entries: self.entries.len(),
186 scorching,
187 hot,
188 warm,
189 cold,
190 total_accesses,
191 }
192 }
193
194 pub fn decay_all(&mut self) {
199 let current_tick = self.current_tick;
200 let decay_factor = self.decay_factor;
201
202 for entry in self.entries.values_mut() {
203 let ticks_elapsed = current_tick.saturating_sub(entry.last_accessed_tick);
204 entry.decay(ticks_elapsed, decay_factor);
205 entry.last_accessed_tick = current_tick;
206 }
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
217 fn test_heat_bucket_cold_below_one() {
218 assert_eq!(HeatBucket::from_score(0.0), HeatBucket::Cold);
219 assert_eq!(HeatBucket::from_score(0.99), HeatBucket::Cold);
220 }
221
222 #[test]
223 fn test_heat_bucket_warm_one_to_ten() {
224 assert_eq!(HeatBucket::from_score(1.0), HeatBucket::Warm);
225 assert_eq!(HeatBucket::from_score(5.0), HeatBucket::Warm);
226 assert_eq!(HeatBucket::from_score(9.999), HeatBucket::Warm);
227 }
228
229 #[test]
230 fn test_heat_bucket_hot_ten_to_hundred() {
231 assert_eq!(HeatBucket::from_score(10.0), HeatBucket::Hot);
232 assert_eq!(HeatBucket::from_score(50.0), HeatBucket::Hot);
233 assert_eq!(HeatBucket::from_score(99.99), HeatBucket::Hot);
234 }
235
236 #[test]
237 fn test_heat_bucket_scorching_at_and_above_hundred() {
238 assert_eq!(HeatBucket::from_score(100.0), HeatBucket::Scorching);
239 assert_eq!(HeatBucket::from_score(1_000.0), HeatBucket::Scorching);
240 }
241
242 #[test]
245 fn test_heat_entry_bucket_delegates_to_from_score() {
246 let entry = HeatEntry {
247 cid: "bafybei1".to_string(),
248 score: 15.0,
249 access_count: 3,
250 last_accessed_tick: 0,
251 };
252 assert_eq!(entry.bucket(), HeatBucket::Hot);
253 }
254
255 #[test]
256 fn test_heat_entry_decay_reduces_score() {
257 let mut entry = HeatEntry {
258 cid: "bafybei2".to_string(),
259 score: 100.0,
260 access_count: 1,
261 last_accessed_tick: 0,
262 };
263 entry.decay(1, 0.9);
264 assert!((entry.score - 90.0).abs() < 1e-9);
266 }
267
268 #[test]
269 fn test_heat_entry_decay_multiple_ticks() {
270 let mut entry = HeatEntry {
271 cid: "bafybei3".to_string(),
272 score: 100.0,
273 access_count: 1,
274 last_accessed_tick: 0,
275 };
276 entry.decay(10, 0.9);
277 let expected = 100.0_f64 * 0.9_f64.powf(10.0);
278 assert!((entry.score - expected).abs() < 1e-9);
279 }
280
281 #[test]
284 fn test_record_access_creates_entry() {
285 let mut tracker = StorageHeatmapTracker::new(0.9);
286 tracker.record_access("cid-a", 5.0);
287 let entry = tracker.entries.get("cid-a").expect("entry must exist");
288 assert!((entry.score - 5.0).abs() < 1e-9);
289 assert_eq!(entry.access_count, 1);
290 assert_eq!(entry.last_accessed_tick, 0);
291 }
292
293 #[test]
294 fn test_record_access_heat_accumulates_same_tick() {
295 let mut tracker = StorageHeatmapTracker::new(0.9);
296 tracker.record_access("cid-b", 3.0);
297 tracker.record_access("cid-b", 7.0);
298 let entry = tracker.entries.get("cid-b").expect("entry must exist");
299 assert!((entry.score - 10.0).abs() < 1e-9);
301 assert_eq!(entry.access_count, 2);
302 }
303
304 #[test]
305 fn test_record_access_lazy_decay_applied_on_next_access() {
306 let mut tracker = StorageHeatmapTracker::new(0.9);
307 tracker.record_access("cid-c", 100.0);
308 tracker.advance_tick();
310 tracker.advance_tick();
311 tracker.record_access("cid-c", 0.0);
312 let entry = tracker.entries.get("cid-c").expect("entry must exist");
313 let expected = 100.0_f64 * 0.9_f64.powf(2.0);
314 assert!((entry.score - expected).abs() < 1e-9);
315 assert_eq!(entry.access_count, 2);
316 assert_eq!(entry.last_accessed_tick, 2);
317 }
318
319 #[test]
322 fn test_advance_tick_increments_current_tick() {
323 let mut tracker = StorageHeatmapTracker::new(0.9);
324 assert_eq!(tracker.current_tick, 0);
325 tracker.advance_tick();
326 assert_eq!(tracker.current_tick, 1);
327 tracker.advance_tick();
328 assert_eq!(tracker.current_tick, 2);
329 }
330
331 #[test]
334 fn test_top_k_ordered_descending() {
335 let mut tracker = StorageHeatmapTracker::new(0.9);
336 tracker.record_access("low", 1.0);
337 tracker.record_access("mid", 50.0);
338 tracker.record_access("high", 200.0);
339 let top = tracker.top_k(3);
340 assert_eq!(top.len(), 3);
341 assert_eq!(top[0].cid, "high");
342 assert_eq!(top[1].cid, "mid");
343 assert_eq!(top[2].cid, "low");
344 }
345
346 #[test]
347 fn test_top_k_fewer_than_k_entries() {
348 let mut tracker = StorageHeatmapTracker::new(0.9);
349 tracker.record_access("only", 10.0);
350 let top = tracker.top_k(5);
351 assert_eq!(top.len(), 1);
352 }
353
354 #[test]
355 fn test_top_k_returns_exactly_k() {
356 let mut tracker = StorageHeatmapTracker::new(0.9);
357 for i in 0..10u64 {
358 tracker.record_access(&format!("cid-{i}"), i as f64);
359 }
360 let top = tracker.top_k(3);
361 assert_eq!(top.len(), 3);
362 }
363
364 #[test]
367 fn test_evict_cold_removes_low_score_entries() {
368 let mut tracker = StorageHeatmapTracker::new(0.9);
369 tracker.record_access("keep", 10.0);
370 tracker.record_access("remove", 0.5);
371 let removed = tracker.evict_cold(1.0);
372 assert_eq!(removed, 1);
373 assert!(tracker.entries.contains_key("keep"));
374 assert!(!tracker.entries.contains_key("remove"));
375 }
376
377 #[test]
378 fn test_evict_cold_keeps_exactly_at_threshold() {
379 let mut tracker = StorageHeatmapTracker::new(0.9);
380 tracker.record_access("exact", 1.0);
381 let removed = tracker.evict_cold(1.0);
382 assert_eq!(removed, 0);
383 assert!(tracker.entries.contains_key("exact"));
384 }
385
386 #[test]
387 fn test_new_entry_after_eviction() {
388 let mut tracker = StorageHeatmapTracker::new(0.9);
389 tracker.record_access("cid-x", 0.1);
390 tracker.evict_cold(1.0);
391 assert!(!tracker.entries.contains_key("cid-x"));
392 tracker.record_access("cid-x", 5.0);
394 let entry = tracker
395 .entries
396 .get("cid-x")
397 .expect("entry must exist after re-add");
398 assert!((entry.score - 5.0).abs() < 1e-9);
399 assert_eq!(entry.access_count, 1);
400 }
401
402 #[test]
405 fn test_stats_counts_per_bucket() {
406 let mut tracker = StorageHeatmapTracker::new(0.9);
407 tracker.record_access("scorching", 200.0);
408 tracker.record_access("hot", 50.0);
409 tracker.record_access("warm", 5.0);
410 tracker.record_access("cold", 0.1);
411 let stats = tracker.stats();
412 assert_eq!(stats.total_entries, 4);
413 assert_eq!(stats.scorching, 1);
414 assert_eq!(stats.hot, 1);
415 assert_eq!(stats.warm, 1);
416 assert_eq!(stats.cold, 1);
417 assert_eq!(stats.total_accesses, 4);
418 }
419
420 #[test]
423 fn test_coverage_ratio_basic() {
424 let stats = HeatmapStats {
425 total_entries: 10,
426 scorching: 1,
427 hot: 2,
428 warm: 3,
429 cold: 4,
430 total_accesses: 20,
431 };
432 let ratio = stats.coverage_ratio(3);
433 assert!((ratio - 0.3).abs() < 1e-9);
434 }
435
436 #[test]
437 fn test_coverage_ratio_zero_entries_does_not_divide_by_zero() {
438 let stats = HeatmapStats {
439 total_entries: 0,
440 scorching: 0,
441 hot: 0,
442 warm: 0,
443 cold: 0,
444 total_accesses: 0,
445 };
446 let ratio = stats.coverage_ratio(5);
448 assert!((ratio - 5.0).abs() < 1e-9);
449 }
450
451 #[test]
454 fn test_decay_all_applies_decay_to_all_entries() {
455 let mut tracker = StorageHeatmapTracker::new(0.9);
456 tracker.record_access("a", 100.0);
457 tracker.record_access("b", 50.0);
458 tracker.advance_tick();
459 tracker.advance_tick();
460 tracker.decay_all();
461 let a = tracker.entries.get("a").expect("a must exist");
462 let b = tracker.entries.get("b").expect("b must exist");
463 let expected_a = 100.0_f64 * 0.9_f64.powf(2.0);
464 let expected_b = 50.0_f64 * 0.9_f64.powf(2.0);
465 assert!((a.score - expected_a).abs() < 1e-9);
466 assert!((b.score - expected_b).abs() < 1e-9);
467 assert_eq!(a.last_accessed_tick, 2);
469 assert_eq!(b.last_accessed_tick, 2);
470 }
471
472 #[test]
473 fn test_decay_all_second_call_same_tick_is_noop() {
474 let mut tracker = StorageHeatmapTracker::new(0.9);
475 tracker.record_access("a", 100.0);
476 tracker.advance_tick();
477 tracker.decay_all();
478 let score_after_first = tracker.entries["a"].score;
479 tracker.decay_all();
480 let score_after_second = tracker.entries["a"].score;
481 assert!((score_after_first - score_after_second).abs() < 1e-9);
482 }
483
484 #[test]
487 fn test_heat_bucket_ordering() {
488 assert!(HeatBucket::Cold < HeatBucket::Warm);
489 assert!(HeatBucket::Warm < HeatBucket::Hot);
490 assert!(HeatBucket::Hot < HeatBucket::Scorching);
491 }
492}