Skip to main content

ipfrs_storage/
hotspot_detector.rs

1//! Storage hotspot detector using sliding window access counting and exponential decay scoring.
2//!
3//! Identifies frequently accessed blocks for prefetching or tier promotion by
4//! maintaining per-block access records and decaying scores over logical time ticks.
5
6use std::collections::HashMap;
7
8// ──────────────────────────────────────────────────────────────────────────────
9// Public types
10// ──────────────────────────────────────────────────────────────────────────────
11
12/// The type of a storage block access operation.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum AccessType {
15    /// A standard read from the block.
16    Read,
17    /// A write to (or creation of) the block.
18    Write,
19    /// A speculative prefetch of the block.
20    Prefetch,
21}
22
23/// A single access event for a specific block at a given logical tick.
24#[derive(Clone, Debug)]
25pub struct AccessEvent {
26    /// Numeric identifier of the block being accessed.
27    pub block_id: u64,
28    /// Logical clock tick at which the access occurred.
29    pub tick: u64,
30    /// The kind of access that occurred.
31    pub access_type: AccessType,
32}
33
34/// Computed hotness score for a single block.
35#[derive(Clone, Debug)]
36pub struct HotspotScore {
37    /// Numeric identifier of the block.
38    pub block_id: u64,
39    /// Decay-adjusted hotness score; higher values indicate hotter blocks.
40    pub score: f64,
41    /// Number of accesses that fall within the current sliding window.
42    pub recent_accesses: u32,
43    /// Whether the block's score meets or exceeds the configured hotspot threshold.
44    pub is_hotspot: bool,
45}
46
47/// Configuration for a [`StorageHotspotDetector`].
48#[derive(Clone, Debug)]
49pub struct DetectorConfig {
50    /// Length of the sliding window in logical ticks.
51    pub window_ticks: u64,
52    /// Minimum score required to classify a block as a hotspot.
53    pub hotspot_threshold: f64,
54    /// Multiplicative decay applied per elapsed tick (must be in `(0, 1]`).
55    pub decay_factor: f64,
56    /// Score contribution added for each [`AccessType::Read`] event.
57    pub read_weight: f64,
58    /// Score contribution added for each [`AccessType::Write`] event.
59    pub write_weight: f64,
60    /// Score contribution added for each [`AccessType::Prefetch`] event.
61    pub prefetch_weight: f64,
62}
63
64impl Default for DetectorConfig {
65    fn default() -> Self {
66        Self {
67            window_ticks: 100,
68            hotspot_threshold: 5.0,
69            decay_factor: 0.95,
70            read_weight: 1.0,
71            write_weight: 1.5,
72            prefetch_weight: 0.5,
73        }
74    }
75}
76
77/// Aggregate statistics reported by a [`StorageHotspotDetector`].
78#[derive(Clone, Debug)]
79pub struct HotspotDetectorStats {
80    /// Total number of distinct blocks currently being tracked.
81    pub total_blocks_tracked: usize,
82    /// Number of those blocks currently classified as hotspots.
83    pub hotspot_count: usize,
84    /// Cumulative count of access events recorded since detector creation.
85    pub total_events_recorded: u64,
86}
87
88/// Internal per-block access record (public to allow external inspection).
89#[derive(Clone, Debug)]
90pub struct BlockAccessRecord {
91    /// Numeric identifier of the tracked block.
92    pub block_id: u64,
93    /// Current accumulated (possibly already decayed) score.
94    pub score: f64,
95    /// All access events still within the sliding window.
96    pub events: Vec<AccessEvent>,
97    /// The tick at which decay was last applied.
98    pub last_decay_tick: u64,
99}
100
101// ──────────────────────────────────────────────────────────────────────────────
102// Detector
103// ──────────────────────────────────────────────────────────────────────────────
104
105/// Detects storage hotspots using sliding-window access counting and exponential decay scoring.
106///
107/// # Algorithm
108///
109/// Each block maintains a floating-point score that grows whenever an access is
110/// recorded (weighted by [`AccessType`]) and shrinks exponentially as logical
111/// time elapses (`score *= decay_factor^ticks_elapsed`).  Events older than
112/// `window_ticks` are purged to bound memory usage.  A block is classified as a
113/// hotspot when its decay-adjusted score equals or exceeds `hotspot_threshold`.
114pub struct StorageHotspotDetector {
115    /// Per-block access records, keyed by block identifier.
116    pub records: HashMap<u64, BlockAccessRecord>,
117    /// Detector configuration.
118    pub config: DetectorConfig,
119    /// Total number of access events recorded since creation.
120    pub total_events: u64,
121}
122
123impl StorageHotspotDetector {
124    /// Create a new detector with the supplied configuration.
125    pub fn new(config: DetectorConfig) -> Self {
126        Self {
127            records: HashMap::new(),
128            config,
129            total_events: 0,
130        }
131    }
132
133    /// Record a block access event.
134    ///
135    /// The record for `event.block_id` is created on first access.  Decay is
136    /// applied for every tick that has elapsed since the last recorded event,
137    /// events older than the window are evicted, and the event weight is added
138    /// to the score.
139    pub fn record_access(&mut self, event: AccessEvent) {
140        let block_id = event.block_id;
141        let tick = event.tick;
142
143        // Obtain or initialise the block record.
144        let record = self
145            .records
146            .entry(block_id)
147            .or_insert_with(|| BlockAccessRecord {
148                block_id,
149                score: 0.0,
150                events: Vec::new(),
151                last_decay_tick: tick,
152            });
153
154        // Apply exponential decay for elapsed ticks.
155        if tick > record.last_decay_tick {
156            let ticks_elapsed = tick - record.last_decay_tick;
157            // Use manual loop for integer exponentiation to avoid precision pitfalls.
158            let decay = pow_f64(self.config.decay_factor, ticks_elapsed);
159            record.score *= decay;
160            record.last_decay_tick = tick;
161        }
162
163        // Evict events that have fallen outside the sliding window.
164        let window_start = tick.saturating_sub(self.config.window_ticks);
165        record.events.retain(|e| e.tick >= window_start);
166
167        // Accumulate score for this event.
168        let weight = match event.access_type {
169            AccessType::Read => self.config.read_weight,
170            AccessType::Write => self.config.write_weight,
171            AccessType::Prefetch => self.config.prefetch_weight,
172        };
173        record.score += weight;
174
175        // Store the event.
176        record.events.push(event);
177
178        self.total_events += 1;
179    }
180
181    /// Return the decay-adjusted [`HotspotScore`] for `block_id` as of `current_tick`.
182    ///
183    /// Returns `None` if the block has never been tracked.
184    pub fn hotspot_score(&self, block_id: u64, current_tick: u64) -> Option<HotspotScore> {
185        let record = self.records.get(&block_id)?;
186
187        let decayed_score = if current_tick > record.last_decay_tick {
188            let ticks_elapsed = current_tick - record.last_decay_tick;
189            record.score * pow_f64(self.config.decay_factor, ticks_elapsed)
190        } else {
191            record.score
192        };
193
194        let window_start = current_tick.saturating_sub(self.config.window_ticks);
195        let recent_accesses = record
196            .events
197            .iter()
198            .filter(|e| e.tick >= window_start)
199            .count() as u32;
200
201        let is_hotspot = decayed_score >= self.config.hotspot_threshold;
202
203        Some(HotspotScore {
204            block_id,
205            score: decayed_score,
206            recent_accesses,
207            is_hotspot,
208        })
209    }
210
211    /// Return all blocks currently classified as hotspots, sorted by score descending.
212    pub fn hotspots(&self, current_tick: u64) -> Vec<HotspotScore> {
213        let mut scores: Vec<HotspotScore> = self
214            .records
215            .keys()
216            .filter_map(|&id| self.hotspot_score(id, current_tick))
217            .filter(|s| s.is_hotspot)
218            .collect();
219
220        scores.sort_by(|a, b| {
221            b.score
222                .partial_cmp(&a.score)
223                .unwrap_or(std::cmp::Ordering::Equal)
224        });
225        scores
226    }
227
228    /// Return the top `n` blocks ordered by decay-adjusted score descending.
229    ///
230    /// Non-hotspot blocks are included; the caller must check `is_hotspot` if
231    /// only confirmed hotspots are required.
232    pub fn top_n(&self, n: usize, current_tick: u64) -> Vec<HotspotScore> {
233        let mut scores: Vec<HotspotScore> = self
234            .records
235            .keys()
236            .filter_map(|&id| self.hotspot_score(id, current_tick))
237            .collect();
238
239        scores.sort_by(|a, b| {
240            b.score
241                .partial_cmp(&a.score)
242                .unwrap_or(std::cmp::Ordering::Equal)
243        });
244        scores.truncate(n);
245        scores
246    }
247
248    /// Remove all block records whose decay-adjusted score has fallen below `0.001`.
249    ///
250    /// Returns the number of records that were removed.
251    pub fn evict_cold(&mut self, current_tick: u64) -> usize {
252        const COLD_THRESHOLD: f64 = 0.001;
253
254        let cold_ids: Vec<u64> = self
255            .records
256            .iter()
257            .filter_map(|(&id, record)| {
258                let decayed = if current_tick > record.last_decay_tick {
259                    let elapsed = current_tick - record.last_decay_tick;
260                    record.score * pow_f64(self.config.decay_factor, elapsed)
261                } else {
262                    record.score
263                };
264                if decayed < COLD_THRESHOLD {
265                    Some(id)
266                } else {
267                    None
268                }
269            })
270            .collect();
271
272        let removed = cold_ids.len();
273        for id in cold_ids {
274            self.records.remove(&id);
275        }
276        removed
277    }
278
279    /// Return aggregate statistics for the current state of the detector.
280    pub fn stats(&self, current_tick: u64) -> HotspotDetectorStats {
281        let total_blocks_tracked = self.records.len();
282        let hotspot_count = self
283            .records
284            .keys()
285            .filter_map(|&id| self.hotspot_score(id, current_tick))
286            .filter(|s| s.is_hotspot)
287            .count();
288
289        HotspotDetectorStats {
290            total_blocks_tracked,
291            hotspot_count,
292            total_events_recorded: self.total_events,
293        }
294    }
295}
296
297// ──────────────────────────────────────────────────────────────────────────────
298// Helper
299// ──────────────────────────────────────────────────────────────────────────────
300
301/// Raise `base` to an integer power using repeated multiplication.
302///
303/// Preferred over `f64::powi` for clarity and to avoid any sign-convention
304/// surprises with very large exponents.
305#[inline]
306fn pow_f64(base: f64, exp: u64) -> f64 {
307    // Fast path for common cases.
308    match exp {
309        0 => 1.0,
310        1 => base,
311        _ => {
312            let mut result = 1.0_f64;
313            let mut b = base;
314            let mut e = exp;
315            // Binary exponentiation.
316            while e > 0 {
317                if e & 1 == 1 {
318                    result *= b;
319                }
320                b *= b;
321                e >>= 1;
322            }
323            result
324        }
325    }
326}
327
328// ──────────────────────────────────────────────────────────────────────────────
329// Tests
330// ──────────────────────────────────────────────────────────────────────────────
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    fn default_detector() -> StorageHotspotDetector {
337        StorageHotspotDetector::new(DetectorConfig::default())
338    }
339
340    fn make_event(block_id: u64, tick: u64, access_type: AccessType) -> AccessEvent {
341        AccessEvent {
342            block_id,
343            tick,
344            access_type,
345        }
346    }
347
348    // ── 1. record_access creates a record ─────────────────────────────────────
349
350    #[test]
351    fn test_record_creates_entry() {
352        let mut det = default_detector();
353        assert!(det.records.is_empty());
354        det.record_access(make_event(42, 0, AccessType::Read));
355        assert_eq!(det.records.len(), 1);
356        assert!(det.records.contains_key(&42));
357    }
358
359    // ── 2. Score increases with accesses ──────────────────────────────────────
360
361    #[test]
362    fn test_score_increases_with_reads() {
363        let mut det = default_detector();
364        det.record_access(make_event(1, 0, AccessType::Read));
365        let s1 = det.hotspot_score(1, 0).unwrap().score;
366        det.record_access(make_event(1, 0, AccessType::Read));
367        let s2 = det.hotspot_score(1, 0).unwrap().score;
368        assert!(s2 > s1, "score should grow: {s2} > {s1}");
369    }
370
371    #[test]
372    fn test_write_weight_higher_than_read() {
373        let mut det = default_detector();
374        det.record_access(make_event(1, 0, AccessType::Read));
375        let read_score = det.hotspot_score(1, 0).unwrap().score;
376
377        let mut det2 = default_detector();
378        det2.record_access(make_event(2, 0, AccessType::Write));
379        let write_score = det2.hotspot_score(2, 0).unwrap().score;
380
381        assert!(
382            write_score > read_score,
383            "write ({write_score}) > read ({read_score})"
384        );
385    }
386
387    #[test]
388    fn test_prefetch_weight_lower_than_read() {
389        let mut det = default_detector();
390        det.record_access(make_event(1, 0, AccessType::Read));
391        let read_score = det.hotspot_score(1, 0).unwrap().score;
392
393        let mut det2 = default_detector();
394        det2.record_access(make_event(2, 0, AccessType::Prefetch));
395        let prefetch_score = det2.hotspot_score(2, 0).unwrap().score;
396
397        assert!(
398            prefetch_score < read_score,
399            "prefetch ({prefetch_score}) < read ({read_score})"
400        );
401    }
402
403    // ── 3. Explicit weight values ──────────────────────────────────────────────
404
405    #[test]
406    fn test_read_weight_exact() {
407        let mut det = default_detector();
408        det.record_access(make_event(5, 10, AccessType::Read));
409        let score = det.hotspot_score(5, 10).unwrap().score;
410        assert!(
411            (score - 1.0).abs() < 1e-10,
412            "read weight should be 1.0, got {score}"
413        );
414    }
415
416    #[test]
417    fn test_write_weight_exact() {
418        let mut det = default_detector();
419        det.record_access(make_event(5, 10, AccessType::Write));
420        let score = det.hotspot_score(5, 10).unwrap().score;
421        assert!(
422            (score - 1.5).abs() < 1e-10,
423            "write weight should be 1.5, got {score}"
424        );
425    }
426
427    #[test]
428    fn test_prefetch_weight_exact() {
429        let mut det = default_detector();
430        det.record_access(make_event(5, 10, AccessType::Prefetch));
431        let score = det.hotspot_score(5, 10).unwrap().score;
432        assert!(
433            (score - 0.5).abs() < 1e-10,
434            "prefetch weight should be 0.5, got {score}"
435        );
436    }
437
438    // ── 4. Decay applied based on ticks elapsed ────────────────────────────────
439
440    #[test]
441    fn test_decay_reduces_score_over_time() {
442        let mut det = default_detector();
443        det.record_access(make_event(1, 0, AccessType::Read));
444        let score_at_0 = det.hotspot_score(1, 0).unwrap().score;
445        let score_at_10 = det.hotspot_score(1, 10).unwrap().score;
446        assert!(
447            score_at_10 < score_at_0,
448            "score should decay: {score_at_10} < {score_at_0}"
449        );
450    }
451
452    #[test]
453    fn test_decay_factor_applied_correctly() {
454        let config = DetectorConfig {
455            decay_factor: 0.5,
456            ..Default::default()
457        };
458        let mut det = StorageHotspotDetector::new(config);
459        det.record_access(make_event(1, 0, AccessType::Read)); // score = 1.0
460                                                               // After 3 ticks: 1.0 * 0.5^3 = 0.125
461        let score = det.hotspot_score(1, 3).unwrap().score;
462        assert!((score - 0.125).abs() < 1e-10, "expected 0.125, got {score}");
463    }
464
465    #[test]
466    fn test_decay_applied_on_new_event() {
467        let config = DetectorConfig {
468            decay_factor: 0.5,
469            ..Default::default()
470        };
471        let mut det = StorageHotspotDetector::new(config);
472        det.record_access(make_event(1, 0, AccessType::Read)); // score = 1.0
473                                                               // Record again at tick 2: decay first (1.0 * 0.25 = 0.25), then add 1.0 → 1.25
474        det.record_access(make_event(1, 2, AccessType::Read));
475        let score = det.hotspot_score(1, 2).unwrap().score;
476        assert!((score - 1.25).abs() < 1e-10, "expected 1.25, got {score}");
477    }
478
479    #[test]
480    fn test_no_decay_when_tick_unchanged() {
481        let mut det = default_detector();
482        det.record_access(make_event(7, 5, AccessType::Read));
483        det.record_access(make_event(7, 5, AccessType::Read));
484        let score = det.hotspot_score(7, 5).unwrap().score;
485        // Two reads at same tick: 1.0 + 1.0 = 2.0
486        assert!((score - 2.0).abs() < 1e-10, "expected 2.0, got {score}");
487    }
488
489    // ── 5. Window eviction removes old events ─────────────────────────────────
490
491    #[test]
492    fn test_window_eviction_removes_old_events() {
493        let config = DetectorConfig {
494            window_ticks: 10,
495            ..Default::default()
496        };
497        let mut det = StorageHotspotDetector::new(config);
498        det.record_access(make_event(1, 0, AccessType::Read));
499        det.record_access(make_event(1, 5, AccessType::Read));
500        // Trigger eviction by recording at tick 15 (window=[5,15], tick 0 falls out)
501        det.record_access(make_event(1, 15, AccessType::Read));
502        let record = &det.records[&1];
503        // Only events at tick >= 15-10=5 remain, i.e. ticks 5 and 15.
504        assert_eq!(record.events.len(), 2, "events at tick 0 should be evicted");
505    }
506
507    #[test]
508    fn test_recent_accesses_counts_window_only() {
509        let config = DetectorConfig {
510            window_ticks: 10,
511            ..Default::default()
512        };
513        let mut det = StorageHotspotDetector::new(config);
514        det.record_access(make_event(1, 0, AccessType::Read));
515        det.record_access(make_event(1, 1, AccessType::Read));
516        det.record_access(make_event(1, 5, AccessType::Read));
517        // At tick 15: window=[5,15], so only the event at tick 5 is recent.
518        let hs = det.hotspot_score(1, 15).unwrap();
519        assert_eq!(hs.recent_accesses, 1);
520    }
521
522    // ── 6. hotspot_score returns None for untracked ────────────────────────────
523
524    #[test]
525    fn test_hotspot_score_none_for_unknown() {
526        let det = default_detector();
527        assert!(det.hotspot_score(999, 0).is_none());
528    }
529
530    // ── 7. is_hotspot based on threshold ──────────────────────────────────────
531
532    #[test]
533    fn test_is_hotspot_false_below_threshold() {
534        let mut det = default_detector(); // threshold = 5.0
535        det.record_access(make_event(1, 0, AccessType::Read)); // score = 1.0
536        let hs = det.hotspot_score(1, 0).unwrap();
537        assert!(!hs.is_hotspot);
538    }
539
540    #[test]
541    fn test_is_hotspot_true_above_threshold() {
542        let mut det = default_detector(); // threshold = 5.0
543        for _ in 0..6 {
544            det.record_access(make_event(1, 0, AccessType::Read)); // +6.0
545        }
546        let hs = det.hotspot_score(1, 0).unwrap();
547        assert!(hs.is_hotspot, "score {} should be >= 5.0", hs.score);
548    }
549
550    #[test]
551    fn test_is_hotspot_exactly_at_threshold() {
552        let config = DetectorConfig {
553            hotspot_threshold: 3.0,
554            read_weight: 3.0,
555            ..Default::default()
556        };
557        let mut det = StorageHotspotDetector::new(config);
558        det.record_access(make_event(1, 0, AccessType::Read)); // score = 3.0
559        let hs = det.hotspot_score(1, 0).unwrap();
560        assert!(hs.is_hotspot, "score == threshold should be hotspot");
561    }
562
563    // ── 8. hotspots returns sorted list ───────────────────────────────────────
564
565    #[test]
566    fn test_hotspots_sorted_descending() {
567        let mut det = default_detector(); // threshold = 5.0
568                                          // block 10: 8 reads → 8.0
569        for _ in 0..8 {
570            det.record_access(make_event(10, 0, AccessType::Read));
571        }
572        // block 20: 6 reads → 6.0
573        for _ in 0..6 {
574            det.record_access(make_event(20, 0, AccessType::Read));
575        }
576        // block 30: 3 reads → 3.0 (not a hotspot)
577        for _ in 0..3 {
578            det.record_access(make_event(30, 0, AccessType::Read));
579        }
580        let hs = det.hotspots(0);
581        assert_eq!(hs.len(), 2, "only blocks 10 and 20 should be hotspots");
582        assert_eq!(hs[0].block_id, 10);
583        assert_eq!(hs[1].block_id, 20);
584        assert!(hs[0].score >= hs[1].score);
585    }
586
587    #[test]
588    fn test_hotspots_empty_when_none_qualify() {
589        let mut det = default_detector();
590        det.record_access(make_event(1, 0, AccessType::Read));
591        let hs = det.hotspots(0);
592        assert!(hs.is_empty());
593    }
594
595    // ── 9. top_n returns top n blocks ─────────────────────────────────────────
596
597    #[test]
598    fn test_top_n_returns_correct_count() {
599        let mut det = default_detector();
600        for id in 0..10_u64 {
601            for _ in 0..((id + 1) as usize) {
602                det.record_access(make_event(id, 0, AccessType::Read));
603            }
604        }
605        let top = det.top_n(3, 0);
606        assert_eq!(top.len(), 3);
607    }
608
609    #[test]
610    fn test_top_n_sorted_descending() {
611        let mut det = default_detector();
612        for id in 0..5_u64 {
613            for _ in 0..((id + 1) as usize) {
614                det.record_access(make_event(id, 0, AccessType::Read));
615            }
616        }
617        let top = det.top_n(5, 0);
618        for w in top.windows(2) {
619            assert!(w[0].score >= w[1].score);
620        }
621    }
622
623    #[test]
624    fn test_top_n_fewer_than_n_blocks() {
625        let mut det = default_detector();
626        det.record_access(make_event(1, 0, AccessType::Read));
627        det.record_access(make_event(2, 0, AccessType::Read));
628        let top = det.top_n(10, 0);
629        assert_eq!(top.len(), 2);
630    }
631
632    #[test]
633    fn test_top_n_zero_returns_empty() {
634        let mut det = default_detector();
635        det.record_access(make_event(1, 0, AccessType::Read));
636        let top = det.top_n(0, 0);
637        assert!(top.is_empty());
638    }
639
640    // ── 10. evict_cold removes cold blocks ───────────────────────────────────
641
642    #[test]
643    fn test_evict_cold_removes_stale_blocks() {
644        let config = DetectorConfig {
645            decay_factor: 0.5,
646            ..Default::default()
647        };
648        let mut det = StorageHotspotDetector::new(config);
649        det.record_access(make_event(1, 0, AccessType::Read)); // score = 1.0
650                                                               // After 20 ticks at 0.5 decay: 1.0 * 0.5^20 ≈ 9.5e-7 < 0.001
651        let removed = det.evict_cold(20);
652        assert_eq!(removed, 1);
653        assert!(det.records.is_empty());
654    }
655
656    #[test]
657    fn test_evict_cold_keeps_warm_blocks() {
658        let mut det = default_detector();
659        for _ in 0..10 {
660            det.record_access(make_event(1, 0, AccessType::Read)); // score = 10.0
661        }
662        let removed = det.evict_cold(0);
663        assert_eq!(removed, 0);
664        assert_eq!(det.records.len(), 1);
665    }
666
667    #[test]
668    fn test_evict_cold_returns_count() {
669        let config = DetectorConfig {
670            decay_factor: 0.1,
671            ..Default::default()
672        };
673        let mut det = StorageHotspotDetector::new(config);
674        det.record_access(make_event(1, 0, AccessType::Read));
675        det.record_access(make_event(2, 0, AccessType::Read));
676        det.record_access(make_event(3, 0, AccessType::Read));
677        // 0.1^10 < 0.001 for all three
678        let removed = det.evict_cold(10);
679        assert_eq!(removed, 3);
680    }
681
682    // ── 11. stats ─────────────────────────────────────────────────────────────
683
684    #[test]
685    fn test_stats_total_events() {
686        let mut det = default_detector();
687        det.record_access(make_event(1, 0, AccessType::Read));
688        det.record_access(make_event(2, 0, AccessType::Write));
689        det.record_access(make_event(1, 1, AccessType::Read));
690        let s = det.stats(1);
691        assert_eq!(s.total_events_recorded, 3);
692    }
693
694    #[test]
695    fn test_stats_blocks_tracked() {
696        let mut det = default_detector();
697        det.record_access(make_event(1, 0, AccessType::Read));
698        det.record_access(make_event(2, 0, AccessType::Read));
699        det.record_access(make_event(1, 0, AccessType::Read)); // duplicate block
700        let s = det.stats(0);
701        assert_eq!(s.total_blocks_tracked, 2);
702    }
703
704    #[test]
705    fn test_stats_hotspot_count() {
706        let mut det = default_detector(); // threshold = 5.0
707        for _ in 0..6 {
708            det.record_access(make_event(1, 0, AccessType::Read));
709        }
710        det.record_access(make_event(2, 0, AccessType::Read)); // score = 1.0, cold
711        let s = det.stats(0);
712        assert_eq!(s.hotspot_count, 1);
713    }
714
715    // ── 12. Custom weights ────────────────────────────────────────────────────
716
717    #[test]
718    fn test_custom_weights_affect_score() {
719        let config = DetectorConfig {
720            read_weight: 2.0,
721            write_weight: 3.0,
722            prefetch_weight: 0.1,
723            ..Default::default()
724        };
725        let mut det = StorageHotspotDetector::new(config);
726        det.record_access(make_event(1, 0, AccessType::Read));
727        det.record_access(make_event(2, 0, AccessType::Write));
728        det.record_access(make_event(3, 0, AccessType::Prefetch));
729
730        let r = det.hotspot_score(1, 0).unwrap().score;
731        let w = det.hotspot_score(2, 0).unwrap().score;
732        let p = det.hotspot_score(3, 0).unwrap().score;
733
734        assert!((r - 2.0).abs() < 1e-10, "read: {r}");
735        assert!((w - 3.0).abs() < 1e-10, "write: {w}");
736        assert!((p - 0.1).abs() < 1e-10, "prefetch: {p}");
737    }
738
739    // ── 13. pow_f64 helper ────────────────────────────────────────────────────
740
741    #[test]
742    fn test_pow_f64_zero_exponent() {
743        assert!((super::pow_f64(0.95, 0) - 1.0).abs() < 1e-12);
744    }
745
746    #[test]
747    fn test_pow_f64_one_exponent() {
748        assert!((super::pow_f64(0.95, 1) - 0.95).abs() < 1e-12);
749    }
750
751    #[test]
752    fn test_pow_f64_large_exponent() {
753        let result = super::pow_f64(0.5, 10);
754        assert!((result - (0.5_f64).powi(10)).abs() < 1e-12);
755    }
756}