metrale_bench/benchmarks/bfcl/
provision.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: Provisions the BFCL artifacts under the plugin's artifact
4//! directory on `load()`: writes the committed scripts, then finds a Python,
5//! builds a venv, pip-installs the pinned `requirements.txt`, runs the
6//! scorer's `--selftest`, and materializes `dataset.jsonl` with
7//! `provision.py`. Each step reports a status before it runs. Everything
8//! after the script writes is skipped when the [`Stamp`] over the three
9//! shipped assets matches and the dataset and venv interpreter exist.
10//!
11//! Owner: bench, BFCL benchmark.
12//! Invariants: the stamp is committed only after the dataset and
13//! `dataset_summary.json` have been written.
14
15use 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    /// 2026-09-26: The venv interpreter, which has the pinned requirements
52    /// installed.
53    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    // 2026-09-26: Rewritten whenever the shipped bytes differ (`write_asset`),
69    // so a stale copy of a script never runs.
70    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    // 2026-09-26: A successful `pip install` does not prove the scorer can
107    // import: `score.py` imports the AST checker inside a function, so this
108    // runs its `--selftest` before any responses are generated. The import
109    // list lives in `score.py` so it cannot drift from what scoring imports.
110    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    // 2026-09-26: Committed last: a stamp written before the data exists would
158    // mark a half-provisioned directory as done.
159    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        // 2026-09-26: Exact, not "contains": every pin is a scorer input, and a
223        // floating dependency can move the score between runs.
224        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        // 2026-09-26: Against a bare system Python `--selftest` fails (no
239        // bfcl-eval), so the exit code must tell "cannot import" from
240        // "argument not recognised". Argparse exits 2 on an unknown flag.
241        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        // 2026-09-26: `aggregate` recombines per-subset (hits, n) counts in Rust
366        // with score.py's weighting, a second implementation of the
367        // aggregation, so it is pinned to real scorer output here.
368        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}