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, 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    // The deterministic `__skill_invoked` code check needs a transcript that
227    // exposes a skill-invocation event; harnesses without one (per their
228    // adapter) fall back to the LLM judge.
229    let code_check_available = ctx
230        .conditions
231        .harness
232        .is_none_or(|h| crate::adapters::adapter_for(h).transcript_surfaces_skill_invocation());
233    let default_judge_model = ctx.conditions.judge_model.clone();
234
235    let mut tasks: Vec<JudgeTask> = Vec::new();
236    let mut summary = EmitSummary::default();
237    let mut unverifiable = 0usize;
238
239    for ev in &ctx.evals.evals {
240        let assertions = ev.assertions.as_deref().unwrap_or(&[]);
241        let has_assertions = !assertions.is_empty();
242
243        for (cond, cond_skill_path, staged_slug) in &conds {
244            let cond_dir = ctx.iteration_dir.join(format!("eval-{}", ev.id)).join(cond);
245            for slot in run_slots(&cond_dir) {
246                let run_record_path = slot.dir.join("run.json");
247                let outputs_dir = slot.dir.join("outputs");
248                let judge_responses_dir = slot.dir.join("judge-responses");
249                let judge_prompts_dir = slot.dir.join("judge-prompts");
250
251                if !run_record_path.exists() {
252                    let run = slot
253                        .run_index
254                        .map(|k| format!("/run-{k}"))
255                        .unwrap_or_default();
256                    eprintln!(
257                        "warn: missing run.json for {}/{cond}{run} — skipping",
258                        ev.id
259                    );
260                    if has_assertions {
261                        summary.skipped_missing += assertions.len();
262                    }
263                    continue;
264                }
265
266                fs::create_dir_all(&judge_responses_dir)?;
267                fs::create_dir_all(&judge_prompts_dir)?;
268                let run_record: RunRecord = validate_against_schema(
269                    SchemaName::RunRecord,
270                    &serde_json::from_str(&fs::read_to_string(&run_record_path)?)?,
271                    &run_record_path.to_string_lossy(),
272                )?;
273
274                for assertion in assertions {
275                    let Assertion::LlmJudge(j) = assertion else {
276                        // transcript_check is graded in finalize, not dispatched here.
277                        unverifiable += 1;
278                        continue;
279                    };
280                    let response_path = judge_responses_dir.join(format!("{}.json", j.id));
281                    let dispatch_prompt =
282                        build_judge_prompt(&j.rubric, &run_record, &outputs_dir, &response_path);
283                    let prompt_path = judge_prompts_dir.join(format!("{}.txt", j.id));
284                    fs::write(&prompt_path, &dispatch_prompt)?;
285                    tasks.push(JudgeTask {
286                        eval_id: ev.id.clone(),
287                        condition: cond.clone(),
288                        run_index: slot.run_index,
289                        assertion_id: j.id.clone(),
290                        rubric: j.rubric.clone(),
291                        model: j.model.clone().or_else(|| default_judge_model.clone()),
292                        is_meta: false,
293                        run_record_path: run_record_path.to_string_lossy().into_owned(),
294                        outputs_dir: outputs_dir.to_string_lossy().into_owned(),
295                        response_path: response_path.to_string_lossy().into_owned(),
296                        dispatch_prompt_path: prompt_path.to_string_lossy().into_owned(),
297                        dispatch_prompt,
298                    });
299                }
300
301                // Skill-invocation meta-check. Negative evals (skill_should_trigger:
302                // false) expect non-invocation, so they carry no meta-check.
303                if cond_skill_path.is_some() && ev.skill_should_trigger != Some(false) {
304                    let response_path =
305                        judge_responses_dir.join(format!("{SKILL_INVOKED_META_ID}.json"));
306                    let transcript_filled = !run_record.tool_invocations.is_empty();
307
308                    if staged_slug.is_some() && transcript_filled && code_check_available {
309                        let invoked = check_skill_invoked_from_transcript(
310                            &run_record.tool_invocations,
311                            staged_slug.as_deref(),
312                        );
313                        let evidence = if invoked {
314                            "Skill invocation verified from transcript.".to_string()
315                        } else {
316                            format!(
317                                "No skill invocation found in transcript across {} transcript invocation(s).",
318                                run_record.tool_invocations.len()
319                            )
320                        };
321                        write_json(
322                            &response_path,
323                            &json!({
324                                "passed": invoked,
325                                "evidence": evidence,
326                                "confidence": 1.0,
327                                "grader": "transcript_check",
328                            }),
329                        )?;
330                        summary.meta_code_checked += 1;
331                    } else {
332                        let skill_content =
333                            fs::read_to_string(cond_skill_path.as_deref().unwrap_or(""))?;
334                        let rubric =
335                            skill_invoked_rubric(&ctx.evals.skill_name, Some(&skill_content));
336                        let dispatch_prompt =
337                            build_judge_prompt(&rubric, &run_record, &outputs_dir, &response_path);
338                        let prompt_path =
339                            judge_prompts_dir.join(format!("{SKILL_INVOKED_META_ID}.txt"));
340                        fs::write(&prompt_path, &dispatch_prompt)?;
341                        tasks.push(JudgeTask {
342                            eval_id: ev.id.clone(),
343                            condition: cond.clone(),
344                            run_index: slot.run_index,
345                            assertion_id: SKILL_INVOKED_META_ID.to_string(),
346                            rubric,
347                            model: default_judge_model.clone(),
348                            is_meta: true,
349                            run_record_path: run_record_path.to_string_lossy().into_owned(),
350                            outputs_dir: outputs_dir.to_string_lossy().into_owned(),
351                            response_path: response_path.to_string_lossy().into_owned(),
352                            dispatch_prompt_path: prompt_path.to_string_lossy().into_owned(),
353                            dispatch_prompt,
354                        });
355                        summary.meta_injected += 1;
356                    }
357                }
358            }
359        }
360    }
361
362    summary.total_tasks = tasks.len();
363    summary.skipped_transcript_checks = unverifiable;
364
365    let tasks_path = ctx.iteration_dir.join("judge-tasks.json");
366    let file = JudgeTasksFile {
367        generated: now_iso8601(),
368        total_tasks: tasks.len(),
369        meta_tasks_injected: summary.meta_injected,
370        skipped_transcript_checks: unverifiable,
371        tasks,
372    };
373    validate_against_schema::<serde_json::Value>(
374        SchemaName::JudgeTasks,
375        &serde_json::to_value(&file)?,
376        &tasks_path.to_string_lossy(),
377    )?;
378    write_json(&tasks_path, &file)?;
379
380    Ok(summary)
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use serde_json::json;
387
388    fn inv(name: &str, args: Option<serde_json::Value>, ordinal: u32) -> ToolInvocation {
389        ToolInvocation {
390            name: name.to_string(),
391            args,
392            result: None,
393            ordinal,
394        }
395    }
396
397    #[test]
398    fn true_when_skill_call_matches_slug() {
399        let slug = "slow-powers-eval-1-with_skill__verification-before-completion";
400        let invs = [
401            inv("Bash", Some(json!({"command": "ls"})), 0),
402            inv("Skill", Some(json!({"skill": slug})), 1),
403            inv("Read", Some(json!({"file_path": "/tmp/x"})), 2),
404        ];
405        assert!(check_skill_invoked_from_transcript(&invs, Some(slug)));
406    }
407
408    #[test]
409    fn false_when_no_skill_calls() {
410        let invs = [
411            inv("Bash", Some(json!({"command": "ls"})), 0),
412            inv("Read", Some(json!({"file_path": "/tmp/x"})), 1),
413        ];
414        assert!(!check_skill_invoked_from_transcript(
415            &invs,
416            Some("slow-powers-eval-1-with_skill__foo")
417        ));
418    }
419
420    #[test]
421    fn false_when_skill_call_references_different_slug() {
422        let slug = "slow-powers-eval-1-with_skill__verification-before-completion";
423        let invs = [
424            inv(
425                "Skill",
426                Some(json!({"skill": "slow-powers:writing-skills"})),
427                0,
428            ),
429            inv(
430                "Skill",
431                Some(json!({"skill": "slow-powers-eval-2-old_skill__other"})),
432                1,
433            ),
434        ];
435        assert!(!check_skill_invoked_from_transcript(&invs, Some(slug)));
436    }
437
438    #[test]
439    fn false_on_empty_invocations() {
440        assert!(!check_skill_invoked_from_transcript(&[], Some("anything")));
441    }
442
443    #[test]
444    fn tolerates_missing_or_malformed_skill_args() {
445        let slug = "slow-powers-eval-1-with_skill__foo";
446        let invs = [
447            inv("Skill", None, 0),
448            inv("Skill", Some(json!("not-an-object")), 1),
449            inv("Skill", Some(json!({"other": "field"})), 2),
450        ];
451        assert!(!check_skill_invoked_from_transcript(&invs, Some(slug)));
452    }
453}