Skip to main content

ipfrs_storage/
prefetch_engine.rs

1//! StoragePrefetchEngine — Intelligent prefetch engine that learns access patterns
2//! and pre-warms the cache by predicting future access.
3//!
4//! ## Overview
5//!
6//! The prefetch engine observes storage access events, maintains a sliding window of
7//! recent accesses, and identifies co-access pairs (CIDs frequently accessed together
8//! within a configurable time window).  When a CID is accessed the engine scores all
9//! known co-access partners using a recency-weighted count and returns up to
10//! `max_prefetch_hints` sorted hints to the caller.
11//!
12//! In addition to co-access correlation the engine detects per-CID access patterns
13//! (`Sequential`, `Repeated`, `Random`, `Strided`, `Unknown`) to help callers adapt
14//! their prefetch depth.
15
16use std::cmp::Reverse;
17use std::collections::{HashMap, VecDeque};
18
19// ── Configuration ────────────────────────────────────────────────────────────
20
21/// Configuration for [`StoragePrefetchEngine`].
22#[derive(Debug, Clone)]
23pub struct PeConfig {
24    /// Time window (milliseconds) within which two accesses are considered co-located.
25    pub coaccess_window_ms: u64,
26    /// Maximum number of co-access pairs stored in memory.
27    pub max_pair_history: usize,
28    /// Minimum times a pair must have co-occurred before it is emitted as a hint.
29    pub min_coaccess_count: u32,
30    /// Maximum number of prefetch hints returned per `record_access` call.
31    pub max_prefetch_hints: usize,
32    /// Number of recent per-CID accesses used for pattern detection.
33    pub pattern_window: usize,
34}
35
36impl Default for PeConfig {
37    fn default() -> Self {
38        Self {
39            coaccess_window_ms: 5_000,
40            max_pair_history: 10_000,
41            min_coaccess_count: 2,
42            max_prefetch_hints: 20,
43            pattern_window: 20,
44        }
45    }
46}
47
48// ── Access primitives ─────────────────────────────────────────────────────────
49
50/// The kind of storage operation that was performed.
51#[derive(Debug, Clone, PartialEq, Eq, Hash)]
52pub enum PeAccessType {
53    /// Block was read.
54    Read,
55    /// Block was written.
56    Write,
57    /// Block was deleted.
58    Delete,
59}
60
61/// A single storage access event recorded by the prefetch engine.
62#[derive(Debug, Clone)]
63pub struct PeAccessEvent {
64    /// The CID of the accessed block.
65    pub cid: String,
66    /// Unix timestamp in milliseconds at which the access occurred.
67    pub timestamp: u64,
68    /// The type of access.
69    pub access_type: PeAccessType,
70}
71
72// ── Co-access tracking ────────────────────────────────────────────────────────
73
74/// A pair of CIDs that have been observed to be accessed within the same time window.
75#[derive(Debug, Clone)]
76pub struct CoAccessPair {
77    /// The lexicographically smaller CID.
78    pub cid_a: String,
79    /// The lexicographically larger CID.
80    pub cid_b: String,
81    /// Number of times this pair has been co-accessed.
82    pub coaccess_count: u32,
83    /// Unix timestamp (ms) of the most recent co-access.
84    pub last_seen: u64,
85}
86
87// ── Prefetch hints ────────────────────────────────────────────────────────────
88
89/// A predicted-access hint produced by the engine.
90#[derive(Debug, Clone)]
91pub struct PePrefetchHint {
92    /// The CID predicted to be accessed next.
93    pub cid: String,
94    /// Normalised priority in [0, 1]; higher means more likely to be needed.
95    pub priority: f64,
96    /// Human-readable justification for this hint.
97    pub reason: String,
98}
99
100// ── Pattern detection ─────────────────────────────────────────────────────────
101
102/// The access pattern detected for a single CID.
103#[derive(Debug, Clone, PartialEq)]
104pub enum PeAccessPattern {
105    /// Accesses are evenly spaced in time (inter-access std-dev < 20 % of mean).
106    Sequential,
107    /// Accesses appear fully random (no other pattern detected).
108    Random,
109    /// Accesses have a consistent stride `stride` in their ordering within the window.
110    Strided { stride: usize },
111    /// The CID has been accessed ≥ 3 times in the recent pattern window.
112    Repeated,
113    /// Not enough data to determine a pattern.
114    Unknown,
115}
116
117// ── Statistics ────────────────────────────────────────────────────────────────
118
119/// Aggregate statistics produced by [`StoragePrefetchEngine::stats`].
120#[derive(Debug, Clone)]
121pub struct PePrefetchStats {
122    /// Total number of access events recorded since creation.
123    pub total_events: u64,
124    /// Number of unique co-access pairs currently tracked.
125    pub total_pairs: usize,
126    /// Mean co-access count across all tracked pairs.
127    pub avg_coaccess_count: f64,
128    /// Textual description of the dominant pattern observed globally.
129    pub top_pattern: String,
130    /// Total number of prefetch hints generated since creation.
131    pub hints_generated: u64,
132}
133
134// ── Main engine ───────────────────────────────────────────────────────────────
135
136/// Intelligent prefetch engine: records access events, identifies co-access
137/// pairs, detects per-CID access patterns, and generates prioritised prefetch
138/// hints.
139pub struct StoragePrefetchEngine {
140    /// Engine configuration (immutable after construction).
141    pub config: PeConfig,
142    /// Ordered history of recent access events (front = oldest).
143    pub access_history: VecDeque<PeAccessEvent>,
144    /// Co-access pairs keyed by `"<cid_a>:<cid_b>"` (cid_a < cid_b).
145    pub coaccesses: HashMap<String, CoAccessPair>,
146    /// CIDs seen in recent accesses (last `config.pattern_window` entries).
147    pub recent_cids: VecDeque<String>,
148    /// Per-CID list of recent access timestamps (last `pattern_window` entries).
149    pub pattern_state: HashMap<String, Vec<u64>>,
150
151    // Internal counters.
152    total_events: u64,
153    hints_generated: u64,
154}
155
156impl StoragePrefetchEngine {
157    // ── Construction ─────────────────────────────────────────────────────────
158
159    /// Create a new engine with the given configuration.
160    pub fn new(config: PeConfig) -> Self {
161        Self {
162            config,
163            access_history: VecDeque::new(),
164            coaccesses: HashMap::new(),
165            recent_cids: VecDeque::new(),
166            pattern_state: HashMap::new(),
167            total_events: 0,
168            hints_generated: 0,
169        }
170    }
171
172    // ── Event recording ───────────────────────────────────────────────────────
173
174    /// Record an access event.
175    ///
176    /// Side effects:
177    /// 1. Appends `event` to `access_history`.
178    /// 2. Updates co-access pairs for every event within `coaccess_window_ms`.
179    /// 3. Updates `pattern_state` for the accessed CID.
180    /// 4. Returns prefetch hints for the accessed CID.
181    pub fn record_access(&mut self, event: PeAccessEvent) -> Vec<PePrefetchHint> {
182        let now = event.timestamp;
183        let cid = event.cid.clone();
184
185        // -- Update pattern state -------------------------------------------------
186        {
187            let ts_vec = self.pattern_state.entry(cid.clone()).or_default();
188            ts_vec.push(now);
189            let window = self.config.pattern_window;
190            if ts_vec.len() > window {
191                let drain_count = ts_vec.len() - window;
192                ts_vec.drain(..drain_count);
193            }
194        }
195
196        // -- Append to history ---------------------------------------------------
197        self.access_history.push_back(event);
198        self.total_events += 1;
199
200        // -- Update co-access pairs for events within the window -----------------
201        self.update_coaccesses(now, &cid);
202
203        // -- Trim history to avoid unbounded growth ------------------------------
204        // We keep events that are still within the coaccess window plus a safety
205        // margin (×2) so that the window look-back always has enough history.
206        let cutoff = now.saturating_sub(self.config.coaccess_window_ms * 2);
207        while self
208            .access_history
209            .front()
210            .map(|e| e.timestamp < cutoff)
211            .unwrap_or(false)
212        {
213            self.access_history.pop_front();
214        }
215
216        // -- Update recent_cids deque --------------------------------------------
217        self.recent_cids.push_back(cid.clone());
218        let pw = self.config.pattern_window;
219        while self.recent_cids.len() > pw {
220            self.recent_cids.pop_front();
221        }
222
223        // -- Enforce max_pair_history --------------------------------------------
224        if self.coaccesses.len() > self.config.max_pair_history {
225            self.evict_lowest_pairs();
226        }
227
228        // -- Generate hints for the just-accessed CID ----------------------------
229        let hints = self.generate_hints(&cid, now);
230        self.hints_generated += hints.len() as u64;
231        hints
232    }
233
234    /// Generate prefetch hints for `cid` at the given `now` timestamp.
235    ///
236    /// Returns up to `config.max_prefetch_hints` hints sorted by descending
237    /// `coaccess_count * recency_weight`.
238    pub fn generate_hints(&self, cid: &str, now: u64) -> Vec<PePrefetchHint> {
239        let max = self.config.max_prefetch_hints;
240        let min_count = self.config.min_coaccess_count;
241
242        // Collect (partner_cid, score, pair_ref) for all pairs involving `cid`.
243        let mut scored: Vec<(String, f64, u32)> = self
244            .coaccesses
245            .values()
246            .filter(|p| p.coaccess_count >= min_count && (p.cid_a == cid || p.cid_b == cid))
247            .map(|p| {
248                let partner = if p.cid_a == cid {
249                    p.cid_b.clone()
250                } else {
251                    p.cid_a.clone()
252                };
253                let age_ms = now.saturating_sub(p.last_seen) as f64;
254                let recency_weight = (-age_ms / 60_000.0_f64).exp();
255                let raw_score = p.coaccess_count as f64 * recency_weight;
256                (partner, raw_score, p.coaccess_count)
257            })
258            .collect();
259
260        // Sort descending by score.
261        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
262        scored.truncate(max);
263
264        scored
265            .into_iter()
266            .map(|(partner, raw_score, count)| {
267                let priority = (raw_score / 10.0_f64).min(1.0_f64);
268                PePrefetchHint {
269                    cid: partner,
270                    priority,
271                    reason: format!("co-accessed {} time(s) with {}", count, cid),
272                }
273            })
274            .collect()
275    }
276
277    // ── Pattern detection ─────────────────────────────────────────────────────
278
279    /// Detect the access pattern for a given CID using the last `pattern_window`
280    /// recorded timestamps.
281    ///
282    /// Decision logic (in order):
283    /// 1. `Repeated`   — ≥ 3 timestamps in the pattern state.
284    /// 2. `Sequential` — inter-access std-dev < 20 % of the mean interval.
285    /// 3. `Unknown`    — otherwise.
286    pub fn detect_pattern(&self, cid: &str) -> PeAccessPattern {
287        let ts = match self.pattern_state.get(cid) {
288            Some(v) if !v.is_empty() => v,
289            _ => return PeAccessPattern::Unknown,
290        };
291
292        if ts.len() >= 3 {
293            // First check: Repeated
294            // Then try Sequential if we have ≥ 2 intervals.
295            if ts.len() >= 2 {
296                let intervals: Vec<f64> = ts
297                    .windows(2)
298                    .map(|w| (w[1].saturating_sub(w[0])) as f64)
299                    .collect();
300
301                if !intervals.is_empty() {
302                    let mean = intervals.iter().sum::<f64>() / intervals.len() as f64;
303                    if mean > 0.0 {
304                        let variance = intervals.iter().map(|x| (x - mean).powi(2)).sum::<f64>()
305                            / intervals.len() as f64;
306                        let std_dev = variance.sqrt();
307                        if std_dev < 0.2 * mean {
308                            return PeAccessPattern::Sequential;
309                        }
310                    }
311                }
312            }
313            return PeAccessPattern::Repeated;
314        }
315
316        PeAccessPattern::Unknown
317    }
318
319    // ── Co-access helpers ─────────────────────────────────────────────────────
320
321    /// Return the top `n` co-access pairs involving `cid`, sorted by
322    /// `coaccess_count` descending.
323    pub fn top_coaccessed<'a>(&'a self, cid: &str, n: usize) -> Vec<&'a CoAccessPair> {
324        let mut pairs: Vec<&CoAccessPair> = self
325            .coaccesses
326            .values()
327            .filter(|p| p.cid_a == cid || p.cid_b == cid)
328            .collect();
329
330        pairs.sort_by_key(|p| Reverse(p.coaccess_count));
331        pairs.truncate(n);
332        pairs
333    }
334
335    /// Remove co-access pairs not seen within the last `max_age_ms` milliseconds.
336    ///
337    /// Returns the number of pairs removed.
338    pub fn evict_stale_pairs(&mut self, max_age_ms: u64, now: u64) -> usize {
339        let cutoff = now.saturating_sub(max_age_ms);
340        let before = self.coaccesses.len();
341        self.coaccesses.retain(|_, p| p.last_seen >= cutoff);
342        before - self.coaccesses.len()
343    }
344
345    // ── Simple accessors ──────────────────────────────────────────────────────
346
347    /// Return the total number of co-access pairs currently tracked.
348    pub fn total_pairs(&self) -> usize {
349        self.coaccesses.len()
350    }
351
352    /// Return the number of events in the access history buffer.
353    pub fn history_len(&self) -> usize {
354        self.access_history.len()
355    }
356
357    /// Return the top `n` most-accessed CIDs by total appearance in
358    /// `access_history`, as `(cid, count)` pairs sorted by count descending.
359    pub fn most_accessed_cids(&self, n: usize) -> Vec<(String, usize)> {
360        let mut counts: HashMap<&str, usize> = HashMap::new();
361        for ev in &self.access_history {
362            *counts.entry(ev.cid.as_str()).or_insert(0) += 1;
363        }
364        let mut vec: Vec<(String, usize)> =
365            counts.into_iter().map(|(k, v)| (k.to_owned(), v)).collect();
366        vec.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
367        vec.truncate(n);
368        vec
369    }
370
371    /// Return aggregate statistics.
372    pub fn stats(&self) -> PePrefetchStats {
373        let total_pairs = self.coaccesses.len();
374        let avg_coaccess_count = if total_pairs == 0 {
375            0.0
376        } else {
377            self.coaccesses
378                .values()
379                .map(|p| p.coaccess_count as f64)
380                .sum::<f64>()
381                / total_pairs as f64
382        };
383
384        // Determine dominant pattern by sampling the pattern_state.
385        let top_pattern = self.dominant_pattern();
386
387        PePrefetchStats {
388            total_events: self.total_events,
389            total_pairs,
390            avg_coaccess_count,
391            top_pattern,
392            hints_generated: self.hints_generated,
393        }
394    }
395
396    // ── Private helpers ───────────────────────────────────────────────────────
397
398    /// Update co-access pairs: for every event in `access_history` whose
399    /// timestamp is within `coaccess_window_ms` of `now`, register a co-access
400    /// with `new_cid`.
401    fn update_coaccesses(&mut self, now: u64, new_cid: &str) {
402        let window = self.config.coaccess_window_ms;
403        let cutoff = now.saturating_sub(window);
404
405        // Collect partner CIDs within the window (excluding the new event itself
406        // which we just pushed — use `len-1` to skip the last element).
407        let history_len = self.access_history.len();
408        let slice_len = if history_len > 0 { history_len - 1 } else { 0 };
409
410        let partners: Vec<String> = self
411            .access_history
412            .iter()
413            .take(slice_len)
414            .filter(|e| e.timestamp >= cutoff && e.cid != new_cid)
415            .map(|e| e.cid.clone())
416            .collect();
417
418        for partner in partners {
419            let key = coacccess_key(&partner, new_cid);
420            let (cid_a, cid_b) = ordered_pair(&partner, new_cid);
421            let entry = self.coaccesses.entry(key).or_insert_with(|| CoAccessPair {
422                cid_a: cid_a.to_owned(),
423                cid_b: cid_b.to_owned(),
424                coaccess_count: 0,
425                last_seen: 0,
426            });
427            entry.coaccess_count = entry.coaccess_count.saturating_add(1);
428            if now > entry.last_seen {
429                entry.last_seen = now;
430            }
431        }
432    }
433
434    /// Evict the lowest-count pairs when the map exceeds `max_pair_history`.
435    fn evict_lowest_pairs(&mut self) {
436        let max = self.config.max_pair_history;
437        if self.coaccesses.len() <= max {
438            return;
439        }
440        // Collect keys sorted by count ascending; drop the bottom half excess.
441        let excess = self.coaccesses.len() - max;
442        let mut keyed: Vec<(String, u32)> = self
443            .coaccesses
444            .iter()
445            .map(|(k, v)| (k.clone(), v.coaccess_count))
446            .collect();
447        keyed.sort_by_key(|(_, c)| *c);
448        for (key, _) in keyed.into_iter().take(excess) {
449            self.coaccesses.remove(&key);
450        }
451    }
452
453    /// Determine the dominant pattern across all tracked CIDs.
454    fn dominant_pattern(&self) -> String {
455        let mut repeated = 0usize;
456        let mut sequential = 0usize;
457        let mut unknown = 0usize;
458
459        for cid in self.pattern_state.keys() {
460            match self.detect_pattern(cid) {
461                PeAccessPattern::Repeated => repeated += 1,
462                PeAccessPattern::Sequential => sequential += 1,
463                PeAccessPattern::Unknown
464                | PeAccessPattern::Random
465                | PeAccessPattern::Strided { .. } => unknown += 1,
466            }
467        }
468
469        if repeated >= sequential && repeated >= unknown {
470            "Repeated".to_owned()
471        } else if sequential >= unknown {
472            "Sequential".to_owned()
473        } else {
474            "Unknown".to_owned()
475        }
476    }
477}
478
479// ── Free functions ────────────────────────────────────────────────────────────
480
481/// Build the canonical map key for a co-access pair.
482///
483/// The key is `"<smaller>:<larger>"` so that each unordered pair has exactly
484/// one key regardless of which CID triggered the update.
485fn coacccess_key(a: &str, b: &str) -> String {
486    if a <= b {
487        format!("{}:{}", a, b)
488    } else {
489        format!("{}:{}", b, a)
490    }
491}
492
493/// Return `(cid_a, cid_b)` in lexicographic order.
494fn ordered_pair<'a>(a: &'a str, b: &'a str) -> (&'a str, &'a str) {
495    if a <= b {
496        (a, b)
497    } else {
498        (b, a)
499    }
500}
501
502// ── Tests ─────────────────────────────────────────────────────────────────────
503
504#[cfg(test)]
505mod tests {
506    use super::{
507        CoAccessPair, PeAccessEvent, PeAccessPattern, PeAccessType, PeConfig, PePrefetchHint,
508        PePrefetchStats, StoragePrefetchEngine,
509    };
510
511    // ── Helpers ───────────────────────────────────────────────────────────────
512
513    fn default_engine() -> StoragePrefetchEngine {
514        StoragePrefetchEngine::new(PeConfig::default())
515    }
516
517    fn make_event(cid: &str, ts: u64) -> PeAccessEvent {
518        PeAccessEvent {
519            cid: cid.to_owned(),
520            timestamp: ts,
521            access_type: PeAccessType::Read,
522        }
523    }
524
525    fn make_write_event(cid: &str, ts: u64) -> PeAccessEvent {
526        PeAccessEvent {
527            cid: cid.to_owned(),
528            timestamp: ts,
529            access_type: PeAccessType::Write,
530        }
531    }
532
533    fn make_delete_event(cid: &str, ts: u64) -> PeAccessEvent {
534        PeAccessEvent {
535            cid: cid.to_owned(),
536            timestamp: ts,
537            access_type: PeAccessType::Delete,
538        }
539    }
540
541    // ── Construction tests ────────────────────────────────────────────────────
542
543    #[test]
544    fn test_new_default_config() {
545        let engine = default_engine();
546        assert_eq!(engine.config.coaccess_window_ms, 5_000);
547        assert_eq!(engine.config.max_pair_history, 10_000);
548        assert_eq!(engine.config.min_coaccess_count, 2);
549        assert_eq!(engine.config.max_prefetch_hints, 20);
550        assert_eq!(engine.config.pattern_window, 20);
551    }
552
553    #[test]
554    fn test_new_custom_config() {
555        let cfg = PeConfig {
556            coaccess_window_ms: 1_000,
557            max_pair_history: 100,
558            min_coaccess_count: 3,
559            max_prefetch_hints: 5,
560            pattern_window: 10,
561        };
562        let engine = StoragePrefetchEngine::new(cfg.clone());
563        assert_eq!(engine.config.coaccess_window_ms, 1_000);
564        assert_eq!(engine.config.max_pair_history, 100);
565        assert_eq!(engine.config.min_coaccess_count, 3);
566        assert_eq!(engine.config.max_prefetch_hints, 5);
567        assert_eq!(engine.config.pattern_window, 10);
568    }
569
570    #[test]
571    fn test_initial_state() {
572        let engine = default_engine();
573        assert_eq!(engine.total_pairs(), 0);
574        assert_eq!(engine.history_len(), 0);
575        assert!(engine.most_accessed_cids(10).is_empty());
576    }
577
578    // ── record_access / history tests ─────────────────────────────────────────
579
580    #[test]
581    fn test_record_single_event() {
582        let mut engine = default_engine();
583        engine.record_access(make_event("Qm1", 1_000));
584        assert_eq!(engine.history_len(), 1);
585    }
586
587    #[test]
588    fn test_history_len_grows() {
589        let mut engine = default_engine();
590        for i in 0u64..5 {
591            engine.record_access(make_event(&format!("Qm{}", i), i * 100));
592        }
593        assert_eq!(engine.history_len(), 5);
594    }
595
596    #[test]
597    fn test_record_returns_empty_hints_when_no_pairs() {
598        let mut engine = default_engine();
599        let hints = engine.record_access(make_event("QmA", 0));
600        assert!(hints.is_empty(), "no pairs yet → no hints");
601    }
602
603    #[test]
604    fn test_write_event_recorded() {
605        let mut engine = default_engine();
606        engine.record_access(make_write_event("QmW", 500));
607        assert_eq!(engine.history_len(), 1);
608    }
609
610    #[test]
611    fn test_delete_event_recorded() {
612        let mut engine = default_engine();
613        engine.record_access(make_delete_event("QmD", 600));
614        assert_eq!(engine.history_len(), 1);
615    }
616
617    // ── Co-access pair tests ──────────────────────────────────────────────────
618
619    #[test]
620    fn test_coaccess_pair_formed_within_window() {
621        let mut engine = default_engine();
622        engine.record_access(make_event("QmA", 1_000));
623        engine.record_access(make_event("QmB", 1_500)); // 500 ms later, within 5s window
624        assert_eq!(engine.total_pairs(), 1);
625    }
626
627    #[test]
628    fn test_coaccess_pair_not_formed_outside_window() {
629        let mut engine = default_engine();
630        engine.record_access(make_event("QmA", 1_000));
631        engine.record_access(make_event("QmB", 10_000)); // 9 seconds later
632        assert_eq!(engine.total_pairs(), 0);
633    }
634
635    #[test]
636    fn test_coaccess_count_increments() {
637        let mut engine = default_engine();
638        // Access A and B twice within the window.
639        engine.record_access(make_event("QmA", 1_000));
640        engine.record_access(make_event("QmB", 1_200));
641        engine.record_access(make_event("QmA", 2_000));
642        engine.record_access(make_event("QmB", 2_200));
643        let pairs = engine.top_coaccessed("QmA", 5);
644        assert!(!pairs.is_empty());
645        let pair = pairs[0];
646        assert!(pair.coaccess_count >= 2, "count={}", pair.coaccess_count);
647    }
648
649    #[test]
650    fn test_coaccess_key_is_sorted() {
651        // Regardless of access order, the key should always be lex-sorted.
652        let mut engine = default_engine();
653        engine.record_access(make_event("QmZ", 1_000));
654        engine.record_access(make_event("QmA", 1_100));
655        assert_eq!(engine.total_pairs(), 1);
656        let pairs: Vec<&CoAccessPair> = engine.coaccesses.values().collect();
657        assert_eq!(pairs[0].cid_a, "QmA");
658        assert_eq!(pairs[0].cid_b, "QmZ");
659    }
660
661    #[test]
662    fn test_top_coaccessed_returns_correct_cids() {
663        let mut engine = default_engine();
664        // Build 3 pairs for QmA.
665        for ts_offset in 0u64..3 {
666            engine.record_access(make_event("QmA", 1_000 + ts_offset * 500));
667            engine.record_access(make_event(
668                &format!("QmX{}", ts_offset),
669                1_100 + ts_offset * 500,
670            ));
671        }
672        let top = engine.top_coaccessed("QmA", 10);
673        // All three partners should appear.
674        assert_eq!(top.len(), 3);
675    }
676
677    #[test]
678    fn test_top_coaccessed_sorted_desc() {
679        let mut engine = default_engine();
680        // Create a high-count pair A-B and a low-count pair A-C.
681        for i in 0u64..4 {
682            engine.record_access(make_event("QmA", 1_000 + i * 300));
683            engine.record_access(make_event("QmB", 1_050 + i * 300));
684        }
685        engine.record_access(make_event("QmA", 10_000));
686        engine.record_access(make_event("QmC", 10_050));
687        let top = engine.top_coaccessed("QmA", 10);
688        assert!(!top.is_empty());
689        // First element should have highest count.
690        for w in top.windows(2) {
691            assert!(w[0].coaccess_count >= w[1].coaccess_count);
692        }
693    }
694
695    // ── generate_hints tests ──────────────────────────────────────────────────
696
697    #[test]
698    fn test_generate_hints_respects_min_count() {
699        let cfg = PeConfig {
700            min_coaccess_count: 3,
701            ..Default::default()
702        };
703        let mut engine = StoragePrefetchEngine::new(cfg);
704        // Create a pair accessed only once.
705        engine.record_access(make_event("QmA", 1_000));
706        engine.record_access(make_event("QmB", 1_100));
707        let hints = engine.generate_hints("QmA", 2_000);
708        assert!(hints.is_empty(), "count=1 < min_coaccess_count=3");
709    }
710
711    #[test]
712    fn test_generate_hints_returns_after_sufficient_coaccesses() {
713        let cfg = PeConfig {
714            min_coaccess_count: 2,
715            ..Default::default()
716        };
717        let mut engine = StoragePrefetchEngine::new(cfg);
718        for i in 0u64..2 {
719            engine.record_access(make_event("QmA", 1_000 + i * 200));
720            engine.record_access(make_event("QmB", 1_050 + i * 200));
721        }
722        let hints = engine.generate_hints("QmA", 2_000);
723        assert!(!hints.is_empty());
724        assert_eq!(hints[0].cid, "QmB");
725    }
726
727    #[test]
728    fn test_generate_hints_priority_capped_at_one() {
729        let mut engine = default_engine();
730        // Many co-accesses to push raw score above 10.
731        for i in 0u64..20 {
732            engine.record_access(make_event("QmA", 1_000 + i * 10));
733            engine.record_access(make_event("QmB", 1_005 + i * 10));
734        }
735        let hints = engine.generate_hints("QmA", 1_500);
736        for h in &hints {
737            assert!(h.priority <= 1.0, "priority={} > 1.0", h.priority);
738        }
739    }
740
741    #[test]
742    fn test_generate_hints_max_hints_limit() {
743        let cfg = PeConfig {
744            max_prefetch_hints: 3,
745            min_coaccess_count: 1,
746            ..Default::default()
747        };
748        let mut engine = StoragePrefetchEngine::new(cfg);
749        for i in 0u64..10 {
750            engine.record_access(make_event("QmA", 1_000 + i * 5));
751            engine.record_access(make_event(&format!("QmP{}", i), 1_002 + i * 5));
752        }
753        let hints = engine.generate_hints("QmA", 2_000);
754        assert!(hints.len() <= 3, "len={}", hints.len());
755    }
756
757    #[test]
758    fn test_hint_reason_mentions_cid() {
759        let mut engine = default_engine();
760        for i in 0u64..2 {
761            engine.record_access(make_event("QmA", 1_000 + i * 100));
762            engine.record_access(make_event("QmB", 1_050 + i * 100));
763        }
764        let hints = engine.generate_hints("QmA", 2_000);
765        assert!(!hints.is_empty());
766        assert!(
767            hints[0].reason.contains("QmA"),
768            "reason missing trigger CID"
769        );
770    }
771
772    // ── detect_pattern tests ──────────────────────────────────────────────────
773
774    #[test]
775    fn test_detect_pattern_unknown_for_new_cid() {
776        let engine = default_engine();
777        assert_eq!(engine.detect_pattern("QmNone"), PeAccessPattern::Unknown);
778    }
779
780    #[test]
781    fn test_detect_pattern_unknown_for_two_events() {
782        let mut engine = default_engine();
783        engine.record_access(make_event("QmA", 1_000));
784        engine.record_access(make_event("QmA", 2_000));
785        // Only 2 timestamps — not enough for Repeated (need ≥ 3).
786        assert_eq!(engine.detect_pattern("QmA"), PeAccessPattern::Unknown);
787    }
788
789    #[test]
790    fn test_detect_pattern_repeated_three_accesses() {
791        let mut engine = default_engine();
792        for ts in [1_000u64, 2_000, 3_000] {
793            engine.record_access(make_event("QmR", ts));
794        }
795        let pattern = engine.detect_pattern("QmR");
796        // Evenly spaced → Sequential (std_dev = 0 < 0.2 * 1000).
797        assert!(
798            matches!(
799                pattern,
800                PeAccessPattern::Sequential | PeAccessPattern::Repeated
801            ),
802            "pattern={:?}",
803            pattern
804        );
805    }
806
807    #[test]
808    fn test_detect_pattern_sequential_evenly_spaced() {
809        let mut engine = default_engine();
810        // Access every 1000 ms — perfectly sequential.
811        for i in 0u64..5 {
812            engine.record_access(make_event("QmS", i * 1_000));
813        }
814        let pattern = engine.detect_pattern("QmS");
815        assert_eq!(
816            pattern,
817            PeAccessPattern::Sequential,
818            "pattern={:?}",
819            pattern
820        );
821    }
822
823    #[test]
824    fn test_detect_pattern_repeated_uneven_intervals() {
825        let mut engine = default_engine();
826        // Timestamps with high variance — should be Repeated (≥ 3 seen) but not Sequential.
827        for ts in [100u64, 200, 1_900] {
828            engine.record_access(make_event("QmU", ts));
829        }
830        let pattern = engine.detect_pattern("QmU");
831        // std_dev of [100, 1700] = ~800, mean = 900 → std_dev/mean ≈ 0.89 > 0.2.
832        assert!(
833            matches!(
834                pattern,
835                PeAccessPattern::Repeated | PeAccessPattern::Unknown
836            ),
837            "pattern={:?}",
838            pattern
839        );
840    }
841
842    // ── evict_stale_pairs tests ───────────────────────────────────────────────
843
844    #[test]
845    fn test_evict_stale_pairs_removes_old() {
846        let mut engine = default_engine();
847        engine.record_access(make_event("QmA", 1_000));
848        engine.record_access(make_event("QmB", 1_100));
849        assert_eq!(engine.total_pairs(), 1);
850
851        let removed = engine.evict_stale_pairs(500, 2_000);
852        assert_eq!(
853            removed, 1,
854            "pair at t=1100 is older than 500ms ago from t=2000"
855        );
856        assert_eq!(engine.total_pairs(), 0);
857    }
858
859    #[test]
860    fn test_evict_stale_pairs_keeps_fresh() {
861        let mut engine = default_engine();
862        engine.record_access(make_event("QmA", 1_000));
863        engine.record_access(make_event("QmB", 1_100));
864
865        let removed = engine.evict_stale_pairs(5_000, 1_500);
866        // last_seen = 1100, cutoff = 1500 - 5000 = 0 (saturating) → pair survives.
867        assert_eq!(removed, 0);
868        assert_eq!(engine.total_pairs(), 1);
869    }
870
871    #[test]
872    fn test_evict_stale_returns_count() {
873        let mut engine = default_engine();
874        for i in 0u64..3 {
875            engine.record_access(make_event("QmA", i * 100));
876            engine.record_access(make_event(&format!("QmX{}", i), i * 100 + 10));
877        }
878        let pairs_before = engine.total_pairs();
879        let removed = engine.evict_stale_pairs(0, 10_000);
880        assert_eq!(removed, pairs_before);
881        assert_eq!(engine.total_pairs(), 0);
882    }
883
884    // ── most_accessed_cids tests ──────────────────────────────────────────────
885
886    #[test]
887    fn test_most_accessed_cids_single() {
888        let mut engine = default_engine();
889        engine.record_access(make_event("QmA", 1_000));
890        let top = engine.most_accessed_cids(5);
891        assert_eq!(top.len(), 1);
892        assert_eq!(top[0].0, "QmA");
893        assert_eq!(top[0].1, 1);
894    }
895
896    #[test]
897    fn test_most_accessed_cids_counts() {
898        let mut engine = default_engine();
899        for ts in [1_000u64, 2_000, 3_000] {
900            engine.record_access(make_event("QmHot", ts));
901        }
902        engine.record_access(make_event("QmCool", 4_000));
903        let top = engine.most_accessed_cids(2);
904        assert_eq!(top[0].0, "QmHot");
905        assert_eq!(top[0].1, 3);
906    }
907
908    #[test]
909    fn test_most_accessed_cids_n_limit() {
910        let mut engine = default_engine();
911        for i in 0u64..10 {
912            engine.record_access(make_event(&format!("Qm{}", i), i * 100));
913        }
914        let top = engine.most_accessed_cids(3);
915        assert_eq!(top.len(), 3);
916    }
917
918    #[test]
919    fn test_most_accessed_cids_sorted_desc() {
920        let mut engine = default_engine();
921        engine.record_access(make_event("QmA", 100));
922        for ts in [200u64, 300, 400] {
923            engine.record_access(make_event("QmB", ts));
924        }
925        for ts in [500u64, 600] {
926            engine.record_access(make_event("QmC", ts));
927        }
928        let top = engine.most_accessed_cids(10);
929        for w in top.windows(2) {
930            assert!(w[0].1 >= w[1].1);
931        }
932    }
933
934    // ── stats tests ───────────────────────────────────────────────────────────
935
936    #[test]
937    fn test_stats_initial() {
938        let engine = default_engine();
939        let s = engine.stats();
940        assert_eq!(s.total_events, 0);
941        assert_eq!(s.total_pairs, 0);
942        assert_eq!(s.avg_coaccess_count, 0.0);
943        assert_eq!(s.hints_generated, 0);
944    }
945
946    #[test]
947    fn test_stats_total_events() {
948        let mut engine = default_engine();
949        for i in 0u64..7 {
950            engine.record_access(make_event(&format!("Qm{}", i), i * 1_000));
951        }
952        assert_eq!(engine.stats().total_events, 7);
953    }
954
955    #[test]
956    fn test_stats_hints_generated_tracked() {
957        let cfg = PeConfig {
958            min_coaccess_count: 1,
959            ..Default::default()
960        };
961        let mut engine = StoragePrefetchEngine::new(cfg);
962        for i in 0u64..2 {
963            engine.record_access(make_event("QmA", 1_000 + i * 100));
964            engine.record_access(make_event("QmB", 1_050 + i * 100));
965        }
966        // After 4 events with one pair, hints should have been generated.
967        let s = engine.stats();
968        assert!(
969            s.hints_generated > 0,
970            "hints_generated={}",
971            s.hints_generated
972        );
973    }
974
975    #[test]
976    fn test_stats_avg_coaccess_count() {
977        let mut engine = default_engine();
978        for i in 0u64..3 {
979            engine.record_access(make_event("QmA", 1_000 + i * 100));
980            engine.record_access(make_event("QmB", 1_050 + i * 100));
981        }
982        let s = engine.stats();
983        assert!(s.avg_coaccess_count > 0.0);
984    }
985
986    #[test]
987    fn test_stats_top_pattern_string() {
988        let engine = default_engine();
989        let s = engine.stats();
990        // Unknown / no data → must return a non-empty string.
991        assert!(!s.top_pattern.is_empty());
992    }
993
994    // ── recency weight tests ──────────────────────────────────────────────────
995
996    #[test]
997    fn test_hints_sorted_by_recency() {
998        // Two partners for QmA: QmB accessed recently, QmC accessed long ago.
999        let cfg = PeConfig {
1000            min_coaccess_count: 1,
1001            ..Default::default()
1002        };
1003        let mut engine = StoragePrefetchEngine::new(cfg);
1004
1005        // QmA + QmC co-accessed at t=0
1006        engine.record_access(make_event("QmA", 0));
1007        engine.record_access(make_event("QmC", 50));
1008
1009        // QmA + QmB co-accessed at t=100_000 (recent)
1010        engine.record_access(make_event("QmA", 100_000));
1011        engine.record_access(make_event("QmB", 100_050));
1012
1013        let hints = engine.generate_hints("QmA", 100_100);
1014        assert!(!hints.is_empty());
1015        // QmB should rank higher than QmC (more recent).
1016        if hints.len() >= 2 {
1017            let pos_b = hints
1018                .iter()
1019                .position(|h| h.cid == "QmB")
1020                .unwrap_or(usize::MAX);
1021            let pos_c = hints
1022                .iter()
1023                .position(|h| h.cid == "QmC")
1024                .unwrap_or(usize::MAX);
1025            assert!(
1026                pos_b < pos_c,
1027                "QmB should precede QmC; hints={:?}",
1028                hints.iter().map(|h| &h.cid).collect::<Vec<_>>()
1029            );
1030        }
1031    }
1032
1033    #[test]
1034    fn test_priority_non_negative() {
1035        let cfg = PeConfig {
1036            min_coaccess_count: 1,
1037            ..Default::default()
1038        };
1039        let mut engine = StoragePrefetchEngine::new(cfg);
1040        for i in 0u64..2 {
1041            engine.record_access(make_event("QmA", i * 200));
1042            engine.record_access(make_event("QmB", i * 200 + 10));
1043        }
1044        let hints = engine.generate_hints("QmA", 500_000);
1045        for h in hints {
1046            assert!(h.priority >= 0.0);
1047        }
1048    }
1049
1050    // ── total_pairs / history_len tests ──────────────────────────────────────
1051
1052    #[test]
1053    fn test_total_pairs_after_eviction() {
1054        let mut engine = default_engine();
1055        engine.record_access(make_event("QmA", 1_000));
1056        engine.record_access(make_event("QmB", 1_100));
1057        assert_eq!(engine.total_pairs(), 1);
1058        engine.evict_stale_pairs(0, 100_000);
1059        assert_eq!(engine.total_pairs(), 0);
1060    }
1061
1062    #[test]
1063    fn test_access_type_variants_compile() {
1064        let _ = PeAccessType::Read;
1065        let _ = PeAccessType::Write;
1066        let _ = PeAccessType::Delete;
1067    }
1068
1069    #[test]
1070    fn test_pe_prefetch_hint_fields() {
1071        let hint = PePrefetchHint {
1072            cid: "QmX".to_owned(),
1073            priority: 0.75,
1074            reason: "test".to_owned(),
1075        };
1076        assert_eq!(hint.cid, "QmX");
1077        assert!((hint.priority - 0.75).abs() < 1e-9);
1078    }
1079
1080    #[test]
1081    fn test_pe_prefetch_stats_fields() {
1082        let s = PePrefetchStats {
1083            total_events: 5,
1084            total_pairs: 2,
1085            avg_coaccess_count: 1.5,
1086            top_pattern: "Repeated".to_owned(),
1087            hints_generated: 10,
1088        };
1089        assert_eq!(s.total_events, 5);
1090        assert_eq!(s.total_pairs, 2);
1091    }
1092
1093    // ── edge-case tests ───────────────────────────────────────────────────────
1094
1095    #[test]
1096    fn test_same_cid_does_not_self_pair() {
1097        let mut engine = default_engine();
1098        engine.record_access(make_event("QmSelf", 1_000));
1099        engine.record_access(make_event("QmSelf", 1_100));
1100        // Same CID appearing twice should NOT create a self-pair.
1101        assert_eq!(engine.total_pairs(), 0);
1102    }
1103
1104    #[test]
1105    fn test_multiple_pairs_single_anchor() {
1106        let mut engine = default_engine();
1107        // Access QmA, then 5 different CIDs in quick succession.
1108        engine.record_access(make_event("QmA", 1_000));
1109        for i in 1u64..=5 {
1110            engine.record_access(make_event(&format!("QmX{}", i), 1_000 + i * 50));
1111        }
1112        // All 5 CIDs should be co-accessed with QmA (and also with each other).
1113        // The engine registers pairs for every CID within the window, so the total
1114        // will be at least 5 (the QmA-QmXn pairs) plus the cross-pairs among QmX*.
1115        assert!(
1116            engine.total_pairs() >= 5,
1117            "expected >= 5 pairs, got {}",
1118            engine.total_pairs()
1119        );
1120        // Verify the 5 QmA-specific pairs are present.
1121        let qm_a_pairs = engine.top_coaccessed("QmA", 10);
1122        assert_eq!(
1123            qm_a_pairs.len(),
1124            5,
1125            "QmA should have 5 direct co-access partners"
1126        );
1127    }
1128
1129    #[test]
1130    fn test_pattern_window_limits_state() {
1131        let cfg = PeConfig {
1132            pattern_window: 3,
1133            ..Default::default()
1134        };
1135        let mut engine = StoragePrefetchEngine::new(cfg);
1136        for i in 0u64..10 {
1137            engine.record_access(make_event("QmPW", i * 100));
1138        }
1139        let ts = engine
1140            .pattern_state
1141            .get("QmPW")
1142            .cloned()
1143            .unwrap_or_default();
1144        assert!(ts.len() <= 3, "len={}", ts.len());
1145    }
1146
1147    #[test]
1148    fn test_generate_hints_no_match() {
1149        let engine = default_engine();
1150        let hints = engine.generate_hints("QmNone", 0);
1151        assert!(hints.is_empty());
1152    }
1153}