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 harness's skill tool invoked with the
68/// staged slug: the invocation named `skill_tool` whose `skill_arg` argument
69/// equals the slug (Claude Code's `Skill`/`skill`, OpenCode's
70/// `skill`/`name`).
71pub fn check_skill_invoked_from_transcript(
72    invocations: &[ToolInvocation],
73    staged_slug: Option<&str>,
74    skill_tool: &str,
75    skill_arg: &str,
76) -> bool {
77    let Some(slug) = staged_slug else {
78        return false;
79    };
80    invocations.iter().any(|inv| {
81        inv.name == skill_tool
82            && inv
83                .args
84                .as_ref()
85                .and_then(|a| a.get(skill_arg))
86                .and_then(|v| v.as_str())
87                == Some(slug)
88    })
89}
90
91/// The meta-check rubric asking a judge whether the agent actually applied the
92/// skill (separate from correctness).
93fn skill_invoked_rubric(skill_name: &str, skill_content: Option<&str>) -> String {
94    let mut lines: Vec<String> = vec![
95        format!(
96            "The agent had access to the **{skill_name}** skill. This meta-check asks whether \
97             there is evidence the agent actually applied the skill in this run — separate from \
98             whether the response was correct."
99        ),
100        String::new(),
101    ];
102    if let Some(content) = skill_content {
103        lines.push("# Skill content".to_string());
104        lines.push(String::new());
105        lines.push("```markdown".to_string());
106        lines.push(content.trim().to_string());
107        lines.push("```".to_string());
108        lines.push(String::new());
109    }
110    lines.extend(
111        [
112            "Evidence the skill WAS applied:",
113            "- 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).",
114            "- The agent's response uses distinctive vocabulary or phrasing taken from the skill content.",
115            "- 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.",
116            "- The agent explicitly acknowledges following the skill's guidance.",
117            "",
118            "Evidence the skill was NOT applied:",
119            "- The response uses only generic best-practice language unrelated to the skill's specific framing.",
120            "- No vocabulary, structure, or rules from the skill content appear anywhere in the response.",
121            "- The response would read identically with or without the skill loaded.",
122            "",
123            "Compare the agent's `final_message` against the skill content. Look for stylistic and procedural fingerprints.",
124            "",
125            "PASS if there is observable evidence the skill influenced the response.",
126            "FAIL if there is no observable evidence — the response is indistinguishable from baseline behavior.",
127        ]
128        .iter()
129        .map(|s| s.to_string()),
130    );
131    lines.join("\n")
132}
133
134/// A directory listing for the judge prompt: visible entries, dirs suffixed `/`,
135/// sorted; `(empty)` when none.
136fn list_outputs(dir: &Path) -> String {
137    let Ok(entries) = fs::read_dir(dir) else {
138        return "(empty)".to_string();
139    };
140    let mut names: Vec<String> = entries
141        .flatten()
142        .filter_map(|e| {
143            let name = e.file_name().to_string_lossy().into_owned();
144            if name.starts_with('.') || name == "node_modules" {
145                return None;
146            }
147            let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
148            Some(if is_dir { format!("{name}/") } else { name })
149        })
150        .collect();
151    names.sort();
152    if names.is_empty() {
153        "(empty)".to_string()
154    } else {
155        names.join("\n")
156    }
157}
158
159/// Assemble the full judge prompt (rubric + run record + outputs listing +
160/// grading principles + where to write the verdict).
161fn build_judge_prompt(
162    rubric: &str,
163    run_record: &RunRecord,
164    outputs_dir: &Path,
165    response_path: &Path,
166) -> String {
167    let outputs_listing = if outputs_dir.exists() {
168        list_outputs(outputs_dir)
169    } else {
170        "(none)".to_string()
171    };
172    let record_json = serde_json::to_string_pretty(run_record).unwrap_or_default();
173
174    [
175        "You are grading one assertion for a skill evaluation run. Be strict but fair.",
176        "Grade only this one assertion. Do not run eval-magic. Do not dispatch other judge tasks. Do not wait for other workers.",
177        "",
178        "# Run record",
179        "",
180        "```json",
181        &record_json,
182        "```",
183        "",
184        "# Outputs directory contents",
185        "",
186        "```",
187        &outputs_listing,
188        "```",
189        "",
190        "# Assertion to grade",
191        "",
192        rubric,
193        "",
194        "# Grading principles",
195        "",
196        "- 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.",
197        "- A correct response expressed in different words from what the assertion implies is still a PASS if the substance matches.",
198        "- 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`.",
199        "",
200        "# Task",
201        "",
202        &format!("Write your verdict as a JSON file to: {}", response_path.display()),
203        "",
204        "The JSON must match this schema (exactly these keys, no extra prose in the file):",
205        "",
206        "```json",
207        "{ \"passed\": true|false, \"evidence\": \"direct quote or reference\", \"confidence\": 0.0-1.0 }",
208        "```",
209        "",
210        "After writing the file, your final user-facing reply should be one sentence summarising the verdict.",
211    ]
212    .join("\n")
213}
214
215/// Emit judge tasks + prompt files for the iteration, writing `judge-tasks.json`.
216/// See the module docs for the per-assertion and meta-check behavior.
217pub fn emit_judge_tasks(ctx: &GradeContext) -> Result<EmitSummary, PipelineError> {
218    let conds: Vec<(String, Option<String>, Option<String>)> = ctx
219        .conditions
220        .conditions
221        .iter()
222        .map(|c| {
223            (
224                c.name.clone(),
225                c.skill_path.clone(),
226                c.staged_skill_slug.clone().flatten(),
227            )
228        })
229        .collect();
230    // The deterministic `__skill_invoked` code check needs a transcript that
231    // exposes a skill-invocation event; harnesses without one (per their
232    // adapter) fall back to the LLM judge.
233    let skill_signature = ctx
234        .conditions
235        .harness
236        .and_then(|h| crate::adapters::adapter_for(h).transcript_skill_invocation());
237    let code_check_available = ctx.conditions.harness.is_none() || skill_signature.is_some();
238    let default_judge_model = ctx.conditions.judge_model.clone();
239
240    let mut tasks: Vec<JudgeTask> = Vec::new();
241    let mut summary = EmitSummary::default();
242    let mut unverifiable = 0usize;
243
244    for ev in &ctx.evals.evals {
245        let assertions = ev.assertions.as_deref().unwrap_or(&[]);
246        let has_assertions = !assertions.is_empty();
247
248        for (cond, cond_skill_path, staged_slug) in &conds {
249            let cond_dir = ctx.iteration_dir.join(format!("eval-{}", ev.id)).join(cond);
250            for slot in run_slots(&cond_dir) {
251                let run_record_path = slot.dir.join("run.json");
252                let outputs_dir = slot.dir.join("outputs");
253                let judge_responses_dir = slot.dir.join("judge-responses");
254                let judge_prompts_dir = slot.dir.join("judge-prompts");
255
256                if !run_record_path.exists() {
257                    let run = slot
258                        .run_index
259                        .map(|k| format!("/run-{k}"))
260                        .unwrap_or_default();
261                    eprintln!(
262                        "warn: missing run.json for {}/{cond}{run} — skipping",
263                        ev.id
264                    );
265                    if has_assertions {
266                        summary.skipped_missing += assertions.len();
267                    }
268                    continue;
269                }
270
271                fs::create_dir_all(&judge_responses_dir)?;
272                fs::create_dir_all(&judge_prompts_dir)?;
273                let run_record: RunRecord = validate_against_schema(
274                    SchemaName::RunRecord,
275                    &serde_json::from_str(&fs::read_to_string(&run_record_path)?)?,
276                    &run_record_path.to_string_lossy(),
277                )?;
278
279                for assertion in assertions {
280                    let Assertion::LlmJudge(j) = assertion else {
281                        // transcript_check is graded in finalize, not dispatched here.
282                        unverifiable += 1;
283                        continue;
284                    };
285                    let response_path = judge_responses_dir.join(format!("{}.json", j.id));
286                    let dispatch_prompt =
287                        build_judge_prompt(&j.rubric, &run_record, &outputs_dir, &response_path);
288                    let prompt_path = judge_prompts_dir.join(format!("{}.txt", j.id));
289                    fs::write(&prompt_path, &dispatch_prompt)?;
290                    tasks.push(JudgeTask {
291                        eval_id: ev.id.clone(),
292                        condition: cond.clone(),
293                        run_index: slot.run_index,
294                        assertion_id: j.id.clone(),
295                        rubric: j.rubric.clone(),
296                        model: j.model.clone().or_else(|| default_judge_model.clone()),
297                        is_meta: false,
298                        run_record_path: run_record_path.to_string_lossy().into_owned(),
299                        outputs_dir: outputs_dir.to_string_lossy().into_owned(),
300                        response_path: response_path.to_string_lossy().into_owned(),
301                        dispatch_prompt_path: prompt_path.to_string_lossy().into_owned(),
302                        dispatch_prompt,
303                    });
304                }
305
306                // Skill-invocation meta-check. Negative evals (skill_should_trigger:
307                // false) expect non-invocation, so they carry no meta-check.
308                if cond_skill_path.is_some() && ev.skill_should_trigger != Some(false) {
309                    let response_path =
310                        judge_responses_dir.join(format!("{SKILL_INVOKED_META_ID}.json"));
311                    let transcript_filled = !run_record.tool_invocations.is_empty();
312
313                    if staged_slug.is_some() && transcript_filled && code_check_available {
314                        let (skill_tool, skill_arg) = skill_signature
315                            .clone()
316                            .unwrap_or_else(|| ("Skill".to_string(), "skill".to_string()));
317                        let invoked = check_skill_invoked_from_transcript(
318                            &run_record.tool_invocations,
319                            staged_slug.as_deref(),
320                            &skill_tool,
321                            &skill_arg,
322                        );
323                        let evidence = if invoked {
324                            "Skill invocation verified from transcript.".to_string()
325                        } else {
326                            format!(
327                                "No skill invocation found in transcript across {} transcript invocation(s).",
328                                run_record.tool_invocations.len()
329                            )
330                        };
331                        write_json(
332                            &response_path,
333                            &json!({
334                                "passed": invoked,
335                                "evidence": evidence,
336                                "confidence": 1.0,
337                                "grader": "transcript_check",
338                            }),
339                        )?;
340                        summary.meta_code_checked += 1;
341                    } else {
342                        let skill_content =
343                            fs::read_to_string(cond_skill_path.as_deref().unwrap_or(""))?;
344                        let rubric =
345                            skill_invoked_rubric(&ctx.evals.skill_name, Some(&skill_content));
346                        let dispatch_prompt =
347                            build_judge_prompt(&rubric, &run_record, &outputs_dir, &response_path);
348                        let prompt_path =
349                            judge_prompts_dir.join(format!("{SKILL_INVOKED_META_ID}.txt"));
350                        fs::write(&prompt_path, &dispatch_prompt)?;
351                        tasks.push(JudgeTask {
352                            eval_id: ev.id.clone(),
353                            condition: cond.clone(),
354                            run_index: slot.run_index,
355                            assertion_id: SKILL_INVOKED_META_ID.to_string(),
356                            rubric,
357                            model: default_judge_model.clone(),
358                            is_meta: true,
359                            run_record_path: run_record_path.to_string_lossy().into_owned(),
360                            outputs_dir: outputs_dir.to_string_lossy().into_owned(),
361                            response_path: response_path.to_string_lossy().into_owned(),
362                            dispatch_prompt_path: prompt_path.to_string_lossy().into_owned(),
363                            dispatch_prompt,
364                        });
365                        summary.meta_injected += 1;
366                    }
367                }
368            }
369        }
370    }
371
372    summary.total_tasks = tasks.len();
373    summary.skipped_transcript_checks = unverifiable;
374
375    let tasks_path = ctx.iteration_dir.join("judge-tasks.json");
376    let file = JudgeTasksFile {
377        generated: now_iso8601(),
378        total_tasks: tasks.len(),
379        meta_tasks_injected: summary.meta_injected,
380        skipped_transcript_checks: unverifiable,
381        tasks,
382    };
383    validate_against_schema::<serde_json::Value>(
384        SchemaName::JudgeTasks,
385        &serde_json::to_value(&file)?,
386        &tasks_path.to_string_lossy(),
387    )?;
388    write_json(&tasks_path, &file)?;
389
390    Ok(summary)
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use serde_json::json;
397
398    fn inv(name: &str, args: Option<serde_json::Value>, ordinal: u32) -> ToolInvocation {
399        ToolInvocation {
400            name: name.to_string(),
401            args,
402            result: None,
403            ordinal,
404        }
405    }
406
407    #[test]
408    fn true_when_skill_call_matches_slug() {
409        let slug = "slow-powers-eval-1-with_skill__verification-before-completion";
410        let invs = [
411            inv("Bash", Some(json!({"command": "ls"})), 0),
412            inv("Skill", Some(json!({"skill": slug})), 1),
413            inv("Read", Some(json!({"file_path": "/tmp/x"})), 2),
414        ];
415        assert!(check_skill_invoked_from_transcript(
416            &invs,
417            Some(slug),
418            "Skill",
419            "skill"
420        ));
421    }
422
423    #[test]
424    fn true_for_opencode_skill_tool_signature() {
425        // OpenCode loads skills via its native `skill` tool, whose input
426        // carries the skill identifier as `name` (not claude's Skill/skill).
427        let slug = "slow-powers-eval-1-with-skill-mr-review";
428        let invs = [
429            inv("bash", Some(json!({"command": "ls"})), 0),
430            inv("skill", Some(json!({"name": slug})), 1),
431        ];
432        assert!(check_skill_invoked_from_transcript(
433            &invs,
434            Some(slug),
435            "skill",
436            "name"
437        ));
438        // The same invocations do not match claude's signature.
439        assert!(!check_skill_invoked_from_transcript(
440            &invs,
441            Some(slug),
442            "Skill",
443            "skill"
444        ));
445    }
446
447    #[test]
448    fn false_when_no_skill_calls() {
449        let invs = [
450            inv("Bash", Some(json!({"command": "ls"})), 0),
451            inv("Read", Some(json!({"file_path": "/tmp/x"})), 1),
452        ];
453        assert!(!check_skill_invoked_from_transcript(
454            &invs,
455            Some("slow-powers-eval-1-with_skill__foo"),
456            "Skill",
457            "skill"
458        ));
459    }
460
461    #[test]
462    fn false_when_skill_call_references_different_slug() {
463        let slug = "slow-powers-eval-1-with_skill__verification-before-completion";
464        let invs = [
465            inv(
466                "Skill",
467                Some(json!({"skill": "slow-powers:writing-skills"})),
468                0,
469            ),
470            inv(
471                "Skill",
472                Some(json!({"skill": "slow-powers-eval-2-old_skill__other"})),
473                1,
474            ),
475        ];
476        assert!(!check_skill_invoked_from_transcript(
477            &invs,
478            Some(slug),
479            "Skill",
480            "skill"
481        ));
482    }
483
484    #[test]
485    fn false_on_empty_invocations() {
486        assert!(!check_skill_invoked_from_transcript(
487            &[],
488            Some("anything"),
489            "Skill",
490            "skill"
491        ));
492    }
493
494    #[test]
495    fn tolerates_missing_or_malformed_skill_args() {
496        let slug = "slow-powers-eval-1-with_skill__foo";
497        let invs = [
498            inv("Skill", None, 0),
499            inv("Skill", Some(json!("not-an-object")), 1),
500            inv("Skill", Some(json!({"other": "field"})), 2),
501        ];
502        assert!(!check_skill_invoked_from_transcript(
503            &invs,
504            Some(slug),
505            "Skill",
506            "skill"
507        ));
508    }
509}