1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum DriftClass {
12 Stable,
14 Improving,
16 Degrading,
18 Critical,
20}
21
22impl DriftClass {
23 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#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct TrendAnalysis {
43 pub metric: String,
45 pub slope_per_run: f64,
47 pub intercept: f64,
49 pub r_squared: f64,
51 pub drift: DriftClass,
53 pub runs_to_breach: Option<u32>,
56 pub current_headroom_pct: f64,
59 pub sample_count: usize,
61}
62
63#[derive(Debug, Clone)]
65pub struct TrendConfig {
66 pub critical_window: u32,
68 pub min_r_squared: f64,
70 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
85pub 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 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 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 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
153pub 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 let breach_run = (threshold - intercept) / slope;
175
176 if breach_run <= current_run {
178 return None;
179 }
180
181 let current_value = slope * current_run + intercept;
183 if direction_lower_is_better {
184 if current_value >= threshold {
186 return None; }
188 if slope <= 0.0 {
189 return None; }
191 } else {
192 if current_value <= threshold {
194 return None; }
196 if slope >= 0.0 {
197 return None; }
199 }
200
201 Some(breach_run)
202}
203
204pub 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 < config.min_r_squared {
220 return DriftClass::Stable;
221 }
222
223 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 let moving_toward_threshold = if direction_lower_is_better {
235 slope > 0.0 } else {
237 slope < 0.0 };
239
240 if !moving_toward_threshold {
241 return DriftClass::Improving;
242 }
243
244 if let Some(runs) = runs_to_breach
246 && runs <= config.critical_window
247 {
248 return DriftClass::Critical;
249 }
250
251 DriftClass::Degrading
252}
253
254pub 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
275pub 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
354pub 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 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 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 assert!((slope - 2.0).abs() < 0.5);
447 assert!(r2 > 0.95);
449 }
450
451 #[test]
452 fn predict_breach_lower_is_better_increasing() {
453 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 assert!((br - 25.0).abs() < 1e-10);
459 }
460
461 #[test]
462 fn predict_breach_lower_is_better_decreasing() {
463 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 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 let breach = predict_breach_run(-3.0, 200.0, 10.0, 50.0, false);
479 assert!(breach.is_some());
480 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 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 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 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 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 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 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 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 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 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 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}