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 observed_constant_reuse_plateau_reports_weighted_session_rate() {
208 let mut monitor = PromptCacheHealthMonitor::new();
211 for (input, cached) in [
212 (19126, 0),
213 (20309, 13708),
214 (25677, 13708),
215 (30443, 13708),
216 (30686, 13708),
217 ] {
218 assert!(monitor.record_turn(&usage(input, cached, input - cached - 3)).is_none());
219 }
220 let alert = monitor
221 .record_turn(&usage(30932, 13708, 17221))
222 .expect("three low-reuse requests");
223 let CacheHealthAlert::SustainedMisses { consecutive, hit_rate, measured_turns } = &alert else {
224 panic!("expected sustained low reuse");
225 };
226 assert_eq!((*consecutive, *measured_turns), (3, 6));
227 assert!((hit_rate - 43.6079988293).abs() < 0.000001);
228 assert!(alert.message().contains("44% over 6 measured requests"));
229 assert!(
230 alert
231 .message()
232 .contains("Cache misses alone do not establish client-side prefix churn")
233 );
234 }
235
236 #[test]
237 fn ignores_turns_without_cache_metrics() {
238 let mut monitor = PromptCacheHealthMonitor::new();
239 for _ in 0..10 {
240 assert_eq!(monitor.record_turn(&usage(50_000, 0, 0)), None);
241 }
242 assert_eq!(monitor.measured_turns, 0);
243 }
244
245 #[test]
246 fn ignores_small_turns_below_volume_floor() {
247 let mut monitor = PromptCacheHealthMonitor::new();
248 for _ in 0..10 {
249 assert_eq!(monitor.record_turn(&usage(100, 0, 200)), None);
250 }
251 assert_eq!(monitor.measured_turns, 0);
252 }
253
254 #[test]
255 fn fires_sustained_miss_after_consecutive_degraded_turns() {
256 let mut monitor = PromptCacheHealthMonitor::new();
257 assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
258 assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
259 let alert = monitor.record_turn(&usage(50_000, 0, 5_000));
260 assert!(matches!(alert, Some(CacheHealthAlert::SustainedMisses { consecutive: 3, .. })));
261 }
262
263 #[test]
264 fn healthy_turn_resets_consecutive_counter() {
265 let mut monitor = PromptCacheHealthMonitor::new();
266 assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
267 assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
268 assert_eq!(monitor.record_turn(&usage(50_000, 45_000, 5_000)), None);
269 assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
270 assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
271 assert!(matches!(
272 monitor.record_turn(&usage(50_000, 0, 5_000)),
273 Some(CacheHealthAlert::SustainedMisses { consecutive: 3, .. })
274 ));
275 }
276
277 #[test]
278 fn alerts_are_one_shot_per_session() {
279 let mut monitor = PromptCacheHealthMonitor::new();
280 for _ in 0..3 {
281 monitor.record_turn(&usage(50_000, 0, 5_000));
282 }
283 for _ in 0..4 {
286 assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
287 }
288 }
289
290 #[test]
291 fn no_alert_when_rolling_rate_stays_above_floor() {
292 let mut monitor = PromptCacheHealthMonitor::new();
293 let mut alert = None;
297 for index in 0..8 {
298 let turn = if index % 3 == 2 {
299 usage(50_000, 30_000, 20_000)
300 } else {
301 usage(50_000, 10_000, 40_000)
302 };
303 alert = monitor.record_turn(&turn).or(alert);
304 }
305 assert_eq!(alert, None);
306 }
307
308 #[test]
309 fn low_hit_rate_fires_below_floor() {
310 let mut monitor = PromptCacheHealthMonitor::new();
311 let mut alert = None;
315 for index in 0..10 {
316 let turn = if index % 3 == 2 {
317 usage(50_000, 30_000, 20_000)
318 } else {
319 usage(50_000, 0, 50_000)
320 };
321 alert = monitor.record_turn(&turn).or(alert);
322 }
323 assert!(matches!(alert, Some(CacheHealthAlert::LowHitRate { .. })));
324 }
325
326 #[test]
327 fn rolling_rate_uses_total_input_not_cache_traffic() {
328 let mut monitor = PromptCacheHealthMonitor::new();
331 let mut alert = None;
335 for index in 0..8 {
336 let turn = if index % 3 == 2 {
337 usage(50_000, 26_000, 4_000)
338 } else {
339 usage(50_000, 5_000, 5_000)
340 };
341 alert = monitor.record_turn(&turn).or(alert);
342 }
343 assert!(matches!(alert, Some(CacheHealthAlert::LowHitRate { .. })));
344 assert!(
345 monitor.rolling_hit_rate() < HIT_RATE_FLOOR_PERCENT,
346 "rolling {} should be below floor",
347 monitor.rolling_hit_rate()
348 );
349 }
350
351 #[test]
352 fn alert_messages_mention_likely_causes() {
353 let sustained =
354 CacheHealthAlert::SustainedMisses { consecutive: 3, hit_rate: 5.0, measured_turns: 3 }.message();
355 assert!(sustained.contains("consecutive"));
356 assert!(sustained.contains("5%"));
357 assert!(sustained.contains("trajectory log"));
358 let low = CacheHealthAlert::LowHitRate { hit_rate: 10.0, measured_turns: 9 }.message();
359 assert!(low.contains("10%"));
360 assert!(low.contains("idle gaps"));
361 }
362}