use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
const TREND_TOLERANCE_PCT: f64 = 5.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Trend {
Up,
Down,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Miss {
pub tokens: u64,
pub at: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Snapshot {
pub input_tokens: u64,
pub cache_read_tokens: u64,
pub trend: Option<Trend>,
#[serde(default)]
pub last_miss: Option<Miss>,
}
impl Snapshot {
pub fn recent_miss(&self, now: u64, visible_seconds: u64) -> Option<u64> {
self.last_miss
.filter(|miss| miss.tokens > 0 && now.saturating_sub(miss.at) <= visible_seconds)
.map(|miss| miss.tokens)
}
}
pub fn assess(
prev: Option<Snapshot>,
input_tokens: u64,
cache_read_tokens: u64,
now: u64,
miss_min_tokens: u64,
) -> Snapshot {
let baseline = |trend, last_miss| Snapshot {
input_tokens,
cache_read_tokens,
trend,
last_miss,
};
let Some(prev) = prev else {
return baseline(None, None);
};
if input_tokens == prev.input_tokens && cache_read_tokens == prev.cache_read_tokens {
return prev;
}
if input_tokens <= prev.input_tokens
|| cache_read_tokens < prev.cache_read_tokens
|| prev.input_tokens == 0
{
return baseline(None, None);
}
let step_input = input_tokens - prev.input_tokens;
let step_read = cache_read_tokens - prev.cache_read_tokens;
let uncached = step_input.saturating_sub(step_read);
let last_miss = if uncached > 0 && uncached >= miss_min_tokens {
Some(Miss {
tokens: uncached,
at: now,
})
} else {
prev.last_miss
};
let pct = |read: u64, input: u64| read as f64 / input as f64 * 100.0;
let delta = pct(step_read, step_input) - pct(prev.cache_read_tokens, prev.input_tokens);
let trend = if delta > TREND_TOLERANCE_PCT {
Some(Trend::Up)
} else if delta < -TREND_TOLERANCE_PCT {
Some(Trend::Down)
} else {
None
};
baseline(trend, last_miss)
}
pub fn snapshot_path(session_id: &str) -> PathBuf {
crate::state::session_file("cache", session_id)
}
#[derive(Debug, Clone, Copy)]
pub struct MissRule {
pub min_tokens: u64,
pub visible_seconds: u64,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Signals {
pub trend: Option<Trend>,
pub recent_miss: Option<u64>,
}
pub fn check(
path: &Path,
input_tokens: u64,
cache_read_tokens: u64,
now: u64,
rule: MissRule,
) -> Signals {
let snapshot = crate::state::update_snapshot(path, |prev| {
assess(prev, input_tokens, cache_read_tokens, now, rule.min_tokens)
});
Signals {
trend: snapshot.trend,
recent_miss: snapshot.recent_miss(now, rule.visible_seconds),
}
}
#[cfg(test)]
mod tests {
use super::*;
const NOW: u64 = 1_000_000;
const MISS_MIN: u64 = 50_000;
const RULE: MissRule = MissRule {
min_tokens: MISS_MIN,
visible_seconds: 300,
};
fn snap(input_tokens: u64, cache_read_tokens: u64, trend: Option<Trend>) -> Snapshot {
Snapshot {
input_tokens,
cache_read_tokens,
trend,
last_miss: None,
}
}
fn assess_at_now(
prev: Option<Snapshot>,
input_tokens: u64,
cache_read_tokens: u64,
) -> Snapshot {
super::assess(prev, input_tokens, cache_read_tokens, NOW, MISS_MIN)
}
fn miss(tokens: u64, at: u64) -> Option<Miss> {
Some(Miss { tokens, at })
}
#[test]
fn test_large_uncached_step_is_recorded_as_miss() {
let prev = snap(2_000_000, 1_886_000, None);
let next = assess_at_now(Some(prev), 2_133_000, 1_886_000);
assert_eq!(next.last_miss, miss(133_000, NOW));
}
#[test]
fn test_miss_counts_only_the_uncached_part_of_the_step() {
let prev = snap(1_000_000, 900_000, None);
let next = assess_at_now(Some(prev), 1_200_000, 1_020_000);
assert_eq!(next.last_miss, miss(80_000, NOW));
}
#[test]
fn test_step_below_the_floor_is_not_a_miss_and_keeps_the_last_one() {
let prev = Snapshot {
last_miss: miss(133_000, NOW - 60),
..snap(2_133_000, 1_886_000, None)
};
let next = assess_at_now(Some(prev), 2_273_000, 2_016_000);
assert_eq!(next.last_miss, miss(133_000, NOW - 60));
}
#[test]
fn test_newer_miss_replaces_the_last_one() {
let prev = Snapshot {
last_miss: miss(133_000, NOW - 60),
..snap(2_133_000, 1_886_000, None)
};
let next = assess_at_now(Some(prev), 2_193_000, 1_886_000);
assert_eq!(next.last_miss, miss(60_000, NOW));
}
#[test]
fn test_first_step_of_a_session_is_not_a_miss() {
let prev = snap(0, 0, None);
assert_eq!(assess_at_now(Some(prev), 80_000, 0).last_miss, None);
}
#[test]
fn test_fully_cached_step_is_not_a_miss_even_with_a_zero_floor() {
let prev = snap(1_000_000, 900_000, None);
let next = super::assess(Some(prev), 1_010_000, 910_000, NOW, 0);
assert_eq!(next.last_miss, None);
}
#[test]
fn test_fully_cached_step_keeps_the_last_miss_with_a_zero_floor() {
let prev = Snapshot {
last_miss: miss(5_000, NOW - 60),
..snap(1_000_000, 900_000, None)
};
let next = super::assess(Some(prev), 1_010_000, 910_000, NOW, 0);
assert_eq!(next.last_miss, miss(5_000, NOW - 60));
}
#[test]
fn test_counters_going_down_forget_the_miss() {
let prev = Snapshot {
last_miss: miss(133_000, NOW - 60),
..snap(2_133_000, 1_886_000, None)
};
assert_eq!(assess_at_now(Some(prev), 5_000, 0).last_miss, None);
}
#[test]
fn test_unchanged_counters_keep_the_miss() {
let prev = Snapshot {
last_miss: miss(133_000, NOW - 60),
..snap(2_133_000, 1_886_000, None)
};
assert_eq!(assess_at_now(Some(prev), 2_133_000, 1_886_000), prev);
}
#[test]
fn test_miss_is_recent_within_the_visible_window() {
let s = Snapshot {
last_miss: miss(133_000, NOW),
..snap(2_133_000, 1_886_000, None)
};
assert_eq!(s.recent_miss(NOW, 300), Some(133_000));
assert_eq!(s.recent_miss(NOW + 300, 300), Some(133_000));
assert_eq!(s.recent_miss(NOW + 301, 300), None);
}
#[test]
fn test_zero_token_miss_saved_by_an_older_version_is_not_recent() {
let s = Snapshot {
last_miss: miss(0, NOW),
..snap(2_133_000, 1_886_000, None)
};
assert_eq!(s.recent_miss(NOW, 300), None);
}
#[test]
fn test_no_recent_miss_without_one() {
assert_eq!(snap(100, 60, None).recent_miss(NOW, 300), None);
}
#[test]
fn test_snapshot_written_before_last_miss_existed_still_loads() {
let old = r#"{"input_tokens":100,"cache_read_tokens":60,"trend":"Down"}"#;
let loaded: Snapshot = serde_json::from_str(old).unwrap();
assert_eq!(loaded, snap(100, 60, Some(Trend::Down)));
}
#[test]
fn test_first_observation_is_baseline_only() {
assert_eq!(
assess_at_now(None, 100_000, 60_000),
snap(100_000, 60_000, None)
);
}
#[test]
fn test_step_hitting_cache_more_than_average_trends_up() {
let prev = snap(100_000, 60_000, None);
assert_eq!(
assess_at_now(Some(prev), 110_000, 69_000),
snap(110_000, 69_000, Some(Trend::Up))
);
}
#[test]
fn test_step_missing_cache_trends_down() {
let prev = snap(100_000, 60_000, None);
assert_eq!(
assess_at_now(Some(prev), 110_000, 61_000).trend,
Some(Trend::Down)
);
}
#[test]
fn test_step_close_to_average_has_no_trend() {
let prev = snap(100_000, 60_000, Some(Trend::Down));
assert_eq!(assess_at_now(Some(prev), 110_000, 66_200).trend, None);
}
#[test]
fn test_unchanged_counters_keep_previous_verdict() {
let prev = snap(110_000, 61_000, Some(Trend::Down));
assert_eq!(assess_at_now(Some(prev), 110_000, 61_000), prev);
}
#[test]
fn test_counters_going_down_rebaseline() {
let prev = snap(110_000, 61_000, Some(Trend::Down));
assert_eq!(assess_at_now(Some(prev), 5_000, 0), snap(5_000, 0, None));
}
#[test]
fn test_input_going_down_alone_rebaselines() {
let prev = snap(110_000, 61_000, Some(Trend::Down));
assert_eq!(
assess_at_now(Some(prev), 100_000, 61_000),
snap(100_000, 61_000, None)
);
}
#[test]
fn test_cache_reads_going_down_alone_rebaseline() {
let prev = snap(110_000, 61_000, Some(Trend::Down));
assert_eq!(
assess_at_now(Some(prev), 120_000, 50_000),
snap(120_000, 50_000, None)
);
}
#[test]
fn test_step_exactly_at_the_tolerance_has_no_trend() {
let prev = snap(100_000, 0, None);
assert_eq!(assess_at_now(Some(prev), 200_000, 5_000).trend, None);
let prev = snap(100_000, 5_000, None);
assert_eq!(assess_at_now(Some(prev), 200_000, 5_000).trend, None);
}
#[test]
fn test_no_baseline_without_prior_input() {
let prev = snap(0, 0, None);
assert_eq!(assess_at_now(Some(prev), 10_000, 0).trend, None);
}
#[test]
fn test_live_payload_cache_write_step_trends_down_and_sticks() {
let path = std::env::temp_dir().join(format!(
"copilot-powerline-cache-trend-{}-{}.json",
std::process::id(),
crate::github::current_timestamp()
));
let trend = |input, read| check(&path, input, read, NOW, RULE).trend;
assert_eq!(trend(96_202, 96_192), None);
assert_eq!(trend(146_242, 96_192), Some(Trend::Down));
assert_eq!(trend(146_242, 96_192), Some(Trend::Down));
let _ = std::fs::remove_file(&path);
}
}