Skip to main content

assay_core/agent_assertions/
cover.rs

1//! The companion cover for trace assertions (#1949, layer 2, the assertion half).
2//!
3//! # The case this exists for
4//!
5//! `matchers::check_one` returns `Option<Diagnostic>`: `Some` is a failure, `None` is a pass. A
6//! pass therefore says nothing about whether anything was checked, and for one variant that gap is
7//! the whole issue:
8//!
9//! ```yaml
10//! assertions:
11//!   - type: trace_must_not_call_tool
12//!     tool: delete_repository
13//! ```
14//!
15//! If the agent was never offered `delete_repository`, this holds on every trace forever. It is
16//! syntactically perfect, it is *config*-effective — layer 3's `ineffective_reason` passes it
17//! correctly, because a trace that called the tool would fail it — and it has never once
18//! constrained the agent. #1949's groundwork named it the headline case, and it is the one thing
19//! neither the static sweep nor the metric-side cover (#2083) could reach: assertions produce
20//! `Diagnostic`s, not `MetricResult`s, so they have no `exercised` dimension to carry.
21//!
22//! # The antecedent is availability, not absence
23//!
24//! The naive signal — "the tool was never called" — is the assertion's own passing condition, so
25//! reporting it would fire on every `trace_must_not_call_tool` that holds. That is the over-eager
26//! detection Beer et al. warn about, and it would be suppressed immediately and deservedly.
27//!
28//! The signal is whether the tool was ever **available** to the agent:
29//!
30//! | the agent | verdict |
31//! |---|---|
32//! | had the tool and did not call it | exercised. A real pass, and the assertion earned it. |
33//! | never had the tool | not exercised. No trace could have failed this. |
34//! | availability unrecorded | **nothing is reported.** |
35//!
36//! The third row is what makes this safe to turn on. Availability comes from
37//! `meta["tool_definitions"]`, which many traces do not carry; treating "no record" as "no tool"
38//! would put a finding on every `trace_must_not_call_tool` in every suite that replays a plain
39//! trace. Absence of evidence is not evidence of absence, and the asymmetry is the same one
40//! `coverage_regressed` uses for a baseline that predates its dimension (#2082).
41//!
42//! Tools that were *called* count as available even when no definition list was recorded: a call
43//! is proof of availability, and it is proof that does not depend on the producer having written
44//! the metadata.
45//!
46//! # What this deliberately does not claim
47//!
48//! Availability is a weaker fact than opportunity. An agent may hold a tool that no reachable state
49//! would ever prompt it to use, and this reports that as exercised. Trajectory evaluation has the
50//! general form of that limit: a single rollout "rules out only one realized path"
51//! ([DiagEval, arXiv:2605.17439]), so no per-run signal settles what the agent *could* have done.
52//! What this catches is the case that needs no counterfactual at all — the tool was not on the
53//! table.
54//!
55//! [DiagEval, arXiv:2605.17439]: https://arxiv.org/abs/2605.17439
56
57use super::model::TraceAssertion;
58use super::EpisodeGraph;
59
60/// The tools an agent could have called during an episode.
61///
62/// `declared` is `None` when nothing recorded a tool list. That is *unknown*, not *empty*, and
63/// every method here keeps the two apart — an empty declared list means "the agent was offered no
64/// tools", which is a real and reportable fact.
65#[derive(Debug, Clone, Default, PartialEq, Eq)]
66pub struct ToolAvailability {
67    declared: Option<Vec<String>>,
68    called: Vec<String>,
69}
70
71impl ToolAvailability {
72    /// Read what an episode and its response say about which tools existed.
73    ///
74    /// `meta["tool_definitions"]` is the same field `tool_collision_detect` and
75    /// `tool_description_integrity` read, spelled once here so the three cannot disagree about
76    /// where a tool list lives.
77    pub fn observe(meta: &serde_json::Value, graph: &EpisodeGraph) -> Self {
78        let declared = meta
79            .get("tool_definitions")
80            .and_then(|v| v.as_array())
81            .map(|defs| {
82                defs.iter()
83                    .filter_map(|d| d.get("name").and_then(|n| n.as_str()))
84                    .map(str::to_owned)
85                    .collect()
86            });
87        let called = graph
88            .tool_calls
89            .iter()
90            .filter_map(|t| t.tool_name.clone())
91            .collect();
92        Self { declared, called }
93    }
94
95    /// Whether `tool` was available: `None` when nothing recorded enough to say.
96    ///
97    /// A tri-state on purpose. Collapsing the unknown case into `false` is precisely the mistake
98    /// that would make this check noise.
99    fn was_available(&self, tool: &str) -> Option<bool> {
100        if self.called.iter().any(|c| c == tool) {
101            // It was called, so it existed. True regardless of what was declared, and true even
102            // when nothing was declared.
103            return Some(true);
104        }
105        let declared = self.declared.as_ref()?;
106        Some(declared.iter().any(|d| d == tool))
107    }
108}
109
110/// One assertion that could not have failed for this run, and why.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct AssertionCover {
113    /// The assertion's `type:` tag, as written in the config.
114    pub assertion: String,
115    pub reason: String,
116}
117
118/// The `type:` tag for an assertion, matching the serde rename in [`TraceAssertion`].
119fn tag(a: &TraceAssertion) -> &'static str {
120    match a {
121        TraceAssertion::TraceMustCallTool { .. } => "trace_must_call_tool",
122        TraceAssertion::TraceMustNotCallTool { .. } => "trace_must_not_call_tool",
123        TraceAssertion::TraceToolSequence { .. } => "trace_tool_sequence",
124        TraceAssertion::TraceMaxSteps { .. } => "trace_max_steps",
125        TraceAssertion::ArgsValid { .. } => "args_valid",
126        TraceAssertion::SequenceValid { .. } => "sequence_valid",
127        TraceAssertion::ToolBlocklist { .. } => "tool_blocklist",
128    }
129}
130
131/// Why this assertion could not have failed for this run, or `None` if it was genuinely exercised.
132///
133/// Separate from `check_one` rather than folded into it, and that is not an accident of
134/// convenience. "Did this fail" and "was this exercised" are orthogonal questions — the reason
135/// `Exercised` is a dimension on `MetricResult` rather than a fourth status. Folding the cover into
136/// `check_one` would also disturb `ineffective_reason`, which runs `check_one` against an empty
137/// episode and keeps the config-decided codes; a cover that fired on that empty episode would be
138/// indistinguishable from a static verdict and the sweep would start rejecting working configs.
139///
140/// Only two variants can be vacuous at runtime, and the rest are listed here rather than caught by
141/// a `_` arm so that a new variant has to be considered:
142///
143/// - `trace_must_call_tool` compares a count against a minimum on every trace, and a trace with no
144///   calls **fails** it. Its antecedent always fires.
145/// - `trace_tool_sequence` likewise: an empty actual sequence fails both the subsequence and the
146///   exact form.
147/// - `args_valid`, `sequence_valid` and `tool_blocklist` evaluate `test_*` fixtures and never read
148///   the graph, so a run cannot leave them unexercised. Without those fixtures they check nothing
149///   at all, which layer 3 already refuses at config time.
150pub fn not_exercised(
151    graph: &EpisodeGraph,
152    tools: &ToolAvailability,
153    a: &TraceAssertion,
154) -> Option<AssertionCover> {
155    let reason = match a {
156        TraceAssertion::TraceMustNotCallTool { tool } => {
157            // `Some(false)` only. `None` is "nothing recorded a tool list", which is not evidence
158            // that the tool was missing.
159            if tools.was_available(tool) == Some(false) {
160                Some(format!(
161                    "the agent was never offered `{tool}`, so no trace could have called it"
162                ))
163            } else {
164                None
165            }
166        }
167        TraceAssertion::TraceMaxSteps { .. } => {
168            // A step ceiling against an episode with no steps compared a budget to nothing. The
169            // assertion is fine; the run had nothing to hold it against.
170            if graph.steps.is_empty() {
171                Some("the episode recorded no steps, so the ceiling was never approached".into())
172            } else {
173                None
174            }
175        }
176        TraceAssertion::TraceMustCallTool { .. }
177        | TraceAssertion::TraceToolSequence { .. }
178        | TraceAssertion::ArgsValid { .. }
179        | TraceAssertion::SequenceValid { .. }
180        | TraceAssertion::ToolBlocklist { .. } => None,
181    }?;
182
183    Some(AssertionCover {
184        assertion: tag(a).to_string(),
185        reason,
186    })
187}
188
189/// The covers for a whole assertion list.
190pub fn evaluate_cover(
191    graph: &EpisodeGraph,
192    tools: &ToolAvailability,
193    assertions: &[TraceAssertion],
194) -> Vec<AssertionCover> {
195    assertions
196        .iter()
197        .filter_map(|a| not_exercised(graph, tools, a))
198        .collect()
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::storage::rows::{StepRow, ToolCallRow};
205
206    fn call(tool: &str) -> ToolCallRow {
207        ToolCallRow {
208            id: 1,
209            step_id: "s1".into(),
210            episode_id: "e1".into(),
211            tool_name: Some(tool.into()),
212            call_index: Some(0),
213            args: None,
214            result: None,
215        }
216    }
217
218    fn step() -> StepRow {
219        StepRow {
220            id: "s1".into(),
221            episode_id: "e1".into(),
222            idx: 0,
223            kind: Some("assistant".into()),
224            name: None,
225            content: None,
226        }
227    }
228
229    fn graph(steps: Vec<StepRow>, calls: Vec<ToolCallRow>) -> EpisodeGraph {
230        EpisodeGraph {
231            episode_id: "e1".into(),
232            steps,
233            tool_calls: calls,
234        }
235    }
236
237    fn defs(names: &[&str]) -> serde_json::Value {
238        serde_json::json!({
239            "tool_definitions": names.iter().map(|n| serde_json::json!({"name": n})).collect::<Vec<_>>()
240        })
241    }
242
243    fn must_not_call(tool: &str) -> TraceAssertion {
244        TraceAssertion::TraceMustNotCallTool { tool: tool.into() }
245    }
246
247    /// The headline case: the agent was never offered the tool, so the guard never guarded.
248    #[test]
249    fn a_guard_against_a_tool_the_agent_never_had_is_not_exercised() {
250        let g = graph(vec![step()], vec![call("read_file")]);
251        let tools = ToolAvailability::observe(&defs(&["read_file", "list_dir"]), &g);
252        let cover = not_exercised(&g, &tools, &must_not_call("delete_repository")).unwrap();
253        assert_eq!(cover.assertion, "trace_must_not_call_tool");
254        assert!(cover.reason.contains("never offered"), "{}", cover.reason);
255    }
256
257    /// The pass that was earned: the tool was on the table and the agent left it alone. Reporting
258    /// this would fire on every working guard, which is the whole failure mode to avoid.
259    #[test]
260    fn a_guard_against_an_available_tool_the_agent_declined_is_exercised() {
261        let g = graph(vec![step()], vec![call("read_file")]);
262        let tools = ToolAvailability::observe(&defs(&["read_file", "delete_repository"]), &g);
263        assert_eq!(
264            not_exercised(&g, &tools, &must_not_call("delete_repository")),
265            None
266        );
267    }
268
269    /// No tool list recorded is *unknown*, never *absent*. Without this, every suite replaying a
270    /// plain trace would get a finding on every guard it has.
271    #[test]
272    fn an_unrecorded_tool_list_reports_nothing() {
273        let g = graph(vec![step()], vec![call("read_file")]);
274        let tools = ToolAvailability::observe(&serde_json::json!({}), &g);
275        assert_eq!(
276            not_exercised(&g, &tools, &must_not_call("delete_repository")),
277            None
278        );
279        assert_eq!(
280            tools.was_available("delete_repository"),
281            None,
282            "unknown, not false"
283        );
284    }
285
286    /// A tool that was called is available even when nothing declared it: the call is the proof,
287    /// and it does not depend on the producer writing the metadata.
288    #[test]
289    fn a_called_tool_counts_as_available_without_a_declaration() {
290        let g = graph(vec![step()], vec![call("delete_repository")]);
291        let tools = ToolAvailability::observe(&serde_json::json!({}), &g);
292        assert_eq!(tools.was_available("delete_repository"), Some(true));
293        assert_eq!(
294            not_exercised(&g, &tools, &must_not_call("delete_repository")),
295            None
296        );
297    }
298
299    /// A recorded but empty tool list is a real fact — the agent was offered nothing — and is not
300    /// the unknown case.
301    #[test]
302    fn an_empty_declared_list_is_evidence_and_reports() {
303        let g = graph(vec![step()], vec![]);
304        let tools = ToolAvailability::observe(&defs(&[]), &g);
305        assert_eq!(tools.was_available("anything"), Some(false));
306        assert!(not_exercised(&g, &tools, &must_not_call("anything")).is_some());
307    }
308
309    #[test]
310    fn a_step_ceiling_against_an_empty_episode_is_not_exercised() {
311        let g = graph(vec![], vec![]);
312        let tools = ToolAvailability::observe(&serde_json::json!({}), &g);
313        let cover = not_exercised(&g, &tools, &TraceAssertion::TraceMaxSteps { max: 10 }).unwrap();
314        assert!(cover.reason.contains("no steps"), "{}", cover.reason);
315
316        let with_steps = graph(vec![step()], vec![]);
317        assert_eq!(
318            not_exercised(
319                &with_steps,
320                &tools,
321                &TraceAssertion::TraceMaxSteps { max: 10 }
322            ),
323            None
324        );
325    }
326
327    /// `trace_must_call_tool` FAILS on an empty trace, so it is never unexercised. Pinned because
328    /// the intuition "no tool calls means nothing was checked" is wrong here, and acting on it
329    /// would report a finding alongside a failure that already says more.
330    #[test]
331    fn a_must_call_assertion_is_never_reported_as_unexercised() {
332        let g = graph(vec![], vec![]);
333        let tools = ToolAvailability::observe(&serde_json::json!({}), &g);
334        assert_eq!(
335            not_exercised(
336                &g,
337                &tools,
338                &TraceAssertion::TraceMustCallTool {
339                    tool: "read_file".into(),
340                    min_calls: Some(1)
341                }
342            ),
343            None
344        );
345    }
346
347    /// The fixture-driven variants never read the graph, so a run cannot leave them unexercised.
348    #[test]
349    fn fixture_driven_variants_are_never_reported() {
350        let g = graph(vec![], vec![]);
351        let tools = ToolAvailability::observe(&serde_json::json!({}), &g);
352        let fixtures = [
353            TraceAssertion::ArgsValid {
354                tool: "t".into(),
355                test_args: Some(serde_json::json!({})),
356                policy: Some(serde_json::json!({})),
357                expect: None,
358            },
359            TraceAssertion::SequenceValid {
360                test_trace: None,
361                test_trace_raw: Some(vec![]),
362                policy: Some(serde_json::json!({})),
363                expect: None,
364            },
365            TraceAssertion::ToolBlocklist {
366                test_tool_calls: Some(vec![]),
367                policy: Some(serde_json::json!({})),
368                expect: None,
369            },
370        ];
371        for a in &fixtures {
372            assert_eq!(not_exercised(&g, &tools, a), None, "{}", tag(a));
373        }
374    }
375
376    /// The tags match the serde renames, so a config author reads back the word they wrote.
377    #[test]
378    fn the_tags_match_the_config_vocabulary() {
379        let yaml = "type: trace_must_not_call_tool\ntool: x\n";
380        let a: TraceAssertion = serde_yaml::from_str(yaml).unwrap();
381        assert_eq!(tag(&a), "trace_must_not_call_tool");
382    }
383
384    #[test]
385    fn evaluate_cover_collects_each_unexercised_assertion() {
386        let g = graph(vec![], vec![]);
387        let tools = ToolAvailability::observe(&defs(&["read_file"]), &g);
388        let covers = evaluate_cover(
389            &g,
390            &tools,
391            &[
392                must_not_call("delete_repository"),
393                TraceAssertion::TraceMaxSteps { max: 5 },
394                TraceAssertion::TraceMustCallTool {
395                    tool: "read_file".into(),
396                    min_calls: Some(1),
397                },
398            ],
399        );
400        assert_eq!(covers.len(), 2);
401        assert_eq!(covers[0].assertion, "trace_must_not_call_tool");
402        assert_eq!(covers[1].assertion, "trace_max_steps");
403    }
404}