1use std::collections::HashMap;
34
35#[derive(Debug, Clone)]
41pub struct TrackerConfig {
42 pub max_records: usize,
45 pub recency_weight: f64,
48 pub decay_factor: f64,
51 pub hot_threshold: f64,
53 pub cold_threshold: f64,
55}
56
57impl Default for TrackerConfig {
58 fn default() -> Self {
59 Self {
60 max_records: 10_000,
61 recency_weight: 1.0,
62 decay_factor: 0.001,
63 hot_threshold: 5.0,
64 cold_threshold: 0.5,
65 }
66 }
67}
68
69#[derive(Debug, Clone)]
75pub struct AccessRecord {
76 pub cid: String,
78 pub access_count: u64,
80 pub last_access: u64,
82 pub first_access: u64,
84 pub size_bytes: u64,
86 pub access_score: f64,
89}
90
91#[derive(Debug, Clone, Default)]
97pub struct TrackerStats {
98 pub total_accesses: u64,
100 pub unique_cids: u64,
102 pub hot_blocks: u64,
104 pub cold_blocks: u64,
106 pub evictions: u64,
108}
109
110pub struct AccessTracker {
116 config: TrackerConfig,
117 records: HashMap<String, AccessRecord>,
118 stats: TrackerStats,
119}
120
121impl AccessTracker {
122 pub fn new(config: TrackerConfig) -> Self {
126 Self {
127 records: HashMap::new(),
128 stats: TrackerStats::default(),
129 config,
130 }
131 }
132
133 pub fn record_access(&mut self, cid: &str, size: u64, now: u64) {
141 self.stats.total_accesses += 1;
142
143 if let Some(record) = self.records.get_mut(cid) {
144 record.access_count += 1;
145 record.last_access = now;
146 if size > 0 {
147 record.size_bytes = size;
148 }
149 let score = Self::compute_score_inner(&self.config, record, now);
150 record.access_score = score;
151 } else {
152 let score_initial = self.config.recency_weight; let record = AccessRecord {
154 cid: cid.to_owned(),
155 access_count: 1,
156 last_access: now,
157 first_access: now,
158 size_bytes: size,
159 access_score: score_initial,
160 };
161 self.records.insert(cid.to_owned(), record);
162 self.stats.unique_cids += 1;
163 }
164
165 self.enforce_capacity(now);
167 self.refresh_stats(now);
168 }
169
170 pub fn compute_score(&self, record: &AccessRecord, now: u64) -> f64 {
182 Self::compute_score_inner(&self.config, record, now)
183 }
184
185 fn compute_score_inner(config: &TrackerConfig, record: &AccessRecord, now: u64) -> f64 {
187 let time_since = now.saturating_sub(record.last_access) as f64;
188 let denominator = 1.0 + time_since * config.decay_factor;
189 (record.access_count as f64 / denominator) * config.recency_weight
190 }
191
192 pub fn update_score(&mut self, cid: &str, now: u64) {
196 if let Some(record) = self.records.get_mut(cid) {
197 let score = Self::compute_score_inner(&self.config, record, now);
198 record.access_score = score;
199 }
200 }
201
202 pub fn is_hot(&self, cid: &str, now: u64) -> bool {
206 self.records
207 .get(cid)
208 .map(|r| Self::compute_score_inner(&self.config, r, now) >= self.config.hot_threshold)
209 .unwrap_or(false)
210 }
211
212 pub fn is_cold(&self, cid: &str, now: u64) -> bool {
214 self.records
215 .get(cid)
216 .map(|r| Self::compute_score_inner(&self.config, r, now) < self.config.cold_threshold)
217 .unwrap_or(true)
218 }
219
220 pub fn hot_blocks(&self, now: u64) -> Vec<&AccessRecord> {
222 self.records
223 .values()
224 .filter(|r| {
225 Self::compute_score_inner(&self.config, r, now) >= self.config.hot_threshold
226 })
227 .collect()
228 }
229
230 pub fn cold_blocks(&self, now: u64) -> Vec<&AccessRecord> {
232 self.records
233 .values()
234 .filter(|r| {
235 Self::compute_score_inner(&self.config, r, now) < self.config.cold_threshold
236 })
237 .collect()
238 }
239
240 pub fn evict_coldest(&mut self, now: u64) -> Option<AccessRecord> {
246 let coldest_key = self
247 .records
248 .iter()
249 .min_by(|a, b| {
250 let sa = Self::compute_score_inner(&self.config, a.1, now);
251 let sb = Self::compute_score_inner(&self.config, b.1, now);
252 sa.partial_cmp(&sb).unwrap_or(std::cmp::Ordering::Equal)
253 })
254 .map(|(k, _)| k.clone());
255
256 if let Some(key) = coldest_key {
257 let record = self.records.remove(&key)?;
258 self.stats.evictions += 1;
259 self.stats.unique_cids = self.stats.unique_cids.saturating_sub(1);
260 self.refresh_stats(now);
261 Some(record)
262 } else {
263 None
264 }
265 }
266
267 pub fn enforce_capacity(&mut self, now: u64) -> usize {
271 let mut evicted = 0usize;
272 while self.records.len() > self.config.max_records {
273 let coldest_key = self
275 .records
276 .iter()
277 .min_by(|a, b| {
278 let sa = Self::compute_score_inner(&self.config, a.1, now);
279 let sb = Self::compute_score_inner(&self.config, b.1, now);
280 sa.partial_cmp(&sb).unwrap_or(std::cmp::Ordering::Equal)
281 })
282 .map(|(k, _)| k.clone());
283
284 if let Some(key) = coldest_key {
285 if self.records.remove(&key).is_some() {
286 self.stats.evictions += 1;
287 self.stats.unique_cids = self.stats.unique_cids.saturating_sub(1);
288 evicted += 1;
289 }
290 } else {
291 break;
292 }
293 }
294 if evicted > 0 {
295 self.refresh_stats(now);
296 }
297 evicted
298 }
299
300 pub fn get_record(&self, cid: &str) -> Option<&AccessRecord> {
304 self.records.get(cid)
305 }
306
307 pub fn record_count(&self) -> usize {
309 self.records.len()
310 }
311
312 pub fn stats(&self) -> &TrackerStats {
314 &self.stats
315 }
316
317 pub fn top_accessed(&self, n: usize) -> Vec<&AccessRecord> {
321 let mut records: Vec<&AccessRecord> = self.records.values().collect();
322 records.sort_by_key(|b| std::cmp::Reverse(b.access_count));
323 records.truncate(n);
324 records
325 }
326
327 fn refresh_stats(&mut self, now: u64) {
331 let hot_threshold = self.config.hot_threshold;
332 let cold_threshold = self.config.cold_threshold;
333 let config = &self.config;
334
335 let mut hot = 0u64;
336 let mut cold = 0u64;
337 for record in self.records.values() {
338 let score = Self::compute_score_inner(config, record, now);
339 if score >= hot_threshold {
340 hot += 1;
341 } else if score < cold_threshold {
342 cold += 1;
343 }
344 }
345 self.stats.hot_blocks = hot;
346 self.stats.cold_blocks = cold;
347 self.stats.unique_cids = self.records.len() as u64;
348 }
349}
350
351#[cfg(test)]
356mod tests {
357 use super::*;
358
359 fn default_config() -> TrackerConfig {
362 TrackerConfig {
363 max_records: 5,
364 recency_weight: 1.0,
365 decay_factor: 0.1,
366 hot_threshold: 3.0,
367 cold_threshold: 0.5,
368 }
369 }
370
371 fn tracker() -> AccessTracker {
372 AccessTracker::new(default_config())
373 }
374
375 #[test]
378 fn test_record_access_creates_entry() {
379 let mut t = tracker();
380 t.record_access("cid1", 512, 0);
381 assert!(t.get_record("cid1").is_some());
382 }
383
384 #[test]
387 fn test_get_record_missing() {
388 let t = tracker();
389 assert!(t.get_record("nonexistent").is_none());
390 }
391
392 #[test]
395 fn test_multiple_accesses_accumulate() {
396 let mut t = tracker();
397 t.record_access("cid1", 512, 0);
398 t.record_access("cid1", 512, 1);
399 t.record_access("cid1", 512, 2);
400 let rec = t.get_record("cid1").expect("record must exist");
401 assert_eq!(rec.access_count, 3);
402 }
403
404 #[test]
407 fn test_first_access_preserved() {
408 let mut t = tracker();
409 t.record_access("cid1", 512, 100);
410 t.record_access("cid1", 512, 200);
411 let rec = t.get_record("cid1").expect("must exist");
412 assert_eq!(rec.first_access, 100);
413 assert_eq!(rec.last_access, 200);
414 }
415
416 #[test]
419 fn test_compute_score_at_access_time() {
420 let t = tracker();
421 let rec = AccessRecord {
422 cid: "c".to_owned(),
423 access_count: 10,
424 last_access: 50,
425 first_access: 0,
426 size_bytes: 1024,
427 access_score: 0.0,
428 };
429 let score = t.compute_score(&rec, 50);
431 assert!((score - 10.0).abs() < 1e-9);
432 }
433
434 #[test]
437 fn test_score_decays_with_time() {
438 let t = tracker();
439 let rec = AccessRecord {
440 cid: "c".to_owned(),
441 access_count: 10,
442 last_access: 0,
443 first_access: 0,
444 size_bytes: 1024,
445 access_score: 0.0,
446 };
447 let score_now = t.compute_score(&rec, 0);
448 let score_later = t.compute_score(&rec, 100);
449 assert!(score_later < score_now, "score must decay over time");
450 }
451
452 #[test]
455 fn test_is_hot_true() {
456 let mut t = tracker();
457 for i in 0u64..4 {
459 t.record_access("hot_cid", 512, i);
460 }
461 assert!(t.is_hot("hot_cid", 3));
462 }
463
464 #[test]
467 fn test_is_hot_missing() {
468 let t = tracker();
469 assert!(!t.is_hot("nope", 0));
470 }
471
472 #[test]
475 fn test_is_cold_with_old_block() {
476 let mut t = tracker();
477 t.record_access("cold_cid", 512, 0);
478 assert!(t.is_cold("cold_cid", 10_000));
480 }
481
482 #[test]
485 fn test_is_cold_missing() {
486 let t = tracker();
487 assert!(t.is_cold("nope", 0));
488 }
489
490 #[test]
493 fn test_hot_blocks_list() {
494 let mut t = tracker();
495 for i in 0u64..4 {
496 t.record_access("hot", 512, i);
497 }
498 t.record_access("warm", 512, 0);
499 let hot = t.hot_blocks(3);
500 assert!(hot.iter().any(|r| r.cid == "hot"));
501 assert!(!hot.iter().any(|r| r.cid == "warm"));
502 }
503
504 #[test]
507 fn test_cold_blocks_list() {
508 let mut t = tracker();
509 t.record_access("cold", 512, 0);
510 let cold = t.cold_blocks(10_000);
511 assert!(cold.iter().any(|r| r.cid == "cold"));
512 }
513
514 #[test]
517 fn test_evict_coldest_removes_lowest() {
518 let mut t = tracker();
519 t.record_access("a", 512, 0); for i in 0u64..4 {
521 t.record_access("b", 512, i); }
523 let evicted = t.evict_coldest(3).expect("must evict something");
524 assert_eq!(evicted.cid, "a");
525 assert!(t.get_record("a").is_none());
526 assert!(t.get_record("b").is_some());
527 }
528
529 #[test]
532 fn test_evict_coldest_empty() {
533 let mut t = tracker();
534 assert!(t.evict_coldest(0).is_none());
535 }
536
537 #[test]
540 fn test_enforce_capacity() {
541 let mut t = tracker(); for i in 0u64..8 {
543 t.records.insert(
546 format!("cid{i}"),
547 AccessRecord {
548 cid: format!("cid{i}"),
549 access_count: i + 1,
550 last_access: i,
551 first_access: 0,
552 size_bytes: 512,
553 access_score: (i + 1) as f64,
554 },
555 );
556 }
557 t.stats.unique_cids = t.records.len() as u64;
558 let evicted = t.enforce_capacity(100);
559 assert_eq!(evicted, 3);
560 assert_eq!(t.record_count(), 5);
561 }
562
563 #[test]
566 fn test_enforce_capacity_no_op() {
567 let mut t = tracker();
568 t.record_access("only_one", 512, 0);
569 let evicted = t.enforce_capacity(0);
570 assert_eq!(evicted, 0);
571 }
572
573 #[test]
576 fn test_top_accessed_ordering() {
577 let mut t = tracker();
578 t.record_access("low", 512, 0);
579 for _ in 0..3 {
580 t.record_access("mid", 512, 0);
581 }
582 for _ in 0..5 {
583 t.record_access("high", 512, 0);
584 }
585 let top = t.top_accessed(3);
586 assert_eq!(top[0].cid, "high");
587 assert_eq!(top[1].cid, "mid");
588 assert_eq!(top[2].cid, "low");
589 }
590
591 #[test]
594 fn test_top_accessed_more_than_records() {
595 let mut t = tracker();
596 t.record_access("a", 512, 0);
597 t.record_access("b", 512, 0);
598 let top = t.top_accessed(100);
599 assert_eq!(top.len(), 2);
600 }
601
602 #[test]
605 fn test_top_accessed_zero() {
606 let mut t = tracker();
607 t.record_access("a", 512, 0);
608 assert!(t.top_accessed(0).is_empty());
609 }
610
611 #[test]
614 fn test_stats_total_accesses() {
615 let mut t = tracker();
616 t.record_access("x", 512, 0);
617 t.record_access("x", 512, 1);
618 t.record_access("y", 512, 2);
619 assert_eq!(t.stats().total_accesses, 3);
620 }
621
622 #[test]
625 fn test_stats_unique_cids() {
626 let mut t = tracker();
627 t.record_access("a", 512, 0);
628 t.record_access("b", 512, 0);
629 t.record_access("a", 512, 1); assert_eq!(t.stats().unique_cids, 2);
631 }
632
633 #[test]
636 fn test_stats_evictions_on_evict_coldest() {
637 let mut t = tracker();
638 t.record_access("a", 512, 0);
639 t.evict_coldest(0);
640 assert_eq!(t.stats().evictions, 1);
641 }
642
643 #[test]
646 fn test_record_count() {
647 let mut t = tracker();
648 assert_eq!(t.record_count(), 0);
649 t.record_access("a", 512, 0);
650 t.record_access("b", 512, 0);
651 assert_eq!(t.record_count(), 2);
652 }
653
654 #[test]
657 fn test_update_score() {
658 let mut t = tracker();
659 t.record_access("a", 512, 0);
660 let initial_score = t.get_record("a").map(|r| r.access_score).unwrap_or(0.0);
661 t.update_score("a", 1000); let later_score = t.get_record("a").map(|r| r.access_score).unwrap_or(0.0);
663 assert!(later_score < initial_score);
664 }
665
666 #[test]
673 fn test_hot_and_cold_disjoint() {
674 let mut t = tracker();
675 for i in 0u64..4 {
677 t.record_access("hot", 512, i);
678 }
679 t.record_access("cold", 512, 0);
682
683 let query_now = 3u64;
685 let hot_ids: std::collections::HashSet<String> = t
686 .hot_blocks(query_now)
687 .iter()
688 .map(|r| r.cid.clone())
689 .collect();
690 let cold_ids: std::collections::HashSet<String> = t
691 .cold_blocks(query_now)
692 .iter()
693 .map(|r| r.cid.clone())
694 .collect();
695
696 let overlap: Vec<_> = hot_ids.intersection(&cold_ids).collect();
698 assert!(
699 overlap.is_empty(),
700 "hot and cold sets must be disjoint at the same timestamp"
701 );
702 }
703
704 #[test]
707 fn test_stats_hot_blocks_count() {
708 let mut t = tracker();
709 for i in 0u64..4 {
710 t.record_access("h", 512, i);
711 }
712 t.record_access("c", 512, 0);
713 t.refresh_stats(3);
715 assert_eq!(t.stats().hot_blocks, t.hot_blocks(3).len() as u64);
716 }
717
718 #[test]
721 fn test_size_bytes_updated() {
722 let mut t = tracker();
723 t.record_access("a", 512, 0);
724 t.record_access("a", 2048, 1);
725 let rec = t.get_record("a").expect("must exist");
726 assert_eq!(rec.size_bytes, 2048);
727 }
728}