Skip to main content

ipfrs_storage/
heatmap_tracker.rs

1//! Storage Heatmap Tracker
2//!
3//! Tracks per-block access heat scores over time using exponential decay,
4//! producing a ranked heatmap for prefetch and cache prioritization.
5
6use std::collections::HashMap;
7
8/// Heat classification bucket for a block.
9#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
10pub enum HeatBucket {
11    /// score < 1.0
12    Cold = 0,
13    /// 1.0 <= score < 10.0
14    Warm = 1,
15    /// 10.0 <= score < 100.0
16    Hot = 2,
17    /// score >= 100.0
18    Scorching = 3,
19}
20
21impl HeatBucket {
22    /// Classify a heat score into a bucket.
23    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/// A single block's heat tracking record.
37#[derive(Clone, Debug)]
38pub struct HeatEntry {
39    /// Content identifier for the block.
40    pub cid: String,
41    /// Accumulated heat score (decays over time).
42    pub score: f64,
43    /// Total number of times this block was accessed.
44    pub access_count: u64,
45    /// The tick at which this block was last accessed.
46    pub last_accessed_tick: u64,
47}
48
49impl HeatEntry {
50    /// Return the heat bucket classification for this entry's current score.
51    pub fn bucket(&self) -> HeatBucket {
52        HeatBucket::from_score(self.score)
53    }
54
55    /// Apply exponential decay for `ticks_elapsed` ticks.
56    ///
57    /// `score *= decay_factor.powf(ticks_elapsed as f64)`
58    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/// Aggregate statistics over all tracked entries.
64#[derive(Clone, Debug, PartialEq)]
65pub struct HeatmapStats {
66    /// Total number of tracked entries.
67    pub total_entries: usize,
68    /// Number of entries in the Scorching bucket.
69    pub scorching: usize,
70    /// Number of entries in the Hot bucket.
71    pub hot: usize,
72    /// Number of entries in the Warm bucket.
73    pub warm: usize,
74    /// Number of entries in the Cold bucket.
75    pub cold: usize,
76    /// Sum of access_count across all entries.
77    pub total_accesses: u64,
78}
79
80impl HeatmapStats {
81    /// Fraction of total entries represented by the top-k.
82    ///
83    /// `top_k / total_entries.max(1) as f64`
84    pub fn coverage_ratio(&self, top_k: usize) -> f64 {
85        top_k as f64 / self.total_entries.max(1) as f64
86    }
87}
88
89/// Tracks per-block access heat scores over time using exponential decay.
90pub struct StorageHeatmapTracker {
91    /// All tracked entries, keyed by CID.
92    pub entries: HashMap<String, HeatEntry>,
93    /// Per-tick decay factor (e.g. 0.9 means score *= 0.9 each tick).
94    pub decay_factor: f64,
95    /// Current logical time tick.
96    pub current_tick: u64,
97}
98
99impl StorageHeatmapTracker {
100    /// Create a new tracker with the given per-tick decay factor.
101    pub fn new(decay_factor: f64) -> Self {
102        Self {
103            entries: HashMap::new(),
104            decay_factor,
105            current_tick: 0,
106        }
107    }
108
109    /// Record an access to `cid`, adding `heat_gain` to its score.
110    ///
111    /// If the entry already exists, lazy decay is applied for the ticks that
112    /// elapsed since the last access before adding the gain. If the entry is
113    /// new it is created with `score = heat_gain`.
114    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    /// Advance the logical clock by one tick.
138    ///
139    /// Decay is applied lazily on the next access; use `decay_all` to force
140    /// an eager decay pass across all entries.
141    pub fn advance_tick(&mut self) {
142        self.current_tick += 1;
143    }
144
145    /// Return the top-`k` entries by score, in descending order.
146    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    /// Remove all entries whose score is strictly less than `min_score`.
158    ///
159    /// Returns the number of entries removed.
160    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    /// Compute aggregate statistics over all current entries.
167    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    /// Eagerly apply decay to every entry relative to `current_tick`.
195    ///
196    /// After this call, every entry's `last_accessed_tick` is updated to
197    /// `current_tick` so that a second call in the same tick is a no-op.
198    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    // ── HeatBucket::from_score ────────────────────────────────────────────────
215
216    #[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    // ── HeatEntry ────────────────────────────────────────────────────────────
243
244    #[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        // 100.0 * 0.9^1 = 90.0
265        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    // ── StorageHeatmapTracker::record_access ─────────────────────────────────
282
283    #[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        // second access: score was 3.0, decayed 0 ticks → still 3.0, then +7.0 = 10.0
300        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        // advance 2 ticks without touching the entry
309        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    // ── StorageHeatmapTracker::advance_tick ──────────────────────────────────
320
321    #[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    // ── StorageHeatmapTracker::top_k ─────────────────────────────────────────
332
333    #[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    // ── StorageHeatmapTracker::evict_cold ────────────────────────────────────
365
366    #[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        // re-add after eviction
393        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    // ── StorageHeatmapTracker::stats ─────────────────────────────────────────
403
404    #[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    // ── HeatmapStats::coverage_ratio ─────────────────────────────────────────
421
422    #[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        // total_entries.max(1) == 1, so top_k / 1
447        let ratio = stats.coverage_ratio(5);
448        assert!((ratio - 5.0).abs() < 1e-9);
449    }
450
451    // ── StorageHeatmapTracker::decay_all ─────────────────────────────────────
452
453    #[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        // last_accessed_tick must be updated so a second decay_all is a no-op
468        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    // ── HeatBucket ordering ───────────────────────────────────────────────────
485
486    #[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}