1use std::cmp::Ordering;
31
32use average::Mean;
33use serde::{Deserialize, Serialize};
34
35use crate::stats::aggregate_measurements;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ChangeDirection {
40 Increase,
42 Decrease,
44}
45
46#[derive(Debug, Clone, PartialEq)]
48pub struct ChangePoint {
49 pub index: usize,
51 pub commit_sha: String,
53 pub magnitude_pct: f64,
55 pub confidence: f64,
57 pub direction: ChangeDirection,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct EpochTransition {
64 pub index: usize,
66 pub from_epoch: u32,
68 pub to_epoch: u32,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct ChangePointConfig {
75 pub enabled: bool,
77 pub min_data_points: usize,
79 pub min_magnitude_pct: f64,
81 pub confidence_threshold: f64,
83 pub penalty: f64,
85}
86
87impl Default for ChangePointConfig {
88 fn default() -> Self {
89 Self {
90 enabled: true,
91 min_data_points: 10,
92 min_magnitude_pct: 5.0,
93 confidence_threshold: 0.75,
94 penalty: 0.5,
95 }
96 }
97}
98
99#[must_use]
110pub fn detect_change_points(measurements: &[f64], config: &ChangePointConfig) -> Vec<usize> {
111 let n = measurements.len();
112 if n < config.min_data_points {
113 return vec![];
114 }
115
116 let variance = calculate_variance(measurements);
119 let scaled_penalty = config.penalty * (n as f64).ln() * variance.max(1.0);
122
123 let mut f = vec![-scaled_penalty; n + 1];
126 let mut cp = vec![0usize; n + 1];
128 let mut r = vec![0usize];
130
131 for t in 1..=n {
132 let (min_cost, best_tau) = r
133 .iter()
134 .map(|&tau| {
135 let cost = f[tau] + segment_cost(measurements, tau, t) + scaled_penalty;
136 (cost, tau)
137 })
138 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal))
139 .unwrap();
140
141 f[t] = min_cost;
142 cp[t] = best_tau;
143
144 r.retain(|&tau| f[tau] + segment_cost(measurements, tau, t) <= min_cost);
146 r.push(t);
147 }
148
149 let mut result = vec![];
151 let mut current = n;
152 while cp[current] > 0 {
153 result.push(cp[current]);
154 current = cp[current];
155 }
156 result.reverse();
157 result
158}
159
160fn segment_cost(measurements: &[f64], start: usize, end: usize) -> f64 {
165 if start >= end {
166 return 0.0;
167 }
168
169 let segment = &measurements[start..end];
170 let mean_calc: Mean = segment.iter().collect();
171 let mean = mean_calc.mean();
172
173 segment.iter().map(|x| (x - mean).powi(2)).sum()
174}
175
176fn calculate_variance(measurements: &[f64]) -> f64 {
180 if measurements.is_empty() {
181 return 0.0;
182 }
183 let stats = aggregate_measurements(measurements.iter());
184 stats.stddev.powi(2) }
186
187fn segment_mean_or_fallback(segment: &[f64], fallback: f64) -> f64 {
191 if segment.is_empty() {
192 fallback
193 } else {
194 let mean_calc: Mean = segment.iter().collect();
195 mean_calc.mean()
196 }
197}
198
199#[must_use]
210pub fn enrich_change_points(
211 indices: &[usize],
212 measurements: &[f64],
213 commit_shas: &[String],
214 config: &ChangePointConfig,
215) -> Vec<ChangePoint> {
216 let mut result = vec![];
217
218 for (i, &idx) in indices.iter().enumerate() {
219 if idx == 0 || idx >= measurements.len() {
220 log::debug!("Changepoint at index {} out of bounds, skipping.", idx);
221 continue;
222 }
223
224 let before_start = if i > 0 { indices[i - 1] } else { 0 };
229 let before_segment = &measurements[before_start..idx];
230 let before_mean =
231 segment_mean_or_fallback(before_segment, measurements.first().copied().unwrap_or(0.0));
232
233 let after_end = if i + 1 < indices.len() {
238 indices[i + 1]
239 } else {
240 measurements.len()
241 };
242 let after_segment = &measurements[idx..after_end];
243 let after_mean =
244 segment_mean_or_fallback(after_segment, measurements.last().copied().unwrap_or(0.0));
245
246 let magnitude_pct = if before_mean.abs() > f64::EPSILON {
248 ((after_mean - before_mean) / before_mean) * 100.0
249 } else {
250 0.0
251 };
252
253 if magnitude_pct.abs() < config.min_magnitude_pct {
255 log::debug!(
256 "Skipping changepoint at index {} as magnitude of {} is below threshold of {}",
257 idx,
258 magnitude_pct.abs(),
259 config.min_magnitude_pct,
260 );
261 continue;
262 }
263
264 let direction = if magnitude_pct > 0.0 {
266 ChangeDirection::Increase
267 } else {
268 ChangeDirection::Decrease
269 };
270
271 let confidence = calculate_confidence(idx, measurements.len(), magnitude_pct.abs());
273
274 if confidence < config.confidence_threshold {
275 log::debug!(
276 "Skipping changepoint at index {} as confidence of {} is below threshold of {}",
277 idx,
278 confidence,
279 config.confidence_threshold
280 );
281 continue;
282 }
283
284 let commit_sha = if idx < commit_shas.len() {
285 commit_shas[idx].clone()
286 } else {
287 String::new()
288 };
289
290 result.push(ChangePoint {
291 index: idx,
292 commit_sha,
293 magnitude_pct,
294 confidence,
295 direction,
296 });
297 }
298
299 result
300}
301
302const CONFIDENCE_MIN_SEGMENT_VERY_LOW: usize = 3;
305const CONFIDENCE_MIN_SEGMENT_LOW: usize = 5;
307const CONFIDENCE_MIN_SEGMENT_MODERATE: usize = 10;
309
310const CONFIDENCE_FACTOR_VERY_LOW: f64 = 0.3;
312const CONFIDENCE_FACTOR_LOW: f64 = 0.6;
314const CONFIDENCE_FACTOR_MODERATE: f64 = 0.8;
316const CONFIDENCE_FACTOR_HIGH: f64 = 1.0;
318
319const CONFIDENCE_MAGNITUDE_SCALE: f64 = 50.0;
321
322const CONFIDENCE_WEIGHT_SIZE: f64 = 0.4;
324const CONFIDENCE_WEIGHT_MAGNITUDE: f64 = 0.6;
326
327fn calculate_confidence(index: usize, total_len: usize, magnitude_pct: f64) -> f64 {
333 let min_segment = index.min(total_len - index);
336 let size_factor = if min_segment < CONFIDENCE_MIN_SEGMENT_VERY_LOW {
337 CONFIDENCE_FACTOR_VERY_LOW
338 } else if min_segment < CONFIDENCE_MIN_SEGMENT_LOW {
339 CONFIDENCE_FACTOR_LOW
340 } else if min_segment < CONFIDENCE_MIN_SEGMENT_MODERATE {
341 CONFIDENCE_FACTOR_MODERATE
342 } else {
343 CONFIDENCE_FACTOR_HIGH
344 };
345
346 let magnitude_factor = (magnitude_pct / CONFIDENCE_MAGNITUDE_SCALE).min(1.0);
349
350 let confidence =
352 CONFIDENCE_WEIGHT_SIZE * size_factor + CONFIDENCE_WEIGHT_MAGNITUDE * magnitude_factor;
353
354 confidence.clamp(0.0, 1.0)
355}
356
357#[must_use]
365pub fn detect_epoch_transitions(epochs: &[u32]) -> Vec<EpochTransition> {
366 let mut transitions = vec![];
367
368 for i in 1..epochs.len() {
369 if epochs[i] != epochs[i - 1] {
370 transitions.push(EpochTransition {
371 index: i,
372 from_epoch: epochs[i - 1],
373 to_epoch: epochs[i],
374 });
375 }
376 }
377
378 transitions
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 #[test]
386 fn test_single_change_point() {
387 let data = vec![10.0, 10.0, 10.0, 10.0, 10.0, 20.0, 20.0, 20.0, 20.0, 20.0];
388 let config = ChangePointConfig {
389 min_data_points: 5,
390 ..Default::default()
391 };
392 let cps = detect_change_points(&data, &config);
393 assert_eq!(cps, vec![5]);
394 }
395
396 #[test]
397 fn test_no_change_points_stable_data() {
398 let data = vec![10.0, 10.1, 9.9, 10.2, 10.0, 10.1, 9.8, 10.3, 10.0, 9.9];
399 let config = ChangePointConfig {
400 min_data_points: 5,
401 penalty: 10.0, ..Default::default()
403 };
404 let cps = detect_change_points(&data, &config);
405 assert!(cps.is_empty());
406 }
407
408 #[test]
409 fn test_multiple_change_points() {
410 let data = vec![
411 10.0, 10.0, 10.0, 10.0, 10.0, 20.0, 20.0, 20.0, 20.0, 20.0, 30.0, 30.0, 30.0, 30.0, 30.0, ];
415 let config = ChangePointConfig {
416 min_data_points: 5,
417 penalty: 0.5, ..Default::default()
419 };
420 let cps = detect_change_points(&data, &config);
421 assert_eq!(cps, vec![5, 10]);
422 }
423
424 #[test]
425 fn test_insufficient_data() {
426 let data = vec![10.0, 20.0, 30.0];
427 let config = ChangePointConfig::default();
428 let cps = detect_change_points(&data, &config);
429 assert!(cps.is_empty());
430 }
431
432 #[test]
433 fn test_segment_cost() {
434 let data = vec![10.0, 20.0, 30.0];
435 let cost = segment_cost(&data, 0, 3);
438 assert!((cost - 200.0).abs() < f64::EPSILON);
439 }
440
441 #[test]
442 fn test_segment_cost_single_value() {
443 let data = vec![10.0];
444 let cost = segment_cost(&data, 0, 1);
445 assert!((cost - 0.0).abs() < f64::EPSILON);
446 }
447
448 #[test]
449 fn test_enrich_change_points_increase() {
450 let measurements = vec![10.0, 10.0, 10.0, 10.0, 10.0, 20.0, 20.0, 20.0, 20.0, 20.0];
451 let commit_shas: Vec<String> = (0..10).map(|i| format!("sha{}", i)).collect();
452 let config = ChangePointConfig {
453 min_data_points: 5,
454 min_magnitude_pct: 5.0,
455 confidence_threshold: 0.5,
456 ..Default::default()
457 };
458
459 let indices = vec![5];
460 let enriched = enrich_change_points(&indices, &measurements, &commit_shas, &config);
461
462 assert_eq!(enriched.len(), 1);
463 assert_eq!(enriched[0].index, 5);
464 assert_eq!(enriched[0].commit_sha, "sha5");
465 assert!((enriched[0].magnitude_pct - 100.0).abs() < f64::EPSILON);
466 assert_eq!(enriched[0].direction, ChangeDirection::Increase);
467 }
468
469 #[test]
470 fn test_enrich_change_points_decrease() {
471 let measurements = vec![20.0, 20.0, 20.0, 20.0, 20.0, 10.0, 10.0, 10.0, 10.0, 10.0];
472 let commit_shas: Vec<String> = (0..10).map(|i| format!("sha{}", i)).collect();
473 let config = ChangePointConfig {
474 min_data_points: 5,
475 min_magnitude_pct: 5.0,
476 confidence_threshold: 0.5,
477 ..Default::default()
478 };
479
480 let indices = vec![5];
481 let enriched = enrich_change_points(&indices, &measurements, &commit_shas, &config);
482
483 assert_eq!(enriched.len(), 1);
484 assert_eq!(enriched[0].direction, ChangeDirection::Decrease);
485 assert!((enriched[0].magnitude_pct - (-50.0)).abs() < f64::EPSILON);
486 }
487
488 #[test]
489 fn test_enrich_filters_small_changes() {
490 let measurements = vec![10.0, 10.0, 10.0, 10.0, 10.0, 10.2, 10.2, 10.2, 10.2, 10.2];
491 let commit_shas: Vec<String> = (0..10).map(|i| format!("sha{}", i)).collect();
492 let config = ChangePointConfig {
493 min_data_points: 5,
494 min_magnitude_pct: 5.0, confidence_threshold: 0.5,
496 ..Default::default()
497 };
498
499 let indices = vec![5];
500 let enriched = enrich_change_points(&indices, &measurements, &commit_shas, &config);
501
502 assert!(enriched.is_empty());
503 }
504
505 #[test]
506 fn test_detect_epoch_transitions() {
507 let epochs = vec![1, 1, 1, 2, 2, 2, 3, 3];
508 let transitions = detect_epoch_transitions(&epochs);
509
510 assert_eq!(transitions.len(), 2);
511 assert_eq!(transitions[0].index, 3);
512 assert_eq!(transitions[0].from_epoch, 1);
513 assert_eq!(transitions[0].to_epoch, 2);
514 assert_eq!(transitions[1].index, 6);
515 assert_eq!(transitions[1].from_epoch, 2);
516 assert_eq!(transitions[1].to_epoch, 3);
517 }
518
519 #[test]
520 fn test_detect_epoch_transitions_no_changes() {
521 let epochs = vec![1, 1, 1, 1];
522 let transitions = detect_epoch_transitions(&epochs);
523 assert!(transitions.is_empty());
524 }
525
526 #[test]
527 fn test_calculate_confidence() {
528 let conf1 = calculate_confidence(50, 100, 50.0);
530 assert!(conf1 > 0.9, "conf1 = {}", conf1); let conf2 = calculate_confidence(10, 100, 50.0);
534 assert!(conf2 > 0.8, "conf2 = {}", conf2); let conf3 = calculate_confidence(2, 100, 50.0);
538 assert!(
539 conf3 < conf2,
540 "conf3 = {} should be less than conf2 = {}",
541 conf3,
542 conf2
543 );
544
545 let conf4 = calculate_confidence(50, 100, 5.0);
547 assert!(
548 conf4 < conf1,
549 "conf4 = {} should be less than conf1 = {}",
550 conf4,
551 conf1
552 );
553 }
554
555 #[test]
556 fn test_full_change_point_detection_workflow() {
557 let measurements = vec![
560 10.0, 10.2, 9.8, 10.1, 9.9, 15.0, 14.8, 15.2, 15.1, 14.9, 12.0, 11.9, 12.1, 12.0, 11.8, ];
564
565 let commit_shas: Vec<String> = (0..15).map(|i| format!("{:040x}", i)).collect();
566
567 let epochs = vec![1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2];
568
569 let config = ChangePointConfig {
570 enabled: true,
571 min_data_points: 5,
572 min_magnitude_pct: 10.0,
573 confidence_threshold: 0.5,
574 penalty: 3.0,
575 };
576
577 let raw_cps = detect_change_points(&measurements, &config);
579 assert!(!raw_cps.is_empty(), "Should detect change points");
580
581 let enriched = enrich_change_points(&raw_cps, &measurements, &commit_shas, &config);
583
584 assert!(
586 enriched.iter().any(|cp| cp.magnitude_pct > 0.0),
587 "Should detect regression"
588 );
589
590 let transitions = detect_epoch_transitions(&epochs);
592 assert_eq!(transitions.len(), 1);
593 assert_eq!(transitions[0].index, 10);
594 assert_eq!(transitions[0].from_epoch, 1);
595 assert_eq!(transitions[0].to_epoch, 2);
596 }
597
598 #[test]
599 fn test_gradual_drift_not_detected_as_change_point() {
600 let measurements: Vec<f64> = (0..20).map(|i| 10.0 + (i as f64 * 0.1)).collect();
602
603 let config = ChangePointConfig {
604 enabled: true,
605 min_data_points: 10,
606 min_magnitude_pct: 20.0,
607 confidence_threshold: 0.8,
608 penalty: 10.0, };
610
611 let cps = detect_change_points(&measurements, &config);
612
613 assert!(
616 cps.len() <= 2,
617 "Should not detect many change points in gradual drift"
618 );
619 }
620
621 #[test]
622 fn test_change_point_at_boundary() {
623 let measurements = vec![
626 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0,
627 ];
628 let config = ChangePointConfig {
629 min_data_points: 10,
630 penalty: 1.0, ..Default::default()
632 };
633
634 let cps = detect_change_points(&measurements, &config);
635 assert_eq!(cps, vec![6], "Should detect change point at index 6");
636 }
637
638 #[test]
639 fn test_enrich_with_empty_sha() {
640 let measurements = vec![10.0, 10.0, 10.0, 10.0, 10.0, 20.0, 20.0, 20.0, 20.0, 20.0];
641 let commit_shas: Vec<String> = vec![]; let config = ChangePointConfig {
643 min_data_points: 5,
644 min_magnitude_pct: 5.0,
645 confidence_threshold: 0.5,
646 ..Default::default()
647 };
648
649 let indices = vec![5];
650 let enriched = enrich_change_points(&indices, &measurements, &commit_shas, &config);
651
652 assert_eq!(enriched.len(), 1);
654 assert_eq!(enriched[0].commit_sha, "");
655 }
656
657 #[test]
658 fn test_two_distinct_performance_regressions() {
659 let mut measurements = Vec::new();
662
663 for _ in 0..80 {
665 measurements.push(12.0 + rand::random::<f64>() * 0.5 - 0.25);
666 }
667
668 for _ in 0..80 {
670 measurements.push(17.0 + rand::random::<f64>() * 0.8 - 0.4);
671 }
672
673 for _ in 0..80 {
675 measurements.push(38.0 + rand::random::<f64>() * 1.5 - 0.75);
676 }
677
678 let config = ChangePointConfig {
679 enabled: true,
680 min_data_points: 10,
681 min_magnitude_pct: 5.0,
682 confidence_threshold: 0.7,
683 penalty: 0.5, };
685
686 let cps = detect_change_points(&measurements, &config);
687
688 assert!(
690 cps.len() >= 2,
691 "Expected at least 2 change points, found {}. Change points: {:?}",
692 cps.len(),
693 cps
694 );
695
696 assert!(
698 cps[0] > 70 && cps[0] < 90,
699 "First change point at {} should be around 80",
700 cps[0]
701 );
702
703 assert!(
705 cps[1] > 150 && cps[1] < 170,
706 "Second change point at {} should be around 160",
707 cps[1]
708 );
709 }
710
711 #[test]
712 fn test_penalty_sensitivity_for_multiple_changes() {
713 let data = vec![
715 10.0, 10.0, 10.0, 10.0, 10.0, 15.0, 15.0, 15.0, 15.0, 15.0, 20.0, 20.0, 20.0, 20.0, 20.0, ];
719
720 let config_low = ChangePointConfig {
722 min_data_points: 3,
723 penalty: 0.5,
724 ..Default::default()
725 };
726 let cps_low = detect_change_points(&data, &config_low);
727 assert_eq!(
728 cps_low.len(),
729 2,
730 "Low penalty should detect 2 change points"
731 );
732
733 let config_high = ChangePointConfig {
735 min_data_points: 3,
736 penalty: 5.0,
737 ..Default::default()
738 };
739 let cps_high = detect_change_points(&data, &config_high);
740 assert!(
741 cps_high.len() < cps_low.len(),
742 "High penalty should detect fewer change points than low penalty"
743 );
744 }
745}