Skip to main content

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}