1use crate::bench::adapters::{select, EngineAdapter};
10use crate::bench::contracts::action_index;
11use crate::bench::metrics::wilson_ci;
12use crate::game::{current_player, has_winning_line};
13use crate::moves::{apply_move, generate_legal_moves};
14use crate::state::State;
15use rayon::prelude::*;
16use serde_json::{json, Value};
17use std::collections::{BTreeMap, HashSet};
18
19pub fn play_game(
22 mover: &dyn EngineAdapter,
23 responder: &dyn EngineAdapter,
24 bb: &crate::bitboard::Bitboard,
25 seed: u64,
26) -> Result<(String, u32, Vec<u8>), String> {
27 let mut bb = *bb;
28 let mut turn = current_player(&bb).ok_or("inconsistent position")?;
29 let start_turn = turn;
30 let engine_for = |player: u8| -> &dyn EngineAdapter {
31 if player == start_turn {
32 mover
33 } else {
34 responder
35 }
36 };
37 let mut plies = 0u32;
38 let mut move_action_indices = Vec::new();
39
40 loop {
41 if has_winning_line(&bb) || generate_legal_moves(&bb).is_empty() {
42 return Ok((
45 engine_for(1 - turn).name().to_string(),
46 plies,
47 move_action_indices,
48 ));
49 }
50 let (mv, _) = select(engine_for(turn), &bb, "h2h", Some(seed))?;
51 move_action_indices.push(action_index(mv.shape, mv.position));
52 bb = apply_move(&bb, &mv);
53 turn ^= 1;
54 plies += 1;
55 }
56}
57
58pub type H2hKey = (String, String, String, u64);
61
62pub fn h2h_key(record: &Value) -> H2hKey {
63 (
64 record["position_id"]
65 .as_str()
66 .unwrap_or_default()
67 .to_string(),
68 record["mover"].as_str().unwrap_or_default().to_string(),
69 record["responder"].as_str().unwrap_or_default().to_string(),
70 record["seed"].as_u64().unwrap_or_default(),
71 )
72}
73
74struct H2hTask<'a> {
77 mover: &'a dyn EngineAdapter,
78 responder: &'a dyn EngineAdapter,
79 position: &'a Value,
80 seed: u64,
81}
82
83fn build_h2h_tasks<'a>(
84 adapter_a: &'a dyn EngineAdapter,
85 adapter_b: &'a dyn EngineAdapter,
86 positions: &'a [Value],
87 seeds: &'a [u64],
88 skip: &HashSet<H2hKey>,
89) -> Vec<H2hTask<'a>> {
90 let mut tasks = Vec::new();
91 for position in positions {
92 let position_id = position["id"].as_str().unwrap_or_default();
93 for &seed in seeds {
94 for (mover, responder) in [(adapter_a, adapter_b), (adapter_b, adapter_a)] {
95 let key: H2hKey = (
96 position_id.to_string(),
97 mover.name().to_string(),
98 responder.name().to_string(),
99 seed,
100 );
101 if skip.contains(&key) {
102 continue;
103 }
104 tasks.push(H2hTask {
105 mover,
106 responder,
107 position,
108 seed,
109 });
110 }
111 }
112 }
113 tasks
114}
115
116fn play_h2h_task(task: &H2hTask) -> Result<Value, String> {
117 let bb = State::from_qfen(task.position["qfen"].as_str().unwrap_or_default())?.bb;
118 let position_id = task.position["id"].as_str().unwrap_or_default();
119 let (winner, plies, move_action_indices) =
120 play_game(task.mover, task.responder, &bb, task.seed)?;
121 Ok(json!({
122 "position_id": position_id,
123 "phase": task.position["phase"],
124 "mover": task.mover.name(),
125 "responder": task.responder.name(),
126 "winner": winner,
127 "plies": plies,
128 "move_action_indices": move_action_indices,
129 "seed": task.seed,
130 }))
131}
132
133pub fn run_head_to_head(
150 adapter_a: &dyn EngineAdapter,
151 adapter_b: &dyn EngineAdapter,
152 positions: &[Value],
153 seeds: &[u64],
154 skip: &HashSet<H2hKey>,
155 workers: usize,
156 mut on_record: impl FnMut(&Value) -> Result<(), String>,
157) -> Result<Vec<Value>, String> {
158 if workers < 1 {
159 return Err("workers must be at least 1".into());
160 }
161
162 let tasks = build_h2h_tasks(adapter_a, adapter_b, positions, seeds, skip);
163 if tasks.is_empty() {
164 return Ok(Vec::new());
165 }
166
167 let mut records = Vec::with_capacity(tasks.len());
168 if workers == 1 {
169 for task in &tasks {
170 let record = play_h2h_task(task)?;
171 on_record(&record)?;
172 records.push(record);
173 }
174 } else {
175 let pool = rayon::ThreadPoolBuilder::new()
176 .num_threads(workers)
177 .build()
178 .map_err(|e| format!("build worker pool: {e}"))?;
179 for chunk in tasks.chunks(workers) {
186 let chunk_records: Result<Vec<Value>, String> =
187 pool.install(|| chunk.par_iter().map(play_h2h_task).collect());
188 for record in chunk_records? {
189 on_record(&record)?;
190 records.push(record);
191 }
192 }
193 }
194 Ok(records)
195}
196
197pub fn aggregate_head_to_head(records: &[Value], name_a: &str, name_b: &str) -> Value {
199 let wins = |rows: &[&Value], name: &str| -> u64 {
200 rows.iter()
201 .filter(|row| row["winner"].as_str() == Some(name))
202 .count() as u64
203 };
204 let all: Vec<&Value> = records.iter().collect();
205
206 let mut by_phase: BTreeMap<String, Vec<&Value>> = BTreeMap::new();
207 for record in records {
208 by_phase
209 .entry(record["phase"].as_str().unwrap_or_default().to_string())
210 .or_default()
211 .push(record);
212 }
213
214 let games = records.len() as u64;
215 let a_wins = wins(&all, name_a);
216 let (ci_low, ci_high) = wilson_ci(a_wins, games);
217 let paired: HashSet<(String, u64)> = records
218 .iter()
219 .map(|record| {
220 (
221 record["position_id"]
222 .as_str()
223 .unwrap_or_default()
224 .to_string(),
225 record["seed"].as_u64().unwrap_or_default(),
226 )
227 })
228 .collect();
229
230 let a_as_mover: Vec<&Value> = records
231 .iter()
232 .filter(|record| record["mover"].as_str() == Some(name_a))
233 .collect();
234 let b_as_mover: Vec<&Value> = records
235 .iter()
236 .filter(|record| record["mover"].as_str() == Some(name_b))
237 .collect();
238
239 json!({
240 "engine_a": name_a,
241 "engine_b": name_b,
242 "games": games,
243 "paired_positions": paired.len(),
244 "a_wins": a_wins,
245 "b_wins": wins(&all, name_b),
246 "draws": 0,
247 "a_win_rate": if games > 0 { a_wins as f64 / games as f64 } else { 0.0 },
248 "a_win_rate_ci95": [ci_low, ci_high],
249 "a_wins_as_mover": wins(&a_as_mover, name_a),
250 "b_wins_as_mover": wins(&b_as_mover, name_b),
251 "by_phase": by_phase
252 .into_iter()
253 .map(|(phase, rows)| {
254 (phase, json!({
255 "games": rows.len(),
256 "a_wins": wins(&rows, name_a),
257 "b_wins": wins(&rows, name_b),
258 }))
259 })
260 .collect::<serde_json::Map<String, Value>>(),
261 })
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use crate::bench::adapters::{MinimaxAdapter, RandomAdapter};
268 use crate::bitboard::Bitboard;
269
270 #[test]
271 fn play_game_terminates_and_credits_winner() {
272 let bb = Bitboard::EMPTY
275 .with_move(0, 0, 0)
276 .with_move(1, 1, 1)
277 .with_move(0, 2, 2);
278 let minimax = MinimaxAdapter {
279 max_depth: 3,
280 time_limit_s: None,
281 };
282 let random = RandomAdapter;
283 let (winner, plies, moves) = play_game(&minimax, &random, &bb, 0).unwrap();
284 assert_eq!(winner, "minimax");
285 assert_eq!(plies, 1);
286 assert_eq!(moves.len(), 1);
287 }
288
289 #[test]
290 fn run_and_aggregate_head_to_head() {
291 let positions = vec![json!({
292 "id": "p0",
293 "qfen": "..../..../..../....",
294 "phase": "opening",
295 })];
296 let a = RandomAdapter;
297 let minimax = MinimaxAdapter {
298 max_depth: 1,
299 time_limit_s: None,
300 };
301 let records = run_head_to_head(
302 &a,
303 &minimax,
304 &positions,
305 &[0, 1],
306 &HashSet::new(),
307 1,
308 |_| Ok(()),
309 )
310 .unwrap();
311 assert_eq!(records.len(), 4);
313 for record in &records {
314 let winner = record["winner"].as_str().unwrap();
315 assert!(winner == "random" || winner == "minimax");
316 assert!(record["plies"].as_u64().unwrap() >= 1);
317 }
318
319 let aggregate = aggregate_head_to_head(&records, "random", "minimax");
320 assert_eq!(aggregate["games"], json!(4));
321 assert_eq!(aggregate["paired_positions"], json!(2));
322 assert_eq!(aggregate["draws"], json!(0));
323 let total = aggregate["a_wins"].as_u64().unwrap() + aggregate["b_wins"].as_u64().unwrap();
324 assert_eq!(total, 4, "every game has a winner");
325 assert_eq!(aggregate["by_phase"]["opening"]["games"], json!(4));
326 }
327
328 #[test]
329 fn workers_zero_rejected() {
330 let a = RandomAdapter;
331 let b = RandomAdapter;
332 assert!(run_head_to_head(&a, &b, &[], &[0], &HashSet::new(), 0, |_| Ok(())).is_err());
333 }
334
335 #[test]
338 fn workers_two_matches_workers_one_exactly() {
339 let positions = vec![
340 json!({"id": "p0", "qfen": "..../..../..../....", "phase": "opening"}),
341 json!({"id": "p1", "qfen": "..../..../..../....", "phase": "opening"}),
342 ];
343 let a = RandomAdapter;
344 let minimax = MinimaxAdapter {
345 max_depth: 1,
346 time_limit_s: None,
347 };
348 let seeds = [0u64, 1u64];
349
350 let serial = run_head_to_head(&a, &minimax, &positions, &seeds, &HashSet::new(), 1, |_| {
351 Ok(())
352 })
353 .unwrap();
354 let parallel =
355 run_head_to_head(&a, &minimax, &positions, &seeds, &HashSet::new(), 2, |_| {
356 Ok(())
357 })
358 .unwrap();
359
360 assert_eq!(serial.len(), parallel.len());
361 assert_eq!(serial, parallel, "workers=2 must match workers=1 exactly");
362 }
363}