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 exempt.
295///
296/// This check reads only the config, so `assay validate` can sweep a suite for
297/// always-green tests without running it.
298fn check_vacuous_expected(cfg: &EvalConfig) -> Vec<Diagnostic> {
299    let mut diags = Vec::new();
300
301    for tc in &cfg.tests {
302        let has_assertions = tc.assertions.as_ref().is_some_and(|a| !a.is_empty());
303        if has_assertions {
304            continue;
305        }
306
307        let Some(field) = crate::model::vacuous_expected_field(&tc.expected) else {
308            continue;
309        };
310
311        diags.push(
312            Diagnostic::new(
313                codes::W_CFG_VACUOUS_EXPECTED,
314                format!(
315                    "Test '{}' asserts nothing: `{}` is empty and there are no `assertions:`, so it passes for any response",
316                    tc.id, field
317                ),
318            )
319            .with_severity("warn")
320            .with_source("config")
321            .with_context(serde_json::json!({
322                "test_id": tc.id,
323                "field": field,
324            }))
325            .with_fix_step("Add an `expected:` block that checks something")
326            .with_fix_step("Or give the test `assertions:`"),
327        );
328    }
329
330    diags
331}
332
333fn validate_strict_requirements(
334    tc: &crate::model::TestCase,
335    resp: &crate::model::LlmResponse,
336    diags: &mut Vec<Diagnostic>,
337    trace_path: Option<&Path>,
338) {
339    let mut missing = Vec::new();
340
341    // Check Semantic Metrics -> Need Embeddings
342    if let Expected::SemanticSimilarityTo { .. } = &tc.expected {
343        if resp.meta.pointer("/assay/embeddings/response").is_none() {
344            missing.push(serde_json::json!({
345                "requirement": "embeddings",
346                "needed_by": ["semantic_similarity_to"],
347                "meta_path": "meta.assay.embeddings"
348            }));
349        }
350    }
351
352    // Check Judge -> Need Judge Results
353    // Only if expected is Faithfulness or Relevance
354    match &tc.expected {
355        Expected::Faithfulness { .. }
356            if resp.meta.pointer("/assay/judge/faithfulness").is_none() =>
357        {
358            missing.push(serde_json::json!({
359                "requirement": "judge_faithfulness",
360                "needed_by": ["faithfulness"],
361                "meta_path": "meta.assay.judge.faithfulness"
362            }));
363        }
364        Expected::Relevance { .. } if resp.meta.pointer("/assay/judge/relevance").is_none() => {
365            missing.push(serde_json::json!({
366                "requirement": "judge_relevance",
367                "needed_by": ["relevance"],
368                "meta_path": "meta.assay.judge.relevance"
369            }));
370        }
371        _ => {}
372    }
373
374    if !missing.is_empty() {
375        diags.push(
376            Diagnostic::new(
377                codes::E_REPLAY_STRICT_MISSING,
378                "Strict replay requires precomputed data that is missing from trace",
379            )
380            .with_source("replay")
381            .with_context(serde_json::json!({
382                "replay_strict": true,
383                "trace_file": trace_path,
384                "missing": missing,
385                "test_id": tc.id
386            }))
387            .with_fix_step("Run `assay trace precompute-embeddings ...`")
388            .with_fix_step("Run `assay trace precompute-judge ...`"),
389        );
390    }
391}
392
393fn check_embedding_dims(
394    resp: &crate::model::LlmResponse,
395    diags: &mut Vec<Diagnostic>,
396    trace_path: Option<&Path>,
397) {
398    // Basic heuristic: if we have embeddings, check simple consistency?
399    // Or if we know expected model?
400    // For now, looking for obvious bad data (empty vectors)
401    // Or strict mismatch if we ever passed an embedder config (not available here yet).
402
403    if let Some(embeddings) = resp
404        .meta
405        .pointer("/assay/embeddings")
406        .and_then(|v| v.as_object())
407    {
408        if let Some(response_vec) = embeddings.get("response").and_then(|v| v.as_array()) {
409            if response_vec.is_empty() {
410                diags.push(
411                    Diagnostic::new(codes::E_EMB_DIMS, "Empty embedding vector found in trace")
412                        .with_source("trace")
413                        .with_context(serde_json::json!({ "trace_file": trace_path }))
414                        .with_fix_step("Regenerate embeddings with precompute-embeddings"),
415                );
416            }
417        }
418    }
419}
420#[cfg(test)]
421mod vacuous_expected_tests {
422    use super::*;
423    use crate::agent_assertions::model::TraceAssertion;
424    use crate::model::{Settings, TestCase, TestInput};
425
426    fn cfg_with(expected: Expected, assertions: Option<Vec<TraceAssertion>>) -> EvalConfig {
427        EvalConfig {
428            version: 1,
429            suite: "s".into(),
430            model: "dummy".into(),
431            settings: Settings::default(),
432            thresholds: Default::default(),
433            otel: Default::default(),
434            tests: vec![TestCase {
435                id: "t1".into(),
436                input: TestInput {
437                    prompt: "hi".into(),
438                    context: None,
439                },
440                expected,
441                assertions,
442                on_error: None,
443                tags: vec![],
444                metadata: None,
445            }],
446        }
447    }
448
449    #[test]
450    fn flags_empty_must_contain() {
451        let cfg = cfg_with(
452            Expected::MustContain {
453                must_contain: vec![],
454            },
455            None,
456        );
457        let diags = check_vacuous_expected(&cfg);
458        assert_eq!(diags.len(), 1);
459        assert_eq!(diags[0].code, codes::W_CFG_VACUOUS_EXPECTED);
460        // Warning, not error: omitted or null `expected:` values resolve to the
461        // default, while an explicitly tagged empty assertion never gets this far.
462        assert_eq!(diags[0].severity, "warn");
463        assert!(diags[0].message.contains("t1"), "{}", diags[0].message);
464        assert!(
465            diags[0].message.contains("`must_contain` is empty"),
466            "{}",
467            diags[0].message
468        );
469        assert!(!diags[0].message.contains("no `expected:` block"));
470    }
471
472    #[test]
473    fn flags_empty_must_not_contain() {
474        let cfg = cfg_with(
475            Expected::MustNotContain {
476                must_not_contain: vec![],
477            },
478            None,
479        );
480        let diags = check_vacuous_expected(&cfg);
481        assert_eq!(diags.len(), 1);
482        assert_eq!(diags[0].context["field"], "must_not_contain");
483    }
484
485    /// A missing `expected:` key resolves to the vacuous default, so the same rule
486    /// covers it — this is what keeps the permissive parse honest.
487    #[test]
488    fn flags_default_expected_from_missing_key() {
489        let cfg = cfg_with(Expected::default(), None);
490        assert_eq!(check_vacuous_expected(&cfg).len(), 1);
491    }
492
493    #[test]
494    fn does_not_flag_populated_must_contain() {
495        let cfg = cfg_with(
496            Expected::MustContain {
497                must_contain: vec!["Paris".into()],
498            },
499            None,
500        );
501        assert!(check_vacuous_expected(&cfg).is_empty());
502    }
503
504    /// Assertion-carrying tests legitimately omit `expected:`.
505    #[test]
506    fn does_not_flag_when_assertions_present() {
507        let cfg = cfg_with(
508            Expected::default(),
509            Some(vec![TraceAssertion::TraceMustCallTool {
510                tool: "search".into(),
511                min_calls: None,
512            }]),
513        );
514        assert!(check_vacuous_expected(&cfg).is_empty());
515    }
516
517    /// The sweep must work with no trace file and no baseline — that is the point of
518    /// being able to check a suite without running it.
519    #[tokio::test]
520    async fn validate_reports_vacuous_without_trace_file() {
521        let cfg = cfg_with(
522            Expected::MustContain {
523                must_contain: vec![],
524            },
525            None,
526        );
527        let opts = ValidateOptions {
528            trace_file: None,
529            baseline_file: None,
530            replay_strict: false,
531        };
532        let resolver = PathResolver::new(Path::new("eval.yaml"));
533
534        let report = validate(&cfg, &opts, &resolver).await.expect("validate");
535        assert_eq!(report.diagnostics.len(), 1);
536        assert_eq!(report.diagnostics[0].code, codes::W_CFG_VACUOUS_EXPECTED);
537    }
538}