Skip to main content

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}