use super::*;
pub(crate) type MetricAccessor = fn(&GauntletRow) -> f64;
const OUTLIER_METRICS: &[(&str, MetricAccessor)] = &[
("spread", |r| r.spread),
("gap_ms", |r| r.gap_ms as f64),
("migrations", |r| r.migrations as f64),
("migration_ratio", |r| r.migration_ratio),
("imbalance", |r| r.imbalance_ratio),
("dsq_depth", |r| r.max_dsq_depth as f64),
("stuck", |r| r.stuck_count),
("fallback", |r| r.fallback_count as f64),
("keep_last", |r| r.keep_last_count as f64),
("worst_p99_wake_latency_us", |r| {
r.ext_metrics
.get("worst_p99_wake_latency_us")
.copied()
.unwrap_or(0.0)
}),
("worst_wake_latency_cv", |r| {
r.ext_metrics
.get("worst_wake_latency_cv")
.copied()
.unwrap_or(0.0)
}),
("worst_mean_run_delay_us", |r| {
r.ext_metrics
.get("worst_mean_run_delay_us")
.copied()
.unwrap_or(0.0)
}),
("worst_run_delay_us", |r| {
r.ext_metrics
.get("worst_run_delay_us")
.copied()
.unwrap_or(0.0)
}),
];
pub(crate) fn mean<I: Iterator<Item = f64>>(iter: I) -> f64 {
let (sum, count) = iter
.filter(|x| x.is_finite())
.fold((0.0_f64, 0usize), |(s, c), x| (s + x, c + 1));
if count == 0 { 0.0 } else { sum / count as f64 }
}
pub(crate) fn std_dev<I: Iterator<Item = f64> + Clone>(iter: I) -> f64 {
let m = mean(iter.clone());
let (sum_sq, count) = iter
.filter(|x| x.is_finite())
.fold((0.0_f64, 0usize), |(s, c), x| {
let d = x - m;
(s + d * d, c + 1)
});
if count < 2 {
0.0
} else {
(sum_sq / (count - 1) as f64).sqrt()
}
}
pub(crate) fn group_field<'a>(row: &'a GauntletRow, col: &str) -> Option<&'a str> {
match col {
"scenario" => Some(row.scenario.as_str()),
"topology" => Some(row.topology.as_str()),
"work_type" => Some(row.work_type.as_str()),
_ => None,
}
}
pub(crate) struct Outlier {
pub(crate) scenario: String,
pub(crate) metric: &'static str,
pub(crate) value: f64,
pub(crate) overall_mean: f64,
pub(crate) sigma: f64,
pub(crate) worst_topos: Vec<String>,
}
impl std::fmt::Display for Outlier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}: {} {:.1} (overall avg {:.1}, +{:.1}\u{03c3})",
self.scenario, self.metric, self.value, self.overall_mean, self.sigma
)?;
if !self.worst_topos.is_empty() {
write!(f, "\n worst on: {}", self.worst_topos.join(", "))?;
}
Ok(())
}
}
pub(crate) fn find_outliers(rows: &[GauntletRow]) -> Vec<Outlier> {
let pass_rows: Vec<&GauntletRow> = rows.iter().filter(|r| r.is_pass()).collect();
if pass_rows.is_empty() {
return Vec::new();
}
let mut by_scenario: BTreeMap<&str, Vec<&GauntletRow>> = BTreeMap::new();
for r in &pass_rows {
by_scenario.entry(r.scenario.as_str()).or_default().push(r);
}
let mut outliers = Vec::new();
for &(name, accessor) in OUTLIER_METRICS {
let overall_mean = mean(pass_rows.iter().map(|r| accessor(r)));
let overall_std = std_dev(pass_rows.iter().map(|r| accessor(r)));
if overall_std < f64::EPSILON {
continue;
}
let threshold = overall_mean + 2.0 * overall_std;
for (&scenario, rows_in_scenario) in &by_scenario {
let scenario_mean = mean(rows_in_scenario.iter().map(|r| accessor(r)));
if scenario_mean <= threshold {
continue;
}
let sigma = (scenario_mean - overall_mean) / overall_std;
let worst = find_worst_topos(rows, scenario, accessor, threshold);
outliers.push(Outlier {
scenario: scenario.to_string(),
metric: name,
value: scenario_mean,
overall_mean,
sigma,
worst_topos: worst,
});
}
}
outliers.sort_by(|a, b| {
b.sigma
.partial_cmp(&a.sigma)
.unwrap_or(std::cmp::Ordering::Equal)
});
outliers
}
pub(crate) fn find_worst_topos(
rows: &[GauntletRow],
scenario: &str,
accessor: MetricAccessor,
threshold: f64,
) -> Vec<String> {
rows.iter()
.filter(|&r| r.scenario == scenario && accessor(r) > threshold)
.map(|r| r.topology.clone())
.collect()
}
pub(crate) fn format_dimension_summary(rows: &[GauntletRow], group_col: &str) -> String {
if rows.is_empty()
|| rows
.first()
.and_then(|r| group_field(r, group_col))
.is_none()
{
return String::new();
}
let mut by_dim: BTreeMap<&str, Vec<&GauntletRow>> = BTreeMap::new();
for r in rows {
if let Some(key) = group_field(r, group_col) {
by_dim.entry(key).or_default().push(r);
}
}
struct GroupStats<'a> {
name: &'a str,
pass_count: usize,
skip_count: usize,
inconc_count: usize,
total: usize,
avg_spread: f64,
avg_gap_ms: f64,
avg_imbalance: f64,
avg_dsq_depth: f64,
total_stuck: f64,
avg_fallback: f64,
}
let mut groups: Vec<GroupStats> = by_dim
.iter()
.map(|(name, group_rows)| GroupStats {
name,
pass_count: group_rows.iter().filter(|r| r.is_pass()).count(),
skip_count: group_rows.iter().filter(|r| r.is_skip()).count(),
inconc_count: group_rows.iter().filter(|r| r.is_inconclusive()).count(),
total: group_rows.len(),
avg_spread: mean(group_rows.iter().map(|r| r.spread)),
avg_gap_ms: mean(group_rows.iter().map(|r| r.gap_ms as f64)),
avg_imbalance: mean(group_rows.iter().map(|r| r.imbalance_ratio)),
avg_dsq_depth: mean(group_rows.iter().map(|r| r.max_dsq_depth as f64)),
total_stuck: group_rows
.iter()
.map(|r| r.stuck_count)
.filter(|x| x.is_finite())
.sum(),
avg_fallback: mean(group_rows.iter().map(|r| r.fallback_count as f64)),
})
.collect();
groups.sort_by(|a, b| {
b.avg_spread
.partial_cmp(&a.avg_spread)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut out = String::new();
for g in &groups {
let fail = g
.total
.saturating_sub(g.pass_count)
.saturating_sub(g.skip_count)
.saturating_sub(g.inconc_count);
let mut line = format!(
" {:<25} {}/{} passed ({} skipped, {} inconclusive, {} failed) avg_spread={:.1}% avg_gap={:.0}ms",
g.name,
g.pass_count,
g.total,
g.skip_count,
g.inconc_count,
fail,
g.avg_spread,
g.avg_gap_ms,
);
if g.avg_imbalance > 1.0 {
line.push_str(&format!(" imbal={:.1}", g.avg_imbalance));
}
if g.avg_dsq_depth > 0.0 {
line.push_str(&format!(" dsq={:.0}", g.avg_dsq_depth));
}
if g.total_stuck > 0.0 {
line.push_str(&format!(" stuck={}", g.total_stuck as u64));
}
if g.avg_fallback > 0.0 {
line.push_str(&format!(" fallback={:.0}", g.avg_fallback));
}
line.push('\n');
out.push_str(&line);
}
out
}
pub fn analyze_rows(rows: &[GauntletRow]) -> String {
if rows.is_empty() {
return String::new();
}
let mut report = String::from("\n=== GAUNTLET ANALYSIS ===\n\n");
let outliers = find_outliers(rows);
if outliers.is_empty() {
report.push_str("No outliers detected.\n");
} else {
report.push_str("Outliers detected:\n");
for o in &outliers {
report.push_str(&format!(" {o}\n"));
}
}
report.push_str("\nBy scenario (worst first):\n");
report.push_str(&format_dimension_summary(rows, "scenario"));
report.push_str("\nBy topology:\n");
report.push_str(&format_dimension_summary(rows, "topology"));
let work_types: std::collections::BTreeSet<&str> =
rows.iter().map(|r| r.work_type.as_str()).collect();
if work_types.len() > 1 {
report.push_str("\nBy work_type:\n");
report.push_str(&format_dimension_summary(rows, "work_type"));
}
report
}
pub fn list_runs() -> anyhow::Result<()> {
let root = crate::test_support::runs_root();
let hint = "Run `cargo ktstr test` to generate sidecar data.";
if !root.exists() {
eprintln!("no runs found at {}. {hint}", root.display());
return Ok(());
}
let rows = sorted_run_entries(&root)?;
if rows.is_empty() {
eprintln!("no runs found at {}. {hint}", root.display());
return Ok(());
}
let mut table = crate::cli::new_table();
table.set_header(vec!["RUN", "TESTS", "DATE", "ARCH"]);
for (path, count, date, arch) in rows {
let key = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let date_cell = date.unwrap_or_else(|| "-".to_string());
let arch_cell = arch.unwrap_or_else(|| "-".to_string());
table.add_row(vec![key, count.to_string(), date_cell, arch_cell]);
}
println!("{table}");
Ok(())
}
type RunEntryRow = (std::path::PathBuf, usize, Option<String>, Option<String>);
pub(crate) fn sorted_run_entries(root: &std::path::Path) -> std::io::Result<Vec<RunEntryRow>> {
use std::fs;
use std::time::SystemTime;
let mut entries: Vec<(fs::DirEntry, Option<SystemTime>)> = fs::read_dir(root)?
.filter_map(|e| e.ok())
.filter(crate::test_support::is_run_directory)
.map(|e| {
let mtime = e.metadata().ok().and_then(|m| m.modified().ok());
(e, mtime)
})
.collect();
entries.sort_by(|(a, a_mtime), (b, b_mtime)| {
use std::cmp::Reverse;
Reverse(*a_mtime)
.cmp(&Reverse(*b_mtime))
.then_with(|| a.file_name().cmp(&b.file_name()))
});
let rows = entries
.into_iter()
.map(|(entry, _)| {
let path = entry.path();
let sidecars = crate::test_support::collect_sidecars(&path);
let count = sidecars.len();
let date = sidecars
.iter()
.map(|s| s.timestamp.as_str())
.filter(|t| !t.is_empty())
.min()
.map(|s| s.to_string());
let arch = sidecars
.iter()
.find_map(|s| s.host.as_ref().and_then(|h| h.arch.clone()));
(path, count, date, arch)
})
.collect();
Ok(rows)
}
pub fn list_values(json: bool, dir: Option<&std::path::Path>) -> anyhow::Result<String> {
use std::collections::BTreeSet;
let (root, override_archive) = match dir {
Some(d) => (d.to_path_buf(), true),
None => (crate::test_support::runs_root(), false),
};
let mut pool = crate::test_support::collect_pool(&root);
if override_archive {
crate::test_support::apply_archive_source_override(&mut pool);
}
let mut kernels: BTreeSet<Option<String>> = BTreeSet::new();
let mut project_commits: BTreeSet<Option<String>> = BTreeSet::new();
let mut kernel_commits: BTreeSet<Option<String>> = BTreeSet::new();
let mut run_sources: BTreeSet<Option<String>> = BTreeSet::new();
let mut cpu_budgets: BTreeSet<u32> = BTreeSet::new();
let mut schedulers: BTreeSet<String> = BTreeSet::new();
let mut topologies: BTreeSet<String> = BTreeSet::new();
let mut work_types: BTreeSet<String> = BTreeSet::new();
for sc in &pool {
kernels.insert(sc.kernel_version.clone());
project_commits.insert(sc.project_commit.clone());
kernel_commits.insert(sc.kernel_commit.clone());
run_sources.insert(sc.run_source.clone());
if sc.cpu_budget != 0 {
cpu_budgets.insert(sc.cpu_budget);
}
schedulers.insert(sc.scheduler.clone());
topologies.insert(sc.topology.clone());
work_types.insert(sc.work_type.clone());
}
if json {
let kernels_json: Vec<serde_json::Value> = kernels
.iter()
.map(|opt| match opt {
Some(s) => serde_json::Value::String(s.clone()),
None => serde_json::Value::Null,
})
.collect();
let project_commits_json: Vec<serde_json::Value> = project_commits
.iter()
.map(|opt| match opt {
Some(s) => serde_json::Value::String(s.clone()),
None => serde_json::Value::Null,
})
.collect();
let kernel_commits_json: Vec<serde_json::Value> = kernel_commits
.iter()
.map(|opt| match opt {
Some(s) => serde_json::Value::String(s.clone()),
None => serde_json::Value::Null,
})
.collect();
let run_sources_json: Vec<serde_json::Value> = run_sources
.iter()
.map(|opt| match opt {
Some(s) => serde_json::Value::String(s.clone()),
None => serde_json::Value::Null,
})
.collect();
let payload = serde_json::json!({
"kernel": kernels_json,
"commit": project_commits_json,
"kernel_commit": kernel_commits_json,
"source": run_sources_json,
"cpu_budget": cpu_budgets.iter().collect::<Vec<_>>(),
"scheduler": schedulers.iter().collect::<Vec<_>>(),
"topology": topologies.iter().collect::<Vec<_>>(),
"work_type": work_types.iter().collect::<Vec<_>>(),
});
return serde_json::to_string_pretty(&payload)
.map(|mut s| {
s.push('\n');
s
})
.map_err(|e| anyhow::anyhow!("serialize list-values JSON: {e}"));
}
let mut out = String::new();
let render_opt_set = |out: &mut String, label: &str, set: &BTreeSet<Option<String>>| {
out.push_str(label);
out.push('\n');
if set.is_empty() {
out.push_str(" (no sidecars in pool)\n");
} else {
for opt in set {
match opt {
Some(s) => {
out.push_str(" ");
out.push_str(s);
out.push('\n');
}
None => out.push_str(" unknown\n"),
}
}
}
out.push('\n');
};
let render_str_set = |out: &mut String, label: &str, set: &BTreeSet<String>| {
out.push_str(label);
out.push('\n');
if set.is_empty() {
out.push_str(" (no sidecars in pool)\n");
} else {
for s in set {
out.push_str(" ");
out.push_str(s);
out.push('\n');
}
}
out.push('\n');
};
render_opt_set(&mut out, "kernel:", &kernels);
render_opt_set(&mut out, "commit:", &project_commits);
render_opt_set(&mut out, "kernel_commit:", &kernel_commits);
render_opt_set(&mut out, "source:", &run_sources);
out.push_str("cpu_budget:\n");
if cpu_budgets.is_empty() {
if pool.is_empty() {
out.push_str(" (no sidecars in pool)\n");
} else {
out.push_str(" (all runs skipped — no budget recorded)\n");
}
} else {
for b in &cpu_budgets {
out.push_str(&format!(" {b}\n"));
}
}
out.push('\n');
render_str_set(&mut out, "scheduler:", &schedulers);
render_str_set(&mut out, "topology:", &topologies);
render_str_set(&mut out, "work_type:", &work_types);
Ok(out)
}