Skip to main content

speccoder_core/
verify.rs

1//! The judge's vocabulary: check results, baseline comparison, and verdicts
2//!.
3//!
4//! Pure: running checks is the runtime's job; deciding what their results
5//! mean happens here, where it is property-tested.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use serde::{Deserialize, Serialize};
10
11/// Outcome of one check.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
13#[serde(rename_all = "snake_case")]
14pub enum CheckStatus {
15    /// Exited 0.
16    Passed,
17    /// Exited non-zero.
18    Failed,
19    /// Killed at its deadline.
20    TimedOut,
21    /// Not applicable (its `when_exists` path is absent); not evidence of anything.
22    Skipped,
23    /// Could not be run at all (e.g. command not found).
24    Error,
25}
26
27impl CheckStatus {
28    /// Whether the check counts against verification.
29    #[must_use]
30    pub const fn is_failure(self) -> bool {
31        matches!(self, Self::Failed | Self::TimedOut | Self::Error)
32    }
33}
34
35/// Outcome of one test from a JUnit report.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
37#[serde(rename_all = "snake_case")]
38pub enum TestOutcome {
39    /// Passed.
40    Passed,
41    /// Failed (an assertion, a `<failure>` in JUnit).
42    Failed,
43    /// Skipped or ignored.
44    Skipped,
45    /// Errored before or outside an assertion (a `<error>` in JUnit: setup,
46    /// collection, an unexpected exception).
47    Error,
48}
49
50impl TestOutcome {
51    /// Whether the test counts as failing (for regressions and digests).
52    #[must_use]
53    pub const fn is_failing(self) -> bool {
54        matches!(self, Self::Failed | Self::Error)
55    }
56}
57
58/// One test.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
60pub struct TestResult {
61    /// `classname::name`, or `name` when there is no class.
62    pub id: String,
63    /// What happened.
64    pub outcome: TestOutcome,
65}
66
67/// One check run.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
69pub struct CheckResult {
70    /// Name from `speccoder.toml`.
71    pub name: String,
72    /// The command, as run.
73    pub argv: Vec<String>,
74    /// What happened.
75    pub status: CheckStatus,
76    /// Process exit code, if it exited.
77    pub exit_code: Option<i32>,
78    /// Wall-clock duration.
79    pub duration_ms: u64,
80    /// Whether the check declares a JUnit report.
81    pub junit: bool,
82    /// Tests from the JUnit report, if one was declared and freshly written.
83    pub tests: Option<Vec<TestResult>>,
84    /// Last part of stdout (lossy UTF-8).
85    pub stdout_tail: String,
86    /// Last part of stderr (lossy UTF-8).
87    pub stderr_tail: String,
88    /// Output exceeded the capture cap.
89    pub truncated: bool,
90    /// Why the check was skipped, errored, or has no tests.
91    pub note: Option<String>,
92    /// It failed once and passed when run again: named, never trusted as
93    /// task evidence.
94    #[serde(default)]
95    pub flaky: bool,
96}
97
98impl CheckResult {
99    /// Counts of (passed, failed, skipped) tests.
100    #[must_use]
101    pub fn test_counts(&self) -> Option<(usize, usize, usize)> {
102        self.tests.as_ref().map(|tests| {
103            let n = |o| tests.iter().filter(|t| t.outcome == o).count();
104            (
105                n(TestOutcome::Passed),
106                n(TestOutcome::Failed) + n(TestOutcome::Error),
107                n(TestOutcome::Skipped),
108            )
109        })
110    }
111
112    /// Whether this check, as a task-specific check, is evidence for the
113    /// task: it passed, and either declares no report (a command check) or
114    /// its report has at least one passing test.
115    #[must_use]
116    pub fn is_evidence(&self) -> bool {
117        !self.flaky
118            && self.status == CheckStatus::Passed
119            && (!self.junit
120                || self
121                    .tests
122                    .as_ref()
123                    .is_some_and(|t| t.iter().any(|t| t.outcome == TestOutcome::Passed)))
124    }
125
126    fn failing_tests(&self) -> BTreeSet<&str> {
127        self.tests
128            .iter()
129            .flatten()
130            .filter(|t| t.outcome.is_failing())
131            .map(|t| t.id.as_str())
132            .collect()
133    }
134}
135
136/// Everything one `verify` run established.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
138pub struct VerifyReport {
139    /// Project root the checks ran in.
140    pub root: String,
141    /// `fast` or `full`.
142    pub tier: String,
143    /// One result per configured check, in configuration order.
144    pub checks: Vec<CheckResult>,
145    /// Comparison with a baseline, if one was given.
146    pub comparison: Option<Comparison>,
147    /// Whether verification passed (see [`passes`]).
148    pub passed: bool,
149}
150
151/// What changed relative to a baseline (pass-to-pass).
152#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
153pub struct Comparison {
154    /// Tests that passed in the baseline and now fail or are missing.
155    pub regressions: Vec<Regression>,
156    /// Tests failing in both runs.
157    pub pre_existing: Vec<String>,
158    /// Tests not in the baseline.
159    pub new_tests: Vec<String>,
160    /// Tests collected in the baseline.
161    pub count_before: usize,
162    /// Tests collected now.
163    pub count_after: usize,
164    /// Checks failing now only because of tests that already failed in the baseline.
165    pub failing_as_before: Vec<String>,
166}
167
168/// A test that passed in the baseline and no longer does.
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
170pub struct Regression {
171    /// `check/test`.
172    pub id: String,
173    /// `failed`, `skipped`, or `missing`.
174    pub now: String,
175}
176
177/// Compares `current` with `baseline`, test by test and check by check.
178#[must_use]
179pub fn compare(baseline: &VerifyReport, current: &VerifyReport) -> Comparison {
180    let index = |r: &VerifyReport| -> BTreeMap<String, TestOutcome> {
181        r.checks
182            .iter()
183            .flat_map(|c| {
184                c.tests
185                    .iter()
186                    .flatten()
187                    .map(move |t| (format!("{}/{}", c.name, t.id), t.outcome))
188            })
189            .collect()
190    };
191    let before = index(baseline);
192    let after = index(current);
193
194    let mut cmp = Comparison {
195        count_before: before.len(),
196        count_after: after.len(),
197        ..Comparison::default()
198    };
199    for (id, was) in &before {
200        match (was, after.get(id)) {
201            (TestOutcome::Passed, Some(TestOutcome::Passed)) => {}
202            (TestOutcome::Passed, Some(now)) => cmp.regressions.push(Regression {
203                id: id.clone(),
204                now: match now {
205                    TestOutcome::Failed => "failed",
206                    TestOutcome::Error => "error",
207                    _ => "skipped",
208                }
209                .to_owned(),
210            }),
211            (TestOutcome::Passed, None) => cmp.regressions.push(Regression {
212                id: id.clone(),
213                now: "missing".to_owned(),
214            }),
215            (was, Some(now)) if was.is_failing() && now.is_failing() => {
216                cmp.pre_existing.push(id.clone());
217            }
218            _ => {}
219        }
220    }
221    cmp.new_tests = after
222        .keys()
223        .filter(|id| !before.contains_key(*id))
224        .cloned()
225        .collect();
226
227    // A check that failed before and now fails only on tests that failed
228    // before is not blamed on the change. Without a report this can't be
229    // known, so such checks stay failures.
230    for c in current
231        .checks
232        .iter()
233        .filter(|c| c.status == CheckStatus::Failed)
234    {
235        let Some(old) = baseline.checks.iter().find(|b| b.name == c.name) else {
236            continue;
237        };
238        let now = c.failing_tests();
239        if old.status == CheckStatus::Failed
240            && c.tests.is_some()
241            && !now.is_empty()
242            && now.is_subset(&old.failing_tests())
243        {
244            cmp.failing_as_before.push(c.name.clone());
245        }
246    }
247    cmp
248}
249
250/// Checks that count against verification: failures not explained by the baseline.
251fn failures<'r>(checks: &'r [CheckResult], cmp: Option<&Comparison>) -> Vec<&'r CheckResult> {
252    checks
253        .iter()
254        .filter(|c| c.status.is_failure())
255        .filter(|c| !cmp.is_some_and(|cmp| cmp.failing_as_before.contains(&c.name)))
256        .collect()
257}
258
259/// Whether a run passes: no unexplained failing check and no regression.
260#[must_use]
261pub fn passes(checks: &[CheckResult], cmp: Option<&Comparison>) -> bool {
262    failures(checks, cmp).is_empty() && cmp.is_none_or(|c| c.regressions.is_empty())
263}
264
265/// What a red run of acceptance tests showed.
266///
267/// Only [`RedClass::Assertion`] and [`RedClass::Absent`] are red: a test was
268/// collected and failed on an assertion, or the tests could not build only
269/// because symbols the task is to introduce do not exist yet. Everything
270/// else is not demonstrated.
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
272#[serde(rename_all = "snake_case")]
273pub enum RedClass {
274    /// A collected test failed on an assertion.
275    Assertion,
276    /// The only build or import errors name symbols that do not exist yet.
277    Absent,
278    /// Any other build error.
279    BuildError,
280    /// Tests errored at setup or collection.
281    CollectionError,
282    /// The run hit its deadline.
283    Timeout,
284    /// Nothing was collected.
285    NoTests,
286    /// Red once, not red when confirmed.
287    Flaky,
288    /// The output was not recognised.
289    Unclassified,
290    /// The tests passed.
291    Passed,
292}
293
294impl RedClass {
295    /// Whether this is red: evidence the tests fail without the task's work.
296    #[must_use]
297    pub const fn is_red(self) -> bool {
298        matches!(self, Self::Assertion | Self::Absent)
299    }
300}
301
302/// Counts read from a test runner's own summary output (libtest, pytest,
303/// jest/vitest, go test), when it writes no JUnit report.
304#[derive(
305    Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
306)]
307pub struct RunnerSummary {
308    /// Tests that passed.
309    pub passed: usize,
310    /// Tests that failed on an assertion.
311    pub failed: usize,
312    /// Tests that errored at setup or collection.
313    pub errors: usize,
314    /// The test code did not build or import.
315    pub build_failed: bool,
316    /// Every build or import diagnostic is an unresolved name.
317    pub unresolved_only: bool,
318}
319
320/// Classifies a red run: a JUnit report first, then the runner's summary,
321/// then the check's status. Nothing recognised is [`RedClass::Unclassified`].
322#[must_use]
323pub fn classify_red(result: &CheckResult, summary: Option<&RunnerSummary>) -> RedClass {
324    if result.status == CheckStatus::TimedOut {
325        return RedClass::Timeout;
326    }
327    if let Some(tests) = result.tests.as_ref() {
328        if tests.iter().any(|t| t.outcome == TestOutcome::Failed) {
329            return RedClass::Assertion;
330        }
331        if tests.iter().any(|t| t.outcome == TestOutcome::Error) {
332            return RedClass::CollectionError;
333        }
334        if tests.is_empty() {
335            return RedClass::NoTests;
336        }
337        if result.status == CheckStatus::Passed {
338            return RedClass::Passed;
339        }
340    }
341    if let Some(s) = summary {
342        if s.build_failed {
343            return if s.unresolved_only {
344                RedClass::Absent
345            } else {
346                RedClass::BuildError
347            };
348        }
349        if s.failed > 0 {
350            return RedClass::Assertion;
351        }
352        if s.errors > 0 {
353            return RedClass::CollectionError;
354        }
355        if s.passed == 0 {
356            return RedClass::NoTests;
357        }
358    }
359    match result.status {
360        CheckStatus::Passed => RedClass::Passed,
361        CheckStatus::Error => RedClass::BuildError,
362        _ => RedClass::Unclassified,
363    }
364}
365
366/// How strong the evidence for a task is.
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
368#[serde(rename_all = "snake_case", tag = "verdict")]
369pub enum Verdict {
370    /// A task-specific check produced evidence, nothing failed, nothing regressed.
371    Verified,
372    /// Nothing failed or regressed, but nothing task-specific was established.
373    RegressionOnly,
374    /// Something failed or regressed.
375    Failed {
376        /// One line per reason.
377        reasons: Vec<String>,
378    },
379}
380
381/// The verdict for a task whose task-specific checks are named in `task_checks`.
382#[must_use]
383pub fn verdict(task_checks: &[&str], checks: &[CheckResult], cmp: Option<&Comparison>) -> Verdict {
384    let mut reasons: Vec<String> = failures(checks, cmp)
385        .iter()
386        .map(|c| format!("check `{}` {:?}", c.name, c.status).to_lowercase())
387        .collect();
388    if let Some(cmp) = cmp {
389        reasons.extend(
390            cmp.regressions
391                .iter()
392                .map(|r| format!("regression: {} is {}", r.id, r.now)),
393        );
394    }
395    if !reasons.is_empty() {
396        return Verdict::Failed { reasons };
397    }
398    let evidence = checks
399        .iter()
400        .any(|c| task_checks.contains(&c.name.as_str()) && c.is_evidence());
401    if evidence {
402        Verdict::Verified
403    } else {
404        Verdict::RegressionOnly
405    }
406}
407
408/// What the held-out acceptance tests said about an attempt that passed
409/// visibly: the judge's own tests, never shown to the model.
410#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
411#[serde(rename_all = "snake_case", tag = "outcome")]
412pub enum HeldOut {
413    /// `[acceptance] held_out = false`.
414    Off,
415    /// No held-out test was kept (none generated, all discarded, …).
416    Absent {
417        /// Why.
418        reason: String,
419    },
420    /// Every kept held-out test passed.
421    Passed {
422        /// How many files.
423        files: usize,
424    },
425    /// These scenarios' held-out tests failed.
426    Failed {
427        /// Scenario IDs, sorted.
428        scenarios: Vec<String>,
429    },
430}
431
432/// The verdict after the held-out tests: a failure fails it (naming the
433/// scenarios only); anything else leaves it as it was.
434#[must_use]
435pub fn with_held_out(verdict: Verdict, held_out: &HeldOut) -> Verdict {
436    let HeldOut::Failed { scenarios } = held_out else {
437        return verdict;
438    };
439    let mut ids = scenarios.clone();
440    ids.sort();
441    ids.dedup();
442    let reason = format!("held-out acceptance failed: {}", ids.join(", "));
443    match verdict {
444        Verdict::Failed { mut reasons } => {
445            reasons.push(reason);
446            Verdict::Failed { reasons }
447        }
448        Verdict::Verified | Verdict::RegressionOnly => Verdict::Failed {
449            reasons: vec![reason],
450        },
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    fn check(
459        name: &str,
460        status: CheckStatus,
461        tests: Option<&[(&str, TestOutcome)]>,
462    ) -> CheckResult {
463        CheckResult {
464            name: name.to_owned(),
465            argv: vec!["x".to_owned()],
466            status,
467            exit_code: Some(i32::from(status != CheckStatus::Passed)),
468            duration_ms: 1,
469            junit: tests.is_some(),
470            tests: tests.map(|t| {
471                t.iter()
472                    .map(|(id, o)| TestResult {
473                        id: (*id).to_owned(),
474                        outcome: *o,
475                    })
476                    .collect()
477            }),
478            stdout_tail: String::new(),
479            stderr_tail: String::new(),
480            truncated: false,
481            note: None,
482            flaky: false,
483        }
484    }
485
486    fn report(checks: Vec<CheckResult>) -> VerifyReport {
487        VerifyReport {
488            root: "/p".to_owned(),
489            tier: "fast".to_owned(),
490            checks,
491            comparison: None,
492            passed: true,
493        }
494    }
495
496    use CheckStatus::{Failed, Passed};
497    use TestOutcome as T;
498
499    #[test]
500    fn comparison_finds_regressions_pre_existing_and_new() {
501        let base = report(vec![check(
502            "test",
503            Failed,
504            Some(&[("a", T::Passed), ("b", T::Passed), ("c", T::Failed)]),
505        )]);
506        let now = report(vec![check(
507            "test",
508            Failed,
509            Some(&[
510                ("a", T::Passed),
511                ("b", T::Failed),
512                ("c", T::Failed),
513                ("d", T::Passed),
514            ]),
515        )]);
516        let cmp = compare(&base, &now);
517        assert_eq!(
518            cmp.regressions,
519            [Regression {
520                id: "test/b".to_owned(),
521                now: "failed".to_owned()
522            }]
523        );
524        assert_eq!(cmp.pre_existing, ["test/c"]);
525        assert_eq!(cmp.new_tests, ["test/d"]);
526        assert_eq!((cmp.count_before, cmp.count_after), (3, 4));
527        assert!(cmp.failing_as_before.is_empty(), "b is a new failure");
528    }
529
530    #[test]
531    fn missing_test_is_a_regression() {
532        let base = report(vec![check(
533            "test",
534            Passed,
535            Some(&[("a", T::Passed), ("b", T::Passed)]),
536        )]);
537        let now = report(vec![check("test", Passed, Some(&[("a", T::Passed)]))]);
538        let cmp = compare(&base, &now);
539        assert_eq!(cmp.regressions[0].now, "missing");
540        assert!(!passes(&now.checks, Some(&cmp)));
541    }
542
543    #[test]
544    fn pre_existing_failures_alone_do_not_fail_against_a_baseline() {
545        let base = report(vec![check(
546            "test",
547            Failed,
548            Some(&[("a", T::Passed), ("c", T::Failed)]),
549        )]);
550        let now = report(vec![check(
551            "test",
552            Failed,
553            Some(&[("a", T::Passed), ("c", T::Failed)]),
554        )]);
555        let cmp = compare(&base, &now);
556        assert_eq!(cmp.failing_as_before, ["test"]);
557        assert!(passes(&now.checks, Some(&cmp)));
558        assert!(
559            !passes(&now.checks, None),
560            "without a baseline a failing check fails"
561        );
562    }
563
564    #[test]
565    fn verdict_levels() {
566        let passing = check("unit", Passed, Some(&[("a", T::Passed)]));
567        let empty = check("unit", Passed, Some(&[]));
568        let lint = check("lint", Passed, None);
569        assert_eq!(
570            verdict(&["unit"], &[passing.clone(), lint.clone()], None),
571            Verdict::Verified
572        );
573        assert_eq!(
574            verdict(&[], &[passing.clone(), lint.clone()], None),
575            Verdict::RegressionOnly
576        );
577        assert_eq!(
578            verdict(&["unit"], &[empty], None),
579            Verdict::RegressionOnly,
580            "zero tests is no evidence"
581        );
582        assert_eq!(
583            verdict(&["lint"], &[lint], None),
584            Verdict::Verified,
585            "a passing command check is evidence"
586        );
587        let failing = check("lint", Failed, None);
588        assert!(matches!(
589            verdict(&["unit"], &[passing, failing], None),
590            Verdict::Failed { .. }
591        ));
592    }
593
594    fn summary(failed: usize, build_failed: bool, unresolved_only: bool) -> RunnerSummary {
595        RunnerSummary {
596            passed: 0,
597            failed,
598            errors: 0,
599            build_failed,
600            unresolved_only,
601        }
602    }
603
604    #[test]
605    fn red_is_an_assertion_or_an_absent_symbol() {
606        use TestOutcome as T;
607        let failed = check("a", CheckStatus::Failed, Some(&[("t", T::Failed)]));
608        assert_eq!(classify_red(&failed, None), RedClass::Assertion);
609        let errored = check("a", CheckStatus::Failed, Some(&[("t", T::Error)]));
610        assert_eq!(classify_red(&errored, None), RedClass::CollectionError);
611        let empty = check("a", CheckStatus::Failed, Some(&[]));
612        assert_eq!(classify_red(&empty, None), RedClass::NoTests);
613        let plain = check("a", CheckStatus::Failed, None);
614        assert_eq!(classify_red(&plain, None), RedClass::Unclassified);
615        assert_eq!(
616            classify_red(&plain, Some(&summary(2, false, false))),
617            RedClass::Assertion
618        );
619        assert_eq!(
620            classify_red(&plain, Some(&summary(0, true, false))),
621            RedClass::BuildError
622        );
623        assert_eq!(
624            classify_red(&plain, Some(&summary(0, true, true))),
625            RedClass::Absent
626        );
627        assert_eq!(
628            classify_red(&plain, Some(&summary(0, false, false))),
629            RedClass::NoTests
630        );
631        let timed_out = check("a", CheckStatus::TimedOut, None);
632        assert_eq!(
633            classify_red(&timed_out, Some(&summary(1, false, false))),
634            RedClass::Timeout
635        );
636        let passed = check("a", CheckStatus::Passed, Some(&[("t", T::Passed)]));
637        assert_eq!(classify_red(&passed, None), RedClass::Passed);
638        assert!(RedClass::Assertion.is_red() && RedClass::Absent.is_red());
639        assert!(!RedClass::BuildError.is_red() && !RedClass::Flaky.is_red());
640    }
641
642    #[test]
643    fn a_flaky_pass_is_not_evidence_and_errors_regress() {
644        use TestOutcome as T;
645        let mut c = check("acceptance", CheckStatus::Passed, None);
646        assert!(c.is_evidence());
647        c.flaky = true;
648        assert!(!c.is_evidence());
649        assert_eq!(
650            verdict(&["acceptance"], &[c], None),
651            Verdict::RegressionOnly
652        );
653
654        let before = report(vec![check(
655            "t",
656            CheckStatus::Passed,
657            Some(&[("x", T::Passed)]),
658        )]);
659        let after = report(vec![check(
660            "t",
661            CheckStatus::Failed,
662            Some(&[("x", T::Error)]),
663        )]);
664        let cmp = compare(&before, &after);
665        assert_eq!(cmp.regressions[0].now, "error");
666        let again = compare(&after, &after);
667        assert_eq!(again.pre_existing, ["t/x"]);
668    }
669
670    mod properties {
671        use super::*;
672        use proptest::prelude::*;
673
674        fn statuses() -> impl Strategy<Value = CheckStatus> {
675            prop_oneof![
676                Just(CheckStatus::Passed),
677                Just(CheckStatus::Failed),
678                Just(CheckStatus::TimedOut),
679                Just(CheckStatus::Skipped),
680                Just(CheckStatus::Error),
681            ]
682        }
683
684        fn outcomes() -> impl Strategy<Value = TestOutcome> {
685            prop_oneof![
686                Just(T::Passed),
687                Just(T::Failed),
688                Just(T::Skipped),
689                Just(T::Error)
690            ]
691        }
692
693        fn checks() -> impl Strategy<Value = Vec<CheckResult>> {
694            prop::collection::vec(
695                (
696                    statuses(),
697                    prop::option::of(prop::collection::vec(outcomes(), 0..5)),
698                    any::<bool>(),
699                ),
700                1..5,
701            )
702            .prop_map(|v| {
703                v.into_iter()
704                    .enumerate()
705                    .map(|(i, (status, tests, flaky))| CheckResult {
706                        name: format!("c{i}"),
707                        argv: vec!["x".to_owned()],
708                        status,
709                        exit_code: None,
710                        duration_ms: 0,
711                        junit: tests.is_some(),
712                        tests: tests.map(|t| {
713                            t.into_iter()
714                                .enumerate()
715                                .map(|(j, outcome)| TestResult {
716                                    id: format!("t{j}"),
717                                    outcome,
718                                })
719                                .collect()
720                        }),
721                        stdout_tail: String::new(),
722                        stderr_tail: String::new(),
723                        truncated: false,
724                        note: None,
725                        flaky,
726                    })
727                    .collect()
728            })
729        }
730
731        fn held_outs() -> impl Strategy<Value = HeldOut> {
732            prop_oneof![
733                Just(HeldOut::Off),
734                Just(HeldOut::Absent {
735                    reason: "not_red".to_owned()
736                }),
737                (0..4usize).prop_map(|files| HeldOut::Passed { files }),
738                proptest::collection::vec("US[1-3]-[1-4]", 1..4)
739                    .prop_map(|scenarios| HeldOut::Failed { scenarios }),
740            ]
741        }
742
743        proptest! {
744            #[test]
745            fn a_held_out_failure_is_never_a_pass(cs in checks(), pick in 0..5usize, h in held_outs()) {
746                let name = format!("c{pick}");
747                let before = verdict(&[name.as_str()], &cs, None);
748                let after = with_held_out(before.clone(), &h);
749                match h {
750                    HeldOut::Failed { .. } => {
751                        let failed = matches!(after, Verdict::Failed { .. });
752                        prop_assert!(failed);
753                    }
754                    _ => prop_assert_eq!(after, before),
755                }
756            }
757
758            #[test]
759            fn any_failing_check_means_failed(cs in checks(), pick in 0..5usize) {
760                let task = [format!("c{pick}")];
761                let task: Vec<&str> = task.iter().map(String::as_str).collect();
762                let v = verdict(&task, &cs, None);
763                let any_failure = cs.iter().any(|c| c.status.is_failure());
764                prop_assert_eq!(matches!(v, Verdict::Failed { .. }), any_failure);
765            }
766
767            #[test]
768            fn verified_requires_task_evidence(cs in checks(), pick in 0..5usize) {
769                let name = format!("c{pick}");
770                let v = verdict(&[name.as_str()], &cs, None);
771                if v == Verdict::Verified {
772                    let c = cs.iter().find(|c| c.name == name);
773                    prop_assert!(c.is_some_and(CheckResult::is_evidence));
774                }
775            }
776
777            #[test]
778            fn a_flaky_task_check_is_never_verified(cs in checks(), pick in 0..5usize) {
779                let name = format!("c{pick}");
780                let v = verdict(&[name.as_str()], &cs, None);
781                if cs.iter().find(|c| c.name == name).is_some_and(|c| c.flaky) {
782                    prop_assert_ne!(v, Verdict::Verified);
783                }
784            }
785
786            #[test]
787            fn red_always_has_a_cause(
788                cs in checks(),
789                passed in 0..3usize,
790                failed in 0..3usize,
791                errors in 0..3usize,
792                build_failed in any::<bool>(),
793                unresolved_only in any::<bool>(),
794                with_summary in any::<bool>(),
795            ) {
796                let summary = RunnerSummary { passed, failed, errors, build_failed, unresolved_only };
797                let summary = with_summary.then_some(summary);
798                for c in &cs {
799                    let class = classify_red(c, summary.as_ref());
800                    if class.is_red() {
801                        let assertion_in_report = c
802                            .tests
803                            .iter()
804                            .flatten()
805                            .any(|t| t.outcome == T::Failed);
806                        let by_summary = summary.is_some_and(|s| s.failed > 0 || (s.build_failed && s.unresolved_only));
807                        prop_assert!(assertion_in_report || by_summary);
808                        prop_assert_ne!(c.status, CheckStatus::TimedOut);
809                    }
810                }
811            }
812
813            #[test]
814            fn a_run_compared_with_itself_has_no_regressions(cs in checks()) {
815                let r = report(cs);
816                let cmp = compare(&r, &r);
817                prop_assert!(cmp.regressions.is_empty());
818                prop_assert!(cmp.new_tests.is_empty());
819                prop_assert_eq!(cmp.count_before, cmp.count_after);
820            }
821
822            #[test]
823            fn regressions_always_fail_the_verdict(cs in checks()) {
824                let before = report(cs.clone());
825                let mut after = report(cs);
826                for c in &mut after.checks {
827                    if let Some(tests) = &mut c.tests {
828                        tests.clear();
829                    }
830                }
831                let cmp = compare(&before, &after);
832                let had_passing = cmp.count_before > 0
833                    && before.checks.iter().flat_map(|c| c.tests.iter().flatten()).any(|t| t.outcome == T::Passed);
834                if had_passing {
835                    let failed = matches!(verdict(&[], &after.checks, Some(&cmp)), Verdict::Failed { .. });
836                    prop_assert!(failed);
837                }
838            }
839        }
840    }
841}