assay_core/metrics_api.rs
1use crate::model::{Expected, LlmResponse, TestCase};
2use async_trait::async_trait;
3
4/// Whether a metric actually evaluated anything for this test.
5///
6/// Orthogonal to `passed`, and deliberately not folded into it. Assertion-based verification
7/// settled this decades ago: an assertion with zero attempts is a coverage hole with no
8/// verification value, so every assert is paired with a *companion cover* confirming it was
9/// genuinely exercised rather than vacuously passed. `passed` answers "did the check hold";
10/// this answers "was there a check to hold".
11///
12/// The distinction is load-bearing here because the runner evaluates all thirteen registered
13/// metrics against every test, and a metric that does not handle a test's `Expected` variant used
14/// to return `pass(1.0)` — indistinguishable from a metric that ran and was satisfied.
15///
16/// `NotExercised` is a status and never a failure. Over-eager vacuity detection earns a
17/// suppression and takes real findings with it (Beer et al. on temporal antecedent failure), so
18/// this reports rather than decides.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Exercised {
21 /// The metric does not handle this test's `Expected` variant. Nothing was checked.
22 NotApplicable,
23 /// The metric applies, but the data it needs never appeared in this run, so its antecedent
24 /// never fired. A `trace_must_not_call_tool` naming a tool the agent never had is the shape:
25 /// syntactically perfect, permanently vacuous for this trace.
26 NotExercised,
27 /// Genuinely evaluated against the response.
28 Exercised,
29}
30
31impl Exercised {
32 /// The stable string for this value in `details["metrics"][…]["exercised"]`.
33 ///
34 /// A vocabulary, not a `Debug` rendering: it reaches `run.json`, so it is an interface. It
35 /// lives on the enum rather than beside the writer because there is now a reader —
36 /// [`crate::report::exercised`] — and a reader with its own copy of `"not_exercised"` would
37 /// match nothing the day the spelling moved, reporting a clean run instead of a broken one.
38 pub const fn label(self) -> &'static str {
39 match self {
40 Self::Exercised => "exercised",
41 Self::NotApplicable => "not_applicable",
42 Self::NotExercised => "not_exercised",
43 }
44 }
45}
46
47#[derive(Debug, Clone)]
48pub struct MetricResult {
49 pub score: f64,
50 pub passed: bool,
51 pub unstable: bool,
52 /// Additive on purpose. Existing `passed` readers keep working, and callers that want to know
53 /// whether the number means anything now have somewhere to look.
54 pub exercised: Exercised,
55 pub details: serde_json::Value,
56}
57
58impl MetricResult {
59 pub fn pass(score: f64) -> Self {
60 Self {
61 score,
62 passed: true,
63 unstable: false,
64 exercised: Exercised::Exercised,
65 details: serde_json::json!({}),
66 }
67 }
68 pub fn fail(score: f64, msg: &str) -> Self {
69 Self {
70 score,
71 passed: false,
72 unstable: false,
73 exercised: Exercised::Exercised,
74 details: serde_json::json!({"message": msg}),
75 }
76 }
77 pub fn unstable(score: f64, msg: &str) -> Self {
78 Self {
79 score,
80 passed: false,
81 unstable: true,
82 exercised: Exercised::Exercised,
83 details: serde_json::json!({"message": msg}),
84 }
85 }
86
87 /// This metric does not handle the test's `Expected` variant.
88 ///
89 /// `passed` stays true and the score stays 1.0 so that no existing reader starts failing tests
90 /// over a metric that was never asked to run. What changes is that the result now says so, and
91 /// the runner no longer lets it set the test's score.
92 pub fn not_applicable() -> Self {
93 Self {
94 score: 1.0,
95 passed: true,
96 unstable: false,
97 exercised: Exercised::NotApplicable,
98 details: serde_json::json!({"exercised": "not_applicable"}),
99 }
100 }
101
102 /// This metric applies, but the run produced nothing for it to check.
103 ///
104 /// `reason` names the missing antecedent, because "not exercised" without it is the same
105 /// unactionable silence the dimension exists to remove.
106 pub fn not_exercised(reason: &str) -> Self {
107 Self {
108 score: 1.0,
109 passed: true,
110 unstable: false,
111 exercised: Exercised::NotExercised,
112 details: serde_json::json!({"exercised": "not_exercised", "reason": reason}),
113 }
114 }
115
116 /// Whether this result's `score` and `passed` describe an actual evaluation.
117 pub fn is_exercised(&self) -> bool {
118 matches!(self.exercised, Exercised::Exercised)
119 }
120}
121
122#[async_trait]
123pub trait Metric: Send + Sync {
124 fn name(&self) -> &'static str;
125 async fn evaluate(
126 &self,
127 tc: &TestCase,
128 expected: &Expected,
129 resp: &LlmResponse,
130 ) -> anyhow::Result<MetricResult>;
131}