Skip to main content

ai_hex/
mcts.rs

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}