test1978 commited on
Commit
52a2708
·
verified ·
1 Parent(s): 7a0d022

Add breakthrough_mcvs.py

Browse files
Files changed (1) hide show
  1. breakthrough_mcvs.py +1054 -0
breakthrough_mcvs.py ADDED
@@ -0,0 +1,1054 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import torch
3
+ import torch.nn as nn
4
+ import torch.optim as optim
5
+ import torch.nn.functional as F
6
+ from torch.utils.data import DataLoader, TensorDataset
7
+ from typing import List, Dict, Tuple
8
+ import random
9
+ import time
10
+ import os
11
+ import bisect
12
+
13
+ from abc_model import ABCModelDynamic
14
+ from matrix_model import WeightedMatrixABC
15
+
16
+ # ============================================================================
17
+ # HILBERT CURVE UTILITIES
18
+ # ============================================================================
19
+
20
+ def xy2d(n: int, x: int, y: int) -> int:
21
+ """Convert (x,y) to Hilbert curve distance."""
22
+ d = 0
23
+ s = n // 2
24
+ while s > 0:
25
+ rx = 1 if (x & s) > 0 else 0
26
+ ry = 1 if (y & s) > 0 else 0
27
+ d += s * s * ((3 * rx) ^ ry)
28
+ if ry == 0:
29
+ if rx == 1:
30
+ x = n - 1 - x
31
+ y = n - 1 - y
32
+ x, y = y, x
33
+ s //= 2
34
+ return d
35
+
36
+ def matrix_to_hilbert_index(W: np.ndarray) -> int:
37
+ """
38
+ Convert 64x64 matrix to Hilbert curve index.
39
+ """
40
+ if W.size == 0:
41
+ return 0
42
+
43
+ # Find location of maximum value
44
+ W_flat = W.flatten()
45
+ max_idx = np.argmax(W_flat)
46
+
47
+ x = max_idx % 64
48
+ y = max_idx // 64
49
+ idx = xy2d(64, x, y)
50
+ # Sanity check: index must be in [0, 64*64-1]
51
+ assert 0 <= idx < 64 * 64, f"Hilbert index out of range: {idx}"
52
+ return idx
53
+
54
+
55
+ def move_to_index(fr: int, fc: int, tr: int, tc: int) -> int:
56
+ """Convert a move tuple (fr,fc,tr,tc) to a policy index in [0,4095].
57
+
58
+ Mapping: idx = (from_sq) * 64 + to_sq, where from_sq = fr*8 + fc, to_sq = tr*8 + tc
59
+ """
60
+ from_sq = fr * 8 + fc
61
+ to_sq = tr * 8 + tc
62
+ return from_sq * 64 + to_sq
63
+
64
+ # ============================================================================
65
+ # BREAKTHROUGH GAME LOGIC
66
+ # ============================================================================
67
+
68
+ class Breakthrough:
69
+ ROWS, COLS = 8, 8
70
+ EMPTY, PLAYER1, PLAYER2 = 0, 1, 2
71
+
72
+ def __init__(self):
73
+ self.board = np.zeros((self.ROWS, self.COLS), dtype=np.int32)
74
+ self.board[0:2, :] = self.PLAYER1
75
+ self.board[6:8, :] = self.PLAYER2
76
+ self.move_count = 0
77
+ self.abc_model = ABCModelDynamic(n=2, t=1.0, T=1.41)
78
+ self._cached_matrix = None
79
+ self._setup_abc_initial()
80
+
81
+ def _setup_abc_initial(self):
82
+ """Initialize ABC model with starting pieces."""
83
+ for row in range(2):
84
+ for col in range(self.COLS):
85
+ x = col - 3.5
86
+ y = 3.5 - row
87
+ self.abc_model.add_piece((x, y), (1.0, 1.0, 1.0))
88
+
89
+ for row in range(6, 8):
90
+ for col in range(self.COLS):
91
+ x = col - 3.5
92
+ y = 3.5 - row
93
+ self.abc_model.add_piece((x, y), (1.0, 1.1, 1.0))
94
+
95
+ def copy(self):
96
+ """Create a deep copy of the game state."""
97
+ new_game = Breakthrough()
98
+ new_game.board = self.board.copy()
99
+ new_game.move_count = self.move_count
100
+ new_game.abc_model = ABCModelDynamic(n=2, t=1.0, T=1.41)
101
+
102
+ for entry in self.abc_model.history:
103
+ pos = entry['SB_position']
104
+ # Preserve kappa (tokenized vector) if present
105
+ kappa_vec = entry.get('kappa', None)
106
+ new_game.abc_model.add_piece(pos, entry['delta'], kappa_vector=kappa_vec)
107
+
108
+ new_game._cached_matrix = self._cached_matrix.copy() if self._cached_matrix is not None else None
109
+ return new_game
110
+
111
+ def get_legal_moves(self) -> List[Tuple[int, int, int, int]]:
112
+ """Get all legal moves for the current player."""
113
+ moves = []
114
+ player = self.PLAYER1 if self.move_count % 2 == 0 else self.PLAYER2
115
+ direction = 1 if player == self.PLAYER1 else -1
116
+
117
+ for r in range(self.ROWS):
118
+ for c in range(self.COLS):
119
+ if self.board[r, c] == player:
120
+ # Forward move
121
+ nr = r + direction
122
+ if 0 <= nr < self.ROWS and self.board[nr, c] == self.EMPTY:
123
+ moves.append((r, c, nr, c))
124
+
125
+ # Diagonal captures
126
+ for dc in [-1, 1]:
127
+ nc = c + dc
128
+ nr = r + direction
129
+ opponent = 3 - player
130
+ if 0 <= nr < self.ROWS and 0 <= nc < self.COLS and self.board[nr, nc] == opponent:
131
+ moves.append((r, c, nr, nc))
132
+
133
+ return moves
134
+
135
+ def apply_move(self, move: Tuple[int, int, int, int]) -> None:
136
+ """Apply a move and update the ABC model."""
137
+ fr, fc, tr, tc = move
138
+ player = self.board[fr, fc]
139
+ self.board[tr, tc] = player
140
+ self.board[fr, fc] = self.EMPTY
141
+ self.move_count += 1
142
+
143
+ x = tc - 3.5
144
+ y = 3.5 - tr
145
+ delta_2 = 1.0 if player == self.PLAYER1 else 1.1
146
+
147
+ self.abc_model.add_piece((x, y), (1.0, delta_2, 1.0))
148
+ self._cached_matrix = None
149
+
150
+ def check_winner(self) -> int:
151
+ """Check if there's a winner."""
152
+ if np.any(self.board[0, :] == self.PLAYER2):
153
+ return self.PLAYER2
154
+ if np.any(self.board[7, :] == self.PLAYER1):
155
+ return self.PLAYER1
156
+ return self.EMPTY
157
+
158
+ def is_terminal(self) -> bool:
159
+ """Check if the game has ended."""
160
+ return self.check_winner() != self.EMPTY or len(self.get_legal_moves()) == 0
161
+
162
+ def get_weighted_adjacency_matrix(self) -> np.ndarray:
163
+ if self._cached_matrix is not None:
164
+ return self._cached_matrix
165
+
166
+ occupied = []
167
+ for r in range(8):
168
+ for c in range(8):
169
+ piece = self.board[r, c]
170
+ if piece != self.EMPTY:
171
+ x = c - 3.5
172
+ y = 3.5 - r
173
+ delta_2 = 1.0 if piece == self.PLAYER1 else 1.1
174
+ kappa = (1.0, delta_2, 1.0) # δ1=1.0 fixed, δ2=color, δ3=1.0 (no piece-type variation)
175
+ hilbert_d = xy2d(8, c, r) # file=c, rank=r
176
+ occupied.append((hilbert_d, (x, y), kappa))
177
+
178
+ occupied.sort(key=lambda t: t[0]) # Hilbert order
179
+
180
+ abc = ABCModelDynamic(n=2, t=1.0, T=1.41)
181
+ for _, pos, kappa_vec in occupied:
182
+ abc.add_piece(pos, delta_values=kappa_vec)
183
+
184
+ builder = WeightedMatrixABC(abc, sigma=1.0)
185
+ W_small = builder.compute_weighted_matrix()
186
+
187
+ n = len(occupied)
188
+ W = np.zeros((64, 64), dtype=np.float32)
189
+ if n > 0:
190
+ W[:n, :n] = W_small
191
+
192
+ self._cached_matrix = W
193
+ return W
194
+
195
+ # ============================================================================
196
+ # NEURAL NETWORKS
197
+ # ============================================================================
198
+
199
+ class PolicyNetworkCNN(nn.Module):
200
+ def __init__(self):
201
+ super().__init__()
202
+ self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1)
203
+ self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
204
+ self.relu = nn.ReLU()
205
+ self.pool = nn.AdaptiveAvgPool2d(8)
206
+ self.fc = nn.Linear(64 * 8 * 8, 4096)
207
+
208
+ def forward(self, Wt):
209
+ x = self.relu(self.conv1(Wt))
210
+ x = self.relu(self.conv2(x))
211
+ x = self.pool(x)
212
+ x = x.view(x.size(0), -1)
213
+ logits = self.fc(x)
214
+ return logits
215
+
216
+
217
+ class ValueNetworkCNN(nn.Module):
218
+ def __init__(self):
219
+ super().__init__()
220
+ self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1)
221
+ self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
222
+ self.relu = nn.ReLU()
223
+ self.pool = nn.AdaptiveAvgPool2d(4)
224
+ self.fc1 = nn.Linear(64 * 4 * 4, 64)
225
+ self.fc2 = nn.Linear(64, 1)
226
+ self.tanh = nn.Tanh()
227
+
228
+ def forward(self, Wt):
229
+ x = self.relu(self.conv1(Wt))
230
+ x = self.relu(self.conv2(x))
231
+ x = self.pool(x)
232
+ x = x.view(x.size(0), -1)
233
+ x = self.relu(self.fc1(x))
234
+ value = self.tanh(self.fc2(x))
235
+ return value
236
+
237
+
238
+ # ============================================================================
239
+ # ZONE DATABASE
240
+ # ============================================================================
241
+
242
+ class HilbertOrderedZoneDatabase:
243
+ def __init__(self, filepath: str = "breakthrough_zone_db.npz", max_size: int = 10000):
244
+ self.filepath = filepath
245
+ self.max_size = max_size # Maximum matrices per zone
246
+ self.winning_matrices = []
247
+ self.winning_indices = []
248
+ self.losing_matrices = []
249
+ self.losing_indices = []
250
+ self.draw_matrices = []
251
+ self.draw_indices = []
252
+ self.load()
253
+
254
+ def add_winning_matrix(self, W: np.ndarray):
255
+ if W.shape != (64, 64):
256
+ return
257
+
258
+ hilbert_idx = matrix_to_hilbert_index(W)
259
+ pos = bisect.bisect_left(self.winning_indices, hilbert_idx)
260
+ self.winning_indices.insert(pos, hilbert_idx)
261
+ self.winning_matrices.insert(pos, W.copy())
262
+
263
+ def add_losing_matrix(self, W: np.ndarray):
264
+ if W.shape != (64, 64):
265
+ return
266
+
267
+ hilbert_idx = matrix_to_hilbert_index(W)
268
+ pos = bisect.bisect_left(self.losing_indices, hilbert_idx)
269
+ self.losing_indices.insert(pos, hilbert_idx)
270
+ self.losing_matrices.insert(pos, W.copy())
271
+
272
+ def add_draw_matrix(self, W: np.ndarray):
273
+ if W.shape != (64, 64):
274
+ return
275
+
276
+ hilbert_idx = matrix_to_hilbert_index(W)
277
+ pos = bisect.bisect_left(self.draw_indices, hilbert_idx)
278
+ self.draw_indices.insert(pos, hilbert_idx)
279
+ self.draw_matrices.insert(pos, W.copy())
280
+
281
+ def add_game_record(self, trajectory: List[Breakthrough], result: int, sample_rate: float = 0.3):
282
+ """Add game record with sampling to control database size."""
283
+ # Sample states: always keep first and last, random subset of middle
284
+ sampled_states = []
285
+
286
+ for i, state in enumerate(trajectory):
287
+ if i == 0 or i == len(trajectory) - 1:
288
+ # Always keep start and end positions
289
+ sampled_states.append(state)
290
+ elif random.random() < sample_rate:
291
+ # Randomly sample middle positions
292
+ sampled_states.append(state)
293
+
294
+ for state in sampled_states:
295
+ W = state.get_weighted_adjacency_matrix()
296
+
297
+ if result == 1:
298
+ self.add_winning_matrix(W)
299
+ elif result == -1:
300
+ self.add_losing_matrix(W)
301
+ else:
302
+ self.add_draw_matrix(W)
303
+
304
+ def save(self):
305
+ """Save database with pruning if too large."""
306
+ # Prune if any zone exceeds 80% of max_size
307
+ threshold = int(self.max_size * 0.8)
308
+
309
+ if (len(self.winning_matrices) > threshold or
310
+ len(self.losing_matrices) > threshold or
311
+ len(self.draw_matrices) > threshold):
312
+ print(f"Pruning database before save...")
313
+ self.prune_database(target_size=int(self.max_size * 0.7))
314
+
315
+ try:
316
+ np.savez_compressed(
317
+ self.filepath,
318
+ winning=np.array(self.winning_matrices, dtype=object),
319
+ winning_indices=np.array(self.winning_indices),
320
+ losing=np.array(self.losing_matrices, dtype=object),
321
+ losing_indices=np.array(self.losing_indices),
322
+ draw=np.array(self.draw_matrices, dtype=object),
323
+ draw_indices=np.array(self.draw_indices),
324
+ )
325
+ print(f"Zone DB saved: W={len(self.winning_matrices)}, L={len(self.losing_matrices)}, D={len(self.draw_matrices)}")
326
+ except MemoryError:
327
+ print(f"MemoryError during save! Pruning more aggressively...")
328
+ self.prune_database(target_size=1000)
329
+ # Try again with much smaller size
330
+ np.savez_compressed(
331
+ self.filepath,
332
+ winning=np.array(self.winning_matrices, dtype=object),
333
+ winning_indices=np.array(self.winning_indices),
334
+ losing=np.array(self.losing_matrices, dtype=object),
335
+ losing_indices=np.array(self.losing_indices),
336
+ draw=np.array(self.draw_matrices, dtype=object),
337
+ draw_indices=np.array(self.draw_indices),
338
+ )
339
+
340
+ def load(self):
341
+ if os.path.exists(self.filepath):
342
+ try:
343
+ data = np.load(self.filepath, allow_pickle=True)
344
+ self.winning_matrices = list(data['winning'])
345
+ self.winning_indices = list(data['winning_indices'])
346
+ self.losing_matrices = list(data['losing'])
347
+ self.losing_indices = list(data['losing_indices'])
348
+ self.draw_matrices = list(data['draw'])
349
+ self.draw_indices = list(data['draw_indices'])
350
+ print(f"Loaded zone DB: W={len(self.winning_matrices)}, L={len(self.losing_matrices)}, D={len(self.draw_matrices)}")
351
+ except Exception as e:
352
+ print(f"Failed to load zone database: {e}")
353
+
354
+ def prune_database(self, target_size: int = 5000):
355
+ """Keep only evenly-spaced diverse samples along Hilbert curve."""
356
+ def prune_zone(matrices, indices, target):
357
+ if len(matrices) <= target:
358
+ return matrices, indices
359
+
360
+ # Keep evenly spaced samples
361
+ step = max(1, len(matrices) // target)
362
+ keep_idx = list(range(0, len(matrices), step))[:target]
363
+
364
+ new_matrices = [matrices[i] for i in keep_idx]
365
+ new_indices = [indices[i] for i in keep_idx]
366
+
367
+ return new_matrices, new_indices
368
+
369
+ old_sizes = (len(self.winning_matrices), len(self.losing_matrices), len(self.draw_matrices))
370
+
371
+ self.winning_matrices, self.winning_indices = prune_zone(
372
+ self.winning_matrices, self.winning_indices, target_size
373
+ )
374
+ self.losing_matrices, self.losing_indices = prune_zone(
375
+ self.losing_matrices, self.losing_indices, target_size
376
+ )
377
+ self.draw_matrices, self.draw_indices = prune_zone(
378
+ self.draw_matrices, self.draw_indices, target_size
379
+ )
380
+
381
+ new_sizes = (len(self.winning_matrices), len(self.losing_matrices), len(self.draw_matrices))
382
+ print(f"Pruned: W {old_sizes}→{new_sizes}, L {old_sizes}→{new_sizes}, D {old_sizes}→{new_sizes}")
383
+
384
+ def compute_zone_score(self, W: np.ndarray, k: int = 5, beta: float = 0.5) -> float:
385
+ """Compute zone guidance score Z(x(t), a) ∈ [-1, 1]."""
386
+ if W.shape != (64, 64):
387
+ return 0.0
388
+
389
+ def knn_similarity(matrices, k_val):
390
+ if len(matrices) == 0:
391
+ return 0.0
392
+
393
+ k_actual = min(k_val, len(matrices))
394
+ distances = []
395
+
396
+ for mat in matrices[:k_actual]:
397
+ dist = np.sum(np.abs(W - mat)) / (64.0 * 64.0)
398
+ distances.append(dist)
399
+
400
+ similarities = [1.0 - d for d in distances]
401
+ return np.mean(similarities)
402
+
403
+ zone_win = knn_similarity(self.winning_matrices, k)
404
+ zone_loss = knn_similarity(self.losing_matrices, k)
405
+ zone_draw = knn_similarity(self.draw_matrices, k)
406
+
407
+ Z = zone_win - zone_loss + beta * zone_draw
408
+ return float(np.clip(Z, -1.0, 1.0))
409
+
410
+
411
+
412
+ # ============================================================================
413
+ # MCVS
414
+ # ============================================================================
415
+
416
+ class MCVSNode:
417
+ def __init__(self, game: Breakthrough, parent=None, move=None):
418
+ self.game = game.copy()
419
+ self.parent = parent
420
+ self.move = move
421
+ self.children = {}
422
+ self.N = 0
423
+ self.Q = 0.0
424
+ self.P = 0.0
425
+ self.Z = 0.0 # Zone guidance score Z(x(t), a) ∈ [-1,1]
426
+
427
+ def value(self) -> float:
428
+ return self.Q / self.N if self.N > 0 else 0.0
429
+
430
+ class MCVSSearcher:
431
+ class Node:
432
+ def __init__(self, prior: float = 0.0, zone: float = 0.0):
433
+ self.prior = prior
434
+ self.zone = zone
435
+ self.visit_count = 0
436
+ self.value_sum = 0.0
437
+ self.children = {}
438
+
439
+ def __init__(self, policy_net=None, value_net=None, zone_db=None, device='cpu',
440
+ cpuct=1.41, lambda_zone=0.0, k_zone=5, use_nets=True):
441
+ self.policy_net = policy_net.to(device) if policy_net and use_nets else None
442
+ self.value_net = value_net.to(device) if value_net and use_nets else None
443
+ self.zone_db = zone_db
444
+ self.device = device
445
+ self.cpuct = cpuct
446
+ self.lambda_zone = lambda_zone
447
+ self.k_zone = k_zone
448
+ self.use_nets = use_nets
449
+
450
+ def _quick_rollout(self, game):
451
+ current = game.copy()
452
+ for _ in range(100):
453
+ if current.is_terminal():
454
+ break
455
+ moves = current.get_legal_moves()
456
+ if not moves:
457
+ break
458
+ current.apply_move(random.choice(moves))
459
+ winner = current.check_winner()
460
+ if winner == Breakthrough.PLAYER1:
461
+ return 1.0
462
+ elif winner == Breakthrough.PLAYER2:
463
+ return -1.0
464
+ else:
465
+ return 0.0
466
+
467
+ def search_with_time_budget(self, game, time_budget: float):
468
+ root = self.Node()
469
+ start_time = time.time()
470
+ simulations = 0
471
+ eps = 1e-6
472
+
473
+ # If there are no legal moves at the root, return empty visits and zero sims
474
+ root_legal = game.get_legal_moves()
475
+ if not root_legal:
476
+ return {}, 0
477
+
478
+ while time.time() - start_time < time_budget:
479
+ current_game = game.copy()
480
+ node = root
481
+ path = []
482
+
483
+ # SELECTION
484
+ while node.children:
485
+ path.append(node)
486
+ total_n = sum(c.visit_count for c in node.children.values()) + 1
487
+ best_score = -float('inf')
488
+ best_move = None
489
+ best_child = None
490
+ for move, child in node.children.items():
491
+ q = child.value_sum / child.visit_count if child.visit_count > 0 else 0.0
492
+ p_lambda = max(child.prior + self.lambda_zone * child.zone, eps)
493
+ u = self.cpuct * p_lambda * np.sqrt(total_n) / (1 + child.visit_count)
494
+ score = q + u
495
+ if score > best_score:
496
+ best_score = score
497
+ best_move = move
498
+ best_child = child
499
+ # Advance game and node
500
+ if best_move is None:
501
+ break
502
+ current_game.apply_move(best_move)
503
+ node = best_child
504
+
505
+ path.append(node)
506
+
507
+ # EXPANSION / EVALUATION
508
+ if current_game.is_terminal():
509
+ w = current_game.check_winner()
510
+ if w == Breakthrough.PLAYER1:
511
+ value = 1.0
512
+ elif w == Breakthrough.PLAYER2:
513
+ value = -1.0
514
+ else:
515
+ value = 0.0
516
+ legal_moves = []
517
+ prior_dict = {}
518
+ else:
519
+ legal_moves = current_game.get_legal_moves()
520
+ if not legal_moves:
521
+ prior_dict = {}
522
+ value = 0.0
523
+ else:
524
+ if self.use_nets and self.policy_net is not None and self.value_net is not None:
525
+ W = current_game.get_weighted_adjacency_matrix()
526
+ W_tensor = torch.tensor(W, dtype=torch.float32).unsqueeze(0).unsqueeze(0).to(self.device)
527
+ with torch.no_grad():
528
+ logits = self.policy_net(W_tensor)
529
+ probs = torch.softmax(logits, dim=1).squeeze(0).cpu().numpy()
530
+ val = float(self.value_net(W_tensor).squeeze().cpu().numpy())
531
+ value = float(np.clip(val, -1.0, 1.0))
532
+
533
+ prior_dict = {}
534
+ total_p = 0.0
535
+ for m in legal_moves:
536
+ fr, fc, tr, tc = m
537
+ from_sq = fr * 8 + fc
538
+ to_sq = tr * 8 + tc
539
+ idx = from_sq * 64 + to_sq
540
+ p = float(probs[idx]) if idx < probs.size else 0.0
541
+ prior_dict[m] = p
542
+ total_p += p
543
+ if total_p <= 0.0:
544
+ prior_dict = {m: 1.0 / len(legal_moves) for m in legal_moves}
545
+ else:
546
+ for m in prior_dict:
547
+ prior_dict[m] /= total_p
548
+ else:
549
+ prior_dict = {m: 1.0 / len(legal_moves) for m in legal_moves}
550
+ value = self._quick_rollout(current_game)
551
+
552
+ # EXPAND CHILDREN
553
+ for m in legal_moves:
554
+ child_game = current_game.copy()
555
+ child_game.apply_move(m)
556
+ child_W = child_game.get_weighted_adjacency_matrix()
557
+ child_Z = self.zone_db.compute_zone_score(child_W, k=self.k_zone) if self.zone_db else 0.0
558
+ child_prior = prior_dict.get(m, 1.0 / len(legal_moves))
559
+ node_prior = max(child_prior + self.lambda_zone * child_Z, eps)
560
+ node.children[m] = self.Node(prior=node_prior, zone=child_Z)
561
+
562
+ # BACKPROPAGATION
563
+ v = value
564
+ for n in reversed(path):
565
+ n.visit_count += 1
566
+ n.value_sum += v
567
+ v = -v
568
+
569
+ simulations += 1
570
+
571
+ visits = {move: child.visit_count for move, child in root.children.items()}
572
+ if not visits:
573
+ legal = game.get_legal_moves()
574
+ visits = {random.choice(legal): 1} if legal else {}
575
+ return visits, simulations
576
+
577
+ # ============================================================================
578
+ # UCT-MCTS (Baseline without guidance)
579
+ # ============================================================================
580
+
581
+ class UCTNode:
582
+ def __init__(self, game: Breakthrough, parent=None, move=None):
583
+ self.game = game.copy()
584
+ self.parent = parent
585
+ self.move = move
586
+ self.children: Dict[Tuple[int,int,int,int], "UCTNode"] = {}
587
+ self.N = 0 # visit count
588
+ self.Q = 0.0 # total value
589
+
590
+ def value(self) -> float:
591
+ return self.Q / self.N if self.N > 0 else 0.0
592
+
593
+ class UCTSearcher:
594
+ def __init__(self, cpuct: float = np.sqrt(2.0)):
595
+ self.cpuct = cpuct
596
+
597
+ def uct_score(self, parent: UCTNode, child: UCTNode) -> float:
598
+ if child.N == 0:
599
+ # Encourage exploration of unvisited nodes
600
+ return float('inf')
601
+ Q = child.value()
602
+ U = self.cpuct * np.sqrt(parent.N + 1) / child.N
603
+ return Q + U
604
+
605
+ def simulate(self, game: Breakthrough) -> float:
606
+ """
607
+ Default policy: random playout to terminal state.
608
+ Return +1 if PLAYER1 wins, -1 if PLAYER2 wins, 0 for draw.
609
+ Value is from the perspective of the player to move at root,
610
+ but since Breakthrough is alternating and we flip sign in backprop,
611
+ we can just return game outcome from fixed perspective.
612
+ """
613
+ sim_game = game.copy()
614
+ while not sim_game.is_terminal():
615
+ moves = sim_game.get_legal_moves()
616
+ if not moves:
617
+ break
618
+ move = random.choice(moves)
619
+ sim_game.apply_move(move)
620
+ winner = sim_game.check_winner()
621
+ if winner == Breakthrough.PLAYER1:
622
+ return 1.0
623
+ elif winner == Breakthrough.PLAYER2:
624
+ return -1.0
625
+ else:
626
+ return 0.0
627
+
628
+ def search_with_time_budget(self, game: Breakthrough, time_budget: float) -> Dict[Tuple[int,int,int,int], int]:
629
+ """
630
+ Standard UCT search with a time budget (seconds).
631
+ Returns a tuple: (move->visit count dict, simulations)
632
+ """
633
+ root = UCTNode(game)
634
+ start_time = time.time()
635
+ simulations = 0
636
+
637
+ legal_moves = game.get_legal_moves()
638
+ if not legal_moves:
639
+ return {}, 0
640
+
641
+ # Run simulations until time budget exhausted
642
+ while time.time() - start_time < time_budget:
643
+ node = root
644
+
645
+ # SELECTION & EXPANSION
646
+ while not node.game.is_terminal():
647
+ legal = node.game.get_legal_moves()
648
+ if not legal:
649
+ break
650
+
651
+ # If any unexpanded moves exist, expand one
652
+ if len(node.children) < len(legal):
653
+ untried = [m for m in legal if m not in node.children]
654
+ if untried:
655
+ move = random.choice(untried)
656
+ child_game = node.game.copy()
657
+ child_game.apply_move(move)
658
+ child = UCTNode(child_game, parent=node, move=move)
659
+ node.children[move] = child
660
+ node = child
661
+ break
662
+
663
+ # Otherwise, select best child by UCT score
664
+ best_move = max(node.children.keys(),
665
+ key=lambda m: self.uct_score(node, node.children[m]))
666
+ node = node.children[best_move]
667
+
668
+ # SIMULATION
669
+ value = self.simulate(node.game)
670
+
671
+ # BACKPROPAGATION
672
+ current = node
673
+ while current is not None:
674
+ current.N += 1
675
+ current.Q += value
676
+ value = -value # switch perspective
677
+ current = current.parent
678
+ simulations += 1
679
+ return {move: child.N for move, child in root.children.items()}, simulations
680
+
681
+
682
+ # ============================================================================
683
+ # Self-play Data Generation
684
+ # ============================================================================
685
+
686
+ def generate_self_play_data(policy_net, value_net, zone_db, max_time_seconds=60.0,
687
+ lambda_zone=0.5, device='cpu'):
688
+ """Generate self-play games using MCTS with zone guidance."""
689
+ searcher = MCVSSearcher(policy_net, value_net, zone_db, device=device,
690
+ cpuct=1.41, lambda_zone=lambda_zone, k_zone=5)
691
+
692
+ W_states = []
693
+ policies = []
694
+ values = []
695
+ trajectories = []
696
+
697
+ start_time = time.time()
698
+ game_idx = 0
699
+
700
+ while time.time() - start_time < max_time_seconds:
701
+ game_start_time = time.time()
702
+ game = Breakthrough()
703
+ trajectory = [game.copy()]
704
+ W_state_list = []
705
+ policy_list = []
706
+
707
+ while not game.is_terminal():
708
+ time_per_move = 0.3 # Increased from 0.05 for more exploration/variety
709
+ W_t = game.get_weighted_adjacency_matrix()
710
+ visits, _ = searcher.search_with_time_budget(game, time_per_move)
711
+
712
+ if not visits:
713
+ break
714
+
715
+ moves = list(visits.keys())
716
+ visit_counts = np.array([visits[m] for m in moves], dtype=float)
717
+ total_visits = visit_counts.sum()
718
+ probs = visit_counts / total_visits
719
+
720
+ # Add temperature for more variety in selection
721
+ temperature = 1.0 if game.move_count < 8 else 0.25 # Higher early, lower late
722
+ if temperature != 1.0:
723
+ probs = probs ** (1.0 / temperature)
724
+ probs = probs / probs.sum()
725
+
726
+ policy_target = np.zeros(4096)
727
+ for move, count in visits.items():
728
+ fr, fc, tr, tc = move
729
+ idx = move_to_index(fr, fc, tr, tc)
730
+ if 0 <= idx < 4096:
731
+ policy_target[idx] = count / total_visits
732
+
733
+ W_state_list.append(W_t)
734
+ policy_list.append(policy_target)
735
+
736
+ if len(moves) > 1:
737
+ chosen_idx = np.random.choice(len(moves), p=probs)
738
+ chosen_move = moves[chosen_idx]
739
+ else:
740
+ chosen_move = moves[0]
741
+
742
+ game.apply_move(chosen_move)
743
+ trajectory.append(game.copy())
744
+
745
+ winner = game.check_winner()
746
+ result = 1 if winner == Breakthrough.PLAYER1 else -1 if winner == Breakthrough.PLAYER2 else 0
747
+
748
+ for i in range(len(W_state_list)):
749
+ player_to_move = 1 if i % 2 == 0 else -1
750
+ value = result if result == 0 else (result if result == player_to_move else -result)
751
+
752
+ W_states.append(W_state_list[i])
753
+ policies.append(policy_list[i])
754
+ values.append(value)
755
+
756
+ zone_db.add_game_record(trajectory, result)
757
+ trajectories.append(trajectory)
758
+
759
+ game_idx += 1
760
+ game_duration = time.time() - game_start_time
761
+ total_elapsed = time.time() - start_time
762
+
763
+ print(f"Game {game_idx}: Winner={winner}, Moves={game.move_count}, Duration={game_duration:.1f}s, Total={total_elapsed:.1f}s")
764
+
765
+ elapsed = time.time() - start_time
766
+ print(f"\n✅ Self-play finished: {game_idx} games in {elapsed:.1f}s, {len(W_states)} training examples collected")
767
+
768
+ return W_states, policies, values, trajectories
769
+
770
+
771
+ # ============================================================================
772
+ # Training
773
+ # ============================================================================
774
+
775
+ def train_networks(policy_net, value_net, W_states, policies, values, epochs=5, batch_size=32, device='cpu', learning_rate=None, lr=0.001):
776
+ """Train policy and value networks.
777
+
778
+ Accepts either `learning_rate=` (preferred) or `lr=` for backward compatibility.
779
+ """
780
+ if learning_rate is not None:
781
+ lr = learning_rate
782
+
783
+ policy_net.train()
784
+ value_net.train()
785
+ opt_p = optim.Adam(policy_net.parameters(), lr=lr)
786
+ opt_v = optim.Adam(value_net.parameters(), lr=lr)
787
+
788
+ W_tensor = torch.tensor(np.array(W_states)[:, np.newaxis, :, :], dtype=torch.float32)
789
+ pi_tensor = torch.tensor(np.array(policies), dtype=torch.float32)
790
+ v_tensor = torch.tensor(np.array(values), dtype=torch.float32).unsqueeze(1)
791
+
792
+ dataset = TensorDataset(W_tensor, pi_tensor, v_tensor)
793
+ loader = DataLoader(dataset, batch_size=batch_size, shuffle=True)
794
+
795
+ for epoch in range(epochs):
796
+ total_loss = 0.0
797
+ for batch_W, batch_pi, batch_v in loader:
798
+ batch_W = batch_W.to(device)
799
+ batch_pi = batch_pi.to(device)
800
+ batch_v = batch_v.to(device)
801
+
802
+ logits = policy_net(batch_W)
803
+ # Fixed policy loss: KL divergence between network policy and MCTS visit distribution
804
+ policy_loss = F.kl_div(F.log_softmax(logits, dim=1), batch_pi, reduction='batchmean')
805
+
806
+ pred_v = value_net(batch_W)
807
+ value_loss = F.mse_loss(pred_v, batch_v)
808
+
809
+ loss = policy_loss + value_loss
810
+ total_loss += loss.item()
811
+
812
+ opt_p.zero_grad()
813
+ opt_v.zero_grad()
814
+ loss.backward()
815
+
816
+ # Optional but recommended: gradient clipping
817
+ torch.nn.utils.clip_grad_norm_(policy_net.parameters(), max_norm=1.0)
818
+ torch.nn.utils.clip_grad_norm_(value_net.parameters(), max_norm=1.0)
819
+
820
+ opt_p.step()
821
+ opt_v.step()
822
+
823
+ # Optional: print epoch loss for monitoring
824
+ print(f"Epoch {epoch+1}/{epochs} - Loss: {total_loss/len(loader):.4f}")
825
+
826
+
827
+ # ============================================================================
828
+ # Diagnostic Test for Move Diversity
829
+ # ============================================================================
830
+
831
+ def test_move_diversity(policy_net, value_net, zone_db, num_trials=30, time_budget=2.0, device='cpu'):
832
+ """Test if the AI chooses different moves in the same starting position."""
833
+ searcher = MCVSSearcher(policy_net, value_net, zone_db, device=device)
834
+ game = Breakthrough() # Starting position; change to any position you want to test
835
+
836
+ move_counts = {}
837
+ for i in range(num_trials):
838
+ visits, _ = searcher.search_with_time_budget(game, time_budget)
839
+ if visits:
840
+ # Use argmax for the "best" move, but since there's noise/temp, it should vary
841
+ best_move = max(visits, key=visits.get)
842
+ move_counts[best_move] = move_counts.get(best_move, 0) + 1
843
+
844
+ print("\nMove diversity test (starting position):")
845
+ total = sum(move_counts.values())
846
+ for m, cnt in sorted(move_counts.items(), key=lambda x: -x[1]):
847
+ print(f"{m}: {cnt:2d}× ({cnt/total:.1%})")
848
+ print(f"Unique moves chosen: {len(move_counts)} out of {len(game.get_legal_moves())} legal\n")
849
+
850
+ # ============================================================================
851
+ # Guided MCVS vs UCT-MCTS Match
852
+ # ============================================================================
853
+
854
+ def play_match_mcvs_vs_uct(policy_net,
855
+ value_net,
856
+ zone_db,
857
+ num_games: int = 200,
858
+ time_per_move: float = 0.3,
859
+ device: str = 'cpu'):
860
+ """
861
+ Play a tournament between guided MCVS and UCT.
862
+ Odd games: MCVS as PLAYER1 (first), UCT as PLAYER2.
863
+ Even games: UCT as PLAYER1, MCVS as PLAYER2.
864
+ """
865
+ mcvs_searcher = MCVSSearcher(policy_net, value_net, zone_db,
866
+ device=device, cpuct=1.41,
867
+ lambda_zone=1.0, k_zone=5)
868
+ uct_searcher = UCTSearcher(cpuct=np.sqrt(2.0))
869
+
870
+ mcvs_wins = 0
871
+ uct_wins = 0
872
+ draws = 0
873
+
874
+ for g in range(1, num_games + 1):
875
+ game = Breakthrough()
876
+ player_to_move = Breakthrough.PLAYER1 # always 1 then 2 then 1...
877
+
878
+ # Alternate roles
879
+ # True: MCVS plays PLAYER1 in this game
880
+ mcvs_as_player1 = (g % 2 == 1)
881
+
882
+ while not game.is_terminal():
883
+ moves = game.get_legal_moves()
884
+ if not moves:
885
+ break
886
+
887
+ # Decide which engine moves
888
+ if player_to_move == Breakthrough.PLAYER1:
889
+ use_mcvs = mcvs_as_player1
890
+ else:
891
+ use_mcvs = not mcvs_as_player1
892
+
893
+ if use_mcvs:
894
+ # Guided MCVS move
895
+ visits, _ = mcvs_searcher.search_with_time_budget(game, time_per_move)
896
+ else:
897
+ # UCT move
898
+ visits, _ = uct_searcher.search_with_time_budget(game, time_per_move)
899
+
900
+ if not visits:
901
+ break
902
+
903
+ # Choose move by max visit count
904
+ moves_list = list(visits.keys())
905
+ counts = np.array([visits[m] for m in moves_list], dtype=float)
906
+ best_index = int(np.argmax(counts))
907
+ chosen_move = moves_list[best_index]
908
+
909
+ game.apply_move(chosen_move)
910
+ player_to_move = Breakthrough.PLAYER2 if player_to_move == Breakthrough.PLAYER1 else Breakthrough.PLAYER1
911
+
912
+ winner = game.check_winner()
913
+ if winner == Breakthrough.PLAYER1:
914
+ if mcvs_as_player1:
915
+ mcvs_wins += 1
916
+ else:
917
+ uct_wins += 1
918
+ elif winner == Breakthrough.PLAYER2:
919
+ if mcvs_as_player1:
920
+ uct_wins += 1
921
+ else:
922
+ mcvs_wins += 1
923
+ else:
924
+ draws += 1
925
+
926
+ print(f"Game {g}/{num_games} finished. Winner = {winner} "
927
+ f"(MCVS as P1: {mcvs_as_player1})")
928
+
929
+ print("\n=== MCVS vs UCT Tournament Results ===")
930
+ print(f"Total games: {num_games}")
931
+ print(f"MCVS wins : {mcvs_wins}")
932
+ print(f"UCT wins : {uct_wins}")
933
+ print(f"Draws : {draws}")
934
+ if num_games > 0:
935
+ mcvs_rate = mcvs_wins / num_games * 100.0
936
+ uct_rate = uct_wins / num_games * 100.0
937
+ draw_rate = draws / num_games * 100.0
938
+ print(f"Win rates: MCVS={mcvs_rate:.1f}%, UCT={uct_rate:.1f}%, Draw={draw_rate:.1f}%\n")
939
+
940
+
941
+ # ============================================================================
942
+ # MAIN
943
+ # ============================================================================
944
+
945
+ if __name__ == "__main__":
946
+ print("="*80)
947
+ print("BREAKTHROUGH MCVS - INCREMENTAL TRAINING")
948
+ print("="*80)
949
+
950
+ device = "cuda" if torch.cuda.is_available() else "cpu"
951
+ print(f"Device: {device}")
952
+
953
+ # Initialize networks
954
+ policy_net = PolicyNetworkCNN().to(device)
955
+ value_net = ValueNetworkCNN().to(device)
956
+
957
+ # Initialize zone database (with fixed version - max_size limits growth)
958
+ zone_db = HilbertOrderedZoneDatabase("breakthrough_zone_db.npz", max_size=10000)
959
+
960
+ # Try to load existing checkpoint
961
+ checkpoint_path = "breakthrough_checkpoint.pt"
962
+ iteration = 0
963
+
964
+ if os.path.exists(checkpoint_path):
965
+ print(f"Loading checkpoint from {checkpoint_path}...")
966
+ checkpoint = torch.load(checkpoint_path, map_location=device)
967
+ policy_net.load_state_dict(checkpoint['policy'])
968
+ value_net.load_state_dict(checkpoint['value'])
969
+ iteration = checkpoint.get('iteration', 0)
970
+ print(f"Resumed from iteration {iteration}")
971
+
972
+ # Accumulate training data across iterations
973
+ all_W_states = []
974
+ all_policies = []
975
+ all_values = []
976
+
977
+ # Main training loop
978
+ while True:
979
+ print("="*80)
980
+ print(f"ITERATION {iteration}")
981
+ print("="*80)
982
+
983
+ # 1. Generate self-play games (adds to zone_db automatically)
984
+ print("Generating self-play data (30 minutes)...")
985
+ lambda_zone = 0.0 if iteration == 0 else 1.0 # No guidance first iteration
986
+
987
+ W_states, policies, values, trajectories = generate_self_play_data(
988
+ policy_net, value_net, zone_db,
989
+ max_time_seconds=1800.0, # 30 minutes
990
+ lambda_zone=lambda_zone,
991
+ device=device
992
+ )
993
+
994
+ print(f"Collected {len(W_states)} training examples from {len(trajectories)} games")
995
+
996
+ # 2. Accumulate training data
997
+ all_W_states.extend(W_states)
998
+ all_policies.extend(policies)
999
+ all_values.extend(values)
1000
+
1001
+ # Optional: Limit total training data size (keep most recent N examples)
1002
+ max_training_examples = 50000
1003
+ if len(all_W_states) > max_training_examples:
1004
+ print(f"Trimming training data to most recent {max_training_examples} examples...")
1005
+ all_W_states = all_W_states[-max_training_examples:]
1006
+ all_policies = all_policies[-max_training_examples:]
1007
+ all_values = all_values[-max_training_examples:]
1008
+
1009
+ print(f"Total accumulated training examples: {len(all_W_states)}")
1010
+
1011
+ # 3. Train networks on ALL accumulated data
1012
+ print("Training networks...")
1013
+ train_networks(
1014
+ policy_net, value_net,
1015
+ all_W_states, all_policies, all_values,
1016
+ epochs=10, # Fewer epochs since we train more frequently
1017
+ device=device,
1018
+ learning_rate=0.001
1019
+ )
1020
+
1021
+ # 4. Save checkpoint (ONE .pt file)
1022
+ print("Saving checkpoint...")
1023
+ torch.save({
1024
+ 'policy': policy_net.state_dict(),
1025
+ 'value': value_net.state_dict(),
1026
+ 'iteration': iteration,
1027
+ 'num_training_examples': len(all_W_states)
1028
+ }, checkpoint_path)
1029
+ print(f"✅ Checkpoint saved: {checkpoint_path}")
1030
+
1031
+ # 5. Save zone database (ONE .npz file)
1032
+ zone_db.save()
1033
+ print(f"✅ Zone DB saved: W={len(zone_db.winning_matrices)}, "
1034
+ f"L={len(zone_db.losing_matrices)}, "
1035
+ f"D={len(zone_db.draw_matrices)}")
1036
+
1037
+ iteration += 1
1038
+
1039
+ print("\n" + "="*80)
1040
+ print(f"Iteration {iteration} complete!")
1041
+ print(f"Files updated:")
1042
+ print(f" - {checkpoint_path}")
1043
+ print(f" - {zone_db.filepath}")
1044
+ print("="*80 + "\n")
1045
+
1046
+ # Optional: Run tournament evaluation every N iterations
1047
+ if iteration % 5 == 0:
1048
+ print("\nRunning tournament evaluation...")
1049
+ play_match_mcvs_vs_uct(
1050
+ policy_net, value_net, zone_db,
1051
+ num_games=20,
1052
+ time_per_move=0.5,
1053
+ device=device
1054
+ )