1use crate::exec::events::Usage;
18
19pub const MIN_INPUT_TOKENS_FOR_SIGNAL: u64 = 1_024;
24
25pub const SUSTAINED_MISS_TURNS: u32 = 3;
27
28pub const HIT_RATE_WINDOW_TURNS: u32 = 8;
30
31pub const HIT_RATE_FLOOR_PERCENT: f64 = 25.0;
33
34pub const DEGRADED_TURN_HIT_RATE_PERCENT: f64 = 50.0;
38
39#[derive(Debug, Clone, PartialEq)]
41pub enum CacheHealthAlert {
42 SustainedMisses {
44 consecutive: u32,
45 hit_rate: f64,
46 measured_turns: u32,
47 },
48 LowHitRate { hit_rate: f64, measured_turns: u32 },
50}
51
52impl CacheHealthAlert {
53 #[must_use]
57 pub fn message(&self) -> String {
58 match self {
59 Self::SustainedMisses { consecutive, hit_rate, measured_turns } => format!(
60 "Prompt cache suffered {consecutive} consecutive near-full misses (hit rate {hit_rate:.0}% over {measured_turns} measured turns); recent requests re-paid full input cost. \
61 Check earlier warnings for model/reasoning switches, MCP refreshes, planning toggles, or idle-gap expiry, or inspect the trajectory log for prefix/catalog hash changes."
62 ),
63 Self::LowHitRate { hit_rate, measured_turns } => format!(
64 "Prompt cache hit rate is {hit_rate:.0}% over {measured_turns} measured turns (floor {HIT_RATE_FLOOR_PERCENT:.0}%). \
65 Requests are re-paying input cost; check for prompt/tool-catalog churn or idle gaps expiring the provider cache."
66 ),
67 }
68 }
69}
70
71#[derive(Debug, Clone, Default)]
79pub struct PromptCacheHealthMonitor {
80 measured_turns: u32,
81 window_input_tokens: u64,
82 window_read_tokens: u64,
83 consecutive_degraded: u32,
84 sustained_miss_fired: bool,
85 low_rate_fired: bool,
86}
87
88impl PromptCacheHealthMonitor {
89 #[must_use]
90 pub fn new() -> Self {
91 Self::default()
92 }
93
94 pub fn record_turn(&mut self, usage: &Usage) -> Option<CacheHealthAlert> {
97 if !Self::is_measured(usage) {
98 return None;
99 }
100 self.measured_turns = self.measured_turns.saturating_add(1);
101 self.window_input_tokens = self.window_input_tokens.saturating_add(usage.input_tokens);
102 self.window_read_tokens = self.window_read_tokens.saturating_add(usage.cached_input_tokens);
103
104 if Self::turn_hit_rate(usage) < DEGRADED_TURN_HIT_RATE_PERCENT {
105 self.consecutive_degraded = self.consecutive_degraded.saturating_add(1);
106 } else {
107 self.consecutive_degraded = 0;
108 }
109
110 if !self.sustained_miss_fired && self.consecutive_degraded >= SUSTAINED_MISS_TURNS {
111 self.sustained_miss_fired = true;
112 return Some(CacheHealthAlert::SustainedMisses {
113 consecutive: self.consecutive_degraded,
114 hit_rate: self.rolling_hit_rate(),
115 measured_turns: self.measured_turns,
116 });
117 }
118
119 if !self.low_rate_fired
120 && self.measured_turns >= HIT_RATE_WINDOW_TURNS
121 && self.rolling_hit_rate() < HIT_RATE_FLOOR_PERCENT
122 {
123 self.low_rate_fired = true;
124 return Some(CacheHealthAlert::LowHitRate {
125 hit_rate: self.rolling_hit_rate(),
126 measured_turns: self.measured_turns,
127 });
128 }
129
130 None
131 }
132
133 #[must_use]
137 pub fn rolling_hit_rate(&self) -> f64 {
138 if self.window_input_tokens == 0 {
139 return 100.0;
140 }
141 (self.window_read_tokens as f64 / self.window_input_tokens as f64) * 100.0
142 }
143
144 fn is_measured(usage: &Usage) -> bool {
145 usage.input_tokens >= MIN_INPUT_TOKENS_FOR_SIGNAL
146 && (usage.cached_input_tokens > 0 || usage.cache_creation_tokens > 0)
147 }
148
149 fn turn_hit_rate(usage: &Usage) -> f64 {
150 usage.cache_hit_rate().map_or(100.0, |rate| rate * 100.0)
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 fn usage(input_tokens: u64, cached: u64, creation: u64) -> Usage {
159 Usage {
160 input_tokens,
161 cached_input_tokens: cached,
162 cache_creation_tokens: creation,
163 output_tokens: 10,
164 }
165 }
166
167 #[test]
168 fn ignores_turns_without_cache_metrics() {
169 let mut monitor = PromptCacheHealthMonitor::new();
170 for _ in 0..10 {
171 assert_eq!(monitor.record_turn(&usage(50_000, 0, 0)), None);
172 }
173 assert_eq!(monitor.measured_turns, 0);
174 }
175
176 #[test]
177 fn ignores_small_turns_below_volume_floor() {
178 let mut monitor = PromptCacheHealthMonitor::new();
179 for _ in 0..10 {
180 assert_eq!(monitor.record_turn(&usage(100, 0, 200)), None);
181 }
182 assert_eq!(monitor.measured_turns, 0);
183 }
184
185 #[test]
186 fn fires_sustained_miss_after_consecutive_degraded_turns() {
187 let mut monitor = PromptCacheHealthMonitor::new();
188 assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
189 assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
190 let alert = monitor.record_turn(&usage(50_000, 0, 5_000));
191 assert!(matches!(alert, Some(CacheHealthAlert::SustainedMisses { consecutive: 3, .. })));
192 }
193
194 #[test]
195 fn healthy_turn_resets_consecutive_counter() {
196 let mut monitor = PromptCacheHealthMonitor::new();
197 assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
198 assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
199 assert_eq!(monitor.record_turn(&usage(50_000, 45_000, 5_000)), None);
200 assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
201 assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
202 assert!(matches!(
203 monitor.record_turn(&usage(50_000, 0, 5_000)),
204 Some(CacheHealthAlert::SustainedMisses { consecutive: 3, .. })
205 ));
206 }
207
208 #[test]
209 fn alerts_are_one_shot_per_session() {
210 let mut monitor = PromptCacheHealthMonitor::new();
211 for _ in 0..3 {
212 monitor.record_turn(&usage(50_000, 0, 5_000));
213 }
214 for _ in 0..4 {
217 assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
218 }
219 }
220
221 #[test]
222 fn no_alert_when_rolling_rate_stays_above_floor() {
223 let mut monitor = PromptCacheHealthMonitor::new();
224 let mut alert = None;
228 for index in 0..8 {
229 let turn = if index % 3 == 2 {
230 usage(50_000, 30_000, 20_000)
231 } else {
232 usage(50_000, 10_000, 40_000)
233 };
234 alert = monitor.record_turn(&turn).or(alert);
235 }
236 assert_eq!(alert, None);
237 }
238
239 #[test]
240 fn low_hit_rate_fires_below_floor() {
241 let mut monitor = PromptCacheHealthMonitor::new();
242 let mut alert = None;
246 for index in 0..10 {
247 let turn = if index % 3 == 2 {
248 usage(50_000, 30_000, 20_000)
249 } else {
250 usage(50_000, 0, 50_000)
251 };
252 alert = monitor.record_turn(&turn).or(alert);
253 }
254 assert!(matches!(alert, Some(CacheHealthAlert::LowHitRate { .. })));
255 }
256
257 #[test]
258 fn rolling_rate_uses_total_input_not_cache_traffic() {
259 let mut monitor = PromptCacheHealthMonitor::new();
262 let mut alert = None;
266 for index in 0..8 {
267 let turn = if index % 3 == 2 {
268 usage(50_000, 26_000, 4_000)
269 } else {
270 usage(50_000, 5_000, 5_000)
271 };
272 alert = monitor.record_turn(&turn).or(alert);
273 }
274 assert!(matches!(alert, Some(CacheHealthAlert::LowHitRate { .. })));
275 assert!(
276 monitor.rolling_hit_rate() < HIT_RATE_FLOOR_PERCENT,
277 "rolling {} should be below floor",
278 monitor.rolling_hit_rate()
279 );
280 }
281
282 #[test]
283 fn alert_messages_mention_likely_causes() {
284 let sustained =
285 CacheHealthAlert::SustainedMisses { consecutive: 3, hit_rate: 5.0, measured_turns: 3 }.message();
286 assert!(sustained.contains("consecutive"));
287 assert!(sustained.contains("5%"));
288 assert!(sustained.contains("trajectory log"));
289 let low = CacheHealthAlert::LowHitRate { hit_rate: 10.0, measured_turns: 9 }.message();
290 assert!(low.contains("10%"));
291 assert!(low.contains("idle gaps"));
292 }
293}