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

Delete analyzer.py

Browse files
Files changed (1) hide show
  1. analyzer.py +0 -679
analyzer.py DELETED
@@ -1,679 +0,0 @@
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
- return False
200
-
201
- # ── Move classification ────────────────────────────────────────────────────────
202
-
203
- COMMENTS = {
204
- "Book": [
205
- "A well-known opening move.",
206
- "Theory — this position has been played thousands of times.",
207
- "A mainline opening move.",
208
- ],
209
- "Brilliant": [
210
- "A stunning sacrifice that seizes a lasting advantage.",
211
- "Extraordinary piece sacrifice — the position rewards bold play.",
212
- "An unexpected sacrifice with deep positional compensation.",
213
- ],
214
- "Great": [
215
- "A critical move that shifts the balance of the game.",
216
- "Finding this move takes real precision — it dramatically improves the position.",
217
- "The only move that keeps things in hand. Well found.",
218
- ],
219
- "Miss": [
220
- "A missed opportunity — the opponent's error went unpunished.",
221
- "After the opponent's mistake, this fails to press the advantage.",
222
- "The position offered a winning shot, but it slipped away here.",
223
- ],
224
- "Best": [
225
- "The engine's top choice. Precise and principled.",
226
- "Perfect play — the ideal move in this position.",
227
- "Exactly what the position demanded.",
228
- ],
229
- "Excellent": [
230
- "A very strong move that keeps the position under control.",
231
- "Nearly best — a fine practical choice.",
232
- "A sharp, high-quality response.",
233
- ],
234
- "Good": [
235
- "A solid move that maintains the balance.",
236
- "Reasonable play — the position stays roughly equal.",
237
- "A sensible continuation with no serious drawbacks.",
238
- ],
239
- "Inaccuracy": [
240
- "A slight imprecision — a better option was available.",
241
- "Not a serious error, but leaves something on the table.",
242
- "The position allowed for more here.",
243
- ],
244
- "Mistake": [
245
- "An error that hands the opponent a meaningful advantage.",
246
- "This weakens the position more than it needed to.",
247
- "A significant misstep — the opponent can now press hard.",
248
- ],
249
- "Blunder": [
250
- "A serious blunder that could cost the game.",
251
- "A major error — this dramatically changes the evaluation.",
252
- "Devastating. The position collapses after this move.",
253
- ],
254
- }
255
-
256
- def _net_exchange(board: 'chess.Board', move: chess.Move, our_color: chess.Color) -> int:
257
- """
258
- Net material result of this move for our_color, accounting for the full
259
- exchange sequence via SEE. Unlike a raw board snapshot, this correctly
260
- handles trades and recaptures.
261
-
262
- Returns:
263
- positive — we come out ahead in material (e.g. win-back tactical combo)
264
- zero — even exchange (true trade)
265
- negative — we lose material net (positional / speculative sacrifice)
266
-
267
- Brilliant gate uses this to distinguish:
268
- gain <= 0 → real sacrifice for compensation → Brilliant candidate
269
- 1 <= gain <= 2 → "cheap" win of a pawn or two → downgrade to Great
270
- gain >= 3 → significant material-winning combo → Brilliant candidate
271
- """
272
- piece = board.piece_at(move.from_square)
273
- if piece is None:
274
- return 0
275
- piece_val = PIECE_VALUES.get(piece.piece_type, 0)
276
-
277
- # What we capture immediately, if anything
278
- captured = board.piece_at(move.to_square)
279
- captured_val = PIECE_VALUES.get(captured.piece_type, 0) if captured else 0
280
-
281
- board_after = board.copy()
282
- board_after.push(move)
283
- opponent = not our_color
284
-
285
- # How much does opponent gain by recapturing our piece on to_square?
286
- opp_gain_dest = max(0, _see(board_after, move.to_square, opponent, piece_val))
287
-
288
- # Net from the primary exchange on the destination square
289
- dest_net = captured_val - opp_gain_dest
290
-
291
- # Also account for any piece newly left hanging (path-b sacrifice —
292
- # e.g. walking a knight out of a pin, exposing the queen).
293
- # We already know check_sacrifice flagged this, so find the newly hanging
294
- # piece and subtract its SEE loss.
295
- hanging_before: set = set()
296
- for sq in (board.pieces(chess.QUEEN, our_color) |
297
- board.pieces(chess.ROOK, our_color) |
298
- board.pieces(chess.BISHOP, our_color) |
299
- board.pieces(chess.KNIGHT, our_color)):
300
- if sq == move.from_square:
301
- continue
302
- if _is_hanging(board, sq, our_color):
303
- hanging_before.add(sq)
304
-
305
- newly_hanging_loss = 0
306
- for sq in (board_after.pieces(chess.QUEEN, our_color) |
307
- board_after.pieces(chess.ROOK, our_color) |
308
- board_after.pieces(chess.BISHOP, our_color) |
309
- board_after.pieces(chess.KNIGHT, our_color)):
310
- if sq == move.to_square:
311
- continue
312
- if sq not in hanging_before and _is_hanging(board_after, sq, our_color):
313
- p = board_after.piece_at(sq)
314
- pv = PIECE_VALUES.get(p.piece_type, 0)
315
- newly_hanging_loss += max(0, _see(board_after, sq, opponent, pv))
316
-
317
- return dest_net - newly_hanging_loss
318
-
319
-
320
- def get_comment(classification: str) -> str:
321
- return random.choice(COMMENTS.get(classification, ["—"]))
322
-
323
- def classify_move(
324
- player_ep_before: float,
325
- player_ep_after: float,
326
- player_ep_second_best: float | None,
327
- is_best: bool,
328
- move_rank: int | None,
329
- is_sacrifice: bool,
330
- opponent_blunder_swing: float | None,
331
- ep_before_opponent_blunder: float | None,
332
- board_before: 'chess.Board | None' = None,
333
- board_after: 'chess.Board | None' = None,
334
- our_color: 'chess.Color | None' = None,
335
- move: 'chess.Move | None' = None,
336
- ) -> str:
337
- ep_loss = player_ep_before - player_ep_after
338
-
339
- # ── Brilliant ──────────────────────────────────────────────────────────────
340
- # Best move + piece sacrifice + lands in a good position + significant material swing.
341
- # Two paths to Brilliant:
342
- # 1. Normal: wasn't already near-completely-winning (< 0.97).
343
- # 2. Was already winning but the sacrifice dramatically improves the position
344
- # (ep jump >= 0.15) -- e.g. forcing bishop sac into a mating attack.
345
- # Downgrade to Great if the material net gain is <= 2 pawns: a tiny material
346
- # pickup doesn't justify Brilliant even if every other condition is met.
347
- if is_best and is_sacrifice:
348
- ep_jump = player_ep_after - player_ep_before
349
- already_winning = player_ep_before >= 0.97
350
- lands_well = player_ep_after >= 0.45 # equal or better after sac is fine
351
- if lands_well and (not already_winning or ep_jump >= 0.15):
352
- # Material gate:
353
- # net < 0 → real material sacrifice (gives up more than gains) → Brilliant
354
- # net 0..2 → equal trade or trivial pickup (e.g. queen swap, +1 pawn) → Great
355
- # net >= 3 → significant material-winning combination → Brilliant
356
- # Trades (net=0) and small wins are explicitly excluded from Brilliant.
357
- if board_before is not None and our_color is not None and move is not None:
358
- net = _net_exchange(board_before, move, our_color)
359
- if 0 <= net <= 2:
360
- return "Great"
361
- return "Brilliant"
362
-
363
- # ── Great ──────────────────────────────────────────────────────────────────
364
- if is_best:
365
- was_losing = player_ep_before < 0.45
366
- is_equal = 0.44 <= player_ep_after <= 0.62
367
- is_winning = player_ep_after > 0.55
368
- major_swing = was_losing and (is_equal or is_winning)
369
-
370
- only_good = (
371
- player_ep_second_best is not None
372
- and player_ep_before - player_ep_second_best > 0.15
373
- )
374
-
375
- if major_swing or only_good:
376
- return "Great"
377
-
378
- # ── Miss ───────────────────────────────────────────────────────────────────
379
- if (
380
- opponent_blunder_swing is not None
381
- and opponent_blunder_swing > 0.10
382
- and ep_before_opponent_blunder is not None
383
- and player_ep_after <= ep_before_opponent_blunder + 0.02
384
- ):
385
- return "Miss"
386
-
387
- # ── EP table ───────────────────────────────────────────────────────────────
388
- ep_loss = max(0.0, ep_loss) # cap negatives (position improved beyond eval)
389
-
390
- # is_best is checked first — float rounding can produce a tiny non-zero
391
- # ep_loss even for the engine's top move, so we must not let the threshold
392
- # bands override a confirmed best-move result.
393
- if is_best:
394
- return "Best"
395
- if ep_loss <= 0.02:
396
- return "Excellent"
397
- if ep_loss <= 0.05:
398
- return "Good"
399
- if ep_loss <= 0.10:
400
- return "Inaccuracy"
401
- if ep_loss <= 0.20:
402
- return "Mistake"
403
- return "Blunder"
404
-
405
- # ── Continuation formatting ────────────────────────────────────────────────────
406
-
407
- def format_continuation(moves_san: list[str], move_number: int, player_color: str) -> str:
408
- """Format a list of SAN continuation moves with proper move numbers.
409
-
410
- After white plays move N → continuation starts with black at N, then white at N+1
411
- After black plays move N → continuation starts with white at N+1
412
- """
413
- if not moves_san:
414
- return ""
415
-
416
- parts: list[str] = []
417
-
418
- if player_color == "white":
419
- # Next is black's response at the same move number
420
- next_is_white = False
421
- num = move_number
422
- else:
423
- # Next is white's move, which is move_number + 1
424
- next_is_white = True
425
- num = move_number + 1
426
-
427
- for i, san in enumerate(moves_san):
428
- if next_is_white:
429
- parts.append(f"{num}.")
430
- parts.append(san)
431
- next_is_white = False
432
- else:
433
- if i == 0:
434
- # First black continuation move: needs the "N..." prefix
435
- parts.append(f"{num}...")
436
- parts.append(san)
437
- next_is_white = True
438
- num += 1 # After black plays, white's next turn is N+1
439
-
440
- return " ".join(parts)
441
-
442
- # ── Main analysis entry point ──────────────────────────────────────────────────
443
-
444
- def analyze_game(pgn_text: str, depth: int, progress_cb):
445
- """
446
- Analyze a full PGN game. Calls progress_cb({type, message, progress, [data]}).
447
- """
448
- pgn_io = io.StringIO(pgn_text)
449
- game = chess.pgn.read_game(pgn_io)
450
-
451
- if game is None:
452
- progress_cb({"type": "error", "message": "Could not parse PGN. Please check the notation."})
453
- return
454
-
455
- white = game.headers.get("White", "White")
456
- black = game.headers.get("Black", "Black")
457
-
458
- moves_list = list(game.mainline_moves())
459
- total = len(moves_list)
460
-
461
- if total == 0:
462
- progress_cb({"type": "error", "message": "The PGN contains no moves."})
463
- return
464
-
465
- progress_cb({"type": "progress", "message": "Initializing Stockfish engine…", "progress": 0.0})
466
-
467
- sf_path = _find_stockfish()
468
- try:
469
- engine = chess.engine.SimpleEngine.popen_uci(sf_path)
470
- except FileNotFoundError:
471
- progress_cb({"type": "error",
472
- "message": f"Stockfish not found (tried '{sf_path}'). Install Stockfish and ensure it is on your PATH."})
473
- return
474
-
475
- try:
476
- # Build board snapshots: boards[i] is the state BEFORE move i
477
- boards: list[chess.Board] = []
478
- b = game.board()
479
- for mv in moves_list:
480
- boards.append(b.copy())
481
- b.push(mv)
482
- boards.append(b.copy()) # final position after all moves
483
-
484
- # Analyse every position with MultiPV=5
485
- multipv: list[list[dict]] = []
486
- ep_white: list[float] = []
487
-
488
- for i, board_snap in enumerate(boards):
489
- if i < total:
490
- msg = f"Analyzing move {i + 1} of {total}…"
491
- prog = i / total * 0.90
492
- else:
493
- msg = "Finalizing analysis…"
494
- prog = 0.93
495
-
496
- progress_cb({"type": "progress", "message": msg, "progress": prog})
497
-
498
- infos = engine.analyse(board_snap, chess.engine.Limit(depth=depth), multipv=5)
499
- multipv.append(infos)
500
- ep_white.append(get_ep_white(infos[0]))
501
-
502
- # Classify each move
503
- progress_cb({"type": "progress", "message": "Classifying moves…", "progress": 0.96})
504
-
505
- results = []
506
-
507
- for i, move in enumerate(moves_list):
508
- board_snap = boards[i]
509
- turn = board_snap.turn
510
- color = "white" if turn == chess.WHITE else "black"
511
-
512
- move_san = board_snap.san(move)
513
- move_uci = move.uci()
514
- fen_before = board_snap.fen()
515
- fen_after = boards[i + 1].fen()
516
-
517
- ep_w_before = ep_white[i]
518
- ep_w_after = ep_white[i + 1]
519
-
520
- if turn == chess.WHITE:
521
- player_ep_before = ep_w_before
522
- player_ep_after = ep_w_after
523
- else:
524
- player_ep_before = 1.0 - ep_w_before
525
- player_ep_after = 1.0 - ep_w_after
526
-
527
- # Best move
528
- best_move_obj = multipv[i][0]["pv"][0]
529
- best_move_san = board_snap.san(best_move_obj)
530
- best_move_uci = best_move_obj.uci()
531
- is_best = (move.uci() == best_move_uci)
532
-
533
- # Second-best EP (for "only good move" detection)
534
- player_ep_second_best: float | None = None
535
- if len(multipv[i]) > 1:
536
- ep_sb_w = get_ep_white(multipv[i][1])
537
- player_ep_second_best = ep_sb_w if turn == chess.WHITE else 1.0 - ep_sb_w
538
-
539
- # Rank among top-5
540
- move_rank: int | None = None
541
- for rank, info in enumerate(multipv[i], 1):
542
- if info["pv"][0].uci() == move.uci():
543
- move_rank = rank
544
- break
545
-
546
- # Sacrifice?
547
- is_sacrifice = check_sacrifice(board_snap, move)
548
-
549
- # Miss detection
550
- opponent_blunder_swing: float | None = None
551
- ep_before_opponent_blunder: float | None = None
552
-
553
- if i >= 1:
554
- ep_w_prev = ep_white[i - 1]
555
- if turn == chess.WHITE:
556
- swing = ep_w_before - ep_w_prev
557
- pre_blunder = ep_w_prev
558
- else:
559
- swing = (1.0 - ep_w_before) - (1.0 - ep_w_prev)
560
- pre_blunder = 1.0 - ep_w_prev
561
-
562
- if swing > 0.10:
563
- opponent_blunder_swing = swing
564
- ep_before_opponent_blunder = pre_blunder
565
-
566
- # Book move check — if the resulting position is in the ECO db,
567
- # classify immediately regardless of EP; opening theory trumps evaluation.
568
- book_entry = lookup_book(fen_after)
569
- if book_entry:
570
- classification = "Book"
571
- opening_name = book_entry.get("name", "")
572
- opening_eco = book_entry.get("eco", "")
573
- else:
574
- opening_name = ""
575
- opening_eco = ""
576
- classification = classify_move(
577
- player_ep_before=player_ep_before,
578
- player_ep_after=player_ep_after,
579
- player_ep_second_best=player_ep_second_best,
580
- is_best=is_best,
581
- move_rank=move_rank,
582
- is_sacrifice=is_sacrifice,
583
- opponent_blunder_swing=opponent_blunder_swing,
584
- ep_before_opponent_blunder=ep_before_opponent_blunder,
585
- board_before=board_snap,
586
- board_after=boards[i + 1],
587
- our_color=turn,
588
- move=move,
589
- )
590
-
591
- # Continuation: engine's best line from the position after this move
592
- continuation_san: list[str] = []
593
- if multipv[i + 1] and "pv" in multipv[i + 1][0]:
594
- temp = boards[i + 1].copy()
595
- for cont_mv in multipv[i + 1][0]["pv"][:6]:
596
- try:
597
- continuation_san.append(temp.san(cont_mv))
598
- temp.push(cont_mv)
599
- except Exception:
600
- break
601
-
602
- ep_loss = max(0.0, player_ep_before - player_ep_after)
603
-
604
- results.append({
605
- "move_number": (i // 2) + 1,
606
- "ply": i,
607
- "color": color,
608
- "san": move_san,
609
- "uci": move_uci,
610
- "from_square": chess.square_name(move.from_square),
611
- "to_square": chess.square_name(move.to_square),
612
- "classification": classification,
613
- "ep_loss": round(ep_loss, 4),
614
- "ep_before": round(player_ep_before, 4),
615
- "ep_after": round(player_ep_after, 4),
616
- "best_move_san": best_move_san if not is_best else None,
617
- "best_move_uci": best_move_uci if not is_best else None,
618
- "continuation": continuation_san,
619
- "continuation_fmt": format_continuation(continuation_san,
620
- (i // 2) + 1, color),
621
- "fen_before": fen_before,
622
- "fen_after": fen_after,
623
- "is_best": is_best,
624
- "comment": get_comment(classification),
625
- "opening_name": opening_name,
626
- "opening_eco": opening_eco,
627
- })
628
-
629
- progress_cb({
630
- "type": "complete",
631
- "message": "Analysis complete!",
632
- "progress": 1.0,
633
- "data": {
634
- "white": white,
635
- "black": black,
636
- "initial_fen": game.board().fen(),
637
- "moves": results,
638
- "summary": _compute_summary(results),
639
- }
640
- })
641
-
642
- finally:
643
- engine.quit()
644
-
645
-
646
- # ── Summary stats ──────────────────────────────────────────────────────────────
647
-
648
- ALL_CLASSIFICATIONS = [
649
- "Book", "Brilliant", "Great", "Best", "Excellent", "Good",
650
- "Inaccuracy", "Mistake", "Blunder", "Miss",
651
- ]
652
-
653
- def _compute_summary(moves: list[dict]) -> dict:
654
- """Return per-player classification counts and accuracy."""
655
- stats = {}
656
- for color in ("white", "black"):
657
- player_moves = [m for m in moves if m["color"] == color]
658
-
659
- counts = {cls: 0 for cls in ALL_CLASSIFICATIONS}
660
- for m in player_moves:
661
- cls = m["classification"]
662
- if cls in counts:
663
- counts[cls] += 1
664
-
665
- # Accuracy: average fraction of winning chances preserved each move.
666
- # For each move: score = ep_after / max(ep_before, 0.01), clamped 0-1.
667
- # Gives 100% for Best/Brilliant, degrades proportionally with EP loss.
668
- if player_moves:
669
- scores = [
670
- max(0.0, min(1.0, m["ep_after"] / max(m["ep_before"], 0.01)))
671
- for m in player_moves
672
- ]
673
- accuracy = round(sum(scores) / len(scores) * 100, 1)
674
- else:
675
- accuracy = 0.0
676
-
677
- stats[color] = {"accuracy": accuracy, "counts": counts}
678
-
679
- return stats