Skip to main content

lean_ctx/core/
proxy_cache_attribution.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Prompt-cache miss attribution counters (#986): the proxy classifies each
3//! anchored turn (`proxy::cache_attribution`) and counts the outcome here; the
4//! proxy's `/status` and the MCP server's metrics read the cumulative gauges.
5
6use std::sync::atomic::{AtomicU64, Ordering};
7
8use serde::{Deserialize, Serialize};
9
10/// The cache outcome attributed to one anchored (`cached > 0`) request, by
11/// comparing its cacheable prefix + idle time against the previous turn.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum CacheOutcome {
14    /// First time this conversation's anchored prefix was seen — no prior turn to
15    /// compare, so nothing is attributable (counts only as a baseline).
16    ColdStart,
17    /// Prefix byte-identical to last turn and within the TTL — the provider cache
18    /// should hit. The healthy steady state.
19    WarmReuse,
20    /// Prefix byte-identical to last turn but idle past the TTL — the entry
21    /// expired; a miss caused by time, not a prefix change.
22    TtlLapse,
23    /// Prefix differs from last turn — the provider re-writes from the first
24    /// changed byte; a miss caused by a mutated prefix, regardless of timing.
25    PrefixChange,
26}
27
28static COLD_STARTS: AtomicU64 = AtomicU64::new(0);
29static WARM_REUSES: AtomicU64 = AtomicU64::new(0);
30static TTL_LAPSES: AtomicU64 = AtomicU64::new(0);
31static PREFIX_CHANGES: AtomicU64 = AtomicU64::new(0);
32
33/// Count one attributed outcome.
34pub fn record_outcome(outcome: CacheOutcome) {
35    let counter = match outcome {
36        CacheOutcome::ColdStart => &COLD_STARTS,
37        CacheOutcome::WarmReuse => &WARM_REUSES,
38        CacheOutcome::TtlLapse => &TTL_LAPSES,
39        CacheOutcome::PrefixChange => &PREFIX_CHANGES,
40    };
41    counter.fetch_add(1, Ordering::Relaxed);
42}
43
44/// Point-in-time view of the miss-attribution counters for `/status`.
45#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
46pub struct CacheAttribution {
47    /// First-sighting anchored turns (baseline only, not a hit or a miss).
48    pub cold_starts: u64,
49    /// Turns whose prefix was stable and within TTL — the cache should hit.
50    pub warm_reuses: u64,
51    /// Misses caused by an expired entry on an otherwise-stable prefix.
52    pub ttl_lapses: u64,
53    /// Misses caused by a changed cacheable prefix (a mutated/edited prefix).
54    pub prefix_changes: u64,
55}
56
57impl CacheAttribution {
58    /// Provider prompt-cache reuse among turns that have a prior anchored
59    /// prefix to compare. Cold starts establish that baseline, so they are not
60    /// a hit or miss in this rate.
61    #[must_use]
62    pub fn provider_reuse_rate(&self) -> f64 {
63        let eligible = self
64            .warm_reuses
65            .saturating_add(self.ttl_lapses)
66            .saturating_add(self.prefix_changes);
67        if eligible == 0 {
68            0.0
69        } else {
70            self.warm_reuses as f64 * 100.0 / eligible as f64
71        }
72    }
73
74    #[must_use]
75    pub fn eligible_provider_requests(&self) -> u64 {
76        self.warm_reuses
77            .saturating_add(self.ttl_lapses)
78            .saturating_add(self.prefix_changes)
79    }
80}
81
82#[must_use]
83pub fn snapshot() -> CacheAttribution {
84    CacheAttribution {
85        cold_starts: COLD_STARTS.load(Ordering::Relaxed),
86        warm_reuses: WARM_REUSES.load(Ordering::Relaxed),
87        ttl_lapses: TTL_LAPSES.load(Ordering::Relaxed),
88        prefix_changes: PREFIX_CHANGES.load(Ordering::Relaxed),
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn provider_reuse_excludes_cold_starts_from_denominator() {
98        let attribution = CacheAttribution {
99            cold_starts: 99,
100            warm_reuses: 6,
101            ttl_lapses: 2,
102            prefix_changes: 4,
103        };
104
105        assert_eq!(attribution.eligible_provider_requests(), 12);
106        assert_eq!(attribution.provider_reuse_rate(), 50.0);
107    }
108}