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; use 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 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 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 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 if let Some(client) = &trace_client {
110 for tc in &cfg.tests {
111 let res = client
119 .complete(&tc.input.prompt, tc.input.context.as_deref())
120 .await;
121 if let Err(e) = res {
122 if let Some(diag) = crate::errors::try_map_error(&e) {
125 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 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 if opts.replay_strict {
143 validate_strict_requirements(tc, &resp, &mut diags, opts.trace_file.as_deref());
144 }
145
146 check_embedding_dims(&resp, &mut diags, opts.trace_file.as_deref());
151
152 if let Expected::ArgsValid {
154 policy: Some(policy_path),
155 ..
156 } = &tc.expected
157 {
158 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 let tool_calls =
179 resp.meta.get("tool_calls").and_then(|v| v.as_array());
180
181 if let Some(calls) = tool_calls {
182 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 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 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 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 }
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 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 Ok(ValidateReport { diagnostics: diags })
278}
279
280fn 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 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 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 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 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 #[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 #[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 #[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}