Skip to main content

ipfrs_storage/
access_predictor.rs

1//! Storage access predictor for proactive prefetching and cache warming.
2//!
3//! Analyzes historical block access sequences to predict future access patterns,
4//! enabling intelligent prefetch scheduling and cache management decisions.
5
6use std::collections::HashMap;
7
8// ──────────────────────────────────────────────────────────────────────────────
9// Public types
10// ──────────────────────────────────────────────────────────────────────────────
11
12/// Detected access pattern for a content-addressed block.
13#[derive(Clone, Debug, PartialEq)]
14pub enum AccessPattern {
15    /// Accesses follow a predictable, monotonically increasing sequence.
16    Sequential,
17    /// Block is accessed every `interval_ticks` ticks.
18    Repeated { interval_ticks: u64 },
19    /// Multiple accesses occur in short bursts (inter-access gap ≤ 5 ticks).
20    Bursty { burst_size: usize },
21    /// No detectable temporal pattern.
22    Random,
23    /// Access frequency is decreasing (intervals are strictly growing).
24    Cooling,
25}
26
27/// A single block access event recorded by the predictor.
28#[derive(Clone, Debug)]
29pub struct AccessEvent {
30    /// Content identifier of the block.
31    pub cid: String,
32    /// Logical clock tick at which the access occurred.
33    pub tick: u64,
34    /// Size of the block in bytes.
35    pub size_bytes: u64,
36}
37
38/// Result of a pattern prediction for a single CID.
39#[derive(Clone, Debug)]
40pub struct PredictionResult {
41    /// Content identifier of the block.
42    pub cid: String,
43    /// Detected access pattern.
44    pub pattern: AccessPattern,
45    /// Predicted tick of the next access; `None` for `Random` and `Cooling`.
46    pub next_access_tick: Option<u64>,
47    /// Confidence score in the range `[0.0, 1.0]`.
48    pub confidence: f32,
49}
50
51/// Aggregate statistics emitted by the predictor.
52#[derive(Clone, Debug, Default)]
53pub struct PredictorStats {
54    /// Total number of recorded access events across all CIDs.
55    pub total_events: u64,
56    /// Number of distinct CIDs currently tracked.
57    pub unique_cids: usize,
58    /// Number of CIDs whose last prediction was `Sequential`.
59    pub sequential_count: usize,
60    /// Number of CIDs whose last prediction was `Repeated`.
61    pub repeated_count: usize,
62    /// Number of CIDs whose last prediction was `Random`.
63    pub random_count: usize,
64}
65
66// ──────────────────────────────────────────────────────────────────────────────
67// StorageAccessPredictor
68// ──────────────────────────────────────────────────────────────────────────────
69
70/// Maximum number of access events retained per CID.
71const MAX_HISTORY: usize = 20;
72
73/// Predicts future block access patterns from historical access sequences.
74///
75/// Events are stored in a bounded per-CID ring (capped at `MAX_HISTORY`).
76/// The predictor derives an [`AccessPattern`] from the inter-access intervals
77/// and computes a confidence score for each prediction.
78pub struct StorageAccessPredictor {
79    /// Per-CID ordered list of access events (oldest first).
80    pub history: HashMap<String, Vec<AccessEvent>>,
81}
82
83impl StorageAccessPredictor {
84    /// Create a new, empty predictor.
85    pub fn new() -> Self {
86        Self {
87            history: HashMap::new(),
88        }
89    }
90
91    /// Record a new access event.
92    ///
93    /// Events are appended to the per-CID history.  When the history exceeds
94    /// `MAX_HISTORY` entries the oldest entry (index 0) is evicted.
95    pub fn record(&mut self, event: AccessEvent) {
96        let entries = self.history.entry(event.cid.clone()).or_default();
97        entries.push(event);
98        if entries.len() > MAX_HISTORY {
99            entries.remove(0);
100        }
101    }
102
103    /// Predict the access pattern for a given CID.
104    ///
105    /// Returns a [`PredictionResult`] with `pattern = Random` and
106    /// `confidence = 0.0` when no history is available, or `confidence = 0.1`
107    /// when only a single event has been recorded.
108    pub fn predict(&self, cid: &str) -> PredictionResult {
109        let no_history = || PredictionResult {
110            cid: cid.to_owned(),
111            pattern: AccessPattern::Random,
112            next_access_tick: None,
113            confidence: 0.0,
114        };
115
116        let entries = match self.history.get(cid) {
117            Some(v) if !v.is_empty() => v,
118            _ => return no_history(),
119        };
120
121        if entries.len() == 1 {
122            return PredictionResult {
123                cid: cid.to_owned(),
124                pattern: AccessPattern::Random,
125                next_access_tick: None,
126                confidence: 0.1,
127            };
128        }
129
130        // Compute successive intervals between recorded ticks.
131        let intervals: Vec<u64> = entries
132            .windows(2)
133            .map(|w| w[1].tick.saturating_sub(w[0].tick))
134            .collect();
135
136        let last_tick = entries.last().map(|e| e.tick).unwrap_or(0);
137
138        // ── 1. All intervals are identical → Repeated ────────────────────────
139        if intervals.iter().all(|&i| i == intervals[0]) {
140            let interval = intervals[0];
141            return PredictionResult {
142                cid: cid.to_owned(),
143                pattern: AccessPattern::Repeated {
144                    interval_ticks: interval,
145                },
146                next_access_tick: Some(last_tick + interval),
147                confidence: 0.9,
148            };
149        }
150
151        // ── 2. Intervals are strictly decreasing → Cooling ───────────────────
152        let strictly_decreasing = intervals.windows(2).all(|w| w[0] > w[1]);
153        if strictly_decreasing {
154            return PredictionResult {
155                cid: cid.to_owned(),
156                pattern: AccessPattern::Cooling,
157                next_access_tick: None,
158                confidence: 0.7,
159            };
160        }
161
162        // ── 3. All intervals ≤ 5 → Bursty ───────────────────────────────────
163        if intervals.iter().all(|&i| i <= 5) {
164            return PredictionResult {
165                cid: cid.to_owned(),
166                pattern: AccessPattern::Bursty {
167                    burst_size: entries.len(),
168                },
169                next_access_tick: None,
170                confidence: 0.6,
171            };
172        }
173
174        // ── 4. Non-decreasing intervals that vary by ≤ 10% → Sequential ─────
175        let non_decreasing = intervals.windows(2).all(|w| w[1] >= w[0]);
176        if non_decreasing {
177            let avg = intervals.iter().sum::<u64>() as f64 / intervals.len() as f64;
178            let max_interval = *intervals.iter().max().unwrap_or(&0);
179            let min_interval = *intervals.iter().min().unwrap_or(&0);
180            // Variation = (max - min) / avg
181            let variation = if avg > 0.0 {
182                (max_interval - min_interval) as f64 / avg
183            } else {
184                0.0
185            };
186            if variation <= 0.10 {
187                let avg_interval = avg.round() as u64;
188                return PredictionResult {
189                    cid: cid.to_owned(),
190                    pattern: AccessPattern::Sequential,
191                    next_access_tick: Some(last_tick + avg_interval),
192                    confidence: 0.75,
193                };
194            }
195        }
196
197        // ── 5. Fallback → Random ─────────────────────────────────────────────
198        PredictionResult {
199            cid: cid.to_owned(),
200            pattern: AccessPattern::Random,
201            next_access_tick: None,
202            confidence: 0.2,
203        }
204    }
205
206    /// Return predictions for all tracked CIDs whose pattern is `Sequential`
207    /// or `Repeated`, sorted by descending confidence.
208    pub fn top_predicted(&self) -> Vec<PredictionResult> {
209        let mut results: Vec<PredictionResult> = self
210            .history
211            .keys()
212            .map(|cid| self.predict(cid))
213            .filter(|r| {
214                matches!(
215                    r.pattern,
216                    AccessPattern::Sequential | AccessPattern::Repeated { .. }
217                )
218            })
219            .collect();
220
221        results.sort_by(|a, b| {
222            b.confidence
223                .partial_cmp(&a.confidence)
224                .unwrap_or(std::cmp::Ordering::Equal)
225        });
226        results
227    }
228
229    /// Compute aggregate statistics for the current state of the predictor.
230    pub fn stats(&self) -> PredictorStats {
231        let total_events: u64 = self.history.values().map(|v| v.len() as u64).sum();
232        let unique_cids = self.history.len();
233
234        let mut sequential_count = 0usize;
235        let mut repeated_count = 0usize;
236        let mut random_count = 0usize;
237
238        for cid in self.history.keys() {
239            match self.predict(cid).pattern {
240                AccessPattern::Sequential => sequential_count += 1,
241                AccessPattern::Repeated { .. } => repeated_count += 1,
242                AccessPattern::Random => random_count += 1,
243                _ => {}
244            }
245        }
246
247        PredictorStats {
248            total_events,
249            unique_cids,
250            sequential_count,
251            repeated_count,
252            random_count,
253        }
254    }
255}
256
257impl Default for StorageAccessPredictor {
258    fn default() -> Self {
259        Self::new()
260    }
261}
262
263// ──────────────────────────────────────────────────────────────────────────────
264// Tests
265// ──────────────────────────────────────────────────────────────────────────────
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    fn make_event(cid: &str, tick: u64) -> AccessEvent {
272        AccessEvent {
273            cid: cid.to_owned(),
274            tick,
275            size_bytes: 512,
276        }
277    }
278
279    // ── 1. new() starts empty ─────────────────────────────────────────────────
280    #[test]
281    fn test_new_starts_empty() {
282        let predictor = StorageAccessPredictor::new();
283        assert!(predictor.history.is_empty());
284    }
285
286    // ── 2. record stores event ────────────────────────────────────────────────
287    #[test]
288    fn test_record_stores_event() {
289        let mut p = StorageAccessPredictor::new();
290        p.record(make_event("cid-a", 1));
291        assert_eq!(p.history["cid-a"].len(), 1);
292        assert_eq!(p.history["cid-a"][0].tick, 1);
293    }
294
295    // ── 3. record caps at 20 events (oldest evicted) ──────────────────────────
296    #[test]
297    fn test_record_caps_at_20() {
298        let mut p = StorageAccessPredictor::new();
299        for i in 0..25u64 {
300            p.record(make_event("cid-a", i));
301        }
302        let entries = &p.history["cid-a"];
303        assert_eq!(entries.len(), 20);
304        // Oldest kept should be tick 5 (ticks 0-4 were evicted)
305        assert_eq!(entries[0].tick, 5);
306    }
307
308    // ── 4. predict unknown cid → Random confidence=0.0 ───────────────────────
309    #[test]
310    fn test_predict_unknown_cid() {
311        let p = StorageAccessPredictor::new();
312        let result = p.predict("unknown");
313        assert_eq!(result.pattern, AccessPattern::Random);
314        assert!((result.confidence - 0.0).abs() < f32::EPSILON);
315        assert!(result.next_access_tick.is_none());
316    }
317
318    // ── 5. predict single event → Random confidence=0.1 ──────────────────────
319    #[test]
320    fn test_predict_single_event() {
321        let mut p = StorageAccessPredictor::new();
322        p.record(make_event("cid-a", 10));
323        let result = p.predict("cid-a");
324        assert_eq!(result.pattern, AccessPattern::Random);
325        assert!((result.confidence - 0.1).abs() < f32::EPSILON);
326    }
327
328    // ── 6. predict equal intervals → Repeated ────────────────────────────────
329    #[test]
330    fn test_predict_equal_intervals_repeated() {
331        let mut p = StorageAccessPredictor::new();
332        for i in 0u64..5 {
333            p.record(make_event("cid-r", i * 10));
334        }
335        let result = p.predict("cid-r");
336        assert_eq!(
337            result.pattern,
338            AccessPattern::Repeated { interval_ticks: 10 }
339        );
340    }
341
342    // ── 7. predict Repeated: next_access = last_tick + interval ──────────────
343    #[test]
344    fn test_predict_repeated_next_access() {
345        let mut p = StorageAccessPredictor::new();
346        for i in 0u64..4 {
347            p.record(make_event("cid-r", i * 5));
348        }
349        let result = p.predict("cid-r");
350        // last_tick = 15, interval = 5 → next = 20
351        assert_eq!(result.next_access_tick, Some(20));
352    }
353
354    // ── 8. predict Repeated: confidence = 0.9 ────────────────────────────────
355    #[test]
356    fn test_predict_repeated_confidence() {
357        let mut p = StorageAccessPredictor::new();
358        for i in 0u64..3 {
359            p.record(make_event("cid-r", i * 7));
360        }
361        let result = p.predict("cid-r");
362        assert!((result.confidence - 0.9).abs() < f32::EPSILON);
363    }
364
365    // ── 9. predict decreasing intervals → Cooling ────────────────────────────
366    #[test]
367    fn test_predict_decreasing_intervals_cooling() {
368        let mut p = StorageAccessPredictor::new();
369        // ticks: 0, 100, 190, 270, 340  → intervals: 100, 90, 80, 70 (strictly decreasing)
370        let ticks = [0u64, 100, 190, 270, 340];
371        for &t in &ticks {
372            p.record(make_event("cid-c", t));
373        }
374        let result = p.predict("cid-c");
375        assert_eq!(result.pattern, AccessPattern::Cooling);
376    }
377
378    // ── 10. predict Cooling: next_access is None ──────────────────────────────
379    #[test]
380    fn test_predict_cooling_next_access_none() {
381        let mut p = StorageAccessPredictor::new();
382        let ticks = [0u64, 100, 190, 270, 340];
383        for &t in &ticks {
384            p.record(make_event("cid-c", t));
385        }
386        let result = p.predict("cid-c");
387        assert!(result.next_access_tick.is_none());
388    }
389
390    // ── 11. predict small intervals (≤5) → Bursty ────────────────────────────
391    #[test]
392    fn test_predict_small_intervals_bursty() {
393        let mut p = StorageAccessPredictor::new();
394        // ticks: 0, 2, 4, 6 → all intervals = 2 ≤ 5
395        // But equal intervals → Repeated takes priority; use mixed ≤5 values
396        for &t in &[0u64, 1, 3, 5, 7] {
397            p.record(make_event("cid-b", t));
398        }
399        let result = p.predict("cid-b");
400        assert_eq!(result.pattern, AccessPattern::Bursty { burst_size: 5 });
401    }
402
403    // ── 12. predict Bursty: burst_size = history len ──────────────────────────
404    #[test]
405    fn test_predict_bursty_burst_size() {
406        let mut p = StorageAccessPredictor::new();
407        for &t in &[0u64, 1, 3, 5, 7, 9] {
408            p.record(make_event("cid-b", t));
409        }
410        let result = p.predict("cid-b");
411        if let AccessPattern::Bursty { burst_size } = result.pattern {
412            assert_eq!(burst_size, 6);
413        } else {
414            panic!("Expected Bursty, got {:?}", result.pattern);
415        }
416    }
417
418    // ── 13. predict varying intervals → Random confidence=0.2 ────────────────
419    #[test]
420    fn test_predict_random_confidence() {
421        let mut p = StorageAccessPredictor::new();
422        // Irregular intervals that are not monotone and not all ≤5
423        for &t in &[0u64, 10, 15, 100, 102, 200] {
424            p.record(make_event("cid-x", t));
425        }
426        let result = p.predict("cid-x");
427        assert_eq!(result.pattern, AccessPattern::Random);
428        assert!((result.confidence - 0.2).abs() < f32::EPSILON);
429    }
430
431    // ── 14. top_predicted returns only Sequential/Repeated ───────────────────
432    #[test]
433    fn test_top_predicted_only_sequential_repeated() {
434        let mut p = StorageAccessPredictor::new();
435
436        // Repeated pattern
437        for i in 0u64..4 {
438            p.record(make_event("cid-rep", i * 10));
439        }
440        // Random pattern
441        for &t in &[0u64, 10, 15, 100, 102, 200] {
442            p.record(make_event("cid-rand", t));
443        }
444
445        let top = p.top_predicted();
446        for r in &top {
447            assert!(
448                matches!(
449                    r.pattern,
450                    AccessPattern::Sequential | AccessPattern::Repeated { .. }
451                ),
452                "Unexpected pattern {:?} for {}",
453                r.pattern,
454                r.cid
455            );
456        }
457        assert!(!top.is_empty());
458    }
459
460    // ── 15. top_predicted sorted by confidence desc ───────────────────────────
461    #[test]
462    fn test_top_predicted_sorted_by_confidence_desc() {
463        let mut p = StorageAccessPredictor::new();
464
465        // Repeated (confidence 0.9)
466        for i in 0u64..4 {
467            p.record(make_event("cid-rep", i * 10));
468        }
469        // Sequential (confidence 0.75) — non-decreasing, ≤10% variation
470        // intervals: 10, 10, 11, 11 — avg=10.5 var=(11-10)/10.5≈0.095 ≤0.10
471        for &t in &[0u64, 10, 20, 31, 42] {
472            p.record(make_event("cid-seq", t));
473        }
474
475        let top = p.top_predicted();
476        for pair in top.windows(2) {
477            assert!(pair[0].confidence >= pair[1].confidence);
478        }
479    }
480
481    // ── 16. stats total_events increments ─────────────────────────────────────
482    #[test]
483    fn test_stats_total_events() {
484        let mut p = StorageAccessPredictor::new();
485        assert_eq!(p.stats().total_events, 0);
486        p.record(make_event("cid-a", 1));
487        assert_eq!(p.stats().total_events, 1);
488        p.record(make_event("cid-a", 2));
489        assert_eq!(p.stats().total_events, 2);
490        p.record(make_event("cid-b", 5));
491        assert_eq!(p.stats().total_events, 3);
492    }
493
494    // ── 17. stats unique_cids correct ─────────────────────────────────────────
495    #[test]
496    fn test_stats_unique_cids() {
497        let mut p = StorageAccessPredictor::new();
498        assert_eq!(p.stats().unique_cids, 0);
499        p.record(make_event("a", 1));
500        assert_eq!(p.stats().unique_cids, 1);
501        p.record(make_event("b", 2));
502        assert_eq!(p.stats().unique_cids, 2);
503        p.record(make_event("a", 3));
504        assert_eq!(p.stats().unique_cids, 2); // still 2 unique
505    }
506
507    // ── 18. multiple CIDs tracked independently ───────────────────────────────
508    #[test]
509    fn test_multiple_cids_independent() {
510        let mut p = StorageAccessPredictor::new();
511        for i in 0u64..3 {
512            p.record(make_event("alpha", i * 10));
513            p.record(make_event("beta", i * 7));
514        }
515        let ra = p.predict("alpha");
516        let rb = p.predict("beta");
517        assert_eq!(ra.pattern, AccessPattern::Repeated { interval_ticks: 10 });
518        assert_eq!(rb.pattern, AccessPattern::Repeated { interval_ticks: 7 });
519    }
520
521    // ── 19. stats sequential_count / repeated_count / random_count ────────────
522    #[test]
523    fn test_stats_pattern_counts() {
524        let mut p = StorageAccessPredictor::new();
525
526        // Repeated
527        for i in 0u64..4 {
528            p.record(make_event("rep", i * 5));
529        }
530        // Random
531        for &t in &[0u64, 10, 15, 100, 102, 200] {
532            p.record(make_event("rnd", t));
533        }
534
535        let s = p.stats();
536        assert_eq!(s.repeated_count, 1);
537        assert_eq!(s.random_count, 1);
538    }
539
540    // ── 20. history eviction preserves order ──────────────────────────────────
541    #[test]
542    fn test_history_eviction_preserves_order() {
543        let mut p = StorageAccessPredictor::new();
544        for i in 0..25u64 {
545            p.record(make_event("cid-ord", i));
546        }
547        let entries = &p.history["cid-ord"];
548        // After evicting 5 (ticks 0..4), remaining should be 5..24 in order.
549        assert_eq!(entries.len(), 20);
550        for (idx, entry) in entries.iter().enumerate() {
551            assert_eq!(entry.tick, (idx as u64) + 5);
552        }
553    }
554
555    // ── 21. predict Bursty: confidence = 0.6 ─────────────────────────────────
556    #[test]
557    fn test_predict_bursty_confidence() {
558        let mut p = StorageAccessPredictor::new();
559        for &t in &[0u64, 1, 3, 5, 7] {
560            p.record(make_event("cid-b2", t));
561        }
562        let result = p.predict("cid-b2");
563        assert!((result.confidence - 0.6).abs() < f32::EPSILON);
564    }
565
566    // ── 22. predict Cooling: confidence = 0.7 ────────────────────────────────
567    #[test]
568    fn test_predict_cooling_confidence() {
569        let mut p = StorageAccessPredictor::new();
570        let ticks = [0u64, 100, 190, 270, 340];
571        for &t in &ticks {
572            p.record(make_event("cid-cool2", t));
573        }
574        let result = p.predict("cid-cool2");
575        assert!((result.confidence - 0.7).abs() < f32::EPSILON);
576    }
577
578    // ── 23. top_predicted is empty when no predictable patterns ───────────────
579    #[test]
580    fn test_top_predicted_empty_when_no_patterns() {
581        let mut p = StorageAccessPredictor::new();
582        // Only random-pattern CIDs
583        for &t in &[0u64, 10, 15, 100, 102, 200] {
584            p.record(make_event("rnd1", t));
585        }
586        for &t in &[0u64, 50, 55, 300, 305, 700] {
587            p.record(make_event("rnd2", t));
588        }
589        assert!(p.top_predicted().is_empty());
590    }
591
592    // ── 24. default() produces same state as new() ────────────────────────────
593    #[test]
594    fn test_default_is_empty() {
595        let p = StorageAccessPredictor::default();
596        assert!(p.history.is_empty());
597        assert_eq!(p.stats().total_events, 0);
598    }
599}