1use 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#[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
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 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 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}