use crate::config::path_resolver::PathResolver;
use crate::errors::diagnostic::{codes, Diagnostic};
use crate::model::EvalConfig;
use crate::model::Expected;
use crate::providers::llm::LlmClient; use crate::providers::trace::TraceClient;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct ValidateOptions {
pub trace_file: Option<PathBuf>,
pub baseline_file: Option<PathBuf>,
pub replay_strict: bool,
}
#[derive(Debug, Clone, Default)]
pub struct ValidateReport {
pub diagnostics: Vec<Diagnostic>,
}
pub async fn validate(
cfg: &EvalConfig,
opts: &ValidateOptions,
resolver: &PathResolver,
) -> anyhow::Result<ValidateReport> {
let mut diags = Vec::new();
if let Some(path) = &opts.trace_file {
if !path.exists() {
diags.push(
Diagnostic::new(
codes::E_PATH_NOT_FOUND,
format!("Trace file not found: {}", path.display()),
)
.with_context(serde_json::json!({ "path": path }))
.with_source("validate")
.with_fix_step("Ensure the --trace-file path is correct and accessible"),
);
}
}
if let Some(path) = &opts.baseline_file {
if !path.exists() {
diags.push(
Diagnostic::new(
codes::E_PATH_NOT_FOUND,
format!("Baseline file not found: {}", path.display()),
)
.with_context(serde_json::json!({ "path": path }))
.with_source("validate")
.with_fix_step("Ensure the --baseline path is correct and accessible"),
);
}
}
let paths_missing = !diags.is_empty();
diags.extend(check_vacuous_expected(cfg));
if paths_missing {
return Ok(ValidateReport { diagnostics: diags });
}
let trace_client = if let Some(path) = &opts.trace_file {
match TraceClient::from_path(path) {
Ok(client) => Some(client),
Err(e) => {
diags.push(
Diagnostic::new(
codes::E_TRACE_INVALID,
format!("Failed to parse trace file: {}", e),
)
.with_source("trace")
.with_context(serde_json::json!({ "path": path, "error": e.to_string() })),
);
return Ok(ValidateReport { diagnostics: diags });
}
}
} else {
None
};
let baseline = if let Some(path) = &opts.baseline_file {
match crate::baseline::Baseline::load(path) {
Ok(b) => Some(b),
Err(e) => {
diags.push(
Diagnostic::new(
codes::E_BASE_MISMATCH,
format!("Failed to parse baseline: {}", e),
)
.with_source("baseline")
.with_context(serde_json::json!({ "path": path, "error": e.to_string() })),
);
return Ok(ValidateReport { diagnostics: diags });
}
}
} else {
None
};
if let Some(client) = &trace_client {
for tc in &cfg.tests {
let res = client
.complete(&tc.input.prompt, tc.input.context.as_deref())
.await;
if let Err(e) = res {
if let Some(diag) = crate::errors::try_map_error(&e) {
let mut d = diag.clone();
if let serde_json::Value::Object(ref mut map) = d.context {
map.insert("test_id".into(), serde_json::json!(tc.id));
map.insert("trace_file".into(), serde_json::json!(opts.trace_file));
}
d.source = "trace".to_string();
diags.push(d);
} else {
diags.push(
Diagnostic::new("E_UNKNOWN", format!("Unexpected trace error: {}", e))
.with_source("trace"),
);
}
} else if let Ok(resp) = res {
if opts.replay_strict {
validate_strict_requirements(tc, &resp, &mut diags, opts.trace_file.as_deref());
}
check_embedding_dims(&resp, &mut diags, opts.trace_file.as_deref());
if let Expected::ArgsValid {
policy: Some(policy_path),
..
} = &tc.expected
{
let mut p_str = policy_path.clone();
resolver.resolve_str(&mut p_str);
let policy_file = std::path::PathBuf::from(p_str);
if !policy_file.exists() {
diags.push(
Diagnostic::new(
codes::E_PATH_NOT_FOUND,
format!("Policy file not found: {}", policy_file.display()),
)
.with_source("validate")
.with_context(serde_json::json!({ "path": policy_file })),
);
} else {
match crate::model::Policy::load(&policy_file) {
Ok(pol) => {
let tool_calls =
resp.meta.get("tool_calls").and_then(|v| v.as_array());
if let Some(calls) = tool_calls {
let policy_val = serde_json::to_value(
pol.tools.arg_constraints.unwrap_or_default(),
)
.unwrap_or(serde_json::Value::Null);
for call in calls {
let tool_name = call
.get("tool_name")
.and_then(|s| s.as_str())
.unwrap_or("unknown");
let args =
call.get("args").unwrap_or(&serde_json::Value::Null);
let verdict = crate::policy_engine::evaluate_tool_args(
&policy_val,
tool_name,
args,
);
if let crate::policy_engine::VerdictStatus::Blocked =
verdict.status
{
let mut d = Diagnostic::new(
verdict.reason_code,
"Policy violation in tool call",
)
.with_source("policy")
.with_context(verdict.details);
if let serde_json::Value::Object(ref mut map) =
d.context
{
map.insert("tool".into(), tool_name.into());
map.insert("test_id".into(), tc.id.clone().into());
}
diags.push(d);
}
}
} else {
}
}
Err(e) => {
diags.push(
Diagnostic::new(
codes::E_CFG_PARSE,
format!("Failed to parse policy: {}", e),
)
.with_source("policy"),
);
}
}
}
}
}
}
}
if let Some(base) = &baseline {
if base.suite != cfg.suite {
diags.push(
Diagnostic::new(codes::E_BASE_MISMATCH, "Baseline suite mismatch")
.with_source("baseline")
.with_context(serde_json::json!({
"expected_suite": cfg.suite,
"baseline_suite": base.suite,
"baseline_file": opts.baseline_file
}))
.with_fix_step("Use the baseline file created for this suite")
.with_fix_step("Or export a new baseline: assay ci ... --export-baseline ..."),
);
}
}
Ok(ValidateReport { diagnostics: diags })
}
pub fn ineffective_assertions(cfg: &EvalConfig) -> Vec<Diagnostic> {
let mut diags = Vec::new();
for tc in &cfg.tests {
for (index, assertion) in tc
.assertions
.as_deref()
.unwrap_or_default()
.iter()
.enumerate()
{
let Some(mut reason) = crate::agent_assertions::matchers::ineffective_reason(assertion)
else {
continue;
};
if let Some(obj) = reason.context.as_object_mut() {
obj.insert("test_id".into(), serde_json::json!(tc.id));
obj.insert("assertion_index".into(), serde_json::json!(index));
}
diags.push(
reason.with_fix_step(
"Or remove the assertion, so the test does not appear to check it",
),
);
}
}
diags
}
fn check_vacuous_expected(cfg: &EvalConfig) -> Vec<Diagnostic> {
let mut diags = ineffective_assertions(cfg);
for tc in &cfg.tests {
let has_assertions = !tc.assertions.as_deref().unwrap_or_default().is_empty();
if has_assertions {
continue;
}
let Some(field) = crate::model::vacuous_expected_field(&tc.expected) else {
continue;
};
diags.push(
Diagnostic::new(
codes::W_CFG_VACUOUS_EXPECTED,
format!(
"Test '{}' asserts nothing: `{}` is empty and there are no `assertions:`, so it passes for any response",
tc.id, field
),
)
.with_severity("warn")
.with_source("config")
.with_context(serde_json::json!({
"test_id": tc.id,
"field": field,
}))
.with_fix_step("Add an `expected:` block that checks something")
.with_fix_step("Or give the test `assertions:`"),
);
}
diags
}
fn validate_strict_requirements(
tc: &crate::model::TestCase,
resp: &crate::model::LlmResponse,
diags: &mut Vec<Diagnostic>,
trace_path: Option<&Path>,
) {
let mut missing = Vec::new();
if let Expected::SemanticSimilarityTo { .. } = &tc.expected {
if resp.meta.pointer("/assay/embeddings/response").is_none() {
missing.push(serde_json::json!({
"requirement": "embeddings",
"needed_by": ["semantic_similarity_to"],
"meta_path": "meta.assay.embeddings"
}));
}
}
#[expect(
clippy::wildcard_enum_match_arm,
reason = "only judge variants require judge meta; a new one would report no missing requirement"
)]
match &tc.expected {
Expected::Faithfulness { .. }
if resp.meta.pointer("/assay/judge/faithfulness").is_none() =>
{
missing.push(serde_json::json!({
"requirement": "judge_faithfulness",
"needed_by": ["faithfulness"],
"meta_path": "meta.assay.judge.faithfulness"
}));
}
Expected::Relevance { .. } if resp.meta.pointer("/assay/judge/relevance").is_none() => {
missing.push(serde_json::json!({
"requirement": "judge_relevance",
"needed_by": ["relevance"],
"meta_path": "meta.assay.judge.relevance"
}));
}
_ => {}
}
if !missing.is_empty() {
diags.push(
Diagnostic::new(
codes::E_REPLAY_STRICT_MISSING,
"Strict replay requires precomputed data that is missing from trace",
)
.with_source("replay")
.with_context(serde_json::json!({
"replay_strict": true,
"trace_file": trace_path,
"missing": missing,
"test_id": tc.id
}))
.with_fix_step("Run `assay trace precompute-embeddings ...`")
.with_fix_step("Run `assay trace precompute-judge ...`"),
);
}
}
fn check_embedding_dims(
resp: &crate::model::LlmResponse,
diags: &mut Vec<Diagnostic>,
trace_path: Option<&Path>,
) {
if let Some(embeddings) = resp
.meta
.pointer("/assay/embeddings")
.and_then(|v| v.as_object())
{
if let Some(response_vec) = embeddings.get("response").and_then(|v| v.as_array()) {
if response_vec.is_empty() {
diags.push(
Diagnostic::new(codes::E_EMB_DIMS, "Empty embedding vector found in trace")
.with_source("trace")
.with_context(serde_json::json!({ "trace_file": trace_path }))
.with_fix_step("Regenerate embeddings with precompute-embeddings"),
);
}
}
}
}
#[cfg(test)]
mod vacuous_expected_tests {
use super::*;
use crate::agent_assertions::model::TraceAssertion;
use crate::model::{Settings, TestCase, TestInput};
fn cfg_with(expected: Expected, assertions: Option<Vec<TraceAssertion>>) -> EvalConfig {
EvalConfig {
version: 1,
suite: "s".into(),
model: "dummy".into(),
settings: Settings::default(),
thresholds: Default::default(),
otel: Default::default(),
tests: vec![TestCase {
id: "t1".into(),
input: TestInput {
prompt: "hi".into(),
context: None,
},
expected,
assertions,
on_error: None,
tags: vec![],
metadata: None,
}],
}
}
#[test]
fn flags_empty_must_contain() {
let cfg = cfg_with(
Expected::MustContain {
must_contain: vec![],
},
None,
);
let diags = check_vacuous_expected(&cfg);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, codes::W_CFG_VACUOUS_EXPECTED);
assert_eq!(diags[0].severity, "warn");
assert!(diags[0].message.contains("t1"), "{}", diags[0].message);
assert!(
diags[0].message.contains("`must_contain` is empty"),
"{}",
diags[0].message
);
assert!(!diags[0].message.contains("no `expected:` block"));
}
#[test]
fn flags_empty_must_not_contain() {
let cfg = cfg_with(
Expected::MustNotContain {
must_not_contain: vec![],
},
None,
);
let diags = check_vacuous_expected(&cfg);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].context["field"], "must_not_contain");
}
#[test]
fn flags_default_expected_from_missing_key() {
let cfg = cfg_with(Expected::default(), None);
assert_eq!(check_vacuous_expected(&cfg).len(), 1);
}
#[test]
fn does_not_flag_populated_must_contain() {
let cfg = cfg_with(
Expected::MustContain {
must_contain: vec!["Paris".into()],
},
None,
);
assert!(check_vacuous_expected(&cfg).is_empty());
}
#[test]
fn does_not_flag_when_assertions_present() {
let cfg = cfg_with(
Expected::default(),
Some(vec![TraceAssertion::TraceMustCallTool {
tool: "search".into(),
min_calls: None,
}]),
);
assert!(check_vacuous_expected(&cfg).is_empty());
}
#[test]
fn flags_an_assertion_that_cannot_fail() {
let cfg = cfg_with(
Expected::default(),
Some(vec![TraceAssertion::TraceMustCallTool {
tool: "search".into(),
min_calls: Some(0),
}]),
);
let diags = check_vacuous_expected(&cfg);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, "E_ASSERT_INEFFECTIVE");
assert_eq!(diags[0].severity, "error");
assert_eq!(diags[0].context["field"], "min_calls");
assert_eq!(diags[0].context["test_id"], "t1");
assert_eq!(diags[0].context["assertion_index"], 0);
}
#[test]
fn flags_a_vacuous_shape_of_every_variant() {
let cases: Vec<(&str, TraceAssertion)> = vec![
(
"tool",
TraceAssertion::TraceMustCallTool {
tool: String::new(),
min_calls: None,
},
),
(
"tool",
TraceAssertion::TraceMustNotCallTool {
tool: String::new(),
},
),
(
"sequence",
TraceAssertion::TraceToolSequence {
sequence: vec![],
allow_other_tools: true,
},
),
("max", TraceAssertion::TraceMaxSteps { max: u32::MAX }),
(
"test_args",
TraceAssertion::ArgsValid {
tool: "t".into(),
test_args: None,
policy: None,
expect: None,
},
),
(
"test_trace_raw",
TraceAssertion::SequenceValid {
test_trace: None,
test_trace_raw: None,
policy: None,
expect: None,
},
),
(
"test_tool_calls",
TraceAssertion::ToolBlocklist {
test_tool_calls: None,
policy: None,
expect: None,
},
),
];
for (field, assertion) in cases {
let cfg = cfg_with(Expected::default(), Some(vec![assertion.clone()]));
let diags = check_vacuous_expected(&cfg);
assert_eq!(diags.len(), 1, "{assertion:?} produced {diags:?}");
assert_eq!(
diags[0].context["field"], field,
"{assertion:?} blamed the wrong field: {}",
diags[0].message
);
}
}
#[test]
fn flags_an_unrecognized_expect_spelling() {
let cfg = cfg_with(
Expected::default(),
Some(vec![TraceAssertion::ArgsValid {
tool: "t".into(),
test_args: Some(serde_json::json!({})),
policy: Some(serde_json::json!({ "schema": {} })),
expect: Some("Pass".into()),
}]),
);
let diags = check_vacuous_expected(&cfg);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, "E_CONFIG_ERROR");
assert!(diags[0].message.contains("expect"), "{}", diags[0].message);
}
#[test]
fn does_not_flag_an_assertion_that_merely_fails_for_a_trace() {
for assertion in [
TraceAssertion::TraceMustCallTool {
tool: "search".into(),
min_calls: Some(3),
},
TraceAssertion::TraceToolSequence {
sequence: vec!["a".into(), "b".into()],
allow_other_tools: true,
},
TraceAssertion::TraceToolSequence {
sequence: vec!["a".into()],
allow_other_tools: false,
},
TraceAssertion::ArgsValid {
tool: "t".into(),
test_args: Some(serde_json::json!({ "percent": 90 })),
policy: Some(serde_json::json!({
"schema": { "properties": { "percent": { "type": "number", "maximum": 30 } } }
})),
expect: Some("pass".into()),
},
TraceAssertion::ToolBlocklist {
test_tool_calls: Some(vec!["rm".into()]),
policy: Some(serde_json::json!({ "blocked": ["rm"] })),
expect: Some("pass".into()),
},
] {
let cfg = cfg_with(Expected::default(), Some(vec![assertion.clone()]));
let diags = check_vacuous_expected(&cfg);
assert!(
diags.is_empty(),
"the static sweep rejected a configuration that merely fails for a trace: \
{assertion:?} -> {diags:?}"
);
}
}
#[test]
fn a_schema_that_cannot_compile_neither_panics_nor_is_swept() {
for (case, policy) in [
(
"type is not a schema keyword value",
serde_json::json!({ "schema": { "type": 42 } }),
),
(
"properties is not an object",
serde_json::json!({ "schema": { "properties": "not-an-object" } }),
),
(
"required is not an array",
serde_json::json!({ "schema": { "required": 7 } }),
),
(
"external ref, which the hermetic compiler refuses",
serde_json::json!({ "schema": { "$ref": "https://example.invalid/s.json" } }),
),
] {
let cfg = cfg_with(
Expected::default(),
Some(vec![TraceAssertion::ArgsValid {
tool: "t".into(),
test_args: Some(serde_json::json!({ "a": 1 })),
policy: Some(policy),
expect: Some("pass".into()),
}]),
);
let diags = check_vacuous_expected(&cfg);
assert!(
diags.is_empty(),
"{case}: the sweep reported {diags:?}. An uncompilable schema is a broken \
assertion, not one that cannot fail, and widening the sweep to catch it would \
cost the narrowness `does_not_flag_an_assertion_that_merely_fails_for_a_trace` \
protects. If this is now wanted, it belongs in a schema-validity check with its \
own diagnostic, not in the vacuity filter."
);
}
}
#[tokio::test]
async fn validate_reports_vacuous_without_trace_file() {
let cfg = cfg_with(
Expected::MustContain {
must_contain: vec![],
},
None,
);
let opts = ValidateOptions {
trace_file: None,
baseline_file: None,
replay_strict: false,
};
let resolver = PathResolver::new(Path::new("eval.yaml"));
let report = validate(&cfg, &opts, &resolver).await.expect("validate");
assert_eq!(report.diagnostics.len(), 1);
assert_eq!(report.diagnostics[0].code, codes::W_CFG_VACUOUS_EXPECTED);
}
}