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#[derive(Debug, Clone, Deserialize, Serialize, Default)]
14#[serde(deny_unknown_fields)]
15pub struct Assertion {
16 #[serde(default)]
18 pub state: Option<String>,
19 #[serde(default)]
21 pub state_in: Option<Vec<String>>,
22 #[serde(default)]
24 pub state_not: Option<String>,
25 #[serde(default)]
27 pub state_history_contains: Option<String>,
28 #[serde(default)]
30 pub response_contains: Option<StringList>,
31 #[serde(default)]
33 pub response_contains_any: Option<StringList>,
34 #[serde(default)]
36 pub response_not_contains: Option<StringList>,
37 #[serde(default)]
39 pub response_not_empty: Option<bool>,
40 #[serde(default)]
42 pub response_semantic: Option<JudgeAssertion>,
43 #[serde(default)]
45 pub disambiguation: Option<DisambiguationExpectation>,
46 #[serde(default)]
48 pub no_disambiguation: Option<bool>,
49 #[serde(default)]
51 pub tool_called: Option<ToolCalledAssertion>,
52 #[serde(default, alias = "llm_messages")]
54 pub llm_request: Option<LlmRequestAssertion>,
55 #[serde(default)]
57 pub approval_requested: Option<ApprovalAssertion>,
58 #[serde(default)]
60 pub approval_not_requested: Option<ApprovalAssertion>,
61 #[serde(default)]
63 pub tool_not_called: Option<String>,
64 #[serde(default)]
66 pub skill_triggered: Option<String>,
67 #[serde(default)]
69 pub metadata_contains: Option<HashMap<String, Value>>,
70 #[serde(default)]
72 pub metadata_path: Option<PathAssertion>,
73 #[serde(default)]
75 pub context_path: Option<PathAssertion>,
76 #[serde(default)]
78 pub facts_include: Option<FactsAssertion>,
79 #[serde(default)]
81 pub relationship: Option<RelationshipAssertion>,
82 #[serde(default)]
84 pub persona_secret_revealed: Option<SecretAssertion>,
85 #[serde(default)]
87 pub orchestration: Option<OrchestrationAssertion>,
88 #[serde(default)]
90 pub observability: Option<ObservabilityAssertion>,
91 #[serde(default)]
93 pub judge: Option<JudgeAssertion>,
94 #[serde(default)]
96 pub any: Option<Vec<Assertion>>,
97 #[serde(default)]
99 pub all: Option<Vec<Assertion>>,
100 #[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#[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#[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#[derive(Debug, Clone, Deserialize, Serialize, Default)]
346#[serde(deny_unknown_fields)]
347pub struct ToolCalledObject {
348 #[serde(default)]
350 pub id: Option<String>,
351 #[serde(default)]
353 pub count: Option<usize>,
354 #[serde(default)]
356 pub count_gte: Option<usize>,
357 #[serde(default)]
359 pub executed: Option<bool>,
360 #[serde(default)]
362 pub success: Option<bool>,
363 #[serde(default)]
365 pub source_in: Option<Vec<String>>,
366 #[serde(default)]
368 pub args: Option<PathAssertion>,
369 #[serde(default)]
371 pub args_original: Option<PathAssertion>,
372 #[serde(default)]
374 pub args_executed: Option<PathAssertion>,
375 #[serde(default)]
377 pub result_path: Option<PathAssertion>,
378}
379
380#[derive(Debug, Clone, Deserialize, Serialize, Default)]
382#[serde(deny_unknown_fields)]
383pub struct LlmRequestAssertion {
384 #[serde(default)]
386 pub system_contains: Option<StringList>,
387 #[serde(default)]
389 pub user_contains: Option<StringList>,
390 #[serde(default)]
392 pub assistant_contains: Option<StringList>,
393 #[serde(default)]
395 pub any_contains: Option<StringList>,
396 #[serde(default)]
398 pub count: Option<usize>,
399 #[serde(default)]
401 pub count_gte: Option<usize>,
402 #[serde(default)]
404 pub count_lte: Option<usize>,
405 #[serde(default)]
407 pub same_request: Option<bool>,
408}
409
410#[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#[derive(Debug, Clone, Deserialize, Serialize, Default)]
424#[serde(deny_unknown_fields)]
425pub struct ApprovalAssertionObject {
426 #[serde(default)]
428 pub count: Option<usize>,
429 #[serde(default)]
431 pub count_gte: Option<usize>,
432 #[serde(default)]
434 pub count_lte: Option<usize>,
435 #[serde(default)]
437 pub trigger: Option<ApprovalTriggerAssertion>,
438 #[serde(default)]
440 pub raw_decision: Option<ApprovalDecision>,
441 #[serde(default)]
443 pub effective_decision: Option<ApprovalDecision>,
444 #[serde(default)]
446 pub message: Option<String>,
447 #[serde(default)]
449 pub message_contains: Option<StringList>,
450 #[serde(default)]
452 pub rejection_reason: Option<String>,
453 #[serde(default)]
455 pub rejection_reason_contains: Option<String>,
456 #[serde(default)]
458 pub error: Option<String>,
459 #[serde(default)]
461 pub error_contains: Option<String>,
462 #[serde(default, alias = "args_original")]
464 pub original_args: Option<PathAssertion>,
465 #[serde(default, alias = "args_modified")]
467 pub modified_args: Option<PathAssertion>,
468 #[serde(default, alias = "args_effective")]
470 pub effective_args: Option<PathAssertion>,
471}
472
473#[derive(Debug, Clone, Deserialize, Serialize, Default)]
475#[serde(deny_unknown_fields)]
476pub struct ApprovalTriggerAssertion {
477 #[serde(default, rename = "type")]
479 pub type_name: Option<String>,
480 #[serde(default)]
482 pub name: Option<String>,
483 #[serde(default)]
485 pub matched: Option<String>,
486 #[serde(default)]
488 pub from: Option<String>,
489 #[serde(default)]
491 pub to: Option<String>,
492}
493
494#[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#[derive(Debug, Clone, Deserialize, Serialize, Default)]
510#[serde(deny_unknown_fields)]
511pub struct PathAssertion {
512 pub path: String,
514 #[serde(default)]
516 pub eq: Option<Value>,
517 #[serde(default)]
519 pub neq: Option<Value>,
520 #[serde(default, rename = "in")]
522 pub in_values: Option<Vec<Value>>,
523 #[serde(default)]
525 pub contains: Option<Value>,
526 #[serde(default)]
528 pub exists: Option<bool>,
529 #[serde(default)]
531 pub gte: Option<f64>,
532 #[serde(default)]
534 pub lte: Option<f64>,
535 #[serde(default)]
537 pub gt: Option<f64>,
538 #[serde(default)]
540 pub lt: Option<f64>,
541}
542
543#[derive(Debug, Clone, Deserialize, Serialize, Default)]
545#[serde(deny_unknown_fields)]
546pub struct FactsAssertion {
547 #[serde(default)]
549 pub actor: Option<String>,
550 #[serde(default)]
552 pub category: Option<String>,
553 #[serde(default)]
555 pub semantic: Option<String>,
556}
557
558#[derive(Debug, Clone, Deserialize, Serialize, Default)]
560#[serde(deny_unknown_fields)]
561pub struct RelationshipAssertion {
562 #[serde(default)]
564 pub actor: Option<String>,
565 #[serde(default)]
567 pub exists: Option<bool>,
568 #[serde(default)]
570 pub perspective: Option<String>,
571 #[serde(default)]
573 pub dimension: Option<String>,
574 #[serde(default)]
576 pub gte: Option<f64>,
577 #[serde(default)]
579 pub lte: Option<f64>,
580 #[serde(default)]
582 pub gt: Option<f64>,
583 #[serde(default)]
585 pub lt: Option<f64>,
586 #[serde(default)]
588 pub eq: Option<f64>,
589 #[serde(default)]
591 pub interaction_count_gte: Option<u64>,
592 #[serde(default)]
594 pub notable_event_count_gte: Option<usize>,
595}
596
597#[derive(Debug, Clone, Deserialize, Serialize)]
599#[serde(untagged)]
600pub enum SecretAssertion {
601 Bool(bool),
602 Id(String),
603}
604
605#[derive(Debug, Clone, Deserialize, Serialize, Default)]
607#[serde(deny_unknown_fields)]
608pub struct OrchestrationAssertion {
609 #[serde(default)]
611 pub pattern: Option<String>,
612 #[serde(default, rename = "type")]
614 pub type_name: Option<String>,
615 #[serde(default)]
617 pub final_agent_in: Option<Vec<String>>,
618 #[serde(default)]
620 pub agents_include: Option<Vec<String>>,
621 #[serde(default)]
623 pub stages: Option<usize>,
624}
625
626#[derive(Debug, Clone, Deserialize, Serialize, Default)]
628#[serde(deny_unknown_fields)]
629pub struct ObservabilityAssertion {
630 #[serde(default)]
632 pub total_llm_calls_lte: Option<u64>,
633 #[serde(default)]
635 pub total_tool_calls_lte: Option<u64>,
636 #[serde(default)]
638 pub total_tokens_lte: Option<u64>,
639 #[serde(default)]
641 pub total_cost_usd_lte: Option<f64>,
642 #[serde(default, deserialize_with = "deserialize_non_empty_path_map")]
644 pub purpose_counts: HashMap<String, PathAssertion>,
645 #[serde(default, deserialize_with = "deserialize_non_empty_path_map")]
647 pub status_counts: HashMap<String, PathAssertion>,
648 #[serde(
650 default,
651 deserialize_with = "deserialize_non_empty_dimension_assertions"
652 )]
653 pub dimension_counts: Vec<ObservabilityDimensionAssertion>,
654}
655
656#[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#[derive(Debug, Clone, Serialize)]
713pub struct AssertionResultDetail {
714 pub assertion: String,
716 pub passed: bool,
718 pub actual: Value,
720 pub expected: Value,
722 pub message: Option<String>,
724}
725
726pub 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#[derive(Clone, Copy)]
762pub struct AssertionEvalContext<'a> {
763 pub evidence: &'a TurnEvidence,
765 pub response: &'a str,
767 pub user_input: Option<&'a str>,
769 pub scenario_id: Option<&'a str>,
771 pub language: Option<&'a str>,
773 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}