mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-22 03:32:39 -05:00
Runs every scenario N times (default 1, unchanged). Every run is still appended to results.jsonl; with N > 1 one "kind": "summary" row per key follows, whose metrics are the medians of the successful runs (user_s and sys_s included, byte/instruction counts rounded) and whose summary holds median, p90 (nearest rank, = max for N < 10), min and max. A failed run marks the summary failed so the gate refuses it. --compare uses the latest row per key; the table labels each side "median of N" or "1 run" and warns when they differ, and --fail-over refuses such keys. --repeat with --with is rejected. docs/testing.md documents the format.
163 lines
7.0 KiB
Rust
163 lines
7.0 KiB
Rust
//! Regression gates must fail when benchmark evidence is corrupt or incomplete.
|
|
use serde_json::{Value, json};
|
|
use std::path::PathBuf;
|
|
use std::process::{Command, Output};
|
|
|
|
struct ResultsDir(PathBuf);
|
|
impl ResultsDir {
|
|
fn new() -> Self {
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
static NEXT: AtomicU64 = AtomicU64::new(0);
|
|
let dir = std::env::temp_dir().join(format!("effectcraft-bench-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
Self(dir)
|
|
}
|
|
fn compare(&self, old: &str, new: &str, gate: bool) -> Output {
|
|
let baseline = self.0.join("baseline.jsonl");
|
|
let candidate = self.0.join("candidate.jsonl");
|
|
std::fs::write(&baseline, old).unwrap();
|
|
std::fs::write(&candidate, new).unwrap();
|
|
let mut command = Command::new(env!("CARGO_BIN_EXE_xtask"));
|
|
command.args(["bench", "--compare"]).arg(baseline).arg("--with").arg(candidate);
|
|
if gate {
|
|
command.args(["--fail-over", "5"]);
|
|
}
|
|
command.output().unwrap()
|
|
}
|
|
}
|
|
impl Drop for ResultsDir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
fn row(scenario: &str, ok: bool) -> Value {
|
|
json!({"scenario": scenario, "run": "full", "ok": ok,
|
|
"settings": {"resolution": "full", "frames": 60, "format": "png"},
|
|
"metrics": {"wall_s": 1.0, "cpu_s": 2.0, "peak_footprint_bytes": 1000, "max_rss_bytes": null, "instructions": null}})
|
|
}
|
|
fn diagnostic(output: &Output) -> String {
|
|
format!("{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr))
|
|
}
|
|
|
|
#[test]
|
|
fn corrupt_records_report_the_input_file_and_line() {
|
|
let files = ResultsDir::new();
|
|
let valid = row("demo", true);
|
|
let mut missing = valid.clone();
|
|
missing["settings"].as_object_mut().unwrap().remove("frames");
|
|
let mut missing_wall = valid.clone();
|
|
missing_wall["metrics"].as_object_mut().unwrap().remove("wall_s");
|
|
let mut bad_metric = valid.clone();
|
|
bad_metric["metrics"]["cpu_s"] = json!("not a measurement");
|
|
let mut negative_metric = valid.clone();
|
|
negative_metric["metrics"]["cpu_s"] = json!(-1);
|
|
for bad in [
|
|
"{".into(),
|
|
"null".into(),
|
|
"[]".into(),
|
|
"{}".into(),
|
|
missing.to_string(),
|
|
missing_wall.to_string(),
|
|
bad_metric.to_string(),
|
|
negative_metric.to_string(),
|
|
] {
|
|
let output = files.compare(&format!("{valid}\n"), &format!("{valid}\n\n \n{bad}\n"), true);
|
|
let message = diagnostic(&output);
|
|
assert!(!output.status.success(), "corrupt input passed: {bad}\n{message}");
|
|
assert!(message.contains("candidate.jsonl:4:"), "{message}");
|
|
}
|
|
let output = files.compare("{\n", &format!("{valid}\n"), false);
|
|
assert!(!output.status.success());
|
|
assert!(diagnostic(&output).contains("baseline.jsonl:1:"));
|
|
}
|
|
|
|
#[test]
|
|
fn gates_reject_failed_latest_runs_and_missing_coverage() {
|
|
let files = ResultsDir::new();
|
|
let good = row("demo", true);
|
|
let failed = row("demo", false);
|
|
let other = row("layers", true);
|
|
for (old, new) in [
|
|
(good.to_string(), String::new()),
|
|
(String::new(), String::new()),
|
|
(good.to_string(), failed.to_string()),
|
|
(good.to_string(), format!("{good}\n{failed}")),
|
|
(format!("{good}\n{failed}"), good.to_string()),
|
|
(good.to_string(), other.to_string()),
|
|
(format!("{good}\n{other}"), good.to_string()),
|
|
(good.to_string(), format!("{good}\n{other}")),
|
|
] {
|
|
let output = files.compare(&old, &new, true);
|
|
assert!(!output.status.success(), "incomplete gate passed: {old} -> {new}\n{}", diagnostic(&output));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn gates_require_a_comparable_resource_metric_for_every_run() {
|
|
let files = ResultsDir::new();
|
|
let good = row("demo", true);
|
|
let mut wall_only = good.clone();
|
|
wall_only["metrics"]["cpu_s"] = Value::Null;
|
|
wall_only["metrics"]["peak_footprint_bytes"] = Value::Null;
|
|
let output = files.compare(&good.to_string(), &wall_only.to_string(), true);
|
|
assert!(!output.status.success(), "{}", diagnostic(&output));
|
|
assert!(diagnostic(&output).contains("comparable"));
|
|
let mut zero = good.clone();
|
|
zero["metrics"]["cpu_s"] = json!(0);
|
|
zero["metrics"]["peak_footprint_bytes"] = json!(0);
|
|
assert!(!files.compare(&zero.to_string(), &good.to_string(), true).status.success());
|
|
let mut mixed = row("layers", true);
|
|
mixed["metrics"] = wall_only["metrics"].clone();
|
|
assert!(!files.compare(&format!("{good}\n{mixed}"), &format!("{good}\n{mixed}"), true).status.success());
|
|
}
|
|
|
|
#[test]
|
|
fn valid_results_keep_comparison_and_exploratory_mode() {
|
|
let files = ResultsDir::new();
|
|
let good = row("demo", true);
|
|
let failed = row("demo", false);
|
|
let output = files.compare(&format!("\n{failed}\n{good}\n"), &format!("{good}\n \n"), true);
|
|
assert!(output.status.success(), "{}", diagnostic(&output));
|
|
assert!(diagnostic(&output).contains("+0.0%"));
|
|
let mut open = good.clone();
|
|
open["run"] = json!("open");
|
|
open["settings"]["resolution"] = json!("-");
|
|
open["settings"]["frames"] = json!(0);
|
|
assert!(files.compare(&open.to_string(), &open.to_string(), true).status.success(), "project-open runs have zero rendered frames");
|
|
let mut regression = good.clone();
|
|
regression["metrics"]["cpu_s"] = json!(3);
|
|
assert!(!files.compare(&good.to_string(), ®ression.to_string(), true).status.success());
|
|
let output = files.compare(&good.to_string(), &format!("{good}\n{failed}"), false);
|
|
assert!(output.status.success(), "exploratory comparison stays available");
|
|
assert!(diagnostic(&output).contains("failed"), "latest failure is visible: {}", diagnostic(&output));
|
|
}
|
|
|
|
/// With `--repeat`, a summary row (metrics = medians) follows the runs; comparison uses it.
|
|
/// The last raw run (cpu 2.0, +0 %) differs from the median (2.2, +10 %), so only the summary fails
|
|
/// the 5 % gate. Both sides are summaries, so the mixed-sample check stays out of the way.
|
|
#[test]
|
|
fn repeat_summary_rows_are_compared_by_median() {
|
|
let dir = ResultsDir::new();
|
|
let summary = |cpu: f64| {
|
|
let mut r = row("demo", true);
|
|
r["metrics"]["cpu_s"] = json!(cpu);
|
|
r["kind"] = json!("summary");
|
|
r["summary"] = json!({"runs": 3, "failed": 0, "stats": {}});
|
|
format!("{r}\n")
|
|
};
|
|
let baseline = summary(2.0);
|
|
let mut candidate = String::new();
|
|
for wall in [1.1, 9.0, 1.0] {
|
|
let mut r = row("demo", true);
|
|
r["metrics"]["cpu_s"] = json!(wall * 2.0);
|
|
candidate.push_str(&format!("{r}\n"));
|
|
}
|
|
let raw_only = dir.compare(&baseline, &candidate, false);
|
|
assert!(diagnostic(&raw_only).contains("median of 3→1 run"), "{}", diagnostic(&raw_only));
|
|
candidate.push_str(&summary(2.2));
|
|
let output = dir.compare(&baseline, &candidate, true);
|
|
assert!(!output.status.success(), "{}", diagnostic(&output));
|
|
assert!(diagnostic(&output).contains("cpu_s +10.0%"), "{}", diagnostic(&output));
|
|
}
|