metrale_bench/benchmarks/mlperf_agentic/
mod.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: MLPerf Agentic Inference: teacher-forced replay of recorded
4//! trajectories with inline scoring. The prompt for each client turn is the
5//! recorded history plus the current client message ([`dataset`]), so the
6//! model's output is scored but never fed forward. Scoring ports the upstream
7//! inline scorer's two rules, workflow intent-code match and coding
8//! bash-executable multiset IoU ([`scoring`]).
9//!
10//! The dataset is not in the repository and nothing downloads it:
11//! `provision::ensure` fails when `dataset.jsonl` is absent, naming the path,
12//! and when it does not match a set `expected_sha256`. The BENCH.toml entry
13//! (`gate = "mlperf-agentic-subset"`) is `status = "unmeasured"` with no
14//! metric bounds, `gate::coverage::NOT_REQUIRED` lists the gate with its
15//! reason, and the verdict is always info (`report.rs`).
16//!
17//! Sampling is not a parameter: every request carries the fixed set below plus
18//! a seed. A temperature-0 run would give numbers that look comparable to the
19//! MLPerf thresholds and are not.
20//!
21//! Owner: bench, mlperf_agentic.
22//! Invariants: a completed run's verdict is info, and a run with no scorable
23//! turn fails instead of reporting 0.0.
24
25pub mod dataset;
26pub mod provision;
27pub mod scoring;
28
29mod descriptors;
30mod report;
31
32pub use descriptors::{SUBSET_DESCRIPTOR, SUBSET_METADATA};
33
34use std::future::Future;
35use std::time::{Duration, Instant};
36
37use anyhow::{Context, Result, bail};
38use serde_json::{Value, json};
39
40use crate::benchmark::{Benchmark, BenchmarkDescriptor};
41use crate::benchmarks::one_line;
42use crate::http;
43use crate::metadata::PluginMetadata;
44use crate::params::{ParamKind, ParamSpec, ParamValue, ParamValues};
45use crate::plugin::{Plugin, PluginHandle};
46use crate::result::{BenchmarkResult, LogLine, RunStatus};
47
48use dataset::{Conversation, DrawSpec};
49use scoring::Domain;
50
51/// 2026-09-26: The sampling set every request carries (`request_body`). The
52/// BENCH.toml entry's note records it as the MLPerf set, which submitters may
53/// not change.
54const TEMPERATURE: f64 = 1.0;
55const TOP_K: i64 = 20;
56const TOP_P: f64 = 0.95;
57const REPETITION_PENALTY: f64 = 1.0;
58const PRESENCE_PENALTY: f64 = 1.5;
59const MAX_NEW_TOKENS: i64 = 8192;
60
61/// 2026-09-26: The `expected_sha256` value that means "no pin". A named
62/// sentinel rather than an empty string, because `ParamKind::Text` refuses
63/// empty values.
64const UNPINNED: &str = "unpinned";
65
66/// 2026-09-26: One replayed client turn, kept for aggregation and the
67/// responses artifact.
68pub(crate) struct TurnRecord {
69    conversation_id: String,
70    turn: i64,
71    domain: Domain,
72    /// 2026-09-26: `None` when the turn has no scorable ground truth (excluded
73    /// from the denominator). A failed request is scored against an empty
74    /// assistant turn, which gives `Some(0.0)` for a scorable turn (in the
75    /// denominator).
76    score: Option<f64>,
77    missing: bool,
78    completion_tokens: usize,
79    /// 2026-09-26: The model turn as the scorer saw it, for rescoring.
80    model: Value,
81}
82
83enum Phase {
84    Provision,
85    Replay,
86    Score,
87    Done,
88}
89
90pub struct MlperfAgentic {
91    handle: Option<PluginHandle>,
92    phase: Phase,
93    artifacts: Option<provision::Artifacts>,
94    conversations: Vec<Conversation>,
95    /// 2026-09-26: Flattened (conversation, client-turn) replay order, one
96    /// request at a time.
97    schedule: Vec<(usize, usize)>,
98    cursor: usize,
99    turns: Vec<TurnRecord>,
100    scores: Option<report::Scores>,
101    /// 2026-09-26: `file-sha256:…;draw-sha256:…`, recorded on the terminal
102    /// frame and carried into the gate record.
103    fingerprint: Option<String>,
104    spec: DrawSpec,
105    seed: i64,
106    expected_sha256: String,
107    request_timeout: Duration,
108    started: Option<Instant>,
109    replay_started: Option<Instant>,
110    replay_wall: Option<Duration>,
111}
112
113impl Default for MlperfAgentic {
114    fn default() -> Self {
115        Self::new()
116    }
117}
118
119impl MlperfAgentic {
120    pub fn new() -> Self {
121        Self {
122            handle: None,
123            phase: Phase::Provision,
124            artifacts: None,
125            conversations: Vec::new(),
126            schedule: Vec::new(),
127            cursor: 0,
128            turns: Vec::new(),
129            scores: None,
130            fingerprint: None,
131            spec: DrawSpec::all(),
132            seed: 42,
133            expected_sha256: String::new(),
134            request_timeout: Duration::from_secs(600),
135            started: None,
136            replay_started: None,
137            replay_wall: None,
138        }
139    }
140
141    fn handle(&self) -> Result<&PluginHandle> {
142        self.handle.as_ref().context("benchmark was not loaded")
143    }
144
145    fn elapsed(&self) -> Duration {
146        self.started.map(|s| s.elapsed()).unwrap_or_default()
147    }
148
149    async fn replay_one(&mut self) -> Result<()> {
150        let handle = self.handle()?.clone();
151        let (conv_idx, turn_idx) = self.schedule[self.cursor];
152        let conv = &self.conversations[conv_idx];
153        let client_turn = &conv.client_turns[turn_idx];
154        let body = request_body(
155            &handle.target().model,
156            &client_turn.messages,
157            conv.tools.as_ref(),
158            self.seed,
159        );
160        let outcome = http::chat_stream(handle.target(), &body, self.request_timeout).await;
161        let (model, missing, completion_tokens) = match outcome {
162            Ok(o) => {
163                let tokens = o.completion_tokens;
164                (model_turn(&o), false, tokens)
165            }
166            Err(e) => {
167                // 2026-09-26: A failed request stays in the denominator at 0
168                // and is logged, so a run degraded by transport errors is
169                // visible.
170                handle.warn(one_line(format!(
171                    "{} turn {}: {e:#}",
172                    conv.id, client_turn.turn
173                )));
174                (json!({"role": "assistant"}), true, 0)
175            }
176        };
177        let (score, domain) = match &client_turn.ground_truth {
178            Some(gt) if scoring::has_ground_truth(conv.domain, gt) => (
179                Some(scoring::score_turn(conv.domain, gt, &model)),
180                conv.domain,
181            ),
182            _ => (None, conv.domain),
183        };
184        self.turns.push(TurnRecord {
185            conversation_id: conv.id.clone(),
186            turn: client_turn.turn,
187            domain,
188            score,
189            missing,
190            completion_tokens,
191            model,
192        });
193        self.cursor += 1;
194        Ok(())
195    }
196
197    /// 2026-09-26: Write the per-turn artifact beside the dataset so a scorer
198    /// change can re-score a finished run without re-generating it.
199    ///
200    /// The filename is fixed, which is safe only while this benchmark has no
201    /// group members; the test
202    /// `sharding_this_benchmark_would_need_a_per_leg_responses_file` checks
203    /// that. `bfcl::exec::responses_file` keys its file by benchmark id.
204    fn write_responses(&self) -> Result<()> {
205        let artifacts = self.artifacts.as_ref().context("not provisioned")?;
206        let path = artifacts.dir.join("responses.jsonl");
207        let mut text = String::new();
208        for t in &self.turns {
209            text.push_str(&serde_json::to_string(&json!({
210                "conversation_id": t.conversation_id,
211                "turn": t.turn,
212                "domain": match t.domain { Domain::Coding => "coding", Domain::Workflow => "workflow" },
213                "score": t.score,
214                "missing": t.missing,
215                "completion_tokens": t.completion_tokens,
216                "model": t.model,
217            }))?);
218            text.push('\n');
219        }
220        std::fs::write(&path, text).with_context(|| format!("writing {}", path.display()))
221    }
222}
223
224/// 2026-09-26: The request for one client turn: the fixed sampling set, the
225/// prebuilt teacher-forced messages, the conversation's tools if any, and the
226/// seed. Nothing else; a test pins the exact key set.
227fn request_body(model: &str, messages: &[Value], tools: Option<&Value>, seed: i64) -> Value {
228    let mut body = json!({
229        "model": model,
230        "stream": true,
231        "messages": messages,
232        "temperature": TEMPERATURE,
233        "top_k": TOP_K,
234        "top_p": TOP_P,
235        "repetition_penalty": REPETITION_PENALTY,
236        "presence_penalty": PRESENCE_PENALTY,
237        "max_tokens": MAX_NEW_TOKENS,
238        "seed": seed,
239        "chat_template_kwargs": {"preserve_thinking": true},
240    });
241    if let Some(tools) = tools {
242        body["tools"] = tools.clone();
243    }
244    body
245}
246
247/// 2026-09-26: The assistant turn as the scorer reads it: `tool_calls` is
248/// `null` (not `[]`) when the model made none, and arguments stay the raw
249/// streamed string.
250fn model_turn(outcome: &http::ChatOutcome) -> Value {
251    let tool_calls: Vec<Value> = outcome
252        .tool_calls
253        .iter()
254        .map(|c| json!({"function": {"name": c.name, "arguments": c.arguments}}))
255        .collect();
256    json!({
257        "role": "assistant",
258        "content": outcome.text,
259        "reasoning_content": outcome.reasoning,
260        "tool_calls": if tool_calls.is_empty() { Value::Null } else { Value::Array(tool_calls) },
261    })
262}
263
264impl Plugin for MlperfAgentic {
265    fn metadata(&self) -> &'static PluginMetadata {
266        &SUBSET_METADATA
267    }
268
269    fn load(&mut self, handle: PluginHandle) -> impl Future<Output = Result<()>> + Send {
270        self.started = Some(Instant::now());
271        self.handle = Some(handle.clone());
272        async move {
273            // 2026-09-26: An absent or mismatched dataset stops the run here
274            // (`provision::ensure`).
275            let artifacts = provision::ensure(handle.artifacts(), &handle, &self.expected_sha256)?;
276            self.artifacts = Some(artifacts);
277            Ok(())
278        }
279    }
280}
281
282impl Benchmark for MlperfAgentic {
283    fn descriptor(&self) -> &'static BenchmarkDescriptor {
284        &SUBSET_DESCRIPTOR
285    }
286
287    fn parameters(&self) -> Vec<ParamSpec> {
288        vec![
289            ParamSpec::new(
290                "coding_trajectories",
291                "Coding trajectories",
292                "First-K coding conversations in file order; 0 takes all. The pinned draw is \
293                 a calibration output — until one exists, there is nothing to default to.",
294                ParamKind::Int {
295                    min: 0,
296                    max: 100_000,
297                },
298                ParamValue::Int(0),
299            ),
300            ParamSpec::new(
301                "workflow_trajectories",
302                "Workflow trajectories",
303                "First-K workflow (sim_*) conversations in file order; 0 takes all.",
304                ParamKind::Int {
305                    min: 0,
306                    max: 100_000,
307                },
308                ParamValue::Int(0),
309            ),
310            ParamSpec::new(
311                "seed",
312                "Seed",
313                "Pinned sampling seed. MLPerf specifies none (temp-1.0 is nondeterministic \
314                 by design); a seed narrows internal run-to-run variance without touching \
315                 the immutable sampling set.",
316                ParamKind::Int {
317                    min: 0,
318                    max: i64::MAX,
319                },
320                ParamValue::Int(42),
321            ),
322            ParamSpec::new(
323                "expected_sha256",
324                "Dataset SHA256 pin",
325                "Refuse to run unless the dataset hashes to this. The literal \"unpinned\" \
326                 means record-only — the only honest default while the official file is \
327                 unpublished; pin it the day the file ships.",
328                ParamKind::Text,
329                ParamValue::Text(String::from(UNPINNED)),
330            ),
331            ParamSpec::new(
332                "request_timeout_s",
333                "Request timeout",
334                "Seconds before one turn is abandoned and scored 0. The official client \
335                 allows 14400 per turn; a merge gate wants a bound that fails fast.",
336                ParamKind::Int {
337                    min: 10,
338                    max: 14_400,
339                },
340                ParamValue::Int(600),
341            ),
342        ]
343    }
344
345    fn configure(&mut self, values: &ParamValues) -> Result<()> {
346        let specs = self.parameters();
347        values.validate_against(&specs)?;
348        self.spec = DrawSpec {
349            coding: values.usize("coding_trajectories")?,
350            workflow: values.usize("workflow_trajectories")?,
351        };
352        self.seed = values.int("seed")?;
353        let pin = values.text("expected_sha256")?.trim();
354        self.expected_sha256 = if pin.eq_ignore_ascii_case(UNPINNED) {
355            String::new()
356        } else {
357            pin.to_string()
358        };
359        self.request_timeout = Duration::from_secs(values.usize("request_timeout_s")? as u64);
360        self.phase = Phase::Provision;
361        self.cursor = 0;
362        self.conversations.clear();
363        self.schedule.clear();
364        self.turns.clear();
365        self.scores = None;
366        self.fingerprint = None;
367        self.replay_started = None;
368        self.replay_wall = None;
369        Ok(())
370    }
371
372    async fn next(&mut self) -> Result<BenchmarkResult> {
373        let handle = self.handle()?.clone();
374        handle.check_cancelled()?;
375        match self.phase {
376            Phase::Provision => {
377                http::probe(handle.target(), Duration::from_secs(10))
378                    .await
379                    .context("endpoint probe failed — check the target URL and port")?;
380                let artifacts = self.artifacts.clone().context("not provisioned")?;
381                self.conversations = dataset::load(&artifacts.dataset, &self.spec)?;
382                self.schedule = self
383                    .conversations
384                    .iter()
385                    .enumerate()
386                    .flat_map(|(c, conv)| (0..conv.client_turns.len()).map(move |t| (c, t)))
387                    .collect();
388                let draw = dataset::draw_fingerprint(&self.conversations);
389                self.fingerprint = Some(format!(
390                    "file-sha256:{};draw-sha256:{draw}",
391                    artifacts.file_sha256
392                ));
393                self.phase = Phase::Replay;
394                self.replay_started = Some(Instant::now());
395                Ok(BenchmarkResult::running("draw", self.elapsed())
396                    .with_progress(0, self.schedule.len() as u64)
397                    .log_line(LogLine::info(format!(
398                        "drew {} trajectories ({} client turns) · draw fingerprint {}",
399                        self.conversations.len(),
400                        self.schedule.len(),
401                        &draw[..12],
402                    ))))
403            }
404            Phase::Replay => {
405                let total = self.schedule.len() as u64;
406                if self.cursor >= self.schedule.len() {
407                    self.replay_wall = self.replay_started.map(|s| s.elapsed());
408                    self.phase = Phase::Score;
409                    handle.status("scoring");
410                    return Ok(BenchmarkResult::running("scoring", self.elapsed())
411                        .with_progress(total, total)
412                        .with_summary(self.summary()));
413                }
414                let (conv_idx, _) = self.schedule[self.cursor];
415                let conv_id = self.conversations[conv_idx].id.clone();
416                self.replay_one().await?;
417                let done = self.cursor as u64;
418                handle.progress(done, total);
419                handle.status(format!("{conv_id} · {done}/{total}"));
420                Ok(BenchmarkResult::running(conv_id, self.elapsed())
421                    .with_progress(done, total)
422                    .with_summary(self.summary()))
423            }
424            Phase::Score => {
425                let Some(scores) = report::aggregate(&self.turns) else {
426                    // 2026-09-26: Refusing, not reporting 0.0: a denominator
427                    // of zero means the draw held no scorable ground truth.
428                    bail!(
429                        "no turn in the draw carried scorable ground truth — refusing to \
430                         report a score from an empty denominator"
431                    );
432                };
433                self.write_responses()?;
434                self.scores = Some(scores);
435                self.phase = Phase::Done;
436                let total = self.schedule.len() as u64;
437                let mut frame = BenchmarkResult {
438                    status: RunStatus::Completed,
439                    ..BenchmarkResult::running("done", self.elapsed())
440                }
441                .with_progress(total, total)
442                .with_summary(self.summary())
443                .with_metrics(self.metrics())
444                .with_verdict(self.verdict());
445                frame.dataset_fingerprint = self.fingerprint.clone();
446                if let Some(t) = self.table() {
447                    frame = frame.with_table(t);
448                }
449                Ok(frame)
450            }
451            Phase::Done => bail!("next() was called after the run finished"),
452        }
453    }
454}
455
456#[cfg(test)]
457#[path = "mlperf_tests.rs"]
458mod tests;