1use std::collections::HashMap;
24use std::sync::atomic::{AtomicU64, Ordering};
25use std::sync::{Mutex, OnceLock};
26use std::time::{SystemTime, UNIX_EPOCH};
27
28use serde::{Deserialize, Serialize};
29use serde_json::Value;
30
31use super::cold_prefix;
32
33const MAX_TRACKED: usize = 4096;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum CacheOutcome {
41 ColdStart,
44 WarmReuse,
47 TtlLapse,
50 PrefixChange,
53}
54
55#[derive(Debug, Clone, Copy)]
58struct PrefixState {
59 prefix_hash: u64,
60 last_touch: u64,
61}
62
63static COLD_STARTS: AtomicU64 = AtomicU64::new(0);
64static WARM_REUSES: AtomicU64 = AtomicU64::new(0);
65static TTL_LAPSES: AtomicU64 = AtomicU64::new(0);
66static PREFIX_CHANGES: AtomicU64 = AtomicU64::new(0);
67
68fn store() -> &'static Mutex<HashMap<u64, PrefixState>> {
69 static STORE: OnceLock<Mutex<HashMap<u64, PrefixState>>> = OnceLock::new();
70 STORE.get_or_init(|| Mutex::new(HashMap::new()))
71}
72
73fn now_secs() -> u64 {
74 SystemTime::now()
75 .duration_since(UNIX_EPOCH)
76 .map_or(0, |d| d.as_secs())
77}
78
79#[must_use]
84pub fn classify(prev: Option<(u64, u64)>, curr_hash: u64, now: u64, ttl_secs: u64) -> CacheOutcome {
85 match prev {
86 None => CacheOutcome::ColdStart,
87 Some((prev_hash, last_touch)) => {
88 if prev_hash != curr_hash {
89 CacheOutcome::PrefixChange
90 } else if now.saturating_sub(last_touch) > ttl_secs {
91 CacheOutcome::TtlLapse
92 } else {
93 CacheOutcome::WarmReuse
94 }
95 }
96 }
97}
98
99#[must_use]
104pub fn estimated_reuse_rate() -> u32 {
105 let warm = WARM_REUSES.load(Ordering::Relaxed);
106 let total = warm
107 + COLD_STARTS.load(Ordering::Relaxed)
108 + TTL_LAPSES.load(Ordering::Relaxed)
109 + PREFIX_CHANGES.load(Ordering::Relaxed);
110 if total == 0 {
111 return 5; }
113 let non_warm = total.saturating_sub(warm).max(1);
115 (warm / non_warm).min(u32::MAX as u64) as u32
116}
117
118fn bump(outcome: CacheOutcome) {
119 let counter = match outcome {
120 CacheOutcome::ColdStart => &COLD_STARTS,
121 CacheOutcome::WarmReuse => &WARM_REUSES,
122 CacheOutcome::TtlLapse => &TTL_LAPSES,
123 CacheOutcome::PrefixChange => &PREFIX_CHANGES,
124 };
125 counter.fetch_add(1, Ordering::Relaxed);
126}
127
128fn evict_oldest(map: &mut HashMap<u64, PrefixState>) {
129 if let Some(oldest) = map
130 .iter()
131 .min_by_key(|(_, s)| s.last_touch)
132 .map(|(k, _)| *k)
133 {
134 map.remove(&oldest);
135 }
136}
137
138pub fn record_request(messages: &[Value], cached: usize) -> Option<CacheOutcome> {
144 let conv_key = cold_prefix::conversation_key(messages)?;
145 let curr_hash = cold_prefix::cached_prefix_hash(messages, cached)?;
146 let ttl = cold_prefix::resolved_ttl_secs(messages, cached).unwrap_or(0);
147 let now = now_secs();
148
149 let outcome = {
150 let mut map = store()
151 .lock()
152 .unwrap_or_else(std::sync::PoisonError::into_inner);
153 let prev = map.get(&conv_key).map(|s| (s.prefix_hash, s.last_touch));
154 let outcome = classify(prev, curr_hash, now, ttl);
155 map.insert(
156 conv_key,
157 PrefixState {
158 prefix_hash: curr_hash,
159 last_touch: now,
160 },
161 );
162 if map.len() > MAX_TRACKED {
163 evict_oldest(&mut map);
164 }
165 outcome
166 };
167 bump(outcome);
168 Some(outcome)
169}
170
171#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
173pub struct CacheAttribution {
174 pub cold_starts: u64,
176 pub warm_reuses: u64,
178 pub ttl_lapses: u64,
180 pub prefix_changes: u64,
182}
183
184#[must_use]
185pub fn snapshot() -> CacheAttribution {
186 CacheAttribution {
187 cold_starts: COLD_STARTS.load(Ordering::Relaxed),
188 warm_reuses: WARM_REUSES.load(Ordering::Relaxed),
189 ttl_lapses: TTL_LAPSES.load(Ordering::Relaxed),
190 prefix_changes: PREFIX_CHANGES.load(Ordering::Relaxed),
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197 use serde_json::json;
198
199 #[test]
200 fn classify_distinguishes_ttl_lapse_from_prefix_change() {
201 assert_eq!(classify(None, 1, 100, 300), CacheOutcome::ColdStart);
203 assert_eq!(
205 classify(Some((1, 100)), 1, 350, 300),
206 CacheOutcome::WarmReuse
207 );
208 assert_eq!(
210 classify(Some((1, 100)), 1, 500, 300),
211 CacheOutcome::TtlLapse
212 );
213 assert_eq!(
215 classify(Some((1, 100)), 2, 110, 300),
216 CacheOutcome::PrefixChange
217 );
218 assert_eq!(
220 classify(Some((1, 100)), 2, 9999, 300),
221 CacheOutcome::PrefixChange
222 );
223 }
224
225 fn anchored(first_text: &str) -> Vec<Value> {
226 vec![
227 json!({"role": "user", "content": [
228 {"type": "text", "text": first_text, "cache_control": {"type": "ephemeral"}}
229 ]}),
230 json!({"role": "assistant", "content": "ok"}),
231 ]
232 }
233
234 #[test]
235 fn unanchored_turn_is_not_attributed() {
236 let msgs = anchored("unanchored-attribution-test");
237 assert_eq!(record_request(&msgs, 0), None);
239 }
240
241 #[test]
242 fn first_anchored_turn_is_cold_start_then_warm() {
243 let msgs = anchored("cold-then-warm-attribution-test");
244 assert_eq!(record_request(&msgs, 1), Some(CacheOutcome::ColdStart));
245 assert_eq!(record_request(&msgs, 1), Some(CacheOutcome::WarmReuse));
247 }
248
249 #[test]
250 fn prefix_change_detected_with_stable_head() {
251 let head = json!({"role": "user", "content": [
254 {"type": "text", "text": "stable-head-attribution", "cache_control": {"type": "ephemeral"}}
255 ]});
256 let v1 = vec![
257 head.clone(),
258 json!({"role": "assistant", "content": "answer one"}),
259 ];
260 let v2 = vec![
261 head,
262 json!({"role": "assistant", "content": "answer two CHANGED"}),
263 ];
264
265 assert_eq!(record_request(&v1, 2), Some(CacheOutcome::ColdStart));
266 assert_eq!(record_request(&v2, 2), Some(CacheOutcome::PrefixChange));
267 }
268}