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