metrale_bench/benchmarks/mlperf_agentic/
dataset.rs1use std::collections::BTreeSet;
19use std::path::Path;
20
21use anyhow::{Context, Result, bail};
22use serde_json::{Map, Value, json};
23use sha2::{Digest, Sha256};
24
25use super::scoring::{Domain, domain_of};
26
27#[derive(Clone, Debug)]
30pub struct ClientTurn {
31 pub turn: i64,
33 pub messages: Vec<Value>,
36 pub ground_truth: Option<Value>,
40}
41
42#[derive(Clone, Debug)]
43pub struct Conversation {
44 pub id: String,
45 pub domain: Domain,
46 pub tools: Option<Value>,
49 pub client_turns: Vec<ClientTurn>,
50}
51
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub struct DrawSpec {
57 pub coding: usize,
58 pub workflow: usize,
59}
60
61impl DrawSpec {
62 pub fn all() -> Self {
63 Self {
64 coding: 0,
65 workflow: 0,
66 }
67 }
68}
69
70pub fn load(path: &Path, spec: &DrawSpec) -> Result<Vec<Conversation>> {
72 let text =
73 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
74 let conversations = parse(&text)?;
75 let mut taken = Vec::new();
76 let (mut coding_taken, mut workflow_taken) = (0usize, 0usize);
77 for conv in conversations {
78 let (count, limit) = match conv.domain {
79 Domain::Coding => (&mut coding_taken, spec.coding),
80 Domain::Workflow => (&mut workflow_taken, spec.workflow),
81 };
82 if limit == 0 || *count < limit {
83 *count += 1;
84 taken.push(conv);
85 }
86 }
87 if taken.is_empty() {
88 bail!("the draw selected no trajectories");
89 }
90 Ok(taken)
91}
92
93pub fn draw_fingerprint(conversations: &[Conversation]) -> String {
99 let mut digest = Sha256::new();
100 let update = |digest: &mut Sha256, bytes: &[u8]| {
101 digest.update((bytes.len() as u64).to_le_bytes());
102 digest.update(bytes);
103 };
104 for conv in conversations {
105 digest.update(b"conversation\0");
106 update(&mut digest, conv.id.as_bytes());
107 update(
108 &mut digest,
109 &serde_json::to_vec(&conv.tools).expect("JSON values serialize"),
110 );
111 for turn in &conv.client_turns {
112 digest.update(b"turn\0");
113 digest.update(turn.turn.to_le_bytes());
114 update(
115 &mut digest,
116 &serde_json::to_vec(&turn.messages).expect("JSON values serialize"),
117 );
118 update(
119 &mut digest,
120 &serde_json::to_vec(&turn.ground_truth).expect("JSON values serialize"),
121 );
122 }
123 }
124 metrale_closure::hex_lower(&digest.finalize())
125}
126
127fn parse(text: &str) -> Result<Vec<Conversation>> {
128 let mut order: Vec<String> = Vec::new();
132 let mut groups: std::collections::BTreeMap<String, Vec<Map<String, Value>>> =
133 Default::default();
134 let mut seen_closed: BTreeSet<String> = BTreeSet::new();
135 let mut last_id: Option<String> = None;
136
137 for (i, line) in text.lines().enumerate() {
138 if line.trim().is_empty() {
139 continue;
140 }
141 let row: Map<String, Value> =
142 serde_json::from_str(line).with_context(|| format!("line {}: malformed row", i + 1))?;
143 let id = match row.get("conversation_id") {
144 Some(Value::String(s)) if !s.is_empty() => s.clone(),
145 other => bail!(
146 "line {}: conversation_id must be a non-empty string, got {}",
147 i + 1,
148 other
149 .map(Value::to_string)
150 .unwrap_or_else(|| "nothing".into())
151 ),
152 };
153 if id.bytes().any(|b| !(32..=126).contains(&b)) {
154 bail!(
155 "line {}: conversation_id {id:?} has non-printable or non-ASCII characters",
156 i + 1
157 );
158 }
159 if last_id.as_ref() != Some(&id) {
160 if seen_closed.contains(&id) {
161 bail!(
162 "rows for conversation {id:?} are not consecutive — the loader takes the \
163 first K per domain in file order, so interleaving would silently change \
164 which trajectories a draw selects"
165 );
166 }
167 if let Some(prev) = last_id.replace(id.clone()) {
168 seen_closed.insert(prev);
169 }
170 order.push(id.clone());
171 }
172 groups.entry(id).or_default().push(row);
173 }
174
175 let mut out = Vec::new();
176 for id in order {
177 let rows = groups.remove(&id).expect("grouped above");
178 out.push(build_conversation(id, rows)?);
179 }
180 if out.is_empty() {
181 bail!("the trajectory file holds no rows");
182 }
183 Ok(out)
184}
185
186fn turn_of(row: &Map<String, Value>) -> Result<i64> {
187 row.get("turn")
188 .and_then(Value::as_i64)
189 .context("row is missing an integer 'turn'")
190}
191
192fn build_conversation(id: String, rows: Vec<Map<String, Value>>) -> Result<Conversation> {
193 for row in &rows {
194 turn_of(row).with_context(|| format!("conversation {id:?}"))?;
195 }
196 let turns: Vec<i64> = rows.iter().map(|r| turn_of(r).unwrap()).collect();
197 if turns.iter().enumerate().any(|(i, &t)| t != i as i64 + 1) {
198 bail!(
199 "conversation {id:?}: turn numbers must be exactly 1..={} in file order, got \
200 {turns:?}",
201 rows.len()
202 );
203 }
204 validate_roles(&id, &rows)?;
205
206 let system = rows
207 .iter()
208 .find_map(|r| {
209 r.get("system")
210 .and_then(Value::as_str)
211 .filter(|s| !s.is_empty())
212 })
213 .map(str::to_string);
214 let tools = rows
215 .iter()
216 .find(|r| r.get("role").and_then(Value::as_str) == Some("user"))
217 .and_then(|r| r.get("tools"))
218 .filter(|t| !t.is_null())
219 .cloned();
220
221 let mut history: Vec<Value> = Vec::new();
225 if let Some(system) = &system {
226 history.push(json!({"role": "system", "content": system}));
227 }
228 let mut client_turns: Vec<ClientTurn> = Vec::new();
229 for (i, row) in rows.iter().enumerate() {
230 let role = row.get("role").and_then(Value::as_str).unwrap_or_default();
231 let row_msgs = format_row(&id, row)?;
232 if role == "user" || role == "tool" {
233 let mut messages = history.clone();
234 messages.extend(row_msgs.iter().cloned());
235 let ground_truth = rows.get(i + 1).and_then(|next| {
238 (next.get("role").and_then(Value::as_str) == Some("assistant"))
239 .then(|| Value::Object(next.clone()))
240 });
241 client_turns.push(ClientTurn {
242 turn: turn_of(row).unwrap(),
243 messages,
244 ground_truth,
245 });
246 }
247 history.extend(row_msgs);
248 }
249
250 Ok(Conversation {
251 domain: domain_of(&id),
252 id,
253 tools,
254 client_turns,
255 })
256}
257
258fn format_row(id: &str, row: &Map<String, Value>) -> Result<Vec<Value>> {
263 if let Some(results) = row.get("tool_results").and_then(Value::as_array)
264 && !results.is_empty()
265 {
266 let mut msgs = Vec::new();
267 for (i, result) in results.iter().enumerate() {
268 let (Some(tool_call_id), Some(content)) =
269 (result.get("tool_call_id"), result.get("content"))
270 else {
271 bail!(
272 "conversation {id:?} turn {}: tool_results[{i}] needs tool_call_id and content",
273 turn_of(row).unwrap_or_default()
274 );
275 };
276 msgs.push(json!({
277 "role": "tool",
278 "tool_call_id": tool_call_id,
279 "content": content,
280 }));
281 }
282 return Ok(msgs);
283 }
284 let mut msg = Map::new();
285 for key in [
286 "role",
287 "content",
288 "name",
289 "tool_calls",
290 "tool_results",
291 "reasoning_content",
292 ] {
293 if let Some(v) = row.get(key).filter(|v| !v.is_null()) {
294 msg.insert(key.to_string(), v.clone());
295 }
296 }
297 if msg.get("role").and_then(Value::as_str) == Some("assistant")
298 && msg.contains_key("tool_calls")
299 && !msg.contains_key("content")
300 {
301 msg.insert("content".into(), Value::Null);
302 }
303 Ok(if msg.contains_key("role") {
304 vec![Value::Object(msg)]
305 } else {
306 Vec::new()
307 })
308}
309
310fn validate_roles(id: &str, rows: &[Map<String, Value>]) -> Result<()> {
316 let mut state = "start";
317 let mut prev_assistant_had_tool_calls = false;
318 for row in rows {
319 let role = row
320 .get("role")
321 .and_then(Value::as_str)
322 .with_context(|| format!("conversation {id:?}: row without a role"))?;
323 let valid: &[&str] = match state {
324 "start" => &["user"],
325 "user" => &["assistant"],
326 "assistant" => &["tool", "user"],
327 "tool" => &["assistant", "user"],
328 _ => unreachable!(),
329 };
330 if !valid.contains(&role) {
331 bail!(
332 "conversation {id:?} turn {}: got role {role:?} after {state:?}",
333 turn_of(row).unwrap_or_default()
334 );
335 }
336 if role == "tool" {
337 if state == "assistant" && !prev_assistant_had_tool_calls {
338 bail!(
339 "conversation {id:?} turn {}: a tool row must follow an assistant row \
340 with non-empty tool_calls",
341 turn_of(row).unwrap_or_default()
342 );
343 }
344 if row
345 .get("tool_results")
346 .and_then(Value::as_array)
347 .is_none_or(|r| r.is_empty())
348 {
349 bail!(
350 "conversation {id:?} turn {}: tool rows need a non-empty tool_results list",
351 turn_of(row).unwrap_or_default()
352 );
353 }
354 }
355 if role == "assistant" {
356 prev_assistant_had_tool_calls = row
357 .get("tool_calls")
358 .and_then(Value::as_array)
359 .is_some_and(|c| !c.is_empty());
360 }
361 state = match role {
362 "user" => "user",
363 "assistant" => "assistant",
364 _ => "tool",
365 };
366 }
367 Ok(())
368}
369
370#[cfg(test)]
371#[path = "dataset_tests.rs"]
372mod tests;