1use regex::Regex;
9
10use crate::adapters::ToolVocabulary;
11use crate::core::{
12 AssertionResult, AssertionTranscriptCheck, ConversationEvent, ConversationRecord, Grader,
13 MustPrecede, ToolInvocation,
14};
15
16fn describe_invocation(inv: &ToolInvocation) -> String {
19 match &inv.args {
20 Some(args) => format!(
21 "{} {}",
22 inv.name,
23 serde_json::to_string(args).unwrap_or_default()
24 ),
25 None => inv.name.clone(),
26 }
27}
28
29fn fail(id: &str, evidence: String) -> AssertionResult {
31 AssertionResult {
32 id: id.to_string(),
33 passed: false,
34 evidence,
35 confidence: Some(1.0),
36 grader: Some(Grader::TranscriptCheck),
37 }
38}
39
40pub fn grade_transcript_check(
44 assertion: &AssertionTranscriptCheck,
45 invocations: &[ToolInvocation],
46) -> AssertionResult {
47 grade_transcript_check_with_context(assertion, invocations, None, &ToolVocabulary::default())
48}
49
50pub fn grade_transcript_check_with_context(
53 assertion: &AssertionTranscriptCheck,
54 invocations: &[ToolInvocation],
55 conversation: Option<&ConversationRecord>,
56 vocabulary: &ToolVocabulary,
57) -> AssertionResult {
58 if !matches!(
59 assertion.check.as_str(),
60 "tool_invocation_matches" | "assistant_message_matches"
61 ) {
62 return fail(
63 &assertion.id,
64 format!("unsupported transcript_check kind: '{}'", assertion.check),
65 );
66 }
67
68 if assertion.check == "tool_invocation_matches" && invocations.is_empty() {
69 return fail(
70 &assertion.id,
71 "tool_invocations is empty — run record was not filled by a transcript adapter. \
72 Run `eval-magic fill-transcripts` for Claude Code, or `eval-magic fill-transcripts \
73 --harness codex` when outputs/codex-events.jsonl is present; otherwise rely on \
74 `llm_judge` assertions for harnesses without an adapter."
75 .to_string(),
76 );
77 }
78
79 if assertion.check == "assistant_message_matches" && conversation.is_none() {
80 return fail(
81 &assertion.id,
82 "assistant_message_matches requires a scripted conversation artifact; this run is \
83 one-shot or was not ingested from conversation.json"
84 .to_string(),
85 );
86 }
87
88 let Some(pattern) = assertion.pattern.as_deref() else {
89 return fail(
90 &assertion.id,
91 format!(
92 "transcript_check '{}' requires a `pattern` field",
93 assertion.check
94 ),
95 );
96 };
97
98 let re = match Regex::new(pattern) {
99 Ok(re) => re,
100 Err(err) => {
101 return fail(
102 &assertion.id,
103 format!("invalid regex in pattern '{pattern}': {err}"),
104 );
105 }
106 };
107
108 let limit = ordering_limit(
109 assertion.must_precede,
110 conversation,
111 invocations,
112 vocabulary,
113 );
114 let order_name = ordering_name(assertion.must_precede);
115 let mut regex_matches = 0_usize;
116
117 if assertion.check == "tool_invocation_matches" {
118 for inv in invocations {
119 let target = describe_invocation(inv);
120 if re.is_match(&target) {
121 regex_matches += 1;
122 if limit.is_none_or(|ordinal| inv.ordinal < ordinal) {
123 return passed(&assertion.id, inv.ordinal, &target);
124 }
125 }
126 }
127 } else if let Some(conversation) = conversation {
128 for event in &conversation.events {
129 let ConversationEvent::AssistantMessage { ordinal, text, .. } = event else {
130 continue;
131 };
132 if re.is_match(text) {
133 regex_matches += 1;
134 if limit.is_none_or(|limit| *ordinal < limit) {
135 return passed(&assertion.id, *ordinal, text);
136 }
137 }
138 }
139 }
140
141 if regex_matches > 0 && limit.is_some() {
142 return fail(
143 &assertion.id,
144 format!(
145 "{regex_matches} match(es) for /{pattern}/ occurred, but none before {order_name}"
146 ),
147 );
148 }
149
150 let candidate_name = if assertion.check == "tool_invocation_matches" {
151 format!("{} invocation(s)", invocations.len())
152 } else {
153 format!(
154 "{} assistant message(s)",
155 conversation
156 .map(|conversation| {
157 conversation
158 .events
159 .iter()
160 .filter(|event| matches!(event, ConversationEvent::AssistantMessage { .. }))
161 .count()
162 })
163 .unwrap_or_default()
164 )
165 };
166 fail(
167 &assertion.id,
168 format!("no candidate matched /{pattern}/ across {candidate_name}"),
169 )
170}
171
172fn passed(id: &str, ordinal: u32, target: &str) -> AssertionResult {
173 let snippet: String = target.chars().take(200).collect();
174 AssertionResult {
175 id: id.to_string(),
176 passed: true,
177 evidence: format!("matched ordinal {ordinal}: {snippet}"),
178 confidence: Some(1.0),
179 grader: Some(Grader::TranscriptCheck),
180 }
181}
182
183fn ordering_limit(
184 constraint: Option<MustPrecede>,
185 conversation: Option<&ConversationRecord>,
186 invocations: &[ToolInvocation],
187 vocabulary: &ToolVocabulary,
188) -> Option<u32> {
189 match constraint.unwrap_or(MustPrecede::Any) {
190 MustPrecede::Any => None,
191 MustPrecede::CompletionClaim => conversation.and_then(|conversation| {
192 conversation
193 .events
194 .iter()
195 .rev()
196 .find_map(|event| match event {
197 ConversationEvent::AssistantMessage { ordinal, .. } => Some(*ordinal),
198 _ => None,
199 })
200 }),
201 MustPrecede::FirstWrite => conversation
202 .and_then(|conversation| {
203 conversation.events.iter().find_map(|event| match event {
204 ConversationEvent::ToolInvocation { ordinal, name, .. }
205 if is_write(name, vocabulary) =>
206 {
207 Some(*ordinal)
208 }
209 _ => None,
210 })
211 })
212 .or_else(|| {
213 invocations
214 .iter()
215 .find(|invocation| is_write(&invocation.name, vocabulary))
216 .map(|invocation| invocation.ordinal)
217 }),
218 }
219}
220
221fn is_write(name: &str, vocabulary: &ToolVocabulary) -> bool {
222 vocabulary.write_tools.iter().any(|tool| tool == name)
223 || vocabulary.patch_tools.iter().any(|tool| tool == name)
224}
225
226fn ordering_name(constraint: Option<MustPrecede>) -> &'static str {
227 match constraint.unwrap_or(MustPrecede::Any) {
228 MustPrecede::CompletionClaim => "the final completion claim",
229 MustPrecede::FirstWrite => "the first write",
230 MustPrecede::Any => "the end of the run",
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use crate::adapters::ToolVocabulary;
238 use crate::core::{ConversationEvent, ConversationRecord, ConversationStatus, MustPrecede};
239 use serde_json::json;
240
241 fn check(pattern: Option<&str>) -> AssertionTranscriptCheck {
242 AssertionTranscriptCheck {
243 id: "t1".to_string(),
244 check: "tool_invocation_matches".to_string(),
245 pattern: pattern.map(str::to_string),
246 must_precede: None,
247 }
248 }
249
250 fn inv(name: &str, args: serde_json::Value, ordinal: u32) -> ToolInvocation {
251 ToolInvocation {
252 name: name.to_string(),
253 args: Some(args),
254 result: None,
255 ordinal,
256 }
257 }
258
259 #[test]
260 fn empty_invocations_fail_with_guidance() {
261 let r = grade_transcript_check(&check(Some("Bash")), &[]);
262 assert!(!r.passed);
263 assert!(r.evidence.contains("tool_invocations is empty"));
264 assert_eq!(r.grader, Some(Grader::TranscriptCheck));
265 }
266
267 #[test]
268 fn missing_pattern_fails() {
269 let r = grade_transcript_check(&check(None), &[inv("Bash", json!({"command": "ls"}), 0)]);
270 assert!(!r.passed);
271 assert!(r.evidence.contains("requires a `pattern`"));
272 }
273
274 #[test]
275 fn unsupported_kind_fails() {
276 let mut c = check(Some("x"));
277 c.check = "something_else".to_string();
278 let r = grade_transcript_check(&c, &[inv("Bash", json!({}), 0)]);
279 assert!(!r.passed);
280 assert!(r.evidence.contains("unsupported transcript_check kind"));
281 }
282
283 #[test]
284 fn matching_pattern_passes_with_ordinal() {
285 let invs = [
286 inv("Read", json!({"file_path": "/x"}), 0),
287 inv("Bash", json!({"command": "bun test"}), 1),
288 ];
289 let r = grade_transcript_check(&check(Some("bun test")), &invs);
290 assert!(r.passed);
291 assert!(r.evidence.contains("matched ordinal 1"));
292 }
293
294 #[test]
295 fn no_match_fails_with_count() {
296 let invs = [inv("Read", json!({"file_path": "/x"}), 0)];
297 let r = grade_transcript_check(&check(Some("npm install")), &invs);
298 assert!(!r.passed);
299 assert!(r.evidence.contains("across 1 invocation(s)"));
300 }
301
302 #[test]
303 fn invalid_regex_fails() {
304 let invs = [inv("Bash", json!({"command": "ls"}), 0)];
305 let r = grade_transcript_check(&check(Some("(unclosed")), &invs);
306 assert!(!r.passed);
307 assert!(r.evidence.contains("invalid regex"));
308 }
309
310 fn conversation() -> ConversationRecord {
311 ConversationRecord {
312 status: ConversationStatus::Completed,
313 delivered_followups: 1,
314 stop_reason: None,
315 stopped_before_followup: None,
316 events: vec![
317 ConversationEvent::UserMessage {
318 ordinal: 0,
319 round: 1,
320 text: "Fix it".into(),
321 },
322 ConversationEvent::AssistantMessage {
323 ordinal: 1,
324 round: 1,
325 text: "Which timezone?".into(),
326 },
327 ConversationEvent::UserMessage {
328 ordinal: 2,
329 round: 2,
330 text: "US timezones".into(),
331 },
332 ConversationEvent::ToolInvocation {
333 ordinal: 3,
334 round: 2,
335 name: "Write".into(),
336 args: Some(json!({"file_path": "date.rs"})),
337 result: None,
338 },
339 ConversationEvent::AssistantMessage {
340 ordinal: 4,
341 round: 2,
342 text: "Done.".into(),
343 },
344 ],
345 }
346 }
347
348 #[test]
349 fn assistant_message_matches_across_conversation_rounds() {
350 let assertion = AssertionTranscriptCheck {
351 id: "asked".into(),
352 check: "assistant_message_matches".into(),
353 pattern: Some("(?i)time ?zone".into()),
354 must_precede: Some(MustPrecede::FirstWrite),
355 };
356 let result = grade_transcript_check_with_context(
357 &assertion,
358 &[],
359 Some(&conversation()),
360 &ToolVocabulary {
361 write_tools: vec!["Write".into()],
362 ..Default::default()
363 },
364 );
365 assert!(result.passed, "{}", result.evidence);
366 assert!(result.evidence.contains("ordinal 1"));
367 }
368
369 #[test]
370 fn assistant_message_after_first_write_fails_ordering_constraint() {
371 let assertion = AssertionTranscriptCheck {
372 id: "late".into(),
373 check: "assistant_message_matches".into(),
374 pattern: Some("Done".into()),
375 must_precede: Some(MustPrecede::FirstWrite),
376 };
377 let result = grade_transcript_check_with_context(
378 &assertion,
379 &[],
380 Some(&conversation()),
381 &ToolVocabulary {
382 write_tools: vec!["Write".into()],
383 ..Default::default()
384 },
385 );
386 assert!(!result.passed);
387 assert!(result.evidence.contains("first write"));
388 }
389}