Skip to main content

eval_magic/pipeline/grade/
transcript_check.rs

1//! Transcript-check grading.
2//!
3//! `tool_invocation_matches` checks regex-match the `"<name> <json-args>"`
4//! rendering of tool calls. `assistant_message_matches` checks ordered
5//! assistant messages in a scripted conversation. Both honor optional
6//! cross-event ordering constraints.
7
8use regex::Regex;
9
10use crate::adapters::ToolVocabulary;
11use crate::core::{
12    AssertionResult, AssertionTranscriptCheck, ConversationEvent, ConversationRecord, Grader,
13    MustPrecede, ToolInvocation,
14};
15
16/// Render an invocation as `"<name> <compact-json-args>"` (args omitted when
17/// absent) — the text the check's `pattern` regex runs against.
18fn 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
29/// A failed transcript-check result with full confidence.
30fn 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
40/// Grade a `transcript_check` assertion against a run's tool invocations,
41/// covering the empty-invocations, unsupported-kind, missing-pattern,
42/// invalid-regex, match, and no-match branches.
43pub 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
50/// Grade with the ordered conversation and harness write vocabulary available.
51/// The legacy wrapper above remains for one-shot callers and tests.
52pub 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}