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, JsonSchema)]
62#[serde(deny_unknown_fields)]
63pub struct BooleanEval {
64 pub criterion: String,
66 #[serde(default = "default_true")]
68 pub expected: bool,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub name: Option<String>,
72}
73
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
77#[serde(deny_unknown_fields)]
78pub struct NumericEval {
79 pub criterion: String,
81 pub min: f64,
83 pub max: f64,
85 pub threshold: f64,
87 #[serde(default)]
89 pub comparator: Comparator,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub name: Option<String>,
93}
94
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
99#[serde(deny_unknown_fields)]
100pub struct CalledEval {
101 pub mock: String,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub times: Option<u64>,
106 #[serde(default, rename = "where", skip_serializing_if = "BTreeMap::is_empty")]
108 pub r#where: BTreeMap<String, FieldPredicate>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub name: Option<String>,
112}
113
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
117#[serde(deny_unknown_fields)]
118pub struct NotCalledEval {
119 pub mock: String,
121 #[serde(default, rename = "where", skip_serializing_if = "BTreeMap::is_empty")]
123 pub r#where: BTreeMap<String, FieldPredicate>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub name: Option<String>,
127}
128
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
139#[serde(tag = "type", rename_all = "lowercase")]
140pub enum Eval {
141 #[schemars(title = "BooleanEval")]
142 Boolean(BooleanEval),
143 #[schemars(title = "NumericEval")]
144 Numeric(NumericEval),
145 #[schemars(title = "CalledEval")]
146 Called(CalledEval),
147 #[serde(rename = "not_called")]
148 #[schemars(title = "NotCalledEval")]
149 NotCalled(NotCalledEval),
150}
151
152impl Eval {
153 #[must_use]
156 pub fn criterion(&self) -> Option<&str> {
157 match self {
158 Eval::Boolean(BooleanEval { criterion, .. })
159 | Eval::Numeric(NumericEval { criterion, .. }) => Some(criterion),
160 Eval::Called(_) | Eval::NotCalled(_) => None,
161 }
162 }
163
164 #[must_use]
167 pub fn label(&self) -> String {
168 match self {
169 Eval::Boolean(BooleanEval {
170 name, criterion, ..
171 })
172 | Eval::Numeric(NumericEval {
173 name, criterion, ..
174 }) => name.as_deref().unwrap_or(criterion).to_string(),
175 Eval::Called(CalledEval { name, mock, .. }) => {
176 name.clone().unwrap_or_else(|| format!("called: {mock}"))
177 }
178 Eval::NotCalled(NotCalledEval { name, mock, .. }) => name
179 .clone()
180 .unwrap_or_else(|| format!("not_called: {mock}")),
181 }
182 }
183
184 pub fn validate(&self) -> Result<()> {
192 if let Some(criterion) = self.criterion() {
193 if criterion.trim().is_empty() {
194 return Err(Error::Invalid("an eval has an empty `criterion`".into()));
195 }
196 }
197 match self {
198 Eval::Numeric(NumericEval {
199 min,
200 max,
201 threshold,
202 ..
203 }) => {
204 if min >= max {
205 return Err(Error::Invalid(format!(
206 "numeric eval scale is degenerate: min ({min}) must be < max ({max})"
207 )));
208 }
209 if threshold < min || threshold > max {
210 return Err(Error::Invalid(format!(
211 "numeric eval threshold ({threshold}) is outside the scale [{min}, {max}]"
212 )));
213 }
214 }
215 Eval::Called(CalledEval {
216 mock,
217 times,
218 r#where,
219 ..
220 }) => {
221 if mock.trim().is_empty() {
222 return Err(Error::Invalid(
223 "a `called` eval has an empty `mock` reference".into(),
224 ));
225 }
226 if *times == Some(0) {
227 return Err(Error::Invalid(
228 "`called` with `times: 0` is ambiguous — use `type: not_called`".into(),
229 ));
230 }
231 validate_where(r#where, &format!("eval `{}`", self.label()))?;
232 }
233 Eval::NotCalled(NotCalledEval { mock, r#where, .. }) => {
234 if mock.trim().is_empty() {
235 return Err(Error::Invalid(
236 "a `not_called` eval has an empty `mock` reference".into(),
237 ));
238 }
239 validate_where(r#where, &format!("eval `{}`", self.label()))?;
240 }
241 Eval::Boolean(_) => {}
242 }
243 Ok(())
244 }
245
246 pub fn outcome_for_calls(&self, count: usize, observed: &str) -> Result<EvalOutcome> {
255 let (times, negated, mock) = match self {
256 Eval::Called(CalledEval { times, mock, .. }) => (*times, false, mock),
257 Eval::NotCalled(NotCalledEval { mock, .. }) => (None, true, mock),
258 _ => {
259 return Err(Error::Invalid(
260 "outcome_for_calls invoked on a judge-backed eval".into(),
261 ))
262 }
263 };
264 let passed = if negated {
265 count == 0
266 } else {
267 match times {
268 Some(t) => count as u64 == t,
269 None => count > 0,
270 }
271 };
272 let expectation = if negated {
273 "no matching calls".to_string()
274 } else {
275 match times {
276 Some(t) => format!("exactly {t}"),
277 None => "at least one".to_string(),
278 }
279 };
280 let reason = if passed {
281 format!("mock `{mock}` matched {count} call(s)")
282 } else if observed.is_empty() {
283 format!("mock `{mock}` matched {count} call(s), expected {expectation}; no tool calls were observed")
284 } else {
285 format!("mock `{mock}` matched {count} call(s), expected {expectation}; observed: {observed}")
286 };
287 Ok(EvalOutcome {
288 label: self.label(),
289 passed,
290 detail: EvalDetail::Calls {
291 count,
292 times,
293 negated,
294 },
295 reason,
296 })
297 }
298
299 pub fn outcome(&self, raw: &JudgeValue, reason: String) -> Result<EvalOutcome> {
308 match (self, raw) {
309 (Eval::Boolean(BooleanEval { expected, .. }), JudgeValue::Bool(value)) => {
310 Ok(EvalOutcome {
311 label: self.label(),
312 passed: value == expected,
313 detail: EvalDetail::Boolean {
314 value: *value,
315 expected: *expected,
316 },
317 reason,
318 })
319 }
320 (
321 Eval::Numeric(NumericEval {
322 min,
323 max,
324 threshold,
325 comparator,
326 ..
327 }),
328 JudgeValue::Number(value),
329 ) => {
330 let clamped = value.clamp(*min, *max);
331 Ok(EvalOutcome {
332 label: self.label(),
333 passed: comparator.satisfied(clamped, *threshold),
334 detail: EvalDetail::Numeric {
335 value: clamped,
336 threshold: *threshold,
337 comparator: *comparator,
338 },
339 reason,
340 })
341 }
342 (Eval::Boolean(_), JudgeValue::Number(_)) => Err(Error::provider(
343 "judge",
344 "boolean eval received a numeric verdict",
345 )),
346 (Eval::Numeric(_), JudgeValue::Bool(_)) => Err(Error::provider(
347 "judge",
348 "numeric eval received a boolean verdict",
349 )),
350 (Eval::Called(_) | Eval::NotCalled(_), _) => Err(Error::Invalid(
353 "a call eval cannot be scored by a judge verdict".into(),
354 )),
355 }
356 }
357}
358
359#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
362#[serde(untagged)]
363pub enum JudgeValue {
364 Bool(bool),
365 Number(f64),
366}
367
368#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
373#[serde(tag = "kind", rename_all = "lowercase")]
374pub enum EvalDetail {
375 #[schemars(title = "BooleanDetail")]
376 Boolean { value: bool, expected: bool },
377 #[schemars(title = "NumericDetail")]
378 Numeric {
379 value: f64,
380 threshold: f64,
381 comparator: Comparator,
382 },
383 #[schemars(title = "CallsDetail")]
386 Calls {
387 count: usize,
389 #[serde(default, skip_serializing_if = "Option::is_none")]
392 times: Option<u64>,
393 #[serde(default)]
395 negated: bool,
396 },
397}
398
399impl EvalDetail {
400 #[must_use]
403 pub fn summary(&self) -> String {
404 match self {
405 EvalDetail::Boolean { value, expected } => {
406 format!("{value} (expected {expected})")
407 }
408 EvalDetail::Numeric {
409 value,
410 threshold,
411 comparator,
412 } => format!("{value} {} {threshold}", comparator.symbol()),
413 EvalDetail::Calls {
414 count,
415 times,
416 negated,
417 } => {
418 let noun = if *count == 1 { "call" } else { "calls" };
419 let expected = if *negated {
420 "none".to_string()
421 } else {
422 match times {
423 Some(t) => format!("exactly {t}"),
424 None => "at least 1".to_string(),
425 }
426 };
427 format!("{count} {noun} (expected {expected})")
428 }
429 }
430 }
431}
432
433#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
435pub struct EvalOutcome {
436 pub label: String,
438 pub passed: bool,
440 pub detail: EvalDetail,
442 pub reason: String,
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449
450 #[test]
451 fn numeric_threshold_gte_passes_at_boundary() {
452 let eval = Eval::Numeric(NumericEval {
453 criterion: "polite".into(),
454 min: 0.0,
455 max: 10.0,
456 threshold: 7.0,
457 comparator: Comparator::Gte,
458 name: None,
459 });
460 let outcome = eval.outcome(&JudgeValue::Number(7.0), "ok".into()).unwrap();
461 assert!(outcome.passed);
462 }
463
464 #[test]
465 fn numeric_value_is_clamped_to_scale() {
466 let eval = Eval::Numeric(NumericEval {
467 criterion: "x".into(),
468 min: 0.0,
469 max: 10.0,
470 threshold: 9.0,
471 comparator: Comparator::Gte,
472 name: None,
473 });
474 let outcome = eval
476 .outcome(&JudgeValue::Number(12.0), String::new())
477 .unwrap();
478 assert!(outcome.passed);
479 assert!(matches!(
480 outcome.detail,
481 EvalDetail::Numeric { value, .. } if (value - 10.0).abs() < f64::EPSILON
482 ));
483 }
484
485 #[test]
486 fn boolean_expected_false_inverts() {
487 let eval = Eval::Boolean(BooleanEval {
488 criterion: "leaks a secret".into(),
489 expected: false,
490 name: None,
491 });
492 let pass = eval
493 .outcome(&JudgeValue::Bool(false), String::new())
494 .unwrap();
495 assert!(pass.passed);
496 let fail = eval
497 .outcome(&JudgeValue::Bool(true), String::new())
498 .unwrap();
499 assert!(!fail.passed);
500 }
501
502 #[test]
503 fn kind_mismatch_is_provider_error() {
504 let eval = Eval::Boolean(BooleanEval {
505 criterion: "x".into(),
506 expected: true,
507 name: None,
508 });
509 assert!(eval
510 .outcome(&JudgeValue::Number(1.0), String::new())
511 .is_err());
512 }
513
514 #[test]
515 fn degenerate_numeric_scale_is_invalid() {
516 let eval = Eval::Numeric(NumericEval {
517 criterion: "x".into(),
518 min: 5.0,
519 max: 5.0,
520 threshold: 5.0,
521 comparator: Comparator::Gte,
522 name: None,
523 });
524 assert!(eval.validate().is_err());
525 }
526
527 #[test]
528 fn comparator_parses_from_symbol() {
529 let c: Comparator = serde_yaml::from_str("\">=\"").unwrap();
530 assert_eq!(c, Comparator::Gte);
531 let c: Comparator = serde_yaml::from_str("lt").unwrap();
532 assert_eq!(c, Comparator::Lt);
533 }
534
535 #[test]
536 fn every_comparator_satisfied_and_symbol() {
537 assert!(Comparator::Gte.satisfied(5.0, 5.0));
538 assert!(Comparator::Gt.satisfied(6.0, 5.0));
539 assert!(!Comparator::Gt.satisfied(5.0, 5.0));
540 assert!(Comparator::Lte.satisfied(5.0, 5.0));
541 assert!(Comparator::Lt.satisfied(4.0, 5.0));
542 assert!(!Comparator::Lt.satisfied(5.0, 5.0));
543 assert_eq!(Comparator::Gte.symbol(), ">=");
544 assert_eq!(Comparator::Gt.symbol(), ">");
545 assert_eq!(Comparator::Lte.symbol(), "<=");
546 assert_eq!(Comparator::Lt.symbol(), "<");
547 }
548
549 #[test]
550 fn criterion_and_label_for_both_kinds() {
551 let bool_named = Eval::Boolean(BooleanEval {
552 criterion: "is polite".into(),
553 expected: true,
554 name: Some("politeness".into()),
555 });
556 assert_eq!(bool_named.criterion(), Some("is polite"));
557 assert_eq!(bool_named.label(), "politeness");
558
559 let numeric_unnamed = Eval::Numeric(NumericEval {
560 criterion: "warmth".into(),
561 min: 0.0,
562 max: 10.0,
563 threshold: 5.0,
564 comparator: Comparator::Gte,
565 name: None,
566 });
567 assert_eq!(numeric_unnamed.criterion(), Some("warmth"));
568 assert_eq!(numeric_unnamed.label(), "warmth");
570 }
571
572 #[test]
573 fn validate_rejects_empty_criterion_and_out_of_range_threshold() {
574 let empty = Eval::Boolean(BooleanEval {
575 criterion: " ".into(),
576 expected: true,
577 name: None,
578 });
579 assert!(empty.validate().is_err());
580
581 let bad_threshold = Eval::Numeric(NumericEval {
582 criterion: "x".into(),
583 min: 0.0,
584 max: 10.0,
585 threshold: 11.0,
586 comparator: Comparator::Gte,
587 name: None,
588 });
589 assert!(bad_threshold.validate().is_err());
590
591 let ok = Eval::Numeric(NumericEval {
593 criterion: "x".into(),
594 min: 0.0,
595 max: 10.0,
596 threshold: 7.0,
597 comparator: Comparator::Gte,
598 name: None,
599 });
600 ok.validate().unwrap();
601 }
602
603 #[test]
604 fn outcome_rejects_numeric_eval_with_boolean_verdict() {
605 let eval = Eval::Numeric(NumericEval {
606 criterion: "x".into(),
607 min: 0.0,
608 max: 10.0,
609 threshold: 5.0,
610 comparator: Comparator::Gte,
611 name: None,
612 });
613 assert!(eval
614 .outcome(&JudgeValue::Bool(true), String::new())
615 .is_err());
616 }
617
618 #[test]
619 fn eval_detail_summary_for_both_kinds() {
620 let boolean = EvalDetail::Boolean {
621 value: true,
622 expected: false,
623 };
624 assert_eq!(boolean.summary(), "true (expected false)");
625 let numeric = EvalDetail::Numeric {
626 value: 8.0,
627 threshold: 7.0,
628 comparator: Comparator::Gte,
629 };
630 assert_eq!(numeric.summary(), "8 >= 7");
631 }
632
633 #[test]
634 fn judge_value_deserializes_untagged() {
635 let b: JudgeValue = serde_json::from_str("true").unwrap();
636 assert!(matches!(b, JudgeValue::Bool(true)));
637 let n: JudgeValue = serde_json::from_str("3.5").unwrap();
638 assert!(matches!(n, JudgeValue::Number(v) if (v - 3.5).abs() < 1e-9));
639 }
640
641 #[test]
642 fn called_and_not_called_parse_from_yaml() {
643 let called: Eval = serde_yaml::from_str(
644 "type: called\nmock: push\ntimes: 1\nwhere: { command: { contains: \"--force\" } }\n",
645 )
646 .unwrap();
647 called.validate().unwrap();
648 assert!(
649 matches!(&called, Eval::Called(CalledEval { mock, times: Some(1), .. }) if mock == "push")
650 );
651 assert_eq!(called.label(), "called: push");
652 assert!(called.criterion().is_none());
653
654 let not_called: Eval = serde_yaml::from_str("type: not_called\nmock: danger\n").unwrap();
655 not_called.validate().unwrap();
656 assert_eq!(not_called.label(), "not_called: danger");
657
658 let named: Eval =
659 serde_yaml::from_str("type: called\nmock: push\nname: pushed once\n").unwrap();
660 assert_eq!(named.label(), "pushed once");
661 }
662
663 #[test]
664 fn call_eval_validation_is_loud() {
665 let empty_mock: Eval = serde_yaml::from_str("type: called\nmock: \"\"\n").unwrap();
666 assert!(empty_mock.validate().is_err());
667 let zero_times: Eval = serde_yaml::from_str("type: called\nmock: m\ntimes: 0\n").unwrap();
668 let err = zero_times.validate().unwrap_err();
669 assert!(err.to_string().contains("not_called"), "{err}");
670 let bad_where: Eval = serde_yaml::from_str(
671 "type: not_called\nmock: m\nwhere: { command: { pattern: \"(\" } }\n",
672 )
673 .unwrap();
674 assert!(bad_where.validate().is_err());
675 }
676
677 #[test]
678 fn outcome_for_calls_covers_every_expectation() {
679 let at_least_once: Eval = serde_yaml::from_str("type: called\nmock: push\n").unwrap();
680 assert!(at_least_once.outcome_for_calls(2, "").unwrap().passed);
681 let failed = at_least_once.outcome_for_calls(0, "bash(ls)").unwrap();
682 assert!(!failed.passed);
683 assert!(
686 failed.reason.contains("observed: bash(ls)"),
687 "{}",
688 failed.reason
689 );
690 assert!(matches!(
691 failed.detail,
692 EvalDetail::Calls {
693 count: 0,
694 times: None,
695 negated: false
696 }
697 ));
698
699 let exactly: Eval = serde_yaml::from_str("type: called\nmock: push\ntimes: 2\n").unwrap();
700 assert!(exactly.outcome_for_calls(2, "").unwrap().passed);
701 assert!(!exactly.outcome_for_calls(1, "").unwrap().passed);
702
703 let never: Eval = serde_yaml::from_str("type: not_called\nmock: danger\n").unwrap();
704 assert!(never.outcome_for_calls(0, "").unwrap().passed);
705 let violated = never.outcome_for_calls(1, "bash(rm -rf /)").unwrap();
706 assert!(!violated.passed);
707 assert!(matches!(
708 violated.detail,
709 EvalDetail::Calls { negated: true, .. }
710 ));
711
712 let judge: Eval = serde_yaml::from_str("type: boolean\ncriterion: x\n").unwrap();
714 assert!(judge.outcome_for_calls(0, "").is_err());
715 }
716
717 #[test]
718 fn calls_detail_summary_reads_naturally() {
719 let one = EvalDetail::Calls {
720 count: 1,
721 times: Some(1),
722 negated: false,
723 };
724 assert_eq!(one.summary(), "1 call (expected exactly 1)");
725 let none_wanted = EvalDetail::Calls {
726 count: 2,
727 times: None,
728 negated: true,
729 };
730 assert_eq!(none_wanted.summary(), "2 calls (expected none)");
731 let at_least = EvalDetail::Calls {
732 count: 0,
733 times: None,
734 negated: false,
735 };
736 assert_eq!(at_least.summary(), "0 calls (expected at least 1)");
737 }
738}