Skip to main content

ipfrs_storage/
block_access_optimizer.rs

1//! Block Access Optimizer — learns co-access patterns, predicts future accesses,
2//! and generates prefetch recommendations to minimize storage latency.
3
4use std::cmp::Reverse;
5use std::collections::{HashMap, VecDeque};
6
7// ---------------------------------------------------------------------------
8// Public types
9// ---------------------------------------------------------------------------
10
11/// A single block access event.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct AccessEvent {
14    /// Content identifier of the accessed block.
15    pub cid: String,
16    /// Wall-clock timestamp in milliseconds since epoch.
17    pub timestamp_ms: u64,
18    /// Observed latency for this access in milliseconds.
19    pub latency_ms: u32,
20    /// Whether the access was served from cache.
21    pub cache_hit: bool,
22}
23
24/// Tracks how often two blocks are accessed together (within the sliding window).
25#[derive(Debug, Clone)]
26pub struct CoAccessPair {
27    pub cid_a: String,
28    pub cid_b: String,
29    /// How many times these two CIDs have been co-accessed.
30    pub co_access_count: u64,
31    /// Average time interval between co-accesses in milliseconds.
32    pub avg_interval_ms: u64,
33}
34
35/// A prefetch recommendation generated when `trigger_cid` is accessed.
36#[derive(Debug, Clone)]
37pub struct PrefetchRecommendation {
38    /// The CID whose access triggered this recommendation.
39    pub trigger_cid: String,
40    /// Ordered list of CIDs to prefetch (highest confidence first).
41    pub prefetch_cids: Vec<String>,
42    /// Aggregate confidence score in [0, 1].
43    pub confidence: f64,
44    /// Estimated latency savings in milliseconds if the prefetch is served from cache.
45    pub estimated_benefit_ms: u32,
46}
47
48/// Summarises the access history for a single block.
49#[derive(Debug, Clone)]
50pub struct AccessPattern {
51    pub cid: String,
52    /// Number of times this block has been accessed.
53    pub total_accesses: u64,
54    /// EWMA-smoothed average inter-access interval in milliseconds.
55    pub avg_interval_ms: u64,
56    /// Timestamp of the most recent access in milliseconds.
57    pub last_access_ms: u64,
58    /// Predicted timestamp of the next access: `last_access_ms + avg_interval_ms`.
59    pub predicted_next_ms: u64,
60}
61
62/// Configuration knobs for `BlockAccessOptimizer`.
63#[derive(Debug, Clone)]
64pub struct OptimizerConfig {
65    /// Maximum number of events kept in the sliding window.
66    pub window_size: usize,
67    /// Minimum co-access count before a pair is considered for prefetch.
68    pub min_co_access_count: u64,
69    /// Maximum number of CIDs included in a single prefetch recommendation.
70    pub max_prefetch_candidates: usize,
71    /// Minimum confidence score for a CID to be included in a recommendation.
72    pub confidence_threshold: f64,
73    /// Multiplicative decay applied to co-access counts on each `apply_decay` call.
74    pub pattern_decay_factor: f64,
75}
76
77impl Default for OptimizerConfig {
78    fn default() -> Self {
79        Self {
80            window_size: 1000,
81            min_co_access_count: 3,
82            max_prefetch_candidates: 5,
83            confidence_threshold: 0.6,
84            pattern_decay_factor: 0.95,
85        }
86    }
87}
88
89/// Snapshot of optimizer-level statistics.
90#[derive(Debug, Clone)]
91pub struct OptimizerStats {
92    pub total_events: u64,
93    pub unique_blocks: usize,
94    pub co_access_pairs: usize,
95    pub cache_hit_rate: f64,
96    pub window_size: usize,
97}
98
99// ---------------------------------------------------------------------------
100// BlockAccessOptimizer
101// ---------------------------------------------------------------------------
102
103/// Access-pattern optimizer that learns block co-access patterns, predicts
104/// future accesses, and generates prefetch recommendations.
105pub struct BlockAccessOptimizer {
106    /// Configuration parameters.
107    pub config: OptimizerConfig,
108    /// Sliding window of recent access events (bounded by `config.window_size`).
109    pub recent_window: VecDeque<AccessEvent>,
110    /// Per-block access statistics.
111    pub patterns: HashMap<String, AccessPattern>,
112    /// Co-access statistics: `co_access[cid_a][cid_b]`.
113    pub co_access: HashMap<String, HashMap<String, CoAccessPair>>,
114    /// Total number of events ever recorded.
115    pub total_events: u64,
116    /// Number of events that were cache hits.
117    pub cache_hit_count: u64,
118}
119
120impl BlockAccessOptimizer {
121    /// Create a new optimizer with the given configuration.
122    pub fn new(config: OptimizerConfig) -> Self {
123        Self {
124            config,
125            recent_window: VecDeque::new(),
126            patterns: HashMap::new(),
127            co_access: HashMap::new(),
128            total_events: 0,
129            cache_hit_count: 0,
130        }
131    }
132
133    /// Create a new optimizer with default configuration.
134    pub fn with_defaults() -> Self {
135        Self::new(OptimizerConfig::default())
136    }
137
138    // -----------------------------------------------------------------------
139    // Core recording logic
140    // -----------------------------------------------------------------------
141
142    /// Record a block access event.
143    ///
144    /// This method:
145    /// 1. Updates the sliding window (evicting the oldest event if necessary).
146    /// 2. Updates the per-block `AccessPattern` using EWMA smoothing (α = 0.3).
147    /// 3. Records co-access pairs with the last 5 events in the window.
148    pub fn record_access(&mut self, event: AccessEvent) {
149        // --- counters ---
150        self.total_events += 1;
151        if event.cache_hit {
152            self.cache_hit_count += 1;
153        }
154
155        let cid = event.cid.clone();
156        let ts = event.timestamp_ms;
157
158        // --- update per-block pattern ---
159        match self.patterns.get_mut(&cid) {
160            Some(pat) => {
161                // Compute inter-access interval only when we have a previous access.
162                let interval = ts.saturating_sub(pat.last_access_ms);
163                // EWMA with α = 0.3
164                const ALPHA: f64 = 0.3;
165                let new_avg = if pat.total_accesses == 1 {
166                    // First update: bootstrap with the observed interval.
167                    interval as f64
168                } else {
169                    ALPHA * interval as f64 + (1.0 - ALPHA) * pat.avg_interval_ms as f64
170                };
171                pat.avg_interval_ms = new_avg.round() as u64;
172                pat.last_access_ms = ts;
173                pat.predicted_next_ms = ts + pat.avg_interval_ms;
174                pat.total_accesses += 1;
175            }
176            None => {
177                self.patterns.insert(
178                    cid.clone(),
179                    AccessPattern {
180                        cid: cid.clone(),
181                        total_accesses: 1,
182                        avg_interval_ms: 0,
183                        last_access_ms: ts,
184                        predicted_next_ms: ts,
185                    },
186                );
187            }
188        }
189
190        // --- record co-access pairs with last 5 window events ---
191        // Collect previous (cid, timestamp) before any mutable borrows to avoid
192        // conflicting borrows on `self`.
193        let look_back = self.recent_window.len().min(5);
194        let start = self.recent_window.len().saturating_sub(look_back);
195        let prev_events: Vec<(String, u64)> = self
196            .recent_window
197            .iter()
198            .skip(start)
199            .filter(|e| e.cid != cid)
200            .map(|e| (e.cid.clone(), e.timestamp_ms))
201            .collect();
202
203        for (prev_cid, prev_ts) in prev_events {
204            let interval = ts.saturating_sub(prev_ts);
205            self.update_co_access_pair(cid.clone(), prev_cid.clone(), interval);
206            self.update_co_access_pair(prev_cid, cid.clone(), interval);
207        }
208
209        // --- maintain sliding window ---
210        if self.recent_window.len() >= self.config.window_size {
211            self.recent_window.pop_front();
212        }
213        self.recent_window.push_back(event);
214    }
215
216    /// Update or insert a `CoAccessPair` entry.
217    fn update_co_access_pair(&mut self, cid_a: String, cid_b: String, interval_ms: u64) {
218        let inner = self.co_access.entry(cid_a.clone()).or_default();
219        match inner.get_mut(&cid_b) {
220            Some(pair) => {
221                pair.co_access_count += 1;
222                // EWMA with α = 0.3 for interval smoothing.
223                const ALPHA: f64 = 0.3;
224                let new_avg =
225                    ALPHA * interval_ms as f64 + (1.0 - ALPHA) * pair.avg_interval_ms as f64;
226                pair.avg_interval_ms = new_avg.round() as u64;
227            }
228            None => {
229                inner.insert(
230                    cid_b.clone(),
231                    CoAccessPair {
232                        cid_a,
233                        cid_b,
234                        co_access_count: 1,
235                        avg_interval_ms: interval_ms,
236                    },
237                );
238            }
239        }
240    }
241
242    // -----------------------------------------------------------------------
243    // Recommendations & predictions
244    // -----------------------------------------------------------------------
245
246    /// Generate a prefetch recommendation for a given trigger CID.
247    ///
248    /// Returns a `PrefetchRecommendation` even when no candidates meet the
249    /// threshold (in that case `prefetch_cids` will be empty and confidence = 0).
250    pub fn recommend_prefetch(&self, trigger_cid: &str) -> PrefetchRecommendation {
251        let candidates = match self.co_access.get(trigger_cid) {
252            Some(inner) => inner,
253            None => {
254                return PrefetchRecommendation {
255                    trigger_cid: trigger_cid.to_owned(),
256                    prefetch_cids: vec![],
257                    confidence: 0.0,
258                    estimated_benefit_ms: 0,
259                };
260            }
261        };
262
263        // Filter by minimum co-access count.
264        let mut pairs: Vec<&CoAccessPair> = candidates
265            .values()
266            .filter(|p| p.co_access_count >= self.config.min_co_access_count)
267            .collect();
268
269        // Sort by co-access count descending.
270        pairs.sort_by_key(|b| Reverse(b.co_access_count));
271
272        // Take the top candidates and compute per-candidate confidence.
273        let mut prefetch_cids: Vec<String> = Vec::new();
274        let mut total_confidence = 0.0_f64;
275        let mut count = 0usize;
276
277        for pair in pairs.into_iter().take(self.config.max_prefetch_candidates) {
278            let n = pair.co_access_count as f64;
279            let confidence = (n / (n + 1.0)).min(1.0);
280            if confidence >= self.config.confidence_threshold {
281                prefetch_cids.push(pair.cid_b.clone());
282                total_confidence += confidence;
283                count += 1;
284            }
285        }
286
287        let aggregate_confidence = if count > 0 {
288            (total_confidence / count as f64).min(1.0)
289        } else {
290            0.0
291        };
292
293        // Estimated benefit: 50 ms placeholder per candidate.
294        let estimated_benefit_ms = (count as u32) * 50;
295
296        PrefetchRecommendation {
297            trigger_cid: trigger_cid.to_owned(),
298            prefetch_cids,
299            confidence: aggregate_confidence,
300            estimated_benefit_ms,
301        }
302    }
303
304    /// Predict the next access timestamp (ms) for the given CID.
305    pub fn predict_next_access(&self, cid: &str) -> Option<u64> {
306        self.patterns.get(cid).map(|p| p.predicted_next_ms)
307    }
308
309    // -----------------------------------------------------------------------
310    // Analytics helpers
311    // -----------------------------------------------------------------------
312
313    /// Return the top-`k` hottest blocks by total access count.
314    pub fn hot_blocks(&self, k: usize) -> Vec<&AccessPattern> {
315        let mut patterns: Vec<&AccessPattern> = self.patterns.values().collect();
316        patterns.sort_by_key(|b| Reverse(b.total_accesses));
317        patterns.into_iter().take(k).collect()
318    }
319
320    /// Return the top-`k` co-access pairs by co-access count across all CIDs.
321    pub fn top_co_access_pairs(&self, k: usize) -> Vec<&CoAccessPair> {
322        let mut all_pairs: Vec<&CoAccessPair> = self
323            .co_access
324            .values()
325            .flat_map(|inner| inner.values())
326            .collect();
327        all_pairs.sort_by_key(|b| Reverse(b.co_access_count));
328        all_pairs.into_iter().take(k).collect()
329    }
330
331    // -----------------------------------------------------------------------
332    // Maintenance
333    // -----------------------------------------------------------------------
334
335    /// Apply exponential decay to all co-access counts.
336    ///
337    /// Counts are multiplied by `pattern_decay_factor` and rounded.
338    /// Pairs whose count drops below 1 are removed.
339    pub fn apply_decay(&mut self) {
340        let decay = self.config.pattern_decay_factor;
341        for inner in self.co_access.values_mut() {
342            inner.retain(|_, pair| {
343                let decayed = (pair.co_access_count as f64 * decay).round() as u64;
344                pair.co_access_count = decayed;
345                decayed >= 1
346            });
347        }
348        // Remove top-level entries that have become empty.
349        self.co_access.retain(|_, inner| !inner.is_empty());
350    }
351
352    // -----------------------------------------------------------------------
353    // Statistics
354    // -----------------------------------------------------------------------
355
356    /// Overall cache-hit rate across all recorded events.
357    pub fn cache_hit_rate(&self) -> f64 {
358        if self.total_events == 0 {
359            0.0
360        } else {
361            self.cache_hit_count as f64 / self.total_events as f64
362        }
363    }
364
365    /// Return a snapshot of high-level optimizer statistics.
366    pub fn optimizer_stats(&self) -> OptimizerStats {
367        let co_access_pairs = self
368            .co_access
369            .values()
370            .map(|inner| inner.len())
371            .sum::<usize>();
372
373        OptimizerStats {
374            total_events: self.total_events,
375            unique_blocks: self.patterns.len(),
376            co_access_pairs,
377            cache_hit_rate: self.cache_hit_rate(),
378            window_size: self.recent_window.len(),
379        }
380    }
381}
382
383// ---------------------------------------------------------------------------
384// Tests
385// ---------------------------------------------------------------------------
386
387#[cfg(test)]
388mod tests {
389    use crate::block_access_optimizer::{AccessEvent, BlockAccessOptimizer, OptimizerConfig};
390
391    // -----------------------------------------------------------------------
392    // Helpers
393    // -----------------------------------------------------------------------
394
395    fn event(cid: &str, ts: u64, latency: u32, hit: bool) -> AccessEvent {
396        AccessEvent {
397            cid: cid.to_owned(),
398            timestamp_ms: ts,
399            latency_ms: latency,
400            cache_hit: hit,
401        }
402    }
403
404    fn default_optimizer() -> BlockAccessOptimizer {
405        BlockAccessOptimizer::with_defaults()
406    }
407
408    // -----------------------------------------------------------------------
409    // Construction
410    // -----------------------------------------------------------------------
411
412    #[test]
413    fn test_new_empty() {
414        let opt = default_optimizer();
415        assert_eq!(opt.total_events, 0);
416        assert_eq!(opt.cache_hit_count, 0);
417        assert!(opt.patterns.is_empty());
418        assert!(opt.co_access.is_empty());
419        assert!(opt.recent_window.is_empty());
420    }
421
422    #[test]
423    fn test_new_with_config() {
424        let cfg = OptimizerConfig {
425            window_size: 50,
426            min_co_access_count: 2,
427            max_prefetch_candidates: 3,
428            confidence_threshold: 0.5,
429            pattern_decay_factor: 0.9,
430        };
431        let opt = BlockAccessOptimizer::new(cfg.clone());
432        assert_eq!(opt.config.window_size, 50);
433        assert_eq!(opt.config.min_co_access_count, 2);
434        assert_eq!(opt.config.max_prefetch_candidates, 3);
435        assert!((opt.config.confidence_threshold - 0.5).abs() < 1e-9);
436        assert!((opt.config.pattern_decay_factor - 0.9).abs() < 1e-9);
437    }
438
439    // -----------------------------------------------------------------------
440    // record_access — basic
441    // -----------------------------------------------------------------------
442
443    #[test]
444    fn test_record_increments_total_events() {
445        let mut opt = default_optimizer();
446        opt.record_access(event("cid1", 1000, 10, false));
447        assert_eq!(opt.total_events, 1);
448        opt.record_access(event("cid2", 2000, 5, true));
449        assert_eq!(opt.total_events, 2);
450    }
451
452    #[test]
453    fn test_record_increments_cache_hit_count() {
454        let mut opt = default_optimizer();
455        opt.record_access(event("cid1", 1000, 10, true));
456        opt.record_access(event("cid2", 2000, 5, false));
457        opt.record_access(event("cid3", 3000, 8, true));
458        assert_eq!(opt.cache_hit_count, 2);
459    }
460
461    #[test]
462    fn test_record_creates_pattern() {
463        let mut opt = default_optimizer();
464        opt.record_access(event("cid1", 1000, 10, false));
465        assert!(opt.patterns.contains_key("cid1"));
466        let pat = opt.patterns.get("cid1").expect("pattern should exist");
467        assert_eq!(pat.cid, "cid1");
468        assert_eq!(pat.total_accesses, 1);
469        assert_eq!(pat.last_access_ms, 1000);
470    }
471
472    #[test]
473    fn test_record_increments_total_accesses() {
474        let mut opt = default_optimizer();
475        opt.record_access(event("cid1", 1000, 10, false));
476        opt.record_access(event("cid1", 2000, 5, false));
477        let pat = opt.patterns.get("cid1").expect("pattern should exist");
478        assert_eq!(pat.total_accesses, 2);
479    }
480
481    #[test]
482    fn test_record_updates_last_access() {
483        let mut opt = default_optimizer();
484        opt.record_access(event("cid1", 1000, 10, false));
485        opt.record_access(event("cid1", 3000, 5, false));
486        let pat = opt.patterns.get("cid1").expect("pattern should exist");
487        assert_eq!(pat.last_access_ms, 3000);
488    }
489
490    #[test]
491    fn test_predicted_next_equals_last_plus_avg() {
492        let mut opt = default_optimizer();
493        opt.record_access(event("cid1", 1000, 10, false));
494        opt.record_access(event("cid1", 3000, 5, false));
495        let pat = opt.patterns.get("cid1").expect("pattern should exist");
496        assert_eq!(
497            pat.predicted_next_ms,
498            pat.last_access_ms + pat.avg_interval_ms
499        );
500    }
501
502    // -----------------------------------------------------------------------
503    // EWMA
504    // -----------------------------------------------------------------------
505
506    #[test]
507    fn test_avg_interval_bootstrap_on_second_access() {
508        let mut opt = default_optimizer();
509        opt.record_access(event("cid1", 0, 10, false));
510        opt.record_access(event("cid1", 1000, 5, false));
511        // After first update EWMA bootstraps to the observed interval (1000 ms).
512        let pat = opt.patterns.get("cid1").expect("pattern should exist");
513        assert_eq!(pat.avg_interval_ms, 1000);
514    }
515
516    #[test]
517    fn test_avg_interval_ewma_third_access() {
518        let mut opt = default_optimizer();
519        opt.record_access(event("cid1", 0, 10, false));
520        opt.record_access(event("cid1", 1000, 5, false)); // avg = 1000
521        opt.record_access(event("cid1", 3000, 5, false)); // interval = 2000, alpha = 0.3
522                                                          // new_avg = 0.3 * 2000 + 0.7 * 1000 = 600 + 700 = 1300
523        let pat = opt.patterns.get("cid1").expect("pattern should exist");
524        assert_eq!(pat.avg_interval_ms, 1300);
525    }
526
527    // -----------------------------------------------------------------------
528    // Sliding window
529    // -----------------------------------------------------------------------
530
531    #[test]
532    fn test_window_respects_size() {
533        let cfg = OptimizerConfig {
534            window_size: 5,
535            ..OptimizerConfig::default()
536        };
537        let mut opt = BlockAccessOptimizer::new(cfg);
538        for i in 0..10u64 {
539            opt.record_access(event(&format!("c{}", i), i * 100, 10, false));
540        }
541        assert_eq!(opt.recent_window.len(), 5);
542    }
543
544    #[test]
545    fn test_window_evicts_oldest() {
546        let cfg = OptimizerConfig {
547            window_size: 3,
548            ..OptimizerConfig::default()
549        };
550        let mut opt = BlockAccessOptimizer::new(cfg);
551        opt.record_access(event("old", 100, 10, false));
552        opt.record_access(event("mid", 200, 10, false));
553        opt.record_access(event("new1", 300, 10, false));
554        opt.record_access(event("new2", 400, 10, false));
555        // "old" should be evicted
556        assert!(opt.recent_window.iter().all(|e| e.cid != "old"));
557    }
558
559    // -----------------------------------------------------------------------
560    // Co-access tracking
561    // -----------------------------------------------------------------------
562
563    #[test]
564    fn test_co_access_created_for_pair() {
565        let mut opt = default_optimizer();
566        opt.record_access(event("A", 100, 5, false));
567        opt.record_access(event("B", 200, 5, false));
568        // B was recorded after A; A should be in B's co-access and vice versa.
569        assert!(opt.co_access.get("B").is_some_and(|m| m.contains_key("A")));
570        assert!(opt.co_access.get("A").is_some_and(|m| m.contains_key("B")));
571    }
572
573    #[test]
574    fn test_co_access_count_increments() {
575        let mut opt = default_optimizer();
576        opt.record_access(event("A", 100, 5, false));
577        opt.record_access(event("B", 200, 5, false));
578        opt.record_access(event("A", 300, 5, false));
579        opt.record_access(event("B", 400, 5, false));
580        let count = opt
581            .co_access
582            .get("B")
583            .and_then(|m| m.get("A"))
584            .map(|p| p.co_access_count)
585            .unwrap_or(0);
586        assert!(count >= 2, "expected at least 2 co-accesses, got {count}");
587    }
588
589    #[test]
590    fn test_co_access_same_cid_skipped() {
591        let mut opt = default_optimizer();
592        opt.record_access(event("A", 100, 5, false));
593        opt.record_access(event("A", 200, 5, false));
594        // Self-pair should not be recorded.
595        let has_self = opt.co_access.get("A").is_some_and(|m| m.contains_key("A"));
596        assert!(!has_self);
597    }
598
599    #[test]
600    fn test_co_access_look_back_limit() {
601        let mut opt = default_optimizer();
602        // Record 8 distinct CIDs.
603        for i in 0..8u64 {
604            opt.record_access(event(&format!("c{}", i), i * 100, 5, false));
605        }
606        // The last event (c7) should have co-accesses with at most 5 predecessors (c2..c6).
607        let inner = opt.co_access.get("c7").cloned().unwrap_or_default();
608        // c0 and c1 should NOT be in c7's co-access (outside look-back window).
609        assert!(!inner.contains_key("c0"), "c0 should be outside look-back");
610        assert!(!inner.contains_key("c1"), "c1 should be outside look-back");
611    }
612
613    // -----------------------------------------------------------------------
614    // predict_next_access
615    // -----------------------------------------------------------------------
616
617    #[test]
618    fn test_predict_next_returns_none_for_unknown() {
619        let opt = default_optimizer();
620        assert!(opt.predict_next_access("nonexistent").is_none());
621    }
622
623    #[test]
624    fn test_predict_next_returns_some_after_access() {
625        let mut opt = default_optimizer();
626        opt.record_access(event("A", 1000, 5, false));
627        assert!(opt.predict_next_access("A").is_some());
628    }
629
630    #[test]
631    fn test_predict_next_matches_pattern() {
632        let mut opt = default_optimizer();
633        opt.record_access(event("A", 0, 5, false));
634        opt.record_access(event("A", 2000, 5, false));
635        let predicted = opt.predict_next_access("A").unwrap_or(0);
636        let pat = opt.patterns.get("A").expect("pattern");
637        assert_eq!(predicted, pat.predicted_next_ms);
638    }
639
640    // -----------------------------------------------------------------------
641    // hot_blocks
642    // -----------------------------------------------------------------------
643
644    #[test]
645    fn test_hot_blocks_ordering() {
646        let mut opt = default_optimizer();
647        // "hot" accessed 5 times, "cold" accessed once.
648        for i in 0..5u64 {
649            opt.record_access(event("hot", i * 100, 5, false));
650        }
651        opt.record_access(event("cold", 600, 5, false));
652        let top = opt.hot_blocks(1);
653        assert_eq!(top.len(), 1);
654        assert_eq!(top[0].cid, "hot");
655    }
656
657    #[test]
658    fn test_hot_blocks_k_limit() {
659        let mut opt = default_optimizer();
660        for i in 0..10u64 {
661            opt.record_access(event(&format!("c{}", i), i * 100, 5, false));
662        }
663        let top = opt.hot_blocks(3);
664        assert_eq!(top.len(), 3);
665    }
666
667    #[test]
668    fn test_hot_blocks_empty() {
669        let opt = default_optimizer();
670        assert!(opt.hot_blocks(5).is_empty());
671    }
672
673    // -----------------------------------------------------------------------
674    // top_co_access_pairs
675    // -----------------------------------------------------------------------
676
677    #[test]
678    fn test_top_co_access_pairs_ordering() {
679        let mut opt = default_optimizer();
680        // Generate many co-accesses between A and B.
681        for i in 0..10u64 {
682            opt.record_access(event("A", i * 100, 5, false));
683            opt.record_access(event("B", i * 100 + 50, 5, false));
684        }
685        // One access between C and D.
686        opt.record_access(event("C", 5000, 5, false));
687        opt.record_access(event("D", 5050, 5, false));
688        let top = opt.top_co_access_pairs(1);
689        assert_eq!(top.len(), 1);
690        assert!(
691            (top[0].cid_a == "A" && top[0].cid_b == "B")
692                || (top[0].cid_a == "B" && top[0].cid_b == "A"),
693            "Expected A-B pair at top, got {}-{}",
694            top[0].cid_a,
695            top[0].cid_b,
696        );
697    }
698
699    #[test]
700    fn test_top_co_access_pairs_empty() {
701        let opt = default_optimizer();
702        assert!(opt.top_co_access_pairs(5).is_empty());
703    }
704
705    // -----------------------------------------------------------------------
706    // recommend_prefetch
707    // -----------------------------------------------------------------------
708
709    #[test]
710    fn test_recommend_prefetch_empty_for_unknown_cid() {
711        let opt = default_optimizer();
712        let rec = opt.recommend_prefetch("nope");
713        assert!(rec.prefetch_cids.is_empty());
714        assert_eq!(rec.trigger_cid, "nope");
715        assert!((rec.confidence - 0.0).abs() < 1e-9);
716    }
717
718    #[test]
719    fn test_recommend_prefetch_filters_below_threshold() {
720        let cfg = OptimizerConfig {
721            min_co_access_count: 10, // very high
722            ..OptimizerConfig::default()
723        };
724        let mut opt = BlockAccessOptimizer::new(cfg);
725        opt.record_access(event("A", 100, 5, false));
726        opt.record_access(event("B", 200, 5, false));
727        let rec = opt.recommend_prefetch("A");
728        // Only 1 co-access, below min_co_access_count=10 → empty.
729        assert!(rec.prefetch_cids.is_empty());
730    }
731
732    #[test]
733    fn test_recommend_prefetch_returns_candidates() {
734        let cfg = OptimizerConfig {
735            min_co_access_count: 2,
736            confidence_threshold: 0.5,
737            ..OptimizerConfig::default()
738        };
739        let mut opt = BlockAccessOptimizer::new(cfg);
740        // Generate enough co-accesses to exceed threshold.
741        for i in 0..10u64 {
742            opt.record_access(event("trigger", i * 200, 5, false));
743            opt.record_access(event("follower", i * 200 + 100, 5, false));
744        }
745        let rec = opt.recommend_prefetch("trigger");
746        assert!(!rec.prefetch_cids.is_empty());
747        assert!(rec.confidence >= 0.5);
748    }
749
750    #[test]
751    fn test_recommend_prefetch_respects_max_candidates() {
752        let cfg = OptimizerConfig {
753            min_co_access_count: 1,
754            max_prefetch_candidates: 2,
755            confidence_threshold: 0.0,
756            ..OptimizerConfig::default()
757        };
758        let mut opt = BlockAccessOptimizer::new(cfg);
759        opt.record_access(event("trigger", 100, 5, false));
760        for i in 1..=6u64 {
761            opt.record_access(event(&format!("follow{}", i), 100 + i * 10, 5, false));
762        }
763        // Prime the window.
764        opt.record_access(event("trigger", 200, 5, false));
765        let rec = opt.recommend_prefetch("trigger");
766        assert!(rec.prefetch_cids.len() <= 2);
767    }
768
769    #[test]
770    fn test_recommend_prefetch_trigger_cid_in_result() {
771        let opt = default_optimizer();
772        let rec = opt.recommend_prefetch("xyz");
773        assert_eq!(rec.trigger_cid, "xyz");
774    }
775
776    #[test]
777    fn test_recommend_prefetch_estimated_benefit_nonzero() {
778        let cfg = OptimizerConfig {
779            min_co_access_count: 2,
780            confidence_threshold: 0.5,
781            ..OptimizerConfig::default()
782        };
783        let mut opt = BlockAccessOptimizer::new(cfg);
784        for i in 0..10u64 {
785            opt.record_access(event("A", i * 200, 5, false));
786            opt.record_access(event("B", i * 200 + 100, 5, false));
787        }
788        let rec = opt.recommend_prefetch("A");
789        if !rec.prefetch_cids.is_empty() {
790            assert!(rec.estimated_benefit_ms > 0);
791        }
792    }
793
794    // -----------------------------------------------------------------------
795    // cache_hit_rate
796    // -----------------------------------------------------------------------
797
798    #[test]
799    fn test_cache_hit_rate_zero_events() {
800        let opt = default_optimizer();
801        assert!((opt.cache_hit_rate() - 0.0).abs() < 1e-9);
802    }
803
804    #[test]
805    fn test_cache_hit_rate_all_hits() {
806        let mut opt = default_optimizer();
807        opt.record_access(event("A", 100, 5, true));
808        opt.record_access(event("B", 200, 5, true));
809        assert!((opt.cache_hit_rate() - 1.0).abs() < 1e-9);
810    }
811
812    #[test]
813    fn test_cache_hit_rate_no_hits() {
814        let mut opt = default_optimizer();
815        opt.record_access(event("A", 100, 5, false));
816        opt.record_access(event("B", 200, 5, false));
817        assert!((opt.cache_hit_rate() - 0.0).abs() < 1e-9);
818    }
819
820    #[test]
821    fn test_cache_hit_rate_partial() {
822        let mut opt = default_optimizer();
823        opt.record_access(event("A", 100, 5, true));
824        opt.record_access(event("B", 200, 5, false));
825        let rate = opt.cache_hit_rate();
826        assert!((rate - 0.5).abs() < 1e-9);
827    }
828
829    // -----------------------------------------------------------------------
830    // apply_decay
831    // -----------------------------------------------------------------------
832
833    #[test]
834    fn test_apply_decay_reduces_counts() {
835        let cfg = OptimizerConfig {
836            pattern_decay_factor: 0.5,
837            ..OptimizerConfig::default()
838        };
839        let mut opt = BlockAccessOptimizer::new(cfg);
840        // Build up a co-access count of 10.
841        for i in 0..10u64 {
842            opt.record_access(event("A", i * 100, 5, false));
843            opt.record_access(event("B", i * 100 + 50, 5, false));
844        }
845        let before = opt
846            .co_access
847            .get("A")
848            .and_then(|m| m.get("B"))
849            .map(|p| p.co_access_count)
850            .unwrap_or(0);
851        opt.apply_decay();
852        let after = opt
853            .co_access
854            .get("A")
855            .and_then(|m| m.get("B"))
856            .map(|p| p.co_access_count)
857            .unwrap_or(0);
858        assert!(after < before, "after={after} should be < before={before}");
859    }
860
861    #[test]
862    fn test_apply_decay_removes_low_count_pairs() {
863        let cfg = OptimizerConfig {
864            pattern_decay_factor: 0.01, // extreme decay
865            ..OptimizerConfig::default()
866        };
867        let mut opt = BlockAccessOptimizer::new(cfg);
868        opt.record_access(event("A", 100, 5, false));
869        opt.record_access(event("B", 200, 5, false));
870        opt.apply_decay();
871        // count was 1, after *0.01 rounds to 0 → removed.
872        let exists = opt.co_access.get("A").is_some_and(|m| m.contains_key("B"));
873        assert!(!exists, "pair should have been removed after extreme decay");
874    }
875
876    #[test]
877    fn test_apply_decay_cleans_empty_outer_map() {
878        let cfg = OptimizerConfig {
879            pattern_decay_factor: 0.01,
880            ..OptimizerConfig::default()
881        };
882        let mut opt = BlockAccessOptimizer::new(cfg);
883        opt.record_access(event("A", 100, 5, false));
884        opt.record_access(event("B", 200, 5, false));
885        opt.apply_decay();
886        // Outer map for "A" should be removed when it becomes empty.
887        let outer_empty = !opt.co_access.contains_key("A")
888            || opt.co_access.get("A").is_some_and(|m| m.is_empty());
889        assert!(outer_empty);
890    }
891
892    // -----------------------------------------------------------------------
893    // optimizer_stats
894    // -----------------------------------------------------------------------
895
896    #[test]
897    fn test_optimizer_stats_initial() {
898        let opt = default_optimizer();
899        let stats = opt.optimizer_stats();
900        assert_eq!(stats.total_events, 0);
901        assert_eq!(stats.unique_blocks, 0);
902        assert_eq!(stats.co_access_pairs, 0);
903        assert_eq!(stats.window_size, 0);
904    }
905
906    #[test]
907    fn test_optimizer_stats_after_events() {
908        let mut opt = default_optimizer();
909        opt.record_access(event("A", 100, 5, true));
910        opt.record_access(event("B", 200, 5, false));
911        let stats = opt.optimizer_stats();
912        assert_eq!(stats.total_events, 2);
913        assert_eq!(stats.unique_blocks, 2);
914        assert_eq!(stats.window_size, 2);
915        assert!((stats.cache_hit_rate - 0.5).abs() < 1e-9);
916    }
917
918    #[test]
919    fn test_optimizer_stats_co_access_count() {
920        let mut opt = default_optimizer();
921        opt.record_access(event("A", 100, 5, false));
922        opt.record_access(event("B", 200, 5, false));
923        let stats = opt.optimizer_stats();
924        // A<->B creates 2 directional pairs.
925        assert_eq!(stats.co_access_pairs, 2);
926    }
927
928    // -----------------------------------------------------------------------
929    // Edge cases
930    // -----------------------------------------------------------------------
931
932    #[test]
933    fn test_single_event_no_co_access() {
934        let mut opt = default_optimizer();
935        opt.record_access(event("only", 100, 5, false));
936        assert!(opt.co_access.is_empty());
937    }
938
939    #[test]
940    fn test_same_cid_repeated_no_co_access_with_itself() {
941        let mut opt = default_optimizer();
942        for i in 0..5u64 {
943            opt.record_access(event("X", i * 100, 5, false));
944        }
945        let has_self_pair = opt.co_access.get("X").is_some_and(|m| m.contains_key("X"));
946        assert!(!has_self_pair);
947    }
948
949    #[test]
950    fn test_hot_blocks_k_larger_than_available() {
951        let mut opt = default_optimizer();
952        opt.record_access(event("only", 100, 5, false));
953        let top = opt.hot_blocks(100);
954        assert_eq!(top.len(), 1);
955    }
956
957    #[test]
958    fn test_top_co_access_pairs_k_larger_than_available() {
959        let mut opt = default_optimizer();
960        opt.record_access(event("A", 100, 5, false));
961        opt.record_access(event("B", 200, 5, false));
962        let pairs = opt.top_co_access_pairs(100);
963        assert_eq!(pairs.len(), 2); // A->B and B->A
964    }
965
966    #[test]
967    fn test_decay_on_empty_optimizer() {
968        let mut opt = default_optimizer();
969        // Should not panic.
970        opt.apply_decay();
971        assert!(opt.co_access.is_empty());
972    }
973
974    #[test]
975    fn test_multiple_decays_converge_to_zero() {
976        // Use an extreme decay factor (0.01) so that any positive count rounds
977        // to 0 after a single application: round(1 * 0.01) = 0 < 1 → removed.
978        let cfg = OptimizerConfig {
979            pattern_decay_factor: 0.01,
980            ..OptimizerConfig::default()
981        };
982        let mut opt = BlockAccessOptimizer::new(cfg);
983        // A single A->B co-access creates directional pairs with count=1.
984        opt.record_access(event("A", 100, 5, false));
985        opt.record_access(event("B", 200, 5, false));
986        // A single decay should prune everything because round(1 * 0.01) = 0.
987        opt.apply_decay();
988        assert!(
989            opt.co_access.is_empty(),
990            "all pairs should be pruned after one aggressive decay"
991        );
992    }
993}