1use 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#[derive(Debug, Clone, Serialize)]
29pub struct JudgeTask {
30 pub eval_id: String,
31 pub condition: String,
32 #[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#[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#[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
67pub 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
87fn 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
130fn 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
155fn 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
211pub 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 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 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}