test1978 commited on
Commit
cf51292
·
verified ·
1 Parent(s): 3c1c280

update chess_mcvs.py

Browse files
Files changed (1) hide show
  1. chess_mcvs.py +1135 -1135
chess_mcvs.py CHANGED
@@ -1,1136 +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")
 
1
+ # chess_mcvs.py
2
+ """
3
+ chess_mcvs.py - Full implementation analogous to breakthrough_mcvs.py
4
+ Hello!!
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")