Spaces:
Sleeping
Sleeping
Delete chess_analyzer.py
Browse files- chess_analyzer.py +0 -964
chess_analyzer.py
DELETED
|
@@ -1,964 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
Chess Analysis Tool — Chess.com-style move classification using Stockfish.
|
| 4 |
-
"""
|
| 5 |
-
|
| 6 |
-
import math
|
| 7 |
-
import chess
|
| 8 |
-
import chess.engine
|
| 9 |
-
import chess.pgn
|
| 10 |
-
import chess.polyglot
|
| 11 |
-
import argparse
|
| 12 |
-
import io
|
| 13 |
-
import os
|
| 14 |
-
import sys
|
| 15 |
-
from dataclasses import dataclass, field
|
| 16 |
-
from enum import Enum
|
| 17 |
-
from typing import Optional, List
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
# ─── Expected-score thresholds (Chess.com model) ─────────────────────────────
|
| 21 |
-
# Expected score E = (wins + draws×0.5) / 1000, ranges 0.0–1.0
|
| 22 |
-
# These are Chess.com's exact published cutoffs.
|
| 23 |
-
|
| 24 |
-
E_EXCELLENT = 0.02 # 0.00–0.02 loss → Excellent
|
| 25 |
-
E_GOOD = 0.05 # 0.02–0.05 loss → Good
|
| 26 |
-
E_INACCURACY = 0.10 # 0.05–0.10 loss → Inaccuracy
|
| 27 |
-
E_MISTAKE = 0.20 # 0.10–0.20 loss → Mistake
|
| 28 |
-
E_BLUNDER = 1.00 # 0.20+ loss → Blunder
|
| 29 |
-
|
| 30 |
-
ONLY_MOVE_GAP = 100 # cp gap to 2nd best to qualify as "only good move"
|
| 31 |
-
MISSED_TACTIC = 0.15 # e_loss above this → Omission candidate
|
| 32 |
-
SACRIFICE_NET = 80 # SEE net loss (cp) required to count as a sacrifice
|
| 33 |
-
BOOK_MOVES = 2 # first N half-moves per side auto-classified as Book
|
| 34 |
-
SEE_MAX_DEPTH = 12 # recursion cap for SEE (avoids slowdowns in busy positions)
|
| 35 |
-
|
| 36 |
-
# Special move thresholds
|
| 37 |
-
E_GREAT_TURN = 0.10 # e must improve by at least this to be "position-turning"
|
| 38 |
-
E_GREAT_TARGET = 0.55 # and must reach at least this after the move
|
| 39 |
-
E_BRILLIANT_MIN_AFTER = 0.45 # sac must leave us in an OK position
|
| 40 |
-
E_BRILLIANT_MAX_BEFORE = 0.80 # sac only brilliant if we weren't already winning
|
| 41 |
-
E_MISS_BEST_TARGET = 0.65 # best move would have been winning
|
| 42 |
-
E_MISS_PLAYED_CAP = 0.55 # but played move didn't capitalise
|
| 43 |
-
|
| 44 |
-
# ── Five-state position model ─────────────────────────────────────────────
|
| 45 |
-
# Winning: e >= 0.72
|
| 46 |
-
# Slightly Winning: 0.60 <= e < 0.72
|
| 47 |
-
# Equal: 0.44 <= e < 0.60
|
| 48 |
-
# Slightly Losing: 0.32 <= e < 0.44
|
| 49 |
-
# Losing: e < 0.32
|
| 50 |
-
E_WINNING = 0.72
|
| 51 |
-
E_SLIGHTLY_WINNING = 0.60
|
| 52 |
-
E_EQUAL_LOW = 0.44 # bottom of equal band
|
| 53 |
-
E_SLIGHTLY_LOSING = 0.32 # below this = losing
|
| 54 |
-
|
| 55 |
-
MULTIPV = 5
|
| 56 |
-
DEFAULT_DEPTH = 18
|
| 57 |
-
DEFAULT_SF_PATH = "stockfish"
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
# ─── Data structures ─────────────────────────────────────────────────────────
|
| 61 |
-
|
| 62 |
-
class Classification(Enum):
|
| 63 |
-
BOOK = "📖 Book"
|
| 64 |
-
BRILLIANT = "!! Brilliant"
|
| 65 |
-
GREAT = "! Great"
|
| 66 |
-
BEST = "★ Best"
|
| 67 |
-
EXCELLENT = "👍 Excellent"
|
| 68 |
-
GOOD = "✓ Good"
|
| 69 |
-
INACCURACY = "?! Inaccuracy"
|
| 70 |
-
MISTAKE = "? Mistake"
|
| 71 |
-
BLUNDER = "?? Blunder"
|
| 72 |
-
OMISSION = "✖ Missed Tactic"
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
PIECE_VALUES = {
|
| 76 |
-
chess.PAWN: 100,
|
| 77 |
-
chess.KNIGHT: 300,
|
| 78 |
-
chess.BISHOP: 320,
|
| 79 |
-
chess.ROOK: 500,
|
| 80 |
-
chess.QUEEN: 900,
|
| 81 |
-
chess.KING: 20000,
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
@dataclass
|
| 86 |
-
class EngineMove:
|
| 87 |
-
move: chess.Move
|
| 88 |
-
score: chess.engine.Score
|
| 89 |
-
cp: float
|
| 90 |
-
pv: List[chess.Move] = field(default_factory=list)
|
| 91 |
-
expected: Optional[float] = None # WDL-based expected score 0.0–1.0
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
@dataclass
|
| 95 |
-
class MoveAnalysis:
|
| 96 |
-
move: chess.Move
|
| 97 |
-
san: str
|
| 98 |
-
classification: Classification
|
| 99 |
-
cp_loss: float
|
| 100 |
-
eval_before: float
|
| 101 |
-
eval_after: float
|
| 102 |
-
best_move: Optional[chess.Move]
|
| 103 |
-
notes: List[str] = field(default_factory=list)
|
| 104 |
-
e_loss: float = 0.0 # expected-score loss (primary severity metric)
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
# ─── Score / WDL helpers ──────────────────────────────────────────────────────
|
| 108 |
-
|
| 109 |
-
MATE_CP = 10_000
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
def score_to_cp(score: chess.engine.Score) -> float:
|
| 113 |
-
if score.is_mate():
|
| 114 |
-
m = score.mate()
|
| 115 |
-
return (MATE_CP - abs(m)) * (1 if m > 0 else -1)
|
| 116 |
-
v = score.score()
|
| 117 |
-
return float(v) if v is not None else 0.0
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
def wdl_to_expected(wdl) -> Optional[float]:
|
| 121 |
-
"""
|
| 122 |
-
Convert a Stockfish WDL tuple (wins, draws, losses), each 0–1000,
|
| 123 |
-
to an expected score in [0.0, 1.0] from the side-to-move's perspective.
|
| 124 |
-
"""
|
| 125 |
-
if wdl is None:
|
| 126 |
-
return None
|
| 127 |
-
try:
|
| 128 |
-
w, d, l = wdl
|
| 129 |
-
total = w + d + l
|
| 130 |
-
return (w + d * 0.5) / total if total else None
|
| 131 |
-
except Exception:
|
| 132 |
-
return None
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
def cp_to_expected(cp: float) -> float:
|
| 136 |
-
"""Fallback sigmoid: ±100cp ≈ 0.63/0.37, ±300cp ≈ 0.80/0.20."""
|
| 137 |
-
return 1.0 / (1.0 + math.exp(-cp / 320.0))
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
# ─── Static Exchange Evaluation ───────────────────────────────────────────────
|
| 141 |
-
|
| 142 |
-
def _least_valuable_attacker(board: chess.Board, square: chess.Square,
|
| 143 |
-
color: chess.Color) -> Optional[chess.Square]:
|
| 144 |
-
attackers = board.attackers(color, square)
|
| 145 |
-
if not attackers:
|
| 146 |
-
return None
|
| 147 |
-
return min(
|
| 148 |
-
attackers,
|
| 149 |
-
key=lambda sq: PIECE_VALUES.get(board.piece_at(sq).piece_type, 99999)
|
| 150 |
-
if board.piece_at(sq) else 99999
|
| 151 |
-
)
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
def see(board: chess.Board, square: chess.Square,
|
| 155 |
-
attacker_color: chess.Color, depth: int = 0) -> int:
|
| 156 |
-
"""
|
| 157 |
-
Static Exchange Evaluation on `square` for `attacker_color`.
|
| 158 |
-
Returns net material gain (cp): positive = attacker profits, negative = loses.
|
| 159 |
-
"""
|
| 160 |
-
if depth >= SEE_MAX_DEPTH:
|
| 161 |
-
return 0
|
| 162 |
-
|
| 163 |
-
attacker_sq = _least_valuable_attacker(board, square, attacker_color)
|
| 164 |
-
if attacker_sq is None:
|
| 165 |
-
return 0
|
| 166 |
-
|
| 167 |
-
attacker_piece = board.piece_at(attacker_sq)
|
| 168 |
-
target_piece = board.piece_at(square)
|
| 169 |
-
if attacker_piece is None:
|
| 170 |
-
return 0
|
| 171 |
-
|
| 172 |
-
target_val = PIECE_VALUES.get(target_piece.piece_type, 0) if target_piece else 0
|
| 173 |
-
|
| 174 |
-
b = board.copy()
|
| 175 |
-
capture = chess.Move(attacker_sq, square)
|
| 176 |
-
if not b.is_legal(capture):
|
| 177 |
-
return 0
|
| 178 |
-
b.push(capture)
|
| 179 |
-
|
| 180 |
-
opponent_gain = see(b, square, not attacker_color, depth + 1)
|
| 181 |
-
return target_val - max(0, opponent_gain)
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
def see_move(board: chess.Board, move: chess.Move) -> int:
|
| 185 |
-
"""SEE for a specific move. Positive = gain, negative = loss."""
|
| 186 |
-
piece = board.piece_at(move.from_square)
|
| 187 |
-
captured = board.piece_at(move.to_square)
|
| 188 |
-
if piece is None:
|
| 189 |
-
return 0
|
| 190 |
-
|
| 191 |
-
captured_val = PIECE_VALUES.get(captured.piece_type, 0) if captured else 0
|
| 192 |
-
|
| 193 |
-
b = board.copy()
|
| 194 |
-
if not b.is_legal(move):
|
| 195 |
-
return 0
|
| 196 |
-
b.push(move)
|
| 197 |
-
|
| 198 |
-
opponent_gain = see(b, move.to_square, b.turn)
|
| 199 |
-
return captured_val - max(0, opponent_gain)
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
def is_sacrifice(board: chess.Board, move: chess.Move) -> bool:
|
| 203 |
-
"""
|
| 204 |
-
True if the move results in a verified net material loss >= SACRIFICE_NET.
|
| 205 |
-
Excludes promotions and profitable captures.
|
| 206 |
-
"""
|
| 207 |
-
piece = board.piece_at(move.from_square)
|
| 208 |
-
if piece is None or move.promotion:
|
| 209 |
-
return False
|
| 210 |
-
return see_move(board, move) <= -SACRIFICE_NET
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
def is_revealed_sacrifice(board: chess.Board,
|
| 214 |
-
move: chess.Move) -> tuple:
|
| 215 |
-
"""
|
| 216 |
-
Detect a "revealed sacrifice": moving a piece that was shielding a more
|
| 217 |
-
valuable friendly piece, deliberately leaving that piece exposed.
|
| 218 |
-
Classic example: unpinning a knight that was pinned to the queen — the
|
| 219 |
-
knight moves away and the queen is now en prise, but the move is brilliant
|
| 220 |
-
because of what follows.
|
| 221 |
-
|
| 222 |
-
Returns (is_revealed, piece_name, material_at_risk_cp) where:
|
| 223 |
-
is_revealed — True if a friendly piece is newly hanging after the move
|
| 224 |
-
piece_name — name of the piece left exposed (e.g. "queen")
|
| 225 |
-
material_at_risk — how much the opponent can gain by capturing it (SEE)
|
| 226 |
-
"""
|
| 227 |
-
if move.promotion:
|
| 228 |
-
return False, "", 0
|
| 229 |
-
|
| 230 |
-
color = board.turn
|
| 231 |
-
b = board.copy()
|
| 232 |
-
if move not in b.legal_moves:
|
| 233 |
-
return False, "", 0
|
| 234 |
-
b.push(move) # now it's opponent's turn in b
|
| 235 |
-
|
| 236 |
-
max_gain = 0
|
| 237 |
-
max_piece = ""
|
| 238 |
-
|
| 239 |
-
for sq in chess.SQUARES:
|
| 240 |
-
# Skip the square the piece just moved TO — that square is already
|
| 241 |
-
# handled by is_sacrifice (SEE on the destination). Including it here
|
| 242 |
-
# causes ordinary trades (pawn takes pawn, opponent takes back) to be
|
| 243 |
-
# flagged as revealed sacrifices.
|
| 244 |
-
if sq == move.to_square:
|
| 245 |
-
continue
|
| 246 |
-
|
| 247 |
-
piece = b.piece_at(sq)
|
| 248 |
-
if piece is None or piece.color != color or piece.piece_type == chess.KING:
|
| 249 |
-
continue
|
| 250 |
-
# How much can the opponent gain by capturing this OTHER piece?
|
| 251 |
-
gain = see(b, sq, not color)
|
| 252 |
-
if gain > max_gain:
|
| 253 |
-
max_gain = gain
|
| 254 |
-
max_piece = chess.piece_name(piece.piece_type)
|
| 255 |
-
|
| 256 |
-
return max_gain >= SACRIFICE_NET, max_piece, max_gain
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
def is_recapture(board: chess.Board, move: chess.Move,
|
| 260 |
-
prev_move: Optional[chess.Move]) -> bool:
|
| 261 |
-
"""True if the move captures back on the square the opponent just moved to."""
|
| 262 |
-
if prev_move is None:
|
| 263 |
-
return False
|
| 264 |
-
return (board.piece_at(move.to_square) is not None
|
| 265 |
-
and move.to_square == prev_move.to_square)
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
# ─── Tactic helpers ──────────────────────────────────────────────────────────
|
| 269 |
-
|
| 270 |
-
def creates_fork(board: chess.Board, move: chess.Move) -> bool:
|
| 271 |
-
"""True if the move attacks 2+ valuable opponent pieces simultaneously."""
|
| 272 |
-
b = board.copy()
|
| 273 |
-
b.push(move)
|
| 274 |
-
attacker = b.piece_at(move.to_square)
|
| 275 |
-
if attacker is None:
|
| 276 |
-
return False
|
| 277 |
-
targets = sum(
|
| 278 |
-
1 for sq in b.attacks(move.to_square)
|
| 279 |
-
if b.piece_at(sq)
|
| 280 |
-
and b.piece_at(sq).color != attacker.color
|
| 281 |
-
and b.piece_at(sq).piece_type in (chess.QUEEN, chess.ROOK,
|
| 282 |
-
chess.KNIGHT, chess.BISHOP, chess.KING)
|
| 283 |
-
)
|
| 284 |
-
return targets >= 2
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
def forced_mate_available(lines: List[EngineMove]) -> Optional[int]:
|
| 288 |
-
"""Return mate-in count if the engine's best move forces mate, else None."""
|
| 289 |
-
if lines and lines[0].score.is_mate():
|
| 290 |
-
m = lines[0].score.mate()
|
| 291 |
-
return m if m > 0 else None
|
| 292 |
-
return None
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
def allows_forced_mate(lines_after: List[EngineMove]) -> Optional[int]:
|
| 296 |
-
"""
|
| 297 |
-
Return opponent's mate-in count if the played move hands them a forced mate.
|
| 298 |
-
lines_after[0] is from the OPPONENT's POV: m > 0 means they have forced mate.
|
| 299 |
-
"""
|
| 300 |
-
if lines_after and lines_after[0].score.is_mate():
|
| 301 |
-
m = lines_after[0].score.mate()
|
| 302 |
-
if m > 0:
|
| 303 |
-
return m
|
| 304 |
-
return None
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
# ─── PV to SAN ───────────────────────────────────────────────────────────────
|
| 308 |
-
|
| 309 |
-
def pv_to_san(board: chess.Board, pv: List[chess.Move], max_moves: int = 5) -> List[str]:
|
| 310 |
-
"""Convert a PV list to SAN notation strings."""
|
| 311 |
-
result = []
|
| 312 |
-
b = board.copy()
|
| 313 |
-
for move in pv[:max_moves]:
|
| 314 |
-
if move not in b.legal_moves:
|
| 315 |
-
break
|
| 316 |
-
result.append(b.san(move))
|
| 317 |
-
b.push(move)
|
| 318 |
-
return result
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
def _material_for(board: chess.Board, color: chess.Color) -> int:
|
| 322 |
-
"""Total piece value for `color` (pawns included, king excluded)."""
|
| 323 |
-
return sum(
|
| 324 |
-
len(board.pieces(pt, color)) * PIECE_VALUES[pt]
|
| 325 |
-
for pt in (chess.PAWN, chess.KNIGHT, chess.BISHOP, chess.ROOK, chess.QUEEN)
|
| 326 |
-
)
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
def pv_wins_material_back(board: chess.Board, move: chess.Move,
|
| 330 |
-
pv: List[chess.Move], max_moves: int = 8) -> bool:
|
| 331 |
-
"""
|
| 332 |
-
True if, after playing `move` followed by the PV continuation, the side
|
| 333 |
-
that made the sacrifice has recovered at least as much material as they lost.
|
| 334 |
-
Catches "combination sacrifices" — give a piece, win it (or more) back.
|
| 335 |
-
|
| 336 |
-
Requires at least 2 PV moves after the sacrifice (the opponent's recapture
|
| 337 |
-
+ one response) to avoid false positives when the opponent hasn't replied yet.
|
| 338 |
-
"""
|
| 339 |
-
if len(pv) < 2:
|
| 340 |
-
return False # not enough continuation to judge
|
| 341 |
-
|
| 342 |
-
mover = board.turn
|
| 343 |
-
mat_before = _material_for(board, mover)
|
| 344 |
-
|
| 345 |
-
b = board.copy()
|
| 346 |
-
if move not in b.legal_moves:
|
| 347 |
-
return False
|
| 348 |
-
b.push(move)
|
| 349 |
-
|
| 350 |
-
# Play out the PV continuation (starts with opponent's response)
|
| 351 |
-
for m in pv[:max_moves]:
|
| 352 |
-
if m not in b.legal_moves:
|
| 353 |
-
break
|
| 354 |
-
b.push(m)
|
| 355 |
-
|
| 356 |
-
mat_after = _material_for(b, mover)
|
| 357 |
-
# Won back at least as much as sacrificed (within a pawn of tolerance)
|
| 358 |
-
return mat_after >= mat_before - PIECE_VALUES[chess.PAWN]
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
# ─── Core classification ──────────────────────────────────────────────────────
|
| 362 |
-
|
| 363 |
-
def classify_move(
|
| 364 |
-
board: chess.Board,
|
| 365 |
-
move: chess.Move,
|
| 366 |
-
lines_before: List[EngineMove],
|
| 367 |
-
lines_after: List[EngineMove],
|
| 368 |
-
is_book: bool = False,
|
| 369 |
-
half_move_idx: int = 99,
|
| 370 |
-
prev_move: Optional[chess.Move] = None,
|
| 371 |
-
) -> MoveAnalysis:
|
| 372 |
-
san = board.san(move)
|
| 373 |
-
|
| 374 |
-
# ── Centipawn evals (kept for notes and CLI output) ───────────────────────
|
| 375 |
-
eval_before = lines_before[0].cp if lines_before else 0.0
|
| 376 |
-
eval_after = (-lines_after[0].cp) if lines_after else eval_before
|
| 377 |
-
cp_loss = eval_before - eval_after
|
| 378 |
-
best_move = lines_before[0].move if lines_before else None
|
| 379 |
-
|
| 380 |
-
# ── Expected score ────────────────────────────────────────────────────────
|
| 381 |
-
# e_before : current player's expected score BEFORE the move (side-to-move POV)
|
| 382 |
-
# e_after : current player's expected score AFTER the move
|
| 383 |
-
# lines_after[0].expected is from the OPPONENT's POV → flip with 1 - x
|
| 384 |
-
e_before = (lines_before[0].expected
|
| 385 |
-
if lines_before and lines_before[0].expected is not None
|
| 386 |
-
else cp_to_expected(eval_before))
|
| 387 |
-
e_after_raw = (lines_after[0].expected
|
| 388 |
-
if lines_after and lines_after[0].expected is not None
|
| 389 |
-
else None)
|
| 390 |
-
e_after = ((1.0 - e_after_raw) if e_after_raw is not None
|
| 391 |
-
else cp_to_expected(eval_after))
|
| 392 |
-
e_loss = max(0.0, e_before - e_after)
|
| 393 |
-
|
| 394 |
-
# ── Five-state position categorisation (computed early for brilliant guard) ─
|
| 395 |
-
def state(e):
|
| 396 |
-
if e >= E_WINNING: return "winning"
|
| 397 |
-
elif e >= E_SLIGHTLY_WINNING: return "slightly_winning"
|
| 398 |
-
elif e >= E_EQUAL_LOW: return "equal"
|
| 399 |
-
elif e >= E_SLIGHTLY_LOSING: return "slightly_losing"
|
| 400 |
-
else: return "losing"
|
| 401 |
-
state_before = state(e_before)
|
| 402 |
-
state_after = state(e_after)
|
| 403 |
-
|
| 404 |
-
# ── Auto-Book ─────────────────────────────────────────────────────────────
|
| 405 |
-
if is_book or ((half_move_idx // 2) + 1) <= BOOK_MOVES:
|
| 406 |
-
return MoveAnalysis(move, san, Classification.BOOK,
|
| 407 |
-
0.0, eval_before, eval_after, best_move,
|
| 408 |
-
["Opening theory"], e_loss=0.0)
|
| 409 |
-
|
| 410 |
-
# ── Move rank among engine suggestions ───────────────────────────────────
|
| 411 |
-
move_rank: Optional[int] = None
|
| 412 |
-
for i, em in enumerate(lines_before):
|
| 413 |
-
if em.move == move:
|
| 414 |
-
move_rank = i
|
| 415 |
-
break
|
| 416 |
-
|
| 417 |
-
notes: List[str] = []
|
| 418 |
-
|
| 419 |
-
# ── Tactical context ──────────────────────────────────────────────────────
|
| 420 |
-
mate_in = forced_mate_available(lines_before)
|
| 421 |
-
mate_allowed = allows_forced_mate(lines_after)
|
| 422 |
-
move_captures = board.piece_at(move.to_square) is not None
|
| 423 |
-
move_see = see_move(board, move)
|
| 424 |
-
sacrifice = is_sacrifice(board, move)
|
| 425 |
-
revealed_sac, revealed_piece, revealed_mat = is_revealed_sacrifice(board, move)
|
| 426 |
-
recapture = is_recapture(board, move, prev_move)
|
| 427 |
-
is_engine_best = (move_rank == 0)
|
| 428 |
-
|
| 429 |
-
# After our move, does the engine say WE have forced mate?
|
| 430 |
-
# lines_after[0].score is from opponent's POV: m < 0 means they are being mated.
|
| 431 |
-
we_have_mate_after: Optional[int] = None
|
| 432 |
-
if lines_after and lines_after[0].score.is_mate():
|
| 433 |
-
m = lines_after[0].score.mate()
|
| 434 |
-
if m < 0:
|
| 435 |
-
we_have_mate_after = abs(m) # we have forced mate in this many moves
|
| 436 |
-
|
| 437 |
-
test_board = board.copy()
|
| 438 |
-
test_board.push(move)
|
| 439 |
-
gives_checkmate = test_board.is_checkmate()
|
| 440 |
-
|
| 441 |
-
# ── BRILLIANT shortcut ───────────────────────────────────────────────────
|
| 442 |
-
# If the move is a sacrifice (direct or revealed) AND the engine ranks it
|
| 443 |
-
# in its top 3 (Best or Excellent), classify immediately as Brilliant before
|
| 444 |
-
# any e_loss checks. A sacrifice that the engine endorses cannot be a Mistake.
|
| 445 |
-
#
|
| 446 |
-
# Extra guard for revealed sacrifices: the exposed piece must actually be
|
| 447 |
-
# threatened in a way the opponent can't easily avoid. We check this by
|
| 448 |
-
# seeing if the opponent's best response (lines_after[0]) captures the
|
| 449 |
-
# exposed piece. If they ignore it and play something else, the "sacrifice"
|
| 450 |
-
# isn't forced and shouldn't be Brilliant.
|
| 451 |
-
is_forced_revealed = False
|
| 452 |
-
if revealed_sac and not sacrifice:
|
| 453 |
-
# Find which square has the exposed piece (highest SEE gain for opponent)
|
| 454 |
-
b_tmp = board.copy()
|
| 455 |
-
b_tmp.push(move)
|
| 456 |
-
exposed_sq = None
|
| 457 |
-
best_gain = 0
|
| 458 |
-
for sq in chess.SQUARES:
|
| 459 |
-
if sq == move.to_square:
|
| 460 |
-
continue
|
| 461 |
-
piece_here = b_tmp.piece_at(sq)
|
| 462 |
-
if piece_here and piece_here.color == board.turn and piece_here.piece_type != chess.KING:
|
| 463 |
-
g = see(b_tmp, sq, not board.turn)
|
| 464 |
-
if g > best_gain:
|
| 465 |
-
best_gain = g
|
| 466 |
-
exposed_sq = sq
|
| 467 |
-
|
| 468 |
-
# Two cases qualify as a genuine revealed sacrifice:
|
| 469 |
-
#
|
| 470 |
-
# A) Opponent's best response IS to take the exposed piece
|
| 471 |
-
# (e.g. previous pawn-trade fix — the threat is forced)
|
| 472 |
-
#
|
| 473 |
-
# B) The exposed piece IS legally capturable (an opponent piece
|
| 474 |
-
# attacks that square), but the engine recommends NOT taking —
|
| 475 |
-
# because taking leads to mate or material loss for the opponent.
|
| 476 |
-
# This is the "knight captures pinned piece, queen exposed but
|
| 477 |
-
# untouchable" pattern.
|
| 478 |
-
opp_can_capture = (
|
| 479 |
-
exposed_sq is not None
|
| 480 |
-
and bool(b_tmp.attackers(not board.turn, exposed_sq))
|
| 481 |
-
)
|
| 482 |
-
opp_takes_it = (
|
| 483 |
-
exposed_sq is not None
|
| 484 |
-
and lines_after
|
| 485 |
-
and lines_after[0].move.to_square == exposed_sq
|
| 486 |
-
)
|
| 487 |
-
if opp_takes_it:
|
| 488 |
-
is_forced_revealed = True
|
| 489 |
-
elif opp_can_capture:
|
| 490 |
-
# Opponent can take but the engine says don't — verify it's actually
|
| 491 |
-
# bad for them by simulating the capture and checking the resulting eval.
|
| 492 |
-
# If after they take, WE have forced mate or our expected score is high,
|
| 493 |
-
# then deliberately leaving the piece en prise is brilliant.
|
| 494 |
-
capture_move = None
|
| 495 |
-
for atk_sq in b_tmp.attackers(not board.turn, exposed_sq):
|
| 496 |
-
cand = chess.Move(atk_sq, exposed_sq)
|
| 497 |
-
if b_tmp.is_legal(cand):
|
| 498 |
-
capture_move = cand
|
| 499 |
-
break
|
| 500 |
-
if capture_move is not None:
|
| 501 |
-
b_capture = b_tmp.copy()
|
| 502 |
-
b_capture.push(capture_move)
|
| 503 |
-
# After they capture: do WE have forced mate, or is our expected score good?
|
| 504 |
-
# Use SEE on the capture square from OUR side to check net material
|
| 505 |
-
our_response_gain = see(b_capture, exposed_sq, board.turn)
|
| 506 |
-
# If taking leads to: forced mate for us (we_have_mate_after already set),
|
| 507 |
-
# OR we win significant material back after their capture, it's brilliant
|
| 508 |
-
if we_have_mate_after is not None or our_response_gain >= SACRIFICE_NET:
|
| 509 |
-
is_forced_revealed = True
|
| 510 |
-
# Also allow: forced mate after the move regardless of capture availability
|
| 511 |
-
if we_have_mate_after is not None and exposed_sq is not None:
|
| 512 |
-
is_forced_revealed = True
|
| 513 |
-
|
| 514 |
-
if (sacrifice or is_forced_revealed) and move_rank is not None and move_rank <= 2\
|
| 515 |
-
and state_after not in ('slightly_losing', 'losing'):
|
| 516 |
-
piece = board.piece_at(move.from_square)
|
| 517 |
-
piece_name = chess.piece_name(piece.piece_type) if piece else "piece"
|
| 518 |
-
if we_have_mate_after is not None:
|
| 519 |
-
label = f"Sacrifice leads to forced mate in {we_have_mate_after}"
|
| 520 |
-
elif is_forced_revealed and not sacrifice:
|
| 521 |
-
label = f"Deliberately exposes the {revealed_piece or 'piece'} for a greater gain"
|
| 522 |
-
elif lines_before and pv_wins_material_back(board, move, lines_before[0].pv[1:]):
|
| 523 |
-
label = "Wins material back in the combination"
|
| 524 |
-
else:
|
| 525 |
-
label = f"Sacrifices the {piece_name} for advantage"
|
| 526 |
-
notes.append(label)
|
| 527 |
-
return MoveAnalysis(move, san, Classification.BRILLIANT,
|
| 528 |
-
cp_loss, eval_before, eval_after, best_move, notes, e_loss)
|
| 529 |
-
|
| 530 |
-
# ── Five-state position categorisation ──────────────────────────────────
|
| 531 |
-
# ── Transition-based severity floor (negative moves) ─────────────────────
|
| 532 |
-
# Maps (state_before, state_after) → minimum classification for non-engine-best moves.
|
| 533 |
-
NEGATIVE_FLOORS = {
|
| 534 |
-
# Winning → worse
|
| 535 |
-
("winning", "slightly_winning"): ("inaccuracy", "Gave up the winning advantage"),
|
| 536 |
-
("winning", "equal"): ("mistake", "Lost a winning advantage"),
|
| 537 |
-
("winning", "slightly_losing"): ("mistake", "Threw away a winning position"),
|
| 538 |
-
("winning", "losing"): ("mistake", "Threw away a winning position"),
|
| 539 |
-
# Slightly winning → worse
|
| 540 |
-
("slightly_winning", "equal"): ("inaccuracy", "Gave up the winning advantage"),
|
| 541 |
-
("slightly_winning", "slightly_losing"): ("mistake", "Lost a winning advantage"),
|
| 542 |
-
("slightly_winning", "losing"): ("mistake", "Threw away a winning position"),
|
| 543 |
-
# Equal → worse
|
| 544 |
-
("equal", "slightly_losing"): ("mistake", "Position went from equal to losing"),
|
| 545 |
-
("equal", "losing"): ("mistake", "Position went from equal to losing"),
|
| 546 |
-
# Slightly losing → much worse
|
| 547 |
-
("slightly_losing", "losing"): ("inaccuracy", None),
|
| 548 |
-
}
|
| 549 |
-
|
| 550 |
-
# ── Transition-based positive upgrade (good moves recovering position) ───
|
| 551 |
-
# Maps (state_before, state_after) → minimum positive classification.
|
| 552 |
-
POSITIVE_UPGRADES = {
|
| 553 |
-
("losing", "winning"): "great",
|
| 554 |
-
("losing", "slightly_winning"): "great",
|
| 555 |
-
("losing", "equal"): "great",
|
| 556 |
-
("losing", "slightly_losing"): "best",
|
| 557 |
-
("slightly_losing", "winning"): "great",
|
| 558 |
-
("slightly_losing", "slightly_winning"): "great",
|
| 559 |
-
("slightly_losing", "equal"): "best",
|
| 560 |
-
("equal", "winning"): "great",
|
| 561 |
-
("equal", "slightly_winning"): "best",
|
| 562 |
-
("slightly_winning","winning"): "best",
|
| 563 |
-
}
|
| 564 |
-
|
| 565 |
-
min_severity = None
|
| 566 |
-
min_pos_upg = None # minimum positive upgrade
|
| 567 |
-
trans_note = None
|
| 568 |
-
|
| 569 |
-
if not is_engine_best:
|
| 570 |
-
floor = NEGATIVE_FLOORS.get((state_before, state_after))
|
| 571 |
-
if floor:
|
| 572 |
-
min_severity, trans_note = floor
|
| 573 |
-
|
| 574 |
-
upgrade = POSITIVE_UPGRADES.get((state_before, state_after))
|
| 575 |
-
if upgrade:
|
| 576 |
-
min_pos_upg = upgrade
|
| 577 |
-
|
| 578 |
-
if trans_note:
|
| 579 |
-
notes.append(trans_note)
|
| 580 |
-
|
| 581 |
-
# ── Context-aware severity cap ────────────────────────────────────────────
|
| 582 |
-
# Caps how bad a move can be if the resulting position is still decent.
|
| 583 |
-
if e_after >= E_SLIGHTLY_WINNING and not mate_allowed:
|
| 584 |
-
max_severity = "inaccuracy"
|
| 585 |
-
elif e_after >= E_EQUAL_LOW and not mate_allowed:
|
| 586 |
-
max_severity = "mistake"
|
| 587 |
-
else:
|
| 588 |
-
max_severity = "blunder"
|
| 589 |
-
|
| 590 |
-
# Wrong recapture choice caps at Inaccuracy
|
| 591 |
-
if recapture and max_severity in ("blunder", "mistake"):
|
| 592 |
-
max_severity = "inaccuracy"
|
| 593 |
-
|
| 594 |
-
# Floor can override cap (e.g. transition floor is worse than position cap)
|
| 595 |
-
SEVERITY_ORDER = ["inaccuracy", "mistake", "blunder"]
|
| 596 |
-
if min_severity and SEVERITY_ORDER.index(min_severity) > SEVERITY_ORDER.index(max_severity):
|
| 597 |
-
max_severity = min_severity
|
| 598 |
-
|
| 599 |
-
# ── BLUNDER ───────────────────────────────────────────────────────────────
|
| 600 |
-
if mate_allowed and not is_engine_best:
|
| 601 |
-
notes.append(f"Allows mate in {mate_allowed}")
|
| 602 |
-
return MoveAnalysis(move, san, Classification.BLUNDER,
|
| 603 |
-
cp_loss, eval_before, eval_after, best_move, notes, e_loss)
|
| 604 |
-
|
| 605 |
-
if not is_engine_best and max_severity == "blunder":
|
| 606 |
-
if e_loss >= E_BLUNDER:
|
| 607 |
-
if not (move_captures and move_see >= -50):
|
| 608 |
-
notes.append("Hangs material")
|
| 609 |
-
return MoveAnalysis(move, san, Classification.BLUNDER,
|
| 610 |
-
cp_loss, eval_before, eval_after, best_move, notes, e_loss)
|
| 611 |
-
|
| 612 |
-
# ── MISTAKE ───────────────────────────────────────────────────────────────
|
| 613 |
-
if not is_engine_best and max_severity in ("blunder", "mistake"):
|
| 614 |
-
if min_severity in ("blunder", "mistake") or e_loss >= E_MISTAKE:
|
| 615 |
-
return MoveAnalysis(move, san, Classification.MISTAKE,
|
| 616 |
-
cp_loss, eval_before, eval_after, best_move, notes, e_loss)
|
| 617 |
-
|
| 618 |
-
# ── INACCURACY ────────────────────────────────────────────────────────────
|
| 619 |
-
if not is_engine_best and move_rank is not None:
|
| 620 |
-
if min_severity or e_loss >= E_INACCURACY:
|
| 621 |
-
return MoveAnalysis(move, san, Classification.INACCURACY,
|
| 622 |
-
cp_loss, eval_before, eval_after, best_move, notes, e_loss)
|
| 623 |
-
|
| 624 |
-
# ── OMISSION ─────────────────────────────────────────────────────────────
|
| 625 |
-
# Case 1: Missed a forced mate
|
| 626 |
-
if mate_in and move_rank is not None and move_rank > 0:
|
| 627 |
-
notes.append(f"Missed mate in {mate_in}")
|
| 628 |
-
return MoveAnalysis(move, san, Classification.OMISSION,
|
| 629 |
-
cp_loss, eval_before, eval_after, best_move, notes, e_loss)
|
| 630 |
-
|
| 631 |
-
# Case 2: Missed a fork
|
| 632 |
-
if e_loss >= MISSED_TACTIC and not is_engine_best:
|
| 633 |
-
if (lines_before
|
| 634 |
-
and creates_fork(board, lines_before[0].move)
|
| 635 |
-
and not creates_fork(board, move)):
|
| 636 |
-
notes.append("Missed a fork")
|
| 637 |
-
return MoveAnalysis(move, san, Classification.OMISSION,
|
| 638 |
-
cp_loss, eval_before, eval_after, best_move, notes, e_loss)
|
| 639 |
-
|
| 640 |
-
# Case 3: Missed opportunity after opponent's mistake
|
| 641 |
-
# e_before is the expected score for the current player given optimal play from here.
|
| 642 |
-
# If e_before (= what we'd get with the best move) is winning, but e_after (what we
|
| 643 |
-
# actually got) is not, we missed the chance to capitalise.
|
| 644 |
-
if (not is_engine_best
|
| 645 |
-
and move_rank is not None
|
| 646 |
-
and e_before >= E_MISS_BEST_TARGET # best move was winning
|
| 647 |
-
and e_after <= E_MISS_PLAYED_CAP # played move didn't capitalise
|
| 648 |
-
and e_before <= E_MISS_PLAYED_CAP + E_MISS_BEST_TARGET): # not already winning before
|
| 649 |
-
notes.append("Missed opportunity to reach a winning position")
|
| 650 |
-
return MoveAnalysis(move, san, Classification.OMISSION,
|
| 651 |
-
cp_loss, eval_before, eval_after, best_move, notes, e_loss)
|
| 652 |
-
|
| 653 |
-
# ── Positive classifications ──────────────────────────────────────────────
|
| 654 |
-
|
| 655 |
-
if move_rank is None:
|
| 656 |
-
return MoveAnalysis(move, san, Classification.GOOD,
|
| 657 |
-
cp_loss, eval_before, eval_after, best_move, notes, e_loss)
|
| 658 |
-
|
| 659 |
-
if move_rank == 0:
|
| 660 |
-
if gives_checkmate:
|
| 661 |
-
notes.append("Checkmate!")
|
| 662 |
-
return MoveAnalysis(move, san, Classification.BEST,
|
| 663 |
-
cp_loss, eval_before, eval_after, best_move, notes, e_loss)
|
| 664 |
-
|
| 665 |
-
# ── BRILLIANT ─────────────────────────────────────────────────────────
|
| 666 |
-
# Guard: a sacrifice that leaves us in a bad position is a blunder, not brilliant.
|
| 667 |
-
# If the position goes to slightly_losing or losing after the sac, it's not brilliant.
|
| 668 |
-
sac_is_valid = (sacrifice or is_forced_revealed) and state_after not in ("slightly_losing", "losing")
|
| 669 |
-
if sac_is_valid:
|
| 670 |
-
piece = board.piece_at(move.from_square)
|
| 671 |
-
piece_name = chess.piece_name(piece.piece_type) if piece else "piece"
|
| 672 |
-
if we_have_mate_after is not None:
|
| 673 |
-
label = f"Sacrifice leads to forced mate in {we_have_mate_after}"
|
| 674 |
-
elif is_forced_revealed and not sacrifice:
|
| 675 |
-
label = f"Deliberately exposes the {revealed_piece or 'piece'} for a greater gain"
|
| 676 |
-
elif lines_before and pv_wins_material_back(board, move, lines_before[0].pv[1:]):
|
| 677 |
-
label = "Wins material back in the combination"
|
| 678 |
-
else:
|
| 679 |
-
label = f"Sacrifices the {piece_name} for advantage"
|
| 680 |
-
notes.append(label)
|
| 681 |
-
return MoveAnalysis(move, san, Classification.BRILLIANT,
|
| 682 |
-
cp_loss, eval_before, eval_after, best_move, notes, e_loss)
|
| 683 |
-
|
| 684 |
-
# ── GREAT / BEST from position transition ─────────────────────────────
|
| 685 |
-
if min_pos_upg == "great" and not recapture:
|
| 686 |
-
label_map = {
|
| 687 |
-
("losing", "winning"): "Turned a losing position into a winning one",
|
| 688 |
-
("losing", "slightly_winning"): "Escaped a losing position",
|
| 689 |
-
("losing", "equal"): "Rescued an equal position from a lost game",
|
| 690 |
-
("losing", "slightly_losing"): "Improved a losing position",
|
| 691 |
-
("slightly_losing","winning"): "Turned the game around completely",
|
| 692 |
-
("slightly_losing","slightly_winning"): "Turned the tables",
|
| 693 |
-
("slightly_losing","equal"): "Equalised from a losing position",
|
| 694 |
-
("equal", "winning"): "Seized the winning advantage",
|
| 695 |
-
("equal", "slightly_winning"): "Gained the winning edge",
|
| 696 |
-
}
|
| 697 |
-
trans_label = label_map.get((state_before, state_after), "Strong recovery move")
|
| 698 |
-
notes.append(trans_label)
|
| 699 |
-
return MoveAnalysis(move, san, Classification.GREAT,
|
| 700 |
-
cp_loss, eval_before, eval_after, best_move, notes, e_loss)
|
| 701 |
-
|
| 702 |
-
# ── GREAT ─────────────────────────────────────────────────────────────
|
| 703 |
-
if not recapture:
|
| 704 |
-
only_good_move = (
|
| 705 |
-
len(lines_before) >= 2
|
| 706 |
-
and (lines_before[0].cp - lines_before[1].cp) >= ONLY_MOVE_GAP
|
| 707 |
-
)
|
| 708 |
-
position_turning = (
|
| 709 |
-
e_after >= E_GREAT_TARGET
|
| 710 |
-
and (e_after - e_before) >= E_GREAT_TURN
|
| 711 |
-
and e_before < E_GREAT_TARGET
|
| 712 |
-
)
|
| 713 |
-
if only_good_move:
|
| 714 |
-
gap = int(lines_before[0].cp - lines_before[1].cp)
|
| 715 |
-
notes.append(f"Only good move (2nd best is {gap} cp worse)")
|
| 716 |
-
return MoveAnalysis(move, san, Classification.GREAT,
|
| 717 |
-
cp_loss, eval_before, eval_after, best_move, notes, e_loss)
|
| 718 |
-
if position_turning:
|
| 719 |
-
notes.append("Turns a losing position around"
|
| 720 |
-
if e_before < E_EQUAL_LOW
|
| 721 |
-
else "Turns an equal position into a winning one")
|
| 722 |
-
return MoveAnalysis(move, san, Classification.GREAT,
|
| 723 |
-
cp_loss, eval_before, eval_after, best_move, notes, e_loss)
|
| 724 |
-
|
| 725 |
-
return MoveAnalysis(move, san, Classification.BEST,
|
| 726 |
-
cp_loss, eval_before, eval_after, best_move, notes, e_loss)
|
| 727 |
-
|
| 728 |
-
if move_rank in (1, 2):
|
| 729 |
-
# ── BRILLIANT (rank 1-2 — Excellent move that is also a sacrifice) ────
|
| 730 |
-
sac_is_valid = (sacrifice or is_forced_revealed) and state_after not in ("slightly_losing", "losing")
|
| 731 |
-
if sac_is_valid:
|
| 732 |
-
piece = board.piece_at(move.from_square)
|
| 733 |
-
piece_name = chess.piece_name(piece.piece_type) if piece else "piece"
|
| 734 |
-
if we_have_mate_after is not None:
|
| 735 |
-
label = f"Sacrifice leads to forced mate in {we_have_mate_after}"
|
| 736 |
-
elif is_forced_revealed and not sacrifice:
|
| 737 |
-
label = f"Deliberately exposes the {revealed_piece or 'piece'} for a greater gain"
|
| 738 |
-
elif lines_before and pv_wins_material_back(board, move, lines_before[0].pv[1:]):
|
| 739 |
-
label = "Wins material back in the combination"
|
| 740 |
-
else:
|
| 741 |
-
label = f"Sacrifices the {piece_name} for advantage"
|
| 742 |
-
notes.append(label)
|
| 743 |
-
return MoveAnalysis(move, san, Classification.BRILLIANT,
|
| 744 |
-
cp_loss, eval_before, eval_after, best_move, notes, e_loss)
|
| 745 |
-
return MoveAnalysis(move, san, Classification.EXCELLENT,
|
| 746 |
-
cp_loss, eval_before, eval_after, best_move, notes, e_loss)
|
| 747 |
-
|
| 748 |
-
return MoveAnalysis(move, san, Classification.GOOD,
|
| 749 |
-
cp_loss, eval_before, eval_after, best_move, notes, e_loss)
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
# ─── Analyzer ─────────────────────────────────────────────────────────────────
|
| 753 |
-
|
| 754 |
-
class ChessAnalyzer:
|
| 755 |
-
def __init__(self,
|
| 756 |
-
stockfish_path: str = DEFAULT_SF_PATH,
|
| 757 |
-
depth: int = DEFAULT_DEPTH,
|
| 758 |
-
multipv: int = MULTIPV,
|
| 759 |
-
time_ms: Optional[int] = None):
|
| 760 |
-
self.stockfish_path = stockfish_path
|
| 761 |
-
self.depth = depth
|
| 762 |
-
self.multipv = multipv
|
| 763 |
-
self.time_ms = time_ms
|
| 764 |
-
self.engine: Optional[chess.engine.SimpleEngine] = None
|
| 765 |
-
|
| 766 |
-
def __enter__(self):
|
| 767 |
-
self.engine = chess.engine.SimpleEngine.popen_uci(self.stockfish_path)
|
| 768 |
-
try:
|
| 769 |
-
self.engine.configure({"UCI_ShowWDL": True})
|
| 770 |
-
except Exception:
|
| 771 |
-
pass # older Stockfish builds silently skip
|
| 772 |
-
return self
|
| 773 |
-
|
| 774 |
-
def __exit__(self, *_):
|
| 775 |
-
if self.engine:
|
| 776 |
-
self.engine.quit()
|
| 777 |
-
|
| 778 |
-
def _limit(self) -> chess.engine.Limit:
|
| 779 |
-
if self.time_ms is not None:
|
| 780 |
-
return chess.engine.Limit(time=self.time_ms / 1000.0)
|
| 781 |
-
return chess.engine.Limit(depth=self.depth)
|
| 782 |
-
|
| 783 |
-
def _top_moves(self, board: chess.Board,
|
| 784 |
-
multipv: Optional[int] = None) -> List[EngineMove]:
|
| 785 |
-
n = multipv if multipv is not None else self.multipv
|
| 786 |
-
infos = self.engine.analyse(board, self._limit(), multipv=n)
|
| 787 |
-
result = []
|
| 788 |
-
for info in infos:
|
| 789 |
-
if "pv" not in info or not info["pv"]:
|
| 790 |
-
continue
|
| 791 |
-
pov_score = info["score"].relative
|
| 792 |
-
cp = score_to_cp(pov_score)
|
| 793 |
-
expected = wdl_to_expected(info.get("wdl")) or cp_to_expected(cp)
|
| 794 |
-
result.append(EngineMove(
|
| 795 |
-
move=info["pv"][0], score=pov_score, cp=cp,
|
| 796 |
-
pv=list(info["pv"]), expected=expected,
|
| 797 |
-
))
|
| 798 |
-
return result
|
| 799 |
-
|
| 800 |
-
def _is_book(self, board: chess.Board, move: chess.Move,
|
| 801 |
-
book_path: Optional[str]) -> bool:
|
| 802 |
-
if not book_path or not os.path.exists(book_path):
|
| 803 |
-
return False
|
| 804 |
-
try:
|
| 805 |
-
with chess.polyglot.open_reader(book_path) as reader:
|
| 806 |
-
return any(e.move == move for e in reader.find_all(board))
|
| 807 |
-
except Exception:
|
| 808 |
-
return False
|
| 809 |
-
|
| 810 |
-
def analyze_game(self, pgn_string: str,
|
| 811 |
-
book_path: Optional[str] = None) -> List[MoveAnalysis]:
|
| 812 |
-
game = chess.pgn.read_game(io.StringIO(pgn_string))
|
| 813 |
-
if game is None:
|
| 814 |
-
raise ValueError("Could not parse PGN.")
|
| 815 |
-
|
| 816 |
-
board = game.board()
|
| 817 |
-
moves = list(game.mainline_moves())
|
| 818 |
-
total = len(moves)
|
| 819 |
-
|
| 820 |
-
print(f"\n White: {game.headers.get('White', '?')}")
|
| 821 |
-
print(f" Black: {game.headers.get('Black', '?')}")
|
| 822 |
-
print(f" Depth: {self.depth} | MultiPV: {self.multipv}\n")
|
| 823 |
-
|
| 824 |
-
lines_before = self._top_moves(board)
|
| 825 |
-
prev_move: Optional[chess.Move] = None
|
| 826 |
-
results: List[MoveAnalysis] = []
|
| 827 |
-
|
| 828 |
-
for i, move in enumerate(moves):
|
| 829 |
-
move_num = (i // 2) + 1
|
| 830 |
-
dot = "." if board.turn == chess.WHITE else "..."
|
| 831 |
-
sys.stdout.write(f"\r Analyzing {i+1}/{total} "
|
| 832 |
-
f"{move_num}{dot}{board.san(move):<10} ")
|
| 833 |
-
sys.stdout.flush()
|
| 834 |
-
|
| 835 |
-
book = self._is_book(board, move, book_path)
|
| 836 |
-
|
| 837 |
-
board.push(move)
|
| 838 |
-
lines_after = self._top_moves(board, multipv=1)
|
| 839 |
-
board.pop()
|
| 840 |
-
|
| 841 |
-
analysis = classify_move(board, move, lines_before, lines_after,
|
| 842 |
-
is_book=book, half_move_idx=i,
|
| 843 |
-
prev_move=prev_move)
|
| 844 |
-
board.push(move)
|
| 845 |
-
results.append(analysis)
|
| 846 |
-
prev_move = move
|
| 847 |
-
|
| 848 |
-
if i + 1 < total:
|
| 849 |
-
lines_before = self._top_moves(board)
|
| 850 |
-
|
| 851 |
-
print("\r" + " " * 60)
|
| 852 |
-
return results
|
| 853 |
-
|
| 854 |
-
def analyze_position(self, fen: str) -> List[EngineMove]:
|
| 855 |
-
return self._top_moves(chess.Board(fen))
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
# ─── Reporting ────────────────────────────────────────────────────────────────
|
| 859 |
-
|
| 860 |
-
COLORS = {
|
| 861 |
-
Classification.BRILLIANT: "\033[96m",
|
| 862 |
-
Classification.GREAT: "\033[94m",
|
| 863 |
-
Classification.BEST: "\033[92m",
|
| 864 |
-
Classification.EXCELLENT: "\033[32m",
|
| 865 |
-
Classification.GOOD: "\033[37m",
|
| 866 |
-
Classification.BOOK: "\033[90m",
|
| 867 |
-
Classification.INACCURACY: "\033[33m",
|
| 868 |
-
Classification.MISTAKE: "\033[91m",
|
| 869 |
-
Classification.BLUNDER: "\033[31m",
|
| 870 |
-
Classification.OMISSION: "\033[35m",
|
| 871 |
-
}
|
| 872 |
-
RESET = "\033[0m"
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
def print_analysis(analyses: List[MoveAnalysis], pgn_string: str,
|
| 876 |
-
white_only: bool = False, black_only: bool = False) -> None:
|
| 877 |
-
print("=" * 68)
|
| 878 |
-
print(" GAME ANALYSIS")
|
| 879 |
-
print("=" * 68)
|
| 880 |
-
|
| 881 |
-
for color_label, start_idx in (("White", 0), ("Black", 1)):
|
| 882 |
-
player_moves = analyses[start_idx::2]
|
| 883 |
-
counts: dict = {}
|
| 884 |
-
for a in player_moves:
|
| 885 |
-
counts[a.classification] = counts.get(a.classification, 0) + 1
|
| 886 |
-
avg_e_loss = (
|
| 887 |
-
sum(a.e_loss for a in player_moves
|
| 888 |
-
if a.classification not in (Classification.BOOK, Classification.BRILLIANT,
|
| 889 |
-
Classification.GREAT, Classification.BEST,
|
| 890 |
-
Classification.EXCELLENT))
|
| 891 |
-
/ max(1, len(player_moves))
|
| 892 |
-
)
|
| 893 |
-
print(f"\n ── {color_label} ──")
|
| 894 |
-
for cls in Classification:
|
| 895 |
-
if cls in counts:
|
| 896 |
-
print(f" {COLORS.get(cls,'')}{cls.value:<22}{RESET} ×{counts[cls]}")
|
| 897 |
-
print(f" Avg expected score loss: {avg_e_loss:.4f}")
|
| 898 |
-
|
| 899 |
-
print("\n" + "=" * 68)
|
| 900 |
-
print(" MOVE-BY-MOVE")
|
| 901 |
-
print("=" * 68)
|
| 902 |
-
|
| 903 |
-
for i, a in enumerate(analyses):
|
| 904 |
-
if white_only and i % 2 != 0: continue
|
| 905 |
-
if black_only and i % 2 != 1: continue
|
| 906 |
-
move_num = (i // 2) + 1
|
| 907 |
-
dot = "." if i % 2 == 0 else "..."
|
| 908 |
-
color = COLORS.get(a.classification, "")
|
| 909 |
-
e_str = f" (−{a.e_loss:.3f} EP)" if a.e_loss > 0.005 else ""
|
| 910 |
-
note_str = f" — {', '.join(a.notes)}" if a.notes else ""
|
| 911 |
-
eval_str = f" [{a.eval_after/100:+.2f}]"
|
| 912 |
-
print(f" {move_num:>3}{dot}{a.san:<8} "
|
| 913 |
-
f"{color}{a.classification.value:<22}{RESET}"
|
| 914 |
-
f"{eval_str}{e_str}{note_str}")
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
def print_position_analysis(moves: List[EngineMove], fen: str) -> None:
|
| 918 |
-
board = chess.Board(fen)
|
| 919 |
-
turn = "White" if board.turn == chess.WHITE else "Black"
|
| 920 |
-
print(f"\n Position: {fen}\n Turn: {turn}\n")
|
| 921 |
-
print(f" {'Rank':<6} {'Move':<10} {'Eval':>8} {'E-score':>8}")
|
| 922 |
-
print(" " + "-" * 40)
|
| 923 |
-
for i, em in enumerate(moves, 1):
|
| 924 |
-
san = board.san(em.move)
|
| 925 |
-
score_str = f"M{em.score.mate()}" if em.score.is_mate() else f"{em.cp/100:+.2f}"
|
| 926 |
-
e_str = f"{em.expected:.3f}" if em.expected is not None else "—"
|
| 927 |
-
print(f" {i:<6} {san:<10} {score_str:>8} {e_str:>8}")
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
# ─── CLI ──────────────────────────────────────────────────────────────────────
|
| 931 |
-
|
| 932 |
-
def main():
|
| 933 |
-
parser = argparse.ArgumentParser(
|
| 934 |
-
description="Chess analysis — Chess.com-style move classification")
|
| 935 |
-
parser.add_argument("pgn", nargs="?", help="Path to .pgn file")
|
| 936 |
-
parser.add_argument("--fen", help="Analyse single position (FEN string)")
|
| 937 |
-
parser.add_argument("--stockfish", default=DEFAULT_SF_PATH)
|
| 938 |
-
parser.add_argument("--depth", type=int, default=DEFAULT_DEPTH)
|
| 939 |
-
parser.add_argument("--multipv", type=int, default=MULTIPV)
|
| 940 |
-
parser.add_argument("--book", help="Polyglot opening book (.bin)")
|
| 941 |
-
parser.add_argument("--white", action="store_true")
|
| 942 |
-
parser.add_argument("--black", action="store_true")
|
| 943 |
-
args = parser.parse_args()
|
| 944 |
-
|
| 945 |
-
if not args.pgn and not args.fen:
|
| 946 |
-
parser.print_help()
|
| 947 |
-
sys.exit(1)
|
| 948 |
-
|
| 949 |
-
try:
|
| 950 |
-
with ChessAnalyzer(args.stockfish, args.depth, args.multipv) as analyzer:
|
| 951 |
-
if args.fen:
|
| 952 |
-
print_position_analysis(analyzer.analyze_position(args.fen), args.fen)
|
| 953 |
-
return
|
| 954 |
-
with open(args.pgn) as f:
|
| 955 |
-
pgn_string = f.read()
|
| 956 |
-
print_analysis(analyzer.analyze_game(pgn_string, args.book),
|
| 957 |
-
pgn_string, args.white, args.black)
|
| 958 |
-
except FileNotFoundError:
|
| 959 |
-
print(f"\n ✗ Stockfish not found at '{args.stockfish}'.")
|
| 960 |
-
sys.exit(1)
|
| 961 |
-
|
| 962 |
-
|
| 963 |
-
if __name__ == "__main__":
|
| 964 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|