Fu01978 commited on
Commit
a8fad5f
·
verified ·
1 Parent(s): d056335

Delete chess_analyzer.py

Browse files
Files changed (1) hide show
  1. 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()