1#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
36pub struct BlockKey(pub u64);
37
38#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
40#[repr(u8)]
41pub enum Tier {
42 Tier0 = 0,
44 Tier1 = 1,
46 Tier2 = 2,
48 Tier3 = 3,
50}
51
52#[derive(Clone, Debug)]
54pub struct BlockMeta {
55 pub ema_rate: f32,
57 pub access_window: u64,
60 pub last_access: u64,
62 pub access_count: u64,
64 pub current_tier: Tier,
66 pub tier_since: u64,
68}
69
70impl BlockMeta {
71 pub fn new(now: u64) -> Self {
73 Self {
74 ema_rate: 0.0,
75 access_window: 0,
76 last_access: now,
77 access_count: 0,
78 current_tier: Tier::Tier1,
79 tier_since: now,
80 }
81 }
82}
83
84#[derive(Clone, Debug)]
92pub struct TierConfig {
93 pub alpha: f32,
95 pub tau: f32,
97 pub w_ema: f32,
99 pub w_pop: f32,
101 pub w_rec: f32,
103 pub t1: f32,
105 pub t2: f32,
107 pub t3: f32,
109 pub hysteresis: f32,
112 pub min_residency: u32,
114 pub max_delta_chain: u8,
116 pub block_bytes: usize,
118 pub tier1_byte_cap: Option<usize>,
120 pub warm_aggressive_threshold: Option<usize>,
122}
123
124impl Default for TierConfig {
125 fn default() -> Self {
126 Self {
127 alpha: 0.3,
128 tau: 100.0,
129 w_ema: 0.4,
130 w_pop: 0.3,
131 w_rec: 0.3,
132 t1: 0.7,
133 t2: 0.3,
134 t3: 0.1,
135 hysteresis: 0.05,
136 min_residency: 5,
137 max_delta_chain: 8,
138 block_bytes: 16384,
139 tier1_byte_cap: None,
140 warm_aggressive_threshold: None,
141 }
142 }
143}
144
145#[inline]
157#[allow(dead_code)]
158fn fast_exp_neg(x: f32) -> f32 {
159 if x < 0.0 {
160 return 1.0;
161 }
162 1.0 / (1.0 + x)
163}
164
165const LUT_SIZE: usize = 64;
167const LUT_X_MAX: f32 = 8.0;
169
170const EXP_LUT: [f32; LUT_SIZE + 1] = {
172 let mut table = [0.0f32; LUT_SIZE + 1];
173 let mut i = 0;
174 while i <= LUT_SIZE {
175 let x = -(i as f64) * (LUT_X_MAX as f64) / (LUT_SIZE as f64);
177 let v = const_exp(x);
179 table[i] = v as f32;
180 i += 1;
181 }
182 table
183};
184
185const fn const_exp(x: f64) -> f64 {
190 if x < 0.0 {
192 let pos = const_exp_pos(-x);
193 return 1.0 / pos;
194 }
195 const_exp_pos(x)
196}
197
198const fn const_exp_pos(x: f64) -> f64 {
201 let mut sum = 1.0f64;
202 let mut term = 1.0f64;
203 let mut k = 1u32;
204 while k <= 35 {
205 term *= x / (k as f64);
206 sum += term;
207 k += 1;
208 }
209 sum
210}
211
212#[inline]
217fn fast_exp_neg_lut(x: f32) -> f32 {
218 if x <= 0.0 {
219 return 1.0;
220 }
221 if x >= LUT_X_MAX {
222 return EXP_LUT[LUT_SIZE];
223 }
224 let scaled = x * (LUT_SIZE as f32) / LUT_X_MAX;
225 let idx = scaled as usize; let frac = scaled - (idx as f32);
227 let lo = EXP_LUT[idx];
229 let hi = EXP_LUT[idx + 1];
230 lo + frac * (hi - lo)
231}
232
233pub fn compute_score(config: &TierConfig, now: u64, meta: &BlockMeta) -> f32 {
248 let ema_component = config.w_ema * meta.ema_rate.clamp(0.0, 1.0);
249
250 let pop = meta.access_window.count_ones() as f32 / 64.0;
251 let pop_component = config.w_pop * pop;
252
253 let dt = now.saturating_sub(meta.last_access) as f32;
254 let recency = fast_exp_neg_lut(dt / config.tau);
255 let rec_component = config.w_rec * recency;
256
257 ema_component + pop_component + rec_component
258}
259
260pub fn choose_tier(config: &TierConfig, now: u64, meta: &BlockMeta) -> Option<Tier> {
270 let ticks_in_tier = now.saturating_sub(meta.tier_since);
272 if ticks_in_tier < config.min_residency as u64 {
273 return None;
274 }
275
276 let score = compute_score(config, now, meta);
277 let current = meta.current_tier;
278
279 let raw_target = if score >= config.t1 {
281 Tier::Tier1
282 } else if score >= config.t2 {
283 Tier::Tier2
284 } else if score >= config.t3 {
285 Tier::Tier3
286 } else {
287 Tier::Tier3 };
289
290 if raw_target == current {
291 return None;
292 }
293
294 let h = config.hysteresis;
297
298 let transition_allowed = if raw_target < current {
299 let threshold = match raw_target {
302 Tier::Tier0 => return None, Tier::Tier1 => config.t1,
304 Tier::Tier2 => config.t2,
305 Tier::Tier3 => config.t3,
306 };
307 score > threshold + h
308 } else {
309 let threshold = match current {
312 Tier::Tier0 => return None,
313 Tier::Tier1 => config.t1,
314 Tier::Tier2 => config.t2,
315 Tier::Tier3 => return None, };
317 score < threshold - h
318 };
319
320 if transition_allowed {
321 Some(raw_target)
322 } else {
323 None
324 }
325}
326
327pub fn touch(config: &TierConfig, now: u64, meta: &mut BlockMeta) {
339 meta.ema_rate = config.alpha + (1.0 - config.alpha) * meta.ema_rate;
341
342 let elapsed = now.saturating_sub(meta.last_access);
344 if elapsed > 0 {
345 if elapsed >= 64 {
346 meta.access_window = 1;
347 } else {
348 meta.access_window = (meta.access_window << elapsed) | 1;
349 }
350 } else {
351 meta.access_window |= 1;
353 }
354
355 meta.last_access = now;
356 meta.access_count = meta.access_count.saturating_add(1);
357}
358
359pub fn tick_decay(config: &TierConfig, meta: &mut BlockMeta) {
369 meta.ema_rate *= 1.0 - config.alpha;
370 meta.access_window <<= 1;
371}
372
373#[derive(Debug, Default)]
379pub struct MaintenanceResult {
380 pub upgrades: u32,
381 pub downgrades: u32,
382 pub evictions: u32,
383 pub bytes_freed: usize,
384 pub ops_used: u32,
385}
386
387#[derive(Debug)]
389pub struct MigrationCandidate {
390 pub key: BlockKey,
391 pub current_tier: Tier,
392 pub target_tier: Tier,
393 pub score: f32,
394}
395
396pub fn select_candidates(
402 config: &TierConfig,
403 now: u64,
404 blocks: &[(BlockKey, &BlockMeta)],
405) -> Vec<MigrationCandidate> {
406 let mut upgrades: Vec<MigrationCandidate> = Vec::new();
407 let mut downgrades: Vec<MigrationCandidate> = Vec::new();
408
409 for &(key, meta) in blocks {
410 if let Some(target) = choose_tier(config, now, meta) {
411 let score = compute_score(config, now, meta);
412 let candidate = MigrationCandidate {
413 key,
414 current_tier: meta.current_tier,
415 target_tier: target,
416 score,
417 };
418 if target < meta.current_tier {
419 upgrades.push(candidate);
420 } else {
421 downgrades.push(candidate);
422 }
423 }
424 }
425
426 upgrades.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(core::cmp::Ordering::Equal));
428 downgrades.sort_by(|a, b| a.score.partial_cmp(&b.score).unwrap_or(core::cmp::Ordering::Equal));
430
431 upgrades.extend(downgrades);
432 upgrades
433}
434
435#[derive(Clone, Debug)]
441pub struct ScoredPartition {
442 pub hot: Vec<usize>,
444 pub warm: Vec<usize>,
446 pub cold: Vec<usize>,
448 pub evict: Vec<usize>,
450 pub scores: Vec<f32>,
452}
453
454pub fn compute_scores_batch(config: &TierConfig, now: u64, metas: &[BlockMeta]) -> Vec<f32> {
459 metas.iter().map(|m| compute_score(config, now, m)).collect()
460}
461
462pub fn choose_tiers_batch(config: &TierConfig, now: u64, metas: &[BlockMeta]) -> Vec<Option<Tier>> {
467 metas.iter().map(|m| choose_tier(config, now, m)).collect()
468}
469
470pub fn score_and_partition(config: &TierConfig, now: u64, metas: &[BlockMeta]) -> ScoredPartition {
477 let scores = compute_scores_batch(config, now, metas);
478 let mut hot = Vec::new();
479 let mut warm = Vec::new();
480 let mut cold = Vec::new();
481 let mut evict = Vec::new();
482 for (i, &score) in scores.iter().enumerate() {
483 if score >= config.t1 {
484 hot.push(i);
485 } else if score >= config.t2 {
486 warm.push(i);
487 } else if score >= config.t3 {
488 cold.push(i);
489 } else {
490 evict.push(i);
491 }
492 }
493 ScoredPartition { hot, warm, cold, evict, scores }
494}
495
496pub fn top_k_coldest(config: &TierConfig, now: u64, metas: &[BlockMeta], k: usize) -> Vec<(usize, f32)> {
502 let scores = compute_scores_batch(config, now, metas);
503 let mut indexed: Vec<(usize, f32)> = scores.into_iter().enumerate().collect();
504 if k < indexed.len() {
506 indexed.select_nth_unstable_by(k, |a, b| a.1.partial_cmp(&b.1).unwrap_or(core::cmp::Ordering::Equal));
507 indexed.truncate(k);
508 }
509 indexed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(core::cmp::Ordering::Equal));
510 indexed
511}
512
513pub fn bits_for_tier(config: &TierConfig, tier: Tier, warm_bytes: usize) -> u8 {
526 match tier {
527 Tier::Tier0 => 0,
528 Tier::Tier1 => 8,
529 Tier::Tier2 => {
530 if let Some(threshold) = config.warm_aggressive_threshold {
531 if warm_bytes > threshold {
532 return 5;
533 }
534 }
535 7
536 }
537 Tier::Tier3 => 3,
538 }
539}
540
541#[cfg(test)]
546mod tests {
547 use super::*;
548
549 fn default_config() -> TierConfig {
550 TierConfig::default()
551 }
552
553 fn make_meta(
554 ema_rate: f32,
555 access_window: u64,
556 last_access: u64,
557 current_tier: Tier,
558 tier_since: u64,
559 ) -> BlockMeta {
560 BlockMeta {
561 ema_rate,
562 access_window,
563 last_access,
564 access_count: 0,
565 current_tier,
566 tier_since,
567 }
568 }
569
570 #[test]
575 fn score_all_components_at_max() {
576 let cfg = default_config();
577 let meta = make_meta(1.0, u64::MAX, 100, Tier::Tier1, 0);
579 let score = compute_score(&cfg, 100, &meta);
580 assert!((score - 1.0).abs() < 1e-4, "score={score}");
582 }
583
584 #[test]
585 fn score_all_components_at_zero() {
586 let cfg = default_config();
587 let meta = make_meta(0.0, 0, 0, Tier::Tier3, 0);
589 let score = compute_score(&cfg, 10_000, &meta);
590 assert!(score < 0.01, "score={score}");
592 }
593
594 #[test]
595 fn score_only_ema_contributes() {
596 let cfg = TierConfig {
597 w_ema: 1.0,
598 w_pop: 0.0,
599 w_rec: 0.0,
600 ..default_config()
601 };
602 let meta = make_meta(0.75, 0, 0, Tier::Tier2, 0);
603 let score = compute_score(&cfg, 1000, &meta);
604 assert!((score - 0.75).abs() < 1e-6, "score={score}");
605 }
606
607 #[test]
608 fn score_only_popcount_contributes() {
609 let cfg = TierConfig {
610 w_ema: 0.0,
611 w_pop: 1.0,
612 w_rec: 0.0,
613 ..default_config()
614 };
615 let meta = make_meta(0.0, 0x0000_FFFF_FFFF_0000, 0, Tier::Tier2, 0);
617 let pop = 0x0000_FFFF_FFFF_0000u64.count_ones() as f32 / 64.0;
618 let score = compute_score(&cfg, 1000, &meta);
619 assert!((score - pop).abs() < 1e-6, "score={score}, expected pop={pop}");
620 }
621
622 #[test]
627 fn fast_exp_neg_monotonic() {
628 let mut prev = fast_exp_neg(0.0);
629 for i in 1..100 {
630 let x = i as f32 * 0.1;
631 let val = fast_exp_neg(x);
632 assert!(val <= prev, "not monotonic at x={x}");
633 assert!(val >= 0.0);
634 prev = val;
635 }
636 }
637
638 #[test]
639 fn fast_exp_neg_at_zero() {
640 assert!((fast_exp_neg(0.0) - 1.0).abs() < 1e-6);
641 }
642
643 #[test]
644 fn fast_exp_neg_negative_input() {
645 assert!((fast_exp_neg(-5.0) - 1.0).abs() < 1e-6);
647 }
648
649 #[test]
650 fn fast_exp_neg_vs_stdlib() {
651 for i in 0..50 {
653 let x = i as f32 * 0.2;
654 let approx = fast_exp_neg(x);
655 let exact = (-x).exp();
656 assert!(
657 approx >= exact - 1e-6,
658 "approx={approx} < exact={exact} at x={x}"
659 );
660 }
661 }
662
663 #[test]
668 fn lut_exp_at_zero() {
669 assert!((fast_exp_neg_lut(0.0) - 1.0).abs() < 1e-4);
670 }
671
672 #[test]
673 fn lut_exp_accuracy() {
674 for i in 0..80 {
676 let x = i as f32 * 0.1;
677 let approx = fast_exp_neg_lut(x);
678 let exact = (-x).exp();
679 let rel_err = if exact > 1e-10 {
680 (approx - exact).abs() / exact
681 } else {
682 (approx - exact).abs()
683 };
684 assert!(
685 rel_err < 0.01,
686 "x={x} approx={approx} exact={exact} rel_err={rel_err}"
687 );
688 }
689 }
690
691 #[test]
692 fn lut_exp_beyond_domain() {
693 let val = fast_exp_neg_lut(100.0);
695 assert!(val < 0.001, "val={val}");
696 assert!(val >= 0.0);
697 }
698
699 #[test]
700 fn lut_exp_monotonic() {
701 let mut prev = fast_exp_neg_lut(0.0);
702 for i in 1..160 {
703 let x = i as f32 * 0.05;
704 let val = fast_exp_neg_lut(x);
705 assert!(val <= prev + 1e-7, "not monotonic at x={x}");
706 prev = val;
707 }
708 }
709
710 #[test]
715 fn tier_selection_clear_hot() {
716 let cfg = default_config();
717 let meta = make_meta(1.0, u64::MAX, 100, Tier::Tier3, 0);
719 let target = choose_tier(&cfg, 100, &meta);
720 assert_eq!(target, Some(Tier::Tier1));
721 }
722
723 #[test]
724 fn tier_selection_clear_cold() {
725 let cfg = default_config();
726 let meta = make_meta(0.0, 0, 0, Tier::Tier1, 0);
728 let target = choose_tier(&cfg, 10_000, &meta);
729 assert_eq!(target, Some(Tier::Tier3));
730 }
731
732 #[test]
733 fn tier_selection_hysteresis_prevents_upgrade() {
734 let cfg = TierConfig {
736 hysteresis: 0.10,
737 ..default_config()
738 };
739 let meta = make_meta(0.4, u64::MAX, 50, Tier::Tier2, 0);
747 let score = compute_score(&cfg, 50, &meta);
748 assert!(score > cfg.t1, "score={score}");
749 assert!(score < cfg.t1 + cfg.hysteresis, "score={score}");
750 let target = choose_tier(&cfg, 50, &meta);
751 assert_eq!(target, None, "score={score} should be within hysteresis band");
753 }
754
755 #[test]
756 fn tier_selection_hysteresis_prevents_downgrade() {
757 let cfg = TierConfig {
758 hysteresis: 0.10,
759 ..default_config()
760 };
761 let meta = make_meta(0.5, 0x0000_0000_FFFF_FFFF, 90, Tier::Tier1, 0);
767 let score = compute_score(&cfg, 100, &meta);
768 assert!(
769 score < cfg.t1 && score > cfg.t1 - cfg.hysteresis,
770 "score={score}, expected in ({}, {})",
771 cfg.t1 - cfg.hysteresis,
772 cfg.t1
773 );
774 let target = choose_tier(&cfg, 100, &meta);
775 assert_eq!(target, None, "hysteresis should prevent downgrade, score={score}");
776 }
777
778 #[test]
783 fn touch_increments_count() {
784 let cfg = default_config();
785 let mut meta = BlockMeta::new(0);
786 assert_eq!(meta.access_count, 0);
787 touch(&cfg, 1, &mut meta);
788 assert_eq!(meta.access_count, 1);
789 touch(&cfg, 2, &mut meta);
790 assert_eq!(meta.access_count, 2);
791 }
792
793 #[test]
794 fn touch_updates_ema() {
795 let cfg = default_config();
796 let mut meta = BlockMeta::new(0);
797 assert_eq!(meta.ema_rate, 0.0);
798 touch(&cfg, 1, &mut meta);
799 assert!((meta.ema_rate - 0.3).abs() < 1e-6);
801 touch(&cfg, 2, &mut meta);
802 assert!((meta.ema_rate - 0.51).abs() < 1e-6);
804 }
805
806 #[test]
807 fn touch_updates_window() {
808 let cfg = default_config();
809 let mut meta = BlockMeta::new(0);
810 meta.access_window = 0;
811 touch(&cfg, 1, &mut meta);
812 assert_eq!(meta.access_window, 1);
813 touch(&cfg, 3, &mut meta);
814 assert_eq!(meta.access_window, 0b101);
816 }
817
818 #[test]
819 fn touch_same_tick() {
820 let cfg = default_config();
821 let mut meta = BlockMeta::new(5);
822 meta.access_window = 0b1010;
823 touch(&cfg, 5, &mut meta);
824 assert_eq!(meta.access_window, 0b1011);
826 }
827
828 #[test]
829 fn touch_large_gap_clears_window() {
830 let cfg = default_config();
831 let mut meta = BlockMeta::new(0);
832 meta.access_window = u64::MAX;
833 touch(&cfg, 100, &mut meta);
834 assert_eq!(meta.access_window, 1);
836 }
837
838 #[test]
843 fn min_residency_blocks_migration() {
844 let cfg = TierConfig {
845 min_residency: 10,
846 ..default_config()
847 };
848 let meta = make_meta(1.0, u64::MAX, 100, Tier::Tier3, 95);
850 let target = choose_tier(&cfg, 100, &meta);
851 assert_eq!(target, None);
852 }
853
854 #[test]
855 fn min_residency_allows_after_enough_ticks() {
856 let cfg = TierConfig {
857 min_residency: 10,
858 ..default_config()
859 };
860 let meta = make_meta(1.0, u64::MAX, 100, Tier::Tier3, 90);
862 let target = choose_tier(&cfg, 100, &meta);
863 assert_eq!(target, Some(Tier::Tier1));
864 }
865
866 #[test]
871 fn candidates_upgrades_before_downgrades() {
872 let cfg = default_config();
873
874 let hot_meta = make_meta(1.0, u64::MAX, 50, Tier::Tier3, 0);
875 let cold_meta = make_meta(0.0, 0, 0, Tier::Tier1, 0);
876
877 let blocks = vec![
878 (BlockKey(1), &cold_meta),
879 (BlockKey(2), &hot_meta),
880 ];
881
882 let candidates = select_candidates(&cfg, 50, &blocks);
883 assert!(candidates.len() >= 2, "expected at least 2 candidates");
884 assert_eq!(candidates[0].key, BlockKey(2));
886 assert_eq!(candidates[0].target_tier, Tier::Tier1);
887 assert_eq!(candidates[1].key, BlockKey(1));
889 assert_eq!(candidates[1].target_tier, Tier::Tier3);
890 }
891
892 #[test]
893 fn candidates_upgrades_sorted_by_highest_score() {
894 let cfg = default_config();
895
896 let meta_a = make_meta(0.9, u64::MAX, 50, Tier::Tier3, 0);
897 let meta_b = make_meta(1.0, u64::MAX, 50, Tier::Tier3, 0);
898
899 let blocks = vec![
900 (BlockKey(1), &meta_a),
901 (BlockKey(2), &meta_b),
902 ];
903
904 let candidates = select_candidates(&cfg, 50, &blocks);
905 assert!(candidates.len() >= 2);
907 assert_eq!(candidates[0].key, BlockKey(2));
908 assert_eq!(candidates[1].key, BlockKey(1));
909 }
910
911 #[test]
912 fn candidates_empty_when_all_stable() {
913 let cfg = default_config();
914 let meta = make_meta(0.5, 0x0000_0000_FFFF_FFFF, 50, Tier::Tier2, 0);
916 let blocks = vec![(BlockKey(1), &meta)];
917 let candidates = select_candidates(&cfg, 50, &blocks);
918 let _ = candidates;
920 }
921
922 #[test]
927 fn bits_tier0() {
928 assert_eq!(bits_for_tier(&default_config(), Tier::Tier0, 0), 0);
929 }
930
931 #[test]
932 fn bits_tier1() {
933 assert_eq!(bits_for_tier(&default_config(), Tier::Tier1, 0), 8);
934 }
935
936 #[test]
937 fn bits_tier2_normal() {
938 assert_eq!(bits_for_tier(&default_config(), Tier::Tier2, 0), 7);
939 }
940
941 #[test]
942 fn bits_tier3() {
943 assert_eq!(bits_for_tier(&default_config(), Tier::Tier3, 0), 3);
944 }
945
946 #[test]
951 fn bits_tier2_aggressive() {
952 let cfg = TierConfig {
953 warm_aggressive_threshold: Some(1024),
954 ..default_config()
955 };
956 assert_eq!(bits_for_tier(&cfg, Tier::Tier2, 512), 7);
957 assert_eq!(bits_for_tier(&cfg, Tier::Tier2, 1024), 7); assert_eq!(bits_for_tier(&cfg, Tier::Tier2, 1025), 5);
959 }
960
961 #[test]
966 fn edge_zero_access_count() {
967 let cfg = default_config();
968 let meta = BlockMeta::new(0);
969 let score = compute_score(&cfg, 0, &meta);
970 assert!((score - cfg.w_rec).abs() < 1e-4, "score={score}");
972 }
973
974 #[test]
975 fn edge_max_timestamp() {
976 let cfg = default_config();
977 let meta = make_meta(0.5, 0xAAAA_AAAA_AAAA_AAAA, u64::MAX - 1, Tier::Tier2, 0);
978 let score = compute_score(&cfg, u64::MAX, &meta);
979 assert!(score.is_finite(), "score={score}");
981 }
982
983 #[test]
984 fn edge_touch_at_u64_max() {
985 let cfg = default_config();
986 let mut meta = BlockMeta::new(u64::MAX - 1);
987 touch(&cfg, u64::MAX, &mut meta);
988 assert_eq!(meta.last_access, u64::MAX);
989 assert_eq!(meta.access_count, 1);
990 }
991
992 #[test]
993 fn edge_access_count_saturates() {
994 let cfg = default_config();
995 let mut meta = BlockMeta::new(0);
996 meta.access_count = u64::MAX;
997 touch(&cfg, 1, &mut meta);
998 assert_eq!(meta.access_count, u64::MAX);
999 }
1000
1001 #[test]
1002 fn tick_decay_reduces_ema() {
1003 let cfg = default_config();
1004 let mut meta = BlockMeta::new(0);
1005 meta.ema_rate = 1.0;
1006 meta.access_window = 0b1111;
1007 tick_decay(&cfg, &mut meta);
1008 assert!((meta.ema_rate - 0.7).abs() < 1e-6, "ema={}", meta.ema_rate);
1009 assert_eq!(meta.access_window, 0b1111_0);
1010 }
1011
1012 #[test]
1013 fn tick_decay_converges_to_zero() {
1014 let cfg = default_config();
1015 let mut meta = BlockMeta::new(0);
1016 meta.ema_rate = 1.0;
1017 for _ in 0..200 {
1018 tick_decay(&cfg, &mut meta);
1019 }
1020 assert!(meta.ema_rate < 1e-10, "ema={}", meta.ema_rate);
1021 }
1022
1023 #[test]
1024 fn tier_config_default_weights_sum_to_one() {
1025 let cfg = default_config();
1026 let sum = cfg.w_ema + cfg.w_pop + cfg.w_rec;
1027 assert!((sum - 1.0).abs() < 1e-6, "sum={sum}");
1028 }
1029
1030 #[test]
1031 fn block_meta_new_defaults() {
1032 let meta = BlockMeta::new(42);
1033 assert_eq!(meta.ema_rate, 0.0);
1034 assert_eq!(meta.access_window, 0);
1035 assert_eq!(meta.last_access, 42);
1036 assert_eq!(meta.access_count, 0);
1037 assert_eq!(meta.current_tier, Tier::Tier1);
1038 assert_eq!(meta.tier_since, 42);
1039 }
1040
1041 #[test]
1042 fn tier_ordering() {
1043 assert!(Tier::Tier0 < Tier::Tier1);
1044 assert!(Tier::Tier1 < Tier::Tier2);
1045 assert!(Tier::Tier2 < Tier::Tier3);
1046 }
1047
1048 #[test]
1053 fn batch_scores_match_individual() {
1054 let cfg = default_config();
1055 let metas: Vec<BlockMeta> = vec![
1056 make_meta(1.0, u64::MAX, 100, Tier::Tier1, 0),
1057 make_meta(0.0, 0, 0, Tier::Tier3, 0),
1058 make_meta(0.5, 0x0000_0000_FFFF_FFFF, 50, Tier::Tier2, 0),
1059 ];
1060 let batch = compute_scores_batch(&cfg, 100, &metas);
1061 for (i, meta) in metas.iter().enumerate() {
1062 let single = compute_score(&cfg, 100, meta);
1063 assert!((batch[i] - single).abs() < 1e-6, "index {i}");
1064 }
1065 }
1066
1067 #[test]
1068 fn batch_tiers_match_individual() {
1069 let cfg = default_config();
1070 let metas: Vec<BlockMeta> = vec![
1071 make_meta(1.0, u64::MAX, 100, Tier::Tier1, 0),
1072 make_meta(0.0, 0, 0, Tier::Tier3, 0),
1073 ];
1074 let batch = choose_tiers_batch(&cfg, 100, &metas);
1075 for (i, meta) in metas.iter().enumerate() {
1076 let single = choose_tier(&cfg, 100, meta);
1077 assert_eq!(batch[i], single, "index {i}");
1078 }
1079 }
1080
1081 #[test]
1082 fn score_and_partition_distributes_correctly() {
1083 let cfg = default_config();
1084 let metas: Vec<BlockMeta> = vec![
1085 make_meta(1.0, u64::MAX, 100, Tier::Tier1, 0), make_meta(0.5, 0x0000_0000_FFFF_FFFF, 90, Tier::Tier2, 0), make_meta(0.0, 0, 0, Tier::Tier3, 0), ];
1089 let part = score_and_partition(&cfg, 100, &metas);
1090 assert!(!part.hot.is_empty(), "should have hot blocks");
1091 assert_eq!(part.scores.len(), 3);
1092 }
1093
1094 #[test]
1095 fn top_k_coldest_returns_lowest() {
1096 let cfg = default_config();
1097 let metas: Vec<BlockMeta> = vec![
1098 make_meta(1.0, u64::MAX, 100, Tier::Tier1, 0),
1099 make_meta(0.0, 0, 0, Tier::Tier3, 0),
1100 make_meta(0.5, 0x0000_0000_FFFF_FFFF, 50, Tier::Tier2, 0),
1101 ];
1102 let coldest = top_k_coldest(&cfg, 100, &metas, 2);
1103 assert_eq!(coldest.len(), 2);
1104 assert_eq!(coldest[0].0, 1);
1106 assert!(coldest[0].1 <= coldest[1].1);
1107 }
1108
1109 #[test]
1110 fn top_k_coldest_k_exceeds_len() {
1111 let cfg = default_config();
1112 let metas: Vec<BlockMeta> = vec![
1113 make_meta(1.0, u64::MAX, 100, Tier::Tier1, 0),
1114 ];
1115 let coldest = top_k_coldest(&cfg, 100, &metas, 10);
1116 assert_eq!(coldest.len(), 1);
1117 }
1118
1119 #[test]
1120 fn batch_empty_input() {
1121 let cfg = default_config();
1122 let empty: Vec<BlockMeta> = vec![];
1123 assert!(compute_scores_batch(&cfg, 100, &empty).is_empty());
1124 assert!(choose_tiers_batch(&cfg, 100, &empty).is_empty());
1125 let part = score_and_partition(&cfg, 100, &empty);
1126 assert!(part.hot.is_empty() && part.warm.is_empty() && part.cold.is_empty() && part.evict.is_empty());
1127 assert!(top_k_coldest(&cfg, 100, &empty, 5).is_empty());
1128 }
1129}