1use std::cmp::Ordering;
31
32use average::Mean;
33
34use crate::stats::aggregate_measurements;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum ChangeDirection {
39 Increase,
41 Decrease,
43}
44
45#[derive(Debug, Clone, PartialEq)]
47pub struct ChangePoint {
48 pub index: usize,
50 pub commit_sha: String,
52 pub magnitude_pct: f64,
54 pub confidence: f64,
56 pub direction: ChangeDirection,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct EpochTransition {
63 pub index: usize,
65 pub from_epoch: u32,
67 pub to_epoch: u32,
69}
70
71#[derive(Debug, Clone)]
73pub struct ChangePointConfig {
74 pub min_data_points: usize,
76 pub min_magnitude_pct: f64,
78 pub confidence_threshold: f64,
80 pub penalty: f64,
82}
83
84impl Default for ChangePointConfig {
85 fn default() -> Self {
86 Self {
87 min_data_points: 10,
88 min_magnitude_pct: 5.0,
89 confidence_threshold: 0.75,
90 penalty: 0.5,
91 }
92 }
93}
94
95#[must_use]
106pub fn detect_change_points(measurements: &[f64], config: &ChangePointConfig) -> Vec<usize> {
107 let n = measurements.len();
108 if n < config.min_data_points {
109 return vec![];
110 }
111
112 let variance = calculate_variance(measurements);
115 let scaled_penalty = config.penalty * (n as f64).ln() * variance.max(1.0);
118
119 let mut f = vec![-scaled_penalty; n + 1];
122 let mut cp = vec![0usize; n + 1];
124 let mut r = vec![0usize];
126
127 for t in 1..=n {
128 let (min_cost, best_tau) = r
129 .iter()
130 .map(|&tau| {
131 let cost = f[tau] + segment_cost(measurements, tau, t) + scaled_penalty;
132 (cost, tau)
133 })
134 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal))
135 .unwrap();
136
137 f[t] = min_cost;
138 cp[t] = best_tau;
139
140 r.retain(|&tau| f[tau] + segment_cost(measurements, tau, t) <= min_cost);
142 r.push(t);
143 }
144
145 let mut result = vec![];
147 let mut current = n;
148 while cp[current] > 0 {
149 result.push(cp[current]);
150 current = cp[current];
151 }
152 result.reverse();
153 result
154}
155
156fn segment_cost(measurements: &[f64], start: usize, end: usize) -> f64 {
161 if start >= end {
162 return 0.0;
163 }
164
165 let segment = &measurements[start..end];
166 let mean_calc: Mean = segment.iter().collect();
167 let mean = mean_calc.mean();
168
169 segment.iter().map(|x| (x - mean).powi(2)).sum()
170}
171
172fn calculate_variance(measurements: &[f64]) -> f64 {
176 if measurements.is_empty() {
177 return 0.0;
178 }
179 let stats = aggregate_measurements(measurements.iter());
180 stats.stddev.powi(2) }
182
183fn segment_mean_or_fallback(segment: &[f64], fallback: f64) -> f64 {
187 if segment.is_empty() {
188 fallback
189 } else {
190 let mean_calc: Mean = segment.iter().collect();
191 mean_calc.mean()
192 }
193}
194
195#[must_use]
206pub fn enrich_change_points(
207 indices: &[usize],
208 measurements: &[f64],
209 commit_shas: &[String],
210 config: &ChangePointConfig,
211) -> Vec<ChangePoint> {
212 let mut result = vec![];
213
214 for (i, &idx) in indices.iter().enumerate() {
215 if idx == 0 || idx >= measurements.len() {
216 log::debug!("Changepoint at index {} out of bounds, skipping.", idx);
217 continue;
218 }
219
220 let before_start = if i > 0 { indices[i - 1] } else { 0 };
225 let before_segment = &measurements[before_start..idx];
226 let before_mean =
227 segment_mean_or_fallback(before_segment, measurements.first().copied().unwrap_or(0.0));
228
229 let after_end = if i + 1 < indices.len() {
234 indices[i + 1]
235 } else {
236 measurements.len()
237 };
238 let after_segment = &measurements[idx..after_end];
239 let after_mean =
240 segment_mean_or_fallback(after_segment, measurements.last().copied().unwrap_or(0.0));
241
242 let magnitude_pct = if before_mean.abs() > f64::EPSILON {
244 ((after_mean - before_mean) / before_mean) * 100.0
245 } else {
246 0.0
247 };
248
249 if magnitude_pct.abs() < config.min_magnitude_pct {
251 log::debug!(
252 "Skipping changepoint at index {} as magnitude of {} is below threshold of {}",
253 idx,
254 magnitude_pct.abs(),
255 config.min_magnitude_pct,
256 );
257 continue;
258 }
259
260 let direction = if magnitude_pct > 0.0 {
262 ChangeDirection::Increase
263 } else {
264 ChangeDirection::Decrease
265 };
266
267 let confidence = calculate_confidence(idx, measurements.len(), magnitude_pct.abs());
269
270 if confidence < config.confidence_threshold {
271 log::debug!(
272 "Skipping changepoint at index {} as confidence of {} is below threshold of {}",
273 idx,
274 confidence,
275 config.confidence_threshold
276 );
277 continue;
278 }
279
280 let commit_sha = if idx < commit_shas.len() {
281 commit_shas[idx].clone()
282 } else {
283 String::new()
284 };
285
286 result.push(ChangePoint {
287 index: idx,
288 commit_sha,
289 magnitude_pct,
290 confidence,
291 direction,
292 });
293 }
294
295 result
296}
297
298const CONFIDENCE_MIN_SEGMENT_VERY_LOW: usize = 3;
301const CONFIDENCE_MIN_SEGMENT_LOW: usize = 5;
303const CONFIDENCE_MIN_SEGMENT_MODERATE: usize = 10;
305
306const CONFIDENCE_FACTOR_VERY_LOW: f64 = 0.3;
308const CONFIDENCE_FACTOR_LOW: f64 = 0.6;
310const CONFIDENCE_FACTOR_MODERATE: f64 = 0.8;
312const CONFIDENCE_FACTOR_HIGH: f64 = 1.0;
314
315const CONFIDENCE_MAGNITUDE_SCALE: f64 = 50.0;
317
318const CONFIDENCE_WEIGHT_SIZE: f64 = 0.4;
320const CONFIDENCE_WEIGHT_MAGNITUDE: f64 = 0.6;
322
323fn calculate_confidence(index: usize, total_len: usize, magnitude_pct: f64) -> f64 {
329 let min_segment = index.min(total_len - index);
332 let size_factor = if min_segment < CONFIDENCE_MIN_SEGMENT_VERY_LOW {
333 CONFIDENCE_FACTOR_VERY_LOW
334 } else if min_segment < CONFIDENCE_MIN_SEGMENT_LOW {
335 CONFIDENCE_FACTOR_LOW
336 } else if min_segment < CONFIDENCE_MIN_SEGMENT_MODERATE {
337 CONFIDENCE_FACTOR_MODERATE
338 } else {
339 CONFIDENCE_FACTOR_HIGH
340 };
341
342 let magnitude_factor = (magnitude_pct / CONFIDENCE_MAGNITUDE_SCALE).min(1.0);
345
346 let confidence =
348 CONFIDENCE_WEIGHT_SIZE * size_factor + CONFIDENCE_WEIGHT_MAGNITUDE * magnitude_factor;
349
350 confidence.clamp(0.0, 1.0)
351}
352
353#[must_use]
361pub fn detect_epoch_transitions(epochs: &[u32]) -> Vec<EpochTransition> {
362 let mut transitions = vec![];
363
364 for i in 1..epochs.len() {
365 if epochs[i] != epochs[i - 1] {
366 transitions.push(EpochTransition {
367 index: i,
368 from_epoch: epochs[i - 1],
369 to_epoch: epochs[i],
370 });
371 }
372 }
373
374 transitions
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 #[test]
382 fn test_single_change_point() {
383 let data = vec![10.0, 10.0, 10.0, 10.0, 10.0, 20.0, 20.0, 20.0, 20.0, 20.0];
384 let config = ChangePointConfig {
385 min_data_points: 5,
386 ..Default::default()
387 };
388 let cps = detect_change_points(&data, &config);
389 assert_eq!(cps, vec![5]);
390 }
391
392 #[test]
393 fn test_no_change_points_stable_data() {
394 let data = vec![10.0, 10.1, 9.9, 10.2, 10.0, 10.1, 9.8, 10.3, 10.0, 9.9];
395 let config = ChangePointConfig {
396 min_data_points: 5,
397 penalty: 10.0, ..Default::default()
399 };
400 let cps = detect_change_points(&data, &config);
401 assert!(cps.is_empty());
402 }
403
404 #[test]
405 fn test_multiple_change_points() {
406 let data = vec![
407 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, ];
411 let config = ChangePointConfig {
412 min_data_points: 5,
413 penalty: 0.5, ..Default::default()
415 };
416 let cps = detect_change_points(&data, &config);
417 assert_eq!(cps, vec![5, 10]);
418 }
419
420 #[test]
421 fn test_insufficient_data() {
422 let data = vec![10.0, 20.0, 30.0];
423 let config = ChangePointConfig::default();
424 let cps = detect_change_points(&data, &config);
425 assert!(cps.is_empty());
426 }
427
428 #[test]
429 fn test_segment_cost() {
430 let data = vec![10.0, 20.0, 30.0];
431 let cost = segment_cost(&data, 0, 3);
434 assert!((cost - 200.0).abs() < f64::EPSILON);
435 }
436
437 #[test]
438 fn test_segment_cost_single_value() {
439 let data = vec![10.0];
440 let cost = segment_cost(&data, 0, 1);
441 assert!((cost - 0.0).abs() < f64::EPSILON);
442 }
443
444 #[test]
445 fn test_enrich_change_points_increase() {
446 let measurements = vec![10.0, 10.0, 10.0, 10.0, 10.0, 20.0, 20.0, 20.0, 20.0, 20.0];
447 let commit_shas: Vec<String> = (0..10).map(|i| format!("sha{}", i)).collect();
448 let config = ChangePointConfig {
449 min_data_points: 5,
450 min_magnitude_pct: 5.0,
451 confidence_threshold: 0.5,
452 ..Default::default()
453 };
454
455 let indices = vec![5];
456 let enriched = enrich_change_points(&indices, &measurements, &commit_shas, &config);
457
458 assert_eq!(enriched.len(), 1);
459 assert_eq!(enriched[0].index, 5);
460 assert_eq!(enriched[0].commit_sha, "sha5");
461 assert!((enriched[0].magnitude_pct - 100.0).abs() < f64::EPSILON);
462 assert_eq!(enriched[0].direction, ChangeDirection::Increase);
463 }
464
465 #[test]
466 fn test_enrich_change_points_decrease() {
467 let measurements = vec![20.0, 20.0, 20.0, 20.0, 20.0, 10.0, 10.0, 10.0, 10.0, 10.0];
468 let commit_shas: Vec<String> = (0..10).map(|i| format!("sha{}", i)).collect();
469 let config = ChangePointConfig {
470 min_data_points: 5,
471 min_magnitude_pct: 5.0,
472 confidence_threshold: 0.5,
473 ..Default::default()
474 };
475
476 let indices = vec![5];
477 let enriched = enrich_change_points(&indices, &measurements, &commit_shas, &config);
478
479 assert_eq!(enriched.len(), 1);
480 assert_eq!(enriched[0].direction, ChangeDirection::Decrease);
481 assert!((enriched[0].magnitude_pct - (-50.0)).abs() < f64::EPSILON);
482 }
483
484 #[test]
485 fn test_enrich_filters_small_changes() {
486 let measurements = vec![10.0, 10.0, 10.0, 10.0, 10.0, 10.2, 10.2, 10.2, 10.2, 10.2];
487 let commit_shas: Vec<String> = (0..10).map(|i| format!("sha{}", i)).collect();
488 let config = ChangePointConfig {
489 min_data_points: 5,
490 min_magnitude_pct: 5.0, confidence_threshold: 0.5,
492 ..Default::default()
493 };
494
495 let indices = vec![5];
496 let enriched = enrich_change_points(&indices, &measurements, &commit_shas, &config);
497
498 assert!(enriched.is_empty());
499 }
500
501 #[test]
502 fn test_detect_epoch_transitions() {
503 let epochs = vec![1, 1, 1, 2, 2, 2, 3, 3];
504 let transitions = detect_epoch_transitions(&epochs);
505
506 assert_eq!(transitions.len(), 2);
507 assert_eq!(transitions[0].index, 3);
508 assert_eq!(transitions[0].from_epoch, 1);
509 assert_eq!(transitions[0].to_epoch, 2);
510 assert_eq!(transitions[1].index, 6);
511 assert_eq!(transitions[1].from_epoch, 2);
512 assert_eq!(transitions[1].to_epoch, 3);
513 }
514
515 #[test]
516 fn test_detect_epoch_transitions_no_changes() {
517 let epochs = vec![1, 1, 1, 1];
518 let transitions = detect_epoch_transitions(&epochs);
519 assert!(transitions.is_empty());
520 }
521
522 #[test]
523 fn test_calculate_confidence() {
524 let conf1 = calculate_confidence(50, 100, 50.0);
526 assert!(conf1 > 0.9, "conf1 = {}", conf1); let conf2 = calculate_confidence(10, 100, 50.0);
530 assert!(conf2 > 0.8, "conf2 = {}", conf2); let conf3 = calculate_confidence(2, 100, 50.0);
534 assert!(
535 conf3 < conf2,
536 "conf3 = {} should be less than conf2 = {}",
537 conf3,
538 conf2
539 );
540
541 let conf4 = calculate_confidence(50, 100, 5.0);
543 assert!(
544 conf4 < conf1,
545 "conf4 = {} should be less than conf1 = {}",
546 conf4,
547 conf1
548 );
549 }
550
551 #[test]
552 fn test_full_change_point_detection_workflow() {
553 let measurements = vec![
556 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, ];
560
561 let commit_shas: Vec<String> = (0..15).map(|i| format!("{:040x}", i)).collect();
562
563 let epochs = vec![1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2];
564
565 let config = ChangePointConfig {
566 min_data_points: 5,
567 min_magnitude_pct: 10.0,
568 confidence_threshold: 0.5,
569 penalty: 3.0,
570 };
571
572 let raw_cps = detect_change_points(&measurements, &config);
574 assert!(!raw_cps.is_empty(), "Should detect change points");
575
576 let enriched = enrich_change_points(&raw_cps, &measurements, &commit_shas, &config);
578
579 assert!(
581 enriched.iter().any(|cp| cp.magnitude_pct > 0.0),
582 "Should detect regression"
583 );
584
585 let transitions = detect_epoch_transitions(&epochs);
587 assert_eq!(transitions.len(), 1);
588 assert_eq!(transitions[0].index, 10);
589 assert_eq!(transitions[0].from_epoch, 1);
590 assert_eq!(transitions[0].to_epoch, 2);
591 }
592
593 #[test]
594 fn test_gradual_drift_not_detected_as_change_point() {
595 let measurements: Vec<f64> = (0..20).map(|i| 10.0 + (i as f64 * 0.1)).collect();
597
598 let config = ChangePointConfig {
599 min_data_points: 10,
600 min_magnitude_pct: 20.0,
601 confidence_threshold: 0.8,
602 penalty: 10.0, };
604
605 let cps = detect_change_points(&measurements, &config);
606
607 assert!(
610 cps.len() <= 2,
611 "Should not detect many change points in gradual drift"
612 );
613 }
614
615 #[test]
616 fn test_change_point_at_boundary() {
617 let measurements = vec![
620 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0,
621 ];
622 let config = ChangePointConfig {
623 min_data_points: 10,
624 penalty: 1.0, ..Default::default()
626 };
627
628 let cps = detect_change_points(&measurements, &config);
629 assert_eq!(cps, vec![6], "Should detect change point at index 6");
630 }
631
632 #[test]
633 fn test_enrich_with_empty_sha() {
634 let measurements = vec![10.0, 10.0, 10.0, 10.0, 10.0, 20.0, 20.0, 20.0, 20.0, 20.0];
635 let commit_shas: Vec<String> = vec![]; let config = ChangePointConfig {
637 min_data_points: 5,
638 min_magnitude_pct: 5.0,
639 confidence_threshold: 0.5,
640 ..Default::default()
641 };
642
643 let indices = vec![5];
644 let enriched = enrich_change_points(&indices, &measurements, &commit_shas, &config);
645
646 assert_eq!(enriched.len(), 1);
648 assert_eq!(enriched[0].commit_sha, "");
649 }
650
651 #[test]
652 fn test_two_distinct_performance_regressions() {
653 let mut measurements = Vec::new();
656
657 for _ in 0..80 {
659 measurements.push(12.0 + rand::random::<f64>() * 0.5 - 0.25);
660 }
661
662 for _ in 0..80 {
664 measurements.push(17.0 + rand::random::<f64>() * 0.8 - 0.4);
665 }
666
667 for _ in 0..80 {
669 measurements.push(38.0 + rand::random::<f64>() * 1.5 - 0.75);
670 }
671
672 let config = ChangePointConfig {
673 min_data_points: 10,
674 min_magnitude_pct: 5.0,
675 confidence_threshold: 0.7,
676 penalty: 0.5, };
678
679 let cps = detect_change_points(&measurements, &config);
680
681 assert!(
683 cps.len() >= 2,
684 "Expected at least 2 change points, found {}. Change points: {:?}",
685 cps.len(),
686 cps
687 );
688
689 assert!(
691 cps[0] > 70 && cps[0] < 90,
692 "First change point at {} should be around 80",
693 cps[0]
694 );
695
696 assert!(
698 cps[1] > 150 && cps[1] < 170,
699 "Second change point at {} should be around 160",
700 cps[1]
701 );
702 }
703
704 #[test]
705 fn test_penalty_sensitivity_for_multiple_changes() {
706 let data = vec![
708 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, ];
712
713 let config_low = ChangePointConfig {
715 min_data_points: 3,
716 penalty: 0.5,
717 ..Default::default()
718 };
719 let cps_low = detect_change_points(&data, &config_low);
720 assert_eq!(
721 cps_low.len(),
722 2,
723 "Low penalty should detect 2 change points"
724 );
725
726 let config_high = ChangePointConfig {
728 min_data_points: 3,
729 penalty: 5.0,
730 ..Default::default()
731 };
732 let cps_high = detect_change_points(&data, &config_high);
733 assert!(
734 cps_high.len() < cps_low.len(),
735 "High penalty should detect fewer change points than low penalty"
736 );
737 }
738}