use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum Comparator {
#[serde(alias = ">=")]
#[default]
Gte,
#[serde(alias = ">")]
Gt,
#[serde(alias = "<=")]
Lte,
#[serde(alias = "<")]
Lt,
}
impl Comparator {
fn satisfied(self, value: f64, threshold: f64) -> bool {
match self {
Comparator::Gte => value >= threshold,
Comparator::Gt => value > threshold,
Comparator::Lte => value <= threshold,
Comparator::Lt => value < threshold,
}
}
fn symbol(self) -> &'static str {
match self {
Comparator::Gte => ">=",
Comparator::Gt => ">",
Comparator::Lte => "<=",
Comparator::Lt => "<",
}
}
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum Eval {
Boolean {
criterion: String,
#[serde(default = "default_true")]
expected: bool,
#[serde(default)]
name: Option<String>,
},
Numeric {
criterion: String,
min: f64,
max: f64,
threshold: f64,
#[serde(default)]
comparator: Comparator,
#[serde(default)]
name: Option<String>,
},
}
impl Eval {
#[must_use]
pub fn criterion(&self) -> &str {
match self {
Eval::Boolean { criterion, .. } | Eval::Numeric { criterion, .. } => criterion,
}
}
#[must_use]
pub fn label(&self) -> &str {
match self {
Eval::Boolean {
name, criterion, ..
}
| Eval::Numeric {
name, criterion, ..
} => name.as_deref().unwrap_or(criterion),
}
}
pub fn validate(&self) -> Result<()> {
if self.criterion().trim().is_empty() {
return Err(Error::Invalid("an eval has an empty `criterion`".into()));
}
if let Eval::Numeric {
min,
max,
threshold,
..
} = self
{
if min >= max {
return Err(Error::Invalid(format!(
"numeric eval scale is degenerate: min ({min}) must be < max ({max})"
)));
}
if threshold < min || threshold > max {
return Err(Error::Invalid(format!(
"numeric eval threshold ({threshold}) is outside the scale [{min}, {max}]"
)));
}
}
Ok(())
}
pub fn outcome(&self, raw: &JudgeValue, reason: String) -> Result<EvalOutcome> {
match (self, raw) {
(Eval::Boolean { expected, .. }, JudgeValue::Bool(value)) => Ok(EvalOutcome {
label: self.label().to_string(),
passed: value == expected,
detail: EvalDetail::Boolean {
value: *value,
expected: *expected,
},
reason,
}),
(
Eval::Numeric {
min,
max,
threshold,
comparator,
..
},
JudgeValue::Number(value),
) => {
let clamped = value.clamp(*min, *max);
Ok(EvalOutcome {
label: self.label().to_string(),
passed: comparator.satisfied(clamped, *threshold),
detail: EvalDetail::Numeric {
value: clamped,
threshold: *threshold,
comparator: *comparator,
},
reason,
})
}
(Eval::Boolean { .. }, JudgeValue::Number(_)) => Err(Error::provider(
"judge",
"boolean eval received a numeric verdict",
)),
(Eval::Numeric { .. }, JudgeValue::Bool(_)) => Err(Error::provider(
"judge",
"numeric eval received a boolean verdict",
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum JudgeValue {
Bool(bool),
Number(f64),
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum EvalDetail {
#[schemars(title = "BooleanDetail")]
Boolean { value: bool, expected: bool },
#[schemars(title = "NumericDetail")]
Numeric {
value: f64,
threshold: f64,
comparator: Comparator,
},
}
impl EvalDetail {
#[must_use]
pub fn summary(&self) -> String {
match self {
EvalDetail::Boolean { value, expected } => {
format!("{value} (expected {expected})")
}
EvalDetail::Numeric {
value,
threshold,
comparator,
} => format!("{value} {} {threshold}", comparator.symbol()),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct EvalOutcome {
pub label: String,
pub passed: bool,
pub detail: EvalDetail,
pub reason: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn numeric_threshold_gte_passes_at_boundary() {
let eval = Eval::Numeric {
criterion: "polite".into(),
min: 0.0,
max: 10.0,
threshold: 7.0,
comparator: Comparator::Gte,
name: None,
};
let outcome = eval.outcome(&JudgeValue::Number(7.0), "ok".into()).unwrap();
assert!(outcome.passed);
}
#[test]
fn numeric_value_is_clamped_to_scale() {
let eval = Eval::Numeric {
criterion: "x".into(),
min: 0.0,
max: 10.0,
threshold: 9.0,
comparator: Comparator::Gte,
name: None,
};
let outcome = eval
.outcome(&JudgeValue::Number(12.0), String::new())
.unwrap();
assert!(outcome.passed);
assert!(matches!(
outcome.detail,
EvalDetail::Numeric { value, .. } if (value - 10.0).abs() < f64::EPSILON
));
}
#[test]
fn boolean_expected_false_inverts() {
let eval = Eval::Boolean {
criterion: "leaks a secret".into(),
expected: false,
name: None,
};
let pass = eval
.outcome(&JudgeValue::Bool(false), String::new())
.unwrap();
assert!(pass.passed);
let fail = eval
.outcome(&JudgeValue::Bool(true), String::new())
.unwrap();
assert!(!fail.passed);
}
#[test]
fn kind_mismatch_is_provider_error() {
let eval = Eval::Boolean {
criterion: "x".into(),
expected: true,
name: None,
};
assert!(eval
.outcome(&JudgeValue::Number(1.0), String::new())
.is_err());
}
#[test]
fn degenerate_numeric_scale_is_invalid() {
let eval = Eval::Numeric {
criterion: "x".into(),
min: 5.0,
max: 5.0,
threshold: 5.0,
comparator: Comparator::Gte,
name: None,
};
assert!(eval.validate().is_err());
}
#[test]
fn comparator_parses_from_symbol() {
let c: Comparator = serde_yaml::from_str("\">=\"").unwrap();
assert_eq!(c, Comparator::Gte);
let c: Comparator = serde_yaml::from_str("lt").unwrap();
assert_eq!(c, Comparator::Lt);
}
}