zeph_experiments/
benchmark.rs1use std::path::Path;
7
8use serde::Deserialize;
9
10use super::error::EvalError;
11
12const MAX_BENCHMARK_SIZE: u64 = 10 * 1024 * 1024;
14
15#[derive(Debug, Clone, Deserialize)]
45pub struct BenchmarkSet {
46 pub cases: Vec<BenchmarkCase>,
48}
49
50#[derive(Debug, Clone, Deserialize)]
70pub struct BenchmarkCase {
71 pub prompt: String,
73 #[serde(default)]
75 pub context: Option<String>,
76 #[serde(default)]
78 pub reference: Option<String>,
79 #[serde(default)]
81 pub tags: Option<Vec<String>>,
82}
83
84impl BenchmarkSet {
85 pub fn from_file(path: &Path) -> Result<Self, EvalError> {
97 let canonical = std::fs::canonicalize(path)
99 .map_err(|e| EvalError::BenchmarkLoad(path.display().to_string(), e))?;
100
101 if let Some(parent) = path.parent()
104 && let Ok(canonical_parent) = std::fs::canonicalize(parent)
105 && !canonical.starts_with(&canonical_parent)
106 {
107 return Err(EvalError::PathTraversal(canonical.display().to_string()));
108 }
109
110 let metadata = std::fs::metadata(&canonical)
112 .map_err(|e| EvalError::BenchmarkLoad(canonical.display().to_string(), e))?;
113 if metadata.len() > MAX_BENCHMARK_SIZE {
114 return Err(EvalError::BenchmarkTooLarge {
115 path: canonical.display().to_string(),
116 size: metadata.len(),
117 limit: MAX_BENCHMARK_SIZE,
118 });
119 }
120
121 let content = std::fs::read_to_string(&canonical)
122 .map_err(|e| EvalError::BenchmarkLoad(canonical.display().to_string(), e))?;
123 toml::from_str(&content)
124 .map_err(|e| EvalError::BenchmarkParse(canonical.display().to_string(), e.to_string()))
125 }
126
127 #[must_use = "validation result must be checked"]
133 pub fn validate(&self) -> Result<(), EvalError> {
134 if self.cases.is_empty() {
135 return Err(EvalError::EmptyBenchmarkSet);
136 }
137 Ok(())
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 #![allow(clippy::redundant_closure_for_method_calls)]
144 use std::assert_matches;
145
146 use super::*;
147
148 fn parse(toml: &str) -> BenchmarkSet {
149 toml::from_str(toml).expect("valid TOML")
150 }
151
152 #[test]
153 fn benchmark_from_toml_happy_path() {
154 let toml = r#"
155[[cases]]
156prompt = "What is 2+2?"
157"#;
158 let set = parse(toml);
159 assert_eq!(set.cases.len(), 1);
160 assert_eq!(set.cases[0].prompt, "What is 2+2?");
161 assert!(set.cases[0].context.is_none());
162 assert!(set.cases[0].reference.is_none());
163 assert!(set.cases[0].tags.is_none());
164 }
165
166 #[test]
167 fn benchmark_from_toml_with_all_fields() {
168 let toml = r#"
169[[cases]]
170prompt = "Explain Rust ownership."
171context = "You are a Rust expert."
172reference = "Ownership is Rust's memory management model."
173tags = ["rust", "concepts"]
174"#;
175 let set = parse(toml);
176 assert_eq!(set.cases.len(), 1);
177 let case = &set.cases[0];
178 assert_eq!(case.context.as_deref(), Some("You are a Rust expert."));
179 assert!(case.reference.is_some());
180 assert_eq!(case.tags.as_ref().map(std::vec::Vec::len), Some(2));
181 }
182
183 #[test]
184 fn benchmark_empty_cases_rejected() {
185 let set = BenchmarkSet { cases: vec![] };
186 assert_matches!(set.validate(), Err(EvalError::EmptyBenchmarkSet));
187 }
188
189 #[test]
190 fn benchmark_from_file_missing_file() {
191 let result = BenchmarkSet::from_file(Path::new("/nonexistent/path/benchmark.toml"));
192 assert_matches!(result, Err(EvalError::BenchmarkLoad(_, _)));
193 }
194
195 #[test]
196 fn benchmark_from_toml_invalid_syntax() {
197 let bad = "[[cases\nprompt = 'unclosed'";
198 let result: Result<BenchmarkSet, _> = toml::from_str(bad);
199 assert!(result.is_err());
200 }
201
202 #[test]
203 fn benchmark_from_file_invalid_toml() {
204 use std::io::Write;
205 let mut f = tempfile::NamedTempFile::new().unwrap();
206 writeln!(f, "not valid toml ][[]").unwrap();
207 let result = BenchmarkSet::from_file(f.path());
208 assert_matches!(result, Err(EvalError::BenchmarkParse(_, _)));
209 }
210
211 #[test]
212 fn benchmark_from_file_too_large() {
213 let err = EvalError::BenchmarkTooLarge {
219 path: "/tmp/bench.toml".into(),
220 size: MAX_BENCHMARK_SIZE + 1,
221 limit: MAX_BENCHMARK_SIZE,
222 };
223 assert!(err.to_string().contains("exceeds size limit"));
224 }
225
226 #[test]
227 fn benchmark_from_file_size_guard_allows_normal_file() {
228 use std::io::Write;
229 let mut f = tempfile::NamedTempFile::new().unwrap();
230 writeln!(f, "[[cases]]\nprompt = \"hello\"").unwrap();
231 let result = BenchmarkSet::from_file(f.path());
233 assert!(result.is_ok());
234 }
235
236 #[test]
237 fn benchmark_validate_passes_for_nonempty() {
238 let set = BenchmarkSet {
239 cases: vec![BenchmarkCase {
240 prompt: "hello".into(),
241 context: None,
242 reference: None,
243 tags: None,
244 }],
245 };
246 assert!(set.validate().is_ok());
247 }
248}