Skip to main content

quantik_core/bench/
adapters.rs

1//! Uniform engine adapters with effective-work observations.
2//!
3//! Port of `benchmarks/adapters.py`: every adapter selects a move through
4//! the same timed, validated path and reports a [`MoveObservation`] whose
5//! JSON field names match the Python `dataclasses.asdict` output exactly,
6//! so result bundles stay schema-compatible.
7
8use crate::beam_search::{BeamSearchConfig, BeamSearchEngine};
9use crate::bench::reference::move_key;
10use crate::bitboard::Bitboard;
11use crate::game::has_winning_line;
12use crate::mcts::{MCTSConfig, MCTSEngine};
13use crate::minimax::{MinimaxConfig, MinimaxEngine};
14use crate::moves::{generate_legal_moves, Move};
15use crate::state::State;
16use rand::prelude::*;
17use serde_json::{json, Map, Value};
18use std::time::Instant;
19
20/// Format a config label like the Python `_label` helper:
21/// `name(k=v,k=v)` with `None` params omitted.
22fn label(name: &str, params: &[(&str, Option<String>)]) -> String {
23    let parts: Vec<String> = params
24        .iter()
25        .filter_map(|(key, value)| value.as_ref().map(|v| format!("{key}={v}")))
26        .collect();
27    if parts.is_empty() {
28        name.to_string()
29    } else {
30        format!("{}({})", name, parts.join(","))
31    }
32}
33
34/// Render a float parameter the way Python string-formats it in labels.
35fn fmt_float(x: f64) -> String {
36    crate::bench::canonical::python_float_repr(x)
37}
38
39/// Effective work measured for one move selection. Serializes with the
40/// exact field names of the Python `MoveObservation` dataclass.
41#[derive(Clone, Debug)]
42pub struct MoveObservation {
43    pub engine: &'static str,
44    pub config_label: String,
45    pub position_id: String,
46    pub mv: String,
47    pub wall_time_s: f64,
48    pub cpu_time_s: f64,
49    pub root_legal_moves: usize,
50    pub exact: bool,
51    pub seed: Option<u64>,
52    pub nodes: Option<u64>,
53    pub iterations: Option<u64>,
54    pub depth_reached: Option<u32>,
55    pub score: Option<f64>,
56    pub peak_memory_bytes: Option<u64>,
57    pub extra: Map<String, Value>,
58}
59
60impl MoveObservation {
61    pub fn to_json(&self) -> Value {
62        json!({
63            "engine": self.engine,
64            "config_label": self.config_label,
65            "position_id": self.position_id,
66            "move": self.mv,
67            "wall_time_s": self.wall_time_s,
68            "cpu_time_s": self.cpu_time_s,
69            "root_legal_moves": self.root_legal_moves,
70            "exact": self.exact,
71            "seed": self.seed,
72            "nodes": self.nodes,
73            "iterations": self.iterations,
74            "depth_reached": self.depth_reached,
75            "score": self.score,
76            "peak_memory_bytes": self.peak_memory_bytes,
77            "extra": self.extra,
78        })
79    }
80}
81
82/// Engine-specific metrics returned by [`EngineAdapter::select_raw`].
83#[derive(Clone, Debug, Default)]
84pub struct RawMetrics {
85    pub exact: bool,
86    pub nodes: Option<u64>,
87    pub iterations: Option<u64>,
88    pub depth_reached: Option<u32>,
89    pub score: Option<f64>,
90    pub extra: Map<String, Value>,
91}
92
93/// Uniform interface over the benchmarked engines.
94///
95/// `Send + Sync` supertrait bounds let `run_agreement`/`run_head_to_head`
96/// share adapter references across a rayon worker pool when `workers > 1`;
97/// every adapter is a stateless value type, so the bounds are automatic.
98pub trait EngineAdapter: Send + Sync {
99    fn name(&self) -> &'static str;
100    fn stochastic(&self) -> bool;
101    fn config_label(&self) -> String;
102    fn select_raw(&self, bb: &Bitboard, seed: Option<u64>) -> Result<(Move, RawMetrics), String>;
103}
104
105/// Process CPU time via `clock_gettime(CLOCK_PROCESS_CPUTIME_ID)`.
106fn process_cpu_time_s() -> f64 {
107    // SAFETY: clock_gettime with a valid clock id and out-pointer.
108    unsafe {
109        let mut ts: libc::timespec = std::mem::zeroed();
110        if libc::clock_gettime(libc::CLOCK_PROCESS_CPUTIME_ID, &mut ts) == 0 {
111            ts.tv_sec as f64 + ts.tv_nsec as f64 / 1e9
112        } else {
113            0.0
114        }
115    }
116}
117
118/// Time, validate, and record an engine call (port of
119/// `EngineAdapter.select`). Rust's `&Bitboard` makes input mutation
120/// impossible by construction; legality and terminality are still checked.
121pub fn select(
122    adapter: &dyn EngineAdapter,
123    bb: &Bitboard,
124    position_id: &str,
125    seed: Option<u64>,
126) -> Result<(Move, MoveObservation), String> {
127    let legal = generate_legal_moves(bb);
128    if has_winning_line(bb) || legal.is_empty() {
129        return Err(format!(
130            "{}: cannot select from a terminal state",
131            adapter.name()
132        ));
133    }
134
135    let wall0 = Instant::now();
136    let cpu0 = process_cpu_time_s();
137    let (mv, metrics) = adapter.select_raw(bb, seed)?;
138    let wall_time_s = wall0.elapsed().as_secs_f64();
139    let cpu_time_s = process_cpu_time_s() - cpu0;
140
141    if !legal.contains(&mv) {
142        return Err(format!("{}: returned illegal move {mv:?}", adapter.name()));
143    }
144
145    let observation = MoveObservation {
146        engine: adapter.name(),
147        config_label: adapter.config_label(),
148        position_id: position_id.to_string(),
149        mv: move_key(&mv),
150        wall_time_s,
151        cpu_time_s,
152        root_legal_moves: legal.len(),
153        exact: metrics.exact,
154        seed,
155        nodes: metrics.nodes,
156        iterations: metrics.iterations,
157        depth_reached: metrics.depth_reached,
158        score: metrics.score,
159        peak_memory_bytes: None,
160        extra: metrics.extra,
161    };
162    Ok((mv, observation))
163}
164
165/// Alpha-beta iterative deepening adapter.
166pub struct MinimaxAdapter {
167    pub max_depth: u32,
168    pub time_limit_s: Option<f64>,
169}
170
171impl EngineAdapter for MinimaxAdapter {
172    fn name(&self) -> &'static str {
173        "minimax"
174    }
175    fn stochastic(&self) -> bool {
176        false
177    }
178    fn config_label(&self) -> String {
179        label(
180            "minimax",
181            &[
182                ("d", Some(self.max_depth.to_string())),
183                ("t", self.time_limit_s.map(fmt_float)),
184            ],
185        )
186    }
187    fn select_raw(&self, bb: &Bitboard, _seed: Option<u64>) -> Result<(Move, RawMetrics), String> {
188        let mut engine = MinimaxEngine::new(MinimaxConfig {
189            max_depth: self.max_depth,
190            time_limit_s: self.time_limit_s,
191            ..Default::default()
192        });
193        let result = engine.search(&State::new(*bb))?;
194        if result.pv.first() != Some(&result.best_move) {
195            return Err("minimax: best_move inconsistent with reported PV".into());
196        }
197        let pieces = bb.player_piece_count(0) + bb.player_piece_count(1);
198        Ok((
199            result.best_move,
200            RawMetrics {
201                exact: result.depth_reached >= 16 - pieces,
202                nodes: Some(result.nodes),
203                depth_reached: Some(result.depth_reached),
204                score: Some(result.score),
205                ..Default::default()
206            },
207        ))
208    }
209}
210
211/// Monte Carlo tree search adapter.
212pub struct MCTSAdapter {
213    pub max_iterations: u32,
214    pub max_depth: u32,
215    pub exploration_weight: f64,
216    pub time_limit_s: Option<f64>,
217}
218
219impl EngineAdapter for MCTSAdapter {
220    fn name(&self) -> &'static str {
221        "mcts"
222    }
223    fn stochastic(&self) -> bool {
224        true
225    }
226    fn config_label(&self) -> String {
227        label(
228            "mcts",
229            &[
230                ("it", Some(self.max_iterations.to_string())),
231                ("d", Some(self.max_depth.to_string())),
232                ("c", Some(fmt_float(self.exploration_weight))),
233                ("t", self.time_limit_s.map(fmt_float)),
234            ],
235        )
236    }
237    fn select_raw(&self, bb: &Bitboard, seed: Option<u64>) -> Result<(Move, RawMetrics), String> {
238        let mut engine = MCTSEngine::new(MCTSConfig {
239            max_iterations: self.max_iterations,
240            max_depth: self.max_depth,
241            exploration_weight: self.exploration_weight,
242            seed,
243            time_limit_s: self.time_limit_s,
244            ..Default::default()
245        });
246        let (mv, win_probability) = engine
247            .search(bb)
248            .ok_or_else(|| "mcts: no move returned".to_string())?;
249        Ok((
250            mv,
251            RawMetrics {
252                exact: false,
253                iterations: Some(engine.iterations_performed() as u64),
254                nodes: Some(engine.nodes_created() as u64),
255                score: Some(win_probability),
256                ..Default::default()
257            },
258        ))
259    }
260}
261
262/// Beam search adapter.
263pub struct BeamAdapter {
264    pub beam_width: usize,
265    pub max_depth: u32,
266    pub time_limit_s: Option<f64>,
267}
268
269impl EngineAdapter for BeamAdapter {
270    fn name(&self) -> &'static str {
271        "beam"
272    }
273    fn stochastic(&self) -> bool {
274        true
275    }
276    fn config_label(&self) -> String {
277        label(
278            "beam",
279            &[
280                ("w", Some(self.beam_width.to_string())),
281                ("d", Some(self.max_depth.to_string())),
282                ("t", self.time_limit_s.map(fmt_float)),
283            ],
284        )
285    }
286    fn select_raw(&self, bb: &Bitboard, seed: Option<u64>) -> Result<(Move, RawMetrics), String> {
287        let mut engine = BeamSearchEngine::new(BeamSearchConfig {
288            beam_width: self.beam_width,
289            max_depth: self.max_depth,
290            random_seed: seed,
291            time_limit_s: self.time_limit_s,
292            ..Default::default()
293        })?;
294        let result = engine.search(bb)?;
295
296        let mv = match result.best_leaf.as_ref().and_then(|l| l.moves.first()) {
297            Some(&mv) => mv,
298            None => {
299                let ranked = result.ranked_root_moves(None);
300                ranked
301                    .first()
302                    .map(|r| r.mv)
303                    .ok_or("beam: beam search produced no candidate moves")?
304            }
305        };
306
307        let score = result.best_leaf.as_ref().map(|leaf| {
308            if result.root_player == 1 {
309                -leaf.value
310            } else {
311                leaf.value
312            }
313        });
314
315        let mut extra = Map::new();
316        extra.insert(
317            "candidates_generated".into(),
318            json!(result.stats.candidates_generated as f64),
319        );
320        extra.insert(
321            "nodes_pruned".into(),
322            json!(result.stats.nodes_pruned as f64),
323        );
324        extra.insert("rollouts".into(), json!(result.stats.rollouts as f64));
325
326        Ok((
327            mv,
328            RawMetrics {
329                exact: false,
330                nodes: Some(result.stats.nodes_inserted),
331                depth_reached: Some(result.max_depth_reached),
332                score,
333                extra,
334                ..Default::default()
335            },
336        ))
337    }
338}
339
340/// Uniform-random baseline adapter.
341pub struct RandomAdapter;
342
343impl EngineAdapter for RandomAdapter {
344    fn name(&self) -> &'static str {
345        "random"
346    }
347    fn stochastic(&self) -> bool {
348        true
349    }
350    fn config_label(&self) -> String {
351        "random".into()
352    }
353    fn select_raw(&self, bb: &Bitboard, seed: Option<u64>) -> Result<(Move, RawMetrics), String> {
354        let moves = generate_legal_moves(bb);
355        if moves.is_empty() {
356            return Err("random: no legal moves".into());
357        }
358        let mut rng = match seed {
359            Some(s) => StdRng::seed_from_u64(s),
360            None => StdRng::from_entropy(),
361        };
362        Ok((moves[rng.gen_range(0..moves.len())], RawMetrics::default()))
363    }
364}
365
366/// The fixed-time minimax, MCTS, and beam adapter family.
367pub fn fixed_time_adapters(time_limit_s: f64, beam_width: usize) -> Vec<Box<dyn EngineAdapter>> {
368    vec![
369        Box::new(MinimaxAdapter {
370            max_depth: 16,
371            time_limit_s: Some(time_limit_s),
372        }),
373        Box::new(MCTSAdapter {
374            max_iterations: 10_000_000,
375            max_depth: 16,
376            exploration_weight: std::f64::consts::SQRT_2,
377            time_limit_s: Some(time_limit_s),
378        }),
379        Box::new(BeamAdapter {
380            beam_width,
381            max_depth: 16,
382            time_limit_s: Some(time_limit_s),
383        }),
384    ]
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::moves::apply_move;
391
392    fn cheap_adapters() -> Vec<Box<dyn EngineAdapter>> {
393        vec![
394            Box::new(MinimaxAdapter {
395                max_depth: 2,
396                time_limit_s: None,
397            }),
398            Box::new(MCTSAdapter {
399                max_iterations: 50,
400                max_depth: 16,
401                exploration_weight: std::f64::consts::SQRT_2,
402                time_limit_s: None,
403            }),
404            Box::new(BeamAdapter {
405                beam_width: 8,
406                max_depth: 4,
407                time_limit_s: None,
408            }),
409            Box::new(RandomAdapter),
410        ]
411    }
412
413    #[test]
414    fn adapters_return_legal_reproducible_moves() {
415        let bb = Bitboard::EMPTY.with_move(0, 0, 5).with_move(1, 2, 10);
416        let legal = generate_legal_moves(&bb);
417        for adapter in cheap_adapters() {
418            let (mv1, obs1) = select(adapter.as_ref(), &bb, "t1", Some(9)).unwrap();
419            let (mv2, obs2) = select(adapter.as_ref(), &bb, "t1", Some(9)).unwrap();
420            assert!(legal.contains(&mv1), "{}", adapter.name());
421            assert_eq!(mv1, mv2, "{} not reproducible", adapter.name());
422            assert_eq!(obs1.mv, obs2.mv);
423            assert!(obs1.wall_time_s >= 0.0);
424            assert_eq!(obs1.root_legal_moves, legal.len());
425        }
426    }
427
428    #[test]
429    fn labels_match_python_format() {
430        let minimax = MinimaxAdapter {
431            max_depth: 16,
432            time_limit_s: Some(1.0),
433        };
434        assert_eq!(minimax.config_label(), "minimax(d=16,t=1.0)");
435        let mcts = MCTSAdapter {
436            max_iterations: 10_000_000,
437            max_depth: 16,
438            exploration_weight: 1.414,
439            time_limit_s: Some(1.0),
440        };
441        assert_eq!(mcts.config_label(), "mcts(it=10000000,d=16,c=1.414,t=1.0)");
442        let beam = BeamAdapter {
443            beam_width: 256,
444            max_depth: 16,
445            time_limit_s: Some(1.0),
446        };
447        assert_eq!(beam.config_label(), "beam(w=256,d=16,t=1.0)");
448        assert_eq!(RandomAdapter.config_label(), "random");
449        let native = MinimaxAdapter {
450            max_depth: 6,
451            time_limit_s: None,
452        };
453        assert_eq!(native.config_label(), "minimax(d=6)");
454    }
455
456    #[test]
457    fn select_rejects_terminal_state() {
458        let won = Bitboard::EMPTY
459            .with_move(0, 0, 0)
460            .with_move(1, 1, 1)
461            .with_move(0, 2, 2)
462            .with_move(1, 3, 3);
463        assert!(select(&RandomAdapter, &won, "t", Some(0)).is_err());
464    }
465
466    #[test]
467    fn observation_json_field_names_match_python() {
468        let bb = Bitboard::EMPTY;
469        let (_, obs) = select(&RandomAdapter, &bb, "p0000", Some(1)).unwrap();
470        let value = obs.to_json();
471        for field in [
472            "engine",
473            "config_label",
474            "position_id",
475            "move",
476            "wall_time_s",
477            "cpu_time_s",
478            "root_legal_moves",
479            "exact",
480            "seed",
481            "nodes",
482            "iterations",
483            "depth_reached",
484            "score",
485            "peak_memory_bytes",
486            "extra",
487        ] {
488            assert!(value.get(field).is_some(), "missing {field}");
489        }
490        assert_eq!(value["engine"], json!("random"));
491        assert_eq!(value["nodes"], Value::Null);
492    }
493
494    #[test]
495    fn minimax_exactness_flag() {
496        // 12 pieces: depth 4 remains; max_depth 16 without a time limit
497        // solves exactly, so exact must be true on a late position.
498        let mut bb = Bitboard::EMPTY;
499        let mut rng = StdRng::seed_from_u64(4);
500        let mut placed = 0;
501        while placed < 12 {
502            let moves = generate_legal_moves(&bb);
503            if moves.is_empty() {
504                bb = Bitboard::EMPTY;
505                placed = 0;
506                continue;
507            }
508            let next = apply_move(&bb, &moves[rng.gen_range(0..moves.len())]);
509            if has_winning_line(&next) {
510                bb = Bitboard::EMPTY;
511                placed = 0;
512                continue;
513            }
514            bb = next;
515            placed += 1;
516        }
517        if generate_legal_moves(&bb).is_empty() {
518            return; // stalemate start: nothing to assert for this seed
519        }
520        let adapter = MinimaxAdapter {
521            max_depth: 16,
522            time_limit_s: None,
523        };
524        let (_, obs) = select(&adapter, &bb, "deep", None).unwrap();
525        assert!(obs.exact);
526        assert_eq!(obs.engine, "minimax");
527    }
528}