Skip to main content

lean_ctx/core/
cache_diagnostics.rs

1//! Provider cache diagnostics integration (#1311).
2//!
3//! Monitor provider-side prompt cache hit rates and alert on regressions.
4//! Anthropic offers 90% cost reduction on cache hits; a drop in hit rate
5//! is a cost incident. This module provides the monitoring infrastructure.
6
7use std::sync::atomic::{AtomicU64, Ordering};
8
9static CACHE_HITS: AtomicU64 = AtomicU64::new(0);
10static CACHE_MISSES: AtomicU64 = AtomicU64::new(0);
11static CACHE_READ_TOKENS: AtomicU64 = AtomicU64::new(0);
12
13/// Record a cache hit from provider response headers.
14pub fn record_hit(read_tokens: u64) {
15    CACHE_HITS.fetch_add(1, Ordering::Relaxed);
16    CACHE_READ_TOKENS.fetch_add(read_tokens, Ordering::Relaxed);
17}
18
19/// Record a cache miss.
20pub fn record_miss() {
21    CACHE_MISSES.fetch_add(1, Ordering::Relaxed);
22}
23
24/// Current cache hit rate (0.0–1.0), or None if no samples yet.
25pub fn hit_rate() -> Option<f64> {
26    let hits = CACHE_HITS.load(Ordering::Relaxed);
27    let misses = CACHE_MISSES.load(Ordering::Relaxed);
28    let total = hits + misses;
29    if total == 0 {
30        return None;
31    }
32    Some(hits as f64 / total as f64)
33}
34
35/// Cache diagnostic snapshot.
36#[derive(Debug, Clone, PartialEq)]
37pub struct CacheDiagnostics {
38    pub hits: u64,
39    pub misses: u64,
40    pub hit_rate: Option<f64>,
41    pub cache_read_tokens: u64,
42    pub estimated_savings_usd: f64,
43}
44
45impl CacheDiagnostics {
46    /// Take a snapshot of current cache diagnostics.
47    pub fn snapshot() -> Self {
48        let hits = CACHE_HITS.load(Ordering::Relaxed);
49        let misses = CACHE_MISSES.load(Ordering::Relaxed);
50        let read_tokens = CACHE_READ_TOKENS.load(Ordering::Relaxed);
51        let total = hits + misses;
52        let rate = if total > 0 {
53            Some(hits as f64 / total as f64)
54        } else {
55            None
56        };
57
58        // Anthropic: cache read = $0.30/Mtok, fresh input = $3.00/Mtok for Sonnet
59        let savings_per_token = (3.00 - 0.30) / 1_000_000.0;
60        let estimated_savings_usd = read_tokens as f64 * savings_per_token;
61
62        Self {
63            hits,
64            misses,
65            hit_rate: rate,
66            cache_read_tokens: read_tokens,
67            estimated_savings_usd,
68        }
69    }
70
71    /// Check if hit rate is below the alert threshold.
72    pub fn needs_alert(&self, threshold: f64) -> bool {
73        self.hit_rate.is_some_and(|rate| {
74            let total = self.hits + self.misses;
75            total >= 10 && rate < threshold
76        })
77    }
78}
79
80/// Alert severity for cache regressions.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum CacheAlertSeverity {
83    Warning,
84    Critical,
85}
86
87/// Generate a cache alert if hit rate is below thresholds.
88pub fn check_alert() -> Option<(CacheAlertSeverity, String)> {
89    let diag = CacheDiagnostics::snapshot();
90    if let Some(rate) = diag.hit_rate {
91        let total = diag.hits + diag.misses;
92        if total < 10 {
93            return None;
94        }
95        if rate < 0.50 {
96            return Some((
97                CacheAlertSeverity::Critical,
98                format!(
99                    "Provider cache hit rate critically low: {:.0}% ({} hits / {} total)",
100                    rate * 100.0,
101                    diag.hits,
102                    total
103                ),
104            ));
105        }
106        if rate < 0.80 {
107            return Some((
108                CacheAlertSeverity::Warning,
109                format!(
110                    "Provider cache hit rate below target: {:.0}% ({} hits / {} total)",
111                    rate * 100.0,
112                    diag.hits,
113                    total
114                ),
115            ));
116        }
117    }
118    None
119}
120
121/// Reset counters (for testing).
122pub fn reset() {
123    CACHE_HITS.store(0, Ordering::Relaxed);
124    CACHE_MISSES.store(0, Ordering::Relaxed);
125    CACHE_READ_TOKENS.store(0, Ordering::Relaxed);
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    fn setup() {
133        reset();
134    }
135
136    #[test]
137    fn no_data_returns_none() {
138        setup();
139        assert_eq!(hit_rate(), None);
140    }
141
142    #[test]
143    fn hit_rate_calculation() {
144        setup();
145        for _ in 0..8 {
146            record_hit(1000);
147        }
148        for _ in 0..2 {
149            record_miss();
150        }
151        let rate = hit_rate().unwrap();
152        assert!((rate - 0.8).abs() < 0.01);
153    }
154
155    #[test]
156    fn diagnostics_snapshot() {
157        setup();
158        record_hit(100_000);
159        record_miss();
160        let diag = CacheDiagnostics::snapshot();
161        assert_eq!(diag.hits, 1);
162        assert_eq!(diag.misses, 1);
163        assert_eq!(diag.cache_read_tokens, 100_000);
164        assert!(diag.estimated_savings_usd > 0.0);
165    }
166
167    #[test]
168    fn alert_below_threshold() {
169        setup();
170        for _ in 0..3 {
171            record_hit(1000);
172        }
173        for _ in 0..7 {
174            record_miss();
175        }
176        let diag = CacheDiagnostics::snapshot();
177        assert!(diag.needs_alert(0.80));
178    }
179
180    #[test]
181    fn no_alert_when_above_threshold() {
182        setup();
183        for _ in 0..9 {
184            record_hit(1000);
185        }
186        record_miss();
187        let diag = CacheDiagnostics::snapshot();
188        assert!(!diag.needs_alert(0.80));
189    }
190
191    #[test]
192    fn critical_alert_below_50() {
193        setup();
194        for _ in 0..3 {
195            record_hit(1000);
196        }
197        for _ in 0..7 {
198            record_miss();
199        }
200        let alert = check_alert();
201        assert!(alert.is_some());
202        let (severity, _) = alert.unwrap();
203        assert_eq!(severity, CacheAlertSeverity::Critical);
204    }
205}