pub const BURN_SAMPLE_CAP: usize = 30;
pub const BURN_HALF_LIFE_SECS: f64 = 45.0;
pub fn alpha_for_half_life(half_life_secs: f64, interval_secs: f64) -> f64 {
if half_life_secs <= 0.0 {
return 1.0;
}
1.0 - 0.5_f64.powf(interval_secs / half_life_secs)
}
pub fn ewma(prev: Option<f64>, sample: f64, alpha: f64) -> f64 {
match prev {
Some(p) => alpha * sample + (1.0 - alpha) * p,
None => sample,
}
}
pub fn percentile(samples: &[f64], p: f64) -> Option<f64> {
if samples.is_empty() {
return None;
}
let mut v: Vec<f64> = samples.to_vec();
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let p = p.clamp(0.0, 1.0);
let idx = p * (v.len() as f64 - 1.0);
let lo = idx.floor() as usize;
let hi = idx.ceil() as usize;
let frac = idx - lo as f64;
Some(v[lo] + (v[hi] - v[lo]) * frac)
}
pub fn eta_hours(headroom_usd: f64, burn_per_hr: f64) -> Option<f64> {
if burn_per_hr <= 1e-6 {
return None;
}
if headroom_usd <= 0.0 {
return Some(0.0);
}
Some(headroom_usd / burn_per_hr)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EtaBand {
pub low_hours: Option<f64>,
pub mid_hours: Option<f64>,
pub high_hours: Option<f64>,
}
pub fn budget_eta_band(headroom_usd: f64, smoothed_burn: f64, samples: &[f64]) -> EtaBand {
let p90 = percentile(samples, 0.9).unwrap_or(smoothed_burn);
let p10 = percentile(samples, 0.1).unwrap_or(smoothed_burn);
EtaBand {
low_hours: eta_hours(headroom_usd, p90),
mid_hours: eta_hours(headroom_usd, smoothed_burn),
high_hours: eta_hours(headroom_usd, p10),
}
}
pub fn format_eta(hours: Option<f64>) -> String {
match hours {
None => "—".into(),
Some(h) if h <= 0.0 => "now".into(),
Some(h) if h >= 24.0 => ">24h".into(),
Some(h) => {
let total_min = (h * 60.0).round() as i64;
let hh = total_min / 60;
let mm = total_min % 60;
if hh > 0 {
format!("~{hh}h{mm:02}m")
} else {
format!("~{mm}m")
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ewma_seeds_then_smooths() {
let a = 0.5;
let seeded = ewma(None, 10.0, a);
assert_eq!(seeded, 10.0);
assert_eq!(ewma(Some(10.0), 0.0, a), 5.0);
}
#[test]
fn ewma_resists_a_single_spike() {
let alpha = alpha_for_half_life(45.0, 2.0);
let after = ewma(Some(1.0), 100.0, alpha);
assert!(after < 6.0, "one spike moved EWMA too far: {after}");
}
#[test]
fn alpha_in_unit_range_and_monotonic() {
let a = alpha_for_half_life(45.0, 2.0);
assert!(a > 0.0 && a < 1.0);
assert!(alpha_for_half_life(10.0, 2.0) > a);
}
#[test]
fn percentile_interpolates() {
let s = [1.0, 2.0, 3.0, 4.0];
assert_eq!(percentile(&s, 0.0), Some(1.0));
assert_eq!(percentile(&s, 1.0), Some(4.0));
assert_eq!(percentile(&s, 0.5), Some(2.5));
assert_eq!(percentile(&[], 0.5), None);
}
#[test]
fn eta_hours_handles_zero_burn_and_spent_budget() {
assert_eq!(eta_hours(5.0, 0.0), None);
assert_eq!(eta_hours(0.0, 2.0), Some(0.0));
assert_eq!(eta_hours(10.0, 5.0), Some(2.0));
}
#[test]
fn band_orders_soonest_to_latest() {
let samples: Vec<f64> = (1..=10).map(|n| n as f64).collect();
let b = budget_eta_band(10.0, 4.0, &samples);
assert!(b.low_hours.unwrap() <= b.mid_hours.unwrap());
assert!(b.mid_hours.unwrap() <= b.high_hours.unwrap());
}
#[test]
fn format_eta_renders_buckets() {
assert_eq!(format_eta(None), "—");
assert_eq!(format_eta(Some(0.0)), "now");
assert_eq!(format_eta(Some(0.2)), "~12m");
assert_eq!(format_eta(Some(3.0 + 40.0 / 60.0)), "~3h40m");
assert_eq!(format_eta(Some(50.0)), ">24h");
}
}