Skip to main content

lean_ctx/proxy/
cold_prefix.rs

1//! Big-gap cold-prefix repack prediction (#480).
2//!
3//! The proxy is deliberately cache-safe: it never rewrites the client-cached
4//! prefix (`history_prune::cached_prefix_len`), so provider prompt caches keep
5//! hitting and cheap cache reads (~0.1x) never turn into full-price writes
6//! (~1.25x) — the #448 invariant.
7//!
8//! That protection has one blind spot. Provider prompt caches EXPIRE after a TTL
9//! of inactivity. After a long idle gap (the agent asked a question, the user
10//! replies hours later) the cached prefix is already gone; the provider will
11//! re-WRITE the whole prefix on the next request regardless. Staying in
12//! "never touch the cached prefix" mode then writes the *uncompressed* prefix at
13//! full price and re-seeds a fat cache for the rest of the session.
14//!
15//! This module makes a PRE-SEND prediction — purely from elapsed idle time vs
16//! the provider's cache TTL — of whether the prefix is already cold. The trigger
17//! must be a clock, not response feedback: hit/miss is only known *after* the
18//! request that already (re-)cached the prefix, and by the next request the
19//! cache is warm again, so feedback would bust the fresh cache.
20//!
21//! Safety is paramount because the cost of a wrong "cold" guess is asymmetric (a
22//! cache write is ~12x a cache read). We therefore:
23//!   * act only when the caller opted in (`repacks_cold_prefix()`),
24//!   * act only on a measured idle gap well past expiry (`TTL × SAFETY_MARGIN`,
25//!     with an absolute floor), skipping the ambiguous near-TTL zone entirely,
26//!   * never act without a prior touch (the first sighting only sets a baseline),
27//!   * and bias every ambiguity toward "warm" (do nothing).
28//!
29//! State persists across restarts (`{data_dir}/cold_prefix_touch.json`, atomic
30//! write, throttled) so an idle gap that straddles a daemon recycle is still
31//! detected — a stale on-disk timestamp is exactly what proves the gap and can
32//! only ever bias toward "warm" if lost (#499). A missing/corrupt file simply
33//! disables the optimization until a fresh baseline is recorded — a safe
34//! degradation that can never wrongly trigger.
35//!
36//! Once a conversation is judged cold and repacked, the decision is *sticky*:
37//! every later turn keeps applying the same deterministic prefix compression, so
38//! the warm follow-ups that resume active use hit the compressed prefix written
39//! at the cold turn instead of re-sending the uncompressed original and busting
40//! the freshly-seeded cache (#499). Deterministic re-compression is prefix-
41//! stable, so the latch stays cache-safe for the rest of the session.
42
43use std::collections::HashMap;
44use std::hash::{Hash, Hasher};
45use std::sync::atomic::{AtomicU64, Ordering};
46use std::sync::{Mutex, OnceLock};
47use std::time::{SystemTime, UNIX_EPOCH};
48
49use serde::{Deserialize, Serialize};
50use serde_json::Value;
51
52/// Multiplier applied to the resolved TTL before a prefix is declared cold. The
53/// provider cache is a sliding inactivity window, so `idle > TTL` already implies
54/// expiry; `× 2` keeps a safety buffer against clock skew and provider nuance.
55const SAFETY_MARGIN: u64 = 2;
56/// Absolute minimum idle (seconds) before any repack, regardless of a short
57/// per-request TTL — never repack on a gap under 10 minutes.
58const COLD_FLOOR_SECS: u64 = 600;
59/// Anthropic default cache TTL when a `cache_control` marker carries no explicit
60/// `ttl` (the API default is "5m").
61const DEFAULT_TTL_SECS: u64 = 300;
62/// Anthropic extended cache TTL (`"ttl":"1h"`).
63const HOUR_TTL_SECS: u64 = 3600;
64/// Hard cap on tracked conversations so a long-lived proxy can't grow the
65/// last-touch map without bound; the oldest entry is evicted past this.
66const MAX_TRACKED: usize = 4096;
67/// Minimum seconds between disk persists. The on-disk baseline only needs to be
68/// "fresh enough" to prove a multi-minute gap, so throttling keeps the hot path
69/// off the disk on every request without weakening the long-gap guarantee.
70const PERSIST_MIN_INTERVAL_SECS: u64 = 30;
71/// Cross-restart baseline store, in the shared data dir.
72const TOUCH_FILE: &str = "cold_prefix_touch.json";
73
74/// Per-conversation tracking state. `last_touch` is the Unix-seconds timestamp of
75/// the most recent request; `repacking` latches on once a cold gap triggered a
76/// repack, so subsequent turns stay cache-stable on the compressed prefix (#499).
77#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
78struct ConvState {
79    last_touch: u64,
80    repacking: bool,
81}
82
83fn store() -> &'static Mutex<HashMap<u64, ConvState>> {
84    static STORE: OnceLock<Mutex<HashMap<u64, ConvState>>> = OnceLock::new();
85    STORE.get_or_init(|| Mutex::new(HashMap::new()))
86}
87
88/// Wall-clock seconds of the last successful disk persist (throttle gate).
89fn last_persist() -> &'static AtomicU64 {
90    static LAST: AtomicU64 = AtomicU64::new(0);
91    &LAST
92}
93
94fn now_secs() -> u64 {
95    SystemTime::now()
96        .duration_since(UNIX_EPOCH)
97        .map_or(0, |d| d.as_secs())
98}
99
100fn hash_bytes(bytes: &[u8]) -> u64 {
101    let mut h = std::collections::hash_map::DefaultHasher::new();
102    bytes.hash(&mut h);
103    h.finish()
104}
105
106/// Stable per-conversation key: a hash of the first message with every
107/// `cache_control` marker stripped first. `messages[0]` is byte-stable across a
108/// conversation's turns *except* for its volatile cache breakpoint — clients move
109/// or retune the `cache_control` (`ephemeral`/`ttl`) marker as the prompt grows.
110/// Hashing the raw message would then change the key mid-conversation → a
111/// permanent "first sighting" that never repacks (#499). Stripping the marker
112/// keys on stable content only. Distinct conversations still differ (distinct
113/// opening turns); a collision can only make the recorded last-touch *more
114/// recent*, biasing toward "warm" — never a wrong "cold". `None` when there is no
115/// first message.
116pub(crate) fn conversation_key(messages: &[Value]) -> Option<u64> {
117    let mut first = messages.first()?.clone();
118    strip_cache_control(&mut first);
119    let bytes = serde_json::to_vec(&first).ok()?;
120    Some(hash_bytes(&bytes))
121}
122
123/// Content hash of the client-cached prefix `messages[0..cached]` with every
124/// volatile `cache_control` marker stripped — the stable identity of the prefix
125/// a provider would cache. Turn-to-turn equality means the cacheable prefix did
126/// not change; inequality means something (a rewrite, an edited earlier turn)
127/// busted it. `None` when `cached == 0` (nothing anchored to compare). Shared so
128/// [`crate::proxy::cache_attribution`] keys on the exact same stable bytes.
129pub(crate) fn cached_prefix_hash(messages: &[Value], cached: usize) -> Option<u64> {
130    let end = cached.min(messages.len());
131    if end == 0 {
132        return None;
133    }
134    let mut prefix: Vec<Value> = messages[..end].to_vec();
135    for msg in &mut prefix {
136        strip_cache_control(msg);
137    }
138    let bytes = serde_json::to_vec(&prefix).ok()?;
139    Some(hash_bytes(&bytes))
140}
141
142/// Recursively remove every `cache_control` field so a moving cache breakpoint
143/// can't change the conversation key. The marker always nests `type:ephemeral`
144/// and any `ttl` inside `cache_control`, so dropping that one field suffices.
145fn strip_cache_control(v: &mut Value) {
146    match v {
147        Value::Object(map) => {
148            map.remove("cache_control");
149            for val in map.values_mut() {
150                strip_cache_control(val);
151            }
152        }
153        Value::Array(arr) => {
154            for val in arr {
155                strip_cache_control(val);
156            }
157        }
158        _ => {}
159    }
160}
161
162fn parse_ttl_str(s: &str) -> Option<u64> {
163    match s.trim() {
164        "1h" => Some(HOUR_TTL_SECS),
165        "5m" => Some(DEFAULT_TTL_SECS),
166        _ => None,
167    }
168}
169
170/// Largest `cache_control.ttl` declared anywhere inside one message (message-,
171/// block-, or nested text-level), in seconds. `None` when no parseable ttl.
172fn max_ttl_in_message(msg: &Value) -> Option<u64> {
173    let mut best: Option<u64> = None;
174    collect_cc_ttl(msg, &mut best);
175    best
176}
177
178fn collect_cc_ttl(v: &Value, best: &mut Option<u64>) {
179    match v {
180        Value::Object(map) => {
181            if let Some(ttl) = map
182                .get("cache_control")
183                .and_then(|cc| cc.get("ttl"))
184                .and_then(Value::as_str)
185                .and_then(parse_ttl_str)
186            {
187                *best = Some(best.map_or(ttl, |b| b.max(ttl)));
188            }
189            for val in map.values() {
190                collect_cc_ttl(val, best);
191            }
192        }
193        Value::Array(arr) => {
194            for val in arr {
195                collect_cc_ttl(val, best);
196            }
197        }
198        _ => {}
199    }
200}
201
202/// Resolve the cache TTL (seconds) for the client-cached prefix `[0..cached)`.
203/// Returns the largest TTL any cached message requested, defaulting to the "5m"
204/// Anthropic default because a `cache_control` marker is present. `None` only
205/// when `cached == 0` (no marker) — in which case there is nothing to repack.
206pub(crate) fn resolved_ttl_secs(messages: &[Value], cached: usize) -> Option<u64> {
207    if cached == 0 {
208        return None;
209    }
210    let end = cached.min(messages.len());
211    let mut ttl = DEFAULT_TTL_SECS;
212    for msg in &messages[..end] {
213        if let Some(t) = max_ttl_in_message(msg) {
214            ttl = ttl.max(t);
215        }
216    }
217    Some(ttl)
218}
219
220fn evict_oldest(map: &mut HashMap<u64, ConvState>) {
221    if let Some(oldest_key) = map
222        .iter()
223        .min_by_key(|(_, s)| s.last_touch)
224        .map(|(k, _)| *k)
225    {
226        map.remove(&oldest_key);
227    }
228}
229
230/// Decide whether to repack the (predicted-cold) cached prefix for THIS request,
231/// recording this request as the conversation's latest touch.
232///
233/// Returns `true` when the conversation is already in the sticky repacking state
234/// (a prior turn went cold — keep the compressed prefix stable, #499) or when a
235/// fresh cold gap is detected: a client-cached prefix exists (`cached > 0`), a
236/// prior touch exists (so the idle gap is measurable), and the idle gap exceeds
237/// `TTL × SAFETY_MARGIN` and the absolute floor. The caller owns the opt-in gate;
238/// this is only ever called when the operator enabled it, so updating the
239/// last-touch baseline here is the intended side effect for the *next* request.
240pub fn repack_decision(messages: &[Value], cached: usize) -> bool {
241    let Some(key) = conversation_key(messages) else {
242        return false;
243    };
244    let now = now_secs();
245    let ttl = resolved_ttl_secs(messages, cached);
246
247    let (decision, changed) = {
248        let mut map = store()
249            .lock()
250            .unwrap_or_else(std::sync::PoisonError::into_inner);
251        let prev = map.get(&key).copied();
252        let was_first = prev.is_none();
253        let already_repacking = prev.is_some_and(|s| s.repacking);
254
255        // A fresh cold gap: a measurable idle past `TTL × margin` (and the floor)
256        // on a turn that actually carries a client-cached prefix.
257        let fresh_cold = match (prev, ttl) {
258            (Some(p), Some(t)) if cached > 0 => {
259                let idle = now.saturating_sub(p.last_touch);
260                idle > t.saturating_mul(SAFETY_MARGIN).max(COLD_FLOOR_SECS)
261            }
262            _ => false,
263        };
264
265        // Sticky latch: once cold→repacked, stay repacking. Deterministic re-
266        // compression keeps the prefix byte-stable so warm follow-ups hit the
267        // cache written at the cold turn instead of busting it (#499).
268        let repacking = already_repacking || fresh_cold;
269        map.insert(
270            key,
271            ConvState {
272                last_touch: now,
273                repacking,
274            },
275        );
276        if map.len() > MAX_TRACKED {
277            evict_oldest(&mut map);
278        }
279
280        // Persist eagerly when the latch first engages or on a first sighting
281        // (both define a baseline that must survive an immediate restart);
282        // otherwise let the throttle decide.
283        let changed = was_first || (repacking && !already_repacking);
284        // Repack only when we are in the repacking state AND there is a cached
285        // prefix to act on this turn (a `cached == 0` turn prunes from 0 anyway).
286        (repacking && cached > 0, changed)
287    };
288
289    maybe_persist(changed, now);
290    decision
291}
292
293/// On-disk shape of the cross-restart baselines. `ts` is advisory (debugging);
294/// the per-conversation `last_touch` values are what prove an idle gap.
295#[derive(Debug, Default, Serialize, Deserialize)]
296struct PersistedTouch {
297    ts: u64,
298    conversations: HashMap<u64, ConvState>,
299}
300
301fn touch_path() -> Option<std::path::PathBuf> {
302    crate::core::data_dir::lean_ctx_data_dir()
303        .ok()
304        .map(|d| d.join(TOUCH_FILE))
305}
306
307/// Seeds the in-memory baselines from disk on proxy startup so an idle gap that
308/// straddles a restart is still detected. Merges by most-recent `last_touch` and
309/// OR-s the sticky `repacking` latch, so a re-seed can only bias toward "warm"
310/// (or keep a latch), never toward a wrong "cold".
311pub fn resume_from_disk() {
312    let Some(path) = touch_path() else {
313        return;
314    };
315    let Ok(data) = std::fs::read_to_string(&path) else {
316        return;
317    };
318    let Ok(persisted) = serde_json::from_str::<PersistedTouch>(&data) else {
319        return;
320    };
321    let mut map = store()
322        .lock()
323        .unwrap_or_else(std::sync::PoisonError::into_inner);
324    for (key, state) in persisted.conversations {
325        let entry = map.entry(key).or_insert(state);
326        if state.last_touch > entry.last_touch {
327            entry.last_touch = state.last_touch;
328        }
329        entry.repacking |= state.repacking;
330    }
331    while map.len() > MAX_TRACKED {
332        evict_oldest(&mut map);
333    }
334}
335
336/// Persist when forced (a baseline/latch change) or when the throttle window has
337/// elapsed. The disk write happens outside the store lock.
338fn maybe_persist(force: bool, now: u64) {
339    let last = last_persist().load(Ordering::Relaxed);
340    if !force && now.saturating_sub(last) < PERSIST_MIN_INTERVAL_SECS {
341        return;
342    }
343    last_persist().store(now, Ordering::Relaxed);
344    persist_now(now);
345}
346
347/// Atomically writes the current baselines to disk (`.tmp` + rename).
348fn persist_now(now: u64) {
349    let Some(path) = touch_path() else {
350        return;
351    };
352    let conversations = {
353        let map = store()
354            .lock()
355            .unwrap_or_else(std::sync::PoisonError::into_inner);
356        map.clone()
357    };
358    let payload = PersistedTouch {
359        ts: now,
360        conversations,
361    };
362    let Ok(json) = serde_json::to_string(&payload) else {
363        return;
364    };
365    let tmp = path.with_extension("json.tmp");
366    if std::fs::write(&tmp, json).is_ok() {
367        let _ = std::fs::rename(&tmp, &path);
368    }
369}
370
371/// Test-only: pre-seed a conversation's last-touch `secs_ago` seconds in the
372/// past so a single `repack_decision` call observes a controlled idle gap
373/// (the function overwrites last-touch with `now` on every call).
374///
375/// Tests must use a *unique* first message (hence a unique `conversation_key`)
376/// so seeding one never disturbs another running in parallel — the global store
377/// is shared, so there is deliberately no global "clear" that would race.
378#[cfg(test)]
379pub(crate) fn test_seed_last_touch(messages: &[Value], secs_ago: u64) {
380    if let Some(key) = conversation_key(messages) {
381        let when = now_secs().saturating_sub(secs_ago);
382        store()
383            .lock()
384            .unwrap_or_else(std::sync::PoisonError::into_inner)
385            .insert(
386                key,
387                ConvState {
388                    last_touch: when,
389                    repacking: false,
390                },
391            );
392    }
393}
394
395/// Test-only: drop a single conversation's in-memory baseline (simulates a proxy
396/// restart losing RAM for that key). Single-key removal stays race-free with the
397/// other tests that share the global store.
398#[cfg(test)]
399fn test_remove(messages: &[Value]) {
400    if let Some(key) = conversation_key(messages) {
401        store()
402            .lock()
403            .unwrap_or_else(std::sync::PoisonError::into_inner)
404            .remove(&key);
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use serde_json::json;
412
413    fn cached_body(first_text: &str, ttl: Option<&str>) -> Vec<Value> {
414        let cc = ttl.map_or_else(
415            || json!({"type": "ephemeral"}),
416            |t| json!({"type": "ephemeral", "ttl": t}),
417        );
418        vec![
419            json!({"role": "user", "content": [
420                {"type": "text", "text": first_text, "cache_control": cc}
421            ]}),
422            json!({"role": "assistant", "content": "ok"}),
423        ]
424    }
425
426    #[test]
427    fn key_is_stable_across_turns_and_distinct_per_conversation() {
428        let mut a1 = cached_body("conversation A opening", None);
429        let a2 = {
430            let mut m = a1.clone();
431            m.push(json!({"role": "user", "content": "a follow-up turn"}));
432            m
433        };
434        let b1 = cached_body("conversation B opening", None);
435
436        let ka = conversation_key(&a1).unwrap();
437        let ka2 = conversation_key(&a2).unwrap();
438        let kb = conversation_key(&b1).unwrap();
439        assert_eq!(ka, ka2, "key must be stable as the conversation grows");
440        assert_ne!(ka, kb, "distinct conversations must get distinct keys");
441
442        // Mutating messages[0] changes the key (different conversation head).
443        a1[0] = json!({"role": "user", "content": "different head"});
444        assert_ne!(conversation_key(&a1).unwrap(), ka);
445    }
446
447    #[test]
448    fn ttl_resolves_from_marker_else_default_else_none() {
449        let hour = cached_body("x", Some("1h"));
450        assert_eq!(resolved_ttl_secs(&hour, 1), Some(HOUR_TTL_SECS));
451
452        let five = cached_body("x", Some("5m"));
453        assert_eq!(resolved_ttl_secs(&five, 1), Some(DEFAULT_TTL_SECS));
454
455        // Marker present without an explicit ttl → Anthropic "5m" default.
456        let bare = cached_body("x", None);
457        assert_eq!(resolved_ttl_secs(&bare, 1), Some(DEFAULT_TTL_SECS));
458
459        // No client-cached prefix → nothing to repack.
460        assert_eq!(resolved_ttl_secs(&bare, 0), None);
461    }
462
463    use super::test_seed_last_touch as seed;
464
465    #[test]
466    fn first_sighting_only_sets_baseline() {
467        let msgs = cached_body("first-sighting conversation", None);
468        // No prior touch → never repack, but a baseline is now recorded.
469        assert!(!repack_decision(&msgs, 1));
470        // Immediately after, idle ≈ 0 → still warm.
471        assert!(!repack_decision(&msgs, 1));
472    }
473
474    #[test]
475    fn warm_prefix_is_never_repacked() {
476        let msgs = cached_body("warm conversation", Some("5m"));
477        seed(&msgs, 60); // 1 minute idle, TTL 5m → warm
478        assert!(!repack_decision(&msgs, 1));
479    }
480
481    #[test]
482    fn large_gap_triggers_repack() {
483        let msgs = cached_body("cold conversation 5m", Some("5m"));
484        seed(&msgs, 2 * 60 * 60); // 2h idle, threshold = max(600, 600) = 600
485        assert!(repack_decision(&msgs, 1));
486    }
487
488    #[test]
489    fn cached_zero_never_repacks_even_when_idle() {
490        let msgs = cached_body("idle but uncached", None);
491        seed(&msgs, 24 * 60 * 60);
492        // cached == 0: there is no client-cached prefix to repack.
493        assert!(!repack_decision(&msgs, 0));
494    }
495
496    #[test]
497    fn hour_ttl_skips_the_ambiguous_zone() {
498        let msgs = cached_body("cold conversation 1h", Some("1h"));
499        // threshold = 3600 * 2 = 7200s. Just under → still protect.
500        seed(&msgs, 7000);
501        assert!(!repack_decision(&msgs, 1));
502        // Well past → repack.
503        seed(&msgs, 8000);
504        assert!(repack_decision(&msgs, 1));
505    }
506
507    #[test]
508    fn key_ignores_cache_control_marker() {
509        // #499 (3): the same opening content with a different — or absent —
510        // cache_control marker must map to the SAME conversation key, so a moving
511        // cache breakpoint never causes a permanent first-sighting.
512        let none = cached_body("marker-invariant conversation", None);
513        let hour = cached_body("marker-invariant conversation", Some("1h"));
514        let five = cached_body("marker-invariant conversation", Some("5m"));
515        let k = conversation_key(&none).unwrap();
516        assert_eq!(k, conversation_key(&hour).unwrap());
517        assert_eq!(k, conversation_key(&five).unwrap());
518        // Different opening content still yields a different key.
519        let other = cached_body("a different opening", Some("1h"));
520        assert_ne!(k, conversation_key(&other).unwrap());
521    }
522
523    #[test]
524    fn sticky_repack_persists_into_warm_followups() {
525        // #499 (1): the N→N+1 interaction the original tests never covered.
526        let msgs = cached_body("sticky cold-then-warm conversation", Some("5m"));
527        // Turn N: a long idle gap → cold → repack fires and latches.
528        seed(&msgs, 2 * 60 * 60);
529        assert!(
530            repack_decision(&msgs, 1),
531            "a cold gap must trigger the repack"
532        );
533        // Turn N+1, seconds later (idle ≈ 0): pre-fix this fell back to protecting
534        // the prefix and re-sent the uncompressed original, busting the cache
535        // written at turn N. The latch must keep repacking so the cold-turn cache
536        // is hit.
537        assert!(
538            repack_decision(&msgs, 1),
539            "an immediate warm follow-up must stay sticky and keep repacking"
540        );
541        assert!(
542            repack_decision(&msgs, 1),
543            "stickiness persists across the rest of the session"
544        );
545    }
546
547    #[test]
548    fn cold_baseline_survives_restart_via_disk() {
549        // #499 (2): a baseline recorded before a restart must be recoverable from
550        // disk so the long gap is still detected (RAM-only loses it).
551        let _iso = crate::core::data_dir::isolated_data_dir();
552        let msgs = cached_body("restart-survival conversation", Some("5m"));
553        seed(&msgs, 3 * 60 * 60);
554        persist_now(now_secs());
555        // Simulate a proxy restart: the in-memory baseline is gone.
556        test_remove(&msgs);
557        // resume_from_disk restores it; the persisted cold gap still triggers.
558        resume_from_disk();
559        assert!(
560            repack_decision(&msgs, 1),
561            "a persisted cold baseline must survive a restart and still repack"
562        );
563    }
564}