assay_core/report/exercised.rs
1//! Companion-cover reporting: the checks a test asked for that evaluated nothing (#1949, layer 2).
2//!
3//! Two config surfaces reach this, and they are found by different means:
4//!
5//! - **`expected:`** — a metric declares its own `Exercised` value and `single.rs` writes it into
6//! `details["metrics"][…]["exercised"]`. Read here.
7//! - **`assertions:`** — an assertion has no such dimension to declare, because
8//! `matchers::check_one` returns `Option<Diagnostic>` where `None` is a pass. A separate cover in
9//! `agent_assertions::cover` judges it, and the runner writes the verdict to
10//! [`ASSERTIONS_NOT_EXERCISED`]. Read here too, and folded into the same output.
11//!
12//! # The condition
13//!
14//! #2068 gave `MetricResult` its third dimension and `single.rs` writes it into
15//! `details["metrics"][…]["exercised"]`. Three values, and only one of them is a finding:
16//!
17//! | value | meaning | reported here |
18//! |---|---|---|
19//! | `exercised` | the metric evaluated the response | no |
20//! | `not_applicable` | the metric declines this test's `Expected` variant | **no** |
21//! | `not_exercised` | the metric accepted this test and evaluated nothing | **yes** |
22//!
23//! The middle row is the whole reason this module is narrow. All thirteen registered metrics run
24//! against every test and twelve of them decline the `Expected` variant, so reporting
25//! `not_applicable` would emit twelve findings per test and be suppressed within a day. Thirteen,
26//! in fact, for a test whose `Expected` is `JudgeCriteria`: no registered metric matches that
27//! variant at all, so every metric declines it. Assertion-based verification has the same warning
28//! from the other side: Beer et al. on temporal antecedent failure, and every treatment since,
29//! records that over-eager vacuity detection earns a suppression and takes the real findings with
30//! it.
31//!
32//! 2026 hardware-verification work on agentic coverage closure ([arXiv:2604.15657]) splits un-hit
33//! coverage along the same seam, and names both halves: a *methodology-bound ceiling* (tied-off
34//! hardware, infeasible boundaries, dead code) against a *reasoning frontier* (protocol sequencing,
35//! warm-up, narrow timing conditions). `not_applicable` is the first shape and `not_exercised` the
36//! second. The disposition below — report one and not the other — is this crate's reading, not the
37//! paper's: its taxonomy is about what an agent can reach, not about what a tool should print.
38//!
39//! Structurally bounded, not merely expected to be quiet: every `not_exercised` site in
40//! `assay-metrics` sits *after* the `Expected`-variant match, and one test has one `Expected`. So a
41//! test contributes at most one finding, and the findings are then folded by metric and reason
42//! rather than listed per test.
43//!
44//! # Why this is not in `codes::`
45//!
46//! `assay_core::errors::diagnostic::codes` is inventoried by the field its members reach: SARIF
47//! `ruleId` under `tool.driver.name = "assay"`. The route to that field is `build_sarif_diagnostics`
48//! (`report/sarif.rs`), and it has exactly one non-test caller, `assay validate --format sarif`.
49//!
50//! The `run` path does build `Diagnostic`s — the trace client, the agent-assertion matchers, and
51//! the pipeline's error classifier all do — so "the run path has no diagnostics" would be false and
52//! is not the reason. The reason is narrower and is the one the inventory keys on: none of those
53//! reaches `build_sarif_diagnostics`, so a code added to `codes::` for this would be recorded on a
54//! surface it never appears on.
55//!
56//! This writes to the `warnings` array of `run.json` / `summary.json` and to the console summary.
57//! That is recorded in the inventory as its own surface. If a run-path diagnostic ever acquires a
58//! route to `build_sarif_diagnostics`, this constant belongs in `codes::` and the inventory entry
59//! moves with it.
60//!
61//! # Not a fail
62//!
63//! Nothing here reads or sets `TestStatus`, and the `warnings` array has never contributed to an
64//! exit code. A not-exercised metric leaves a green suite green — which is the point: it is a
65//! coverage observation, and a coverage observation that fails a build is a coverage observation
66//! people delete.
67//!
68//! [arXiv:2604.15657]: https://arxiv.org/abs/2604.15657
69
70use crate::metrics_api::Exercised;
71use crate::model::TestResultRow;
72use std::collections::BTreeMap;
73
74/// The identifier carried by every warning this module produces.
75///
76/// Named in #1949's layer-2 groundwork. It is a `W_` code by the same convention as
77/// `codes::W_CFG_VACUOUS_EXPECTED` — an observation that never decides an exit — but it lives here
78/// rather than in that registry, for the reason in the module docs.
79pub const W_METRIC_NOT_EXERCISED: &str = "W_METRIC_NOT_EXERCISED";
80
81/// The same observation for the `assertions:` surface.
82///
83/// A separate code rather than a broader spelling of the first. The two are found differently — a
84/// metric declares its own `exercised` value, an assertion is judged by a companion cover in
85/// `agent_assertions::cover` — and they name different things in a config. A reader filtering their
86/// CI log for one should not silently get the other.
87pub const W_ASSERTION_NOT_EXERCISED: &str = "W_ASSERTION_NOT_EXERCISED";
88
89/// The `details` key the runner writes assertion covers to, and this module reads back.
90///
91/// Beside `details["assertions"]` rather than inside it, because that field already holds two
92/// different shapes — an array of diagnostics when something failed, `{"passed": true}` when
93/// nothing did — and a reader that had to branch on which one it got would break the first time a
94/// third shape appeared.
95///
96/// Declared here, in the reader, and imported by the writer. The other way round is not reachable:
97/// `engine::runner_next` is private to its parent, and a second copy of the string in this module
98/// would be a reader that silently stops finding anything the day the writer's spelling changes.
99pub const ASSERTIONS_NOT_EXERCISED: &str = "assertions_not_exercised";
100
101/// How many test ids a single warning names before it stops and counts the rest.
102const MAX_NAMED_TESTS: usize = 3;
103
104/// Which config surface a finding came from.
105///
106/// Carried rather than inferred from the name: `sequence_valid` is both a metric under `expected:`
107/// and an assertion under `assertions:`, so the string alone cannot say which was meant.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
109pub enum Surface {
110 Metric,
111 Assertion,
112}
113
114impl Surface {
115 fn code(self) -> &'static str {
116 match self {
117 Self::Metric => W_METRIC_NOT_EXERCISED,
118 Self::Assertion => W_ASSERTION_NOT_EXERCISED,
119 }
120 }
121}
122
123/// One check that evaluated nothing, and the tests that asked for it.
124///
125/// Folded by `(surface, check, reason)` rather than emitted per test: a coverage hole is a property
126/// of the check, and a suite where sixty tests all fail to exercise `sequence_valid` has one hole,
127/// not sixty.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct NotExercised {
130 pub surface: Surface,
131 /// The metric's name, or the assertion's `type:` tag.
132 pub check: String,
133 pub reason: String,
134 /// Sorted, so the same run reports the same order regardless of how the tests were scheduled.
135 pub test_ids: Vec<String>,
136}
137
138impl NotExercised {
139 /// The warning line for the `warnings` array and the console.
140 pub fn render(&self) -> String {
141 let named = self
142 .test_ids
143 .iter()
144 .take(MAX_NAMED_TESTS)
145 .cloned()
146 .collect::<Vec<_>>()
147 .join(", ");
148 let rest = self.test_ids.len().saturating_sub(MAX_NAMED_TESTS);
149 let tail = if rest > 0 {
150 format!("{named} and {rest} more")
151 } else {
152 named
153 };
154 format!(
155 "{}: {} was requested by {} test(s) and evaluated nothing ({}) — {}",
156 self.surface.code(),
157 self.check,
158 self.test_ids.len(),
159 self.reason,
160 tail
161 )
162 }
163}
164
165/// The reason a metric recorded for evaluating nothing, or a stand-in.
166///
167/// `MetricResult::not_exercised` always carries one, so the fallback is for a details object that
168/// has been reshaped since — a missing reason must not silently drop the finding, because the
169/// finding is that the check did not run and that is true either way.
170const UNRECORDED_REASON: &str = "no reason recorded";
171
172/// Collect the not-exercised findings from a finished run, across both config surfaces.
173///
174/// Reads the fields the runner writes — `details["metrics"][…]["exercised"]` from `single.rs` and
175/// `details["assertions_not_exercised"]` from `runner_next::assertions` — rather than taking a
176/// second path from `MetricResult` or re-running the cover. One producer, one consumer, one
177/// spelling: the metric comparison uses [`Exercised::label`], the same function that wrote the
178/// value, so the two cannot drift into disagreeing about what `not_exercised` is called.
179pub fn collect(results: &[TestResultRow]) -> Vec<NotExercised> {
180 let mut folded: BTreeMap<(Surface, String, String), Vec<String>> = BTreeMap::new();
181
182 for row in results {
183 if let Some(metrics) = row.details.get("metrics").and_then(|m| m.as_object()) {
184 for (metric_name, metric) in metrics {
185 let label = metric.get("exercised").and_then(|e| e.as_str());
186 if label != Some(Exercised::NotExercised.label()) {
187 continue;
188 }
189 let reason = metric
190 .get("details")
191 .and_then(|d| d.get("reason"))
192 .and_then(|r| r.as_str())
193 .unwrap_or(UNRECORDED_REASON);
194 folded
195 .entry((Surface::Metric, metric_name.clone(), reason.to_string()))
196 .or_default()
197 .push(row.test_id.clone());
198 }
199 }
200
201 let covers = row
202 .details
203 .get(ASSERTIONS_NOT_EXERCISED)
204 .and_then(|c| c.as_array());
205 for cover in covers.into_iter().flatten() {
206 let Some(assertion) = cover.get("assertion").and_then(|a| a.as_str()) else {
207 continue;
208 };
209 let reason = cover
210 .get("reason")
211 .and_then(|r| r.as_str())
212 .unwrap_or(UNRECORDED_REASON);
213 folded
214 .entry((
215 Surface::Assertion,
216 assertion.to_string(),
217 reason.to_string(),
218 ))
219 .or_default()
220 .push(row.test_id.clone());
221 }
222 }
223
224 folded
225 .into_iter()
226 .map(|((surface, check, reason), mut test_ids)| {
227 test_ids.sort();
228 NotExercised {
229 surface,
230 check,
231 reason,
232 test_ids,
233 }
234 })
235 .collect()
236}
237
238/// The findings as warning lines, ready for `RunOutcome::warnings`.
239pub fn warnings(results: &[TestResultRow]) -> Vec<String> {
240 collect(results).iter().map(NotExercised::render).collect()
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246 use crate::model::TestStatus;
247
248 /// A row shaped the way `single.rs` writes one: `metrics` keyed by name, each with `exercised`
249 /// and a nested `details`.
250 fn row(test_id: &str, metrics: serde_json::Value) -> TestResultRow {
251 TestResultRow {
252 test_id: test_id.to_string(),
253 status: TestStatus::Pass,
254 score: Some(1.0),
255 cached: false,
256 message: "ok".into(),
257 details: serde_json::json!({ "metrics": metrics }),
258 duration_ms: Some(1),
259 fingerprint: None,
260 skip_reason: None,
261 attempts: None,
262 error_policy_applied: None,
263 }
264 }
265
266 fn metric(exercised: Exercised, reason: Option<&str>) -> serde_json::Value {
267 let details = match reason {
268 Some(r) => serde_json::json!({ "reason": r }),
269 None => serde_json::json!({}),
270 };
271 serde_json::json!({
272 "score": 1.0,
273 "passed": true,
274 "unstable": false,
275 "exercised": exercised.label(),
276 "details": details
277 })
278 }
279
280 /// The case the slice exists for: a metric the test asked for, which evaluated nothing.
281 #[test]
282 fn a_requested_metric_that_evaluated_nothing_is_reported() {
283 let rows = vec![row(
284 "t1",
285 serde_json::json!({
286 "sequence_valid": metric(Exercised::NotExercised, Some("no tool calls in the trace"))
287 }),
288 )];
289 let found = collect(&rows);
290 assert_eq!(found.len(), 1);
291 assert_eq!(found[0].surface, Surface::Metric);
292 assert_eq!(found[0].check, "sequence_valid");
293 assert_eq!(found[0].reason, "no tool calls in the trace");
294 assert_eq!(found[0].test_ids, vec!["t1"]);
295 }
296
297 /// The load-bearing exclusion. Twelve of thirteen metrics decline every test's `Expected`
298 /// variant, so reporting `not_applicable` would put twelve findings on every passing test and
299 /// earn the suppression that the vacuity literature warns about.
300 #[test]
301 fn a_not_applicable_metric_is_not_a_finding() {
302 let rows = vec![row(
303 "t1",
304 serde_json::json!({
305 "must_contain": metric(Exercised::NotApplicable, None),
306 "regex_match": metric(Exercised::NotApplicable, None),
307 "semantic": metric(Exercised::Exercised, None)
308 }),
309 )];
310 assert!(collect(&rows).is_empty());
311 }
312
313 /// A hole is a property of the check, so sixty tests that all miss one metric are one finding.
314 #[test]
315 fn the_same_metric_across_tests_folds_into_one_finding() {
316 let m = || {
317 serde_json::json!({
318 "tool_output_valid": metric(Exercised::NotExercised, Some("no output schemas configured"))
319 })
320 };
321 let rows = vec![row("t2", m()), row("t1", m()), row("t3", m())];
322 let found = collect(&rows);
323 assert_eq!(found.len(), 1);
324 assert_eq!(found[0].test_ids, vec!["t1", "t2", "t3"], "sorted");
325 }
326
327 /// Two reasons are two holes even under one metric: "no schemas configured" and "the trace had
328 /// no tool calls" are different things to go and fix.
329 #[test]
330 fn one_metric_with_two_reasons_is_two_findings() {
331 let rows = vec![
332 row(
333 "t1",
334 serde_json::json!({ "seq": metric(Exercised::NotExercised, Some("no tool calls")) }),
335 ),
336 row(
337 "t2",
338 serde_json::json!({ "seq": metric(Exercised::NotExercised, Some("no policy")) }),
339 ),
340 ];
341 assert_eq!(collect(&rows).len(), 2);
342 }
343
344 /// A details object with no `reason` still produces the finding. The finding is that the check
345 /// did not run; the reason is context, and losing context must not lose the finding.
346 #[test]
347 fn a_missing_reason_does_not_drop_the_finding() {
348 let rows = vec![row(
349 "t1",
350 serde_json::json!({ "seq": metric(Exercised::NotExercised, None) }),
351 )];
352 let found = collect(&rows);
353 assert_eq!(found.len(), 1);
354 assert_eq!(found[0].reason, UNRECORDED_REASON);
355 }
356
357 /// A row with no `metrics` object — an error or skip row — is skipped rather than panicking.
358 #[test]
359 fn a_row_without_metrics_is_skipped() {
360 let mut r = row("t1", serde_json::json!({}));
361 r.details = serde_json::json!({ "prompt": "hello" });
362 assert!(collect(&[r]).is_empty());
363 }
364
365 /// The rendered line names the code, the metric, the count and the reason.
366 #[test]
367 fn the_rendered_warning_names_the_code_metric_count_and_reason() {
368 let f = NotExercised {
369 surface: Surface::Metric,
370 check: "sequence_valid".into(),
371 reason: "no tool calls in the trace".into(),
372 test_ids: vec!["t1".into(), "t2".into()],
373 };
374 let line = f.render();
375 assert!(line.starts_with("W_METRIC_NOT_EXERCISED: "), "{line}");
376 assert!(line.contains("sequence_valid"), "{line}");
377 assert!(line.contains("2 test(s)"), "{line}");
378 assert!(line.contains("no tool calls in the trace"), "{line}");
379 assert!(line.contains("t1, t2"), "{line}");
380 }
381
382 /// A wide suite names a few tests and counts the rest, so one hole is one line however many
383 /// tests hit it.
384 #[test]
385 fn a_long_test_list_is_bounded_and_counts_the_remainder() {
386 let f = NotExercised {
387 surface: Surface::Metric,
388 check: "seq".into(),
389 reason: "no tool calls".into(),
390 test_ids: (1..=10).map(|i| format!("t{i:02}")).collect(),
391 };
392 let line = f.render();
393 assert!(line.contains("t01, t02, t03 and 7 more"), "{line}");
394 assert_eq!(line.lines().count(), 1, "one hole is one line");
395 }
396
397 /// An assertion cover reaches the same output as a metric, under its own code.
398 #[test]
399 fn an_assertion_cover_is_collected_under_the_assertion_code() {
400 let mut r = row("t1", serde_json::json!({}));
401 r.details[ASSERTIONS_NOT_EXERCISED] = serde_json::json!([{
402 "assertion": "trace_must_not_call_tool",
403 "reason": "the agent was never offered `delete_repository`, so no trace could have called it"
404 }]);
405 let found = collect(&[r]);
406 assert_eq!(found.len(), 1);
407 assert_eq!(found[0].surface, Surface::Assertion);
408 assert_eq!(found[0].check, "trace_must_not_call_tool");
409 assert!(found[0].render().starts_with("W_ASSERTION_NOT_EXERCISED: "));
410 }
411
412 /// The two surfaces stay apart even when they share a name.
413 ///
414 /// `sequence_valid` is both a metric under `expected:` and an assertion type under
415 /// `assertions:`. Folding on the name alone would merge two different holes into one line and
416 /// report a test id under a check it never ran.
417 #[test]
418 fn a_name_shared_by_both_surfaces_does_not_fold_together() {
419 let mut r = row(
420 "t1",
421 serde_json::json!({
422 "sequence_valid": metric(Exercised::NotExercised, Some("no sequence configured"))
423 }),
424 );
425 r.details[ASSERTIONS_NOT_EXERCISED] = serde_json::json!([{
426 "assertion": "sequence_valid",
427 "reason": "no sequence configured"
428 }]);
429 let found = collect(&[r]);
430 assert_eq!(found.len(), 2, "{found:?}");
431 assert_eq!(found[0].surface, Surface::Metric);
432 assert_eq!(found[1].surface, Surface::Assertion);
433 assert_ne!(found[0].render(), found[1].render());
434 }
435
436 /// A cover with no `assertion` name is skipped rather than reported as an empty check.
437 #[test]
438 fn a_nameless_cover_is_skipped() {
439 let mut r = row("t1", serde_json::json!({}));
440 r.details[ASSERTIONS_NOT_EXERCISED] = serde_json::json!([{ "reason": "something" }]);
441 assert!(collect(&[r]).is_empty());
442 }
443
444 /// The key is absent on almost every row, and that is not a finding.
445 #[test]
446 fn a_row_without_assertion_covers_reports_nothing() {
447 let r = row("t1", serde_json::json!({}));
448 assert!(collect(&[r]).is_empty());
449 }
450
451 /// The reader compares against the writer's own vocabulary rather than a second copy of the
452 /// string. If `Exercised::label` is ever respelled, this module follows it instead of silently
453 /// matching nothing and reporting a clean run.
454 #[test]
455 fn the_label_compared_against_is_the_one_the_runner_writes() {
456 assert_eq!(Exercised::NotExercised.label(), "not_exercised");
457 let rows = vec![row(
458 "t1",
459 serde_json::json!({ "seq": {
460 "exercised": Exercised::NotExercised.label(),
461 "details": { "reason": "no tool calls" }
462 }}),
463 )];
464 assert_eq!(collect(&rows).len(), 1);
465 }
466}