Fu01978 commited on
Commit
ba06a25
·
verified ·
1 Parent(s): 0ba6cfd

Upload analyzer (13).py

Browse files
Files changed (1) hide show
  1. analyzer (13).py +689 -0
analyzer (13).py ADDED
@@ -0,0 +1,689 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chess game analyzer using Stockfish + Expected Points model.
3
+
4
+ Classification hierarchy (highest priority first):
5
+ Book → Brilliant → Great → Miss → Best → Excellent → Good → Inaccuracy → Mistake → Blunder
6
+ """
7
+
8
+ import chess
9
+ import chess.pgn
10
+ import chess.engine
11
+ import io
12
+ import math
13
+ import json
14
+ import random
15
+ import shutil
16
+ import os
17
+
18
+ def _find_stockfish() -> str:
19
+ """Locate stockfish binary on common paths."""
20
+ # Try PATH first
21
+ found = shutil.which("stockfish")
22
+ if found:
23
+ return found
24
+ for path in [
25
+ "/usr/games/stockfish",
26
+ "/usr/bin/stockfish",
27
+ "/usr/local/bin/stockfish",
28
+ "/opt/homebrew/bin/stockfish",
29
+ "/opt/homebrew/games/stockfish",
30
+ ]:
31
+ if os.path.isfile(path) and os.access(path, os.X_OK):
32
+ return path
33
+ return "stockfish" # Let it fail with a clear error
34
+
35
+
36
+ # ── ECO opening book ───────────────────────────────────────────────────────────
37
+
38
+ def _load_eco_db() -> dict:
39
+ """
40
+ Load ECO JSON files (ecoA-D.json) from an eco/ folder next to this file.
41
+ Keys are FEN strings; values contain name, eco code, and moves.
42
+ Returns an empty dict gracefully if the folder or files are missing.
43
+ """
44
+ db: dict = {}
45
+ eco_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "eco")
46
+ if not os.path.isdir(eco_dir):
47
+ return db
48
+ for letter in "ABCD":
49
+ path = os.path.join(eco_dir, f"eco{letter}.json")
50
+ if not os.path.isfile(path):
51
+ continue
52
+ try:
53
+ with open(path, encoding="utf-8") as f:
54
+ data = json.load(f)
55
+ db.update(data)
56
+ except Exception:
57
+ pass
58
+ return db
59
+
60
+ ECO_DB: dict = _load_eco_db()
61
+
62
+ def lookup_book(fen_after: str) -> dict | None:
63
+ """Return opening entry if position is in ECO db, else None."""
64
+ return ECO_DB.get(fen_after)
65
+
66
+ # ── Expected Points ────────────────────────────────────────────────────────────
67
+
68
+ def cp_to_ep(cp: float, k: float = 0.4) -> float:
69
+ """Convert centipawn score to Expected Points from White's perspective."""
70
+ return 1.0 / (1.0 + math.exp(-k * (cp / 100.0)))
71
+
72
+ def score_to_ep_white(score: chess.engine.Score) -> float:
73
+ """Convert a PovScore (already in White's POV) to EP."""
74
+ if score.is_mate():
75
+ m = score.mate()
76
+ return 1.0 if (m is not None and m > 0) else 0.0
77
+ cp = score.score()
78
+ return cp_to_ep(cp if cp is not None else 0)
79
+
80
+ def get_ep_white(info: dict) -> float:
81
+ return score_to_ep_white(info["score"].white())
82
+
83
+ # ── Sacrifice detection ────────────────────────────────────────────────────────
84
+
85
+ PIECE_VALUES = {
86
+ chess.PAWN: 1, chess.KNIGHT: 3, chess.BISHOP: 3,
87
+ chess.ROOK: 5, chess.QUEEN: 9, chess.KING: 99
88
+ }
89
+
90
+ def _see(board: chess.Board, square: int, attacker: chess.Color, target_val: int) -> int:
91
+ """
92
+ Recursive Static Exchange Evaluation.
93
+ Returns the net material gain for `attacker` if they initiate a capture
94
+ sequence on `square`, where `target_val` is the value of the piece there.
95
+ Positive = attacker profits, 0 = even trade, negative = attacker loses.
96
+ """
97
+ attackers = board.attackers(attacker, square)
98
+ if not attackers:
99
+ return 0
100
+
101
+ # Always capture with the least valuable piece first
102
+ best_sq = min(
103
+ (sq for sq in attackers if board.piece_at(sq)),
104
+ key=lambda sq: PIECE_VALUES.get(board.piece_at(sq).piece_type, 99),
105
+ default=None,
106
+ )
107
+ if best_sq is None:
108
+ return 0
109
+
110
+ capturing_piece = board.piece_at(best_sq)
111
+ capturing_val = PIECE_VALUES.get(capturing_piece.piece_type, 0)
112
+
113
+ # Simulate the capture by manually updating a board copy
114
+ b2 = board.copy()
115
+ b2.remove_piece_at(best_sq)
116
+ b2.set_piece_at(square, chess.Piece(capturing_piece.piece_type, attacker))
117
+
118
+ # The other side may now recapture — they'll only do so if it's profitable
119
+ recapture_gain = _see(b2, square, not attacker, capturing_val)
120
+
121
+ # Our gain: captured target_val, but may lose capturing_val if opponent recaptures
122
+ return target_val - max(0, recapture_gain)
123
+
124
+
125
+ def _is_hanging(board: chess.Board, square: int, our_color: chess.Color) -> bool:
126
+ """
127
+ Returns True if the piece on `square` is en prise for the opponent —
128
+ i.e. the opponent comes out ahead if they capture it (SEE > 0).
129
+ """
130
+ piece = board.piece_at(square)
131
+ if piece is None or piece.color != our_color:
132
+ return False
133
+ piece_val = PIECE_VALUES.get(piece.piece_type, 0)
134
+ if piece_val < 3:
135
+ return False # Ignore pawns and kings
136
+ opponent = not our_color
137
+ return _see(board, square, opponent, piece_val) > 0
138
+
139
+
140
+ def check_sacrifice(board: chess.Board, move: chess.Move) -> bool:
141
+ """
142
+ Returns True if this move involves a genuine piece sacrifice — either:
143
+ (a) The moved piece itself lands en prise (SEE favours the opponent), OR
144
+ (b) A friendly piece is newly left hanging after the move — e.g. a queen
145
+ that was shielded by the moving piece (pin scenario) is now exposed.
146
+
147
+ A knight defended by a pawn attacked by a bishop is NOT a sacrifice:
148
+ Bxn, pxB = even trade → SEE = 0 → not flagged.
149
+ """
150
+ our_color = board.turn
151
+ piece = board.piece_at(move.from_square)
152
+ if piece is None:
153
+ return False
154
+
155
+ piece_val = PIECE_VALUES.get(piece.piece_type, 0)
156
+
157
+ # ── (a) Moved-piece sacrifice ──────────────────────────────────────────
158
+ if piece_val >= 3:
159
+ # What we already captured by making this move (0 if not a capture)
160
+ captured = board.piece_at(move.to_square)
161
+ captured_val = PIECE_VALUES.get(captured.piece_type, 0) if captured else 0
162
+
163
+ board_after = board.copy()
164
+ board_after.push(move)
165
+ opponent = board_after.turn
166
+
167
+ # The opponent's net gain = what they take from us minus what we already took.
168
+ # e.g. Qxd8 Rxd8: opponent SEE=9, but we already took 9 → net gain=0 → not a sac.
169
+ # e.g. Bh6 gxh6: opponent SEE=3, we took nothing → net gain=3 → is a sac.
170
+ net_gain_for_opponent = _see(board_after, move.to_square, opponent, piece_val) - captured_val
171
+ if net_gain_for_opponent > 0:
172
+ return True
173
+
174
+ # ── (b) Piece-left-behind sacrifice (e.g. walking out of a pin) ───────
175
+ # Collect which friendly pieces were already hanging BEFORE the move,
176
+ # so we only flag pieces that are *newly* exposed.
177
+ hanging_before: set[int] = set()
178
+ for sq in board.pieces(chess.QUEEN, our_color) | \
179
+ board.pieces(chess.ROOK, our_color) | \
180
+ board.pieces(chess.BISHOP, our_color) | \
181
+ board.pieces(chess.KNIGHT, our_color):
182
+ if sq == move.from_square:
183
+ continue # This is the piece we're moving — skip
184
+ if _is_hanging(board, sq, our_color):
185
+ hanging_before.add(sq)
186
+
187
+ board_after = board.copy()
188
+ board_after.push(move)
189
+
190
+ for sq in board_after.pieces(chess.QUEEN, our_color) | \
191
+ board_after.pieces(chess.ROOK, our_color) | \
192
+ board_after.pieces(chess.BISHOP, our_color) | \
193
+ board_after.pieces(chess.KNIGHT, our_color):
194
+ if sq == move.to_square:
195
+ continue # Already handled in (a)
196
+ if sq not in hanging_before and _is_hanging(board_after, sq, our_color):
197
+ return True # This piece is newly hanging — it's the real sacrifice
198
+
199
+ # ── (c) Deliberately ignored threat ────────────────────────────────────
200
+ # Our piece was already hanging before this move (opponent attacked it last
201
+ # turn), and our move neither moved it nor added a defender — we intentionally
202
+ # left it en prise to play something more important elsewhere.
203
+ # _is_hanging uses SEE so a piece that is now defended returns False.
204
+ opponent = board_after.turn
205
+ for sq in hanging_before:
206
+ if _is_hanging(board_after, sq, our_color):
207
+ return True # Still hanging after our move — deliberate sacrifice
208
+
209
+ return False
210
+
211
+ # ── Move classification ────────────────────────────────────────────────────────
212
+
213
+ COMMENTS = {
214
+ "Book": [
215
+ "A well-known opening move.",
216
+ "Theory — this position has been played thousands of times.",
217
+ "A mainline opening move.",
218
+ ],
219
+ "Brilliant": [
220
+ "A stunning sacrifice that seizes a lasting advantage.",
221
+ "Extraordinary piece sacrifice — the position rewards bold play.",
222
+ "An unexpected sacrifice with deep positional compensation.",
223
+ ],
224
+ "Great": [
225
+ "A critical move that shifts the balance of the game.",
226
+ "Finding this move takes real precision — it dramatically improves the position.",
227
+ "The only move that keeps things in hand. Well found.",
228
+ ],
229
+ "Miss": [
230
+ "A missed opportunity — the opponent's error went unpunished.",
231
+ "After the opponent's mistake, this fails to press the advantage.",
232
+ "The position offered a winning shot, but it slipped away here.",
233
+ ],
234
+ "Best": [
235
+ "The engine's top choice. Precise and principled.",
236
+ "Perfect play — the ideal move in this position.",
237
+ "Exactly what the position demanded.",
238
+ ],
239
+ "Excellent": [
240
+ "A very strong move that keeps the position under control.",
241
+ "Nearly best — a fine practical choice.",
242
+ "A sharp, high-quality response.",
243
+ ],
244
+ "Good": [
245
+ "A solid move that maintains the balance.",
246
+ "Reasonable play — the position stays roughly equal.",
247
+ "A sensible continuation with no serious drawbacks.",
248
+ ],
249
+ "Inaccuracy": [
250
+ "A slight imprecision — a better option was available.",
251
+ "Not a serious error, but leaves something on the table.",
252
+ "The position allowed for more here.",
253
+ ],
254
+ "Mistake": [
255
+ "An error that hands the opponent a meaningful advantage.",
256
+ "This weakens the position more than it needed to.",
257
+ "A significant misstep — the opponent can now press hard.",
258
+ ],
259
+ "Blunder": [
260
+ "A serious blunder that could cost the game.",
261
+ "A major error — this dramatically changes the evaluation.",
262
+ "Devastating. The position collapses after this move.",
263
+ ],
264
+ }
265
+
266
+ def _net_exchange(board: 'chess.Board', move: chess.Move, our_color: chess.Color) -> int:
267
+ """
268
+ Net material result of this move for our_color, accounting for the full
269
+ exchange sequence via SEE. Unlike a raw board snapshot, this correctly
270
+ handles trades and recaptures.
271
+
272
+ Returns:
273
+ positive — we come out ahead in material (e.g. win-back tactical combo)
274
+ zero — even exchange (true trade)
275
+ negative — we lose material net (positional / speculative sacrifice)
276
+
277
+ Brilliant gate uses this to distinguish:
278
+ gain <= 0 → real sacrifice for compensation → Brilliant candidate
279
+ 1 <= gain <= 2 → "cheap" win of a pawn or two → downgrade to Great
280
+ gain >= 3 → significant material-winning combo → Brilliant candidate
281
+ """
282
+ piece = board.piece_at(move.from_square)
283
+ if piece is None:
284
+ return 0
285
+ piece_val = PIECE_VALUES.get(piece.piece_type, 0)
286
+
287
+ # What we capture immediately, if anything
288
+ captured = board.piece_at(move.to_square)
289
+ captured_val = PIECE_VALUES.get(captured.piece_type, 0) if captured else 0
290
+
291
+ board_after = board.copy()
292
+ board_after.push(move)
293
+ opponent = not our_color
294
+
295
+ # How much does opponent gain by recapturing our piece on to_square?
296
+ opp_gain_dest = max(0, _see(board_after, move.to_square, opponent, piece_val))
297
+
298
+ # Net from the primary exchange on the destination square
299
+ dest_net = captured_val - opp_gain_dest
300
+
301
+ # Also account for any piece newly left hanging (path-b sacrifice —
302
+ # e.g. walking a knight out of a pin, exposing the queen).
303
+ # We already know check_sacrifice flagged this, so find the newly hanging
304
+ # piece and subtract its SEE loss.
305
+ hanging_before: set = set()
306
+ for sq in (board.pieces(chess.QUEEN, our_color) |
307
+ board.pieces(chess.ROOK, our_color) |
308
+ board.pieces(chess.BISHOP, our_color) |
309
+ board.pieces(chess.KNIGHT, our_color)):
310
+ if sq == move.from_square:
311
+ continue
312
+ if _is_hanging(board, sq, our_color):
313
+ hanging_before.add(sq)
314
+
315
+ newly_hanging_loss = 0
316
+ for sq in (board_after.pieces(chess.QUEEN, our_color) |
317
+ board_after.pieces(chess.ROOK, our_color) |
318
+ board_after.pieces(chess.BISHOP, our_color) |
319
+ board_after.pieces(chess.KNIGHT, our_color)):
320
+ if sq == move.to_square:
321
+ continue
322
+ if sq not in hanging_before and _is_hanging(board_after, sq, our_color):
323
+ p = board_after.piece_at(sq)
324
+ pv = PIECE_VALUES.get(p.piece_type, 0)
325
+ newly_hanging_loss += max(0, _see(board_after, sq, opponent, pv))
326
+
327
+ return dest_net - newly_hanging_loss
328
+
329
+
330
+ def get_comment(classification: str) -> str:
331
+ return random.choice(COMMENTS.get(classification, ["—"]))
332
+
333
+ def classify_move(
334
+ player_ep_before: float,
335
+ player_ep_after: float,
336
+ player_ep_second_best: float | None,
337
+ is_best: bool,
338
+ move_rank: int | None,
339
+ is_sacrifice: bool,
340
+ opponent_blunder_swing: float | None,
341
+ ep_before_opponent_blunder: float | None,
342
+ board_before: 'chess.Board | None' = None,
343
+ board_after: 'chess.Board | None' = None,
344
+ our_color: 'chess.Color | None' = None,
345
+ move: 'chess.Move | None' = None,
346
+ ) -> str:
347
+ ep_loss = player_ep_before - player_ep_after
348
+
349
+ # ── Brilliant ──────────────────────────────────────────────────────────────
350
+ # Best move + piece sacrifice + lands in a good position + significant material swing.
351
+ # Two paths to Brilliant:
352
+ # 1. Normal: wasn't already near-completely-winning (< 0.97).
353
+ # 2. Was already winning but the sacrifice dramatically improves the position
354
+ # (ep jump >= 0.15) -- e.g. forcing bishop sac into a mating attack.
355
+ # Downgrade to Great if the material net gain is <= 2 pawns: a tiny material
356
+ # pickup doesn't justify Brilliant even if every other condition is met.
357
+ if is_best and is_sacrifice:
358
+ ep_jump = player_ep_after - player_ep_before
359
+ already_winning = player_ep_before >= 0.97
360
+ lands_well = player_ep_after >= 0.45 # equal or better after sac is fine
361
+ if lands_well and (not already_winning or ep_jump >= 0.15):
362
+ # Material gate:
363
+ # net < 0 → real material sacrifice (gives up more than gains) → Brilliant
364
+ # net 0..2 → equal trade or trivial pickup (e.g. queen swap, +1 pawn) → Great
365
+ # net >= 3 → significant material-winning combination → Brilliant
366
+ # Trades (net=0) and small wins are explicitly excluded from Brilliant.
367
+ if board_before is not None and our_color is not None and move is not None:
368
+ net = _net_exchange(board_before, move, our_color)
369
+ if 0 <= net <= 2:
370
+ return "Great"
371
+ return "Brilliant"
372
+
373
+ # ── Great ──────────────────────────────────────────────────────────────────
374
+ if is_best:
375
+ was_losing = player_ep_before < 0.45
376
+ is_equal = 0.44 <= player_ep_after <= 0.62
377
+ is_winning = player_ep_after > 0.55
378
+ major_swing = was_losing and (is_equal or is_winning)
379
+
380
+ only_good = (
381
+ player_ep_second_best is not None
382
+ and player_ep_before - player_ep_second_best > 0.15
383
+ )
384
+
385
+ if major_swing or only_good:
386
+ return "Great"
387
+
388
+ # ── Miss ───────────────────────────────────────────────────────────────────
389
+ if (
390
+ opponent_blunder_swing is not None
391
+ and opponent_blunder_swing > 0.10
392
+ and ep_before_opponent_blunder is not None
393
+ and player_ep_after <= ep_before_opponent_blunder + 0.02
394
+ ):
395
+ return "Miss"
396
+
397
+ # ── EP table ───────────────────────────────────────────────────────────────
398
+ ep_loss = max(0.0, ep_loss) # cap negatives (position improved beyond eval)
399
+
400
+ # is_best is checked first — float rounding can produce a tiny non-zero
401
+ # ep_loss even for the engine's top move, so we must not let the threshold
402
+ # bands override a confirmed best-move result.
403
+ if is_best:
404
+ return "Best"
405
+ if ep_loss <= 0.02:
406
+ return "Excellent"
407
+ if ep_loss <= 0.05:
408
+ return "Good"
409
+ if ep_loss <= 0.10:
410
+ return "Inaccuracy"
411
+ if ep_loss <= 0.20:
412
+ return "Mistake"
413
+ return "Blunder"
414
+
415
+ # ── Continuation formatting ────────────────────────────────────────────────────
416
+
417
+ def format_continuation(moves_san: list[str], move_number: int, player_color: str) -> str:
418
+ """Format a list of SAN continuation moves with proper move numbers.
419
+
420
+ After white plays move N → continuation starts with black at N, then white at N+1
421
+ After black plays move N → continuation starts with white at N+1
422
+ """
423
+ if not moves_san:
424
+ return ""
425
+
426
+ parts: list[str] = []
427
+
428
+ if player_color == "white":
429
+ # Next is black's response at the same move number
430
+ next_is_white = False
431
+ num = move_number
432
+ else:
433
+ # Next is white's move, which is move_number + 1
434
+ next_is_white = True
435
+ num = move_number + 1
436
+
437
+ for i, san in enumerate(moves_san):
438
+ if next_is_white:
439
+ parts.append(f"{num}.")
440
+ parts.append(san)
441
+ next_is_white = False
442
+ else:
443
+ if i == 0:
444
+ # First black continuation move: needs the "N..." prefix
445
+ parts.append(f"{num}...")
446
+ parts.append(san)
447
+ next_is_white = True
448
+ num += 1 # After black plays, white's next turn is N+1
449
+
450
+ return " ".join(parts)
451
+
452
+ # ── Main analysis entry point ──────────────────────────────────────────────────
453
+
454
+ def analyze_game(pgn_text: str, depth: int, progress_cb):
455
+ """
456
+ Analyze a full PGN game. Calls progress_cb({type, message, progress, [data]}).
457
+ """
458
+ pgn_io = io.StringIO(pgn_text)
459
+ game = chess.pgn.read_game(pgn_io)
460
+
461
+ if game is None:
462
+ progress_cb({"type": "error", "message": "Could not parse PGN. Please check the notation."})
463
+ return
464
+
465
+ white = game.headers.get("White", "White")
466
+ black = game.headers.get("Black", "Black")
467
+
468
+ moves_list = list(game.mainline_moves())
469
+ total = len(moves_list)
470
+
471
+ if total == 0:
472
+ progress_cb({"type": "error", "message": "The PGN contains no moves."})
473
+ return
474
+
475
+ progress_cb({"type": "progress", "message": "Initializing Stockfish engine…", "progress": 0.0})
476
+
477
+ sf_path = _find_stockfish()
478
+ try:
479
+ engine = chess.engine.SimpleEngine.popen_uci(sf_path)
480
+ except FileNotFoundError:
481
+ progress_cb({"type": "error",
482
+ "message": f"Stockfish not found (tried '{sf_path}'). Install Stockfish and ensure it is on your PATH."})
483
+ return
484
+
485
+ try:
486
+ # Build board snapshots: boards[i] is the state BEFORE move i
487
+ boards: list[chess.Board] = []
488
+ b = game.board()
489
+ for mv in moves_list:
490
+ boards.append(b.copy())
491
+ b.push(mv)
492
+ boards.append(b.copy()) # final position after all moves
493
+
494
+ # Analyse every position with MultiPV=5
495
+ multipv: list[list[dict]] = []
496
+ ep_white: list[float] = []
497
+
498
+ for i, board_snap in enumerate(boards):
499
+ if i < total:
500
+ msg = f"Analyzing move {i + 1} of {total}…"
501
+ prog = i / total * 0.90
502
+ else:
503
+ msg = "Finalizing analysis…"
504
+ prog = 0.93
505
+
506
+ progress_cb({"type": "progress", "message": msg, "progress": prog})
507
+
508
+ infos = engine.analyse(board_snap, chess.engine.Limit(depth=depth), multipv=5)
509
+ multipv.append(infos)
510
+ ep_white.append(get_ep_white(infos[0]))
511
+
512
+ # Classify each move
513
+ progress_cb({"type": "progress", "message": "Classifying moves…", "progress": 0.96})
514
+
515
+ results = []
516
+
517
+ for i, move in enumerate(moves_list):
518
+ board_snap = boards[i]
519
+ turn = board_snap.turn
520
+ color = "white" if turn == chess.WHITE else "black"
521
+
522
+ move_san = board_snap.san(move)
523
+ move_uci = move.uci()
524
+ fen_before = board_snap.fen()
525
+ fen_after = boards[i + 1].fen()
526
+
527
+ ep_w_before = ep_white[i]
528
+ ep_w_after = ep_white[i + 1]
529
+
530
+ if turn == chess.WHITE:
531
+ player_ep_before = ep_w_before
532
+ player_ep_after = ep_w_after
533
+ else:
534
+ player_ep_before = 1.0 - ep_w_before
535
+ player_ep_after = 1.0 - ep_w_after
536
+
537
+ # Best move
538
+ best_move_obj = multipv[i][0]["pv"][0]
539
+ best_move_san = board_snap.san(best_move_obj)
540
+ best_move_uci = best_move_obj.uci()
541
+ is_best = (move.uci() == best_move_uci)
542
+
543
+ # Second-best EP (for "only good move" detection)
544
+ player_ep_second_best: float | None = None
545
+ if len(multipv[i]) > 1:
546
+ ep_sb_w = get_ep_white(multipv[i][1])
547
+ player_ep_second_best = ep_sb_w if turn == chess.WHITE else 1.0 - ep_sb_w
548
+
549
+ # Rank among top-5
550
+ move_rank: int | None = None
551
+ for rank, info in enumerate(multipv[i], 1):
552
+ if info["pv"][0].uci() == move.uci():
553
+ move_rank = rank
554
+ break
555
+
556
+ # Sacrifice?
557
+ is_sacrifice = check_sacrifice(board_snap, move)
558
+
559
+ # Miss detection
560
+ opponent_blunder_swing: float | None = None
561
+ ep_before_opponent_blunder: float | None = None
562
+
563
+ if i >= 1:
564
+ ep_w_prev = ep_white[i - 1]
565
+ if turn == chess.WHITE:
566
+ swing = ep_w_before - ep_w_prev
567
+ pre_blunder = ep_w_prev
568
+ else:
569
+ swing = (1.0 - ep_w_before) - (1.0 - ep_w_prev)
570
+ pre_blunder = 1.0 - ep_w_prev
571
+
572
+ if swing > 0.10:
573
+ opponent_blunder_swing = swing
574
+ ep_before_opponent_blunder = pre_blunder
575
+
576
+ # Book move check — if the resulting position is in the ECO db,
577
+ # classify immediately regardless of EP; opening theory trumps evaluation.
578
+ book_entry = lookup_book(fen_after)
579
+ if book_entry:
580
+ classification = "Book"
581
+ opening_name = book_entry.get("name", "")
582
+ opening_eco = book_entry.get("eco", "")
583
+ else:
584
+ opening_name = ""
585
+ opening_eco = ""
586
+ classification = classify_move(
587
+ player_ep_before=player_ep_before,
588
+ player_ep_after=player_ep_after,
589
+ player_ep_second_best=player_ep_second_best,
590
+ is_best=is_best,
591
+ move_rank=move_rank,
592
+ is_sacrifice=is_sacrifice,
593
+ opponent_blunder_swing=opponent_blunder_swing,
594
+ ep_before_opponent_blunder=ep_before_opponent_blunder,
595
+ board_before=board_snap,
596
+ board_after=boards[i + 1],
597
+ our_color=turn,
598
+ move=move,
599
+ )
600
+
601
+ # Continuation: engine's best line from the position after this move
602
+ continuation_san: list[str] = []
603
+ if multipv[i + 1] and "pv" in multipv[i + 1][0]:
604
+ temp = boards[i + 1].copy()
605
+ for cont_mv in multipv[i + 1][0]["pv"][:6]:
606
+ try:
607
+ continuation_san.append(temp.san(cont_mv))
608
+ temp.push(cont_mv)
609
+ except Exception:
610
+ break
611
+
612
+ ep_loss = max(0.0, player_ep_before - player_ep_after)
613
+
614
+ results.append({
615
+ "move_number": (i // 2) + 1,
616
+ "ply": i,
617
+ "color": color,
618
+ "san": move_san,
619
+ "uci": move_uci,
620
+ "from_square": chess.square_name(move.from_square),
621
+ "to_square": chess.square_name(move.to_square),
622
+ "classification": classification,
623
+ "ep_loss": round(ep_loss, 4),
624
+ "ep_before": round(player_ep_before, 4),
625
+ "ep_after": round(player_ep_after, 4),
626
+ "best_move_san": best_move_san if not is_best else None,
627
+ "best_move_uci": best_move_uci if not is_best else None,
628
+ "continuation": continuation_san,
629
+ "continuation_fmt": format_continuation(continuation_san,
630
+ (i // 2) + 1, color),
631
+ "fen_before": fen_before,
632
+ "fen_after": fen_after,
633
+ "is_best": is_best,
634
+ "comment": get_comment(classification),
635
+ "opening_name": opening_name,
636
+ "opening_eco": opening_eco,
637
+ })
638
+
639
+ progress_cb({
640
+ "type": "complete",
641
+ "message": "Analysis complete!",
642
+ "progress": 1.0,
643
+ "data": {
644
+ "white": white,
645
+ "black": black,
646
+ "initial_fen": game.board().fen(),
647
+ "moves": results,
648
+ "summary": _compute_summary(results),
649
+ }
650
+ })
651
+
652
+ finally:
653
+ engine.quit()
654
+
655
+
656
+ # ── Summary stats ──────────────────────────────────────────────────────────────
657
+
658
+ ALL_CLASSIFICATIONS = [
659
+ "Book", "Brilliant", "Great", "Best", "Excellent", "Good",
660
+ "Inaccuracy", "Mistake", "Blunder", "Miss",
661
+ ]
662
+
663
+ def _compute_summary(moves: list[dict]) -> dict:
664
+ """Return per-player classification counts and accuracy."""
665
+ stats = {}
666
+ for color in ("white", "black"):
667
+ player_moves = [m for m in moves if m["color"] == color]
668
+
669
+ counts = {cls: 0 for cls in ALL_CLASSIFICATIONS}
670
+ for m in player_moves:
671
+ cls = m["classification"]
672
+ if cls in counts:
673
+ counts[cls] += 1
674
+
675
+ # Accuracy: average fraction of winning chances preserved each move.
676
+ # For each move: score = ep_after / max(ep_before, 0.01), clamped 0-1.
677
+ # Gives 100% for Best/Brilliant, degrades proportionally with EP loss.
678
+ if player_moves:
679
+ scores = [
680
+ max(0.0, min(1.0, m["ep_after"] / max(m["ep_before"], 0.01)))
681
+ for m in player_moves
682
+ ]
683
+ accuracy = round(sum(scores) / len(scores) * 100, 1)
684
+ else:
685
+ accuracy = 0.0
686
+
687
+ stats[color] = {"accuracy": accuracy, "counts": counts}
688
+
689
+ return stats