metrale_bench/benchmarks/bfcl/
mod.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: BFCL v4 single-turn, full and subset draws, whole or sharded.
4//!
5//! Its ground truth and AST checker live in `bfcl-eval`, so the work is split:
6//!
7//! * Python materialises the dataset and scores the responses. Both scripts are
8//!   committed in `assets/bfcl/`, written into the artifact store's
9//!   `artifacts/bfcl`, and run from a venv provisioned during `load()`.
10//! * Rust owns the draw, the generation, the streaming and the presentation, so
11//!   a run reports its `n` (and warns when it is not the pinned size) before
12//!   generation starts, and is cancellable between samples.
13//!
14//! Generation sends one sample at a time.
15//!
16//! Owner: bench, BFCL benchmark.
17//! Invariants: none beyond the types.
18
19pub mod aggregate;
20pub mod dataset;
21pub mod draw;
22pub mod provision;
23pub mod report;
24pub mod sensitive;
25
26use std::collections::BTreeMap;
27use std::future::Future;
28use std::path::PathBuf;
29use std::time::{Duration, Instant};
30
31use anyhow::{Context, Result, bail};
32use serde::Deserialize;
33use serde_json::json;
34
35use crate::benchmark::{Benchmark, BenchmarkDescriptor};
36use crate::benchmarks::one_line;
37use crate::http;
38use crate::metadata::PluginMetadata;
39use crate::params::{ParamKind, ParamSpec, ParamValue, ParamValues};
40use crate::plugin::{Plugin, PluginHandle};
41use crate::result::{BenchmarkResult, LogLine, RunStatus};
42
43use draw::DrawSpec;
44
45pub use report::{
46    MLPERF_FLOOR_CHECKPOINTS, MLPERF_FLOOR_NORMALIZED, MLPERF_FLOOR_OVERALL,
47    is_mlperf_submission_checkpoint,
48};
49
50mod descriptors;
51mod mini_descriptors;
52pub use descriptors::{
53    ECHOLP_METADATA, FULL_DESCRIPTOR, FULL_METADATA, SUBSET_DESCRIPTOR, SUBSET_ECHOLP_DESCRIPTOR,
54    SUBSET_METADATA,
55};
56pub use mini_descriptors::{
57    MINI_METADATA, MINI_NEMOTRON_NANO_DESCRIPTOR, MINI_NEMOTRON_SUPER_DESCRIPTOR,
58    SUBSET_MINI_DESCRIPTOR,
59};
60
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub enum Variant {
63    Subset,
64    SubsetEcholp,
65    Full,
66    /// 2026-10-03: The 192-sample mini draw (`DrawSpec::mini`).
67    SubsetMini,
68}
69
70/// 2026-09-26: What `score.py` prints.
71#[derive(Debug, Deserialize)]
72struct Scores {
73    overall_accuracy: f64,
74    normalized_single_turn_score: f64,
75    category_scores: BTreeMap<String, f64>,
76    subset_scores: BTreeMap<String, f64>,
77    total_samples: usize,
78    unmatched_responses: usize,
79    /// 2026-09-26: Per-subset `(hits, n)`, the numbers that recombine exactly
80    /// across shards (see `aggregate`). Scorer output without the key
81    /// deserializes to empty.
82    #[serde(default)]
83    subset_totals: BTreeMap<String, (u64, u64)>,
84}
85
86enum Phase {
87    Provision,
88    Generate,
89    Score,
90    Done,
91}
92
93pub struct Bfcl {
94    variant: Variant,
95    /// 2026-09-26: Which slice of the draw this run measures, if it is a shard
96    /// of a group. `None` is the whole draw.
97    shard: Option<dataset::Shard>,
98    handle: Option<PluginHandle>,
99    phase: Phase,
100    artifacts: Option<provision::Artifacts>,
101    samples: Vec<dataset::Sample>,
102    cursor: usize,
103    responses: Vec<serde_json::Value>,
104    responses_path: Option<PathBuf>,
105    scores: Option<Scores>,
106    spec: DrawSpec,
107    max_new_tokens: usize,
108    temperature: f64,
109    request_timeout: Duration,
110    started: Option<Instant>,
111    tool_call_samples: usize,
112    /// 2026-09-26: Samples whose request failed at the transport, scored as "no
113    /// call". Published as a metric so a group can refuse a degraded member.
114    transport_errors: usize,
115    /// 2026-09-26: Samples run that are in
116    /// [`sensitive::KNOWN_PARTITION_SENSITIVE`].
117    known_sensitive_seen: usize,
118    /// 2026-09-26: The served model, captured at `load()` from the target
119    /// endpoint. The MLPerf floor verdict applies only when it is one of
120    /// `report::MLPERF_FLOOR_CHECKPOINTS`.
121    target_model: Option<String>,
122    /// 2026-09-26: Baseline floors for other checkpoints, the `min_overall` and
123    /// `min_normalized` params, which the gate fills from the variant's
124    /// BENCH.toml. Both 0.0 gives an info verdict.
125    baseline_mins: report::BaselineMins,
126}
127
128/// 2026-09-26: The `shard` parameter value that means "leave the current
129/// slice alone". The note on the spec's default says why it is a word and not
130/// an empty string or `0/1`.
131pub const INHERIT_SHARD: &str = "inherit";
132
133/// 2026-09-26: The default per-sample output budget. `kat_equality`, which
134/// replays BFCL samples, takes its default budget from this constant too.
135pub const MAX_NEW_TOKENS: usize = 1024;
136
137impl Bfcl {
138    /// 2026-09-26: The sample count this run should produce: the variant's
139    /// pinned draw, or this shard's slice of it via `draw::shard_take` over the
140    /// plan for `draw::reference_subset_totals`.
141    fn expected_samples(&self) -> Option<usize> {
142        let whole = self.variant.expected_samples()?;
143        match self.shard {
144            None => Some(whole),
145            Some(sh) => {
146                let totals = draw::reference_subset_totals();
147                let plan = draw::plan(&self.variant.spec(), &totals);
148                Some(
149                    plan.iter()
150                        .map(|(_, take)| draw::shard_take(*take, sh.index, sh.count))
151                        .sum(),
152                )
153            }
154        }
155    }
156
157    pub fn new(variant: Variant) -> Self {
158        Self::maybe_sharded(variant, None)
159    }
160
161    /// 2026-09-26: One shard of `variant`'s draw. The gate aggregates a group's
162    /// shards (`gate::check_group`).
163    pub fn sharded(variant: Variant, index: usize, count: usize) -> Self {
164        Self::maybe_sharded(variant, Some(dataset::Shard { index, count }))
165    }
166
167    fn maybe_sharded(variant: Variant, shard: Option<dataset::Shard>) -> Self {
168        Self {
169            variant,
170            shard,
171            handle: None,
172            phase: Phase::Provision,
173            artifacts: None,
174            samples: Vec::new(),
175            cursor: 0,
176            responses: Vec::new(),
177            responses_path: None,
178            scores: None,
179            spec: variant.spec(),
180            max_new_tokens: MAX_NEW_TOKENS,
181            temperature: 0.0,
182            request_timeout: Duration::from_secs(600),
183            started: None,
184            tool_call_samples: 0,
185            transport_errors: 0,
186            known_sensitive_seen: 0,
187            target_model: None,
188            baseline_mins: report::BaselineMins::default(),
189        }
190    }
191
192    fn handle(&self) -> Result<&PluginHandle> {
193        self.handle.as_ref().context("benchmark was not loaded")
194    }
195
196    fn elapsed(&self) -> Duration {
197        self.started.map(|s| s.elapsed()).unwrap_or_default()
198    }
199}
200
201impl Plugin for Bfcl {
202    fn metadata(&self) -> &'static PluginMetadata {
203        self.variant.metadata()
204    }
205
206    fn load(&mut self, handle: PluginHandle) -> impl Future<Output = Result<()>> + Send {
207        self.started = Some(Instant::now());
208        self.target_model = Some(handle.target().model.clone());
209        self.handle = Some(handle.clone());
210        async move {
211            let artifacts = provision::ensure(handle.artifacts(), &handle).await?;
212            self.artifacts = Some(artifacts);
213            Ok(())
214        }
215    }
216}
217
218impl Benchmark for Bfcl {
219    fn descriptor(&self) -> &'static BenchmarkDescriptor {
220        self.variant.descriptor()
221    }
222
223    fn parameters(&self) -> Vec<ParamSpec> {
224        let v = self.variant;
225        let mut specs = vec![
226            ParamSpec::new(
227                "non_live_pct",
228                "non_live %",
229                "Percentage of each non_live subset to draw. 62 is the golden MLPerf draw.",
230                ParamKind::Float {
231                    min: 0.01,
232                    max: 100.0,
233                },
234                ParamValue::Float(v.default_pct("non_live")),
235            ),
236            ParamSpec::new(
237                "live_pct",
238                "live %",
239                "Percentage of each live subset to draw. 10 is the golden MLPerf draw.",
240                ParamKind::Float {
241                    min: 0.01,
242                    max: 100.0,
243                },
244                ParamValue::Float(v.default_pct("live")),
245            ),
246            ParamSpec::new(
247                "hallucination_pct",
248                "hallucination %",
249                "Percentage of each hallucination subset to draw. 10 is the golden MLPerf draw.",
250                ParamKind::Float {
251                    min: 0.01,
252                    max: 100.0,
253                },
254                ParamValue::Float(v.default_pct("hallucination")),
255            ),
256            ParamSpec::new(
257                "subset_floor",
258                "Subset floor",
259                "Subsets this small are taken whole, so tiny ones do not collapse to noise.",
260                ParamKind::Int {
261                    min: 0,
262                    max: 10_000,
263                },
264                ParamValue::Int(v.default_floor() as i64),
265            ),
266            ParamSpec::new(
267                "max_new_tokens",
268                "Max new tokens",
269                "Output budget per sample. The MLPerf config uses 1024.",
270                ParamKind::Int {
271                    min: 16,
272                    max: 32_768,
273                },
274                ParamValue::Int(MAX_NEW_TOKENS as i64),
275            ),
276            ParamSpec::new(
277                "temperature",
278                "Temperature",
279                "0 is greedy, which is what every recorded BFCL score was produced with.",
280                ParamKind::Float { min: 0.0, max: 2.0 },
281                ParamValue::Float(0.0),
282            ),
283            ParamSpec::new(
284                "request_timeout_s",
285                "Request timeout",
286                "Seconds before one sample is abandoned and scored as no tool call.",
287                ParamKind::Int { min: 10, max: 3600 },
288                ParamValue::Int(600),
289            ),
290            ParamSpec::new(
291                "shard",
292                "Shard",
293                "Run one Nth of the draw, as `index/count` with a 0-based index \
294                 (`2/7`; the whole draw is `0/1`). This is how a certification \
295                 splits the gate across boxes: `met bench certify` picks the count \
296                 from its fleet and runs every index; `gate::group` recombines the \
297                 records. `inherit` runs whatever the constructor selected โ€” the \
298                 whole draw for this id.",
299                ParamKind::Text,
300                // 2026-09-26: The default is `inherit`, not `0/1`, so a slice
301                // chosen by `Bfcl::sharded` survives `configure` under default
302                // parameters (tested by
303                // `a_shard_member_keeps_its_slice_under_default_parameters`). A
304                // word rather than an empty string because `ParamKind::Text`
305                // refuses an empty value, and `configure` validates every value.
306                ParamValue::Text(INHERIT_SHARD.to_string()),
307            ),
308        ];
309        specs.extend(report::BaselineMins::specs());
310        specs
311    }
312
313    fn configure(&mut self, values: &ParamValues) -> Result<()> {
314        let specs = self.parameters();
315        values.validate_against(&specs)?;
316        let floor = values.usize("subset_floor")?;
317        self.spec = DrawSpec {
318            categories: draw::CATEGORIES.iter().map(|c| c.to_string()).collect(),
319            category_pct: [
320                ("non_live".to_string(), values.float("non_live_pct")?),
321                ("live".to_string(), values.float("live_pct")?),
322                (
323                    "hallucination".to_string(),
324                    values.float("hallucination_pct")?,
325                ),
326            ]
327            .into_iter()
328            .collect(),
329            subset_floor: (floor > 0).then_some(floor),
330        };
331        // 2026-09-26: `inherit` leaves `self.shard` as it is.
332        let shard = values.text("shard")?;
333        if shard.trim() != INHERIT_SHARD {
334            self.shard = Some(dataset::Shard::parse(shard.trim()).map_err(anyhow::Error::msg)?);
335        }
336        self.max_new_tokens = values.usize("max_new_tokens")?;
337        self.temperature = values.float("temperature")?;
338        self.request_timeout = Duration::from_secs(values.usize("request_timeout_s")? as u64);
339        self.baseline_mins = report::BaselineMins::from_values(values)?;
340        self.phase = Phase::Provision;
341        self.cursor = 0;
342        self.responses.clear();
343        self.samples.clear();
344        self.scores = None;
345        self.tool_call_samples = 0;
346        self.known_sensitive_seen = 0;
347        Ok(())
348    }
349
350    async fn next(&mut self) -> Result<BenchmarkResult> {
351        let handle = self.handle()?.clone();
352        handle.check_cancelled()?;
353        match self.phase {
354            Phase::Provision => {
355                http::probe(handle.target(), Duration::from_secs(10))
356                    .await
357                    .context("endpoint probe failed โ€” check the target URL and port")?;
358                let artifacts = self
359                    .artifacts
360                    .clone()
361                    .context("artifacts were not provisioned")?;
362                self.samples = dataset::load_shard(&artifacts.dataset, &self.spec, self.shard)?;
363                self.phase = Phase::Generate;
364                let n = self.samples.len();
365                let mut frame = BenchmarkResult::running("draw", self.elapsed())
366                    .with_progress(0, n as u64)
367                    .log_line(LogLine::info(format!(
368                        "drew {n} samples across {} subsets",
369                        self.samples
370                            .iter()
371                            .map(|s| s.subset.as_str())
372                            .collect::<std::collections::BTreeSet<_>>()
373                            .len()
374                    )));
375                // 2026-09-26: Warn when the draw is not the pinned size for this
376                // variant or shard.
377                if let Some(want) = self.expected_samples()
378                    && n != want
379                {
380                    let of = match self.shard {
381                        None => String::new(),
382                        Some(sh) => format!(" (shard {} of {})", sh.index, sh.count),
383                    };
384                    frame = frame.log_line(LogLine::warn(format!(
385                        "n={n}, not the pinned {want}{of} โ€” this run is NOT comparable to \
386                         this draw's baseline"
387                    )));
388                }
389                Ok(frame)
390            }
391            Phase::Generate => {
392                let total = self.samples.len() as u64;
393                if self.cursor >= self.samples.len() {
394                    self.phase = Phase::Score;
395                    handle.status("scoring with bfcl-eval");
396                    return Ok(BenchmarkResult::running("scoring", self.elapsed())
397                        .with_progress(total, total)
398                        .with_summary(self.summary())
399                        .log_line(LogLine::info(format!(
400                            "generated {} responses; running the AST checker",
401                            self.responses.len()
402                        ))));
403                }
404                let subset = self.samples[self.cursor].subset.clone();
405                self.generate_one().await?;
406                let done = self.cursor as u64;
407                handle.progress(done, total);
408                handle.status(format!("{subset} ยท {done}/{total}"));
409                Ok(BenchmarkResult::running(subset, self.elapsed())
410                    .with_progress(done, total)
411                    .with_summary(self.summary()))
412            }
413            Phase::Score => {
414                let scores = self.score().await?;
415                if scores.unmatched_responses > 0 {
416                    handle.warn(format!(
417                        "{} response(s) did not match a dataset sample",
418                        scores.unmatched_responses
419                    ));
420                }
421                self.scores = Some(scores);
422                self.phase = Phase::Done;
423                let total = self.samples.len() as u64;
424                let mut frame = BenchmarkResult {
425                    status: RunStatus::Completed,
426                    ..BenchmarkResult::running("done", self.elapsed())
427                }
428                .with_progress(total, total)
429                .with_summary(self.summary())
430                .with_metrics(self.metrics())
431                .with_verdict(self.verdict());
432                if let Some(t) = self.table() {
433                    frame = frame.with_table(t);
434                }
435                // 2026-10-03: The draw this score is for: N, the category percentages and the
436                // hash of the ordered sample ids, with the shard when this run is one.
437                let mut fp = draw::fingerprint(
438                    &self.spec,
439                    self.samples.iter().map(|s| s.sample_id.as_str()),
440                );
441                if let Some(sh) = &self.shard {
442                    fp.push_str(&format!(";shard={}/{}", sh.index, sh.count));
443                }
444                frame.dataset_fingerprint = Some(fp);
445                Ok(frame)
446            }
447            Phase::Done => bail!("next() was called after the run finished"),
448        }
449    }
450}
451
452#[path = "variant.rs"]
453mod variant_impl;
454
455#[path = "exec.rs"]
456mod exec;
457
458#[cfg(test)]
459#[path = "bfcl_tests.rs"]
460mod tests;
461
462#[cfg(test)]
463#[path = "bfcl_shard_tests.rs"]
464mod shard_tests;
465
466#[cfg(test)]
467#[path = "aggregate_tests.rs"]
468mod aggregate_tests;