Skip to main content

skilltest_core/
eval.rs

1//! Evaluations. The judge-backed kinds pose a criterion in plain English and
2//! ask the provider's judge to score the transcript (a boolean assertion, or a
3//! numeric score compared against a threshold). The deterministic kinds
4//! (`called` / `not_called`) assert on the mock/spy channel's observed tool
5//! calls — no judge, no flakiness — referencing a `mocks:` declaration by name.
6
7use 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/// How a numeric score is compared to its threshold.
16#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
17#[serde(rename_all = "lowercase")]
18pub enum Comparator {
19    /// value >= threshold (the default).
20    #[serde(alias = ">=")]
21    #[default]
22    Gte,
23    /// value > threshold.
24    #[serde(alias = ">")]
25    Gt,
26    /// value <= threshold.
27    #[serde(alias = "<=")]
28    Lte,
29    /// value < threshold.
30    #[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
54/// The default boolean expectation (the criterion should hold).
55fn default_true() -> bool {
56    true
57}
58
59/// Assert a plain-English criterion holds (or, with `expected: false`, that
60/// it does not).
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
62#[serde(deny_unknown_fields)]
63pub struct BooleanEval {
64    /// The criterion the judge evaluates against the transcript.
65    pub criterion: String,
66    /// What the judge's verdict must equal to pass. Defaults to `true`.
67    #[serde(default = "default_true")]
68    pub expected: bool,
69    /// Optional human label for reports.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub name: Option<String>,
72}
73
74/// Score a plain-English criterion on a numeric scale and compare it to a
75/// threshold.
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
77#[serde(deny_unknown_fields)]
78pub struct NumericEval {
79    /// The criterion the judge scores.
80    pub criterion: String,
81    /// Inclusive lower bound of the scale.
82    pub min: f64,
83    /// Inclusive upper bound of the scale.
84    pub max: f64,
85    /// The passing threshold.
86    pub threshold: f64,
87    /// How the score is compared to `threshold`. Defaults to `>=`.
88    #[serde(default)]
89    pub comparator: Comparator,
90    /// Optional human label for reports.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub name: Option<String>,
93}
94
95/// Deterministic: assert the referenced mock/spy observed at least one
96/// matching call (or exactly `times`). Evaluated against the mock channel's
97/// records, not by a judge.
98#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
99#[serde(deny_unknown_fields)]
100pub struct CalledEval {
101    /// The `mocks:` declaration (mock or spy) this asserts on, by name.
102    pub mock: String,
103    /// Exact required call count; absent means "at least once".
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub times: Option<u64>,
106    /// Optional per-field input predicates narrowing which calls count.
107    #[serde(default, rename = "where", skip_serializing_if = "BTreeMap::is_empty")]
108    pub r#where: BTreeMap<String, FieldPredicate>,
109    /// Optional human label for reports.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub name: Option<String>,
112}
113
114/// Deterministic: assert the referenced mock/spy observed **no** matching
115/// call.
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
117#[serde(deny_unknown_fields)]
118pub struct NotCalledEval {
119    /// The `mocks:` declaration (mock or spy) this asserts on, by name.
120    pub mock: String,
121    /// Optional per-field input predicates narrowing which calls count.
122    #[serde(default, rename = "where", skip_serializing_if = "BTreeMap::is_empty")]
123    pub r#where: BTreeMap<String, FieldPredicate>,
124    /// Optional human label for reports.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub name: Option<String>,
127}
128
129/// An eval specification, as written in a test case's YAML (or compiled by an
130/// SDK's case builders).
131///
132/// Newtype variants on purpose: serde cannot `deny_unknown_fields` on an
133/// internally tagged enum, but it *does* enforce it on the variant structs, so
134/// a typo'd eval field (`expcted:`) is a loud parse error instead of a
135/// silently-applied default. The variant titles name the generated SDK model
136/// for each union arm, so keep them stable: they are part of the SDK API
137/// surface (the input contract, `schemas/case.schema.json`).
138#[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    /// The criterion text the judge sees; `None` for the deterministic
154    /// (`called`/`not_called`) kinds, which no judge ever scores.
155    #[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    /// A short label for reports: the explicit `name` if given, else the
165    /// criterion (judge kinds) or `called: <mock>` / `not_called: <mock>`.
166    #[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    /// Validate the eval's own parameters (independent of any transcript).
185    ///
186    /// # Errors
187    /// [`Error::Invalid`] when a criterion is empty, a numeric scale is
188    /// degenerate (`min >= max`), the threshold falls outside `[min, max]`, a
189    /// call eval references an empty mock name or asks for `times: 0`, or a
190    /// `where` predicate is malformed.
191    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    /// Apply a deterministic call eval's pass rule to the number of matching
247    /// observed calls, producing an outcome. Only meaningful for
248    /// [`Eval::Called`]/[`Eval::NotCalled`]; the runner never routes judge
249    /// kinds here.
250    ///
251    /// # Errors
252    /// [`Error::Invalid`] if invoked on a judge-backed eval kind (a runner bug,
253    /// surfaced loudly rather than scored vacuously).
254    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    /// Apply this eval's pass rule to a raw judge value, producing an outcome.
300    ///
301    /// `raw` is the value the judge returned: `JudgeValue::Bool` for boolean
302    /// evals, `JudgeValue::Number` for numeric. A mismatch is a provider error.
303    ///
304    /// # Errors
305    /// [`Error::Provider`] if the judge returned the wrong value kind for this
306    /// eval.
307    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            // Deterministic kinds are scored via `outcome_for_calls`, never by
351            // a judge verdict — routing one here is a runner bug.
352            (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/// The raw value a judge returns: either a boolean or a number, matching the
360/// eval kind. Deserialized untagged from the provider's `value` field.
361#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
362#[serde(untagged)]
363pub enum JudgeValue {
364    Bool(bool),
365    Number(f64),
366}
367
368/// The kind-specific detail of an eval outcome, for reporting.
369///
370/// The variant titles name the generated SDK model for each union arm, so keep
371/// them stable: they are part of the SDK API surface.
372#[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    /// The deterministic `called`/`not_called` verdict: how many observed calls
384    /// matched, against what expectation.
385    #[schemars(title = "CallsDetail")]
386    Calls {
387        /// Matching calls observed.
388        count: usize,
389        /// The exact count required (`times`); `null` means "at least one"
390        /// (or, with `negated`, "none").
391        #[serde(default, skip_serializing_if = "Option::is_none")]
392        times: Option<u64>,
393        /// True for `not_called` (the eval required absence).
394        #[serde(default)]
395        negated: bool,
396    },
397}
398
399impl EvalDetail {
400    /// A compact human description of the verdict, e.g. `8.0 >= 7`,
401    /// `true (expected true)`, or `2 calls (expected exactly 1)`.
402    #[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/// The result of running one eval against a transcript.
434#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
435pub struct EvalOutcome {
436    /// The eval's label (name or criterion).
437    pub label: String,
438    /// Whether the eval passed.
439    pub passed: bool,
440    /// Kind-specific verdict detail.
441    pub detail: EvalDetail,
442    /// The judge's stated reason.
443    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        // Judge over-reports 12 -> clamped to 10, still passes.
475        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        // Falls back to the criterion when unnamed.
569        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        // A well-formed numeric eval validates.
592        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        // The failure reason carries the observed calls, so the mismatch is
684        // diagnosable from the assertion output alone.
685        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        // Routing a judge eval here is a loud bug, not a vacuous score.
713        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}