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
569mod regression;
570mod runner;
571#[cfg(test)]
572mod tests;
573mod validation;
574
575pub use regression::{MetricRegression, RegressionComparison, RegressionPolicy};
576pub use runner::EvaluationRunner;
577use validation::{
578 ensure_close, ensure_non_negative, ensure_not_empty, ensure_ratio, validate_case_metrics,
579};
580
581#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
583#[serde(rename_all = "snake_case")]
584#[non_exhaustive]
585pub enum EvaluationFailureStage {
586 Target,
588 Scorer,
590}
591
592#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
594pub struct EvaluationFailure {
595 pub stage: EvaluationFailureStage,
597 pub scorer: Option<String>,
599 pub message: String,
601}
602
603#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
605pub struct EvaluationCaseResult {
606 pub case_id: EvaluationCaseId,
608 pub run_id: Option<RunId>,
610 #[serde(default, skip_serializing_if = "Option::is_none")]
612 pub metrics: Option<EvaluationMetrics>,
613 pub scores: Vec<EvaluationScore>,
615 pub failures: Vec<EvaluationFailure>,
617}
618
619#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
621pub struct EvaluationScoreSummary {
622 pub name: String,
624 pub scored_cases: usize,
626 pub total_cases: usize,
628 pub mean: f64,
630 pub pass_rate: f64,
632}
633
634#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
636pub struct EvaluationReport {
637 pub dataset_name: String,
639 pub dataset_version: String,
641 pub candidate_version: String,
643 pub execution_success_rate: f64,
645 pub cases: Vec<EvaluationCaseResult>,
647 pub summaries: Vec<EvaluationScoreSummary>,
649}
650
651impl EvaluationReport {
652 pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
658 serde_json::to_string_pretty(self)
659 }
660
661 pub fn compare(
668 &self,
669 baseline: &Self,
670 policy: &RegressionPolicy,
671 ) -> Result<RegressionComparison, EvaluationError> {
672 self.validate()?;
673 baseline.validate()?;
674 policy.validate()?;
675 if self.dataset_name != baseline.dataset_name
676 || self.dataset_version != baseline.dataset_version
677 {
678 return Err(EvaluationError::DatasetMismatch {
679 baseline_name: baseline.dataset_name.clone(),
680 baseline_version: baseline.dataset_version.clone(),
681 candidate_name: self.dataset_name.clone(),
682 candidate_version: self.dataset_version.clone(),
683 });
684 }
685 let metrics = baseline
686 .summaries
687 .iter()
688 .map(|baseline_summary| {
689 let candidate = self
690 .summaries
691 .iter()
692 .find(|summary| summary.name == baseline_summary.name);
693 let candidate_mean = candidate.map_or(0.0, |summary| summary.mean);
694 let candidate_pass_rate = candidate.map_or(0.0, |summary| summary.pass_rate);
695 let mean_delta = candidate_mean - baseline_summary.mean;
696 let pass_rate_delta = candidate_pass_rate - baseline_summary.pass_rate;
697 MetricRegression {
698 name: baseline_summary.name.clone(),
699 baseline_mean: baseline_summary.mean,
700 candidate_mean,
701 mean_delta,
702 baseline_pass_rate: baseline_summary.pass_rate,
703 candidate_pass_rate,
704 pass_rate_delta,
705 passed: mean_delta >= -policy.max_mean_drop
706 && pass_rate_delta >= -policy.max_pass_rate_drop,
707 }
708 })
709 .collect::<Vec<_>>();
710 let execution_success_rate_delta =
711 self.execution_success_rate - baseline.execution_success_rate;
712 let passed = execution_success_rate_delta >= -policy.max_execution_success_drop
713 && metrics.iter().all(|metric| metric.passed);
714 Ok(RegressionComparison {
715 baseline_version: baseline.candidate_version.clone(),
716 candidate_version: self.candidate_version.clone(),
717 execution_success_rate_delta,
718 metrics,
719 passed,
720 })
721 }
722
723 pub fn validate(&self) -> Result<(), EvaluationError> {
730 ensure_not_empty("dataset name", &self.dataset_name)?;
731 ensure_not_empty("dataset version", &self.dataset_version)?;
732 ensure_not_empty("candidate version", &self.candidate_version)?;
733 ensure_ratio("execution success rate", self.execution_success_rate)?;
734 if self.cases.is_empty() {
735 return Err(EvaluationError::InconsistentReport {
736 message: "report contains no cases",
737 });
738 }
739 let total_cases = self.cases.iter().fold(0.0, |total, _| total + 1.0);
740 let mut successful_cases = 0.0;
741 let mut case_ids = BTreeSet::new();
742 let mut aggregate = BTreeMap::<&str, (usize, f64, f64, f64)>::new();
743 for case in &self.cases {
744 ensure_not_empty("report case id", case.case_id.as_str())?;
745 if !case_ids.insert(case.case_id.as_str()) {
746 return Err(EvaluationError::InconsistentReport {
747 message: "report contains duplicate case IDs",
748 });
749 }
750 if !case
751 .failures
752 .iter()
753 .any(|failure| failure.stage == EvaluationFailureStage::Target)
754 {
755 successful_cases += 1.0;
756 }
757 validate_case_metrics(case)?;
758 let mut score_names = BTreeSet::new();
759 for score in &case.scores {
760 ensure_not_empty("report score name", &score.name)?;
761 ensure_ratio("report score", score.value)?;
762 ensure_ratio("report score threshold", score.threshold)?;
763 if score.passed != (score.value >= score.threshold) {
764 return Err(EvaluationError::InconsistentReport {
765 message: "stored score decision contradicts its threshold",
766 });
767 }
768 if !score_names.insert(score.name.as_str()) {
769 return Err(EvaluationError::InconsistentReport {
770 message: "one case contains duplicate score names",
771 });
772 }
773 let entry = aggregate.entry(&score.name).or_default();
774 entry.0 += 1;
775 entry.1 += score.value;
776 entry.2 += 1.0;
777 entry.3 += if score.passed { 1.0 } else { 0.0 };
778 }
779 }
780 ensure_close(
781 self.execution_success_rate,
782 successful_cases / total_cases,
783 "execution success rate contradicts case failures",
784 )?;
785 let mut names = BTreeSet::new();
786 for summary in &self.summaries {
787 ensure_not_empty("score summary name", &summary.name)?;
788 ensure_ratio("score mean", summary.mean)?;
789 ensure_ratio("score pass rate", summary.pass_rate)?;
790 if !names.insert(summary.name.as_str()) {
791 return Err(EvaluationError::DuplicateScorer {
792 scorer: summary.name.clone(),
793 });
794 }
795 let Some((scored_cases, total, scored_cases_ratio, passed)) =
796 aggregate.get(summary.name.as_str())
797 else {
798 return Err(EvaluationError::InconsistentReport {
799 message: "score summary has no per-case evidence",
800 });
801 };
802 if summary.scored_cases != *scored_cases || summary.total_cases != self.cases.len() {
803 return Err(EvaluationError::InconsistentReport {
804 message: "score summary case counts are inconsistent",
805 });
806 }
807 ensure_close(
808 summary.mean,
809 total / scored_cases_ratio,
810 "score summary mean contradicts case scores",
811 )?;
812 ensure_close(
813 summary.pass_rate,
814 passed / total_cases,
815 "score summary pass rate contradicts case scores",
816 )?;
817 }
818 if names.len() != aggregate.len() {
819 return Err(EvaluationError::InconsistentReport {
820 message: "per-case score is missing its summary",
821 });
822 }
823 Ok(())
824 }
825}