use std::cmp::Reverse;
use std::collections::{HashMap, VecDeque};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccessEvent {
pub cid: String,
pub timestamp_ms: u64,
pub latency_ms: u32,
pub cache_hit: bool,
}
#[derive(Debug, Clone)]
pub struct CoAccessPair {
pub cid_a: String,
pub cid_b: String,
pub co_access_count: u64,
pub avg_interval_ms: u64,
}
#[derive(Debug, Clone)]
pub struct PrefetchRecommendation {
pub trigger_cid: String,
pub prefetch_cids: Vec<String>,
pub confidence: f64,
pub estimated_benefit_ms: u32,
}
#[derive(Debug, Clone)]
pub struct AccessPattern {
pub cid: String,
pub total_accesses: u64,
pub avg_interval_ms: u64,
pub last_access_ms: u64,
pub predicted_next_ms: u64,
}
#[derive(Debug, Clone)]
pub struct OptimizerConfig {
pub window_size: usize,
pub min_co_access_count: u64,
pub max_prefetch_candidates: usize,
pub confidence_threshold: f64,
pub pattern_decay_factor: f64,
}
impl Default for OptimizerConfig {
fn default() -> Self {
Self {
window_size: 1000,
min_co_access_count: 3,
max_prefetch_candidates: 5,
confidence_threshold: 0.6,
pattern_decay_factor: 0.95,
}
}
}
#[derive(Debug, Clone)]
pub struct OptimizerStats {
pub total_events: u64,
pub unique_blocks: usize,
pub co_access_pairs: usize,
pub cache_hit_rate: f64,
pub window_size: usize,
}
pub struct BlockAccessOptimizer {
pub config: OptimizerConfig,
pub recent_window: VecDeque<AccessEvent>,
pub patterns: HashMap<String, AccessPattern>,
pub co_access: HashMap<String, HashMap<String, CoAccessPair>>,
pub total_events: u64,
pub cache_hit_count: u64,
}
impl BlockAccessOptimizer {
pub fn new(config: OptimizerConfig) -> Self {
Self {
config,
recent_window: VecDeque::new(),
patterns: HashMap::new(),
co_access: HashMap::new(),
total_events: 0,
cache_hit_count: 0,
}
}
pub fn with_defaults() -> Self {
Self::new(OptimizerConfig::default())
}
pub fn record_access(&mut self, event: AccessEvent) {
self.total_events += 1;
if event.cache_hit {
self.cache_hit_count += 1;
}
let cid = event.cid.clone();
let ts = event.timestamp_ms;
match self.patterns.get_mut(&cid) {
Some(pat) => {
let interval = ts.saturating_sub(pat.last_access_ms);
const ALPHA: f64 = 0.3;
let new_avg = if pat.total_accesses == 1 {
interval as f64
} else {
ALPHA * interval as f64 + (1.0 - ALPHA) * pat.avg_interval_ms as f64
};
pat.avg_interval_ms = new_avg.round() as u64;
pat.last_access_ms = ts;
pat.predicted_next_ms = ts + pat.avg_interval_ms;
pat.total_accesses += 1;
}
None => {
self.patterns.insert(
cid.clone(),
AccessPattern {
cid: cid.clone(),
total_accesses: 1,
avg_interval_ms: 0,
last_access_ms: ts,
predicted_next_ms: ts,
},
);
}
}
let look_back = self.recent_window.len().min(5);
let start = self.recent_window.len().saturating_sub(look_back);
let prev_events: Vec<(String, u64)> = self
.recent_window
.iter()
.skip(start)
.filter(|e| e.cid != cid)
.map(|e| (e.cid.clone(), e.timestamp_ms))
.collect();
for (prev_cid, prev_ts) in prev_events {
let interval = ts.saturating_sub(prev_ts);
self.update_co_access_pair(cid.clone(), prev_cid.clone(), interval);
self.update_co_access_pair(prev_cid, cid.clone(), interval);
}
if self.recent_window.len() >= self.config.window_size {
self.recent_window.pop_front();
}
self.recent_window.push_back(event);
}
fn update_co_access_pair(&mut self, cid_a: String, cid_b: String, interval_ms: u64) {
let inner = self.co_access.entry(cid_a.clone()).or_default();
match inner.get_mut(&cid_b) {
Some(pair) => {
pair.co_access_count += 1;
const ALPHA: f64 = 0.3;
let new_avg =
ALPHA * interval_ms as f64 + (1.0 - ALPHA) * pair.avg_interval_ms as f64;
pair.avg_interval_ms = new_avg.round() as u64;
}
None => {
inner.insert(
cid_b.clone(),
CoAccessPair {
cid_a,
cid_b,
co_access_count: 1,
avg_interval_ms: interval_ms,
},
);
}
}
}
pub fn recommend_prefetch(&self, trigger_cid: &str) -> PrefetchRecommendation {
let candidates = match self.co_access.get(trigger_cid) {
Some(inner) => inner,
None => {
return PrefetchRecommendation {
trigger_cid: trigger_cid.to_owned(),
prefetch_cids: vec![],
confidence: 0.0,
estimated_benefit_ms: 0,
};
}
};
let mut pairs: Vec<&CoAccessPair> = candidates
.values()
.filter(|p| p.co_access_count >= self.config.min_co_access_count)
.collect();
pairs.sort_by_key(|b| Reverse(b.co_access_count));
let mut prefetch_cids: Vec<String> = Vec::new();
let mut total_confidence = 0.0_f64;
let mut count = 0usize;
for pair in pairs.into_iter().take(self.config.max_prefetch_candidates) {
let n = pair.co_access_count as f64;
let confidence = (n / (n + 1.0)).min(1.0);
if confidence >= self.config.confidence_threshold {
prefetch_cids.push(pair.cid_b.clone());
total_confidence += confidence;
count += 1;
}
}
let aggregate_confidence = if count > 0 {
(total_confidence / count as f64).min(1.0)
} else {
0.0
};
let estimated_benefit_ms = (count as u32) * 50;
PrefetchRecommendation {
trigger_cid: trigger_cid.to_owned(),
prefetch_cids,
confidence: aggregate_confidence,
estimated_benefit_ms,
}
}
pub fn predict_next_access(&self, cid: &str) -> Option<u64> {
self.patterns.get(cid).map(|p| p.predicted_next_ms)
}
pub fn hot_blocks(&self, k: usize) -> Vec<&AccessPattern> {
let mut patterns: Vec<&AccessPattern> = self.patterns.values().collect();
patterns.sort_by_key(|b| Reverse(b.total_accesses));
patterns.into_iter().take(k).collect()
}
pub fn top_co_access_pairs(&self, k: usize) -> Vec<&CoAccessPair> {
let mut all_pairs: Vec<&CoAccessPair> = self
.co_access
.values()
.flat_map(|inner| inner.values())
.collect();
all_pairs.sort_by_key(|b| Reverse(b.co_access_count));
all_pairs.into_iter().take(k).collect()
}
pub fn apply_decay(&mut self) {
let decay = self.config.pattern_decay_factor;
for inner in self.co_access.values_mut() {
inner.retain(|_, pair| {
let decayed = (pair.co_access_count as f64 * decay).round() as u64;
pair.co_access_count = decayed;
decayed >= 1
});
}
self.co_access.retain(|_, inner| !inner.is_empty());
}
pub fn cache_hit_rate(&self) -> f64 {
if self.total_events == 0 {
0.0
} else {
self.cache_hit_count as f64 / self.total_events as f64
}
}
pub fn optimizer_stats(&self) -> OptimizerStats {
let co_access_pairs = self
.co_access
.values()
.map(|inner| inner.len())
.sum::<usize>();
OptimizerStats {
total_events: self.total_events,
unique_blocks: self.patterns.len(),
co_access_pairs,
cache_hit_rate: self.cache_hit_rate(),
window_size: self.recent_window.len(),
}
}
}
#[cfg(test)]
mod tests {
use crate::block_access_optimizer::{AccessEvent, BlockAccessOptimizer, OptimizerConfig};
fn event(cid: &str, ts: u64, latency: u32, hit: bool) -> AccessEvent {
AccessEvent {
cid: cid.to_owned(),
timestamp_ms: ts,
latency_ms: latency,
cache_hit: hit,
}
}
fn default_optimizer() -> BlockAccessOptimizer {
BlockAccessOptimizer::with_defaults()
}
#[test]
fn test_new_empty() {
let opt = default_optimizer();
assert_eq!(opt.total_events, 0);
assert_eq!(opt.cache_hit_count, 0);
assert!(opt.patterns.is_empty());
assert!(opt.co_access.is_empty());
assert!(opt.recent_window.is_empty());
}
#[test]
fn test_new_with_config() {
let cfg = OptimizerConfig {
window_size: 50,
min_co_access_count: 2,
max_prefetch_candidates: 3,
confidence_threshold: 0.5,
pattern_decay_factor: 0.9,
};
let opt = BlockAccessOptimizer::new(cfg.clone());
assert_eq!(opt.config.window_size, 50);
assert_eq!(opt.config.min_co_access_count, 2);
assert_eq!(opt.config.max_prefetch_candidates, 3);
assert!((opt.config.confidence_threshold - 0.5).abs() < 1e-9);
assert!((opt.config.pattern_decay_factor - 0.9).abs() < 1e-9);
}
#[test]
fn test_record_increments_total_events() {
let mut opt = default_optimizer();
opt.record_access(event("cid1", 1000, 10, false));
assert_eq!(opt.total_events, 1);
opt.record_access(event("cid2", 2000, 5, true));
assert_eq!(opt.total_events, 2);
}
#[test]
fn test_record_increments_cache_hit_count() {
let mut opt = default_optimizer();
opt.record_access(event("cid1", 1000, 10, true));
opt.record_access(event("cid2", 2000, 5, false));
opt.record_access(event("cid3", 3000, 8, true));
assert_eq!(opt.cache_hit_count, 2);
}
#[test]
fn test_record_creates_pattern() {
let mut opt = default_optimizer();
opt.record_access(event("cid1", 1000, 10, false));
assert!(opt.patterns.contains_key("cid1"));
let pat = opt.patterns.get("cid1").expect("pattern should exist");
assert_eq!(pat.cid, "cid1");
assert_eq!(pat.total_accesses, 1);
assert_eq!(pat.last_access_ms, 1000);
}
#[test]
fn test_record_increments_total_accesses() {
let mut opt = default_optimizer();
opt.record_access(event("cid1", 1000, 10, false));
opt.record_access(event("cid1", 2000, 5, false));
let pat = opt.patterns.get("cid1").expect("pattern should exist");
assert_eq!(pat.total_accesses, 2);
}
#[test]
fn test_record_updates_last_access() {
let mut opt = default_optimizer();
opt.record_access(event("cid1", 1000, 10, false));
opt.record_access(event("cid1", 3000, 5, false));
let pat = opt.patterns.get("cid1").expect("pattern should exist");
assert_eq!(pat.last_access_ms, 3000);
}
#[test]
fn test_predicted_next_equals_last_plus_avg() {
let mut opt = default_optimizer();
opt.record_access(event("cid1", 1000, 10, false));
opt.record_access(event("cid1", 3000, 5, false));
let pat = opt.patterns.get("cid1").expect("pattern should exist");
assert_eq!(
pat.predicted_next_ms,
pat.last_access_ms + pat.avg_interval_ms
);
}
#[test]
fn test_avg_interval_bootstrap_on_second_access() {
let mut opt = default_optimizer();
opt.record_access(event("cid1", 0, 10, false));
opt.record_access(event("cid1", 1000, 5, false));
let pat = opt.patterns.get("cid1").expect("pattern should exist");
assert_eq!(pat.avg_interval_ms, 1000);
}
#[test]
fn test_avg_interval_ewma_third_access() {
let mut opt = default_optimizer();
opt.record_access(event("cid1", 0, 10, false));
opt.record_access(event("cid1", 1000, 5, false)); opt.record_access(event("cid1", 3000, 5, false)); let pat = opt.patterns.get("cid1").expect("pattern should exist");
assert_eq!(pat.avg_interval_ms, 1300);
}
#[test]
fn test_window_respects_size() {
let cfg = OptimizerConfig {
window_size: 5,
..OptimizerConfig::default()
};
let mut opt = BlockAccessOptimizer::new(cfg);
for i in 0..10u64 {
opt.record_access(event(&format!("c{}", i), i * 100, 10, false));
}
assert_eq!(opt.recent_window.len(), 5);
}
#[test]
fn test_window_evicts_oldest() {
let cfg = OptimizerConfig {
window_size: 3,
..OptimizerConfig::default()
};
let mut opt = BlockAccessOptimizer::new(cfg);
opt.record_access(event("old", 100, 10, false));
opt.record_access(event("mid", 200, 10, false));
opt.record_access(event("new1", 300, 10, false));
opt.record_access(event("new2", 400, 10, false));
assert!(opt.recent_window.iter().all(|e| e.cid != "old"));
}
#[test]
fn test_co_access_created_for_pair() {
let mut opt = default_optimizer();
opt.record_access(event("A", 100, 5, false));
opt.record_access(event("B", 200, 5, false));
assert!(opt.co_access.get("B").is_some_and(|m| m.contains_key("A")));
assert!(opt.co_access.get("A").is_some_and(|m| m.contains_key("B")));
}
#[test]
fn test_co_access_count_increments() {
let mut opt = default_optimizer();
opt.record_access(event("A", 100, 5, false));
opt.record_access(event("B", 200, 5, false));
opt.record_access(event("A", 300, 5, false));
opt.record_access(event("B", 400, 5, false));
let count = opt
.co_access
.get("B")
.and_then(|m| m.get("A"))
.map(|p| p.co_access_count)
.unwrap_or(0);
assert!(count >= 2, "expected at least 2 co-accesses, got {count}");
}
#[test]
fn test_co_access_same_cid_skipped() {
let mut opt = default_optimizer();
opt.record_access(event("A", 100, 5, false));
opt.record_access(event("A", 200, 5, false));
let has_self = opt.co_access.get("A").is_some_and(|m| m.contains_key("A"));
assert!(!has_self);
}
#[test]
fn test_co_access_look_back_limit() {
let mut opt = default_optimizer();
for i in 0..8u64 {
opt.record_access(event(&format!("c{}", i), i * 100, 5, false));
}
let inner = opt.co_access.get("c7").cloned().unwrap_or_default();
assert!(!inner.contains_key("c0"), "c0 should be outside look-back");
assert!(!inner.contains_key("c1"), "c1 should be outside look-back");
}
#[test]
fn test_predict_next_returns_none_for_unknown() {
let opt = default_optimizer();
assert!(opt.predict_next_access("nonexistent").is_none());
}
#[test]
fn test_predict_next_returns_some_after_access() {
let mut opt = default_optimizer();
opt.record_access(event("A", 1000, 5, false));
assert!(opt.predict_next_access("A").is_some());
}
#[test]
fn test_predict_next_matches_pattern() {
let mut opt = default_optimizer();
opt.record_access(event("A", 0, 5, false));
opt.record_access(event("A", 2000, 5, false));
let predicted = opt.predict_next_access("A").unwrap_or(0);
let pat = opt.patterns.get("A").expect("pattern");
assert_eq!(predicted, pat.predicted_next_ms);
}
#[test]
fn test_hot_blocks_ordering() {
let mut opt = default_optimizer();
for i in 0..5u64 {
opt.record_access(event("hot", i * 100, 5, false));
}
opt.record_access(event("cold", 600, 5, false));
let top = opt.hot_blocks(1);
assert_eq!(top.len(), 1);
assert_eq!(top[0].cid, "hot");
}
#[test]
fn test_hot_blocks_k_limit() {
let mut opt = default_optimizer();
for i in 0..10u64 {
opt.record_access(event(&format!("c{}", i), i * 100, 5, false));
}
let top = opt.hot_blocks(3);
assert_eq!(top.len(), 3);
}
#[test]
fn test_hot_blocks_empty() {
let opt = default_optimizer();
assert!(opt.hot_blocks(5).is_empty());
}
#[test]
fn test_top_co_access_pairs_ordering() {
let mut opt = default_optimizer();
for i in 0..10u64 {
opt.record_access(event("A", i * 100, 5, false));
opt.record_access(event("B", i * 100 + 50, 5, false));
}
opt.record_access(event("C", 5000, 5, false));
opt.record_access(event("D", 5050, 5, false));
let top = opt.top_co_access_pairs(1);
assert_eq!(top.len(), 1);
assert!(
(top[0].cid_a == "A" && top[0].cid_b == "B")
|| (top[0].cid_a == "B" && top[0].cid_b == "A"),
"Expected A-B pair at top, got {}-{}",
top[0].cid_a,
top[0].cid_b,
);
}
#[test]
fn test_top_co_access_pairs_empty() {
let opt = default_optimizer();
assert!(opt.top_co_access_pairs(5).is_empty());
}
#[test]
fn test_recommend_prefetch_empty_for_unknown_cid() {
let opt = default_optimizer();
let rec = opt.recommend_prefetch("nope");
assert!(rec.prefetch_cids.is_empty());
assert_eq!(rec.trigger_cid, "nope");
assert!((rec.confidence - 0.0).abs() < 1e-9);
}
#[test]
fn test_recommend_prefetch_filters_below_threshold() {
let cfg = OptimizerConfig {
min_co_access_count: 10, ..OptimizerConfig::default()
};
let mut opt = BlockAccessOptimizer::new(cfg);
opt.record_access(event("A", 100, 5, false));
opt.record_access(event("B", 200, 5, false));
let rec = opt.recommend_prefetch("A");
assert!(rec.prefetch_cids.is_empty());
}
#[test]
fn test_recommend_prefetch_returns_candidates() {
let cfg = OptimizerConfig {
min_co_access_count: 2,
confidence_threshold: 0.5,
..OptimizerConfig::default()
};
let mut opt = BlockAccessOptimizer::new(cfg);
for i in 0..10u64 {
opt.record_access(event("trigger", i * 200, 5, false));
opt.record_access(event("follower", i * 200 + 100, 5, false));
}
let rec = opt.recommend_prefetch("trigger");
assert!(!rec.prefetch_cids.is_empty());
assert!(rec.confidence >= 0.5);
}
#[test]
fn test_recommend_prefetch_respects_max_candidates() {
let cfg = OptimizerConfig {
min_co_access_count: 1,
max_prefetch_candidates: 2,
confidence_threshold: 0.0,
..OptimizerConfig::default()
};
let mut opt = BlockAccessOptimizer::new(cfg);
opt.record_access(event("trigger", 100, 5, false));
for i in 1..=6u64 {
opt.record_access(event(&format!("follow{}", i), 100 + i * 10, 5, false));
}
opt.record_access(event("trigger", 200, 5, false));
let rec = opt.recommend_prefetch("trigger");
assert!(rec.prefetch_cids.len() <= 2);
}
#[test]
fn test_recommend_prefetch_trigger_cid_in_result() {
let opt = default_optimizer();
let rec = opt.recommend_prefetch("xyz");
assert_eq!(rec.trigger_cid, "xyz");
}
#[test]
fn test_recommend_prefetch_estimated_benefit_nonzero() {
let cfg = OptimizerConfig {
min_co_access_count: 2,
confidence_threshold: 0.5,
..OptimizerConfig::default()
};
let mut opt = BlockAccessOptimizer::new(cfg);
for i in 0..10u64 {
opt.record_access(event("A", i * 200, 5, false));
opt.record_access(event("B", i * 200 + 100, 5, false));
}
let rec = opt.recommend_prefetch("A");
if !rec.prefetch_cids.is_empty() {
assert!(rec.estimated_benefit_ms > 0);
}
}
#[test]
fn test_cache_hit_rate_zero_events() {
let opt = default_optimizer();
assert!((opt.cache_hit_rate() - 0.0).abs() < 1e-9);
}
#[test]
fn test_cache_hit_rate_all_hits() {
let mut opt = default_optimizer();
opt.record_access(event("A", 100, 5, true));
opt.record_access(event("B", 200, 5, true));
assert!((opt.cache_hit_rate() - 1.0).abs() < 1e-9);
}
#[test]
fn test_cache_hit_rate_no_hits() {
let mut opt = default_optimizer();
opt.record_access(event("A", 100, 5, false));
opt.record_access(event("B", 200, 5, false));
assert!((opt.cache_hit_rate() - 0.0).abs() < 1e-9);
}
#[test]
fn test_cache_hit_rate_partial() {
let mut opt = default_optimizer();
opt.record_access(event("A", 100, 5, true));
opt.record_access(event("B", 200, 5, false));
let rate = opt.cache_hit_rate();
assert!((rate - 0.5).abs() < 1e-9);
}
#[test]
fn test_apply_decay_reduces_counts() {
let cfg = OptimizerConfig {
pattern_decay_factor: 0.5,
..OptimizerConfig::default()
};
let mut opt = BlockAccessOptimizer::new(cfg);
for i in 0..10u64 {
opt.record_access(event("A", i * 100, 5, false));
opt.record_access(event("B", i * 100 + 50, 5, false));
}
let before = opt
.co_access
.get("A")
.and_then(|m| m.get("B"))
.map(|p| p.co_access_count)
.unwrap_or(0);
opt.apply_decay();
let after = opt
.co_access
.get("A")
.and_then(|m| m.get("B"))
.map(|p| p.co_access_count)
.unwrap_or(0);
assert!(after < before, "after={after} should be < before={before}");
}
#[test]
fn test_apply_decay_removes_low_count_pairs() {
let cfg = OptimizerConfig {
pattern_decay_factor: 0.01, ..OptimizerConfig::default()
};
let mut opt = BlockAccessOptimizer::new(cfg);
opt.record_access(event("A", 100, 5, false));
opt.record_access(event("B", 200, 5, false));
opt.apply_decay();
let exists = opt.co_access.get("A").is_some_and(|m| m.contains_key("B"));
assert!(!exists, "pair should have been removed after extreme decay");
}
#[test]
fn test_apply_decay_cleans_empty_outer_map() {
let cfg = OptimizerConfig {
pattern_decay_factor: 0.01,
..OptimizerConfig::default()
};
let mut opt = BlockAccessOptimizer::new(cfg);
opt.record_access(event("A", 100, 5, false));
opt.record_access(event("B", 200, 5, false));
opt.apply_decay();
let outer_empty = !opt.co_access.contains_key("A")
|| opt.co_access.get("A").is_some_and(|m| m.is_empty());
assert!(outer_empty);
}
#[test]
fn test_optimizer_stats_initial() {
let opt = default_optimizer();
let stats = opt.optimizer_stats();
assert_eq!(stats.total_events, 0);
assert_eq!(stats.unique_blocks, 0);
assert_eq!(stats.co_access_pairs, 0);
assert_eq!(stats.window_size, 0);
}
#[test]
fn test_optimizer_stats_after_events() {
let mut opt = default_optimizer();
opt.record_access(event("A", 100, 5, true));
opt.record_access(event("B", 200, 5, false));
let stats = opt.optimizer_stats();
assert_eq!(stats.total_events, 2);
assert_eq!(stats.unique_blocks, 2);
assert_eq!(stats.window_size, 2);
assert!((stats.cache_hit_rate - 0.5).abs() < 1e-9);
}
#[test]
fn test_optimizer_stats_co_access_count() {
let mut opt = default_optimizer();
opt.record_access(event("A", 100, 5, false));
opt.record_access(event("B", 200, 5, false));
let stats = opt.optimizer_stats();
assert_eq!(stats.co_access_pairs, 2);
}
#[test]
fn test_single_event_no_co_access() {
let mut opt = default_optimizer();
opt.record_access(event("only", 100, 5, false));
assert!(opt.co_access.is_empty());
}
#[test]
fn test_same_cid_repeated_no_co_access_with_itself() {
let mut opt = default_optimizer();
for i in 0..5u64 {
opt.record_access(event("X", i * 100, 5, false));
}
let has_self_pair = opt.co_access.get("X").is_some_and(|m| m.contains_key("X"));
assert!(!has_self_pair);
}
#[test]
fn test_hot_blocks_k_larger_than_available() {
let mut opt = default_optimizer();
opt.record_access(event("only", 100, 5, false));
let top = opt.hot_blocks(100);
assert_eq!(top.len(), 1);
}
#[test]
fn test_top_co_access_pairs_k_larger_than_available() {
let mut opt = default_optimizer();
opt.record_access(event("A", 100, 5, false));
opt.record_access(event("B", 200, 5, false));
let pairs = opt.top_co_access_pairs(100);
assert_eq!(pairs.len(), 2); }
#[test]
fn test_decay_on_empty_optimizer() {
let mut opt = default_optimizer();
opt.apply_decay();
assert!(opt.co_access.is_empty());
}
#[test]
fn test_multiple_decays_converge_to_zero() {
let cfg = OptimizerConfig {
pattern_decay_factor: 0.01,
..OptimizerConfig::default()
};
let mut opt = BlockAccessOptimizer::new(cfg);
opt.record_access(event("A", 100, 5, false));
opt.record_access(event("B", 200, 5, false));
opt.apply_decay();
assert!(
opt.co_access.is_empty(),
"all pairs should be pruned after one aggressive decay"
);
}
}