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