Skip to main content

aether_evals/
judge.rs

1use aether_core::events::AgentMessage;
2use futures::StreamExt;
3use llm::types::IsoString;
4use llm::{ChatMessage, ContentBlock, Context, LlmResponse, StreamingModelProvider};
5use schemars::{JsonSchema, Schema, schema_for};
6use serde::{Deserialize, Serialize};
7use std::borrow::Borrow;
8use std::collections::{BTreeMap, BTreeSet};
9use std::fmt::Write as _;
10use thiserror::Error;
11
12const TRANSCRIPT_PAYLOAD_CHARS: usize = 2_000;
13
14/// Start building an LLM-as-judge from structured context and rubric criteria.
15pub fn judge() -> JudgeBuilder {
16    JudgeBuilder::default()
17}
18
19/// A built judge: the assembled prompt plus the normalized rubric it grades against. Run it with
20/// [`Judge::run`] against a model, or grade a parsed [`JudgeRubricResponse`] with
21/// [`Judge::summarize`].
22#[derive(Debug, Clone)]
23pub struct Judge {
24    pub prompt: String,
25    pub criteria: Vec<JudgeCriterionSpec>,
26}
27
28#[derive(Debug, Clone, Default)]
29pub struct JudgeBuilder {
30    instructions: Option<String>,
31    task: Option<String>,
32    context: JudgeContext,
33    criteria: Vec<JudgeCriterionSpec>,
34}
35
36/// Evidence the judge grades against: the agent transcript, a workspace diff, and/or final files.
37#[derive(Debug, Clone, Default)]
38pub struct JudgeContext {
39    pub transcript: Option<Vec<AgentMessage>>,
40    pub diff: Option<String>,
41    pub files: BTreeMap<String, String>,
42}
43
44/// A single rubric criterion scored on a normalized 0.0..=1.0 scale.
45#[derive(Debug, Clone, JsonSchema)]
46#[serde(rename_all = "camelCase", deny_unknown_fields)]
47pub struct JudgeCriterionSpec {
48    pub id: String,
49    pub description: String,
50    #[serde(default = "default_blocking")]
51    pub blocking: bool,
52    #[serde(default = "default_weight")]
53    pub weight: f64,
54    #[serde(default = "default_threshold")]
55    pub threshold: f64,
56}
57
58/// The graded result of running a judge: an overall pass/score plus per-criterion detail.
59#[derive(Debug, Clone, Serialize, JsonSchema)]
60#[serde(rename_all = "camelCase", deny_unknown_fields)]
61pub struct JudgeSummary {
62    pub passed: bool,
63    pub score: f64,
64    pub reason: String,
65    pub criteria: Vec<JudgeCriterionSummary>,
66}
67
68#[derive(Debug, Clone, Serialize, JsonSchema)]
69#[serde(rename_all = "camelCase", deny_unknown_fields)]
70pub struct JudgeCriterionSummary {
71    pub id: String,
72    pub description: String,
73    pub blocking: bool,
74    pub weight: f64,
75    pub threshold: f64,
76    pub score: f64,
77    pub passed: bool,
78    pub reason: String,
79}
80
81/// The raw rubric response the judge model is expected to return.
82#[derive(Debug, Deserialize, JsonSchema)]
83#[serde(deny_unknown_fields)]
84pub struct JudgeRubricResponse {
85    pub criteria: Vec<JudgeCriterionResponse>,
86    pub overall_reason: String,
87}
88
89#[derive(Debug, Deserialize, JsonSchema)]
90#[serde(deny_unknown_fields)]
91pub struct JudgeCriterionResponse {
92    pub id: String,
93    pub score: f64,
94    pub reason: String,
95}
96
97#[derive(Debug, Error)]
98pub enum JudgeError {
99    #[error("invalid judge input: {0}")]
100    InvalidInput(String),
101
102    #[error("judge LLM stream error: {0}")]
103    Stream(#[from] llm::LlmError),
104
105    #[error("judge returned invalid JSON: {source}\nRaw response: {raw_response}")]
106    InvalidJson {
107        #[source]
108        source: serde_json::Error,
109        raw_response: String,
110    },
111
112    #[error("judge returned invalid judgment: {reason}\nRaw response: {raw_response}")]
113    InvalidJudgment { reason: String, raw_response: String },
114}
115
116impl Judge {
117    pub fn response_schema() -> Schema {
118        JudgeRubricResponse::schema()
119    }
120
121    /// Grade `llm` against this judge's rubric: stream the model's response, parse it as a
122    /// [`JudgeRubricResponse`], and summarize it.
123    pub async fn run(&self, llm: &dyn StreamingModelProvider) -> Result<JudgeSummary, JudgeError> {
124        tracing::info!("Running LLM judge");
125        let raw_response = self.stream_response(llm).await?;
126        let response: JudgeRubricResponse = serde_json::from_str(extract_json_object(&raw_response))
127            .map_err(|source| JudgeError::InvalidJson { source, raw_response: raw_response.clone() })?;
128        self.summarize(response)
129    }
130
131    pub fn summarize(&self, response: JudgeRubricResponse) -> Result<JudgeSummary, JudgeError> {
132        let mut responses = BTreeMap::new();
133        for criterion in response.criteria {
134            let id = criterion.id.clone();
135            if responses.insert(id.clone(), criterion).is_some() {
136                return Err(invalid_judgment(format!("duplicate response criterion id `{id}`"), ""));
137            }
138        }
139
140        let mut summaries = Vec::with_capacity(self.criteria.len());
141        let mut weighted_score = 0.0;
142        let mut total_weight = 0.0;
143        let mut blocking_failed = false;
144
145        for criterion in &self.criteria {
146            let Some(response) = responses.remove(&criterion.id) else {
147                return Err(invalid_judgment(format!("missing response criterion `{}`", criterion.id), ""));
148            };
149            if !response.score.is_finite() || !(0.0..=1.0).contains(&response.score) {
150                return Err(invalid_judgment(
151                    format!("criterion `{}` score must be between 0.0 and 1.0", criterion.id),
152                    "",
153                ));
154            }
155
156            let passed = response.score >= criterion.threshold;
157            blocking_failed |= criterion.blocking && !passed;
158            weighted_score += response.score * criterion.weight;
159            total_weight += criterion.weight;
160            summaries.push(JudgeCriterionSummary {
161                id: criterion.id.clone(),
162                description: criterion.description.clone(),
163                blocking: criterion.blocking,
164                weight: criterion.weight,
165                threshold: criterion.threshold,
166                score: response.score,
167                passed,
168                reason: response.reason,
169            });
170        }
171
172        if let Some(id) = responses.keys().next() {
173            return Err(invalid_judgment(format!("unknown response criterion `{id}`"), ""));
174        }
175
176        let weighted_score = weighted_score / total_weight;
177        let score = if blocking_failed { 0.0 } else { weighted_score };
178        let reason = if blocking_failed {
179            format!("weighted score {:.2}; one or more blockers failed; {}", weighted_score, response.overall_reason)
180        } else {
181            format!("weighted score {:.2}; all blockers met; {}", weighted_score, response.overall_reason)
182        };
183
184        Ok(JudgeSummary { passed: !blocking_failed, score, reason, criteria: summaries })
185    }
186
187    async fn stream_response(&self, llm: &dyn StreamingModelProvider) -> Result<String, JudgeError> {
188        let message =
189            ChatMessage::User { content: vec![ContentBlock::text(self.prompt.clone())], timestamp: IsoString::now() };
190        let mut response_stream = llm.stream_response(&Context::new(vec![message], vec![]));
191        let mut raw_response = String::new();
192        while let Some(result) = response_stream.next().await {
193            match result {
194                Ok(LlmResponse::Text { chunk }) => raw_response.push_str(&chunk),
195                Err(error) => return Err(JudgeError::Stream(error)),
196                _ => {}
197            }
198        }
199        Ok(raw_response)
200    }
201}
202
203impl JudgeBuilder {
204    pub fn instructions(mut self, instructions: impl Into<String>) -> Self {
205        self.instructions = Some(instructions.into());
206        self
207    }
208
209    pub fn task(mut self, task: impl Into<String>) -> Self {
210        self.task = Some(task.into());
211        self
212    }
213
214    pub fn transcript(mut self, transcript: impl Into<Vec<AgentMessage>>) -> Self {
215        self.context.transcript = Some(transcript.into());
216        self
217    }
218
219    pub fn diff(mut self, diff: impl Into<String>) -> Self {
220        self.context.diff = Some(diff.into());
221        self
222    }
223
224    pub fn file(mut self, path: impl Into<String>, contents: impl Into<String>) -> Self {
225        self.context.files.insert(path.into(), contents.into());
226        self
227    }
228
229    pub fn files<T, U, V>(mut self, files: T) -> Self
230    where
231        T: IntoIterator<Item = (U, V)>,
232        U: Into<String>,
233        V: Into<String>,
234    {
235        self.context.files.extend(files.into_iter().map(|(path, contents)| (path.into(), contents.into())));
236        self
237    }
238
239    pub fn criteria<T, U>(mut self, criteria: T) -> Self
240    where
241        T: IntoIterator<Item = U>,
242        U: Borrow<JudgeCriterionSpec>,
243    {
244        self.criteria = criteria.into_iter().map(|criterion| criterion.borrow().clone()).collect();
245        self
246    }
247
248    pub fn context(mut self, context: JudgeContext) -> Self {
249        self.context = context;
250        self
251    }
252
253    pub fn build(self) -> Result<Judge, JudgeError> {
254        let task = self.task.ok_or_else(|| JudgeError::InvalidInput("judge task must be provided".to_string()))?;
255        let criteria = normalize_criteria(self.criteria)?;
256        let prompt = build_prompt(&self.instructions.unwrap_or_default(), &task, &self.context, &criteria);
257        Ok(Judge { prompt, criteria })
258    }
259}
260
261impl JudgeCriterionSpec {
262    pub fn new(id: impl Into<String>, description: impl Into<String>) -> Self {
263        Self {
264            id: id.into(),
265            description: description.into(),
266            blocking: default_blocking(),
267            weight: default_weight(),
268            threshold: default_threshold(),
269        }
270    }
271
272    pub fn blocking(mut self, blocking: bool) -> Self {
273        self.blocking = blocking;
274        self
275    }
276
277    pub fn weight(mut self, weight: f64) -> Self {
278        self.weight = weight;
279        self
280    }
281
282    pub fn threshold(mut self, threshold: f64) -> Self {
283        self.threshold = threshold;
284        self
285    }
286}
287
288impl JudgeSummary {
289    /// Failure messages for blocking criteria that scored below their threshold.
290    pub fn blocking_failures(&self) -> impl Iterator<Item = String> + '_ {
291        self.criteria
292            .iter()
293            .filter(|criterion| criterion.blocking && !criterion.passed)
294            .map(|criterion| format!("judge criterion `{}`: {}", criterion.id, criterion.reason))
295    }
296}
297
298impl JudgeRubricResponse {
299    pub fn schema() -> Schema {
300        schema_for!(Self)
301    }
302}
303
304fn build_prompt(instructions: &str, task: &str, context: &JudgeContext, criteria: &[JudgeCriterionSpec]) -> String {
305    let mut sections = vec![
306        format!("## Instructions\n\n{instructions}"),
307        format!("## Task\n\nThe agent you're evaluating was given this task: <task>{task}</task>"),
308    ];
309
310    if let Some(transcript) = &context.transcript
311        && !transcript.is_empty()
312    {
313        sections.push(format!(
314            "## Agent Transcript\n\nTranscript of the agent you're evaluating: <transcript>{}</transcript>",
315            format_transcript(transcript)
316        ));
317    }
318
319    if let Some(diff) = &context.diff
320        && !diff.is_empty()
321    {
322        sections.push(format!("## Git diff\n\nGit diff produced by the agent you're evaluating: <diff>{diff}</diff>"));
323    }
324
325    if !context.files.is_empty() {
326        let blocks = context
327            .files
328            .iter()
329            .map(|(path, contents)| format!("<file><path>{path}</path><contents>{contents}</contents></file>"))
330            .collect::<Vec<_>>()
331            .join("\n");
332        sections.push(format!("## File Contents\n\nFiles under evaluation: <files>{blocks}</files>"));
333    }
334
335    let rubric = criteria
336        .iter()
337        .map(|criterion| {
338            format!(
339                "- id: {}\n  blocking: {}\n  weight: {}\n  threshold: {}\n  description: {}",
340                criterion.id, criterion.blocking, criterion.weight, criterion.threshold, criterion.description
341            )
342        })
343        .collect::<Vec<_>>()
344        .join("\n");
345    sections.push(format!("## Rubric criteria\n\n{rubric}"));
346    sections.push(format!(
347        "{}\n{}\n{}\n{}",
348        "Return exactly one result for every criterion ID above and no extra criteria.",
349        "Scores must be normalized numbers from 0.0 to 1.0.",
350        "Respond with ONLY a JSON object matching this schema:",
351        judge_response_schema()
352    ));
353
354    sections.join("\n\n")
355}
356
357fn normalize_criteria(criteria: Vec<JudgeCriterionSpec>) -> Result<Vec<JudgeCriterionSpec>, JudgeError> {
358    if criteria.is_empty() {
359        return Err(JudgeError::InvalidInput("judge criteria must not be empty".to_string()));
360    }
361
362    let mut ids = BTreeSet::new();
363    let mut normalized = Vec::with_capacity(criteria.len());
364    for mut criterion in criteria {
365        criterion.id = criterion.id.trim().to_string();
366        if criterion.id.is_empty() {
367            return Err(JudgeError::InvalidInput("judge criterion id must not be empty".to_string()));
368        }
369        if !ids.insert(criterion.id.clone()) {
370            return Err(JudgeError::InvalidInput(format!("duplicate judge criterion id `{}`", criterion.id)));
371        }
372        if criterion.description.trim().is_empty() {
373            return Err(JudgeError::InvalidInput(format!(
374                "judge criterion `{}` description must not be empty",
375                criterion.id
376            )));
377        }
378        if !criterion.weight.is_finite() || criterion.weight <= 0.0 {
379            return Err(JudgeError::InvalidInput(format!(
380                "judge criterion `{}` weight must be positive and finite",
381                criterion.id
382            )));
383        }
384        if !criterion.threshold.is_finite() || !(0.0..=1.0).contains(&criterion.threshold) {
385            return Err(JudgeError::InvalidInput(format!(
386                "judge criterion `{}` threshold must be between 0.0 and 1.0",
387                criterion.id
388            )));
389        }
390        normalized.push(criterion);
391    }
392    Ok(normalized)
393}
394
395fn extract_json_object(response: &str) -> &str {
396    let trimmed = response.trim();
397    match (trimmed.find('{'), trimmed.rfind('}')) {
398        (Some(start), Some(end)) if start <= end => &trimmed[start..=end],
399        _ => trimmed,
400    }
401}
402
403fn invalid_judgment(reason: String, raw_response: &str) -> JudgeError {
404    JudgeError::InvalidJudgment { reason, raw_response: raw_response.to_string() }
405}
406
407fn judge_response_schema() -> String {
408    serde_json::to_string_pretty(&JudgeRubricResponse::schema()).unwrap()
409}
410
411fn default_blocking() -> bool {
412    true
413}
414
415fn default_weight() -> f64 {
416    1.0
417}
418
419fn default_threshold() -> f64 {
420    1.0
421}
422
423fn format_transcript(messages: &[AgentMessage]) -> String {
424    let mut transcript = String::new();
425    for message in messages {
426        if let Some(line) = get_transcript_line(message, TRANSCRIPT_PAYLOAD_CHARS) {
427            let _ = writeln!(transcript, "{line}");
428        }
429    }
430    transcript
431}
432
433fn get_transcript_line(message: &AgentMessage, max_payload_chars: usize) -> Option<String> {
434    match message {
435        AgentMessage::Text { chunk, is_complete: true, .. } if !chunk.is_empty() => {
436            Some(format!("[agent] {}", truncate_chars(chunk, max_payload_chars)))
437        }
438        AgentMessage::ToolCall { request, .. } => Some(format!(
439            "[tool-call] {} arguments={}",
440            request.name,
441            truncate_chars(&request.arguments, max_payload_chars)
442        )),
443        AgentMessage::ToolResult { result, .. } => {
444            Some(format!("[tool-result] {}: {}", result.name, truncate_chars(&result.result, max_payload_chars)))
445        }
446        AgentMessage::ToolError { error, .. } => {
447            Some(format!("[tool-error] {}", truncate_chars(&format!("{error:?}"), max_payload_chars)))
448        }
449        AgentMessage::Error { message } => Some(format!("[error] {}", truncate_chars(message, max_payload_chars))),
450        AgentMessage::Cancelled { message } => {
451            Some(format!("[error] Cancelled: {}", truncate_chars(message, max_payload_chars)))
452        }
453        AgentMessage::Done => Some("[done]".to_string()),
454        _ => None,
455    }
456}
457
458fn truncate_chars(value: &str, max_chars: usize) -> String {
459    if value.chars().count() <= max_chars {
460        return value.to_string();
461    }
462
463    let truncated: String = value.chars().take(max_chars).collect();
464    format!("{truncated}... [truncated]")
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use aether_core::events::AgentMessage;
471    use llm::testing::FakeLlmProvider;
472    use llm::{LlmError, ToolCallRequest, ToolCallResult};
473
474    const VALID_RESPONSE: &str = r#"{"criteria":[{"id":"behavior","score":1.0,"reason":"correct"},{"id":"clarity","score":0.5,"reason":"brief"}],"overall_reason":"good"}"#;
475
476    #[test]
477    fn transcript_lines_label_each_message_kind() {
478        let call = AgentMessage::ToolCall {
479            request: ToolCallRequest {
480                id: "call_1".to_string(),
481                name: "bash".to_string(),
482                arguments: "{}".to_string(),
483            },
484            model_name: "test".to_string(),
485        };
486
487        assert_eq!(get_transcript_line(&AgentMessage::text("msg_1", "hi", true, "test"), 100).unwrap(), "[agent] hi");
488        assert_eq!(get_transcript_line(&call, 100).unwrap(), "[tool-call] bash arguments={}");
489        assert_eq!(get_transcript_line(&AgentMessage::Done, 100).unwrap(), "[done]");
490    }
491
492    #[test]
493    fn transcript_lines_truncate_long_payloads() {
494        let line = get_transcript_line(&AgentMessage::text("msg_1", &"a".repeat(50), true, "test"), 10).unwrap();
495
496        assert_eq!(line, format!("[agent] {}... [truncated]", "a".repeat(10)));
497    }
498
499    #[test]
500    fn tool_result_transcript_uses_result_arguments() {
501        let message = AgentMessage::ToolResult {
502            result: ToolCallResult {
503                id: "call_1".to_string(),
504                name: "coding__read_file".to_string(),
505                arguments: r#"["Cargo.toml"]"#.to_string(),
506                result: "file contents".to_string(),
507            },
508            result_meta: None,
509            model_name: "test".to_string(),
510        };
511
512        assert_eq!(get_transcript_line(&message, 100).unwrap(), "[tool-result] coding__read_file: file contents");
513    }
514
515    #[test]
516    fn judge_builder_builds_prompt_from_context_and_criteria() {
517        let judge = judge()
518            .instructions("be strict")
519            .task("do the thing")
520            .diff("+added line")
521            .file("notes.txt", "beta\n")
522            .criteria([criterion("works", "the task works", true, 2.0, 0.9)])
523            .build()
524            .unwrap();
525
526        assert!(judge.prompt.contains("## Instructions\n\nbe strict"));
527        assert!(judge.prompt.contains("## Task"));
528        assert!(judge.prompt.contains("The agent you're evaluating was given this task: <task>do the thing</task>"));
529        assert!(judge.prompt.contains("## Git diff"));
530        assert!(judge.prompt.contains("Git diff produced by the agent you're evaluating: <diff>+added line</diff>"));
531        assert!(judge.prompt.contains("## File Contents"));
532        assert!(judge.prompt.contains("<path>notes.txt</path>"));
533        assert!(judge.prompt.contains("<contents>beta\n</contents>"));
534        assert!(judge.prompt.contains("## Rubric criteria"));
535        assert!(judge.prompt.contains("blocking: true"));
536        assert!(judge.prompt.contains("threshold: 0.9"));
537        assert!(judge.prompt.contains("weight: 2"));
538        assert!(judge.prompt.contains("Return exactly one result for every criterion ID above and no extra criteria."));
539        assert!(judge.prompt.contains("Respond with ONLY a JSON object matching this schema:"));
540    }
541
542    #[test]
543    fn judge_builder_renders_transcript_context() {
544        let messages = vec![
545            AgentMessage::ToolCall {
546                request: ToolCallRequest {
547                    id: "call_1".to_string(),
548                    name: "bash".to_string(),
549                    arguments: "{}".to_string(),
550                },
551                model_name: "test".to_string(),
552            },
553            AgentMessage::text("msg_1", "all done", true, "test"),
554        ];
555
556        let judge = judge()
557            .task("edit the file")
558            .transcript(messages)
559            .criteria([criterion("behavior", "did it work", true, 1.0, 1.0)])
560            .build()
561            .unwrap();
562
563        assert!(judge.prompt.contains("## Agent Transcript"));
564        assert!(judge.prompt.contains("[tool-call] bash"));
565        assert!(judge.prompt.contains("[agent] all done"));
566    }
567
568    #[test]
569    fn judge_builder_accepts_slice_criteria() {
570        let criteria = vec![criterion("behavior", "does the thing", true, 1.0, 0.8)];
571        let judge = judge().task("do it").criteria(&criteria).build().unwrap();
572        assert_eq!(judge.criteria[0].id, "behavior");
573    }
574
575    #[test]
576    fn judge_summarizes_weighted_rubric() {
577        let judge = judge().task("prompt").criteria(default_criteria()).build().unwrap();
578
579        let summary = judge.summarize(serde_json::from_str(VALID_RESPONSE).unwrap()).unwrap();
580
581        assert!(summary.passed);
582        assert!((summary.score - 0.875).abs() < f64::EPSILON);
583        assert!((summary.criteria[1].score - 0.5).abs() < f64::EPSILON);
584        assert!(summary.reason.contains("all blockers met"));
585    }
586
587    #[test]
588    fn judge_zeroes_score_when_blocker_fails() {
589        let judge = judge().task("prompt").criteria(default_criteria()).build().unwrap();
590        let response = serde_json::from_str(
591            r#"{"criteria":[{"id":"behavior","score":0.75,"reason":"wrong behavior"},{"id":"clarity","score":1.0,"reason":"clear"}],"overall_reason":"bad"}"#,
592        )
593        .unwrap();
594
595        let summary = judge.summarize(response).unwrap();
596
597        assert!(!summary.passed);
598        assert!(summary.score.abs() < f64::EPSILON);
599        assert!(!summary.criteria[0].passed);
600        assert!(summary.reason.contains("one or more blockers failed"));
601    }
602
603    #[test]
604    fn judge_rejects_invalid_criterion_sets() {
605        let judge = judge().task("prompt").criteria(default_criteria()).build().unwrap();
606        for raw_response in [
607            r#"{"criteria":[],"overall_reason":"missing"}"#,
608            r#"{"criteria":[{"id":"behavior","score":1.0,"reason":"ok"},{"id":"behavior","score":1.0,"reason":"ok"}],"overall_reason":"duplicate"}"#,
609            r#"{"criteria":[{"id":"behavior","score":1.0,"reason":"ok"},{"id":"clarity","score":1.0,"reason":"ok"},{"id":"extra","score":1.0,"reason":"ok"}],"overall_reason":"unknown"}"#,
610            r#"{"criteria":[{"id":"behavior","score":1.5,"reason":"bad"},{"id":"clarity","score":1.0,"reason":"ok"}],"overall_reason":"score"}"#,
611        ] {
612            let response = serde_json::from_str(raw_response).unwrap();
613
614            let error = judge.summarize(response).unwrap_err();
615
616            assert!(matches!(error, JudgeError::InvalidJudgment { .. }), "response: {raw_response}");
617        }
618    }
619
620    #[test]
621    fn judge_builder_rejects_invalid_inputs() {
622        for (builder, expected) in [
623            (judge().criteria([criterion("behavior", "ok", true, 1.0, 0.8)]), "judge task must be provided"),
624            (judge().task("prompt"), "judge criteria must not be empty"),
625            (
626                judge().task("prompt").criteria([criterion("", "ok", true, 1.0, 0.8)]),
627                "judge criterion id must not be empty",
628            ),
629            (
630                judge().task("prompt").criteria([criterion("behavior", "ok", true, 0.0, 0.8)]),
631                "weight must be positive and finite",
632            ),
633        ] {
634            let error = builder.build().unwrap_err();
635            assert!(error.to_string().contains(expected), "got: {error}");
636        }
637    }
638
639    #[test]
640    fn blocking_failures_report_only_blocking_criteria_below_threshold() {
641        let criterion = |id: &str, blocking, score: f64| JudgeCriterionSummary {
642            id: id.to_string(),
643            description: "desc".to_string(),
644            blocking,
645            weight: 1.0,
646            threshold: 0.8,
647            score,
648            passed: score >= 0.8,
649            reason: format!("{id} reason"),
650        };
651        let summary = JudgeSummary {
652            passed: false,
653            score: 0.0,
654            reason: "r".to_string(),
655            criteria: vec![
656                criterion("met", true, 0.9),
657                criterion("failed", true, 0.5),
658                criterion("advisory", false, 0.0),
659            ],
660        };
661
662        let failures: Vec<String> = summary.blocking_failures().collect();
663
664        assert_eq!(failures, vec!["judge criterion `failed`: failed reason".to_string()]);
665    }
666
667    #[tokio::test]
668    async fn judge_run_extracts_json_object_from_surrounding_prose() {
669        let response = format!("Here is my assessment:\n{VALID_RESPONSE}");
670        let judge_llm = FakeLlmProvider::with_single_response(vec![LlmResponse::text(&response)]);
671        let judge = judge().task("prompt").criteria(default_criteria()).build().unwrap();
672
673        let summary = judge.run(&judge_llm).await.unwrap();
674
675        assert!(summary.passed);
676    }
677
678    #[tokio::test]
679    async fn judge_run_returns_invalid_json_error_with_raw_response() {
680        let judge_llm = FakeLlmProvider::with_single_response(vec![LlmResponse::text("not json")]);
681        let judge = judge().task("prompt").criteria(default_criteria()).build().unwrap();
682
683        let error = judge.run(&judge_llm).await.unwrap_err();
684
685        let JudgeError::InvalidJson { raw_response, .. } = error else {
686            panic!("expected InvalidJson, got {error:?}");
687        };
688        assert_eq!(raw_response, "not json");
689    }
690
691    #[tokio::test]
692    async fn judge_run_returns_stream_error_on_llm_failure() {
693        let judge_llm = FakeLlmProvider::from_results(vec![vec![Err(LlmError::Other("boom".to_string()))]]);
694        let judge = judge().task("prompt").criteria(default_criteria()).build().unwrap();
695
696        let error = judge.run(&judge_llm).await.unwrap_err();
697
698        assert!(matches!(error, JudgeError::Stream(_)));
699        assert!(error.to_string().contains("boom"));
700    }
701
702    fn criterion(id: &str, description: &str, blocking: bool, weight: f64, threshold: f64) -> JudgeCriterionSpec {
703        JudgeCriterionSpec { id: id.to_string(), description: description.to_string(), blocking, weight, threshold }
704    }
705
706    fn default_criteria() -> Vec<JudgeCriterionSpec> {
707        vec![
708            criterion("behavior", "The behavior is correct.", true, 3.0, 1.0),
709            criterion("clarity", "The response is clear.", false, 1.0, 0.5),
710        ]
711    }
712}