test1978 commited on
Commit
79aaa18
·
verified ·
1 Parent(s): 7860f31

Add chess_mcvs.py

Browse files
Files changed (1) hide show
  1. chess_mcvs.py +1136 -0
chess_mcvs.py ADDED
@@ -0,0 +1,1136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # chess_mcvs.py
2
+ """
3
+ chess_mcvs.py - Full implementation analogous to breakthrough_mcvs.py
4
+
5
+ Features:
6
+ - Chess game logic using python-chess (full rules)
7
+ - Fixed 64×64 padded weighted adjacency matrix (Hilbert-ordered pieces)
8
+ - Piece-specific kappa values
9
+ - Queen-only promotions for flat 4096 policy head compatibility
10
+ - Policy and Value CNNs (lightweight with pooling for feasibility)
11
+ - HilbertOrderedZoneDatabase with add methods and proper k-NN zone score
12
+ - MCVSSearcher (guided λ-PUCT with policy/value/zone)
13
+ - UCTSearcher (baseline)
14
+ - train_networks function
15
+ - Ready for chess_mcvs_vs_uct.py tournament
16
+
17
+ Requires: pip install chess torch numpy
18
+ """
19
+
20
+ import numpy as np
21
+ import torch
22
+ import torch.nn as nn
23
+ import torch.nn.functional as F
24
+ import torch.optim as optim
25
+ from torch.utils.data import DataLoader, TensorDataset
26
+ import chess
27
+ import random
28
+ import time
29
+ import os
30
+ import bisect
31
+ from collections import Counter
32
+ from typing import List, Dict, Tuple
33
+
34
+ # ============================================================================
35
+ # HILBERT CURVE UTILITIES
36
+ # ============================================================================
37
+
38
+ def xy2d(n: int, x: int, y: int) -> int:
39
+ d = 0
40
+ s = n // 2
41
+ while s > 0:
42
+ rx = 1 if (x & s) > 0 else 0
43
+ ry = 1 if (y & s) > 0 else 0
44
+ d += s * s * ((3 * rx) ^ ry)
45
+ if ry == 0:
46
+ if rx == 1:
47
+ x = n - 1 - x
48
+ y = n - 1 - y
49
+ x, y = y, x
50
+ s //= 2
51
+ return d
52
+
53
+ def matrix_to_hilbert_index(W: np.ndarray) -> int:
54
+ if W.size == 0:
55
+ return 0
56
+ W_flat = W.flatten()
57
+ max_idx = np.argmax(W_flat)
58
+ x = max_idx % 64
59
+ y = max_idx // 64
60
+ return xy2d(64, x, y)
61
+
62
+ # ============================================================================
63
+ # ABC MODEL & WEIGHTED MATRIX
64
+ # ============================================================================
65
+
66
+ class ABCModelDynamic:
67
+ """ABC Model with displacement-based B matrices."""
68
+
69
+ def __init__(self, n: int = 2, t: float = 1.0, T: float = 1.41):
70
+ """
71
+ Initialize for 2D board.
72
+
73
+ Args:
74
+ n: Dimension (2 for 2D board)
75
+ t: Minimum distance threshold for adjacency
76
+ T: Maximum distance threshold for adjacency
77
+ """
78
+ self.n = n
79
+ self.t = t
80
+ self.T = T
81
+ self.c0 = np.array([0.0, 0.0, 1.0])
82
+ self.B_blocks = [] # Displacement matrices
83
+ self.piece_positions = [] # Actual positions on SB
84
+ self.a_product = np.eye(3) # Accumulated product
85
+ self.delta = {} # Delta values for each piece
86
+ self.kappa = {} # Tokenized vectors for each piece (ADDED)
87
+ self.stage = 0
88
+
89
+ # MB position tracking
90
+ self.MB_current = self.c0.copy() # Current MB center
91
+ self.MB_previous = self.c0.copy() # Previous MB center
92
+ self.history = []
93
+
94
+ def create_B_displacement(self, dx: float, dy: float) -> np.ndarray:
95
+ """
96
+ Create displacement matrix B.
97
+
98
+ Moves MB by (dx, dy) displacement.
99
+
100
+ B · current_MB = new_MB
101
+
102
+ B = [1 0 dx]
103
+ [0 1 dy]
104
+ [0 0 1]
105
+
106
+ Right column: (dx, dy, 1)ᵀ
107
+ """
108
+ B = np.array([
109
+ [1.0, 0.0, dx],
110
+ [0.0, 1.0, dy],
111
+ [0.0, 0.0, 1.0]
112
+ ])
113
+ return B
114
+
115
+ def add_piece(self, position: tuple, delta_values: tuple, kappa_vector: np.ndarray = None):
116
+ """
117
+ Add a piece at position with delta values and optional kappa vector.
118
+
119
+ Args:
120
+ position: (x, y) tuple
121
+ delta_values: (Δ₁, Δ₂, Δ₃) tuple
122
+ kappa_vector: Tokenized feature vector (optional, defaults to delta_values)
123
+ """
124
+ x, y = position
125
+
126
+ # Current MB position
127
+ current_MB = self.MB_current.copy()
128
+
129
+ # Calculate displacement
130
+ dx = x - current_MB[0]
131
+ dy = y - current_MB[1]
132
+
133
+ # Create displacement matrix
134
+ B_i = self.create_B_displacement(dx, dy)
135
+
136
+ # Store
137
+ idx = len(self.B_blocks)
138
+ self.B_blocks.append(B_i)
139
+ self.piece_positions.append((x, y))
140
+
141
+ # Update accumulated product: a_t = a_{t-1} · B_t
142
+ self.a_product = self.a_product @ B_i
143
+
144
+ # Track MB positions
145
+ self.MB_previous = self.MB_current.copy()
146
+ self.MB_current = self.a_product @ self.c0 # Should equal (x, y, 1)
147
+
148
+ # Store differential vector
149
+ self.delta[idx] = delta_values
150
+
151
+ # Store tokenized vector (or use delta_values as default)
152
+ if kappa_vector is None:
153
+ kappa_vector = np.array(delta_values)
154
+ self.kappa[idx] = kappa_vector
155
+
156
+ # Increment stage
157
+ self.stage += 1
158
+
159
+ # Record move
160
+ self.history.append({
161
+ 'stage': self.stage,
162
+ 'SB_position': (x, y),
163
+ 'displacement': (dx, dy),
164
+ 'B_i': B_i.copy(),
165
+ 'a_t': self.a_product.copy(),
166
+ 'MB_prev': self.MB_previous.copy(),
167
+ 'MB_curr': self.MB_current.copy(),
168
+ 'delta': delta_values,
169
+ 'kappa': kappa_vector.copy()
170
+ })
171
+
172
+ def add_move(self, x: float, y: float, player: int):
173
+ """
174
+ Convenience method: Add a move with automatic delta values.
175
+
176
+ Args:
177
+ x, y: Position coordinates
178
+ player: 1 for X (Δ₂=1.0), 2 for O (Δ₂=1.1)
179
+ """
180
+ delta_1 = 1.0 # Occupied
181
+ delta_2 = 1.0 if player == 1 else 1.1 # Player color
182
+ delta_3 = 1.0 # Same value for all pieces
183
+
184
+ self.add_piece((x, y), (delta_1, delta_2, delta_3))
185
+
186
+ def get_board_state(self) -> str:
187
+ """Visual board state."""
188
+ pos_map = {
189
+ (-1, 1): 0, (0, 1): 1, (1, 1): 2,
190
+ (-1, 0): 3, (0, 0): 4, (1, 0): 5,
191
+ (-1, -1): 6, (0, -1): 7, (1, -1): 8
192
+ }
193
+
194
+ board = [' '] * 9
195
+ for i, pos in enumerate(self.piece_positions):
196
+ if pos in pos_map:
197
+ player = 'X' if self.delta[i][1] == 1.0 else 'O'
198
+ board[pos_map[pos]] = player
199
+
200
+ s = f" {board[0]} | {board[1]} | {board[2]} \n"
201
+ s += "---|---|---\n"
202
+ s += f" {board[3]} | {board[4]} | {board[5]} \n"
203
+ s += "---|---|---\n"
204
+ s += f" {board[6]} | {board[7]} | {board[8]} \n"
205
+ return s
206
+
207
+ def describe_move(self, stage: int):
208
+ """Describe a specific move."""
209
+ if stage < 1 or stage > len(self.history):
210
+ return "Invalid stage"
211
+
212
+ move = self.history[stage - 1]
213
+ desc = "=" * 70 + "\n"
214
+ desc += f"MOVE {stage}: {move['SB_position']}\n"
215
+ desc += "=" * 70 + "\n\n"
216
+
217
+ idx = stage - 1
218
+ B_i = self.B_blocks[idx]
219
+
220
+ desc += f"Mobile Board (MB) movement:\n"
221
+ desc += f" Previous MB: {move['MB_prev']}\n"
222
+ desc += f" Target (SB): {move['SB_position']}\n"
223
+ desc += f" Displacement: Δ = {move['displacement']}\n"
224
+ desc += f" New MB: {move['MB_curr']}\n\n"
225
+ desc += f"Displacement matrix B_{stage}:\n{B_i}\n"
226
+ desc += f"Right column: {B_i[:, 2]} = (Δx={move['displacement'][0]}, Δy={move['displacement'][1]}, 1)\n\n"
227
+
228
+ desc += f"Accumulated product a_{stage}:\n{move['a_t']}\n"
229
+ desc += f"Right column: {move['a_t'][:, 2]}\n\n"
230
+
231
+ desc += f"Delta values: {move['delta']}\n"
232
+ desc += f"Kappa vector: {move['kappa']}\n\n"
233
+
234
+ desc += f"Verification:\n"
235
+ desc += f" a_{stage} · c₀ = {move['a_t'] @ self.c0}\n"
236
+ desc += f" Should equal: {np.array([move['SB_position'][0], move['SB_position'][1], 1.0])}\n\n"
237
+
238
+ desc += "Board state:\n"
239
+ desc += self.get_board_state()
240
+
241
+ return desc
242
+
243
+
244
+ class WeightedMatrixABC:
245
+ """
246
+ Compute weighted adjacency matrix from ABCModelDynamic.
247
+
248
+ CRITICAL: Converts (x,y) tuples to homogeneous coords [x,y,1]
249
+ to match algebraic structure of B matrices from abc_model.py
250
+
251
+ Implements Definition from Section 6:
252
+ W[i,j](t) = A[i,j](t) ⊙ S[i,j](t) ⊙ F[i,j](t)
253
+ """
254
+
255
+ def __init__(self, abc_model: ABCModelDynamic, sigma: float = 1.0):
256
+ """
257
+ Initialize with ABC model instance.
258
+
259
+ Args:
260
+ abc_model: ABCModelDynamic instance
261
+ sigma: Standard deviation for Gaussian spatial kernel
262
+ """
263
+ self.abc = abc_model
264
+ self.n = abc_model.n
265
+ self.t = abc_model.t
266
+ self.T = abc_model.T
267
+ self.sigma = sigma
268
+ self.positions = None # Will store homogeneous coords [x, y, 1]
269
+ self.D = None
270
+ self.A = None
271
+ self.S = None
272
+ self.F = None
273
+ self.W = None
274
+
275
+ def compute_piece_positions(self) -> np.ndarray:
276
+ """
277
+ Extract positions from ABC model and convert to homogeneous coordinates.
278
+
279
+ In abc_model.py:
280
+ - piece_positions are (x, y) tuples
281
+ - MB_current = a_product @ c0 produces [x, y, 1]
282
+
283
+ For matrix computations, convert to homogeneous coords to match
284
+ the algebraic structure of B matrices.
285
+
286
+ Returns: Array of shape (num_pieces, 3) with homogeneous coords [x, y, 1]
287
+ """
288
+ positions = []
289
+ for pos in self.abc.piece_positions:
290
+ # pos is (x, y) from piece_positions
291
+ x, y = pos
292
+ # Convert to homogeneous coordinates to align with B matrix algebra
293
+ positions.append(np.array([x, y, 1.0]))
294
+
295
+ self.positions = np.array(positions)
296
+ return self.positions
297
+
298
+ def compute_distance_matrix(self) -> np.ndarray:
299
+ """
300
+ Compute pairwise Euclidean distances using 2D components only.
301
+
302
+ Even though positions are [x, y, 1], we compute distance using only [x, y]
303
+ D[i,j] = ||[x_i, y_i] - [x_j, y_j]||₂
304
+ """
305
+ if self.positions is None:
306
+ self.compute_piece_positions()
307
+
308
+ num = len(self.positions)
309
+ D = np.zeros((num, num))
310
+
311
+ for i in range(num):
312
+ for j in range(num):
313
+ # Use only 2D coordinates for distance
314
+ pos_i = self.positions[i][:2]
315
+ pos_j = self.positions[j][:2]
316
+ D[i, j] = np.linalg.norm(pos_i - pos_j)
317
+
318
+ self.D = D
319
+ return D
320
+
321
+ def compute_adjacency_matrix(self) -> np.ndarray:
322
+ """
323
+ Compute Adjacency Matrix (Definition 6.2).
324
+
325
+ A[i,j] = 1 if:
326
+ - k ≤ D[i,j] ≤ K (grid-adjacent)
327
+ - i == j AND i is isolated (no neighbors in [k,K])
328
+
329
+ A[i,j] = 0 otherwise
330
+ """
331
+ if self.D is None:
332
+ self.compute_distance_matrix()
333
+
334
+ num = len(self.positions)
335
+ A = np.zeros((num, num))
336
+
337
+ # Identify isolated pieces
338
+ isolated = set()
339
+ for i in range(num):
340
+ has_neighbor = False
341
+ for j in range(num):
342
+ if i != j and self.t <= self.D[i, j] <= self.T:
343
+ has_neighbor = True
344
+ break
345
+ if not has_neighbor:
346
+ isolated.add(i)
347
+
348
+ # Fill adjacency matrix
349
+ for i in range(num):
350
+ for j in range(num):
351
+ # Check adjacency distance
352
+ if i != j and self.t <= self.D[i, j] <= self.T:
353
+ A[i, j] = 1.0
354
+ # Check if isolated piece
355
+ elif i == j and i in isolated:
356
+ A[i, j] = 1.0
357
+
358
+ self.A = A
359
+ return A
360
+
361
+ def compute_spatial_matrix(self) -> np.ndarray:
362
+ """
363
+ Compute Spatial Matrix (Definition 6.3).
364
+
365
+ S[i,j](t) = exp(-||c_i(t) - c_j(t)||² / (2σ²))
366
+
367
+ Gaussian kernel based on Euclidean distance.
368
+ """
369
+ if self.D is None:
370
+ self.compute_distance_matrix()
371
+
372
+ num = len(self.positions)
373
+ S = np.zeros((num, num))
374
+
375
+ for i in range(num):
376
+ for j in range(num):
377
+ S[i, j] = np.exp(-self.D[i, j]**2 / (2 * self.sigma**2))
378
+
379
+ self.S = S
380
+ return S
381
+
382
+ def compute_feature_matrix(self) -> np.ndarray:
383
+ """
384
+ Compute Feature Matrix (Definition 6.4).
385
+
386
+ F[i,j](t) = <κ(B_i), κ(B_j)> / (||κ(B_i)|| · ||κ(B_j)||)
387
+
388
+ Cosine similarity between tokenized vectors.
389
+ """
390
+ num = len(self.positions)
391
+ F = np.zeros((num, num))
392
+
393
+ # Extract tokenized vectors
394
+ kappas = []
395
+ for i in range(num):
396
+ if i in self.abc.kappa:
397
+ kappas.append(self.abc.kappa[i])
398
+ else:
399
+ # Default: use delta as feature vector
400
+ if i in self.abc.delta:
401
+ kappas.append(np.array(self.abc.delta[i]))
402
+ else:
403
+ kappas.append(np.array([1.0, 1.0, 1.0]))
404
+
405
+ # Compute cosine similarity
406
+ for i in range(num):
407
+ for j in range(num):
408
+ kappa_i = kappas[i]
409
+ kappa_j = kappas[j]
410
+
411
+ dot_product = np.dot(kappa_i, kappa_j)
412
+ norm_i = np.linalg.norm(kappa_i)
413
+ norm_j = np.linalg.norm(kappa_j)
414
+
415
+ if norm_i > 0 and norm_j > 0:
416
+ F[i, j] = dot_product / (norm_i * norm_j)
417
+ else:
418
+ F[i, j] = 0.0
419
+
420
+ self.F = F
421
+ return F
422
+
423
+ def compute_weighted_matrix(self) -> np.ndarray:
424
+ """
425
+ Compute Weighted Matrix (Definition from Section 6).
426
+
427
+ W[i,j](t) = A[i,j](t) ⊙ S[i,j](t) ⊙ F[i,j](t)
428
+
429
+ Returns: W - the full weighted matrix
430
+ """
431
+ if self.A is None:
432
+ self.compute_adjacency_matrix()
433
+ if self.S is None:
434
+ self.compute_spatial_matrix()
435
+ if self.F is None:
436
+ self.compute_feature_matrix()
437
+
438
+ # Hadamard product (element-wise multiplication)
439
+ W = self.A * self.S * self.F
440
+
441
+ self.W = W
442
+ return W
443
+
444
+ def compute_manhattan_distance(self, other: 'WeightedMatrixABC') -> float:
445
+ """
446
+ Compute Manhattan distance between two weighted matrices.
447
+
448
+ distance = ||W_1 - W_2||₁
449
+ """
450
+ if self.W is None:
451
+ self.compute_weighted_matrix()
452
+ if other.W is None:
453
+ other.compute_weighted_matrix()
454
+
455
+ W1 = self.W
456
+ W2 = other.W
457
+
458
+ # Pad to same size
459
+ size = max(len(W1), len(W2))
460
+ W1_padded = np.zeros((size, size))
461
+ W2_padded = np.zeros((size, size))
462
+ W1_padded[:len(W1), :len(W1)] = W1
463
+ W2_padded[:len(W2), :len(W2)] = W2
464
+
465
+ return np.linalg.norm(W1_padded - W2_padded, ord=1)
466
+
467
+ # ============================================================================
468
+ # CHESS GAME LOGIC
469
+ # ============================================================================
470
+
471
+ class Chess:
472
+ def __init__(self):
473
+ self.board = chess.Board()
474
+ self.move_count = 0
475
+ self.position_history = Counter()
476
+ self._update_position_key()
477
+
478
+ def _update_position_key(self):
479
+ key = (self.board.fen(), self.board.turn)
480
+ self.position_history[key] += 1
481
+
482
+ def copy(self):
483
+ new_game = Chess()
484
+ new_game.board = self.board.copy()
485
+ new_game.move_count = self.move_count
486
+ new_game.position_history = self.position_history.copy()
487
+ return new_game
488
+
489
+ def undo_move(self) -> None:
490
+ """
491
+ Undo the last move (pop) and revert bookkeeping (move_count, position_history).
492
+
493
+ This complements `apply_move` which increments move_count and updates
494
+ the position_history for the new state after a push. `undo_move` decrements
495
+ the same position record and pops the board to restore the previous state.
496
+ """
497
+ # The current board fen/turn corresponds to the state that was last pushed;
498
+ # decrement its count before popping to fully revert the update.
499
+ key = (self.board.fen(), self.board.turn)
500
+ if key in self.position_history:
501
+ # decrement safely (don't remove key to avoid KeyError on repeated ops)
502
+ self.position_history[key] = max(0, self.position_history[key] - 1)
503
+ # pop the board and update counters
504
+ self.board.pop()
505
+ self.move_count = max(0, self.move_count - 1)
506
+
507
+ def get_legal_moves(self) -> List[chess.Move]:
508
+ moves = []
509
+ for move in self.board.legal_moves:
510
+ if move.promotion is None or move.promotion == chess.QUEEN:
511
+ moves.append(move)
512
+ return moves
513
+
514
+ def apply_move(self, move: chess.Move) -> None:
515
+ self.board.push(move)
516
+ self.move_count += 1
517
+ self._update_position_key()
518
+
519
+ def is_terminal(self) -> bool:
520
+ return self.board.is_game_over(claim_draw=True)
521
+
522
+ def check_winner(self) -> int:
523
+ if not self.is_terminal():
524
+ return 0
525
+ outcome = self.board.outcome(claim_draw=True)
526
+ if outcome is None or outcome.winner is None:
527
+ return 0
528
+ return 1 if outcome.winner == chess.WHITE else -1
529
+
530
+ def get_weighted_adjacency_matrix(self) -> np.ndarray:
531
+ occupied = []
532
+ for sq in chess.SQUARES:
533
+ piece = self.board.piece_at(sq)
534
+ if piece:
535
+ file = chess.square_file(sq)
536
+ rank = chess.square_rank(sq)
537
+ x = float(file) - 3.5
538
+ y = float(rank) - 3.5
539
+ color_val = 1.0 if piece.color == chess.WHITE else 1.1
540
+ type_val = {
541
+ chess.PAWN: 1.0,
542
+ chess.KNIGHT: 3.0,
543
+ chess.BISHOP: 3.2,
544
+ chess.ROOK: 5.0,
545
+ chess.QUEEN: 9.0,
546
+ chess.KING: 20.0
547
+ }[piece.piece_type]
548
+ kappa = np.array([1.0, color_val, type_val])
549
+ hilbert_d = xy2d(8, file, rank)
550
+ occupied.append((hilbert_d, (x, y), kappa))
551
+
552
+ occupied.sort(key=lambda t: t[0])
553
+
554
+ abc = ABCModelDynamic(n=2, t=1.0, T=1.41)
555
+ for _, pos, kappa_vec in occupied:
556
+ abc.add_piece(pos, delta_values=(1.0, kappa_vec[1], kappa_vec[2]), kappa_vector=kappa_vec)
557
+
558
+ w_calc = WeightedMatrixABC(abc, sigma=1.0)
559
+ W_var = w_calc.compute_weighted_matrix()
560
+
561
+ num = len(occupied)
562
+ W = np.zeros((64, 64))
563
+ if num > 0:
564
+ W[:num, :num] = W_var
565
+ return W
566
+
567
+ # ============================================================================
568
+ # NEURAL NETWORKS
569
+ # ============================================================================
570
+
571
+ class PolicyNetworkCNN(nn.Module):
572
+ def __init__(self):
573
+ super().__init__()
574
+ self.conv = nn.Sequential(
575
+ nn.Conv2d(1, 64, kernel_size=5, padding=2, stride=2),
576
+ nn.ReLU(),
577
+ nn.Conv2d(64, 128, kernel_size=5, padding=2, stride=2),
578
+ nn.ReLU(),
579
+ nn.Conv2d(128, 128, kernel_size=3, padding=1, stride=2),
580
+ nn.ReLU(),
581
+ )
582
+ self.fc = nn.Linear(128 * 8 * 8, 4096)
583
+
584
+ def forward(self, x):
585
+ x = self.conv(x)
586
+ x = x.view(x.size(0), -1)
587
+ return self.fc(x)
588
+
589
+ class ValueNetworkCNN(nn.Module):
590
+ def __init__(self):
591
+ super().__init__()
592
+ self.conv = nn.Sequential(
593
+ nn.Conv2d(1, 64, kernel_size=5, padding=2, stride=2),
594
+ nn.ReLU(),
595
+ nn.Conv2d(64, 128, kernel_size=5, padding=2, stride=2),
596
+ nn.ReLU(),
597
+ nn.Conv2d(128, 128, kernel_size=3, padding=1, stride=2),
598
+ nn.ReLU(),
599
+ )
600
+ self.fc = nn.Sequential(
601
+ nn.Linear(128 * 8 * 8, 512),
602
+ nn.ReLU(),
603
+ nn.Linear(512, 1),
604
+ nn.Tanh()
605
+ )
606
+
607
+ def forward(self, x):
608
+ x = self.conv(x)
609
+ x = x.view(x.size(0), -1)
610
+ return self.fc(x)
611
+
612
+ # ============================================================================
613
+ # ZONE DATABASE
614
+ # ============================================================================
615
+
616
+ class HilbertOrderedZoneDatabase:
617
+ def __init__(self, filepath: str = "chess_zone_db.npz", max_size: int = 100000):
618
+ self.filepath = filepath
619
+ self.max_size = max_size # Maximum matrices per zone
620
+ self.winning_matrices = []
621
+ self.winning_indices = []
622
+ self.losing_matrices = []
623
+ self.losing_indices = []
624
+ self.draw_matrices = []
625
+ self.draw_indices = []
626
+ self.load()
627
+
628
+ def add_winning_matrix(self, W: np.ndarray):
629
+ if W.shape != (64, 64):
630
+ return
631
+
632
+ hilbert_idx = matrix_to_hilbert_index(W)
633
+ pos = bisect.bisect_left(self.winning_indices, hilbert_idx)
634
+ self.winning_indices.insert(pos, hilbert_idx)
635
+ self.winning_matrices.insert(pos, W.copy())
636
+
637
+ def add_losing_matrix(self, W: np.ndarray):
638
+ if W.shape != (64, 64):
639
+ return
640
+
641
+ hilbert_idx = matrix_to_hilbert_index(W)
642
+ pos = bisect.bisect_left(self.losing_indices, hilbert_idx)
643
+ self.losing_indices.insert(pos, hilbert_idx)
644
+ self.losing_matrices.insert(pos, W.copy())
645
+
646
+ def add_draw_matrix(self, W: np.ndarray):
647
+ if W.shape != (64, 64):
648
+ return
649
+
650
+ hilbert_idx = matrix_to_hilbert_index(W)
651
+ pos = bisect.bisect_left(self.draw_indices, hilbert_idx)
652
+ self.draw_indices.insert(pos, hilbert_idx)
653
+ self.draw_matrices.insert(pos, W.copy())
654
+
655
+ def add_game_record(self, trajectory: List[Chess], result: int, sample_rate: float = 0.3):
656
+ """Add game record with sampling to control database size."""
657
+ # Sample states: always keep first and last, random subset of middle
658
+ sampled_states = []
659
+
660
+ for i, state in enumerate(trajectory):
661
+ if i == 0 or i == len(trajectory) - 1:
662
+ # Always keep start and end positions
663
+ sampled_states.append(state)
664
+ elif random.random() < sample_rate:
665
+ # Randomly sample middle positions
666
+ sampled_states.append(state)
667
+
668
+ for state in sampled_states:
669
+ W = state.get_weighted_adjacency_matrix()
670
+
671
+ if result == 1:
672
+ self.add_winning_matrix(W)
673
+ elif result == -1:
674
+ self.add_losing_matrix(W)
675
+ else:
676
+ self.add_draw_matrix(W)
677
+
678
+ def save(self):
679
+ """Save database with pruning if too large."""
680
+ # Prune if any zone exceeds 80% of max_size
681
+ threshold = int(self.max_size * 0.8)
682
+
683
+ if (len(self.winning_matrices) > threshold or
684
+ len(self.losing_matrices) > threshold or
685
+ len(self.draw_matrices) > threshold):
686
+ print(f"Pruning database before save...")
687
+ self.prune_database(target_size=int(self.max_size * 0.7))
688
+
689
+ try:
690
+ np.savez_compressed(
691
+ self.filepath,
692
+ winning=np.array(self.winning_matrices, dtype=object),
693
+ winning_indices=np.array(self.winning_indices),
694
+ losing=np.array(self.losing_matrices, dtype=object),
695
+ losing_indices=np.array(self.losing_indices),
696
+ draw=np.array(self.draw_matrices, dtype=object),
697
+ draw_indices=np.array(self.draw_indices),
698
+ )
699
+ print(f"Zone DB saved: W={len(self.winning_matrices)}, L={len(self.losing_matrices)}, D={len(self.draw_matrices)}")
700
+ except MemoryError:
701
+ print(f"MemoryError during save! Pruning more aggressively...")
702
+ self.prune_database(target_size=1000)
703
+ # Try again with much smaller size
704
+ np.savez_compressed(
705
+ self.filepath,
706
+ winning=np.array(self.winning_matrices, dtype=object),
707
+ winning_indices=np.array(self.winning_indices),
708
+ losing=np.array(self.losing_matrices, dtype=object),
709
+ losing_indices=np.array(self.losing_indices),
710
+ draw=np.array(self.draw_matrices, dtype=object),
711
+ draw_indices=np.array(self.draw_indices),
712
+ )
713
+
714
+ def load(self):
715
+ if os.path.exists(self.filepath):
716
+ try:
717
+ data = np.load(self.filepath, allow_pickle=True)
718
+ self.winning_matrices = list(data['winning_matrices'])
719
+ self.winning_indices = list(data['winning_indices'])
720
+ self.losing_matrices = list(data['losing_matrices'])
721
+ self.losing_indices = list(data['losing_indices'])
722
+ self.draw_matrices = list(data['draw_matrices'])
723
+ self.draw_indices = list(data['draw_indices'])
724
+ print(f"Loaded zone DB: W={len(self.winning_matrices)}, L={len(self.losing_matrices)}, D={len(self.draw_matrices)}")
725
+ except Exception as e:
726
+ print(f"Failed to load zone database: {e}")
727
+
728
+ def prune_database(self, target_size: int = 5000):
729
+ """Keep only evenly-spaced diverse samples along Hilbert curve."""
730
+ def prune_zone(matrices, indices, target):
731
+ if len(matrices) <= target:
732
+ return matrices, indices
733
+
734
+ # Keep evenly spaced samples
735
+ step = max(1, len(matrices) // target)
736
+ keep_idx = list(range(0, len(matrices), step))[:target]
737
+
738
+ new_matrices = [matrices[i] for i in keep_idx]
739
+ new_indices = [indices[i] for i in keep_idx]
740
+
741
+ return new_matrices, new_indices
742
+
743
+ old_sizes = (len(self.winning_matrices), len(self.losing_matrices), len(self.draw_matrices))
744
+
745
+ self.winning_matrices, self.winning_indices = prune_zone(
746
+ self.winning_matrices, self.winning_indices, target_size
747
+ )
748
+ self.losing_matrices, self.losing_indices = prune_zone(
749
+ self.losing_matrices, self.losing_indices, target_size
750
+ )
751
+ self.draw_matrices, self.draw_indices = prune_zone(
752
+ self.draw_matrices, self.draw_indices, target_size
753
+ )
754
+
755
+ new_sizes = (len(self.winning_matrices), len(self.losing_matrices), len(self.draw_matrices))
756
+ print(f"Pruned: W {old_sizes}→{new_sizes}, L {old_sizes}→{new_sizes}, D {old_sizes}→{new_sizes}")
757
+
758
+ def compute_zone_score(self, W: np.ndarray, k: int = 5, beta: float = 0.5) -> float:
759
+ """Compute zone guidance score Z(x(t), a) ∈ [-1, 1]."""
760
+ if W.shape != (64, 64):
761
+ return 0.0
762
+
763
+ def knn_similarity(matrices, k_val):
764
+ if len(matrices) == 0:
765
+ return 0.0
766
+
767
+ k_actual = min(k_val, len(matrices))
768
+ distances = []
769
+
770
+ for mat in matrices[:k_actual]:
771
+ dist = np.sum(np.abs(W - mat)) / (64.0 * 64.0)
772
+ distances.append(dist)
773
+
774
+ similarities = [1.0 - d for d in distances]
775
+ return np.mean(similarities)
776
+
777
+ zone_win = knn_similarity(self.winning_matrices, k)
778
+ zone_loss = knn_similarity(self.losing_matrices, k)
779
+ zone_draw = knn_similarity(self.draw_matrices, k)
780
+
781
+ Z = zone_win - zone_loss + beta * zone_draw
782
+ return float(np.clip(Z, -1.0, 1.0))
783
+
784
+ # ============================================================================
785
+ # TRAINING FUNCTION
786
+ # ============================================================================
787
+
788
+ def train_networks(policy_net, value_net, W_states, policies, values, epochs=5, batch_size=32, device='cpu', lr=0.001):
789
+ policy_net.train()
790
+ value_net.train()
791
+ opt_p = optim.Adam(policy_net.parameters(), lr=lr)
792
+ opt_v = optim.Adam(value_net.parameters(), lr=lr)
793
+
794
+ W_tensor = torch.tensor(np.array(W_states)[:, np.newaxis, :, :], dtype=torch.float32)
795
+ pi_tensor = torch.tensor(np.array(policies), dtype=torch.float32)
796
+ v_tensor = torch.tensor(np.array(values), dtype=torch.float32).unsqueeze(1)
797
+
798
+ dataset = TensorDataset(W_tensor, pi_tensor, v_tensor)
799
+ loader = DataLoader(dataset, batch_size=batch_size, shuffle=True)
800
+
801
+ for epoch in range(epochs):
802
+ total_loss = 0.0
803
+ for batch_W, batch_pi, batch_v in loader:
804
+ batch_W = batch_W.to(device)
805
+ batch_pi = batch_pi.to(device)
806
+ batch_v = batch_v.to(device)
807
+
808
+ logits = policy_net(batch_W)
809
+ # Fixed policy loss: KL divergence between network policy and MCTS visit distribution
810
+ policy_loss = F.kl_div(F.log_softmax(logits, dim=1), batch_pi, reduction='batchmean')
811
+
812
+ pred_v = value_net(batch_W)
813
+ value_loss = F.mse_loss(pred_v, batch_v)
814
+
815
+ loss = policy_loss + value_loss
816
+ total_loss += loss.item()
817
+
818
+ opt_p.zero_grad()
819
+ opt_v.zero_grad()
820
+ loss.backward()
821
+
822
+ # Optional but recommended: gradient clipping
823
+ torch.nn.utils.clip_grad_norm_(policy_net.parameters(), max_norm=1.0)
824
+ torch.nn.utils.clip_grad_norm_(value_net.parameters(), max_norm=1.0)
825
+
826
+ opt_p.step()
827
+ opt_v.step()
828
+
829
+ # Optional: print epoch loss for monitoring
830
+ print(f"Epoch {epoch+1}/{epochs} - Loss: {total_loss/len(loader):.4f}")
831
+
832
+ # ============================================================================
833
+ # MCVS SEARCHER
834
+ # ============================================================================
835
+
836
+ class MCVSSearcher:
837
+ def __init__(self, policy_net, value_net, zone_db, device='cpu',
838
+ cpuct=1.41, lambda_zone=0.0, k_zone=5, use_nets=True,
839
+ dirichlet_alpha: float = 0.3, dirichlet_noise_fraction: float = 0.25):
840
+ self.policy_net = policy_net.to(device) if policy_net and use_nets else None
841
+ self.value_net = value_net.to(device) if value_net and use_nets else None
842
+ self.zone_db = zone_db
843
+ self.device = device
844
+ self.cpuct = cpuct
845
+ self.lambda_zone = lambda_zone
846
+ self.k_zone = k_zone
847
+ self.use_nets = use_nets
848
+ # Dirichlet noise hyperparameters (alpha for the Dirichlet, and fraction)
849
+ self.dirichlet_alpha = float(dirichlet_alpha)
850
+ self.dirichlet_noise_fraction = float(dirichlet_noise_fraction)
851
+
852
+ def _rollout(self, game: Chess) -> float:
853
+ """Simple random rollout for leaf evaluation when no value network.
854
+
855
+ Uses push/pop (apply_move / undo_move) on the provided `game` to avoid
856
+ allocating a full copy per rollout. The rollout will undo its own
857
+ moves before returning.
858
+ """
859
+ depth = 0
860
+ max_depth = 60 # reduced depth for faster rollouts (configurable)
861
+ moves_played = []
862
+ while not game.is_terminal() and depth < max_depth:
863
+ moves = game.get_legal_moves()
864
+ if not moves:
865
+ break
866
+ m = random.choice(moves)
867
+ game.apply_move(m)
868
+ moves_played.append(m)
869
+ depth += 1
870
+
871
+ winner = game.check_winner()
872
+
873
+ # Undo rollout moves
874
+ for _ in range(len(moves_played)):
875
+ game.undo_move()
876
+
877
+ return float(winner) # +1, -1, or 0
878
+
879
+ class Node:
880
+ def __init__(self, prior: float = 0.0, zone: float = 0.0):
881
+ self.prior = prior
882
+ self.zone = zone
883
+ self.visit_count = 0
884
+ self.value_sum = 0.0
885
+ self.children: Dict[chess.Move, 'MCVSSearcher.Node'] = {}
886
+
887
+ def search_with_time_budget(self, game: Chess, time_budget: float) -> Tuple[Dict[chess.Move, int], int]:
888
+ root = self.Node()
889
+ start_time = time.time()
890
+ simulations = 0
891
+
892
+ while time.time() - start_time < time_budget:
893
+ current_game = game
894
+ node = root
895
+ path = []
896
+ moves_made = []
897
+
898
+ # Selection
899
+ while node.children:
900
+ path.append(node)
901
+ total_n = sum(c.visit_count for c in node.children.values()) + 1
902
+ best_score = -float('inf')
903
+ best_move = None
904
+ best_child = None
905
+ for move, child in node.children.items():
906
+ q = child.value_sum / child.visit_count if child.visit_count > 0 else 0.0
907
+ p_lambda = max(child.prior + self.lambda_zone * child.zone, 0.001)
908
+ u = self.cpuct * p_lambda * np.sqrt(total_n) / (1 + child.visit_count)
909
+ score = q + u
910
+ if score > best_score:
911
+ best_score = score
912
+ best_move = move
913
+ best_child = child
914
+ current_game.apply_move(best_move)
915
+ moves_made.append(best_move)
916
+ node = best_child
917
+
918
+ path.append(node)
919
+
920
+ # Terminal node?
921
+ if current_game.is_terminal():
922
+ value = float(current_game.check_winner())
923
+ else:
924
+ # Expansion & Evaluation
925
+ legal_moves = current_game.get_legal_moves()
926
+
927
+ if self.use_nets and self.policy_net is not None and self.value_net is not None:
928
+ # === Neural network branch ===
929
+ W = current_game.get_weighted_adjacency_matrix()
930
+ W_tensor = torch.from_numpy(W)[None, None, :, :].float().to(self.device)
931
+ with torch.no_grad():
932
+ logits = self.policy_net(W_tensor)[0]
933
+ value = self.value_net(W_tensor)[0, 0].item()
934
+
935
+ probs = F.softmax(logits, dim=0).cpu().numpy()
936
+ prior_dict = {}
937
+ prior_sum = 0.0
938
+ for m in legal_moves:
939
+ idx = m.from_square * 64 + m.to_square
940
+ p = float(probs[idx])
941
+ prior_dict[m] = p
942
+ prior_sum += p
943
+ if prior_sum > 0.0:
944
+ for m in prior_dict:
945
+ prior_dict[m] /= prior_sum
946
+ else:
947
+ p = 1.0 / len(legal_moves)
948
+ prior_dict = {m: p for m in legal_moves}
949
+ else:
950
+ # === Zone-only / no-nets branch ===
951
+ value = self._rollout(current_game)
952
+ prior_dict = {m: 1.0 / len(legal_moves) for m in legal_moves} if legal_moves else {}
953
+
954
+ # Dirichlet noise only at root and only when using nets.
955
+ # Use configured hyperparameters so the same alpha/fraction
956
+ # can be applied to both priors and zone guidance.
957
+ if len(path) == 1 and self.use_nets and legal_moves:
958
+ noise = np.random.dirichlet([self.dirichlet_alpha] * len(legal_moves))
959
+ for i, m in enumerate(legal_moves):
960
+ prior_dict[m] = (1 - self.dirichlet_noise_fraction) * prior_dict[m] + \
961
+ self.dirichlet_noise_fraction * noise[i]
962
+ # Renormalize
963
+ prior_sum = sum(prior_dict.values())
964
+ if prior_sum > 0:
965
+ for m in prior_dict:
966
+ prior_dict[m] /= prior_sum
967
+
968
+ # Create children with zone scores. Use apply/undo on the shared
969
+ # board to avoid allocating a full copy per child. Skip heavy
970
+ # weighted-matrix computations when zone guidance is disabled
971
+ # (lambda_zone == 0) or when the DB is empty.
972
+ zone_db_empty = (len(self.zone_db.winning_matrices) == 0 and
973
+ len(self.zone_db.losing_matrices) == 0 and
974
+ len(self.zone_db.draw_matrices) == 0)
975
+ need_zone = (self.lambda_zone != 0.0) and (not zone_db_empty)
976
+
977
+ for i, m in enumerate(legal_moves):
978
+ if need_zone:
979
+ current_game.apply_move(m)
980
+ child_W = current_game.get_weighted_adjacency_matrix()
981
+ child_Z = self.zone_db.compute_zone_score(child_W, k=self.k_zone)
982
+ current_game.undo_move()
983
+ else:
984
+ child_Z = 0.0
985
+
986
+ # If Dirichlet noise was added to priors at the root, apply the same
987
+ # noise (mapped to [-1,1]) to zone guidance so both signals share
988
+ # the same stochastic perturbation controlled by the same alpha.
989
+ if len(path) == 1 and self.use_nets and legal_moves:
990
+ z_noise = (noise[i] * 2.0) - 1.0 # map [0,1] -> [-1,1]
991
+ child_Z = (1 - self.dirichlet_noise_fraction) * child_Z + \
992
+ self.dirichlet_noise_fraction * z_noise
993
+
994
+ child = self.Node(prior=prior_dict.get(m, 0.0), zone=child_Z)
995
+ node.children[m] = child
996
+
997
+ # Backpropagation
998
+ v = value
999
+ for n in reversed(path):
1000
+ n.visit_count += 1
1001
+ n.value_sum += v
1002
+ v = -v
1003
+
1004
+ # Undo moves that were applied during selection (the rollout
1005
+ # already undid its own moves). This restores `game` to the root
1006
+ # state for the next simulation.
1007
+ for _ in range(len(moves_made)):
1008
+ current_game.undo_move()
1009
+
1010
+ simulations += 1
1011
+
1012
+ visits = {move: child.visit_count for move, child in root.children.items()}
1013
+ if not visits:
1014
+ legal = game.get_legal_moves()
1015
+ visits = {random.choice(legal): 1} if legal else {}
1016
+ return visits, simulations
1017
+
1018
+ # ============================================================================
1019
+ # UCT SEARCHER (your original from the provided file)
1020
+ # ============================================================================
1021
+
1022
+ class UCTSearcher:
1023
+ def __init__(self, cpuct=np.sqrt(2.0)):
1024
+ self.cpuct = cpuct
1025
+
1026
+ class Node:
1027
+ def __init__(self):
1028
+ self.visit_count = 0
1029
+ self.value_sum = 0.0
1030
+ self.children: Dict[chess.Move, 'UCTSearcher.Node'] = {}
1031
+
1032
+ def _rollout(self, game: Chess) -> float:
1033
+ depth = 0
1034
+ max_depth = 60
1035
+ moves_played = []
1036
+ while not game.is_terminal() and depth < max_depth:
1037
+ moves = game.get_legal_moves()
1038
+ if not moves:
1039
+ break
1040
+ m = random.choice(moves)
1041
+ game.apply_move(m)
1042
+ moves_played.append(m)
1043
+ depth += 1
1044
+
1045
+ if depth >= max_depth:
1046
+ # undo rollout moves
1047
+ for _ in range(len(moves_played)):
1048
+ game.undo_move()
1049
+ return 0.0
1050
+
1051
+ winner = game.check_winner()
1052
+
1053
+ # undo rollout moves
1054
+ for _ in range(len(moves_played)):
1055
+ game.undo_move()
1056
+
1057
+ if winner == 1:
1058
+ return 1.0
1059
+ elif winner == -1:
1060
+ return -1.0
1061
+ else:
1062
+ return 0.0
1063
+
1064
+ def search_with_time_budget(self, game: Chess, time_budget: float) -> Tuple[Dict[chess.Move, int], int]:
1065
+ root = self.Node()
1066
+ start_time = time.time()
1067
+ simulations = 0
1068
+
1069
+ while time.time() - start_time < time_budget:
1070
+ current_game = game
1071
+ node = root
1072
+ path = [node]
1073
+ moves_made = []
1074
+
1075
+ while True:
1076
+ legal_moves = current_game.get_legal_moves()
1077
+
1078
+ if current_game.is_terminal():
1079
+ winner = current_game.check_winner()
1080
+ value = 1.0 if winner == 1 else -1.0 if winner == -1 else 0.0
1081
+ break
1082
+
1083
+ if not legal_moves:
1084
+ value = 0.0
1085
+ break
1086
+
1087
+ unvisited_moves = [m for m in legal_moves if m not in node.children]
1088
+ if unvisited_moves:
1089
+ move = random.choice(unvisited_moves)
1090
+ child = self.Node()
1091
+ node.children[move] = child
1092
+ current_game.apply_move(move)
1093
+ moves_made.append(move)
1094
+ path.append(child)
1095
+ value = self._rollout(current_game)
1096
+ break
1097
+
1098
+ best_score = -float('inf')
1099
+ best_move = None
1100
+ best_child = None
1101
+ total_visits = node.visit_count
1102
+
1103
+ for move in legal_moves:
1104
+ child = node.children[move]
1105
+ Q = child.value_sum / (child.visit_count + 1e-8) if child.visit_count > 0 else 0.0
1106
+ U = self.cpuct * np.sqrt(np.log(total_visits + 1) / (child.visit_count + 1e-8))
1107
+ score = Q + U
1108
+ if score > best_score:
1109
+ best_score = score
1110
+ best_move = move
1111
+ best_child = child
1112
+
1113
+ current_game.apply_move(best_move)
1114
+ moves_made.append(best_move)
1115
+ node = best_child
1116
+ path.append(node)
1117
+
1118
+ for n in reversed(path):
1119
+ n.visit_count += 1
1120
+ n.value_sum += value
1121
+ value = -value
1122
+
1123
+ # Undo selection moves to restore root state
1124
+ for _ in range(len(moves_made)):
1125
+ current_game.undo_move()
1126
+
1127
+ simulations += 1
1128
+
1129
+ visits = {move: child.visit_count for move, child in root.children.items()}
1130
+ if not visits:
1131
+ legal = game.get_legal_moves()
1132
+ return {random.choice(legal): 1} if legal else {}, simulations
1133
+ return visits, simulations
1134
+
1135
+ if __name__ == "__main__":
1136
+ print("chess_mcvs.py loaded successfully")