use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub(crate) const NANO_AIU_PER_USD: f64 = 1e11;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Snapshot {
pub nano_aiu: u64,
pub total_tokens: u64,
pub alert: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SpikeRule {
pub ratio: f64,
pub min_usd: f64,
}
pub fn assess(
prev: Option<Snapshot>,
nano_aiu: u64,
total_tokens: u64,
rule: SpikeRule,
) -> Snapshot {
let baseline = |alert| Snapshot {
nano_aiu,
total_tokens,
alert,
};
let Some(prev) = prev else {
return baseline(false);
};
if nano_aiu == prev.nano_aiu && total_tokens == prev.total_tokens {
return prev;
}
if nano_aiu < prev.nano_aiu || total_tokens < prev.total_tokens || prev.total_tokens == 0 {
return baseline(false);
}
let step_nano = (nano_aiu - prev.nano_aiu) as f64;
let step_tokens = (total_tokens - prev.total_tokens) as f64;
if step_nano / NANO_AIU_PER_USD < rule.min_usd {
return baseline(false);
}
let session_rate = prev.nano_aiu as f64 / prev.total_tokens as f64;
baseline(step_nano > rule.ratio * session_rate * step_tokens)
}
pub fn snapshot_path(session_id: &str) -> PathBuf {
crate::state::session_file("spend", session_id)
}
pub fn check_spike(path: &Path, nano_aiu: u64, total_tokens: u64, rule: SpikeRule) -> bool {
crate::state::update_snapshot(path, |prev| assess(prev, nano_aiu, total_tokens, rule)).alert
}
#[cfg(test)]
mod tests {
use super::*;
const RULE: SpikeRule = SpikeRule {
ratio: 2.0,
min_usd: 0.05,
};
const CENT: u64 = 1_000_000_000;
fn snap(nano_aiu: u64, total_tokens: u64, alert: bool) -> Snapshot {
Snapshot {
nano_aiu,
total_tokens,
alert,
}
}
#[test]
fn test_first_observation_is_baseline_only() {
let s = assess(None, 50 * CENT, 100_000, RULE);
assert_eq!(s, snap(50 * CENT, 100_000, false));
}
#[test]
fn test_step_at_session_rate_is_not_a_spike() {
let prev = snap(50 * CENT, 100_000, false);
assert!(!assess(Some(prev), 75 * CENT, 150_000, RULE).alert);
}
#[test]
fn test_step_far_above_session_rate_is_a_spike() {
let prev = snap(50 * CENT, 100_000, false);
let s = assess(Some(prev), 80 * CENT, 110_000, RULE);
assert_eq!(s, snap(80 * CENT, 110_000, true));
}
#[test]
fn test_cheap_steep_step_is_ignored() {
let prev = snap(50 * CENT, 100_000, false);
assert!(!assess(Some(prev), 53 * CENT, 101_000, RULE).alert);
}
#[test]
fn test_spend_without_new_tokens_is_a_spike() {
let prev = snap(50 * CENT, 100_000, false);
assert!(assess(Some(prev), 60 * CENT, 100_000, RULE).alert);
}
#[test]
fn test_unchanged_counters_keep_previous_verdict() {
let prev = snap(80 * CENT, 110_000, true);
assert_eq!(assess(Some(prev), 80 * CENT, 110_000, RULE), prev);
}
#[test]
fn test_counters_going_down_rebaseline() {
let prev = snap(80 * CENT, 110_000, true);
let s = assess(Some(prev), 5 * CENT, 2_000, RULE);
assert_eq!(s, snap(5 * CENT, 2_000, false));
}
#[test]
fn test_no_baseline_without_prior_tokens() {
let prev = snap(0, 0, false);
assert!(!assess(Some(prev), 90 * CENT, 1_000, RULE).alert);
}
#[test]
fn test_check_spike_persists_between_calls() {
let path = std::env::temp_dir().join(format!(
"copilot-powerline-spend-{}-{}.json",
std::process::id(),
crate::github::current_timestamp()
));
assert!(!check_spike(&path, 50 * CENT, 100_000, RULE));
assert!(check_spike(&path, 80 * CENT, 110_000, RULE));
assert!(check_spike(&path, 80 * CENT, 110_000, RULE));
assert!(!check_spike(&path, 90 * CENT, 140_000, RULE));
let _ = std::fs::remove_file(&path);
}
}