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