1pub 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
51const 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
61const UNPINNED: &str = "unpinned";
65
66pub(crate) struct TurnRecord {
69 conversation_id: String,
70 turn: i64,
71 domain: Domain,
72 score: Option<f64>,
77 missing: bool,
78 completion_tokens: usize,
79 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 schedule: Vec<(usize, usize)>,
98 cursor: usize,
99 turns: Vec<TurnRecord>,
100 scores: Option<report::Scores>,
101 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 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 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
224fn 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
247fn 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 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 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;