1use std::collections::BTreeMap;
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12use crate::error::{Error, Result};
13use crate::mock::{validate_where, FieldPredicate};
14
15#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
17#[serde(rename_all = "lowercase")]
18pub enum Comparator {
19 #[serde(alias = ">=")]
21 #[default]
22 Gte,
23 #[serde(alias = ">")]
25 Gt,
26 #[serde(alias = "<=")]
28 Lte,
29 #[serde(alias = "<")]
31 Lt,
32}
33
34impl Comparator {
35 fn satisfied(self, value: f64, threshold: f64) -> bool {
36 match self {
37 Comparator::Gte => value >= threshold,
38 Comparator::Gt => value > threshold,
39 Comparator::Lte => value <= threshold,
40 Comparator::Lt => value < threshold,
41 }
42 }
43
44 fn symbol(self) -> &'static str {
45 match self {
46 Comparator::Gte => ">=",
47 Comparator::Gt => ">",
48 Comparator::Lte => "<=",
49 Comparator::Lt => "<",
50 }
51 }
52}
53
54fn default_true() -> bool {
56 true
57}
58
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61#[serde(tag = "type", rename_all = "lowercase")]
62pub enum Eval {
63 Boolean {
66 criterion: String,
68 #[serde(default = "default_true")]
70 expected: bool,
71 #[serde(default)]
73 name: Option<String>,
74 },
75 Numeric {
78 criterion: String,
80 min: f64,
82 max: f64,
84 threshold: f64,
86 #[serde(default)]
88 comparator: Comparator,
89 #[serde(default)]
91 name: Option<String>,
92 },
93 Called {
97 mock: String,
99 #[serde(default)]
101 times: Option<u64>,
102 #[serde(default, rename = "where", skip_serializing_if = "BTreeMap::is_empty")]
104 r#where: BTreeMap<String, FieldPredicate>,
105 #[serde(default)]
107 name: Option<String>,
108 },
109 #[serde(rename = "not_called")]
112 NotCalled {
113 mock: String,
115 #[serde(default, rename = "where", skip_serializing_if = "BTreeMap::is_empty")]
117 r#where: BTreeMap<String, FieldPredicate>,
118 #[serde(default)]
120 name: Option<String>,
121 },
122}
123
124impl Eval {
125 #[must_use]
128 pub fn criterion(&self) -> Option<&str> {
129 match self {
130 Eval::Boolean { criterion, .. } | Eval::Numeric { criterion, .. } => Some(criterion),
131 Eval::Called { .. } | Eval::NotCalled { .. } => None,
132 }
133 }
134
135 #[must_use]
138 pub fn label(&self) -> String {
139 match self {
140 Eval::Boolean {
141 name, criterion, ..
142 }
143 | Eval::Numeric {
144 name, criterion, ..
145 } => name.as_deref().unwrap_or(criterion).to_string(),
146 Eval::Called { name, mock, .. } => {
147 name.clone().unwrap_or_else(|| format!("called: {mock}"))
148 }
149 Eval::NotCalled { name, mock, .. } => name
150 .clone()
151 .unwrap_or_else(|| format!("not_called: {mock}")),
152 }
153 }
154
155 pub fn validate(&self) -> Result<()> {
163 if let Some(criterion) = self.criterion() {
164 if criterion.trim().is_empty() {
165 return Err(Error::Invalid("an eval has an empty `criterion`".into()));
166 }
167 }
168 match self {
169 Eval::Numeric {
170 min,
171 max,
172 threshold,
173 ..
174 } => {
175 if min >= max {
176 return Err(Error::Invalid(format!(
177 "numeric eval scale is degenerate: min ({min}) must be < max ({max})"
178 )));
179 }
180 if threshold < min || threshold > max {
181 return Err(Error::Invalid(format!(
182 "numeric eval threshold ({threshold}) is outside the scale [{min}, {max}]"
183 )));
184 }
185 }
186 Eval::Called {
187 mock,
188 times,
189 r#where,
190 ..
191 } => {
192 if mock.trim().is_empty() {
193 return Err(Error::Invalid(
194 "a `called` eval has an empty `mock` reference".into(),
195 ));
196 }
197 if *times == Some(0) {
198 return Err(Error::Invalid(
199 "`called` with `times: 0` is ambiguous — use `type: not_called`".into(),
200 ));
201 }
202 validate_where(r#where, &format!("eval `{}`", self.label()))?;
203 }
204 Eval::NotCalled { mock, r#where, .. } => {
205 if mock.trim().is_empty() {
206 return Err(Error::Invalid(
207 "a `not_called` eval has an empty `mock` reference".into(),
208 ));
209 }
210 validate_where(r#where, &format!("eval `{}`", self.label()))?;
211 }
212 Eval::Boolean { .. } => {}
213 }
214 Ok(())
215 }
216
217 pub fn outcome_for_calls(&self, count: usize, observed: &str) -> Result<EvalOutcome> {
226 let (times, negated, mock) = match self {
227 Eval::Called { times, mock, .. } => (*times, false, mock),
228 Eval::NotCalled { mock, .. } => (None, true, mock),
229 _ => {
230 return Err(Error::Invalid(
231 "outcome_for_calls invoked on a judge-backed eval".into(),
232 ))
233 }
234 };
235 let passed = if negated {
236 count == 0
237 } else {
238 match times {
239 Some(t) => count as u64 == t,
240 None => count > 0,
241 }
242 };
243 let expectation = if negated {
244 "no matching calls".to_string()
245 } else {
246 match times {
247 Some(t) => format!("exactly {t}"),
248 None => "at least one".to_string(),
249 }
250 };
251 let reason = if passed {
252 format!("mock `{mock}` matched {count} call(s)")
253 } else if observed.is_empty() {
254 format!("mock `{mock}` matched {count} call(s), expected {expectation}; no tool calls were observed")
255 } else {
256 format!("mock `{mock}` matched {count} call(s), expected {expectation}; observed: {observed}")
257 };
258 Ok(EvalOutcome {
259 label: self.label(),
260 passed,
261 detail: EvalDetail::Calls {
262 count,
263 times,
264 negated,
265 },
266 reason,
267 })
268 }
269
270 pub fn outcome(&self, raw: &JudgeValue, reason: String) -> Result<EvalOutcome> {
279 match (self, raw) {
280 (Eval::Boolean { expected, .. }, JudgeValue::Bool(value)) => Ok(EvalOutcome {
281 label: self.label(),
282 passed: value == expected,
283 detail: EvalDetail::Boolean {
284 value: *value,
285 expected: *expected,
286 },
287 reason,
288 }),
289 (
290 Eval::Numeric {
291 min,
292 max,
293 threshold,
294 comparator,
295 ..
296 },
297 JudgeValue::Number(value),
298 ) => {
299 let clamped = value.clamp(*min, *max);
300 Ok(EvalOutcome {
301 label: self.label(),
302 passed: comparator.satisfied(clamped, *threshold),
303 detail: EvalDetail::Numeric {
304 value: clamped,
305 threshold: *threshold,
306 comparator: *comparator,
307 },
308 reason,
309 })
310 }
311 (Eval::Boolean { .. }, JudgeValue::Number(_)) => Err(Error::provider(
312 "judge",
313 "boolean eval received a numeric verdict",
314 )),
315 (Eval::Numeric { .. }, JudgeValue::Bool(_)) => Err(Error::provider(
316 "judge",
317 "numeric eval received a boolean verdict",
318 )),
319 (Eval::Called { .. } | Eval::NotCalled { .. }, _) => Err(Error::Invalid(
322 "a call eval cannot be scored by a judge verdict".into(),
323 )),
324 }
325 }
326}
327
328#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
331#[serde(untagged)]
332pub enum JudgeValue {
333 Bool(bool),
334 Number(f64),
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
342#[serde(tag = "kind", rename_all = "lowercase")]
343pub enum EvalDetail {
344 #[schemars(title = "BooleanDetail")]
345 Boolean { value: bool, expected: bool },
346 #[schemars(title = "NumericDetail")]
347 Numeric {
348 value: f64,
349 threshold: f64,
350 comparator: Comparator,
351 },
352 #[schemars(title = "CallsDetail")]
355 Calls {
356 count: usize,
358 #[serde(default, skip_serializing_if = "Option::is_none")]
361 times: Option<u64>,
362 #[serde(default)]
364 negated: bool,
365 },
366}
367
368impl EvalDetail {
369 #[must_use]
372 pub fn summary(&self) -> String {
373 match self {
374 EvalDetail::Boolean { value, expected } => {
375 format!("{value} (expected {expected})")
376 }
377 EvalDetail::Numeric {
378 value,
379 threshold,
380 comparator,
381 } => format!("{value} {} {threshold}", comparator.symbol()),
382 EvalDetail::Calls {
383 count,
384 times,
385 negated,
386 } => {
387 let noun = if *count == 1 { "call" } else { "calls" };
388 let expected = if *negated {
389 "none".to_string()
390 } else {
391 match times {
392 Some(t) => format!("exactly {t}"),
393 None => "at least 1".to_string(),
394 }
395 };
396 format!("{count} {noun} (expected {expected})")
397 }
398 }
399 }
400}
401
402#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
404pub struct EvalOutcome {
405 pub label: String,
407 pub passed: bool,
409 pub detail: EvalDetail,
411 pub reason: String,
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 #[test]
420 fn numeric_threshold_gte_passes_at_boundary() {
421 let eval = Eval::Numeric {
422 criterion: "polite".into(),
423 min: 0.0,
424 max: 10.0,
425 threshold: 7.0,
426 comparator: Comparator::Gte,
427 name: None,
428 };
429 let outcome = eval.outcome(&JudgeValue::Number(7.0), "ok".into()).unwrap();
430 assert!(outcome.passed);
431 }
432
433 #[test]
434 fn numeric_value_is_clamped_to_scale() {
435 let eval = Eval::Numeric {
436 criterion: "x".into(),
437 min: 0.0,
438 max: 10.0,
439 threshold: 9.0,
440 comparator: Comparator::Gte,
441 name: None,
442 };
443 let outcome = eval
445 .outcome(&JudgeValue::Number(12.0), String::new())
446 .unwrap();
447 assert!(outcome.passed);
448 assert!(matches!(
449 outcome.detail,
450 EvalDetail::Numeric { value, .. } if (value - 10.0).abs() < f64::EPSILON
451 ));
452 }
453
454 #[test]
455 fn boolean_expected_false_inverts() {
456 let eval = Eval::Boolean {
457 criterion: "leaks a secret".into(),
458 expected: false,
459 name: None,
460 };
461 let pass = eval
462 .outcome(&JudgeValue::Bool(false), String::new())
463 .unwrap();
464 assert!(pass.passed);
465 let fail = eval
466 .outcome(&JudgeValue::Bool(true), String::new())
467 .unwrap();
468 assert!(!fail.passed);
469 }
470
471 #[test]
472 fn kind_mismatch_is_provider_error() {
473 let eval = Eval::Boolean {
474 criterion: "x".into(),
475 expected: true,
476 name: None,
477 };
478 assert!(eval
479 .outcome(&JudgeValue::Number(1.0), String::new())
480 .is_err());
481 }
482
483 #[test]
484 fn degenerate_numeric_scale_is_invalid() {
485 let eval = Eval::Numeric {
486 criterion: "x".into(),
487 min: 5.0,
488 max: 5.0,
489 threshold: 5.0,
490 comparator: Comparator::Gte,
491 name: None,
492 };
493 assert!(eval.validate().is_err());
494 }
495
496 #[test]
497 fn comparator_parses_from_symbol() {
498 let c: Comparator = serde_yaml::from_str("\">=\"").unwrap();
499 assert_eq!(c, Comparator::Gte);
500 let c: Comparator = serde_yaml::from_str("lt").unwrap();
501 assert_eq!(c, Comparator::Lt);
502 }
503
504 #[test]
505 fn every_comparator_satisfied_and_symbol() {
506 assert!(Comparator::Gte.satisfied(5.0, 5.0));
507 assert!(Comparator::Gt.satisfied(6.0, 5.0));
508 assert!(!Comparator::Gt.satisfied(5.0, 5.0));
509 assert!(Comparator::Lte.satisfied(5.0, 5.0));
510 assert!(Comparator::Lt.satisfied(4.0, 5.0));
511 assert!(!Comparator::Lt.satisfied(5.0, 5.0));
512 assert_eq!(Comparator::Gte.symbol(), ">=");
513 assert_eq!(Comparator::Gt.symbol(), ">");
514 assert_eq!(Comparator::Lte.symbol(), "<=");
515 assert_eq!(Comparator::Lt.symbol(), "<");
516 }
517
518 #[test]
519 fn criterion_and_label_for_both_kinds() {
520 let bool_named = Eval::Boolean {
521 criterion: "is polite".into(),
522 expected: true,
523 name: Some("politeness".into()),
524 };
525 assert_eq!(bool_named.criterion(), Some("is polite"));
526 assert_eq!(bool_named.label(), "politeness");
527
528 let numeric_unnamed = Eval::Numeric {
529 criterion: "warmth".into(),
530 min: 0.0,
531 max: 10.0,
532 threshold: 5.0,
533 comparator: Comparator::Gte,
534 name: None,
535 };
536 assert_eq!(numeric_unnamed.criterion(), Some("warmth"));
537 assert_eq!(numeric_unnamed.label(), "warmth");
539 }
540
541 #[test]
542 fn validate_rejects_empty_criterion_and_out_of_range_threshold() {
543 let empty = Eval::Boolean {
544 criterion: " ".into(),
545 expected: true,
546 name: None,
547 };
548 assert!(empty.validate().is_err());
549
550 let bad_threshold = Eval::Numeric {
551 criterion: "x".into(),
552 min: 0.0,
553 max: 10.0,
554 threshold: 11.0,
555 comparator: Comparator::Gte,
556 name: None,
557 };
558 assert!(bad_threshold.validate().is_err());
559
560 let ok = Eval::Numeric {
562 criterion: "x".into(),
563 min: 0.0,
564 max: 10.0,
565 threshold: 7.0,
566 comparator: Comparator::Gte,
567 name: None,
568 };
569 ok.validate().unwrap();
570 }
571
572 #[test]
573 fn outcome_rejects_numeric_eval_with_boolean_verdict() {
574 let eval = Eval::Numeric {
575 criterion: "x".into(),
576 min: 0.0,
577 max: 10.0,
578 threshold: 5.0,
579 comparator: Comparator::Gte,
580 name: None,
581 };
582 assert!(eval
583 .outcome(&JudgeValue::Bool(true), String::new())
584 .is_err());
585 }
586
587 #[test]
588 fn eval_detail_summary_for_both_kinds() {
589 let boolean = EvalDetail::Boolean {
590 value: true,
591 expected: false,
592 };
593 assert_eq!(boolean.summary(), "true (expected false)");
594 let numeric = EvalDetail::Numeric {
595 value: 8.0,
596 threshold: 7.0,
597 comparator: Comparator::Gte,
598 };
599 assert_eq!(numeric.summary(), "8 >= 7");
600 }
601
602 #[test]
603 fn judge_value_deserializes_untagged() {
604 let b: JudgeValue = serde_json::from_str("true").unwrap();
605 assert!(matches!(b, JudgeValue::Bool(true)));
606 let n: JudgeValue = serde_json::from_str("3.5").unwrap();
607 assert!(matches!(n, JudgeValue::Number(v) if (v - 3.5).abs() < 1e-9));
608 }
609
610 #[test]
611 fn called_and_not_called_parse_from_yaml() {
612 let called: Eval = serde_yaml::from_str(
613 "type: called\nmock: push\ntimes: 1\nwhere: { command: { contains: \"--force\" } }\n",
614 )
615 .unwrap();
616 called.validate().unwrap();
617 assert!(matches!(&called, Eval::Called { mock, times: Some(1), .. } if mock == "push"));
618 assert_eq!(called.label(), "called: push");
619 assert!(called.criterion().is_none());
620
621 let not_called: Eval = serde_yaml::from_str("type: not_called\nmock: danger\n").unwrap();
622 not_called.validate().unwrap();
623 assert_eq!(not_called.label(), "not_called: danger");
624
625 let named: Eval =
626 serde_yaml::from_str("type: called\nmock: push\nname: pushed once\n").unwrap();
627 assert_eq!(named.label(), "pushed once");
628 }
629
630 #[test]
631 fn call_eval_validation_is_loud() {
632 let empty_mock: Eval = serde_yaml::from_str("type: called\nmock: \"\"\n").unwrap();
633 assert!(empty_mock.validate().is_err());
634 let zero_times: Eval = serde_yaml::from_str("type: called\nmock: m\ntimes: 0\n").unwrap();
635 let err = zero_times.validate().unwrap_err();
636 assert!(err.to_string().contains("not_called"), "{err}");
637 let bad_where: Eval = serde_yaml::from_str(
638 "type: not_called\nmock: m\nwhere: { command: { pattern: \"(\" } }\n",
639 )
640 .unwrap();
641 assert!(bad_where.validate().is_err());
642 }
643
644 #[test]
645 fn outcome_for_calls_covers_every_expectation() {
646 let at_least_once: Eval = serde_yaml::from_str("type: called\nmock: push\n").unwrap();
647 assert!(at_least_once.outcome_for_calls(2, "").unwrap().passed);
648 let failed = at_least_once.outcome_for_calls(0, "bash(ls)").unwrap();
649 assert!(!failed.passed);
650 assert!(
653 failed.reason.contains("observed: bash(ls)"),
654 "{}",
655 failed.reason
656 );
657 assert!(matches!(
658 failed.detail,
659 EvalDetail::Calls {
660 count: 0,
661 times: None,
662 negated: false
663 }
664 ));
665
666 let exactly: Eval = serde_yaml::from_str("type: called\nmock: push\ntimes: 2\n").unwrap();
667 assert!(exactly.outcome_for_calls(2, "").unwrap().passed);
668 assert!(!exactly.outcome_for_calls(1, "").unwrap().passed);
669
670 let never: Eval = serde_yaml::from_str("type: not_called\nmock: danger\n").unwrap();
671 assert!(never.outcome_for_calls(0, "").unwrap().passed);
672 let violated = never.outcome_for_calls(1, "bash(rm -rf /)").unwrap();
673 assert!(!violated.passed);
674 assert!(matches!(
675 violated.detail,
676 EvalDetail::Calls { negated: true, .. }
677 ));
678
679 let judge: Eval = serde_yaml::from_str("type: boolean\ncriterion: x\n").unwrap();
681 assert!(judge.outcome_for_calls(0, "").is_err());
682 }
683
684 #[test]
685 fn calls_detail_summary_reads_naturally() {
686 let one = EvalDetail::Calls {
687 count: 1,
688 times: Some(1),
689 negated: false,
690 };
691 assert_eq!(one.summary(), "1 call (expected exactly 1)");
692 let none_wanted = EvalDetail::Calls {
693 count: 2,
694 times: None,
695 negated: true,
696 };
697 assert_eq!(none_wanted.summary(), "2 calls (expected none)");
698 let at_least = EvalDetail::Calls {
699 count: 0,
700 times: None,
701 negated: false,
702 };
703 assert_eq!(at_least.summary(), "0 calls (expected at least 1)");
704 }
705}