use chrono::{DateTime, Utc};
use serde::Serialize;
use crate::{report::BenchReport, util::rate};
use super::Baseline;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, clap::ValueEnum, strum::Display)]
#[strum(serialize_all = "kebab-case")]
#[serde(rename_all = "kebab-case")]
pub enum RegressionMetric {
ItersRate,
ItemsRate,
BytesRate,
LatencyMean,
LatencyMedian,
LatencyP90,
LatencyP99,
LatencyMax,
SuccessRatio,
}
impl RegressionMetric {
pub fn display_name(&self) -> &'static str {
match self {
Self::ItersRate => "Iters/s",
Self::ItemsRate => "Items/s",
Self::BytesRate => "Bytes/s",
Self::SuccessRatio => "Success",
Self::LatencyMean => "Avg",
Self::LatencyMedian => "Med",
Self::LatencyP90 => "p90",
Self::LatencyP99 => "p99",
Self::LatencyMax => "Max",
}
}
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DeltaStatus {
Improved,
Regressed,
Unchanged,
}
#[derive(Debug, Clone, Serialize)]
pub struct Delta {
pub current: f64,
pub baseline: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub ratio: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delta: Option<f64>,
pub status: DeltaStatus,
}
#[derive(Debug, Clone, Serialize)]
pub struct LatencyDeltas {
pub mean: Delta,
pub median: Delta,
#[serde(skip_serializing_if = "Option::is_none")]
pub p90: Option<Delta>,
#[serde(skip_serializing_if = "Option::is_none")]
pub p99: Option<Delta>,
pub max: Delta,
}
#[derive(Debug, Clone, Serialize)]
pub struct ThroughputDeltas {
pub iters_rate: Delta,
#[serde(skip_serializing_if = "Option::is_none")]
pub items_rate: Option<Delta>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes_rate: Option<Delta>,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, strum::Display)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum Verdict {
Improved,
Regressed,
Unchanged,
Mixed,
}
#[derive(Debug, Clone, Serialize)]
pub struct Comparison {
pub baseline_name: String,
pub baseline_created_at: DateTime<Utc>,
pub schema_version: u32,
pub noise_threshold: f64,
pub regression_metrics: Vec<RegressionMetric>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub skipped_metrics: Vec<RegressionMetric>,
pub verdict: Verdict,
pub throughput: ThroughputDeltas,
#[serde(skip_serializing_if = "Option::is_none")]
pub latency: Option<LatencyDeltas>,
pub success_ratio: Delta,
}
pub fn compare(
report: &BenchReport,
baseline: &Baseline,
noise_threshold: f64,
regression_metrics: &[RegressionMetric],
) -> Comparison {
let elapsed = report.elapsed.as_secs_f64();
let counter = &report.stats.counter;
let baseline_summary = &baseline.report.summary;
let throughput = ThroughputDeltas {
iters_rate: calculate_throughput_delta(
rate(counter.iters, elapsed),
baseline_summary.iters.rate,
noise_threshold,
),
items_rate: if counter.items > 0 || baseline_summary.items.total > 0 {
Some(calculate_throughput_delta(
rate(counter.items, elapsed),
baseline_summary.items.rate,
noise_threshold,
))
} else {
None
},
bytes_rate: if counter.bytes > 0 || baseline_summary.bytes.total > 0 {
Some(calculate_throughput_delta(
rate(counter.bytes, elapsed),
baseline_summary.bytes.rate,
noise_threshold,
))
} else {
None
},
};
let latency = if report.hist.is_empty() {
None
} else {
baseline.report.latency.as_ref().map(|baseline_latency| LatencyDeltas {
mean: calculate_latency_delta(
report.hist.mean().as_secs_f64(),
baseline_latency.stats.mean,
noise_threshold,
),
median: calculate_latency_delta(
report.hist.median().as_secs_f64(),
baseline_latency.stats.median,
noise_threshold,
),
p90: baseline_latency.percentiles.get("p90").map(|&v| {
calculate_latency_delta(report.hist.value_at_quantile(0.90).as_secs_f64(), v, noise_threshold)
}),
p99: baseline_latency.percentiles.get("p99").map(|&v| {
calculate_latency_delta(report.hist.value_at_quantile(0.99).as_secs_f64(), v, noise_threshold)
}),
max: calculate_latency_delta(
report.hist.max().as_secs_f64(),
baseline_latency.stats.max,
noise_threshold,
),
})
};
let success_ratio =
calculate_success_ratio_delta(report.success_ratio(), baseline_summary.success_ratio, noise_threshold);
let (verdict, skipped) = calculate_verdict(&throughput, latency.as_ref(), &success_ratio, regression_metrics);
Comparison {
baseline_name: baseline.metadata.name.clone(),
baseline_created_at: baseline.metadata.created_at,
schema_version: baseline.schema_version,
noise_threshold,
regression_metrics: regression_metrics.to_vec(),
skipped_metrics: skipped,
verdict,
throughput,
latency,
success_ratio,
}
}
fn calculate_throughput_delta(current: f64, baseline: f64, noise_threshold: f64) -> Delta {
calculate_delta(current, baseline, noise_threshold, true)
}
fn calculate_latency_delta(current: f64, baseline: f64, noise_threshold: f64) -> Delta {
calculate_delta(current, baseline, noise_threshold, false)
}
fn calculate_success_ratio_delta(current: f64, baseline: f64, noise_threshold: f64) -> Delta {
calculate_delta(current, baseline, noise_threshold, true)
}
fn calculate_delta(current: f64, baseline: f64, noise_threshold: f64, higher_is_better: bool) -> Delta {
if baseline == 0.0 && current == 0.0 {
return Delta {
current,
baseline,
ratio: Some(1.0),
delta: Some(0.0),
status: DeltaStatus::Unchanged,
};
}
let (ratio, delta) = if baseline == 0.0 {
(None, None)
} else {
let r = current / baseline;
let d = (current - baseline) / baseline * 100.0;
(Some(r), Some(d))
};
let status = match ratio {
Some(r) => {
let percent_change = (r - 1.0).abs() * 100.0;
if percent_change <= noise_threshold {
DeltaStatus::Unchanged
} else {
let is_improved = if higher_is_better { r > 1.0 } else { r < 1.0 };
if is_improved {
DeltaStatus::Improved
} else {
DeltaStatus::Regressed
}
}
}
None => {
if higher_is_better {
DeltaStatus::Improved
} else {
DeltaStatus::Regressed
}
}
};
Delta { current, baseline, ratio, delta, status }
}
fn calculate_verdict(
throughput: &ThroughputDeltas,
latency: Option<&LatencyDeltas>,
success_ratio: &Delta,
metrics: &[RegressionMetric],
) -> (Verdict, Vec<RegressionMetric>) {
let mut statuses = Vec::new();
let mut skipped = Vec::new();
for metric in metrics {
let status = match metric {
RegressionMetric::ItersRate => Some(throughput.iters_rate.status),
RegressionMetric::ItemsRate => throughput.items_rate.as_ref().map(|d| d.status),
RegressionMetric::BytesRate => throughput.bytes_rate.as_ref().map(|d| d.status),
RegressionMetric::LatencyMean => latency.map(|l| l.mean.status),
RegressionMetric::LatencyMedian => latency.map(|l| l.median.status),
RegressionMetric::LatencyP90 => latency.and_then(|l| l.p90.as_ref().map(|d| d.status)),
RegressionMetric::LatencyP99 => latency.and_then(|l| l.p99.as_ref().map(|d| d.status)),
RegressionMetric::LatencyMax => latency.map(|l| l.max.status),
RegressionMetric::SuccessRatio => Some(success_ratio.status),
};
match status {
Some(s) => statuses.push(s),
None => skipped.push(*metric),
}
}
let has_improved = statuses.contains(&DeltaStatus::Improved);
let has_regressed = statuses.contains(&DeltaStatus::Regressed);
let verdict = match (has_improved, has_regressed) {
(true, true) => Verdict::Mixed,
(true, false) => Verdict::Improved,
(false, true) => Verdict::Regressed,
(false, false) => Verdict::Unchanged,
};
(verdict, skipped)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_calculate_throughput_delta_improved() {
let delta = calculate_throughput_delta(1100.0, 1000.0, 1.0);
assert_eq!(delta.status, DeltaStatus::Improved);
assert!((delta.ratio.unwrap() - 1.1).abs() < 0.001);
}
#[test]
fn test_calculate_throughput_delta_regressed() {
let delta = calculate_throughput_delta(900.0, 1000.0, 1.0);
assert_eq!(delta.status, DeltaStatus::Regressed);
assert!((delta.ratio.unwrap() - 0.9).abs() < 0.001);
}
#[test]
fn test_calculate_throughput_delta_unchanged() {
let delta = calculate_throughput_delta(1005.0, 1000.0, 1.0);
assert_eq!(delta.status, DeltaStatus::Unchanged);
}
#[test]
fn test_calculate_latency_delta_improved() {
let delta = calculate_latency_delta(0.9, 1.0, 1.0);
assert_eq!(delta.status, DeltaStatus::Improved);
}
#[test]
fn test_calculate_latency_delta_regressed() {
let delta = calculate_latency_delta(1.1, 1.0, 1.0);
assert_eq!(delta.status, DeltaStatus::Regressed);
}
#[test]
fn test_calculate_verdict_improved() {
let throughput = ThroughputDeltas {
iters_rate: Delta {
current: 1100.0,
baseline: 1000.0,
ratio: Some(1.1),
delta: Some(10.0),
status: DeltaStatus::Improved,
},
items_rate: None,
bytes_rate: None,
};
let success_ratio = Delta {
current: 1.0,
baseline: 1.0,
ratio: Some(1.0),
delta: Some(0.0),
status: DeltaStatus::Unchanged,
};
let (verdict, skipped) = calculate_verdict(&throughput, None, &success_ratio, &[RegressionMetric::ItersRate]);
assert_eq!(verdict, Verdict::Improved);
assert!(skipped.is_empty());
}
#[test]
fn test_calculate_verdict_mixed() {
let throughput = ThroughputDeltas {
iters_rate: Delta {
current: 1100.0,
baseline: 1000.0,
ratio: Some(1.1),
delta: Some(10.0),
status: DeltaStatus::Improved,
},
items_rate: None,
bytes_rate: None,
};
let success_ratio = Delta {
current: 0.95,
baseline: 0.99,
ratio: Some(0.9596),
delta: Some(-4.04), status: DeltaStatus::Regressed,
};
let (verdict, skipped) = calculate_verdict(
&throughput,
None,
&success_ratio,
&[RegressionMetric::ItersRate, RegressionMetric::SuccessRatio],
);
assert_eq!(verdict, Verdict::Mixed);
assert!(skipped.is_empty());
}
}