use std::{
collections::{BTreeMap, BTreeSet},
fmt,
future::Future,
num::NonZeroUsize,
pin::Pin,
sync::Arc,
};
use futures_util::{StreamExt, stream};
use runifold_core::RunId;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
pub type EvaluationFuture<T> = Pin<Box<dyn Future<Output = T> + Send + 'static>>;
#[derive(Clone, Debug, Error, PartialEq)]
#[non_exhaustive]
pub enum EvaluationError {
#[error("{field} must not be empty")]
EmptyField {
field: &'static str,
},
#[error("evaluation dataset must contain at least one case")]
EmptyDataset,
#[error("evaluation rule scorer must contain at least one rule")]
EmptyRules,
#[error("evaluation report is inconsistent: {message}")]
InconsistentReport {
message: &'static str,
},
#[error("evaluation runner must contain at least one scorer")]
NoScorers,
#[error("duplicate evaluation case id: {case_id}")]
DuplicateCase {
case_id: String,
},
#[error("duplicate evaluation scorer name: {scorer}")]
DuplicateScorer {
scorer: String,
},
#[error("{field} must be finite and between 0 and 1, got {value}")]
InvalidRatio {
field: &'static str,
value: f64,
},
#[error("{field} must be finite and non-negative, got {value}")]
InvalidMetric {
field: &'static str,
value: f64,
},
#[error("evaluation target failed: {message}")]
Target {
message: String,
},
#[error("evaluation scorer {scorer} failed: {message}")]
Scorer {
scorer: String,
message: String,
},
#[error(
"evaluation dataset mismatch: baseline {baseline_name}@{baseline_version}, candidate {candidate_name}@{candidate_version}"
)]
DatasetMismatch {
baseline_name: String,
baseline_version: String,
candidate_name: String,
candidate_version: String,
},
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(transparent)]
pub struct EvaluationCaseId(String);
impl EvaluationCaseId {
pub fn new(value: impl Into<String>) -> Result<Self, EvaluationError> {
let value = value.into();
ensure_not_empty("case id", &value)?;
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for EvaluationCaseId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct EvaluationCase {
id: EvaluationCaseId,
input: Value,
expected: Option<Value>,
tags: BTreeSet<String>,
}
impl EvaluationCase {
pub fn new(id: impl Into<String>, input: Value) -> Result<Self, EvaluationError> {
Ok(Self {
id: EvaluationCaseId::new(id)?,
input,
expected: None,
tags: BTreeSet::new(),
})
}
#[must_use]
pub fn with_expected(mut self, expected: Value) -> Self {
self.expected = Some(expected);
self
}
pub fn with_tag(mut self, tag: impl Into<String>) -> Result<Self, EvaluationError> {
let tag = tag.into();
ensure_not_empty("case tag", &tag)?;
self.tags.insert(tag);
Ok(self)
}
pub const fn id(&self) -> &EvaluationCaseId {
&self.id
}
pub const fn input(&self) -> &Value {
&self.input
}
pub const fn expected(&self) -> Option<&Value> {
self.expected.as_ref()
}
pub const fn tags(&self) -> &BTreeSet<String> {
&self.tags
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct EvaluationDataset {
name: String,
version: String,
cases: Vec<EvaluationCase>,
}
impl EvaluationDataset {
pub fn new(
name: impl Into<String>,
version: impl Into<String>,
cases: Vec<EvaluationCase>,
) -> Result<Self, EvaluationError> {
let name = name.into();
let version = version.into();
ensure_not_empty("dataset name", &name)?;
ensure_not_empty("dataset version", &version)?;
if cases.is_empty() {
return Err(EvaluationError::EmptyDataset);
}
let mut ids = BTreeSet::new();
for case in &cases {
ensure_not_empty("case id", case.id.as_str())?;
for tag in &case.tags {
ensure_not_empty("case tag", tag)?;
}
if !ids.insert(case.id.clone()) {
return Err(EvaluationError::DuplicateCase {
case_id: case.id.to_string(),
});
}
}
Ok(Self {
name,
version,
cases,
})
}
pub fn name(&self) -> &str {
&self.name
}
pub fn version(&self) -> &str {
&self.version
}
pub fn cases(&self) -> &[EvaluationCase] {
&self.cases
}
pub fn validate(&self) -> Result<(), EvaluationError> {
Self::new(&self.name, &self.version, self.cases.clone()).map(|_| ())
}
}
#[derive(Clone, Debug)]
pub struct EvaluationOutput {
value: Value,
run_id: Option<RunId>,
metadata: BTreeMap<String, Value>,
metrics: Option<EvaluationMetrics>,
}
impl EvaluationOutput {
pub fn new(value: Value) -> Self {
Self {
value,
run_id: None,
metadata: BTreeMap::new(),
metrics: None,
}
}
#[must_use]
pub const fn with_run_id(mut self, run_id: RunId) -> Self {
self.run_id = Some(run_id);
self
}
pub fn with_metadata(
mut self,
key: impl Into<String>,
value: Value,
) -> Result<Self, EvaluationError> {
let key = key.into();
ensure_not_empty("output metadata key", &key)?;
self.metadata.insert(key, value);
Ok(self)
}
pub const fn value(&self) -> &Value {
&self.value
}
pub const fn run_id(&self) -> Option<RunId> {
self.run_id
}
pub const fn metadata(&self) -> &BTreeMap<String, Value> {
&self.metadata
}
#[must_use]
pub const fn with_metrics(mut self, metrics: EvaluationMetrics) -> Self {
self.metrics = Some(metrics);
self
}
pub const fn metrics(&self) -> Option<&EvaluationMetrics> {
self.metrics.as_ref()
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct EvaluationMetrics {
pub duration_ms: f64,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cost_usd: Option<f64>,
}
impl EvaluationMetrics {
pub fn new(duration_ms: f64) -> Result<Self, EvaluationError> {
ensure_non_negative("evaluation duration milliseconds", duration_ms)?;
Ok(Self {
duration_ms,
input_tokens: None,
output_tokens: None,
cost_usd: None,
})
}
#[must_use]
pub const fn with_tokens(mut self, input_tokens: u64, output_tokens: u64) -> Self {
self.input_tokens = Some(input_tokens);
self.output_tokens = Some(output_tokens);
self
}
pub fn with_cost_usd(mut self, cost_usd: f64) -> Result<Self, EvaluationError> {
ensure_non_negative("evaluation cost USD", cost_usd)?;
self.cost_usd = Some(cost_usd);
Ok(self)
}
fn validate(&self) -> Result<(), EvaluationError> {
ensure_non_negative("evaluation duration milliseconds", self.duration_ms)?;
if self.input_tokens.is_some() != self.output_tokens.is_some() {
return Err(EvaluationError::InconsistentReport {
message: "evaluation token metrics must include input and output together",
});
}
if let Some(cost_usd) = self.cost_usd {
ensure_non_negative("evaluation cost USD", cost_usd)?;
}
Ok(())
}
}
pub trait EvaluationTarget: Send + Sync {
fn execute(
&self,
case: EvaluationCase,
) -> EvaluationFuture<Result<EvaluationOutput, EvaluationError>>;
}
impl<F, Fut> EvaluationTarget for F
where
F: Fn(EvaluationCase) -> Fut + Send + Sync,
Fut: Future<Output = Result<EvaluationOutput, EvaluationError>> + Send + 'static,
{
fn execute(
&self,
case: EvaluationCase,
) -> EvaluationFuture<Result<EvaluationOutput, EvaluationError>> {
Box::pin(self(case))
}
}
#[derive(Clone, Debug)]
pub struct ScoreValue {
value: f64,
rationale: Option<String>,
}
impl ScoreValue {
pub fn new(value: f64) -> Result<Self, EvaluationError> {
ensure_ratio("score", value)?;
Ok(Self {
value,
rationale: None,
})
}
pub const fn value(&self) -> f64 {
self.value
}
pub fn rationale(&self) -> Option<&str> {
self.rationale.as_deref()
}
#[must_use]
pub fn with_rationale(mut self, rationale: impl Into<String>) -> Self {
self.rationale = Some(rationale.into());
self
}
}
pub trait EvaluationScorer: Send + Sync {
fn name(&self) -> &str;
fn threshold(&self) -> f64;
fn score(
&self,
case: EvaluationCase,
output: EvaluationOutput,
) -> EvaluationFuture<Result<ScoreValue, EvaluationError>>;
}
pub struct FnScorer<F> {
name: String,
threshold: f64,
scorer: F,
}
impl<F> FnScorer<F> {
pub fn new(
name: impl Into<String>,
threshold: f64,
scorer: F,
) -> Result<Self, EvaluationError> {
let name = name.into();
ensure_not_empty("scorer name", &name)?;
ensure_ratio("score threshold", threshold)?;
Ok(Self {
name,
threshold,
scorer,
})
}
}
impl<F, Fut> EvaluationScorer for FnScorer<F>
where
F: Fn(EvaluationCase, EvaluationOutput) -> Fut + Send + Sync,
Fut: Future<Output = Result<ScoreValue, EvaluationError>> + Send + 'static,
{
fn name(&self) -> &str {
&self.name
}
fn threshold(&self) -> f64 {
self.threshold
}
fn score(
&self,
case: EvaluationCase,
output: EvaluationOutput,
) -> EvaluationFuture<Result<ScoreValue, EvaluationError>> {
Box::pin((self.scorer)(case, output))
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct JsonExactMatchScorer;
impl EvaluationScorer for JsonExactMatchScorer {
fn name(&self) -> &'static str {
"json_exact_match"
}
fn threshold(&self) -> f64 {
1.0
}
fn score(
&self,
case: EvaluationCase,
output: EvaluationOutput,
) -> EvaluationFuture<Result<ScoreValue, EvaluationError>> {
Box::pin(async move {
let expected = case.expected.ok_or_else(|| EvaluationError::Scorer {
scorer: "json_exact_match".into(),
message: "case has no reference answer".into(),
})?;
ScoreValue::new(if expected == output.value { 1.0 } else { 0.0 })
})
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct EvaluationScore {
pub name: String,
pub value: f64,
pub threshold: f64,
pub passed: bool,
pub rationale: Option<String>,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum EvaluationFailureStage {
Target,
Scorer,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct EvaluationFailure {
pub stage: EvaluationFailureStage,
pub scorer: Option<String>,
pub message: String,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct EvaluationCaseResult {
pub case_id: EvaluationCaseId,
pub run_id: Option<RunId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metrics: Option<EvaluationMetrics>,
pub scores: Vec<EvaluationScore>,
pub failures: Vec<EvaluationFailure>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct EvaluationScoreSummary {
pub name: String,
pub scored_cases: usize,
pub total_cases: usize,
pub mean: f64,
pub pass_rate: f64,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct EvaluationReport {
pub dataset_name: String,
pub dataset_version: String,
pub candidate_version: String,
pub execution_success_rate: f64,
pub cases: Vec<EvaluationCaseResult>,
pub summaries: Vec<EvaluationScoreSummary>,
}
impl EvaluationReport {
pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
pub fn compare(
&self,
baseline: &Self,
policy: &RegressionPolicy,
) -> Result<RegressionComparison, EvaluationError> {
self.validate()?;
baseline.validate()?;
policy.validate()?;
if self.dataset_name != baseline.dataset_name
|| self.dataset_version != baseline.dataset_version
{
return Err(EvaluationError::DatasetMismatch {
baseline_name: baseline.dataset_name.clone(),
baseline_version: baseline.dataset_version.clone(),
candidate_name: self.dataset_name.clone(),
candidate_version: self.dataset_version.clone(),
});
}
let metrics = baseline
.summaries
.iter()
.map(|baseline_summary| {
let candidate = self
.summaries
.iter()
.find(|summary| summary.name == baseline_summary.name);
let candidate_mean = candidate.map_or(0.0, |summary| summary.mean);
let candidate_pass_rate = candidate.map_or(0.0, |summary| summary.pass_rate);
let mean_delta = candidate_mean - baseline_summary.mean;
let pass_rate_delta = candidate_pass_rate - baseline_summary.pass_rate;
MetricRegression {
name: baseline_summary.name.clone(),
baseline_mean: baseline_summary.mean,
candidate_mean,
mean_delta,
baseline_pass_rate: baseline_summary.pass_rate,
candidate_pass_rate,
pass_rate_delta,
passed: mean_delta >= -policy.max_mean_drop
&& pass_rate_delta >= -policy.max_pass_rate_drop,
}
})
.collect::<Vec<_>>();
let execution_success_rate_delta =
self.execution_success_rate - baseline.execution_success_rate;
let passed = execution_success_rate_delta >= -policy.max_execution_success_drop
&& metrics.iter().all(|metric| metric.passed);
Ok(RegressionComparison {
baseline_version: baseline.candidate_version.clone(),
candidate_version: self.candidate_version.clone(),
execution_success_rate_delta,
metrics,
passed,
})
}
pub fn validate(&self) -> Result<(), EvaluationError> {
ensure_not_empty("dataset name", &self.dataset_name)?;
ensure_not_empty("dataset version", &self.dataset_version)?;
ensure_not_empty("candidate version", &self.candidate_version)?;
ensure_ratio("execution success rate", self.execution_success_rate)?;
if self.cases.is_empty() {
return Err(EvaluationError::InconsistentReport {
message: "report contains no cases",
});
}
let total_cases = self.cases.iter().fold(0.0, |total, _| total + 1.0);
let mut successful_cases = 0.0;
let mut case_ids = BTreeSet::new();
let mut aggregate = BTreeMap::<&str, (usize, f64, f64, f64)>::new();
for case in &self.cases {
ensure_not_empty("report case id", case.case_id.as_str())?;
if !case_ids.insert(case.case_id.as_str()) {
return Err(EvaluationError::InconsistentReport {
message: "report contains duplicate case IDs",
});
}
if !case
.failures
.iter()
.any(|failure| failure.stage == EvaluationFailureStage::Target)
{
successful_cases += 1.0;
}
validate_case_metrics(case)?;
let mut score_names = BTreeSet::new();
for score in &case.scores {
ensure_not_empty("report score name", &score.name)?;
ensure_ratio("report score", score.value)?;
ensure_ratio("report score threshold", score.threshold)?;
if score.passed != (score.value >= score.threshold) {
return Err(EvaluationError::InconsistentReport {
message: "stored score decision contradicts its threshold",
});
}
if !score_names.insert(score.name.as_str()) {
return Err(EvaluationError::InconsistentReport {
message: "one case contains duplicate score names",
});
}
let entry = aggregate.entry(&score.name).or_default();
entry.0 += 1;
entry.1 += score.value;
entry.2 += 1.0;
entry.3 += if score.passed { 1.0 } else { 0.0 };
}
}
ensure_close(
self.execution_success_rate,
successful_cases / total_cases,
"execution success rate contradicts case failures",
)?;
let mut names = BTreeSet::new();
for summary in &self.summaries {
ensure_not_empty("score summary name", &summary.name)?;
ensure_ratio("score mean", summary.mean)?;
ensure_ratio("score pass rate", summary.pass_rate)?;
if !names.insert(summary.name.as_str()) {
return Err(EvaluationError::DuplicateScorer {
scorer: summary.name.clone(),
});
}
let Some((scored_cases, total, scored_cases_ratio, passed)) =
aggregate.get(summary.name.as_str())
else {
return Err(EvaluationError::InconsistentReport {
message: "score summary has no per-case evidence",
});
};
if summary.scored_cases != *scored_cases || summary.total_cases != self.cases.len() {
return Err(EvaluationError::InconsistentReport {
message: "score summary case counts are inconsistent",
});
}
ensure_close(
summary.mean,
total / scored_cases_ratio,
"score summary mean contradicts case scores",
)?;
ensure_close(
summary.pass_rate,
passed / total_cases,
"score summary pass rate contradicts case scores",
)?;
}
if names.len() != aggregate.len() {
return Err(EvaluationError::InconsistentReport {
message: "per-case score is missing its summary",
});
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
pub struct RegressionPolicy {
pub max_mean_drop: f64,
pub max_pass_rate_drop: f64,
pub max_execution_success_drop: f64,
}
impl RegressionPolicy {
pub fn new(
max_mean_drop: f64,
max_pass_rate_drop: f64,
max_execution_success_drop: f64,
) -> Result<Self, EvaluationError> {
let policy = Self {
max_mean_drop,
max_pass_rate_drop,
max_execution_success_drop,
};
policy.validate()?;
Ok(policy)
}
fn validate(&self) -> Result<(), EvaluationError> {
ensure_ratio("maximum mean drop", self.max_mean_drop)?;
ensure_ratio("maximum pass-rate drop", self.max_pass_rate_drop)?;
ensure_ratio(
"maximum execution-success drop",
self.max_execution_success_drop,
)
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct MetricRegression {
pub name: String,
pub baseline_mean: f64,
pub candidate_mean: f64,
pub mean_delta: f64,
pub baseline_pass_rate: f64,
pub candidate_pass_rate: f64,
pub pass_rate_delta: f64,
pub passed: bool,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct RegressionComparison {
pub baseline_version: String,
pub candidate_version: String,
pub execution_success_rate_delta: f64,
pub metrics: Vec<MetricRegression>,
pub passed: bool,
}
pub struct EvaluationRunner {
target: Arc<dyn EvaluationTarget>,
scorers: Vec<Arc<dyn EvaluationScorer>>,
concurrency: NonZeroUsize,
}
impl EvaluationRunner {
pub fn new(target: impl EvaluationTarget + 'static) -> Self {
Self {
target: Arc::new(target),
scorers: Vec::new(),
concurrency: NonZeroUsize::MIN,
}
}
#[must_use]
pub fn with_scorer(mut self, scorer: impl EvaluationScorer + 'static) -> Self {
self.scorers.push(Arc::new(scorer));
self
}
#[must_use]
pub const fn with_concurrency(mut self, concurrency: NonZeroUsize) -> Self {
self.concurrency = concurrency;
self
}
pub async fn run(
&self,
dataset: &EvaluationDataset,
candidate_version: impl Into<String>,
) -> Result<EvaluationReport, EvaluationError> {
let candidate_version = candidate_version.into();
ensure_not_empty("candidate version", &candidate_version)?;
validate_scorers(&self.scorers)?;
let target = Arc::clone(&self.target);
let scorers = self.scorers.clone();
let mut indexed = stream::iter(dataset.cases.iter().cloned().enumerate())
.map(|(index, case)| {
let target = Arc::clone(&target);
let scorers = scorers.clone();
async move { (index, evaluate_case(target, scorers, case).await) }
})
.buffer_unordered(self.concurrency.get())
.collect::<Vec<_>>()
.await;
indexed.sort_by_key(|(index, _)| *index);
let cases = indexed
.into_iter()
.map(|(_, result)| result)
.collect::<Vec<_>>();
Ok(build_report(dataset, candidate_version, cases))
}
}
impl fmt::Debug for EvaluationRunner {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("EvaluationRunner")
.field("scorers", &self.scorers.len())
.field("concurrency", &self.concurrency)
.finish_non_exhaustive()
}
}
async fn evaluate_case(
target: Arc<dyn EvaluationTarget>,
scorers: Vec<Arc<dyn EvaluationScorer>>,
case: EvaluationCase,
) -> EvaluationCaseResult {
let output = match target.execute(case.clone()).await {
Ok(output) => output,
Err(error) => {
return EvaluationCaseResult {
case_id: case.id,
run_id: None,
metrics: None,
scores: Vec::new(),
failures: vec![EvaluationFailure {
stage: EvaluationFailureStage::Target,
scorer: None,
message: error.to_string(),
}],
};
}
};
let run_id = output.run_id;
let metrics = output.metrics.clone();
let scorer_concurrency = scorers.len().max(1);
let scored = stream::iter(scorers)
.map(|scorer| {
let case = case.clone();
let output = output.clone();
async move {
let name = scorer.name().to_owned();
let threshold = scorer.threshold();
let result = scorer.score(case, output).await;
(name, threshold, result)
}
})
.buffer_unordered(scorer_concurrency)
.collect::<Vec<_>>()
.await;
let mut case_scores = Vec::new();
let mut failures = Vec::new();
for (name, threshold, result) in scored {
match result {
Ok(score) => case_scores.push(EvaluationScore {
name,
value: score.value,
threshold,
passed: score.value >= threshold,
rationale: score.rationale,
}),
Err(error) => failures.push(EvaluationFailure {
stage: EvaluationFailureStage::Scorer,
scorer: Some(name),
message: error.to_string(),
}),
}
}
case_scores.sort_by(|left, right| left.name.cmp(&right.name));
failures.sort_by(|left, right| left.scorer.cmp(&right.scorer));
EvaluationCaseResult {
case_id: case.id,
run_id,
metrics,
scores: case_scores,
failures,
}
}
fn build_report(
dataset: &EvaluationDataset,
candidate_version: String,
cases: Vec<EvaluationCaseResult>,
) -> EvaluationReport {
let total_cases = cases.len();
let total_cases_ratio = cases.iter().fold(0.0, |total, _| total + 1.0);
let successful = cases.iter().fold(0.0, |total, result| {
if result
.failures
.iter()
.any(|failure| failure.stage == EvaluationFailureStage::Target)
{
total
} else {
total + 1.0
}
});
let mut aggregate = BTreeMap::<String, (usize, f64, f64, f64)>::new();
for score in cases.iter().flat_map(|result| &result.scores) {
let entry = aggregate.entry(score.name.clone()).or_default();
entry.0 += 1;
entry.1 += score.value;
entry.2 += 1.0;
entry.3 += if score.passed { 1.0 } else { 0.0 };
}
let summaries = aggregate
.into_iter()
.map(
|(name, (scored_cases, total, scored_cases_ratio, passed))| EvaluationScoreSummary {
name,
scored_cases,
total_cases,
mean: total / scored_cases_ratio,
pass_rate: passed / total_cases_ratio,
},
)
.collect();
EvaluationReport {
dataset_name: dataset.name.clone(),
dataset_version: dataset.version.clone(),
candidate_version,
execution_success_rate: successful / total_cases_ratio,
cases,
summaries,
}
}
fn ensure_not_empty(field: &'static str, value: &str) -> Result<(), EvaluationError> {
if value.trim().is_empty() {
return Err(EvaluationError::EmptyField { field });
}
Ok(())
}
fn validate_case_metrics(case: &EvaluationCaseResult) -> Result<(), EvaluationError> {
if case.metrics.is_some()
&& case
.failures
.iter()
.any(|failure| failure.stage == EvaluationFailureStage::Target)
{
return Err(EvaluationError::InconsistentReport {
message: "target failure cannot contain successful execution metrics",
});
}
if let Some(metrics) = &case.metrics {
metrics.validate()?;
}
Ok(())
}
fn ensure_ratio(field: &'static str, value: f64) -> Result<(), EvaluationError> {
if !value.is_finite() || !(0.0..=1.0).contains(&value) {
return Err(EvaluationError::InvalidRatio { field, value });
}
Ok(())
}
fn ensure_non_negative(field: &'static str, value: f64) -> Result<(), EvaluationError> {
if !value.is_finite() || value < 0.0 {
return Err(EvaluationError::InvalidMetric { field, value });
}
Ok(())
}
fn ensure_close(actual: f64, expected: f64, message: &'static str) -> Result<(), EvaluationError> {
const REPORT_RATIO_TOLERANCE: f64 = 1e-12;
if (actual - expected).abs() > REPORT_RATIO_TOLERANCE {
return Err(EvaluationError::InconsistentReport { message });
}
Ok(())
}
fn validate_scorers(scorers: &[Arc<dyn EvaluationScorer>]) -> Result<(), EvaluationError> {
if scorers.is_empty() {
return Err(EvaluationError::NoScorers);
}
let mut names = BTreeSet::new();
for scorer in scorers {
ensure_not_empty("scorer name", scorer.name())?;
ensure_ratio("score threshold", scorer.threshold())?;
if !names.insert(scorer.name()) {
return Err(EvaluationError::DuplicateScorer {
scorer: scorer.name().to_owned(),
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::num::NonZeroUsize;
use runifold_core::RunId;
use super::{
EvaluationCase, EvaluationDataset, EvaluationError, EvaluationOutput, EvaluationRunner,
JsonExactMatchScorer, RegressionPolicy, ScoreValue,
};
#[test]
fn dataset_rejects_duplicate_case_ids() {
let first = EvaluationCase::new("same", serde_json::json!("one")).unwrap();
let second = EvaluationCase::new("same", serde_json::json!("two")).unwrap();
let error = EvaluationDataset::new("dataset", "1", vec![first, second]).unwrap_err();
assert!(matches!(error, EvaluationError::DuplicateCase { .. }));
}
#[test]
fn score_rejects_non_finite_or_out_of_range_values() {
for value in [-0.1, 1.1, f64::NAN, f64::INFINITY] {
assert!(matches!(
ScoreValue::new(value),
Err(EvaluationError::InvalidRatio { .. })
));
}
}
#[test]
fn metrics_reject_negative_or_non_finite_values() {
for value in [-0.1, f64::NAN, f64::INFINITY] {
assert!(matches!(
super::EvaluationMetrics::new(value),
Err(EvaluationError::InvalidMetric { .. })
));
}
assert!(
super::EvaluationMetrics::new(1.0)
.unwrap()
.with_cost_usd(-0.1)
.is_err()
);
}
#[test]
fn runner_requires_at_least_one_scorer() {
let dataset = EvaluationDataset::new(
"answers",
"1",
vec![EvaluationCase::new("one", serde_json::json!("answer")).unwrap()],
)
.unwrap();
let runner = EvaluationRunner::new(|case: EvaluationCase| async move {
Ok(EvaluationOutput::new(case.input().clone()))
});
let error = futures_executor::block_on(runner.run(&dataset, "candidate")).unwrap_err();
assert_eq!(error, EvaluationError::NoScorers);
}
#[test]
fn concurrent_runner_is_ordered_correlated_and_output_free() {
let dataset = EvaluationDataset::new(
"answers",
"2026-07-26",
vec![
EvaluationCase::new("first", serde_json::json!("secret-one"))
.unwrap()
.with_expected(serde_json::json!("secret-one")),
EvaluationCase::new("second", serde_json::json!("secret-two"))
.unwrap()
.with_expected(serde_json::json!("secret-two")),
],
)
.unwrap();
let runner = EvaluationRunner::new(|case: EvaluationCase| async move {
let output = EvaluationOutput::new(case.input().clone());
Ok(if case.id().as_str() == "first" {
output.with_run_id(RunId::new())
} else {
output
})
})
.with_scorer(JsonExactMatchScorer)
.with_concurrency(NonZeroUsize::new(2).unwrap());
let report = futures_executor::block_on(runner.run(&dataset, "candidate-a")).unwrap();
let json = report.to_json_pretty().unwrap();
assert_eq!(report.cases[0].case_id.as_str(), "first");
assert_eq!(report.cases[1].case_id.as_str(), "second");
assert!(report.cases[0].run_id.is_some());
assert!(report.cases[1].run_id.is_none());
assert!((report.execution_success_rate - 1.0).abs() < 1e-12);
assert!((report.summaries[0].mean - 1.0).abs() < 1e-12);
assert!(!json.contains("secret-one"));
assert!(!json.contains("secret-two"));
}
#[test]
fn relative_gate_detects_mean_and_pass_rate_regression() {
let baseline = report("baseline", 1.0, 1.0);
let candidate = report("candidate", 0.8, 0.5);
let policy = RegressionPolicy::new(0.05, 0.1, 0.0).unwrap();
let comparison = candidate.compare(&baseline, &policy).unwrap();
assert!(!comparison.passed);
assert!((comparison.metrics[0].mean_delta - -0.2).abs() < 1e-12);
assert!((comparison.metrics[0].pass_rate_delta - -0.5).abs() < 1e-12);
}
#[test]
fn externally_loaded_report_cannot_forge_aggregate_quality() {
let mut forged = report("candidate", 0.8, 0.5);
forged.summaries[0].mean = 1.0;
assert!(matches!(
forged.validate(),
Err(EvaluationError::InconsistentReport { .. })
));
}
fn report(candidate: &str, mean: f64, pass_rate: f64) -> super::EvaluationReport {
let values = if pass_rate > 0.75 {
[mean, mean]
} else {
[mean - 0.1, mean + 0.1]
};
let cases = values
.into_iter()
.enumerate()
.map(|(index, value)| super::EvaluationCaseResult {
case_id: super::EvaluationCaseId::new(format!("case-{index}")).unwrap(),
run_id: None,
metrics: None,
scores: vec![super::EvaluationScore {
name: "quality".into(),
value,
threshold: 0.8,
passed: value >= 0.8,
rationale: None,
}],
failures: Vec::new(),
})
.collect();
super::EvaluationReport {
dataset_name: "answers".into(),
dataset_version: "1".into(),
candidate_version: candidate.into(),
execution_success_rate: 1.0,
cases,
summaries: vec![super::EvaluationScoreSummary {
name: "quality".into(),
scored_cases: 2,
total_cases: 2,
mean,
pass_rate,
}],
}
}
}