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| {
428        b.score
429            .partial_cmp(&a.score)
430            .unwrap_or(core::cmp::Ordering::Equal)
431    });
432    // Downgrades: lowest score first.
433    downgrades.sort_by(|a, b| {
434        a.score
435            .partial_cmp(&b.score)
436            .unwrap_or(core::cmp::Ordering::Equal)
437    });
438
439    upgrades.extend(downgrades);
440    upgrades
441}
442
443// ---------------------------------------------------------------------------
444// Batch scoring
445// ---------------------------------------------------------------------------
446
447/// Result of scoring and partitioning blocks into tier buckets.
448#[derive(Clone, Debug)]
449pub struct ScoredPartition {
450    /// Indices of blocks classified as hot (Tier1).
451    pub hot: Vec<usize>,
452    /// Indices of blocks classified as warm (Tier2).
453    pub warm: Vec<usize>,
454    /// Indices of blocks classified as cold (Tier3).
455    pub cold: Vec<usize>,
456    /// Indices of blocks below eviction threshold.
457    pub evict: Vec<usize>,
458    /// Computed scores, parallel to input slice.
459    pub scores: Vec<f32>,
460}
461
462/// Compute scores for many blocks at once.
463///
464/// Returns a `Vec<f32>` parallel to `metas`, where each entry is
465/// `compute_score(config, now, &metas[i])`.
466pub fn compute_scores_batch(config: &TierConfig, now: u64, metas: &[BlockMeta]) -> Vec<f32> {
467    metas
468        .iter()
469        .map(|m| compute_score(config, now, m))
470        .collect()
471}
472
473/// Compute tier decisions for many blocks at once.
474///
475/// Returns a `Vec<Option<Tier>>` parallel to `metas`, where each entry is
476/// `choose_tier(config, now, &metas[i])`.
477pub fn choose_tiers_batch(config: &TierConfig, now: u64, metas: &[BlockMeta]) -> Vec<Option<Tier>> {
478    metas.iter().map(|m| choose_tier(config, now, m)).collect()
479}
480
481/// Score blocks and partition into hot/warm/cold/evict buckets based on raw
482/// score thresholds.
483///
484/// Unlike [`choose_tier`], this function uses the *raw* thresholds (`t1`,
485/// `t2`, `t3`) without hysteresis or residency checks, making it suitable
486/// for bulk classification and capacity planning.
487pub fn score_and_partition(config: &TierConfig, now: u64, metas: &[BlockMeta]) -> ScoredPartition {
488    let scores = compute_scores_batch(config, now, metas);
489    let mut hot = Vec::new();
490    let mut warm = Vec::new();
491    let mut cold = Vec::new();
492    let mut evict = Vec::new();
493    for (i, &score) in scores.iter().enumerate() {
494        if score >= config.t1 {
495            hot.push(i);
496        } else if score >= config.t2 {
497            warm.push(i);
498        } else if score >= config.t3 {
499            cold.push(i);
500        } else {
501            evict.push(i);
502        }
503    }
504    ScoredPartition {
505        hot,
506        warm,
507        cold,
508        evict,
509        scores,
510    }
511}
512
513/// Find the `k` blocks with the lowest scores (useful for eviction).
514///
515/// Returns up to `k` `(index, score)` pairs sorted in ascending score order.
516/// Uses a partial sort (`select_nth_unstable_by`) for efficiency when
517/// `k << metas.len()`.
518pub fn top_k_coldest(
519    config: &TierConfig,
520    now: u64,
521    metas: &[BlockMeta],
522    k: usize,
523) -> Vec<(usize, f32)> {
524    let scores = compute_scores_batch(config, now, metas);
525    let mut indexed: Vec<(usize, f32)> = scores.into_iter().enumerate().collect();
526    // Partial sort: we only need the k smallest
527    if k < indexed.len() {
528        indexed.select_nth_unstable_by(k, |a, b| {
529            a.1.partial_cmp(&b.1).unwrap_or(core::cmp::Ordering::Equal)
530        });
531        indexed.truncate(k);
532    }
533    indexed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(core::cmp::Ordering::Equal));
534    indexed
535}
536
537// ---------------------------------------------------------------------------
538// Quantization bit-width selection
539// ---------------------------------------------------------------------------
540
541/// Get the quantization bit width for a tier.
542///
543/// | Tier  | Bits | Notes |
544/// |-------|------|-------|
545/// | Tier0 | 0    | Uncompressed (f32) |
546/// | Tier1 | 8    | Hot |
547/// | Tier2 | 7    | Warm (5 if `warm_bytes > warm_aggressive_threshold`) |
548/// | Tier3 | 3    | Cold |
549pub fn bits_for_tier(config: &TierConfig, tier: Tier, warm_bytes: usize) -> u8 {
550    match tier {
551        Tier::Tier0 => 0,
552        Tier::Tier1 => 8,
553        Tier::Tier2 => {
554            if let Some(threshold) = config.warm_aggressive_threshold {
555                if warm_bytes > threshold {
556                    return 5;
557                }
558            }
559            7
560        }
561        Tier::Tier3 => 3,
562    }
563}
564
565// ---------------------------------------------------------------------------
566// Tests
567// ---------------------------------------------------------------------------
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572
573    fn default_config() -> TierConfig {
574        TierConfig::default()
575    }
576
577    fn make_meta(
578        ema_rate: f32,
579        access_window: u64,
580        last_access: u64,
581        current_tier: Tier,
582        tier_since: u64,
583    ) -> BlockMeta {
584        BlockMeta {
585            ema_rate,
586            access_window,
587            last_access,
588            access_count: 0,
589            current_tier,
590            tier_since,
591        }
592    }
593
594    // -----------------------------------------------------------------------
595    // 1. Score computation with known inputs
596    // -----------------------------------------------------------------------
597
598    #[test]
599    fn score_all_components_at_max() {
600        let cfg = default_config();
601        // ema_rate = 1.0, all 64 bits set, last_access == now
602        let meta = make_meta(1.0, u64::MAX, 100, Tier::Tier1, 0);
603        let score = compute_score(&cfg, 100, &meta);
604        // Expected: 0.4*1.0 + 0.3*(64/64) + 0.3*exp(0) = 0.4 + 0.3 + 0.3 = 1.0
605        assert!((score - 1.0).abs() < 1e-4, "score={score}");
606    }
607
608    #[test]
609    fn score_all_components_at_zero() {
610        let cfg = default_config();
611        // ema_rate = 0, no window bits, access far in the past
612        let meta = make_meta(0.0, 0, 0, Tier::Tier3, 0);
613        let score = compute_score(&cfg, 10_000, &meta);
614        // EMA = 0, pop = 0, recency ~ exp(-100) ~ 0
615        assert!(score < 0.01, "score={score}");
616    }
617
618    #[test]
619    fn score_only_ema_contributes() {
620        let cfg = TierConfig {
621            w_ema: 1.0,
622            w_pop: 0.0,
623            w_rec: 0.0,
624            ..default_config()
625        };
626        let meta = make_meta(0.75, 0, 0, Tier::Tier2, 0);
627        let score = compute_score(&cfg, 1000, &meta);
628        assert!((score - 0.75).abs() < 1e-6, "score={score}");
629    }
630
631    #[test]
632    fn score_only_popcount_contributes() {
633        let cfg = TierConfig {
634            w_ema: 0.0,
635            w_pop: 1.0,
636            w_rec: 0.0,
637            ..default_config()
638        };
639        // 32 of 64 bits set
640        let meta = make_meta(0.0, 0x0000_FFFF_FFFF_0000, 0, Tier::Tier2, 0);
641        let pop = 0x0000_FFFF_FFFF_0000u64.count_ones() as f32 / 64.0;
642        let score = compute_score(&cfg, 1000, &meta);
643        assert!(
644            (score - pop).abs() < 1e-6,
645            "score={score}, expected pop={pop}"
646        );
647    }
648
649    // -----------------------------------------------------------------------
650    // 2. Fast exp approximation accuracy
651    // -----------------------------------------------------------------------
652
653    #[test]
654    fn fast_exp_neg_monotonic() {
655        let mut prev = fast_exp_neg(0.0);
656        for i in 1..100 {
657            let x = i as f32 * 0.1;
658            let val = fast_exp_neg(x);
659            assert!(val <= prev, "not monotonic at x={x}");
660            assert!(val >= 0.0);
661            prev = val;
662        }
663    }
664
665    #[test]
666    fn fast_exp_neg_at_zero() {
667        assert!((fast_exp_neg(0.0) - 1.0).abs() < 1e-6);
668    }
669
670    #[test]
671    fn fast_exp_neg_negative_input() {
672        // Negative input should clamp to 1.0
673        assert!((fast_exp_neg(-5.0) - 1.0).abs() < 1e-6);
674    }
675
676    #[test]
677    fn fast_exp_neg_vs_stdlib() {
678        // 1/(1+x) should always be >= exp(-x) for x >= 0 (it is an upper bound).
679        for i in 0..50 {
680            let x = i as f32 * 0.2;
681            let approx = fast_exp_neg(x);
682            let exact = (-x).exp();
683            assert!(
684                approx >= exact - 1e-6,
685                "approx={approx} < exact={exact} at x={x}"
686            );
687        }
688    }
689
690    // -----------------------------------------------------------------------
691    // 3. LUT exp accuracy
692    // -----------------------------------------------------------------------
693
694    #[test]
695    fn lut_exp_at_zero() {
696        assert!((fast_exp_neg_lut(0.0) - 1.0).abs() < 1e-4);
697    }
698
699    #[test]
700    fn lut_exp_accuracy() {
701        // Check accuracy across the LUT domain.
702        for i in 0..80 {
703            let x = i as f32 * 0.1;
704            let approx = fast_exp_neg_lut(x);
705            let exact = (-x).exp();
706            let rel_err = if exact > 1e-10 {
707                (approx - exact).abs() / exact
708            } else {
709                (approx - exact).abs()
710            };
711            assert!(
712                rel_err < 0.01,
713                "x={x} approx={approx} exact={exact} rel_err={rel_err}"
714            );
715        }
716    }
717
718    #[test]
719    fn lut_exp_beyond_domain() {
720        // x >= 8.0 should return the last LUT entry (near zero).
721        let val = fast_exp_neg_lut(100.0);
722        assert!(val < 0.001, "val={val}");
723        assert!(val >= 0.0);
724    }
725
726    #[test]
727    fn lut_exp_monotonic() {
728        let mut prev = fast_exp_neg_lut(0.0);
729        for i in 1..160 {
730            let x = i as f32 * 0.05;
731            let val = fast_exp_neg_lut(x);
732            assert!(val <= prev + 1e-7, "not monotonic at x={x}");
733            prev = val;
734        }
735    }
736
737    // -----------------------------------------------------------------------
738    // 4. Tier selection with and without hysteresis
739    // -----------------------------------------------------------------------
740
741    #[test]
742    fn tier_selection_clear_hot() {
743        let cfg = default_config();
744        // Score ~ 1.0, clearly above t1(0.7) + hysteresis(0.05)
745        let meta = make_meta(1.0, u64::MAX, 100, Tier::Tier3, 0);
746        let target = choose_tier(&cfg, 100, &meta);
747        assert_eq!(target, Some(Tier::Tier1));
748    }
749
750    #[test]
751    fn tier_selection_clear_cold() {
752        let cfg = default_config();
753        // Score ~ 0, clearly below t2(0.3) - hysteresis(0.05)
754        let meta = make_meta(0.0, 0, 0, Tier::Tier1, 0);
755        let target = choose_tier(&cfg, 10_000, &meta);
756        assert_eq!(target, Some(Tier::Tier3));
757    }
758
759    #[test]
760    fn tier_selection_hysteresis_prevents_upgrade() {
761        // Score just barely above t1 but within hysteresis band.
762        let cfg = TierConfig {
763            hysteresis: 0.10,
764            ..default_config()
765        };
766        // Craft a score that is above t1(0.7) but below t1+hysteresis(0.8).
767        // ema=0.75, window=all-set, last_access=now
768        // score = 0.4*0.75 + 0.3*1.0 + 0.3*1.0 = 0.3 + 0.3 + 0.3 = 0.9
769        // Actually that is above 0.8, so let us reduce ema.
770        // ema=0.5: score = 0.4*0.5 + 0.3 + 0.3 = 0.2 + 0.3 + 0.3 = 0.8
771        // Need score in (0.7, 0.8): ema=0.25 -> 0.1+0.3+0.3=0.7 exactly, not enough.
772        // ema=0.4: 0.16 + 0.3 + 0.3 = 0.76. This is >0.7 but <0.8. Good.
773        let meta = make_meta(0.4, u64::MAX, 50, Tier::Tier2, 0);
774        let score = compute_score(&cfg, 50, &meta);
775        assert!(score > cfg.t1, "score={score}");
776        assert!(score < cfg.t1 + cfg.hysteresis, "score={score}");
777        let target = choose_tier(&cfg, 50, &meta);
778        // Hysteresis should prevent the upgrade.
779        assert_eq!(
780            target, None,
781            "score={score} should be within hysteresis band"
782        );
783    }
784
785    #[test]
786    fn tier_selection_hysteresis_prevents_downgrade() {
787        let cfg = TierConfig {
788            hysteresis: 0.10,
789            ..default_config()
790        };
791        // Block in Tier1 with score just below t1(0.7) but above t1-hysteresis(0.6).
792        // ema=0.6: 0.4*0.6 + 0.3*1 + 0.3*1 = 0.24+0.3+0.3=0.84 -- too high
793        // Need score in (0.6, 0.7). Set some bits off and add time gap.
794        // ema=0.5, window=32bits, dt=10, tau=100: rec=exp(-0.1)~0.905
795        // score = 0.4*0.5 + 0.3*(32/64) + 0.3*0.905 = 0.2+0.15+0.2715 = 0.6215
796        let meta = make_meta(0.5, 0x0000_0000_FFFF_FFFF, 90, Tier::Tier1, 0);
797        let score = compute_score(&cfg, 100, &meta);
798        assert!(
799            score < cfg.t1 && score > cfg.t1 - cfg.hysteresis,
800            "score={score}, expected in ({}, {})",
801            cfg.t1 - cfg.hysteresis,
802            cfg.t1
803        );
804        let target = choose_tier(&cfg, 100, &meta);
805        assert_eq!(
806            target, None,
807            "hysteresis should prevent downgrade, score={score}"
808        );
809    }
810
811    // -----------------------------------------------------------------------
812    // 5. Touch updates access stats correctly
813    // -----------------------------------------------------------------------
814
815    #[test]
816    fn touch_increments_count() {
817        let cfg = default_config();
818        let mut meta = BlockMeta::new(0);
819        assert_eq!(meta.access_count, 0);
820        touch(&cfg, 1, &mut meta);
821        assert_eq!(meta.access_count, 1);
822        touch(&cfg, 2, &mut meta);
823        assert_eq!(meta.access_count, 2);
824    }
825
826    #[test]
827    fn touch_updates_ema() {
828        let cfg = default_config();
829        let mut meta = BlockMeta::new(0);
830        assert_eq!(meta.ema_rate, 0.0);
831        touch(&cfg, 1, &mut meta);
832        // ema = 0.3 * 1.0 + 0.7 * 0.0 = 0.3
833        assert!((meta.ema_rate - 0.3).abs() < 1e-6);
834        touch(&cfg, 2, &mut meta);
835        // ema = 0.3 + 0.7 * 0.3 = 0.3 + 0.21 = 0.51
836        assert!((meta.ema_rate - 0.51).abs() < 1e-6);
837    }
838
839    #[test]
840    fn touch_updates_window() {
841        let cfg = default_config();
842        let mut meta = BlockMeta::new(0);
843        meta.access_window = 0;
844        touch(&cfg, 1, &mut meta);
845        assert_eq!(meta.access_window, 1);
846        touch(&cfg, 3, &mut meta);
847        // Elapsed 2: shift left 2, set bit 0 -> 0b100 | 1 = 0b101
848        assert_eq!(meta.access_window, 0b101);
849    }
850
851    #[test]
852    fn touch_same_tick() {
853        let cfg = default_config();
854        let mut meta = BlockMeta::new(5);
855        meta.access_window = 0b1010;
856        touch(&cfg, 5, &mut meta);
857        // Same tick: just OR in bit 0 -> 0b1011
858        assert_eq!(meta.access_window, 0b1011);
859    }
860
861    #[test]
862    fn touch_large_gap_clears_window() {
863        let cfg = default_config();
864        let mut meta = BlockMeta::new(0);
865        meta.access_window = u64::MAX;
866        touch(&cfg, 100, &mut meta);
867        // Gap >= 64: window reset to 1
868        assert_eq!(meta.access_window, 1);
869    }
870
871    // -----------------------------------------------------------------------
872    // 6. Min residency enforcement
873    // -----------------------------------------------------------------------
874
875    #[test]
876    fn min_residency_blocks_migration() {
877        let cfg = TierConfig {
878            min_residency: 10,
879            ..default_config()
880        };
881        // Block assigned to Tier3 at tick 95, now is 100 (5 ticks < 10).
882        let meta = make_meta(1.0, u64::MAX, 100, Tier::Tier3, 95);
883        let target = choose_tier(&cfg, 100, &meta);
884        assert_eq!(target, None);
885    }
886
887    #[test]
888    fn min_residency_allows_after_enough_ticks() {
889        let cfg = TierConfig {
890            min_residency: 10,
891            ..default_config()
892        };
893        // Block assigned to Tier3 at tick 90, now is 100 (10 ticks >= 10).
894        let meta = make_meta(1.0, u64::MAX, 100, Tier::Tier3, 90);
895        let target = choose_tier(&cfg, 100, &meta);
896        assert_eq!(target, Some(Tier::Tier1));
897    }
898
899    // -----------------------------------------------------------------------
900    // 7. Candidate selection ordering
901    // -----------------------------------------------------------------------
902
903    #[test]
904    fn candidates_upgrades_before_downgrades() {
905        let cfg = default_config();
906
907        let hot_meta = make_meta(1.0, u64::MAX, 50, Tier::Tier3, 0);
908        let cold_meta = make_meta(0.0, 0, 0, Tier::Tier1, 0);
909
910        let blocks = vec![(BlockKey(1), &cold_meta), (BlockKey(2), &hot_meta)];
911
912        let candidates = select_candidates(&cfg, 50, &blocks);
913        assert!(candidates.len() >= 2, "expected at least 2 candidates");
914        // First candidate should be the upgrade (key=2, target=Tier1).
915        assert_eq!(candidates[0].key, BlockKey(2));
916        assert_eq!(candidates[0].target_tier, Tier::Tier1);
917        // Second candidate should be the downgrade (key=1, target=Tier3).
918        assert_eq!(candidates[1].key, BlockKey(1));
919        assert_eq!(candidates[1].target_tier, Tier::Tier3);
920    }
921
922    #[test]
923    fn candidates_upgrades_sorted_by_highest_score() {
924        let cfg = default_config();
925
926        let meta_a = make_meta(0.9, u64::MAX, 50, Tier::Tier3, 0);
927        let meta_b = make_meta(1.0, u64::MAX, 50, Tier::Tier3, 0);
928
929        let blocks = vec![(BlockKey(1), &meta_a), (BlockKey(2), &meta_b)];
930
931        let candidates = select_candidates(&cfg, 50, &blocks);
932        // Block 2 has higher ema_rate, so higher score, should come first.
933        assert!(candidates.len() >= 2);
934        assert_eq!(candidates[0].key, BlockKey(2));
935        assert_eq!(candidates[1].key, BlockKey(1));
936    }
937
938    #[test]
939    fn candidates_empty_when_all_stable() {
940        let cfg = default_config();
941        // Block already in correct tier with score matching.
942        let meta = make_meta(0.5, 0x0000_0000_FFFF_FFFF, 50, Tier::Tier2, 0);
943        let blocks = vec![(BlockKey(1), &meta)];
944        let candidates = select_candidates(&cfg, 50, &blocks);
945        // May or may not have candidates depending on exact score; just verify no panic.
946        let _ = candidates;
947    }
948
949    // -----------------------------------------------------------------------
950    // 8. Bits selection for each tier
951    // -----------------------------------------------------------------------
952
953    #[test]
954    fn bits_tier0() {
955        assert_eq!(bits_for_tier(&default_config(), Tier::Tier0, 0), 0);
956    }
957
958    #[test]
959    fn bits_tier1() {
960        assert_eq!(bits_for_tier(&default_config(), Tier::Tier1, 0), 8);
961    }
962
963    #[test]
964    fn bits_tier2_normal() {
965        assert_eq!(bits_for_tier(&default_config(), Tier::Tier2, 0), 7);
966    }
967
968    #[test]
969    fn bits_tier3() {
970        assert_eq!(bits_for_tier(&default_config(), Tier::Tier3, 0), 3);
971    }
972
973    // -----------------------------------------------------------------------
974    // 9. Warm aggressive mode (5-bit when over threshold)
975    // -----------------------------------------------------------------------
976
977    #[test]
978    fn bits_tier2_aggressive() {
979        let cfg = TierConfig {
980            warm_aggressive_threshold: Some(1024),
981            ..default_config()
982        };
983        assert_eq!(bits_for_tier(&cfg, Tier::Tier2, 512), 7);
984        assert_eq!(bits_for_tier(&cfg, Tier::Tier2, 1024), 7); // at threshold, not over
985        assert_eq!(bits_for_tier(&cfg, Tier::Tier2, 1025), 5);
986    }
987
988    // -----------------------------------------------------------------------
989    // 10. Edge cases
990    // -----------------------------------------------------------------------
991
992    #[test]
993    fn edge_zero_access_count() {
994        let cfg = default_config();
995        let meta = BlockMeta::new(0);
996        let score = compute_score(&cfg, 0, &meta);
997        // ema=0, pop=0, dt=0 -> rec=exp(0)=1 -> score = 0.3
998        assert!((score - cfg.w_rec).abs() < 1e-4, "score={score}");
999    }
1000
1001    #[test]
1002    fn edge_max_timestamp() {
1003        let cfg = default_config();
1004        let meta = make_meta(0.5, 0xAAAA_AAAA_AAAA_AAAA, u64::MAX - 1, Tier::Tier2, 0);
1005        let score = compute_score(&cfg, u64::MAX, &meta);
1006        // Should not panic; dt=1 -> recency ~ exp(-1/100) ~ 0.99
1007        assert!(score.is_finite(), "score={score}");
1008    }
1009
1010    #[test]
1011    fn edge_touch_at_u64_max() {
1012        let cfg = default_config();
1013        let mut meta = BlockMeta::new(u64::MAX - 1);
1014        touch(&cfg, u64::MAX, &mut meta);
1015        assert_eq!(meta.last_access, u64::MAX);
1016        assert_eq!(meta.access_count, 1);
1017    }
1018
1019    #[test]
1020    fn edge_access_count_saturates() {
1021        let cfg = default_config();
1022        let mut meta = BlockMeta::new(0);
1023        meta.access_count = u64::MAX;
1024        touch(&cfg, 1, &mut meta);
1025        assert_eq!(meta.access_count, u64::MAX);
1026    }
1027
1028    #[test]
1029    fn tick_decay_reduces_ema() {
1030        let cfg = default_config();
1031        let mut meta = BlockMeta::new(0);
1032        meta.ema_rate = 1.0;
1033        meta.access_window = 0b1111;
1034        tick_decay(&cfg, &mut meta);
1035        assert!((meta.ema_rate - 0.7).abs() < 1e-6, "ema={}", meta.ema_rate);
1036        assert_eq!(meta.access_window, 0b1111_0);
1037    }
1038
1039    #[test]
1040    fn tick_decay_converges_to_zero() {
1041        let cfg = default_config();
1042        let mut meta = BlockMeta::new(0);
1043        meta.ema_rate = 1.0;
1044        for _ in 0..200 {
1045            tick_decay(&cfg, &mut meta);
1046        }
1047        assert!(meta.ema_rate < 1e-10, "ema={}", meta.ema_rate);
1048    }
1049
1050    #[test]
1051    fn tier_config_default_weights_sum_to_one() {
1052        let cfg = default_config();
1053        let sum = cfg.w_ema + cfg.w_pop + cfg.w_rec;
1054        assert!((sum - 1.0).abs() < 1e-6, "sum={sum}");
1055    }
1056
1057    #[test]
1058    fn block_meta_new_defaults() {
1059        let meta = BlockMeta::new(42);
1060        assert_eq!(meta.ema_rate, 0.0);
1061        assert_eq!(meta.access_window, 0);
1062        assert_eq!(meta.last_access, 42);
1063        assert_eq!(meta.access_count, 0);
1064        assert_eq!(meta.current_tier, Tier::Tier1);
1065        assert_eq!(meta.tier_since, 42);
1066    }
1067
1068    #[test]
1069    fn tier_ordering() {
1070        assert!(Tier::Tier0 < Tier::Tier1);
1071        assert!(Tier::Tier1 < Tier::Tier2);
1072        assert!(Tier::Tier2 < Tier::Tier3);
1073    }
1074
1075    // -----------------------------------------------------------------------
1076    // 11. Batch scoring
1077    // -----------------------------------------------------------------------
1078
1079    #[test]
1080    fn batch_scores_match_individual() {
1081        let cfg = default_config();
1082        let metas: Vec<BlockMeta> = vec![
1083            make_meta(1.0, u64::MAX, 100, Tier::Tier1, 0),
1084            make_meta(0.0, 0, 0, Tier::Tier3, 0),
1085            make_meta(0.5, 0x0000_0000_FFFF_FFFF, 50, Tier::Tier2, 0),
1086        ];
1087        let batch = compute_scores_batch(&cfg, 100, &metas);
1088        for (i, meta) in metas.iter().enumerate() {
1089            let single = compute_score(&cfg, 100, meta);
1090            assert!((batch[i] - single).abs() < 1e-6, "index {i}");
1091        }
1092    }
1093
1094    #[test]
1095    fn batch_tiers_match_individual() {
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        ];
1101        let batch = choose_tiers_batch(&cfg, 100, &metas);
1102        for (i, meta) in metas.iter().enumerate() {
1103            let single = choose_tier(&cfg, 100, meta);
1104            assert_eq!(batch[i], single, "index {i}");
1105        }
1106    }
1107
1108    #[test]
1109    fn score_and_partition_distributes_correctly() {
1110        let cfg = default_config();
1111        let metas: Vec<BlockMeta> = vec![
1112            make_meta(1.0, u64::MAX, 100, Tier::Tier1, 0), // hot
1113            make_meta(0.5, 0x0000_0000_FFFF_FFFF, 90, Tier::Tier2, 0), // warm
1114            make_meta(0.0, 0, 0, Tier::Tier3, 0),          // cold/evict
1115        ];
1116        let part = score_and_partition(&cfg, 100, &metas);
1117        assert!(!part.hot.is_empty(), "should have hot blocks");
1118        assert_eq!(part.scores.len(), 3);
1119    }
1120
1121    #[test]
1122    fn top_k_coldest_returns_lowest() {
1123        let cfg = default_config();
1124        let metas: Vec<BlockMeta> = vec![
1125            make_meta(1.0, u64::MAX, 100, Tier::Tier1, 0),
1126            make_meta(0.0, 0, 0, Tier::Tier3, 0),
1127            make_meta(0.5, 0x0000_0000_FFFF_FFFF, 50, Tier::Tier2, 0),
1128        ];
1129        let coldest = top_k_coldest(&cfg, 100, &metas, 2);
1130        assert_eq!(coldest.len(), 2);
1131        // The coldest should be index 1 (score near 0)
1132        assert_eq!(coldest[0].0, 1);
1133        assert!(coldest[0].1 <= coldest[1].1);
1134    }
1135
1136    #[test]
1137    fn top_k_coldest_k_exceeds_len() {
1138        let cfg = default_config();
1139        let metas: Vec<BlockMeta> = vec![make_meta(1.0, u64::MAX, 100, Tier::Tier1, 0)];
1140        let coldest = top_k_coldest(&cfg, 100, &metas, 10);
1141        assert_eq!(coldest.len(), 1);
1142    }
1143
1144    #[test]
1145    fn batch_empty_input() {
1146        let cfg = default_config();
1147        let empty: Vec<BlockMeta> = vec![];
1148        assert!(compute_scores_batch(&cfg, 100, &empty).is_empty());
1149        assert!(choose_tiers_batch(&cfg, 100, &empty).is_empty());
1150        let part = score_and_partition(&cfg, 100, &empty);
1151        assert!(
1152            part.hot.is_empty()
1153                && part.warm.is_empty()
1154                && part.cold.is_empty()
1155                && part.evict.is_empty()
1156        );
1157        assert!(top_k_coldest(&cfg, 100, &empty, 5).is_empty());
1158    }
1159}