1use 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
20fn 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
34fn fmt_float(x: f64) -> String {
36 crate::bench::canonical::python_float_repr(x)
37}
38
39#[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#[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
93pub 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
105fn process_cpu_time_s() -> f64 {
107 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
118pub 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
165pub 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
211pub 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
262pub 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
340pub 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
366pub 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 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; }
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}