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
99/// Estimated reuse count for a conversation. Returns the global warm_reuse
100/// count as a rough proxy — individual per-conversation tracking would
101/// require extending PrefixState. Global warm_reuses / total_anchored gives
102/// the average reuse rate across all conversations.
103#[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; // conservative default: assume 5 reuses
112    }
113    // Average reuse: warm / (total - warm) capped at u32
114    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
138/// Attribute this request's cache outcome and record it, updating the
139/// conversation's last-seen prefix baseline for the next turn. Only anchored
140/// turns (`cached > 0`) are attributable; an unanchored turn returns `None`
141/// (the cache-aligner telemetry #940 covers "client never anchors"). The caller
142/// owns the opt-in gate — this only runs when `proxy.cache_policy` is enabled.
143pub 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/// Point-in-time view of the miss-attribution counters for `/status`.
172#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
173pub struct CacheAttribution {
174    /// First-sighting anchored turns (baseline only, not a hit or a miss).
175    pub cold_starts: u64,
176    /// Turns whose prefix was stable and within TTL — the cache should hit.
177    pub warm_reuses: u64,
178    /// Misses caused by an expired entry on an otherwise-stable prefix.
179    pub ttl_lapses: u64,
180    /// Misses caused by a changed cacheable prefix (a mutated/edited prefix).
181    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        // No prior turn → cold start.
202        assert_eq!(classify(None, 1, 100, 300), CacheOutcome::ColdStart);
203        // Same prefix, within TTL → warm reuse (should hit).
204        assert_eq!(
205            classify(Some((1, 100)), 1, 350, 300),
206            CacheOutcome::WarmReuse
207        );
208        // Same prefix, idle past TTL → ttl lapse.
209        assert_eq!(
210            classify(Some((1, 100)), 1, 500, 300),
211            CacheOutcome::TtlLapse
212        );
213        // Different prefix → prefix change, regardless of timing.
214        assert_eq!(
215            classify(Some((1, 100)), 2, 110, 300),
216            CacheOutcome::PrefixChange
217        );
218        // Different prefix wins even past TTL (the mutation is the root cause).
219        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        // cached == 0: nothing anchored to attribute.
238        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        // Immediately again (idle ~0, prefix identical) → warm reuse.
246        assert_eq!(record_request(&msgs, 1), Some(CacheOutcome::WarmReuse));
247    }
248
249    #[test]
250    fn prefix_change_detected_with_stable_head() {
251        // Stable head (conversation key), but the cached prefix spans two messages
252        // and the second one changes — a true mid-prefix mutation.
253        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}