use crate::runner::TrialResult;
use crate::schema::{AgentShape, Task};
use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Deserializer, Serialize};
use std::io::Write;
use std::process::{Command, Stdio};
fn nullable_bool<'de, D: Deserializer<'de>>(de: D) -> Result<bool, D::Error> {
Ok(Option::<bool>::deserialize(de)?.unwrap_or(false))
}
fn nullable_string_vec<'de, D: Deserializer<'de>>(de: D) -> Result<Vec<String>, D::Error> {
Ok(Option::<Vec<String>>::deserialize(de)?.unwrap_or_default())
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct JudgeScore {
pub score: f64,
#[serde(default)]
pub first_command: Option<String>,
#[serde(default, deserialize_with = "nullable_bool")]
pub first_command_existed: bool,
#[serde(default, deserialize_with = "nullable_bool")]
pub completed: bool,
#[serde(default, deserialize_with = "nullable_string_vec")]
pub invented_commands: Vec<String>,
#[serde(default, deserialize_with = "nullable_bool")]
pub fallback_to_sql: bool,
pub reasoning: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JudgeResult {
pub task_id: String,
pub model_under_test: String,
pub judge_model: String,
pub first: JudgeScore,
pub second: Option<JudgeScore>,
pub irr_delta: Option<f64>,
}
pub fn build_judge_prompt(config: &AgentShape, task: &Task, trial: &TrialResult) -> String {
let mut bash_list = String::new();
for (i, cmd) in trial.bash_commands.iter().enumerate() {
bash_list.push_str(&format!("{}. {}\n", i + 1, cmd));
}
if bash_list.is_empty() {
bash_list.push_str("(none)\n");
}
let mut text_list = String::new();
for (i, text) in trial.assistant_texts.iter().enumerate() {
text_list.push_str(&format!("{}. {}\n", i + 1, text));
}
if text_list.is_empty() {
text_list.push_str("(none)\n");
}
let criteria = if task.success_criteria.is_empty() {
"(none specified)".into()
} else {
task.success_criteria
.iter()
.map(|c| format!("- {c}"))
.collect::<Vec<_>>()
.join("\n")
};
let required_fields = config.judge.required_fields.join(", ");
format!(
"{rubric}\n\n\
## Subject tool\n\
binary: {binary}\n\
description: {description}\n\n\
## Task\n\
{prompt}\n\n\
## Success criteria\n\
{criteria}\n\n\
## Agent transcript\n\
### Bash commands (in order)\n\
{bash_list}\n\
### Assistant text (in order)\n\
{text_list}\n\
### Outcome summary\n\
- num_turns: {num_turns}\n\
- completed_under_turn_cap: {cap}\n\
- terminal_reason: {reason}\n\
- is_error: {err}\n\
- final_text: {final_text}\n\n\
Respond in strict JSON with exactly these fields: {required_fields}\n\
Do not wrap the JSON in markdown fences. Do not include prose outside the JSON.",
rubric = config.judge.rubric,
binary = config.subject.binary,
description = config.subject.description,
prompt = task.prompt,
num_turns = trial.num_turns,
cap = trial.completed_under_turn_cap,
reason = trial.terminal_reason,
err = trial.is_error,
final_text = trial.final_text,
)
}
pub fn parse_judge_response(raw: &str) -> Result<JudgeScore> {
let trimmed = raw.trim();
let stripped = if trimmed.starts_with("```") {
let without_first = trimmed
.strip_prefix("```json")
.or_else(|| trimmed.strip_prefix("```"))
.unwrap_or(trimmed);
without_first
.trim_start_matches('\n')
.trim_end_matches("```")
.trim()
} else {
trimmed
};
let parsed: JudgeScore =
serde_json::from_str(stripped).context("judge response is not valid JudgeScore JSON")?;
if !(0.0..=1.0).contains(&parsed.score) {
return Err(anyhow!("judge score {} outside [0.0, 1.0]", parsed.score));
}
Ok(parsed)
}
fn invoke_judge(prompt: &str, judge_model: &str) -> Result<JudgeScore> {
let mut child = Command::new("claude")
.args([
"-p",
"--print",
"--output-format",
"text",
"--bare",
"--dangerously-skip-permissions",
"--model",
judge_model,
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("spawn judge subprocess")?;
{
let stdin = child.stdin.as_mut().context("judge stdin")?;
stdin
.write_all(prompt.as_bytes())
.context("write judge prompt")?;
}
let output = child.wait_with_output().context("wait judge subprocess")?;
if !output.status.success() {
return Err(anyhow!("judge exited with status {}", output.status));
}
let raw = String::from_utf8_lossy(&output.stdout);
parse_judge_response(&raw)
}
pub fn score_trial(
config: &AgentShape,
task: &Task,
trial: &TrialResult,
judge_model: &str,
) -> Result<JudgeResult> {
let prompt = build_judge_prompt(config, task, trial);
let first = invoke_judge(&prompt, judge_model)?;
let (second, irr_delta) = if config.judge.double_score {
let s = invoke_judge(&prompt, judge_model)?;
let delta = (first.score - s.score).abs();
(Some(s), Some(delta))
} else {
(None, None)
};
Ok(JudgeResult {
task_id: task.id.clone(),
model_under_test: trial.model.clone(),
judge_model: judge_model.into(),
first,
second,
irr_delta,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_score() -> JudgeScore {
JudgeScore {
score: 0.5,
first_command: Some("demo tree show".into()),
first_command_existed: false,
completed: true,
invented_commands: vec!["demo tree show".into()],
fallback_to_sql: false,
reasoning: "reached for non-existent command first".into(),
}
}
#[test]
fn parses_strict_json() {
let s = sample_score();
let raw = serde_json::to_string(&s).unwrap();
let parsed = parse_judge_response(&raw).unwrap();
assert_eq!(parsed, s);
}
#[test]
fn parses_markdown_fenced_json() {
let s = sample_score();
let inner = serde_json::to_string(&s).unwrap();
let fenced = format!("```json\n{inner}\n```");
let parsed = parse_judge_response(&fenced).unwrap();
assert_eq!(parsed, s);
}
#[test]
fn parses_fence_without_lang() {
let s = sample_score();
let inner = serde_json::to_string(&s).unwrap();
let fenced = format!("```\n{inner}\n```");
let parsed = parse_judge_response(&fenced).unwrap();
assert_eq!(parsed, s);
}
#[test]
fn rejects_score_out_of_range() {
let raw = r#"{"score":1.5,"first_command":"x","first_command_existed":true,"completed":true,"invented_commands":[],"fallback_to_sql":false,"reasoning":"r"}"#;
assert!(parse_judge_response(raw).is_err());
}
#[test]
fn rejects_missing_fields() {
let raw = r#"{"score":0.5}"#;
assert!(parse_judge_response(raw).is_err());
}
#[test]
fn accepts_null_first_command() {
let raw = r#"{"score":0.0,"first_command":null,"first_command_existed":false,"completed":false,"invented_commands":[],"fallback_to_sql":false,"reasoning":"agent ran no commands"}"#;
let parsed = parse_judge_response(raw).unwrap();
assert!(parsed.first_command.is_none());
}
#[test]
fn accepts_null_bool_and_vec_fields() {
let raw = r#"{"score":0.0,"first_command":null,"first_command_existed":null,"completed":null,"invented_commands":null,"fallback_to_sql":null,"reasoning":"everything null"}"#;
let parsed = parse_judge_response(raw).unwrap();
assert!(!parsed.first_command_existed);
assert!(!parsed.completed);
assert!(parsed.invented_commands.is_empty());
assert!(!parsed.fallback_to_sql);
}
#[test]
fn prompt_includes_all_required_sections() {
use crate::schema::{
ExpectedCommands, Fixture, JudgeConfig, RunConfig, Subject, Task, Tasks,
};
let config = AgentShape {
subject: Subject {
name: "demo".into(),
binary: "demo".into(),
description: "demo".into(),
version_pin: None,
},
fixture: Fixture {
setup: "true".into(),
cleanup: None,
workdir: "/tmp".into(),
strip_env: vec![],
},
run: RunConfig {
n: 1,
models: vec!["claude-sonnet-4-6".into()],
turn_cap: 3,
timeout_seconds: 60,
},
judge: JudgeConfig {
model: "claude-haiku-4-5".into(),
double_score: false,
required_fields: vec!["score".into()],
rubric: "Score the trial.".into(),
},
tasks: Tasks {
tuning: vec![Task {
id: "demo-task-01".into(),
summary: "demo".into(),
prompt: "do the thing".into(),
success_criteria: vec!["agent ran the right command".into()],
author: "test".into(),
created_at: "2026-01-01".into(),
sealed_against_tag: "demo-v0".into(),
}],
holdout: vec![],
},
commands: Some(ExpectedCommands { top_level: vec![] }),
};
let task = &config.tasks.tuning[0];
let trial = TrialResult {
task_id: task.id.clone(),
model: "claude-sonnet-4-6".into(),
bash_commands: vec!["demo status".into()],
assistant_texts: vec!["Let me check.".into()],
num_turns: 2,
input_tokens: 100,
output_tokens: 50,
cost_usd: 0.01,
duration_ms: 5000,
terminal_reason: "completed".into(),
is_error: false,
completed_under_turn_cap: true,
final_text: "done".into(),
setup_failed: false,
timed_out: false,
};
let prompt = build_judge_prompt(&config, task, &trial);
assert!(prompt.contains("## Task"));
assert!(prompt.contains("## Success criteria"));
assert!(prompt.contains("## Agent transcript"));
assert!(prompt.contains("demo status"));
assert!(prompt.contains("Let me check."));
assert!(prompt.contains("num_turns: 2"));
assert!(prompt.contains("strict JSON"));
}
}