lean_ctx_proxy/
cache_attribution.rs1use std::collections::HashMap;
25use std::sync::{Mutex, OnceLock};
26use std::time::{SystemTime, UNIX_EPOCH};
27
28use serde_json::Value;
29
30use super::cold_prefix;
31
32pub use lean_ctx::core::proxy_cache_attribution::{CacheAttribution, CacheOutcome, snapshot};
33
34const MAX_TRACKED: usize = 4096;
37
38#[derive(Debug, Clone, Copy)]
41struct PrefixState {
42 prefix_hash: u64,
43 last_touch: u64,
44}
45
46fn store() -> &'static Mutex<HashMap<u64, PrefixState>> {
47 static STORE: OnceLock<Mutex<HashMap<u64, PrefixState>>> = OnceLock::new();
48 STORE.get_or_init(|| Mutex::new(HashMap::new()))
49}
50
51fn now_secs() -> u64 {
52 SystemTime::now()
53 .duration_since(UNIX_EPOCH)
54 .map_or(0, |d| d.as_secs())
55}
56
57#[must_use]
62pub fn classify(prev: Option<(u64, u64)>, curr_hash: u64, now: u64, ttl_secs: u64) -> CacheOutcome {
63 match prev {
64 None => CacheOutcome::ColdStart,
65 Some((prev_hash, last_touch)) => {
66 if prev_hash != curr_hash {
67 CacheOutcome::PrefixChange
68 } else if now.saturating_sub(last_touch) > ttl_secs {
69 CacheOutcome::TtlLapse
70 } else {
71 CacheOutcome::WarmReuse
72 }
73 }
74 }
75}
76
77fn evict_oldest(map: &mut HashMap<u64, PrefixState>) {
78 if let Some(oldest) = map
79 .iter()
80 .min_by_key(|(_, s)| s.last_touch)
81 .map(|(k, _)| *k)
82 {
83 map.remove(&oldest);
84 }
85}
86
87pub fn record_request(messages: &[Value], cached: usize) -> Option<CacheOutcome> {
93 let conv_key = cold_prefix::conversation_key(messages)?;
94 let curr_hash = cold_prefix::cached_prefix_hash(messages, cached)?;
95 let ttl = cold_prefix::resolved_ttl_secs(messages, cached).unwrap_or(0);
96 let now = now_secs();
97
98 let outcome = {
99 let mut map = store()
100 .lock()
101 .unwrap_or_else(std::sync::PoisonError::into_inner);
102 let prev = map.get(&conv_key).map(|s| (s.prefix_hash, s.last_touch));
103 let outcome = classify(prev, curr_hash, now, ttl);
104 map.insert(
105 conv_key,
106 PrefixState {
107 prefix_hash: curr_hash,
108 last_touch: now,
109 },
110 );
111 if map.len() > MAX_TRACKED {
112 evict_oldest(&mut map);
113 }
114 outcome
115 };
116 lean_ctx::core::proxy_cache_attribution::record_outcome(outcome);
117 Some(outcome)
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use serde_json::json;
124
125 #[test]
126 fn classify_distinguishes_ttl_lapse_from_prefix_change() {
127 assert_eq!(classify(None, 1, 100, 300), CacheOutcome::ColdStart);
129 assert_eq!(
131 classify(Some((1, 100)), 1, 350, 300),
132 CacheOutcome::WarmReuse
133 );
134 assert_eq!(
136 classify(Some((1, 100)), 1, 500, 300),
137 CacheOutcome::TtlLapse
138 );
139 assert_eq!(
141 classify(Some((1, 100)), 2, 110, 300),
142 CacheOutcome::PrefixChange
143 );
144 assert_eq!(
146 classify(Some((1, 100)), 2, 9999, 300),
147 CacheOutcome::PrefixChange
148 );
149 }
150
151 fn anchored(first_text: &str) -> Vec<Value> {
152 vec![
153 json!({"role": "user", "content": [
154 {"type": "text", "text": first_text, "cache_control": {"type": "ephemeral"}}
155 ]}),
156 json!({"role": "assistant", "content": "ok"}),
157 ]
158 }
159
160 #[test]
161 fn unanchored_turn_is_not_attributed() {
162 let msgs = anchored("unanchored-attribution-test");
163 assert_eq!(record_request(&msgs, 0), None);
165 }
166
167 #[test]
168 fn first_anchored_turn_is_cold_start_then_warm() {
169 let msgs = anchored("cold-then-warm-attribution-test");
170 assert_eq!(record_request(&msgs, 1), Some(CacheOutcome::ColdStart));
171 assert_eq!(record_request(&msgs, 1), Some(CacheOutcome::WarmReuse));
173 }
174
175 #[test]
176 fn prefix_change_detected_with_stable_head() {
177 let head = json!({"role": "user", "content": [
180 {"type": "text", "text": "stable-head-attribution", "cache_control": {"type": "ephemeral"}}
181 ]});
182 let v1 = vec![
183 head.clone(),
184 json!({"role": "assistant", "content": "answer one"}),
185 ];
186 let v2 = vec![
187 head,
188 json!({"role": "assistant", "content": "answer two CHANGED"}),
189 ];
190
191 assert_eq!(record_request(&v1, 2), Some(CacheOutcome::ColdStart));
192 assert_eq!(record_request(&v2, 2), Some(CacheOutcome::PrefixChange));
193 }
194}