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
99fn bump(outcome: CacheOutcome) {
100 let counter = match outcome {
101 CacheOutcome::ColdStart => &COLD_STARTS,
102 CacheOutcome::WarmReuse => &WARM_REUSES,
103 CacheOutcome::TtlLapse => &TTL_LAPSES,
104 CacheOutcome::PrefixChange => &PREFIX_CHANGES,
105 };
106 counter.fetch_add(1, Ordering::Relaxed);
107}
108
109fn evict_oldest(map: &mut HashMap<u64, PrefixState>) {
110 if let Some(oldest) = map
111 .iter()
112 .min_by_key(|(_, s)| s.last_touch)
113 .map(|(k, _)| *k)
114 {
115 map.remove(&oldest);
116 }
117}
118
119pub fn record_request(messages: &[Value], cached: usize) -> Option<CacheOutcome> {
125 let conv_key = cold_prefix::conversation_key(messages)?;
126 let curr_hash = cold_prefix::cached_prefix_hash(messages, cached)?;
127 let ttl = cold_prefix::resolved_ttl_secs(messages, cached).unwrap_or(0);
128 let now = now_secs();
129
130 let outcome = {
131 let mut map = store()
132 .lock()
133 .unwrap_or_else(std::sync::PoisonError::into_inner);
134 let prev = map.get(&conv_key).map(|s| (s.prefix_hash, s.last_touch));
135 let outcome = classify(prev, curr_hash, now, ttl);
136 map.insert(
137 conv_key,
138 PrefixState {
139 prefix_hash: curr_hash,
140 last_touch: now,
141 },
142 );
143 if map.len() > MAX_TRACKED {
144 evict_oldest(&mut map);
145 }
146 outcome
147 };
148 bump(outcome);
149 Some(outcome)
150}
151
152#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
154pub struct CacheAttribution {
155 pub cold_starts: u64,
157 pub warm_reuses: u64,
159 pub ttl_lapses: u64,
161 pub prefix_changes: u64,
163}
164
165#[must_use]
166pub fn snapshot() -> CacheAttribution {
167 CacheAttribution {
168 cold_starts: COLD_STARTS.load(Ordering::Relaxed),
169 warm_reuses: WARM_REUSES.load(Ordering::Relaxed),
170 ttl_lapses: TTL_LAPSES.load(Ordering::Relaxed),
171 prefix_changes: PREFIX_CHANGES.load(Ordering::Relaxed),
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use serde_json::json;
179
180 #[test]
181 fn classify_distinguishes_ttl_lapse_from_prefix_change() {
182 assert_eq!(classify(None, 1, 100, 300), CacheOutcome::ColdStart);
184 assert_eq!(
186 classify(Some((1, 100)), 1, 350, 300),
187 CacheOutcome::WarmReuse
188 );
189 assert_eq!(
191 classify(Some((1, 100)), 1, 500, 300),
192 CacheOutcome::TtlLapse
193 );
194 assert_eq!(
196 classify(Some((1, 100)), 2, 110, 300),
197 CacheOutcome::PrefixChange
198 );
199 assert_eq!(
201 classify(Some((1, 100)), 2, 9999, 300),
202 CacheOutcome::PrefixChange
203 );
204 }
205
206 fn anchored(first_text: &str) -> Vec<Value> {
207 vec![
208 json!({"role": "user", "content": [
209 {"type": "text", "text": first_text, "cache_control": {"type": "ephemeral"}}
210 ]}),
211 json!({"role": "assistant", "content": "ok"}),
212 ]
213 }
214
215 #[test]
216 fn unanchored_turn_is_not_attributed() {
217 let msgs = anchored("unanchored-attribution-test");
218 assert_eq!(record_request(&msgs, 0), None);
220 }
221
222 #[test]
223 fn first_anchored_turn_is_cold_start_then_warm() {
224 let msgs = anchored("cold-then-warm-attribution-test");
225 assert_eq!(record_request(&msgs, 1), Some(CacheOutcome::ColdStart));
226 assert_eq!(record_request(&msgs, 1), Some(CacheOutcome::WarmReuse));
228 }
229
230 #[test]
231 fn prefix_change_detected_with_stable_head() {
232 let head = json!({"role": "user", "content": [
235 {"type": "text", "text": "stable-head-attribution", "cache_control": {"type": "ephemeral"}}
236 ]});
237 let v1 = vec![
238 head.clone(),
239 json!({"role": "assistant", "content": "answer one"}),
240 ];
241 let v2 = vec![
242 head,
243 json!({"role": "assistant", "content": "answer two CHANGED"}),
244 ];
245
246 assert_eq!(record_request(&v1, 2), Some(CacheOutcome::ColdStart));
247 assert_eq!(record_request(&v2, 2), Some(CacheOutcome::PrefixChange));
248 }
249}