1use std::path::{Path, PathBuf};
16
17use anyhow::{Context, Result};
18use serde::Deserialize;
19
20use crate::artifacts::{ArtifactStore, Stamp, write_asset};
21use crate::plugin::PluginHandle;
22use crate::python;
23
24pub const PLUGIN_ID: &str = "bfcl";
25
26const REQUIREMENTS: &str = include_str!("../../../assets/bfcl/requirements.txt");
27const PROVISION_PY: &str = include_str!("../../../assets/bfcl/provision.py");
28const SCORE_PY: &str = include_str!("../../../assets/bfcl/score.py");
29
30const MIN_PYTHON: (u32, u32) = (3, 10);
31
32fn stamp_value(requirements: &str, provision: &str, scorer: &str) -> String {
33 use sha2::{Digest, Sha256};
34
35 let mut digest = Sha256::new();
36 for asset in [requirements, provision, scorer] {
37 digest.update((asset.len() as u64).to_le_bytes());
38 digest.update(asset.as_bytes());
39 }
40 format!(
41 "v2 py>={}.{} assets={}",
42 MIN_PYTHON.0,
43 MIN_PYTHON.1,
44 metrale_closure::hex_lower(&digest.finalize())
45 )
46}
47
48#[derive(Clone, Debug)]
49pub struct Artifacts {
50 pub dir: PathBuf,
51 pub python: PathBuf,
54 pub dataset: PathBuf,
55 pub scorer: PathBuf,
56 pub subset_totals: std::collections::BTreeMap<String, usize>,
57}
58
59#[derive(Deserialize)]
60struct ProvisionSummary {
61 total: usize,
62 sha256: String,
63 subsets: std::collections::BTreeMap<String, usize>,
64}
65
66pub async fn ensure(store: &ArtifactStore, handle: &PluginHandle) -> Result<Artifacts> {
67 let dir = store.plugin_dir(PLUGIN_ID)?;
68 write_asset(&dir, "requirements.txt", REQUIREMENTS)?;
71 write_asset(&dir, "provision.py", PROVISION_PY)?;
72 write_asset(&dir, "score.py", SCORE_PY)?;
73
74 let dataset = dir.join("dataset.jsonl");
75 let scorer = dir.join("score.py");
76 let venv = dir.join("venv");
77 let interpreter = python::venv_python(&venv);
78 let stamp = Stamp::new(
79 &dir,
80 ".provisioned",
81 stamp_value(REQUIREMENTS, PROVISION_PY, SCORE_PY),
82 );
83
84 if stamp.is_current() && dataset.is_file() && interpreter.is_file() {
85 handle.info("BFCL artifacts already provisioned");
86 let totals = read_totals(&dir)?;
87 return Ok(Artifacts {
88 dir,
89 python: interpreter,
90 dataset,
91 scorer,
92 subset_totals: totals,
93 });
94 }
95
96 handle.status("BFCL: checking for python");
97 let system_python = python::find_python(MIN_PYTHON.0, MIN_PYTHON.1).await?;
98 handle.info(format!("python: {}", system_python.display()));
99
100 handle.status("BFCL: creating venv");
101 let interpreter = python::ensure_venv(&system_python, &venv).await?;
102
103 handle.status("BFCL: downloading bfcl-eval (needs network)");
104 python::pip_install(&interpreter, &dir.join("requirements.txt")).await?;
105
106 handle.status("BFCL: checking the scorer can import");
111 python::run(
112 &interpreter,
113 &[
114 dir.join("score.py")
115 .to_str()
116 .context("artifact path is not valid UTF-8")?,
117 "--selftest",
118 ],
119 Some(&dir),
120 )
121 .await
122 .context(
123 "the BFCL scorer cannot import its dependencies. Delete \
124 ~/.metrale/artifacts/bfcl to re-provision; if it recurs, a transitive \
125 dependency is missing from requirements.txt",
126 )?;
127
128 handle.status("BFCL: materializing the single-turn dataset");
129 let out = python::run(
130 &interpreter,
131 &[
132 dir.join("provision.py")
133 .to_str()
134 .context("artifact path is not valid UTF-8")?,
135 "--out",
136 dataset
137 .to_str()
138 .context("dataset path is not valid UTF-8")?,
139 ],
140 Some(&dir),
141 )
142 .await
143 .context("materializing the BFCL dataset")?;
144
145 let summary: ProvisionSummary = serde_json::from_str(out.stdout.trim())
146 .with_context(|| format!("provision.py printed unexpected output: {}", out.stdout))?;
147 handle.info(format!(
148 "BFCL dataset: {} samples across {} subsets (sha256 {}…)",
149 summary.total,
150 summary.subsets.len(),
151 &summary.sha256[..12.min(summary.sha256.len())]
152 ));
153 std::fs::write(
154 dir.join("dataset_summary.json"),
155 serde_json::to_string_pretty(&summary.subsets)?,
156 )?;
157 stamp.commit()?;
160
161 Ok(Artifacts {
162 dir,
163 python: interpreter,
164 dataset,
165 scorer,
166 subset_totals: summary.subsets,
167 })
168}
169
170fn read_totals(dir: &Path) -> Result<std::collections::BTreeMap<String, usize>> {
171 let text = std::fs::read_to_string(dir.join("dataset_summary.json")).context(
172 "dataset_summary.json is missing — delete ~/.metrale/artifacts/bfcl to re-provision",
173 )?;
174 Ok(serde_json::from_str(&text)?)
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 fn temp_dir(name: &str) -> PathBuf {
182 let dir = std::env::temp_dir().join(format!(
183 "metrale-bfcl-{name}-{}-{:?}",
184 std::process::id(),
185 std::thread::current().id()
186 ));
187 let _ = std::fs::remove_dir_all(&dir);
188 std::fs::create_dir_all(&dir).unwrap();
189 dir
190 }
191
192 fn python() -> &'static str {
193 ["python3", "python"]
194 .into_iter()
195 .find(|candidate| {
196 std::process::Command::new(candidate)
197 .arg("--version")
198 .output()
199 .is_ok_and(|out| out.status.success())
200 })
201 .expect("the committed Python assets require a Python interpreter")
202 }
203
204 fn write_jsonl(path: &Path, rows: &[serde_json::Value]) {
205 let text = rows
206 .iter()
207 .map(serde_json::Value::to_string)
208 .collect::<Vec<_>>()
209 .join("\n");
210 std::fs::write(path, format!("{text}\n")).unwrap();
211 }
212
213 #[test]
214 fn the_committed_python_assets_are_non_empty_and_own_their_cli() {
215 assert!(!PROVISION_PY.trim().is_empty());
216 assert!(!SCORE_PY.trim().is_empty());
217 let pins: Vec<&str> = REQUIREMENTS
218 .lines()
219 .map(str::trim)
220 .filter(|line| !line.is_empty() && !line.starts_with('#'))
221 .collect();
222 assert_eq!(pins, ["bfcl-eval==2026.3.23", "soundfile==0.14.0"]);
225
226 let dir = temp_dir("cli");
227 let provision = dir.join("provision.py");
228 let scorer = dir.join("score.py");
229 std::fs::write(&provision, PROVISION_PY).unwrap();
230 std::fs::write(&scorer, SCORE_PY).unwrap();
231
232 let out = std::process::Command::new(python())
233 .arg(&provision)
234 .output()
235 .unwrap();
236 assert_eq!(out.status.code(), Some(2), "provision.py accepted no --out");
237
238 let selftest = std::process::Command::new(python())
242 .arg(&scorer)
243 .arg("--selftest")
244 .output()
245 .unwrap();
246 assert_ne!(
247 selftest.status.code(),
248 Some(2),
249 "score.py does not recognise --selftest, so provisioning's check \
250 would pass for the wrong reason: {}",
251 String::from_utf8_lossy(&selftest.stderr)
252 );
253 let stderr = String::from_utf8(out.stderr).unwrap();
254 assert!(
255 stderr.contains("the following arguments are required: --out"),
256 "provision.py did not require --out: {stderr}"
257 );
258
259 for (provided, missing) in [
260 (["--dataset", "unused"], "--responses"),
261 (["--responses", "unused"], "--dataset"),
262 ] {
263 let out = std::process::Command::new(python())
264 .arg(&scorer)
265 .args(provided)
266 .output()
267 .unwrap();
268 assert_eq!(out.status.code(), Some(2), "score.py accepted no {missing}");
269 let stderr = String::from_utf8(out.stderr).unwrap();
270 assert!(
271 stderr.contains(&format!("the following arguments are required: {missing}")),
272 "score.py did not require {missing}: {stderr}"
273 );
274 }
275 }
276
277 #[test]
278 fn the_scorer_reproduces_the_reference_aggregation_strategies() {
279 let dir = temp_dir("score");
280 let fake = dir.join("bfcl_eval");
281 for package in [
282 fake.clone(),
283 fake.join("constants"),
284 fake.join("eval_checker"),
285 fake.join("eval_checker/ast_eval"),
286 ] {
287 std::fs::create_dir_all(&package).unwrap();
288 std::fs::write(package.join("__init__.py"), "").unwrap();
289 }
290 std::fs::write(
291 fake.join("constants/enums.py"),
292 "from enum import Enum\nclass Language(Enum):\n PYTHON='PYTHON'\n JAVA='JAVA'\n JAVASCRIPT='JAVASCRIPT'\n",
293 )
294 .unwrap();
295 std::fs::write(
296 fake.join("eval_checker/ast_eval/ast_checker.py"),
297 "def ast_checker(**kwargs):\n return {'valid': bool(kwargs['model_output']) and kwargs['model_name'] == 'gpt-4o-2024-11-20-FC'}\n",
298 )
299 .unwrap();
300
301 let mut dataset = Vec::new();
302 let mut responses = Vec::new();
303 let cases = [
304 ("simple_python", [true, true, false]),
305 ("simple_java", [false, false, false]),
306 ("multiple", [false, false, false]),
307 ("live_simple", [true, true, true]),
308 ("live_multiple", [false, false, false]),
309 ("irrelevance", [true, true, true]),
310 ("live_irrelevance", [false, false, false]),
311 ];
312 let counts = [2, 1, 1, 3, 1, 3, 1];
313 for ((subset, outcomes), count) in cases.into_iter().zip(counts) {
314 for (index, passes) in outcomes.into_iter().take(count).enumerate() {
315 let id = format!("{subset}-{index}");
316 dataset.push(serde_json::json!({
317 "sample_id": id,
318 "subset": subset,
319 "ground_truth": "[1]",
320 "func_description": "[]"
321 }));
322 responses.push(serde_json::json!({
323 "sample_id": id,
324 "has_tool_calls": if subset.contains("irrelevance") { !passes } else { passes },
325 "tool_calls": if passes && !subset.contains("irrelevance") {
326 serde_json::json!([{"name": "f", "arguments": {}}])
327 } else {
328 serde_json::json!([])
329 }
330 }));
331 }
332 }
333 let dataset_path = dir.join("dataset.jsonl");
334 let responses_path = dir.join("responses.jsonl");
335 let scorer_path = dir.join("score.py");
336 write_jsonl(&dataset_path, &dataset);
337 write_jsonl(&responses_path, &responses);
338 std::fs::write(&scorer_path, SCORE_PY).unwrap();
339
340 let out = std::process::Command::new(python())
341 .args([
342 scorer_path.as_os_str(),
343 "--dataset".as_ref(),
344 dataset_path.as_os_str(),
345 "--responses".as_ref(),
346 responses_path.as_os_str(),
347 ])
348 .env("PYTHONPATH", &dir)
349 .output()
350 .unwrap();
351 assert!(
352 out.status.success(),
353 "scorer failed: {}",
354 String::from_utf8_lossy(&out.stderr)
355 );
356 let result: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
357 assert_eq!(result["category_scores"]["non_live"], 25.0);
358 assert_eq!(result["category_scores"]["live"], 75.0);
359 assert_eq!(result["category_scores"]["hallucination"], 50.0);
360 assert_eq!(result["normalized_single_turn_score"], 50.0);
361 assert_eq!(result["overall_accuracy"], 66.67);
362 assert_eq!(result["total_samples"], 12);
363 assert_eq!(result["unmatched_responses"], 0);
364
365 let totals = result["subset_totals"]
369 .as_object()
370 .expect("score.py must emit subset_totals for the shard aggregator");
371 let tallies: std::collections::BTreeMap<String, super::super::aggregate::Tally> = totals
372 .iter()
373 .map(|(k, v)| {
374 let a = v.as_array().expect("[hits, n]");
375 (
376 k.clone(),
377 super::super::aggregate::Tally {
378 hits: a[0].as_u64().expect("hits"),
379 n: a[1].as_u64().expect("n"),
380 },
381 )
382 })
383 .collect();
384 let rust = super::super::aggregate::aggregate(&tallies);
385 assert_eq!(
386 rust.overall_accuracy,
387 result["overall_accuracy"].as_f64().unwrap(),
388 "the Rust aggregator disagrees with score.py on overall_accuracy"
389 );
390 assert_eq!(
391 rust.normalized_single_turn_score,
392 result["normalized_single_turn_score"].as_f64().unwrap(),
393 "the Rust aggregator disagrees with score.py on normalized"
394 );
395 assert_eq!(
396 rust.total_samples,
397 result["total_samples"].as_u64().unwrap(),
398 "sample count disagrees"
399 );
400 for (cat, v) in result["category_scores"].as_object().unwrap() {
401 assert_eq!(
402 rust.category_scores.get(cat).copied(),
403 v.as_f64(),
404 "category {cat} disagrees between score.py and the Rust aggregator"
405 );
406 }
407 }
408
409 #[test]
410 fn the_stamp_changes_when_any_shipped_asset_changes() {
411 let dir = temp_dir("stamp");
412 let original = stamp_value(REQUIREMENTS, PROVISION_PY, SCORE_PY);
413 let a = Stamp::new(&dir, ".provisioned", &original);
414 a.commit().unwrap();
415 assert!(a.is_current());
416 for changed in [
417 stamp_value(&REQUIREMENTS.replacen('#', "!", 1), PROVISION_PY, SCORE_PY),
418 stamp_value(REQUIREMENTS, &PROVISION_PY.replacen('#', "!", 1), SCORE_PY),
419 stamp_value(REQUIREMENTS, PROVISION_PY, &SCORE_PY.replacen('#', "!", 1)),
420 ] {
421 assert_eq!(changed.len(), original.len());
422 assert_ne!(changed, original);
423 assert!(!Stamp::new(&dir, ".provisioned", changed).is_current());
424 }
425 }
426}