metrale_bench/benchmarks/bfcl/
dataset.rs1use std::collections::BTreeMap;
11use std::path::Path;
12
13use anyhow::{Context, Result, bail};
14use serde::Deserialize;
15use serde_json::Value;
16
17use super::draw::{self, DrawSpec};
18
19#[derive(Clone, Debug, Deserialize)]
21pub struct Sample {
22 pub subset: String,
23 pub sample_id: String,
24 pub messages: Vec<Value>,
25 pub tools: Vec<Value>,
26 pub tool_choice: Value,
27}
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub struct Shard {
32 pub index: usize,
34 pub count: usize,
36}
37
38impl Shard {
39 pub fn parse(s: &str) -> std::result::Result<Self, String> {
44 let (i, n) = s
45 .split_once('/')
46 .ok_or_else(|| format!("shard {s:?} is not `index/count`, e.g. `2/4`"))?;
47 let index: usize = i
48 .trim()
49 .parse()
50 .map_err(|_| format!("shard index {i:?} is not a number"))?;
51 let count: usize = n
52 .trim()
53 .parse()
54 .map_err(|_| format!("shard count {n:?} is not a number"))?;
55 if count == 0 {
56 return Err("shard count must be at least 1".to_string());
57 }
58 if index >= count {
59 return Err(format!(
60 "shard index {index} is out of range for {count} shards — \
61 indices are 0-based, so the last is {}",
62 count - 1
63 ));
64 }
65 Ok(Self { index, count })
66 }
67}
68
69pub fn load(path: &Path, spec: &DrawSpec) -> Result<Vec<Sample>> {
70 load_shard(path, spec, None)
71}
72
73pub fn load_shard(path: &Path, spec: &DrawSpec, shard: Option<Shard>) -> Result<Vec<Sample>> {
78 let text = std::fs::read_to_string(path).with_context(|| {
79 format!(
80 "reading {} — delete ~/.metrale/artifacts/bfcl to re-provision",
81 path.display()
82 )
83 })?;
84 let totals = totals_of(&text)?;
85 let plan: BTreeMap<String, usize> = draw::plan(spec, &totals).into_iter().collect();
86
87 let mut taken: BTreeMap<String, usize> = BTreeMap::new();
88 let mut out = Vec::new();
89 for (i, line) in text.lines().enumerate() {
90 if line.trim().is_empty() {
91 continue;
92 }
93 let sample: Sample = serde_json::from_str(line)
94 .with_context(|| format!("{}:{}: malformed sample", path.display(), i + 1))?;
95 let Some(limit) = plan.get(&sample.subset) else {
96 continue;
97 };
98 let count = taken.entry(sample.subset.clone()).or_insert(0);
99 if *count >= *limit {
100 continue;
101 }
102 let position = *count;
107 *count += 1;
108 if let Some(sh) = shard
109 && !draw::shard_owns(position, sh.index, sh.count)
110 {
111 continue;
112 }
113 out.push(sample);
114 }
115 if out.is_empty() {
116 match shard {
117 None => bail!("the draw selected no samples — check the categories and percentages"),
118 Some(sh) => bail!(
119 "shard {} of {} selected no samples — the draw is smaller than the \
120 shard count, or the plan is empty",
121 sh.index,
122 sh.count
123 ),
124 }
125 }
126 out.sort_by(|a, b| a.subset.cmp(&b.subset));
128 Ok(out)
129}
130
131pub fn totals(path: &Path) -> Result<BTreeMap<String, usize>> {
133 totals_of(&std::fs::read_to_string(path)?)
134}
135
136fn totals_of(text: &str) -> Result<BTreeMap<String, usize>> {
137 #[derive(Deserialize)]
138 struct SubsetOnly {
139 subset: String,
140 }
141 let mut totals = BTreeMap::new();
142 for (i, line) in text.lines().enumerate() {
143 if line.trim().is_empty() {
144 continue;
145 }
146 let row: SubsetOnly = serde_json::from_str(line)
149 .with_context(|| format!("line {}: malformed sample", i + 1))?;
150 *totals.entry(row.subset).or_insert(0) += 1;
151 }
152 Ok(totals)
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 fn write(name: &str, rows: &[(&str, usize)]) -> std::path::PathBuf {
160 let p = std::env::temp_dir().join(format!(
161 "metrale-bfcl-ds-{name}-{}.jsonl",
162 std::process::id()
163 ));
164 let mut text = String::new();
165 for (subset, n) in rows {
166 for i in 0..*n {
167 text.push_str(&format!(
168 r#"{{"subset":"{subset}","sample_id":"{subset}_{i}","messages":[],"tools":[],"tool_choice":"auto"}}"#
169 ));
170 text.push('\n');
171 }
172 }
173 std::fs::write(&p, text).unwrap();
174 p
175 }
176
177 #[test]
178 fn the_draw_takes_the_first_n_of_each_subset() {
179 let p = write(
180 "head",
181 &[
182 ("simple_python", 10),
183 ("simple_java", 10),
184 ("irrelevance", 10),
185 ],
186 );
187 let spec = DrawSpec {
188 categories: vec!["non_live".into()],
189 category_pct: [("non_live".to_string(), 50.0)].into_iter().collect(),
190 subset_floor: None,
191 };
192 let s = load(&p, &spec).unwrap();
193 assert_eq!(
194 s.len(),
195 10,
196 "50% of both selected subsets, and irrelevance is excluded"
197 );
198 let ids: Vec<&str> = s.iter().map(|x| x.sample_id.as_str()).collect();
199 assert_eq!(
200 ids,
201 vec![
202 "simple_java_0",
203 "simple_java_1",
204 "simple_java_2",
205 "simple_java_3",
206 "simple_java_4",
207 "simple_python_0",
208 "simple_python_1",
209 "simple_python_2",
210 "simple_python_3",
211 "simple_python_4"
212 ],
213 "head(n), not a random sample"
214 );
215 }
216
217 #[test]
218 fn totals_are_counted_without_parsing_whole_samples() {
219 let p = write("totals", &[]);
220 std::fs::write(
221 &p,
222 concat!(
223 "{\"subset\":\"multiple\"}\n",
224 "{\"subset\":\"multiple\"}\n",
225 "{\"subset\":\"multiple\"}\n",
226 "{\"subset\":\"live_simple\"}\n",
227 "{\"subset\":\"live_simple\"}\n",
228 ),
229 )
230 .unwrap();
231 let t = totals(&p).unwrap();
232 assert_eq!(t.len(), 2);
233 assert_eq!(t["multiple"], 3);
234 assert_eq!(t["live_simple"], 2);
235 }
236
237 #[test]
238 fn an_empty_draw_is_an_error_rather_than_a_zero_sample_run() {
239 let p = write("empty", &[("live_relevance", 4)]);
240 let err = load(&p, &DrawSpec::golden()).unwrap_err().to_string();
241 assert!(err.contains("selected no samples"), "{err}");
242 }
243
244 #[test]
245 fn a_malformed_line_names_its_line_number() {
246 let p = write("bad", &[("multiple", 1)]);
247 let mut text = std::fs::read_to_string(&p).unwrap();
248 text.push_str("{not json}\n");
249 std::fs::write(&p, text).unwrap();
250 let err = load(&p, &DrawSpec::full()).unwrap_err().to_string();
251 assert!(err.contains("line 2: malformed sample"), "{err}");
252 }
253}