1use crate::budget::budget_skipped_output;
20use crate::error::MultiError;
21use crate::mailbox::Mailbox;
22use crate::runner::AgentRunner;
23use crate::shared::SharedInfra;
24use crate::types::{AgentOutput, AgentSpec};
25use serde::{Deserialize, Serialize};
26use std::sync::Arc;
27use tracing::instrument;
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct MatchResult {
32 pub round: u32,
34 pub a: String,
36 pub b: String,
38 pub winner: String,
40 pub rationale: String,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct TournamentResult {
47 pub task: String,
48 pub candidates: Vec<AgentOutput>,
50 pub matches: Vec<MatchResult>,
52 pub winner_name: String,
54 pub winner_answer: String,
56 pub ranking: Vec<String>,
59}
60
61pub struct Tournament {
63 pub competitors: Vec<AgentSpec>,
65 pub judge: AgentSpec,
68}
69
70impl Tournament {
71 pub fn new(competitors: Vec<AgentSpec>, judge: AgentSpec) -> Self {
72 Self { competitors, judge }
73 }
74
75 #[instrument(name = "multi.tournament", skip_all)]
76 pub async fn run(
77 &self,
78 task: &str,
79 runner: &Arc<dyn AgentRunner>,
80 infra: &SharedInfra,
81 ) -> Result<TournamentResult, MultiError> {
82 let candidates = self.gather_candidates(task, runner, infra).await;
84
85 let mut alive: Vec<(String, String)> = candidates
87 .iter()
88 .filter(|o| o.succeeded())
89 .map(|o| (o.name.clone(), o.answer.clone()))
90 .collect();
91
92 let mut matches = Vec::new();
93 let mut elimination_order: Vec<String> = Vec::new();
96
97 if alive.is_empty() {
98 return Ok(TournamentResult {
99 task: task.to_string(),
100 candidates,
101 matches,
102 winner_name: String::new(),
103 winner_answer: String::new(),
104 ranking: Vec::new(),
105 });
106 }
107
108 let mut round = 1;
109 while alive.len() > 1 {
110 let mut next: Vec<(String, String)> = Vec::new();
111 let mut i = 0;
112 while i + 1 < alive.len() {
113 let (name_a, ans_a) = alive[i].clone();
114 let (name_b, ans_b) = alive[i + 1].clone();
115
116 let (winner_side, rationale) = self
117 .judge_pair(task, &name_a, &ans_a, &name_b, &ans_b, runner, infra)
118 .await;
119
120 let (winner_name, winner_ans, loser_name) = match winner_side {
121 Side::A => (name_a.clone(), ans_a.clone(), name_b.clone()),
122 Side::B => (name_b.clone(), ans_b.clone(), name_a.clone()),
123 };
124 matches.push(MatchResult {
125 round,
126 a: name_a,
127 b: name_b,
128 winner: winner_name.clone(),
129 rationale,
130 });
131 elimination_order.push(loser_name);
132 next.push((winner_name, winner_ans));
133 i += 2;
134 }
135 if i < alive.len() {
137 next.push(alive[i].clone());
138 }
139 alive = next;
140 round += 1;
141 }
142
143 let (winner_name, winner_answer) = alive
144 .into_iter()
145 .next()
146 .unwrap_or_else(|| (String::new(), String::new()));
147
148 let mut ranking = vec![winner_name.clone()];
150 ranking.extend(elimination_order.into_iter().rev());
151
152 Ok(TournamentResult {
153 task: task.to_string(),
154 candidates,
155 matches,
156 winner_name,
157 winner_answer,
158 ranking,
159 })
160 }
161
162 async fn gather_candidates(
164 &self,
165 task: &str,
166 runner: &Arc<dyn AgentRunner>,
167 infra: &SharedInfra,
168 ) -> Vec<AgentOutput> {
169 let mailbox = Arc::new(Mailbox::default());
170 enum Slot {
171 Spawned(usize),
172 Skipped(AgentOutput),
173 }
174 let mut handles: Vec<tokio::task::JoinHandle<Result<AgentOutput, MultiError>>> = Vec::new();
175 let mut slots: Vec<Slot> = Vec::new();
176
177 for spec in &self.competitors {
178 if let Err(e) = infra.begin_agent() {
179 slots.push(Slot::Skipped(budget_skipped_output(&spec.name, &e)));
180 continue;
181 }
182 let runner = Arc::clone(runner);
183 let spec = spec.clone();
184 let task = task.to_string();
185 let mailbox = Arc::clone(&mailbox);
186 let rt = infra.make_runtime();
189 for tool in &spec.tools {
190 rt.register_tool(tool).await;
191 }
192 handles.push(tokio::spawn(async move {
193 runner.run(&spec, &task, &rt, &mailbox).await
194 }));
195 slots.push(Slot::Spawned(handles.len() - 1));
196 }
197
198 let mut results: Vec<Option<_>> = futures::future::join_all(handles)
199 .await
200 .into_iter()
201 .map(Some)
202 .collect();
203 let mut outputs = Vec::new();
204 for (i, slot) in slots.into_iter().enumerate() {
205 let idx = match slot {
206 Slot::Skipped(out) => {
207 outputs.push(out);
208 continue;
209 }
210 Slot::Spawned(idx) => idx,
211 };
212 match results.get_mut(idx).and_then(Option::take) {
213 Some(Ok(Ok(out))) => {
214 infra.record_output(&out);
215 outputs.push(out);
216 }
217 Some(Ok(Err(e))) => {
218 outputs.push(failed_output(&self.competitors[i].name, e.to_string()))
219 }
220 Some(Err(e)) => outputs.push(failed_output(
221 &self.competitors[i].name,
222 format!("join error: {e}"),
223 )),
224 None => outputs.push(failed_output(
225 &self.competitors[i].name,
226 "internal: missing join result".into(),
227 )),
228 }
229 }
230 outputs
231 }
232
233 async fn judge_pair(
236 &self,
237 task: &str,
238 name_a: &str,
239 ans_a: &str,
240 name_b: &str,
241 ans_b: &str,
242 runner: &Arc<dyn AgentRunner>,
243 infra: &SharedInfra,
244 ) -> (Side, String) {
245 if infra.begin_agent().is_err() {
246 return (
247 Side::A,
248 "budget exhausted: defaulted to first candidate".into(),
249 );
250 }
251
252 let judge_task = format!(
253 r#"You are judging a head-to-head comparison for this task:
254
255## Task
256{task}
257
258## Candidate A
259{ans_a}
260
261## Candidate B
262{ans_b}
263
264Decide which candidate better accomplishes the task. Be decisive.
265Respond with a JSON object:
266```json
267{{"winner": "A", "rationale": "one sentence why"}}
268```
269`winner` must be exactly "A" or "B"."#,
270 );
271
272 let mut judge_spec = self.judge.clone();
273 judge_spec.name = format!("{}_{}_vs_{}", self.judge.name, name_a, name_b);
275
276 let mailbox = Mailbox::default();
277 let rt = infra.make_runtime();
278 match runner.run(&judge_spec, &judge_task, &rt, &mailbox).await {
279 Ok(out) => {
280 infra.record_output(&out);
281 parse_verdict(&out.answer)
282 }
283 Err(e) => (Side::A, format!("judge failed, defaulted to A: {e}")),
284 }
285 }
286}
287
288#[derive(Clone, Copy)]
289enum Side {
290 A,
291 B,
292}
293
294fn failed_output(name: &str, error: String) -> AgentOutput {
295 AgentOutput {
296 name: name.to_string(),
297 answer: String::new(),
298 turns: 0,
299 tool_calls: 0,
300 duration_ms: 0.0,
301 error: Some(error),
302 outcome: None,
303 tokens: None,
304 tools_used: Vec::new(),
305 }
306}
307
308fn parse_verdict(answer: &str) -> (Side, String) {
316 if let Some(json) = car_ir::json_extract::extract_json_object(answer) {
317 if let Ok(v) = serde_json::from_str::<serde_json::Value>(&json) {
318 let rationale = v
319 .get("rationale")
320 .and_then(|r| r.as_str())
321 .unwrap_or("")
322 .to_string();
323 if let Some(w) = v.get("winner").and_then(|w| w.as_str()) {
324 let side = if w.trim().eq_ignore_ascii_case("b") {
325 Side::B
326 } else {
327 Side::A
328 };
329 return (side, rationale);
330 }
331 }
332 }
333
334 const FALLBACK_RATIONALE: &str = "verdict parsed heuristically (no JSON winner field)";
336 let upper = answer.to_uppercase();
337
338 let phrase_a = ["CANDIDATE A", "WINNER: A", "WINNER A", "\"A\"", "ANSWER A"]
340 .iter()
341 .filter_map(|p| upper.find(p))
342 .min();
343 let phrase_b = ["CANDIDATE B", "WINNER: B", "WINNER B", "\"B\"", "ANSWER B"]
344 .iter()
345 .filter_map(|p| upper.find(p))
346 .min();
347 match (phrase_a, phrase_b) {
348 (Some(a), Some(b)) => {
349 return (
350 if b < a { Side::B } else { Side::A },
351 FALLBACK_RATIONALE.into(),
352 )
353 }
354 (Some(_), None) => return (Side::A, FALLBACK_RATIONALE.into()),
355 (None, Some(_)) => return (Side::B, FALLBACK_RATIONALE.into()),
356 (None, None) => {}
357 }
358
359 if let Some(side) = first_standalone_ab(&upper) {
361 return (side, FALLBACK_RATIONALE.into());
362 }
363
364 (Side::A, FALLBACK_RATIONALE.into())
366}
367
368fn first_standalone_ab(upper: &str) -> Option<Side> {
371 let bytes = upper.as_bytes();
372 for (i, &c) in bytes.iter().enumerate() {
373 if c == b'A' || c == b'B' {
374 let prev_alnum = i > 0 && bytes[i - 1].is_ascii_alphanumeric();
375 let next_alnum = i + 1 < bytes.len() && bytes[i + 1].is_ascii_alphanumeric();
376 if !prev_alnum && !next_alnum {
377 return Some(if c == b'B' { Side::B } else { Side::A });
378 }
379 }
380 }
381 None
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387 use car_engine::Runtime;
388
389 struct ScriptedRunner;
392
393 #[async_trait::async_trait]
394 impl AgentRunner for ScriptedRunner {
395 async fn run(
396 &self,
397 spec: &AgentSpec,
398 task: &str,
399 _runtime: &Runtime,
400 _mailbox: &Mailbox,
401 ) -> Result<AgentOutput, MultiError> {
402 let answer = if spec.name.contains("_vs_") {
405 let a = extract_block(task, "## Candidate A");
406 let b = extract_block(task, "## Candidate B");
407 let winner = if b > a { "B" } else { "A" };
408 format!("{{\"winner\": \"{winner}\", \"rationale\": \"later sorts higher\"}}")
409 } else {
410 spec.name.clone()
411 };
412 Ok(AgentOutput {
413 name: spec.name.clone(),
414 answer,
415 turns: 1,
416 tool_calls: 0,
417 duration_ms: 1.0,
418 error: None,
419 outcome: None,
420 tokens: None,
421 tools_used: Vec::new(),
422 })
423 }
424 }
425
426 fn extract_block<'a>(task: &'a str, header: &str) -> &'a str {
427 task.split(header)
428 .nth(1)
429 .map(|s| s.split("##").next().unwrap_or("").trim())
430 .unwrap_or("")
431 }
432
433 #[tokio::test]
434 async fn single_elimination_picks_alphabetical_max() {
435 let competitors = vec![
436 AgentSpec::new("alpha", ""),
437 AgentSpec::new("bravo", ""),
438 AgentSpec::new("charlie", ""),
439 AgentSpec::new("delta", ""),
440 ];
441 let judge = AgentSpec::new("judge", "pick the better answer");
442 let runner: Arc<dyn AgentRunner> = Arc::new(ScriptedRunner);
443 let infra = SharedInfra::new();
444
445 let r = Tournament::new(competitors, judge)
446 .run("rank these", &runner, &infra)
447 .await
448 .unwrap();
449
450 assert_eq!(r.winner_name, "delta");
453 assert_eq!(r.winner_answer, "delta");
454 assert_eq!(r.matches.len(), 3);
456 assert_eq!(r.ranking.first().unwrap(), "delta");
457 assert_eq!(r.ranking.len(), 4);
458 }
459
460 #[tokio::test]
461 async fn odd_competitor_gets_a_bye() {
462 let competitors = vec![
463 AgentSpec::new("alpha", ""),
464 AgentSpec::new("bravo", ""),
465 AgentSpec::new("charlie", ""),
466 ];
467 let judge = AgentSpec::new("judge", "");
468 let runner: Arc<dyn AgentRunner> = Arc::new(ScriptedRunner);
469 let infra = SharedInfra::new();
470
471 let r = Tournament::new(competitors, judge)
472 .run("rank", &runner, &infra)
473 .await
474 .unwrap();
475
476 assert_eq!(r.matches.len(), 2);
479 assert_eq!(r.winner_name, "charlie"); }
481
482 #[test]
483 fn parse_verdict_handles_prose_not_just_json() {
484 let (s, _) = parse_verdict("Candidate B is clearly better, it covers more cases.");
486 assert!(matches!(s, Side::B), "phrase 'Candidate B' should win");
487 let (s, _) = parse_verdict("After careful analysis, B.");
488 assert!(matches!(s, Side::B), "standalone trailing 'B' should win");
489 let (s, _) = parse_verdict("Answer A is the stronger submission.");
490 assert!(matches!(s, Side::A));
491 let (s, r) = parse_verdict("I think... {\"winner\": \"B\", \"rationale\": \"x\"}");
493 assert!(matches!(s, Side::B));
494 assert_eq!(r, "x");
495 }
496
497 #[test]
498 fn parse_verdict_does_not_leak_full_answer_on_fallback() {
499 let huge = format!("Candidate B wins. {}", "blah ".repeat(500));
500 let (_, rationale) = parse_verdict(&huge);
501 assert!(
502 rationale.len() < 100,
503 "fallback rationale must not echo the whole answer"
504 );
505 }
506
507 #[tokio::test]
508 async fn budget_cap_limits_competitors() {
509 let competitors: Vec<AgentSpec> = (0..4)
510 .map(|i| AgentSpec::new(&format!("c{i}"), ""))
511 .collect();
512 let judge = AgentSpec::new("judge", "");
513 let runner: Arc<dyn AgentRunner> = Arc::new(ScriptedRunner);
514 let infra = SharedInfra::new().with_budget(crate::BudgetLimits {
516 max_agents: Some(2),
517 ..Default::default()
518 });
519
520 let r = Tournament::new(competitors, judge)
521 .run("rank", &runner, &infra)
522 .await
523 .unwrap();
524
525 let produced = r.candidates.iter().filter(|o| o.succeeded()).count();
526 assert_eq!(produced, 2, "only two competitors fit the agent budget");
527 }
528}