use ipfrs_core::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::Duration;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PerformanceMetrics {
pub avg_query_latency: Duration,
pub p99_latency: Duration,
pub throughput_qps: f64,
pub memory_mb: f64,
pub index_size: usize,
}
#[derive(Debug, Clone)]
pub struct RegressionIssue {
pub metric: String,
pub baseline: f64,
pub current: f64,
pub percent_change: f64,
pub severity: f64,
}
#[derive(Debug, Clone)]
pub struct RegressionReport {
pub has_regression: bool,
pub issues: Vec<RegressionIssue>,
pub regression_score: f64,
}
#[derive(Debug, Clone)]
pub struct RegressionConfig {
pub latency_threshold: f64,
pub throughput_threshold: f64,
pub memory_threshold: f64,
}
impl Default for RegressionConfig {
fn default() -> Self {
Self {
latency_threshold: 0.15, throughput_threshold: 0.10, memory_threshold: 0.20, }
}
}
pub struct RegressionDetector {
config: RegressionConfig,
baseline: Option<PerformanceMetrics>,
history: Vec<(std::time::SystemTime, PerformanceMetrics)>,
}
impl RegressionDetector {
pub fn new() -> Self {
Self {
config: RegressionConfig::default(),
baseline: None,
history: Vec::new(),
}
}
pub fn with_config(config: RegressionConfig) -> Self {
Self {
config,
baseline: None,
history: Vec::new(),
}
}
pub fn set_baseline(&mut self, metrics: PerformanceMetrics) -> Result<()> {
self.baseline = Some(metrics);
Ok(())
}
pub fn record_metrics(&mut self, metrics: PerformanceMetrics) {
let now = std::time::SystemTime::now();
self.history.push((now, metrics));
if self.history.len() > 100 {
self.history.remove(0);
}
}
pub fn check_regression(&self, current: &PerformanceMetrics) -> Result<RegressionReport> {
let baseline = self
.baseline
.as_ref()
.ok_or_else(|| ipfrs_core::Error::InvalidInput("No baseline set".into()))?;
let mut issues = Vec::new();
let latency_change = self.calculate_change(
baseline.avg_query_latency.as_micros() as f64,
current.avg_query_latency.as_micros() as f64,
);
if latency_change > self.config.latency_threshold {
issues.push(RegressionIssue {
metric: "avg_query_latency".to_string(),
baseline: baseline.avg_query_latency.as_micros() as f64,
current: current.avg_query_latency.as_micros() as f64,
percent_change: latency_change * 100.0,
severity: (latency_change / self.config.latency_threshold).min(1.0),
});
}
let p99_change = self.calculate_change(
baseline.p99_latency.as_micros() as f64,
current.p99_latency.as_micros() as f64,
);
if p99_change > self.config.latency_threshold {
issues.push(RegressionIssue {
metric: "p99_latency".to_string(),
baseline: baseline.p99_latency.as_micros() as f64,
current: current.p99_latency.as_micros() as f64,
percent_change: p99_change * 100.0,
severity: (p99_change / self.config.latency_threshold).min(1.0),
});
}
let throughput_change =
self.calculate_change(baseline.throughput_qps, current.throughput_qps);
if throughput_change < -self.config.throughput_threshold {
issues.push(RegressionIssue {
metric: "throughput_qps".to_string(),
baseline: baseline.throughput_qps,
current: current.throughput_qps,
percent_change: throughput_change * 100.0,
severity: (-throughput_change / self.config.throughput_threshold).min(1.0),
});
}
let memory_change = self.calculate_change(baseline.memory_mb, current.memory_mb);
if memory_change > self.config.memory_threshold {
issues.push(RegressionIssue {
metric: "memory_mb".to_string(),
baseline: baseline.memory_mb,
current: current.memory_mb,
percent_change: memory_change * 100.0,
severity: (memory_change / self.config.memory_threshold).min(1.0),
});
}
let regression_score = if issues.is_empty() {
0.0
} else {
issues.iter().map(|i| i.severity).sum::<f64>() / issues.len() as f64
};
Ok(RegressionReport {
has_regression: !issues.is_empty(),
issues,
regression_score,
})
}
fn calculate_change(&self, baseline: f64, current: f64) -> f64 {
if baseline == 0.0 {
return 0.0;
}
(current - baseline) / baseline
}
pub fn get_trend(&self, metric_name: &str) -> Vec<(std::time::SystemTime, f64)> {
self.history
.iter()
.map(|(time, metrics)| {
let value = match metric_name {
"avg_query_latency" => metrics.avg_query_latency.as_micros() as f64,
"p99_latency" => metrics.p99_latency.as_micros() as f64,
"throughput_qps" => metrics.throughput_qps,
"memory_mb" => metrics.memory_mb,
_ => 0.0,
};
(*time, value)
})
.collect()
}
pub fn summary(&self) -> HashMap<String, MetricSummary> {
let mut summaries = HashMap::new();
if self.history.is_empty() {
return summaries;
}
let mut latencies = Vec::new();
let mut p99_latencies = Vec::new();
let mut throughputs = Vec::new();
let mut memories = Vec::new();
for (_, metrics) in &self.history {
latencies.push(metrics.avg_query_latency.as_micros() as f64);
p99_latencies.push(metrics.p99_latency.as_micros() as f64);
throughputs.push(metrics.throughput_qps);
memories.push(metrics.memory_mb);
}
summaries.insert(
"avg_query_latency".to_string(),
Self::compute_summary(&latencies),
);
summaries.insert(
"p99_latency".to_string(),
Self::compute_summary(&p99_latencies),
);
summaries.insert(
"throughput_qps".to_string(),
Self::compute_summary(&throughputs),
);
summaries.insert("memory_mb".to_string(), Self::compute_summary(&memories));
summaries
}
fn compute_summary(values: &[f64]) -> MetricSummary {
if values.is_empty() {
return MetricSummary::default();
}
let mut sorted = values.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let min = sorted[0];
let max = sorted[sorted.len() - 1];
let mean = sorted.iter().sum::<f64>() / sorted.len() as f64;
let median = sorted[sorted.len() / 2];
MetricSummary {
min,
max,
mean,
median,
count: values.len(),
}
}
}
impl Default for RegressionDetector {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Default)]
pub struct MetricSummary {
pub min: f64,
pub max: f64,
pub mean: f64,
pub median: f64,
pub count: usize,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_regression_detector_creation() {
let detector = RegressionDetector::new();
assert!(detector.baseline.is_none());
assert_eq!(detector.history.len(), 0);
}
#[test]
fn test_set_baseline() {
let mut detector = RegressionDetector::new();
let metrics = PerformanceMetrics {
avg_query_latency: Duration::from_micros(500),
p99_latency: Duration::from_millis(2),
throughput_qps: 5000.0,
memory_mb: 512.0,
index_size: 100000,
};
detector
.set_baseline(metrics)
.expect("test: set_baseline should succeed with valid metrics");
assert!(detector.baseline.is_some());
}
#[test]
fn test_no_regression() {
let mut detector = RegressionDetector::new();
let baseline = PerformanceMetrics {
avg_query_latency: Duration::from_micros(500),
p99_latency: Duration::from_millis(2),
throughput_qps: 5000.0,
memory_mb: 512.0,
index_size: 100000,
};
detector
.set_baseline(baseline.clone())
.expect("test: set_baseline should succeed with valid metrics");
let report = detector
.check_regression(&baseline)
.expect("test: check_regression should succeed when baseline is set");
assert!(!report.has_regression);
assert_eq!(report.issues.len(), 0);
}
#[test]
fn test_latency_regression() {
let mut detector = RegressionDetector::new();
let baseline = PerformanceMetrics {
avg_query_latency: Duration::from_micros(500),
p99_latency: Duration::from_millis(2),
throughput_qps: 5000.0,
memory_mb: 512.0,
index_size: 100000,
};
detector
.set_baseline(baseline)
.expect("test: set_baseline should succeed with valid metrics");
let current = PerformanceMetrics {
avg_query_latency: Duration::from_micros(750),
p99_latency: Duration::from_millis(2),
throughput_qps: 5000.0,
memory_mb: 512.0,
index_size: 100000,
};
let report = detector
.check_regression(¤t)
.expect("test: check_regression should succeed when baseline is set");
assert!(report.has_regression);
assert!(report
.issues
.iter()
.any(|i| i.metric == "avg_query_latency"));
}
#[test]
fn test_throughput_regression() {
let mut detector = RegressionDetector::new();
let baseline = PerformanceMetrics {
avg_query_latency: Duration::from_micros(500),
p99_latency: Duration::from_millis(2),
throughput_qps: 5000.0,
memory_mb: 512.0,
index_size: 100000,
};
detector
.set_baseline(baseline)
.expect("test: set_baseline should succeed with valid metrics");
let current = PerformanceMetrics {
avg_query_latency: Duration::from_micros(500),
p99_latency: Duration::from_millis(2),
throughput_qps: 4000.0,
memory_mb: 512.0,
index_size: 100000,
};
let report = detector
.check_regression(¤t)
.expect("test: check_regression should succeed when baseline is set");
assert!(report.has_regression);
assert!(report.issues.iter().any(|i| i.metric == "throughput_qps"));
}
#[test]
fn test_memory_regression() {
let mut detector = RegressionDetector::new();
let baseline = PerformanceMetrics {
avg_query_latency: Duration::from_micros(500),
p99_latency: Duration::from_millis(2),
throughput_qps: 5000.0,
memory_mb: 512.0,
index_size: 100000,
};
detector
.set_baseline(baseline)
.expect("test: set_baseline should succeed with valid metrics");
let current = PerformanceMetrics {
avg_query_latency: Duration::from_micros(500),
p99_latency: Duration::from_millis(2),
throughput_qps: 5000.0,
memory_mb: 665.6, index_size: 100000,
};
let report = detector
.check_regression(¤t)
.expect("test: check_regression should succeed when baseline is set");
assert!(report.has_regression);
assert!(report.issues.iter().any(|i| i.metric == "memory_mb"));
}
#[test]
fn test_record_metrics() {
let mut detector = RegressionDetector::new();
let metrics = PerformanceMetrics {
avg_query_latency: Duration::from_micros(500),
p99_latency: Duration::from_millis(2),
throughput_qps: 5000.0,
memory_mb: 512.0,
index_size: 100000,
};
detector.record_metrics(metrics.clone());
detector.record_metrics(metrics);
assert_eq!(detector.history.len(), 2);
}
#[test]
fn test_summary() {
let mut detector = RegressionDetector::new();
for i in 0..10 {
let metrics = PerformanceMetrics {
avg_query_latency: Duration::from_micros(500 + i * 10),
p99_latency: Duration::from_millis(2),
throughput_qps: 5000.0,
memory_mb: 512.0,
index_size: 100000,
};
detector.record_metrics(metrics);
}
let summary = detector.summary();
assert!(summary.contains_key("avg_query_latency"));
assert_eq!(summary["avg_query_latency"].count, 10);
}
#[test]
fn test_custom_thresholds() {
let config = RegressionConfig {
latency_threshold: 0.50, throughput_threshold: 0.30,
memory_threshold: 0.40,
};
let mut detector = RegressionDetector::with_config(config);
let baseline = PerformanceMetrics {
avg_query_latency: Duration::from_micros(500),
p99_latency: Duration::from_millis(2),
throughput_qps: 5000.0,
memory_mb: 512.0,
index_size: 100000,
};
detector
.set_baseline(baseline)
.expect("test: set_baseline should succeed with valid metrics");
let current = PerformanceMetrics {
avg_query_latency: Duration::from_micros(650),
p99_latency: Duration::from_millis(2),
throughput_qps: 5000.0,
memory_mb: 512.0,
index_size: 100000,
};
let report = detector
.check_regression(¤t)
.expect("test: check_regression should succeed when baseline is set");
assert!(!report.has_regression);
}
}