eval_core/case.rs
1//! The generic, RON-authored eval **case schema** + loader.
2//!
3//! An [`EvalCase`] is one natural-language instruction, the world it runs against (the host's `Setup`),
4//! and the host's `Expect` predicates its result must satisfy. The case container is domain-agnostic:
5//! `Setup` and `Expect` are the host's own types (e.g. AetherCore's voxel `EvalSetup` / `Expectation`),
6//! so `eval-core` stays free of any game/LLM specifics.
7//!
8//! Cases are authored as `.ron` files and loaded by [`load_cases`], which is fail-loud (a malformed
9//! file is a hard error naming it) and deterministic (files are read in sorted order).
10//!
11//! ## One file, one OR many cases
12//!
13//! A `.ron` case file may hold EITHER a single [`EvalCase`] or a list `[EvalCase, EvalCase, …]` of
14//! related cases — see [`parse_cases_from_str`], which [`load_cases`] uses per file. This lets an author
15//! group a whole capability (say all the arithmetic checks) in one file without one-file-per-case
16//! sprawl, while existing single-case files keep loading unchanged.
17
18use serde::Deserialize;
19use serde::de::DeserializeOwned;
20
21use crate::error::EvalError;
22
23/// One eval case: a named instruction, the world it runs in (`Setup`), and the predicates
24/// (`Expect`) its result must satisfy.
25///
26/// Generic over the host's two domain types:
27/// - `Setup`: how to build the world the case runs against (the input to [`crate::Harness::setup`]).
28/// - `Expect`: a single predicate scored against the post-run world (by [`crate::Scorer::score`]).
29///
30/// A case PASSES iff EVERY one of its `expect` predicates holds (and the run did not error); each
31/// predicate's pass/fail is also recorded individually for debugging (see [`crate::report::CaseOutcome`]).
32#[derive(Debug, Clone, Deserialize)]
33pub struct EvalCase<Setup, Expect> {
34 /// Stable case name (also used in the report). Conventionally matches the file stem.
35 pub name: String,
36 /// The natural-language instruction handed to the harness verbatim (the user turn).
37 pub instruction: String,
38 /// How to build the world the case runs against.
39 ///
40 /// `#[serde(default)]` lets a case omit `setup` entirely and get `Setup::default()` — so this
41 /// requires `Setup: Default` at deserialization time (RON only; the in-memory struct has no such
42 /// bound).
43 #[serde(default)]
44 pub setup: Setup,
45 /// The predicates the result must satisfy (ALL must hold for the case to pass).
46 pub expect: Vec<Expect>,
47}
48
49/// Parse one `.ron` case file's `contents` into a list of [`EvalCase`]s, accepting EITHER authoring shape.
50///
51/// `name` is used only for error messages (typically the file path/stem). The parse tries the LIST form
52/// (`Vec<EvalCase>`, RON `[EvalCase(…), EvalCase(…)]`) FIRST, and only if that fails falls back to the
53/// SINGLE form (one `EvalCase(…)`), wrapping the lone case in a one-element `Vec`. List-first is the
54/// correct order: a single `EvalCase(…)` can never parse as a `Vec` (it doesn't start with `[`), so a
55/// genuine single-case file flows cleanly to the fallback; whereas single-first would mis-handle a list.
56///
57/// ### Error reporting when BOTH parses fail
58///
59/// A file that is neither a valid list nor a valid single case is a hard [`EvalError::RonParse`] naming
60/// `name`. We report the *single*-case parse error as the `source`: for a file the author intended as one
61/// case (the common shape, and what a typo'd single-case file is) it is the directly relevant message,
62/// and for a genuinely-malformed list the single-parse error is just as diagnostic (RON still points at
63/// the offending span). We do not silently drop the case.
64///
65/// Bounds: `Setup: DeserializeOwned + Default` (a case may omit `setup`); `Expect: DeserializeOwned`.
66pub fn parse_cases_from_str<Setup, Expect>(
67 name: &str,
68 contents: &str,
69) -> Result<Vec<EvalCase<Setup, Expect>>, EvalError>
70where
71 Setup: DeserializeOwned + Default,
72 Expect: DeserializeOwned,
73{
74 // LIST form first: `[EvalCase(…), …]`. A single `EvalCase(…)` can't parse as a Vec, so this
75 // cleanly fails over to the single-case branch below for genuine one-case files.
76 if let Ok(list) = ron::from_str::<Vec<EvalCase<Setup, Expect>>>(contents) {
77 return Ok(list);
78 }
79 // SINGLE form fallback: one `EvalCase(…)`. If this also fails, the file is malformed under both
80 // shapes — surface this (single-case) parse error, named, rather than swallowing it.
81 match ron::from_str::<EvalCase<Setup, Expect>>(contents) {
82 Ok(case) => Ok(vec![case]),
83 Err(source) => Err(EvalError::RonParse {
84 path: std::path::PathBuf::from(name),
85 source,
86 }),
87 }
88}
89
90/// Load every `*.ron` case file from `dir`, parsing each via [`parse_cases_from_str`] and flattening the
91/// results — so a directory may freely mix single-case and multi-case files.
92///
93/// Files are read in sorted order so report ordering is deterministic across runs and machines, and a
94/// multi-case file contributes its cases in authored order. A malformed `.ron` is a hard error naming
95/// the file, so a typo fails the load (and any CI load test) rather than silently dropping a case.
96/// Non-`.ron` entries and subdirectories are ignored.
97///
98/// `Setup: Default` is required because [`EvalCase::setup`] is `#[serde(default)]` (a case may omit it).
99///
100/// Errors as [`EvalError::Io`] (naming the directory/file that couldn't be read) or
101/// [`EvalError::RonParse`] (naming the malformed file).
102pub fn load_cases<Setup, Expect>(
103 dir: &std::path::Path,
104) -> Result<Vec<EvalCase<Setup, Expect>>, EvalError>
105where
106 Setup: DeserializeOwned + Default,
107 Expect: DeserializeOwned,
108{
109 let mut paths: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
110 .map_err(|source| EvalError::Io {
111 path: Some(dir.to_path_buf()),
112 source,
113 })?
114 .filter_map(Result::ok)
115 .map(|e| e.path())
116 .filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("ron")))
117 .collect();
118 paths.sort();
119
120 let mut cases = Vec::with_capacity(paths.len());
121 for path in paths {
122 let text = std::fs::read_to_string(&path).map_err(|source| EvalError::Io {
123 path: Some(path.clone()),
124 source,
125 })?;
126 // One file may hold a single case or a list; flatten either into the running list. The error
127 // already names the file (the loader passes the full path as the parse `name`).
128 let parsed = parse_cases_from_str::<Setup, Expect>(&path.to_string_lossy(), &text)?;
129 cases.extend(parsed);
130 }
131 Ok(cases)
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137 use serde::Deserialize;
138 use std::io::Write;
139
140 #[derive(Debug, Default, Deserialize, PartialEq)]
141 struct Setup {
142 seed: u64,
143 }
144
145 #[derive(Debug, Deserialize, PartialEq)]
146 struct Expect {
147 contains: String,
148 }
149
150 /// `load_cases` reads every `*.ron` in sorted order, applies `Setup::default()` when `setup` is
151 /// omitted, ignores non-`.ron` files, and is fail-loud on a malformed file.
152 #[test]
153 fn loads_sorted_with_default_setup_and_ignores_non_ron() {
154 let dir = tempfile::tempdir().unwrap();
155
156 // `b.ron` comes second in sort order but omits `setup` → it gets `Setup::default()`.
157 let mut a = std::fs::File::create(dir.path().join("a.ron")).unwrap();
158 write!(
159 a,
160 r#"(name: "alpha", instruction: "do A", setup: (seed: 5), expect: [(contains: "x")])"#
161 )
162 .unwrap();
163 let mut b = std::fs::File::create(dir.path().join("b.ron")).unwrap();
164 write!(
165 b,
166 r#"(name: "beta", instruction: "do B", expect: [(contains: "y")])"#
167 )
168 .unwrap();
169 // A non-`.ron` file is ignored entirely.
170 std::fs::write(dir.path().join("notes.txt"), "ignored").unwrap();
171
172 let cases: Vec<EvalCase<Setup, Expect>> = load_cases(dir.path()).unwrap();
173
174 assert_eq!(cases.len(), 2, "two .ron cases, the .txt ignored");
175 assert_eq!(cases[0].name, "alpha");
176 assert_eq!(cases[0].setup, Setup { seed: 5 });
177 assert_eq!(cases[1].name, "beta");
178 assert_eq!(cases[1].setup, Setup::default(), "omitted setup defaults");
179 }
180
181 /// A malformed `.ron` is a hard error naming the file (fail-loud), not a silently dropped case.
182 #[test]
183 fn malformed_ron_is_a_hard_error() {
184 let dir = tempfile::tempdir().unwrap();
185 std::fs::write(dir.path().join("bad.ron"), "this is not ron").unwrap();
186
187 let err = load_cases::<Setup, Expect>(dir.path()).unwrap_err();
188 assert!(
189 err.to_string().contains("bad.ron"),
190 "error names the offending file: {err}"
191 );
192 }
193
194 /// `parse_cases_from_str` accepts BOTH the single-case and the list authoring shapes, and a file that
195 /// is valid under neither is a hard error naming the file.
196 #[test]
197 fn parse_cases_handles_single_and_list_and_names_malformed_file() {
198 // Single-case form (one `EvalCase(…)`), with `setup` omitted → `Setup::default()`.
199 let single: Vec<EvalCase<Setup, Expect>> = parse_cases_from_str(
200 "single.ron",
201 r#"(name: "solo", instruction: "do it", expect: [(contains: "z")])"#,
202 )
203 .unwrap();
204 assert_eq!(single.len(), 1);
205 assert_eq!(single[0].name, "solo");
206 assert_eq!(single[0].setup, Setup::default());
207
208 // List form (`[EvalCase(…), EvalCase(…)]`) → two cases in authored order.
209 let many: Vec<EvalCase<Setup, Expect>> = parse_cases_from_str(
210 "many.ron",
211 r#"[
212 (name: "one", instruction: "first", setup: (seed: 1), expect: [(contains: "a")]),
213 (name: "two", instruction: "second", expect: [(contains: "b")]),
214 ]"#,
215 )
216 .unwrap();
217 assert_eq!(many.len(), 2);
218 assert_eq!(many[0].name, "one");
219 assert_eq!(many[0].setup, Setup { seed: 1 });
220 assert_eq!(many[1].name, "two");
221
222 // Neither shape parses → hard error naming the file.
223 let err = parse_cases_from_str::<Setup, Expect>("oops.ron", "this is not ron").unwrap_err();
224 assert!(
225 err.to_string().contains("oops.ron"),
226 "both-parse-failed error names the file: {err}"
227 );
228 }
229
230 /// A directory mixing a single-case file and a multi-case file loads the correct flattened total in
231 /// sorted-by-filename order.
232 #[test]
233 fn load_cases_mixes_single_and_multi_case_files() {
234 let dir = tempfile::tempdir().unwrap();
235 // `a_single.ron` sorts first: one case.
236 std::fs::write(
237 dir.path().join("a_single.ron"),
238 r#"(name: "solo", instruction: "alone", expect: [(contains: "x")])"#,
239 )
240 .unwrap();
241 // `b_multi.ron` sorts second: a list of two cases.
242 std::fs::write(
243 dir.path().join("b_multi.ron"),
244 r#"[
245 (name: "m1", instruction: "first", expect: [(contains: "y")]),
246 (name: "m2", instruction: "second", expect: [(contains: "z")]),
247 ]"#,
248 )
249 .unwrap();
250
251 let cases: Vec<EvalCase<Setup, Expect>> = load_cases(dir.path()).unwrap();
252 assert_eq!(
253 cases.len(),
254 3,
255 "1 from the single file + 2 from the multi file"
256 );
257 // Sorted by filename, then authored order within the multi-case file.
258 let names: Vec<&str> = cases.iter().map(|c| c.name.as_str()).collect();
259 assert_eq!(names, vec!["solo", "m1", "m2"]);
260 }
261}