Skip to main content

ev/
verdict.rs

1//! The pure verdict engine: per Test-bound ground, the resurface precedence.
2//! No I/O — receipts, the live-origin sha, and the selected-list are passed in. Facts,
3//! not verdicts: every not-green state is a co-equal fact, never ranked or scored.
4//!
5//! Precedence (first match wins): sha-stale → not-run → age-stale → unproven → gray→red →
6//! red → silently-unbound → green.
7use crate::receipt::Receipt;
8use crate::selected::SelectedList;
9use crate::tick::{Check, Ground};
10
11#[derive(Debug, Clone, PartialEq)]
12pub enum Verdict {
13    Green,
14    Red,
15    GrayRed,
16    Unproven,
17    NotRun { missing_platforms: Vec<String> },
18    Stale { reason: String },
19    SilentlyUnbound,
20    Exempt,        // this runner attests none of the binding's declared platforms (non-gating)
21    Memo, // a C/D-jurisdiction (detect-only) not-green fact — surfaced but never gates (non-gating)
22    NotApplicable, // no check, or a person re-check
23}
24
25impl Verdict {
26    /// The flat, human-facing label — facts, not verdicts (no score, no rank).
27    pub fn label(&self) -> &'static str {
28        match self {
29            Verdict::Green => "green",
30            Verdict::Red => "red",
31            Verdict::GrayRed => "gray->red",
32            Verdict::Unproven => "unproven",
33            Verdict::NotRun { .. } => "not-run",
34            Verdict::Stale { .. } => "stale",
35            Verdict::SilentlyUnbound => "silently-unbound",
36            Verdict::Exempt => "exempt",
37            Verdict::Memo => "memo",
38            Verdict::NotApplicable => "n/a",
39        }
40    }
41}
42
43use time::{format_description::well_known::Rfc3339, OffsetDateTime};
44
45/// The evaluation context, built once per `ev check` / `ev reopen` invocation:
46/// the staleness reference sha, the selected-list, and the clock for age-staleness.
47pub struct Ctx {
48    pub live_origin_sha: Option<String>, // None ⇒ sha-staleness not evaluated
49    pub selected: Option<SelectedList>,  // None ⇒ L2 not evaluated
50    pub now_unix: i64,                   // current time, unix seconds
51    pub staleness_secs: i64, // a deciding receipt older than this is stale; i64::MAX disables
52    pub attest: Option<Vec<String>>, // platforms this runner speaks for; None ⇒ attest all
53}
54
55/// Verdict for one ground against `receipts` (this ground's run-receipts) and `ctx`.
56pub fn verdict_for(
57    ground: &Ground,
58    receipts: &[Receipt],
59    ctx: &Ctx,
60    triggered_since: bool,
61) -> Verdict {
62    let (reference, verified_at_sha, liveness) = match &ground.check {
63        Some(Check::Test {
64            reference,
65            verified_at_sha,
66            liveness,
67            ..
68        }) => (reference.as_str(), verified_at_sha.as_str(), liveness),
69        _ => return Verdict::NotApplicable,
70    };
71
72    if let Some(origin) = ctx.live_origin_sha.as_deref() {
73        if origin != verified_at_sha {
74            return Verdict::Stale {
75                reason: "verified_at_sha behind live origin".into(),
76            };
77        }
78    }
79
80    // Per-runner attestation: only platforms this runner speaks for count toward not-run.
81    // None ⇒ attest all (cross-platform audit / default).
82    let attested: Vec<&String> = match &ctx.attest {
83        Some(set) => liveness
84            .platforms
85            .iter()
86            .filter(|p| set.contains(p))
87            .collect(),
88        None => liveness.platforms.iter().collect(),
89    };
90    if ctx.attest.is_some() && attested.is_empty() {
91        return Verdict::Exempt; // this runner speaks for none of the declared platforms
92    }
93    let mut missing = Vec::new();
94    let mut deciding: Vec<&Receipt> = Vec::new();
95    for p in attested {
96        // RFC-3339 UTC timestamps sort chronologically, so the lexicographic max is the latest run.
97        let latest = receipts
98            .iter()
99            .filter(|r| r.test == reference && &r.platform == p)
100            .max_by(|a, b| a.ran_at.cmp(&b.ran_at));
101        match latest {
102            None => missing.push(p.clone()),
103            Some(r) => deciding.push(r),
104        }
105    }
106    if !missing.is_empty() {
107        return Verdict::NotRun {
108            missing_platforms: missing,
109        };
110    }
111
112    // Event-driven freshness: a commit touching a declared trigger landed after the last run,
113    // so the green is for a stale world. A not-green fact (the count-N window is rejected — a
114    // refactor moving the assumption out of triggered_by would otherwise stay green forever).
115    if triggered_since {
116        return Verdict::Stale {
117            reason: "a triggering change landed after the last run".into(),
118        };
119    }
120
121    // Age-staleness: a deciding receipt older than the staleness window is too old to trust.
122    // An unparseable ran_at is skipped (a data fault, not a freshness signal).
123    let stale_by_age = deciding.iter().any(|r| {
124        OffsetDateTime::parse(&r.ran_at, &Rfc3339)
125            .map(|dt| ctx.now_unix - dt.unix_timestamp() > ctx.staleness_secs)
126            .unwrap_or(false)
127    });
128    if stale_by_age {
129        return Verdict::Stale {
130            reason: "deciding receipt older than the staleness window".into(),
131        };
132    }
133
134    // The check could not be shown to flip (its counter-test failed to produce the opposite) —
135    // its green/red reading is untrustworthy, so this overrides them.
136    if deciding.iter().any(|r| r.falsifiable == Some(false)) {
137        return Verdict::Unproven;
138    }
139
140    if deciding.iter().any(|r| r.result == "gray") {
141        return Verdict::GrayRed;
142    }
143    if deciding.iter().any(|r| r.result == "red") {
144        return Verdict::Red;
145    }
146
147    // L2 selected: the latest diff touched a declared trigger but did not select this ref —
148    // the receipts look green but were not re-run for the change that touched the assumption.
149    if let Some(sl) = ctx.selected.as_ref() {
150        let touched = liveness
151            .triggered_by
152            .iter()
153            .any(|t| sl.changed.iter().any(|c| c == t));
154        let was_selected = sl.selected.iter().any(|s| s == reference);
155        if touched && !was_selected {
156            return Verdict::SilentlyUnbound;
157        }
158    }
159
160    Verdict::Green
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::receipt::Receipt;
167    use crate::tick::{Check, Ground, Liveness};
168
169    fn test_ground(platforms: &[&str]) -> Ground {
170        Ground {
171            claim: "no Redis".into(),
172            supports: "chosen".into(),
173            check: Some(Check::Test {
174                reference: "pytest x".into(),
175                verified_at_sha: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
176                counter_test: Some("pytest x::flips".into()),
177                liveness: Liveness {
178                    platforms: platforms.iter().map(|s| s.to_string()).collect(),
179                    triggered_by: vec!["pyproject.toml".into()],
180                    surfaces: vec!["pyproject-deps".into()],
181                },
182            }),
183        }
184    }
185    fn rcpt(platform: &str, ran_at: &str, result: &str) -> Receipt {
186        Receipt {
187            test: "pytest x".into(),
188            platform: platform.into(),
189            commit: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
190            ran_at: ran_at.into(),
191            result: result.into(),
192            falsifiable: None,
193        }
194    }
195    // A Ctx with age-staleness DISABLED (staleness_secs = i64::MAX), for the non-age tests.
196    fn ctx(live_origin_sha: Option<&str>, selected: Option<SelectedList>) -> Ctx {
197        Ctx {
198            live_origin_sha: live_origin_sha.map(|s| s.to_string()),
199            selected,
200            now_unix: 0,
201            staleness_secs: i64::MAX,
202            attest: None,
203        }
204    }
205    fn ctx_attest(attest: Option<&[&str]>) -> Ctx {
206        Ctx {
207            live_origin_sha: None,
208            selected: None,
209            now_unix: 0,
210            staleness_secs: i64::MAX,
211            attest: attest.map(|a| a.iter().map(|s| s.to_string()).collect()),
212        }
213    }
214
215    #[test]
216    fn verdict_for_should_be_not_applicable_when_the_ground_has_a_person_check() {
217        // given: a person-rechecked ground
218        let g = Ground {
219            claim: "c".into(),
220            supports: "chosen".into(),
221            check: Some(Check::Person {
222                reference: "Q3".into(),
223            }),
224        };
225
226        // when: its verdict is computed
227        let v = verdict_for(&g, &[], &ctx(None, None), false);
228
229        // then: it is not applicable (person grounds never appear in check)
230        assert_eq!(v, Verdict::NotApplicable);
231    }
232
233    #[test]
234    fn verdict_for_should_be_not_run_when_a_declared_platform_has_no_receipt() {
235        // given: a binding on two platforms with a receipt for only one
236        let g = test_ground(&["linux-ci", "mac"]);
237        let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
238
239        // when: its verdict is computed
240        let v = verdict_for(&g, &receipts, &ctx(None, None), false);
241
242        // then: it is not-run, naming the missing platform
243        assert_eq!(
244            v,
245            Verdict::NotRun {
246                missing_platforms: vec!["mac".into()]
247            }
248        );
249    }
250
251    #[test]
252    fn verdict_for_should_promote_gray_to_red_when_the_deciding_receipt_is_gray() {
253        // given: a single-platform binding whose latest receipt is gray
254        let g = test_ground(&["linux-ci"]);
255        let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "gray")];
256
257        // when: its verdict is computed
258        let v = verdict_for(&g, &receipts, &ctx(None, None), false);
259
260        // then: gray is promoted to red, never dropped
261        assert_eq!(v, Verdict::GrayRed);
262    }
263
264    #[test]
265    fn verdict_for_should_be_red_when_the_latest_receipt_is_red() {
266        // given: a binding whose later receipt (by ran_at) is red, an earlier one green
267        let g = test_ground(&["linux-ci"]);
268        let receipts = vec![
269            rcpt("linux-ci", "2026-01-01T00:00:00Z", "green"),
270            rcpt("linux-ci", "2026-02-01T00:00:00Z", "red"),
271        ];
272
273        // when: its verdict is computed
274        let v = verdict_for(&g, &receipts, &ctx(None, None), false);
275
276        // then: the latest (red) decides
277        assert_eq!(v, Verdict::Red);
278    }
279
280    #[test]
281    fn verdict_for_should_be_green_when_every_platform_has_a_fresh_green_receipt() {
282        // given: a two-platform binding green on both, no stale reference
283        let g = test_ground(&["linux-ci", "mac"]);
284        let receipts = vec![
285            rcpt("linux-ci", "2026-01-01T00:00:00Z", "green"),
286            rcpt("mac", "2026-01-01T00:00:00Z", "green"),
287        ];
288
289        // when: its verdict is computed
290        let v = verdict_for(&g, &receipts, &ctx(None, None), false);
291
292        // then: it is green
293        assert_eq!(v, Verdict::Green);
294    }
295
296    #[test]
297    fn verdict_for_should_be_stale_when_verified_at_sha_is_behind_the_live_origin() {
298        // given: a green binding whose verified_at_sha differs from the live-origin sha
299        let g = test_ground(&["linux-ci"]);
300        let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
301        let origin = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
302
303        // when: its verdict is computed against that origin
304        let v = verdict_for(&g, &receipts, &ctx(Some(origin), None), false);
305
306        // then: it is stale (binary, no grace) — never shown green
307        assert!(matches!(v, Verdict::Stale { .. }));
308    }
309
310    #[test]
311    fn verdict_for_should_be_silently_unbound_when_a_touched_trigger_was_not_selected() {
312        // given: a green-otherwise binding whose declared trigger the diff changed but did not select
313        let g = test_ground(&["linux-ci"]);
314        let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
315        let sl = crate::selected::SelectedList {
316            commit: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
317            changed: vec!["pyproject.toml".into()],
318            selected: vec![],
319        };
320
321        // when: its verdict is computed against that selected-list
322        let v = verdict_for(&g, &receipts, &ctx(None, Some(sl)), false);
323
324        // then: it is silently-unbound (never counted green)
325        assert_eq!(v, Verdict::SilentlyUnbound);
326    }
327
328    #[test]
329    fn verdict_for_should_be_green_when_the_touched_trigger_was_selected() {
330        // given: the same binding, but the diff did select its ref
331        let g = test_ground(&["linux-ci"]);
332        let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
333        let sl = crate::selected::SelectedList {
334            commit: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
335            changed: vec!["pyproject.toml".into()],
336            selected: vec!["pytest x".into()],
337        };
338
339        // when: its verdict is computed
340        let v = verdict_for(&g, &receipts, &ctx(None, Some(sl)), false);
341
342        // then: it is green (selected, so not silently-unbound)
343        assert_eq!(v, Verdict::Green);
344    }
345
346    #[test]
347    fn verdict_for_should_be_stale_when_the_deciding_receipt_is_older_than_the_window() {
348        // given: a green receipt from 2026-01-01 evaluated ~5 months later, 7-day window
349        let g = test_ground(&["linux-ci"]);
350        let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
351        let c = Ctx {
352            live_origin_sha: None,
353            selected: None,
354            now_unix: OffsetDateTime::parse("2026-06-01T00:00:00Z", &Rfc3339)
355                .unwrap()
356                .unix_timestamp(),
357            staleness_secs: 7 * 86_400,
358            attest: None,
359        };
360
361        // when: its verdict is computed against that clock
362        let v = verdict_for(&g, &receipts, &c, false);
363
364        // then: it is stale (too old to trust), never green
365        assert!(matches!(v, Verdict::Stale { .. }));
366    }
367
368    #[test]
369    fn verdict_for_should_be_green_when_the_deciding_receipt_is_within_the_window() {
370        // given: a green receipt one hour before now, 7-day window
371        let g = test_ground(&["linux-ci"]);
372        let receipts = vec![rcpt("linux-ci", "2026-06-01T00:00:00Z", "green")];
373        let c = Ctx {
374            live_origin_sha: None,
375            selected: None,
376            now_unix: OffsetDateTime::parse("2026-06-01T01:00:00Z", &Rfc3339)
377                .unwrap()
378                .unix_timestamp(),
379            staleness_secs: 7 * 86_400,
380            attest: None,
381        };
382
383        // when: its verdict is computed
384        let v = verdict_for(&g, &receipts, &c, false);
385
386        // then: it is green (fresh)
387        assert_eq!(v, Verdict::Green);
388    }
389
390    #[test]
391    fn verdict_for_should_be_stale_when_a_triggering_change_landed_after_the_last_run() {
392        // given: a green binding whose deciding receipt is behind a triggering change
393        let g = test_ground(&["linux-ci"]);
394        let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
395
396        // when: its verdict is computed with triggered_since = true
397        let v = verdict_for(&g, &receipts, &ctx(None, None), true);
398
399        // then: it is stale — the green is for a stale world, never shown green
400        assert!(matches!(v, Verdict::Stale { .. }));
401    }
402
403    #[test]
404    fn verdict_for_should_ignore_triggered_since_when_a_platform_is_already_not_run() {
405        // given: a two-platform binding missing a receipt on one platform, and a triggering change
406        let g = test_ground(&["linux-ci", "mac"]);
407        let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
408
409        // when: its verdict is computed with triggered_since = true
410        let v = verdict_for(&g, &receipts, &ctx(None, None), true);
411
412        // then: absence-not-run still wins (precedence: not-run before triggering-stale)
413        assert_eq!(
414            v,
415            Verdict::NotRun {
416                missing_platforms: vec!["mac".into()]
417            }
418        );
419    }
420
421    #[test]
422    fn verdict_for_should_exempt_a_binding_whose_platforms_this_runner_does_not_attest() {
423        // given: a mac-only binding, a runner attesting only linux-ci, no receipts
424        let g = test_ground(&["mac"]);
425        // then: exempt (a non-gating fact), NOT not-run
426        assert_eq!(
427            verdict_for(&g, &[], &ctx_attest(Some(&["linux-ci"])), false),
428            Verdict::Exempt
429        );
430    }
431
432    #[test]
433    fn verdict_for_should_ignore_an_unattested_platform_when_an_attested_one_is_green() {
434        // given: a [linux-ci, mac] binding, runner attests linux-ci, a green linux-ci receipt
435        let g = test_ground(&["linux-ci", "mac"]);
436        let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
437        // then: green — mac is exempt, only the attested linux-ci needs a receipt
438        assert_eq!(
439            verdict_for(&g, &receipts, &ctx_attest(Some(&["linux-ci"])), false),
440            Verdict::Green
441        );
442    }
443
444    #[test]
445    fn verdict_for_should_be_unproven_when_a_deciding_receipt_is_not_falsifiable() {
446        // given: a single-platform binding whose green receipt failed its falsifiability proof
447        let g = test_ground(&["linux-ci"]);
448        let receipts = vec![Receipt {
449            test: "pytest x".into(),
450            platform: "linux-ci".into(),
451            commit: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
452            ran_at: "2026-01-01T00:00:00Z".into(),
453            result: "green".into(),
454            falsifiable: Some(false),
455        }];
456        // when: its verdict is computed
457        let v = verdict_for(&g, &receipts, &ctx(None, None), false);
458        // then: it is unproven (the check can't be shown to flip), never shown green
459        assert_eq!(v, Verdict::Unproven);
460    }
461
462    #[test]
463    fn verdict_for_should_be_green_when_a_deciding_receipt_is_proven_falsifiable() {
464        // given: a green receipt that passed its falsifiability proof
465        let g = test_ground(&["linux-ci"]);
466        let receipts = vec![Receipt {
467            test: "pytest x".into(),
468            platform: "linux-ci".into(),
469            commit: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
470            ran_at: "2026-01-01T00:00:00Z".into(),
471            result: "green".into(),
472            falsifiable: Some(true),
473        }];
474        // when: its verdict is computed
475        let v = verdict_for(&g, &receipts, &ctx(None, None), false);
476        // then: it is green (proven + passing)
477        assert_eq!(v, Verdict::Green);
478    }
479}