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/// An eval specification, as written in a test case's YAML.
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61#[serde(tag = "type", rename_all = "lowercase")]
62pub enum Eval {
63    /// Assert a plain-English criterion holds (or, with `expected: false`, that
64    /// it does not).
65    Boolean {
66        /// The criterion the judge evaluates against the transcript.
67        criterion: String,
68        /// What the judge's verdict must equal to pass. Defaults to `true`.
69        #[serde(default = "default_true")]
70        expected: bool,
71        /// Optional human label for reports.
72        #[serde(default)]
73        name: Option<String>,
74    },
75    /// Score a plain-English criterion on a numeric scale and compare it to a
76    /// threshold.
77    Numeric {
78        /// The criterion the judge scores.
79        criterion: String,
80        /// Inclusive lower bound of the scale.
81        min: f64,
82        /// Inclusive upper bound of the scale.
83        max: f64,
84        /// The passing threshold.
85        threshold: f64,
86        /// How the score is compared to `threshold`. Defaults to `>=`.
87        #[serde(default)]
88        comparator: Comparator,
89        /// Optional human label for reports.
90        #[serde(default)]
91        name: Option<String>,
92    },
93    /// Deterministic: assert the referenced mock/spy observed at least one
94    /// matching call (or exactly `times`). Evaluated against the mock channel's
95    /// records, not by a judge.
96    Called {
97        /// The `mocks:` declaration (mock or spy) this asserts on, by name.
98        mock: String,
99        /// Exact required call count; absent means "at least once".
100        #[serde(default)]
101        times: Option<u64>,
102        /// Optional per-field input predicates narrowing which calls count.
103        #[serde(default, rename = "where", skip_serializing_if = "BTreeMap::is_empty")]
104        r#where: BTreeMap<String, FieldPredicate>,
105        /// Optional human label for reports.
106        #[serde(default)]
107        name: Option<String>,
108    },
109    /// Deterministic: assert the referenced mock/spy observed **no** matching
110    /// call.
111    #[serde(rename = "not_called")]
112    NotCalled {
113        /// The `mocks:` declaration (mock or spy) this asserts on, by name.
114        mock: String,
115        /// Optional per-field input predicates narrowing which calls count.
116        #[serde(default, rename = "where", skip_serializing_if = "BTreeMap::is_empty")]
117        r#where: BTreeMap<String, FieldPredicate>,
118        /// Optional human label for reports.
119        #[serde(default)]
120        name: Option<String>,
121    },
122}
123
124impl Eval {
125    /// The criterion text the judge sees; `None` for the deterministic
126    /// (`called`/`not_called`) kinds, which no judge ever scores.
127    #[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    /// A short label for reports: the explicit `name` if given, else the
136    /// criterion (judge kinds) or `called: <mock>` / `not_called: <mock>`.
137    #[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    /// Validate the eval's own parameters (independent of any transcript).
156    ///
157    /// # Errors
158    /// [`Error::Invalid`] when a criterion is empty, a numeric scale is
159    /// degenerate (`min >= max`), the threshold falls outside `[min, max]`, a
160    /// call eval references an empty mock name or asks for `times: 0`, or a
161    /// `where` predicate is malformed.
162    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    /// Apply a deterministic call eval's pass rule to the number of matching
218    /// observed calls, producing an outcome. Only meaningful for
219    /// [`Eval::Called`]/[`Eval::NotCalled`]; the runner never routes judge
220    /// kinds here.
221    ///
222    /// # Errors
223    /// [`Error::Invalid`] if invoked on a judge-backed eval kind (a runner bug,
224    /// surfaced loudly rather than scored vacuously).
225    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    /// Apply this eval's pass rule to a raw judge value, producing an outcome.
271    ///
272    /// `raw` is the value the judge returned: `JudgeValue::Bool` for boolean
273    /// evals, `JudgeValue::Number` for numeric. A mismatch is a provider error.
274    ///
275    /// # Errors
276    /// [`Error::Provider`] if the judge returned the wrong value kind for this
277    /// eval.
278    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            // Deterministic kinds are scored via `outcome_for_calls`, never by
320            // a judge verdict — routing one here is a runner bug.
321            (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/// The raw value a judge returns: either a boolean or a number, matching the
329/// eval kind. Deserialized untagged from the provider's `value` field.
330#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
331#[serde(untagged)]
332pub enum JudgeValue {
333    Bool(bool),
334    Number(f64),
335}
336
337/// The kind-specific detail of an eval outcome, for reporting.
338///
339/// The variant titles name the generated SDK model for each union arm, so keep
340/// them stable: they are part of the SDK API surface.
341#[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    /// The deterministic `called`/`not_called` verdict: how many observed calls
353    /// matched, against what expectation.
354    #[schemars(title = "CallsDetail")]
355    Calls {
356        /// Matching calls observed.
357        count: usize,
358        /// The exact count required (`times`); `null` means "at least one"
359        /// (or, with `negated`, "none").
360        #[serde(default, skip_serializing_if = "Option::is_none")]
361        times: Option<u64>,
362        /// True for `not_called` (the eval required absence).
363        #[serde(default)]
364        negated: bool,
365    },
366}
367
368impl EvalDetail {
369    /// A compact human description of the verdict, e.g. `8.0 >= 7`,
370    /// `true (expected true)`, or `2 calls (expected exactly 1)`.
371    #[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/// The result of running one eval against a transcript.
403#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
404pub struct EvalOutcome {
405    /// The eval's label (name or criterion).
406    pub label: String,
407    /// Whether the eval passed.
408    pub passed: bool,
409    /// Kind-specific verdict detail.
410    pub detail: EvalDetail,
411    /// The judge's stated reason.
412    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        // Judge over-reports 12 -> clamped to 10, still passes.
444        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        // Falls back to the criterion when unnamed.
538        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        // A well-formed numeric eval validates.
561        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        // The failure reason carries the observed calls, so the mismatch is
651        // diagnosable from the assertion output alone.
652        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        // Routing a judge eval here is a loud bug, not a vacuous score.
680        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}