use std::cmp::Reverse;
use std::collections::{HashMap, VecDeque};
#[derive(Debug, Clone)]
pub struct PeConfig {
pub coaccess_window_ms: u64,
pub max_pair_history: usize,
pub min_coaccess_count: u32,
pub max_prefetch_hints: usize,
pub pattern_window: usize,
}
impl Default for PeConfig {
fn default() -> Self {
Self {
coaccess_window_ms: 5_000,
max_pair_history: 10_000,
min_coaccess_count: 2,
max_prefetch_hints: 20,
pattern_window: 20,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum PeAccessType {
Read,
Write,
Delete,
}
#[derive(Debug, Clone)]
pub struct PeAccessEvent {
pub cid: String,
pub timestamp: u64,
pub access_type: PeAccessType,
}
#[derive(Debug, Clone)]
pub struct CoAccessPair {
pub cid_a: String,
pub cid_b: String,
pub coaccess_count: u32,
pub last_seen: u64,
}
#[derive(Debug, Clone)]
pub struct PePrefetchHint {
pub cid: String,
pub priority: f64,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum PeAccessPattern {
Sequential,
Random,
Strided { stride: usize },
Repeated,
Unknown,
}
#[derive(Debug, Clone)]
pub struct PePrefetchStats {
pub total_events: u64,
pub total_pairs: usize,
pub avg_coaccess_count: f64,
pub top_pattern: String,
pub hints_generated: u64,
}
pub struct StoragePrefetchEngine {
pub config: PeConfig,
pub access_history: VecDeque<PeAccessEvent>,
pub coaccesses: HashMap<String, CoAccessPair>,
pub recent_cids: VecDeque<String>,
pub pattern_state: HashMap<String, Vec<u64>>,
total_events: u64,
hints_generated: u64,
}
impl StoragePrefetchEngine {
pub fn new(config: PeConfig) -> Self {
Self {
config,
access_history: VecDeque::new(),
coaccesses: HashMap::new(),
recent_cids: VecDeque::new(),
pattern_state: HashMap::new(),
total_events: 0,
hints_generated: 0,
}
}
pub fn record_access(&mut self, event: PeAccessEvent) -> Vec<PePrefetchHint> {
let now = event.timestamp;
let cid = event.cid.clone();
{
let ts_vec = self.pattern_state.entry(cid.clone()).or_default();
ts_vec.push(now);
let window = self.config.pattern_window;
if ts_vec.len() > window {
let drain_count = ts_vec.len() - window;
ts_vec.drain(..drain_count);
}
}
self.access_history.push_back(event);
self.total_events += 1;
self.update_coaccesses(now, &cid);
let cutoff = now.saturating_sub(self.config.coaccess_window_ms * 2);
while self
.access_history
.front()
.map(|e| e.timestamp < cutoff)
.unwrap_or(false)
{
self.access_history.pop_front();
}
self.recent_cids.push_back(cid.clone());
let pw = self.config.pattern_window;
while self.recent_cids.len() > pw {
self.recent_cids.pop_front();
}
if self.coaccesses.len() > self.config.max_pair_history {
self.evict_lowest_pairs();
}
let hints = self.generate_hints(&cid, now);
self.hints_generated += hints.len() as u64;
hints
}
pub fn generate_hints(&self, cid: &str, now: u64) -> Vec<PePrefetchHint> {
let max = self.config.max_prefetch_hints;
let min_count = self.config.min_coaccess_count;
let mut scored: Vec<(String, f64, u32)> = self
.coaccesses
.values()
.filter(|p| p.coaccess_count >= min_count && (p.cid_a == cid || p.cid_b == cid))
.map(|p| {
let partner = if p.cid_a == cid {
p.cid_b.clone()
} else {
p.cid_a.clone()
};
let age_ms = now.saturating_sub(p.last_seen) as f64;
let recency_weight = (-age_ms / 60_000.0_f64).exp();
let raw_score = p.coaccess_count as f64 * recency_weight;
(partner, raw_score, p.coaccess_count)
})
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scored.truncate(max);
scored
.into_iter()
.map(|(partner, raw_score, count)| {
let priority = (raw_score / 10.0_f64).min(1.0_f64);
PePrefetchHint {
cid: partner,
priority,
reason: format!("co-accessed {} time(s) with {}", count, cid),
}
})
.collect()
}
pub fn detect_pattern(&self, cid: &str) -> PeAccessPattern {
let ts = match self.pattern_state.get(cid) {
Some(v) if !v.is_empty() => v,
_ => return PeAccessPattern::Unknown,
};
if ts.len() >= 3 {
if ts.len() >= 2 {
let intervals: Vec<f64> = ts
.windows(2)
.map(|w| (w[1].saturating_sub(w[0])) as f64)
.collect();
if !intervals.is_empty() {
let mean = intervals.iter().sum::<f64>() / intervals.len() as f64;
if mean > 0.0 {
let variance = intervals.iter().map(|x| (x - mean).powi(2)).sum::<f64>()
/ intervals.len() as f64;
let std_dev = variance.sqrt();
if std_dev < 0.2 * mean {
return PeAccessPattern::Sequential;
}
}
}
}
return PeAccessPattern::Repeated;
}
PeAccessPattern::Unknown
}
pub fn top_coaccessed<'a>(&'a self, cid: &str, n: usize) -> Vec<&'a CoAccessPair> {
let mut pairs: Vec<&CoAccessPair> = self
.coaccesses
.values()
.filter(|p| p.cid_a == cid || p.cid_b == cid)
.collect();
pairs.sort_by_key(|p| Reverse(p.coaccess_count));
pairs.truncate(n);
pairs
}
pub fn evict_stale_pairs(&mut self, max_age_ms: u64, now: u64) -> usize {
let cutoff = now.saturating_sub(max_age_ms);
let before = self.coaccesses.len();
self.coaccesses.retain(|_, p| p.last_seen >= cutoff);
before - self.coaccesses.len()
}
pub fn total_pairs(&self) -> usize {
self.coaccesses.len()
}
pub fn history_len(&self) -> usize {
self.access_history.len()
}
pub fn most_accessed_cids(&self, n: usize) -> Vec<(String, usize)> {
let mut counts: HashMap<&str, usize> = HashMap::new();
for ev in &self.access_history {
*counts.entry(ev.cid.as_str()).or_insert(0) += 1;
}
let mut vec: Vec<(String, usize)> =
counts.into_iter().map(|(k, v)| (k.to_owned(), v)).collect();
vec.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
vec.truncate(n);
vec
}
pub fn stats(&self) -> PePrefetchStats {
let total_pairs = self.coaccesses.len();
let avg_coaccess_count = if total_pairs == 0 {
0.0
} else {
self.coaccesses
.values()
.map(|p| p.coaccess_count as f64)
.sum::<f64>()
/ total_pairs as f64
};
let top_pattern = self.dominant_pattern();
PePrefetchStats {
total_events: self.total_events,
total_pairs,
avg_coaccess_count,
top_pattern,
hints_generated: self.hints_generated,
}
}
fn update_coaccesses(&mut self, now: u64, new_cid: &str) {
let window = self.config.coaccess_window_ms;
let cutoff = now.saturating_sub(window);
let history_len = self.access_history.len();
let slice_len = if history_len > 0 { history_len - 1 } else { 0 };
let partners: Vec<String> = self
.access_history
.iter()
.take(slice_len)
.filter(|e| e.timestamp >= cutoff && e.cid != new_cid)
.map(|e| e.cid.clone())
.collect();
for partner in partners {
let key = coacccess_key(&partner, new_cid);
let (cid_a, cid_b) = ordered_pair(&partner, new_cid);
let entry = self.coaccesses.entry(key).or_insert_with(|| CoAccessPair {
cid_a: cid_a.to_owned(),
cid_b: cid_b.to_owned(),
coaccess_count: 0,
last_seen: 0,
});
entry.coaccess_count = entry.coaccess_count.saturating_add(1);
if now > entry.last_seen {
entry.last_seen = now;
}
}
}
fn evict_lowest_pairs(&mut self) {
let max = self.config.max_pair_history;
if self.coaccesses.len() <= max {
return;
}
let excess = self.coaccesses.len() - max;
let mut keyed: Vec<(String, u32)> = self
.coaccesses
.iter()
.map(|(k, v)| (k.clone(), v.coaccess_count))
.collect();
keyed.sort_by_key(|(_, c)| *c);
for (key, _) in keyed.into_iter().take(excess) {
self.coaccesses.remove(&key);
}
}
fn dominant_pattern(&self) -> String {
let mut repeated = 0usize;
let mut sequential = 0usize;
let mut unknown = 0usize;
for cid in self.pattern_state.keys() {
match self.detect_pattern(cid) {
PeAccessPattern::Repeated => repeated += 1,
PeAccessPattern::Sequential => sequential += 1,
PeAccessPattern::Unknown
| PeAccessPattern::Random
| PeAccessPattern::Strided { .. } => unknown += 1,
}
}
if repeated >= sequential && repeated >= unknown {
"Repeated".to_owned()
} else if sequential >= unknown {
"Sequential".to_owned()
} else {
"Unknown".to_owned()
}
}
}
fn coacccess_key(a: &str, b: &str) -> String {
if a <= b {
format!("{}:{}", a, b)
} else {
format!("{}:{}", b, a)
}
}
fn ordered_pair<'a>(a: &'a str, b: &'a str) -> (&'a str, &'a str) {
if a <= b {
(a, b)
} else {
(b, a)
}
}
#[cfg(test)]
mod tests {
use super::{
CoAccessPair, PeAccessEvent, PeAccessPattern, PeAccessType, PeConfig, PePrefetchHint,
PePrefetchStats, StoragePrefetchEngine,
};
fn default_engine() -> StoragePrefetchEngine {
StoragePrefetchEngine::new(PeConfig::default())
}
fn make_event(cid: &str, ts: u64) -> PeAccessEvent {
PeAccessEvent {
cid: cid.to_owned(),
timestamp: ts,
access_type: PeAccessType::Read,
}
}
fn make_write_event(cid: &str, ts: u64) -> PeAccessEvent {
PeAccessEvent {
cid: cid.to_owned(),
timestamp: ts,
access_type: PeAccessType::Write,
}
}
fn make_delete_event(cid: &str, ts: u64) -> PeAccessEvent {
PeAccessEvent {
cid: cid.to_owned(),
timestamp: ts,
access_type: PeAccessType::Delete,
}
}
#[test]
fn test_new_default_config() {
let engine = default_engine();
assert_eq!(engine.config.coaccess_window_ms, 5_000);
assert_eq!(engine.config.max_pair_history, 10_000);
assert_eq!(engine.config.min_coaccess_count, 2);
assert_eq!(engine.config.max_prefetch_hints, 20);
assert_eq!(engine.config.pattern_window, 20);
}
#[test]
fn test_new_custom_config() {
let cfg = PeConfig {
coaccess_window_ms: 1_000,
max_pair_history: 100,
min_coaccess_count: 3,
max_prefetch_hints: 5,
pattern_window: 10,
};
let engine = StoragePrefetchEngine::new(cfg.clone());
assert_eq!(engine.config.coaccess_window_ms, 1_000);
assert_eq!(engine.config.max_pair_history, 100);
assert_eq!(engine.config.min_coaccess_count, 3);
assert_eq!(engine.config.max_prefetch_hints, 5);
assert_eq!(engine.config.pattern_window, 10);
}
#[test]
fn test_initial_state() {
let engine = default_engine();
assert_eq!(engine.total_pairs(), 0);
assert_eq!(engine.history_len(), 0);
assert!(engine.most_accessed_cids(10).is_empty());
}
#[test]
fn test_record_single_event() {
let mut engine = default_engine();
engine.record_access(make_event("Qm1", 1_000));
assert_eq!(engine.history_len(), 1);
}
#[test]
fn test_history_len_grows() {
let mut engine = default_engine();
for i in 0u64..5 {
engine.record_access(make_event(&format!("Qm{}", i), i * 100));
}
assert_eq!(engine.history_len(), 5);
}
#[test]
fn test_record_returns_empty_hints_when_no_pairs() {
let mut engine = default_engine();
let hints = engine.record_access(make_event("QmA", 0));
assert!(hints.is_empty(), "no pairs yet → no hints");
}
#[test]
fn test_write_event_recorded() {
let mut engine = default_engine();
engine.record_access(make_write_event("QmW", 500));
assert_eq!(engine.history_len(), 1);
}
#[test]
fn test_delete_event_recorded() {
let mut engine = default_engine();
engine.record_access(make_delete_event("QmD", 600));
assert_eq!(engine.history_len(), 1);
}
#[test]
fn test_coaccess_pair_formed_within_window() {
let mut engine = default_engine();
engine.record_access(make_event("QmA", 1_000));
engine.record_access(make_event("QmB", 1_500)); assert_eq!(engine.total_pairs(), 1);
}
#[test]
fn test_coaccess_pair_not_formed_outside_window() {
let mut engine = default_engine();
engine.record_access(make_event("QmA", 1_000));
engine.record_access(make_event("QmB", 10_000)); assert_eq!(engine.total_pairs(), 0);
}
#[test]
fn test_coaccess_count_increments() {
let mut engine = default_engine();
engine.record_access(make_event("QmA", 1_000));
engine.record_access(make_event("QmB", 1_200));
engine.record_access(make_event("QmA", 2_000));
engine.record_access(make_event("QmB", 2_200));
let pairs = engine.top_coaccessed("QmA", 5);
assert!(!pairs.is_empty());
let pair = pairs[0];
assert!(pair.coaccess_count >= 2, "count={}", pair.coaccess_count);
}
#[test]
fn test_coaccess_key_is_sorted() {
let mut engine = default_engine();
engine.record_access(make_event("QmZ", 1_000));
engine.record_access(make_event("QmA", 1_100));
assert_eq!(engine.total_pairs(), 1);
let pairs: Vec<&CoAccessPair> = engine.coaccesses.values().collect();
assert_eq!(pairs[0].cid_a, "QmA");
assert_eq!(pairs[0].cid_b, "QmZ");
}
#[test]
fn test_top_coaccessed_returns_correct_cids() {
let mut engine = default_engine();
for ts_offset in 0u64..3 {
engine.record_access(make_event("QmA", 1_000 + ts_offset * 500));
engine.record_access(make_event(
&format!("QmX{}", ts_offset),
1_100 + ts_offset * 500,
));
}
let top = engine.top_coaccessed("QmA", 10);
assert_eq!(top.len(), 3);
}
#[test]
fn test_top_coaccessed_sorted_desc() {
let mut engine = default_engine();
for i in 0u64..4 {
engine.record_access(make_event("QmA", 1_000 + i * 300));
engine.record_access(make_event("QmB", 1_050 + i * 300));
}
engine.record_access(make_event("QmA", 10_000));
engine.record_access(make_event("QmC", 10_050));
let top = engine.top_coaccessed("QmA", 10);
assert!(!top.is_empty());
for w in top.windows(2) {
assert!(w[0].coaccess_count >= w[1].coaccess_count);
}
}
#[test]
fn test_generate_hints_respects_min_count() {
let cfg = PeConfig {
min_coaccess_count: 3,
..Default::default()
};
let mut engine = StoragePrefetchEngine::new(cfg);
engine.record_access(make_event("QmA", 1_000));
engine.record_access(make_event("QmB", 1_100));
let hints = engine.generate_hints("QmA", 2_000);
assert!(hints.is_empty(), "count=1 < min_coaccess_count=3");
}
#[test]
fn test_generate_hints_returns_after_sufficient_coaccesses() {
let cfg = PeConfig {
min_coaccess_count: 2,
..Default::default()
};
let mut engine = StoragePrefetchEngine::new(cfg);
for i in 0u64..2 {
engine.record_access(make_event("QmA", 1_000 + i * 200));
engine.record_access(make_event("QmB", 1_050 + i * 200));
}
let hints = engine.generate_hints("QmA", 2_000);
assert!(!hints.is_empty());
assert_eq!(hints[0].cid, "QmB");
}
#[test]
fn test_generate_hints_priority_capped_at_one() {
let mut engine = default_engine();
for i in 0u64..20 {
engine.record_access(make_event("QmA", 1_000 + i * 10));
engine.record_access(make_event("QmB", 1_005 + i * 10));
}
let hints = engine.generate_hints("QmA", 1_500);
for h in &hints {
assert!(h.priority <= 1.0, "priority={} > 1.0", h.priority);
}
}
#[test]
fn test_generate_hints_max_hints_limit() {
let cfg = PeConfig {
max_prefetch_hints: 3,
min_coaccess_count: 1,
..Default::default()
};
let mut engine = StoragePrefetchEngine::new(cfg);
for i in 0u64..10 {
engine.record_access(make_event("QmA", 1_000 + i * 5));
engine.record_access(make_event(&format!("QmP{}", i), 1_002 + i * 5));
}
let hints = engine.generate_hints("QmA", 2_000);
assert!(hints.len() <= 3, "len={}", hints.len());
}
#[test]
fn test_hint_reason_mentions_cid() {
let mut engine = default_engine();
for i in 0u64..2 {
engine.record_access(make_event("QmA", 1_000 + i * 100));
engine.record_access(make_event("QmB", 1_050 + i * 100));
}
let hints = engine.generate_hints("QmA", 2_000);
assert!(!hints.is_empty());
assert!(
hints[0].reason.contains("QmA"),
"reason missing trigger CID"
);
}
#[test]
fn test_detect_pattern_unknown_for_new_cid() {
let engine = default_engine();
assert_eq!(engine.detect_pattern("QmNone"), PeAccessPattern::Unknown);
}
#[test]
fn test_detect_pattern_unknown_for_two_events() {
let mut engine = default_engine();
engine.record_access(make_event("QmA", 1_000));
engine.record_access(make_event("QmA", 2_000));
assert_eq!(engine.detect_pattern("QmA"), PeAccessPattern::Unknown);
}
#[test]
fn test_detect_pattern_repeated_three_accesses() {
let mut engine = default_engine();
for ts in [1_000u64, 2_000, 3_000] {
engine.record_access(make_event("QmR", ts));
}
let pattern = engine.detect_pattern("QmR");
assert!(
matches!(
pattern,
PeAccessPattern::Sequential | PeAccessPattern::Repeated
),
"pattern={:?}",
pattern
);
}
#[test]
fn test_detect_pattern_sequential_evenly_spaced() {
let mut engine = default_engine();
for i in 0u64..5 {
engine.record_access(make_event("QmS", i * 1_000));
}
let pattern = engine.detect_pattern("QmS");
assert_eq!(
pattern,
PeAccessPattern::Sequential,
"pattern={:?}",
pattern
);
}
#[test]
fn test_detect_pattern_repeated_uneven_intervals() {
let mut engine = default_engine();
for ts in [100u64, 200, 1_900] {
engine.record_access(make_event("QmU", ts));
}
let pattern = engine.detect_pattern("QmU");
assert!(
matches!(
pattern,
PeAccessPattern::Repeated | PeAccessPattern::Unknown
),
"pattern={:?}",
pattern
);
}
#[test]
fn test_evict_stale_pairs_removes_old() {
let mut engine = default_engine();
engine.record_access(make_event("QmA", 1_000));
engine.record_access(make_event("QmB", 1_100));
assert_eq!(engine.total_pairs(), 1);
let removed = engine.evict_stale_pairs(500, 2_000);
assert_eq!(
removed, 1,
"pair at t=1100 is older than 500ms ago from t=2000"
);
assert_eq!(engine.total_pairs(), 0);
}
#[test]
fn test_evict_stale_pairs_keeps_fresh() {
let mut engine = default_engine();
engine.record_access(make_event("QmA", 1_000));
engine.record_access(make_event("QmB", 1_100));
let removed = engine.evict_stale_pairs(5_000, 1_500);
assert_eq!(removed, 0);
assert_eq!(engine.total_pairs(), 1);
}
#[test]
fn test_evict_stale_returns_count() {
let mut engine = default_engine();
for i in 0u64..3 {
engine.record_access(make_event("QmA", i * 100));
engine.record_access(make_event(&format!("QmX{}", i), i * 100 + 10));
}
let pairs_before = engine.total_pairs();
let removed = engine.evict_stale_pairs(0, 10_000);
assert_eq!(removed, pairs_before);
assert_eq!(engine.total_pairs(), 0);
}
#[test]
fn test_most_accessed_cids_single() {
let mut engine = default_engine();
engine.record_access(make_event("QmA", 1_000));
let top = engine.most_accessed_cids(5);
assert_eq!(top.len(), 1);
assert_eq!(top[0].0, "QmA");
assert_eq!(top[0].1, 1);
}
#[test]
fn test_most_accessed_cids_counts() {
let mut engine = default_engine();
for ts in [1_000u64, 2_000, 3_000] {
engine.record_access(make_event("QmHot", ts));
}
engine.record_access(make_event("QmCool", 4_000));
let top = engine.most_accessed_cids(2);
assert_eq!(top[0].0, "QmHot");
assert_eq!(top[0].1, 3);
}
#[test]
fn test_most_accessed_cids_n_limit() {
let mut engine = default_engine();
for i in 0u64..10 {
engine.record_access(make_event(&format!("Qm{}", i), i * 100));
}
let top = engine.most_accessed_cids(3);
assert_eq!(top.len(), 3);
}
#[test]
fn test_most_accessed_cids_sorted_desc() {
let mut engine = default_engine();
engine.record_access(make_event("QmA", 100));
for ts in [200u64, 300, 400] {
engine.record_access(make_event("QmB", ts));
}
for ts in [500u64, 600] {
engine.record_access(make_event("QmC", ts));
}
let top = engine.most_accessed_cids(10);
for w in top.windows(2) {
assert!(w[0].1 >= w[1].1);
}
}
#[test]
fn test_stats_initial() {
let engine = default_engine();
let s = engine.stats();
assert_eq!(s.total_events, 0);
assert_eq!(s.total_pairs, 0);
assert_eq!(s.avg_coaccess_count, 0.0);
assert_eq!(s.hints_generated, 0);
}
#[test]
fn test_stats_total_events() {
let mut engine = default_engine();
for i in 0u64..7 {
engine.record_access(make_event(&format!("Qm{}", i), i * 1_000));
}
assert_eq!(engine.stats().total_events, 7);
}
#[test]
fn test_stats_hints_generated_tracked() {
let cfg = PeConfig {
min_coaccess_count: 1,
..Default::default()
};
let mut engine = StoragePrefetchEngine::new(cfg);
for i in 0u64..2 {
engine.record_access(make_event("QmA", 1_000 + i * 100));
engine.record_access(make_event("QmB", 1_050 + i * 100));
}
let s = engine.stats();
assert!(
s.hints_generated > 0,
"hints_generated={}",
s.hints_generated
);
}
#[test]
fn test_stats_avg_coaccess_count() {
let mut engine = default_engine();
for i in 0u64..3 {
engine.record_access(make_event("QmA", 1_000 + i * 100));
engine.record_access(make_event("QmB", 1_050 + i * 100));
}
let s = engine.stats();
assert!(s.avg_coaccess_count > 0.0);
}
#[test]
fn test_stats_top_pattern_string() {
let engine = default_engine();
let s = engine.stats();
assert!(!s.top_pattern.is_empty());
}
#[test]
fn test_hints_sorted_by_recency() {
let cfg = PeConfig {
min_coaccess_count: 1,
..Default::default()
};
let mut engine = StoragePrefetchEngine::new(cfg);
engine.record_access(make_event("QmA", 0));
engine.record_access(make_event("QmC", 50));
engine.record_access(make_event("QmA", 100_000));
engine.record_access(make_event("QmB", 100_050));
let hints = engine.generate_hints("QmA", 100_100);
assert!(!hints.is_empty());
if hints.len() >= 2 {
let pos_b = hints
.iter()
.position(|h| h.cid == "QmB")
.unwrap_or(usize::MAX);
let pos_c = hints
.iter()
.position(|h| h.cid == "QmC")
.unwrap_or(usize::MAX);
assert!(
pos_b < pos_c,
"QmB should precede QmC; hints={:?}",
hints.iter().map(|h| &h.cid).collect::<Vec<_>>()
);
}
}
#[test]
fn test_priority_non_negative() {
let cfg = PeConfig {
min_coaccess_count: 1,
..Default::default()
};
let mut engine = StoragePrefetchEngine::new(cfg);
for i in 0u64..2 {
engine.record_access(make_event("QmA", i * 200));
engine.record_access(make_event("QmB", i * 200 + 10));
}
let hints = engine.generate_hints("QmA", 500_000);
for h in hints {
assert!(h.priority >= 0.0);
}
}
#[test]
fn test_total_pairs_after_eviction() {
let mut engine = default_engine();
engine.record_access(make_event("QmA", 1_000));
engine.record_access(make_event("QmB", 1_100));
assert_eq!(engine.total_pairs(), 1);
engine.evict_stale_pairs(0, 100_000);
assert_eq!(engine.total_pairs(), 0);
}
#[test]
fn test_access_type_variants_compile() {
let _ = PeAccessType::Read;
let _ = PeAccessType::Write;
let _ = PeAccessType::Delete;
}
#[test]
fn test_pe_prefetch_hint_fields() {
let hint = PePrefetchHint {
cid: "QmX".to_owned(),
priority: 0.75,
reason: "test".to_owned(),
};
assert_eq!(hint.cid, "QmX");
assert!((hint.priority - 0.75).abs() < 1e-9);
}
#[test]
fn test_pe_prefetch_stats_fields() {
let s = PePrefetchStats {
total_events: 5,
total_pairs: 2,
avg_coaccess_count: 1.5,
top_pattern: "Repeated".to_owned(),
hints_generated: 10,
};
assert_eq!(s.total_events, 5);
assert_eq!(s.total_pairs, 2);
}
#[test]
fn test_same_cid_does_not_self_pair() {
let mut engine = default_engine();
engine.record_access(make_event("QmSelf", 1_000));
engine.record_access(make_event("QmSelf", 1_100));
assert_eq!(engine.total_pairs(), 0);
}
#[test]
fn test_multiple_pairs_single_anchor() {
let mut engine = default_engine();
engine.record_access(make_event("QmA", 1_000));
for i in 1u64..=5 {
engine.record_access(make_event(&format!("QmX{}", i), 1_000 + i * 50));
}
assert!(
engine.total_pairs() >= 5,
"expected >= 5 pairs, got {}",
engine.total_pairs()
);
let qm_a_pairs = engine.top_coaccessed("QmA", 10);
assert_eq!(
qm_a_pairs.len(),
5,
"QmA should have 5 direct co-access partners"
);
}
#[test]
fn test_pattern_window_limits_state() {
let cfg = PeConfig {
pattern_window: 3,
..Default::default()
};
let mut engine = StoragePrefetchEngine::new(cfg);
for i in 0u64..10 {
engine.record_access(make_event("QmPW", i * 100));
}
let ts = engine
.pattern_state
.get("QmPW")
.cloned()
.unwrap_or_default();
assert!(ts.len() <= 3, "len={}", ts.len());
}
#[test]
fn test_generate_hints_no_match() {
let engine = default_engine();
let hints = engine.generate_hints("QmNone", 0);
assert!(hints.is_empty());
}
}