Skip to main content

quantik_core/bench/
head_to_head.rs

1//! Paired, side-balanced head-to-head games from shared positions.
2//!
3//! Port of `benchmarks/head_to_head.py`: for every sampled position and
4//! seed, two games are played — engine A as the side already to move,
5//! then engine B as the side to move. Results are attributed to the
6//! actual engine/color mapping because sampled positions can have either
7//! player to move.
8
9use 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
19/// Play from `bb`; `mover` is the side already to move.
20/// Returns `(winner adapter name, plies played, move action indices)`.
21pub 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            // The previous mover ended the game (line or block): the side
43            // to move now has lost.
44            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
58/// Identity of one head-to-head game — `(position_id, mover, responder,
59/// seed)`, matching Python's `checkpoint.h2h_key` tuple order.
60pub 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
74/// One (mover, responder, position, seed) unit of work, matching Python's
75/// `_h2h_tasks` ordering: positions, then seeds, then the two orientations.
76struct 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
133/// Play both engine orientations per position and seed.
134///
135/// `on_record` is invoked after each completed game, IN TASK ORDER
136/// (checkpoint hook — it returns `Result` so a checkpoint write failure
137/// aborts the run instead of being silently dropped); `skip` games are not
138/// replayed (their records must already be accounted for by the caller,
139/// e.g. loaded from a checkpoint).
140///
141/// `workers` follows the same contract as
142/// [`super::agreement::run_agreement`]: `workers == 1` is serial;
143/// `workers > 1` processes the ordered task list in sequential chunks of
144/// `workers` tasks on a sized rayon pool — each chunk computed in parallel
145/// via `par_iter().collect()` (order-preserving), then streamed to
146/// `on_record` in task order before the next chunk starts — so results are
147/// byte-identical to a `workers == 1` run while still checkpointing
148/// incrementally (at most one chunk of in-flight work is lost on a crash).
149pub 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        // Process the ordered task list in sequential chunks of `workers`
180        // tasks: each chunk is computed in parallel, then its records are
181        // streamed to `on_record` in task order before the next chunk
182        // starts. This keeps checkpoint durability incremental even under
183        // parallelism — a crash loses at most one in-flight chunk instead
184        // of the entire phase (see PR review finding M1).
185        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
197/// Aggregate totals, as-mover splits, and per-phase splits.
198pub 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        // A@0, b@1, C@2: mover (P1) has an immediate win available; minimax
273        // as mover must win in one ply.
274        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        // 1 position × 2 seeds × 2 orientations.
312        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    /// workers=2 must produce EXACTLY the same records, in the same order,
336    /// as workers=1.
337    #[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}