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(
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
91fn 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
134fn 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
159fn 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
215pub 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 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 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 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 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 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}