1use std::{
2 collections::{BTreeMap, BTreeSet},
3 fmt,
4 future::Future,
5 num::NonZeroUsize,
6 pin::Pin,
7 sync::Arc,
8};
9
10use futures_util::{StreamExt, stream};
11use runifold_core::RunId;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use thiserror::Error;
15
16pub type EvaluationFuture<T> = Pin<Box<dyn Future<Output = T> + Send + 'static>>;
18
19#[derive(Clone, Debug, Error, PartialEq)]
21#[non_exhaustive]
22pub enum EvaluationError {
23 #[error("{field} must not be empty")]
25 EmptyField {
26 field: &'static str,
28 },
29 #[error("evaluation dataset must contain at least one case")]
31 EmptyDataset,
32 #[error("evaluation rule scorer must contain at least one rule")]
34 EmptyRules,
35 #[error("evaluation report is inconsistent: {message}")]
37 InconsistentReport {
38 message: &'static str,
40 },
41 #[error("evaluation runner must contain at least one scorer")]
43 NoScorers,
44 #[error("duplicate evaluation case id: {case_id}")]
46 DuplicateCase {
47 case_id: String,
49 },
50 #[error("duplicate evaluation scorer name: {scorer}")]
52 DuplicateScorer {
53 scorer: String,
55 },
56 #[error("{field} must be finite and between 0 and 1, got {value}")]
58 InvalidRatio {
59 field: &'static str,
61 value: f64,
63 },
64 #[error("{field} must be finite and non-negative, got {value}")]
66 InvalidMetric {
67 field: &'static str,
69 value: f64,
71 },
72 #[error("evaluation target failed: {message}")]
74 Target {
75 message: String,
77 },
78 #[error("evaluation scorer {scorer} failed: {message}")]
80 Scorer {
81 scorer: String,
83 message: String,
85 },
86 #[error(
88 "evaluation dataset mismatch: baseline {baseline_name}@{baseline_version}, candidate {candidate_name}@{candidate_version}"
89 )]
90 DatasetMismatch {
91 baseline_name: String,
93 baseline_version: String,
95 candidate_name: String,
97 candidate_version: String,
99 },
100}
101
102#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
104#[serde(transparent)]
105pub struct EvaluationCaseId(String);
106
107impl EvaluationCaseId {
108 pub fn new(value: impl Into<String>) -> Result<Self, EvaluationError> {
114 let value = value.into();
115 ensure_not_empty("case id", &value)?;
116 Ok(Self(value))
117 }
118
119 pub fn as_str(&self) -> &str {
121 &self.0
122 }
123}
124
125impl fmt::Display for EvaluationCaseId {
126 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
127 formatter.write_str(&self.0)
128 }
129}
130
131#[derive(Clone, Debug, Deserialize, Serialize)]
133pub struct EvaluationCase {
134 id: EvaluationCaseId,
135 input: Value,
136 expected: Option<Value>,
137 tags: BTreeSet<String>,
138}
139
140impl EvaluationCase {
141 pub fn new(id: impl Into<String>, input: Value) -> Result<Self, EvaluationError> {
147 Ok(Self {
148 id: EvaluationCaseId::new(id)?,
149 input,
150 expected: None,
151 tags: BTreeSet::new(),
152 })
153 }
154
155 #[must_use]
157 pub fn with_expected(mut self, expected: Value) -> Self {
158 self.expected = Some(expected);
159 self
160 }
161
162 pub fn with_tag(mut self, tag: impl Into<String>) -> Result<Self, EvaluationError> {
168 let tag = tag.into();
169 ensure_not_empty("case tag", &tag)?;
170 self.tags.insert(tag);
171 Ok(self)
172 }
173
174 pub const fn id(&self) -> &EvaluationCaseId {
176 &self.id
177 }
178
179 pub const fn input(&self) -> &Value {
181 &self.input
182 }
183
184 pub const fn expected(&self) -> Option<&Value> {
186 self.expected.as_ref()
187 }
188
189 pub const fn tags(&self) -> &BTreeSet<String> {
191 &self.tags
192 }
193}
194
195#[derive(Clone, Debug, Deserialize, Serialize)]
197pub struct EvaluationDataset {
198 name: String,
199 version: String,
200 cases: Vec<EvaluationCase>,
201}
202
203impl EvaluationDataset {
204 pub fn new(
210 name: impl Into<String>,
211 version: impl Into<String>,
212 cases: Vec<EvaluationCase>,
213 ) -> Result<Self, EvaluationError> {
214 let name = name.into();
215 let version = version.into();
216 ensure_not_empty("dataset name", &name)?;
217 ensure_not_empty("dataset version", &version)?;
218 if cases.is_empty() {
219 return Err(EvaluationError::EmptyDataset);
220 }
221 let mut ids = BTreeSet::new();
222 for case in &cases {
223 ensure_not_empty("case id", case.id.as_str())?;
224 for tag in &case.tags {
225 ensure_not_empty("case tag", tag)?;
226 }
227 if !ids.insert(case.id.clone()) {
228 return Err(EvaluationError::DuplicateCase {
229 case_id: case.id.to_string(),
230 });
231 }
232 }
233 Ok(Self {
234 name,
235 version,
236 cases,
237 })
238 }
239
240 pub fn name(&self) -> &str {
242 &self.name
243 }
244
245 pub fn version(&self) -> &str {
247 &self.version
248 }
249
250 pub fn cases(&self) -> &[EvaluationCase] {
252 &self.cases
253 }
254
255 pub fn validate(&self) -> Result<(), EvaluationError> {
261 Self::new(&self.name, &self.version, self.cases.clone()).map(|_| ())
262 }
263}
264
265#[derive(Clone, Debug)]
267pub struct EvaluationOutput {
268 value: Value,
269 run_id: Option<RunId>,
270 metadata: BTreeMap<String, Value>,
271 metrics: Option<EvaluationMetrics>,
272}
273
274impl EvaluationOutput {
275 pub fn new(value: Value) -> Self {
277 Self {
278 value,
279 run_id: None,
280 metadata: BTreeMap::new(),
281 metrics: None,
282 }
283 }
284
285 #[must_use]
287 pub const fn with_run_id(mut self, run_id: RunId) -> Self {
288 self.run_id = Some(run_id);
289 self
290 }
291
292 pub fn with_metadata(
298 mut self,
299 key: impl Into<String>,
300 value: Value,
301 ) -> Result<Self, EvaluationError> {
302 let key = key.into();
303 ensure_not_empty("output metadata key", &key)?;
304 self.metadata.insert(key, value);
305 Ok(self)
306 }
307
308 pub const fn value(&self) -> &Value {
310 &self.value
311 }
312
313 pub const fn run_id(&self) -> Option<RunId> {
315 self.run_id
316 }
317
318 pub const fn metadata(&self) -> &BTreeMap<String, Value> {
320 &self.metadata
321 }
322
323 #[must_use]
325 pub const fn with_metrics(mut self, metrics: EvaluationMetrics) -> Self {
326 self.metrics = Some(metrics);
327 self
328 }
329
330 pub const fn metrics(&self) -> Option<&EvaluationMetrics> {
332 self.metrics.as_ref()
333 }
334}
335
336#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
338pub struct EvaluationMetrics {
339 pub duration_ms: f64,
341 pub input_tokens: Option<u64>,
343 pub output_tokens: Option<u64>,
345 pub cost_usd: Option<f64>,
347}
348
349impl EvaluationMetrics {
350 pub fn new(duration_ms: f64) -> Result<Self, EvaluationError> {
356 ensure_non_negative("evaluation duration milliseconds", duration_ms)?;
357 Ok(Self {
358 duration_ms,
359 input_tokens: None,
360 output_tokens: None,
361 cost_usd: None,
362 })
363 }
364
365 #[must_use]
367 pub const fn with_tokens(mut self, input_tokens: u64, output_tokens: u64) -> Self {
368 self.input_tokens = Some(input_tokens);
369 self.output_tokens = Some(output_tokens);
370 self
371 }
372
373 pub fn with_cost_usd(mut self, cost_usd: f64) -> Result<Self, EvaluationError> {
379 ensure_non_negative("evaluation cost USD", cost_usd)?;
380 self.cost_usd = Some(cost_usd);
381 Ok(self)
382 }
383
384 fn validate(&self) -> Result<(), EvaluationError> {
385 ensure_non_negative("evaluation duration milliseconds", self.duration_ms)?;
386 if self.input_tokens.is_some() != self.output_tokens.is_some() {
387 return Err(EvaluationError::InconsistentReport {
388 message: "evaluation token metrics must include input and output together",
389 });
390 }
391 if let Some(cost_usd) = self.cost_usd {
392 ensure_non_negative("evaluation cost USD", cost_usd)?;
393 }
394 Ok(())
395 }
396}
397
398pub trait EvaluationTarget: Send + Sync {
400 fn execute(
402 &self,
403 case: EvaluationCase,
404 ) -> EvaluationFuture<Result<EvaluationOutput, EvaluationError>>;
405}
406
407impl<F, Fut> EvaluationTarget for F
408where
409 F: Fn(EvaluationCase) -> Fut + Send + Sync,
410 Fut: Future<Output = Result<EvaluationOutput, EvaluationError>> + Send + 'static,
411{
412 fn execute(
413 &self,
414 case: EvaluationCase,
415 ) -> EvaluationFuture<Result<EvaluationOutput, EvaluationError>> {
416 Box::pin(self(case))
417 }
418}
419
420#[derive(Clone, Debug)]
422pub struct ScoreValue {
423 value: f64,
424 rationale: Option<String>,
425}
426
427impl ScoreValue {
428 pub fn new(value: f64) -> Result<Self, EvaluationError> {
434 ensure_ratio("score", value)?;
435 Ok(Self {
436 value,
437 rationale: None,
438 })
439 }
440
441 pub const fn value(&self) -> f64 {
443 self.value
444 }
445
446 pub fn rationale(&self) -> Option<&str> {
448 self.rationale.as_deref()
449 }
450
451 #[must_use]
453 pub fn with_rationale(mut self, rationale: impl Into<String>) -> Self {
454 self.rationale = Some(rationale.into());
455 self
456 }
457}
458
459pub trait EvaluationScorer: Send + Sync {
461 fn name(&self) -> &str;
463
464 fn threshold(&self) -> f64;
466
467 fn score(
469 &self,
470 case: EvaluationCase,
471 output: EvaluationOutput,
472 ) -> EvaluationFuture<Result<ScoreValue, EvaluationError>>;
473}
474
475pub struct FnScorer<F> {
477 name: String,
478 threshold: f64,
479 scorer: F,
480}
481
482impl<F> FnScorer<F> {
483 pub fn new(
489 name: impl Into<String>,
490 threshold: f64,
491 scorer: F,
492 ) -> Result<Self, EvaluationError> {
493 let name = name.into();
494 ensure_not_empty("scorer name", &name)?;
495 ensure_ratio("score threshold", threshold)?;
496 Ok(Self {
497 name,
498 threshold,
499 scorer,
500 })
501 }
502}
503
504impl<F, Fut> EvaluationScorer for FnScorer<F>
505where
506 F: Fn(EvaluationCase, EvaluationOutput) -> Fut + Send + Sync,
507 Fut: Future<Output = Result<ScoreValue, EvaluationError>> + Send + 'static,
508{
509 fn name(&self) -> &str {
510 &self.name
511 }
512
513 fn threshold(&self) -> f64 {
514 self.threshold
515 }
516
517 fn score(
518 &self,
519 case: EvaluationCase,
520 output: EvaluationOutput,
521 ) -> EvaluationFuture<Result<ScoreValue, EvaluationError>> {
522 Box::pin((self.scorer)(case, output))
523 }
524}
525
526#[derive(Clone, Copy, Debug, Default)]
528pub struct JsonExactMatchScorer;
529
530impl EvaluationScorer for JsonExactMatchScorer {
531 fn name(&self) -> &'static str {
532 "json_exact_match"
533 }
534
535 fn threshold(&self) -> f64 {
536 1.0
537 }
538
539 fn score(
540 &self,
541 case: EvaluationCase,
542 output: EvaluationOutput,
543 ) -> EvaluationFuture<Result<ScoreValue, EvaluationError>> {
544 Box::pin(async move {
545 let expected = case.expected.ok_or_else(|| EvaluationError::Scorer {
546 scorer: "json_exact_match".into(),
547 message: "case has no reference answer".into(),
548 })?;
549 ScoreValue::new(if expected == output.value { 1.0 } else { 0.0 })
550 })
551 }
552}
553
554#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
556pub struct EvaluationScore {
557 pub name: String,
559 pub value: f64,
561 pub threshold: f64,
563 pub passed: bool,
565 pub rationale: Option<String>,
567}
568
569#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
571#[serde(rename_all = "snake_case")]
572#[non_exhaustive]
573pub enum EvaluationFailureStage {
574 Target,
576 Scorer,
578}
579
580#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
582pub struct EvaluationFailure {
583 pub stage: EvaluationFailureStage,
585 pub scorer: Option<String>,
587 pub message: String,
589}
590
591#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
593pub struct EvaluationCaseResult {
594 pub case_id: EvaluationCaseId,
596 pub run_id: Option<RunId>,
598 #[serde(default, skip_serializing_if = "Option::is_none")]
600 pub metrics: Option<EvaluationMetrics>,
601 pub scores: Vec<EvaluationScore>,
603 pub failures: Vec<EvaluationFailure>,
605}
606
607#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
609pub struct EvaluationScoreSummary {
610 pub name: String,
612 pub scored_cases: usize,
614 pub total_cases: usize,
616 pub mean: f64,
618 pub pass_rate: f64,
620}
621
622#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
624pub struct EvaluationReport {
625 pub dataset_name: String,
627 pub dataset_version: String,
629 pub candidate_version: String,
631 pub execution_success_rate: f64,
633 pub cases: Vec<EvaluationCaseResult>,
635 pub summaries: Vec<EvaluationScoreSummary>,
637}
638
639impl EvaluationReport {
640 pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
646 serde_json::to_string_pretty(self)
647 }
648
649 pub fn compare(
656 &self,
657 baseline: &Self,
658 policy: &RegressionPolicy,
659 ) -> Result<RegressionComparison, EvaluationError> {
660 self.validate()?;
661 baseline.validate()?;
662 policy.validate()?;
663 if self.dataset_name != baseline.dataset_name
664 || self.dataset_version != baseline.dataset_version
665 {
666 return Err(EvaluationError::DatasetMismatch {
667 baseline_name: baseline.dataset_name.clone(),
668 baseline_version: baseline.dataset_version.clone(),
669 candidate_name: self.dataset_name.clone(),
670 candidate_version: self.dataset_version.clone(),
671 });
672 }
673 let metrics = baseline
674 .summaries
675 .iter()
676 .map(|baseline_summary| {
677 let candidate = self
678 .summaries
679 .iter()
680 .find(|summary| summary.name == baseline_summary.name);
681 let candidate_mean = candidate.map_or(0.0, |summary| summary.mean);
682 let candidate_pass_rate = candidate.map_or(0.0, |summary| summary.pass_rate);
683 let mean_delta = candidate_mean - baseline_summary.mean;
684 let pass_rate_delta = candidate_pass_rate - baseline_summary.pass_rate;
685 MetricRegression {
686 name: baseline_summary.name.clone(),
687 baseline_mean: baseline_summary.mean,
688 candidate_mean,
689 mean_delta,
690 baseline_pass_rate: baseline_summary.pass_rate,
691 candidate_pass_rate,
692 pass_rate_delta,
693 passed: mean_delta >= -policy.max_mean_drop
694 && pass_rate_delta >= -policy.max_pass_rate_drop,
695 }
696 })
697 .collect::<Vec<_>>();
698 let execution_success_rate_delta =
699 self.execution_success_rate - baseline.execution_success_rate;
700 let passed = execution_success_rate_delta >= -policy.max_execution_success_drop
701 && metrics.iter().all(|metric| metric.passed);
702 Ok(RegressionComparison {
703 baseline_version: baseline.candidate_version.clone(),
704 candidate_version: self.candidate_version.clone(),
705 execution_success_rate_delta,
706 metrics,
707 passed,
708 })
709 }
710
711 pub fn validate(&self) -> Result<(), EvaluationError> {
718 ensure_not_empty("dataset name", &self.dataset_name)?;
719 ensure_not_empty("dataset version", &self.dataset_version)?;
720 ensure_not_empty("candidate version", &self.candidate_version)?;
721 ensure_ratio("execution success rate", self.execution_success_rate)?;
722 if self.cases.is_empty() {
723 return Err(EvaluationError::InconsistentReport {
724 message: "report contains no cases",
725 });
726 }
727 let total_cases = self.cases.iter().fold(0.0, |total, _| total + 1.0);
728 let mut successful_cases = 0.0;
729 let mut case_ids = BTreeSet::new();
730 let mut aggregate = BTreeMap::<&str, (usize, f64, f64, f64)>::new();
731 for case in &self.cases {
732 ensure_not_empty("report case id", case.case_id.as_str())?;
733 if !case_ids.insert(case.case_id.as_str()) {
734 return Err(EvaluationError::InconsistentReport {
735 message: "report contains duplicate case IDs",
736 });
737 }
738 if !case
739 .failures
740 .iter()
741 .any(|failure| failure.stage == EvaluationFailureStage::Target)
742 {
743 successful_cases += 1.0;
744 }
745 validate_case_metrics(case)?;
746 let mut score_names = BTreeSet::new();
747 for score in &case.scores {
748 ensure_not_empty("report score name", &score.name)?;
749 ensure_ratio("report score", score.value)?;
750 ensure_ratio("report score threshold", score.threshold)?;
751 if score.passed != (score.value >= score.threshold) {
752 return Err(EvaluationError::InconsistentReport {
753 message: "stored score decision contradicts its threshold",
754 });
755 }
756 if !score_names.insert(score.name.as_str()) {
757 return Err(EvaluationError::InconsistentReport {
758 message: "one case contains duplicate score names",
759 });
760 }
761 let entry = aggregate.entry(&score.name).or_default();
762 entry.0 += 1;
763 entry.1 += score.value;
764 entry.2 += 1.0;
765 entry.3 += if score.passed { 1.0 } else { 0.0 };
766 }
767 }
768 ensure_close(
769 self.execution_success_rate,
770 successful_cases / total_cases,
771 "execution success rate contradicts case failures",
772 )?;
773 let mut names = BTreeSet::new();
774 for summary in &self.summaries {
775 ensure_not_empty("score summary name", &summary.name)?;
776 ensure_ratio("score mean", summary.mean)?;
777 ensure_ratio("score pass rate", summary.pass_rate)?;
778 if !names.insert(summary.name.as_str()) {
779 return Err(EvaluationError::DuplicateScorer {
780 scorer: summary.name.clone(),
781 });
782 }
783 let Some((scored_cases, total, scored_cases_ratio, passed)) =
784 aggregate.get(summary.name.as_str())
785 else {
786 return Err(EvaluationError::InconsistentReport {
787 message: "score summary has no per-case evidence",
788 });
789 };
790 if summary.scored_cases != *scored_cases || summary.total_cases != self.cases.len() {
791 return Err(EvaluationError::InconsistentReport {
792 message: "score summary case counts are inconsistent",
793 });
794 }
795 ensure_close(
796 summary.mean,
797 total / scored_cases_ratio,
798 "score summary mean contradicts case scores",
799 )?;
800 ensure_close(
801 summary.pass_rate,
802 passed / total_cases,
803 "score summary pass rate contradicts case scores",
804 )?;
805 }
806 if names.len() != aggregate.len() {
807 return Err(EvaluationError::InconsistentReport {
808 message: "per-case score is missing its summary",
809 });
810 }
811 Ok(())
812 }
813}
814
815#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
817pub struct RegressionPolicy {
818 pub max_mean_drop: f64,
820 pub max_pass_rate_drop: f64,
822 pub max_execution_success_drop: f64,
824}
825
826impl RegressionPolicy {
827 pub fn new(
833 max_mean_drop: f64,
834 max_pass_rate_drop: f64,
835 max_execution_success_drop: f64,
836 ) -> Result<Self, EvaluationError> {
837 let policy = Self {
838 max_mean_drop,
839 max_pass_rate_drop,
840 max_execution_success_drop,
841 };
842 policy.validate()?;
843 Ok(policy)
844 }
845
846 fn validate(&self) -> Result<(), EvaluationError> {
847 ensure_ratio("maximum mean drop", self.max_mean_drop)?;
848 ensure_ratio("maximum pass-rate drop", self.max_pass_rate_drop)?;
849 ensure_ratio(
850 "maximum execution-success drop",
851 self.max_execution_success_drop,
852 )
853 }
854}
855
856#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
858pub struct MetricRegression {
859 pub name: String,
861 pub baseline_mean: f64,
863 pub candidate_mean: f64,
865 pub mean_delta: f64,
867 pub baseline_pass_rate: f64,
869 pub candidate_pass_rate: f64,
871 pub pass_rate_delta: f64,
873 pub passed: bool,
875}
876
877#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
879pub struct RegressionComparison {
880 pub baseline_version: String,
882 pub candidate_version: String,
884 pub execution_success_rate_delta: f64,
886 pub metrics: Vec<MetricRegression>,
888 pub passed: bool,
890}
891
892pub struct EvaluationRunner {
894 target: Arc<dyn EvaluationTarget>,
895 scorers: Vec<Arc<dyn EvaluationScorer>>,
896 concurrency: NonZeroUsize,
897}
898
899impl EvaluationRunner {
900 pub fn new(target: impl EvaluationTarget + 'static) -> Self {
902 Self {
903 target: Arc::new(target),
904 scorers: Vec::new(),
905 concurrency: NonZeroUsize::MIN,
906 }
907 }
908
909 #[must_use]
911 pub fn with_scorer(mut self, scorer: impl EvaluationScorer + 'static) -> Self {
912 self.scorers.push(Arc::new(scorer));
913 self
914 }
915
916 #[must_use]
918 pub const fn with_concurrency(mut self, concurrency: NonZeroUsize) -> Self {
919 self.concurrency = concurrency;
920 self
921 }
922
923 pub async fn run(
932 &self,
933 dataset: &EvaluationDataset,
934 candidate_version: impl Into<String>,
935 ) -> Result<EvaluationReport, EvaluationError> {
936 let candidate_version = candidate_version.into();
937 ensure_not_empty("candidate version", &candidate_version)?;
938 validate_scorers(&self.scorers)?;
939 let target = Arc::clone(&self.target);
940 let scorers = self.scorers.clone();
941 let mut indexed = stream::iter(dataset.cases.iter().cloned().enumerate())
942 .map(|(index, case)| {
943 let target = Arc::clone(&target);
944 let scorers = scorers.clone();
945 async move { (index, evaluate_case(target, scorers, case).await) }
946 })
947 .buffer_unordered(self.concurrency.get())
948 .collect::<Vec<_>>()
949 .await;
950 indexed.sort_by_key(|(index, _)| *index);
951 let cases = indexed
952 .into_iter()
953 .map(|(_, result)| result)
954 .collect::<Vec<_>>();
955 Ok(build_report(dataset, candidate_version, cases))
956 }
957}
958
959impl fmt::Debug for EvaluationRunner {
960 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
961 formatter
962 .debug_struct("EvaluationRunner")
963 .field("scorers", &self.scorers.len())
964 .field("concurrency", &self.concurrency)
965 .finish_non_exhaustive()
966 }
967}
968
969async fn evaluate_case(
970 target: Arc<dyn EvaluationTarget>,
971 scorers: Vec<Arc<dyn EvaluationScorer>>,
972 case: EvaluationCase,
973) -> EvaluationCaseResult {
974 let output = match target.execute(case.clone()).await {
975 Ok(output) => output,
976 Err(error) => {
977 return EvaluationCaseResult {
978 case_id: case.id,
979 run_id: None,
980 metrics: None,
981 scores: Vec::new(),
982 failures: vec![EvaluationFailure {
983 stage: EvaluationFailureStage::Target,
984 scorer: None,
985 message: error.to_string(),
986 }],
987 };
988 }
989 };
990 let run_id = output.run_id;
991 let metrics = output.metrics.clone();
992 let scorer_concurrency = scorers.len().max(1);
993 let scored = stream::iter(scorers)
994 .map(|scorer| {
995 let case = case.clone();
996 let output = output.clone();
997 async move {
998 let name = scorer.name().to_owned();
999 let threshold = scorer.threshold();
1000 let result = scorer.score(case, output).await;
1001 (name, threshold, result)
1002 }
1003 })
1004 .buffer_unordered(scorer_concurrency)
1005 .collect::<Vec<_>>()
1006 .await;
1007 let mut case_scores = Vec::new();
1008 let mut failures = Vec::new();
1009 for (name, threshold, result) in scored {
1010 match result {
1011 Ok(score) => case_scores.push(EvaluationScore {
1012 name,
1013 value: score.value,
1014 threshold,
1015 passed: score.value >= threshold,
1016 rationale: score.rationale,
1017 }),
1018 Err(error) => failures.push(EvaluationFailure {
1019 stage: EvaluationFailureStage::Scorer,
1020 scorer: Some(name),
1021 message: error.to_string(),
1022 }),
1023 }
1024 }
1025 case_scores.sort_by(|left, right| left.name.cmp(&right.name));
1026 failures.sort_by(|left, right| left.scorer.cmp(&right.scorer));
1027 EvaluationCaseResult {
1028 case_id: case.id,
1029 run_id,
1030 metrics,
1031 scores: case_scores,
1032 failures,
1033 }
1034}
1035
1036fn build_report(
1037 dataset: &EvaluationDataset,
1038 candidate_version: String,
1039 cases: Vec<EvaluationCaseResult>,
1040) -> EvaluationReport {
1041 let total_cases = cases.len();
1042 let total_cases_ratio = cases.iter().fold(0.0, |total, _| total + 1.0);
1043 let successful = cases.iter().fold(0.0, |total, result| {
1044 if result
1045 .failures
1046 .iter()
1047 .any(|failure| failure.stage == EvaluationFailureStage::Target)
1048 {
1049 total
1050 } else {
1051 total + 1.0
1052 }
1053 });
1054 let mut aggregate = BTreeMap::<String, (usize, f64, f64, f64)>::new();
1055 for score in cases.iter().flat_map(|result| &result.scores) {
1056 let entry = aggregate.entry(score.name.clone()).or_default();
1057 entry.0 += 1;
1058 entry.1 += score.value;
1059 entry.2 += 1.0;
1060 entry.3 += if score.passed { 1.0 } else { 0.0 };
1061 }
1062 let summaries = aggregate
1063 .into_iter()
1064 .map(
1065 |(name, (scored_cases, total, scored_cases_ratio, passed))| EvaluationScoreSummary {
1066 name,
1067 scored_cases,
1068 total_cases,
1069 mean: total / scored_cases_ratio,
1070 pass_rate: passed / total_cases_ratio,
1071 },
1072 )
1073 .collect();
1074 EvaluationReport {
1075 dataset_name: dataset.name.clone(),
1076 dataset_version: dataset.version.clone(),
1077 candidate_version,
1078 execution_success_rate: successful / total_cases_ratio,
1079 cases,
1080 summaries,
1081 }
1082}
1083
1084fn ensure_not_empty(field: &'static str, value: &str) -> Result<(), EvaluationError> {
1085 if value.trim().is_empty() {
1086 return Err(EvaluationError::EmptyField { field });
1087 }
1088 Ok(())
1089}
1090
1091fn validate_case_metrics(case: &EvaluationCaseResult) -> Result<(), EvaluationError> {
1092 if case.metrics.is_some()
1093 && case
1094 .failures
1095 .iter()
1096 .any(|failure| failure.stage == EvaluationFailureStage::Target)
1097 {
1098 return Err(EvaluationError::InconsistentReport {
1099 message: "target failure cannot contain successful execution metrics",
1100 });
1101 }
1102 if let Some(metrics) = &case.metrics {
1103 metrics.validate()?;
1104 }
1105 Ok(())
1106}
1107
1108fn ensure_ratio(field: &'static str, value: f64) -> Result<(), EvaluationError> {
1109 if !value.is_finite() || !(0.0..=1.0).contains(&value) {
1110 return Err(EvaluationError::InvalidRatio { field, value });
1111 }
1112 Ok(())
1113}
1114
1115fn ensure_non_negative(field: &'static str, value: f64) -> Result<(), EvaluationError> {
1116 if !value.is_finite() || value < 0.0 {
1117 return Err(EvaluationError::InvalidMetric { field, value });
1118 }
1119 Ok(())
1120}
1121
1122fn ensure_close(actual: f64, expected: f64, message: &'static str) -> Result<(), EvaluationError> {
1123 const REPORT_RATIO_TOLERANCE: f64 = 1e-12;
1124 if (actual - expected).abs() > REPORT_RATIO_TOLERANCE {
1125 return Err(EvaluationError::InconsistentReport { message });
1126 }
1127 Ok(())
1128}
1129
1130fn validate_scorers(scorers: &[Arc<dyn EvaluationScorer>]) -> Result<(), EvaluationError> {
1131 if scorers.is_empty() {
1132 return Err(EvaluationError::NoScorers);
1133 }
1134 let mut names = BTreeSet::new();
1135 for scorer in scorers {
1136 ensure_not_empty("scorer name", scorer.name())?;
1137 ensure_ratio("score threshold", scorer.threshold())?;
1138 if !names.insert(scorer.name()) {
1139 return Err(EvaluationError::DuplicateScorer {
1140 scorer: scorer.name().to_owned(),
1141 });
1142 }
1143 }
1144 Ok(())
1145}
1146
1147#[cfg(test)]
1148mod tests {
1149 use std::num::NonZeroUsize;
1150
1151 use runifold_core::RunId;
1152
1153 use super::{
1154 EvaluationCase, EvaluationDataset, EvaluationError, EvaluationOutput, EvaluationRunner,
1155 JsonExactMatchScorer, RegressionPolicy, ScoreValue,
1156 };
1157
1158 #[test]
1159 fn dataset_rejects_duplicate_case_ids() {
1160 let first = EvaluationCase::new("same", serde_json::json!("one")).unwrap();
1161 let second = EvaluationCase::new("same", serde_json::json!("two")).unwrap();
1162
1163 let error = EvaluationDataset::new("dataset", "1", vec![first, second]).unwrap_err();
1164
1165 assert!(matches!(error, EvaluationError::DuplicateCase { .. }));
1166 }
1167
1168 #[test]
1169 fn score_rejects_non_finite_or_out_of_range_values() {
1170 for value in [-0.1, 1.1, f64::NAN, f64::INFINITY] {
1171 assert!(matches!(
1172 ScoreValue::new(value),
1173 Err(EvaluationError::InvalidRatio { .. })
1174 ));
1175 }
1176 }
1177
1178 #[test]
1179 fn metrics_reject_negative_or_non_finite_values() {
1180 for value in [-0.1, f64::NAN, f64::INFINITY] {
1181 assert!(matches!(
1182 super::EvaluationMetrics::new(value),
1183 Err(EvaluationError::InvalidMetric { .. })
1184 ));
1185 }
1186 assert!(
1187 super::EvaluationMetrics::new(1.0)
1188 .unwrap()
1189 .with_cost_usd(-0.1)
1190 .is_err()
1191 );
1192 }
1193
1194 #[test]
1195 fn runner_requires_at_least_one_scorer() {
1196 let dataset = EvaluationDataset::new(
1197 "answers",
1198 "1",
1199 vec![EvaluationCase::new("one", serde_json::json!("answer")).unwrap()],
1200 )
1201 .unwrap();
1202 let runner = EvaluationRunner::new(|case: EvaluationCase| async move {
1203 Ok(EvaluationOutput::new(case.input().clone()))
1204 });
1205
1206 let error = futures_executor::block_on(runner.run(&dataset, "candidate")).unwrap_err();
1207
1208 assert_eq!(error, EvaluationError::NoScorers);
1209 }
1210
1211 #[test]
1212 fn concurrent_runner_is_ordered_correlated_and_output_free() {
1213 let dataset = EvaluationDataset::new(
1214 "answers",
1215 "2026-07-26",
1216 vec![
1217 EvaluationCase::new("first", serde_json::json!("secret-one"))
1218 .unwrap()
1219 .with_expected(serde_json::json!("secret-one")),
1220 EvaluationCase::new("second", serde_json::json!("secret-two"))
1221 .unwrap()
1222 .with_expected(serde_json::json!("secret-two")),
1223 ],
1224 )
1225 .unwrap();
1226 let runner = EvaluationRunner::new(|case: EvaluationCase| async move {
1227 let output = EvaluationOutput::new(case.input().clone());
1228 Ok(if case.id().as_str() == "first" {
1229 output.with_run_id(RunId::new())
1230 } else {
1231 output
1232 })
1233 })
1234 .with_scorer(JsonExactMatchScorer)
1235 .with_concurrency(NonZeroUsize::new(2).unwrap());
1236
1237 let report = futures_executor::block_on(runner.run(&dataset, "candidate-a")).unwrap();
1238 let json = report.to_json_pretty().unwrap();
1239
1240 assert_eq!(report.cases[0].case_id.as_str(), "first");
1241 assert_eq!(report.cases[1].case_id.as_str(), "second");
1242 assert!(report.cases[0].run_id.is_some());
1243 assert!(report.cases[1].run_id.is_none());
1244 assert!((report.execution_success_rate - 1.0).abs() < 1e-12);
1245 assert!((report.summaries[0].mean - 1.0).abs() < 1e-12);
1246 assert!(!json.contains("secret-one"));
1247 assert!(!json.contains("secret-two"));
1248 }
1249
1250 #[test]
1251 fn relative_gate_detects_mean_and_pass_rate_regression() {
1252 let baseline = report("baseline", 1.0, 1.0);
1253 let candidate = report("candidate", 0.8, 0.5);
1254 let policy = RegressionPolicy::new(0.05, 0.1, 0.0).unwrap();
1255
1256 let comparison = candidate.compare(&baseline, &policy).unwrap();
1257
1258 assert!(!comparison.passed);
1259 assert!((comparison.metrics[0].mean_delta - -0.2).abs() < 1e-12);
1260 assert!((comparison.metrics[0].pass_rate_delta - -0.5).abs() < 1e-12);
1261 }
1262
1263 #[test]
1264 fn externally_loaded_report_cannot_forge_aggregate_quality() {
1265 let mut forged = report("candidate", 0.8, 0.5);
1266 forged.summaries[0].mean = 1.0;
1267
1268 assert!(matches!(
1269 forged.validate(),
1270 Err(EvaluationError::InconsistentReport { .. })
1271 ));
1272 }
1273
1274 fn report(candidate: &str, mean: f64, pass_rate: f64) -> super::EvaluationReport {
1275 let values = if pass_rate > 0.75 {
1276 [mean, mean]
1277 } else {
1278 [mean - 0.1, mean + 0.1]
1279 };
1280 let cases = values
1281 .into_iter()
1282 .enumerate()
1283 .map(|(index, value)| super::EvaluationCaseResult {
1284 case_id: super::EvaluationCaseId::new(format!("case-{index}")).unwrap(),
1285 run_id: None,
1286 metrics: None,
1287 scores: vec![super::EvaluationScore {
1288 name: "quality".into(),
1289 value,
1290 threshold: 0.8,
1291 passed: value >= 0.8,
1292 rationale: None,
1293 }],
1294 failures: Vec::new(),
1295 })
1296 .collect();
1297 super::EvaluationReport {
1298 dataset_name: "answers".into(),
1299 dataset_version: "1".into(),
1300 candidate_version: candidate.into(),
1301 execution_success_rate: 1.0,
1302 cases,
1303 summaries: vec![super::EvaluationScoreSummary {
1304 name: "quality".into(),
1305 scored_cases: 2,
1306 total_cases: 2,
1307 mean,
1308 pass_rate,
1309 }],
1310 }
1311 }
1312}