1use crate::knowledge::{Knowledge, analyze};
2use crate::{Cell, Color, GameResult, Move, Position, Seat};
3#[cfg(not(target_arch = "wasm32"))]
4use std::time::{Duration, Instant};
5
6#[cfg(target_arch = "wasm32")]
7use wasm_bindgen::prelude::*;
8
9const DEFAULT_EXPLORATION: f64 = 0.2;
10const DEFAULT_RAVE_EQUIVALENCE: f64 = 1_000.0;
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum MctsStrategy {
14 PlainUct,
15 UctRave,
16}
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19pub enum RolloutPolicy {
20 Random,
21 SaveBridge,
22}
23
24#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25pub enum KnowledgePolicy {
26 Disabled,
27 InferiorCells { min_visits: u32 },
28}
29
30impl KnowledgePolicy {
31 pub const fn min_visits(self) -> Option<u32> {
32 match self {
33 Self::Disabled => None,
34 Self::InferiorCells { min_visits } => Some(min_visits),
35 }
36 }
37}
38
39impl RolloutPolicy {
40 pub const fn as_str(self) -> &'static str {
41 match self {
42 Self::Random => "random",
43 Self::SaveBridge => "save-bridge",
44 }
45 }
46}
47
48impl MctsStrategy {
49 pub const fn as_str(self) -> &'static str {
50 match self {
51 Self::PlainUct => "plain-uct",
52 Self::UctRave => "uct-rave",
53 }
54 }
55}
56
57#[derive(Clone, Copy, Debug, PartialEq)]
58pub struct MctsOptions {
59 pub max_simulations: u32,
60 pub soft_time_ms: u32,
61 pub exploration: f64,
62 pub strategy: MctsStrategy,
63 pub rave_equivalence: f64,
64 pub rollout_policy: RolloutPolicy,
65 pub knowledge_policy: KnowledgePolicy,
66 pub use_virtual_connections: bool,
67 pub seed: u64,
68}
69
70impl Default for MctsOptions {
71 fn default() -> Self {
72 MCTS_PRESETS[MCTS_PRESETS.len() - 1].options
73 }
74}
75
76#[derive(Clone, Copy, Debug, PartialEq)]
77pub struct MctsPreset {
78 pub name: &'static str,
79 pub options: MctsOptions,
80}
81
82pub const MCTS_PRESETS: [MctsPreset; 6] = [
83 preset("beginner", 200, 50),
84 preset("easy", 1_000, 100),
85 preset("medium", 4_000, 200),
86 preset("hard", 20_000, 500),
87 preset("expert", 80_000, 1_300),
88 preset("maximum", 200_000, 2_000),
89];
90
91const fn preset(name: &'static str, max_simulations: u32, soft_time_ms: u32) -> MctsPreset {
92 MctsPreset {
93 name,
94 options: MctsOptions {
95 max_simulations,
96 soft_time_ms,
97 exploration: DEFAULT_EXPLORATION,
98 strategy: MctsStrategy::UctRave,
99 rave_equivalence: DEFAULT_RAVE_EQUIVALENCE,
100 rollout_policy: RolloutPolicy::SaveBridge,
101 knowledge_policy: KnowledgePolicy::InferiorCells { min_visits: 32 },
102 use_virtual_connections: true,
103 seed: 1,
104 },
105 }
106}
107
108pub fn mcts_preset(name: &str) -> Option<MctsPreset> {
109 MCTS_PRESETS
110 .iter()
111 .copied()
112 .find(|preset| preset.name == name)
113}
114
115#[derive(Clone, Debug, PartialEq)]
116pub struct MctsMoveStats {
117 pub mv: Move,
118 pub visits: u32,
119 pub expected_score: f64,
120 pub rave_visits: u32,
121 pub rave_expected_score: f64,
122 pub proven_winner: Option<Seat>,
123 pub proof_distance: Option<u16>,
124}
125
126#[derive(Clone, Debug, PartialEq)]
127pub struct MctsReport {
128 pub best_move: Option<Move>,
129 pub strategy: MctsStrategy,
130 pub rave_equivalence: f64,
131 pub rollout_policy: RolloutPolicy,
132 pub knowledge_policy: KnowledgePolicy,
133 pub virtual_connections_enabled: bool,
134 pub simulations: u32,
135 pub tree_nodes: u32,
136 pub root_visits: u32,
137 pub expected_score: f64,
138 pub elapsed_ms: u32,
139 pub rollout_moves: u64,
140 pub bridge_replies: u64,
141 pub knowledge_nodes: u32,
142 pub pruned_moves: u32,
143 pub must_play_nodes: u32,
144 pub root_pruned_moves: u32,
145 pub root_must_play_moves: u32,
146 pub virtual_connections: u32,
147 pub semi_connections: u32,
148 pub connection_search_truncated_nodes: u32,
149 pub proven_nodes: u32,
150 pub solver_propagations: u32,
151 pub proven_winner: Option<Seat>,
152 pub proof_distance: Option<u16>,
153 pub root_moves: Vec<MctsMoveStats>,
154}
155
156#[derive(Clone, Copy, Debug, Eq, PartialEq)]
157struct Proof {
158 winner: Seat,
159 distance: Option<u16>,
160}
161
162struct Node {
163 position: Position,
164 parent: Option<usize>,
165 incoming_move: Option<Move>,
166 children: Vec<usize>,
167 permutation_offset: u16,
168 permutation_step: u16,
169 next_slot: u16,
170 swap_unexpanded: bool,
171 visits: u32,
172 value_sum: f64,
173 rave_visits: u32,
174 rave_value_sum: f64,
175 knowledge: Option<Knowledge>,
176 proof: Option<Proof>,
177}
178
179impl Node {
180 fn new(
181 position: Position,
182 parent: Option<usize>,
183 incoming_move: Option<Move>,
184 random: &mut SplitMix64,
185 ) -> Self {
186 let cells = position.size().cell_count();
187 let proof = match position.result() {
188 GameResult::Win(winner) => Some(Proof {
189 winner,
190 distance: Some(0),
191 }),
192 GameResult::Ongoing => None,
193 };
194 Self {
195 position,
196 parent,
197 incoming_move,
198 children: Vec::new(),
199 permutation_offset: random.index(usize::from(cells)) as u16,
200 permutation_step: coprime_step(cells, random),
201 next_slot: 0,
202 swap_unexpanded: position.swap_available(),
203 visits: 0,
204 value_sum: 0.0,
205 rave_visits: 0,
206 rave_value_sum: 0.0,
207 knowledge: None,
208 proof,
209 }
210 }
211
212 fn next_unexpanded(&mut self) -> Option<Move> {
213 if self.swap_unexpanded {
214 self.swap_unexpanded = false;
215 return Some(Move::Swap);
216 }
217 let cells = self.position.size().cell_count();
218 while self.next_slot < cells {
219 let dense = (u32::from(self.permutation_offset)
220 + u32::from(self.next_slot) * u32::from(self.permutation_step))
221 % u32::from(cells);
222 self.next_slot += 1;
223 let cell = crate::Cell::from_dense(dense as u16, self.position.size());
224 let mv = Move::Place(cell);
225 if self.position.is_legal(mv) && self.knowledge.is_none_or(|info| info.allows(mv)) {
226 return Some(mv);
227 }
228 }
229 None
230 }
231
232 fn allows(&self, mv: Move) -> bool {
233 self.knowledge.is_none_or(|knowledge| knowledge.allows(mv))
234 }
235
236 fn expansion_exhausted(&self) -> bool {
237 !self.swap_unexpanded && self.next_slot >= self.position.size().cell_count()
238 }
239}
240
241pub struct MctsSearcher {
242 nodes: Vec<Node>,
243 random: SplitMix64,
244}
245
246impl Default for MctsSearcher {
247 fn default() -> Self {
248 Self::new()
249 }
250}
251
252impl MctsSearcher {
253 pub fn new() -> Self {
254 Self {
255 nodes: Vec::with_capacity(16_384),
256 random: SplitMix64(1),
257 }
258 }
259
260 pub fn search(&mut self, position: Position, options: MctsOptions) -> MctsReport {
261 self.nodes.clear();
262 self.random = SplitMix64(options.seed);
263 self.nodes
264 .push(Node::new(position, None, None, &mut self.random));
265 let root_seat = position.seat_to_move();
266 let clock = Clock::new(options.soft_time_ms);
267 let mut simulations = 0;
268 let mut rollout_moves = 0;
269 let mut bridge_replies = 0;
270 let mut counters = SearchCounters::default();
271
272 if position.result() == GameResult::Ongoing {
273 for simulation in 0..options.max_simulations.max(1) {
274 if simulation > 0 && simulation % 16 == 0 && clock.expired() {
275 break;
276 }
277 let rollout = self.run_simulation(root_seat, options, &mut counters);
278 rollout_moves += u64::from(rollout.moves);
279 bridge_replies += u64::from(rollout.bridge_replies);
280 simulations += 1;
281 if self.nodes[0]
282 .proof
283 .is_some_and(|proof| proof.winner == root_seat)
284 && !self.nodes[0].children.is_empty()
285 {
286 break;
287 }
288 }
289 }
290
291 let mut root_moves = self.nodes[0]
292 .children
293 .iter()
294 .filter(|&&index| {
295 self.nodes[0].allows(
296 self.nodes[index]
297 .incoming_move
298 .expect("root children have moves"),
299 )
300 })
301 .map(|&index| {
302 let child = &self.nodes[index];
303 MctsMoveStats {
304 mv: child.incoming_move.expect("root children have moves"),
305 visits: child.visits,
306 expected_score: child.proof.map_or_else(
307 || mean_score(child),
308 |proof| f64::from(proof.winner == root_seat),
309 ),
310 rave_visits: child.rave_visits,
311 rave_expected_score: rave_mean_score(child),
312 proven_winner: child.proof.map(|proof| proof.winner),
313 proof_distance: child.proof.and_then(|proof| proof.distance),
314 }
315 })
316 .collect::<Vec<_>>();
317 root_moves.sort_by(|left, right| compare_root_moves(left, right, root_seat));
318 let best_move = root_moves.first().map(|stats| stats.mv);
319 let expected_score = self.nodes[0].proof.map_or_else(
320 || root_moves.first().map_or(0.5, |stats| stats.expected_score),
321 |proof| f64::from(proof.winner == root_seat),
322 );
323
324 MctsReport {
325 best_move,
326 strategy: options.strategy,
327 rave_equivalence: options.rave_equivalence,
328 rollout_policy: options.rollout_policy,
329 knowledge_policy: options.knowledge_policy,
330 virtual_connections_enabled: options.use_virtual_connections,
331 simulations,
332 tree_nodes: self.nodes.len() as u32,
333 root_visits: self.nodes[0].visits,
334 expected_score,
335 elapsed_ms: clock.elapsed_ms(),
336 rollout_moves,
337 bridge_replies,
338 knowledge_nodes: counters.knowledge_nodes,
339 pruned_moves: counters.pruned_moves,
340 must_play_nodes: counters.must_play_nodes,
341 root_pruned_moves: self.nodes[0]
342 .knowledge
343 .map_or(0, |knowledge| knowledge.pruned.count()),
344 root_must_play_moves: self.nodes[0]
345 .knowledge
346 .and_then(|knowledge| knowledge.must_play)
347 .map_or(0, |moves| moves.count()),
348 virtual_connections: counters.virtual_connections,
349 semi_connections: counters.semi_connections,
350 connection_search_truncated_nodes: counters.connection_search_truncated_nodes,
351 proven_nodes: counters.proven_nodes,
352 solver_propagations: counters.solver_propagations,
353 proven_winner: self.nodes[0].proof.map(|proof| proof.winner),
354 proof_distance: self.nodes[0].proof.and_then(|proof| proof.distance),
355 root_moves,
356 }
357 }
358
359 fn run_simulation(
360 &mut self,
361 root_seat: Seat,
362 options: MctsOptions,
363 counters: &mut SearchCounters,
364 ) -> RolloutSummary {
365 let mut node = 0;
366 loop {
367 if self.nodes[node].position.result() != GameResult::Ongoing
368 || (node != 0 && self.nodes[node].proof.is_some())
369 {
370 break;
371 }
372 self.prepare_knowledge(
373 node,
374 options.knowledge_policy,
375 options.use_virtual_connections,
376 counters,
377 );
378 if node != 0 && self.nodes[node].proof.is_some() {
379 break;
380 }
381 let next_move = self.nodes[node].next_unexpanded();
382 if let Some(mv) = next_move {
383 let position = self.nodes[node]
384 .position
385 .play(mv)
386 .expect("tree expansion uses legal moves");
387 let child = self.nodes.len();
388 self.nodes
389 .push(Node::new(position, Some(node), Some(mv), &mut self.random));
390 self.nodes[node].children.push(child);
391 node = child;
392 break;
393 }
394 if self.nodes[node].children.is_empty() {
395 break;
396 }
397 node = self.select_child(node, root_seat, options);
398 }
399
400 let last_move = self.nodes[node].incoming_move.and_then(Move::cell);
401 let rollout = self.nodes[node].proof.map_or_else(
402 || {
403 self.rollout(
404 self.nodes[node].position,
405 root_seat,
406 last_move,
407 options.rollout_policy,
408 )
409 },
410 |proof| Rollout {
411 score: f64::from(proof.winner == root_seat),
412 moves: 0,
413 bridge_replies: 0,
414 trace: MoveTrace::default(),
415 },
416 );
417 let mut trace = rollout.trace;
418 let mut current = Some(node);
419 while let Some(index) = current {
420 let parent = self.nodes[index].parent;
421 self.nodes[index].visits += 1;
422 self.nodes[index].value_sum += rollout.score;
423 if options.strategy == MctsStrategy::UctRave {
424 self.update_rave(index, trace, rollout.score);
425 }
426 if let (Some(parent), Some(mv)) = (parent, self.nodes[index].incoming_move) {
427 trace.prepend(self.nodes[parent].position.color_to_move(), mv);
428 }
429 self.refresh_proof(index, counters);
430 current = parent;
431 }
432 RolloutSummary {
433 moves: rollout.moves,
434 bridge_replies: rollout.bridge_replies,
435 }
436 }
437
438 fn prepare_knowledge(
439 &mut self,
440 node: usize,
441 policy: KnowledgePolicy,
442 use_virtual_connections: bool,
443 counters: &mut SearchCounters,
444 ) {
445 let Some(min_visits) = policy.min_visits() else {
446 return;
447 };
448 if self.nodes[node].knowledge.is_some()
449 || (node != 0 && self.nodes[node].visits < min_visits)
450 {
451 return;
452 }
453
454 let knowledge = analyze(self.nodes[node].position, use_virtual_connections);
455 counters.knowledge_nodes += 1;
456 counters.pruned_moves += knowledge.pruned.count();
457 counters.must_play_nodes += u32::from(knowledge.must_play.is_some());
458 counters.virtual_connections += knowledge.virtual_connections;
459 counters.semi_connections += knowledge.semi_connections;
460 counters.connection_search_truncated_nodes +=
461 u32::from(knowledge.connection_search_truncated);
462 if let Some(winner) = knowledge.proven_winner {
463 self.nodes[node].proof = Some(Proof {
464 winner,
465 distance: None,
466 });
467 counters.proven_nodes += 1;
468 }
469 self.nodes[node].knowledge = Some(knowledge);
470 }
471
472 fn select_child(&self, parent: usize, root_seat: Seat, options: MctsOptions) -> usize {
473 let node = &self.nodes[parent];
474 let mover = node.position.seat_to_move();
475 let root_turn = node.position.seat_to_move() == root_seat;
476 let log_parent = f64::from(node.visits.max(1)).ln();
477 let allowed = node
478 .children
479 .iter()
480 .copied()
481 .filter(|&child| {
482 node.allows(
483 self.nodes[child]
484 .incoming_move
485 .expect("children have moves"),
486 )
487 })
488 .collect::<Vec<_>>();
489 if let Some(winning) = allowed
490 .iter()
491 .copied()
492 .filter(|&child| {
493 self.nodes[child]
494 .proof
495 .is_some_and(|proof| proof.winner == mover)
496 })
497 .min_by_key(|&child| {
498 (
499 self.nodes[child]
500 .proof
501 .expect("filtered proof")
502 .distance
503 .unwrap_or(u16::MAX),
504 self.nodes[child]
505 .incoming_move
506 .expect("children have moves")
507 .order_key(),
508 )
509 })
510 {
511 return winning;
512 }
513 let unresolved = allowed
514 .iter()
515 .copied()
516 .filter(|&child| self.nodes[child].proof.is_none())
517 .collect::<Vec<_>>();
518 if !unresolved.is_empty() {
519 return unresolved
520 .into_iter()
521 .max_by(|&left, &right| {
522 let score = |index| {
523 let child = &self.nodes[index];
524 selection_score(child, root_turn, log_parent, options)
525 };
526 score(left).total_cmp(&score(right)).then_with(|| {
527 self.nodes[right]
528 .incoming_move
529 .expect("children have moves")
530 .order_key()
531 .cmp(
532 &self.nodes[left]
533 .incoming_move
534 .expect("children have moves")
535 .order_key(),
536 )
537 })
538 })
539 .expect("unresolved selection requires a child");
540 }
541 allowed
542 .into_iter()
543 .max_by_key(|&child| {
544 (
545 self.nodes[child]
546 .proof
547 .expect("all children proven")
548 .distance
549 .unwrap_or(u16::MAX),
550 std::cmp::Reverse(
551 self.nodes[child]
552 .incoming_move
553 .expect("children have moves")
554 .order_key(),
555 ),
556 )
557 })
558 .expect("selection requires a child")
559 }
560
561 fn refresh_proof(&mut self, node: usize, counters: &mut SearchCounters) {
562 if self.nodes[node].proof.is_some()
563 || self.nodes[node].position.result() != GameResult::Ongoing
564 {
565 return;
566 }
567 let mover = self.nodes[node].position.seat_to_move();
568 let allowed = self.nodes[node]
569 .children
570 .iter()
571 .copied()
572 .filter(|&child| {
573 self.nodes[node].allows(
574 self.nodes[child]
575 .incoming_move
576 .expect("children have moves"),
577 )
578 })
579 .collect::<Vec<_>>();
580 let proof = if let Some(proof) = allowed
581 .iter()
582 .filter_map(|&child| self.nodes[child].proof)
583 .filter(|proof| proof.winner == mover)
584 .min_by_key(|proof| proof.distance.unwrap_or(u16::MAX))
585 {
586 Some(Proof {
587 winner: mover,
588 distance: proof.distance.map(|distance| distance.saturating_add(1)),
589 })
590 } else if self.nodes[node].expansion_exhausted()
591 && !allowed.is_empty()
592 && allowed.iter().all(|&child| {
593 self.nodes[child]
594 .proof
595 .is_some_and(|proof| proof.winner == mover.other())
596 })
597 {
598 Some(Proof {
599 winner: mover.other(),
600 distance: allowed
601 .iter()
602 .try_fold(0, |longest, &child| {
603 self.nodes[child]
604 .proof
605 .and_then(|proof| proof.distance)
606 .map(|distance| longest.max(distance))
607 })
608 .map(|distance| distance.saturating_add(1)),
609 })
610 } else {
611 None
612 };
613 if let Some(proof) = proof {
614 self.nodes[node].proof = Some(proof);
615 counters.proven_nodes += 1;
616 counters.solver_propagations += 1;
617 }
618 }
619
620 fn update_rave(&mut self, node: usize, trace: MoveTrace, score: f64) {
621 let color = self.nodes[node].position.color_to_move();
622 let child_count = self.nodes[node].children.len();
623 for slot in 0..child_count {
624 let child = self.nodes[node].children[slot];
625 let Some(cell) = self.nodes[child]
626 .incoming_move
627 .expect("children have moves")
628 .cell()
629 else {
630 continue;
631 };
632 if trace.contains(color, cell) {
633 self.nodes[child].rave_visits += 1;
634 self.nodes[child].rave_value_sum += score;
635 }
636 }
637 }
638
639 fn rollout(
640 &mut self,
641 mut position: Position,
642 root_seat: Seat,
643 mut last_move: Option<Cell>,
644 policy: RolloutPolicy,
645 ) -> Rollout {
646 if let GameResult::Win(winner) = position.result() {
647 return Rollout {
648 score: f64::from(winner == root_seat),
649 moves: 0,
650 bridge_replies: 0,
651 trace: MoveTrace::default(),
652 };
653 }
654
655 let mut moves = 0;
656 let mut trace = MoveTrace::default();
657 let mut crossed_swap = false;
658 let mut bridge_replies = 0;
659 if position.swap_available() {
660 if self.random.next() & 1 == 0 {
661 position = position.play(Move::Swap).expect("swap is available");
662 moves += 1;
663 crossed_swap = true;
664 last_move = None;
665 } else {
666 position.rollout_decline_swap();
667 }
668 }
669
670 let mut empty = position.empty_cells();
671 for index in (1..empty.len()).rev() {
672 let other = self.random.index(index + 1);
673 empty.swap(index, other);
674 }
675 let empty_count = empty.len();
676 let mut cursor = 0;
677 while moves - u32::from(crossed_swap) < empty_count as u32 {
678 let response = (policy != RolloutPolicy::Random)
679 .then(|| last_move.and_then(|cell| self.random_bridge_response(position, cell)))
680 .flatten();
681 let cell = if let Some(response) = response {
682 bridge_replies += 1;
683 response
684 } else {
685 loop {
686 let cell = empty[cursor];
687 cursor += 1;
688 if position.color_at(cell).is_none() {
689 break cell;
690 }
691 }
692 };
693 if !crossed_swap {
694 trace.insert(position.color_to_move(), cell);
695 }
696 position.rollout_place(cell);
697 moves += 1;
698 last_move = Some(cell);
699 }
700 let winner = position.winner_on_full_board();
701 Rollout {
702 score: f64::from(winner == root_seat),
703 moves,
704 bridge_replies,
705 trace,
706 }
707 }
708
709 fn random_bridge_response(&mut self, position: Position, attacked: Cell) -> Option<Cell> {
710 let (responses, count) =
711 crate::patterns::bridge_responses(position, position.color_to_move(), attacked);
712 (count > 0).then(|| responses[self.random.index(count)])
713 }
714}
715
716fn mean_score(node: &Node) -> f64 {
717 node.value_sum / f64::from(node.visits.max(1))
718}
719
720fn rave_mean_score(node: &Node) -> f64 {
721 if node.rave_visits == 0 {
722 0.5
723 } else {
724 node.rave_value_sum / f64::from(node.rave_visits)
725 }
726}
727
728fn compare_root_moves(
729 left: &MctsMoveStats,
730 right: &MctsMoveStats,
731 root_seat: Seat,
732) -> std::cmp::Ordering {
733 let rank = |stats: &MctsMoveStats| match stats.proven_winner {
734 Some(winner) if winner == root_seat => 2,
735 None => 1,
736 Some(_) => 0,
737 };
738 rank(right)
739 .cmp(&rank(left))
740 .then_with(|| match (left.proven_winner, right.proven_winner) {
741 (Some(left_winner), Some(right_winner))
742 if left_winner == root_seat && right_winner == root_seat =>
743 {
744 proof_distance_rank(left.proof_distance)
745 .cmp(&proof_distance_rank(right.proof_distance))
746 }
747 (Some(left_winner), Some(right_winner))
748 if left_winner != root_seat && right_winner != root_seat =>
749 {
750 proof_distance_rank(right.proof_distance)
751 .cmp(&proof_distance_rank(left.proof_distance))
752 }
753 _ => right
754 .visits
755 .cmp(&left.visits)
756 .then_with(|| right.expected_score.total_cmp(&left.expected_score)),
757 })
758 .then_with(|| left.mv.order_key().cmp(&right.mv.order_key()))
759}
760
761fn proof_distance_rank(distance: Option<u16>) -> u16 {
762 distance.unwrap_or(u16::MAX)
763}
764
765fn selection_score(node: &Node, root_turn: bool, log_parent: f64, options: MctsOptions) -> f64 {
766 let orient = |score| if root_turn { score } else { 1.0 - score };
767 let direct = orient(mean_score(node));
768 let exploration =
769 options.exploration.max(0.0) * (log_parent / f64::from(node.visits.max(1))).sqrt();
770 if options.strategy == MctsStrategy::PlainUct || node.rave_visits == 0 {
771 return direct + exploration;
772 }
773 let visits = f64::from(node.visits);
774 let rave_visits = f64::from(node.rave_visits);
775 let equivalence = options.rave_equivalence.max(1.0);
776 let beta = rave_visits / (visits + rave_visits + 4.0 * visits * rave_visits / equivalence);
777 (direct + exploration) * (1.0 - beta) + orient(rave_mean_score(node)) * beta
778}
779
780#[derive(Clone, Copy, Default)]
781struct MoveTrace {
782 red: [u64; 9],
783 blue: [u64; 9],
784}
785
786impl MoveTrace {
787 fn insert(&mut self, color: Color, cell: Cell) {
788 let bits = match color {
789 Color::Red => &mut self.red,
790 Color::Blue => &mut self.blue,
791 };
792 let index = usize::from(cell.index());
793 bits[index / 64] |= 1_u64 << (index % 64);
794 }
795
796 fn contains(self, color: Color, cell: Cell) -> bool {
797 let bits = match color {
798 Color::Red => &self.red,
799 Color::Blue => &self.blue,
800 };
801 let index = usize::from(cell.index());
802 bits[index / 64] & (1_u64 << (index % 64)) != 0
803 }
804
805 fn clear(&mut self) {
806 *self = Self::default();
807 }
808
809 fn prepend(&mut self, color: Color, mv: Move) {
810 match mv {
811 Move::Place(cell) => self.insert(color, cell),
812 Move::Swap => self.clear(),
813 }
814 }
815}
816
817struct Rollout {
818 score: f64,
819 moves: u32,
820 bridge_replies: u32,
821 trace: MoveTrace,
822}
823
824struct RolloutSummary {
825 moves: u32,
826 bridge_replies: u32,
827}
828
829#[derive(Default)]
830struct SearchCounters {
831 knowledge_nodes: u32,
832 pruned_moves: u32,
833 must_play_nodes: u32,
834 virtual_connections: u32,
835 semi_connections: u32,
836 connection_search_truncated_nodes: u32,
837 proven_nodes: u32,
838 solver_propagations: u32,
839}
840
841fn coprime_step(cells: u16, random: &mut SplitMix64) -> u16 {
842 loop {
843 let candidate = 1 + random.index(usize::from(cells - 1)) as u16;
844 if gcd(candidate, cells) == 1 {
845 return candidate;
846 }
847 }
848}
849
850const fn gcd(mut left: u16, mut right: u16) -> u16 {
851 while right != 0 {
852 let remainder = left % right;
853 left = right;
854 right = remainder;
855 }
856 left
857}
858
859struct SplitMix64(u64);
860
861impl SplitMix64 {
862 fn next(&mut self) -> u64 {
863 self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
864 let mut value = self.0;
865 value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
866 value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
867 value ^ (value >> 31)
868 }
869
870 fn index(&mut self, length: usize) -> usize {
871 debug_assert!(length > 0);
872 (self.next() as usize) % length
873 }
874}
875
876#[cfg(not(target_arch = "wasm32"))]
877struct Clock {
878 started: Instant,
879 deadline: Option<Instant>,
880}
881
882#[cfg(not(target_arch = "wasm32"))]
883impl Clock {
884 fn new(milliseconds: u32) -> Self {
885 let started = Instant::now();
886 Self {
887 started,
888 deadline: (milliseconds > 0)
889 .then(|| started + Duration::from_millis(u64::from(milliseconds))),
890 }
891 }
892
893 fn expired(&self) -> bool {
894 self.deadline
895 .is_some_and(|deadline| Instant::now() >= deadline)
896 }
897
898 fn elapsed_ms(&self) -> u32 {
899 self.started.elapsed().as_millis().min(u128::from(u32::MAX)) as u32
900 }
901}
902
903#[cfg(target_arch = "wasm32")]
904#[wasm_bindgen]
905extern "C" {
906 #[wasm_bindgen(js_namespace = performance, js_name = now)]
907 fn performance_now() -> f64;
908}
909
910#[cfg(target_arch = "wasm32")]
911struct Clock {
912 started: f64,
913 deadline: Option<f64>,
914}
915
916#[cfg(target_arch = "wasm32")]
917impl Clock {
918 fn new(milliseconds: u32) -> Self {
919 let started = performance_now();
920 Self {
921 started,
922 deadline: (milliseconds > 0).then(|| started + f64::from(milliseconds)),
923 }
924 }
925
926 fn expired(&self) -> bool {
927 self.deadline
928 .is_some_and(|deadline| performance_now() >= deadline)
929 }
930
931 fn elapsed_ms(&self) -> u32 {
932 (performance_now() - self.started).clamp(0.0, f64::from(u32::MAX)) as u32
933 }
934}
935
936#[cfg(test)]
937mod tests {
938 use super::*;
939 use crate::{BoardSize, SwapRule};
940
941 #[test]
942 fn amaf_uses_stone_color_after_the_pie_rule_swap() {
943 let position = Position::new(BoardSize::new(9).unwrap(), SwapRule::Enabled)
944 .play("e5".parse().unwrap())
945 .unwrap()
946 .play(Move::Swap)
947 .unwrap();
948 assert_eq!(position.color_to_move(), Color::Blue);
949
950 let mv: Move = "f5".parse().unwrap();
951 let mut random = SplitMix64(1);
952 let root = Node::new(position, None, None, &mut random);
953 let child = Node::new(position.play(mv).unwrap(), Some(0), Some(mv), &mut random);
954 let mut searcher = MctsSearcher {
955 nodes: vec![root, child],
956 random,
957 };
958 searcher.nodes[0].children.push(1);
959
960 let cell = mv.cell().unwrap();
961 let mut wrong_color = MoveTrace::default();
962 wrong_color.insert(Color::Red, cell);
963 searcher.update_rave(0, wrong_color, 1.0);
964 assert_eq!(searcher.nodes[1].rave_visits, 0);
965
966 let mut correct_color = MoveTrace::default();
967 correct_color.insert(Color::Blue, cell);
968 searcher.update_rave(0, correct_color, 1.0);
969 assert_eq!(searcher.nodes[1].rave_visits, 1);
970 assert_eq!(searcher.nodes[1].rave_value_sum, 1.0);
971 }
972
973 #[test]
974 fn rollout_swap_does_not_leak_amaf_evidence_across_the_color_change() {
975 let position = Position::new(BoardSize::new(9).unwrap(), SwapRule::Enabled)
976 .play("e5".parse().unwrap())
977 .unwrap();
978 let mut saw_swap = false;
979 let mut saw_decline = false;
980 for seed in 0..16 {
981 let mut searcher = MctsSearcher {
982 nodes: Vec::new(),
983 random: SplitMix64(seed),
984 };
985 let rollout = searcher.rollout(position, Seat::Two, None, RolloutPolicy::Random);
986 if rollout.moves == 81 {
987 saw_swap = true;
988 assert_eq!(rollout.trace.red, [0; 9]);
989 assert_eq!(rollout.trace.blue, [0; 9]);
990 } else {
991 saw_decline = true;
992 assert!(rollout.trace.red != [0; 9]);
993 assert!(rollout.trace.blue != [0; 9]);
994 }
995 }
996 assert!(saw_swap && saw_decline);
997 }
998
999 #[test]
1000 fn save_bridge_finds_the_other_carrier() {
1001 let moves = ["d4", "a1", "e5", "e4"].map(|mv| mv.parse().expect("valid move"));
1002 let position =
1003 Position::from_moves(BoardSize::new(9).unwrap(), SwapRule::Disabled, &moves).unwrap();
1004 let attacked = "e4".parse::<Move>().unwrap().cell().unwrap();
1005 let expected = "d5".parse::<Move>().unwrap().cell().unwrap();
1006
1007 let (responses, count) =
1008 crate::patterns::bridge_responses(position, position.color_to_move(), attacked);
1009
1010 assert_eq!(&responses[..count], &[expected]);
1011 }
1012
1013 #[test]
1014 fn adjacent_stones_do_not_create_a_bridge_response() {
1015 let moves = ["d4", "a1", "e4", "d5"].map(|mv| mv.parse().expect("valid move"));
1016 let position =
1017 Position::from_moves(BoardSize::new(9).unwrap(), SwapRule::Disabled, &moves).unwrap();
1018 let attacked = "d5".parse::<Move>().unwrap().cell().unwrap();
1019
1020 let (_, count) =
1021 crate::patterns::bridge_responses(position, position.color_to_move(), attacked);
1022
1023 assert_eq!(count, 0);
1024 }
1025
1026 #[test]
1027 fn tree_swap_is_an_amaf_boundary() {
1028 let cell = Cell::new(2, 3).unwrap();
1029 let mut trace = MoveTrace::default();
1030 trace.insert(Color::Red, cell);
1031 trace.prepend(Color::Blue, Move::Swap);
1032 assert!(!trace.contains(Color::Red, cell));
1033
1034 trace.prepend(Color::Blue, Move::Place(cell));
1035 assert!(trace.contains(Color::Blue, cell));
1036 }
1037
1038 #[test]
1039 fn solver_propagates_a_winning_child() {
1040 let position = Position::new(BoardSize::for_test(3), SwapRule::Disabled);
1041 let mut random = SplitMix64(1);
1042 let mut root = Node::new(position, None, None, &mut random);
1043 let mv = Move::Place(Cell::new(1, 1).unwrap());
1044 let mut child = Node::new(position.play(mv).unwrap(), Some(0), Some(mv), &mut random);
1045 child.proof = Some(Proof {
1046 winner: Seat::One,
1047 distance: Some(2),
1048 });
1049 root.children.push(1);
1050 let mut searcher = MctsSearcher {
1051 nodes: vec![root, child],
1052 random,
1053 };
1054
1055 searcher.refresh_proof(0, &mut SearchCounters::default());
1056
1057 assert_eq!(
1058 searcher.nodes[0].proof,
1059 Some(Proof {
1060 winner: Seat::One,
1061 distance: Some(3),
1062 })
1063 );
1064 }
1065
1066 #[test]
1067 fn solver_proves_a_loss_only_after_every_move_is_proven() {
1068 let position = Position::new(BoardSize::for_test(3), SwapRule::Disabled);
1069 let mut random = SplitMix64(1);
1070 let mut root = Node::new(position, None, None, &mut random);
1071 root.next_slot = position.size().cell_count();
1072 let mut nodes = vec![root];
1073 for (distance, mv) in position.legal_moves().into_iter().enumerate() {
1074 let mut child = Node::new(position.play(mv).unwrap(), Some(0), Some(mv), &mut random);
1075 child.proof = Some(Proof {
1076 winner: Seat::Two,
1077 distance: Some(distance as u16),
1078 });
1079 nodes.push(child);
1080 let child = nodes.len() - 1;
1081 nodes[0].children.push(child);
1082 }
1083 let mut searcher = MctsSearcher { nodes, random };
1084
1085 searcher.refresh_proof(0, &mut SearchCounters::default());
1086
1087 assert_eq!(
1088 searcher.nodes[0].proof,
1089 Some(Proof {
1090 winner: Seat::Two,
1091 distance: Some(9),
1092 })
1093 );
1094 }
1095
1096 #[test]
1097 fn solver_does_not_invent_a_distance_for_a_knowledge_proof() {
1098 let position = Position::new(BoardSize::for_test(3), SwapRule::Disabled);
1099 let mut random = SplitMix64(1);
1100 let mut root = Node::new(position, None, None, &mut random);
1101 let mv = Move::Place(Cell::new(1, 1).unwrap());
1102 let mut child = Node::new(position.play(mv).unwrap(), Some(0), Some(mv), &mut random);
1103 child.proof = Some(Proof {
1104 winner: Seat::One,
1105 distance: None,
1106 });
1107 root.children.push(1);
1108 let mut searcher = MctsSearcher {
1109 nodes: vec![root, child],
1110 random,
1111 };
1112
1113 searcher.refresh_proof(0, &mut SearchCounters::default());
1114
1115 assert_eq!(
1116 searcher.nodes[0].proof,
1117 Some(Proof {
1118 winner: Seat::One,
1119 distance: None,
1120 })
1121 );
1122 }
1123
1124 #[test]
1125 fn virtual_connection_key_ends_a_solved_root_search() {
1126 let moves = ["b1", "c1", "a3", "c2"].map(|mv| mv.parse().unwrap());
1127 let position =
1128 Position::from_moves(BoardSize::for_test(3), SwapRule::Disabled, &moves).unwrap();
1129 let report = MctsSearcher::new().search(
1130 position,
1131 MctsOptions {
1132 max_simulations: 100,
1133 soft_time_ms: 0,
1134 knowledge_policy: KnowledgePolicy::InferiorCells { min_visits: 32 },
1135 ..MctsOptions::default()
1136 },
1137 );
1138
1139 assert_eq!(report.best_move, Some("b2".parse().unwrap()));
1140 assert_eq!(report.proven_winner, Some(position.seat_to_move()));
1141 assert_eq!(report.proof_distance, None);
1142 assert_eq!(report.simulations, 1);
1143 assert!(report.virtual_connections > 0);
1144 assert!(report.semi_connections > 0);
1145 }
1146
1147 #[test]
1148 fn rave_blends_the_complete_uct_score() {
1149 let position = Position::new(BoardSize::new(9).unwrap(), SwapRule::Disabled);
1150 let mut random = SplitMix64(1);
1151 let mut node = Node::new(position, None, None, &mut random);
1152 node.visits = 10;
1153 node.value_sum = 6.0;
1154 node.rave_visits = 10;
1155 node.rave_value_sum = 8.0;
1156 let options = MctsOptions {
1157 max_simulations: 1,
1158 soft_time_ms: 0,
1159 exploration: 1.0,
1160 strategy: MctsStrategy::UctRave,
1161 rave_equivalence: 40.0,
1162 rollout_policy: RolloutPolicy::Random,
1163 knowledge_policy: KnowledgePolicy::Disabled,
1164 use_virtual_connections: false,
1165 seed: 1,
1166 };
1167
1168 let beta = 10.0 / (10.0 + 10.0 + 4.0 * 10.0 * 10.0 / 40.0);
1169 let exploration = (100.0_f64.ln() / 10.0).sqrt();
1170 let expected = (0.6 + exploration) * (1.0 - beta) + 0.8 * beta;
1171 let actual = selection_score(&node, true, 100.0_f64.ln(), options);
1172
1173 assert!((actual - expected).abs() < 1e-12);
1174 }
1175
1176 #[test]
1177 fn selection_orients_direct_and_rave_scores_for_the_opponent() {
1178 let position = Position::new(BoardSize::new(9).unwrap(), SwapRule::Disabled);
1179 let mut random = SplitMix64(1);
1180 let mut node = Node::new(position, None, None, &mut random);
1181 node.visits = 10;
1182 node.value_sum = 7.0;
1183 node.rave_visits = 10;
1184 node.rave_value_sum = 9.0;
1185 let options = MctsOptions {
1186 max_simulations: 1,
1187 soft_time_ms: 0,
1188 exploration: 0.0,
1189 strategy: MctsStrategy::UctRave,
1190 rave_equivalence: 40.0,
1191 rollout_policy: RolloutPolicy::Random,
1192 knowledge_policy: KnowledgePolicy::Disabled,
1193 use_virtual_connections: false,
1194 seed: 1,
1195 };
1196
1197 let beta = 10.0 / (10.0 + 10.0 + 4.0 * 10.0 * 10.0 / 40.0);
1198 let expected = 0.3 * (1.0 - beta) + 0.1 * beta;
1199 let actual = selection_score(&node, false, 0.0, options);
1200
1201 assert!((actual - expected).abs() < 1e-12);
1202 }
1203}