use crate::board::{
BOARD_SIZE, BitBoard, Board, BoardSearchState, Move, NUM_CELLS, RuleSet, Stone,
};
use crate::heuristic::{DIR, scan_line};
use crate::pattern_table::{
PATTERN_RARE_ID, WindowThreat, pattern_threat_after_my_play, pattern_threat_after_my_play_caro,
pattern_threat_after_my_play_exact5, read_window, swap_mapped_id, visit_gap_four_gaps,
window_has_gap_four, window_has_jump_three,
};
use noru::trainer::SimpleRng;
use serde_json::{Value, json};
use std::collections::{BTreeMap, HashMap};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
static ZOBRIST_KEYS: OnceLock<[[u64; 2]; NUM_CELLS]> = OnceLock::new();
const ZOBRIST_SIDE_WHITE: u64 = 0x5A5A_5A5A_A5A5_A5A5;
fn zobrist_keys() -> &'static [[u64; 2]; NUM_CELLS] {
ZOBRIST_KEYS.get_or_init(|| {
let mut rng = SimpleRng::new(0xDEAD_BEEF_CAFE_BABE);
let mut arr = [[0u64; 2]; NUM_CELLS];
for slot in arr.iter_mut() {
slot[0] = rng.next_u64();
slot[1] = rng.next_u64();
}
arr
})
}
fn zobrist_hash(board: &Board) -> u64 {
let keys = zobrist_keys();
let mut h = 0u64;
for idx in 0..NUM_CELLS {
if board.black.get(idx) {
h ^= keys[idx][0];
}
if board.white.get(idx) {
h ^= keys[idx][1];
}
}
if board.side_to_move == Stone::White {
h ^= ZOBRIST_SIDE_WHITE;
}
h
}
#[derive(Clone, Copy)]
struct TtEntry {
depth: u32,
result: TtResult,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum TtResult {
AttackerWins,
Fails,
}
type TransTable = HashMap<u64, TtEntry>;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum LineThreat {
None,
OpenTwo, ClosedThree, OpenThree, ClosedFour, OpenFour, Five, }
fn classify_line(count: u32, open_ends: u32, rule_set: RuleSet, side: Stone) -> LineThreat {
if rule_set.line_wins(side, count, open_ends) {
return LineThreat::Five;
}
match (count, open_ends) {
(4, 2) => LineThreat::OpenFour,
(4, 1) => LineThreat::ClosedFour,
(3, 2) => LineThreat::OpenThree,
(3, 1) => LineThreat::ClosedThree,
(2, 2) => LineThreat::OpenTwo,
_ => LineThreat::None,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum ThreatKind {
None = 0,
ClosedFour = 1,
OpenThree = 2,
Five = 3,
OpenFour = 4,
DoubleFour = 5,
FourThree = 6,
DoubleThree = 7,
JumpThree = 8,
}
pub const THREAT_KIND_COUNT: usize = 9;
impl ThreatKind {
pub fn is_winning(self) -> bool {
matches!(
self,
ThreatKind::Five
| ThreatKind::OpenFour
| ThreatKind::DoubleFour
| ThreatKind::FourThree
| ThreatKind::DoubleThree
)
}
pub fn is_forcing(self) -> bool {
matches!(
self,
ThreatKind::ClosedFour | ThreatKind::OpenThree | ThreatKind::JumpThree
) || self.is_winning()
}
}
fn is_vct_terminal_win(kind: ThreatKind) -> bool {
matches!(
kind,
ThreatKind::Five | ThreatKind::OpenFour | ThreatKind::DoubleFour
)
}
pub fn classify_move(my_bb: &BitBoard, opp_bb: &BitBoard, mv: Move, exact5: bool) -> ThreatKind {
let rule_set = if exact5 {
RuleSet::Standard
} else {
RuleSet::Freestyle
};
classify_move_rules(my_bb, opp_bb, mv, Stone::Black, rule_set)
}
pub fn classify_move_rules(
my_bb: &BitBoard,
opp_bb: &BitBoard,
mv: Move,
side: Stone,
rule_set: RuleSet,
) -> ThreatKind {
classify_move_rules_with_flags(my_bb, opp_bb, mv, side, rule_set, false, false)
}
#[cfg(test)]
fn classify_move_rules_with_jump_three(
my_bb: &BitBoard,
opp_bb: &BitBoard,
mv: Move,
side: Stone,
rule_set: RuleSet,
enable_jump_three: bool,
) -> ThreatKind {
classify_move_rules_with_flags(my_bb, opp_bb, mv, side, rule_set, enable_jump_three, false)
}
fn classify_move_rules_with_flags(
my_bb: &BitBoard,
opp_bb: &BitBoard,
mv: Move,
side: Stone,
rule_set: RuleSet,
enable_jump_three: bool,
enable_gap_four: bool,
) -> ThreatKind {
let row = (mv / BOARD_SIZE) as i32;
let col = (mv % BOARD_SIZE) as i32;
let mut my_tmp = *my_bb;
my_tmp.set(mv);
let mut fours = 0u32;
let mut open_fours = 0u32;
let mut open_threes = 0u32;
let mut closed_fours = 0u32;
let mut fives = 0u32;
let mut jump_threes = 0u32;
for &(dr, dc) in &DIR {
let info = scan_line(&my_tmp, opp_bb, row, col, dr, dc);
let open_ends = info.open_front as u32 + info.open_back as u32;
let mut line_threat = classify_line(info.count, open_ends, rule_set, side);
let mut window = None;
if enable_gap_four
&& !matches!(
line_threat,
LineThreat::Five | LineThreat::OpenFour | LineThreat::ClosedFour
)
{
let w = read_window(&my_tmp, opp_bb, row, col, dr, dc);
if window_has_gap_four(&w) {
line_threat = LineThreat::ClosedFour;
}
window = Some(w);
}
match line_threat {
LineThreat::Five => fives += 1,
LineThreat::OpenFour => {
open_fours += 1;
fours += 1;
}
LineThreat::ClosedFour => {
closed_fours += 1;
fours += 1;
}
LineThreat::OpenThree => open_threes += 1,
_ => {}
}
if enable_jump_three
&& !matches!(
line_threat,
LineThreat::Five | LineThreat::OpenFour | LineThreat::ClosedFour
)
{
let w = window.unwrap_or_else(|| read_window(&my_tmp, opp_bb, row, col, dr, dc));
if window_has_jump_three(&w) {
jump_threes += 1;
}
}
}
if fives >= 1 {
return ThreatKind::Five;
}
if open_fours >= 1 {
return ThreatKind::OpenFour;
}
if fours >= 2 {
return ThreatKind::DoubleFour;
}
if closed_fours >= 1 && open_threes >= 1 {
return ThreatKind::FourThree;
}
if open_threes >= 2 {
return ThreatKind::DoubleThree;
}
if closed_fours >= 1 {
return ThreatKind::ClosedFour;
}
if open_threes >= 1 {
return ThreatKind::OpenThree;
}
if enable_jump_three && jump_threes >= 1 {
return ThreatKind::JumpThree;
}
ThreatKind::None
}
pub fn classify_move_fast(board: &Board, mv: Move, side: Stone) -> ThreatKind {
classify_move_fast_with_flags(board, mv, side, false, false)
}
pub fn classify_move_rules_with_flags_for_audit(
board: &Board,
mv: Move,
side: Stone,
enable_jump_three: bool,
enable_gap_four: bool,
) -> ThreatKind {
debug_assert!(board.is_empty(mv), "candidate move cell must be empty");
let (my, opp) = bb_pair(board, side);
classify_move_rules_with_flags(
my,
opp,
mv,
side,
board.effective_rule_set(),
enable_jump_three,
enable_gap_four,
)
}
pub fn classify_move_fast_with_flags_for_audit(
board: &Board,
mv: Move,
side: Stone,
enable_jump_three: bool,
enable_gap_four: bool,
) -> ThreatKind {
debug_assert!(board.is_empty(mv), "candidate move cell must be empty");
classify_move_fast_with_flags(board, mv, side, enable_jump_three, enable_gap_four)
}
#[cfg(test)]
fn classify_move_fast_with_jump_three(
board: &Board,
mv: Move,
side: Stone,
enable_jump_three: bool,
) -> ThreatKind {
classify_move_fast_with_flags(board, mv, side, enable_jump_three, false)
}
pub(crate) fn classify_move_fast_with_flags(
board: &Board,
mv: Move,
side: Stone,
enable_jump_three: bool,
enable_gap_four: bool,
) -> ThreatKind {
let row = (mv / BOARD_SIZE) as i32;
let col = (mv % BOARD_SIZE) as i32;
let side_is_black = matches!(side, Stone::Black);
let (mine, opp) = if side_is_black {
(&board.black, &board.white)
} else {
(&board.white, &board.black)
};
let rule_set = board.effective_rule_set();
let mut fours = 0u32;
let mut open_fours = 0u32;
let mut closed_fours = 0u32;
let mut open_threes = 0u32;
let mut fives = 0u32;
let mut jump_threes = 0u32;
for (dir_idx, &(dr, dc)) in DIR.iter().enumerate() {
let pid_black = board.line_pattern_ids[mv][dir_idx];
let pid_my = if side_is_black {
pid_black
} else {
swap_mapped_id(pid_black)
};
let mut threat = if pid_my == PATTERN_RARE_ID {
let mut w = read_window(mine, opp, row, col, dr, dc);
debug_assert_eq!(w[5], 0, "candidate move cell must be empty");
w[5] = 1;
let mut count = 1u32;
let mut open_front = false;
for off in 1usize..=5 {
match w[5 + off] {
1 => count += 1,
0 => {
open_front = true;
break;
}
_ => break,
}
}
let mut open_back = false;
for off in 1usize..=5 {
match w[5 - off] {
1 => count += 1,
0 => {
open_back = true;
break;
}
_ => break,
}
}
let open_ends = open_front as u32 + open_back as u32;
if rule_set.line_wins(side, count, open_ends) {
WindowThreat::Five
} else {
match (count, open_ends) {
(4, 2) => WindowThreat::OpenFour,
(4, 1) => WindowThreat::ClosedFour,
(3, 2) => WindowThreat::OpenThree,
(3, 1) => WindowThreat::ClosedThree,
_ if enable_gap_four && window_has_gap_four(&w) => WindowThreat::ClosedFour,
_ if enable_jump_three && window_has_jump_three(&w) => WindowThreat::JumpThree,
(2, 2) => WindowThreat::OpenTwo,
_ => WindowThreat::None,
}
}
} else {
match rule_set {
RuleSet::Caro => pattern_threat_after_my_play_caro(pid_my),
RuleSet::Standard => pattern_threat_after_my_play_exact5(pid_my),
RuleSet::Renju if matches!(side, Stone::Black) => {
pattern_threat_after_my_play_exact5(pid_my)
}
_ => pattern_threat_after_my_play(pid_my),
}
};
if enable_gap_four
&& !matches!(
threat,
WindowThreat::Five | WindowThreat::OpenFour | WindowThreat::ClosedFour
)
{
let mut w = read_window(mine, opp, row, col, dr, dc);
debug_assert_eq!(w[5], 0, "candidate move cell must be empty");
w[5] = 1;
if window_has_gap_four(&w) {
threat = WindowThreat::ClosedFour;
}
}
match threat {
WindowThreat::Five => fives += 1,
WindowThreat::OpenFour => {
open_fours += 1;
fours += 1;
}
WindowThreat::ClosedFour => {
closed_fours += 1;
fours += 1;
}
WindowThreat::OpenThree => open_threes += 1,
WindowThreat::JumpThree if enable_jump_three => jump_threes += 1,
_ => {}
}
let _ = (dr, dc);
}
if fives >= 1 {
return ThreatKind::Five;
}
if open_fours >= 1 {
return ThreatKind::OpenFour;
}
if fours >= 2 {
return ThreatKind::DoubleFour;
}
if closed_fours >= 1 && open_threes >= 1 {
return ThreatKind::FourThree;
}
if open_threes >= 2 {
return ThreatKind::DoubleThree;
}
if closed_fours >= 1 {
return ThreatKind::ClosedFour;
}
if open_threes >= 1 {
return ThreatKind::OpenThree;
}
if enable_jump_three && jump_threes >= 1 {
return ThreatKind::JumpThree;
}
ThreatKind::None
}
pub struct VctConfig {
pub max_depth: u32,
pub time_budget: Option<Duration>,
pub node_budget: Option<u64>,
pub enable_jump_three: bool,
pub enable_jump_three_attack_defense: bool,
pub enable_jump_three_counter: bool,
pub enable_jump_three_kind_scoped_defense: bool,
pub jump_attack_max_or_levels: u32,
pub enable_gap_four: bool,
pub gap_four_attack_max_or_levels: u32,
pub use_fast_classify: bool,
pub use_threat_index: bool,
pub profile: bool,
pub use_reach_mask: bool,
pub use_fast_immediate_five: bool,
pub use_vct_scratch_buffers: bool,
}
#[derive(Clone, Copy, Debug, Default)]
struct VctJumpThreeFlags {
attack_defense: bool,
counter: bool,
kind_scoped_defense: bool,
attack_max_or_levels: u32,
gap_four: bool,
gap_four_attack_max_or_levels: u32,
use_fast_classify: bool,
use_threat_index: bool,
use_reach_mask: bool,
use_fast_immediate_five: bool,
use_vct_scratch_buffers: bool,
}
impl VctConfig {
fn jump_three_flags(&self) -> VctJumpThreeFlags {
VctJumpThreeFlags {
attack_defense: self.enable_jump_three || self.enable_jump_three_attack_defense,
counter: self.enable_jump_three || self.enable_jump_three_counter,
kind_scoped_defense: self.enable_jump_three_kind_scoped_defense,
attack_max_or_levels: self.jump_attack_max_or_levels,
gap_four: self.enable_gap_four,
gap_four_attack_max_or_levels: self.gap_four_attack_max_or_levels,
use_fast_classify: self.use_fast_classify,
use_threat_index: self.use_threat_index,
use_reach_mask: self.use_reach_mask,
use_fast_immediate_five: self.use_fast_immediate_five,
use_vct_scratch_buffers: self.use_vct_scratch_buffers,
}
}
}
#[inline]
fn gap_four_attack_enabled(flags: &VctJumpThreeFlags, or_level: u32) -> bool {
flags.gap_four && or_level < flags.gap_four_attack_max_or_levels
}
#[derive(Default)]
struct VctScratch {
attack_pool: Vec<Vec<(Move, ThreatKind)>>,
defense_pool: Vec<Vec<Move>>,
}
impl VctScratch {
fn take_attack(&mut self, enabled: bool) -> Vec<(Move, ThreatKind)> {
if enabled {
self.attack_pool.pop().unwrap_or_default()
} else {
Vec::new()
}
}
fn put_attack(&mut self, enabled: bool, mut buf: Vec<(Move, ThreatKind)>) {
if enabled {
buf.clear();
self.attack_pool.push(buf);
}
}
fn take_defense(&mut self, enabled: bool) -> Vec<Move> {
if enabled {
self.defense_pool.pop().unwrap_or_default()
} else {
Vec::new()
}
}
fn put_defense(&mut self, enabled: bool, mut buf: Vec<Move>) {
if enabled {
buf.clear();
self.defense_pool.push(buf);
}
}
}
const THREAT_INDEX_FLAG_COMBOS: usize = 4;
const THREAT_INDEX_SIDES: usize = 2;
#[derive(Clone)]
struct VctThreatIndex {
cell_kinds: Box<[[[u8; NUM_CELLS]; THREAT_INDEX_FLAG_COMBOS]; THREAT_INDEX_SIDES]>,
bits: [[[BitBoard; THREAT_KIND_COUNT]; THREAT_INDEX_FLAG_COMBOS]; THREAT_INDEX_SIDES],
}
impl VctThreatIndex {
fn new(board: &Board) -> Self {
let mut index = Self {
cell_kinds: Box::new(
[[[ThreatKind::None as u8; NUM_CELLS]; THREAT_INDEX_FLAG_COMBOS];
THREAT_INDEX_SIDES],
),
bits: [[[BitBoard::EMPTY; THREAT_KIND_COUNT]; THREAT_INDEX_FLAG_COMBOS];
THREAT_INDEX_SIDES],
};
for cell in 0..NUM_CELLS {
index.recompute_cell(board, cell);
}
index
}
fn attack_moves(
&self,
side: Stone,
enable_jump_three: bool,
enable_gap_four: bool,
) -> Vec<(Move, ThreatKind)> {
let side_idx = threat_index_side(side);
let flags_idx = threat_index_flags(enable_jump_three, enable_gap_four);
let mut out = Vec::new();
for &kind in &THREAT_PRIORITY_ORDER {
let bits = self.bits[side_idx][flags_idx][kind as usize];
for mv in bits.iter_ones() {
out.push((mv, kind));
}
}
out
}
fn forcing_moves_in_cell_order(
&self,
side: Stone,
enable_jump_three: bool,
enable_gap_four: bool,
) -> Vec<Move> {
let side_idx = threat_index_side(side);
let flags_idx = threat_index_flags(enable_jump_three, enable_gap_four);
let mut out = Vec::new();
for cell in 0..NUM_CELLS {
let kind = threat_kind_from_index(self.cell_kinds[side_idx][flags_idx][cell]);
if kind.is_forcing() {
out.push(cell);
}
}
out
}
fn has_kind(
&self,
side: Stone,
enable_jump_three: bool,
enable_gap_four: bool,
kind: ThreatKind,
) -> bool {
self.bits[threat_index_side(side)][threat_index_flags(enable_jump_three, enable_gap_four)]
[kind as usize]
.count_ones()
> 0
}
fn clear_cell(&mut self, cell: usize) {
for side_idx in 0..THREAT_INDEX_SIDES {
for flags_idx in 0..THREAT_INDEX_FLAG_COMBOS {
let kind_idx = self.cell_kinds[side_idx][flags_idx][cell] as usize;
if kind_idx != ThreatKind::None as usize {
self.bits[side_idx][flags_idx][kind_idx].clear(cell);
self.cell_kinds[side_idx][flags_idx][cell] = ThreatKind::None as u8;
}
}
}
}
fn recompute_cell(&mut self, board: &Board, cell: usize) {
self.clear_cell(cell);
if !board.is_empty(cell) {
return;
}
for &side in &[Stone::Black, Stone::White] {
let side_idx = threat_index_side(side);
for flags_idx in 0..THREAT_INDEX_FLAG_COMBOS {
let (enable_jump_three, enable_gap_four) = threat_index_combo_flags(flags_idx);
let kind = classify_move_fast_with_flags(
board,
cell,
side,
enable_jump_three,
enable_gap_four,
);
self.cell_kinds[side_idx][flags_idx][cell] = kind as u8;
if kind != ThreatKind::None {
self.bits[side_idx][flags_idx][kind as usize].set(cell);
}
}
}
}
fn clear_cells(&mut self, cells: &[usize]) {
for &cell in cells {
self.clear_cell(cell);
}
}
fn recompute_cells(&mut self, board: &Board, cells: &[usize]) {
for &cell in cells {
self.recompute_cell(board, cell);
}
}
fn matches_rebuild(&self, board: &Board) -> bool {
let rebuilt = VctThreatIndex::new(board);
self.cell_kinds == rebuilt.cell_kinds && self.bits == rebuilt.bits
}
#[cfg(test)]
fn assert_matches_rebuild(&self, board: &Board) {
assert!(
self.matches_rebuild(board),
"threat-index mismatch against rebuild"
);
}
}
const THREAT_PRIORITY_ORDER: [ThreatKind; THREAT_KIND_COUNT - 1] = [
ThreatKind::Five,
ThreatKind::OpenFour,
ThreatKind::DoubleFour,
ThreatKind::FourThree,
ThreatKind::DoubleThree,
ThreatKind::ClosedFour,
ThreatKind::OpenThree,
ThreatKind::JumpThree,
];
#[inline]
fn threat_index_side(side: Stone) -> usize {
match side {
Stone::Black => 0,
Stone::White => 1,
}
}
#[inline]
fn threat_index_flags(enable_jump_three: bool, enable_gap_four: bool) -> usize {
(enable_jump_three as usize) | ((enable_gap_four as usize) << 1)
}
#[inline]
fn threat_index_combo_flags(flags_idx: usize) -> (bool, bool) {
((flags_idx & 1) != 0, (flags_idx & 2) != 0)
}
fn threat_kind_from_index(kind: u8) -> ThreatKind {
match kind as usize {
x if x == ThreatKind::Five as usize => ThreatKind::Five,
x if x == ThreatKind::OpenFour as usize => ThreatKind::OpenFour,
x if x == ThreatKind::ClosedFour as usize => ThreatKind::ClosedFour,
x if x == ThreatKind::OpenThree as usize => ThreatKind::OpenThree,
x if x == ThreatKind::DoubleThree as usize => ThreatKind::DoubleThree,
x if x == ThreatKind::FourThree as usize => ThreatKind::FourThree,
x if x == ThreatKind::DoubleFour as usize => ThreatKind::DoubleFour,
x if x == ThreatKind::JumpThree as usize => ThreatKind::JumpThree,
_ => ThreatKind::None,
}
}
fn line_pattern_dirty_cells(mv: Move) -> Vec<usize> {
let mut seen = [false; NUM_CELLS];
let mut out = Vec::with_capacity(44);
let row = (mv / BOARD_SIZE) as i32;
let col = (mv % BOARD_SIZE) as i32;
for &(dr, dc) in &DIR {
for offset in -5i32..=5 {
let r = row + dr * offset;
let c = col + dc * offset;
if r < 0 || r >= BOARD_SIZE as i32 || c < 0 || c >= BOARD_SIZE as i32 {
continue;
}
let cell = (r as usize) * BOARD_SIZE + c as usize;
if !seen[cell] {
seen[cell] = true;
out.push(cell);
}
}
}
out
}
fn make_vct_move(board: &mut Board, threat_index: &mut Option<VctThreatIndex>, mv: Move) {
make_vct_move_with_board_search_state(board, threat_index, None, mv);
}
fn make_vct_move_with_board_search_state(
board: &mut Board,
threat_index: &mut Option<VctThreatIndex>,
board_search_state: Option<&mut BoardSearchState>,
mv: Move,
) {
if let Some(index) = threat_index {
let dirty = line_pattern_dirty_cells(mv);
index.clear_cells(&dirty);
if let Some(state) = board_search_state {
state.make_move_synchronized(board, mv);
} else {
board.make_move(mv);
}
index.recompute_cells(board, &dirty);
} else if let Some(state) = board_search_state {
state.make_move_synchronized(board, mv);
} else {
board.make_move(mv);
}
}
fn undo_vct_move(board: &mut Board, threat_index: &mut Option<VctThreatIndex>) {
undo_vct_move_with_board_search_state(board, threat_index, None);
}
fn undo_vct_move_with_board_search_state(
board: &mut Board,
threat_index: &mut Option<VctThreatIndex>,
board_search_state: Option<&mut BoardSearchState>,
) {
if let Some(index) = threat_index {
if let Some(mv) = board.last_move {
let dirty = line_pattern_dirty_cells(mv);
index.clear_cells(&dirty);
if let Some(state) = board_search_state {
state.undo_move_synchronized(board);
} else {
board.undo_move();
}
index.recompute_cells(board, &dirty);
} else if let Some(state) = board_search_state {
state.undo_move_synchronized(board);
} else {
board.undo_move();
}
} else if let Some(state) = board_search_state {
state.undo_move_synchronized(board);
} else {
board.undo_move();
}
}
fn make_vct_move_profiled(
board: &mut Board,
threat_index: &mut Option<VctThreatIndex>,
board_search_state: Option<&mut BoardSearchState>,
mv: Move,
stats: &mut VctSearchStats,
) {
let start = profile_start(stats);
make_vct_move_with_board_search_state(board, threat_index, board_search_state, mv);
if let Some(start) = start {
stats.profile.make_move_calls += 1;
stats.profile.make_move_ns += start.elapsed().as_nanos();
}
}
fn undo_vct_move_profiled(
board: &mut Board,
threat_index: &mut Option<VctThreatIndex>,
board_search_state: Option<&mut BoardSearchState>,
stats: &mut VctSearchStats,
) {
let start = profile_start(stats);
undo_vct_move_with_board_search_state(board, threat_index, board_search_state);
if let Some(start) = start {
stats.profile.undo_move_calls += 1;
stats.profile.undo_move_ns += start.elapsed().as_nanos();
}
}
#[doc(hidden)]
pub fn vct_threat_index_transition_check_for_audit(
moves: &[Move],
) -> Result<(usize, usize), String> {
let mut board = Board::new();
let mut threat_index = Some(VctThreatIndex::new(&board));
let mut transitions = 0usize;
for &mv in moves {
if mv >= NUM_CELLS {
return Err(format!("move out of board: {mv}"));
}
if !board.is_empty(mv) {
return Err(format!("occupied move at ply {transitions}: {mv}"));
}
make_vct_move(&mut board, &mut threat_index, mv);
transitions += 1;
if !threat_index.as_ref().unwrap().matches_rebuild(&board) {
return Err(format!(
"index mismatch after make ply {transitions} move {mv}"
));
}
}
let mut undos = 0usize;
while board.last_move.is_some() {
let ply_before = board.move_count;
undo_vct_move(&mut board, &mut threat_index);
undos += 1;
if !threat_index.as_ref().unwrap().matches_rebuild(&board) {
return Err(format!("index mismatch after undo from ply {ply_before}"));
}
}
Ok((transitions, undos))
}
#[derive(Clone, Debug, Default)]
pub struct VctProfileStats {
pub make_move_calls: u64,
pub make_move_ns: u128,
pub undo_move_calls: u64,
pub undo_move_ns: u128,
pub gather_calls: u64,
pub gather_total_ns: u128,
pub gather_classify_calls: u64,
pub gather_classify_ns: u128,
pub gather_sort_ns: u128,
pub gather_len_sum: u64,
pub gather_len_max: u64,
pub gather_len_buckets: [u64; 17],
pub find_defenses_calls: u64,
pub find_defenses_total_ns: u128,
pub find_defenses_direct_ns: u128,
pub find_defenses_counter_scan_ns: u128,
pub find_defenses_counter_classify_calls: u64,
pub find_defenses_counter_classify_ns: u128,
pub tt_hash_calls: u64,
pub tt_hash_ns: u128,
pub tt_get_calls: u64,
pub tt_get_ns: u128,
pub tt_insert_calls: u64,
pub tt_insert_ns: u128,
pub immediate_five_calls: u64,
pub immediate_five_ns: u128,
pub terminal_check_calls: u64,
pub terminal_check_ns: u128,
}
impl VctProfileStats {
fn record_gather_len(&mut self, len: usize) {
self.gather_len_sum += len as u64;
self.gather_len_max = self.gather_len_max.max(len as u64);
let bucket = len.min(self.gather_len_buckets.len() - 1);
self.gather_len_buckets[bucket] += 1;
}
fn known_ns(&self) -> u128 {
self.make_move_ns
+ self.undo_move_ns
+ self.gather_total_ns
+ self.find_defenses_total_ns
+ self.tt_hash_ns
+ self.tt_get_ns
+ self.tt_insert_ns
+ self.immediate_five_ns
+ self.terminal_check_ns
}
pub fn to_json(&self) -> Value {
json!({
"make_move": {"calls": self.make_move_calls, "ns": self.make_move_ns},
"undo_move": {"calls": self.undo_move_calls, "ns": self.undo_move_ns},
"gather_attack_moves": {
"calls": self.gather_calls,
"ns": self.gather_total_ns,
"classify_calls": self.gather_classify_calls,
"classify_ns": self.gather_classify_ns,
"sort_ns": self.gather_sort_ns,
"other_ns": self.gather_total_ns.saturating_sub(self.gather_classify_ns + self.gather_sort_ns),
"len_sum": self.gather_len_sum,
"len_max": self.gather_len_max,
"len_buckets_0_15_plus": self.gather_len_buckets,
},
"find_defenses_with_counters": {
"calls": self.find_defenses_calls,
"ns": self.find_defenses_total_ns,
"direct_ns": self.find_defenses_direct_ns,
"counter_scan_ns": self.find_defenses_counter_scan_ns,
"counter_classify_calls": self.find_defenses_counter_classify_calls,
"counter_classify_ns": self.find_defenses_counter_classify_ns,
"other_ns": self.find_defenses_total_ns.saturating_sub(self.find_defenses_direct_ns + self.find_defenses_counter_scan_ns),
},
"tt": {
"hash_calls": self.tt_hash_calls,
"hash_ns": self.tt_hash_ns,
"get_calls": self.tt_get_calls,
"get_ns": self.tt_get_ns,
"insert_calls": self.tt_insert_calls,
"insert_ns": self.tt_insert_ns,
},
"immediate_five": {"calls": self.immediate_five_calls, "ns": self.immediate_five_ns},
"terminal_check": {"calls": self.terminal_check_calls, "ns": self.terminal_check_ns},
"known_ns": self.known_ns(),
})
}
}
#[derive(Clone, Debug, Default)]
pub struct VctSearchStats {
pub nodes: u64,
pub deadline_hits: u64,
pub node_budget_hits: u64,
pub profile: VctProfileStats,
profile_enabled: bool,
}
impl VctSearchStats {
fn enter_node(&mut self) {
self.nodes += 1;
}
fn mark_deadline(&mut self) {
self.deadline_hits += 1;
}
fn mark_node_budget(&mut self) {
self.node_budget_hits += 1;
}
pub fn hit_deadline(&self) -> bool {
self.deadline_hits > 0
}
pub fn hit_node_budget(&self) -> bool {
self.node_budget_hits > 0
}
pub fn hit_stop(&self) -> bool {
self.hit_deadline() || self.hit_node_budget()
}
#[inline]
fn profiling(&self) -> bool {
self.profile_enabled
}
}
#[inline]
fn profile_start(stats: &VctSearchStats) -> Option<Instant> {
if stats.profiling() {
Some(Instant::now())
} else {
None
}
}
#[inline]
fn elapsed_ns(start: Option<Instant>) -> u128 {
start.map(|s| s.elapsed().as_nanos()).unwrap_or(0)
}
fn reach_mask_for_stones(stones: &BitBoard) -> BitBoard {
let mut mask = BitBoard::EMPTY;
for idx in stones.iter_ones() {
let row = (idx / BOARD_SIZE) as i32;
let col = (idx % BOARD_SIZE) as i32;
for &(dr, dc) in &DIR {
for sign in [-1, 1] {
for dist in 1..=4 {
let r = row + dr * sign * dist;
let c = col + dc * sign * dist;
if !in_board(r, c) {
break;
}
mask.set((r as usize) * BOARD_SIZE + c as usize);
}
}
}
}
mask
}
fn reach_mask_for_side(board: &Board, side: Stone) -> BitBoard {
let (my, _) = bb_pair(board, side);
reach_mask_for_stones(my)
}
#[doc(hidden)]
pub fn reach_mask_for_side_for_audit(board: &Board, side: Stone) -> BitBoard {
reach_mask_for_side(board, side)
}
#[derive(Clone, Debug)]
pub struct VctSearchResult {
pub sequence: Option<Vec<Move>>,
pub stats: VctSearchStats,
}
impl VctSearchResult {
pub fn termination_reason(&self) -> &'static str {
if self.sequence.is_some() {
"proved"
} else if self.stats.hit_node_budget() {
"node_budget"
} else if self.stats.hit_deadline() {
"deadline"
} else {
"exhausted"
}
}
}
impl Default for VctConfig {
fn default() -> Self {
Self {
max_depth: 16,
time_budget: Some(Duration::from_millis(500)),
node_budget: None,
enable_jump_three: false,
enable_jump_three_attack_defense: false,
enable_jump_three_counter: false,
enable_jump_three_kind_scoped_defense: false,
jump_attack_max_or_levels: u32::MAX,
enable_gap_four: false,
gap_four_attack_max_or_levels: u32::MAX,
use_fast_classify: true,
use_threat_index: false,
profile: false,
use_reach_mask: true,
use_fast_immediate_five: false,
use_vct_scratch_buffers: false,
}
}
}
pub fn search_vct(board: &mut Board, cfg: &VctConfig) -> Option<Vec<Move>> {
search_vct_with_stats(board, cfg).sequence
}
pub fn search_vct_with_stats(board: &mut Board, cfg: &VctConfig) -> VctSearchResult {
search_vct_with_stats_internal(board, cfg, None)
}
pub(crate) fn search_vct_with_board_search_state(
board: &mut Board,
cfg: &VctConfig,
board_search_state: &mut BoardSearchState,
) -> Option<Vec<Move>> {
search_vct_with_stats_internal(board, cfg, Some(board_search_state)).sequence
}
fn search_vct_with_stats_internal(
board: &mut Board,
cfg: &VctConfig,
mut board_search_state: Option<&mut BoardSearchState>,
) -> VctSearchResult {
let deadline = cfg.time_budget.map(|d| Instant::now() + d);
let attacker = board.side_to_move;
let mut sequence = Vec::with_capacity(cfg.max_depth as usize * 2);
let mut tt: TransTable = HashMap::with_capacity(65536);
let mut stats = VctSearchStats::default();
stats.profile_enabled = cfg.profile;
let flags = cfg.jump_three_flags();
let mut scratch = VctScratch::default();
let mut threat_index = if flags.use_threat_index && flags.use_fast_classify {
Some(VctThreatIndex::new(board))
} else {
None
};
let hit = vct_or(
board,
attacker,
cfg.max_depth,
0,
deadline,
cfg.node_budget,
flags,
&mut board_search_state,
&mut threat_index,
&mut sequence,
&mut tt,
&mut stats,
&mut scratch,
);
VctSearchResult {
sequence: if hit { Some(sequence) } else { None },
stats,
}
}
pub fn search_vct_audit_json(board: &mut Board, cfg: &VctConfig) -> Value {
let deadline = cfg.time_budget.map(|d| Instant::now() + d);
let attacker = board.side_to_move;
let mut sequence = Vec::with_capacity(cfg.max_depth as usize * 2);
let mut tt: TransTable = HashMap::with_capacity(65536);
let mut audit = VctAuditLog::default();
let mut stats = VctSearchStats::default();
stats.profile_enabled = cfg.profile;
let flags = cfg.jump_three_flags();
let mut scratch = VctScratch::default();
let mut threat_index = if flags.use_threat_index && flags.use_fast_classify {
Some(VctThreatIndex::new(board))
} else {
None
};
let hit = vct_or_audit(
board,
attacker,
cfg.max_depth,
0,
deadline,
cfg.node_budget,
flags,
&mut threat_index,
&mut sequence,
&mut tt,
&mut audit,
&mut stats,
&mut scratch,
);
let result = VctSearchResult {
sequence: if hit { Some(sequence.clone()) } else { None },
stats,
};
json!({
"format": "vct-proof-audit-v1",
"hit": hit,
"attacker": stone_json(attacker),
"max_depth": cfg.max_depth,
"time_budget_ms": cfg.time_budget.map(|d| d.as_millis() as u64),
"node_budget": cfg.node_budget,
"termination_reason": result.termination_reason(),
"nodes": result.stats.nodes,
"deadline_hits": result.stats.deadline_hits,
"node_budget_hits": result.stats.node_budget_hits,
"jump_three_attack_defense": flags.attack_defense,
"jump_three_counter": flags.counter,
"jump_three_kind_scoped_defense": flags.kind_scoped_defense,
"jump_attack_max_or_levels": flags.attack_max_or_levels,
"gap_four": flags.gap_four,
"gap_four_attack_max_or_levels": flags.gap_four_attack_max_or_levels,
"use_fast_classify": flags.use_fast_classify,
"use_threat_index": flags.use_threat_index && flags.use_fast_classify,
"profile_enabled": cfg.profile,
"profile": result.stats.profile.to_json(),
"sequence": if hit { Some(sequence.iter().map(|&mv| move_json(mv)).collect::<Vec<_>>()) } else { None },
"and_nodes": audit.and_nodes,
"terminal_event_count": audit.terminal_event_count,
"terminal_event_counts": audit.terminal_event_counts,
"terminal_event_samples": audit.terminal_event_samples,
"tt_hit_count": audit.tt_hit_count,
"tt_hit_events": audit.tt_hit_events,
})
}
#[derive(Default)]
struct VctAuditLog {
and_nodes: Vec<Value>,
terminal_event_count: usize,
terminal_event_counts: BTreeMap<String, usize>,
terminal_event_samples: Vec<Value>,
tt_hit_count: usize,
tt_hit_events: Vec<Value>,
}
impl VctAuditLog {
fn record_terminal(&mut self, kind: &str, event: Value) {
self.terminal_event_count += 1;
*self
.terminal_event_counts
.entry(kind.to_string())
.or_default() += 1;
if self.terminal_event_samples.len() < 64 {
self.terminal_event_samples.push(event);
}
}
}
fn vct_or(
board: &mut Board,
attacker: Stone,
depth: u32,
or_level: u32,
deadline: Option<Instant>,
node_budget: Option<u64>,
jump_three: VctJumpThreeFlags,
board_search_state: &mut Option<&mut BoardSearchState>,
threat_index: &mut Option<VctThreatIndex>,
sequence: &mut Vec<Move>,
tt: &mut TransTable,
stats: &mut VctSearchStats,
scratch: &mut VctScratch,
) -> bool {
stats.enter_node();
if node_budget_exceeded(node_budget, stats) {
stats.mark_node_budget();
return false;
}
if depth == 0 {
return false;
}
if timed_out(deadline) {
stats.mark_deadline();
return false;
}
debug_assert_eq!(board.side_to_move, attacker);
let start = profile_start(stats);
let hash = zobrist_hash(board);
if let Some(start) = start {
stats.profile.tt_hash_calls += 1;
stats.profile.tt_hash_ns += start.elapsed().as_nanos();
}
let start = profile_start(stats);
let tt_entry = tt.get(&hash).copied();
if let Some(start) = start {
stats.profile.tt_get_calls += 1;
stats.profile.tt_get_ns += start.elapsed().as_nanos();
}
if let Some(entry) = tt_entry {
if entry.depth >= depth {
if matches!(entry.result, TtResult::Fails) {
return false;
}
}
}
let (my, opp) = bb_pair(board, attacker);
let rule_set = board.effective_rule_set();
let attacker_reach_mask = jump_three.use_reach_mask.then(|| reach_mask_for_stones(my));
let opponent_reach_mask = jump_three
.use_reach_mask
.then(|| reach_mask_for_stones(opp));
let start = profile_start(stats);
let opp_has_immediate_five = if let Some(index) = threat_index.as_ref() {
index.has_kind(attacker.opponent(), false, false, ThreatKind::Five)
} else {
has_immediate_five_query(
opp,
my,
attacker.opponent(),
rule_set,
opponent_reach_mask.as_ref(),
jump_three.use_fast_immediate_five,
)
};
if let Some(start) = start {
stats.profile.immediate_five_calls += 1;
stats.profile.immediate_five_ns += start.elapsed().as_nanos();
}
let enable_jump_three_attack =
jump_three.attack_defense && or_level < jump_three.attack_max_or_levels;
let enable_gap_four_attack = gap_four_attack_enabled(&jump_three, or_level);
let attack_moves = if let Some(index) = threat_index.as_ref() {
index.attack_moves(attacker, enable_jump_three_attack, enable_gap_four_attack)
} else {
gather_attack_moves(
board,
my,
opp,
attacker,
rule_set,
enable_jump_three_attack,
enable_gap_four_attack,
jump_three.use_fast_classify,
attacker_reach_mask.as_ref(),
Some(stats),
scratch,
jump_three.use_vct_scratch_buffers,
)
};
if attack_moves.is_empty() {
scratch.put_attack(jump_three.use_vct_scratch_buffers, attack_moves);
let start = profile_start(stats);
tt.insert(
hash,
TtEntry {
depth,
result: TtResult::Fails,
},
);
if let Some(start) = start {
stats.profile.tt_insert_calls += 1;
stats.profile.tt_insert_ns += start.elapsed().as_nanos();
}
return false;
}
for (mv, kind) in attack_moves.iter().copied() {
let start = profile_start(stats);
let terminal_win = is_vct_terminal_win(kind);
if let Some(start) = start {
stats.profile.terminal_check_calls += 1;
stats.profile.terminal_check_ns += start.elapsed().as_nanos();
}
if terminal_win {
if opp_has_immediate_five && kind != ThreatKind::Five {
continue;
}
sequence.push(mv);
let start = profile_start(stats);
tt.insert(
hash,
TtEntry {
depth,
result: TtResult::AttackerWins,
},
);
if let Some(start) = start {
stats.profile.tt_insert_calls += 1;
stats.profile.tt_insert_ns += start.elapsed().as_nanos();
}
scratch.put_attack(jump_three.use_vct_scratch_buffers, attack_moves);
return true;
}
if opp_has_immediate_five {
continue;
}
sequence.push(mv);
make_vct_move_profiled(
board,
threat_index,
board_search_state.as_deref_mut(),
mv,
stats,
);
let won = vct_and(
board,
attacker,
kind,
depth - 1,
or_level,
deadline,
node_budget,
jump_three,
board_search_state,
threat_index,
sequence,
tt,
stats,
scratch,
);
undo_vct_move_profiled(
board,
threat_index,
board_search_state.as_deref_mut(),
stats,
);
if stats.hit_stop() {
sequence.pop();
scratch.put_attack(jump_three.use_vct_scratch_buffers, attack_moves);
return false;
}
if won {
let start = profile_start(stats);
tt.insert(
hash,
TtEntry {
depth,
result: TtResult::AttackerWins,
},
);
if let Some(start) = start {
stats.profile.tt_insert_calls += 1;
stats.profile.tt_insert_ns += start.elapsed().as_nanos();
}
scratch.put_attack(jump_three.use_vct_scratch_buffers, attack_moves);
return true;
}
sequence.pop();
}
let start = profile_start(stats);
tt.insert(
hash,
TtEntry {
depth,
result: TtResult::Fails,
},
);
if let Some(start) = start {
stats.profile.tt_insert_calls += 1;
stats.profile.tt_insert_ns += start.elapsed().as_nanos();
}
scratch.put_attack(jump_three.use_vct_scratch_buffers, attack_moves);
false
}
fn vct_and(
board: &mut Board,
attacker: Stone,
attack_kind: ThreatKind,
depth: u32,
or_level: u32,
deadline: Option<Instant>,
node_budget: Option<u64>,
jump_three: VctJumpThreeFlags,
board_search_state: &mut Option<&mut BoardSearchState>,
threat_index: &mut Option<VctThreatIndex>,
sequence: &mut Vec<Move>,
tt: &mut TransTable,
stats: &mut VctSearchStats,
scratch: &mut VctScratch,
) -> bool {
stats.enter_node();
if node_budget_exceeded(node_budget, stats) {
stats.mark_node_budget();
return false;
}
if depth == 0 {
return false;
}
if timed_out(deadline) {
stats.mark_deadline();
return false;
}
debug_assert_ne!(board.side_to_move, attacker);
let (def_my, def_opp) = bb_pair(board, board.side_to_move);
let rule_set = board.effective_rule_set();
let defender_reach_mask = jump_three
.use_reach_mask
.then(|| reach_mask_for_stones(def_my));
let start = profile_start(stats);
let defender_has_immediate_five = if let Some(index) = threat_index.as_ref() {
index.has_kind(board.side_to_move, false, false, ThreatKind::Five)
} else {
has_immediate_five_query(
def_my,
def_opp,
board.side_to_move,
rule_set,
defender_reach_mask.as_ref(),
jump_three.use_fast_immediate_five,
)
};
if let Some(start) = start {
stats.profile.immediate_five_calls += 1;
stats.profile.immediate_five_ns += start.elapsed().as_nanos();
}
if defender_has_immediate_five {
return false;
}
let defenses = match board.last_move {
Some(attack_mv) => find_defenses_with_counters(
board,
attack_mv,
attack_kind,
jump_three,
threat_index.as_ref(),
defender_reach_mask.as_ref(),
Some(stats),
scratch,
jump_three.use_vct_scratch_buffers,
),
None => board.candidate_moves(),
};
if defenses.is_empty() {
scratch.put_defense(jump_three.use_vct_scratch_buffers, defenses);
return false;
}
let checkpoint = sequence.len();
for mv in defenses.iter().copied() {
sequence.truncate(checkpoint);
sequence.push(mv);
make_vct_move_profiled(
board,
threat_index,
board_search_state.as_deref_mut(),
mv,
stats,
);
let attacker_still_wins = vct_or(
board,
attacker,
depth - 1,
or_level + 1,
deadline,
node_budget,
jump_three,
board_search_state,
threat_index,
sequence,
tt,
stats,
scratch,
);
undo_vct_move_profiled(
board,
threat_index,
board_search_state.as_deref_mut(),
stats,
);
if stats.hit_stop() {
sequence.truncate(checkpoint);
scratch.put_defense(jump_three.use_vct_scratch_buffers, defenses);
return false;
}
if !attacker_still_wins {
sequence.truncate(checkpoint);
scratch.put_defense(jump_three.use_vct_scratch_buffers, defenses);
return false;
}
}
scratch.put_defense(jump_three.use_vct_scratch_buffers, defenses);
true
}
fn vct_or_audit(
board: &mut Board,
attacker: Stone,
depth: u32,
or_level: u32,
deadline: Option<Instant>,
node_budget: Option<u64>,
jump_three: VctJumpThreeFlags,
threat_index: &mut Option<VctThreatIndex>,
sequence: &mut Vec<Move>,
tt: &mut TransTable,
audit: &mut VctAuditLog,
stats: &mut VctSearchStats,
scratch: &mut VctScratch,
) -> bool {
stats.enter_node();
if node_budget_exceeded(node_budget, stats) {
stats.mark_node_budget();
return false;
}
if depth == 0 {
return false;
}
if timed_out(deadline) {
stats.mark_deadline();
return false;
}
debug_assert_eq!(board.side_to_move, attacker);
let start = profile_start(stats);
let hash = zobrist_hash(board);
if let Some(start) = start {
stats.profile.tt_hash_calls += 1;
stats.profile.tt_hash_ns += start.elapsed().as_nanos();
}
let start = profile_start(stats);
let tt_entry = tt.get(&hash).copied();
if let Some(start) = start {
stats.profile.tt_get_calls += 1;
stats.profile.tt_get_ns += start.elapsed().as_nanos();
}
if let Some(entry) = tt_entry {
if entry.depth >= depth {
audit.tt_hit_count += 1;
audit.tt_hit_events.push(json!({
"node": "or",
"hash": hash,
"requested_depth": depth,
"entry_depth": entry.depth,
"result": tt_result_json(entry.result),
"side_to_move": stone_json(board.side_to_move),
"history": history_json(board),
}));
if matches!(entry.result, TtResult::Fails) {
return false;
}
}
}
let (my, opp) = bb_pair(board, attacker);
let rule_set = board.effective_rule_set();
let attacker_reach_mask = jump_three.use_reach_mask.then(|| reach_mask_for_stones(my));
let opponent_reach_mask = jump_three
.use_reach_mask
.then(|| reach_mask_for_stones(opp));
let opp_has_immediate_five = if let Some(index) = threat_index.as_ref() {
index.has_kind(attacker.opponent(), false, false, ThreatKind::Five)
} else {
has_immediate_five_query(
opp,
my,
attacker.opponent(),
rule_set,
opponent_reach_mask.as_ref(),
jump_three.use_fast_immediate_five,
)
};
let enable_jump_three_attack =
jump_three.attack_defense && or_level < jump_three.attack_max_or_levels;
let enable_gap_four_attack = gap_four_attack_enabled(&jump_three, or_level);
let attack_moves = if let Some(index) = threat_index.as_ref() {
index.attack_moves(attacker, enable_jump_three_attack, enable_gap_four_attack)
} else {
gather_attack_moves(
board,
my,
opp,
attacker,
rule_set,
enable_jump_three_attack,
enable_gap_four_attack,
jump_three.use_fast_classify,
attacker_reach_mask.as_ref(),
Some(stats),
scratch,
jump_three.use_vct_scratch_buffers,
)
};
if attack_moves.is_empty() {
scratch.put_attack(jump_three.use_vct_scratch_buffers, attack_moves);
let start = profile_start(stats);
tt.insert(
hash,
TtEntry {
depth,
result: TtResult::Fails,
},
);
if let Some(start) = start {
stats.profile.tt_insert_calls += 1;
stats.profile.tt_insert_ns += start.elapsed().as_nanos();
}
return false;
}
for (mv, kind) in attack_moves.iter().copied() {
let start = profile_start(stats);
let terminal_win = is_vct_terminal_win(kind);
if let Some(start) = start {
stats.profile.terminal_check_calls += 1;
stats.profile.terminal_check_ns += start.elapsed().as_nanos();
}
if terminal_win {
if opp_has_immediate_five && kind != ThreatKind::Five {
audit.record_terminal(
"winning_attack_skipped_opp_immediate_five",
json!({
"kind": "winning_attack_skipped_opp_immediate_five",
"depth": depth,
"move": move_json(mv),
"threat": threat_json(kind),
"history": history_json(board),
}),
);
continue;
}
sequence.push(mv);
audit.record_terminal(
"winning_attack_accepted",
json!({
"kind": "winning_attack_accepted",
"depth": depth,
"move": move_json(mv),
"threat": threat_json(kind),
"history": history_json(board),
"opp_has_immediate_five": opp_has_immediate_five,
}),
);
let start = profile_start(stats);
tt.insert(
hash,
TtEntry {
depth,
result: TtResult::AttackerWins,
},
);
if let Some(start) = start {
stats.profile.tt_insert_calls += 1;
stats.profile.tt_insert_ns += start.elapsed().as_nanos();
}
scratch.put_attack(jump_three.use_vct_scratch_buffers, attack_moves);
return true;
}
if opp_has_immediate_five {
audit.record_terminal(
"forcing_attack_skipped_opp_immediate_five",
json!({
"kind": "forcing_attack_skipped_opp_immediate_five",
"depth": depth,
"move": move_json(mv),
"threat": threat_json(kind),
"history": history_json(board),
}),
);
continue;
}
sequence.push(mv);
make_vct_move_profiled(board, threat_index, None, mv, stats);
let won = vct_and_audit(
board,
attacker,
kind,
depth - 1,
or_level,
deadline,
node_budget,
jump_three,
threat_index,
sequence,
tt,
audit,
stats,
scratch,
);
undo_vct_move_profiled(board, threat_index, None, stats);
if stats.hit_stop() {
sequence.pop();
scratch.put_attack(jump_three.use_vct_scratch_buffers, attack_moves);
return false;
}
if won {
let start = profile_start(stats);
tt.insert(
hash,
TtEntry {
depth,
result: TtResult::AttackerWins,
},
);
if let Some(start) = start {
stats.profile.tt_insert_calls += 1;
stats.profile.tt_insert_ns += start.elapsed().as_nanos();
}
scratch.put_attack(jump_three.use_vct_scratch_buffers, attack_moves);
return true;
}
sequence.pop();
}
let start = profile_start(stats);
tt.insert(
hash,
TtEntry {
depth,
result: TtResult::Fails,
},
);
if let Some(start) = start {
stats.profile.tt_insert_calls += 1;
stats.profile.tt_insert_ns += start.elapsed().as_nanos();
}
scratch.put_attack(jump_three.use_vct_scratch_buffers, attack_moves);
false
}
fn vct_and_audit(
board: &mut Board,
attacker: Stone,
attack_kind: ThreatKind,
depth: u32,
or_level: u32,
deadline: Option<Instant>,
node_budget: Option<u64>,
jump_three: VctJumpThreeFlags,
threat_index: &mut Option<VctThreatIndex>,
sequence: &mut Vec<Move>,
tt: &mut TransTable,
audit: &mut VctAuditLog,
stats: &mut VctSearchStats,
scratch: &mut VctScratch,
) -> bool {
stats.enter_node();
if node_budget_exceeded(node_budget, stats) {
stats.mark_node_budget();
return false;
}
if depth == 0 {
return false;
}
if timed_out(deadline) {
stats.mark_deadline();
return false;
}
debug_assert_ne!(board.side_to_move, attacker);
let node_history = history_json(board);
let last_attack = board.last_move.map(move_json);
let last_attack_threat = threat_json(attack_kind);
let defender = board.side_to_move;
let (def_my, def_opp) = bb_pair(board, defender);
let rule_set = board.effective_rule_set();
let defender_reach_mask = jump_three
.use_reach_mask
.then(|| reach_mask_for_stones(def_my));
let start = profile_start(stats);
let defender_has_immediate_five = if let Some(index) = threat_index.as_ref() {
index.has_kind(defender, false, false, ThreatKind::Five)
} else {
has_immediate_five_query(
def_my,
def_opp,
defender,
rule_set,
defender_reach_mask.as_ref(),
jump_three.use_fast_immediate_five,
)
};
if let Some(start) = start {
stats.profile.immediate_five_calls += 1;
stats.profile.immediate_five_ns += start.elapsed().as_nanos();
}
if defender_has_immediate_five {
audit.and_nodes.push(json!({
"node": "and",
"depth": depth,
"or_level": or_level,
"attacker": stone_json(attacker),
"defender": stone_json(defender),
"history": node_history,
"last_attack": last_attack,
"last_attack_threat": last_attack_threat,
"defender_has_immediate_five": true,
"defenses": [],
"result": false,
"terminal_reason": "defender_immediate_five",
}));
return false;
}
let defenses = match board.last_move {
Some(attack_mv) => find_defenses_with_counters(
board,
attack_mv,
attack_kind,
jump_three,
threat_index.as_ref(),
defender_reach_mask.as_ref(),
Some(stats),
scratch,
jump_three.use_vct_scratch_buffers,
),
None => board.candidate_moves(),
};
if defenses.is_empty() {
audit.and_nodes.push(json!({
"node": "and",
"depth": depth,
"or_level": or_level,
"attacker": stone_json(attacker),
"defender": stone_json(defender),
"history": node_history,
"last_attack": last_attack,
"last_attack_threat": last_attack_threat,
"defender_has_immediate_five": false,
"defenses": [],
"result": false,
"terminal_reason": "no_defenses",
}));
scratch.put_defense(jump_three.use_vct_scratch_buffers, defenses);
return false;
}
let checkpoint = sequence.len();
let mut defense_results = Vec::with_capacity(defenses.len());
for mv in defenses.iter().copied() {
sequence.truncate(checkpoint);
sequence.push(mv);
let tt_before = audit.tt_hit_count;
make_vct_move_profiled(board, threat_index, None, mv, stats);
let attacker_still_wins = vct_or_audit(
board,
attacker,
depth - 1,
or_level + 1,
deadline,
node_budget,
jump_three,
threat_index,
sequence,
tt,
audit,
stats,
scratch,
);
undo_vct_move_profiled(board, threat_index, None, stats);
if stats.hit_stop() {
sequence.truncate(checkpoint);
scratch.put_defense(jump_three.use_vct_scratch_buffers, defenses);
return false;
}
let tt_after = audit.tt_hit_count;
let continuation = sequence[checkpoint..]
.iter()
.map(|&mv| move_json(mv))
.collect::<Vec<_>>();
defense_results.push(json!({
"move": move_json(mv),
"attacker_still_wins": attacker_still_wins,
"tt_hits_delta": tt_after - tt_before,
"sequence_after_len": sequence.len(),
"continuation": continuation,
}));
if !attacker_still_wins {
sequence.truncate(checkpoint);
audit.and_nodes.push(json!({
"node": "and",
"depth": depth,
"or_level": or_level,
"attacker": stone_json(attacker),
"defender": stone_json(defender),
"history": node_history,
"last_attack": last_attack,
"last_attack_threat": last_attack_threat,
"defender_has_immediate_five": false,
"defenses": defense_results,
"result": false,
"terminal_reason": "defense_refutes",
}));
scratch.put_defense(jump_three.use_vct_scratch_buffers, defenses);
return false;
}
}
audit.and_nodes.push(json!({
"node": "and",
"depth": depth,
"or_level": or_level,
"attacker": stone_json(attacker),
"defender": stone_json(defender),
"history": node_history,
"last_attack": last_attack,
"last_attack_threat": last_attack_threat,
"defender_has_immediate_five": false,
"defenses": defense_results,
"result": true,
}));
scratch.put_defense(jump_three.use_vct_scratch_buffers, defenses);
true
}
fn gather_attack_moves(
board: &Board,
my: &BitBoard,
opp: &BitBoard,
side: Stone,
rule_set: RuleSet,
enable_jump_three: bool,
enable_gap_four: bool,
use_fast_classify: bool,
reach_mask: Option<&BitBoard>,
stats: Option<&mut VctSearchStats>,
scratch: &mut VctScratch,
reuse_buffers: bool,
) -> Vec<(Move, ThreatKind)> {
let profile_enabled = stats.as_ref().map(|s| s.profiling()).unwrap_or(false);
let total_start = if profile_enabled {
Some(Instant::now())
} else {
None
};
let mut classify_ns = 0u128;
let mut classify_calls = 0u64;
let mut out = scratch.take_attack(reuse_buffers);
let cells = my.count_ones() + opp.count_ones();
if cells == 0 {
if let Some(stats) = stats {
if let Some(start) = total_start {
stats.profile.gather_calls += 1;
stats.profile.gather_total_ns += start.elapsed().as_nanos();
stats.profile.record_gather_len(0);
}
}
return out;
}
for idx in 0..(BOARD_SIZE * BOARD_SIZE) {
if my.get(idx) || opp.get(idx) {
continue;
}
if reach_mask.is_some_and(|mask| !mask.get(idx)) {
continue;
}
let classify_start = if profile_enabled {
Some(Instant::now())
} else {
None
};
let kind = if use_fast_classify {
classify_move_fast_with_flags(board, idx, side, enable_jump_three, enable_gap_four)
} else {
classify_move_rules_with_flags(
my,
opp,
idx,
side,
rule_set,
enable_jump_three,
enable_gap_four,
)
};
if let Some(start) = classify_start {
classify_calls += 1;
classify_ns += start.elapsed().as_nanos();
}
if kind.is_forcing() {
out.push((idx, kind));
}
}
let sort_start = if profile_enabled {
Some(Instant::now())
} else {
None
};
out.sort_by_key(|(_, k)| threat_priority(*k));
let sort_ns = elapsed_ns(sort_start);
if let Some(stats) = stats {
if let Some(start) = total_start {
stats.profile.gather_calls += 1;
stats.profile.gather_total_ns += start.elapsed().as_nanos();
stats.profile.gather_classify_calls += classify_calls;
stats.profile.gather_classify_ns += classify_ns;
stats.profile.gather_sort_ns += sort_ns;
stats.profile.record_gather_len(out.len());
}
}
out
}
fn threat_priority(k: ThreatKind) -> i32 {
match k {
ThreatKind::Five => 0,
ThreatKind::OpenFour => 1,
ThreatKind::DoubleFour => 2,
ThreatKind::FourThree => 3,
ThreatKind::DoubleThree => 4,
ThreatKind::ClosedFour => 5,
ThreatKind::OpenThree => 6,
ThreatKind::JumpThree => 7,
ThreatKind::None => 100,
}
}
fn has_immediate_five(
my: &BitBoard,
opp: &BitBoard,
side: Stone,
rule_set: RuleSet,
reach_mask: Option<&BitBoard>,
) -> bool {
for idx in 0..(BOARD_SIZE * BOARD_SIZE) {
if my.get(idx) || opp.get(idx) {
continue;
}
if reach_mask.is_some_and(|mask| !mask.get(idx)) {
continue;
}
if classify_move_rules(my, opp, idx, side, rule_set) == ThreatKind::Five {
return true;
}
}
false
}
fn has_immediate_five_query(
my: &BitBoard,
opp: &BitBoard,
side: Stone,
rule_set: RuleSet,
reach_mask: Option<&BitBoard>,
use_fast_immediate_five: bool,
) -> bool {
if use_fast_immediate_five {
has_immediate_five_direction_scan(my, opp, side, rule_set, reach_mask)
} else {
has_immediate_five(my, opp, side, rule_set, reach_mask)
}
}
fn has_immediate_five_direction_scan(
my: &BitBoard,
opp: &BitBoard,
side: Stone,
rule_set: RuleSet,
reach_mask: Option<&BitBoard>,
) -> bool {
for idx in 0..NUM_CELLS {
if my.get(idx) || opp.get(idx) {
continue;
}
if reach_mask.is_some_and(|mask| !mask.get(idx)) {
continue;
}
if completes_five_at(my, opp, idx, side, rule_set) {
return true;
}
}
false
}
fn completes_five_at(
my: &BitBoard,
opp: &BitBoard,
mv: Move,
side: Stone,
rule_set: RuleSet,
) -> bool {
let row = (mv / BOARD_SIZE) as i32;
let col = (mv % BOARD_SIZE) as i32;
for &(dr, dc) in &DIR {
let (front_count, front_open) = count_line_side(my, opp, row, col, dr, dc);
let (back_count, back_open) = count_line_side(my, opp, row, col, -dr, -dc);
let count = 1 + front_count + back_count;
let open_ends = front_open as u32 + back_open as u32;
if rule_set.line_wins(side, count, open_ends) {
return true;
}
}
false
}
fn count_line_side(
my: &BitBoard,
opp: &BitBoard,
row: i32,
col: i32,
dr: i32,
dc: i32,
) -> (u32, bool) {
let mut count = 0u32;
let mut r = row + dr;
let mut c = col + dc;
while in_board(r, c) {
let idx = r as usize * BOARD_SIZE + c as usize;
if my.get(idx) {
count += 1;
r += dr;
c += dc;
continue;
}
return (count, !opp.get(idx));
}
(count, false)
}
#[doc(hidden)]
pub fn has_immediate_five_reference_for_audit(
board: &Board,
side: Stone,
use_reach_mask: bool,
) -> bool {
let (my, opp) = bb_pair(board, side);
let reach_mask = use_reach_mask.then(|| reach_mask_for_stones(my));
has_immediate_five(
my,
opp,
side,
board.effective_rule_set(),
reach_mask.as_ref(),
)
}
#[doc(hidden)]
pub fn has_immediate_five_fast_for_audit(board: &Board, side: Stone, use_reach_mask: bool) -> bool {
let (my, opp) = bb_pair(board, side);
let reach_mask = use_reach_mask.then(|| reach_mask_for_stones(my));
has_immediate_five_direction_scan(
my,
opp,
side,
board.effective_rule_set(),
reach_mask.as_ref(),
)
}
#[inline]
fn in_board(r: i32, c: i32) -> bool {
r >= 0 && r < BOARD_SIZE as i32 && c >= 0 && c < BOARD_SIZE as i32
}
fn find_defenses_with_counters(
board: &Board,
attack_move: Move,
attack_kind: ThreatKind,
jump_three: VctJumpThreeFlags,
threat_index: Option<&VctThreatIndex>,
reach_mask: Option<&BitBoard>,
stats: Option<&mut VctSearchStats>,
scratch: &mut VctScratch,
reuse_buffers: bool,
) -> Vec<Move> {
let profile_enabled = stats.as_ref().map(|s| s.profiling()).unwrap_or(false);
let total_start = if profile_enabled {
Some(Instant::now())
} else {
None
};
let direct_jump_three_defense = jump_three.attack_defense
&& (!jump_three.kind_scoped_defense || attack_kind == ThreatKind::JumpThree);
let direct_start = if profile_enabled {
Some(Instant::now())
} else {
None
};
let direct_defenses = find_defenses(
board,
attack_move,
direct_jump_three_defense,
jump_three.gap_four,
);
let mut defenses = scratch.take_defense(reuse_buffers);
defenses.extend(direct_defenses);
let direct_ns = elapsed_ns(direct_start);
let mut seen = BitBoard::EMPTY;
for &d in &defenses {
seen.set(d);
}
let (def_my, def_opp) = bb_pair(board, board.side_to_move);
let rule_set = board.effective_rule_set();
let counter_start = if profile_enabled {
Some(Instant::now())
} else {
None
};
let mut counter_classify_calls = 0u64;
let mut counter_classify_ns = 0u128;
if let Some(index) = threat_index {
for idx in index.forcing_moves_in_cell_order(
board.side_to_move,
jump_three.counter,
jump_three.gap_four,
) {
if reach_mask.is_some_and(|mask| !mask.get(idx)) {
continue;
}
if !seen.get(idx) {
seen.set(idx);
defenses.push(idx);
}
}
let counter_scan_ns = elapsed_ns(counter_start);
if let Some(stats) = stats {
if let Some(start) = total_start {
stats.profile.find_defenses_calls += 1;
stats.profile.find_defenses_total_ns += start.elapsed().as_nanos();
stats.profile.find_defenses_direct_ns += direct_ns;
stats.profile.find_defenses_counter_scan_ns += counter_scan_ns;
}
}
return defenses;
}
for idx in 0..NUM_CELLS {
if def_my.get(idx) || def_opp.get(idx) || seen.get(idx) {
continue;
}
if reach_mask.is_some_and(|mask| !mask.get(idx)) {
continue;
}
let classify_start = if profile_enabled {
Some(Instant::now())
} else {
None
};
let kind = if jump_three.use_fast_classify {
classify_move_fast_with_flags(
board,
idx,
board.side_to_move,
jump_three.counter,
jump_three.gap_four,
)
} else {
classify_move_rules_with_flags(
def_my,
def_opp,
idx,
board.side_to_move,
rule_set,
jump_three.counter,
jump_three.gap_four,
)
};
if let Some(start) = classify_start {
counter_classify_calls += 1;
counter_classify_ns += start.elapsed().as_nanos();
}
if kind.is_forcing() {
seen.set(idx);
defenses.push(idx);
}
}
let counter_scan_ns = elapsed_ns(counter_start);
if let Some(stats) = stats {
if let Some(start) = total_start {
stats.profile.find_defenses_calls += 1;
stats.profile.find_defenses_total_ns += start.elapsed().as_nanos();
stats.profile.find_defenses_direct_ns += direct_ns;
stats.profile.find_defenses_counter_scan_ns += counter_scan_ns;
stats.profile.find_defenses_counter_classify_calls += counter_classify_calls;
stats.profile.find_defenses_counter_classify_ns += counter_classify_ns;
}
}
defenses
}
fn find_defenses(
board: &Board,
attack_move: Move,
enable_jump_three: bool,
enable_gap_four: bool,
) -> Vec<Move> {
let row = (attack_move / BOARD_SIZE) as i32;
let col = (attack_move % BOARD_SIZE) as i32;
let mut seen = BitBoard::EMPTY;
let mut out = Vec::with_capacity(24);
for dr in -2..=2 {
for dc in -2..=2 {
if dr == 0 && dc == 0 {
continue;
}
let nr = row + dr;
let nc = col + dc;
if !in_board(nr, nc) {
continue;
}
let idx = (nr as usize) * BOARD_SIZE + (nc as usize);
if board.is_empty(idx) && !seen.get(idx) {
seen.set(idx);
out.push(idx);
}
}
}
for &(dr, dc) in &DIR {
for step in [-4i32, -3, 3, 4] {
let nr = row + dr * step;
let nc = col + dc * step;
if !in_board(nr, nc) {
continue;
}
let idx = (nr as usize) * BOARD_SIZE + (nc as usize);
if board.is_empty(idx) && !seen.get(idx) {
seen.set(idx);
out.push(idx);
}
}
}
if enable_jump_three {
append_jump_three_defenses(board, attack_move, &mut seen, &mut out);
}
if enable_gap_four {
append_gap_four_defenses(board, attack_move, &mut seen, &mut out);
}
out
}
fn append_jump_three_defenses(
board: &Board,
attack_move: Move,
seen: &mut BitBoard,
out: &mut Vec<Move>,
) {
let row = (attack_move / BOARD_SIZE) as i32;
let col = (attack_move % BOARD_SIZE) as i32;
let attacker = board.side_to_move.opponent();
let (my, opp) = bb_pair(board, attacker);
for &(dr, dc) in &DIR {
let w = read_window(my, opp, row, col, dr, dc);
for start in 0..=5 {
if !(start <= 5 && 5 < start + 6) {
continue;
}
let s = &w[start..start + 6];
if s != [0, 1, 0, 1, 1, 0] && s != [0, 1, 1, 0, 1, 0] {
continue;
}
for i in 0..6 {
if s[i] != 0 {
continue;
}
let off = (start + i) as i32 - 5;
let nr = row + dr * off;
let nc = col + dc * off;
if !in_board(nr, nc) {
continue;
}
let idx = (nr as usize) * BOARD_SIZE + nc as usize;
if board.is_empty(idx) && !seen.get(idx) {
seen.set(idx);
out.push(idx);
}
}
}
}
}
fn append_gap_four_defenses(
board: &Board,
attack_move: Move,
seen: &mut BitBoard,
out: &mut Vec<Move>,
) {
let row = (attack_move / BOARD_SIZE) as i32;
let col = (attack_move % BOARD_SIZE) as i32;
let attacker = board.side_to_move.opponent();
let (my, opp) = bb_pair(board, attacker);
for &(dr, dc) in &DIR {
let w = read_window(my, opp, row, col, dr, dc);
visit_gap_four_gaps(&w, |off| {
let nr = row + dr * off;
let nc = col + dc * off;
if !in_board(nr, nc) {
return;
}
let idx = (nr as usize) * BOARD_SIZE + nc as usize;
if board.is_empty(idx) && !seen.get(idx) {
seen.set(idx);
out.push(idx);
}
});
}
}
fn bb_pair(board: &Board, side: Stone) -> (&BitBoard, &BitBoard) {
match side {
Stone::Black => (&board.black, &board.white),
Stone::White => (&board.white, &board.black),
}
}
fn node_budget_exceeded(node_budget: Option<u64>, stats: &VctSearchStats) -> bool {
node_budget.is_some_and(|limit| stats.nodes > limit)
}
fn timed_out(deadline: Option<Instant>) -> bool {
if let Some(d) = deadline {
if Instant::now() >= d {
return true;
}
}
false
}
fn move_json(mv: Move) -> Value {
json!({"x": mv % BOARD_SIZE, "y": mv / BOARD_SIZE})
}
fn history_json(board: &Board) -> Value {
let mut side = Stone::Black;
let moves = board
.history
.iter()
.map(|&mv| {
let out = json!({
"x": mv % BOARD_SIZE,
"y": mv / BOARD_SIZE,
"color": stone_json(side),
});
side = side.opponent();
out
})
.collect::<Vec<_>>();
json!(moves)
}
fn stone_json(side: Stone) -> &'static str {
match side {
Stone::Black => "B",
Stone::White => "W",
}
}
fn threat_json(kind: ThreatKind) -> &'static str {
match kind {
ThreatKind::None => "None",
ThreatKind::ClosedFour => "ClosedFour",
ThreatKind::OpenThree => "OpenThree",
ThreatKind::Five => "Five",
ThreatKind::OpenFour => "OpenFour",
ThreatKind::DoubleFour => "DoubleFour",
ThreatKind::FourThree => "FourThree",
ThreatKind::DoubleThree => "DoubleThree",
ThreatKind::JumpThree => "JumpThree",
}
}
fn tt_result_json(result: TtResult) -> &'static str {
match result {
TtResult::AttackerWins => "attacker_wins",
TtResult::Fails => "fails",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::board::to_idx;
use noru::trainer::SimpleRng;
#[test]
fn pattern4_fast_classify_matches_baseline() {
let mut rng = SimpleRng::new(0xCAFE_BABE);
for trial in 0..1500 {
let mut board = Board::new();
let ply_target = 6 + rng.next_usize(45);
for _ in 0..ply_target {
if !matches!(board.game_result(), crate::board::GameResult::Ongoing) {
break;
}
let candidates = board.candidate_moves();
if candidates.is_empty() {
break;
}
let idx = rng.next_usize(candidates.len());
board.make_move(candidates[idx]);
}
if !matches!(board.game_result(), crate::board::GameResult::Ongoing) {
continue;
}
let side = board.side_to_move;
for &exact5 in &[false, true] {
board.exact5 = exact5;
let (my, opp) = match side {
Stone::Black => (&board.black, &board.white),
Stone::White => (&board.white, &board.black),
};
for cell in 0..NUM_CELLS {
if board.black.get(cell) || board.white.get(cell) {
continue;
}
let baseline = classify_move(my, opp, cell, exact5);
let fast = classify_move_fast(&board, cell, side);
assert_eq!(
baseline, fast,
"mismatch at trial {trial} cell {cell} side {side:?} exact5 {exact5}"
);
}
}
}
}
#[test]
fn test_classify_move_five() {
let mut board = Board::new();
board.make_move(to_idx(7, 3));
board.make_move(to_idx(0, 0));
board.make_move(to_idx(7, 4));
board.make_move(to_idx(0, 14));
board.make_move(to_idx(7, 5));
board.make_move(to_idx(14, 0));
board.make_move(to_idx(7, 6));
let k1 = classify_move(&board.black, &board.white, to_idx(7, 2), false);
let k2 = classify_move(&board.black, &board.white, to_idx(7, 7), false);
assert_eq!(k1, ThreatKind::Five, "(7,2) should complete Five");
assert_eq!(k2, ThreatKind::Five, "(7,7) should complete Five");
}
#[test]
fn test_classify_move_open_four() {
let mut board = Board::new();
board.make_move(to_idx(7, 4));
board.make_move(to_idx(0, 0));
board.make_move(to_idx(7, 5));
board.make_move(to_idx(0, 14));
board.make_move(to_idx(7, 6));
let k = classify_move(&board.black, &board.white, to_idx(7, 7), false);
assert_eq!(k, ThreatKind::OpenFour);
assert_ne!(
k,
ThreatKind::Five,
"open four must not be classified as Five"
);
}
#[test]
fn test_classify_move_jump_three() {
let mut board = Board::new();
board.make_move(to_idx(7, 5));
board.make_move(to_idx(0, 0));
board.make_move(to_idx(7, 6));
board.make_move(to_idx(0, 14));
let mv = to_idx(7, 3);
let slow = classify_move_rules_with_jump_three(
&board.black,
&board.white,
mv,
Stone::Black,
board.effective_rule_set(),
true,
);
let fast = classify_move_fast_with_jump_three(&board, mv, Stone::Black, true);
assert_eq!(slow, ThreatKind::JumpThree);
assert_eq!(fast, ThreatKind::JumpThree);
assert_eq!(
classify_move(&board.black, &board.white, mv, false),
ThreatKind::None
);
assert_eq!(
classify_move_fast(&board, mv, Stone::Black),
ThreatKind::None
);
assert!(ThreatKind::JumpThree.is_forcing());
assert!(!ThreatKind::JumpThree.is_winning());
assert!(!is_vct_terminal_win(ThreatKind::JumpThree));
}
#[test]
fn rq560_jump_three_flag_on_off_preserves_legacy_classification() {
let mut left_gap = Board::new();
left_gap.make_move(to_idx(7, 5));
left_gap.make_move(to_idx(0, 0));
left_gap.make_move(to_idx(7, 6));
left_gap.make_move(to_idx(0, 14));
let left_mv = to_idx(7, 3);
assert_eq!(
classify_move_rules_with_jump_three(
&left_gap.black,
&left_gap.white,
left_mv,
Stone::Black,
left_gap.effective_rule_set(),
false,
),
ThreatKind::None
);
assert_eq!(
classify_move_rules_with_jump_three(
&left_gap.black,
&left_gap.white,
left_mv,
Stone::Black,
left_gap.effective_rule_set(),
true,
),
ThreatKind::JumpThree
);
assert_eq!(
classify_move_fast_with_jump_three(&left_gap, left_mv, Stone::Black, false),
ThreatKind::None
);
assert_eq!(
classify_move_fast_with_jump_three(&left_gap, left_mv, Stone::Black, true),
ThreatKind::JumpThree
);
let mut right_gap = Board::new();
right_gap.make_move(to_idx(7, 3));
right_gap.make_move(to_idx(0, 0));
right_gap.make_move(to_idx(7, 4));
right_gap.make_move(to_idx(0, 14));
let right_mv = to_idx(7, 6);
assert_eq!(
classify_move_rules_with_jump_three(
&right_gap.black,
&right_gap.white,
right_mv,
Stone::Black,
right_gap.effective_rule_set(),
false,
),
ThreatKind::None
);
assert_eq!(
classify_move_rules_with_jump_three(
&right_gap.black,
&right_gap.white,
right_mv,
Stone::Black,
right_gap.effective_rule_set(),
true,
),
ThreatKind::JumpThree
);
}
fn board_with_black_stones(stones: &[(usize, usize)]) -> Board {
let fillers = [(0, 0), (0, 14), (14, 0), (14, 14), (1, 0), (1, 14)];
let mut board = Board::new();
for (i, &(row, col)) in stones.iter().enumerate() {
board.make_move(to_idx(row, col));
board.make_move(to_idx(fillers[i].0, fillers[i].1));
}
board
}
#[test]
fn rq567_gap_four_flag_on_off_preserves_legacy_classification() {
let cases = [
(&[(7, 5), (7, 6), (7, 7)][..], to_idx(7, 3)),
(&[(7, 3), (7, 6), (7, 7)][..], to_idx(7, 4)),
(&[(7, 3), (7, 4), (7, 7)][..], to_idx(7, 5)),
];
for (stones, mv) in cases {
let board = board_with_black_stones(stones);
let legacy = classify_move(&board.black, &board.white, mv, false);
assert_eq!(
classify_move_rules_with_flags(
&board.black,
&board.white,
mv,
Stone::Black,
board.effective_rule_set(),
false,
false,
),
legacy,
"gap-four flag off must preserve legacy classification"
);
assert_ne!(legacy, ThreatKind::ClosedFour);
assert_eq!(
classify_move_rules_with_flags(
&board.black,
&board.white,
mv,
Stone::Black,
board.effective_rule_set(),
false,
true,
),
ThreatKind::ClosedFour
);
assert_eq!(
classify_move_fast_with_flags(&board, mv, Stone::Black, false, true),
ThreatKind::ClosedFour
);
}
assert!(!ThreatKind::ClosedFour.is_winning());
assert!(!is_vct_terminal_win(ThreatKind::ClosedFour));
}
#[test]
fn test_jump_three_is_attack_candidate() {
let mut board = Board::new();
board.make_move(to_idx(7, 5));
board.make_move(to_idx(0, 0));
board.make_move(to_idx(7, 6));
board.make_move(to_idx(0, 14));
let mut scratch = VctScratch::default();
let moves = gather_attack_moves(
&board,
&board.black,
&board.white,
Stone::Black,
board.effective_rule_set(),
true,
false,
false,
None,
None,
&mut scratch,
false,
);
assert!(
moves
.iter()
.any(|&(mv, kind)| mv == to_idx(7, 3) && kind == ThreatKind::JumpThree),
"jump-three move must enter attack candidates: {:?}",
moves
);
}
#[test]
fn rq560_g90_jump_three_attack_candidate() {
let mut board = Board::new();
for mv in [
to_idx(7, 7),
to_idx(8, 7),
to_idx(5, 8),
to_idx(10, 7),
to_idx(5, 9),
to_idx(6, 8),
to_idx(5, 7),
to_idx(5, 6),
to_idx(4, 7),
to_idx(6, 7),
to_idx(6, 9),
to_idx(7, 10),
] {
board.make_move(mv);
}
let rapfi = to_idx(3, 9);
assert_eq!(
classify_move_fast_with_jump_three(&board, rapfi, Stone::Black, true),
ThreatKind::JumpThree
);
assert_eq!(
classify_move_fast(&board, rapfi, Stone::Black),
ThreatKind::None
);
let mut scratch = VctScratch::default();
let moves = gather_attack_moves(
&board,
&board.black,
&board.white,
Stone::Black,
board.effective_rule_set(),
true,
false,
false,
None,
None,
&mut scratch,
false,
);
assert!(
moves
.iter()
.any(|&(mv, kind)| mv == rapfi && kind == ThreatKind::JumpThree),
"g90 rapfi move (9,3) must be a JumpThree attack candidate: {:?}",
moves
);
}
#[test]
fn rq567_g58_gap_four_attack_candidate() {
let mut board = Board::new();
for mv in [
to_idx(7, 7),
to_idx(6, 8),
to_idx(8, 9),
to_idx(11, 10),
to_idx(8, 8),
to_idx(8, 7),
to_idx(9, 9),
to_idx(10, 10),
to_idx(7, 9),
to_idx(10, 9),
to_idx(9, 8),
to_idx(9, 10),
to_idx(8, 10),
to_idx(10, 8),
] {
board.make_move(mv);
}
let rapfi = to_idx(5, 9);
assert_ne!(
classify_move_fast(&board, rapfi, Stone::Black),
ThreatKind::ClosedFour
);
assert_eq!(
classify_move_fast_with_flags(&board, rapfi, Stone::Black, true, true),
ThreatKind::ClosedFour
);
let mut scratch = VctScratch::default();
let moves = gather_attack_moves(
&board,
&board.black,
&board.white,
Stone::Black,
board.effective_rule_set(),
true,
true,
false,
None,
None,
&mut scratch,
false,
);
assert!(
moves
.iter()
.any(|&(mv, kind)| mv == rapfi && kind == ThreatKind::ClosedFour),
"g58 rapfi move (9,5) must enter attack candidates as gap-four ClosedFour: {:?}",
moves
);
board.make_move(rapfi);
let defenses = find_defenses(&board, rapfi, false, true);
assert!(
defenses.contains(&to_idx(6, 9)),
"gap-four defense set must include the gap cell (9,6); got {:?}",
defenses
);
}
#[test]
fn rq581_gap_four_attack_schedule_is_or_level_scoped() {
let flags = VctJumpThreeFlags {
gap_four: true,
gap_four_attack_max_or_levels: 1,
..VctJumpThreeFlags::default()
};
assert!(gap_four_attack_enabled(&flags, 0));
assert!(!gap_four_attack_enabled(&flags, 1));
assert!(!gap_four_attack_enabled(&flags, 2));
let disabled = VctJumpThreeFlags {
gap_four: false,
gap_four_attack_max_or_levels: 1,
..VctJumpThreeFlags::default()
};
assert!(!gap_four_attack_enabled(&disabled, 0));
}
#[test]
fn test_jump_three_defenses_include_gap_and_completion_cells() {
let mut board = Board::new();
board.make_move(to_idx(7, 5));
board.make_move(to_idx(0, 0));
board.make_move(to_idx(7, 6));
board.make_move(to_idx(0, 14));
board.make_move(to_idx(7, 3));
let defenses = find_defenses(&board, to_idx(7, 3), true, false);
for expected in [to_idx(7, 2), to_idx(7, 4), to_idx(7, 7)] {
assert!(
defenses.contains(&expected),
"jump-three defense set must include {:?}; got {:?}",
expected,
defenses
);
}
}
#[test]
fn rq574_threat_index_make_undo_matches_rebuild() {
let initial = Board::new();
VctThreatIndex::new(&initial).assert_matches_rebuild(&initial);
let moves = [
to_idx(7, 7),
to_idx(8, 7),
to_idx(7, 6),
to_idx(8, 6),
to_idx(7, 5),
to_idx(8, 5),
to_idx(7, 4),
to_idx(5, 5),
to_idx(6, 6),
to_idx(9, 5),
to_idx(5, 7),
to_idx(10, 4),
];
let (makes, undos) = vct_threat_index_transition_check_for_audit(&moves).unwrap();
assert_eq!(makes, moves.len());
assert_eq!(undos, moves.len());
}
#[test]
fn classify_move_exact5_overline_not_five() {
let mut board = Board::new();
for (b, w) in [
((7, 2), (0, 0)),
((7, 3), (0, 14)),
((7, 4), (14, 0)),
((7, 5), (14, 14)),
((7, 7), (3, 3)),
] {
board.make_move(to_idx(b.0, b.1));
board.make_move(to_idx(w.0, w.1));
}
let mv = to_idx(7, 6);
assert_eq!(
classify_move(&board.black, &board.white, mv, false),
ThreatKind::Five,
"freestyle: overline still counts as a win"
);
assert_eq!(
classify_move(&board.black, &board.white, mv, true),
ThreatKind::None,
"standard: overline is not a win"
);
}
#[test]
fn classify_move_exact5_exact_five_still_wins() {
let mut board = Board::new();
for (b, w) in [
((7, 2), (0, 0)),
((7, 3), (0, 14)),
((7, 4), (14, 0)),
((7, 5), (14, 14)),
] {
board.make_move(to_idx(b.0, b.1));
board.make_move(to_idx(w.0, w.1));
}
let mv = to_idx(7, 6);
assert_eq!(
classify_move(&board.black, &board.white, mv, true),
ThreatKind::Five,
"standard: exactly five is a win"
);
assert_eq!(
classify_move(&board.black, &board.white, mv, false),
ThreatKind::Five
);
}
#[test]
fn test_vct_open_four_mate_in_1() {
let mut board = Board::new();
board.make_move(to_idx(7, 3));
board.make_move(to_idx(0, 0));
board.make_move(to_idx(7, 4));
board.make_move(to_idx(0, 14));
board.make_move(to_idx(7, 5));
board.make_move(to_idx(14, 0));
board.make_move(to_idx(7, 6));
board.make_move(to_idx(14, 14));
let cfg = VctConfig::default();
let seq = search_vct(&mut board, &cfg);
assert!(seq.is_some(), "should find mate");
let seq = seq.unwrap();
assert_eq!(seq.len(), 1, "mate in 1");
assert!(
[to_idx(7, 2), to_idx(7, 7)].contains(&seq[0]),
"got {:?}",
seq[0]
);
}
#[test]
fn test_classify_move_double_three() {
let mut board = Board::new();
board.make_move(to_idx(7, 4));
board.make_move(to_idx(0, 0));
board.make_move(to_idx(7, 5));
board.make_move(to_idx(0, 14));
board.make_move(to_idx(5, 6));
board.make_move(to_idx(14, 0));
board.make_move(to_idx(6, 6));
let k = classify_move(&board.black, &board.white, to_idx(7, 6), false);
assert_eq!(
k,
ThreatKind::DoubleThree,
"should be double three, got {:?}",
k
);
}
#[test]
fn test_classify_move_four_three() {
let mut board = Board::new();
board.make_move(to_idx(7, 3));
board.make_move(to_idx(0, 0));
board.make_move(to_idx(7, 4));
board.make_move(to_idx(0, 14));
board.make_move(to_idx(7, 5));
board.make_move(to_idx(14, 0));
board.make_move(to_idx(5, 6));
board.make_move(to_idx(14, 14));
board.make_move(to_idx(6, 6));
let k = classify_move(&board.black, &board.white, to_idx(7, 6), false);
assert_eq!(k, ThreatKind::OpenFour, "open four dominates; got {:?}", k);
}
#[test]
fn test_vct_double_three_mate_in_3() {
let mut board = Board::new();
board.make_move(to_idx(7, 4));
board.make_move(to_idx(0, 0));
board.make_move(to_idx(7, 5));
board.make_move(to_idx(0, 14));
board.make_move(to_idx(5, 6));
board.make_move(to_idx(14, 0));
board.make_move(to_idx(6, 6));
board.make_move(to_idx(14, 14));
let cfg = VctConfig::default();
let seq = search_vct(&mut board, &cfg);
assert!(seq.is_some(), "should find VCT mate");
let seq = seq.unwrap();
assert!(seq.len() >= 1, "non-empty sequence");
assert_eq!(seq[0], to_idx(7, 6), "first move must be (7,6) DoubleThree");
}
#[test]
fn test_vct_no_winning_sequence() {
let mut board = Board::new();
board.make_move(to_idx(7, 7));
board.make_move(to_idx(6, 6));
let cfg = VctConfig {
max_depth: 8,
time_budget: Some(Duration::from_millis(100)),
node_budget: None,
enable_jump_three: false,
enable_jump_three_attack_defense: false,
enable_jump_three_counter: false,
enable_jump_three_kind_scoped_defense: false,
jump_attack_max_or_levels: u32::MAX,
enable_gap_four: false,
gap_four_attack_max_or_levels: u32::MAX,
use_fast_classify: false,
use_threat_index: false,
profile: false,
use_reach_mask: false,
use_fast_immediate_five: false,
use_vct_scratch_buffers: false,
};
let seq = search_vct(&mut board, &cfg);
assert!(seq.is_none(), "no VCT should exist, got {:?}", seq);
}
#[test]
fn test_vct_loses_to_faster_counter_threat() {
let mut board = Board::new();
board.make_move(to_idx(7, 3));
board.make_move(to_idx(8, 0));
board.make_move(to_idx(7, 4));
board.make_move(to_idx(8, 1));
board.make_move(to_idx(7, 5));
board.make_move(to_idx(8, 2));
board.make_move(to_idx(7, 6));
board.make_move(to_idx(8, 3));
let cfg = VctConfig::default();
let seq = search_vct(&mut board, &cfg);
assert!(seq.is_some(), "Five wins before opponent's 4");
let seq = seq.unwrap();
assert_eq!(seq.len(), 1);
assert!([to_idx(7, 2), to_idx(7, 7)].contains(&seq[0]));
}
#[test]
fn test_vct_mate_in_5_chain() {
let mut board = Board::new();
board.make_move(to_idx(7, 5));
board.make_move(to_idx(0, 0));
board.make_move(to_idx(7, 6));
board.make_move(to_idx(0, 14));
board.make_move(to_idx(7, 7));
board.make_move(to_idx(0, 7));
let cfg = VctConfig {
max_depth: 8,
time_budget: Some(Duration::from_millis(300)),
node_budget: None,
enable_jump_three: false,
enable_jump_three_attack_defense: false,
enable_jump_three_counter: false,
enable_jump_three_kind_scoped_defense: false,
jump_attack_max_or_levels: u32::MAX,
enable_gap_four: false,
gap_four_attack_max_or_levels: u32::MAX,
use_fast_classify: false,
use_threat_index: false,
profile: false,
use_reach_mask: false,
use_fast_immediate_five: false,
use_vct_scratch_buffers: false,
};
let seq = search_vct(&mut board, &cfg);
assert!(seq.is_some(), "should find mate via open-three chain");
}
#[test]
fn test_vct_tt_consistency() {
let mut board = Board::new();
board.make_move(to_idx(7, 5));
board.make_move(to_idx(0, 0));
board.make_move(to_idx(7, 6));
board.make_move(to_idx(0, 14));
board.make_move(to_idx(7, 7));
board.make_move(to_idx(0, 7));
let cfg = VctConfig {
max_depth: 8,
time_budget: Some(Duration::from_millis(500)),
node_budget: None,
enable_jump_three: false,
enable_jump_three_attack_defense: false,
enable_jump_three_counter: false,
enable_jump_three_kind_scoped_defense: false,
jump_attack_max_or_levels: u32::MAX,
enable_gap_four: false,
gap_four_attack_max_or_levels: u32::MAX,
use_fast_classify: false,
use_threat_index: false,
profile: false,
use_reach_mask: false,
use_fast_immediate_five: false,
use_vct_scratch_buffers: false,
};
let s1 = search_vct(&mut board, &cfg);
let s2 = search_vct(&mut board, &cfg);
assert_eq!(s1.is_some(), s2.is_some(), "VCT should be deterministic");
}
#[test]
fn test_vct_cannot_ignore_opponent_five_threat_for_forcing() {
let mut board = Board::new();
board.make_move(to_idx(7, 4));
board.make_move(to_idx(8, 0));
board.make_move(to_idx(7, 5));
board.make_move(to_idx(8, 1));
board.make_move(to_idx(0, 0));
board.make_move(to_idx(8, 2));
board.make_move(to_idx(0, 14));
board.make_move(to_idx(8, 3));
let cfg = VctConfig::default();
let seq = search_vct(&mut board, &cfg);
assert!(seq.is_none(), "no VCT when opponent has immediate Five");
}
}