Skip to main content

zeroclaw_eval/
case.rs

1//! The evaluation case format — JSON trace fixtures for deterministic replay.
2
3use anyhow::Context;
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6
7/// A complete LLM conversation trace loaded from a JSON fixture.
8///
9/// Unknown keys are rejected. A required, 100%-pass regression gate must not
10/// be able to certify a fixture whose expectation was silently dropped
11/// because its key was misspelled.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct LlmTrace {
15    /// Identifier for the trace (surfaced in reports).
16    pub model_name: String,
17    /// Conversation turns, replayed in order.
18    ///
19    /// Must be non-empty. [`LlmTrace::from_file`] rejects a zero-turn fixture,
20    /// which would grade its expectations against a run that never happened.
21    pub turns: Vec<TraceTurn>,
22    /// Declarative expectations graded against the run.
23    #[serde(default)]
24    pub expects: TraceExpects,
25}
26
27/// A single conversation turn (user input + scripted LLM response steps).
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct TraceTurn {
30    pub user_input: String,
31    pub steps: Vec<TraceStep>,
32}
33
34/// A single LLM response step within a turn.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct TraceStep {
37    pub response: TraceResponse,
38}
39
40/// The response content for one step — either plain text or tool calls.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(tag = "type")]
43pub enum TraceResponse {
44    #[serde(rename = "text")]
45    Text {
46        content: String,
47        #[serde(default)]
48        input_tokens: u64,
49        #[serde(default)]
50        output_tokens: u64,
51    },
52    #[serde(rename = "tool_calls")]
53    ToolCalls {
54        tool_calls: Vec<TraceToolCall>,
55        #[serde(default)]
56        input_tokens: u64,
57        #[serde(default)]
58        output_tokens: u64,
59    },
60}
61
62/// A tool call within a trace response.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct TraceToolCall {
65    pub id: String,
66    pub name: String,
67    pub arguments: serde_json::Value,
68}
69
70/// Declarative expectations for grading a run.
71///
72/// Unknown keys are rejected so a typoed expectation cannot degrade a case
73/// into a silent no-op. See [`TraceExpects::is_empty`] for the companion
74/// guard against a fixture that declares no expectation at all.
75#[derive(Debug, Clone, Default, Serialize, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct TraceExpects {
78    /// Substrings the final response must contain.
79    #[serde(default)]
80    pub response_contains: Vec<String>,
81    /// Substrings the final response must NOT contain.
82    #[serde(default)]
83    pub response_not_contains: Vec<String>,
84    /// Tool names that must have been called.
85    #[serde(default)]
86    pub tools_used: Vec<String>,
87    /// Tool names that must NOT have been called.
88    #[serde(default)]
89    pub tools_not_used: Vec<String>,
90    /// Upper bound on the number of tool calls.
91    #[serde(default)]
92    pub max_tool_calls: Option<usize>,
93    /// If set, whether every tool call must have succeeded.
94    #[serde(default)]
95    pub all_tools_succeeded: Option<bool>,
96    /// Regex patterns the final response must match.
97    #[serde(default)]
98    pub response_matches: Vec<String>,
99    /// Lower bound on the number of tool calls.
100    #[serde(default)]
101    pub min_tool_calls: Option<usize>,
102    /// Exact number of tool calls the run must have made. Unlike `tools_used`
103    /// (existential) and `max_tool_calls` (upper bound), this fails when a
104    /// dispatch the fixture claims is missing.
105    #[serde(default)]
106    pub exact_tool_calls: Option<usize>,
107    /// Substrings that must appear in the arguments dispatched to a tool.
108    #[serde(default)]
109    pub tool_arguments_contain: Vec<ToolPayloadExpect>,
110    /// Substrings that must appear in the result a tool returned.
111    #[serde(default)]
112    pub tool_results_contain: Vec<ToolPayloadExpect>,
113}
114
115/// An expectation over one dispatched tool call's argument or result payload.
116///
117/// `call_index` selects which call to inspect: when omitted, the check passes if
118/// *any* call to `tool` carries `needle`; when set, it grades that specific call
119/// (0-based, counted across calls to the named tool in dispatch order), which is
120/// how a fixture asserts ordering — e.g. call 0 carried `alpha`, call 1 `beta`.
121#[derive(Debug, Clone, Default, Serialize, Deserialize)]
122#[serde(deny_unknown_fields)]
123pub struct ToolPayloadExpect {
124    /// Tool name whose payload is inspected.
125    pub tool: String,
126    /// Substring the payload must contain.
127    pub needle: String,
128    /// Optional 0-based index among calls to `tool`, in dispatch order.
129    #[serde(default)]
130    pub call_index: Option<usize>,
131}
132
133impl TraceExpects {
134    /// True when the fixture declares no effective assertion.
135    ///
136    /// `evaluate_expects` produces one grade per declared expectation, and
137    /// `CaseReport::passed()` is vacuously true over zero grades. A case in
138    /// this state exercises the agent but certifies nothing, so a required
139    /// gate must reject it at load time rather than report it green.
140    pub fn is_empty(&self) -> bool {
141        self.response_contains.is_empty()
142            && self.response_not_contains.is_empty()
143            && self.tools_used.is_empty()
144            && self.tools_not_used.is_empty()
145            && self.max_tool_calls.is_none()
146            && self.all_tools_succeeded.is_none()
147            && self.response_matches.is_empty()
148            && self.min_tool_calls.is_none()
149            && self.exact_tool_calls.is_none()
150            && self.tool_arguments_contain.is_empty()
151            && self.tool_results_contain.is_empty()
152    }
153
154    /// The name of the first string-backed family holding a zero-length entry.
155    ///
156    /// A zero-length entry is admitted by [`is_empty`](Self::is_empty) because
157    /// the vector is non-empty, yet it asserts nothing: every response contains
158    /// the empty substring, the empty regex matches every response, and no tool
159    /// is ever recorded under an empty name. In the positive families that
160    /// yields a tautological pass and in `tools_not_used` a degenerate one, so
161    /// such an entry can certify the required gate green without testing any
162    /// behavior. The negative families are rejected on the same rule for a
163    /// consistent schema, even though they fail rather than falsely certify.
164    fn empty_entry_family(&self) -> Option<&'static str> {
165        [
166            ("response_contains", &self.response_contains),
167            ("response_not_contains", &self.response_not_contains),
168            ("tools_used", &self.tools_used),
169            ("tools_not_used", &self.tools_not_used),
170            ("response_matches", &self.response_matches),
171        ]
172        .into_iter()
173        .find(|(_, values)| values.iter().any(|value| value.is_empty()))
174        .map(|(name, _)| name)
175    }
176
177    /// The first empty tool/payload field in a dispatch-boundary expectation.
178    fn empty_payload_field(&self) -> Option<&'static str> {
179        [
180            ("tool_arguments_contain", &self.tool_arguments_contain),
181            ("tool_results_contain", &self.tool_results_contain),
182        ]
183        .into_iter()
184        .find_map(|(family, entries)| {
185            entries.iter().find_map(|entry| {
186                if entry.tool.is_empty() {
187                    Some(match family {
188                        "tool_arguments_contain" => "tool_arguments_contain.tool",
189                        _ => "tool_results_contain.tool",
190                    })
191                } else if entry.needle.is_empty() {
192                    Some(match family {
193                        "tool_arguments_contain" => "tool_arguments_contain.needle",
194                        _ => "tool_results_contain.needle",
195                    })
196                } else {
197                    None
198                }
199            })
200        })
201    }
202
203    fn invalid_tool_call_bounds(&self) -> Option<String> {
204        if self.min_tool_calls == Some(0) {
205            return Some("`min_tool_calls` must be at least 1".to_string());
206        }
207        if let (Some(min), Some(max)) = (self.min_tool_calls, self.max_tool_calls)
208            && min > max
209        {
210            return Some(format!(
211                "`min_tool_calls` ({min}) exceeds `max_tool_calls` ({max})"
212            ));
213        }
214        if let Some(exact) = self.exact_tool_calls {
215            if let Some(min) = self.min_tool_calls
216                && exact < min
217            {
218                return Some(format!(
219                    "`exact_tool_calls` ({exact}) is below `min_tool_calls` ({min})"
220                ));
221            }
222            if let Some(max) = self.max_tool_calls
223                && exact > max
224            {
225                return Some(format!(
226                    "`exact_tool_calls` ({exact}) exceeds `max_tool_calls` ({max})"
227                ));
228            }
229        }
230        None
231    }
232}
233
234impl LlmTrace {
235    /// Load a trace from a JSON file.
236    pub fn from_file(path: &Path) -> anyhow::Result<Self> {
237        let content = std::fs::read_to_string(path)
238            .with_context(|| format!("reading trace fixture {}", path.display()))?;
239        let trace: LlmTrace = serde_json::from_str(&content)
240            .with_context(|| format!("parsing trace fixture {}", path.display()))?;
241        if trace.expects.is_empty() {
242            anyhow::bail!(
243                "trace fixture {} declares no effective expectation; a case that asserts \
244                 nothing would pass vacuously and cannot certify the required regression gate",
245                path.display()
246            );
247        }
248        if let Some(family) = trace.expects.empty_entry_family() {
249            anyhow::bail!(
250                "trace fixture {} declares a zero-length entry in `{}`; an empty value asserts \
251                 nothing and cannot certify the required regression gate",
252                path.display(),
253                family
254            );
255        }
256        if let Some(field) = trace.expects.empty_payload_field() {
257            anyhow::bail!(
258                "trace fixture {} declares an empty `{}` value; an empty tool or needle cannot \
259                 certify the required regression gate",
260                path.display(),
261                field
262            );
263        }
264        if let Some(reason) = trace.expects.invalid_tool_call_bounds() {
265            anyhow::bail!(
266                "trace fixture {} declares invalid tool-call bounds: {}",
267                path.display(),
268                reason
269            );
270        }
271        if trace.turns.is_empty() {
272            anyhow::bail!(
273                "trace fixture {} declares no conversation turns; the replay would drive the \
274                 agent zero times and grade a real-looking expectation such as \
275                 `max_tool_calls: 0` against an empty run, certifying the required regression \
276                 gate green without exercising any behavior",
277                path.display()
278            );
279        }
280        Ok(trace)
281    }
282}
283
284/// Collect the `*.json` fixture paths from an iterator of directory entries,
285/// sorted by path for stable ordering.
286///
287/// Every entry error aborts the collection. A suite that silently shrank
288/// because one entry could not be read would let the regression gate report
289/// success while certifying fewer fixtures than it claims, so a partial
290/// listing is never treated as a valid suite.
291fn collect_fixture_paths(
292    entries: impl Iterator<Item = std::io::Result<PathBuf>>,
293    dir: &Path,
294) -> anyhow::Result<Vec<PathBuf>> {
295    let mut paths = Vec::new();
296    for entry in entries {
297        let path = entry.with_context(|| {
298            format!("reading an entry in eval suite directory {}", dir.display())
299        })?;
300        if path.extension().and_then(|e| e.to_str()) == Some("json") {
301            paths.push(path);
302        }
303    }
304    paths.sort();
305    Ok(paths)
306}
307
308/// Load every `*.json` trace fixture in `dir`, sorted by path for stable ordering.
309pub fn load_suite(dir: &Path) -> anyhow::Result<Vec<(PathBuf, LlmTrace)>> {
310    let read = std::fs::read_dir(dir)
311        .with_context(|| format!("reading eval suite directory {}", dir.display()))?;
312
313    let paths = collect_fixture_paths(read.map(|entry| entry.map(|e| e.path())), dir)?;
314
315    let mut out = Vec::with_capacity(paths.len());
316    for path in paths {
317        let trace = LlmTrace::from_file(&path)?;
318        out.push((path, trace));
319    }
320    Ok(out)
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    /// The smallest fixture body that satisfies every admission rule: one
328    /// replayed turn plus one real assertion.
329    fn admissible_fixture(name: &str) -> String {
330        format!(
331            r#"{{"model_name":"{name}","turns":[{{"user_input":"hi","steps":[{{"response":{{"type":"text","content":"{name}"}}}}]}}],"expects":{{"response_contains":["{name}"]}}}}"#
332        )
333    }
334
335    #[test]
336    fn trace_response_text_variant_defaults_tokens_to_zero() {
337        let r: TraceResponse = serde_json::from_str(r#"{"type":"text","content":"hi"}"#).unwrap();
338        match r {
339            TraceResponse::Text {
340                content,
341                input_tokens,
342                output_tokens,
343            } => {
344                assert_eq!(content, "hi");
345                assert_eq!(input_tokens, 0);
346                assert_eq!(output_tokens, 0);
347            }
348            _ => panic!("expected Text variant"),
349        }
350    }
351
352    #[test]
353    fn trace_response_tool_calls_variant_parses() {
354        let j = r#"{"type":"tool_calls","tool_calls":[{"id":"1","name":"search","arguments":{"q":"x"}}],"input_tokens":5}"#;
355        let r: TraceResponse = serde_json::from_str(j).unwrap();
356        match r {
357            TraceResponse::ToolCalls {
358                tool_calls,
359                input_tokens,
360                output_tokens,
361            } => {
362                assert_eq!(tool_calls.len(), 1);
363                assert_eq!(tool_calls[0].id, "1");
364                assert_eq!(tool_calls[0].name, "search");
365                assert_eq!(input_tokens, 5);
366                assert_eq!(output_tokens, 0);
367            }
368            _ => panic!("expected ToolCalls variant"),
369        }
370    }
371
372    #[test]
373    fn llm_trace_uses_default_expects_when_omitted() {
374        let t: LlmTrace = serde_json::from_str(r#"{"model_name":"m","turns":[]}"#).unwrap();
375        assert_eq!(t.model_name, "m");
376        assert!(t.turns.is_empty());
377        assert!(t.expects.response_contains.is_empty());
378        assert!(t.expects.max_tool_calls.is_none());
379        // Deserialization still tolerates the omitted field; `from_file` is
380        // the layer that refuses to admit the resulting no-op case.
381        assert!(t.expects.is_empty());
382    }
383
384    #[test]
385    fn from_file_reads_and_parses_trace() {
386        let path = std::env::temp_dir().join("zeroclaw_eval_case_from_file_test.json");
387        std::fs::write(&path, admissible_fixture("demo")).unwrap();
388        let t = LlmTrace::from_file(&path).unwrap();
389        assert_eq!(t.model_name, "demo");
390        assert_eq!(t.turns.len(), 1);
391        let _ = std::fs::remove_file(&path);
392    }
393
394    #[test]
395    fn from_file_rejects_a_fixture_with_omitted_expectations() {
396        let path = std::env::temp_dir().join("zeroclaw_eval_case_omitted_expects_test.json");
397        std::fs::write(&path, r#"{"model_name":"demo","turns":[]}"#).unwrap();
398
399        let err = LlmTrace::from_file(&path)
400            .expect_err("a fixture that asserts nothing must not load into a required gate");
401
402        let rendered = format!("{err:#}");
403        assert!(
404            rendered.contains("declares no effective expectation"),
405            "error must explain the vacuous-pass rejection, got: {rendered}"
406        );
407        let _ = std::fs::remove_file(&path);
408    }
409
410    #[test]
411    fn from_file_rejects_a_fixture_with_an_empty_expects_block() {
412        let path = std::env::temp_dir().join("zeroclaw_eval_case_empty_expects_test.json");
413        std::fs::write(&path, r#"{"model_name":"demo","turns":[],"expects":{}}"#).unwrap();
414
415        let err = LlmTrace::from_file(&path)
416            .expect_err("an explicitly empty expects block asserts nothing either");
417
418        assert!(
419            format!("{err:#}").contains("declares no effective expectation"),
420            "an empty `expects` object must be rejected like an omitted one"
421        );
422        let _ = std::fs::remove_file(&path);
423    }
424
425    #[test]
426    fn from_file_rejects_a_typoed_expectation_key() {
427        // Without `deny_unknown_fields` a misspelled key is dropped in
428        // silence, leaving a case that runs the agent and grades nothing.
429        let path = std::env::temp_dir().join("zeroclaw_eval_case_typo_expects_test.json");
430        std::fs::write(
431            &path,
432            r#"{"model_name":"demo","turns":[],"expects":{"response_contain":["ok"]}}"#,
433        )
434        .unwrap();
435
436        let err = LlmTrace::from_file(&path)
437            .expect_err("a typoed expectation key must fail loudly, not degrade to a no-op");
438
439        let rendered = format!("{err:#}");
440        assert!(
441            rendered.contains("response_contain"),
442            "error must name the offending key, got: {rendered}"
443        );
444        let _ = std::fs::remove_file(&path);
445    }
446
447    #[test]
448    fn from_file_rejects_an_unknown_top_level_key() {
449        let path = std::env::temp_dir().join("zeroclaw_eval_case_typo_toplevel_test.json");
450        std::fs::write(
451            &path,
452            r#"{"model_name":"demo","turns":[],"expect":{"response_contains":["ok"]}}"#,
453        )
454        .unwrap();
455
456        let err = LlmTrace::from_file(&path)
457            .expect_err("a misspelled top-level key silently discards every expectation");
458
459        assert!(
460            format!("{err:#}").contains("expect"),
461            "error must name the offending top-level key"
462        );
463        let _ = std::fs::remove_file(&path);
464    }
465
466    #[test]
467    fn trace_expects_is_empty_is_false_for_each_individual_expectation() {
468        // Each field alone must be enough to admit the fixture, otherwise a
469        // legitimate single-assertion case would be rejected as vacuous.
470        let cases = [
471            r#"{"response_contains":["a"]}"#,
472            r#"{"response_not_contains":["a"]}"#,
473            r#"{"tools_used":["echo"]}"#,
474            r#"{"tools_not_used":["echo"]}"#,
475            r#"{"max_tool_calls":0}"#,
476            r#"{"all_tools_succeeded":false}"#,
477            r#"{"response_matches":["^a"]}"#,
478            r#"{"min_tool_calls":1}"#,
479            r#"{"exact_tool_calls":0}"#,
480            r#"{"tool_arguments_contain":[{"tool":"echo","needle":"alpha"}]}"#,
481            r#"{"tool_results_contain":[{"tool":"echo","needle":"alpha"}]}"#,
482        ];
483        for raw in cases {
484            let expects: TraceExpects = serde_json::from_str(raw).unwrap();
485            assert!(
486                !expects.is_empty(),
487                "{raw} declares a real assertion and must not count as empty"
488            );
489        }
490        // `max_tool_calls: 0` is a genuine assertion (no tool may run), so it
491        // must not be confused with an absent bound.
492        let zero: TraceExpects = serde_json::from_str(r#"{"max_tool_calls":0}"#).unwrap();
493        assert_eq!(zero.max_tool_calls, Some(0));
494    }
495
496    #[test]
497    fn from_file_rejects_a_zero_length_entry_in_every_string_backed_family() {
498        // Each of these loads today because the vector is non-empty, yet the
499        // entry asserts nothing. `response_contains`/`response_matches`/
500        // `tools_not_used` would additionally report a passing grade, which is
501        // a false green rather than the vacuous zero-grade case above.
502        let cases = [
503            ("response_contains", r#"{"response_contains":[""]}"#),
504            ("response_matches", r#"{"response_matches":[""]}"#),
505            ("tools_not_used", r#"{"tools_not_used":[""]}"#),
506            ("response_not_contains", r#"{"response_not_contains":[""]}"#),
507            ("tools_used", r#"{"tools_used":[""]}"#),
508        ];
509
510        for (family, expects) in cases {
511            let path = std::env::temp_dir()
512                .join(format!("zeroclaw_eval_case_empty_entry_{family}_test.json"));
513            std::fs::write(
514                &path,
515                format!(r#"{{"model_name":"demo","turns":[],"expects":{expects}}}"#),
516            )
517            .unwrap();
518
519            let err = LlmTrace::from_file(&path)
520                .expect_err("a zero-length entry must not load into a required gate");
521            let rendered = format!("{err:#}");
522            assert!(
523                rendered.contains("declares a zero-length entry"),
524                "error must explain the zero-length rejection, got: {rendered}"
525            );
526            assert!(
527                rendered.contains(family),
528                "error must name the offending family {family}, got: {rendered}"
529            );
530            let _ = std::fs::remove_file(&path);
531        }
532    }
533
534    #[test]
535    fn from_file_rejects_a_fixture_with_no_conversation_turns() {
536        // `turns: []` passes every expectation check: `max_tool_calls: 0` is a
537        // real assertion with no zero-length form. The replay would still run
538        // the agent zero times and grade that bound against an empty run, so
539        // the case reports green while certifying nothing.
540        let path = std::env::temp_dir().join("zeroclaw_eval_case_zero_turns_test.json");
541        std::fs::write(
542            &path,
543            r#"{"model_name":"empty-turns","turns":[],"expects":{"max_tool_calls":0}}"#,
544        )
545        .unwrap();
546
547        let err = LlmTrace::from_file(&path)
548            .expect_err("a fixture that replays no turn must not load into a required gate");
549
550        let rendered = format!("{err:#}");
551        assert!(
552            rendered.contains("declares no conversation turns"),
553            "error must explain the zero-turn rejection, got: {rendered}"
554        );
555        assert!(
556            rendered.contains("zeroclaw_eval_case_zero_turns_test.json"),
557            "error must name the offending fixture, got: {rendered}"
558        );
559        let _ = std::fs::remove_file(&path);
560    }
561
562    #[test]
563    fn from_file_admits_a_single_turn_fixture() {
564        // The zero-turn rule must reject only the degenerate case: one turn is
565        // enough to drive the agent, so it stays admissible.
566        let path = std::env::temp_dir().join("zeroclaw_eval_case_single_turn_test.json");
567        std::fs::write(&path, admissible_fixture("single-turn")).unwrap();
568
569        let trace = LlmTrace::from_file(&path).expect("one turn is a real replay");
570
571        assert_eq!(trace.turns.len(), 1);
572        assert_eq!(trace.turns[0].user_input, "hi");
573        let _ = std::fs::remove_file(&path);
574    }
575
576    #[test]
577    fn trace_expects_keeps_non_empty_entries_admissible() {
578        let expects: TraceExpects = serde_json::from_str(
579            r#"{"response_contains":["ok"],"tools_not_used":["shell"],"response_matches":["^o"]}"#,
580        )
581        .unwrap();
582        assert!(expects.empty_entry_family().is_none());
583    }
584
585    #[test]
586    fn from_file_rejects_empty_dispatch_payload_fields() {
587        let cases = [
588            (
589                "tool_arguments_contain.tool",
590                r#"{"tool_arguments_contain":[{"tool":"","needle":"alpha"}]}"#,
591            ),
592            (
593                "tool_arguments_contain.needle",
594                r#"{"tool_arguments_contain":[{"tool":"echo","needle":""}]}"#,
595            ),
596            (
597                "tool_results_contain.tool",
598                r#"{"tool_results_contain":[{"tool":"","needle":"alpha"}]}"#,
599            ),
600            (
601                "tool_results_contain.needle",
602                r#"{"tool_results_contain":[{"tool":"echo","needle":""}]}"#,
603            ),
604        ];
605
606        for (field, expects) in cases {
607            let path = std::env::temp_dir().join(format!(
608                "zeroclaw_eval_case_empty_payload_{}_test.json",
609                field.replace('.', "_")
610            ));
611            std::fs::write(
612                &path,
613                format!(r#"{{"model_name":"demo","turns":[],"expects":{expects}}}"#),
614            )
615            .unwrap();
616            let err = LlmTrace::from_file(&path).expect_err("empty payload fields must fail");
617            assert!(
618                format!("{err:#}").contains(field),
619                "error must name {field}: {err:#}"
620            );
621            let _ = std::fs::remove_file(path);
622        }
623    }
624
625    #[test]
626    fn from_file_rejects_unknown_dispatch_expectation_fields() {
627        let path = std::env::temp_dir().join("zeroclaw_eval_case_payload_typo_test.json");
628        std::fs::write(
629            &path,
630            r#"{"model_name":"demo","turns":[],"expects":{"tool_arguments_contain":[{"tool":"echo","nedle":"alpha"}]}}"#,
631        )
632        .unwrap();
633        let err = LlmTrace::from_file(&path).expect_err("nested expectation typos must fail");
634        assert!(format!("{err:#}").contains("nedle"));
635        let _ = std::fs::remove_file(path);
636    }
637
638    #[test]
639    fn from_file_rejects_vacuous_or_contradictory_tool_call_bounds() {
640        let cases = [
641            ("min_tool_calls", r#"{"min_tool_calls":0}"#),
642            ("exceeds", r#"{"min_tool_calls":2,"max_tool_calls":1}"#),
643            ("below", r#"{"min_tool_calls":2,"exact_tool_calls":1}"#),
644            ("exceeds", r#"{"max_tool_calls":1,"exact_tool_calls":2}"#),
645        ];
646
647        for (reason, expects) in cases {
648            let path = std::env::temp_dir().join(format!(
649                "zeroclaw_eval_case_invalid_bounds_{}_test.json",
650                reason
651            ));
652            std::fs::write(
653                &path,
654                format!(r#"{{"model_name":"demo","turns":[],"expects":{expects}}}"#),
655            )
656            .unwrap();
657            let err = LlmTrace::from_file(&path).expect_err("invalid bounds must fail");
658            assert!(
659                format!("{err:#}").contains(reason),
660                "error must explain {reason}: {err:#}"
661            );
662            let _ = std::fs::remove_file(path);
663        }
664    }
665
666    #[test]
667    fn load_suite_filters_json_and_sorts_by_path() {
668        let dir = std::env::temp_dir().join("zeroclaw_eval_case_suite_test");
669        let _ = std::fs::remove_dir_all(&dir);
670        std::fs::create_dir_all(&dir).unwrap();
671        std::fs::write(dir.join("b.json"), admissible_fixture("b")).unwrap();
672        std::fs::write(dir.join("a.json"), admissible_fixture("a")).unwrap();
673        std::fs::write(dir.join("note.txt"), "ignored").unwrap();
674        let suite = load_suite(&dir).unwrap();
675        assert_eq!(suite.len(), 2); // the .txt file is ignored
676        assert_eq!(suite[0].1.model_name, "a"); // sorted by path
677        assert_eq!(suite[1].1.model_name, "b");
678        let _ = std::fs::remove_dir_all(&dir);
679    }
680
681    #[test]
682    fn collect_fixture_paths_propagates_entry_errors() {
683        let dir = Path::new("/eval/suite");
684        let entries = vec![
685            Ok(dir.join("a.json")),
686            Err(std::io::Error::new(
687                std::io::ErrorKind::PermissionDenied,
688                "entry unreadable",
689            )),
690            Ok(dir.join("b.json")),
691        ];
692
693        let err = collect_fixture_paths(entries.into_iter(), dir)
694            .expect_err("an unreadable entry must abort the suite, not shrink it");
695
696        let rendered = format!("{err:#}");
697        assert!(
698            rendered.contains("reading an entry in eval suite directory /eval/suite"),
699            "error must name the suite directory, got: {rendered}"
700        );
701        assert!(
702            rendered.contains("entry unreadable"),
703            "error must preserve the underlying io cause, got: {rendered}"
704        );
705    }
706
707    #[test]
708    fn collect_fixture_paths_keeps_only_sorted_json_on_the_happy_path() {
709        let dir = Path::new("/eval/suite");
710        let entries = vec![
711            Ok(dir.join("b.json")),
712            Ok(dir.join("note.txt")),
713            Ok(dir.join("a.json")),
714        ];
715
716        let paths = collect_fixture_paths(entries.into_iter(), dir).unwrap();
717
718        assert_eq!(paths, vec![dir.join("a.json"), dir.join("b.json")]);
719    }
720}