Skip to main content

ruvector_temporal_tensor/
tiering.rs

1//! Enhanced temporal scoring with EMA + popcount + recency, hysteresis,
2//! and budgeted maintenance (ADR-020).
3//!
4//! # Scoring Formula
5//!
6//! ```text
7//! score = w_ema * ema_rate
8//!       + w_pop * (popcount(access_window) / 64)
9//!       + w_rec * exp(-dt / tau)
10//! ```
11//!
12//! Where `dt = now - last_access` and `tau` is the recency decay constant.
13//!
14//! # Hysteresis
15//!
16//! To prevent tier oscillation, upgrades require the score to exceed the
17//! threshold by the hysteresis margin, and downgrades require the score to
18//! fall below the threshold by the same margin. A minimum residency period
19//! further dampens churn.
20//!
21//! # Types
22//!
23//! The types `BlockKey`, `BlockMeta`, and `Tier` are defined here for
24//! self-containment while `store.rs` is developed in parallel. Once
25//! `crate::store` lands, replace these definitions with:
26//! ```ignore
27//! use crate::store::{BlockKey, BlockMeta, Tier};
28//! ```
29
30// ---------------------------------------------------------------------------
31// Types (to be migrated to crate::store)
32// ---------------------------------------------------------------------------
33
34/// Opaque block identifier.
35#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
36pub struct BlockKey(pub u64);
37
38/// Storage tier for a block.
39#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
40#[repr(u8)]
41pub enum Tier {
42    /// In-memory / uncompressed (full f32).
43    Tier0 = 0,
44    /// Hot: 8-bit quantization.
45    Tier1 = 1,
46    /// Warm: 7-bit (or 5-bit aggressive) quantization.
47    Tier2 = 2,
48    /// Cold: 3-bit quantization.
49    Tier3 = 3,
50}
51
52/// Per-block metadata tracked by the tiered store.
53#[derive(Clone, Debug)]
54pub struct BlockMeta {
55    /// Exponentially-weighted moving average of access rate.
56    pub ema_rate: f32,
57    /// Sliding window bitmap of tick-level activity (1 bit per tick).
58    /// `popcount` gives the number of active ticks in the last 64.
59    pub access_window: u64,
60    /// Timestamp (tick) of the most recent access.
61    pub last_access: u64,
62    /// Cumulative access count.
63    pub access_count: u64,
64    /// Current storage tier.
65    pub current_tier: Tier,
66    /// Tick at which the block was last assigned to its current tier.
67    pub tier_since: u64,
68}
69
70impl BlockMeta {
71    /// Create metadata for a freshly inserted block.
72    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// ---------------------------------------------------------------------------
85// Configuration
86// ---------------------------------------------------------------------------
87
88/// Enhanced tier policy with EMA + popcount + recency scoring.
89///
90/// Score = w_ema * ema_rate + w_pop * (popcount(window)/64) + w_rec * exp(-dt/tau)
91#[derive(Clone, Debug)]
92pub struct TierConfig {
93    /// EMA smoothing factor (0..1). Higher = more responsive to recent access.
94    pub alpha: f32,
95    /// Recency decay time constant. Larger = slower decay.
96    pub tau: f32,
97    /// Weight for EMA access rate in score.
98    pub w_ema: f32,
99    /// Weight for popcount (recent tick activity) in score.
100    pub w_pop: f32,
101    /// Weight for recency (time since last access) in score.
102    pub w_rec: f32,
103    /// Score threshold for Tier1 (hot).
104    pub t1: f32,
105    /// Score threshold for Tier2 (warm).
106    pub t2: f32,
107    /// Score threshold for Tier3 (cold).
108    pub t3: f32,
109    /// Hysteresis margin. Upgrade needs score > threshold + hysteresis,
110    /// downgrade needs score < threshold - hysteresis.
111    pub hysteresis: f32,
112    /// Minimum ticks a block must stay in its current tier.
113    pub min_residency: u32,
114    /// Maximum delta chain length before compaction.
115    pub max_delta_chain: u8,
116    /// Block size in bytes.
117    pub block_bytes: usize,
118    /// Maximum bytes allowed in Tier1.
119    pub tier1_byte_cap: Option<usize>,
120    /// Use 5-bit instead of 7-bit when warm set exceeds this byte count.
121    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// ---------------------------------------------------------------------------
146// Exponential approximation
147// ---------------------------------------------------------------------------
148
149/// Fast approximation of `exp(-x)` for `x >= 0`.
150///
151/// Uses `1 / (1 + x)` as a cheap monotonically decreasing bound. Sufficient
152/// for relative ordering of scores; not suitable for absolute accuracy.
153///
154/// This function is available as a lightweight alternative to the LUT version
155/// for contexts where code size matters more than precision.
156#[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
165/// Number of entries in the exp(-x) look-up table.
166const LUT_SIZE: usize = 64;
167/// Domain upper bound for the LUT. Values beyond this clamp to ~0.
168const LUT_X_MAX: f32 = 8.0;
169
170/// Pre-computed LUT: `LUT[i] = exp(-i * LUT_X_MAX / LUT_SIZE)`.
171const 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        // const-context: approximate exp via Taylor(20) for good precision
176        let x = -(i as f64) * (LUT_X_MAX as f64) / (LUT_SIZE as f64);
177        // Horner form for exp(x) where x is negative
178        let v = const_exp(x);
179        table[i] = v as f32;
180        i += 1;
181    }
182    table
183};
184
185/// Compile-time exp(x) via truncated Taylor series (35 terms).
186///
187/// For negative `x`, computes `exp(|x|)` and inverts to avoid
188/// alternating-series cancellation.
189const fn const_exp(x: f64) -> f64 {
190    // Avoid catastrophic cancellation for negative x.
191    if x < 0.0 {
192        let pos = const_exp_pos(-x);
193        return 1.0 / pos;
194    }
195    const_exp_pos(x)
196}
197
198/// Taylor expansion of exp(x) for x >= 0. 35 terms give excellent
199/// precision up to x = 10 (term_35 = 10^35 / 35! ~ 2.8e-8).
200const 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/// LUT-based `exp(-x)` approximation with linear interpolation.
213///
214/// 64 entries covering `x` in `[0, 8]`, clamped beyond that range.
215/// Maximum relative error is approximately 0.2% within the LUT domain.
216#[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; // floor index
226    let frac = scaled - (idx as f32);
227    // Safety: idx < LUT_SIZE because x < LUT_X_MAX.
228    let lo = EXP_LUT[idx];
229    let hi = EXP_LUT[idx + 1];
230    lo + frac * (hi - lo)
231}
232
233// ---------------------------------------------------------------------------
234// Core scoring
235// ---------------------------------------------------------------------------
236
237/// Compute the composite score for a block.
238///
239/// ```text
240/// score = w_ema * ema_rate
241///       + w_pop * (popcount(access_window) / 64)
242///       + w_rec * exp(-dt / tau)
243/// ```
244///
245/// All component values are in `[0, 1]` (assuming `ema_rate` is clamped),
246/// so the maximum possible score equals `w_ema + w_pop + w_rec`.
247pub 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
260// ---------------------------------------------------------------------------
261// Tier selection with hysteresis
262// ---------------------------------------------------------------------------
263
264/// Choose the target tier for a block, applying hysteresis and residency.
265///
266/// Returns `None` if the block should stay in its current tier because:
267/// - The score falls within the hysteresis band, or
268/// - The block has not met the minimum residency requirement.
269pub fn choose_tier(config: &TierConfig, now: u64, meta: &BlockMeta) -> Option<Tier> {
270    // Enforce minimum residency.
271    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    // Determine raw target tier based on score thresholds.
280    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 // Below t3 still maps to coldest available tier.
288    };
289
290    if raw_target == current {
291        return None;
292    }
293
294    // Apply hysteresis: upgrades need score > threshold + h,
295    // downgrades need score < threshold - h.
296    let h = config.hysteresis;
297
298    let transition_allowed = if raw_target < current {
299        // Upgrade (lower ordinal = hotter tier). The score must exceed
300        // the *target* tier's lower threshold plus hysteresis.
301        let threshold = match raw_target {
302            Tier::Tier0 => return None, // Cannot promote to Tier0 via scoring.
303            Tier::Tier1 => config.t1,
304            Tier::Tier2 => config.t2,
305            Tier::Tier3 => config.t3,
306        };
307        score > threshold + h
308    } else {
309        // Downgrade (higher ordinal = colder tier). The score must fall
310        // below the *current* tier's lower threshold minus hysteresis.
311        let threshold = match current {
312            Tier::Tier0 => return None,
313            Tier::Tier1 => config.t1,
314            Tier::Tier2 => config.t2,
315            Tier::Tier3 => return None, // Already coldest.
316        };
317        score < threshold - h
318    };
319
320    if transition_allowed {
321        Some(raw_target)
322    } else {
323        None
324    }
325}
326
327// ---------------------------------------------------------------------------
328// Access recording
329// ---------------------------------------------------------------------------
330
331/// Record an access event on a block's metadata.
332///
333/// Updates:
334/// - `ema_rate` via exponential moving average (`alpha`).
335/// - `access_window` by shifting and setting the low bit.
336/// - `last_access` to `now`.
337/// - `access_count` incremented by one.
338pub fn touch(config: &TierConfig, now: u64, meta: &mut BlockMeta) {
339    // EMA update: ema = alpha * 1.0 + (1 - alpha) * ema
340    meta.ema_rate = config.alpha + (1.0 - config.alpha) * meta.ema_rate;
341
342    // Shift the window by the number of elapsed ticks and set bit 0.
343    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        // Same tick: just ensure bit 0 is set.
352        meta.access_window |= 1;
353    }
354
355    meta.last_access = now;
356    meta.access_count = meta.access_count.saturating_add(1);
357}
358
359// ---------------------------------------------------------------------------
360// Tick decay
361// ---------------------------------------------------------------------------
362
363/// Decay EMA for blocks not accessed in the current tick.
364///
365/// Should be called once per tick for every block that was *not* touched.
366/// Applies `ema_rate *= (1 - alpha)` and shifts the access window left by 1
367/// (inserting a 0 bit).
368pub fn tick_decay(config: &TierConfig, meta: &mut BlockMeta) {
369    meta.ema_rate *= 1.0 - config.alpha;
370    meta.access_window <<= 1;
371}
372
373// ---------------------------------------------------------------------------
374// Budgeted maintenance
375// ---------------------------------------------------------------------------
376
377/// Result of a maintenance tick operation.
378#[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/// Candidate for tier migration during maintenance.
388#[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
396/// Select blocks that need tier migration.
397///
398/// Returns candidates sorted by priority:
399/// - Upgrades first, ordered by highest score (hottest blocks promoted first).
400/// - Then downgrades, ordered by lowest score (coldest blocks demoted first).
401pub 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: highest score first.
427    upgrades.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(core::cmp::Ordering::Equal));
428    // Downgrades: lowest score first.
429    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// ---------------------------------------------------------------------------
436// Batch scoring
437// ---------------------------------------------------------------------------
438
439/// Result of scoring and partitioning blocks into tier buckets.
440#[derive(Clone, Debug)]
441pub struct ScoredPartition {
442    /// Indices of blocks classified as hot (Tier1).
443    pub hot: Vec<usize>,
444    /// Indices of blocks classified as warm (Tier2).
445    pub warm: Vec<usize>,
446    /// Indices of blocks classified as cold (Tier3).
447    pub cold: Vec<usize>,
448    /// Indices of blocks below eviction threshold.
449    pub evict: Vec<usize>,
450    /// Computed scores, parallel to input slice.
451    pub scores: Vec<f32>,
452}
453
454/// Compute scores for many blocks at once.
455///
456/// Returns a `Vec<f32>` parallel to `metas`, where each entry is
457/// `compute_score(config, now, &metas[i])`.
458pub 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
462/// Compute tier decisions for many blocks at once.
463///
464/// Returns a `Vec<Option<Tier>>` parallel to `metas`, where each entry is
465/// `choose_tier(config, now, &metas[i])`.
466pub 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
470/// Score blocks and partition into hot/warm/cold/evict buckets based on raw
471/// score thresholds.
472///
473/// Unlike [`choose_tier`], this function uses the *raw* thresholds (`t1`,
474/// `t2`, `t3`) without hysteresis or residency checks, making it suitable
475/// for bulk classification and capacity planning.
476pub 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
496/// Find the `k` blocks with the lowest scores (useful for eviction).
497///
498/// Returns up to `k` `(index, score)` pairs sorted in ascending score order.
499/// Uses a partial sort (`select_nth_unstable_by`) for efficiency when
500/// `k << metas.len()`.
501pub 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    // Partial sort: we only need the k smallest
505    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
513// ---------------------------------------------------------------------------
514// Quantization bit-width selection
515// ---------------------------------------------------------------------------
516
517/// Get the quantization bit width for a tier.
518///
519/// | Tier  | Bits | Notes |
520/// |-------|------|-------|
521/// | Tier0 | 0    | Uncompressed (f32) |
522/// | Tier1 | 8    | Hot |
523/// | Tier2 | 7    | Warm (5 if `warm_bytes > warm_aggressive_threshold`) |
524/// | Tier3 | 3    | Cold |
525pub 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// ---------------------------------------------------------------------------
542// Tests
543// ---------------------------------------------------------------------------
544
545#[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    // -----------------------------------------------------------------------
571    // 1. Score computation with known inputs
572    // -----------------------------------------------------------------------
573
574    #[test]
575    fn score_all_components_at_max() {
576        let cfg = default_config();
577        // ema_rate = 1.0, all 64 bits set, last_access == now
578        let meta = make_meta(1.0, u64::MAX, 100, Tier::Tier1, 0);
579        let score = compute_score(&cfg, 100, &meta);
580        // Expected: 0.4*1.0 + 0.3*(64/64) + 0.3*exp(0) = 0.4 + 0.3 + 0.3 = 1.0
581        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        // ema_rate = 0, no window bits, access far in the past
588        let meta = make_meta(0.0, 0, 0, Tier::Tier3, 0);
589        let score = compute_score(&cfg, 10_000, &meta);
590        // EMA = 0, pop = 0, recency ~ exp(-100) ~ 0
591        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        // 32 of 64 bits set
616        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    // -----------------------------------------------------------------------
623    // 2. Fast exp approximation accuracy
624    // -----------------------------------------------------------------------
625
626    #[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        // Negative input should clamp to 1.0
646        assert!((fast_exp_neg(-5.0) - 1.0).abs() < 1e-6);
647    }
648
649    #[test]
650    fn fast_exp_neg_vs_stdlib() {
651        // 1/(1+x) should always be >= exp(-x) for x >= 0 (it is an upper bound).
652        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    // -----------------------------------------------------------------------
664    // 3. LUT exp accuracy
665    // -----------------------------------------------------------------------
666
667    #[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        // Check accuracy across the LUT domain.
675        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        // x >= 8.0 should return the last LUT entry (near zero).
694        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    // -----------------------------------------------------------------------
711    // 4. Tier selection with and without hysteresis
712    // -----------------------------------------------------------------------
713
714    #[test]
715    fn tier_selection_clear_hot() {
716        let cfg = default_config();
717        // Score ~ 1.0, clearly above t1(0.7) + hysteresis(0.05)
718        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        // Score ~ 0, clearly below t2(0.3) - hysteresis(0.05)
727        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        // Score just barely above t1 but within hysteresis band.
735        let cfg = TierConfig {
736            hysteresis: 0.10,
737            ..default_config()
738        };
739        // Craft a score that is above t1(0.7) but below t1+hysteresis(0.8).
740        // ema=0.75, window=all-set, last_access=now
741        // score = 0.4*0.75 + 0.3*1.0 + 0.3*1.0 = 0.3 + 0.3 + 0.3 = 0.9
742        // Actually that is above 0.8, so let us reduce ema.
743        // ema=0.5: score = 0.4*0.5 + 0.3 + 0.3 = 0.2 + 0.3 + 0.3 = 0.8
744        // Need score in (0.7, 0.8): ema=0.25 -> 0.1+0.3+0.3=0.7 exactly, not enough.
745        // ema=0.4: 0.16 + 0.3 + 0.3 = 0.76. This is >0.7 but <0.8. Good.
746        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        // Hysteresis should prevent the upgrade.
752        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        // Block in Tier1 with score just below t1(0.7) but above t1-hysteresis(0.6).
762        // ema=0.6: 0.4*0.6 + 0.3*1 + 0.3*1 = 0.24+0.3+0.3=0.84 -- too high
763        // Need score in (0.6, 0.7). Set some bits off and add time gap.
764        // ema=0.5, window=32bits, dt=10, tau=100: rec=exp(-0.1)~0.905
765        // score = 0.4*0.5 + 0.3*(32/64) + 0.3*0.905 = 0.2+0.15+0.2715 = 0.6215
766        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    // -----------------------------------------------------------------------
779    // 5. Touch updates access stats correctly
780    // -----------------------------------------------------------------------
781
782    #[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        // ema = 0.3 * 1.0 + 0.7 * 0.0 = 0.3
800        assert!((meta.ema_rate - 0.3).abs() < 1e-6);
801        touch(&cfg, 2, &mut meta);
802        // ema = 0.3 + 0.7 * 0.3 = 0.3 + 0.21 = 0.51
803        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        // Elapsed 2: shift left 2, set bit 0 -> 0b100 | 1 = 0b101
815        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        // Same tick: just OR in bit 0 -> 0b1011
825        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        // Gap >= 64: window reset to 1
835        assert_eq!(meta.access_window, 1);
836    }
837
838    // -----------------------------------------------------------------------
839    // 6. Min residency enforcement
840    // -----------------------------------------------------------------------
841
842    #[test]
843    fn min_residency_blocks_migration() {
844        let cfg = TierConfig {
845            min_residency: 10,
846            ..default_config()
847        };
848        // Block assigned to Tier3 at tick 95, now is 100 (5 ticks < 10).
849        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        // Block assigned to Tier3 at tick 90, now is 100 (10 ticks >= 10).
861        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    // -----------------------------------------------------------------------
867    // 7. Candidate selection ordering
868    // -----------------------------------------------------------------------
869
870    #[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        // First candidate should be the upgrade (key=2, target=Tier1).
885        assert_eq!(candidates[0].key, BlockKey(2));
886        assert_eq!(candidates[0].target_tier, Tier::Tier1);
887        // Second candidate should be the downgrade (key=1, target=Tier3).
888        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        // Block 2 has higher ema_rate, so higher score, should come first.
906        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        // Block already in correct tier with score matching.
915        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        // May or may not have candidates depending on exact score; just verify no panic.
919        let _ = candidates;
920    }
921
922    // -----------------------------------------------------------------------
923    // 8. Bits selection for each tier
924    // -----------------------------------------------------------------------
925
926    #[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    // -----------------------------------------------------------------------
947    // 9. Warm aggressive mode (5-bit when over threshold)
948    // -----------------------------------------------------------------------
949
950    #[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); // at threshold, not over
958        assert_eq!(bits_for_tier(&cfg, Tier::Tier2, 1025), 5);
959    }
960
961    // -----------------------------------------------------------------------
962    // 10. Edge cases
963    // -----------------------------------------------------------------------
964
965    #[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        // ema=0, pop=0, dt=0 -> rec=exp(0)=1 -> score = 0.3
971        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        // Should not panic; dt=1 -> recency ~ exp(-1/100) ~ 0.99
980        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    // -----------------------------------------------------------------------
1049    // 11. Batch scoring
1050    // -----------------------------------------------------------------------
1051
1052    #[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),   // hot
1086            make_meta(0.5, 0x0000_0000_FFFF_FFFF, 90, Tier::Tier2, 0),  // warm
1087            make_meta(0.0, 0, 0, Tier::Tier3, 0),             // cold/evict
1088        ];
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        // The coldest should be index 1 (score near 0)
1105        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}