use serde::{Deserialize, Serialize};
use crate::corpus::ItemId;
use crate::judge::JudgeCriterion;
use crate::report::EvalReport;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ControlRun {
pub first: EvalReport,
pub second: EvalReport,
}
impl ControlRun {
#[must_use]
pub const fn new(first: EvalReport, second: EvalReport) -> Self {
Self { first, second }
}
#[must_use]
pub fn noise_floor(&self) -> NoiseFloor {
let mut moved = Vec::new();
let mut unpaired = 0_usize;
let mut compared = 0_usize;
for after in &self.second.items {
let Some(before) = self.first.item(&after.id) else {
unpaired += 1;
continue;
};
compared += 1;
let pass_rate = after.deterministic_pass_rate() - before.deterministic_pass_rate();
let judge = judge_movement(before, after);
let judge_score = judge.as_ref().map_or(0.0, |moved| moved.delta);
if pass_rate == 0.0 && judge_score == 0.0 {
continue;
}
moved.push(ItemNoise {
item: after.id.clone(),
pass_rate_delta: pass_rate,
judge_score_delta: judge_score,
judge_criterion: judge.map(|moved| moved.criterion),
});
}
unpaired += self
.first
.items
.iter()
.filter(|item| self.second.item(&item.id).is_none())
.count();
NoiseFloor {
suite: self.second.suite.clone(),
items_compared: compared,
unpaired_items: unpaired,
pass_rate: moved
.iter()
.map(|item| item.pass_rate_delta.abs())
.fold(0.0_f64, f64::max),
judge_score: moved
.iter()
.map(|item| item.judge_score_delta.abs())
.fold(0.0_f64, f64::max),
suite_pass_rate: (self.second.deterministic_pass_rate()
- self.first.deterministic_pass_rate())
.abs(),
moved,
}
}
}
struct JudgeMovement {
criterion: JudgeCriterion,
delta: f64,
}
fn judge_movement(
before: &crate::report::ItemReport,
after: &crate::report::ItemReport,
) -> Option<JudgeMovement> {
let previous = before.judge_summaries();
let mut largest: Option<JudgeMovement> = None;
for current in after.judge_summaries() {
let Some(was) = previous
.iter()
.find(|summary| summary.criterion == current.criterion)
.and_then(|summary| summary.mean_score)
else {
continue;
};
let Some(now) = current.mean_score else {
continue;
};
let delta = now - was;
if largest
.as_ref()
.is_none_or(|found| delta.abs() > found.delta.abs())
{
largest = Some(JudgeMovement {
criterion: current.criterion,
delta,
});
}
}
largest
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct NoiseFloor {
pub suite: String,
pub items_compared: usize,
pub unpaired_items: usize,
pub pass_rate: f64,
pub judge_score: f64,
pub suite_pass_rate: f64,
pub moved: Vec<ItemNoise>,
}
impl NoiseFloor {
#[must_use]
pub fn deterministic(suite: impl Into<String>) -> Self {
Self {
suite: suite.into(),
..Self::default()
}
}
#[must_use]
pub fn is_flat(&self) -> bool {
self.moved.is_empty()
}
#[must_use]
pub fn covers_pass_rate(&self, delta: f64) -> bool {
delta.abs() <= self.pass_rate
}
#[must_use]
pub fn covers_judge_score(&self, delta: f64) -> bool {
delta.abs() <= self.judge_score
}
#[must_use]
pub fn summary(&self) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(
out,
"noise floor for {} over {} paired item(s){}: pass rate ±{:.2}, judge score ±{:.2}, \
suite pass rate ±{:.2}",
self.suite,
self.items_compared,
if self.unpaired_items == 0 {
String::new()
} else {
format!(" ({} unpaired, ignored)", self.unpaired_items)
},
self.pass_rate,
self.judge_score,
self.suite_pass_rate
);
if self.is_flat() {
let _ = writeln!(
out,
" nothing moved: every item repeated itself exactly across the two passes"
);
}
for item in &self.moved {
let _ = writeln!(out, " {item}");
}
out
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ItemNoise {
pub item: ItemId,
pub pass_rate_delta: f64,
pub judge_score_delta: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub judge_criterion: Option<JudgeCriterion>,
}
impl std::fmt::Display for ItemNoise {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: pass rate {:+.2}", self.item, self.pass_rate_delta)?;
if let Some(criterion) = self.judge_criterion {
write!(f, ", {criterion} {:+.2}", self.judge_score_delta)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use chrono::DateTime;
use super::*;
use crate::assertions::{AssertionFailure, ExpectationName};
use crate::config::EvalConfig;
use crate::corpus::ItemFingerprint;
use crate::report::{ItemReport, SampleReport};
fn sample(index: u32, failing: bool) -> SampleReport {
SampleReport {
sample: index,
failures: if failing {
vec![AssertionFailure::new(
ExpectationName::Commands,
"[a]",
"[]",
)]
} else {
Vec::new()
},
harness_error: None,
signature: format!("sig{failing}"),
judge: Vec::new(),
acts_proposed: 0,
acts_refused: 0,
commands_journaled: 0,
provider_failures: 0,
cards_created: 0,
abandoned: false,
discarded_answers: Vec::new(),
answer: String::new(),
tasks: Default::default(),
}
}
fn report(failing: &[bool]) -> EvalReport {
EvalReport::new(
"s",
DateTime::from_timestamp(0, 0).unwrap_or_default(),
EvalConfig::default(),
vec![ItemReport {
id: ItemId::new("i"),
name: "An item".to_owned(),
tags: Vec::new(),
fingerprint: ItemFingerprint::default(),
samples: failing
.iter()
.enumerate()
.map(|(index, failing)| sample(u32::try_from(index).unwrap_or(0) + 1, *failing))
.collect(),
}],
)
}
#[test]
fn a_system_that_repeats_itself_has_a_flat_floor() {
let control = ControlRun::new(report(&[false, false]), report(&[false, false]));
let floor = control.noise_floor();
assert!(floor.is_flat());
assert!((floor.pass_rate - 0.0).abs() < 1e-9);
assert!(floor.summary().contains("nothing moved"));
}
#[test]
fn a_system_that_wobbles_reports_the_wobble_as_the_floor() {
let control = ControlRun::new(
report(&[false, false, false, false]),
report(&[false, false, false, true]),
);
let floor = control.noise_floor();
assert_eq!(floor.items_compared, 1);
assert!((floor.pass_rate - 0.25).abs() < 1e-9);
assert!(floor.covers_pass_rate(-0.25));
assert!(!floor.covers_pass_rate(-0.5));
assert_eq!(floor.moved.len(), 1);
}
#[test]
fn an_item_present_in_only_one_pass_is_not_a_movement() {
let mut second = report(&[false]);
second.items[0].id = ItemId::new("other");
let floor = ControlRun::new(report(&[false]), second).noise_floor();
assert_eq!(floor.items_compared, 0);
assert_eq!(floor.unpaired_items, 2);
assert!(floor.is_flat());
}
}