Skip to main content

ai_agents_eval/
assertion.rs

1use ai_agents_core::Role;
2use serde::{Deserialize, Serialize};
3use serde_json::{Value, json};
4use std::collections::HashMap;
5
6use crate::evidence::{
7    ApprovalDecision, ApprovalEvidence, ApprovalTriggerEvidence, DisambiguationStatus,
8    ToolExecutionRecord, TurnEvidence,
9};
10use crate::judge::{JudgeAssertion, JudgeInput, JudgeResolver};
11
12/// Collection of assertion clauses evaluated against one turn.
13#[derive(Debug, Clone, Deserialize, Serialize, Default)]
14#[serde(deny_unknown_fields)]
15pub struct Assertion {
16    /// Current or expected state name.
17    #[serde(default)]
18    pub state: Option<String>,
19    /// Allowed current state names.
20    #[serde(default)]
21    pub state_in: Option<Vec<String>>,
22    /// State name that must not be current.
23    #[serde(default)]
24    pub state_not: Option<String>,
25    /// State name expected in transition history.
26    #[serde(default)]
27    pub state_history_contains: Option<String>,
28    /// Required substring or substrings in the response.
29    #[serde(default)]
30    pub response_contains: Option<StringList>,
31    /// Response passes when any listed substring is present.
32    #[serde(default)]
33    pub response_contains_any: Option<StringList>,
34    /// Substring or substrings that must be absent from the response.
35    #[serde(default)]
36    pub response_not_contains: Option<StringList>,
37    /// Whether the response must contain non-whitespace text.
38    #[serde(default)]
39    pub response_not_empty: Option<bool>,
40    /// Semantic response-quality judge assertion.
41    #[serde(default)]
42    pub response_semantic: Option<JudgeAssertion>,
43    /// Expected disambiguation status or evidence.
44    #[serde(default)]
45    pub disambiguation: Option<DisambiguationExpectation>,
46    /// Whether no active disambiguation should have occurred.
47    #[serde(default)]
48    pub no_disambiguation: Option<bool>,
49    /// Tool call assertion in string or object form.
50    #[serde(default)]
51    pub tool_called: Option<ToolCalledAssertion>,
52    /// LLM request assertion over captured message roles and content.
53    #[serde(default, alias = "llm_messages")]
54    pub llm_request: Option<LlmRequestAssertion>,
55    /// Approval request assertion with optional trigger and result filters.
56    #[serde(default)]
57    pub approval_requested: Option<ApprovalAssertion>,
58    /// Assertion that no approval request matches the optional filters.
59    #[serde(default)]
60    pub approval_not_requested: Option<ApprovalAssertion>,
61    /// Tool ID that must not appear in tool evidence.
62    #[serde(default)]
63    pub tool_not_called: Option<String>,
64    /// Skill ID expected in skill evidence.
65    #[serde(default)]
66    pub skill_triggered: Option<String>,
67    /// Top-level response metadata key-value expectations.
68    #[serde(default)]
69    pub metadata_contains: Option<HashMap<String, Value>>,
70    /// Dot-path assertion over response metadata.
71    #[serde(default)]
72    pub metadata_path: Option<PathAssertion>,
73    /// Dot-path assertion over runtime context.
74    #[serde(default)]
75    pub context_path: Option<PathAssertion>,
76    /// Fact assertion for actor memory evidence.
77    #[serde(default)]
78    pub facts_include: Option<FactsAssertion>,
79    /// Relationship memory assertion or evidence.
80    #[serde(default)]
81    pub relationship: Option<RelationshipAssertion>,
82    /// Persona secret reveal assertion.
83    #[serde(default)]
84    pub persona_secret_revealed: Option<SecretAssertion>,
85    /// Orchestration metadata assertion or evidence.
86    #[serde(default)]
87    pub orchestration: Option<OrchestrationAssertion>,
88    /// Observability assertion, setting, or report value.
89    #[serde(default)]
90    pub observability: Option<ObservabilityAssertion>,
91    /// LLM judge assertion or resolver for semantic quality.
92    #[serde(default)]
93    pub judge: Option<JudgeAssertion>,
94    /// Child assertions where at least one must pass.
95    #[serde(default)]
96    pub any: Option<Vec<Assertion>>,
97    /// Child assertions where every child must pass.
98    #[serde(default)]
99    pub all: Option<Vec<Assertion>>,
100    /// Child assertion that must fail.
101    #[serde(default)]
102    pub not: Option<Box<Assertion>>,
103}
104
105impl Assertion {
106    pub(crate) fn validate(&self, location: &str) -> crate::Result<()> {
107        if !self.has_simple_clause()
108            && self.any.is_none()
109            && self.all.is_none()
110            && self.not.is_none()
111        {
112            return Err(crate::EvalError::Config(format!(
113                "{location} must not be empty"
114            )));
115        }
116        for (name, children) in [("any", &self.any), ("all", &self.all)] {
117            if let Some(children) = children {
118                if children.is_empty() {
119                    return Err(crate::EvalError::Config(format!(
120                        "{location}.{name} must contain at least one assertion"
121                    )));
122                }
123                for (index, child) in children.iter().enumerate() {
124                    child.validate(&format!("{location}.{name}[{index}]"))?;
125                }
126            }
127        }
128        if let Some(child) = &self.not {
129            child.validate(&format!("{location}.not"))?;
130        }
131        for (name, values) in [
132            ("response_contains", self.response_contains.as_ref()),
133            ("response_contains_any", self.response_contains_any.as_ref()),
134            ("response_not_contains", self.response_not_contains.as_ref()),
135        ] {
136            validate_string_list(values, &format!("{location}.{name}"))?;
137        }
138        validate_slice(self.state_in.as_deref(), &format!("{location}.state_in"))?;
139        validate_map(
140            self.metadata_contains.as_ref(),
141            &format!("{location}.metadata_contains"),
142        )?;
143        if let Some(ToolCalledAssertion::Object(tool)) = &self.tool_called {
144            validate_slice(
145                tool.source_in.as_deref(),
146                &format!("{location}.tool_called.source_in"),
147            )?;
148            for (name, path) in [
149                ("args", tool.args.as_ref()),
150                ("args_original", tool.args_original.as_ref()),
151                ("args_executed", tool.args_executed.as_ref()),
152                ("result_path", tool.result_path.as_ref()),
153            ] {
154                validate_path(path, &format!("{location}.tool_called.{name}"))?;
155            }
156        }
157        if let Some(llm_request) = &self.llm_request {
158            for (name, values) in [
159                ("system_contains", llm_request.system_contains.as_ref()),
160                ("user_contains", llm_request.user_contains.as_ref()),
161                (
162                    "assistant_contains",
163                    llm_request.assistant_contains.as_ref(),
164                ),
165                ("any_contains", llm_request.any_contains.as_ref()),
166            ] {
167                validate_string_list(values, &format!("{location}.llm_request.{name}"))?;
168            }
169        }
170        for (name, approval) in [
171            ("approval_requested", self.approval_requested.as_ref()),
172            (
173                "approval_not_requested",
174                self.approval_not_requested.as_ref(),
175            ),
176        ] {
177            if let Some(ApprovalAssertion::Object(approval)) = approval {
178                validate_string_list(
179                    approval.message_contains.as_ref(),
180                    &format!("{location}.{name}.message_contains"),
181                )?;
182                for (path_name, path) in [
183                    ("original_args", approval.original_args.as_ref()),
184                    ("modified_args", approval.modified_args.as_ref()),
185                    ("effective_args", approval.effective_args.as_ref()),
186                ] {
187                    validate_path(path, &format!("{location}.{name}.{path_name}"))?;
188                }
189            }
190        }
191        validate_path(
192            self.metadata_path.as_ref(),
193            &format!("{location}.metadata_path"),
194        )?;
195        validate_path(
196            self.context_path.as_ref(),
197            &format!("{location}.context_path"),
198        )?;
199        if let Some(orchestration) = &self.orchestration {
200            validate_slice(
201                orchestration.final_agent_in.as_deref(),
202                &format!("{location}.orchestration.final_agent_in"),
203            )?;
204            validate_slice(
205                orchestration.agents_include.as_deref(),
206                &format!("{location}.orchestration.agents_include"),
207            )?;
208        }
209        if let Some(observability) = &self.observability {
210            for (name, assertions) in [
211                ("purpose_counts", &observability.purpose_counts),
212                ("status_counts", &observability.status_counts),
213            ] {
214                for (key, assertion) in assertions {
215                    validate_path(
216                        Some(assertion),
217                        &format!("{location}.observability.{name}.{key}"),
218                    )?;
219                }
220            }
221            for (index, assertion) in observability.dimension_counts.iter().enumerate() {
222                validate_path(
223                    Some(&assertion.assertion),
224                    &format!("{location}.observability.dimension_counts[{index}].assert"),
225                )?;
226            }
227        }
228        for (name, judge) in [
229            ("response_semantic", self.response_semantic.as_ref()),
230            ("judge", self.judge.as_ref()),
231        ] {
232            if judge.is_some_and(|judge| judge.criteria.is_empty()) {
233                return Err(crate::EvalError::Config(format!(
234                    "{location}.{name}.criteria must contain at least one value"
235                )));
236            }
237        }
238        Ok(())
239    }
240
241    fn has_simple_clause(&self) -> bool {
242        self.state.is_some()
243            || self.state_in.is_some()
244            || self.state_not.is_some()
245            || self.state_history_contains.is_some()
246            || self.response_contains.is_some()
247            || self.response_contains_any.is_some()
248            || self.response_not_contains.is_some()
249            || self.response_not_empty.is_some()
250            || self.response_semantic.is_some()
251            || self.disambiguation.is_some()
252            || self.no_disambiguation.is_some()
253            || self.tool_called.is_some()
254            || self.llm_request.is_some()
255            || self.approval_requested.is_some()
256            || self.approval_not_requested.is_some()
257            || self.tool_not_called.is_some()
258            || self.skill_triggered.is_some()
259            || self.metadata_contains.is_some()
260            || self.metadata_path.is_some()
261            || self.context_path.is_some()
262            || self.facts_include.is_some()
263            || self.relationship.is_some()
264            || self.persona_secret_revealed.is_some()
265            || self.orchestration.is_some()
266            || self.observability.is_some()
267            || self.judge.is_some()
268    }
269}
270
271fn validate_string_list(values: Option<&StringList>, location: &str) -> crate::Result<()> {
272    if values.is_some_and(StringList::is_empty) {
273        return Err(crate::EvalError::Config(format!(
274            "{location} must contain at least one value"
275        )));
276    }
277    Ok(())
278}
279
280fn validate_slice<T>(values: Option<&[T]>, location: &str) -> crate::Result<()> {
281    if values.is_some_and(|values| values.is_empty()) {
282        return Err(crate::EvalError::Config(format!(
283            "{location} must contain at least one value"
284        )));
285    }
286    Ok(())
287}
288
289fn validate_map<K, V>(values: Option<&HashMap<K, V>>, location: &str) -> crate::Result<()> {
290    if values.is_some_and(|values| values.is_empty()) {
291        return Err(crate::EvalError::Config(format!(
292            "{location} must contain at least one value"
293        )));
294    }
295    Ok(())
296}
297
298fn validate_path(assertion: Option<&PathAssertion>, location: &str) -> crate::Result<()> {
299    if assertion.is_some_and(|assertion| assertion.in_values.as_ref().is_some_and(Vec::is_empty)) {
300        return Err(crate::EvalError::Config(format!(
301            "{location}.in must contain at least one value"
302        )));
303    }
304    Ok(())
305}
306
307/// YAML helper accepting either one string or a list of strings.
308#[derive(Debug, Clone, Deserialize, Serialize)]
309#[serde(untagged)]
310pub enum StringList {
311    One(String),
312    Many(Vec<String>),
313}
314
315impl StringList {
316    fn is_empty(&self) -> bool {
317        matches!(self, Self::Many(values) if values.is_empty())
318    }
319
320    fn items(&self) -> Vec<String> {
321        match self {
322            Self::One(value) => vec![value.clone()],
323            Self::Many(values) => values.clone(),
324        }
325    }
326}
327
328/// String or object form for tool call assertions.
329#[derive(Debug, Clone, Deserialize, Serialize)]
330#[serde(untagged)]
331#[allow(
332    clippy::large_enum_variant,
333    reason = "boxing would change the frozen public assertion construction API"
334)]
335pub enum ToolCalledAssertion {
336    Id(String),
337    Object(ToolCalledObject),
338}
339
340/// Object form for checking tool execution evidence.
341///
342/// Every configured record predicate must match the same execution. `count` and
343/// `count_gte` are evaluated against the number of executions that satisfy all
344/// configured record predicates; `tool_called` still requires at least one match.
345#[derive(Debug, Clone, Deserialize, Serialize, Default)]
346#[serde(deny_unknown_fields)]
347pub struct ToolCalledObject {
348    /// Tool ID or requested name required on each matching execution.
349    #[serde(default)]
350    pub id: Option<String>,
351    /// Exact number of executions satisfying every configured record predicate.
352    #[serde(default)]
353    pub count: Option<usize>,
354    /// Minimum number of executions satisfying every configured record predicate.
355    #[serde(default)]
356    pub count_gte: Option<usize>,
357    /// Whether the wrapped tool implementation was invoked.
358    #[serde(default)]
359    pub executed: Option<bool>,
360    /// Whether the operation succeeded.
361    #[serde(default)]
362    pub success: Option<bool>,
363    /// Allowed snake_case source labels, including `llm`, `skill`, and `plan`.
364    #[serde(default)]
365    pub source_in: Option<Vec<String>>,
366    /// Alias for checking executed tool arguments.
367    #[serde(default)]
368    pub args: Option<PathAssertion>,
369    /// Path assertion over original tool arguments.
370    #[serde(default)]
371    pub args_original: Option<PathAssertion>,
372    /// Path assertion over executed tool arguments.
373    #[serde(default)]
374    pub args_executed: Option<PathAssertion>,
375    /// Path assertion over parsed tool output.
376    #[serde(default)]
377    pub result_path: Option<PathAssertion>,
378}
379
380/// Filters and counts for matching complete LLM requests.
381#[derive(Debug, Clone, Deserialize, Serialize, Default)]
382#[serde(deny_unknown_fields)]
383pub struct LlmRequestAssertion {
384    /// Required substrings in system messages.
385    #[serde(default)]
386    pub system_contains: Option<StringList>,
387    /// Required substrings in user messages.
388    #[serde(default)]
389    pub user_contains: Option<StringList>,
390    /// Required substrings in assistant messages.
391    #[serde(default)]
392    pub assistant_contains: Option<StringList>,
393    /// Required substrings in messages of any role.
394    #[serde(default)]
395    pub any_contains: Option<StringList>,
396    /// Exact number of matching requests required.
397    #[serde(default)]
398    pub count: Option<usize>,
399    /// Minimum number of matching requests required.
400    #[serde(default)]
401    pub count_gte: Option<usize>,
402    /// Maximum number of matching requests required.
403    #[serde(default)]
404    pub count_lte: Option<usize>,
405    /// Whether all role checks must be satisfied by the same request.
406    #[serde(default)]
407    pub same_request: Option<bool>,
408}
409
410/// Boolean or object form for approval assertions.
411#[derive(Debug, Clone, Deserialize, Serialize)]
412#[serde(untagged)]
413#[allow(
414    clippy::large_enum_variant,
415    reason = "boxing would change the frozen public assertion construction API"
416)]
417pub enum ApprovalAssertion {
418    Bool(bool),
419    Object(ApprovalAssertionObject),
420}
421
422/// Filters and counts for matching approval evidence.
423#[derive(Debug, Clone, Deserialize, Serialize, Default)]
424#[serde(deny_unknown_fields)]
425pub struct ApprovalAssertionObject {
426    /// Exact number of matching approval records required.
427    #[serde(default)]
428    pub count: Option<usize>,
429    /// Minimum number of matching approval records required.
430    #[serde(default)]
431    pub count_gte: Option<usize>,
432    /// Maximum number of matching approval records required.
433    #[serde(default)]
434    pub count_lte: Option<usize>,
435    /// Trigger fields that must match one approval record.
436    #[serde(default)]
437    pub trigger: Option<ApprovalTriggerAssertion>,
438    /// Decision returned directly by the approval handler.
439    #[serde(default)]
440    pub raw_decision: Option<ApprovalDecision>,
441    /// Decision after runtime resolution.
442    #[serde(default)]
443    pub effective_decision: Option<ApprovalDecision>,
444    /// Exact localized approval message.
445    #[serde(default)]
446    pub message: Option<String>,
447    /// Required substrings in the localized approval message.
448    #[serde(default)]
449    pub message_contains: Option<StringList>,
450    /// Exact effective rejection reason.
451    #[serde(default)]
452    pub rejection_reason: Option<String>,
453    /// Required substring in an effective rejection reason.
454    #[serde(default)]
455    pub rejection_reason_contains: Option<String>,
456    /// Exact effective resolution error.
457    #[serde(default)]
458    pub error: Option<String>,
459    /// Required substring in an effective resolution error.
460    #[serde(default)]
461    pub error_contains: Option<String>,
462    /// Path assertion over original tool arguments.
463    #[serde(default, alias = "args_original")]
464    pub original_args: Option<PathAssertion>,
465    /// Path assertion over modified tool arguments.
466    #[serde(default, alias = "args_modified")]
467    pub modified_args: Option<PathAssertion>,
468    /// Path assertion over effective tool arguments.
469    #[serde(default, alias = "args_effective")]
470    pub effective_args: Option<PathAssertion>,
471}
472
473/// Filters for normalized approval trigger fields.
474#[derive(Debug, Clone, Deserialize, Serialize, Default)]
475#[serde(deny_unknown_fields)]
476pub struct ApprovalTriggerAssertion {
477    /// Normalized trigger type: tool, condition, or state.
478    #[serde(default, rename = "type")]
479    pub type_name: Option<String>,
480    /// Tool or condition name.
481    #[serde(default)]
482    pub name: Option<String>,
483    /// Matched condition expression.
484    #[serde(default)]
485    pub matched: Option<String>,
486    /// Source state for state transition approval.
487    #[serde(default)]
488    pub from: Option<String>,
489    /// Destination state for state transition approval.
490    #[serde(default)]
491    pub to: Option<String>,
492}
493
494/// Expected disambiguation state for an assertion.
495#[derive(Debug, Clone, Deserialize, Serialize)]
496#[serde(rename_all = "snake_case")]
497pub enum DisambiguationExpectation {
498    Triggered,
499    Skipped,
500    Clarified,
501    Abandoned,
502    GiveUp,
503    Escalated,
504    BestGuess,
505    Clear,
506}
507
508/// Dot-path assertion used for metadata, context, tools, and metrics.
509#[derive(Debug, Clone, Deserialize, Serialize, Default)]
510#[serde(deny_unknown_fields)]
511pub struct PathAssertion {
512    /// Path used for file lookup, HTTP routing, or dot-path checks.
513    pub path: String,
514    /// Expected JSON value for exact equality.
515    #[serde(default)]
516    pub eq: Option<Value>,
517    /// JSON value that must not equal the actual value.
518    #[serde(default)]
519    pub neq: Option<Value>,
520    /// Allowed JSON values for membership checks.
521    #[serde(default, rename = "in")]
522    pub in_values: Option<Vec<Value>>,
523    /// String substring or array element expected in the actual value.
524    #[serde(default)]
525    pub contains: Option<Value>,
526    /// Whether the path must exist or be absent.
527    #[serde(default)]
528    pub exists: Option<bool>,
529    /// Numeric lower bound using greater-than-or-equal.
530    #[serde(default)]
531    pub gte: Option<f64>,
532    /// Numeric upper bound using less-than-or-equal.
533    #[serde(default)]
534    pub lte: Option<f64>,
535    /// Numeric lower bound using greater-than.
536    #[serde(default)]
537    pub gt: Option<f64>,
538    /// Numeric upper bound using less-than.
539    #[serde(default)]
540    pub lt: Option<f64>,
541}
542
543/// Assertion over actor facts collected after a turn.
544#[derive(Debug, Clone, Deserialize, Serialize, Default)]
545#[serde(deny_unknown_fields)]
546pub struct FactsAssertion {
547    /// Actor ID used for this scenario, turn, or assertion.
548    #[serde(default)]
549    pub actor: Option<String>,
550    /// Fact category that must be present.
551    #[serde(default)]
552    pub category: Option<String>,
553    /// Semantic claim checked by a judge.
554    #[serde(default)]
555    pub semantic: Option<String>,
556}
557
558/// Assertion over actor relationship memory evidence.
559#[derive(Debug, Clone, Deserialize, Serialize, Default)]
560#[serde(deny_unknown_fields)]
561pub struct RelationshipAssertion {
562    /// Actor ID used for this scenario, turn, or assertion.
563    #[serde(default)]
564    pub actor: Option<String>,
565    /// Whether the path must exist or be absent.
566    #[serde(default)]
567    pub exists: Option<bool>,
568    /// Relationship perspective to inspect.
569    #[serde(default)]
570    pub perspective: Option<String>,
571    /// Relationship dimension to compare.
572    #[serde(default)]
573    pub dimension: Option<String>,
574    /// Numeric lower bound using greater-than-or-equal.
575    #[serde(default)]
576    pub gte: Option<f64>,
577    /// Numeric upper bound using less-than-or-equal.
578    #[serde(default)]
579    pub lte: Option<f64>,
580    /// Numeric lower bound using greater-than.
581    #[serde(default)]
582    pub gt: Option<f64>,
583    /// Numeric upper bound using less-than.
584    #[serde(default)]
585    pub lt: Option<f64>,
586    /// Expected JSON value for exact equality.
587    #[serde(default)]
588    pub eq: Option<f64>,
589    /// Minimum interaction count expected.
590    #[serde(default)]
591    pub interaction_count_gte: Option<u64>,
592    /// Minimum notable event count expected.
593    #[serde(default)]
594    pub notable_event_count_gte: Option<usize>,
595}
596
597/// Boolean or ID form for persona secret assertions.
598#[derive(Debug, Clone, Deserialize, Serialize)]
599#[serde(untagged)]
600pub enum SecretAssertion {
601    Bool(bool),
602    Id(String),
603}
604
605/// Assertion over orchestration metadata attached to a turn.
606#[derive(Debug, Clone, Deserialize, Serialize, Default)]
607#[serde(deny_unknown_fields)]
608pub struct OrchestrationAssertion {
609    /// Expected orchestration pattern label.
610    #[serde(default)]
611    pub pattern: Option<String>,
612    /// Expected orchestration type when YAML uses the type key.
613    #[serde(default, rename = "type")]
614    pub type_name: Option<String>,
615    /// Allowed final agent IDs.
616    #[serde(default)]
617    pub final_agent_in: Option<Vec<String>>,
618    /// Agent IDs expected somewhere in orchestration metadata.
619    #[serde(default)]
620    pub agents_include: Option<Vec<String>>,
621    /// Exact number of pipeline or stage records expected.
622    #[serde(default)]
623    pub stages: Option<usize>,
624}
625
626/// Assertion over the observability report for a turn.
627#[derive(Debug, Clone, Deserialize, Serialize, Default)]
628#[serde(deny_unknown_fields)]
629pub struct ObservabilityAssertion {
630    /// Upper bound for total LLM calls.
631    #[serde(default)]
632    pub total_llm_calls_lte: Option<u64>,
633    /// Upper bound for total tool calls.
634    #[serde(default)]
635    pub total_tool_calls_lte: Option<u64>,
636    /// Upper bound for total tokens.
637    #[serde(default)]
638    pub total_tokens_lte: Option<u64>,
639    /// Upper bound for total estimated cost in USD.
640    #[serde(default)]
641    pub total_cost_usd_lte: Option<f64>,
642    /// Path assertions over counts grouped by purpose.
643    #[serde(default, deserialize_with = "deserialize_non_empty_path_map")]
644    pub purpose_counts: HashMap<String, PathAssertion>,
645    /// Path assertions over counts grouped by status.
646    #[serde(default, deserialize_with = "deserialize_non_empty_path_map")]
647    pub status_counts: HashMap<String, PathAssertion>,
648    /// Path assertions over counts matching configured dimensions.
649    #[serde(
650        default,
651        deserialize_with = "deserialize_non_empty_dimension_assertions"
652    )]
653    pub dimension_counts: Vec<ObservabilityDimensionAssertion>,
654}
655
656/// Assertion over observability metrics matching all listed dimensions.
657#[derive(Debug, Clone, Deserialize, Serialize, Default)]
658#[serde(deny_unknown_fields)]
659pub struct ObservabilityDimensionAssertion {
660    #[serde(default, deserialize_with = "deserialize_non_empty_string_map")]
661    pub match_dimensions: HashMap<String, String>,
662    #[serde(rename = "assert")]
663    pub assertion: PathAssertion,
664}
665
666fn deserialize_non_empty_path_map<'de, D>(
667    deserializer: D,
668) -> std::result::Result<HashMap<String, PathAssertion>, D::Error>
669where
670    D: serde::Deserializer<'de>,
671{
672    let values = HashMap::deserialize(deserializer)?;
673    if values.is_empty() {
674        return Err(serde::de::Error::custom(
675            "assertion collection must contain at least one value",
676        ));
677    }
678    Ok(values)
679}
680
681fn deserialize_non_empty_dimension_assertions<'de, D>(
682    deserializer: D,
683) -> std::result::Result<Vec<ObservabilityDimensionAssertion>, D::Error>
684where
685    D: serde::Deserializer<'de>,
686{
687    let values = Vec::deserialize(deserializer)?;
688    if values.is_empty() {
689        return Err(serde::de::Error::custom(
690            "assertion collection must contain at least one value",
691        ));
692    }
693    Ok(values)
694}
695
696fn deserialize_non_empty_string_map<'de, D>(
697    deserializer: D,
698) -> std::result::Result<HashMap<String, String>, D::Error>
699where
700    D: serde::Deserializer<'de>,
701{
702    let values = HashMap::deserialize(deserializer)?;
703    if values.is_empty() {
704        return Err(serde::de::Error::custom(
705            "assertion collection must contain at least one value",
706        ));
707    }
708    Ok(values)
709}
710
711/// Result detail for one evaluated assertion clause.
712#[derive(Debug, Clone, Serialize)]
713pub struct AssertionResultDetail {
714    /// Stable name of the assertion that produced this detail.
715    pub assertion: String,
716    /// Passed count or boolean result.
717    pub passed: bool,
718    /// Actual value observed during evaluation.
719    pub actual: Value,
720    /// Expected assertion object for a generated turn.
721    pub expected: Value,
722    /// Optional failure message for debugging.
723    pub message: Option<String>,
724}
725
726/// Final outcome returned by assertion evaluation.
727pub enum AssertionOutcome {
728    Passed(Vec<AssertionResultDetail>),
729    Failed(Vec<AssertionResultDetail>),
730    Error(String),
731}
732
733impl AssertionResultDetail {
734    fn pass(name: impl Into<String>, actual: Value, expected: Value) -> Self {
735        Self {
736            assertion: name.into(),
737            passed: true,
738            actual,
739            expected,
740            message: None,
741        }
742    }
743
744    fn fail(
745        name: impl Into<String>,
746        actual: Value,
747        expected: Value,
748        message: impl Into<String>,
749    ) -> Self {
750        Self {
751            assertion: name.into(),
752            passed: false,
753            actual,
754            expected,
755            message: Some(message.into()),
756        }
757    }
758}
759
760/// Runtime inputs needed while evaluating one assertion tree.
761#[derive(Clone, Copy)]
762pub struct AssertionEvalContext<'a> {
763    /// Full assertion-time evidence for this turn.
764    pub evidence: &'a TurnEvidence,
765    /// Assistant response text or redacted output value.
766    pub response: &'a str,
767    /// Optional user input for judge prompt context.
768    pub user_input: Option<&'a str>,
769    /// Optional scenario ID for judge prompt context.
770    pub scenario_id: Option<&'a str>,
771    /// Optional language label for filtering, metrics, and judge context.
772    pub language: Option<&'a str>,
773    /// Optional resolver for semantic judge assertions.
774    pub judge_resolver: Option<&'a JudgeResolver>,
775}
776
777pub async fn evaluate_assertion(
778    assertion: &Assertion,
779    context: AssertionEvalContext<'_>,
780) -> AssertionOutcome {
781    let mut details = Vec::new();
782
783    if let Some(children) = &assertion.all {
784        for child in children {
785            match evaluate_assertion_boxed(child, context).await {
786                AssertionOutcome::Passed(mut d) => details.append(&mut d),
787                AssertionOutcome::Failed(mut d) => {
788                    details.append(&mut d);
789                    return AssertionOutcome::Failed(details);
790                }
791                AssertionOutcome::Error(e) => return AssertionOutcome::Error(e),
792            }
793        }
794        details.push(AssertionResultDetail::pass("all", json!(true), json!(true)));
795    }
796
797    if let Some(children) = &assertion.any {
798        let mut failures = Vec::new();
799        for child in children {
800            match evaluate_assertion_boxed(child, context).await {
801                AssertionOutcome::Passed(mut d) => {
802                    details.append(&mut d);
803                    details.push(AssertionResultDetail::pass("any", json!(true), json!(true)));
804                    return AssertionOutcome::Passed(details);
805                }
806                AssertionOutcome::Failed(mut d) => failures.append(&mut d),
807                AssertionOutcome::Error(e) => failures.push(AssertionResultDetail::fail(
808                    "any_branch_error",
809                    json!(e),
810                    json!("pass"),
811                    "branch error",
812                )),
813            }
814        }
815        details.extend(failures);
816        details.push(AssertionResultDetail::fail(
817            "any",
818            json!(false),
819            json!(true),
820            "no branch passed",
821        ));
822    }
823
824    if let Some(child) = &assertion.not {
825        match evaluate_assertion_boxed(child, context).await {
826            AssertionOutcome::Passed(_) => details.push(AssertionResultDetail::fail(
827                "not",
828                json!(true),
829                json!(false),
830                "child assertion passed",
831            )),
832            AssertionOutcome::Failed(_) => details.push(AssertionResultDetail::pass(
833                "not",
834                json!(false),
835                json!(false),
836            )),
837            AssertionOutcome::Error(e) => return AssertionOutcome::Error(e),
838        }
839    }
840
841    evaluate_simple(assertion, context, &mut details).await;
842
843    if details.iter().any(|d| !d.passed) {
844        AssertionOutcome::Failed(details)
845    } else {
846        AssertionOutcome::Passed(details)
847    }
848}
849
850fn evaluate_assertion_boxed<'a>(
851    assertion: &'a Assertion,
852    context: AssertionEvalContext<'a>,
853) -> std::pin::Pin<Box<dyn std::future::Future<Output = AssertionOutcome> + Send + 'a>> {
854    Box::pin(evaluate_assertion(assertion, context))
855}
856
857async fn evaluate_simple(
858    assertion: &Assertion,
859    context: AssertionEvalContext<'_>,
860    details: &mut Vec<AssertionResultDetail>,
861) {
862    let evidence = context.evidence;
863    let response = context.response;
864    if let Some(expected) = &assertion.state {
865        check_eq("state", evidence.state.clone(), expected.clone(), details);
866    }
867    if let Some(expected) = &assertion.state_in {
868        push_bool(
869            "state_in",
870            evidence
871                .state
872                .as_ref()
873                .is_some_and(|s| expected.contains(s)),
874            json!(evidence.state),
875            json!(expected),
876            details,
877        );
878    }
879    if let Some(expected) = &assertion.state_not {
880        push_bool(
881            "state_not",
882            evidence.state.as_ref().is_none_or(|s| s != expected),
883            json!(evidence.state),
884            json!(expected),
885            details,
886        );
887    }
888    if let Some(expected) = &assertion.state_history_contains {
889        let passed = evidence
890            .state_history
891            .iter()
892            .any(|event| &event.to == expected || &event.from == expected);
893        push_bool(
894            "state_history_contains",
895            passed,
896            json!(evidence.state_history),
897            json!(expected),
898            details,
899        );
900    }
901    if let Some(expected) = &assertion.response_contains {
902        for item in expected.items() {
903            push_bool(
904                "response_contains",
905                response.contains(&item),
906                json!(response),
907                json!(item),
908                details,
909            );
910        }
911    }
912    if let Some(expected) = &assertion.response_contains_any {
913        let items = expected.items();
914        push_bool(
915            "response_contains_any",
916            items.iter().any(|item| response.contains(item)),
917            json!(response),
918            json!(items),
919            details,
920        );
921    }
922    if let Some(expected) = &assertion.response_not_contains {
923        for item in expected.items() {
924            push_bool(
925                "response_not_contains",
926                !response.contains(&item),
927                json!(response),
928                json!(item),
929                details,
930            );
931        }
932    }
933    if let Some(expected) = assertion.response_not_empty {
934        push_bool(
935            "response_not_empty",
936            (!response.trim().is_empty()) == expected,
937            json!(response),
938            json!(expected),
939            details,
940        );
941    }
942    if let Some(expected) = &assertion.disambiguation {
943        let actual = evidence.disambiguation.as_ref().map(|d| &d.status);
944        push_bool(
945            "disambiguation",
946            actual.is_some_and(|status| disambiguation_matches(status, expected)),
947            json!(actual),
948            json!(expected),
949            details,
950        );
951    }
952    if let Some(expected) = assertion.no_disambiguation {
953        let triggered = evidence.disambiguation.as_ref().is_some_and(|d| {
954            matches!(
955                d.status,
956                DisambiguationStatus::Triggered
957                    | DisambiguationStatus::Clarified
958                    | DisambiguationStatus::BestGuess
959            )
960        });
961        push_bool(
962            "no_disambiguation",
963            (!triggered) == expected,
964            json!(!triggered),
965            json!(expected),
966            details,
967        );
968    }
969    if let Some(tool) = &assertion.tool_called {
970        evaluate_tool_called(tool, evidence, details);
971    }
972    if let Some(llm_request) = &assertion.llm_request {
973        evaluate_llm_request(llm_request, evidence, details);
974    }
975    if let Some(approval) = &assertion.approval_requested {
976        evaluate_approval_requested(approval, evidence, details);
977    }
978    if let Some(approval) = &assertion.approval_not_requested {
979        evaluate_approval_not_requested(approval, evidence, details);
980    }
981    if let Some(tool_id) = &assertion.tool_not_called {
982        let passed = !evidence
983            .tool_executions
984            .iter()
985            .any(|record| &record.tool_id == tool_id || &record.requested_name == tool_id);
986        push_bool(
987            "tool_not_called",
988            passed,
989            json!(tool_id),
990            json!("not called"),
991            details,
992        );
993    }
994    if let Some(skill_id) = &assertion.skill_triggered {
995        let passed = evidence.skill.as_ref().is_some_and(|skill| {
996            skill.selected_skill_id.as_deref() == Some(skill_id)
997                || skill.executed_skill_id.as_deref() == Some(skill_id)
998        });
999        push_bool(
1000            "skill_triggered",
1001            passed,
1002            json!(evidence.skill),
1003            json!(skill_id),
1004            details,
1005        );
1006    }
1007    if let Some(expected) = &assertion.metadata_contains {
1008        evaluate_metadata_contains(expected, evidence, details);
1009    }
1010    if let Some(path) = &assertion.metadata_path {
1011        evaluate_path(
1012            "metadata_path",
1013            evidence.response_metadata.as_ref(),
1014            path,
1015            details,
1016        );
1017    }
1018    if let Some(path) = &assertion.context_path {
1019        evaluate_path("context_path", Some(&evidence.context), path, details);
1020    }
1021    if let Some(expected) = &assertion.facts_include {
1022        evaluate_facts(expected, evidence, context.judge_resolver, details).await;
1023    }
1024    if let Some(expected) = &assertion.relationship {
1025        evaluate_relationship(expected, evidence, details);
1026    }
1027    if let Some(expected) = &assertion.persona_secret_revealed {
1028        evaluate_secret(expected, evidence, details);
1029    }
1030    if let Some(expected) = &assertion.orchestration {
1031        evaluate_orchestration(expected, evidence, details);
1032    }
1033    if let Some(expected) = &assertion.observability {
1034        evaluate_observability(expected, evidence, details);
1035    }
1036    if let Some(criteria) = assertion
1037        .judge
1038        .as_ref()
1039        .or(assertion.response_semantic.as_ref())
1040    {
1041        if let Some(resolver) = context.judge_resolver {
1042            match resolver.resolve(criteria.llm.as_deref()) {
1043                Ok(judge) => match judge
1044                    .evaluate_input(
1045                        JudgeInput {
1046                            response,
1047                            user_input: context.user_input,
1048                            scenario_id: context.scenario_id,
1049                            language: context.language,
1050                        },
1051                        criteria,
1052                    )
1053                    .await
1054                {
1055                    Ok(result) => push_bool(
1056                        "judge",
1057                        result.passed,
1058                        json!(result.overall_score),
1059                        json!(criteria.pass_threshold),
1060                        details,
1061                    ),
1062                    Err(error) => details.push(AssertionResultDetail::fail(
1063                        "judge",
1064                        json!(error.to_string()),
1065                        json!("valid judge result"),
1066                        "judge failed",
1067                    )),
1068                },
1069                Err(error) => details.push(AssertionResultDetail::fail(
1070                    "judge",
1071                    json!(error.to_string()),
1072                    json!("available judge LLM"),
1073                    "judge failed",
1074                )),
1075            }
1076        } else {
1077            details.push(AssertionResultDetail::fail(
1078                "judge",
1079                json!(null),
1080                json!("judge configured"),
1081                "no judge LLM available",
1082            ));
1083        }
1084    }
1085}
1086
1087fn check_eq<T: PartialEq + Serialize>(
1088    name: &str,
1089    actual: Option<T>,
1090    expected: T,
1091    details: &mut Vec<AssertionResultDetail>,
1092) {
1093    push_bool(
1094        name,
1095        actual.as_ref().is_some_and(|a| *a == expected),
1096        json!(actual),
1097        json!(expected),
1098        details,
1099    );
1100}
1101fn push_bool(
1102    name: &str,
1103    passed: bool,
1104    actual: Value,
1105    expected: Value,
1106    details: &mut Vec<AssertionResultDetail>,
1107) {
1108    if passed {
1109        details.push(AssertionResultDetail::pass(name, actual, expected));
1110    } else {
1111        details.push(AssertionResultDetail::fail(
1112            name,
1113            actual,
1114            expected,
1115            "assertion failed",
1116        ));
1117    }
1118}
1119
1120fn disambiguation_matches(
1121    actual: &DisambiguationStatus,
1122    expected: &DisambiguationExpectation,
1123) -> bool {
1124    matches!(
1125        (actual, expected),
1126        (
1127            DisambiguationStatus::Triggered,
1128            DisambiguationExpectation::Triggered
1129        ) | (
1130            DisambiguationStatus::Skipped,
1131            DisambiguationExpectation::Skipped
1132        ) | (
1133            DisambiguationStatus::Clarified,
1134            DisambiguationExpectation::Clarified
1135        ) | (
1136            DisambiguationStatus::Abandoned,
1137            DisambiguationExpectation::Abandoned
1138        ) | (
1139            DisambiguationStatus::GiveUp,
1140            DisambiguationExpectation::GiveUp
1141        ) | (
1142            DisambiguationStatus::Escalated,
1143            DisambiguationExpectation::Escalated
1144        ) | (
1145            DisambiguationStatus::BestGuess,
1146            DisambiguationExpectation::BestGuess
1147        ) | (
1148            DisambiguationStatus::Clear,
1149            DisambiguationExpectation::Clear
1150        )
1151    )
1152}
1153
1154fn evaluate_tool_called(
1155    assertion: &ToolCalledAssertion,
1156    evidence: &TurnEvidence,
1157    details: &mut Vec<AssertionResultDetail>,
1158) {
1159    let (id, object) = match assertion {
1160        ToolCalledAssertion::Id(id) => (Some(id.as_str()), None),
1161        ToolCalledAssertion::Object(object) => (object.id.as_deref(), Some(object)),
1162    };
1163    let count = evidence
1164        .tool_executions
1165        .iter()
1166        .filter(|record| tool_execution_matches(record, id, object))
1167        .count();
1168    let mut passed = count > 0;
1169    if let Some(object) = object {
1170        if let Some(expected) = object.count {
1171            passed &= count == expected;
1172        }
1173        if let Some(expected) = object.count_gte {
1174            passed &= count >= expected;
1175        }
1176    }
1177    push_bool(
1178        "tool_called",
1179        passed,
1180        json!(count),
1181        json!(assertion),
1182        details,
1183    );
1184}
1185
1186fn tool_execution_matches(
1187    record: &ToolExecutionRecord,
1188    id: Option<&str>,
1189    object: Option<&ToolCalledObject>,
1190) -> bool {
1191    if id.is_some_and(|id| record.tool_id != id && record.requested_name != id) {
1192        return false;
1193    }
1194    let Some(object) = object else {
1195        return true;
1196    };
1197
1198    object
1199        .executed
1200        .is_none_or(|executed| record.executed == executed)
1201        && object
1202            .success
1203            .is_none_or(|success| record.success == success)
1204        && object.source_in.as_ref().is_none_or(|sources| {
1205            let actual = serde_plain_source(&record.source);
1206            sources.iter().any(|source| source == &actual)
1207        })
1208        && object
1209            .args
1210            .as_ref()
1211            .is_none_or(|path| path_matches(&record.arguments_executed, path))
1212        && object
1213            .args_executed
1214            .as_ref()
1215            .is_none_or(|path| path_matches(&record.arguments_executed, path))
1216        && object
1217            .args_original
1218            .as_ref()
1219            .is_none_or(|path| path_matches(&record.arguments_original, path))
1220        && object.result_path.as_ref().is_none_or(|path| {
1221            record
1222                .output
1223                .as_ref()
1224                .is_some_and(|value| path_matches(value, path))
1225        })
1226}
1227
1228fn evaluate_llm_request(
1229    assertion: &LlmRequestAssertion,
1230    evidence: &TurnEvidence,
1231    details: &mut Vec<AssertionResultDetail>,
1232) {
1233    let checks = llm_message_checks(assertion);
1234    let same_request = assertion.same_request.unwrap_or(true);
1235    let matching_count = evidence
1236        .llm_requests
1237        .iter()
1238        .filter(|request| {
1239            if checks.is_empty() {
1240                return true;
1241            }
1242            if same_request {
1243                checks.iter().all(|(role, text)| {
1244                    request.messages.iter().any(|message| {
1245                        role.is_none_or(|role| message.role == role)
1246                            && message.content.contains(text)
1247                    })
1248                })
1249            } else {
1250                checks.iter().any(|(role, text)| {
1251                    request.messages.iter().any(|message| {
1252                        role.is_none_or(|role| message.role == role)
1253                            && message.content.contains(text)
1254                    })
1255                })
1256            }
1257        })
1258        .count();
1259    let content_matches = if same_request {
1260        matching_count > 0
1261    } else {
1262        checks.iter().all(|(role, text)| {
1263            evidence.llm_requests.iter().any(|request| {
1264                request.messages.iter().any(|message| {
1265                    role.is_none_or(|role| message.role == role) && message.content.contains(text)
1266                })
1267            })
1268        })
1269    };
1270    let has_count_constraint =
1271        assertion.count.is_some() || assertion.count_gte.is_some() || assertion.count_lte.is_some();
1272    let passed = (checks.is_empty() || content_matches)
1273        && assertion.count.is_none_or(|count| matching_count == count)
1274        && assertion
1275            .count_gte
1276            .is_none_or(|count| matching_count >= count)
1277        && assertion
1278            .count_lte
1279            .is_none_or(|count| matching_count <= count)
1280        && (has_count_constraint || matching_count > 0);
1281    let mut roles: Vec<&str> = checks
1282        .iter()
1283        .map(|(role, _)| match role {
1284            Some(Role::System) => "system",
1285            Some(Role::User) => "user",
1286            Some(Role::Assistant) => "assistant",
1287            _ => "any",
1288        })
1289        .collect();
1290    roles.sort_unstable();
1291    roles.dedup();
1292    push_bool(
1293        "llm_request",
1294        passed,
1295        json!({
1296            "matched_count": matching_count,
1297            "total_count": evidence.llm_requests.len(),
1298        }),
1299        json!({
1300            "roles": roles,
1301            "contains_checks": checks.len(),
1302            "count": assertion.count,
1303            "count_gte": assertion.count_gte,
1304            "count_lte": assertion.count_lte,
1305            "same_request": same_request,
1306        }),
1307        details,
1308    );
1309}
1310
1311fn llm_message_checks(assertion: &LlmRequestAssertion) -> Vec<(Option<Role>, String)> {
1312    let mut checks = Vec::new();
1313    for (role, contains) in [
1314        (Some(Role::System), assertion.system_contains.as_ref()),
1315        (Some(Role::User), assertion.user_contains.as_ref()),
1316        (Some(Role::Assistant), assertion.assistant_contains.as_ref()),
1317        (None, assertion.any_contains.as_ref()),
1318    ] {
1319        if let Some(contains) = contains {
1320            checks.extend(contains.items().into_iter().map(|text| (role, text)));
1321        }
1322    }
1323    checks
1324}
1325
1326fn evaluate_approval_requested(
1327    assertion: &ApprovalAssertion,
1328    evidence: &TurnEvidence,
1329    details: &mut Vec<AssertionResultDetail>,
1330) {
1331    let (matched, expected) = approval_match_counts(assertion, &evidence.approvals);
1332    let passed = match assertion {
1333        ApprovalAssertion::Bool(expected) => (matched > 0) == *expected,
1334        ApprovalAssertion::Object(object) => {
1335            matched > 0
1336                && object.count.is_none_or(|count| matched == count)
1337                && object.count_gte.is_none_or(|count| matched >= count)
1338                && object.count_lte.is_none_or(|count| matched <= count)
1339        }
1340    };
1341    push_bool(
1342        "approval_requested",
1343        passed,
1344        json!({"matched_count": matched, "total_count": evidence.approvals.len()}),
1345        expected,
1346        details,
1347    );
1348}
1349
1350fn evaluate_approval_not_requested(
1351    assertion: &ApprovalAssertion,
1352    evidence: &TurnEvidence,
1353    details: &mut Vec<AssertionResultDetail>,
1354) {
1355    let (matched, expected) = approval_match_counts(assertion, &evidence.approvals);
1356    let not_requested = matched == 0;
1357    let passed = match assertion {
1358        ApprovalAssertion::Bool(expected) => not_requested == *expected,
1359        ApprovalAssertion::Object(_) => not_requested,
1360    };
1361    push_bool(
1362        "approval_not_requested",
1363        passed,
1364        json!({"matched_count": matched, "total_count": evidence.approvals.len()}),
1365        expected,
1366        details,
1367    );
1368}
1369
1370fn approval_match_counts(
1371    assertion: &ApprovalAssertion,
1372    approvals: &[ApprovalEvidence],
1373) -> (usize, Value) {
1374    match assertion {
1375        ApprovalAssertion::Bool(expected) => (approvals.len(), json!(expected)),
1376        ApprovalAssertion::Object(object) => {
1377            let count = approvals
1378                .iter()
1379                .filter(|approval| approval_record_matches(approval, object))
1380                .count();
1381            (count, approval_assertion_summary(object))
1382        }
1383    }
1384}
1385
1386fn approval_record_matches(
1387    approval: &ApprovalEvidence,
1388    assertion: &ApprovalAssertionObject,
1389) -> bool {
1390    assertion
1391        .trigger
1392        .as_ref()
1393        .is_none_or(|trigger| approval_trigger_matches(&approval.trigger, trigger))
1394        && assertion
1395            .raw_decision
1396            .is_none_or(|decision| approval.raw_decision == decision)
1397        && assertion
1398            .effective_decision
1399            .is_none_or(|decision| approval.effective_decision == decision)
1400        && assertion
1401            .message
1402            .as_ref()
1403            .is_none_or(|message| approval.message == *message)
1404        && assertion.message_contains.as_ref().is_none_or(|items| {
1405            items
1406                .items()
1407                .iter()
1408                .all(|item| approval.message.contains(item))
1409        })
1410        && assertion
1411            .rejection_reason
1412            .as_ref()
1413            .is_none_or(|reason| approval.rejection_reason.as_ref() == Some(reason))
1414        && assertion
1415            .rejection_reason_contains
1416            .as_ref()
1417            .is_none_or(|text| {
1418                approval
1419                    .rejection_reason
1420                    .as_ref()
1421                    .is_some_and(|reason| reason.contains(text))
1422            })
1423        && assertion
1424            .error
1425            .as_ref()
1426            .is_none_or(|error| approval.error.as_ref() == Some(error))
1427        && assertion.error_contains.as_ref().is_none_or(|text| {
1428            approval
1429                .error
1430                .as_ref()
1431                .is_some_and(|error| error.contains(text))
1432        })
1433        && argument_path_matches(
1434            approval.original_args.as_ref(),
1435            assertion.original_args.as_ref(),
1436        )
1437        && argument_path_matches(
1438            approval.modified_args.as_ref(),
1439            assertion.modified_args.as_ref(),
1440        )
1441        && argument_path_matches(
1442            approval.effective_args.as_ref(),
1443            assertion.effective_args.as_ref(),
1444        )
1445}
1446
1447fn argument_path_matches(value: Option<&Value>, assertion: Option<&PathAssertion>) -> bool {
1448    assertion.is_none_or(|assertion| value.is_some_and(|value| path_matches(value, assertion)))
1449}
1450
1451fn approval_trigger_matches(
1452    trigger: &ApprovalTriggerEvidence,
1453    assertion: &ApprovalTriggerAssertion,
1454) -> bool {
1455    let (type_name, name, matched, from, to) = match trigger {
1456        ApprovalTriggerEvidence::Tool { name } => ("tool", Some(name.as_str()), None, None, None),
1457        ApprovalTriggerEvidence::Condition { name, matched } => (
1458            "condition",
1459            Some(name.as_str()),
1460            Some(matched.as_str()),
1461            None,
1462            None,
1463        ),
1464        ApprovalTriggerEvidence::State { from, to } => {
1465            ("state", None, None, from.as_deref(), Some(to.as_str()))
1466        }
1467    };
1468    assertion
1469        .type_name
1470        .as_deref()
1471        .is_none_or(|value| value == type_name)
1472        && assertion
1473            .name
1474            .as_deref()
1475            .is_none_or(|value| Some(value) == name)
1476        && assertion
1477            .matched
1478            .as_deref()
1479            .is_none_or(|value| Some(value) == matched)
1480        && assertion
1481            .from
1482            .as_deref()
1483            .is_none_or(|value| Some(value) == from)
1484        && assertion
1485            .to
1486            .as_deref()
1487            .is_none_or(|value| Some(value) == to)
1488}
1489
1490fn approval_assertion_summary(assertion: &ApprovalAssertionObject) -> Value {
1491    json!({
1492        "count": assertion.count,
1493        "count_gte": assertion.count_gte,
1494        "count_lte": assertion.count_lte,
1495        "trigger": assertion.trigger,
1496        "raw_decision": assertion.raw_decision,
1497        "effective_decision": assertion.effective_decision,
1498        "message_check": assertion.message.is_some() || assertion.message_contains.is_some(),
1499        "rejection_reason_check": assertion.rejection_reason.is_some() || assertion.rejection_reason_contains.is_some(),
1500        "error_check": assertion.error.is_some() || assertion.error_contains.is_some(),
1501        "original_args_path": assertion.original_args.as_ref().map(|value| value.path.as_str()),
1502        "modified_args_path": assertion.modified_args.as_ref().map(|value| value.path.as_str()),
1503        "effective_args_path": assertion.effective_args.as_ref().map(|value| value.path.as_str()),
1504    })
1505}
1506
1507fn serde_plain_source(source: &crate::evidence::ToolExecutionSource) -> String {
1508    serde_json::to_string(source)
1509        .unwrap_or_default()
1510        .trim_matches('"')
1511        .to_string()
1512}
1513fn evaluate_metadata_contains(
1514    expected: &HashMap<String, Value>,
1515    evidence: &TurnEvidence,
1516    details: &mut Vec<AssertionResultDetail>,
1517) {
1518    let metadata = evidence.response_metadata.as_ref();
1519    let passed = metadata.is_some_and(|metadata| {
1520        expected
1521            .iter()
1522            .all(|(key, expected)| metadata.get(key) == Some(expected))
1523    }) || (expected.is_empty() && metadata.is_none());
1524    push_bool(
1525        "metadata_contains",
1526        passed,
1527        json!(metadata),
1528        json!(expected),
1529        details,
1530    );
1531}
1532
1533fn evaluate_path(
1534    name: &str,
1535    root: Option<&Value>,
1536    assertion: &PathAssertion,
1537    details: &mut Vec<AssertionResultDetail>,
1538) {
1539    let actual = root.and_then(|value| get_path(value, &assertion.path));
1540    push_bool(
1541        name,
1542        path_actual_matches(actual, assertion),
1543        json!(actual),
1544        json!(assertion),
1545        details,
1546    );
1547}
1548fn path_matches(root: &Value, assertion: &PathAssertion) -> bool {
1549    path_actual_matches(get_path(root, &assertion.path), assertion)
1550}
1551
1552fn path_actual_matches(actual: Option<&Value>, assertion: &PathAssertion) -> bool {
1553    if let Some(exists) = assertion.exists
1554        && exists != actual.is_some()
1555    {
1556        return false;
1557    }
1558    let Some(actual) = actual else {
1559        return assertion.exists == Some(false);
1560    };
1561    if let Some(expected) = &assertion.eq
1562        && actual != expected
1563    {
1564        return false;
1565    }
1566    if let Some(expected) = &assertion.neq
1567        && actual == expected
1568    {
1569        return false;
1570    }
1571    if let Some(values) = &assertion.in_values
1572        && !values.contains(actual)
1573    {
1574        return false;
1575    }
1576    if let Some(expected) = &assertion.contains {
1577        let contains = match (actual, expected) {
1578            (Value::String(a), Value::String(e)) => a.contains(e),
1579            (Value::Array(arr), e) => arr.contains(e),
1580            _ => false,
1581        };
1582        if !contains {
1583            return false;
1584        }
1585    }
1586    let has_numeric_bound = assertion.gte.is_some()
1587        || assertion.lte.is_some()
1588        || assertion.gt.is_some()
1589        || assertion.lt.is_some();
1590    let actual_number = has_numeric_bound.then(|| actual.as_f64()).flatten();
1591    if has_numeric_bound && actual_number.is_none() {
1592        return false;
1593    }
1594    if let Some(expected) = assertion.gte
1595        && actual_number.is_none_or(|actual| actual < expected)
1596    {
1597        return false;
1598    }
1599    if let Some(expected) = assertion.lte
1600        && actual_number.is_none_or(|actual| actual > expected)
1601    {
1602        return false;
1603    }
1604    if let Some(expected) = assertion.gt
1605        && actual_number.is_none_or(|actual| actual <= expected)
1606    {
1607        return false;
1608    }
1609    if let Some(expected) = assertion.lt
1610        && actual_number.is_none_or(|actual| actual >= expected)
1611    {
1612        return false;
1613    }
1614    true
1615}
1616
1617fn get_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
1618    if path.is_empty() {
1619        return Some(value);
1620    }
1621    let mut current = value;
1622    for part in path.split('.') {
1623        current = current.get(part)?;
1624    }
1625    Some(current)
1626}
1627
1628async fn evaluate_facts(
1629    assertion: &FactsAssertion,
1630    evidence: &TurnEvidence,
1631    judge_resolver: Option<&JudgeResolver>,
1632    details: &mut Vec<AssertionResultDetail>,
1633) {
1634    let Some(fact_evidence) = &evidence.facts else {
1635        push_bool(
1636            "facts_include",
1637            false,
1638            json!(null),
1639            json!(assertion),
1640            details,
1641        );
1642        return;
1643    };
1644    if let Some(actor) = &assertion.actor
1645        && fact_evidence.actor_id.as_deref() != Some(actor.as_str())
1646    {
1647        push_bool(
1648            "facts_include",
1649            false,
1650            json!(fact_evidence.actor_id),
1651            json!(actor),
1652            details,
1653        );
1654        return;
1655    }
1656    let facts: Vec<Value> = fact_evidence
1657        .facts
1658        .iter()
1659        .filter(|fact| {
1660            assertion.category.as_ref().is_none_or(|category| {
1661                fact.get("category")
1662                    .map(|value| value.to_string().trim_matches('"').to_string())
1663                    .is_some_and(|actual| actual == *category || actual.ends_with(category))
1664            })
1665        })
1666        .cloned()
1667        .collect();
1668    let mut passed = !facts.is_empty();
1669    if let Some(semantic) = &assertion.semantic {
1670        if let Some(resolver) = judge_resolver {
1671            match resolver.resolve(None) {
1672                Ok(judge) => {
1673                    let criteria = JudgeAssertion {
1674                        llm: None,
1675                        pass_threshold: 0.75,
1676                        criteria: vec![crate::judge::JudgeCriterion::Text(format!(
1677                            "The fact set supports this claim: {}",
1678                            semantic
1679                        ))],
1680                    };
1681                    let fact_text = serde_json::to_string(&facts).unwrap_or_default();
1682                    match judge.evaluate(&fact_text, &criteria).await {
1683                        Ok(result) => passed &= result.passed,
1684                        Err(error) => {
1685                            details.push(AssertionResultDetail::fail(
1686                                "facts_include",
1687                                json!(error.to_string()),
1688                                json!(semantic),
1689                                "fact semantic judge failed",
1690                            ));
1691                            return;
1692                        }
1693                    }
1694                }
1695                Err(error) => {
1696                    details.push(AssertionResultDetail::fail(
1697                        "facts_include",
1698                        json!(error.to_string()),
1699                        json!(semantic),
1700                        "fact semantic judge failed",
1701                    ));
1702                    return;
1703                }
1704            }
1705        } else {
1706            details.push(AssertionResultDetail::fail(
1707                "facts_include",
1708                json!(null),
1709                json!(semantic),
1710                "semantic fact assertion requires a judge LLM",
1711            ));
1712            return;
1713        }
1714    }
1715    push_bool(
1716        "facts_include",
1717        passed,
1718        json!(facts),
1719        json!(assertion),
1720        details,
1721    );
1722}
1723
1724fn evaluate_relationship(
1725    assertion: &RelationshipAssertion,
1726    evidence: &TurnEvidence,
1727    details: &mut Vec<AssertionResultDetail>,
1728) {
1729    let Some(rel) = &evidence.relationship else {
1730        push_bool(
1731            "relationship",
1732            assertion.exists == Some(false),
1733            json!(null),
1734            json!(assertion),
1735            details,
1736        );
1737        return;
1738    };
1739    if let Some(actor) = &assertion.actor
1740        && rel.actor_id.as_deref() != Some(actor.as_str())
1741    {
1742        push_bool(
1743            "relationship",
1744            false,
1745            json!(rel.actor_id),
1746            json!(actor),
1747            details,
1748        );
1749        return;
1750    }
1751    let current = rel.current.as_ref();
1752    let mut passed = assertion
1753        .exists
1754        .map(|expected| expected == current.is_some())
1755        .unwrap_or(true);
1756    let perspective = assertion.perspective.as_deref().unwrap_or("agent_to_actor");
1757    if !rel.available_perspectives.iter().any(|p| p == perspective) {
1758        details.push(AssertionResultDetail::fail(
1759            "relationship",
1760            json!(rel.available_perspectives),
1761            json!(perspective),
1762            "relationship perspective unavailable for model",
1763        ));
1764        return;
1765    }
1766    if let Some(count) = assertion.interaction_count_gte {
1767        let actual = current
1768            .and_then(|v| v.get("interaction_count"))
1769            .and_then(Value::as_u64)
1770            .unwrap_or(0);
1771        passed &= actual >= count;
1772    }
1773    if let Some(count) = assertion.notable_event_count_gte {
1774        let actual = current
1775            .and_then(|v| v.get("notable_events"))
1776            .and_then(Value::as_array)
1777            .map(Vec::len)
1778            .unwrap_or(0);
1779        passed &= actual >= count;
1780    }
1781    if let Some(dimension) = &assertion.dimension {
1782        let value = relationship_dimension_value(current, perspective, dimension);
1783        let mut dim_pass = value.is_some();
1784        if let Some(v) = assertion.gte {
1785            dim_pass &= value.unwrap_or(f64::NAN) >= v;
1786        }
1787        if let Some(v) = assertion.lte {
1788            dim_pass &= value.unwrap_or(f64::NAN) <= v;
1789        }
1790        if let Some(v) = assertion.gt {
1791            dim_pass &= value.unwrap_or(f64::NAN) > v;
1792        }
1793        if let Some(v) = assertion.lt {
1794            dim_pass &= value.unwrap_or(f64::NAN) < v;
1795        }
1796        if let Some(v) = assertion.eq {
1797            dim_pass &= (value.unwrap_or(f64::NAN) - v).abs() < f64::EPSILON;
1798        }
1799        passed &= dim_pass;
1800    }
1801    push_bool(
1802        "relationship",
1803        passed,
1804        json!(current),
1805        json!(assertion),
1806        details,
1807    );
1808}
1809
1810fn relationship_dimension_value(
1811    current: Option<&Value>,
1812    perspective: &str,
1813    dimension: &str,
1814) -> Option<f64> {
1815    let current = current?;
1816    match perspective {
1817        "agent_to_actor" => current.get("dimensions")?.get(dimension)?.as_f64(),
1818        "perceived_actor_to_agent" => current
1819            .get("perceived_actor_to_agent")?
1820            .get(dimension)?
1821            .as_f64(),
1822        "mutual" => current.get("dimensions")?.get(dimension)?.as_f64(),
1823        _ => None,
1824    }
1825}
1826
1827fn evaluate_secret(
1828    assertion: &SecretAssertion,
1829    evidence: &TurnEvidence,
1830    details: &mut Vec<AssertionResultDetail>,
1831) {
1832    let persona = evidence.persona.as_ref();
1833    let actual = persona.is_some_and(|p| p.secret_revealed);
1834    let passed = match assertion {
1835        SecretAssertion::Bool(expected) => actual == *expected,
1836        SecretAssertion::Id(id) => persona.is_some_and(|p| p.revealed_secret_ids.contains(id)),
1837    };
1838    push_bool(
1839        "persona_secret_revealed",
1840        passed,
1841        json!(actual),
1842        json!(assertion),
1843        details,
1844    );
1845}
1846
1847fn evaluate_orchestration(
1848    assertion: &OrchestrationAssertion,
1849    evidence: &TurnEvidence,
1850    details: &mut Vec<AssertionResultDetail>,
1851) {
1852    let Some(value) = &evidence.orchestration else {
1853        push_bool(
1854            "orchestration",
1855            false,
1856            json!(null),
1857            json!(assertion),
1858            details,
1859        );
1860        return;
1861    };
1862    let mut passed = true;
1863    if let Some(pattern) = assertion.pattern.as_ref().or(assertion.type_name.as_ref()) {
1864        passed &= value
1865            .get("type")
1866            .or_else(|| value.get("pattern"))
1867            .and_then(Value::as_str)
1868            == Some(pattern.as_str());
1869    }
1870    if let Some(finals) = &assertion.final_agent_in {
1871        let actual = value
1872            .get("final_agent")
1873            .or_else(|| value.get("to_agent"))
1874            .or_else(|| value.get("agent"))
1875            .and_then(Value::as_str);
1876        passed &= actual.is_some_and(|a| finals.iter().any(|f| f == a));
1877    }
1878    if let Some(required) = &assertion.agents_include {
1879        let agents = collect_orchestration_agents(value);
1880        passed &= required
1881            .iter()
1882            .all(|agent| agents.iter().any(|a| a == agent));
1883    }
1884    if let Some(stages) = assertion.stages {
1885        let actual = value
1886            .get("stages")
1887            .and_then(Value::as_array)
1888            .map(Vec::len)
1889            .unwrap_or(0);
1890        passed &= actual == stages;
1891    }
1892    push_bool(
1893        "orchestration",
1894        passed,
1895        value.clone(),
1896        json!(assertion),
1897        details,
1898    );
1899}
1900
1901fn collect_orchestration_agents(value: &Value) -> Vec<String> {
1902    let mut agents = Vec::new();
1903    collect_agent_strings(value, &mut agents);
1904    agents.sort();
1905    agents.dedup();
1906    agents
1907}
1908
1909fn collect_agent_strings(value: &Value, agents: &mut Vec<String>) {
1910    match value {
1911        Value::Object(map) => {
1912            for (key, value) in map {
1913                if matches!(
1914                    key.as_str(),
1915                    "agent" | "agent_id" | "id" | "final_agent" | "to_agent" | "from_agent"
1916                ) && let Some(text) = value.as_str()
1917                {
1918                    agents.push(text.to_string());
1919                }
1920                collect_agent_strings(value, agents);
1921            }
1922        }
1923        Value::Array(values) => {
1924            for value in values {
1925                if let Some(text) = value.as_str() {
1926                    agents.push(text.to_string());
1927                }
1928                collect_agent_strings(value, agents);
1929            }
1930        }
1931        _ => {}
1932    }
1933}
1934
1935fn evaluate_observability(
1936    assertion: &ObservabilityAssertion,
1937    evidence: &TurnEvidence,
1938    details: &mut Vec<AssertionResultDetail>,
1939) {
1940    let report = evidence
1941        .observability
1942        .as_ref()
1943        .and_then(|o| o.report.as_ref());
1944    let Some(report) = report else {
1945        push_bool(
1946            "observability",
1947            false,
1948            json!(null),
1949            json!(assertion),
1950            details,
1951        );
1952        return;
1953    };
1954    let mut passed = true;
1955    if let Some(max) = assertion.total_llm_calls_lte {
1956        passed &= report.summary.total_llm_calls <= max;
1957    }
1958    if let Some(max) = assertion.total_tool_calls_lte {
1959        passed &= report.summary.total_tool_calls <= max;
1960    }
1961    if let Some(max) = assertion.total_tokens_lte {
1962        passed &= report.summary.total_tokens <= max;
1963    }
1964    if let Some(max) = assertion.total_cost_usd_lte {
1965        passed &= report.summary.total_cost_usd <= max;
1966    }
1967    for (purpose, path_assertion) in &assertion.purpose_counts {
1968        let count = report
1969            .by_purpose
1970            .iter()
1971            .find(|metric| metric.dimensions.get("purpose") == Some(purpose))
1972            .map(|metric| metric.count)
1973            .unwrap_or(0);
1974        passed &= path_matches(&json!({"count": count}), path_assertion);
1975    }
1976    for (status, path_assertion) in &assertion.status_counts {
1977        let count = report
1978            .configured
1979            .iter()
1980            .find(|metric| metric.dimensions.get("status") == Some(status))
1981            .map(|metric| metric.count)
1982            .unwrap_or(0);
1983        passed &= path_matches(&json!({"count": count}), path_assertion);
1984    }
1985    for dimension_assertion in &assertion.dimension_counts {
1986        let count: u64 = report
1987            .configured
1988            .iter()
1989            .filter(|metric| {
1990                dimension_assertion
1991                    .match_dimensions
1992                    .iter()
1993                    .all(|(key, value)| metric.dimensions.get(key) == Some(value))
1994            })
1995            .map(|metric| metric.count)
1996            .sum();
1997        passed &= path_matches(&json!({"count": count}), &dimension_assertion.assertion);
1998    }
1999    push_bool(
2000        "observability",
2001        passed,
2002        json!(report.summary),
2003        json!(assertion),
2004        details,
2005    );
2006}
2007
2008#[cfg(test)]
2009mod tests {
2010    use super::*;
2011    use crate::evidence::{FactsEvidence, ToolExecutionSource, TurnObservabilityEvidence};
2012    use ai_agents_observability::{
2013        AggregatedMetrics, CostBreakdown, CostStats, LatencyStats, ObservabilityReport,
2014        ReportSummary, TokenBreakdown, TokenStats,
2015    };
2016
2017    fn evidence() -> TurnEvidence {
2018        TurnEvidence {
2019            response_metadata: Some(json!({"intent":"greeting","score":0.9})),
2020            state: Some("ready".to_string()),
2021            state_history: vec![ai_agents_core::StateTransitionEvent {
2022                from: "start".to_string(),
2023                to: "ready".to_string(),
2024                reason: "test".to_string(),
2025                timestamp: chrono::Utc::now(),
2026            }],
2027            context: json!({"user":{"tier":"vip"}}),
2028            tool_executions: vec![ToolExecutionRecord {
2029                call_id: "call-1".to_string(),
2030                tool_id: "lookup_order".to_string(),
2031                requested_name: "lookup_order".to_string(),
2032                source: ToolExecutionSource::Mock,
2033                state: None,
2034                actor_id: Some("actor-1".to_string()),
2035                arguments_original: json!({"id":"ORD-1"}),
2036                arguments_executed: json!({"id":"ORD-1"}),
2037                executed: true,
2038                success: true,
2039                output: Some(json!({"status":"cancellable"})),
2040                error: None,
2041                metadata: None,
2042                started_at: chrono::Utc::now(),
2043                duration_ms: 1,
2044                observability_span_id: None,
2045            }],
2046            approvals: vec![ApprovalEvidence {
2047                request_id: "approval-1".to_string(),
2048                trigger: ApprovalTriggerEvidence::Tool {
2049                    name: "transfer".to_string(),
2050                },
2051                raw_decision: ApprovalDecision::Modified,
2052                effective_decision: ApprovalDecision::Modified,
2053                original_args: Some(json!({"amount": 100, "currency": "USD"})),
2054                modified_args: Some(json!({"amount": 25, "currency": "USD"})),
2055                effective_args: Some(json!({"amount": 25, "currency": "USD"})),
2056                message: "Approve transfer for VIP customer?".to_string(),
2057                rejection_reason: None,
2058                error: None,
2059            }],
2060            llm_requests: vec![],
2061            skill: None,
2062            disambiguation: None,
2063            facts: Some(FactsEvidence {
2064                actor_id: Some("actor-1".to_string()),
2065                facts: vec![
2066                    json!({"category":"user_preference","content":"Prefers concise answers"}),
2067                ],
2068                before_count: None,
2069                after_count: Some(1),
2070            }),
2071            relationship: None,
2072            persona: None,
2073            orchestration: Some(json!({
2074                "type":"pipeline",
2075                "stages":[{"agent_id":"writer"},{"agent_id":"editor"}],
2076                "agents":["writer","editor"]
2077            })),
2078            observability: None,
2079        }
2080    }
2081
2082    #[tokio::test]
2083    async fn evaluates_structured_assertions() {
2084        let mut metadata = HashMap::new();
2085        metadata.insert("intent".to_string(), json!("greeting"));
2086        let assertion = Assertion {
2087            state: Some("ready".to_string()),
2088            state_history_contains: Some("ready".to_string()),
2089            response_contains: Some(StringList::One("Hello".to_string())),
2090            metadata_contains: Some(metadata),
2091            context_path: Some(PathAssertion {
2092                path: "user.tier".to_string(),
2093                eq: Some(json!("vip")),
2094                ..Default::default()
2095            }),
2096            tool_called: Some(ToolCalledAssertion::Object(ToolCalledObject {
2097                id: Some("lookup_order".to_string()),
2098                success: Some(true),
2099                result_path: Some(PathAssertion {
2100                    path: "status".to_string(),
2101                    eq: Some(json!("cancellable")),
2102                    ..Default::default()
2103                }),
2104                ..Default::default()
2105            })),
2106            facts_include: Some(FactsAssertion {
2107                actor: Some("actor-1".to_string()),
2108                category: Some("user_preference".to_string()),
2109                semantic: None,
2110            }),
2111            orchestration: Some(OrchestrationAssertion {
2112                pattern: Some("pipeline".to_string()),
2113                agents_include: Some(vec!["writer".to_string(), "editor".to_string()]),
2114                stages: Some(2),
2115                ..Default::default()
2116            }),
2117            ..Default::default()
2118        };
2119        let evidence = evidence();
2120        let result = evaluate_assertion(
2121            &assertion,
2122            AssertionEvalContext {
2123                evidence: &evidence,
2124                response: "Hello there",
2125                user_input: Some("Hello"),
2126                scenario_id: Some("test"),
2127                language: Some("en"),
2128                judge_resolver: None,
2129            },
2130        )
2131        .await;
2132        assert!(matches!(result, AssertionOutcome::Passed(_)));
2133    }
2134
2135    #[test]
2136    fn tool_called_exposes_plan_as_a_distinct_source_label() {
2137        let mut evidence = evidence();
2138        evidence.tool_executions[0].source = ToolExecutionSource::Plan;
2139        let assertion = ToolCalledAssertion::Object(ToolCalledObject {
2140            id: Some("lookup_order".to_string()),
2141            count: Some(1),
2142            source_in: Some(vec!["plan".to_string()]),
2143            ..Default::default()
2144        });
2145        let mut details = Vec::new();
2146
2147        evaluate_tool_called(&assertion, &evidence, &mut details);
2148
2149        assert!(details[0].passed);
2150        assert_eq!(details[0].actual, json!(1));
2151
2152        let llm_assertion = ToolCalledAssertion::Object(ToolCalledObject {
2153            id: Some("lookup_order".to_string()),
2154            source_in: Some(vec!["llm".to_string()]),
2155            ..Default::default()
2156        });
2157        let mut llm_details = Vec::new();
2158        evaluate_tool_called(&llm_assertion, &evidence, &mut llm_details);
2159        assert!(!llm_details[0].passed);
2160    }
2161
2162    #[test]
2163    fn tool_called_path_predicates_do_not_match_across_execution_records() {
2164        let mut evidence = evidence();
2165        let first = &mut evidence.tool_executions[0];
2166        first.tool_id = "calculator".to_string();
2167        first.requested_name = "calculator".to_string();
2168        first.source = ToolExecutionSource::Plan;
2169        first.arguments_original = json!({"expression":"18 * 7"});
2170        first.arguments_executed = json!({"expression":"1 + 1"});
2171        first.output = Some(json!({"result":2}));
2172
2173        let mut second = first.clone();
2174        second.call_id = "call-2".to_string();
2175        second.arguments_original = json!({"expression":"2 + 2"});
2176        second.arguments_executed = json!({"expression":"18 * 7"});
2177        second.output = Some(json!({"result":126}));
2178        evidence.tool_executions.push(second);
2179
2180        let assertion = ToolCalledAssertion::Object(ToolCalledObject {
2181            id: Some("calculator".to_string()),
2182            executed: Some(true),
2183            success: Some(true),
2184            source_in: Some(vec!["plan".to_string()]),
2185            args_original: Some(PathAssertion {
2186                path: "expression".to_string(),
2187                eq: Some(json!("18 * 7")),
2188                ..Default::default()
2189            }),
2190            args_executed: Some(PathAssertion {
2191                path: "expression".to_string(),
2192                eq: Some(json!("18 * 7")),
2193                ..Default::default()
2194            }),
2195            result_path: Some(PathAssertion {
2196                path: "result".to_string(),
2197                eq: Some(json!(126)),
2198                ..Default::default()
2199            }),
2200            ..Default::default()
2201        });
2202        let mut details = Vec::new();
2203
2204        evaluate_tool_called(&assertion, &evidence, &mut details);
2205
2206        assert!(!details[0].passed);
2207        assert_eq!(details[0].actual, json!(0));
2208    }
2209
2210    #[test]
2211    fn tool_called_checks_both_executed_argument_aliases_when_both_are_configured() {
2212        let mut evidence = evidence();
2213        let record = &mut evidence.tool_executions[0];
2214        record.arguments_executed = json!({"expression":"18 * 7"});
2215
2216        let assertion = ToolCalledAssertion::Object(ToolCalledObject {
2217            id: Some("lookup_order".to_string()),
2218            args: Some(PathAssertion {
2219                path: "expression".to_string(),
2220                eq: Some(json!("18 * 7")),
2221                ..Default::default()
2222            }),
2223            args_executed: Some(PathAssertion {
2224                path: "expression".to_string(),
2225                eq: Some(json!("2 + 2")),
2226                ..Default::default()
2227            }),
2228            ..Default::default()
2229        });
2230        let mut details = Vec::new();
2231
2232        evaluate_tool_called(&assertion, &evidence, &mut details);
2233
2234        assert!(!details[0].passed);
2235        assert_eq!(details[0].actual, json!(0));
2236    }
2237
2238    #[test]
2239    fn tool_called_count_counts_complete_record_matches() {
2240        let mut evidence = evidence();
2241        let first = &mut evidence.tool_executions[0];
2242        first.tool_id = "calculator".to_string();
2243        first.requested_name = "calculator".to_string();
2244        first.arguments_original = json!({"expression":"18 * 7"});
2245        first.arguments_executed = json!({"expression":"18 * 7"});
2246        first.output = Some(json!({"result":126}));
2247
2248        let mut second = first.clone();
2249        second.call_id = "call-2".to_string();
2250        second.arguments_executed = json!({"expression":"2 + 2"});
2251        second.output = Some(json!({"result":4}));
2252        evidence.tool_executions.push(second);
2253
2254        let assertion = ToolCalledAssertion::Object(ToolCalledObject {
2255            id: Some("calculator".to_string()),
2256            count: Some(1),
2257            executed: Some(true),
2258            success: Some(true),
2259            args_executed: Some(PathAssertion {
2260                path: "expression".to_string(),
2261                eq: Some(json!("18 * 7")),
2262                ..Default::default()
2263            }),
2264            result_path: Some(PathAssertion {
2265                path: "result".to_string(),
2266                eq: Some(json!(126)),
2267                ..Default::default()
2268            }),
2269            ..Default::default()
2270        });
2271        let mut details = Vec::new();
2272
2273        evaluate_tool_called(&assertion, &evidence, &mut details);
2274
2275        assert!(details[0].passed);
2276        assert_eq!(details[0].actual, json!(1));
2277    }
2278
2279    #[tokio::test]
2280    async fn unfiltered_exact_tool_count_rejects_extra_nonmatching_calls() {
2281        let mut evidence = evidence();
2282        let first = &mut evidence.tool_executions[0];
2283        first.tool_id = "file_write".to_string();
2284        first.requested_name = "file_write".to_string();
2285        first.executed = false;
2286        first.success = false;
2287        first.arguments_executed = json!({"dry_run":false});
2288        first.output = Some(json!({"dry_run":false,"bytes_written":0}));
2289
2290        let mut second = first.clone();
2291        second.call_id = "call-2".to_string();
2292        second.executed = true;
2293        second.success = true;
2294        second.arguments_executed = json!({"dry_run":true});
2295        second.output = Some(json!({"dry_run":true,"bytes_written":0}));
2296        evidence.tool_executions.push(second);
2297
2298        let assertion = Assertion {
2299            all: Some(vec![
2300                Assertion {
2301                    tool_called: Some(ToolCalledAssertion::Object(ToolCalledObject {
2302                        id: Some("file_write".to_string()),
2303                        count: Some(1),
2304                        ..Default::default()
2305                    })),
2306                    ..Default::default()
2307                },
2308                Assertion {
2309                    tool_called: Some(ToolCalledAssertion::Object(ToolCalledObject {
2310                        id: Some("file_write".to_string()),
2311                        executed: Some(true),
2312                        success: Some(true),
2313                        args_executed: Some(PathAssertion {
2314                            path: "dry_run".to_string(),
2315                            eq: Some(json!(true)),
2316                            ..Default::default()
2317                        }),
2318                        ..Default::default()
2319                    })),
2320                    ..Default::default()
2321                },
2322            ]),
2323            ..Default::default()
2324        };
2325
2326        let result = evaluate_assertion(
2327            &assertion,
2328            AssertionEvalContext {
2329                evidence: &evidence,
2330                response: "Dry run only",
2331                user_input: None,
2332                scenario_id: None,
2333                language: None,
2334                judge_resolver: None,
2335            },
2336        )
2337        .await;
2338
2339        assert!(matches!(result, AssertionOutcome::Failed(_)));
2340    }
2341
2342    #[tokio::test]
2343    async fn llm_request_matches_roles_within_one_request_without_serializing_content() {
2344        let mut evidence = evidence();
2345        evidence.llm_requests = vec![
2346            crate::evidence::LlmRequestEvidence {
2347                messages: vec![
2348                    crate::evidence::LlmMessageEvidence {
2349                        role: Role::System,
2350                        content: "persona marker and reasoning marker".to_string(),
2351                    },
2352                    crate::evidence::LlmMessageEvidence {
2353                        role: Role::User,
2354                        content: "current question".to_string(),
2355                    },
2356                ],
2357            },
2358            crate::evidence::LlmRequestEvidence {
2359                messages: vec![crate::evidence::LlmMessageEvidence {
2360                    role: Role::Assistant,
2361                    content: "historical answer".to_string(),
2362                }],
2363            },
2364        ];
2365        let assertion = Assertion {
2366            llm_request: Some(LlmRequestAssertion {
2367                system_contains: Some(StringList::Many(vec![
2368                    "persona marker".to_string(),
2369                    "reasoning marker".to_string(),
2370                ])),
2371                user_contains: Some(StringList::One("current question".to_string())),
2372                any_contains: Some(StringList::One("reasoning marker".to_string())),
2373                count: Some(1),
2374                count_gte: Some(1),
2375                count_lte: Some(1),
2376                same_request: Some(true),
2377                ..Default::default()
2378            }),
2379            ..Default::default()
2380        };
2381
2382        let result = evaluate_assertion(
2383            &assertion,
2384            AssertionEvalContext {
2385                evidence: &evidence,
2386                response: "ok",
2387                user_input: None,
2388                scenario_id: None,
2389                language: None,
2390                judge_resolver: None,
2391            },
2392        )
2393        .await;
2394
2395        let AssertionOutcome::Passed(details) = result else {
2396            panic!("expected LLM request assertion to pass");
2397        };
2398        let serialized = serde_json::to_string(&details).unwrap();
2399        assert!(!serialized.contains("persona marker"));
2400        assert!(!serialized.contains("reasoning marker"));
2401        assert!(!serialized.contains("current question"));
2402        assert!(!serialized.contains("historical answer"));
2403    }
2404
2405    #[tokio::test]
2406    async fn llm_request_does_not_combine_role_checks_across_requests() {
2407        let mut evidence = evidence();
2408        evidence.llm_requests = vec![
2409            crate::evidence::LlmRequestEvidence {
2410                messages: vec![crate::evidence::LlmMessageEvidence {
2411                    role: Role::System,
2412                    content: "persona marker".to_string(),
2413                }],
2414            },
2415            crate::evidence::LlmRequestEvidence {
2416                messages: vec![crate::evidence::LlmMessageEvidence {
2417                    role: Role::User,
2418                    content: "current question".to_string(),
2419                }],
2420            },
2421        ];
2422        let assertion = Assertion {
2423            llm_request: Some(LlmRequestAssertion {
2424                system_contains: Some(StringList::One("persona marker".to_string())),
2425                user_contains: Some(StringList::One("current question".to_string())),
2426                same_request: Some(true),
2427                ..Default::default()
2428            }),
2429            ..Default::default()
2430        };
2431
2432        let result = evaluate_assertion(
2433            &assertion,
2434            AssertionEvalContext {
2435                evidence: &evidence,
2436                response: "ok",
2437                user_input: None,
2438                scenario_id: None,
2439                language: None,
2440                judge_resolver: None,
2441            },
2442        )
2443        .await;
2444
2445        assert!(matches!(result, AssertionOutcome::Failed(_)));
2446    }
2447
2448    #[tokio::test]
2449    async fn approval_requested_matches_all_constraints_on_one_record() {
2450        let assertion = Assertion {
2451            approval_requested: Some(ApprovalAssertion::Object(ApprovalAssertionObject {
2452                count: Some(1),
2453                trigger: Some(ApprovalTriggerAssertion {
2454                    type_name: Some("tool".to_string()),
2455                    name: Some("transfer".to_string()),
2456                    ..Default::default()
2457                }),
2458                raw_decision: Some(ApprovalDecision::Modified),
2459                effective_decision: Some(ApprovalDecision::Modified),
2460                message_contains: Some(StringList::One("VIP".to_string())),
2461                original_args: Some(PathAssertion {
2462                    path: "amount".to_string(),
2463                    eq: Some(json!(100)),
2464                    ..Default::default()
2465                }),
2466                effective_args: Some(PathAssertion {
2467                    path: "amount".to_string(),
2468                    eq: Some(json!(25)),
2469                    ..Default::default()
2470                }),
2471                ..Default::default()
2472            })),
2473            ..Default::default()
2474        };
2475        let evidence = evidence();
2476        let result = evaluate_assertion(
2477            &assertion,
2478            AssertionEvalContext {
2479                evidence: &evidence,
2480                response: "ok",
2481                user_input: None,
2482                scenario_id: None,
2483                language: None,
2484                judge_resolver: None,
2485            },
2486        )
2487        .await;
2488
2489        assert!(matches!(result, AssertionOutcome::Passed(_)));
2490    }
2491
2492    #[tokio::test]
2493    async fn approval_constraints_do_not_match_across_records_and_summaries_are_redacted() {
2494        let mut evidence = evidence();
2495        evidence.approvals.push(ApprovalEvidence {
2496            request_id: "approval-2".to_string(),
2497            trigger: ApprovalTriggerEvidence::Tool {
2498                name: "transfer".to_string(),
2499            },
2500            raw_decision: ApprovalDecision::Rejected,
2501            effective_decision: ApprovalDecision::Rejected,
2502            original_args: Some(json!({"amount": 9999})),
2503            modified_args: None,
2504            effective_args: None,
2505            message: "Secret second message".to_string(),
2506            rejection_reason: Some("private rejection".to_string()),
2507            error: None,
2508        });
2509        let assertion = Assertion {
2510            approval_requested: Some(ApprovalAssertion::Object(ApprovalAssertionObject {
2511                raw_decision: Some(ApprovalDecision::Rejected),
2512                effective_args: Some(PathAssertion {
2513                    path: "amount".to_string(),
2514                    eq: Some(json!(25)),
2515                    ..Default::default()
2516                }),
2517                message_contains: Some(StringList::One("Secret".to_string())),
2518                ..Default::default()
2519            })),
2520            ..Default::default()
2521        };
2522        let result = evaluate_assertion(
2523            &assertion,
2524            AssertionEvalContext {
2525                evidence: &evidence,
2526                response: "ok",
2527                user_input: None,
2528                scenario_id: None,
2529                language: None,
2530                judge_resolver: None,
2531            },
2532        )
2533        .await;
2534
2535        let AssertionOutcome::Failed(details) = result else {
2536            panic!("expected failed approval assertion");
2537        };
2538        let serialized = serde_json::to_string(&details).unwrap();
2539        assert!(!serialized.contains("Secret"));
2540        assert!(!serialized.contains("private rejection"));
2541        assert!(!serialized.contains("25"));
2542        assert!(!serialized.contains("9999"));
2543    }
2544
2545    #[tokio::test]
2546    async fn approval_not_requested_uses_trigger_filters() {
2547        let assertion = Assertion {
2548            approval_not_requested: Some(ApprovalAssertion::Object(ApprovalAssertionObject {
2549                trigger: Some(ApprovalTriggerAssertion {
2550                    type_name: Some("state".to_string()),
2551                    ..Default::default()
2552                }),
2553                ..Default::default()
2554            })),
2555            ..Default::default()
2556        };
2557        let evidence = evidence();
2558        let result = evaluate_assertion(
2559            &assertion,
2560            AssertionEvalContext {
2561                evidence: &evidence,
2562                response: "ok",
2563                user_input: None,
2564                scenario_id: None,
2565                language: None,
2566                judge_resolver: None,
2567            },
2568        )
2569        .await;
2570
2571        assert!(matches!(result, AssertionOutcome::Passed(_)));
2572    }
2573
2574    #[tokio::test]
2575    async fn facts_actor_mismatch_fails() {
2576        let assertion = Assertion {
2577            facts_include: Some(FactsAssertion {
2578                actor: Some("other".to_string()),
2579                category: Some("user_preference".to_string()),
2580                semantic: None,
2581            }),
2582            ..Default::default()
2583        };
2584        let evidence = evidence();
2585        let result = evaluate_assertion(
2586            &assertion,
2587            AssertionEvalContext {
2588                evidence: &evidence,
2589                response: "ok",
2590                user_input: None,
2591                scenario_id: None,
2592                language: None,
2593                judge_resolver: None,
2594            },
2595        )
2596        .await;
2597        assert!(matches!(result, AssertionOutcome::Failed(_)));
2598    }
2599
2600    #[tokio::test]
2601    async fn observability_dimension_counts_match_configured_metrics() {
2602        let mut evidence = evidence();
2603        let mut dimensions = HashMap::new();
2604        dimensions.insert("background".to_string(), "true".to_string());
2605        dimensions.insert("maintenance".to_string(), "facts".to_string());
2606        let metric = AggregatedMetrics {
2607            dimensions,
2608            count: 2,
2609            errors: 0,
2610            latency: LatencyStats::default(),
2611            tokens: TokenStats::default(),
2612            cost: CostStats::default(),
2613        };
2614        evidence.observability = Some(TurnObservabilityEvidence {
2615            trace_id: Some("trace".to_string()),
2616            span_ids: vec!["span".to_string()],
2617            report: Some(ObservabilityReport {
2618                summary: ReportSummary::default(),
2619                configured: vec![metric],
2620                by_model: vec![],
2621                by_purpose: vec![],
2622                by_language: vec![],
2623                by_state: vec![],
2624                by_agent: vec![],
2625                by_orchestration_pattern: vec![],
2626                cost_breakdown: CostBreakdown::default(),
2627                token_breakdown: TokenBreakdown::default(),
2628                dropped_events: 0,
2629            }),
2630        });
2631        let mut match_dimensions = HashMap::new();
2632        match_dimensions.insert("background".to_string(), "true".to_string());
2633        let assertion = Assertion {
2634            observability: Some(ObservabilityAssertion {
2635                dimension_counts: vec![ObservabilityDimensionAssertion {
2636                    match_dimensions,
2637                    assertion: PathAssertion {
2638                        path: "count".to_string(),
2639                        gte: Some(2.0),
2640                        ..Default::default()
2641                    },
2642                }],
2643                ..Default::default()
2644            }),
2645            ..Default::default()
2646        };
2647
2648        let result = evaluate_assertion(
2649            &assertion,
2650            AssertionEvalContext {
2651                evidence: &evidence,
2652                response: "ok",
2653                user_input: None,
2654                scenario_id: None,
2655                language: None,
2656                judge_resolver: None,
2657            },
2658        )
2659        .await;
2660
2661        assert!(matches!(result, AssertionOutcome::Passed(_)));
2662    }
2663
2664    #[test]
2665    fn numeric_bounds_reject_nonnumeric_actual_values() {
2666        for assertion in [
2667            PathAssertion {
2668                path: "value".to_string(),
2669                gte: Some(1.0),
2670                ..Default::default()
2671            },
2672            PathAssertion {
2673                path: "value".to_string(),
2674                lte: Some(1.0),
2675                ..Default::default()
2676            },
2677            PathAssertion {
2678                path: "value".to_string(),
2679                gt: Some(1.0),
2680                ..Default::default()
2681            },
2682            PathAssertion {
2683                path: "value".to_string(),
2684                lt: Some(1.0),
2685                ..Default::default()
2686            },
2687        ] {
2688            assert!(!path_matches(&json!({"value": "not-a-number"}), &assertion));
2689        }
2690    }
2691}