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 Assertion::LlmJudge(j) = assertion else {
281 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 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 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 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}