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/// What the sandbox denied a command that failed: shown to the model and
68/// to people, never evidence for a verdict.
69///
70/// Lines are written from the sandbox's record of the run: the most
71/// specific rule first, identical denials merged with a count, control
72/// characters escaped. A partial record (Linux reports refused system calls
73/// only) carries a note saying what it cannot show, and macOS reports are
74/// best effort: an empty list never means "nothing was denied".
75#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
76pub struct Denials {
77    /// One line per denial, at most ten.
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    pub denied: Vec<String>,
80    /// How many more denials there were.
81    #[serde(default, skip_serializing_if = "is_zero")]
82    pub more: usize,
83    /// What the record cannot show, when it cannot show everything.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub note: Option<String>,
86}
87
88impl Denials {
89    /// Neither a denial nor a note.
90    #[must_use]
91    pub fn is_empty(&self) -> bool {
92        self.denied.is_empty() && self.note.is_none()
93    }
94}
95
96#[expect(clippy::trivially_copy_pass_by_ref, reason = "serde's signature")]
97fn is_zero(n: &usize) -> bool {
98    *n == 0
99}
100
101/// One check run.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
103pub struct CheckResult {
104    /// Name from `speccoder.toml`.
105    pub name: String,
106    /// The command, as run.
107    pub argv: Vec<String>,
108    /// What happened.
109    pub status: CheckStatus,
110    /// Process exit code, if it exited.
111    pub exit_code: Option<i32>,
112    /// Wall-clock duration.
113    pub duration_ms: u64,
114    /// Whether the check declares a JUnit report.
115    pub junit: bool,
116    /// Tests from the JUnit report, if one was declared and freshly written.
117    pub tests: Option<Vec<TestResult>>,
118    /// Last part of stdout (lossy UTF-8).
119    pub stdout_tail: String,
120    /// Last part of stderr (lossy UTF-8).
121    pub stderr_tail: String,
122    /// Output exceeded the capture cap.
123    pub truncated: bool,
124    /// Why the check was skipped, errored, or has no tests.
125    pub note: Option<String>,
126    /// It failed once and passed when run again: named, never trusted as
127    /// task evidence.
128    #[serde(default)]
129    pub flaky: bool,
130    /// What the sandbox denied, when the check failed.
131    #[serde(default, skip_serializing_if = "Denials::is_empty")]
132    pub sandbox: Denials,
133}
134
135impl CheckResult {
136    /// Counts of (passed, failed, skipped) tests.
137    #[must_use]
138    pub fn test_counts(&self) -> Option<(usize, usize, usize)> {
139        self.tests.as_ref().map(|tests| {
140            let n = |o| tests.iter().filter(|t| t.outcome == o).count();
141            (
142                n(TestOutcome::Passed),
143                n(TestOutcome::Failed) + n(TestOutcome::Error),
144                n(TestOutcome::Skipped),
145            )
146        })
147    }
148
149    /// Whether this check, as a task-specific check, is evidence for the
150    /// task: it passed, and either declares no report (a command check) or
151    /// its report has at least one passing test.
152    #[must_use]
153    pub fn is_evidence(&self) -> bool {
154        !self.flaky
155            && self.status == CheckStatus::Passed
156            && (!self.junit
157                || self
158                    .tests
159                    .as_ref()
160                    .is_some_and(|t| t.iter().any(|t| t.outcome == TestOutcome::Passed)))
161    }
162
163    fn failing_tests(&self) -> BTreeSet<&str> {
164        self.tests
165            .iter()
166            .flatten()
167            .filter(|t| t.outcome.is_failing())
168            .map(|t| t.id.as_str())
169            .collect()
170    }
171}
172
173/// Everything one `verify` run established.
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
175pub struct VerifyReport {
176    /// Project root the checks ran in.
177    pub root: String,
178    /// `fast` or `full`.
179    pub tier: String,
180    /// One result per configured check, in configuration order.
181    pub checks: Vec<CheckResult>,
182    /// Comparison with a baseline, if one was given.
183    pub comparison: Option<Comparison>,
184    /// Whether verification passed (see [`passes`]).
185    pub passed: bool,
186}
187
188/// What changed relative to a baseline (pass-to-pass).
189#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
190pub struct Comparison {
191    /// Tests that passed in the baseline and now fail or are missing.
192    pub regressions: Vec<Regression>,
193    /// Tests failing in both runs.
194    pub pre_existing: Vec<String>,
195    /// Tests not in the baseline.
196    pub new_tests: Vec<String>,
197    /// Tests collected in the baseline.
198    pub count_before: usize,
199    /// Tests collected now.
200    pub count_after: usize,
201    /// Checks failing now only because of tests that already failed in the
202    /// baseline, or tests of a story's oracle (judged by the oracle's own runs).
203    pub failing_as_before: Vec<String>,
204}
205
206/// A test that passed in the baseline and no longer does.
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
208pub struct Regression {
209    /// `check/test`.
210    pub id: String,
211    /// `failed`, `skipped`, or `missing`.
212    pub now: String,
213}
214
215/// Compares `current` with `baseline`, test by test and check by check.
216#[must_use]
217pub fn compare(baseline: &VerifyReport, current: &VerifyReport) -> Comparison {
218    compare_except(baseline, current, &[])
219}
220
221/// Whether test `id` (as a runner reports it) is one of `files`: the path
222/// itself, or a segment naming the file (`us1_count` for
223/// `tests/us1_count.rs`, `tests.test_x.test_a` for `tests/test_x.py`).
224#[must_use]
225pub fn test_of(id: &str, files: &[String]) -> bool {
226    files.iter().any(|f| {
227        let name = f.rsplit('/').next().unwrap_or(f);
228        let stem = name.rsplit_once('.').map_or(name, |(s, _)| s);
229        id.contains(f.as_str())
230            || id
231                .split(['/', ':', ' ', '>'])
232                .any(|seg| seg == name || seg.split('.').any(|part| part == stem))
233    })
234}
235
236/// [`compare`], with the failing tests of `oracle` files never blamed on
237/// the change: a story's oracle is judged by its own runs, and its
238/// scenarios that later tasks make pass stay red meanwhile.
239#[must_use]
240pub fn compare_except(
241    baseline: &VerifyReport,
242    current: &VerifyReport,
243    oracle: &[String],
244) -> Comparison {
245    let index = |r: &VerifyReport| -> BTreeMap<String, TestOutcome> {
246        r.checks
247            .iter()
248            .flat_map(|c| {
249                c.tests
250                    .iter()
251                    .flatten()
252                    .map(move |t| (format!("{}/{}", c.name, t.id), t.outcome))
253            })
254            .collect()
255    };
256    let before = index(baseline);
257    let after = index(current);
258
259    let mut cmp = Comparison {
260        count_before: before.len(),
261        count_after: after.len(),
262        ..Comparison::default()
263    };
264    for (id, was) in &before {
265        match (was, after.get(id)) {
266            (TestOutcome::Passed, Some(TestOutcome::Passed)) => {}
267            (TestOutcome::Passed, Some(now)) => cmp.regressions.push(Regression {
268                id: id.clone(),
269                now: match now {
270                    TestOutcome::Failed => "failed",
271                    TestOutcome::Error => "error",
272                    _ => "skipped",
273                }
274                .to_owned(),
275            }),
276            (TestOutcome::Passed, None) => cmp.regressions.push(Regression {
277                id: id.clone(),
278                now: "missing".to_owned(),
279            }),
280            (was, Some(now)) if was.is_failing() && now.is_failing() => {
281                cmp.pre_existing.push(id.clone());
282            }
283            _ => {}
284        }
285    }
286    cmp.new_tests = after
287        .keys()
288        .filter(|id| !before.contains_key(*id))
289        .cloned()
290        .collect();
291
292    // A check that failed before and now fails only on tests that failed
293    // before is not blamed on the change. Without a report this can't be
294    // known, so such checks stay failures.
295    for c in current
296        .checks
297        .iter()
298        .filter(|c| c.status == CheckStatus::Failed)
299    {
300        let Some(old) = baseline.checks.iter().find(|b| b.name == c.name) else {
301            continue;
302        };
303        let now = c.failing_tests();
304        let old_failing = old.failing_tests();
305        let unexplained = now
306            .iter()
307            .filter(|t| !old_failing.contains(*t) && !test_of(t, oracle))
308            .count();
309        if c.tests.is_some()
310            && !now.is_empty()
311            && unexplained == 0
312            && (old.status == CheckStatus::Failed || now.iter().all(|t| test_of(t, oracle)))
313        {
314            cmp.failing_as_before.push(c.name.clone());
315        }
316    }
317    cmp
318}
319
320/// Checks that count against verification: failures not explained by the baseline.
321fn failures<'r>(checks: &'r [CheckResult], cmp: Option<&Comparison>) -> Vec<&'r CheckResult> {
322    checks
323        .iter()
324        .filter(|c| c.status.is_failure())
325        .filter(|c| !cmp.is_some_and(|cmp| cmp.failing_as_before.contains(&c.name)))
326        .collect()
327}
328
329/// Whether a run passes: no unexplained failing check and no regression.
330#[must_use]
331pub fn passes(checks: &[CheckResult], cmp: Option<&Comparison>) -> bool {
332    failures(checks, cmp).is_empty() && cmp.is_none_or(|c| c.regressions.is_empty())
333}
334
335/// What a red run of acceptance tests showed.
336///
337/// Only [`RedClass::Assertion`] and [`RedClass::Absent`] are red: a test was
338/// collected and failed on an assertion, or the tests could not build only
339/// because symbols the task is to introduce do not exist yet. Everything
340/// else is not demonstrated.
341#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
342#[serde(rename_all = "snake_case")]
343pub enum RedClass {
344    /// A collected test failed on an assertion.
345    Assertion,
346    /// The only build or import errors name symbols that do not exist yet.
347    Absent,
348    /// Any other build error.
349    BuildError,
350    /// Tests errored at setup or collection.
351    CollectionError,
352    /// The run hit its deadline.
353    Timeout,
354    /// Nothing was collected.
355    NoTests,
356    /// Red once, not red when confirmed.
357    Flaky,
358    /// The output was not recognised.
359    Unclassified,
360    /// The tests passed.
361    Passed,
362}
363
364impl RedClass {
365    /// Whether this is red: evidence the tests fail without the task's work.
366    #[must_use]
367    pub const fn is_red(self) -> bool {
368        matches!(self, Self::Assertion | Self::Absent)
369    }
370}
371
372/// Counts read from a test runner's own summary output (libtest, pytest,
373/// jest/vitest, go test), when it writes no JUnit report.
374#[derive(
375    Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
376)]
377pub struct RunnerSummary {
378    /// Tests that passed.
379    pub passed: usize,
380    /// Tests that failed on an assertion.
381    pub failed: usize,
382    /// Tests that errored at setup or collection.
383    pub errors: usize,
384    /// The test code did not build or import.
385    pub build_failed: bool,
386    /// Every build or import diagnostic is an unresolved name.
387    pub unresolved_only: bool,
388}
389
390/// How many tests a run collected — ran, passed or failed (skipped and
391/// errored at setup excluded): from its JUnit report, else the runner's
392/// summary; `None` when neither says.
393#[must_use]
394pub fn collected_count(result: &CheckResult, summary: Option<&RunnerSummary>) -> Option<usize> {
395    if let Some(tests) = result.tests.as_ref() {
396        return Some(
397            tests
398                .iter()
399                .filter(|t| matches!(t.outcome, TestOutcome::Passed | TestOutcome::Failed))
400                .count(),
401        );
402    }
403    summary
404        .filter(|s| !s.build_failed)
405        .map(|s| s.passed + s.failed)
406}
407
408/// Classifies a red run: a JUnit report first, then the runner's summary,
409/// then the check's status. Nothing recognised is [`RedClass::Unclassified`].
410#[must_use]
411pub fn classify_red(result: &CheckResult, summary: Option<&RunnerSummary>) -> RedClass {
412    classified(result, summary)
413}
414
415/// The names a red run's every failure says are not there — pytest's
416/// `FAILED t.py::a - ModuleNotFoundError: No module named 'x'`, an
417/// import, attribute or name error; vitest's `Cannot find module` — or
418/// `None` when any failure is something else. Red of code a task has yet
419/// to write looks the same: the caller decides whose names they are.
420#[must_use]
421pub fn missing_names(result: &CheckResult) -> Option<Vec<String>> {
422    const PYTHON: &[&str] = &[
423        "ModuleNotFoundError",
424        "ImportError",
425        "AttributeError",
426        "NameError",
427    ];
428    let text = format!("{}\n{}", result.stdout_tail, result.stderr_tail);
429    let quoted = |s: &str| -> Vec<String> {
430        s.split(['\'', '"'])
431            .skip(1)
432            .step_by(2)
433            .map(str::to_owned)
434            .collect()
435    };
436    let failed: Vec<&str> = text
437        .lines()
438        .map(str::trim)
439        .filter_map(|l| {
440            l.strip_prefix("FAILED ")
441                .or_else(|| l.strip_prefix("ERROR "))
442        })
443        .collect();
444    if !failed.is_empty() {
445        let mut names = Vec::new();
446        for rest in failed {
447            let cause = rest.split_once(" - ").map_or("", |(_, c)| c);
448            let (kind, message) = cause.split_once(':').unwrap_or((cause, ""));
449            let kind = kind.trim();
450            if !PYTHON.contains(&kind.rsplit('.').next().unwrap_or(kind)) {
451                return None;
452            }
453            // `No module named 'a.b'`, `cannot import name 'X'`, `'T' object
454            // has no attribute 'f'`: the last quoted word is what is missing.
455            names.extend(quoted(message).pop());
456        }
457        return Some(names);
458    }
459    let js = text.lines().any(|l| l.trim_start().starts_with("FAIL "));
460    if js && !text.contains("AssertionError") && !text.contains("expected ") {
461        let names: Vec<String> = text
462            .lines()
463            .filter(|l| l.contains("Cannot find module") || l.contains("Failed to resolve import"))
464            .filter_map(|l| quoted(l).first().cloned())
465            .collect();
466        return (!names.is_empty()).then_some(names);
467    }
468    None
469}
470
471fn classified(result: &CheckResult, summary: Option<&RunnerSummary>) -> RedClass {
472    if result.status == CheckStatus::TimedOut {
473        return RedClass::Timeout;
474    }
475    if let Some(tests) = result.tests.as_ref() {
476        if tests.iter().any(|t| t.outcome == TestOutcome::Failed) {
477            return RedClass::Assertion;
478        }
479        if tests.iter().any(|t| t.outcome == TestOutcome::Error) {
480            return RedClass::CollectionError;
481        }
482        if tests.is_empty() {
483            return RedClass::NoTests;
484        }
485        if result.status == CheckStatus::Passed {
486            return RedClass::Passed;
487        }
488    }
489    if let Some(s) = summary {
490        if s.build_failed {
491            return if s.unresolved_only {
492                RedClass::Absent
493            } else {
494                RedClass::BuildError
495            };
496        }
497        if s.failed > 0 {
498            return RedClass::Assertion;
499        }
500        if s.errors > 0 {
501            return RedClass::CollectionError;
502        }
503        if s.passed == 0 {
504            return RedClass::NoTests;
505        }
506    }
507    match result.status {
508        CheckStatus::Passed => RedClass::Passed,
509        CheckStatus::Error => RedClass::BuildError,
510        _ => RedClass::Unclassified,
511    }
512}
513
514/// Classifies a red run against the same command run without the file
515/// under test: red by an assertion only where the file adds a failing test —
516/// one that did not fail without it (by id when both runs name their tests,
517/// else by more failures). A failure the command shows anyway is another
518/// test's, never this file's red: it is [`RedClass::Passed`].
519#[must_use]
520pub fn classify_red_added(
521    with: (&CheckResult, Option<&RunnerSummary>),
522    without: (&CheckResult, Option<&RunnerSummary>),
523) -> RedClass {
524    let class = classify_red(with.0, with.1);
525    if class != RedClass::Assertion {
526        return class;
527    }
528    let failing = |r: &CheckResult| {
529        r.tests.as_ref().map(|t| {
530            t.iter()
531                .filter(|t| t.outcome == TestOutcome::Failed)
532                .map(|t| t.id.clone())
533                .collect::<std::collections::BTreeSet<_>>()
534        })
535    };
536    let added = match (failing(with.0), failing(without.0)) {
537        (Some(w), Some(o)) => w.difference(&o).next().is_some(),
538        _ => match (with.1, without.1) {
539            (Some(w), Some(o)) => w.failed > o.failed,
540            // Without the file the command reports no count — a real runner
541            // refuses a missing test file: no assertion failed there, so the
542            // failure with it is the file's own.
543            (Some(_), None) => classify_red(without.0, None) != RedClass::Assertion,
544            _ => false,
545        },
546    };
547    if added {
548        RedClass::Assertion
549    } else if with.1.is_none() && with.0.tests.is_none() {
550        RedClass::Unclassified
551    } else {
552        RedClass::Passed
553    }
554}
555
556/// The class of each test in `wanted` from a run's JUnit report: a test
557/// matches by its id or its last `::` segments; one the report does not
558/// name was not collected. `None` when the run has no per-test report —
559/// then each test is run on its own, by name.
560#[must_use]
561pub fn classify_red_each(
562    result: &CheckResult,
563    wanted: &[String],
564) -> Option<Vec<(String, RedClass)>> {
565    if result.status == CheckStatus::TimedOut {
566        return Some(
567            wanted
568                .iter()
569                .map(|w| (w.clone(), RedClass::Timeout))
570                .collect(),
571        );
572    }
573    let tests = result.tests.as_ref()?;
574    Some(
575        wanted
576            .iter()
577            .map(|w| {
578                let found = tests.iter().find(|t| {
579                    t.id == *w
580                        || t.id.ends_with(&format!("::{w}"))
581                        || t.id.ends_with(&format!(".{w}"))
582                });
583                let class = match found.map(|t| t.outcome) {
584                    Some(TestOutcome::Failed) => RedClass::Assertion,
585                    Some(TestOutcome::Error) => RedClass::CollectionError,
586                    Some(TestOutcome::Passed) => RedClass::Passed,
587                    Some(TestOutcome::Skipped) | None => RedClass::NoTests,
588                };
589                (w.clone(), class)
590            })
591            .collect(),
592    )
593}
594
595/// The class of the one test `test` from a run of the command that is to
596/// run only it: by name from a per-test report; without one, from the
597/// runner's summary only when it ran exactly one test — a run of several
598/// tests cannot say which of them failed, so it is
599/// [`RedClass::Unclassified`], never an assertion of `test`'s.
600#[must_use]
601pub fn classify_red_one(
602    result: &CheckResult,
603    summary: Option<&RunnerSummary>,
604    test: &str,
605) -> RedClass {
606    if test.is_empty() {
607        // No name: a per-test report must hold exactly one test.
608        if result.tests.as_ref().is_some_and(|t| t.len() > 1) {
609            return RedClass::Unclassified;
610        }
611    } else if let Some(each) = classify_red_each(result, &[test.to_owned()]) {
612        return each.first().map_or(RedClass::NoTests, |(_, c)| *c);
613    }
614    let class = classify_red(result, summary);
615    match summary {
616        Some(s) if !s.build_failed && s.passed + s.failed + s.errors > 1 => RedClass::Unclassified,
617        _ => class,
618    }
619}
620
621/// How strong the evidence for a task is.
622#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
623#[serde(rename_all = "snake_case", tag = "verdict")]
624pub enum Verdict {
625    /// A task-specific check produced evidence, nothing failed, nothing regressed.
626    Verified,
627    /// Nothing failed or regressed, but nothing task-specific was established.
628    RegressionOnly,
629    /// Something failed or regressed.
630    Failed {
631        /// One line per reason.
632        reasons: Vec<String>,
633    },
634}
635
636/// One line per regressed test — but a check whose tests are all gone (its
637/// file no longer collects or builds) is one line, naming the runner's
638/// first error, never one per test it no longer runs.
639fn regression_reasons(checks: &[CheckResult], regressions: &[Regression]) -> Vec<String> {
640    let mut out = Vec::new();
641    let mut seen: Vec<&str> = Vec::new();
642    for r in regressions {
643        let check = r.id.split('/').next().unwrap_or(&r.id);
644        if seen.contains(&check) {
645            continue;
646        }
647        seen.push(check);
648        let of: Vec<&Regression> = regressions
649            .iter()
650            .filter(|x| x.id.split('/').next() == Some(check))
651            .collect();
652        // Only when the runner says its tests could not even be collected
653        // or built; tests the attempt removed stay named one by one.
654        const BROKEN: &[&str] = &[
655            "ERROR collecting",
656            "ImportError while importing",
657            "SyntaxError",
658            "error: could not compile",
659            "error[E",
660            "Failed to load",
661            "no tests ran",
662        ];
663        let broken = checks
664            .iter()
665            .find(|c| c.name == check)
666            .map(|c| format!("{}\n{}", c.stdout_tail, c.stderr_tail))
667            .filter(|t| BROKEN.iter().any(|m| t.contains(m)));
668        if let Some(text) =
669            broken.filter(|_| of.len() >= 3 && of.iter().all(|x| x.now == "missing"))
670        {
671            // pytest's `E   …` line says what broke; else the marker's own.
672            let lines = || text.lines().map(str::trim);
673            let first = lines()
674                .find(|l| l.starts_with("E "))
675                .or_else(|| lines().find(|l| BROKEN.iter().any(|m| l.contains(m))))
676                .map(|l| format!(": {}", l.chars().take(200).collect::<String>()))
677                .unwrap_or_default();
678            out.push(format!(
679                "regression: check `{check}` no longer runs {} of its tests{first}",
680                of.len()
681            ));
682        } else {
683            out.extend(
684                of.iter()
685                    .map(|x| format!("regression: {} is {}", x.id, x.now)),
686            );
687        }
688    }
689    out
690}
691
692/// The verdict for a task whose task-specific checks are named in `task_checks`.
693#[must_use]
694pub fn verdict(task_checks: &[&str], checks: &[CheckResult], cmp: Option<&Comparison>) -> Verdict {
695    let mut reasons: Vec<String> = failures(checks, cmp)
696        .iter()
697        .map(|c| format!("check `{}` {:?}", c.name, c.status).to_lowercase())
698        .collect();
699    if let Some(cmp) = cmp {
700        reasons.extend(regression_reasons(checks, &cmp.regressions));
701    }
702    if !reasons.is_empty() {
703        return Verdict::Failed { reasons };
704    }
705    let evidence = checks
706        .iter()
707        .any(|c| task_checks.contains(&c.name.as_str()) && c.is_evidence());
708    if evidence {
709        Verdict::Verified
710    } else {
711        Verdict::RegressionOnly
712    }
713}
714
715/// What the held-out acceptance tests said about an attempt that passed
716/// visibly: the judge's own tests, never shown to the model.
717#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
718#[serde(rename_all = "snake_case", tag = "outcome")]
719pub enum HeldOut {
720    /// `[acceptance] held_out = false`.
721    Off,
722    /// No held-out test was kept (none generated, all discarded, …).
723    Absent {
724        /// Why.
725        reason: String,
726    },
727    /// Every kept held-out test passed.
728    Passed {
729        /// How many files.
730        files: usize,
731    },
732    /// These scenarios' held-out tests failed.
733    Failed {
734        /// Scenario IDs, sorted.
735        scenarios: Vec<String>,
736    },
737}
738
739/// The verdict after the held-out tests: a failure fails it (naming the
740/// scenarios only); anything else leaves it as it was.
741#[must_use]
742pub fn with_held_out(verdict: Verdict, held_out: &HeldOut) -> Verdict {
743    let HeldOut::Failed { scenarios } = held_out else {
744        return verdict;
745    };
746    let mut ids = scenarios.clone();
747    ids.sort();
748    ids.dedup();
749    let reason = format!("held-out acceptance failed: {}", ids.join(", "));
750    match verdict {
751        Verdict::Failed { mut reasons } => {
752            reasons.push(reason);
753            Verdict::Failed { reasons }
754        }
755        Verdict::Verified | Verdict::RegressionOnly => Verdict::Failed {
756            reasons: vec![reason],
757        },
758    }
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764
765    #[test]
766    fn each_test_gets_its_own_red_class() {
767        let mut r = check("acceptance", CheckStatus::Failed, Some(&[]));
768        r.tests = Some(vec![
769            TestResult {
770                id: "title::empty_title_is_refused".to_owned(),
771                outcome: TestOutcome::Failed,
772            },
773            TestResult {
774                id: "tests.test_title.test_long".to_owned(),
775                outcome: TestOutcome::Passed,
776            },
777            TestResult {
778                id: "setup_broken".to_owned(),
779                outcome: TestOutcome::Error,
780            },
781        ]);
782        let wanted: Vec<String> = [
783            "empty_title_is_refused",
784            "test_long",
785            "setup_broken",
786            "gone",
787        ]
788        .iter()
789        .map(|s| (*s).to_owned())
790        .collect();
791        let classes: Vec<RedClass> = classify_red_each(&r, &wanted)
792            .unwrap_or_default()
793            .into_iter()
794            .map(|(_, c)| c)
795            .collect();
796        assert_eq!(
797            classes,
798            [
799                RedClass::Assertion,
800                RedClass::Passed,
801                RedClass::CollectionError,
802                RedClass::NoTests
803            ]
804        );
805        r.tests = None;
806        assert!(classify_red_each(&r, &wanted).is_none(), "by name instead");
807    }
808
809    #[test]
810    fn one_test_is_red_only_by_its_own_assertion() {
811        let failed = check("acceptance", CheckStatus::Failed, None);
812        let one = RunnerSummary {
813            failed: 1,
814            ..RunnerSummary::default()
815        };
816        assert_eq!(
817            classify_red_one(&failed, Some(&one), "t"),
818            RedClass::Assertion
819        );
820        // An unrelated test failing next to it says nothing about `t`.
821        let many = RunnerSummary {
822            passed: 3,
823            failed: 1,
824            ..RunnerSummary::default()
825        };
826        assert_eq!(
827            classify_red_one(&failed, Some(&many), "t"),
828            RedClass::Unclassified
829        );
830        // By name from a report: the unrelated failure is not `t`'s.
831        let mut r = check("acceptance", CheckStatus::Failed, Some(&[]));
832        r.tests = Some(vec![
833            TestResult {
834                id: "other::broken".to_owned(),
835                outcome: TestOutcome::Failed,
836            },
837            TestResult {
838                id: "repro::t".to_owned(),
839                outcome: TestOutcome::Passed,
840            },
841        ]);
842        assert_eq!(classify_red_one(&r, None, "t"), RedClass::Passed);
843        assert_eq!(classify_red_one(&r, None, "broken"), RedClass::Assertion);
844        assert_eq!(classify_red_one(&r, None, "gone"), RedClass::NoTests);
845        // A reused set names files, not tests: the one-test rule alone.
846        assert_eq!(classify_red_one(&r, None, ""), RedClass::Unclassified);
847        assert_eq!(
848            classify_red_one(&failed, Some(&many), ""),
849            RedClass::Unclassified
850        );
851    }
852
853    #[test]
854    fn a_file_is_red_only_by_the_failures_it_adds() {
855        use TestOutcome::{Failed, Passed};
856        let without = check(
857            "held-out",
858            CheckStatus::Failed,
859            Some(&[("old::broken", Failed)]),
860        );
861        // The command fails anyway: the file's own test passes — not red.
862        let borrowed = check(
863            "held-out",
864            CheckStatus::Failed,
865            Some(&[("old::broken", Failed), ("new::variant", Passed)]),
866        );
867        assert_eq!(
868            classify_red_added((&borrowed, None), (&without, None)),
869            RedClass::Passed
870        );
871        // The file's own test fails: red.
872        let own = check(
873            "held-out",
874            CheckStatus::Failed,
875            Some(&[("old::broken", Failed), ("new::variant", Failed)]),
876        );
877        assert_eq!(
878            classify_red_added((&own, None), (&without, None)),
879            RedClass::Assertion
880        );
881        // No per-test report: by the runner's counts.
882        let plain = |status| check("held-out", status, None);
883        let count = |failed| RunnerSummary {
884            passed: 1,
885            failed,
886            ..RunnerSummary::default()
887        };
888        let (w, o) = (plain(CheckStatus::Failed), plain(CheckStatus::Failed));
889        assert_eq!(
890            classify_red_added((&w, Some(&count(1))), (&o, Some(&count(1)))),
891            RedClass::Passed
892        );
893        assert_eq!(
894            classify_red_added((&w, Some(&count(2))), (&o, Some(&count(1)))),
895            RedClass::Assertion
896        );
897        // Without the file the runner refused (no count): the failure is the file's.
898        let refused = plain(CheckStatus::Failed);
899        assert_eq!(
900            classify_red_added((&w, Some(&count(1))), (&refused, None)),
901            RedClass::Assertion
902        );
903        // Not red on its own terms stays what it is.
904        let built = plain(CheckStatus::Error);
905        let broken = RunnerSummary {
906            build_failed: true,
907            ..RunnerSummary::default()
908        };
909        assert_eq!(
910            classify_red_added((&built, Some(&broken)), (&o, Some(&count(1)))),
911            RedClass::BuildError
912        );
913    }
914
915    /// The sample's T032 test (2026-11-02) failed only on names that are
916    /// not there; whose they are is the caller's to say. A failing
917    /// assertion, or a domain error, is the behaviour.
918    #[test]
919    fn a_red_run_says_which_names_are_not_there() {
920        let ran = |out: &str| {
921            let mut c = check(
922                "acceptance",
923                CheckStatus::Failed,
924                Some(&[("t::a", TestOutcome::Failed)]),
925            );
926            c.stdout_tail = out.to_owned();
927            (classify_red(&c, None), missing_names(&c))
928        };
929        let names = |v: &[&str]| Some(v.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>());
930        assert_eq!(
931            ran(
932                "FAILED tests/test_acceptance_t032.py::test_header - ModuleNotFoundError: No module named 'src.lib.convert_table_to_csv'\n1 failed"
933            ),
934            (
935                RedClass::Assertion,
936                names(&["src.lib.convert_table_to_csv"])
937            )
938        );
939        assert_eq!(
940            ran("FAILED tests/t.py::a - AttributeError: 'tuple' object has no attribute 'header'")
941                .1,
942            names(&["header"])
943        );
944        assert_eq!(ran("FAILED tests/t.py::a - NameError: x\nFAILED tests/t.py::b - AssertionError: assert 1 == 2").1, None);
945        assert_eq!(
946            ran("FAILED tests/t.py::a - src.models.ConversionError: header missing").1,
947            None
948        );
949        assert_eq!(
950            ran("FAILED tests/t.py::a - NotImplementedError: soon").1,
951            None,
952            "a stub the task fills"
953        );
954        assert_eq!(
955            ran(" FAIL  tests/csv.test.ts > converts\nError: Cannot find module '../src/convert'")
956                .1,
957            names(&["../src/convert"])
958        );
959        assert_eq!(
960            ran(" FAIL  tests/csv.test.ts > converts\nAssertionError: expected 'a' to be 'b'").1,
961            None
962        );
963        assert_eq!(ran("test result: FAILED. 0 passed; 1 failed").1, None);
964    }
965
966    fn check(
967        name: &str,
968        status: CheckStatus,
969        tests: Option<&[(&str, TestOutcome)]>,
970    ) -> CheckResult {
971        CheckResult {
972            name: name.to_owned(),
973            argv: vec!["x".to_owned()],
974            status,
975            exit_code: Some(i32::from(status != CheckStatus::Passed)),
976            duration_ms: 1,
977            junit: tests.is_some(),
978            tests: tests.map(|t| {
979                t.iter()
980                    .map(|(id, o)| TestResult {
981                        id: (*id).to_owned(),
982                        outcome: *o,
983                    })
984                    .collect()
985            }),
986            stdout_tail: String::new(),
987            stderr_tail: String::new(),
988            truncated: false,
989            note: None,
990            flaky: false,
991            sandbox: Default::default(),
992        }
993    }
994
995    fn report(checks: Vec<CheckResult>) -> VerifyReport {
996        VerifyReport {
997            root: "/p".to_owned(),
998            tier: "fast".to_owned(),
999            checks,
1000            comparison: None,
1001            passed: true,
1002        }
1003    }
1004
1005    use CheckStatus::{Failed, Passed};
1006    use TestOutcome as T;
1007
1008    #[test]
1009    fn a_test_is_known_by_its_file_in_each_runner_s_words() {
1010        let files = [
1011            "tests/us1_word_count.rs".to_owned(),
1012            "tests/test_title.py".to_owned(),
1013        ];
1014        assert!(test_of("wordcount::us1_word_count::us1_1_counts", &files));
1015        assert!(test_of("tests.test_title.test_long", &files));
1016        assert!(test_of("tests/test_title.py::test_long", &files));
1017        assert!(!test_of("wordcount::us1_word_counter::other", &files));
1018        assert!(!test_of("lib::tests::word_count", &files));
1019    }
1020
1021    #[test]
1022    fn the_story_s_oracle_tests_failing_are_never_the_check_s_failure() {
1023        let oracle = ["tests/us1_count.rs".to_owned()];
1024        // At baseline the oracle's file did not build: no test ran.
1025        let before = report(vec![check("test", Failed, Some(&[]))]);
1026        let after = report(vec![check(
1027            "test",
1028            Failed,
1029            Some(&[
1030                ("demo::us1_count::one", T::Passed),
1031                ("demo::us1_count::two", T::Failed),
1032            ]),
1033        )]);
1034        let cmp = compare_except(&before, &after, &oracle);
1035        assert_eq!(cmp.failing_as_before, ["test"]);
1036        assert!(passes(&after.checks, Some(&cmp)));
1037        // Any other failing test is the change's.
1038        let worse = report(vec![check(
1039            "test",
1040            Failed,
1041            Some(&[
1042                ("demo::us1_count::two", T::Failed),
1043                ("demo::lib::other", T::Failed),
1044            ]),
1045        )]);
1046        assert!(
1047            compare_except(&before, &worse, &oracle)
1048                .failing_as_before
1049                .is_empty()
1050        );
1051        // Without the oracle, as before.
1052        assert!(compare(&before, &after).failing_as_before.is_empty());
1053    }
1054
1055    #[test]
1056    fn comparison_finds_regressions_pre_existing_and_new() {
1057        let base = report(vec![check(
1058            "test",
1059            Failed,
1060            Some(&[("a", T::Passed), ("b", T::Passed), ("c", T::Failed)]),
1061        )]);
1062        let now = report(vec![check(
1063            "test",
1064            Failed,
1065            Some(&[
1066                ("a", T::Passed),
1067                ("b", T::Failed),
1068                ("c", T::Failed),
1069                ("d", T::Passed),
1070            ]),
1071        )]);
1072        let cmp = compare(&base, &now);
1073        assert_eq!(
1074            cmp.regressions,
1075            [Regression {
1076                id: "test/b".to_owned(),
1077                now: "failed".to_owned()
1078            }]
1079        );
1080        assert_eq!(cmp.pre_existing, ["test/c"]);
1081        assert_eq!(cmp.new_tests, ["test/d"]);
1082        assert_eq!((cmp.count_before, cmp.count_after), (3, 4));
1083        assert!(cmp.failing_as_before.is_empty(), "b is a new failure");
1084    }
1085
1086    /// The sample's T009 (2026-11-01): one test file that no longer
1087    /// collected read as 17 regressions; it is one reason, with its error.
1088    #[test]
1089    fn a_check_whose_tests_are_gone_is_one_reason() {
1090        let mut now = check("test", CheckStatus::Failed, None);
1091        now.stdout_tail = "ERROR collecting tests/test_acceptance_us1.py\nE   SyntaxError: from __future__ imports must occur at the beginning of the file".to_owned();
1092        let gone: Vec<Regression> = (1..=17)
1093            .map(|n| Regression {
1094                id: format!("test/tests.unit::t{n}"),
1095                now: "missing".to_owned(),
1096            })
1097            .collect();
1098        assert_eq!(
1099            regression_reasons(&[now], &gone),
1100            [
1101                "regression: check `test` no longer runs 17 of its tests: E   SyntaxError: from __future__ imports must occur at the beginning of the file"
1102            ]
1103        );
1104        // A test or two gone stays named; so do tests an attempt removed
1105        // while the rest still ran.
1106        assert_eq!(regression_reasons(&[], &gone[..2]).len(), 2);
1107        let ran = check("test", CheckStatus::Passed, None);
1108        assert_eq!(regression_reasons(&[ran], &gone[..3]).len(), 3);
1109    }
1110
1111    #[test]
1112    fn missing_test_is_a_regression() {
1113        let base = report(vec![check(
1114            "test",
1115            Passed,
1116            Some(&[("a", T::Passed), ("b", T::Passed)]),
1117        )]);
1118        let now = report(vec![check("test", Passed, Some(&[("a", T::Passed)]))]);
1119        let cmp = compare(&base, &now);
1120        assert_eq!(cmp.regressions[0].now, "missing");
1121        assert!(!passes(&now.checks, Some(&cmp)));
1122    }
1123
1124    #[test]
1125    fn pre_existing_failures_alone_do_not_fail_against_a_baseline() {
1126        let base = report(vec![check(
1127            "test",
1128            Failed,
1129            Some(&[("a", T::Passed), ("c", T::Failed)]),
1130        )]);
1131        let now = report(vec![check(
1132            "test",
1133            Failed,
1134            Some(&[("a", T::Passed), ("c", T::Failed)]),
1135        )]);
1136        let cmp = compare(&base, &now);
1137        assert_eq!(cmp.failing_as_before, ["test"]);
1138        assert!(passes(&now.checks, Some(&cmp)));
1139        assert!(
1140            !passes(&now.checks, None),
1141            "without a baseline a failing check fails"
1142        );
1143    }
1144
1145    #[test]
1146    fn verdict_levels() {
1147        let passing = check("unit", Passed, Some(&[("a", T::Passed)]));
1148        let empty = check("unit", Passed, Some(&[]));
1149        let lint = check("lint", Passed, None);
1150        assert_eq!(
1151            verdict(&["unit"], &[passing.clone(), lint.clone()], None),
1152            Verdict::Verified
1153        );
1154        assert_eq!(
1155            verdict(&[], &[passing.clone(), lint.clone()], None),
1156            Verdict::RegressionOnly
1157        );
1158        assert_eq!(
1159            verdict(&["unit"], &[empty], None),
1160            Verdict::RegressionOnly,
1161            "zero tests is no evidence"
1162        );
1163        assert_eq!(
1164            verdict(&["lint"], &[lint], None),
1165            Verdict::Verified,
1166            "a passing command check is evidence"
1167        );
1168        let failing = check("lint", Failed, None);
1169        assert!(matches!(
1170            verdict(&["unit"], &[passing, failing], None),
1171            Verdict::Failed { .. }
1172        ));
1173    }
1174
1175    fn summary(failed: usize, build_failed: bool, unresolved_only: bool) -> RunnerSummary {
1176        RunnerSummary {
1177            passed: 0,
1178            failed,
1179            errors: 0,
1180            build_failed,
1181            unresolved_only,
1182        }
1183    }
1184
1185    #[test]
1186    fn red_is_an_assertion_or_an_absent_symbol() {
1187        use TestOutcome as T;
1188        let failed = check("a", CheckStatus::Failed, Some(&[("t", T::Failed)]));
1189        assert_eq!(classify_red(&failed, None), RedClass::Assertion);
1190        let errored = check("a", CheckStatus::Failed, Some(&[("t", T::Error)]));
1191        assert_eq!(classify_red(&errored, None), RedClass::CollectionError);
1192        let empty = check("a", CheckStatus::Failed, Some(&[]));
1193        assert_eq!(classify_red(&empty, None), RedClass::NoTests);
1194        let plain = check("a", CheckStatus::Failed, None);
1195        assert_eq!(classify_red(&plain, None), RedClass::Unclassified);
1196        assert_eq!(
1197            classify_red(&plain, Some(&summary(2, false, false))),
1198            RedClass::Assertion
1199        );
1200        assert_eq!(
1201            classify_red(&plain, Some(&summary(0, true, false))),
1202            RedClass::BuildError
1203        );
1204        assert_eq!(
1205            classify_red(&plain, Some(&summary(0, true, true))),
1206            RedClass::Absent
1207        );
1208        assert_eq!(
1209            classify_red(&plain, Some(&summary(0, false, false))),
1210            RedClass::NoTests
1211        );
1212        let timed_out = check("a", CheckStatus::TimedOut, None);
1213        assert_eq!(
1214            classify_red(&timed_out, Some(&summary(1, false, false))),
1215            RedClass::Timeout
1216        );
1217        let passed = check("a", CheckStatus::Passed, Some(&[("t", T::Passed)]));
1218        assert_eq!(classify_red(&passed, None), RedClass::Passed);
1219        assert!(RedClass::Assertion.is_red() && RedClass::Absent.is_red());
1220        assert!(!RedClass::BuildError.is_red() && !RedClass::Flaky.is_red());
1221    }
1222
1223    #[test]
1224    fn a_flaky_pass_is_not_evidence_and_errors_regress() {
1225        use TestOutcome as T;
1226        let mut c = check("acceptance", CheckStatus::Passed, None);
1227        assert!(c.is_evidence());
1228        c.flaky = true;
1229        assert!(!c.is_evidence());
1230        assert_eq!(
1231            verdict(&["acceptance"], &[c], None),
1232            Verdict::RegressionOnly
1233        );
1234
1235        let before = report(vec![check(
1236            "t",
1237            CheckStatus::Passed,
1238            Some(&[("x", T::Passed)]),
1239        )]);
1240        let after = report(vec![check(
1241            "t",
1242            CheckStatus::Failed,
1243            Some(&[("x", T::Error)]),
1244        )]);
1245        let cmp = compare(&before, &after);
1246        assert_eq!(cmp.regressions[0].now, "error");
1247        let again = compare(&after, &after);
1248        assert_eq!(again.pre_existing, ["t/x"]);
1249    }
1250
1251    mod properties {
1252        use super::*;
1253        use proptest::prelude::*;
1254
1255        fn statuses() -> impl Strategy<Value = CheckStatus> {
1256            prop_oneof![
1257                Just(CheckStatus::Passed),
1258                Just(CheckStatus::Failed),
1259                Just(CheckStatus::TimedOut),
1260                Just(CheckStatus::Skipped),
1261                Just(CheckStatus::Error),
1262            ]
1263        }
1264
1265        fn outcomes() -> impl Strategy<Value = TestOutcome> {
1266            prop_oneof![
1267                Just(T::Passed),
1268                Just(T::Failed),
1269                Just(T::Skipped),
1270                Just(T::Error)
1271            ]
1272        }
1273
1274        fn checks() -> impl Strategy<Value = Vec<CheckResult>> {
1275            prop::collection::vec(
1276                (
1277                    statuses(),
1278                    prop::option::of(prop::collection::vec(outcomes(), 0..5)),
1279                    any::<bool>(),
1280                ),
1281                1..5,
1282            )
1283            .prop_map(|v| {
1284                v.into_iter()
1285                    .enumerate()
1286                    .map(|(i, (status, tests, flaky))| CheckResult {
1287                        name: format!("c{i}"),
1288                        argv: vec!["x".to_owned()],
1289                        status,
1290                        exit_code: None,
1291                        duration_ms: 0,
1292                        junit: tests.is_some(),
1293                        tests: tests.map(|t| {
1294                            t.into_iter()
1295                                .enumerate()
1296                                .map(|(j, outcome)| TestResult {
1297                                    id: format!("t{j}"),
1298                                    outcome,
1299                                })
1300                                .collect()
1301                        }),
1302                        stdout_tail: String::new(),
1303                        stderr_tail: String::new(),
1304                        truncated: false,
1305                        note: None,
1306                        flaky,
1307                        sandbox: Denials::default(),
1308                    })
1309                    .collect()
1310            })
1311        }
1312
1313        fn held_outs() -> impl Strategy<Value = HeldOut> {
1314            prop_oneof![
1315                Just(HeldOut::Off),
1316                Just(HeldOut::Absent {
1317                    reason: "not_red".to_owned()
1318                }),
1319                (0..4usize).prop_map(|files| HeldOut::Passed { files }),
1320                proptest::collection::vec("US[1-3]-[1-4]", 1..4)
1321                    .prop_map(|scenarios| HeldOut::Failed { scenarios }),
1322            ]
1323        }
1324
1325        proptest! {
1326            #[test]
1327            fn a_held_out_failure_is_never_a_pass(cs in checks(), pick in 0..5usize, h in held_outs()) {
1328                let name = format!("c{pick}");
1329                let before = verdict(&[name.as_str()], &cs, None);
1330                let after = with_held_out(before.clone(), &h);
1331                match h {
1332                    HeldOut::Failed { .. } => {
1333                        let failed = matches!(after, Verdict::Failed { .. });
1334                        prop_assert!(failed);
1335                    }
1336                    _ => prop_assert_eq!(after, before),
1337                }
1338            }
1339
1340            #[test]
1341            fn any_failing_check_means_failed(cs in checks(), pick in 0..5usize) {
1342                let task = [format!("c{pick}")];
1343                let task: Vec<&str> = task.iter().map(String::as_str).collect();
1344                let v = verdict(&task, &cs, None);
1345                let any_failure = cs.iter().any(|c| c.status.is_failure());
1346                prop_assert_eq!(matches!(v, Verdict::Failed { .. }), any_failure);
1347            }
1348
1349            #[test]
1350            fn verified_requires_task_evidence(cs in checks(), pick in 0..5usize) {
1351                let name = format!("c{pick}");
1352                let v = verdict(&[name.as_str()], &cs, None);
1353                if v == Verdict::Verified {
1354                    let c = cs.iter().find(|c| c.name == name);
1355                    prop_assert!(c.is_some_and(CheckResult::is_evidence));
1356                }
1357            }
1358
1359            #[test]
1360            fn a_flaky_task_check_is_never_verified(cs in checks(), pick in 0..5usize) {
1361                let name = format!("c{pick}");
1362                let v = verdict(&[name.as_str()], &cs, None);
1363                if cs.iter().find(|c| c.name == name).is_some_and(|c| c.flaky) {
1364                    prop_assert_ne!(v, Verdict::Verified);
1365                }
1366            }
1367
1368            #[test]
1369            fn red_always_has_a_cause(
1370                cs in checks(),
1371                passed in 0..3usize,
1372                failed in 0..3usize,
1373                errors in 0..3usize,
1374                build_failed in any::<bool>(),
1375                unresolved_only in any::<bool>(),
1376                with_summary in any::<bool>(),
1377            ) {
1378                let summary = RunnerSummary { passed, failed, errors, build_failed, unresolved_only };
1379                let summary = with_summary.then_some(summary);
1380                for c in &cs {
1381                    let class = classify_red(c, summary.as_ref());
1382                    if class.is_red() {
1383                        let assertion_in_report = c
1384                            .tests
1385                            .iter()
1386                            .flatten()
1387                            .any(|t| t.outcome == T::Failed);
1388                        let by_summary = summary.is_some_and(|s| s.failed > 0 || (s.build_failed && s.unresolved_only));
1389                        prop_assert!(assertion_in_report || by_summary);
1390                        prop_assert_ne!(c.status, CheckStatus::TimedOut);
1391                    }
1392                }
1393            }
1394
1395            #[test]
1396            fn a_run_compared_with_itself_has_no_regressions(cs in checks()) {
1397                let r = report(cs);
1398                let cmp = compare(&r, &r);
1399                prop_assert!(cmp.regressions.is_empty());
1400                prop_assert!(cmp.new_tests.is_empty());
1401                prop_assert_eq!(cmp.count_before, cmp.count_after);
1402            }
1403
1404            #[test]
1405            fn regressions_always_fail_the_verdict(cs in checks()) {
1406                let before = report(cs.clone());
1407                let mut after = report(cs);
1408                for c in &mut after.checks {
1409                    if let Some(tests) = &mut c.tests {
1410                        tests.clear();
1411                    }
1412                }
1413                let cmp = compare(&before, &after);
1414                let had_passing = cmp.count_before > 0
1415                    && before.checks.iter().flat_map(|c| c.tests.iter().flatten()).any(|t| t.outcome == T::Passed);
1416                if had_passing {
1417                    let failed = matches!(verdict(&[], &after.checks, Some(&cmp)), Verdict::Failed { .. });
1418                    prop_assert!(failed);
1419                }
1420            }
1421        }
1422    }
1423}