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}