use std::cmp::Ordering;
use average::Mean;
use serde::{Deserialize, Serialize};
use crate::stats::aggregate_measurements;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeDirection {
Increase,
Decrease,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ChangePoint {
pub index: usize,
pub commit_sha: String,
pub magnitude_pct: f64,
pub confidence: f64,
pub direction: ChangeDirection,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpochTransition {
pub index: usize,
pub from_epoch: u32,
pub to_epoch: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangePointConfig {
pub enabled: bool,
pub min_data_points: usize,
pub min_magnitude_pct: f64,
pub confidence_threshold: f64,
pub penalty: f64,
}
impl Default for ChangePointConfig {
fn default() -> Self {
Self {
enabled: true,
min_data_points: 10,
min_magnitude_pct: 5.0,
confidence_threshold: 0.75,
penalty: 0.5,
}
}
}
#[must_use]
pub fn detect_change_points(measurements: &[f64], config: &ChangePointConfig) -> Vec<usize> {
let n = measurements.len();
if n < config.min_data_points {
return vec![];
}
let variance = calculate_variance(measurements);
let scaled_penalty = config.penalty * (n as f64).ln() * variance.max(1.0);
let mut f = vec![-scaled_penalty; n + 1];
let mut cp = vec![0usize; n + 1];
let mut r = vec![0usize];
for t in 1..=n {
let (min_cost, best_tau) = r
.iter()
.map(|&tau| {
let cost = f[tau] + segment_cost(measurements, tau, t) + scaled_penalty;
(cost, tau)
})
.min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal))
.unwrap();
f[t] = min_cost;
cp[t] = best_tau;
r.retain(|&tau| f[tau] + segment_cost(measurements, tau, t) <= min_cost);
r.push(t);
}
let mut result = vec![];
let mut current = n;
while cp[current] > 0 {
result.push(cp[current]);
current = cp[current];
}
result.reverse();
result
}
fn segment_cost(measurements: &[f64], start: usize, end: usize) -> f64 {
if start >= end {
return 0.0;
}
let segment = &measurements[start..end];
let mean_calc: Mean = segment.iter().collect();
let mean = mean_calc.mean();
segment.iter().map(|x| (x - mean).powi(2)).sum()
}
fn calculate_variance(measurements: &[f64]) -> f64 {
if measurements.is_empty() {
return 0.0;
}
let stats = aggregate_measurements(measurements.iter());
stats.stddev.powi(2) }
fn segment_mean_or_fallback(segment: &[f64], fallback: f64) -> f64 {
if segment.is_empty() {
fallback
} else {
let mean_calc: Mean = segment.iter().collect();
mean_calc.mean()
}
}
#[must_use]
pub fn enrich_change_points(
indices: &[usize],
measurements: &[f64],
commit_shas: &[String],
config: &ChangePointConfig,
) -> Vec<ChangePoint> {
let mut result = vec![];
for (i, &idx) in indices.iter().enumerate() {
if idx == 0 || idx >= measurements.len() {
log::debug!("Changepoint at index {} out of bounds, skipping.", idx);
continue;
}
let before_start = if i > 0 { indices[i - 1] } else { 0 };
let before_segment = &measurements[before_start..idx];
let before_mean =
segment_mean_or_fallback(before_segment, measurements.first().copied().unwrap_or(0.0));
let after_end = if i + 1 < indices.len() {
indices[i + 1]
} else {
measurements.len()
};
let after_segment = &measurements[idx..after_end];
let after_mean =
segment_mean_or_fallback(after_segment, measurements.last().copied().unwrap_or(0.0));
let magnitude_pct = if before_mean.abs() > f64::EPSILON {
((after_mean - before_mean) / before_mean) * 100.0
} else {
0.0
};
if magnitude_pct.abs() < config.min_magnitude_pct {
log::debug!(
"Skipping changepoint at index {} as magnitude of {} is below threshold of {}",
idx,
magnitude_pct.abs(),
config.min_magnitude_pct,
);
continue;
}
let direction = if magnitude_pct > 0.0 {
ChangeDirection::Increase
} else {
ChangeDirection::Decrease
};
let confidence = calculate_confidence(idx, measurements.len(), magnitude_pct.abs());
if confidence < config.confidence_threshold {
log::debug!(
"Skipping changepoint at index {} as confidence of {} is below threshold of {}",
idx,
confidence,
config.confidence_threshold
);
continue;
}
let commit_sha = if idx < commit_shas.len() {
commit_shas[idx].clone()
} else {
String::new()
};
result.push(ChangePoint {
index: idx,
commit_sha,
magnitude_pct,
confidence,
direction,
});
}
result
}
const CONFIDENCE_MIN_SEGMENT_VERY_LOW: usize = 3;
const CONFIDENCE_MIN_SEGMENT_LOW: usize = 5;
const CONFIDENCE_MIN_SEGMENT_MODERATE: usize = 10;
const CONFIDENCE_FACTOR_VERY_LOW: f64 = 0.3;
const CONFIDENCE_FACTOR_LOW: f64 = 0.6;
const CONFIDENCE_FACTOR_MODERATE: f64 = 0.8;
const CONFIDENCE_FACTOR_HIGH: f64 = 1.0;
const CONFIDENCE_MAGNITUDE_SCALE: f64 = 50.0;
const CONFIDENCE_WEIGHT_SIZE: f64 = 0.4;
const CONFIDENCE_WEIGHT_MAGNITUDE: f64 = 0.6;
fn calculate_confidence(index: usize, total_len: usize, magnitude_pct: f64) -> f64 {
let min_segment = index.min(total_len - index);
let size_factor = if min_segment < CONFIDENCE_MIN_SEGMENT_VERY_LOW {
CONFIDENCE_FACTOR_VERY_LOW
} else if min_segment < CONFIDENCE_MIN_SEGMENT_LOW {
CONFIDENCE_FACTOR_LOW
} else if min_segment < CONFIDENCE_MIN_SEGMENT_MODERATE {
CONFIDENCE_FACTOR_MODERATE
} else {
CONFIDENCE_FACTOR_HIGH
};
let magnitude_factor = (magnitude_pct / CONFIDENCE_MAGNITUDE_SCALE).min(1.0);
let confidence =
CONFIDENCE_WEIGHT_SIZE * size_factor + CONFIDENCE_WEIGHT_MAGNITUDE * magnitude_factor;
confidence.clamp(0.0, 1.0)
}
#[must_use]
pub fn detect_epoch_transitions(epochs: &[u32]) -> Vec<EpochTransition> {
let mut transitions = vec![];
for i in 1..epochs.len() {
if epochs[i] != epochs[i - 1] {
transitions.push(EpochTransition {
index: i,
from_epoch: epochs[i - 1],
to_epoch: epochs[i],
});
}
}
transitions
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_single_change_point() {
let data = vec![10.0, 10.0, 10.0, 10.0, 10.0, 20.0, 20.0, 20.0, 20.0, 20.0];
let config = ChangePointConfig {
min_data_points: 5,
..Default::default()
};
let cps = detect_change_points(&data, &config);
assert_eq!(cps, vec![5]);
}
#[test]
fn test_no_change_points_stable_data() {
let data = vec![10.0, 10.1, 9.9, 10.2, 10.0, 10.1, 9.8, 10.3, 10.0, 9.9];
let config = ChangePointConfig {
min_data_points: 5,
penalty: 10.0, ..Default::default()
};
let cps = detect_change_points(&data, &config);
assert!(cps.is_empty());
}
#[test]
fn test_multiple_change_points() {
let data = vec![
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, ];
let config = ChangePointConfig {
min_data_points: 5,
penalty: 0.5, ..Default::default()
};
let cps = detect_change_points(&data, &config);
assert_eq!(cps, vec![5, 10]);
}
#[test]
fn test_insufficient_data() {
let data = vec![10.0, 20.0, 30.0];
let config = ChangePointConfig::default();
let cps = detect_change_points(&data, &config);
assert!(cps.is_empty());
}
#[test]
fn test_segment_cost() {
let data = vec![10.0, 20.0, 30.0];
let cost = segment_cost(&data, 0, 3);
assert!((cost - 200.0).abs() < f64::EPSILON);
}
#[test]
fn test_segment_cost_single_value() {
let data = vec![10.0];
let cost = segment_cost(&data, 0, 1);
assert!((cost - 0.0).abs() < f64::EPSILON);
}
#[test]
fn test_enrich_change_points_increase() {
let measurements = vec![10.0, 10.0, 10.0, 10.0, 10.0, 20.0, 20.0, 20.0, 20.0, 20.0];
let commit_shas: Vec<String> = (0..10).map(|i| format!("sha{}", i)).collect();
let config = ChangePointConfig {
min_data_points: 5,
min_magnitude_pct: 5.0,
confidence_threshold: 0.5,
..Default::default()
};
let indices = vec![5];
let enriched = enrich_change_points(&indices, &measurements, &commit_shas, &config);
assert_eq!(enriched.len(), 1);
assert_eq!(enriched[0].index, 5);
assert_eq!(enriched[0].commit_sha, "sha5");
assert!((enriched[0].magnitude_pct - 100.0).abs() < f64::EPSILON);
assert_eq!(enriched[0].direction, ChangeDirection::Increase);
}
#[test]
fn test_enrich_change_points_decrease() {
let measurements = vec![20.0, 20.0, 20.0, 20.0, 20.0, 10.0, 10.0, 10.0, 10.0, 10.0];
let commit_shas: Vec<String> = (0..10).map(|i| format!("sha{}", i)).collect();
let config = ChangePointConfig {
min_data_points: 5,
min_magnitude_pct: 5.0,
confidence_threshold: 0.5,
..Default::default()
};
let indices = vec![5];
let enriched = enrich_change_points(&indices, &measurements, &commit_shas, &config);
assert_eq!(enriched.len(), 1);
assert_eq!(enriched[0].direction, ChangeDirection::Decrease);
assert!((enriched[0].magnitude_pct - (-50.0)).abs() < f64::EPSILON);
}
#[test]
fn test_enrich_filters_small_changes() {
let measurements = vec![10.0, 10.0, 10.0, 10.0, 10.0, 10.2, 10.2, 10.2, 10.2, 10.2];
let commit_shas: Vec<String> = (0..10).map(|i| format!("sha{}", i)).collect();
let config = ChangePointConfig {
min_data_points: 5,
min_magnitude_pct: 5.0, confidence_threshold: 0.5,
..Default::default()
};
let indices = vec![5];
let enriched = enrich_change_points(&indices, &measurements, &commit_shas, &config);
assert!(enriched.is_empty());
}
#[test]
fn test_detect_epoch_transitions() {
let epochs = vec![1, 1, 1, 2, 2, 2, 3, 3];
let transitions = detect_epoch_transitions(&epochs);
assert_eq!(transitions.len(), 2);
assert_eq!(transitions[0].index, 3);
assert_eq!(transitions[0].from_epoch, 1);
assert_eq!(transitions[0].to_epoch, 2);
assert_eq!(transitions[1].index, 6);
assert_eq!(transitions[1].from_epoch, 2);
assert_eq!(transitions[1].to_epoch, 3);
}
#[test]
fn test_detect_epoch_transitions_no_changes() {
let epochs = vec![1, 1, 1, 1];
let transitions = detect_epoch_transitions(&epochs);
assert!(transitions.is_empty());
}
#[test]
fn test_calculate_confidence() {
let conf1 = calculate_confidence(50, 100, 50.0);
assert!(conf1 > 0.9, "conf1 = {}", conf1);
let conf2 = calculate_confidence(10, 100, 50.0);
assert!(conf2 > 0.8, "conf2 = {}", conf2);
let conf3 = calculate_confidence(2, 100, 50.0);
assert!(
conf3 < conf2,
"conf3 = {} should be less than conf2 = {}",
conf3,
conf2
);
let conf4 = calculate_confidence(50, 100, 5.0);
assert!(
conf4 < conf1,
"conf4 = {} should be less than conf1 = {}",
conf4,
conf1
);
}
#[test]
fn test_full_change_point_detection_workflow() {
let measurements = vec![
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, ];
let commit_shas: Vec<String> = (0..15).map(|i| format!("{:040x}", i)).collect();
let epochs = vec![1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2];
let config = ChangePointConfig {
enabled: true,
min_data_points: 5,
min_magnitude_pct: 10.0,
confidence_threshold: 0.5,
penalty: 3.0,
};
let raw_cps = detect_change_points(&measurements, &config);
assert!(!raw_cps.is_empty(), "Should detect change points");
let enriched = enrich_change_points(&raw_cps, &measurements, &commit_shas, &config);
assert!(
enriched.iter().any(|cp| cp.magnitude_pct > 0.0),
"Should detect regression"
);
let transitions = detect_epoch_transitions(&epochs);
assert_eq!(transitions.len(), 1);
assert_eq!(transitions[0].index, 10);
assert_eq!(transitions[0].from_epoch, 1);
assert_eq!(transitions[0].to_epoch, 2);
}
#[test]
fn test_gradual_drift_not_detected_as_change_point() {
let measurements: Vec<f64> = (0..20).map(|i| 10.0 + (i as f64 * 0.1)).collect();
let config = ChangePointConfig {
enabled: true,
min_data_points: 10,
min_magnitude_pct: 20.0,
confidence_threshold: 0.8,
penalty: 10.0, };
let cps = detect_change_points(&measurements, &config);
assert!(
cps.len() <= 2,
"Should not detect many change points in gradual drift"
);
}
#[test]
fn test_change_point_at_boundary() {
let measurements = vec![
10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0,
];
let config = ChangePointConfig {
min_data_points: 10,
penalty: 1.0, ..Default::default()
};
let cps = detect_change_points(&measurements, &config);
assert_eq!(cps, vec![6], "Should detect change point at index 6");
}
#[test]
fn test_enrich_with_empty_sha() {
let measurements = vec![10.0, 10.0, 10.0, 10.0, 10.0, 20.0, 20.0, 20.0, 20.0, 20.0];
let commit_shas: Vec<String> = vec![]; let config = ChangePointConfig {
min_data_points: 5,
min_magnitude_pct: 5.0,
confidence_threshold: 0.5,
..Default::default()
};
let indices = vec![5];
let enriched = enrich_change_points(&indices, &measurements, &commit_shas, &config);
assert_eq!(enriched.len(), 1);
assert_eq!(enriched[0].commit_sha, "");
}
#[test]
fn test_two_distinct_performance_regressions() {
let mut measurements = Vec::new();
for _ in 0..80 {
measurements.push(12.0 + rand::random::<f64>() * 0.5 - 0.25);
}
for _ in 0..80 {
measurements.push(17.0 + rand::random::<f64>() * 0.8 - 0.4);
}
for _ in 0..80 {
measurements.push(38.0 + rand::random::<f64>() * 1.5 - 0.75);
}
let config = ChangePointConfig {
enabled: true,
min_data_points: 10,
min_magnitude_pct: 5.0,
confidence_threshold: 0.7,
penalty: 0.5, };
let cps = detect_change_points(&measurements, &config);
assert!(
cps.len() >= 2,
"Expected at least 2 change points, found {}. Change points: {:?}",
cps.len(),
cps
);
assert!(
cps[0] > 70 && cps[0] < 90,
"First change point at {} should be around 80",
cps[0]
);
assert!(
cps[1] > 150 && cps[1] < 170,
"Second change point at {} should be around 160",
cps[1]
);
}
#[test]
fn test_penalty_sensitivity_for_multiple_changes() {
let data = vec![
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, ];
let config_low = ChangePointConfig {
min_data_points: 3,
penalty: 0.5,
..Default::default()
};
let cps_low = detect_change_points(&data, &config_low);
assert_eq!(
cps_low.len(),
2,
"Low penalty should detect 2 change points"
);
let config_high = ChangePointConfig {
min_data_points: 3,
penalty: 5.0,
..Default::default()
};
let cps_high = detect_change_points(&data, &config_high);
assert!(
cps_high.len() < cps_low.len(),
"High penalty should detect fewer change points than low penalty"
);
}
}