use crate::exec::events::Usage;
pub const MIN_INPUT_TOKENS_FOR_SIGNAL: u64 = 1_024;
pub const SUSTAINED_MISS_TURNS: u32 = 3;
pub const HIT_RATE_WINDOW_TURNS: u32 = 8;
pub const HIT_RATE_FLOOR_PERCENT: f64 = 25.0;
pub const DEGRADED_TURN_HIT_RATE_PERCENT: f64 = 50.0;
#[derive(Debug, Clone, PartialEq)]
pub enum CacheHealthAlert {
SustainedMisses { consecutive: u32 },
LowHitRate { hit_rate: f64, measured_turns: u32 },
}
impl CacheHealthAlert {
#[must_use]
pub fn message(&self) -> String {
match self {
Self::SustainedMisses { consecutive } => format!(
"Prompt cache suffered {consecutive} consecutive near-full misses; recent requests re-paid full input cost. \
Check for prompt/tool-catalog churn (model switches, MCP refreshes, planning toggles) or long idle gaps expiring the provider cache."
),
Self::LowHitRate { hit_rate, measured_turns } => format!(
"Prompt cache hit rate is {hit_rate:.0}% over {measured_turns} measured turns (floor {HIT_RATE_FLOOR_PERCENT:.0}%). \
Requests are re-paying input cost; check for prompt/tool-catalog churn or idle gaps expiring the provider cache."
),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct PromptCacheHealthMonitor {
measured_turns: u32,
window_read_tokens: u64,
window_creation_tokens: u64,
consecutive_degraded: u32,
sustained_miss_fired: bool,
low_rate_fired: bool,
}
impl PromptCacheHealthMonitor {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn record_turn(&mut self, usage: &Usage) -> Option<CacheHealthAlert> {
if !Self::is_measured(usage) {
return None;
}
self.measured_turns = self.measured_turns.saturating_add(1);
self.window_read_tokens = self.window_read_tokens.saturating_add(usage.cached_input_tokens);
self.window_creation_tokens = self.window_creation_tokens.saturating_add(usage.cache_creation_tokens);
if Self::turn_hit_rate(usage) < DEGRADED_TURN_HIT_RATE_PERCENT {
self.consecutive_degraded = self.consecutive_degraded.saturating_add(1);
} else {
self.consecutive_degraded = 0;
}
if !self.sustained_miss_fired && self.consecutive_degraded >= SUSTAINED_MISS_TURNS {
self.sustained_miss_fired = true;
return Some(CacheHealthAlert::SustainedMisses { consecutive: self.consecutive_degraded });
}
if !self.low_rate_fired
&& self.measured_turns >= HIT_RATE_WINDOW_TURNS
&& self.rolling_hit_rate() < HIT_RATE_FLOOR_PERCENT
{
self.low_rate_fired = true;
return Some(CacheHealthAlert::LowHitRate {
hit_rate: self.rolling_hit_rate(),
measured_turns: self.measured_turns,
});
}
None
}
#[must_use]
pub fn rolling_hit_rate(&self) -> f64 {
let total = self.window_read_tokens.saturating_add(self.window_creation_tokens);
if total == 0 {
return 100.0;
}
(self.window_read_tokens as f64 / total as f64) * 100.0
}
fn is_measured(usage: &Usage) -> bool {
usage.input_tokens >= MIN_INPUT_TOKENS_FOR_SIGNAL
&& (usage.cached_input_tokens > 0 || usage.cache_creation_tokens > 0)
}
fn turn_hit_rate(usage: &Usage) -> f64 {
usage.cache_hit_rate().map_or(100.0, |rate| rate * 100.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn usage(input_tokens: u64, cached: u64, creation: u64) -> Usage {
Usage {
input_tokens,
cached_input_tokens: cached,
cache_creation_tokens: creation,
output_tokens: 10,
}
}
#[test]
fn ignores_turns_without_cache_metrics() {
let mut monitor = PromptCacheHealthMonitor::new();
for _ in 0..10 {
assert_eq!(monitor.record_turn(&usage(50_000, 0, 0)), None);
}
assert_eq!(monitor.measured_turns, 0);
}
#[test]
fn ignores_small_turns_below_volume_floor() {
let mut monitor = PromptCacheHealthMonitor::new();
for _ in 0..10 {
assert_eq!(monitor.record_turn(&usage(100, 0, 200)), None);
}
assert_eq!(monitor.measured_turns, 0);
}
#[test]
fn fires_sustained_miss_after_consecutive_degraded_turns() {
let mut monitor = PromptCacheHealthMonitor::new();
assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
let alert = monitor.record_turn(&usage(50_000, 0, 5_000));
assert_eq!(alert, Some(CacheHealthAlert::SustainedMisses { consecutive: 3 }));
}
#[test]
fn healthy_turn_resets_consecutive_counter() {
let mut monitor = PromptCacheHealthMonitor::new();
assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
assert_eq!(monitor.record_turn(&usage(50_000, 45_000, 5_000)), None);
assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
assert_eq!(
monitor.record_turn(&usage(50_000, 0, 5_000)),
Some(CacheHealthAlert::SustainedMisses { consecutive: 3 })
);
}
#[test]
fn alerts_are_one_shot_per_session() {
let mut monitor = PromptCacheHealthMonitor::new();
for _ in 0..3 {
monitor.record_turn(&usage(50_000, 0, 5_000));
}
for _ in 0..4 {
assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
}
}
#[test]
fn no_alert_when_rolling_rate_stays_above_floor() {
let mut monitor = PromptCacheHealthMonitor::new();
let mut alert = None;
for index in 0..8 {
let turn = if index % 3 == 2 {
usage(50_000, 30_000, 20_000)
} else {
usage(50_000, 10_000, 40_000)
};
alert = monitor.record_turn(&turn).or(alert);
}
assert_eq!(alert, None);
}
#[test]
fn low_hit_rate_fires_below_floor() {
let mut monitor = PromptCacheHealthMonitor::new();
let mut alert = None;
for index in 0..10 {
let turn = if index % 3 == 2 {
usage(50_000, 30_000, 20_000)
} else {
usage(50_000, 0, 50_000)
};
alert = monitor.record_turn(&turn).or(alert);
}
assert!(matches!(alert, Some(CacheHealthAlert::LowHitRate { .. })));
}
#[test]
fn alert_messages_mention_likely_causes() {
let sustained = CacheHealthAlert::SustainedMisses { consecutive: 3 }.message();
assert!(sustained.contains("consecutive"));
assert!(sustained.contains("planning toggles"));
let low = CacheHealthAlert::LowHitRate { hit_rate: 10.0, measured_turns: 9 }.message();
assert!(low.contains("10%"));
assert!(low.contains("idle gaps"));
}
}