Skip to main content

firstpass_core/
verdict.rs

1//! Verdicts and scores — the atomic unit of ground truth (SPEC §7.1, §9).
2//!
3//! A gate emits a [`Verdict`] (`pass`/`fail`/`abstain`) plus an optional [`Score`]
4//! in `[0, 1]`, its cost, its latency, and optional evidence. These types are the
5//! wire/audit contract: their serde field names appear verbatim in the trace (§9.1),
6//! so renaming one is a breaking change.
7
8use crate::error::{Error, Result};
9use serde::{Deserialize, Serialize};
10
11/// A gate's judgement of an attempt.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum Verdict {
15    /// The output cleared the bar — safe to serve.
16    Pass,
17    /// The output failed the bar — escalate (or, for deferred gates, a learning signal).
18    Fail,
19    /// The gate could not decide (provider/gate error, timeout). Policy resolves it
20    /// fail-open or fail-closed (§7.2); an abstain is never silently a pass.
21    Abstain,
22}
23
24impl Verdict {
25    /// True only for [`Verdict::Pass`].
26    #[must_use]
27    pub const fn is_pass(self) -> bool {
28        matches!(self, Verdict::Pass)
29    }
30
31    /// Its lowercase wire form (`"pass"`/`"fail"`/`"abstain"`).
32    #[must_use]
33    pub const fn as_str(self) -> &'static str {
34        match self {
35            Verdict::Pass => "pass",
36            Verdict::Fail => "fail",
37            Verdict::Abstain => "abstain",
38        }
39    }
40}
41
42/// A confidence score, validated to the closed unit interval `[0, 1]`.
43///
44/// Validation happens at the trust boundary: construction and deserialization both
45/// reject `NaN`, infinities, and out-of-range values, so nothing downstream has to
46/// re-check. Serializes transparently as an `f64`.
47#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize)]
48#[serde(transparent)]
49pub struct Score(f64);
50
51impl Score {
52    /// Construct a score, rejecting non-finite or out-of-range values.
53    ///
54    /// # Errors
55    /// Returns [`Error::InvalidScore`] if `v` is not finite or falls outside `[0, 1]`.
56    pub fn new(v: f64) -> Result<Self> {
57        if v.is_finite() && (0.0..=1.0).contains(&v) {
58            Ok(Self(v))
59        } else {
60            Err(Error::InvalidScore(v))
61        }
62    }
63
64    /// Construct a score by clamping to `[0, 1]`. A non-finite input is treated as `0.0`.
65    #[must_use]
66    pub fn clamped(v: f64) -> Self {
67        if v.is_finite() {
68            Self(v.clamp(0.0, 1.0))
69        } else {
70            Self(0.0)
71        }
72    }
73
74    /// The underlying value in `[0, 1]`.
75    #[must_use]
76    pub const fn value(self) -> f64 {
77        self.0
78    }
79}
80
81impl<'de> Deserialize<'de> for Score {
82    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
83        let v = f64::deserialize(d)?;
84        Score::new(v).map_err(serde::de::Error::custom)
85    }
86}
87
88impl std::fmt::Display for Score {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        write!(f, "{}", self.0)
91    }
92}
93
94/// The outcome of running one gate against one attempt (a row in `attempts[].gates`, §9.1).
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96pub struct GateResult {
97    /// Stable, versioned gate identity, e.g. `"judge-diff@v2"`.
98    pub gate_id: String,
99    /// The gate's judgement.
100    pub verdict: Verdict,
101    /// Confidence in `[0, 1]`. Absent when the gate abstained without a score.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub score: Option<Score>,
104    /// Marginal USD spent running this gate (0.0 for deterministic gates like a test run).
105    pub cost_usd: f64,
106    /// Wall-clock milliseconds the gate took.
107    pub ms: u64,
108    /// Machine-readable reason, primarily for abstains (e.g. `"provider_error"`, `"timeout"`).
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub reason: Option<String>,
111    /// Opaque pointer to an evidence blob (judge rationale) stored separately with its
112    /// own retention clock (§9.2) — never the rationale inline.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub evidence_ref: Option<String>,
115}
116
117/// Well-known abstain reasons (§7.2). Reasons are stored as open strings on
118/// [`GateResult::reason`]; these constants keep producers consistent.
119pub mod reason {
120    /// The provider serving the attempt (or the gate's own model) errored.
121    pub const PROVIDER_ERROR: &str = "provider_error";
122    /// The gate exceeded its time budget.
123    pub const TIMEOUT: &str = "timeout";
124    /// The gate process crashed or returned a non-zero exit.
125    pub const GATE_CRASH: &str = "gate_crash";
126    /// The gate was auto-disabled for exceeding its error budget (§7.2).
127    pub const GATE_DISABLED: &str = "gate_disabled";
128}
129
130impl GateResult {
131    /// Build a deterministic (zero-cost) gate result.
132    #[must_use]
133    pub fn deterministic(gate_id: impl Into<String>, verdict: Verdict, ms: u64) -> Self {
134        Self {
135            gate_id: gate_id.into(),
136            verdict,
137            score: None,
138            cost_usd: 0.0,
139            ms,
140            reason: None,
141            evidence_ref: None,
142        }
143    }
144
145    /// Build an abstain result carrying a machine-readable reason.
146    #[must_use]
147    pub fn abstain(gate_id: impl Into<String>, reason: impl Into<String>, ms: u64) -> Self {
148        Self {
149            gate_id: gate_id.into(),
150            verdict: Verdict::Abstain,
151            score: None,
152            cost_usd: 0.0,
153            ms,
154            reason: Some(reason.into()),
155            evidence_ref: None,
156        }
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn score_validates_range_and_finiteness() {
166        assert!(Score::new(0.0).is_ok());
167        assert!(Score::new(1.0).is_ok());
168        assert!(Score::new(0.31).is_ok());
169        assert!(Score::new(-0.01).is_err());
170        assert!(Score::new(1.01).is_err());
171        assert!(Score::new(f64::NAN).is_err());
172        assert!(Score::new(f64::INFINITY).is_err());
173    }
174
175    #[test]
176    fn score_clamps() {
177        assert_eq!(Score::clamped(2.0).value(), 1.0);
178        assert_eq!(Score::clamped(-5.0).value(), 0.0);
179        assert_eq!(Score::clamped(f64::NAN).value(), 0.0);
180        assert_eq!(Score::clamped(0.5).value(), 0.5);
181    }
182
183    #[test]
184    fn score_deserialize_rejects_out_of_range() {
185        assert!(serde_json::from_str::<Score>("0.5").is_ok());
186        assert!(serde_json::from_str::<Score>("1.5").is_err());
187    }
188
189    #[test]
190    fn verdict_wire_form_is_lowercase() {
191        assert_eq!(serde_json::to_string(&Verdict::Pass).unwrap(), "\"pass\"");
192        assert_eq!(
193            serde_json::to_string(&Verdict::Abstain).unwrap(),
194            "\"abstain\""
195        );
196        assert_eq!(
197            serde_json::from_str::<Verdict>("\"fail\"").unwrap(),
198            Verdict::Fail
199        );
200    }
201
202    #[test]
203    fn gate_result_omits_absent_optionals() {
204        let g = GateResult::deterministic("cargo-test", Verdict::Pass, 3100);
205        let j = serde_json::to_string(&g).unwrap();
206        assert!(!j.contains("score"), "absent score should be omitted: {j}");
207        assert!(!j.contains("reason"));
208        assert!(j.contains("\"verdict\":\"pass\""));
209    }
210}