Skip to main content

rvm_memory/
tier.rs

1//! Memory tier management per ADR-136.
2//!
3//! Implements the four-tier coherence-driven memory model:
4//! Hot (tier 0), Warm (tier 1), Dormant (tier 2), Cold (tier 3).
5//!
6//! Tier placement is driven by the residency rule:
7//!   `cut_value + recency_score > eviction_threshold`
8//!
9//! When the coherence engine is absent (DC-1), `cut_value` defaults to 0
10//! and only `recency_score` drives tier placement against a static threshold.
11
12use rvm_types::{OwnedRegionId, RvmError, RvmResult};
13
14/// The four memory tiers defined in ADR-136.
15///
16/// This extends the 3-tier `MemoryTier` from `rvm-types` by adding the
17/// Dormant tier that stores compressed reconstructable state.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
19#[repr(u8)]
20pub enum Tier {
21    /// Tier 0 -- Hot: per-core SRAM or L1/L2 cache-resident.
22    /// Always resident during partition execution.
23    Hot = 0,
24    /// Tier 1 -- Warm: cluster-shared DRAM.
25    /// Resident if `cut_value + recency_score > eviction_threshold`.
26    Warm = 1,
27    /// Tier 2 -- Dormant: compressed storage in main memory.
28    /// Stored as witness checkpoint + delta compression; reconstructed on demand.
29    Dormant = 2,
30    /// Tier 3 -- Cold: RVF-backed archival on persistent storage.
31    /// Accessed only during recovery or explicit restore. Never auto-promoted.
32    Cold = 3,
33}
34
35impl Tier {
36    /// Return the numeric tier index.
37    #[must_use]
38    pub const fn index(self) -> u8 {
39        self as u8
40    }
41
42    /// Try to create a `Tier` from a raw `u8` value.
43    #[must_use]
44    pub const fn from_u8(val: u8) -> Option<Self> {
45        match val {
46            0 => Some(Self::Hot),
47            1 => Some(Self::Warm),
48            2 => Some(Self::Dormant),
49            3 => Some(Self::Cold),
50            _ => None,
51        }
52    }
53}
54
55/// Static fallback thresholds for tier transitions when the coherence
56/// engine is absent (DC-1). All values are in basis points (`0..=10_000`).
57pub struct TierThresholds {
58    /// Threshold for Hot -> Warm demotion.
59    /// If `cut_value + recency_score` drops below this, demote from Hot.
60    pub hot_to_warm: u16,
61    /// Threshold for Warm -> Dormant demotion.
62    pub warm_to_dormant: u16,
63    /// Threshold for Dormant -> Cold demotion.
64    pub dormant_to_cold: u16,
65    /// Threshold for Warm -> Hot promotion.
66    pub warm_to_hot: u16,
67    /// Threshold for Dormant -> Warm promotion (triggers reconstruction).
68    pub dormant_to_warm: u16,
69}
70
71impl TierThresholds {
72    /// Conservative default thresholds for DC-1 (no coherence engine).
73    pub const DEFAULT: Self = Self {
74        hot_to_warm: 7_000,
75        warm_to_dormant: 4_000,
76        dormant_to_cold: 1_000,
77        warm_to_hot: 8_000,
78        dormant_to_warm: 5_000,
79    };
80}
81
82/// Per-region metadata tracked by the tier manager.
83#[derive(Debug, Clone, Copy)]
84pub struct RegionTierState {
85    /// The region identifier.
86    pub region_id: OwnedRegionId,
87    /// Current tier placement.
88    pub tier: Tier,
89    /// Epoch of last access (monotonically increasing).
90    pub last_access_epoch: u32,
91    /// Coherence graph cut-value for this region (basis points, `0..=10_000`).
92    /// Defaults to 0 when coherence engine is absent (DC-1).
93    pub cut_value: u16,
94    /// Recency score (basis points, `0..=10_000`). Decays each epoch.
95    pub recency_score: u16,
96    /// Whether this slot is occupied.
97    occupied: bool,
98}
99
100impl RegionTierState {
101    /// An empty (unoccupied) slot.
102    const EMPTY: Self = Self {
103        region_id: OwnedRegionId::new(0),
104        tier: Tier::Warm,
105        last_access_epoch: 0,
106        cut_value: 0,
107        recency_score: 0,
108        occupied: false,
109    };
110
111    /// Compute the composite residency score: `cut_value + recency_score`.
112    /// Saturates at `u16::MAX` to avoid overflow.
113    #[must_use]
114    pub const fn residency_score(self) -> u16 {
115        self.cut_value.saturating_add(self.recency_score)
116    }
117}
118
119/// Manages tier placement for a fixed set of memory regions.
120///
121/// `MAX_REGIONS` is the compile-time upper bound on tracked regions.
122/// This avoids heap allocation and is suitable for `no_std` environments.
123pub struct TierManager<const MAX_REGIONS: usize> {
124    /// Per-region tier state, indexed by slot (not by region ID).
125    regions: [RegionTierState; MAX_REGIONS],
126    /// Number of occupied slots.
127    count: usize,
128    /// Tier thresholds (static fallback for DC-1).
129    thresholds: TierThresholds,
130    /// Current epoch for recency tracking.
131    current_epoch: u32,
132}
133
134impl<const MAX_REGIONS: usize> Default for TierManager<MAX_REGIONS> {
135    fn default() -> Self {
136        Self::new()
137    }
138}
139
140impl<const MAX_REGIONS: usize> TierManager<MAX_REGIONS> {
141    /// Create a new `TierManager` with default DC-1 thresholds.
142    #[must_use]
143    pub const fn new() -> Self {
144        Self {
145            regions: [RegionTierState::EMPTY; MAX_REGIONS],
146            count: 0,
147            thresholds: TierThresholds::DEFAULT,
148            current_epoch: 0,
149        }
150    }
151
152    /// Create a new `TierManager` with custom thresholds.
153    #[must_use]
154    pub const fn with_thresholds(thresholds: TierThresholds) -> Self {
155        Self {
156            regions: [RegionTierState::EMPTY; MAX_REGIONS],
157            count: 0,
158            thresholds,
159            current_epoch: 0,
160        }
161    }
162
163    /// Return the number of tracked regions.
164    #[must_use]
165    pub const fn count(&self) -> usize {
166        self.count
167    }
168
169    /// Return the current epoch.
170    #[must_use]
171    pub const fn current_epoch(&self) -> u32 {
172        self.current_epoch
173    }
174
175    /// Advance the epoch counter. Call this once per scheduler epoch.
176    pub fn advance_epoch(&mut self) {
177        self.current_epoch = self.current_epoch.saturating_add(1);
178    }
179
180    /// Register a new region in the tier manager at the given initial tier.
181    ///
182    /// # Errors
183    ///
184    /// Returns [`RvmError::ResourceLimitExceeded`] if the manager is at capacity.
185    /// Returns [`RvmError::MemoryOverlap`] if the region is already registered.
186    pub fn register(&mut self, region_id: OwnedRegionId, initial_tier: Tier) -> RvmResult<()> {
187        if self.count >= MAX_REGIONS {
188            return Err(RvmError::ResourceLimitExceeded);
189        }
190        // Check for duplicate registration.
191        if self.find_slot(region_id).is_some() {
192            return Err(RvmError::MemoryOverlap);
193        }
194        // Find the first empty slot.
195        for slot in &mut self.regions {
196            if !slot.occupied {
197                *slot = RegionTierState {
198                    region_id,
199                    tier: initial_tier,
200                    last_access_epoch: self.current_epoch,
201                    cut_value: 0,
202                    recency_score: 5_000, // Start at midpoint
203                    occupied: true,
204                };
205                self.count += 1;
206                return Ok(());
207            }
208        }
209        Err(RvmError::ResourceLimitExceeded)
210    }
211
212    /// Unregister a region from the tier manager.
213    ///
214    /// # Errors
215    ///
216    /// Returns [`RvmError::PartitionNotFound`] if the region is not tracked.
217    pub fn unregister(&mut self, region_id: OwnedRegionId) -> RvmResult<()> {
218        match self.find_slot(region_id) {
219            Some(idx) => {
220                self.regions[idx] = RegionTierState::EMPTY;
221                self.count -= 1;
222                Ok(())
223            }
224            None => Err(RvmError::PartitionNotFound),
225        }
226    }
227
228    /// Record an access to the given region, updating its recency score.
229    ///
230    /// # Errors
231    ///
232    /// Returns [`RvmError::PartitionNotFound`] if the region is not tracked.
233    pub fn record_access(&mut self, region_id: OwnedRegionId) -> RvmResult<()> {
234        match self.find_slot(region_id) {
235            Some(idx) => {
236                self.regions[idx].last_access_epoch = self.current_epoch;
237                // Boost recency score on access, saturate at 10_000.
238                self.regions[idx].recency_score = self.regions[idx]
239                    .recency_score
240                    .saturating_add(1_000)
241                    .min(10_000);
242                Ok(())
243            }
244            None => Err(RvmError::PartitionNotFound),
245        }
246    }
247
248    /// Update the cut value for a region (from the coherence engine).
249    ///
250    /// # Errors
251    ///
252    /// Returns [`RvmError::PartitionNotFound`] if the region is not tracked.
253    pub fn update_cut_value(&mut self, region_id: OwnedRegionId, cut_value: u16) -> RvmResult<()> {
254        match self.find_slot(region_id) {
255            Some(idx) => {
256                self.regions[idx].cut_value = cut_value.min(10_000);
257                Ok(())
258            }
259            None => Err(RvmError::PartitionNotFound),
260        }
261    }
262
263    /// Promote a region to a higher (lower-numbered) tier.
264    ///
265    /// Returns the previous tier on success.
266    ///
267    /// # Errors
268    ///
269    /// Returns [`RvmError::PartitionNotFound`] if the region is not tracked.
270    /// Returns [`RvmError::InvalidTierTransition`] if the target tier is not
271    /// higher than the current tier, or if promoting from Cold.
272    /// Returns [`RvmError::CoherenceBelowThreshold`] if the residency score
273    /// does not meet the promotion threshold.
274    pub fn promote(&mut self, region_id: OwnedRegionId, target_tier: Tier) -> RvmResult<Tier> {
275        let idx = self
276            .find_slot(region_id)
277            .ok_or(RvmError::PartitionNotFound)?;
278        let current = self.regions[idx].tier;
279
280        // Target must be a higher (lower-numbered) tier.
281        if target_tier >= current {
282            return Err(RvmError::InvalidTierTransition);
283        }
284        // Cold regions never auto-promote (ADR-136: accessed only during recovery).
285        if current == Tier::Cold {
286            return Err(RvmError::InvalidTierTransition);
287        }
288        // Validate the residency rule for the target tier.
289        let score = self.regions[idx].residency_score();
290        let threshold = self.promotion_threshold(target_tier);
291        if score < threshold {
292            return Err(RvmError::CoherenceBelowThreshold);
293        }
294
295        let old_tier = self.regions[idx].tier;
296        self.regions[idx].tier = target_tier;
297        self.regions[idx].last_access_epoch = self.current_epoch;
298        Ok(old_tier)
299    }
300
301    /// Demote a region to a lower (higher-numbered) tier.
302    ///
303    /// Returns the previous tier on success.
304    ///
305    /// # Errors
306    ///
307    /// Returns [`RvmError::PartitionNotFound`] if the region is not tracked.
308    /// Returns [`RvmError::InvalidTierTransition`] if the target tier is not
309    /// lower than the current tier.
310    pub fn demote(&mut self, region_id: OwnedRegionId, target_tier: Tier) -> RvmResult<Tier> {
311        let idx = self
312            .find_slot(region_id)
313            .ok_or(RvmError::PartitionNotFound)?;
314        let current = self.regions[idx].tier;
315
316        // Target must be a lower (higher-numbered) tier.
317        if target_tier <= current {
318            return Err(RvmError::InvalidTierTransition);
319        }
320
321        let old_tier = self.regions[idx].tier;
322        self.regions[idx].tier = target_tier;
323        Ok(old_tier)
324    }
325
326    /// Return the current tier state for a region, if tracked.
327    #[must_use]
328    pub fn get(&self, region_id: OwnedRegionId) -> Option<&RegionTierState> {
329        self.find_slot(region_id).map(|idx| &self.regions[idx])
330    }
331
332    /// Decay recency scores for all tracked regions by the given amount
333    /// (in basis points). Call this once per epoch to age out stale regions.
334    pub fn decay_recency(&mut self, decay_amount: u16) {
335        for slot in &mut self.regions {
336            if slot.occupied {
337                slot.recency_score = slot.recency_score.saturating_sub(decay_amount);
338            }
339        }
340    }
341
342    /// Identify regions that should be demoted based on current thresholds.
343    ///
344    /// Returns (`region_id`, `recommended_target_tier`) pairs.
345    /// Caller is responsible for acting on recommendations (e.g., triggering
346    /// compression for Dormant demotion).
347    ///
348    /// `out` is a caller-provided buffer; returns the number of entries written.
349    pub fn find_demotion_candidates(&self, out: &mut [(OwnedRegionId, Tier)]) -> usize {
350        let mut written = 0;
351        for slot in &self.regions {
352            if !slot.occupied || written >= out.len() {
353                continue;
354            }
355            let score = slot.residency_score();
356            let target = match slot.tier {
357                Tier::Hot if score < self.thresholds.hot_to_warm => Some(Tier::Warm),
358                Tier::Warm if score < self.thresholds.warm_to_dormant => Some(Tier::Dormant),
359                Tier::Dormant if score < self.thresholds.dormant_to_cold => Some(Tier::Cold),
360                _ => None,
361            };
362            if let Some(target_tier) = target {
363                out[written] = (slot.region_id, target_tier);
364                written += 1;
365            }
366        }
367        written
368    }
369
370    // --- Private helpers ---
371
372    /// Find the slot index for a given region ID.
373    fn find_slot(&self, region_id: OwnedRegionId) -> Option<usize> {
374        self.regions
375            .iter()
376            .position(|s| s.occupied && s.region_id == region_id)
377    }
378
379    /// Return the promotion threshold for a given target tier.
380    const fn promotion_threshold(&self, target: Tier) -> u16 {
381        match target {
382            Tier::Hot => self.thresholds.warm_to_hot,
383            Tier::Warm => self.thresholds.dormant_to_warm,
384            // Dormant and Cold are demotion targets, not promotion targets.
385            Tier::Dormant | Tier::Cold => u16::MAX,
386        }
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    fn rid(id: u64) -> OwnedRegionId {
395        OwnedRegionId::new(id)
396    }
397
398    #[test]
399    fn tier_from_u8_round_trips() {
400        for val in 0..=3u8 {
401            let tier = Tier::from_u8(val).unwrap();
402            assert_eq!(tier.index(), val);
403        }
404        assert!(Tier::from_u8(4).is_none());
405        assert!(Tier::from_u8(255).is_none());
406    }
407
408    #[test]
409    fn tier_ordering() {
410        assert!(Tier::Hot < Tier::Warm);
411        assert!(Tier::Warm < Tier::Dormant);
412        assert!(Tier::Dormant < Tier::Cold);
413    }
414
415    #[test]
416    fn register_and_get() {
417        let mut mgr = TierManager::<8>::new();
418        mgr.register(rid(1), Tier::Warm).unwrap();
419        assert_eq!(mgr.count(), 1);
420
421        let state = mgr.get(rid(1)).unwrap();
422        assert_eq!(state.tier, Tier::Warm);
423        assert_eq!(state.region_id, rid(1));
424        assert!(state.occupied);
425    }
426
427    #[test]
428    fn register_duplicate_fails() {
429        let mut mgr = TierManager::<8>::new();
430        mgr.register(rid(1), Tier::Warm).unwrap();
431        assert_eq!(
432            mgr.register(rid(1), Tier::Hot),
433            Err(RvmError::MemoryOverlap)
434        );
435    }
436
437    #[test]
438    fn register_at_capacity_fails() {
439        let mut mgr = TierManager::<2>::new();
440        mgr.register(rid(1), Tier::Warm).unwrap();
441        mgr.register(rid(2), Tier::Warm).unwrap();
442        assert_eq!(
443            mgr.register(rid(3), Tier::Warm),
444            Err(RvmError::ResourceLimitExceeded)
445        );
446    }
447
448    #[test]
449    fn unregister_frees_slot() {
450        let mut mgr = TierManager::<4>::new();
451        mgr.register(rid(1), Tier::Warm).unwrap();
452        mgr.register(rid(2), Tier::Hot).unwrap();
453        assert_eq!(mgr.count(), 2);
454
455        mgr.unregister(rid(1)).unwrap();
456        assert_eq!(mgr.count(), 1);
457        assert!(mgr.get(rid(1)).is_none());
458
459        // Can re-register into freed slot.
460        mgr.register(rid(3), Tier::Dormant).unwrap();
461        assert_eq!(mgr.count(), 2);
462    }
463
464    #[test]
465    fn unregister_nonexistent_fails() {
466        let mut mgr = TierManager::<4>::new();
467        assert_eq!(mgr.unregister(rid(99)), Err(RvmError::PartitionNotFound));
468    }
469
470    #[test]
471    fn promote_warm_to_hot() {
472        let mut mgr = TierManager::<4>::new();
473        mgr.register(rid(1), Tier::Warm).unwrap();
474        // Default recency_score is 5_000, cut_value is 0.
475        // warm_to_hot threshold is 8_000, so score=5_000 is insufficient.
476        assert_eq!(
477            mgr.promote(rid(1), Tier::Hot),
478            Err(RvmError::CoherenceBelowThreshold)
479        );
480
481        // Boost cut_value to make it pass.
482        mgr.update_cut_value(rid(1), 4_000).unwrap();
483        // Now score = 4_000 + 5_000 = 9_000 > 8_000.
484        let old = mgr.promote(rid(1), Tier::Hot).unwrap();
485        assert_eq!(old, Tier::Warm);
486        assert_eq!(mgr.get(rid(1)).unwrap().tier, Tier::Hot);
487    }
488
489    #[test]
490    fn promote_dormant_to_warm() {
491        let mut mgr = TierManager::<4>::new();
492        mgr.register(rid(1), Tier::Dormant).unwrap();
493        // Default recency is 5_000, dormant_to_warm threshold is 5_000.
494        // 5_000 >= 5_000, so it should pass.
495        let old = mgr.promote(rid(1), Tier::Warm).unwrap();
496        assert_eq!(old, Tier::Dormant);
497        assert_eq!(mgr.get(rid(1)).unwrap().tier, Tier::Warm);
498    }
499
500    #[test]
501    fn promote_cold_always_fails() {
502        let mut mgr = TierManager::<4>::new();
503        mgr.register(rid(1), Tier::Cold).unwrap();
504        mgr.update_cut_value(rid(1), 10_000).unwrap();
505        assert_eq!(
506            mgr.promote(rid(1), Tier::Dormant),
507            Err(RvmError::InvalidTierTransition)
508        );
509    }
510
511    #[test]
512    fn promote_same_tier_fails() {
513        let mut mgr = TierManager::<4>::new();
514        mgr.register(rid(1), Tier::Warm).unwrap();
515        assert_eq!(
516            mgr.promote(rid(1), Tier::Warm),
517            Err(RvmError::InvalidTierTransition)
518        );
519    }
520
521    #[test]
522    fn promote_to_lower_tier_fails() {
523        let mut mgr = TierManager::<4>::new();
524        mgr.register(rid(1), Tier::Warm).unwrap();
525        assert_eq!(
526            mgr.promote(rid(1), Tier::Dormant),
527            Err(RvmError::InvalidTierTransition)
528        );
529    }
530
531    #[test]
532    fn demote_hot_to_warm() {
533        let mut mgr = TierManager::<4>::new();
534        mgr.register(rid(1), Tier::Hot).unwrap();
535        let old = mgr.demote(rid(1), Tier::Warm).unwrap();
536        assert_eq!(old, Tier::Hot);
537        assert_eq!(mgr.get(rid(1)).unwrap().tier, Tier::Warm);
538    }
539
540    #[test]
541    fn demote_to_higher_tier_fails() {
542        let mut mgr = TierManager::<4>::new();
543        mgr.register(rid(1), Tier::Warm).unwrap();
544        assert_eq!(
545            mgr.demote(rid(1), Tier::Hot),
546            Err(RvmError::InvalidTierTransition)
547        );
548    }
549
550    #[test]
551    fn demote_same_tier_fails() {
552        let mut mgr = TierManager::<4>::new();
553        mgr.register(rid(1), Tier::Warm).unwrap();
554        assert_eq!(
555            mgr.demote(rid(1), Tier::Warm),
556            Err(RvmError::InvalidTierTransition)
557        );
558    }
559
560    #[test]
561    fn demote_warm_to_cold_skipping_dormant() {
562        let mut mgr = TierManager::<4>::new();
563        mgr.register(rid(1), Tier::Warm).unwrap();
564        let old = mgr.demote(rid(1), Tier::Cold).unwrap();
565        assert_eq!(old, Tier::Warm);
566        assert_eq!(mgr.get(rid(1)).unwrap().tier, Tier::Cold);
567    }
568
569    #[test]
570    fn record_access_boosts_recency() {
571        let mut mgr = TierManager::<4>::new();
572        mgr.register(rid(1), Tier::Warm).unwrap();
573        let before = mgr.get(rid(1)).unwrap().recency_score;
574
575        mgr.record_access(rid(1)).unwrap();
576        let after = mgr.get(rid(1)).unwrap().recency_score;
577        assert!(after > before);
578    }
579
580    #[test]
581    fn record_access_nonexistent_fails() {
582        let mut mgr = TierManager::<4>::new();
583        assert_eq!(mgr.record_access(rid(99)), Err(RvmError::PartitionNotFound));
584    }
585
586    #[test]
587    fn decay_recency_reduces_scores() {
588        let mut mgr = TierManager::<4>::new();
589        mgr.register(rid(1), Tier::Warm).unwrap();
590        let before = mgr.get(rid(1)).unwrap().recency_score;
591
592        mgr.decay_recency(1_000);
593        let after = mgr.get(rid(1)).unwrap().recency_score;
594        assert_eq!(after, before - 1_000);
595    }
596
597    #[test]
598    fn decay_recency_saturates_at_zero() {
599        let mut mgr = TierManager::<4>::new();
600        mgr.register(rid(1), Tier::Warm).unwrap();
601        mgr.decay_recency(20_000); // Way more than current score.
602        assert_eq!(mgr.get(rid(1)).unwrap().recency_score, 0);
603    }
604
605    #[test]
606    fn find_demotion_candidates() {
607        let mut mgr = TierManager::<8>::new();
608        mgr.register(rid(1), Tier::Hot).unwrap();
609        mgr.register(rid(2), Tier::Warm).unwrap();
610        mgr.register(rid(3), Tier::Dormant).unwrap();
611
612        // Decay all recency scores heavily so they drop below thresholds.
613        mgr.decay_recency(10_000);
614
615        let mut buf = [(OwnedRegionId::new(0), Tier::Hot); 8];
616        let n = mgr.find_demotion_candidates(&mut buf);
617
618        // All three should be candidates for demotion.
619        assert_eq!(n, 3);
620
621        // Verify each demotion target is correct.
622        let candidates: &[(OwnedRegionId, Tier)] = &buf[..n];
623        assert!(candidates
624            .iter()
625            .any(|(id, t)| *id == rid(1) && *t == Tier::Warm));
626        assert!(candidates
627            .iter()
628            .any(|(id, t)| *id == rid(2) && *t == Tier::Dormant));
629        assert!(candidates
630            .iter()
631            .any(|(id, t)| *id == rid(3) && *t == Tier::Cold));
632    }
633
634    #[test]
635    fn advance_epoch() {
636        let mut mgr = TierManager::<4>::new();
637        assert_eq!(mgr.current_epoch(), 0);
638        mgr.advance_epoch();
639        assert_eq!(mgr.current_epoch(), 1);
640        mgr.advance_epoch();
641        assert_eq!(mgr.current_epoch(), 2);
642    }
643
644    #[test]
645    fn residency_score_computation() {
646        let state = RegionTierState {
647            region_id: rid(1),
648            tier: Tier::Warm,
649            last_access_epoch: 0,
650            cut_value: 3_000,
651            recency_score: 4_000,
652            occupied: true,
653        };
654        assert_eq!(state.residency_score(), 7_000);
655    }
656
657    #[test]
658    fn residency_score_saturates() {
659        let state = RegionTierState {
660            region_id: rid(1),
661            tier: Tier::Warm,
662            last_access_epoch: 0,
663            cut_value: 60_000,
664            recency_score: 60_000,
665            occupied: true,
666        };
667        assert_eq!(state.residency_score(), u16::MAX);
668    }
669
670    #[test]
671    fn residency_score_no_overflow_within_range() {
672        let state = RegionTierState {
673            region_id: rid(1),
674            tier: Tier::Warm,
675            last_access_epoch: 0,
676            cut_value: 10_000,
677            recency_score: 10_000,
678            occupied: true,
679        };
680        assert_eq!(state.residency_score(), 20_000);
681    }
682
683    #[test]
684    fn update_cut_value_clamps() {
685        let mut mgr = TierManager::<4>::new();
686        mgr.register(rid(1), Tier::Warm).unwrap();
687        mgr.update_cut_value(rid(1), 50_000).unwrap();
688        assert_eq!(mgr.get(rid(1)).unwrap().cut_value, 10_000);
689    }
690
691    // ---------------------------------------------------------------
692    // DC-1 static fallback: coherence-absent behavior
693    // ---------------------------------------------------------------
694
695    #[test]
696    fn dc1_fallback_cut_value_stays_zero_without_coherence() {
697        // When the coherence engine is absent, cut_value defaults to 0.
698        // Only recency_score drives tier placement.
699        let mut mgr = TierManager::<4>::new();
700        mgr.register(rid(1), Tier::Warm).unwrap();
701
702        let state = mgr.get(rid(1)).unwrap();
703        assert_eq!(state.cut_value, 0);
704        // Residency score = 0 + 5_000 = 5_000.
705        assert_eq!(state.residency_score(), 5_000);
706    }
707
708    #[test]
709    fn dc1_fallback_promotion_blocked_by_low_recency() {
710        // Without coherence engine, warm->hot requires score >= 8_000.
711        // Default recency=5_000, cut_value=0, score=5_000 < 8_000.
712        let mut mgr = TierManager::<4>::new();
713        mgr.register(rid(1), Tier::Warm).unwrap();
714        assert_eq!(
715            mgr.promote(rid(1), Tier::Hot),
716            Err(RvmError::CoherenceBelowThreshold)
717        );
718    }
719
720    #[test]
721    fn dc1_fallback_promotion_possible_with_high_recency() {
722        let mut mgr = TierManager::<4>::new();
723        mgr.register(rid(1), Tier::Warm).unwrap();
724
725        // Boost recency to 8_000 by accessing 3 times
726        // (5_000 + 1_000 + 1_000 + 1_000 = 8_000)
727        mgr.record_access(rid(1)).unwrap();
728        mgr.record_access(rid(1)).unwrap();
729        mgr.record_access(rid(1)).unwrap();
730        let state = mgr.get(rid(1)).unwrap();
731        assert_eq!(state.recency_score, 8_000);
732        assert_eq!(state.residency_score(), 8_000); // cut_value still 0
733
734        // Now promotion to Hot should succeed (8_000 >= 8_000 threshold).
735        let old = mgr.promote(rid(1), Tier::Hot).unwrap();
736        assert_eq!(old, Tier::Warm);
737    }
738
739    #[test]
740    fn dc1_fallback_demotion_on_decay() {
741        let mut mgr = TierManager::<8>::new();
742        mgr.register(rid(1), Tier::Hot).unwrap();
743
744        // Default recency = 5_000, cut_value = 0.
745        // Hot->Warm threshold is 7_000. Since score=5_000 < 7_000, region
746        // should be a demotion candidate immediately.
747        let mut buf = [(OwnedRegionId::new(0), Tier::Hot); 8];
748        let n = mgr.find_demotion_candidates(&mut buf);
749        assert_eq!(n, 1);
750        assert_eq!(buf[0].0, rid(1));
751        assert_eq!(buf[0].1, Tier::Warm);
752    }
753
754    #[test]
755    fn dc1_fallback_warm_to_dormant_demotion_after_decay() {
756        let mut mgr = TierManager::<4>::new();
757        mgr.register(rid(1), Tier::Warm).unwrap();
758
759        // Decay recency to below warm_to_dormant threshold (4_000).
760        // Initial recency=5_000, decay by 2_000 -> 3_000 < 4_000.
761        mgr.decay_recency(2_000);
762        let mut buf = [(OwnedRegionId::new(0), Tier::Hot); 4];
763        let n = mgr.find_demotion_candidates(&mut buf);
764        assert_eq!(n, 1);
765        assert_eq!(buf[0].0, rid(1));
766        assert_eq!(buf[0].1, Tier::Dormant);
767    }
768
769    #[test]
770    fn dc1_fallback_dormant_to_cold_demotion() {
771        let mut mgr = TierManager::<4>::new();
772        mgr.register(rid(1), Tier::Dormant).unwrap();
773
774        // Decay recency to below dormant_to_cold threshold (1_000).
775        mgr.decay_recency(5_000); // recency 0
776        let mut buf = [(OwnedRegionId::new(0), Tier::Hot); 4];
777        let n = mgr.find_demotion_candidates(&mut buf);
778        assert_eq!(n, 1);
779        assert_eq!(buf[0].0, rid(1));
780        assert_eq!(buf[0].1, Tier::Cold);
781    }
782
783    #[test]
784    fn recency_access_saturates_at_10000() {
785        let mut mgr = TierManager::<4>::new();
786        mgr.register(rid(1), Tier::Warm).unwrap();
787
788        // Access many times to saturate.
789        for _ in 0..20 {
790            mgr.record_access(rid(1)).unwrap();
791        }
792        assert_eq!(mgr.get(rid(1)).unwrap().recency_score, 10_000);
793    }
794
795    #[test]
796    fn epoch_advance_is_monotonic() {
797        let mut mgr = TierManager::<4>::new();
798        for expected in 0..10u32 {
799            assert_eq!(mgr.current_epoch(), expected);
800            mgr.advance_epoch();
801        }
802        assert_eq!(mgr.current_epoch(), 10);
803    }
804
805    #[test]
806    fn registered_region_records_access_epoch() {
807        let mut mgr = TierManager::<4>::new();
808        mgr.advance_epoch();
809        mgr.advance_epoch(); // epoch = 2
810        mgr.register(rid(1), Tier::Warm).unwrap();
811        assert_eq!(mgr.get(rid(1)).unwrap().last_access_epoch, 2);
812
813        mgr.advance_epoch(); // epoch = 3
814        mgr.record_access(rid(1)).unwrap();
815        assert_eq!(mgr.get(rid(1)).unwrap().last_access_epoch, 3);
816    }
817
818    #[test]
819    fn find_demotion_candidates_respects_buffer_size() {
820        let mut mgr = TierManager::<8>::new();
821        for i in 1..=5u64 {
822            mgr.register(rid(i), Tier::Hot).unwrap();
823        }
824        // All 5 should be demotion candidates with default scores.
825        // But we only provide a buffer of size 2.
826        let mut buf = [(OwnedRegionId::new(0), Tier::Hot); 2];
827        let n = mgr.find_demotion_candidates(&mut buf);
828        assert_eq!(n, 2); // Only 2 fit.
829    }
830
831    #[test]
832    fn cold_region_not_a_demotion_candidate() {
833        let mut mgr = TierManager::<4>::new();
834        mgr.register(rid(1), Tier::Cold).unwrap();
835        mgr.decay_recency(10_000);
836
837        let mut buf = [(OwnedRegionId::new(0), Tier::Hot); 4];
838        let n = mgr.find_demotion_candidates(&mut buf);
839        // Cold has no lower tier, so not a candidate.
840        assert_eq!(n, 0);
841    }
842
843    #[test]
844    fn custom_thresholds() {
845        let thresholds = TierThresholds {
846            hot_to_warm: 9_000,
847            warm_to_dormant: 6_000,
848            dormant_to_cold: 3_000,
849            warm_to_hot: 9_500,
850            dormant_to_warm: 7_000,
851        };
852        let mgr = TierManager::<4>::with_thresholds(thresholds);
853        assert_eq!(mgr.count(), 0);
854    }
855
856    #[test]
857    fn update_cut_value_nonexistent_fails() {
858        let mut mgr = TierManager::<4>::new();
859        assert_eq!(
860            mgr.update_cut_value(rid(99), 5_000),
861            Err(RvmError::PartitionNotFound)
862        );
863    }
864
865    #[test]
866    fn promote_nonexistent_fails() {
867        let mut mgr = TierManager::<4>::new();
868        assert_eq!(
869            mgr.promote(rid(99), Tier::Hot),
870            Err(RvmError::PartitionNotFound)
871        );
872    }
873
874    #[test]
875    fn demote_nonexistent_fails() {
876        let mut mgr = TierManager::<4>::new();
877        assert_eq!(
878            mgr.demote(rid(99), Tier::Cold),
879            Err(RvmError::PartitionNotFound)
880        );
881    }
882}