1use std::collections::HashMap;
9use std::sync::LazyLock;
10
11use jsonschema::Validator;
12use serde::de::DeserializeOwned;
13use serde_json::Value;
14
15use crate::validation::error::ValidationError;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum SchemaName {
21 RunRecord,
22 Evals,
23 Grading,
24 StrayWrites,
25 GuardDenials,
26 Benchmark,
27 JudgeTasks,
28 CommandCheck,
29 DiffScope,
30 HarnessDescriptor,
31 Conversation,
32}
33
34impl SchemaName {
35 const ALL: [SchemaName; 11] = [
37 SchemaName::RunRecord,
38 SchemaName::Evals,
39 SchemaName::Grading,
40 SchemaName::StrayWrites,
41 SchemaName::GuardDenials,
42 SchemaName::Benchmark,
43 SchemaName::JudgeTasks,
44 SchemaName::CommandCheck,
45 SchemaName::DiffScope,
46 SchemaName::HarnessDescriptor,
47 SchemaName::Conversation,
48 ];
49
50 pub fn as_str(self) -> &'static str {
53 match self {
54 SchemaName::RunRecord => "run-record",
55 SchemaName::Evals => "evals",
56 SchemaName::Grading => "grading",
57 SchemaName::StrayWrites => "stray-writes",
58 SchemaName::GuardDenials => "guard-denials",
59 SchemaName::Benchmark => "benchmark",
60 SchemaName::JudgeTasks => "judge-tasks",
61 SchemaName::CommandCheck => "command-check",
62 SchemaName::DiffScope => "diff-scope",
63 SchemaName::HarnessDescriptor => "harness-descriptor",
64 SchemaName::Conversation => "conversation",
65 }
66 }
67
68 fn source(self) -> &'static str {
70 match self {
71 SchemaName::RunRecord => include_str!("../../schema/run-record.schema.json"),
72 SchemaName::Evals => include_str!("../../schema/evals.schema.json"),
73 SchemaName::Grading => include_str!("../../schema/grading.schema.json"),
74 SchemaName::StrayWrites => include_str!("../../schema/stray-writes.schema.json"),
75 SchemaName::GuardDenials => {
76 include_str!("../../schema/guard-denials.schema.json")
77 }
78 SchemaName::Benchmark => include_str!("../../schema/benchmark.schema.json"),
79 SchemaName::JudgeTasks => include_str!("../../schema/judge-tasks.schema.json"),
80 SchemaName::CommandCheck => {
81 include_str!("../../schema/command-check.schema.json")
82 }
83 SchemaName::DiffScope => include_str!("../../schema/diff-scope.schema.json"),
84 SchemaName::HarnessDescriptor => {
85 include_str!("../../schema/harness-descriptor.schema.json")
86 }
87 SchemaName::Conversation => include_str!("../../schema/conversation.schema.json"),
88 }
89 }
90}
91
92static VALIDATORS: LazyLock<HashMap<SchemaName, Validator>> = LazyLock::new(|| {
96 SchemaName::ALL
97 .iter()
98 .map(|&name| {
99 let schema: Value = serde_json::from_str(name.source()).unwrap_or_else(|e| {
100 panic!("bundled {} schema is not valid JSON: {e}", name.as_str())
101 });
102 let validator = jsonschema::validator_for(&schema).unwrap_or_else(|e| {
103 panic!("bundled {} schema does not compile: {e}", name.as_str())
104 });
105 (name, validator)
106 })
107 .collect()
108});
109
110pub fn validate_against_schema<T: DeserializeOwned>(
117 name: SchemaName,
118 data: &Value,
119 source: &str,
120) -> Result<T, ValidationError> {
121 let validator = &VALIDATORS[&name];
122
123 if !validator.is_valid(data) {
124 let details = validator
125 .iter_errors(data)
126 .map(|e| {
127 let instance = e.instance_path().to_string();
128 let instance = if instance.is_empty() {
129 "/".to_string()
130 } else {
131 instance
132 };
133 format!(" {instance} {e}")
134 })
135 .collect::<Vec<_>>()
136 .join("\n");
137 return Err(ValidationError::SchemaMismatch {
138 path: source.to_string(),
139 schema: name.as_str().to_string(),
140 details,
141 });
142 }
143
144 serde_json::from_value(data.clone()).map_err(|e| ValidationError::Deserialize {
145 path: source.to_string(),
146 message: e.to_string(),
147 })
148}
149
150#[cfg(test)]
151mod tests {
152 use super::{SchemaName, validate_against_schema};
153 use serde_json::{Value, json};
154
155 fn valid_run_record() -> Value {
157 json!({
158 "eval_id": "e1",
159 "condition": "with_skill",
160 "skill_path": null,
161 "prompt": "do the thing",
162 "files": [],
163 "final_message": "done",
164 "tool_invocations": [],
165 "total_tokens": 100,
166 "duration_ms": 1000
167 })
168 }
169
170 #[test]
171 fn returns_data_when_it_matches_the_run_record_schema() {
172 let data = valid_run_record();
173 let out: Value =
174 validate_against_schema(SchemaName::RunRecord, &data, "/tmp/run.json").unwrap();
175 assert_eq!(out, data);
176 }
177
178 #[test]
179 fn accepts_an_empty_tool_invocations_array() {
180 let mut data = valid_run_record();
181 data["tool_invocations"] = json!([]);
182 let r: Result<Value, _> = validate_against_schema(SchemaName::RunRecord, &data, "run.json");
183 assert!(r.is_ok());
184 }
185
186 #[test]
187 fn conversation_tool_results_use_the_portable_run_record_value_shape() {
188 let conversation = json!({
189 "status": "completed",
190 "delivered_followups": 0,
191 "events": [
192 {"type": "user_message", "ordinal": 0, "round": 1, "text": "Fix it."},
193 {
194 "type": "tool_invocation",
195 "ordinal": 1,
196 "round": 1,
197 "name": "Read",
198 "result": ["not", "portable"]
199 },
200 {"type": "assistant_message", "ordinal": 2, "round": 1, "text": "Done."}
201 ]
202 });
203
204 let result: Result<Value, _> =
205 validate_against_schema(SchemaName::Conversation, &conversation, "conversation.json");
206
207 assert!(result.is_err());
208 }
209
210 #[test]
211 fn accepts_skill_path_null_on_the_without_skill_arm() {
212 let mut data = valid_run_record();
213 data["condition"] = json!("without_skill");
214 data["skill_path"] = Value::Null;
215 let r: Result<Value, _> = validate_against_schema(SchemaName::RunRecord, &data, "run.json");
216 assert!(r.is_ok());
217 }
218
219 #[test]
220 fn source_prefixed_error_when_required_field_missing() {
221 let mut data = valid_run_record();
222 data.as_object_mut().unwrap().remove("eval_id");
223 let err = validate_against_schema::<Value>(SchemaName::RunRecord, &data, "/tmp/run.json")
224 .unwrap_err()
225 .to_string();
226 assert!(err.contains("/tmp/run.json"), "error was: {err}");
227 }
228
229 #[test]
230 fn requires_skill_path() {
231 let mut data = valid_run_record();
232 data.as_object_mut().unwrap().remove("skill_path");
233 let err = validate_against_schema::<Value>(SchemaName::RunRecord, &data, "run.json")
234 .unwrap_err()
235 .to_string();
236 assert!(err.contains("skill_path"), "error was: {err}");
237 }
238
239 #[test]
240 fn requires_files() {
241 let mut data = valid_run_record();
242 data.as_object_mut().unwrap().remove("files");
243 let err = validate_against_schema::<Value>(SchemaName::RunRecord, &data, "run.json")
244 .unwrap_err()
245 .to_string();
246 assert!(err.contains("files"), "error was: {err}");
247 }
248
249 #[test]
250 fn rejects_unknown_extra_property() {
251 let mut data = valid_run_record();
252 data["surprise"] = json!(true);
253 let r: Result<Value, _> = validate_against_schema(SchemaName::RunRecord, &data, "run.json");
254 assert!(r.is_err());
255 }
256
257 #[test]
258 fn grading_schema_accepts_command_check_grader() {
259 let data = json!({
260 "assertion_results": [{
261 "id": "tests-pass",
262 "passed": true,
263 "evidence": "exit code matched",
264 "confidence": 1.0,
265 "grader": "command_check"
266 }],
267 "summary": {
268 "passed": 1,
269 "failed": 0,
270 "total": 1,
271 "pass_rate": 1.0
272 }
273 });
274 let result: Result<Value, _> =
275 validate_against_schema(SchemaName::Grading, &data, "grading.json");
276 assert!(
277 result.is_ok(),
278 "command_check grading should validate: {result:?}"
279 );
280 }
281
282 #[test]
283 fn validates_diff_scope_metrics_and_rejects_extra_fields() {
284 let metrics = json!({
285 "files_touched": 2,
286 "lines_added": 4,
287 "lines_removed": 1,
288 "hunks": 2
289 });
290 let valid: Result<Value, _> =
291 validate_against_schema(SchemaName::DiffScope, &metrics, "diff-scope.json");
292 assert!(valid.is_ok(), "{valid:?}");
293
294 let mut extra = metrics;
295 extra["paths"] = json!(["src/main.rs"]);
296 let invalid: Result<Value, _> =
297 validate_against_schema(SchemaName::DiffScope, &extra, "diff-scope.json");
298 assert!(invalid.is_err());
299 }
300
301 #[test]
302 fn validates_guard_denials_and_rejects_unexpected_payload_content() {
303 let report = serde_json::json!({
304 "generated": "2026-07-26T12:00:00.000Z",
305 "iteration": 1,
306 "total_denials": 1,
307 "tasks": [{
308 "eval_id": "e1",
309 "condition": "old_skill",
310 "run_index": 2,
311 "denial_count": 1,
312 "denials": [{
313 "timestamp": "2026-07-26T11:59:00.000Z",
314 "harness": "codex",
315 "tool": "Bash",
316 "reason": "outside redirect",
317 "resolved_targets": ["/etc/out"],
318 "input_keys": ["command"],
319 }],
320 }],
321 });
322 validate_against_schema::<serde_json::Value>(
323 SchemaName::GuardDenials,
324 &report,
325 "guard-denials.json",
326 )
327 .unwrap();
328
329 let mut extra = report;
330 extra["tasks"][0]["denials"][0]["command"] = serde_json::json!("secret");
331 assert!(
332 validate_against_schema::<serde_json::Value>(
333 SchemaName::GuardDenials,
334 &extra,
335 "guard-denials.json",
336 )
337 .is_err()
338 );
339 }
340
341 #[test]
342 fn tool_invocation_ordinal_must_be_an_integer() {
343 let mut data = valid_run_record();
344 data["tool_invocations"] = json!([{ "name": "Bash", "ordinal": "zero" }]);
345 let r: Result<Value, _> = validate_against_schema(SchemaName::RunRecord, &data, "run.json");
346 assert!(r.is_err());
347 }
348
349 #[test]
350 fn validates_a_well_formed_benchmark() {
351 let benchmark = json!({
352 "generated": "2026-06-08T00:00:00.000Z",
353 "mode": "new-skill",
354 "conditions_compared": ["with_skill", "without_skill"],
355 "missing_gradings": 0,
356 "validity_warnings": [],
357 "run_summary": {
358 "with_skill": {
359 "pass_rate": { "mean": 1.0, "stddev": 0.0, "n": 1 },
360 "duration_ms": { "mean": 1000.0, "stddev": 0.0, "n": 1 },
361 "total_tokens": { "mean": 5000.0, "stddev": 0.0, "n": 1 },
362 "skill_invocation_n": 0,
363 "skill_invocation_rate": null
364 },
365 "without_skill": {
366 "pass_rate": { "mean": 0.0, "stddev": 0.0, "n": 1 },
367 "duration_ms": { "mean": 1000.0, "stddev": 0.0, "n": 1 },
368 "total_tokens": { "mean": 3000.0, "stddev": 0.0, "n": 1 }
369 }
370 },
371 "delta": { "direction": "with_skill - without_skill", "pass_rate": 1.0, "duration_ms": 0.0, "total_tokens": 2000.0 }
372 });
373 let r: Result<Value, _> =
374 validate_against_schema(SchemaName::Benchmark, &benchmark, "benchmark.json");
375 assert!(r.is_ok(), "benchmark should validate: {r:?}");
376 }
377
378 #[test]
379 fn rejects_a_benchmark_missing_delta() {
380 let mut benchmark = json!({
381 "generated": "t", "mode": "new-skill",
382 "conditions_compared": ["a", "b"], "missing_gradings": 0,
383 "validity_warnings": [], "run_summary": {},
384 "delta": { "direction": "a - b", "pass_rate": 0.0, "duration_ms": 0.0, "total_tokens": 0.0 }
385 });
386 benchmark.as_object_mut().unwrap().remove("delta");
387 let r: Result<Value, _> =
388 validate_against_schema(SchemaName::Benchmark, &benchmark, "benchmark.json");
389 assert!(r.is_err());
390 }
391
392 #[test]
393 fn validates_a_well_formed_judge_tasks_file() {
394 let tasks = json!({
395 "generated": "2026-06-08T00:00:00.000Z",
396 "total_tasks": 1,
397 "meta_tasks_injected": 1,
398 "skipped_transcript_checks": 0,
399 "tasks": [{
400 "eval_id": "e1", "condition": "with_skill", "assertion_id": "__skill_invoked",
401 "rubric": "did it apply the skill?", "model": null, "is_meta": true,
402 "run_record_path": "/w/run.json", "outputs_dir": "/w/outputs",
403 "response_path": "/w/judge-responses/__skill_invoked.json",
404 "dispatch_prompt_path": "/w/judge-prompts/__skill_invoked.txt"
405 }]
406 });
407 let r: Result<Value, _> =
408 validate_against_schema(SchemaName::JudgeTasks, &tasks, "judge-tasks.json");
409 assert!(r.is_ok(), "judge-tasks should validate: {r:?}");
410 }
411
412 #[test]
413 fn rejects_a_judge_task_with_an_inlined_dispatch_prompt() {
414 let tasks = json!({
416 "generated": "t", "total_tasks": 1, "meta_tasks_injected": 0,
417 "skipped_transcript_checks": 0,
418 "tasks": [{
419 "eval_id": "e1", "condition": "with_skill", "assertion_id": "a1",
420 "rubric": "r", "model": null, "is_meta": false,
421 "run_record_path": "/w/run.json", "outputs_dir": "/w/outputs",
422 "response_path": "/w/r.json", "dispatch_prompt_path": "/w/p.txt",
423 "dispatch_prompt": "SHOULD NOT BE HERE"
424 }]
425 });
426 let r: Result<Value, _> =
427 validate_against_schema(SchemaName::JudgeTasks, &tasks, "judge-tasks.json");
428 assert!(r.is_err());
429 }
430
431 #[test]
432 fn compiles_and_validates_the_grading_schema_too() {
433 let grading = json!({
434 "assertion_results": [
435 {
436 "id": "a1",
437 "passed": true,
438 "evidence": "quoted output",
439 "grader": "transcript_check"
440 }
441 ],
442 "summary": { "passed": 1, "failed": 0, "total": 1, "pass_rate": 1.0 }
443 });
444 let r: Result<Value, _> =
445 validate_against_schema(SchemaName::Grading, &grading, "grading.json");
446 assert!(r.is_ok(), "grading should validate");
447 }
448}