Skip to main content

lean_ctx/proxy/
cache_attribution.rs

1//! Prompt-cache miss attribution (#986, cache-economics telemetry).
2//!
3//! The proxy already keeps the client-cached prefix byte-stable (#448) and can
4//! re-seed a leaner one on a cold resume (#480). What it could not yet *measure*
5//! is **why** a turn fails to hit the provider prompt-cache — the single most
6//! actionable cache signal. There are only two causes, and they want opposite
7//! fixes:
8//!
9//! - **TTL lapse** — the cacheable prefix is byte-identical to last turn, but the
10//!   idle gap exceeded the provider's cache TTL, so the entry expired. The fix is
11//!   the cold-prefix repack (#480) / longer TTL, never a prefix change.
12//! - **Prefix change** — the cacheable prefix is *different* from last turn, so
13//!   the provider re-writes from the first changed byte regardless of timing. The
14//!   fix is to stop mutating the prefix (a moving system prompt, an edited earlier
15//!   turn, volatile fields — see the cache-aligner #940/#974).
16//!
17//! This module classifies every anchored turn (`cached > 0`) into one of four
18//! outcomes by comparing the `cached_prefix_hash` and idle time against the
19//! conversation's previous turn, and exposes cumulative gauges on `/status`. It
20//! is **measurement-only** — the request body is never touched — and gated behind
21//! the opt-in `proxy.cache_policy`, so a default proxy pays nothing.
22
23use 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
33/// Hard cap on tracked conversations so a long-lived proxy can't grow the
34/// last-prefix map without bound; the oldest entry is evicted past this.
35const MAX_TRACKED: usize = 4096;
36
37/// The cache outcome attributed to one anchored (`cached > 0`) request, by
38/// comparing its cacheable prefix + idle time against the previous turn.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum CacheOutcome {
41    /// First time this conversation's anchored prefix was seen — no prior turn to
42    /// compare, so nothing is attributable (counts only as a baseline).
43    ColdStart,
44    /// Prefix byte-identical to last turn and within the TTL — the provider cache
45    /// should hit. The healthy steady state.
46    WarmReuse,
47    /// Prefix byte-identical to last turn but idle past the TTL — the entry
48    /// expired; a miss caused by time, not a prefix change.
49    TtlLapse,
50    /// Prefix differs from last turn — the provider re-writes from the first
51    /// changed byte; a miss caused by a mutated prefix, regardless of timing.
52    PrefixChange,
53}
54
55/// Previous-turn record for one conversation: the cacheable-prefix hash and the
56/// Unix-seconds timestamp it was last seen.
57#[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/// Pure classification of a turn's cache outcome. `prev` is the conversation's
80/// previous `(prefix_hash, last_touch)`, `curr_hash` this turn's cacheable-prefix
81/// hash, `now`/`ttl_secs` the idle clock. Pure (no globals, no I/O) so the
82/// TTL-vs-prefix decision is unit-tested independently of the live store.
83#[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
119/// Attribute this request's cache outcome and record it, updating the
120/// conversation's last-seen prefix baseline for the next turn. Only anchored
121/// turns (`cached > 0`) are attributable; an unanchored turn returns `None`
122/// (the cache-aligner telemetry #940 covers "client never anchors"). The caller
123/// owns the opt-in gate — this only runs when `proxy.cache_policy` is enabled.
124pub 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/// Point-in-time view of the miss-attribution counters for `/status`.
153#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
154pub struct CacheAttribution {
155    /// First-sighting anchored turns (baseline only, not a hit or a miss).
156    pub cold_starts: u64,
157    /// Turns whose prefix was stable and within TTL — the cache should hit.
158    pub warm_reuses: u64,
159    /// Misses caused by an expired entry on an otherwise-stable prefix.
160    pub ttl_lapses: u64,
161    /// Misses caused by a changed cacheable prefix (a mutated/edited prefix).
162    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        // No prior turn → cold start.
183        assert_eq!(classify(None, 1, 100, 300), CacheOutcome::ColdStart);
184        // Same prefix, within TTL → warm reuse (should hit).
185        assert_eq!(
186            classify(Some((1, 100)), 1, 350, 300),
187            CacheOutcome::WarmReuse
188        );
189        // Same prefix, idle past TTL → ttl lapse.
190        assert_eq!(
191            classify(Some((1, 100)), 1, 500, 300),
192            CacheOutcome::TtlLapse
193        );
194        // Different prefix → prefix change, regardless of timing.
195        assert_eq!(
196            classify(Some((1, 100)), 2, 110, 300),
197            CacheOutcome::PrefixChange
198        );
199        // Different prefix wins even past TTL (the mutation is the root cause).
200        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        // cached == 0: nothing anchored to attribute.
219        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        // Immediately again (idle ~0, prefix identical) → warm reuse.
227        assert_eq!(record_request(&msgs, 1), Some(CacheOutcome::WarmReuse));
228    }
229
230    #[test]
231    fn prefix_change_detected_with_stable_head() {
232        // Stable head (conversation key), but the cached prefix spans two messages
233        // and the second one changes — a true mid-prefix mutation.
234        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}