Skip to main content

eval_magic/validation/
schema.rs

1//! Schema embedding + the generic `validate_against_schema` entry point.
2//!
3//! The portable-artifact schemas are the single source of truth for each
4//! artifact's shape. They are embedded at compile time with `include_str!` (so
5//! the binary is self-contained, with no `schema/` directory to ship alongside)
6//! and compiled once into reusable `jsonschema` validators.
7
8use 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/// Names the portable-artifact schemas. `benchmark` and `judge-tasks` are
18/// first-class here, schema-gated like every other pipeline output.
19#[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    /// Every schema, for building the validator cache.
35    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    /// The schema's kebab-case name, as used in error messages and the on-disk
49    /// `<name>.schema.json` filenames.
50    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    /// The embedded schema JSON source.
66    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
86/// Compiled validators, built once on first use. The schemas are embedded and
87/// known-valid, so a failure here is a programmer error (a malformed bundled
88/// schema) and panics rather than being surfaced as a runtime validation error.
89static 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
104/// Validate `data` against the named schema. Returns it deserialized into `T` on
105/// success; on mismatch, returns a `source`-prefixed [`ValidationError`] listing
106/// every failure.
107///
108/// Deserializing into `T` makes the typed result honest: the schema gate and
109/// the Rust type agree, or the call fails.
110pub 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    /// The canonical valid run-record the cases below mutate.
150    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        // dispatch_prompt is stripped before write; the schema forbids extras.
369        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}