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;
37
38#[derive(Debug, Clone, PartialEq)]
40pub enum CacheHealthAlert {
41 SustainedMisses {
43 consecutive: u32,
44 hit_rate: f64,
45 measured_turns: u32,
46 },
47 LowHitRate { hit_rate: f64, measured_turns: u32 },
49}
50
51impl CacheHealthAlert {
52 #[must_use]
56 pub fn message(&self) -> String {
57 match self {
58 Self::SustainedMisses { consecutive, hit_rate, measured_turns } => format!(
59 "Prompt cache reuse stayed below {DEGRADED_TURN_HIT_RATE_PERCENT:.0}% for {consecutive} consecutive requests (overall hit rate {hit_rate:.0}% over {measured_turns} measured requests). \
60 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. Cache misses alone do not establish client-side prefix churn."
61 ),
62 Self::LowHitRate { hit_rate, measured_turns } => format!(
63 "Prompt cache hit rate is {hit_rate:.0}% over {measured_turns} measured turns (floor {HIT_RATE_FLOOR_PERCENT:.0}%). \
64 Requests are re-paying input cost; check for prompt/tool-catalog churn or idle gaps expiring the provider cache."
65 ),
66 }
67 }
68}
69
70#[derive(Debug, Clone, Default)]
78pub struct PromptCacheHealthMonitor {
79 measured_turns: u32,
80 window_input_tokens: u64,
81 window_read_tokens: u64,
82 consecutive_degraded: u32,
83 sustained_miss_fired: bool,
84 low_rate_fired: bool,
85}
86
87impl PromptCacheHealthMonitor {
88 #[must_use]
89 pub fn new() -> Self {
90 Self::default()
91 }
92
93 pub fn record_compaction_boundary(&mut self) {
96 self.consecutive_degraded = 0;
97 }
98
99 pub fn record_turn(&mut self, usage: &Usage) -> Option<CacheHealthAlert> {
102 if !Self::is_measured(usage) {
103 return None;
104 }
105 self.measured_turns = self.measured_turns.saturating_add(1);
106 self.window_input_tokens = self.window_input_tokens.saturating_add(usage.input_tokens);
107 self.window_read_tokens = self.window_read_tokens.saturating_add(usage.cached_input_tokens);
108
109 if Self::turn_hit_rate(usage) < DEGRADED_TURN_HIT_RATE_PERCENT {
110 self.consecutive_degraded = self.consecutive_degraded.saturating_add(1);
111 } else {
112 self.consecutive_degraded = 0;
113 }
114
115 if !self.sustained_miss_fired && self.consecutive_degraded >= SUSTAINED_MISS_TURNS {
116 self.sustained_miss_fired = true;
117 return Some(CacheHealthAlert::SustainedMisses {
118 consecutive: self.consecutive_degraded,
119 hit_rate: self.rolling_hit_rate(),
120 measured_turns: self.measured_turns,
121 });
122 }
123
124 if !self.low_rate_fired
125 && self.measured_turns >= HIT_RATE_WINDOW_TURNS
126 && self.rolling_hit_rate() < HIT_RATE_FLOOR_PERCENT
127 {
128 self.low_rate_fired = true;
129 return Some(CacheHealthAlert::LowHitRate {
130 hit_rate: self.rolling_hit_rate(),
131 measured_turns: self.measured_turns,
132 });
133 }
134
135 None
136 }
137
138 #[must_use]
142 pub fn rolling_hit_rate(&self) -> f64 {
143 if self.window_input_tokens == 0 {
144 return 100.0;
145 }
146 (self.window_read_tokens as f64 / self.window_input_tokens as f64) * 100.0
147 }
148
149 fn is_measured(usage: &Usage) -> bool {
150 usage.input_tokens >= MIN_INPUT_TOKENS_FOR_SIGNAL
151 && (usage.cached_input_tokens > 0 || usage.cache_creation_tokens > 0)
152 }
153
154 fn turn_hit_rate(usage: &Usage) -> f64 {
155 usage.cache_hit_rate().map_or(100.0, |rate| rate * 100.0)
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 fn usage(input_tokens: u64, cached: u64, creation: u64) -> Usage {
164 Usage {
165 input_tokens,
166 cached_input_tokens: cached,
167 cache_creation_tokens: creation,
168 output_tokens: 10,
169 }
170 }
171
172 #[test]
173 fn compaction_separates_observed_session_misses_without_losing_totals() {
174 let mut monitor = PromptCacheHealthMonitor::new();
175 assert!(monitor.record_turn(&usage(12645, 0, 12642)).is_none());
176 assert!(monitor.record_turn(&usage(12669, 5710, 5575)).is_none());
177 monitor.record_compaction_boundary();
178 let miss = usage(12740, 0, 5212);
179 assert!(monitor.record_turn(&miss).is_none());
180 assert_eq!(monitor.window_input_tokens, 38054);
181 assert_eq!(monitor.window_read_tokens, 5710);
182 assert!(monitor.record_turn(&miss).is_none());
183 assert!(matches!(
184 monitor.record_turn(&miss),
185 Some(CacheHealthAlert::SustainedMisses { consecutive: 3, measured_turns: 5, .. })
186 ));
187 monitor.record_compaction_boundary();
188 assert!(monitor.record_turn(&miss).is_none());
189 assert!(monitor.record_turn(&miss).is_none());
190 assert!(matches!(monitor.record_turn(&miss), Some(CacheHealthAlert::LowHitRate { measured_turns: 8, .. })));
191 assert!(monitor.record_turn(&miss).is_none());
192 }
193
194 #[test]
195 fn partial_hits_are_reported_as_low_reuse_not_full_cost_misses() {
196 let mut monitor = PromptCacheHealthMonitor::new();
197 assert!(monitor.record_turn(&usage(40000, 12000, 0)).is_none());
198 assert!(monitor.record_turn(&usage(40000, 18000, 0)).is_none());
199 let message = monitor.record_turn(&usage(40000, 16000, 0)).expect("degraded streak").message();
200 assert!(message.contains("below 50%"));
201 assert!(message.contains("38%"));
202 assert!(!message.contains("near-full"));
203 assert!(!message.contains("full input cost"));
204 }
205
206 #[test]
207 fn ignores_turns_without_cache_metrics() {
208 let mut monitor = PromptCacheHealthMonitor::new();
209 for _ in 0..10 {
210 assert_eq!(monitor.record_turn(&usage(50_000, 0, 0)), None);
211 }
212 assert_eq!(monitor.measured_turns, 0);
213 }
214
215 #[test]
216 fn ignores_small_turns_below_volume_floor() {
217 let mut monitor = PromptCacheHealthMonitor::new();
218 for _ in 0..10 {
219 assert_eq!(monitor.record_turn(&usage(100, 0, 200)), None);
220 }
221 assert_eq!(monitor.measured_turns, 0);
222 }
223
224 #[test]
225 fn fires_sustained_miss_after_consecutive_degraded_turns() {
226 let mut monitor = PromptCacheHealthMonitor::new();
227 assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
228 assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
229 let alert = monitor.record_turn(&usage(50_000, 0, 5_000));
230 assert!(matches!(alert, Some(CacheHealthAlert::SustainedMisses { consecutive: 3, .. })));
231 }
232
233 #[test]
234 fn healthy_turn_resets_consecutive_counter() {
235 let mut monitor = PromptCacheHealthMonitor::new();
236 assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
237 assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
238 assert_eq!(monitor.record_turn(&usage(50_000, 45_000, 5_000)), None);
239 assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
240 assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
241 assert!(matches!(
242 monitor.record_turn(&usage(50_000, 0, 5_000)),
243 Some(CacheHealthAlert::SustainedMisses { consecutive: 3, .. })
244 ));
245 }
246
247 #[test]
248 fn alerts_are_one_shot_per_session() {
249 let mut monitor = PromptCacheHealthMonitor::new();
250 for _ in 0..3 {
251 monitor.record_turn(&usage(50_000, 0, 5_000));
252 }
253 for _ in 0..4 {
256 assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
257 }
258 }
259
260 #[test]
261 fn no_alert_when_rolling_rate_stays_above_floor() {
262 let mut monitor = PromptCacheHealthMonitor::new();
263 let mut alert = None;
267 for index in 0..8 {
268 let turn = if index % 3 == 2 {
269 usage(50_000, 30_000, 20_000)
270 } else {
271 usage(50_000, 10_000, 40_000)
272 };
273 alert = monitor.record_turn(&turn).or(alert);
274 }
275 assert_eq!(alert, None);
276 }
277
278 #[test]
279 fn low_hit_rate_fires_below_floor() {
280 let mut monitor = PromptCacheHealthMonitor::new();
281 let mut alert = None;
285 for index in 0..10 {
286 let turn = if index % 3 == 2 {
287 usage(50_000, 30_000, 20_000)
288 } else {
289 usage(50_000, 0, 50_000)
290 };
291 alert = monitor.record_turn(&turn).or(alert);
292 }
293 assert!(matches!(alert, Some(CacheHealthAlert::LowHitRate { .. })));
294 }
295
296 #[test]
297 fn rolling_rate_uses_total_input_not_cache_traffic() {
298 let mut monitor = PromptCacheHealthMonitor::new();
301 let mut alert = None;
305 for index in 0..8 {
306 let turn = if index % 3 == 2 {
307 usage(50_000, 26_000, 4_000)
308 } else {
309 usage(50_000, 5_000, 5_000)
310 };
311 alert = monitor.record_turn(&turn).or(alert);
312 }
313 assert!(matches!(alert, Some(CacheHealthAlert::LowHitRate { .. })));
314 assert!(
315 monitor.rolling_hit_rate() < HIT_RATE_FLOOR_PERCENT,
316 "rolling {} should be below floor",
317 monitor.rolling_hit_rate()
318 );
319 }
320
321 #[test]
322 fn alert_messages_mention_likely_causes() {
323 let sustained =
324 CacheHealthAlert::SustainedMisses { consecutive: 3, hit_rate: 5.0, measured_turns: 3 }.message();
325 assert!(sustained.contains("consecutive"));
326 assert!(sustained.contains("5%"));
327 assert!(sustained.contains("trajectory log"));
328 let low = CacheHealthAlert::LowHitRate { hit_rate: 10.0, measured_turns: 9 }.message();
329 assert!(low.contains("10%"));
330 assert!(low.contains("idle gaps"));
331 }
332}