use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Verdict {
Pass,
Fail,
Abstain,
}
impl Verdict {
#[must_use]
pub const fn is_pass(self) -> bool {
matches!(self, Verdict::Pass)
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Verdict::Pass => "pass",
Verdict::Fail => "fail",
Verdict::Abstain => "abstain",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize)]
#[serde(transparent)]
pub struct Score(f64);
impl Score {
pub fn new(v: f64) -> Result<Self> {
if v.is_finite() && (0.0..=1.0).contains(&v) {
Ok(Self(v))
} else {
Err(Error::InvalidScore(v))
}
}
#[must_use]
pub fn clamped(v: f64) -> Self {
if v.is_finite() {
Self(v.clamp(0.0, 1.0))
} else {
Self(0.0)
}
}
#[must_use]
pub const fn value(self) -> f64 {
self.0
}
}
impl<'de> Deserialize<'de> for Score {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
let v = f64::deserialize(d)?;
Score::new(v).map_err(serde::de::Error::custom)
}
}
impl std::fmt::Display for Score {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GateResult {
pub gate_id: String,
pub verdict: Verdict,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub score: Option<Score>,
pub cost_usd: f64,
pub ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub evidence_ref: Option<String>,
}
pub mod reason {
pub const PROVIDER_ERROR: &str = "provider_error";
pub const TIMEOUT: &str = "timeout";
pub const GATE_CRASH: &str = "gate_crash";
pub const GATE_DISABLED: &str = "gate_disabled";
}
impl GateResult {
#[must_use]
pub fn deterministic(gate_id: impl Into<String>, verdict: Verdict, ms: u64) -> Self {
Self {
gate_id: gate_id.into(),
verdict,
score: None,
cost_usd: 0.0,
ms,
reason: None,
evidence_ref: None,
}
}
#[must_use]
pub fn abstain(gate_id: impl Into<String>, reason: impl Into<String>, ms: u64) -> Self {
Self {
gate_id: gate_id.into(),
verdict: Verdict::Abstain,
score: None,
cost_usd: 0.0,
ms,
reason: Some(reason.into()),
evidence_ref: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn score_validates_range_and_finiteness() {
assert!(Score::new(0.0).is_ok());
assert!(Score::new(1.0).is_ok());
assert!(Score::new(0.31).is_ok());
assert!(Score::new(-0.01).is_err());
assert!(Score::new(1.01).is_err());
assert!(Score::new(f64::NAN).is_err());
assert!(Score::new(f64::INFINITY).is_err());
}
#[test]
fn score_clamps() {
assert_eq!(Score::clamped(2.0).value(), 1.0);
assert_eq!(Score::clamped(-5.0).value(), 0.0);
assert_eq!(Score::clamped(f64::NAN).value(), 0.0);
assert_eq!(Score::clamped(0.5).value(), 0.5);
}
#[test]
fn score_deserialize_rejects_out_of_range() {
assert!(serde_json::from_str::<Score>("0.5").is_ok());
assert!(serde_json::from_str::<Score>("1.5").is_err());
}
#[test]
fn verdict_wire_form_is_lowercase() {
assert_eq!(serde_json::to_string(&Verdict::Pass).unwrap(), "\"pass\"");
assert_eq!(
serde_json::to_string(&Verdict::Abstain).unwrap(),
"\"abstain\""
);
assert_eq!(
serde_json::from_str::<Verdict>("\"fail\"").unwrap(),
Verdict::Fail
);
}
#[test]
fn gate_result_omits_absent_optionals() {
let g = GateResult::deterministic("cargo-test", Verdict::Pass, 3100);
let j = serde_json::to_string(&g).unwrap();
assert!(!j.contains("score"), "absent score should be omitted: {j}");
assert!(!j.contains("reason"));
assert!(j.contains("\"verdict\":\"pass\""));
}
}