use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use serde::Deserialize;
const LATENCY_DEGRADATION_FRACTION: f64 = 0.15;
const RECALL_DEGRADATION_ABSOLUTE: f64 = 0.03;
const ROLLING_WINDOW: usize = 10;
const MIN_RUNS_FOR_MEDIAN: usize = 2;
#[derive(Debug, Deserialize)]
struct RawRecord {
commit_sha: String,
ac_id: String,
n: u64,
#[serde(default)]
p50_ms: Option<f64>,
#[serde(default)]
p99_ms: Option<f64>,
#[serde(default)]
recall: Option<f64>,
timestamp: String,
}
#[derive(Debug, Clone)]
struct Record {
commit_sha: String,
ac_id: String,
n: u64,
p50_ms: Option<f64>,
p99_ms: Option<f64>,
recall: Option<f64>,
timestamp: String,
ts_epoch: i64,
}
fn fixed_digits(field: &str, width: usize) -> Option<i64> {
if field.len() == width && field.bytes().all(|b| b.is_ascii_digit()) {
field.parse().ok()
} else {
None
}
}
fn parse_rfc3339_epoch(s: &str) -> Result<i64, String> {
let (date, rest) = s.split_once('T').ok_or_else(|| format!("missing 'T': {s}"))?;
let d: Vec<&str> = date.split('-').collect();
if d.len() != 3 {
return Err(format!("bad date: {s}"));
}
let year = fixed_digits(d[0], 4).ok_or_else(|| format!("bad year (need YYYY): {s}"))?;
let month = fixed_digits(d[1], 2).ok_or_else(|| format!("bad month (need MM): {s}"))?;
let day = fixed_digits(d[2], 2).ok_or_else(|| format!("bad day (need DD): {s}"))?;
if !(1..=12).contains(&month) {
return Err(format!("month out of range: {s}"));
}
let leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
let days_in_month = match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if leap => 29,
2 => 28,
_ => 0,
};
if !(1..=days_in_month).contains(&day) {
return Err(format!("day out of range for month: {s}"));
}
let (time_part, offset_secs) = if let Some(t) = rest.strip_suffix('Z') {
(t, 0i64)
} else {
let sign_idx =
rest.rfind(['+', '-']).ok_or_else(|| format!("missing zone designator: {s}"))?;
let (t, off) = rest.split_at(sign_idx);
let sign = if off.starts_with('-') { -1 } else { 1 };
let (oh, om) = off[1..].split_once(':').ok_or_else(|| format!("bad zone offset: {s}"))?;
let oh = fixed_digits(oh, 2).ok_or_else(|| format!("bad offset hour (need HH): {s}"))?;
let om = fixed_digits(om, 2).ok_or_else(|| format!("bad offset minute (need MM): {s}"))?;
if oh > 23 || om > 59 {
return Err(format!("zone offset out of range: {s}"));
}
(t, sign * (oh * 3600 + om * 60))
};
let (time_core, frac) = match time_part.split_once('.') {
Some((core, f)) => (core, Some(f)),
None => (time_part, None),
};
if let Some(f) = frac {
if f.is_empty() || !f.bytes().all(|b| b.is_ascii_digit()) {
return Err(format!("bad fractional seconds: {s}"));
}
}
let tp: Vec<&str> = time_core.split(':').collect();
if tp.len() != 3 {
return Err(format!("bad time: {s}"));
}
let hh = fixed_digits(tp[0], 2).ok_or_else(|| format!("bad hour (need HH): {s}"))?;
let mi = fixed_digits(tp[1], 2).ok_or_else(|| format!("bad minute (need MM): {s}"))?;
let ss = fixed_digits(tp[2], 2).ok_or_else(|| format!("bad second (need SS): {s}"))?;
if hh > 23 || mi > 59 || ss > 60 {
return Err(format!("time out of range: {s}"));
}
let y = if month <= 2 { year - 1 } else { year };
let era = (if y >= 0 { y } else { y - 399 }) / 400;
let yoe = y - era * 400;
let mp = (month + 9) % 12;
let doy = (153 * mp + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146097 + doe - 719468;
Ok(days * 86400 + hh * 3600 + mi * 60 + ss - offset_secs)
}
fn load_record(path: &Path) -> Result<Record, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("{}: cannot read: {e}", path.display()))?;
let raw: RawRecord = serde_json::from_str(&text)
.map_err(|e| format!("{}: invalid JSON / schema: {e}", path.display()))?;
if raw.p50_ms.is_none() && raw.p99_ms.is_none() && raw.recall.is_none() {
return Err(format!(
"{}: record has no comparable metric (need p50_ms/p99_ms or recall)",
path.display()
));
}
let ts_epoch = parse_rfc3339_epoch(&raw.timestamp)
.map_err(|e| format!("{}: malformed timestamp: {e}", path.display()))?;
Ok(Record {
commit_sha: raw.commit_sha,
ac_id: raw.ac_id,
n: raw.n,
p50_ms: raw.p50_ms,
p99_ms: raw.p99_ms,
recall: raw.recall,
timestamp: raw.timestamp,
ts_epoch,
})
}
fn median(values: &[f64]) -> Option<f64> {
if values.is_empty() {
return None;
}
let mut v = values.to_vec();
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mid = v.len() / 2;
if v.len() % 2 == 1 {
Some(v[mid])
} else {
Some((v[mid - 1] + v[mid]) / 2.0)
}
}
#[derive(Debug, serde::Serialize)]
struct GroupVerdict {
ac_id: String,
n: u64,
latest_commit: String,
latest_timestamp: String,
runs: usize,
flagged: bool,
status: String,
reasons: Vec<String>,
}
#[derive(Debug, serde::Serialize)]
struct Report {
flagged: bool,
groups: Vec<GroupVerdict>,
}
fn evaluate_group(ac_id: &str, n: u64, runs: &[Record]) -> GroupVerdict {
let latest = runs.last().expect("group is never empty");
let mut verdict = GroupVerdict {
ac_id: ac_id.to_string(),
n,
latest_commit: latest.commit_sha.clone(),
latest_timestamp: latest.timestamp.clone(),
runs: runs.len(),
flagged: false,
status: "ok".to_string(),
reasons: Vec::new(),
};
if runs.len() < MIN_RUNS_FOR_MEDIAN {
verdict.status = "insufficient-history".to_string();
verdict.reasons.push(format!(
"only {} run(s) in group; need >= {} to form a median",
runs.len(),
MIN_RUNS_FOR_MEDIAN
));
return verdict;
}
let prior = &runs[..runs.len() - 1];
let window_start = prior.len().saturating_sub(ROLLING_WINDOW);
let window = &prior[window_start..];
for (label, get) in [
("p50_ms", &Record::p50_ms as &dyn Fn(&Record) -> Option<f64>),
("p99_ms", &Record::p99_ms as &dyn Fn(&Record) -> Option<f64>),
] {
let prior_vals: Vec<f64> = window.iter().filter_map(get).collect();
if let (Some(latest_v), Some(med)) = (get(latest), median(&prior_vals)) {
if med > 0.0 {
let frac = (latest_v - med) / med;
if frac > LATENCY_DEGRADATION_FRACTION {
verdict.flagged = true;
verdict.reasons.push(format!(
"{label} {latest_v:.2} ms is {:.1}% over rolling median {med:.2} ms (threshold {:.0}%) at commit {}",
frac * 100.0,
LATENCY_DEGRADATION_FRACTION * 100.0,
latest.commit_sha,
));
}
}
}
}
let prior_recall: Vec<f64> = window.iter().filter_map(|r| r.recall).collect();
if let (Some(latest_r), Some(med)) = (latest.recall, median(&prior_recall)) {
let drop = med - latest_r;
if drop > RECALL_DEGRADATION_ABSOLUTE {
verdict.flagged = true;
verdict.reasons.push(format!(
"recall {latest_r:.4} is {drop:.4} below rolling median {med:.4} (threshold {RECALL_DEGRADATION_ABSOLUTE}) at commit {}",
latest.commit_sha,
));
}
}
if verdict.flagged {
verdict.status = "regression".to_string();
}
verdict
}
impl Record {
fn p50_ms(&self) -> Option<f64> {
self.p50_ms
}
fn p99_ms(&self) -> Option<f64> {
self.p99_ms
}
}
fn build_report(dir: &Path) -> Result<Report, String> {
if !dir.is_dir() {
return Err(format!("perf-history directory not found: {}", dir.display()));
}
let mut groups: BTreeMap<(String, u64), Vec<Record>> = BTreeMap::new();
let entries =
std::fs::read_dir(dir).map_err(|e| format!("cannot read dir {}: {e}", dir.display()))?;
for entry in entries {
let entry = entry.map_err(|e| format!("dir entry error: {e}"))?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let rec = load_record(&path)?;
groups.entry((rec.ac_id.clone(), rec.n)).or_default().push(rec);
}
if groups.is_empty() {
return Err(format!("no *.json perf records found in {}", dir.display()));
}
let mut report = Report { flagged: false, groups: Vec::new() };
for ((ac_id, n), mut runs) in groups {
runs.sort_by(|a, b| {
a.ts_epoch.cmp(&b.ts_epoch).then_with(|| a.commit_sha.cmp(&b.commit_sha))
});
let v = evaluate_group(&ac_id, n, &runs);
report.flagged |= v.flagged;
report.groups.push(v);
}
Ok(report)
}
fn print_human(report: &Report) {
println!("perf-regression-check — {} group(s)\n", report.groups.len());
for g in &report.groups {
let marker = if g.flagged {
"REGRESSION"
} else if g.status == "insufficient-history" {
"insufficient"
} else {
"ok"
};
println!(
"[{marker}] {} @ n={} — {} run(s), latest {} ({})",
g.ac_id, g.n, g.runs, g.latest_commit, g.latest_timestamp
);
for reason in &g.reasons {
println!(" - {reason}");
}
}
println!();
if report.flagged {
println!("RESULT: regression(s) flagged.");
} else {
println!("RESULT: no regressions.");
}
}
fn print_usage() {
eprintln!(
"usage: perf-regression-check <perf-history-dir> [--json]\n\
\n\
Reads the append-only perf-history directory, groups runs by\n\
(ac_id, n), and flags the latest run vs the rolling median of the\n\
prior up-to-{ROLLING_WINDOW} runs.\n\
Exit: 0 = clean, 1 = regression flagged, 2 = data missing/malformed."
);
}
fn main() -> ExitCode {
let mut dir: Option<PathBuf> = None;
let mut json = false;
for arg in std::env::args().skip(1) {
match arg.as_str() {
"--json" => json = true,
"-h" | "--help" => {
print_usage();
return ExitCode::from(0);
}
other if other.starts_with('-') => {
eprintln!("unknown flag: {other}");
print_usage();
return ExitCode::from(2);
}
other => {
if dir.is_some() {
eprintln!("unexpected extra argument: {other}");
print_usage();
return ExitCode::from(2);
}
dir = Some(PathBuf::from(other));
}
}
}
let dir = match dir {
Some(d) => d,
None => {
print_usage();
return ExitCode::from(2);
}
};
match build_report(&dir) {
Ok(report) => {
if json {
match serde_json::to_string_pretty(&report) {
Ok(s) => println!("{s}"),
Err(e) => {
eprintln!("failed to serialize report: {e}");
return ExitCode::from(2);
}
}
} else {
print_human(&report);
}
if report.flagged {
ExitCode::from(1)
} else {
ExitCode::from(0)
}
}
Err(e) => {
eprintln!("perf-regression-check: {e}");
ExitCode::from(2)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rfc3339_utc_epoch() {
assert_eq!(parse_rfc3339_epoch("1970-01-01T00:00:00Z").unwrap(), 0);
assert_eq!(parse_rfc3339_epoch("1970-01-02T00:00:00Z").unwrap(), 86_400);
}
#[test]
fn rfc3339_offset_normalizes_to_utc() {
let z = parse_rfc3339_epoch("2026-05-27T21:54:27Z").unwrap();
let off = parse_rfc3339_epoch("2026-05-27T16:54:27-05:00").unwrap();
assert_eq!(z, off);
}
#[test]
fn rfc3339_orders_the_batch_collapse_arc() {
let bug = parse_rfc3339_epoch("2026-05-27T21:47:35Z").unwrap(); let fix = parse_rfc3339_epoch("2026-05-27T21:54:27Z").unwrap(); let v070 = parse_rfc3339_epoch("2026-05-28T01:18:00Z").unwrap(); assert!(bug < fix && fix < v070);
}
#[test]
fn rfc3339_accepts_valid_edge_cases() {
assert!(parse_rfc3339_epoch("2024-02-29T00:00:00Z").is_ok());
assert!(parse_rfc3339_epoch("2026-06-01T07:23:39.500Z").is_ok());
assert!(parse_rfc3339_epoch("2026-06-30T23:59:60Z").is_ok());
assert_eq!(
parse_rfc3339_epoch("2026-06-01T07:23:39.999Z").unwrap(),
parse_rfc3339_epoch("2026-06-01T07:23:39Z").unwrap()
);
}
#[test]
fn rfc3339_rejects_malformed() {
for bad in [
"not-a-date",
"2026-05-27", "2026-05-27 21:54:27Z", "2026-13-01T00:00:00Z", "2026-05-27T25:00:00Z", "2026-05-27T21:54:27", "2026-02-31T00:00:00Z", "2026-02-29T00:00:00Z", "2026-05-27T21:54:27+24:00", "2026-05-27T21:54:27-05:99", "2026-06-01T07:23:39.badZ", "2026-06-01T07:23:39.Z", "2026-6-1T7:3:9Z", "2026-06-01T07:23:39+5:00", "12026-06-01T07:23:39Z", "2026-06-01T07:23:39+7Z", ] {
assert!(parse_rfc3339_epoch(bad).is_err(), "should reject: {bad}");
}
}
}