use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DriftClass {
Stable,
Improving,
Degrading,
Critical,
}
impl DriftClass {
pub fn as_str(self) -> &'static str {
match self {
Self::Stable => "stable",
Self::Improving => "improving",
Self::Degrading => "degrading",
Self::Critical => "critical",
}
}
}
impl std::fmt::Display for DriftClass {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrendAnalysis {
pub metric: String,
pub slope_per_run: f64,
pub intercept: f64,
pub r_squared: f64,
pub drift: DriftClass,
pub runs_to_breach: Option<u32>,
pub current_headroom_pct: f64,
pub sample_count: usize,
}
#[derive(Debug, Clone)]
pub struct TrendConfig {
pub critical_window: u32,
pub min_r_squared: f64,
pub stable_threshold: f64,
}
impl Default for TrendConfig {
fn default() -> Self {
Self {
critical_window: 10,
min_r_squared: 0.3,
stable_threshold: 0.001,
}
}
}
#[must_use = "pure computation; call site should use the returned regression coefficients"]
pub fn linear_regression(points: &[(f64, f64)]) -> Option<(f64, f64, f64)> {
let n = points.len();
if n < 2 {
return None;
}
let n_f = n as f64;
let (sum_x, sum_y, sum_xy, sum_x2) = points.iter().fold(
(0.0f64, 0.0f64, 0.0f64, 0.0f64),
|(sx, sy, sxy, sx2), &(x, y)| (sx + x, sy + y, sxy + x * y, sx2 + x * x),
);
let denom = n_f * sum_x2 - sum_x * sum_x;
if denom.abs() < f64::EPSILON {
return None;
}
let slope = (n_f * sum_xy - sum_x * sum_y) / denom;
let intercept = (sum_y - slope * sum_x) / n_f;
let mean_y = sum_y / n_f;
let (ss_tot, ss_res) = points.iter().fold((0.0f64, 0.0f64), |(tot, res), &(x, y)| {
let predicted = slope * x + intercept;
(tot + (y - mean_y).powi(2), res + (y - predicted).powi(2))
});
let r_squared = if ss_tot.abs() < f64::EPSILON {
if ss_res.abs() < f64::EPSILON {
1.0
} else {
0.0
}
} else {
(1.0 - ss_res / ss_tot).clamp(0.0, 1.0)
};
if slope.is_finite() && intercept.is_finite() && r_squared.is_finite() {
Some((slope, intercept, r_squared))
} else {
None
}
}
#[must_use = "pure computation; call site should use the returned breach prediction"]
pub fn predict_breach_run(
slope: f64,
intercept: f64,
current_run: f64,
threshold: f64,
direction_lower_is_better: bool,
) -> Option<f64> {
if slope.abs() < f64::EPSILON {
return None;
}
let breach_run = (threshold - intercept) / slope;
if breach_run <= current_run {
return None;
}
let current_value = slope * current_run + intercept;
if direction_lower_is_better {
if current_value >= threshold {
return None; }
if slope <= 0.0 {
return None; }
} else {
if current_value <= threshold {
return None; }
if slope >= 0.0 {
return None; }
}
Some(breach_run)
}
#[must_use = "pure computation; call site should use the returned DriftClass"]
pub fn classify_drift(
slope: f64,
r_squared: f64,
current_value: f64,
_threshold: f64,
direction_lower_is_better: bool,
config: &TrendConfig,
runs_to_breach: Option<u32>,
) -> DriftClass {
if r_squared < config.min_r_squared {
return DriftClass::Stable;
}
let reference = if current_value.abs() > f64::EPSILON {
current_value.abs()
} else {
1.0
};
if (slope / reference).abs() < config.stable_threshold {
return DriftClass::Stable;
}
let moving_toward_threshold = if direction_lower_is_better {
slope > 0.0 } else {
slope < 0.0 };
if !moving_toward_threshold {
return DriftClass::Improving;
}
if let Some(runs) = runs_to_breach
&& runs <= config.critical_window
{
return DriftClass::Critical;
}
DriftClass::Degrading
}
#[must_use = "pure computation; call site should use the returned headroom percentage"]
pub fn compute_headroom_pct(
current_value: f64,
threshold: f64,
direction_lower_is_better: bool,
) -> f64 {
if threshold.abs() < f64::EPSILON {
return 0.0;
}
if direction_lower_is_better {
(threshold - current_value) / threshold * 100.0
} else {
(current_value - threshold) / threshold * 100.0
}
}
#[must_use = "pure computation; call site should use the returned TrendAnalysis"]
pub fn analyze_trend(
values: &[f64],
metric_name: &str,
threshold: f64,
direction_lower_is_better: bool,
config: &TrendConfig,
) -> Option<TrendAnalysis> {
if values.len() < 2 {
return None;
}
let points: Vec<(f64, f64)> = values
.iter()
.enumerate()
.map(|(i, &v)| (i as f64, v))
.collect();
let (slope, intercept, r_squared) = linear_regression(&points)?;
let current_run = (values.len() - 1) as f64;
let current_value = slope * current_run + intercept;
let headroom_pct = compute_headroom_pct(current_value, threshold, direction_lower_is_better);
let breach_run = predict_breach_run(
slope,
intercept,
current_run,
threshold,
direction_lower_is_better,
);
let runs_to_breach = breach_run.map(|br| {
let remaining = br - current_run;
remaining.ceil().max(1.0) as u32
});
let drift = classify_drift(
slope,
r_squared,
current_value,
threshold,
direction_lower_is_better,
config,
runs_to_breach,
);
Some(TrendAnalysis {
metric: metric_name.to_string(),
slope_per_run: slope,
intercept,
r_squared,
drift,
runs_to_breach,
current_headroom_pct: headroom_pct,
sample_count: values.len(),
})
}
#[must_use = "pure computation; call site should use the returned spark chart"]
pub fn spark_chart(values: &[f64]) -> String {
if values.is_empty() {
return String::new();
}
let min = values.iter().cloned().fold(f64::INFINITY, f64::min);
let max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let range = max - min;
if range < f64::EPSILON {
return "_".repeat(values.len());
}
let sparks = ['_', '.', '-', '~', '=', '+', '^', '#'];
values
.iter()
.map(|&v| {
let normalized = (v - min) / range;
let idx = (normalized * (sparks.len() - 1) as f64).round() as usize;
sparks[idx.min(sparks.len() - 1)]
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn linear_regression_perfect_fit() {
let points = vec![(0.0, 1.0), (1.0, 3.0), (2.0, 5.0), (3.0, 7.0)];
let (slope, intercept, r2) = linear_regression(&points).unwrap();
assert!((slope - 2.0).abs() < 1e-10);
assert!((intercept - 1.0).abs() < 1e-10);
assert!((r2 - 1.0).abs() < 1e-10);
}
#[test]
fn linear_regression_flat_line() {
let points = vec![(0.0, 5.0), (1.0, 5.0), (2.0, 5.0)];
let (slope, intercept, r2) = linear_regression(&points).unwrap();
assert!(slope.abs() < 1e-10);
assert!((intercept - 5.0).abs() < 1e-10);
assert!((r2 - 1.0).abs() < 1e-10);
}
#[test]
fn linear_regression_two_points() {
let points = vec![(0.0, 10.0), (1.0, 20.0)];
let (slope, intercept, r2) = linear_regression(&points).unwrap();
assert!((slope - 10.0).abs() < 1e-10);
assert!((intercept - 10.0).abs() < 1e-10);
assert!((r2 - 1.0).abs() < 1e-10);
}
#[test]
fn linear_regression_single_point_returns_none() {
assert!(linear_regression(&[(0.0, 5.0)]).is_none());
}
#[test]
fn linear_regression_empty_returns_none() {
assert!(linear_regression(&[]).is_none());
}
#[test]
fn linear_regression_same_x_returns_none() {
let points = vec![(1.0, 2.0), (1.0, 4.0), (1.0, 6.0)];
assert!(linear_regression(&points).is_none());
}
#[test]
fn linear_regression_noisy_data() {
let points = vec![(0.0, 1.2), (1.0, 2.8), (2.0, 5.1), (3.0, 7.3), (4.0, 8.9)];
let (slope, _intercept, r2) = linear_regression(&points).unwrap();
assert!((slope - 2.0).abs() < 0.5);
assert!(r2 > 0.95);
}
#[test]
fn predict_breach_lower_is_better_increasing() {
let breach = predict_breach_run(2.0, 100.0, 4.0, 150.0, true);
assert!(breach.is_some());
let br = breach.unwrap();
assert!((br - 25.0).abs() < 1e-10);
}
#[test]
fn predict_breach_lower_is_better_decreasing() {
let breach = predict_breach_run(-2.0, 100.0, 4.0, 150.0, true);
assert!(breach.is_none());
}
#[test]
fn predict_breach_already_past() {
let breach = predict_breach_run(2.0, 160.0, 4.0, 150.0, true);
assert!(breach.is_none());
}
#[test]
fn predict_breach_higher_is_better_decreasing() {
let breach = predict_breach_run(-3.0, 200.0, 10.0, 50.0, false);
assert!(breach.is_some());
let br = breach.unwrap();
assert!((br - 50.0).abs() < 1e-10);
}
#[test]
fn predict_breach_zero_slope() {
assert!(predict_breach_run(0.0, 100.0, 4.0, 150.0, true).is_none());
}
#[test]
fn classify_drift_stable_low_r2() {
let drift = classify_drift(1.0, 0.1, 100.0, 150.0, true, &TrendConfig::default(), None);
assert_eq!(drift, DriftClass::Stable);
}
#[test]
fn classify_drift_stable_small_slope() {
let drift = classify_drift(
0.0001,
0.9,
100.0,
150.0,
true,
&TrendConfig::default(),
None,
);
assert_eq!(drift, DriftClass::Stable);
}
#[test]
fn classify_drift_improving() {
let drift = classify_drift(-2.0, 0.9, 100.0, 150.0, true, &TrendConfig::default(), None);
assert_eq!(drift, DriftClass::Improving);
}
#[test]
fn classify_drift_degrading() {
let drift = classify_drift(
2.0,
0.9,
100.0,
150.0,
true,
&TrendConfig::default(),
Some(30),
);
assert_eq!(drift, DriftClass::Degrading);
}
#[test]
fn classify_drift_critical() {
let drift = classify_drift(
2.0,
0.9,
100.0,
150.0,
true,
&TrendConfig::default(),
Some(5),
);
assert_eq!(drift, DriftClass::Critical);
}
#[test]
fn classify_drift_critical_boundary() {
let drift = classify_drift(
2.0,
0.9,
100.0,
150.0,
true,
&TrendConfig::default(),
Some(10),
);
assert_eq!(drift, DriftClass::Critical);
}
#[test]
fn classify_drift_just_outside_critical() {
let drift = classify_drift(
2.0,
0.9,
100.0,
150.0,
true,
&TrendConfig::default(),
Some(11),
);
assert_eq!(drift, DriftClass::Degrading);
}
#[test]
fn headroom_pct_within_budget() {
let h = compute_headroom_pct(100.0, 120.0, true);
assert!((h - 16.666666666666668).abs() < 1e-10);
}
#[test]
fn headroom_pct_exceeded_budget() {
let h = compute_headroom_pct(130.0, 120.0, true);
assert!(h < 0.0);
}
#[test]
fn headroom_pct_higher_is_better() {
let h = compute_headroom_pct(200.0, 100.0, false);
assert!((h - 100.0).abs() < 1e-10);
}
#[test]
fn headroom_pct_zero_threshold() {
assert_eq!(compute_headroom_pct(100.0, 0.0, true), 0.0);
}
#[test]
fn analyze_trend_degrading() {
let values = vec![100.0, 102.0, 104.0, 106.0, 108.0];
let result = analyze_trend(&values, "wall_ms", 120.0, true, &TrendConfig::default());
let analysis = result.unwrap();
assert_eq!(analysis.metric, "wall_ms");
assert!((analysis.slope_per_run - 2.0).abs() < 1e-10);
assert!(analysis.r_squared > 0.99);
assert!(matches!(
analysis.drift,
DriftClass::Degrading | DriftClass::Critical
));
assert!(analysis.runs_to_breach.is_some());
assert!(analysis.current_headroom_pct > 0.0);
}
#[test]
fn analyze_trend_improving() {
let values = vec![115.0, 112.0, 109.0, 106.0, 103.0];
let result = analyze_trend(&values, "wall_ms", 120.0, true, &TrendConfig::default());
let analysis = result.unwrap();
assert_eq!(analysis.drift, DriftClass::Improving);
assert!(analysis.runs_to_breach.is_none());
}
#[test]
fn analyze_trend_critical() {
let values = vec![100.0, 105.0, 110.0, 115.0];
let result = analyze_trend(&values, "wall_ms", 120.0, true, &TrendConfig::default());
let analysis = result.unwrap();
assert_eq!(analysis.drift, DriftClass::Critical);
assert!(analysis.runs_to_breach.unwrap() <= 10);
}
#[test]
fn analyze_trend_single_point() {
assert!(analyze_trend(&[100.0], "wall_ms", 120.0, true, &TrendConfig::default()).is_none());
}
#[test]
fn analyze_trend_empty() {
assert!(analyze_trend(&[], "wall_ms", 120.0, true, &TrendConfig::default()).is_none());
}
#[test]
fn analyze_trend_flat() {
let values = vec![100.0, 100.0, 100.0, 100.0, 100.0];
let result = analyze_trend(&values, "wall_ms", 120.0, true, &TrendConfig::default());
let analysis = result.unwrap();
assert_eq!(analysis.drift, DriftClass::Stable);
}
#[test]
fn analyze_trend_higher_is_better() {
let values = vec![200.0, 195.0, 190.0, 185.0, 180.0];
let result = analyze_trend(
&values,
"throughput_per_s",
100.0,
false,
&TrendConfig::default(),
);
let analysis = result.unwrap();
assert_eq!(analysis.drift, DriftClass::Degrading);
assert!(analysis.runs_to_breach.is_some());
}
#[test]
fn spark_chart_basic() {
let chart = spark_chart(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert_eq!(chart.len(), 5);
assert_eq!(chart.chars().next(), Some('_'));
assert_eq!(chart.chars().last(), Some('#'));
}
#[test]
fn spark_chart_flat() {
let chart = spark_chart(&[5.0, 5.0, 5.0]);
assert_eq!(chart, "___");
}
#[test]
fn spark_chart_empty() {
assert_eq!(spark_chart(&[]), "");
}
#[test]
fn spark_chart_single() {
let chart = spark_chart(&[42.0]);
assert_eq!(chart, "_");
}
#[test]
fn analyze_trend_sample_count() {
let values = vec![10.0, 20.0, 30.0];
let analysis =
analyze_trend(&values, "wall_ms", 100.0, true, &TrendConfig::default()).unwrap();
assert_eq!(analysis.sample_count, 3);
}
#[test]
fn runs_to_breach_rounds_up() {
let values = vec![100.0, 102.0, 104.0, 106.0, 108.0];
let result = analyze_trend(&values, "wall_ms", 110.0, true, &TrendConfig::default());
let analysis = result.unwrap();
assert_eq!(analysis.runs_to_breach, Some(1));
}
}