Skip to main content

eval_magic/pipeline/grade/
judge_tasks.rs

1//! Judge-task emission.
2//!
3//! For each
4//! `(eval, condition)` it builds judge prompts for `llm_judge` assertions and a
5//! skill-invocation meta-check (code-checked from the transcript when possible,
6//! else emitted as an LLM judge task), writing `judge-tasks.json` plus the
7//! per-assertion prompt files. `transcript_check` assertions are not dispatched
8//! here — they are graded directly in `finalize`.
9
10use std::fs;
11use std::path::Path;
12
13use serde::Serialize;
14use serde_json::json;
15
16use crate::core::{Assertion, Harness, RunRecord, SKILL_INVOKED_META_ID, ToolInvocation};
17use crate::pipeline::error::PipelineError;
18use crate::pipeline::io::{now_iso8601, write_json};
19use crate::pipeline::slots::run_slots;
20use crate::validation::{SchemaName, validate_against_schema};
21
22use super::GradeContext;
23
24/// One judge task. `dispatch_prompt` carries the full prompt in memory but is
25/// stripped from the serialized `judge-tasks.json` (the orchestrator reads it
26/// from `dispatch_prompt_path` instead). `model` is always present (null or a
27/// model id).
28#[derive(Debug, Clone, Serialize)]
29pub struct JudgeTask {
30    pub eval_id: String,
31    pub condition: String,
32    /// 1-based run index within a multi-run cell; absent for single-run cells.
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub run_index: Option<u32>,
35    pub assertion_id: String,
36    pub rubric: String,
37    pub model: Option<String>,
38    pub is_meta: bool,
39    pub run_record_path: String,
40    pub outputs_dir: String,
41    pub response_path: String,
42    pub dispatch_prompt_path: String,
43    #[serde(skip_serializing)]
44    pub dispatch_prompt: String,
45}
46
47/// The serialized `judge-tasks.json` envelope.
48#[derive(Debug, Serialize)]
49struct JudgeTasksFile {
50    generated: String,
51    total_tasks: usize,
52    meta_tasks_injected: usize,
53    skipped_transcript_checks: usize,
54    tasks: Vec<JudgeTask>,
55}
56
57/// What emission produced, for the CLI summary.
58#[derive(Debug, Default, Clone, Copy)]
59pub struct EmitSummary {
60    pub total_tasks: usize,
61    pub meta_injected: usize,
62    pub meta_code_checked: usize,
63    pub skipped_transcript_checks: usize,
64    pub skipped_missing: usize,
65}
66
67/// True when the transcript shows the `Skill` tool invoked with `input.skill`
68/// equal to the staged slug.
69pub fn check_skill_invoked_from_transcript(
70    invocations: &[ToolInvocation],
71    staged_slug: Option<&str>,
72) -> bool {
73    let Some(slug) = staged_slug else {
74        return false;
75    };
76    invocations.iter().any(|inv| {
77        inv.name == "Skill"
78            && inv
79                .args
80                .as_ref()
81                .and_then(|a| a.get("skill"))
82                .and_then(|v| v.as_str())
83                == Some(slug)
84    })
85}
86
87/// The meta-check rubric asking a judge whether the agent actually applied the
88/// skill (separate from correctness).
89fn skill_invoked_rubric(skill_name: &str, skill_content: Option<&str>) -> String {
90    let mut lines: Vec<String> = vec![
91        format!(
92            "The agent had access to the **{skill_name}** skill. This meta-check asks whether \
93             there is evidence the agent actually applied the skill in this run — separate from \
94             whether the response was correct."
95        ),
96        String::new(),
97    ];
98    if let Some(content) = skill_content {
99        lines.push("# Skill content".to_string());
100        lines.push(String::new());
101        lines.push("```markdown".to_string());
102        lines.push(content.trim().to_string());
103        lines.push("```".to_string());
104        lines.push(String::new());
105    }
106    lines.extend(
107        [
108            "Evidence the skill WAS applied:",
109            "- The agent cites the skill by name or references specific named sections (e.g. \"Iron Law\", \"Red Flags\", \"Gate Function\", or any other distinctive heading from the skill).",
110            "- The agent's response uses distinctive vocabulary or phrasing taken from the skill content.",
111            "- The agent's behavior follows a specific procedural step prescribed by the skill in a way that mirrors the skill's phrasing — not just generic best practice.",
112            "- The agent explicitly acknowledges following the skill's guidance.",
113            "",
114            "Evidence the skill was NOT applied:",
115            "- The response uses only generic best-practice language unrelated to the skill's specific framing.",
116            "- No vocabulary, structure, or rules from the skill content appear anywhere in the response.",
117            "- The response would read identically with or without the skill loaded.",
118            "",
119            "Compare the agent's `final_message` against the skill content. Look for stylistic and procedural fingerprints.",
120            "",
121            "PASS if there is observable evidence the skill influenced the response.",
122            "FAIL if there is no observable evidence — the response is indistinguishable from baseline behavior.",
123        ]
124        .iter()
125        .map(|s| s.to_string()),
126    );
127    lines.join("\n")
128}
129
130/// A directory listing for the judge prompt: visible entries, dirs suffixed `/`,
131/// sorted; `(empty)` when none.
132fn list_outputs(dir: &Path) -> String {
133    let Ok(entries) = fs::read_dir(dir) else {
134        return "(empty)".to_string();
135    };
136    let mut names: Vec<String> = entries
137        .flatten()
138        .filter_map(|e| {
139            let name = e.file_name().to_string_lossy().into_owned();
140            if name.starts_with('.') || name == "node_modules" {
141                return None;
142            }
143            let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
144            Some(if is_dir { format!("{name}/") } else { name })
145        })
146        .collect();
147    names.sort();
148    if names.is_empty() {
149        "(empty)".to_string()
150    } else {
151        names.join("\n")
152    }
153}
154
155/// Assemble the full judge prompt (rubric + run record + outputs listing +
156/// grading principles + where to write the verdict).
157fn build_judge_prompt(
158    rubric: &str,
159    run_record: &RunRecord,
160    outputs_dir: &Path,
161    response_path: &Path,
162) -> String {
163    let outputs_listing = if outputs_dir.exists() {
164        list_outputs(outputs_dir)
165    } else {
166        "(none)".to_string()
167    };
168    let record_json = serde_json::to_string_pretty(run_record).unwrap_or_default();
169
170    [
171        "You are grading one assertion for a skill evaluation run. Be strict but fair.",
172        "Grade only this one assertion. Do not run eval-magic. Do not dispatch other judge tasks. Do not wait for other workers.",
173        "",
174        "# Run record",
175        "",
176        "```json",
177        &record_json,
178        "```",
179        "",
180        "# Outputs directory contents",
181        "",
182        "```",
183        &outputs_listing,
184        "```",
185        "",
186        "# Assertion to grade",
187        "",
188        rubric,
189        "",
190        "# Grading principles",
191        "",
192        "- PASS requires concrete evidence (a direct quote or specific reference from the run record's `final_message` or outputs). Don't infer behavior not present in the record.",
193        "- A correct response expressed in different words from what the assertion implies is still a PASS if the substance matches.",
194        "- If the assertion is unverifiable from the available material (e.g. requires the tool-invocation list and the run record has none), return `passed: false`, `evidence: 'assertion is unverifiable from available material'`, `confidence: 1.0`.",
195        "",
196        "# Task",
197        "",
198        &format!("Write your verdict as a JSON file to: {}", response_path.display()),
199        "",
200        "The JSON must match this schema (exactly these keys, no extra prose in the file):",
201        "",
202        "```json",
203        "{ \"passed\": true|false, \"evidence\": \"direct quote or reference\", \"confidence\": 0.0-1.0 }",
204        "```",
205        "",
206        "After writing the file, your final user-facing reply should be one sentence summarising the verdict.",
207    ]
208    .join("\n")
209}
210
211/// Emit judge tasks + prompt files for the iteration, writing `judge-tasks.json`.
212/// See the module docs for the per-assertion and meta-check behavior.
213pub fn emit_judge_tasks(ctx: &GradeContext) -> Result<EmitSummary, PipelineError> {
214    let conds: Vec<(String, Option<String>, Option<String>)> = ctx
215        .conditions
216        .conditions
217        .iter()
218        .map(|c| {
219            (
220                c.name.clone(),
221                c.skill_path.clone(),
222                c.staged_skill_slug.clone().flatten(),
223            )
224        })
225        .collect();
226    let code_check_available = ctx.conditions.harness != Some(Harness::Codex);
227    let default_judge_model = ctx.conditions.judge_model.clone();
228
229    let mut tasks: Vec<JudgeTask> = Vec::new();
230    let mut summary = EmitSummary::default();
231    let mut unverifiable = 0usize;
232
233    for ev in &ctx.evals.evals {
234        let assertions = ev.assertions.as_deref().unwrap_or(&[]);
235        let has_assertions = !assertions.is_empty();
236
237        for (cond, cond_skill_path, staged_slug) in &conds {
238            let cond_dir = ctx.iteration_dir.join(format!("eval-{}", ev.id)).join(cond);
239            for slot in run_slots(&cond_dir) {
240                let run_record_path = slot.dir.join("run.json");
241                let outputs_dir = slot.dir.join("outputs");
242                let judge_responses_dir = slot.dir.join("judge-responses");
243                let judge_prompts_dir = slot.dir.join("judge-prompts");
244
245                if !run_record_path.exists() {
246                    let run = slot
247                        .run_index
248                        .map(|k| format!("/run-{k}"))
249                        .unwrap_or_default();
250                    eprintln!(
251                        "warn: missing run.json for {}/{cond}{run} — skipping",
252                        ev.id
253                    );
254                    if has_assertions {
255                        summary.skipped_missing += assertions.len();
256                    }
257                    continue;
258                }
259
260                fs::create_dir_all(&judge_responses_dir)?;
261                fs::create_dir_all(&judge_prompts_dir)?;
262                let run_record: RunRecord = validate_against_schema(
263                    SchemaName::RunRecord,
264                    &serde_json::from_str(&fs::read_to_string(&run_record_path)?)?,
265                    &run_record_path.to_string_lossy(),
266                )?;
267
268                for assertion in assertions {
269                    let Assertion::LlmJudge(j) = assertion else {
270                        // transcript_check is graded in finalize, not dispatched here.
271                        unverifiable += 1;
272                        continue;
273                    };
274                    let response_path = judge_responses_dir.join(format!("{}.json", j.id));
275                    let dispatch_prompt =
276                        build_judge_prompt(&j.rubric, &run_record, &outputs_dir, &response_path);
277                    let prompt_path = judge_prompts_dir.join(format!("{}.txt", j.id));
278                    fs::write(&prompt_path, &dispatch_prompt)?;
279                    tasks.push(JudgeTask {
280                        eval_id: ev.id.clone(),
281                        condition: cond.clone(),
282                        run_index: slot.run_index,
283                        assertion_id: j.id.clone(),
284                        rubric: j.rubric.clone(),
285                        model: j.model.clone().or_else(|| default_judge_model.clone()),
286                        is_meta: false,
287                        run_record_path: run_record_path.to_string_lossy().into_owned(),
288                        outputs_dir: outputs_dir.to_string_lossy().into_owned(),
289                        response_path: response_path.to_string_lossy().into_owned(),
290                        dispatch_prompt_path: prompt_path.to_string_lossy().into_owned(),
291                        dispatch_prompt,
292                    });
293                }
294
295                // Skill-invocation meta-check. Negative evals (skill_should_trigger:
296                // false) expect non-invocation, so they carry no meta-check.
297                if cond_skill_path.is_some() && ev.skill_should_trigger != Some(false) {
298                    let response_path =
299                        judge_responses_dir.join(format!("{SKILL_INVOKED_META_ID}.json"));
300                    let transcript_filled = !run_record.tool_invocations.is_empty();
301
302                    if staged_slug.is_some() && transcript_filled && code_check_available {
303                        let invoked = check_skill_invoked_from_transcript(
304                            &run_record.tool_invocations,
305                            staged_slug.as_deref(),
306                        );
307                        let evidence = if invoked {
308                            "Skill invocation verified from transcript.".to_string()
309                        } else {
310                            format!(
311                                "No skill invocation found in transcript across {} transcript invocation(s).",
312                                run_record.tool_invocations.len()
313                            )
314                        };
315                        write_json(
316                            &response_path,
317                            &json!({
318                                "passed": invoked,
319                                "evidence": evidence,
320                                "confidence": 1.0,
321                                "grader": "transcript_check",
322                            }),
323                        )?;
324                        summary.meta_code_checked += 1;
325                    } else {
326                        let skill_content =
327                            fs::read_to_string(cond_skill_path.as_deref().unwrap_or(""))?;
328                        let rubric =
329                            skill_invoked_rubric(&ctx.evals.skill_name, Some(&skill_content));
330                        let dispatch_prompt =
331                            build_judge_prompt(&rubric, &run_record, &outputs_dir, &response_path);
332                        let prompt_path =
333                            judge_prompts_dir.join(format!("{SKILL_INVOKED_META_ID}.txt"));
334                        fs::write(&prompt_path, &dispatch_prompt)?;
335                        tasks.push(JudgeTask {
336                            eval_id: ev.id.clone(),
337                            condition: cond.clone(),
338                            run_index: slot.run_index,
339                            assertion_id: SKILL_INVOKED_META_ID.to_string(),
340                            rubric,
341                            model: default_judge_model.clone(),
342                            is_meta: true,
343                            run_record_path: run_record_path.to_string_lossy().into_owned(),
344                            outputs_dir: outputs_dir.to_string_lossy().into_owned(),
345                            response_path: response_path.to_string_lossy().into_owned(),
346                            dispatch_prompt_path: prompt_path.to_string_lossy().into_owned(),
347                            dispatch_prompt,
348                        });
349                        summary.meta_injected += 1;
350                    }
351                }
352            }
353        }
354    }
355
356    summary.total_tasks = tasks.len();
357    summary.skipped_transcript_checks = unverifiable;
358
359    let tasks_path = ctx.iteration_dir.join("judge-tasks.json");
360    let file = JudgeTasksFile {
361        generated: now_iso8601(),
362        total_tasks: tasks.len(),
363        meta_tasks_injected: summary.meta_injected,
364        skipped_transcript_checks: unverifiable,
365        tasks,
366    };
367    validate_against_schema::<serde_json::Value>(
368        SchemaName::JudgeTasks,
369        &serde_json::to_value(&file)?,
370        &tasks_path.to_string_lossy(),
371    )?;
372    write_json(&tasks_path, &file)?;
373
374    Ok(summary)
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use serde_json::json;
381
382    fn inv(name: &str, args: Option<serde_json::Value>, ordinal: u32) -> ToolInvocation {
383        ToolInvocation {
384            name: name.to_string(),
385            args,
386            result: None,
387            ordinal,
388        }
389    }
390
391    #[test]
392    fn true_when_skill_call_matches_slug() {
393        let slug = "slow-powers-eval-1-with_skill__verification-before-completion";
394        let invs = [
395            inv("Bash", Some(json!({"command": "ls"})), 0),
396            inv("Skill", Some(json!({"skill": slug})), 1),
397            inv("Read", Some(json!({"file_path": "/tmp/x"})), 2),
398        ];
399        assert!(check_skill_invoked_from_transcript(&invs, Some(slug)));
400    }
401
402    #[test]
403    fn false_when_no_skill_calls() {
404        let invs = [
405            inv("Bash", Some(json!({"command": "ls"})), 0),
406            inv("Read", Some(json!({"file_path": "/tmp/x"})), 1),
407        ];
408        assert!(!check_skill_invoked_from_transcript(
409            &invs,
410            Some("slow-powers-eval-1-with_skill__foo")
411        ));
412    }
413
414    #[test]
415    fn false_when_skill_call_references_different_slug() {
416        let slug = "slow-powers-eval-1-with_skill__verification-before-completion";
417        let invs = [
418            inv(
419                "Skill",
420                Some(json!({"skill": "slow-powers:writing-skills"})),
421                0,
422            ),
423            inv(
424                "Skill",
425                Some(json!({"skill": "slow-powers-eval-2-old_skill__other"})),
426                1,
427            ),
428        ];
429        assert!(!check_skill_invoked_from_transcript(&invs, Some(slug)));
430    }
431
432    #[test]
433    fn false_on_empty_invocations() {
434        assert!(!check_skill_invoked_from_transcript(&[], Some("anything")));
435    }
436
437    #[test]
438    fn tolerates_missing_or_malformed_skill_args() {
439        let slug = "slow-powers-eval-1-with_skill__foo";
440        let invs = [
441            inv("Skill", None, 0),
442            inv("Skill", Some(json!("not-an-object")), 1),
443            inv("Skill", Some(json!({"other": "field"})), 2),
444        ];
445        assert!(!check_skill_invoked_from_transcript(&invs, Some(slug)));
446    }
447}