Skip to main content

turnframe_eval/
control.rs

1//! A control run: the same corpus, twice, against the same code.
2//!
3//! # Why a harness needs one
4//!
5//! Every other number in this crate is a difference between two runs, and a
6//! difference is only news if it is bigger than the difference the harness
7//! produces when nothing has changed at all. Agents are sampled from a
8//! distribution; judges are too. Without a control, "the pass rate fell from
9//! 1.00 to 0.75" and "the pass rate wobbles by a quarter between any two runs"
10//! are indistinguishable, and a team that cannot tell them apart eventually
11//! learns to ignore its own evaluation.
12//!
13//! So the affordance is first class. [`Runner::run_control`](crate::runner::Runner::run_control)
14//! runs a suite twice against the same harness and hands back a [`ControlRun`];
15//! [`ControlRun::noise_floor`] turns the two reports into a [`NoiseFloor`],
16//! which is the largest movement the *unchanged* system produced against
17//! itself. A [`Comparison`](crate::baseline::Comparison) built with that floor
18//! then labels every change it reports as
19//! [`WithinNoise`](crate::baseline::NoiseVerdict::WithinNoise) or
20//! [`ExceedsNoise`](crate::baseline::NoiseVerdict::ExceedsNoise), so a reader
21//! never has to guess.
22//!
23//! # What a control run cannot check for you
24//!
25//! That both passes really saw the same code and the same corpus. Running them
26//! back to back in one call is the strongest guarantee a library can offer;
27//! deploying a change between the two passes would produce a "noise floor" that
28//! is a measurement of the change, and nothing here can detect that. The
29//! [`NoiseFloor::suite`] name and the fingerprints on both reports are what a
30//! reviewer checks.
31//!
32//! # A floor is a ceiling on credulity, not a licence
33//!
34//! A large noise floor is itself the finding. A corpus whose control run moves
35//! by half is not a corpus that tolerates movement of half; it is a corpus
36//! whose items are too flaky to measure anything, and the honest next step is
37//! more samples per item rather than a wider tolerance.
38
39use serde::{Deserialize, Serialize};
40
41use crate::corpus::ItemId;
42use crate::judge::JudgeCriterion;
43use crate::report::EvalReport;
44
45/// Two runs of one suite against the same code.
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct ControlRun {
49    /// The first pass.
50    pub first: EvalReport,
51    /// The second pass, over the same corpus and the same code.
52    pub second: EvalReport,
53}
54
55impl ControlRun {
56    /// Records two passes as a control run.
57    #[must_use]
58    pub const fn new(first: EvalReport, second: EvalReport) -> Self {
59        Self { first, second }
60    }
61
62    /// The largest movement the unchanged system produced against itself.
63    ///
64    /// Items are joined by identifier; an item that appears in only one pass is
65    /// counted in [`NoiseFloor::unpaired_items`] and contributes nothing to the
66    /// floor, because a movement from "absent" to "present" is not a movement.
67    #[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
118/// One item's largest judge movement, and which criterion it was on.
119struct 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/// How much the unchanged system moved when it was measured twice.
156#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
157#[serde(deny_unknown_fields, default)]
158pub struct NoiseFloor {
159    /// The suite the control run measured.
160    pub suite: String,
161    /// How many items appeared in both passes.
162    pub items_compared: usize,
163    /// How many appeared in only one, and therefore said nothing.
164    pub unpaired_items: usize,
165    /// The largest absolute movement in any one item's deterministic pass rate.
166    /// This is the number a pass-rate difference is judged against.
167    pub pass_rate: f64,
168    /// The largest absolute movement in any one item's mean judge score.
169    pub judge_score: f64,
170    /// The movement of the suite-wide pass rate, for a reader who wants the
171    /// headline rather than the worst item.
172    pub suite_pass_rate: f64,
173    /// Every item that moved at all, worst first is not assumed — they are in
174    /// suite order, so the list reads like the corpus.
175    pub moved: Vec<ItemNoise>,
176}
177
178impl NoiseFloor {
179    /// A floor of exactly zero, for a system asserted to be deterministic.
180    ///
181    /// Use it when a corpus is scripted end to end and any movement at all is
182    /// news. It is not a default: assuming a floor of zero without measuring
183    /// one is the assumption this module exists to replace.
184    #[must_use]
185    pub fn deterministic(suite: impl Into<String>) -> Self {
186        Self {
187            suite: suite.into(),
188            ..Self::default()
189        }
190    }
191
192    /// Returns `true` when nothing moved between the two passes.
193    #[must_use]
194    pub fn is_flat(&self) -> bool {
195        self.moved.is_empty()
196    }
197
198    /// Returns `true` when a pass-rate difference is no larger than the
199    /// movement the unchanged system produced.
200    #[must_use]
201    pub fn covers_pass_rate(&self, delta: f64) -> bool {
202        delta.abs() <= self.pass_rate
203    }
204
205    /// Returns `true` when a judge-score difference is no larger than the
206    /// movement the unchanged system produced.
207    #[must_use]
208    pub fn covers_judge_score(&self, delta: f64) -> bool {
209        delta.abs() <= self.judge_score
210    }
211
212    /// A readable rendering.
213    #[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/// How much one item moved between the two passes of a control run.
246#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
247#[serde(deny_unknown_fields)]
248pub struct ItemNoise {
249    /// Which item.
250    pub item: ItemId,
251    /// Its deterministic pass rate in the second pass minus the first.
252    pub pass_rate_delta: f64,
253    /// Its largest mean judge score movement, zero when nothing was judged.
254    pub judge_score_delta: f64,
255    /// The criterion the judge movement was on, when there was one.
256    #[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        // 4/4 in the first pass, 3/4 in the second: the unchanged system moves
338        // by a quarter on its own.
339        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}