use serde::{Deserialize, Serialize};
use super::{AbComparison, BenchResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Axis {
Qa,
Url,
Determinism,
}
impl Axis {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Qa => "qa",
Self::Url => "url",
Self::Determinism => "determinism",
}
}
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"qa" | "with-qa" | "without-qa" => Some(Self::Qa),
"url" | "with-url" | "without-url" => Some(Self::Url),
"determinism" | "round-1-vs-round-2" | "rerun" => Some(Self::Determinism),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AbReport {
pub axis: Axis,
pub comparisons: Vec<AbComparison>,
pub aggregate_score_delta: i32,
pub aggregate_wall_clock_delta_ms: i64,
}
impl AbReport {
#[must_use]
pub fn new(axis: Axis, pairs: Vec<(BenchResult, BenchResult)>) -> Self {
let comparisons: Vec<AbComparison> = pairs
.into_iter()
.map(|(c, v)| AbComparison::new(c, v))
.collect();
let aggregate_score_delta: i32 = comparisons.iter().map(|c| i32::from(c.score_delta)).sum();
let aggregate_wall_clock_delta_ms: i64 =
comparisons.iter().map(|c| c.wall_clock_delta_ms).sum();
Self {
axis,
comparisons,
aggregate_score_delta,
aggregate_wall_clock_delta_ms,
}
}
#[must_use]
pub fn winner(&self) -> &'static str {
match self.aggregate_score_delta.cmp(&0) {
std::cmp::Ordering::Greater => "variant",
std::cmp::Ordering::Less => "control",
std::cmp::Ordering::Equal => "tie",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn br(template: &str, condition: &str, score: u8, wall_ms: u64) -> BenchResult {
BenchResult {
run_id: format!("ab-test-{template}-{condition}"),
template: template.to_string(),
condition: condition.to_string(),
brain_model: "qwen3.6".to_string(),
verifier_score: score,
verifier_pass: score >= 8,
wall_clock_ms: wall_ms,
rounds: 1,
best_round_restore_fired: false,
failure_category: None,
verifier_feedback: String::new(),
}
}
#[test]
fn axis_parse_accepts_canonical_names() {
assert_eq!(Axis::parse("qa"), Some(Axis::Qa));
assert_eq!(Axis::parse("url"), Some(Axis::Url));
assert_eq!(Axis::parse("determinism"), Some(Axis::Determinism));
}
#[test]
fn axis_parse_accepts_variant_spellings() {
assert_eq!(Axis::parse("WITH-QA"), Some(Axis::Qa));
assert_eq!(Axis::parse("without-url"), Some(Axis::Url));
assert_eq!(Axis::parse("rerun"), Some(Axis::Determinism));
}
#[test]
fn axis_parse_rejects_unknown() {
assert!(Axis::parse("bogus").is_none());
assert!(Axis::parse("").is_none());
}
#[test]
fn report_aggregates_score_deltas() {
let pairs = vec![
(br("a", "control", 6, 1000), br("a", "variant", 9, 1500)),
(br("b", "control", 8, 2000), br("b", "variant", 7, 1800)),
];
let r = AbReport::new(Axis::Qa, pairs);
assert_eq!(r.aggregate_score_delta, 3 + -1);
assert_eq!(r.aggregate_wall_clock_delta_ms, 500 + -200);
assert_eq!(r.winner(), "variant");
}
#[test]
fn report_winner_handles_tie_and_loss() {
let tie = AbReport::new(
Axis::Url,
vec![(br("a", "control", 6, 0), br("a", "variant", 6, 0))],
);
assert_eq!(tie.winner(), "tie");
let loss = AbReport::new(
Axis::Determinism,
vec![(br("a", "control", 9, 0), br("a", "variant", 5, 0))],
);
assert_eq!(loss.winner(), "control");
}
#[test]
fn report_round_trips_through_json() {
let r = AbReport::new(
Axis::Qa,
vec![(
br("storefront", "control", 7, 100),
br("storefront", "variant", 8, 120),
)],
);
let json = serde_json::to_string(&r).unwrap();
let back: AbReport = serde_json::from_str(&json).unwrap();
assert_eq!(r, back);
}
}