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}
28
29impl SchemaName {
30 const ALL: [SchemaName; 6] = [
32 SchemaName::RunRecord,
33 SchemaName::Evals,
34 SchemaName::Grading,
35 SchemaName::StrayWrites,
36 SchemaName::Benchmark,
37 SchemaName::JudgeTasks,
38 ];
39
40 pub fn as_str(self) -> &'static str {
43 match self {
44 SchemaName::RunRecord => "run-record",
45 SchemaName::Evals => "evals",
46 SchemaName::Grading => "grading",
47 SchemaName::StrayWrites => "stray-writes",
48 SchemaName::Benchmark => "benchmark",
49 SchemaName::JudgeTasks => "judge-tasks",
50 }
51 }
52
53 fn source(self) -> &'static str {
55 match self {
56 SchemaName::RunRecord => include_str!("../../schema/run-record.schema.json"),
57 SchemaName::Evals => include_str!("../../schema/evals.schema.json"),
58 SchemaName::Grading => include_str!("../../schema/grading.schema.json"),
59 SchemaName::StrayWrites => include_str!("../../schema/stray-writes.schema.json"),
60 SchemaName::Benchmark => include_str!("../../schema/benchmark.schema.json"),
61 SchemaName::JudgeTasks => include_str!("../../schema/judge-tasks.schema.json"),
62 }
63 }
64}
65
66static VALIDATORS: LazyLock<HashMap<SchemaName, Validator>> = LazyLock::new(|| {
70 SchemaName::ALL
71 .iter()
72 .map(|&name| {
73 let schema: Value = serde_json::from_str(name.source()).unwrap_or_else(|e| {
74 panic!("bundled {} schema is not valid JSON: {e}", name.as_str())
75 });
76 let validator = jsonschema::validator_for(&schema).unwrap_or_else(|e| {
77 panic!("bundled {} schema does not compile: {e}", name.as_str())
78 });
79 (name, validator)
80 })
81 .collect()
82});
83
84pub fn validate_against_schema<T: DeserializeOwned>(
91 name: SchemaName,
92 data: &Value,
93 source: &str,
94) -> Result<T, ValidationError> {
95 let validator = &VALIDATORS[&name];
96
97 if !validator.is_valid(data) {
98 let details = validator
99 .iter_errors(data)
100 .map(|e| {
101 let instance = e.instance_path().to_string();
102 let instance = if instance.is_empty() {
103 "/".to_string()
104 } else {
105 instance
106 };
107 format!(" {instance} {e}")
108 })
109 .collect::<Vec<_>>()
110 .join("\n");
111 return Err(ValidationError::SchemaMismatch {
112 path: source.to_string(),
113 schema: name.as_str().to_string(),
114 details,
115 });
116 }
117
118 serde_json::from_value(data.clone()).map_err(|e| ValidationError::Deserialize {
119 path: source.to_string(),
120 message: e.to_string(),
121 })
122}
123
124#[cfg(test)]
125mod tests {
126 use super::{SchemaName, validate_against_schema};
127 use serde_json::{Value, json};
128
129 fn valid_run_record() -> Value {
131 json!({
132 "eval_id": "e1",
133 "condition": "with_skill",
134 "skill_path": null,
135 "prompt": "do the thing",
136 "files": [],
137 "final_message": "done",
138 "tool_invocations": [],
139 "total_tokens": 100,
140 "duration_ms": 1000
141 })
142 }
143
144 #[test]
145 fn returns_data_when_it_matches_the_run_record_schema() {
146 let data = valid_run_record();
147 let out: Value =
148 validate_against_schema(SchemaName::RunRecord, &data, "/tmp/run.json").unwrap();
149 assert_eq!(out, data);
150 }
151
152 #[test]
153 fn accepts_an_empty_tool_invocations_array() {
154 let mut data = valid_run_record();
155 data["tool_invocations"] = json!([]);
156 let r: Result<Value, _> = validate_against_schema(SchemaName::RunRecord, &data, "run.json");
157 assert!(r.is_ok());
158 }
159
160 #[test]
161 fn accepts_skill_path_null_on_the_without_skill_arm() {
162 let mut data = valid_run_record();
163 data["condition"] = json!("without_skill");
164 data["skill_path"] = Value::Null;
165 let r: Result<Value, _> = validate_against_schema(SchemaName::RunRecord, &data, "run.json");
166 assert!(r.is_ok());
167 }
168
169 #[test]
170 fn source_prefixed_error_when_required_field_missing() {
171 let mut data = valid_run_record();
172 data.as_object_mut().unwrap().remove("eval_id");
173 let err = validate_against_schema::<Value>(SchemaName::RunRecord, &data, "/tmp/run.json")
174 .unwrap_err()
175 .to_string();
176 assert!(err.contains("/tmp/run.json"), "error was: {err}");
177 }
178
179 #[test]
180 fn requires_skill_path() {
181 let mut data = valid_run_record();
182 data.as_object_mut().unwrap().remove("skill_path");
183 let err = validate_against_schema::<Value>(SchemaName::RunRecord, &data, "run.json")
184 .unwrap_err()
185 .to_string();
186 assert!(err.contains("skill_path"), "error was: {err}");
187 }
188
189 #[test]
190 fn requires_files() {
191 let mut data = valid_run_record();
192 data.as_object_mut().unwrap().remove("files");
193 let err = validate_against_schema::<Value>(SchemaName::RunRecord, &data, "run.json")
194 .unwrap_err()
195 .to_string();
196 assert!(err.contains("files"), "error was: {err}");
197 }
198
199 #[test]
200 fn rejects_unknown_extra_property() {
201 let mut data = valid_run_record();
202 data["surprise"] = json!(true);
203 let r: Result<Value, _> = validate_against_schema(SchemaName::RunRecord, &data, "run.json");
204 assert!(r.is_err());
205 }
206
207 #[test]
208 fn tool_invocation_ordinal_must_be_an_integer() {
209 let mut data = valid_run_record();
210 data["tool_invocations"] = json!([{ "name": "Bash", "ordinal": "zero" }]);
211 let r: Result<Value, _> = validate_against_schema(SchemaName::RunRecord, &data, "run.json");
212 assert!(r.is_err());
213 }
214
215 #[test]
216 fn validates_a_well_formed_benchmark() {
217 let benchmark = json!({
218 "generated": "2026-06-08T00:00:00.000Z",
219 "mode": "new-skill",
220 "conditions_compared": ["with_skill", "without_skill"],
221 "missing_gradings": 0,
222 "validity_warnings": [],
223 "run_summary": {
224 "with_skill": {
225 "pass_rate": { "mean": 1.0, "stddev": 0.0, "n": 1 },
226 "duration_ms": { "mean": 1000.0, "stddev": 0.0, "n": 1 },
227 "total_tokens": { "mean": 5000.0, "stddev": 0.0, "n": 1 },
228 "skill_invocation_n": 0,
229 "skill_invocation_rate": null
230 },
231 "without_skill": {
232 "pass_rate": { "mean": 0.0, "stddev": 0.0, "n": 1 },
233 "duration_ms": { "mean": 1000.0, "stddev": 0.0, "n": 1 },
234 "total_tokens": { "mean": 3000.0, "stddev": 0.0, "n": 1 }
235 }
236 },
237 "delta": { "direction": "with_skill - without_skill", "pass_rate": 1.0, "duration_ms": 0.0, "total_tokens": 2000.0 }
238 });
239 let r: Result<Value, _> =
240 validate_against_schema(SchemaName::Benchmark, &benchmark, "benchmark.json");
241 assert!(r.is_ok(), "benchmark should validate: {r:?}");
242 }
243
244 #[test]
245 fn rejects_a_benchmark_missing_delta() {
246 let mut benchmark = json!({
247 "generated": "t", "mode": "new-skill",
248 "conditions_compared": ["a", "b"], "missing_gradings": 0,
249 "validity_warnings": [], "run_summary": {},
250 "delta": { "direction": "a - b", "pass_rate": 0.0, "duration_ms": 0.0, "total_tokens": 0.0 }
251 });
252 benchmark.as_object_mut().unwrap().remove("delta");
253 let r: Result<Value, _> =
254 validate_against_schema(SchemaName::Benchmark, &benchmark, "benchmark.json");
255 assert!(r.is_err());
256 }
257
258 #[test]
259 fn validates_a_well_formed_judge_tasks_file() {
260 let tasks = json!({
261 "generated": "2026-06-08T00:00:00.000Z",
262 "total_tasks": 1,
263 "meta_tasks_injected": 1,
264 "skipped_transcript_checks": 0,
265 "tasks": [{
266 "eval_id": "e1", "condition": "with_skill", "assertion_id": "__skill_invoked",
267 "rubric": "did it apply the skill?", "model": null, "is_meta": true,
268 "run_record_path": "/w/run.json", "outputs_dir": "/w/outputs",
269 "response_path": "/w/judge-responses/__skill_invoked.json",
270 "dispatch_prompt_path": "/w/judge-prompts/__skill_invoked.txt"
271 }]
272 });
273 let r: Result<Value, _> =
274 validate_against_schema(SchemaName::JudgeTasks, &tasks, "judge-tasks.json");
275 assert!(r.is_ok(), "judge-tasks should validate: {r:?}");
276 }
277
278 #[test]
279 fn rejects_a_judge_task_with_an_inlined_dispatch_prompt() {
280 let tasks = json!({
282 "generated": "t", "total_tasks": 1, "meta_tasks_injected": 0,
283 "skipped_transcript_checks": 0,
284 "tasks": [{
285 "eval_id": "e1", "condition": "with_skill", "assertion_id": "a1",
286 "rubric": "r", "model": null, "is_meta": false,
287 "run_record_path": "/w/run.json", "outputs_dir": "/w/outputs",
288 "response_path": "/w/r.json", "dispatch_prompt_path": "/w/p.txt",
289 "dispatch_prompt": "SHOULD NOT BE HERE"
290 }]
291 });
292 let r: Result<Value, _> =
293 validate_against_schema(SchemaName::JudgeTasks, &tasks, "judge-tasks.json");
294 assert!(r.is_err());
295 }
296
297 #[test]
298 fn compiles_and_validates_the_grading_schema_too() {
299 let grading = json!({
300 "assertion_results": [
301 {
302 "id": "a1",
303 "passed": true,
304 "evidence": "quoted output",
305 "grader": "transcript_check"
306 }
307 ],
308 "summary": { "passed": 1, "failed": 0, "total": 1, "pass_rate": 1.0 }
309 });
310 let r: Result<Value, _> =
311 validate_against_schema(SchemaName::Grading, &grading, "grading.json");
312 assert!(r.is_ok(), "grading should validate");
313 }
314}