firstpass_core/
verdict.rs1use crate::error::{Error, Result};
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum Verdict {
15 Pass,
17 Fail,
19 Abstain,
22}
23
24impl Verdict {
25 #[must_use]
27 pub const fn is_pass(self) -> bool {
28 matches!(self, Verdict::Pass)
29 }
30
31 #[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#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize)]
48#[serde(transparent)]
49pub struct Score(f64);
50
51impl Score {
52 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 #[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 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96pub struct GateResult {
97 pub gate_id: String,
99 pub verdict: Verdict,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub score: Option<Score>,
104 pub cost_usd: f64,
106 pub ms: u64,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub reason: Option<String>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub evidence_ref: Option<String>,
115}
116
117pub mod reason {
120 pub const PROVIDER_ERROR: &str = "provider_error";
122 pub const TIMEOUT: &str = "timeout";
124 pub const GATE_CRASH: &str = "gate_crash";
126 pub const GATE_DISABLED: &str = "gate_disabled";
128}
129
130impl GateResult {
131 #[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 #[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}