From 0008dd736d00aaabe78ac192560ae881af92bb71 Mon Sep 17 00:00:00 2001 From: zliu1022 Date: Sat, 26 Sep 2026 00:40:52 -0700 Subject: [PATCH] Fix zobrist hash when removing captured stones in python Board Board.remove_unsafe XORed ZOBRIST_STONE[opp][loc] for each captured stone, i.e. the key of the other color rather than the captured stone's own color. After any capture Board.zobrist no longer matched the stones on the board, undo() of a capturing move did not restore it, and distinct positions could share a hash. The C++ Board::removeChain uses the stone's own color (ZOBRIST_BOARD_HASH[cur][colors[cur]]). Add tests checking the incremental hash against a from-scratch recomputation after a capture, over random games, and across playRecordedUnsafe/undo. Co-Authored-By: Claude Opus 5.5 (1M context) --- python/katago/game/board.py | 2 +- python/tests/test_board_zobrist.py | 60 ++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 python/tests/test_board_zobrist.py diff --git a/python/katago/game/board.py b/python/katago/game/board.py index 7c5002986e..09d38a55fd 100644 --- a/python/katago/game/board.py +++ b/python/katago/game/board.py @@ -731,7 +731,7 @@ def remove_unsafe(self,group): #Zero out all the stuff self.board[loc] = Board.EMPTY - self.zobrist ^= Board.ZOBRIST_STONE[opp][loc] + self.zobrist ^= Board.ZOBRIST_STONE[pla][loc] self.group_head[loc] = 0 self.group_next[loc] = 0 self.group_prev[loc] = 0 diff --git a/python/tests/test_board_zobrist.py b/python/tests/test_board_zobrist.py new file mode 100644 index 0000000000..282c90744b --- /dev/null +++ b/python/tests/test_board_zobrist.py @@ -0,0 +1,60 @@ +""" +Unit tests for the incremental zobrist hash in katago.game.board.Board. + +Board.zobrist is updated incrementally as stones are placed and captured. These tests check that it always equals +the hash recomputed from scratch for the stones currently on the board, in particular after captures. +""" + +import random + +from katago.game.board import Board + + +def recomputed_zobrist(board): + h = 0 + for loc in range(board.arrsize): + if board.board[loc] == Board.BLACK or board.board[loc] == Board.WHITE: + h ^= Board.ZOBRIST_STONE[board.board[loc]][loc] + return h + + +def test_zobrist_after_single_capture(): + board = Board(9) + board.play(Board.WHITE, board.loc(1, 0)) + board.play(Board.BLACK, board.loc(0, 0)) + board.play(Board.BLACK, board.loc(2, 0)) + board.play(Board.BLACK, board.loc(1, 1)) # captures the white stone at (1,0) + assert board.board[board.loc(1, 0)] == Board.EMPTY + assert board.zobrist == recomputed_zobrist(board) + + +def test_zobrist_matches_recomputation_in_random_games(): + rand = random.Random(12345) + for _ in range(100): + board = Board(5) + pla = Board.BLACK + for _ in range(80): + moves = [loc for loc in range(board.arrsize) if board.board[loc] == Board.EMPTY and board.would_be_legal(pla, loc)] + if not moves: + break + board.play(pla, rand.choice(moves)) + assert board.zobrist == recomputed_zobrist(board) + pla = Board.get_opp(pla) + + +def test_zobrist_restored_by_undo(): + rand = random.Random(54321) + for _ in range(100): + board = Board(5) + pla = Board.BLACK + for _ in range(80): + moves = [loc for loc in range(board.arrsize) if board.board[loc] == Board.EMPTY and board.would_be_legal(pla, loc)] + if not moves: + break + zobrist_before = board.zobrist + record = board.playRecordedUnsafe(pla, rand.choice(moves)) + assert board.zobrist == recomputed_zobrist(board) + board.undo(record) + assert board.zobrist == zobrist_before + board.play(pla, rand.choice(moves)) + pla = Board.get_opp(pla)