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 j = match assertion {
281                        Assertion::LlmJudge(j) => j,
282                        Assertion::TranscriptCheck(_) => {
283                            unverifiable += 1;
284                            continue;
285                        }
286                        // command_check was executed by the runner before judge
287                        // task emission and is folded in during finalize.
288                        Assertion::CommandCheck(_) | Assertion::DiffScope(_) => continue,
289                    };
290                    let response_path = judge_responses_dir.join(format!("{}.json", j.id));
291                    let dispatch_prompt =
292                        build_judge_prompt(&j.rubric, &run_record, &outputs_dir, &response_path);
293                    let prompt_path = judge_prompts_dir.join(format!("{}.txt", j.id));
294                    fs::write(&prompt_path, &dispatch_prompt)?;
295                    tasks.push(JudgeTask {
296                        eval_id: ev.id.clone(),
297                        condition: cond.clone(),
298                        run_index: slot.run_index,
299                        assertion_id: j.id.clone(),
300                        rubric: j.rubric.clone(),
301                        model: j.model.clone().or_else(|| default_judge_model.clone()),
302                        is_meta: false,
303                        run_record_path: run_record_path.to_string_lossy().into_owned(),
304                        outputs_dir: outputs_dir.to_string_lossy().into_owned(),
305                        response_path: response_path.to_string_lossy().into_owned(),
306                        dispatch_prompt_path: prompt_path.to_string_lossy().into_owned(),
307                        dispatch_prompt,
308                    });
309                }
310
311                // Skill-invocation meta-check. Negative evals (skill_should_trigger:
312                // false) expect non-invocation, so they carry no meta-check.
313                if cond_skill_path.is_some() && ev.skill_should_trigger != Some(false) {
314                    let response_path =
315                        judge_responses_dir.join(format!("{SKILL_INVOKED_META_ID}.json"));
316                    let transcript_filled = !run_record.tool_invocations.is_empty();
317
318                    if staged_slug.is_some() && transcript_filled && code_check_available {
319                        let (skill_tool, skill_arg) = skill_signature
320                            .clone()
321                            .unwrap_or_else(|| ("Skill".to_string(), "skill".to_string()));
322                        let invoked = check_skill_invoked_from_transcript(
323                            &run_record.tool_invocations,
324                            staged_slug.as_deref(),
325                            &skill_tool,
326                            &skill_arg,
327                        );
328                        let evidence = if invoked {
329                            "Skill invocation verified from transcript.".to_string()
330                        } else {
331                            format!(
332                                "No skill invocation found in transcript across {} transcript invocation(s).",
333                                run_record.tool_invocations.len()
334                            )
335                        };
336                        write_json(
337                            &response_path,
338                            &json!({
339                                "passed": invoked,
340                                "evidence": evidence,
341                                "confidence": 1.0,
342                                "grader": "transcript_check",
343                            }),
344                        )?;
345                        summary.meta_code_checked += 1;
346                    } else {
347                        let skill_content =
348                            fs::read_to_string(cond_skill_path.as_deref().unwrap_or(""))?;
349                        let rubric =
350                            skill_invoked_rubric(&ctx.evals.skill_name, Some(&skill_content));
351                        let dispatch_prompt =
352                            build_judge_prompt(&rubric, &run_record, &outputs_dir, &response_path);
353                        let prompt_path =
354                            judge_prompts_dir.join(format!("{SKILL_INVOKED_META_ID}.txt"));
355                        fs::write(&prompt_path, &dispatch_prompt)?;
356                        tasks.push(JudgeTask {
357                            eval_id: ev.id.clone(),
358                            condition: cond.clone(),
359                            run_index: slot.run_index,
360                            assertion_id: SKILL_INVOKED_META_ID.to_string(),
361                            rubric,
362                            model: default_judge_model.clone(),
363                            is_meta: true,
364                            run_record_path: run_record_path.to_string_lossy().into_owned(),
365                            outputs_dir: outputs_dir.to_string_lossy().into_owned(),
366                            response_path: response_path.to_string_lossy().into_owned(),
367                            dispatch_prompt_path: prompt_path.to_string_lossy().into_owned(),
368                            dispatch_prompt,
369                        });
370                        summary.meta_injected += 1;
371                    }
372                }
373            }
374        }
375    }
376
377    summary.total_tasks = tasks.len();
378    summary.skipped_transcript_checks = unverifiable;
379
380    let tasks_path = ctx.iteration_dir.join("judge-tasks.json");
381    let file = JudgeTasksFile {
382        generated: now_iso8601(),
383        total_tasks: tasks.len(),
384        meta_tasks_injected: summary.meta_injected,
385        skipped_transcript_checks: unverifiable,
386        tasks,
387    };
388    validate_against_schema::<serde_json::Value>(
389        SchemaName::JudgeTasks,
390        &serde_json::to_value(&file)?,
391        &tasks_path.to_string_lossy(),
392    )?;
393    write_json(&tasks_path, &file)?;
394
395    Ok(summary)
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use serde_json::json;
402
403    fn inv(name: &str, args: Option<serde_json::Value>, ordinal: u32) -> ToolInvocation {
404        ToolInvocation {
405            name: name.to_string(),
406            args,
407            result: None,
408            ordinal,
409        }
410    }
411
412    #[test]
413    fn true_when_skill_call_matches_slug() {
414        let slug = "slow-powers-eval-1-with_skill__verification-before-completion";
415        let invs = [
416            inv("Bash", Some(json!({"command": "ls"})), 0),
417            inv("Skill", Some(json!({"skill": slug})), 1),
418            inv("Read", Some(json!({"file_path": "/tmp/x"})), 2),
419        ];
420        assert!(check_skill_invoked_from_transcript(
421            &invs,
422            Some(slug),
423            "Skill",
424            "skill"
425        ));
426    }
427
428    #[test]
429    fn true_for_opencode_skill_tool_signature() {
430        // OpenCode loads skills via its native `skill` tool, whose input
431        // carries the skill identifier as `name` (not claude's Skill/skill).
432        let slug = "slow-powers-eval-1-with-skill-mr-review";
433        let invs = [
434            inv("bash", Some(json!({"command": "ls"})), 0),
435            inv("skill", Some(json!({"name": slug})), 1),
436        ];
437        assert!(check_skill_invoked_from_transcript(
438            &invs,
439            Some(slug),
440            "skill",
441            "name"
442        ));
443        // The same invocations do not match claude's signature.
444        assert!(!check_skill_invoked_from_transcript(
445            &invs,
446            Some(slug),
447            "Skill",
448            "skill"
449        ));
450    }
451
452    #[test]
453    fn false_when_no_skill_calls() {
454        let invs = [
455            inv("Bash", Some(json!({"command": "ls"})), 0),
456            inv("Read", Some(json!({"file_path": "/tmp/x"})), 1),
457        ];
458        assert!(!check_skill_invoked_from_transcript(
459            &invs,
460            Some("slow-powers-eval-1-with_skill__foo"),
461            "Skill",
462            "skill"
463        ));
464    }
465
466    #[test]
467    fn false_when_skill_call_references_different_slug() {
468        let slug = "slow-powers-eval-1-with_skill__verification-before-completion";
469        let invs = [
470            inv(
471                "Skill",
472                Some(json!({"skill": "slow-powers:writing-skills"})),
473                0,
474            ),
475            inv(
476                "Skill",
477                Some(json!({"skill": "slow-powers-eval-2-old_skill__other"})),
478                1,
479            ),
480        ];
481        assert!(!check_skill_invoked_from_transcript(
482            &invs,
483            Some(slug),
484            "Skill",
485            "skill"
486        ));
487    }
488
489    #[test]
490    fn false_on_empty_invocations() {
491        assert!(!check_skill_invoked_from_transcript(
492            &[],
493            Some("anything"),
494            "Skill",
495            "skill"
496        ));
497    }
498
499    #[test]
500    fn tolerates_missing_or_malformed_skill_args() {
501        let slug = "slow-powers-eval-1-with_skill__foo";
502        let invs = [
503            inv("Skill", None, 0),
504            inv("Skill", Some(json!("not-an-object")), 1),
505            inv("Skill", Some(json!({"other": "field"})), 2),
506        ];
507        assert!(!check_skill_invoked_from_transcript(
508            &invs,
509            Some(slug),
510            "Skill",
511            "skill"
512        ));
513    }
514}