1use serde::{Deserialize, Serialize};
40
41use crate::corpus::ItemId;
42use crate::judge::JudgeCriterion;
43use crate::report::EvalReport;
44
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct ControlRun {
49 pub first: EvalReport,
51 pub second: EvalReport,
53}
54
55impl ControlRun {
56 #[must_use]
58 pub const fn new(first: EvalReport, second: EvalReport) -> Self {
59 Self { first, second }
60 }
61
62 #[must_use]
68 pub fn noise_floor(&self) -> NoiseFloor {
69 let mut moved = Vec::new();
70 let mut unpaired = 0_usize;
71 let mut compared = 0_usize;
72 for after in &self.second.items {
73 let Some(before) = self.first.item(&after.id) else {
74 unpaired += 1;
75 continue;
76 };
77 compared += 1;
78 let pass_rate = after.deterministic_pass_rate() - before.deterministic_pass_rate();
79 let judge = judge_movement(before, after);
80 let judge_score = judge.as_ref().map_or(0.0, |moved| moved.delta);
81 if pass_rate == 0.0 && judge_score == 0.0 {
82 continue;
83 }
84 moved.push(ItemNoise {
85 item: after.id.clone(),
86 pass_rate_delta: pass_rate,
87 judge_score_delta: judge_score,
88 judge_criterion: judge.map(|moved| moved.criterion),
89 });
90 }
91 unpaired += self
92 .first
93 .items
94 .iter()
95 .filter(|item| self.second.item(&item.id).is_none())
96 .count();
97
98 NoiseFloor {
99 suite: self.second.suite.clone(),
100 items_compared: compared,
101 unpaired_items: unpaired,
102 pass_rate: moved
103 .iter()
104 .map(|item| item.pass_rate_delta.abs())
105 .fold(0.0_f64, f64::max),
106 judge_score: moved
107 .iter()
108 .map(|item| item.judge_score_delta.abs())
109 .fold(0.0_f64, f64::max),
110 suite_pass_rate: (self.second.deterministic_pass_rate()
111 - self.first.deterministic_pass_rate())
112 .abs(),
113 moved,
114 }
115 }
116}
117
118struct JudgeMovement {
120 criterion: JudgeCriterion,
121 delta: f64,
122}
123
124fn judge_movement(
125 before: &crate::report::ItemReport,
126 after: &crate::report::ItemReport,
127) -> Option<JudgeMovement> {
128 let previous = before.judge_summaries();
129 let mut largest: Option<JudgeMovement> = None;
130 for current in after.judge_summaries() {
131 let Some(was) = previous
132 .iter()
133 .find(|summary| summary.criterion == current.criterion)
134 .and_then(|summary| summary.mean_score)
135 else {
136 continue;
137 };
138 let Some(now) = current.mean_score else {
139 continue;
140 };
141 let delta = now - was;
142 if largest
143 .as_ref()
144 .is_none_or(|found| delta.abs() > found.delta.abs())
145 {
146 largest = Some(JudgeMovement {
147 criterion: current.criterion,
148 delta,
149 });
150 }
151 }
152 largest
153}
154
155#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
157#[serde(deny_unknown_fields, default)]
158pub struct NoiseFloor {
159 pub suite: String,
161 pub items_compared: usize,
163 pub unpaired_items: usize,
165 pub pass_rate: f64,
168 pub judge_score: f64,
170 pub suite_pass_rate: f64,
173 pub moved: Vec<ItemNoise>,
176}
177
178impl NoiseFloor {
179 #[must_use]
185 pub fn deterministic(suite: impl Into<String>) -> Self {
186 Self {
187 suite: suite.into(),
188 ..Self::default()
189 }
190 }
191
192 #[must_use]
194 pub fn is_flat(&self) -> bool {
195 self.moved.is_empty()
196 }
197
198 #[must_use]
201 pub fn covers_pass_rate(&self, delta: f64) -> bool {
202 delta.abs() <= self.pass_rate
203 }
204
205 #[must_use]
208 pub fn covers_judge_score(&self, delta: f64) -> bool {
209 delta.abs() <= self.judge_score
210 }
211
212 #[must_use]
214 pub fn summary(&self) -> String {
215 use std::fmt::Write as _;
216 let mut out = String::new();
217 let _ = writeln!(
218 out,
219 "noise floor for {} over {} paired item(s){}: pass rate ±{:.2}, judge score ±{:.2}, \
220 suite pass rate ±{:.2}",
221 self.suite,
222 self.items_compared,
223 if self.unpaired_items == 0 {
224 String::new()
225 } else {
226 format!(" ({} unpaired, ignored)", self.unpaired_items)
227 },
228 self.pass_rate,
229 self.judge_score,
230 self.suite_pass_rate
231 );
232 if self.is_flat() {
233 let _ = writeln!(
234 out,
235 " nothing moved: every item repeated itself exactly across the two passes"
236 );
237 }
238 for item in &self.moved {
239 let _ = writeln!(out, " {item}");
240 }
241 out
242 }
243}
244
245#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
247#[serde(deny_unknown_fields)]
248pub struct ItemNoise {
249 pub item: ItemId,
251 pub pass_rate_delta: f64,
253 pub judge_score_delta: f64,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub judge_criterion: Option<JudgeCriterion>,
258}
259
260impl std::fmt::Display for ItemNoise {
261 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262 write!(f, "{}: pass rate {:+.2}", self.item, self.pass_rate_delta)?;
263 if let Some(criterion) = self.judge_criterion {
264 write!(f, ", {criterion} {:+.2}", self.judge_score_delta)?;
265 }
266 Ok(())
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use chrono::DateTime;
273
274 use super::*;
275 use crate::assertions::{AssertionFailure, ExpectationName};
276 use crate::config::EvalConfig;
277 use crate::corpus::ItemFingerprint;
278 use crate::report::{ItemReport, SampleReport};
279
280 fn sample(index: u32, failing: bool) -> SampleReport {
281 SampleReport {
282 sample: index,
283 failures: if failing {
284 vec![AssertionFailure::new(
285 ExpectationName::Commands,
286 "[a]",
287 "[]",
288 )]
289 } else {
290 Vec::new()
291 },
292 harness_error: None,
293 signature: format!("sig{failing}"),
294 judge: Vec::new(),
295 acts_proposed: 0,
296 acts_refused: 0,
297 commands_journaled: 0,
298 provider_failures: 0,
299 cards_created: 0,
300 abandoned: false,
301 discarded_answers: Vec::new(),
302 answer: String::new(),
303 tasks: Default::default(),
304 }
305 }
306
307 fn report(failing: &[bool]) -> EvalReport {
308 EvalReport::new(
309 "s",
310 DateTime::from_timestamp(0, 0).unwrap_or_default(),
311 EvalConfig::default(),
312 vec![ItemReport {
313 id: ItemId::new("i"),
314 name: "An item".to_owned(),
315 tags: Vec::new(),
316 fingerprint: ItemFingerprint::default(),
317 samples: failing
318 .iter()
319 .enumerate()
320 .map(|(index, failing)| sample(u32::try_from(index).unwrap_or(0) + 1, *failing))
321 .collect(),
322 }],
323 )
324 }
325
326 #[test]
327 fn a_system_that_repeats_itself_has_a_flat_floor() {
328 let control = ControlRun::new(report(&[false, false]), report(&[false, false]));
329 let floor = control.noise_floor();
330 assert!(floor.is_flat());
331 assert!((floor.pass_rate - 0.0).abs() < 1e-9);
332 assert!(floor.summary().contains("nothing moved"));
333 }
334
335 #[test]
336 fn a_system_that_wobbles_reports_the_wobble_as_the_floor() {
337 let control = ControlRun::new(
340 report(&[false, false, false, false]),
341 report(&[false, false, false, true]),
342 );
343 let floor = control.noise_floor();
344 assert_eq!(floor.items_compared, 1);
345 assert!((floor.pass_rate - 0.25).abs() < 1e-9);
346 assert!(floor.covers_pass_rate(-0.25));
347 assert!(!floor.covers_pass_rate(-0.5));
348 assert_eq!(floor.moved.len(), 1);
349 }
350
351 #[test]
352 fn an_item_present_in_only_one_pass_is_not_a_movement() {
353 let mut second = report(&[false]);
354 second.items[0].id = ItemId::new("other");
355 let floor = ControlRun::new(report(&[false]), second).noise_floor();
356 assert_eq!(floor.items_compared, 0);
357 assert_eq!(floor.unpaired_items, 2);
358 assert!(floor.is_flat());
359 }
360}