File size: 9,433 Bytes
308bdff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
"""
Position Evaluation Module
Combines neural network evaluation with classical heuristics

Research References:
- AlphaZero (Silver et al., 2017) - Pure neural evaluation
- Stockfish NNUE (2020) - Hybrid neural-classical approach
- Leela Chess Zero - MCTS with neural evaluation
"""

import onnxruntime as ort
import numpy as np
import chess
import logging
from pathlib import Path
from typing import Dict, Optional

logger = logging.getLogger(__name__)


class NeuralEvaluator:
    """
    Synapse-Base neural network evaluator
    119-channel input, hybrid CNN-Transformer architecture
    """
    
    # Piece values for material balance (Stockfish values)
    PIECE_VALUES = {
        chess.PAWN: 100,
        chess.KNIGHT: 320,
        chess.BISHOP: 330,
        chess.ROOK: 500,
        chess.QUEEN: 900,
        chess.KING: 0
    }
    
    # Piece-Square Tables (simplified Stockfish PST)
    PST_PAWN = np.array([
        [0,  0,  0,  0,  0,  0,  0,  0],
        [50, 50, 50, 50, 50, 50, 50, 50],
        [10, 10, 20, 30, 30, 20, 10, 10],
        [5,  5, 10, 25, 25, 10,  5,  5],
        [0,  0,  0, 20, 20,  0,  0,  0],
        [5, -5,-10,  0,  0,-10, -5,  5],
        [5, 10, 10,-20,-20, 10, 10,  5],
        [0,  0,  0,  0,  0,  0,  0,  0]
    ], dtype=np.float32)
    
    PST_KNIGHT = np.array([
        [-50,-40,-30,-30,-30,-30,-40,-50],
        [-40,-20,  0,  0,  0,  0,-20,-40],
        [-30,  0, 10, 15, 15, 10,  0,-30],
        [-30,  5, 15, 20, 20, 15,  5,-30],
        [-30,  0, 15, 20, 20, 15,  0,-30],
        [-30,  5, 10, 15, 15, 10,  5,-30],
        [-40,-20,  0,  5,  5,  0,-20,-40],
        [-50,-40,-30,-30,-30,-30,-40,-50]
    ], dtype=np.float32)
    
    PST_KING_MG = np.array([
        [-30,-40,-40,-50,-50,-40,-40,-30],
        [-30,-40,-40,-50,-50,-40,-40,-30],
        [-30,-40,-40,-50,-50,-40,-40,-30],
        [-30,-40,-40,-50,-50,-40,-40,-30],
        [-20,-30,-30,-40,-40,-30,-30,-20],
        [-10,-20,-20,-20,-20,-20,-20,-10],
        [ 20, 20,  0,  0,  0,  0, 20, 20],
        [ 20, 30, 10,  0,  0, 10, 30, 20]
    ], dtype=np.float32)
    
    def __init__(self, model_path: str, num_threads: int = 2):
        """Initialize neural evaluator"""
        
        self.model_path = Path(model_path)
        if not self.model_path.exists():
            raise FileNotFoundError(f"Model not found: {model_path}")
        
        # ONNX Runtime session (CPU optimized)
        sess_options = ort.SessionOptions()
        sess_options.intra_op_num_threads = num_threads
        sess_options.inter_op_num_threads = num_threads
        sess_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
        sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
        
        logger.info(f"Loading Synapse-Base model from {model_path}...")
        self.session = ort.InferenceSession(
            str(self.model_path),
            sess_options=sess_options,
            providers=['CPUExecutionProvider']
        )
        
        self.input_name = self.session.get_inputs()[0].name
        self.output_names = [output.name for output in self.session.get_outputs()]
        
        logger.info(f"✅ Model loaded: {self.input_name} -> {self.output_names}")
    
    def _build_119_channel_tensor(self, board: chess.Board) -> np.ndarray:
        """
        Convert board to 119-channel tensor
        Based on Synapse-Base input specification
        """
        tensor = np.zeros((1, 119, 8, 8), dtype=np.float32)
        
        # === CHANNELS 0-11: Piece Positions ===
        piece_map = board.piece_map()
        piece_to_channel = {
            chess.PAWN: 0, chess.KNIGHT: 1, chess.BISHOP: 2,
            chess.ROOK: 3, chess.QUEEN: 4, chess.KING: 5
        }
        
        for square, piece in piece_map.items():
            rank, file = divmod(square, 8)
            channel = piece_to_channel[piece.piece_type]
            if piece.color == chess.BLACK:
                channel += 6
            tensor[0, channel, rank, file] = 1.0
        
        # === CHANNELS 12-26: Metadata ===
        tensor[0, 12, :, :] = float(board.turn == chess.WHITE)
        tensor[0, 13, :, :] = float(board.has_kingside_castling_rights(chess.WHITE))
        tensor[0, 14, :, :] = float(board.has_queenside_castling_rights(chess.WHITE))
        tensor[0, 15, :, :] = float(board.has_kingside_castling_rights(chess.BLACK))
        tensor[0, 16, :, :] = float(board.has_queenside_castling_rights(chess.BLACK))
        
        if board.ep_square is not None:
            ep_rank, ep_file = divmod(board.ep_square, 8)
            tensor[0, 17, ep_rank, ep_file] = 1.0
        
        tensor[0, 18, :, :] = min(board.halfmove_clock / 100.0, 1.0)
        tensor[0, 19, :, :] = min(board.fullmove_number / 100.0, 1.0)
        tensor[0, 20, :, :] = float(board.is_check() and board.turn == chess.WHITE)
        tensor[0, 21, :, :] = float(board.is_check() and board.turn == chess.BLACK)
        
        # Material counts
        for i, piece_type in enumerate([chess.PAWN, chess.KNIGHT, chess.BISHOP, chess.ROOK, chess.QUEEN]):
            white_count = len(board.pieces(piece_type, chess.WHITE))
            black_count = len(board.pieces(piece_type, chess.BLACK))
            max_count = 8 if piece_type == chess.PAWN else 2
            tensor[0, 22 + i*2, :, :] = white_count / max_count
            tensor[0, 23 + i*2, :, :] = black_count / max_count
        
        # === CHANNELS 27-50: Attack Maps ===
        for square in chess.SQUARES:
            rank, file = divmod(square, 8)
            if board.is_attacked_by(chess.WHITE, square):
                tensor[0, 27, rank, file] = 1.0
            if board.is_attacked_by(chess.BLACK, square):
                tensor[0, 28, rank, file] = 1.0
        
        # Mobility (number of legal moves)
        white_mobility = len(list(board.legal_moves)) if board.turn == chess.WHITE else 0
        black_mobility = len(list(board.legal_moves)) if board.turn == chess.BLACK else 0
        tensor[0, 29, :, :] = min(white_mobility / 50.0, 1.0)
        tensor[0, 30, :, :] = min(black_mobility / 50.0, 1.0)
        
        # === CHANNELS 51-66: Coordinate Encoding ===
        for rank in range(8):
            tensor[0, 51 + rank, rank, :] = 1.0
        for file in range(8):
            tensor[0, 59 + file, :, file] = 1.0
        
        # === CHANNELS 67-118: Positional Features ===
        # Center control
        center = [chess.D4, chess.D5, chess.E4, chess.E5]
        for sq in center:
            r, f = divmod(sq, 8)
            tensor[0, 67, r, f] = 0.5
        
        # King safety zones
        for color, offset in [(chess.WHITE, 68), (chess.BLACK, 69)]:
            king_sq = board.king(color)
            if king_sq is not None:
                kr, kf = divmod(king_sq, 8)
                for dr in [-1, 0, 1]:
                    for df in [-1, 0, 1]:
                        r, f = kr + dr, kf + df
                        if 0 <= r < 8 and 0 <= f < 8:
                            tensor[0, offset, r, f] = 1.0
        
        # Piece-square table values
        for square, piece in piece_map.items():
            rank, file = divmod(square, 8)
            if piece.piece_type == chess.PAWN:
                pst_value = self.PST_PAWN[rank, file] / 50.0
                tensor[0, 70, rank, file] = pst_value if piece.color == chess.WHITE else -pst_value
            elif piece.piece_type == chess.KNIGHT:
                pst_value = self.PST_KNIGHT[rank, file] / 30.0
                tensor[0, 71, rank, file] = pst_value if piece.color == chess.WHITE else -pst_value
        
        return tensor
    
    def evaluate_neural(self, board: chess.Board) -> float:
        """
        Neural network evaluation
        Returns score from white's perspective
        """
        input_tensor = self._build_119_channel_tensor(board)
        outputs = self.session.run(self.output_names, {self.input_name: input_tensor})
        
        # Value head output (tanh normalized to [-1, 1])
        raw_eval = float(outputs[0][0][0])
        
        # Convert to centipawns (multiply by 4 for scaling)
        return raw_eval * 400.0
    
    def evaluate_material(self, board: chess.Board) -> int:
        """Classical material evaluation"""
        material = 0
        for piece_type in [chess.PAWN, chess.KNIGHT, chess.BISHOP, chess.ROOK, chess.QUEEN]:
            material += len(board.pieces(piece_type, chess.WHITE)) * self.PIECE_VALUES[piece_type]
            material -= len(board.pieces(piece_type, chess.BLACK)) * self.PIECE_VALUES[piece_type]
        return material
    
    def evaluate_hybrid(self, board: chess.Board) -> float:
        """
        Hybrid evaluation combining neural and classical
        Research: Stockfish NNUE approach
        """
        # Neural evaluation (primary)
        neural_eval = self.evaluate_neural(board)
        
        # Classical material balance (safety check)
        material_eval = self.evaluate_material(board)
        
        # Blend: 95% neural, 5% material (for stability)
        hybrid_eval = 0.95 * neural_eval + 0.05 * material_eval
        
        # Perspective flip for black
        if board.turn == chess.BLACK:
            hybrid_eval = -hybrid_eval
        
        return hybrid_eval
    
    def get_model_size_mb(self) -> float:
        """Get model size in MB"""
        return self.model_path.stat().st_size / (1024 * 1024)