Skip to main content

assay_core/validate/
mod.rs

1use crate::config::path_resolver::PathResolver;
2use crate::errors::diagnostic::{codes, Diagnostic};
3use crate::model::EvalConfig;
4use crate::model::Expected;
5use crate::providers::llm::LlmClient; // Import trait for .complete()
6use crate::providers::trace::TraceClient;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone)]
10pub struct ValidateOptions {
11    pub trace_file: Option<PathBuf>,
12    pub baseline_file: Option<PathBuf>,
13    pub replay_strict: bool,
14}
15
16#[derive(Debug, Clone, Default)]
17pub struct ValidateReport {
18    pub diagnostics: Vec<Diagnostic>,
19}
20
21pub async fn validate(
22    cfg: &EvalConfig,
23    opts: &ValidateOptions,
24    resolver: &PathResolver,
25) -> anyhow::Result<ValidateReport> {
26    let mut diags = Vec::new();
27
28    // 1. Path Resolution Checks (E_PATH_NOT_FOUND)
29    // Actually the CLI loader does this, but we can double check config assets if any.
30    // For now, let's assume config is loaded correctly if we are here,
31    // but check the explicitly provided trace/baseline files if they exist.
32
33    if let Some(path) = &opts.trace_file {
34        if !path.exists() {
35            diags.push(
36                Diagnostic::new(
37                    codes::E_PATH_NOT_FOUND,
38                    format!("Trace file not found: {}", path.display()),
39                )
40                .with_context(serde_json::json!({ "path": path }))
41                .with_source("validate")
42                .with_fix_step("Ensure the --trace-file path is correct and accessible"),
43            );
44        }
45    }
46
47    if let Some(path) = &opts.baseline_file {
48        if !path.exists() {
49            diags.push(
50                Diagnostic::new(
51                    codes::E_PATH_NOT_FOUND,
52                    format!("Baseline file not found: {}", path.display()),
53                )
54                .with_context(serde_json::json!({ "path": path }))
55                .with_source("validate")
56                .with_fix_step("Ensure the --baseline path is correct and accessible"),
57            );
58        }
59    }
60
61    // Missing path assets stop the deeper checks to avoid noise. The vacuous scan
62    // still runs once because it needs neither trace nor baseline.
63    let paths_missing = !diags.is_empty();
64    diags.extend(check_vacuous_expected(cfg));
65    if paths_missing {
66        return Ok(ValidateReport { diagnostics: diags });
67    }
68
69    // 2. Load Trace & Baseline for deeper checks
70    let trace_client = if let Some(path) = &opts.trace_file {
71        match TraceClient::from_path(path) {
72            Ok(client) => Some(client),
73            Err(e) => {
74                diags.push(
75                    Diagnostic::new(
76                        codes::E_TRACE_INVALID,
77                        format!("Failed to parse trace file: {}", e),
78                    )
79                    .with_source("trace")
80                    .with_context(serde_json::json!({ "path": path, "error": e.to_string() })),
81                );
82                return Ok(ValidateReport { diagnostics: diags });
83            }
84        }
85    } else {
86        None
87    };
88
89    let baseline = if let Some(path) = &opts.baseline_file {
90        match crate::baseline::Baseline::load(path) {
91            Ok(b) => Some(b),
92            Err(e) => {
93                diags.push(
94                    Diagnostic::new(
95                        codes::E_BASE_MISMATCH,
96                        format!("Failed to parse baseline: {}", e),
97                    )
98                    .with_source("baseline")
99                    .with_context(serde_json::json!({ "path": path, "error": e.to_string() })),
100                );
101                return Ok(ValidateReport { diagnostics: diags });
102            }
103        }
104    } else {
105        None
106    };
107
108    // 3. Trace Coverage (E_TRACE_MISS)
109    if let Some(client) = &trace_client {
110        for tc in &cfg.tests {
111            // We use the same lookup logic as TraceClient::complete
112            // But here we want to collect ALL misses, not just fail on first.
113            // Since `complete` is not exposed as "check only", we iterate.
114            // Actually TraceClient doesn't expose keys publicly yet.
115            // We might need to call complete and catch error?
116            // OR better: call complete() on client. Since it returns LlmResponse or Err(Diagnostic)
117
118            let res = client
119                .complete(&tc.input.prompt, tc.input.context.as_deref())
120                .await;
121            if let Err(e) = res {
122                // If it's a diagnostic, push it.
123                // We use try_map_error from errors module
124                if let Some(diag) = crate::errors::try_map_error(&e) {
125                    // Enrich with test_id
126                    let mut d = diag.clone();
127                    if let serde_json::Value::Object(ref mut map) = d.context {
128                        map.insert("test_id".into(), serde_json::json!(tc.id));
129                        map.insert("trace_file".into(), serde_json::json!(opts.trace_file));
130                    }
131                    d.source = "trace".to_string();
132                    diags.push(d);
133                } else {
134                    // Unexpected error?
135                    diags.push(
136                        Diagnostic::new("E_UNKNOWN", format!("Unexpected trace error: {}", e))
137                            .with_source("trace"),
138                    );
139                }
140            } else if let Ok(resp) = res {
141                // Check Strict Replay (Requirement 4)
142                if opts.replay_strict {
143                    validate_strict_requirements(tc, &resp, &mut diags, opts.trace_file.as_deref());
144                }
145
146                // Check Embedding Dims (Requirement 5)
147                // This is checking per-test, potentially spammy.
148                // Better to check once per trace? But we don't have access to all embeddings.
149                // We'll check via response meta if available.
150                check_embedding_dims(&resp, &mut diags, opts.trace_file.as_deref());
151
152                // Check Policy (Requirement 2: ArgsValid)
153                if let Expected::ArgsValid {
154                    policy: Some(policy_path),
155                    ..
156                } = &tc.expected
157                {
158                    // 1. Load Policy
159                    // For now, load fully. In future, cache via resolver.
160                    // We need to resolve relative to config?
161                    // resolver.resolve_path(policy_path)?
162                    let mut p_str = policy_path.clone();
163                    resolver.resolve_str(&mut p_str);
164                    let policy_file = std::path::PathBuf::from(p_str);
165                    if !policy_file.exists() {
166                        diags.push(
167                            Diagnostic::new(
168                                codes::E_PATH_NOT_FOUND,
169                                format!("Policy file not found: {}", policy_file.display()),
170                            )
171                            .with_source("validate")
172                            .with_context(serde_json::json!({ "path": policy_file })),
173                        );
174                    } else {
175                        match crate::model::Policy::load(&policy_file) {
176                            Ok(pol) => {
177                                // 2. Get Tool Calls from Trace
178                                let tool_calls =
179                                    resp.meta.get("tool_calls").and_then(|v| v.as_array());
180
181                                if let Some(calls) = tool_calls {
182                                    // Convert to policy value for engine
183                                    let policy_val = serde_json::to_value(
184                                        pol.tools.arg_constraints.unwrap_or_default(),
185                                    )
186                                    .unwrap_or(serde_json::Value::Null);
187
188                                    // Check for Allowed/Denied lists first?
189                                    // Let's use simple policy_engine:evaluate_tool_args which expects JSON schema map.
190                                    // Wait, Policy struct has complex structure.
191                                    // policy.tools.arg_constraints is Map<Tool, Schema>.
192                                    // policy.tools.allow/deny are lists.
193
194                                    // Simplified validation for v1.2.1: Just check args against schema if present.
195                                    // TODO(validate-v13): full policy context for arg enforcement
196
197                                    for call in calls {
198                                        let tool_name = call
199                                            .get("tool_name")
200                                            .and_then(|s| s.as_str())
201                                            .unwrap_or("unknown");
202                                        let args =
203                                            call.get("args").unwrap_or(&serde_json::Value::Null);
204
205                                        // Need to construct the "policy" value expected by evaluate_tool_args
206                                        // It expects { "ToolName": Schema, ... }
207                                        // This is exactly `arg_constraints`.
208
209                                        let verdict = crate::policy_engine::evaluate_tool_args(
210                                            &policy_val,
211                                            tool_name,
212                                            args,
213                                        );
214
215                                        if let crate::policy_engine::VerdictStatus::Blocked =
216                                            verdict.status
217                                        {
218                                            let mut d = Diagnostic::new(
219                                                verdict.reason_code,
220                                                "Policy violation in tool call",
221                                            )
222                                            .with_source("policy")
223                                            .with_context(verdict.details);
224
225                                            // Add trace context
226                                            if let serde_json::Value::Object(ref mut map) =
227                                                d.context
228                                            {
229                                                map.insert("tool".into(), tool_name.into());
230                                                map.insert("test_id".into(), tc.id.clone().into());
231                                            }
232                                            diags.push(d);
233                                        }
234                                    }
235                                } else {
236                                    // No tool calls found in trace?
237                                    // If policy expects validation, maybe warn?
238                                }
239                            }
240                            Err(e) => {
241                                diags.push(
242                                    Diagnostic::new(
243                                        codes::E_CFG_PARSE,
244                                        format!("Failed to parse policy: {}", e),
245                                    )
246                                    .with_source("policy"),
247                                );
248                            }
249                        }
250                    }
251                }
252            }
253        }
254    }
255
256    // Baseline Compat (Requirement 3)
257    if let Some(base) = &baseline {
258        if base.suite != cfg.suite {
259            diags.push(
260                Diagnostic::new(codes::E_BASE_MISMATCH, "Baseline suite mismatch")
261                    .with_source("baseline")
262                    .with_context(serde_json::json!({
263                        "expected_suite": cfg.suite,
264                        "baseline_suite": base.suite,
265                        "baseline_file": opts.baseline_file
266                    }))
267                    .with_fix_step("Use the baseline file created for this suite")
268                    .with_fix_step("Or export a new baseline: assay ci ... --export-baseline ..."),
269            );
270        }
271    }
272
273    // Deduplicate diagnostics?
274    // E_EMB_DIMS might be spammy if every test fails.
275    // Simple dedup by code + message signature could be added later.
276
277    Ok(ValidateReport { diagnostics: diags })
278}
279
280/// Warn about tests that assert nothing and therefore always pass.
281///
282/// By the time a config has loaded, a vacuous value normally came from an omitted or
283/// null `expected:` key resolving to `Expected::default()`. An explicit tagged
284/// assertion that has no effective constraint is rejected at parse time (see
285/// `model::serde::reject_vacuous`), which is a hard error for every command that
286/// loads a config, including `assay run` and `assay ci`.
287///
288/// That split is deliberate. Omitting `expected:` is a documented, legitimate shape —
289/// a test may carry its checks in `assertions:` — so making it an error here would
290/// contradict the permissive parse and break configs the tool itself writes. It is
291/// still worth reporting when such a test has no assertions either, because then it
292/// really does assert nothing; hence a warning rather than an error.
293///
294/// Tests that carry `assertions:` are not exempt — they are swept too.
295///
296/// The exemption used to test for a **non-empty** `assertions:` list rather than an **effective**
297/// one, and nothing looked at the assertions afterwards. One assertion that could not fail
298/// therefore cleared both gates in a single move: the `expected:` check was skipped because
299/// assertions existed, and the assertions were never examined. Reporting a suite as swept while
300/// stepping over the case the sweep exists to find is the failure this function is meant to
301/// prevent, one layer up.
302///
303/// Effectiveness is decided by `agent_assertions::matchers::ineffective_reason`, which is the same
304/// code the evaluator runs. Nothing here re-states what "cannot fail" means.
305///
306/// This check reads only the config, so `assay validate` can sweep a suite for
307/// always-green tests without running it.
308/// Every assertion in the config that cannot fail, located in the suite.
309///
310/// Two callers need this answer and they must not each derive it: `assay validate` reports it as a
311/// warning, and `load_config` refuses the config outright when the caller opted into
312/// `allow_ineffective_assertions`. Two implementations of one rule drift; the fix is one
313/// implementation with two callers, which is also why the "cannot fail" decision itself stays in
314/// `agent_assertions::matchers::ineffective_reason` — the evaluator's own code — rather than being
315/// restated here.
316///
317/// The diagnostics carry the evaluator's context, which names the variant and the responsible
318/// field, plus `test_id` and `assertion_index`, which only a sweep over the config can supply.
319pub fn ineffective_assertions(cfg: &EvalConfig) -> Vec<Diagnostic> {
320    let mut diags = Vec::new();
321    for tc in &cfg.tests {
322        for (index, assertion) in tc
323            .assertions
324            .as_deref()
325            .unwrap_or_default()
326            .iter()
327            .enumerate()
328        {
329            let Some(mut reason) = crate::agent_assertions::matchers::ineffective_reason(assertion)
330            else {
331                continue;
332            };
333            if let Some(obj) = reason.context.as_object_mut() {
334                obj.insert("test_id".into(), serde_json::json!(tc.id));
335                obj.insert("assertion_index".into(), serde_json::json!(index));
336            }
337            diags.push(
338                reason.with_fix_step(
339                    "Or remove the assertion, so the test does not appear to check it",
340                ),
341            );
342        }
343    }
344    diags
345}
346
347fn check_vacuous_expected(cfg: &EvalConfig) -> Vec<Diagnostic> {
348    // An assertion that cannot fail is reported here rather than only when a run reaches it,
349    // so a suite can be swept for always-green tests without executing anything.
350    let mut diags = ineffective_assertions(cfg);
351
352    for tc in &cfg.tests {
353        let has_assertions = !tc.assertions.as_deref().unwrap_or_default().is_empty();
354
355        if has_assertions {
356            continue;
357        }
358
359        let Some(field) = crate::model::vacuous_expected_field(&tc.expected) else {
360            continue;
361        };
362
363        diags.push(
364            Diagnostic::new(
365                codes::W_CFG_VACUOUS_EXPECTED,
366                format!(
367                    "Test '{}' asserts nothing: `{}` is empty and there are no `assertions:`, so it passes for any response",
368                    tc.id, field
369                ),
370            )
371            .with_severity("warn")
372            .with_source("config")
373            .with_context(serde_json::json!({
374                "test_id": tc.id,
375                "field": field,
376            }))
377            .with_fix_step("Add an `expected:` block that checks something")
378            .with_fix_step("Or give the test `assertions:`"),
379        );
380    }
381
382    diags
383}
384
385fn validate_strict_requirements(
386    tc: &crate::model::TestCase,
387    resp: &crate::model::LlmResponse,
388    diags: &mut Vec<Diagnostic>,
389    trace_path: Option<&Path>,
390) {
391    let mut missing = Vec::new();
392
393    // Check Semantic Metrics -> Need Embeddings
394    if let Expected::SemanticSimilarityTo { .. } = &tc.expected {
395        if resp.meta.pointer("/assay/embeddings/response").is_none() {
396            missing.push(serde_json::json!({
397                "requirement": "embeddings",
398                "needed_by": ["semantic_similarity_to"],
399                "meta_path": "meta.assay.embeddings"
400            }));
401        }
402    }
403
404    // Check Judge -> Need Judge Results
405    // Only if expected is Faithfulness or Relevance
406    #[expect(
407        clippy::wildcard_enum_match_arm,
408        reason = "only judge variants require judge meta; a new one would report no missing requirement"
409    )]
410    match &tc.expected {
411        Expected::Faithfulness { .. }
412            if resp.meta.pointer("/assay/judge/faithfulness").is_none() =>
413        {
414            missing.push(serde_json::json!({
415                "requirement": "judge_faithfulness",
416                "needed_by": ["faithfulness"],
417                "meta_path": "meta.assay.judge.faithfulness"
418            }));
419        }
420        Expected::Relevance { .. } if resp.meta.pointer("/assay/judge/relevance").is_none() => {
421            missing.push(serde_json::json!({
422                "requirement": "judge_relevance",
423                "needed_by": ["relevance"],
424                "meta_path": "meta.assay.judge.relevance"
425            }));
426        }
427        _ => {}
428    }
429
430    if !missing.is_empty() {
431        diags.push(
432            Diagnostic::new(
433                codes::E_REPLAY_STRICT_MISSING,
434                "Strict replay requires precomputed data that is missing from trace",
435            )
436            .with_source("replay")
437            .with_context(serde_json::json!({
438                "replay_strict": true,
439                "trace_file": trace_path,
440                "missing": missing,
441                "test_id": tc.id
442            }))
443            .with_fix_step("Run `assay trace precompute-embeddings ...`")
444            .with_fix_step("Run `assay trace precompute-judge ...`"),
445        );
446    }
447}
448
449fn check_embedding_dims(
450    resp: &crate::model::LlmResponse,
451    diags: &mut Vec<Diagnostic>,
452    trace_path: Option<&Path>,
453) {
454    // Basic heuristic: if we have embeddings, check simple consistency?
455    // Or if we know expected model?
456    // For now, looking for obvious bad data (empty vectors)
457    // Or strict mismatch if we ever passed an embedder config (not available here yet).
458
459    if let Some(embeddings) = resp
460        .meta
461        .pointer("/assay/embeddings")
462        .and_then(|v| v.as_object())
463    {
464        if let Some(response_vec) = embeddings.get("response").and_then(|v| v.as_array()) {
465            if response_vec.is_empty() {
466                diags.push(
467                    Diagnostic::new(codes::E_EMB_DIMS, "Empty embedding vector found in trace")
468                        .with_source("trace")
469                        .with_context(serde_json::json!({ "trace_file": trace_path }))
470                        .with_fix_step("Regenerate embeddings with precompute-embeddings"),
471                );
472            }
473        }
474    }
475}
476#[cfg(test)]
477mod vacuous_expected_tests {
478    use super::*;
479    use crate::agent_assertions::model::TraceAssertion;
480    use crate::model::{Settings, TestCase, TestInput};
481
482    fn cfg_with(expected: Expected, assertions: Option<Vec<TraceAssertion>>) -> EvalConfig {
483        EvalConfig {
484            version: 1,
485            suite: "s".into(),
486            model: "dummy".into(),
487            settings: Settings::default(),
488            thresholds: Default::default(),
489            otel: Default::default(),
490            tests: vec![TestCase {
491                id: "t1".into(),
492                input: TestInput {
493                    prompt: "hi".into(),
494                    context: None,
495                },
496                expected,
497                assertions,
498                on_error: None,
499                tags: vec![],
500                metadata: None,
501            }],
502        }
503    }
504
505    #[test]
506    fn flags_empty_must_contain() {
507        let cfg = cfg_with(
508            Expected::MustContain {
509                must_contain: vec![],
510            },
511            None,
512        );
513        let diags = check_vacuous_expected(&cfg);
514        assert_eq!(diags.len(), 1);
515        assert_eq!(diags[0].code, codes::W_CFG_VACUOUS_EXPECTED);
516        // Warning, not error: omitted or null `expected:` values resolve to the
517        // default, while an explicitly tagged empty assertion never gets this far.
518        assert_eq!(diags[0].severity, "warn");
519        assert!(diags[0].message.contains("t1"), "{}", diags[0].message);
520        assert!(
521            diags[0].message.contains("`must_contain` is empty"),
522            "{}",
523            diags[0].message
524        );
525        assert!(!diags[0].message.contains("no `expected:` block"));
526    }
527
528    #[test]
529    fn flags_empty_must_not_contain() {
530        let cfg = cfg_with(
531            Expected::MustNotContain {
532                must_not_contain: vec![],
533            },
534            None,
535        );
536        let diags = check_vacuous_expected(&cfg);
537        assert_eq!(diags.len(), 1);
538        assert_eq!(diags[0].context["field"], "must_not_contain");
539    }
540
541    /// A missing `expected:` key resolves to the vacuous default, so the same rule
542    /// covers it — this is what keeps the permissive parse honest.
543    #[test]
544    fn flags_default_expected_from_missing_key() {
545        let cfg = cfg_with(Expected::default(), None);
546        assert_eq!(check_vacuous_expected(&cfg).len(), 1);
547    }
548
549    #[test]
550    fn does_not_flag_populated_must_contain() {
551        let cfg = cfg_with(
552            Expected::MustContain {
553                must_contain: vec!["Paris".into()],
554            },
555            None,
556        );
557        assert!(check_vacuous_expected(&cfg).is_empty());
558    }
559
560    /// Assertion-carrying tests legitimately omit `expected:`.
561    #[test]
562    fn does_not_flag_when_assertions_present() {
563        let cfg = cfg_with(
564            Expected::default(),
565            Some(vec![TraceAssertion::TraceMustCallTool {
566                tool: "search".into(),
567                min_calls: None,
568            }]),
569        );
570        assert!(check_vacuous_expected(&cfg).is_empty());
571    }
572
573    /// The case the exemption used to step over: a test carrying one assertion that cannot fail.
574    ///
575    /// Before this check, the non-empty `assertions:` list suppressed the `expected:` warning and
576    /// nothing looked at the assertion, so the suite swept clean while asserting nothing.
577    #[test]
578    fn flags_an_assertion_that_cannot_fail() {
579        let cfg = cfg_with(
580            Expected::default(),
581            Some(vec![TraceAssertion::TraceMustCallTool {
582                tool: "search".into(),
583                min_calls: Some(0),
584            }]),
585        );
586        let diags = check_vacuous_expected(&cfg);
587        assert_eq!(diags.len(), 1, "{diags:?}");
588        assert_eq!(diags[0].code, "E_ASSERT_INEFFECTIVE");
589        assert_eq!(diags[0].severity, "error");
590        assert_eq!(diags[0].context["field"], "min_calls");
591        assert_eq!(diags[0].context["test_id"], "t1");
592        assert_eq!(diags[0].context["assertion_index"], 0);
593    }
594
595    /// One per variant. A sweep that reached only the variants convenient to write would be the
596    /// same partial-coverage problem it exists to report.
597    #[test]
598    fn flags_a_vacuous_shape_of_every_variant() {
599        let cases: Vec<(&str, TraceAssertion)> = vec![
600            (
601                "tool",
602                TraceAssertion::TraceMustCallTool {
603                    tool: String::new(),
604                    min_calls: None,
605                },
606            ),
607            (
608                "tool",
609                TraceAssertion::TraceMustNotCallTool {
610                    tool: String::new(),
611                },
612            ),
613            (
614                "sequence",
615                TraceAssertion::TraceToolSequence {
616                    sequence: vec![],
617                    allow_other_tools: true,
618                },
619            ),
620            ("max", TraceAssertion::TraceMaxSteps { max: u32::MAX }),
621            (
622                "test_args",
623                TraceAssertion::ArgsValid {
624                    tool: "t".into(),
625                    test_args: None,
626                    policy: None,
627                    expect: None,
628                },
629            ),
630            (
631                "test_trace_raw",
632                TraceAssertion::SequenceValid {
633                    test_trace: None,
634                    test_trace_raw: None,
635                    policy: None,
636                    expect: None,
637                },
638            ),
639            (
640                "test_tool_calls",
641                TraceAssertion::ToolBlocklist {
642                    test_tool_calls: None,
643                    policy: None,
644                    expect: None,
645                },
646            ),
647        ];
648
649        for (field, assertion) in cases {
650            let cfg = cfg_with(Expected::default(), Some(vec![assertion.clone()]));
651            let diags = check_vacuous_expected(&cfg);
652            assert_eq!(diags.len(), 1, "{assertion:?} produced {diags:?}");
653            assert_eq!(
654                diags[0].context["field"], field,
655                "{assertion:?} blamed the wrong field: {}",
656                diags[0].message
657            );
658        }
659    }
660
661    /// An unrecognized `expect` spelling silently selected *expect failure* and inverted the
662    /// assertion. That is worse than a no-op — a no-op stops checking, an inversion checks the
663    /// opposite — so the static sweep has to reach it too, not only the evaluator.
664    #[test]
665    fn flags_an_unrecognized_expect_spelling() {
666        let cfg = cfg_with(
667            Expected::default(),
668            Some(vec![TraceAssertion::ArgsValid {
669                tool: "t".into(),
670                test_args: Some(serde_json::json!({})),
671                policy: Some(serde_json::json!({ "schema": {} })),
672                expect: Some("Pass".into()),
673            }]),
674        );
675        let diags = check_vacuous_expected(&cfg);
676        assert_eq!(diags.len(), 1, "{diags:?}");
677        assert_eq!(diags[0].code, "E_CONFIG_ERROR");
678        assert!(diags[0].message.contains("expect"), "{}", diags[0].message);
679    }
680
681    /// The invariant the static sweep rests on: it must reject configurations that cannot check
682    /// anything, and **only** those. An assertion that constrains something and would simply not
683    /// hold for a given trace is not a config defect, and reporting it here would make
684    /// `assay validate` refuse working suites — the over-eager detection that earns a suppression
685    /// and takes the real findings down with it.
686    ///
687    /// Each case below fails when evaluated against an empty episode, which is exactly the input
688    /// the sweep uses internally. If a future check answered one of these from the configuration,
689    /// this test fails rather than the sweep quietly growing false positives.
690    #[test]
691    fn does_not_flag_an_assertion_that_merely_fails_for_a_trace() {
692        for assertion in [
693            // Requires three calls; an empty episode has none.
694            TraceAssertion::TraceMustCallTool {
695                tool: "search".into(),
696                min_calls: Some(3),
697            },
698            // Requires this order; an empty episode has no calls at all.
699            TraceAssertion::TraceToolSequence {
700                sequence: vec!["a".into(), "b".into()],
701                allow_other_tools: true,
702            },
703            // Exact-sequence form, likewise unsatisfied by an empty episode.
704            TraceAssertion::TraceToolSequence {
705                sequence: vec!["a".into()],
706                allow_other_tools: false,
707            },
708            // A well-formed policy the supplied arguments violate: a real failure, not a
709            // configuration that checks nothing.
710            TraceAssertion::ArgsValid {
711                tool: "t".into(),
712                test_args: Some(serde_json::json!({ "percent": 90 })),
713                policy: Some(serde_json::json!({
714                    "schema": { "properties": { "percent": { "type": "number", "maximum": 30 } } }
715                })),
716                expect: Some("pass".into()),
717            },
718            // A blocked call that is actually made, expected to pass: fails, and should.
719            TraceAssertion::ToolBlocklist {
720                test_tool_calls: Some(vec!["rm".into()]),
721                policy: Some(serde_json::json!({ "blocked": ["rm"] })),
722                expect: Some("pass".into()),
723            },
724        ] {
725            let cfg = cfg_with(Expected::default(), Some(vec![assertion.clone()]));
726            let diags = check_vacuous_expected(&cfg);
727            assert!(
728                diags.is_empty(),
729                "the static sweep rejected a configuration that merely fails for a trace: \
730                 {assertion:?} -> {diags:?}"
731            );
732        }
733    }
734
735    /// The sweep reaches the schema compiler from a caller that did not exist before, so a
736    /// schema that cannot compile now has a new way to be encountered. A panic here would take
737    /// down `assay validate` on the one input it most needs to survive: a config someone is
738    /// trying to fix.
739    ///
740    /// It returns, and it says nothing. That was worth measuring rather than assuming, because
741    /// an earlier version of the doc comment on `ineffective_reason` claimed the opposite — that
742    /// the schema compilation the sweep performs would surface a broken schema early. It does
743    /// not: the resulting diagnostic carries an evaluation-decided code, which the filter drops.
744    /// Both are pinned here so the boundary is stated rather than rediscovered.
745    #[test]
746    fn a_schema_that_cannot_compile_neither_panics_nor_is_swept() {
747        for (case, policy) in [
748            (
749                "type is not a schema keyword value",
750                serde_json::json!({ "schema": { "type": 42 } }),
751            ),
752            (
753                "properties is not an object",
754                serde_json::json!({ "schema": { "properties": "not-an-object" } }),
755            ),
756            (
757                "required is not an array",
758                serde_json::json!({ "schema": { "required": 7 } }),
759            ),
760            (
761                "external ref, which the hermetic compiler refuses",
762                serde_json::json!({ "schema": { "$ref": "https://example.invalid/s.json" } }),
763            ),
764        ] {
765            let cfg = cfg_with(
766                Expected::default(),
767                Some(vec![TraceAssertion::ArgsValid {
768                    tool: "t".into(),
769                    test_args: Some(serde_json::json!({ "a": 1 })),
770                    policy: Some(policy),
771                    expect: Some("pass".into()),
772                }]),
773            );
774            // The assertion is that this returns rather than unwinding.
775            let diags = check_vacuous_expected(&cfg);
776            assert!(
777                diags.is_empty(),
778                "{case}: the sweep reported {diags:?}. An uncompilable schema is a broken \
779                 assertion, not one that cannot fail, and widening the sweep to catch it would \
780                 cost the narrowness `does_not_flag_an_assertion_that_merely_fails_for_a_trace` \
781                 protects. If this is now wanted, it belongs in a schema-validity check with its \
782                 own diagnostic, not in the vacuity filter."
783            );
784        }
785    }
786
787    /// The sweep must work with no trace file and no baseline — that is the point of
788    /// being able to check a suite without running it.
789    #[tokio::test]
790    async fn validate_reports_vacuous_without_trace_file() {
791        let cfg = cfg_with(
792            Expected::MustContain {
793                must_contain: vec![],
794            },
795            None,
796        );
797        let opts = ValidateOptions {
798            trace_file: None,
799            baseline_file: None,
800            replay_strict: false,
801        };
802        let resolver = PathResolver::new(Path::new("eval.yaml"));
803
804        let report = validate(&cfg, &opts, &resolver).await.expect("validate");
805        assert_eq!(report.diagnostics.len(), 1);
806        assert_eq!(report.diagnostics[0].code, codes::W_CFG_VACUOUS_EXPECTED);
807    }
808}