Skip to main content

perfgate_stats/
trend.rs

1//! Trend analysis for predicting budget breaches.
2//!
3//! Provides simple linear regression over a metric history to detect drift
4//! and predict when a budget threshold will be exceeded.
5
6use serde::{Deserialize, Serialize};
7
8/// Classification of metric drift direction and severity.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum DriftClass {
12    /// Metric is stable (slope near zero or improving).
13    Stable,
14    /// Metric is improving (moving away from threshold).
15    Improving,
16    /// Metric is degrading (moving toward threshold but not imminent).
17    Degrading,
18    /// Metric will breach threshold within the critical window.
19    Critical,
20}
21
22impl DriftClass {
23    /// Returns the string representation.
24    pub fn as_str(self) -> &'static str {
25        match self {
26            Self::Stable => "stable",
27            Self::Improving => "improving",
28            Self::Degrading => "degrading",
29            Self::Critical => "critical",
30        }
31    }
32}
33
34impl std::fmt::Display for DriftClass {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        write!(f, "{}", self.as_str())
37    }
38}
39
40/// Result of trend analysis for a single metric.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct TrendAnalysis {
43    /// Metric name (e.g., "wall_ms").
44    pub metric: String,
45    /// Slope per run (change in metric value per run index).
46    pub slope_per_run: f64,
47    /// Intercept of the linear regression line.
48    pub intercept: f64,
49    /// R-squared value (0.0-1.0) indicating fit quality.
50    pub r_squared: f64,
51    /// Drift classification.
52    pub drift: DriftClass,
53    /// Estimated runs until budget threshold is exceeded.
54    /// `None` if the metric is not trending toward the threshold.
55    pub runs_to_breach: Option<u32>,
56    /// Current headroom as a percentage (how far current value is from threshold).
57    /// Positive means below threshold, negative means already exceeded.
58    pub current_headroom_pct: f64,
59    /// Number of data points used.
60    pub sample_count: usize,
61}
62
63/// Parameters controlling drift classification thresholds.
64#[derive(Debug, Clone)]
65pub struct TrendConfig {
66    /// Number of runs within which a breach is considered "critical".
67    pub critical_window: u32,
68    /// Minimum R-squared to trust the trend direction.
69    pub min_r_squared: f64,
70    /// Minimum absolute slope (relative to current value) to count as non-stable.
71    /// Slopes smaller than `stable_threshold * current_value` are considered stable.
72    pub stable_threshold: f64,
73}
74
75impl Default for TrendConfig {
76    fn default() -> Self {
77        Self {
78            critical_window: 10,
79            min_r_squared: 0.3,
80            stable_threshold: 0.001,
81        }
82    }
83}
84
85/// Fit a simple linear regression: y = slope * x + intercept.
86///
87/// Takes `(x, y)` pairs and returns `(slope, intercept, r_squared)`.
88///
89/// Returns `None` if fewer than 2 points are provided or if the regression
90/// is degenerate (all x values equal).
91///
92/// # Examples
93///
94/// ```
95/// use perfgate_stats::trend::linear_regression;
96///
97/// let points = vec![(0.0, 1.0), (1.0, 2.0), (2.0, 3.0)];
98/// let (slope, intercept, r2) = linear_regression(&points).unwrap();
99/// assert!((slope - 1.0).abs() < 1e-10);
100/// assert!((intercept - 1.0).abs() < 1e-10);
101/// assert!((r2 - 1.0).abs() < 1e-10);
102/// ```
103pub fn linear_regression(points: &[(f64, f64)]) -> Option<(f64, f64, f64)> {
104    let n = points.len();
105    if n < 2 {
106        return None;
107    }
108
109    let n_f = n as f64;
110    let sum_x: f64 = points.iter().map(|(x, _)| x).sum();
111    let sum_y: f64 = points.iter().map(|(_, y)| y).sum();
112    let sum_xy: f64 = points.iter().map(|(x, y)| x * y).sum();
113    let sum_x2: f64 = points.iter().map(|(x, _)| x * x).sum();
114
115    let denom = n_f * sum_x2 - sum_x * sum_x;
116    if denom.abs() < f64::EPSILON {
117        // All x values are equal; cannot fit a line.
118        return None;
119    }
120
121    let slope = (n_f * sum_xy - sum_x * sum_y) / denom;
122    let intercept = (sum_y - slope * sum_x) / n_f;
123
124    // R-squared: coefficient of determination
125    let mean_y = sum_y / n_f;
126    let ss_tot: f64 = points.iter().map(|(_, y)| (y - mean_y).powi(2)).sum();
127    let ss_res: f64 = points
128        .iter()
129        .map(|(x, y)| {
130            let predicted = slope * x + intercept;
131            (y - predicted).powi(2)
132        })
133        .sum();
134
135    let r_squared = if ss_tot.abs() < f64::EPSILON {
136        // All y values are equal: perfect fit if ss_res is also 0.
137        if ss_res.abs() < f64::EPSILON {
138            1.0
139        } else {
140            0.0
141        }
142    } else {
143        (1.0 - ss_res / ss_tot).clamp(0.0, 1.0)
144    };
145
146    if slope.is_finite() && intercept.is_finite() && r_squared.is_finite() {
147        Some((slope, intercept, r_squared))
148    } else {
149        None
150    }
151}
152
153/// Predict the run index at which the regression line crosses `threshold`.
154///
155/// Returns `None` if the slope is zero, the line does not approach the threshold,
156/// or the crossing is in the past.
157///
158/// `direction_lower_is_better` indicates whether lower metric values are desirable.
159/// - `true` (e.g., wall_ms): breach when value rises above threshold.
160/// - `false` (e.g., throughput_per_s): breach when value drops below threshold.
161pub fn predict_breach_run(
162    slope: f64,
163    intercept: f64,
164    current_run: f64,
165    threshold: f64,
166    direction_lower_is_better: bool,
167) -> Option<f64> {
168    if slope.abs() < f64::EPSILON {
169        return None;
170    }
171
172    // y = slope * x + intercept = threshold
173    // x = (threshold - intercept) / slope
174    let breach_run = (threshold - intercept) / slope;
175
176    // Only return if breach is in the future
177    if breach_run <= current_run {
178        return None;
179    }
180
181    // Check that the slope is actually moving toward the threshold
182    let current_value = slope * current_run + intercept;
183    if direction_lower_is_better {
184        // For "lower is better", degrading means value is increasing toward threshold
185        if current_value >= threshold {
186            return None; // Already past threshold
187        }
188        if slope <= 0.0 {
189            return None; // Moving away from threshold
190        }
191    } else {
192        // For "higher is better", degrading means value is decreasing toward threshold
193        if current_value <= threshold {
194            return None; // Already past threshold
195        }
196        if slope >= 0.0 {
197            return None; // Moving away from threshold
198        }
199    }
200
201    Some(breach_run)
202}
203
204/// Classify the drift of a metric given regression parameters.
205///
206/// `current_value` is the metric value at the latest run.
207/// `threshold` is the budget fail threshold (absolute value the metric must not exceed).
208/// `direction_lower_is_better` specifies the metric direction.
209pub fn classify_drift(
210    slope: f64,
211    r_squared: f64,
212    current_value: f64,
213    _threshold: f64,
214    direction_lower_is_better: bool,
215    config: &TrendConfig,
216    runs_to_breach: Option<u32>,
217) -> DriftClass {
218    // If R-squared is too low, the trend is unreliable
219    if r_squared < config.min_r_squared {
220        return DriftClass::Stable;
221    }
222
223    // Check if slope is negligible relative to current value
224    let reference = if current_value.abs() > f64::EPSILON {
225        current_value.abs()
226    } else {
227        1.0
228    };
229    if (slope / reference).abs() < config.stable_threshold {
230        return DriftClass::Stable;
231    }
232
233    // Determine if the metric is moving toward or away from the threshold
234    let moving_toward_threshold = if direction_lower_is_better {
235        slope > 0.0 // Value increasing toward threshold
236    } else {
237        slope < 0.0 // Value decreasing toward threshold
238    };
239
240    if !moving_toward_threshold {
241        return DriftClass::Improving;
242    }
243
244    // The metric is degrading; check if breach is imminent
245    if let Some(runs) = runs_to_breach
246        && runs <= config.critical_window
247    {
248        return DriftClass::Critical;
249    }
250
251    DriftClass::Degrading
252}
253
254/// Compute headroom as a percentage: how far the current value is from the threshold.
255///
256/// For "lower is better" metrics: headroom = (threshold - current) / threshold * 100.
257/// For "higher is better" metrics: headroom = (current - threshold) / threshold * 100.
258///
259/// Positive headroom means the metric is within budget. Negative means it has exceeded.
260pub fn compute_headroom_pct(
261    current_value: f64,
262    threshold: f64,
263    direction_lower_is_better: bool,
264) -> f64 {
265    if threshold.abs() < f64::EPSILON {
266        return 0.0;
267    }
268    if direction_lower_is_better {
269        (threshold - current_value) / threshold * 100.0
270    } else {
271        (current_value - threshold) / threshold * 100.0
272    }
273}
274
275/// Perform a full trend analysis on a sequence of metric values.
276///
277/// `values` is a series of metric values in chronological order (index = run number).
278/// `metric_name` is the metric identifier (e.g., "wall_ms").
279/// `threshold` is the absolute budget threshold value.
280/// `direction_lower_is_better` specifies the metric direction.
281/// `config` controls classification thresholds.
282///
283/// Returns `None` if fewer than 2 data points are provided or regression fails.
284///
285/// # Examples
286///
287/// ```
288/// use perfgate_stats::trend::{TrendConfig, analyze_trend, DriftClass};
289///
290/// let values = vec![100.0, 102.0, 104.0, 106.0, 108.0];
291/// let result = analyze_trend(&values, "wall_ms", 150.0, true, &TrendConfig::default()).unwrap();
292/// assert_eq!(result.drift, DriftClass::Degrading);
293/// assert!(result.runs_to_breach.is_some());
294/// ```
295pub fn analyze_trend(
296    values: &[f64],
297    metric_name: &str,
298    threshold: f64,
299    direction_lower_is_better: bool,
300    config: &TrendConfig,
301) -> Option<TrendAnalysis> {
302    if values.len() < 2 {
303        return None;
304    }
305
306    let points: Vec<(f64, f64)> = values
307        .iter()
308        .enumerate()
309        .map(|(i, &v)| (i as f64, v))
310        .collect();
311
312    let (slope, intercept, r_squared) = linear_regression(&points)?;
313
314    let current_run = (values.len() - 1) as f64;
315    let current_value = slope * current_run + intercept;
316
317    let headroom_pct = compute_headroom_pct(current_value, threshold, direction_lower_is_better);
318
319    let breach_run = predict_breach_run(
320        slope,
321        intercept,
322        current_run,
323        threshold,
324        direction_lower_is_better,
325    );
326
327    let runs_to_breach = breach_run.map(|br| {
328        let remaining = br - current_run;
329        remaining.ceil().max(1.0) as u32
330    });
331
332    let drift = classify_drift(
333        slope,
334        r_squared,
335        current_value,
336        threshold,
337        direction_lower_is_better,
338        config,
339        runs_to_breach,
340    );
341
342    Some(TrendAnalysis {
343        metric: metric_name.to_string(),
344        slope_per_run: slope,
345        intercept,
346        r_squared,
347        drift,
348        runs_to_breach,
349        current_headroom_pct: headroom_pct,
350        sample_count: values.len(),
351    })
352}
353
354/// Render a mini ASCII spark chart for a series of values.
355///
356/// Returns a string like `_-^-_--^^` representing the relative magnitude
357/// of each value within the series.
358///
359/// # Examples
360///
361/// ```
362/// use perfgate_stats::trend::spark_chart;
363///
364/// let chart = spark_chart(&[1.0, 2.0, 3.0, 4.0, 5.0]);
365/// assert_eq!(chart.len(), 5);
366/// ```
367pub fn spark_chart(values: &[f64]) -> String {
368    if values.is_empty() {
369        return String::new();
370    }
371
372    let min = values.iter().cloned().fold(f64::INFINITY, f64::min);
373    let max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
374    let range = max - min;
375
376    if range < f64::EPSILON {
377        return "_".repeat(values.len());
378    }
379
380    // Use 8 levels of spark characters
381    let sparks = ['_', '.', '-', '~', '=', '+', '^', '#'];
382
383    values
384        .iter()
385        .map(|&v| {
386            let normalized = (v - min) / range;
387            let idx = (normalized * (sparks.len() - 1) as f64).round() as usize;
388            sparks[idx.min(sparks.len() - 1)]
389        })
390        .collect()
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn linear_regression_perfect_fit() {
399        let points = vec![(0.0, 1.0), (1.0, 3.0), (2.0, 5.0), (3.0, 7.0)];
400        let (slope, intercept, r2) = linear_regression(&points).unwrap();
401        assert!((slope - 2.0).abs() < 1e-10);
402        assert!((intercept - 1.0).abs() < 1e-10);
403        assert!((r2 - 1.0).abs() < 1e-10);
404    }
405
406    #[test]
407    fn linear_regression_flat_line() {
408        let points = vec![(0.0, 5.0), (1.0, 5.0), (2.0, 5.0)];
409        let (slope, intercept, r2) = linear_regression(&points).unwrap();
410        assert!(slope.abs() < 1e-10);
411        assert!((intercept - 5.0).abs() < 1e-10);
412        assert!((r2 - 1.0).abs() < 1e-10);
413    }
414
415    #[test]
416    fn linear_regression_two_points() {
417        let points = vec![(0.0, 10.0), (1.0, 20.0)];
418        let (slope, intercept, r2) = linear_regression(&points).unwrap();
419        assert!((slope - 10.0).abs() < 1e-10);
420        assert!((intercept - 10.0).abs() < 1e-10);
421        assert!((r2 - 1.0).abs() < 1e-10);
422    }
423
424    #[test]
425    fn linear_regression_single_point_returns_none() {
426        assert!(linear_regression(&[(0.0, 5.0)]).is_none());
427    }
428
429    #[test]
430    fn linear_regression_empty_returns_none() {
431        assert!(linear_regression(&[]).is_none());
432    }
433
434    #[test]
435    fn linear_regression_same_x_returns_none() {
436        let points = vec![(1.0, 2.0), (1.0, 4.0), (1.0, 6.0)];
437        assert!(linear_regression(&points).is_none());
438    }
439
440    #[test]
441    fn linear_regression_noisy_data() {
442        // y ~= 2*x + 1 with some noise
443        let points = vec![(0.0, 1.2), (1.0, 2.8), (2.0, 5.1), (3.0, 7.3), (4.0, 8.9)];
444        let (slope, _intercept, r2) = linear_regression(&points).unwrap();
445        // Slope should be approximately 2.0
446        assert!((slope - 2.0).abs() < 0.5);
447        // R-squared should be high for this nearly-linear data
448        assert!(r2 > 0.95);
449    }
450
451    #[test]
452    fn predict_breach_lower_is_better_increasing() {
453        // Value increasing toward threshold of 150
454        let breach = predict_breach_run(2.0, 100.0, 4.0, 150.0, true);
455        assert!(breach.is_some());
456        let br = breach.unwrap();
457        // 2.0 * x + 100 = 150 => x = 25
458        assert!((br - 25.0).abs() < 1e-10);
459    }
460
461    #[test]
462    fn predict_breach_lower_is_better_decreasing() {
463        // Value decreasing away from threshold (improving)
464        let breach = predict_breach_run(-2.0, 100.0, 4.0, 150.0, true);
465        assert!(breach.is_none());
466    }
467
468    #[test]
469    fn predict_breach_already_past() {
470        // Current value already past threshold
471        let breach = predict_breach_run(2.0, 160.0, 4.0, 150.0, true);
472        assert!(breach.is_none());
473    }
474
475    #[test]
476    fn predict_breach_higher_is_better_decreasing() {
477        // Throughput dropping toward threshold of 50
478        let breach = predict_breach_run(-3.0, 200.0, 10.0, 50.0, false);
479        assert!(breach.is_some());
480        // -3 * x + 200 = 50 => x = 50
481        let br = breach.unwrap();
482        assert!((br - 50.0).abs() < 1e-10);
483    }
484
485    #[test]
486    fn predict_breach_zero_slope() {
487        assert!(predict_breach_run(0.0, 100.0, 4.0, 150.0, true).is_none());
488    }
489
490    #[test]
491    fn classify_drift_stable_low_r2() {
492        let drift = classify_drift(1.0, 0.1, 100.0, 150.0, true, &TrendConfig::default(), None);
493        assert_eq!(drift, DriftClass::Stable);
494    }
495
496    #[test]
497    fn classify_drift_stable_small_slope() {
498        let drift = classify_drift(
499            0.0001,
500            0.9,
501            100.0,
502            150.0,
503            true,
504            &TrendConfig::default(),
505            None,
506        );
507        assert_eq!(drift, DriftClass::Stable);
508    }
509
510    #[test]
511    fn classify_drift_improving() {
512        // Lower is better, slope negative => improving
513        let drift = classify_drift(-2.0, 0.9, 100.0, 150.0, true, &TrendConfig::default(), None);
514        assert_eq!(drift, DriftClass::Improving);
515    }
516
517    #[test]
518    fn classify_drift_degrading() {
519        // Lower is better, slope positive, will breach in 30 runs
520        let drift = classify_drift(
521            2.0,
522            0.9,
523            100.0,
524            150.0,
525            true,
526            &TrendConfig::default(),
527            Some(30),
528        );
529        assert_eq!(drift, DriftClass::Degrading);
530    }
531
532    #[test]
533    fn classify_drift_critical() {
534        // Lower is better, slope positive, will breach in 5 runs (within default window of 10)
535        let drift = classify_drift(
536            2.0,
537            0.9,
538            100.0,
539            150.0,
540            true,
541            &TrendConfig::default(),
542            Some(5),
543        );
544        assert_eq!(drift, DriftClass::Critical);
545    }
546
547    #[test]
548    fn classify_drift_critical_boundary() {
549        // Exactly at critical window boundary
550        let drift = classify_drift(
551            2.0,
552            0.9,
553            100.0,
554            150.0,
555            true,
556            &TrendConfig::default(),
557            Some(10),
558        );
559        assert_eq!(drift, DriftClass::Critical);
560    }
561
562    #[test]
563    fn classify_drift_just_outside_critical() {
564        let drift = classify_drift(
565            2.0,
566            0.9,
567            100.0,
568            150.0,
569            true,
570            &TrendConfig::default(),
571            Some(11),
572        );
573        assert_eq!(drift, DriftClass::Degrading);
574    }
575
576    #[test]
577    fn headroom_pct_within_budget() {
578        let h = compute_headroom_pct(100.0, 120.0, true);
579        // (120 - 100) / 120 * 100 = 16.67%
580        assert!((h - 16.666666666666668).abs() < 1e-10);
581    }
582
583    #[test]
584    fn headroom_pct_exceeded_budget() {
585        let h = compute_headroom_pct(130.0, 120.0, true);
586        // (120 - 130) / 120 * 100 = -8.33%
587        assert!(h < 0.0);
588    }
589
590    #[test]
591    fn headroom_pct_higher_is_better() {
592        let h = compute_headroom_pct(200.0, 100.0, false);
593        // (200 - 100) / 100 * 100 = 100%
594        assert!((h - 100.0).abs() < 1e-10);
595    }
596
597    #[test]
598    fn headroom_pct_zero_threshold() {
599        assert_eq!(compute_headroom_pct(100.0, 0.0, true), 0.0);
600    }
601
602    #[test]
603    fn analyze_trend_degrading() {
604        let values = vec![100.0, 102.0, 104.0, 106.0, 108.0];
605        let result = analyze_trend(&values, "wall_ms", 120.0, true, &TrendConfig::default());
606        let analysis = result.unwrap();
607        assert_eq!(analysis.metric, "wall_ms");
608        assert!((analysis.slope_per_run - 2.0).abs() < 1e-10);
609        assert!(analysis.r_squared > 0.99);
610        assert!(matches!(
611            analysis.drift,
612            DriftClass::Degrading | DriftClass::Critical
613        ));
614        assert!(analysis.runs_to_breach.is_some());
615        assert!(analysis.current_headroom_pct > 0.0);
616    }
617
618    #[test]
619    fn analyze_trend_improving() {
620        let values = vec![115.0, 112.0, 109.0, 106.0, 103.0];
621        let result = analyze_trend(&values, "wall_ms", 120.0, true, &TrendConfig::default());
622        let analysis = result.unwrap();
623        assert_eq!(analysis.drift, DriftClass::Improving);
624        assert!(analysis.runs_to_breach.is_none());
625    }
626
627    #[test]
628    fn analyze_trend_critical() {
629        // At this rate will breach 120 within a few runs
630        let values = vec![100.0, 105.0, 110.0, 115.0];
631        let result = analyze_trend(&values, "wall_ms", 120.0, true, &TrendConfig::default());
632        let analysis = result.unwrap();
633        assert_eq!(analysis.drift, DriftClass::Critical);
634        assert!(analysis.runs_to_breach.unwrap() <= 10);
635    }
636
637    #[test]
638    fn analyze_trend_single_point() {
639        assert!(analyze_trend(&[100.0], "wall_ms", 120.0, true, &TrendConfig::default()).is_none());
640    }
641
642    #[test]
643    fn analyze_trend_empty() {
644        assert!(analyze_trend(&[], "wall_ms", 120.0, true, &TrendConfig::default()).is_none());
645    }
646
647    #[test]
648    fn analyze_trend_flat() {
649        let values = vec![100.0, 100.0, 100.0, 100.0, 100.0];
650        let result = analyze_trend(&values, "wall_ms", 120.0, true, &TrendConfig::default());
651        let analysis = result.unwrap();
652        assert_eq!(analysis.drift, DriftClass::Stable);
653    }
654
655    #[test]
656    fn analyze_trend_higher_is_better() {
657        // Throughput declining
658        let values = vec![200.0, 195.0, 190.0, 185.0, 180.0];
659        let result = analyze_trend(
660            &values,
661            "throughput_per_s",
662            100.0,
663            false,
664            &TrendConfig::default(),
665        );
666        let analysis = result.unwrap();
667        assert_eq!(analysis.drift, DriftClass::Degrading);
668        assert!(analysis.runs_to_breach.is_some());
669    }
670
671    #[test]
672    fn spark_chart_basic() {
673        let chart = spark_chart(&[1.0, 2.0, 3.0, 4.0, 5.0]);
674        assert_eq!(chart.len(), 5);
675        assert_eq!(chart.chars().next(), Some('_'));
676        assert_eq!(chart.chars().last(), Some('#'));
677    }
678
679    #[test]
680    fn spark_chart_flat() {
681        let chart = spark_chart(&[5.0, 5.0, 5.0]);
682        assert_eq!(chart, "___");
683    }
684
685    #[test]
686    fn spark_chart_empty() {
687        assert_eq!(spark_chart(&[]), "");
688    }
689
690    #[test]
691    fn spark_chart_single() {
692        let chart = spark_chart(&[42.0]);
693        assert_eq!(chart, "_");
694    }
695
696    #[test]
697    fn analyze_trend_sample_count() {
698        let values = vec![10.0, 20.0, 30.0];
699        let analysis =
700            analyze_trend(&values, "wall_ms", 100.0, true, &TrendConfig::default()).unwrap();
701        assert_eq!(analysis.sample_count, 3);
702    }
703
704    #[test]
705    fn runs_to_breach_rounds_up() {
706        // slope=2, intercept=100, current_run=4, threshold=110
707        // breach at x = (110-100)/2 = 5.0, remaining = 5.0 - 4.0 = 1.0 => 1 run
708        let values = vec![100.0, 102.0, 104.0, 106.0, 108.0];
709        let result = analyze_trend(&values, "wall_ms", 110.0, true, &TrendConfig::default());
710        let analysis = result.unwrap();
711        assert_eq!(analysis.runs_to_breach, Some(1));
712    }
713}