ankigammon 1.0.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. ankigammon/__init__.py +7 -0
  2. ankigammon/__main__.py +6 -0
  3. ankigammon/analysis/__init__.py +13 -0
  4. ankigammon/analysis/score_matrix.py +391 -0
  5. ankigammon/anki/__init__.py +6 -0
  6. ankigammon/anki/ankiconnect.py +216 -0
  7. ankigammon/anki/apkg_exporter.py +111 -0
  8. ankigammon/anki/card_generator.py +1325 -0
  9. ankigammon/anki/card_styles.py +1054 -0
  10. ankigammon/gui/__init__.py +8 -0
  11. ankigammon/gui/app.py +192 -0
  12. ankigammon/gui/dialogs/__init__.py +10 -0
  13. ankigammon/gui/dialogs/export_dialog.py +594 -0
  14. ankigammon/gui/dialogs/import_options_dialog.py +201 -0
  15. ankigammon/gui/dialogs/input_dialog.py +762 -0
  16. ankigammon/gui/dialogs/note_dialog.py +93 -0
  17. ankigammon/gui/dialogs/settings_dialog.py +420 -0
  18. ankigammon/gui/dialogs/update_dialog.py +373 -0
  19. ankigammon/gui/format_detector.py +377 -0
  20. ankigammon/gui/main_window.py +1611 -0
  21. ankigammon/gui/resources/down-arrow.svg +3 -0
  22. ankigammon/gui/resources/icon.icns +0 -0
  23. ankigammon/gui/resources/icon.ico +0 -0
  24. ankigammon/gui/resources/icon.png +0 -0
  25. ankigammon/gui/resources/style.qss +402 -0
  26. ankigammon/gui/resources.py +26 -0
  27. ankigammon/gui/update_checker.py +259 -0
  28. ankigammon/gui/widgets/__init__.py +8 -0
  29. ankigammon/gui/widgets/position_list.py +166 -0
  30. ankigammon/gui/widgets/smart_input.py +268 -0
  31. ankigammon/models.py +356 -0
  32. ankigammon/parsers/__init__.py +7 -0
  33. ankigammon/parsers/gnubg_match_parser.py +1094 -0
  34. ankigammon/parsers/gnubg_parser.py +468 -0
  35. ankigammon/parsers/sgf_parser.py +290 -0
  36. ankigammon/parsers/xg_binary_parser.py +1097 -0
  37. ankigammon/parsers/xg_text_parser.py +688 -0
  38. ankigammon/renderer/__init__.py +5 -0
  39. ankigammon/renderer/animation_controller.py +391 -0
  40. ankigammon/renderer/animation_helper.py +191 -0
  41. ankigammon/renderer/color_schemes.py +145 -0
  42. ankigammon/renderer/svg_board_renderer.py +791 -0
  43. ankigammon/settings.py +315 -0
  44. ankigammon/thirdparty/__init__.py +7 -0
  45. ankigammon/thirdparty/xgdatatools/__init__.py +17 -0
  46. ankigammon/thirdparty/xgdatatools/xgimport.py +160 -0
  47. ankigammon/thirdparty/xgdatatools/xgstruct.py +1032 -0
  48. ankigammon/thirdparty/xgdatatools/xgutils.py +118 -0
  49. ankigammon/thirdparty/xgdatatools/xgzarc.py +260 -0
  50. ankigammon/utils/__init__.py +13 -0
  51. ankigammon/utils/gnubg_analyzer.py +590 -0
  52. ankigammon/utils/gnuid.py +577 -0
  53. ankigammon/utils/move_parser.py +204 -0
  54. ankigammon/utils/ogid.py +326 -0
  55. ankigammon/utils/xgid.py +387 -0
  56. ankigammon-1.0.6.dist-info/METADATA +352 -0
  57. ankigammon-1.0.6.dist-info/RECORD +61 -0
  58. ankigammon-1.0.6.dist-info/WHEEL +5 -0
  59. ankigammon-1.0.6.dist-info/entry_points.txt +2 -0
  60. ankigammon-1.0.6.dist-info/licenses/LICENSE +21 -0
  61. ankigammon-1.0.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1097 @@
1
+ """
2
+ Parser for eXtreme Gammon binary (.xg) files.
3
+
4
+ This parser wraps the xgdatatools library to convert XG binary format
5
+ into AnkiGammon's Decision objects.
6
+ """
7
+
8
+ import logging
9
+ from pathlib import Path
10
+ from typing import List, Optional, Tuple
11
+
12
+ from ankigammon.models import (
13
+ Decision,
14
+ Move,
15
+ Position,
16
+ Player,
17
+ CubeState,
18
+ DecisionType
19
+ )
20
+
21
+ # Import xgdatatools modules from thirdparty
22
+ from ankigammon.thirdparty.xgdatatools import xgimport
23
+ from ankigammon.thirdparty.xgdatatools import xgstruct
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class ParseError(Exception):
29
+ """Custom exception for parsing failures"""
30
+ pass
31
+
32
+
33
+ class XGBinaryParser:
34
+ """Parser for eXtreme Gammon binary (.xg) files"""
35
+
36
+ @staticmethod
37
+ def extract_player_names(file_path: str) -> Tuple[Optional[str], Optional[str]]:
38
+ """
39
+ Extract player names from .xg binary file.
40
+
41
+ Args:
42
+ file_path: Path to .xg file
43
+
44
+ Returns:
45
+ Tuple[Optional[str], Optional[str]]: (player1_name, player2_name)
46
+ Returns (None, None) if names cannot be extracted.
47
+ """
48
+ path = Path(file_path)
49
+ if not path.exists():
50
+ return (None, None)
51
+
52
+ try:
53
+ xg_import = xgimport.Import(str(path))
54
+
55
+ # Look for the first HeaderMatchEntry to get player names
56
+ for segment in xg_import.getfilesegment():
57
+ if segment.type == xgimport.Import.Segment.XG_GAMEFILE:
58
+ segment.fd.seek(0)
59
+ record = xgstruct.GameFileRecord(version=-1).fromstream(segment.fd)
60
+
61
+ if isinstance(record, xgstruct.HeaderMatchEntry):
62
+ # Try to get player names (prefer Unicode over ANSI)
63
+ player1 = record.get('Player1') or record.get('SPlayer1')
64
+ player2 = record.get('Player2') or record.get('SPlayer2')
65
+
66
+ # Decode bytes if needed
67
+ if isinstance(player1, bytes):
68
+ player1 = player1.decode('utf-8', errors='ignore')
69
+ if isinstance(player2, bytes):
70
+ player2 = player2.decode('utf-8', errors='ignore')
71
+
72
+ logger.debug(f"Extracted player names: {player1} vs {player2}")
73
+ return (player1, player2)
74
+
75
+ # No header found
76
+ return (None, None)
77
+
78
+ except Exception as e:
79
+ logger.warning(f"Failed to extract player names from {file_path}: {e}")
80
+ return (None, None)
81
+
82
+ @staticmethod
83
+ def parse_file(file_path: str) -> List[Decision]:
84
+ """
85
+ Parse .xg binary file.
86
+
87
+ Args:
88
+ file_path: Path to .xg file
89
+
90
+ Returns:
91
+ List[Decision]: Parsed decisions
92
+
93
+ Raises:
94
+ FileNotFoundError: File not found
95
+ ValueError: Invalid .xg format
96
+ ParseError: Parsing failed
97
+ """
98
+ path = Path(file_path)
99
+ if not path.exists():
100
+ raise FileNotFoundError(f"File not found: {file_path}")
101
+
102
+ logger.info(f"Parsing XG binary file: {file_path}")
103
+
104
+ try:
105
+ # Use xgimport to read the .xg file
106
+ xg_import = xgimport.Import(str(path))
107
+ decisions = []
108
+
109
+ # Track game state across records
110
+ file_version = -1
111
+ match_length = 0
112
+ score_x = 0
113
+ score_o = 0
114
+ crawford = False
115
+
116
+ # Process file segments
117
+ for segment in xg_import.getfilesegment():
118
+ if segment.type == xgimport.Import.Segment.XG_GAMEFILE:
119
+ # Parse game file segment
120
+ segment.fd.seek(0)
121
+
122
+ while True:
123
+ record = xgstruct.GameFileRecord(version=file_version).fromstream(segment.fd)
124
+ if record is None:
125
+ break
126
+
127
+ # Process different record types
128
+ if isinstance(record, xgstruct.HeaderMatchEntry):
129
+ file_version = record.Version
130
+ match_length = record.MatchLength
131
+ logger.debug(f"Match header: version={file_version}, match_length={match_length}")
132
+
133
+ elif isinstance(record, xgstruct.HeaderGameEntry):
134
+ # XG binary stores scores from Player 1's perspective
135
+ # Scores are swapped during position flip when Player 2 is on roll
136
+ score_x = record.Score1
137
+ score_o = record.Score2
138
+ crawford = bool(record.CrawfordApply)
139
+ logger.debug(f"Game header: score={score_x}-{score_o}, crawford={crawford}")
140
+
141
+ elif isinstance(record, xgstruct.MoveEntry):
142
+ try:
143
+ decision = XGBinaryParser._parse_move_entry(
144
+ record, match_length, score_x, score_o, crawford
145
+ )
146
+ if decision:
147
+ decisions.append(decision)
148
+ except Exception as e:
149
+ logger.warning(f"Failed to parse move entry: {e}")
150
+
151
+ elif isinstance(record, xgstruct.CubeEntry):
152
+ try:
153
+ decision = XGBinaryParser._parse_cube_entry(
154
+ record, match_length, score_x, score_o, crawford
155
+ )
156
+ if decision:
157
+ decisions.append(decision)
158
+ except Exception as e:
159
+ logger.warning(f"Failed to parse cube entry: {e}")
160
+
161
+ if not decisions:
162
+ raise ParseError("No valid positions found in file")
163
+
164
+ logger.info(f"Successfully parsed {len(decisions)} decisions from {file_path}")
165
+ return decisions
166
+
167
+ except xgimport.Error as e:
168
+ raise ParseError(f"XG import error: {e}")
169
+ except Exception as e:
170
+ raise ParseError(f"Failed to parse .xg file: {e}")
171
+
172
+ @staticmethod
173
+ def _transform_position(raw_points: List[int], on_roll: Player) -> Position:
174
+ """
175
+ Transform XG binary position array to internal Position model.
176
+
177
+ XG binary format uses opposite sign convention from AnkiGammon:
178
+ - XG: Positive = O checkers, Negative = X checkers
179
+ - AnkiGammon: Positive = X checkers, Negative = O checkers
180
+
181
+ This method inverts all signs during the conversion. XG binary always stores
182
+ positions from O's (Player 1's) perspective. The caller is responsible for
183
+ flipping the position when it needs to be shown from X's perspective.
184
+
185
+ Args:
186
+ raw_points: Raw 26-element position array from XG binary
187
+ on_roll: Player who is on roll (currently unused, kept for compatibility)
188
+
189
+ Returns:
190
+ Position object with signs inverted (still from O's perspective)
191
+ """
192
+ position = Position()
193
+
194
+ # XG binary uses opposite sign convention - invert all signs
195
+ position.points = [-count for count in raw_points]
196
+
197
+ # Calculate borne-off checkers (each player starts with 15)
198
+ total_x = sum(count for count in position.points if count > 0)
199
+ total_o = sum(abs(count) for count in position.points if count < 0)
200
+
201
+ position.x_off = 15 - total_x
202
+ position.o_off = 15 - total_o
203
+
204
+ # Validate position
205
+ XGBinaryParser._validate_position(position)
206
+
207
+ return position
208
+
209
+ @staticmethod
210
+ def _validate_position(position: Position) -> None:
211
+ """
212
+ Validate position to catch inversions and corruption.
213
+
214
+ Args:
215
+ position: Position to validate
216
+
217
+ Raises:
218
+ ValueError: If position is invalid
219
+ """
220
+ # Count checkers
221
+ total_x = sum(count for count in position.points if count > 0)
222
+ total_o = sum(abs(count) for count in position.points if count < 0)
223
+
224
+ # Each player should have at most 15 checkers on board
225
+ if total_x > 15:
226
+ raise ValueError(f"Invalid position: X has {total_x} checkers on board (max 15)")
227
+ if total_o > 15:
228
+ raise ValueError(f"Invalid position: O has {total_o} checkers on board (max 15)")
229
+
230
+ # Total checkers (on board + borne off) should be exactly 15 per player
231
+ if total_x + position.x_off != 15:
232
+ raise ValueError(
233
+ f"Invalid position: X has {total_x} on board + {position.x_off} off = "
234
+ f"{total_x + position.x_off} (expected 15)"
235
+ )
236
+ if total_o + position.o_off != 15:
237
+ raise ValueError(
238
+ f"Invalid position: O has {total_o} on board + {position.o_off} off = "
239
+ f"{total_o + position.o_off} (expected 15)"
240
+ )
241
+
242
+ # Check bar constraints (should be <= 2 per player in normal positions)
243
+ x_bar = position.points[0]
244
+ o_bar = abs(position.points[25])
245
+ if x_bar > 15: # Relaxed constraint - theoretically up to 15
246
+ raise ValueError(f"Invalid position: X has {x_bar} checkers on bar")
247
+ if o_bar > 15:
248
+ raise ValueError(f"Invalid position: O has {o_bar} checkers on bar")
249
+
250
+ @staticmethod
251
+ def _parse_move_entry(
252
+ move_entry: xgstruct.MoveEntry,
253
+ match_length: int,
254
+ score_x: int,
255
+ score_o: int,
256
+ crawford: bool
257
+ ) -> Optional[Decision]:
258
+ """
259
+ Convert MoveEntry to Decision object.
260
+
261
+ Args:
262
+ move_entry: MoveEntry from xgstruct
263
+ match_length: Match length (0 for money game)
264
+ score_x: Player X score
265
+ score_o: Player O score
266
+ crawford: Crawford game flag
267
+
268
+ Returns:
269
+ Decision object or None if invalid
270
+ """
271
+ # Determine player on roll
272
+ # XG uses ActiveP: 1 or 2
273
+ # Map to AnkiGammon: Player.O (bottom) or Player.X (top)
274
+ on_roll = Player.O if move_entry.ActiveP == 1 else Player.X
275
+
276
+ # Create position from XG position array
277
+ # XG binary format ALWAYS stores positions from O's (Player 1's) perspective
278
+ # We need to flip to X's perspective when X is on roll
279
+ position = XGBinaryParser._transform_position(
280
+ list(move_entry.PositionI),
281
+ on_roll
282
+ )
283
+
284
+ # Flip position if X is on roll (since XG stores from O's perspective)
285
+ if on_roll == Player.X:
286
+ # Flip the position by reversing points and swapping signs
287
+ flipped_points = [0] * 26
288
+ flipped_points[0] = -position.points[25] # X's bar = O's bar (negated)
289
+ flipped_points[25] = -position.points[0] # O's bar = X's bar (negated)
290
+ for i in range(1, 25):
291
+ flipped_points[i] = -position.points[25 - i]
292
+ position.points = flipped_points
293
+ position.x_off, position.o_off = position.o_off, position.x_off
294
+ # Swap scores to match flipped perspective
295
+ score_x, score_o = score_o, score_x
296
+
297
+ # Get dice
298
+ dice = tuple(move_entry.Dice) if move_entry.Dice else None
299
+
300
+ # Parse cube state
301
+ # CubeA encoding: sign indicates owner, absolute value is log2 of cube value
302
+ # 0 = centered at 1, ±1 = owned at 2^1=2, ±2 = owned at 2^2=4, etc.
303
+ if move_entry.CubeA == 0:
304
+ cube_value = 1
305
+ cube_owner = CubeState.CENTERED
306
+ else:
307
+ cube_value = 2 ** abs(move_entry.CubeA)
308
+ # XG binary sign convention: Positive = XG Player 1, Negative = XG Player 2
309
+ # Mapping: XG Player 1 → Player.O, XG Player 2 → Player.X
310
+ if move_entry.CubeA > 0:
311
+ cube_owner = CubeState.O_OWNS # XG Player 1 owns
312
+ else:
313
+ cube_owner = CubeState.X_OWNS # XG Player 2 owns
314
+
315
+ # Swap cube owner if position was flipped
316
+ if on_roll == Player.X:
317
+ if cube_owner == CubeState.X_OWNS:
318
+ cube_owner = CubeState.O_OWNS
319
+ elif cube_owner == CubeState.O_OWNS:
320
+ cube_owner = CubeState.X_OWNS
321
+
322
+ # Parse candidate moves from analysis
323
+ moves = []
324
+ if hasattr(move_entry, 'DataMoves') and move_entry.DataMoves:
325
+ data_moves = move_entry.DataMoves
326
+ n_moves = min(move_entry.NMoveEval, data_moves.NMoves)
327
+
328
+ for i in range(n_moves):
329
+ # Parse move notation with compound move combination and hit detection
330
+ notation = XGBinaryParser._convert_move_notation(
331
+ data_moves.Moves[i],
332
+ position,
333
+ on_roll
334
+ )
335
+
336
+ # Get equity (7-element tuple from XG)
337
+ # XG Format: [Lose_BG, Lose_G, Lose_S, Win_S, Win_G, Win_BG, Equity]
338
+ # Indices: [0] [1] [2] [3] [4] [5] [6]
339
+ #
340
+ # These are cumulative probabilities:
341
+ # Lose_S (index 2) = Total losses (all types: normal + gammon + backgammon)
342
+ # Lose_G (index 1) = Gammon + backgammon losses (subset of Lose_S)
343
+ # Lose_BG (index 0) = Backgammon losses only (subset of Lose_G)
344
+ # Win_S (index 3) = Total wins (all types: normal + gammon + backgammon)
345
+ # Win_G (index 4) = Gammon + backgammon wins (subset of Win_S)
346
+ # Win_BG (index 5) = Backgammon wins only (subset of Win_G)
347
+ # Equity (index 6) = Overall equity value
348
+ #
349
+ # Note: Lose_S + Win_S = 1.0 (or very close to 1.0)
350
+ equity_tuple = data_moves.Eval[i]
351
+ equity = equity_tuple[6] # Overall equity at index 6
352
+
353
+ # Extract winning chances (convert from decimals to percentages)
354
+ # Store cumulative values as displayed by XG/GnuBG:
355
+ # "Player: 50.41% (G:15.40% B:2.03%)" means:
356
+ # 50.41% total wins, of which 15.40% are gammon or better,
357
+ # of which 2.03% are backgammon
358
+ opponent_win_pct = equity_tuple[2] * 100 # Total opponent wins (index 2 = Lose_S)
359
+ opponent_gammon_pct = equity_tuple[1] * 100 # Opp gammon+BG (index 1 = Lose_G)
360
+ opponent_backgammon_pct = equity_tuple[0] * 100 # Opp BG only (index 0 = Lose_BG)
361
+ player_win_pct = equity_tuple[3] * 100 # Total player wins (index 3 = Win_S)
362
+ player_gammon_pct = equity_tuple[4] * 100 # Player gammon+BG (index 4 = Win_G)
363
+ player_backgammon_pct = equity_tuple[5] * 100 # Player BG only (index 5 = Win_BG)
364
+
365
+ move = Move(
366
+ notation=notation,
367
+ equity=equity,
368
+ error=0.0, # Will be calculated based on best move
369
+ rank=i + 1, # Temporary rank
370
+ xg_rank=i + 1,
371
+ xg_error=0.0,
372
+ xg_notation=notation,
373
+ from_xg_analysis=True,
374
+ player_win_pct=player_win_pct,
375
+ player_gammon_pct=player_gammon_pct,
376
+ player_backgammon_pct=player_backgammon_pct,
377
+ opponent_win_pct=opponent_win_pct,
378
+ opponent_gammon_pct=opponent_gammon_pct,
379
+ opponent_backgammon_pct=opponent_backgammon_pct
380
+ )
381
+ moves.append(move)
382
+
383
+ # Mark which move was actually played
384
+ if hasattr(move_entry, 'Moves') and move_entry.Moves:
385
+ played_notation = XGBinaryParser._convert_move_notation(
386
+ move_entry.Moves,
387
+ position,
388
+ on_roll
389
+ )
390
+ # Normalize by sorting sub-moves for comparison
391
+ played_normalized = XGBinaryParser._normalize_move_notation(played_notation)
392
+
393
+ for move in moves:
394
+ move_normalized = XGBinaryParser._normalize_move_notation(move.notation)
395
+ if move_normalized == played_normalized:
396
+ move.was_played = True
397
+ break
398
+
399
+ # Sort moves by equity (highest first) and assign ranks
400
+ if moves:
401
+ moves.sort(key=lambda m: m.equity, reverse=True)
402
+ best_equity = moves[0].equity
403
+
404
+ for i, move in enumerate(moves):
405
+ move.rank = i + 1
406
+ move.error = abs(best_equity - move.equity)
407
+ move.xg_error = move.equity - best_equity # Negative for worse moves
408
+
409
+ # Extract XG's error value for the played move
410
+ # This is the authoritative error for filtering purposes
411
+ xg_err_move = None
412
+ if hasattr(move_entry, 'ErrMove'):
413
+ err_move_raw = move_entry.ErrMove
414
+ if err_move_raw != -1000: # -1000 indicates not analyzed
415
+ xg_err_move = abs(err_move_raw) # Use absolute value for error magnitude
416
+
417
+ # Generate XGID for the position
418
+ crawford_jacoby = 1 if crawford else 0
419
+ xgid = position.to_xgid(
420
+ cube_value=cube_value,
421
+ cube_owner=cube_owner,
422
+ dice=dice,
423
+ on_roll=on_roll,
424
+ score_x=score_x,
425
+ score_o=score_o,
426
+ match_length=match_length,
427
+ crawford_jacoby=crawford_jacoby
428
+ )
429
+
430
+ # Create Decision
431
+ decision = Decision(
432
+ position=position,
433
+ on_roll=on_roll,
434
+ dice=dice,
435
+ score_x=score_x,
436
+ score_o=score_o,
437
+ match_length=match_length,
438
+ crawford=crawford,
439
+ cube_value=cube_value,
440
+ cube_owner=cube_owner,
441
+ decision_type=DecisionType.CHECKER_PLAY,
442
+ candidate_moves=moves,
443
+ xg_error_move=xg_err_move, # XG's authoritative error value
444
+ xgid=xgid
445
+ )
446
+
447
+ return decision
448
+
449
+ @staticmethod
450
+ def _parse_cube_entry(
451
+ cube_entry: xgstruct.CubeEntry,
452
+ match_length: int,
453
+ score_x: int,
454
+ score_o: int,
455
+ crawford: bool
456
+ ) -> Optional[Decision]:
457
+ """
458
+ Convert CubeEntry to Decision object.
459
+
460
+ XG binary files contain cube entries for all cube decisions in a game,
461
+ but not all of them are analyzed. This method filters out unanalyzed
462
+ cube decisions and extracts equity values from analyzed ones.
463
+
464
+ Unanalyzed cube decisions are identified by:
465
+ - FlagDouble == -100 or -1000 (indicates not analyzed)
466
+ - All equities are 0.0 and position is empty
467
+
468
+ Analyzed cube decisions contain:
469
+ - equB: Equity for "No Double"
470
+ - equDouble: Equity for "Double/Take"
471
+ - equDrop: Equity for "Double/Pass" (typically -1.0 for opponent)
472
+ - Eval: Win probabilities for "No Double" scenario
473
+ - EvalDouble: Win probabilities for "Double/Take" scenario
474
+
475
+ Note: For cube decisions, the position is shown from the doubler's perspective
476
+ (the player who has the cube decision), regardless of whether the error was
477
+ made by the doubler or the responder. This ensures consistency for score
478
+ matrix generation and position display.
479
+
480
+ Args:
481
+ cube_entry: CubeEntry from xgstruct
482
+ match_length: Match length (0 for money game)
483
+ score_x: Player X score
484
+ score_o: Player O score
485
+ crawford: Crawford game flag
486
+
487
+ Returns:
488
+ Decision object with 5 cube options, or None if unanalyzed
489
+ """
490
+ # Determine player on roll from ActiveP
491
+ # Note: ActiveP may represent the responder for take/pass errors,
492
+ # but we always show cube decisions from the doubler's perspective
493
+ active_player = Player.O if cube_entry.ActiveP == 1 else Player.X
494
+
495
+ # Create position with perspective transformation (using active_player)
496
+ position = XGBinaryParser._transform_position(
497
+ list(cube_entry.Position),
498
+ active_player
499
+ )
500
+
501
+ # Parse cube state
502
+ # CubeB encoding: sign indicates owner, absolute value is log2 of cube value
503
+ # 0 = centered at 1, ±1 = owned at 2^1=2, ±2 = owned at 2^2=4, etc.
504
+ if cube_entry.CubeB == 0:
505
+ cube_value = 1
506
+ cube_owner = CubeState.CENTERED
507
+ else:
508
+ cube_value = 2 ** abs(cube_entry.CubeB)
509
+ # XG binary sign convention: Positive = XG Player 1, Negative = XG Player 2
510
+ # Mapping: XG Player 1 → Player.O, XG Player 2 → Player.X
511
+ if cube_entry.CubeB > 0:
512
+ cube_owner = CubeState.O_OWNS # XG Player 1 owns
513
+ else:
514
+ cube_owner = CubeState.X_OWNS # XG Player 2 owns
515
+
516
+ # Parse cube decisions from Doubled analysis
517
+ moves = []
518
+ if hasattr(cube_entry, 'Doubled') and cube_entry.Doubled:
519
+ doubled = cube_entry.Doubled
520
+
521
+ # Check if cube decision was analyzed
522
+ # FlagDouble -100 or -1000 indicates unanalyzed position
523
+ flag_double = doubled.get('FlagDouble', -100)
524
+ if flag_double in (-100, -1000):
525
+ logger.debug("Skipping unanalyzed cube decision (FlagDouble=%d)", flag_double)
526
+ return None
527
+
528
+ # Extract equities
529
+ eq_no_double = doubled.get('equB', 0.0)
530
+ eq_double_take = doubled.get('equDouble', 0.0)
531
+ eq_double_drop = doubled.get('equDrop', -1.0)
532
+
533
+ # Validate that we have actual analysis data
534
+ # If all equities are zero and position is empty, skip this decision
535
+ if (eq_no_double == 0.0 and eq_double_take == 0.0 and
536
+ abs(eq_double_drop - (-1.0)) < 0.001):
537
+ # Check if position has any checkers
538
+ pos = doubled.get('Pos', None)
539
+ if pos and all(v == 0 for v in pos):
540
+ logger.debug("Skipping cube decision with no analysis data")
541
+ return None
542
+
543
+ # Extract winning chances
544
+ eval_no_double = doubled.get('Eval', None)
545
+ eval_double = doubled.get('EvalDouble', None)
546
+
547
+ # Create 5 cube options (similar to XGTextParser)
548
+ cube_options = []
549
+
550
+ # 1. No double
551
+ if eval_no_double:
552
+ cube_options.append({
553
+ 'notation': 'No Double/Take',
554
+ 'equity': eq_no_double,
555
+ 'xg_notation': 'No double',
556
+ 'from_xg': True,
557
+ 'eval': eval_no_double
558
+ })
559
+
560
+ # 2. Double/Take
561
+ if eval_double:
562
+ cube_options.append({
563
+ 'notation': 'Double/Take',
564
+ 'equity': eq_double_take,
565
+ 'xg_notation': 'Double/Take',
566
+ 'from_xg': True,
567
+ 'eval': eval_double
568
+ })
569
+
570
+ # 3. Double/Pass
571
+ cube_options.append({
572
+ 'notation': 'Double/Pass',
573
+ 'equity': eq_double_drop,
574
+ 'xg_notation': 'Double/Pass',
575
+ 'from_xg': True,
576
+ 'eval': None
577
+ })
578
+
579
+ # 4 & 5. Too good options (synthetic)
580
+ cube_options.append({
581
+ 'notation': 'Too good/Take',
582
+ 'equity': eq_double_drop,
583
+ 'xg_notation': None,
584
+ 'from_xg': False,
585
+ 'eval': None
586
+ })
587
+
588
+ cube_options.append({
589
+ 'notation': 'Too good/Pass',
590
+ 'equity': eq_double_drop,
591
+ 'xg_notation': None,
592
+ 'from_xg': False,
593
+ 'eval': None
594
+ })
595
+
596
+ # Create Move objects
597
+ for i, opt in enumerate(cube_options):
598
+ eval_data = opt.get('eval')
599
+
600
+ # Extract winning chances if available
601
+ player_win_pct = None
602
+ player_gammon_pct = None
603
+ player_backgammon_pct = None
604
+ opponent_win_pct = None
605
+ opponent_gammon_pct = None
606
+ opponent_backgammon_pct = None
607
+
608
+ if eval_data and len(eval_data) >= 7:
609
+ # Same format as MoveEntry: [Lose_BG, Lose_G, Lose_S, Win_S, Win_G, Win_BG, Equity]
610
+ # Cumulative probabilities where Lose_S and Win_S are totals
611
+ opponent_win_pct = eval_data[2] * 100 # Total opponent wins (Lose_S)
612
+ opponent_gammon_pct = eval_data[1] * 100 # Opp gammon+BG (Lose_G)
613
+ opponent_backgammon_pct = eval_data[0] * 100 # Opp BG only (Lose_BG)
614
+ player_win_pct = eval_data[3] * 100 # Total player wins (Win_S)
615
+ player_gammon_pct = eval_data[4] * 100 # Player gammon+BG (Win_G)
616
+ player_backgammon_pct = eval_data[5] * 100 # Player BG only (Win_BG)
617
+
618
+ move = Move(
619
+ notation=opt['notation'],
620
+ equity=opt['equity'],
621
+ error=0.0,
622
+ rank=0, # Will be assigned later
623
+ xg_rank=i + 1 if opt['from_xg'] else None,
624
+ xg_error=None,
625
+ xg_notation=opt['xg_notation'],
626
+ from_xg_analysis=opt['from_xg'],
627
+ player_win_pct=player_win_pct,
628
+ player_gammon_pct=player_gammon_pct,
629
+ player_backgammon_pct=player_backgammon_pct,
630
+ opponent_win_pct=opponent_win_pct,
631
+ opponent_gammon_pct=opponent_gammon_pct,
632
+ opponent_backgammon_pct=opponent_backgammon_pct
633
+ )
634
+ moves.append(move)
635
+
636
+ # Mark which cube action was actually played
637
+ # Double: 0=no double, 1=doubled
638
+ # Take: 0=pass, 1=take, 2=beaver
639
+ if hasattr(cube_entry, 'Double') and hasattr(cube_entry, 'Take'):
640
+ if cube_entry.Double == 0:
641
+ # No double was the action taken
642
+ played_action = 'No Double/Take'
643
+ elif cube_entry.Double == 1:
644
+ if cube_entry.Take == 1:
645
+ # Doubled and taken
646
+ played_action = 'Double/Take'
647
+ else:
648
+ # Doubled and passed
649
+ played_action = 'Double/Pass'
650
+ else:
651
+ played_action = None
652
+
653
+ if played_action:
654
+ for move in moves:
655
+ if move.notation == played_action:
656
+ move.was_played = True
657
+ break
658
+
659
+ # Determine best move and assign ranks
660
+ # Cube decision logic must account for perfect opponent response.
661
+ # Key insight: equDouble represents equity if opponent TAKES, but opponent
662
+ # will only take if it's correct for them.
663
+ #
664
+ # Algorithm:
665
+ # 1. Determine opponent's correct response: take or pass?
666
+ # - If equDouble > equDrop: opponent should PASS (taking is worse for them)
667
+ # - If equDouble < equDrop: opponent should TAKE (taking is better for them)
668
+ # 2. Compare equB (No Double) vs the correct doubling equity
669
+ # - If opponent passes: compare equB vs equDrop (Double/Pass)
670
+ # - If opponent takes: compare equB vs equDouble (Double/Take)
671
+ if moves:
672
+ # Find the three main cube options
673
+ no_double_move = None
674
+ double_take_move = None
675
+ double_pass_move = None
676
+
677
+ for move in moves:
678
+ if move.notation == "No Double/Take":
679
+ no_double_move = move
680
+ elif move.notation == "Double/Take":
681
+ double_take_move = move
682
+ elif move.notation == "Double/Pass":
683
+ double_pass_move = move
684
+
685
+ if no_double_move and double_take_move and double_pass_move:
686
+ # Step 1: Determine opponent's correct response
687
+ # If equDouble > equDrop, opponent should pass (taking gives them worse equity)
688
+ if double_take_move.equity > double_pass_move.equity:
689
+ # Opponent should PASS
690
+ # Compare No Double vs Double/Pass
691
+ if no_double_move.equity >= double_pass_move.equity:
692
+ best_move_notation = "Too good/Pass"
693
+ best_equity = no_double_move.equity
694
+ else:
695
+ best_move_notation = "Double/Pass"
696
+ best_equity = double_pass_move.equity
697
+ else:
698
+ # Opponent should TAKE
699
+ # Compare No Double vs Double/Take
700
+ if no_double_move.equity >= double_take_move.equity:
701
+ if no_double_move.equity > double_pass_move.equity:
702
+ best_move_notation = "Too good/Take"
703
+ else:
704
+ best_move_notation = "No Double/Take"
705
+ best_equity = no_double_move.equity
706
+ else:
707
+ best_move_notation = "Double/Take"
708
+ best_equity = double_take_move.equity
709
+ elif no_double_move:
710
+ best_move_notation = "No Double/Take"
711
+ best_equity = no_double_move.equity
712
+ elif double_take_move:
713
+ best_move_notation = "Double/Take"
714
+ best_equity = double_take_move.equity
715
+ else:
716
+ # Fallback: sort by equity
717
+ moves.sort(key=lambda m: m.equity, reverse=True)
718
+ best_move_notation = moves[0].notation
719
+ best_equity = moves[0].equity
720
+
721
+ # Assign rank 1 to best move
722
+ for move in moves:
723
+ if move.notation == best_move_notation:
724
+ move.rank = 1
725
+ move.error = 0.0
726
+ if move.from_xg_analysis:
727
+ move.xg_error = 0.0
728
+
729
+ # Assign ranks 2-5 to other moves based on equity
730
+ other_moves = [m for m in moves if m.notation != best_move_notation]
731
+ other_moves.sort(key=lambda m: m.equity, reverse=True)
732
+
733
+ for i, move in enumerate(other_moves):
734
+ move.rank = i + 2 # Ranks 2, 3, 4, 5
735
+ move.error = abs(best_equity - move.equity)
736
+ if move.from_xg_analysis:
737
+ move.xg_error = move.equity - best_equity
738
+
739
+ # Extract decision-level winning chances from "No Double" evaluation
740
+ # This represents the current position's winning chances
741
+ decision_player_win_pct = None
742
+ decision_player_gammon_pct = None
743
+ decision_player_backgammon_pct = None
744
+ decision_opponent_win_pct = None
745
+ decision_opponent_gammon_pct = None
746
+ decision_opponent_backgammon_pct = None
747
+
748
+ if eval_no_double and len(eval_no_double) >= 7:
749
+ # Same format as MoveEntry: [Lose_BG, Lose_G, Lose_S, Win_S, Win_G, Win_BG, Equity]
750
+ decision_opponent_win_pct = eval_no_double[2] * 100 # Total opponent wins
751
+ decision_opponent_gammon_pct = eval_no_double[1] * 100 # Opp gammon+BG
752
+ decision_opponent_backgammon_pct = eval_no_double[0] * 100 # Opp BG only
753
+ decision_player_win_pct = eval_no_double[3] * 100 # Total player wins
754
+ decision_player_gammon_pct = eval_no_double[4] * 100 # Player gammon+BG
755
+ decision_player_backgammon_pct = eval_no_double[5] * 100 # Player BG only
756
+
757
+ # Extract cube and take errors from XG binary data
758
+ # ErrCube: error made by doubler on double/no double decision
759
+ # ErrTake: error made by responder on take/pass decision
760
+ # Value of -1000 indicates not analyzed
761
+ cube_error = None
762
+ take_error = None
763
+ if hasattr(cube_entry, 'ErrCube'):
764
+ err_cube_raw = cube_entry.ErrCube
765
+ if err_cube_raw != -1000:
766
+ cube_error = err_cube_raw
767
+ if hasattr(cube_entry, 'ErrTake'):
768
+ err_take_raw = cube_entry.ErrTake
769
+ if err_take_raw != -1000:
770
+ take_error = err_take_raw
771
+
772
+ # Determine who the doubler is (the player making the cube decision)
773
+ # For cube decisions, we show the position from the doubler's perspective,
774
+ # even if the error was made by the responder on the take/pass decision.
775
+ #
776
+ # Key relationships:
777
+ # - ActiveP = the player who had the cube decision (on roll)
778
+ # - cube_error = error made by ActiveP on the double/no double decision
779
+ # - take_error = error made by the opponent of ActiveP on the take/pass decision
780
+ #
781
+ # The doubler is determined by:
782
+ # 1. If cube is owned by X: only X can redouble (X is the doubler)
783
+ # 2. If cube is owned by O: only O can redouble (O is the doubler)
784
+ # 3. If cube is centered: ActiveP is the doubler (had the cube decision)
785
+
786
+ # Check the actual cube action taken in the game
787
+ doubled_in_game = hasattr(cube_entry, 'Double') and cube_entry.Double == 1
788
+
789
+ if doubled_in_game:
790
+ # A double occurred in the game - determine who doubled
791
+ if cube_owner == CubeState.X_OWNS:
792
+ # X owns cube and redoubled
793
+ doubler = Player.X
794
+ elif cube_owner == CubeState.O_OWNS:
795
+ # O owns cube and redoubled
796
+ doubler = Player.O
797
+ else:
798
+ # Cube is centered - ActiveP is the doubler
799
+ doubler = active_player
800
+ else:
801
+ # No double occurred - determine who had the cube decision
802
+ if cube_owner == CubeState.X_OWNS:
803
+ # X owns cube - X had the decision (chose not to redouble)
804
+ doubler = Player.X
805
+ elif cube_owner == CubeState.O_OWNS:
806
+ # O owns cube - O had the decision (chose not to redouble)
807
+ doubler = Player.O
808
+ else:
809
+ # Cube is centered - ActiveP had the cube decision (chose not to double)
810
+ doubler = active_player
811
+
812
+ # Always use doubler as on_roll for cube decisions
813
+ on_roll = doubler
814
+
815
+ # XG binary always stores positions from O's (Player 1's) perspective
816
+ # If the doubler is X, flip the position to show it from X's perspective
817
+ if doubler == Player.X:
818
+ logger.debug(
819
+ f"Flipping position from O's perspective to X's perspective (doubler is X)"
820
+ )
821
+ # Flip the position by reversing points and swapping signs
822
+ flipped_points = [0] * 26
823
+ # Swap the bars
824
+ flipped_points[0] = -position.points[25] # X's bar = O's bar (negated)
825
+ flipped_points[25] = -position.points[0] # O's bar = X's bar (negated)
826
+ # Reverse and negate board points
827
+ for i in range(1, 25):
828
+ flipped_points[i] = -position.points[25 - i]
829
+
830
+ position.points = flipped_points
831
+ # Swap borne-off counts
832
+ position.x_off, position.o_off = position.o_off, position.x_off
833
+ # Swap scores to match flipped perspective
834
+ score_x, score_o = score_o, score_x
835
+ # Swap cube owner to match flipped perspective
836
+ if cube_owner == CubeState.X_OWNS:
837
+ cube_owner = CubeState.O_OWNS
838
+ elif cube_owner == CubeState.O_OWNS:
839
+ cube_owner = CubeState.X_OWNS
840
+
841
+ # Generate XGID for the position
842
+ crawford_jacoby = 1 if crawford else 0
843
+ xgid = position.to_xgid(
844
+ cube_value=cube_value,
845
+ cube_owner=cube_owner,
846
+ dice=None, # No dice for cube decisions
847
+ on_roll=on_roll,
848
+ score_x=score_x,
849
+ score_o=score_o,
850
+ match_length=match_length,
851
+ crawford_jacoby=crawford_jacoby
852
+ )
853
+
854
+ # Create Decision
855
+ decision = Decision(
856
+ position=position,
857
+ on_roll=on_roll,
858
+ dice=None, # No dice for cube decisions
859
+ score_x=score_x,
860
+ score_o=score_o,
861
+ match_length=match_length,
862
+ crawford=crawford,
863
+ cube_value=cube_value,
864
+ cube_owner=cube_owner,
865
+ decision_type=DecisionType.CUBE_ACTION,
866
+ candidate_moves=moves,
867
+ cube_error=cube_error,
868
+ take_error=take_error,
869
+ xgid=xgid,
870
+ player_win_pct=decision_player_win_pct,
871
+ player_gammon_pct=decision_player_gammon_pct,
872
+ player_backgammon_pct=decision_player_backgammon_pct,
873
+ opponent_win_pct=decision_opponent_win_pct,
874
+ opponent_gammon_pct=decision_opponent_gammon_pct,
875
+ opponent_backgammon_pct=decision_opponent_backgammon_pct
876
+ )
877
+
878
+ return decision
879
+
880
+ @staticmethod
881
+ def _normalize_move_notation(notation: str) -> str:
882
+ """
883
+ Normalize move notation by sorting sub-moves.
884
+
885
+ This handles cases where "7/6 12/8" and "12/8 7/6" represent the same move
886
+ but with sub-moves in different order.
887
+
888
+ Args:
889
+ notation: Move notation string (e.g., "12/8 7/6")
890
+
891
+ Returns:
892
+ Normalized notation with sub-moves sorted (e.g., "7/6 12/8")
893
+ """
894
+ if not notation or notation == "Cannot move":
895
+ return notation
896
+
897
+ # Split into sub-moves
898
+ parts = notation.split()
899
+
900
+ # Sort sub-moves for consistent comparison
901
+ # Sort by from point (descending), then by to point
902
+ parts.sort(reverse=True)
903
+
904
+ return " ".join(parts)
905
+
906
+ @staticmethod
907
+ def _convert_move_notation(
908
+ xg_moves: Tuple[int, ...],
909
+ position: Optional[Position] = None,
910
+ on_roll: Optional[Player] = None
911
+ ) -> str:
912
+ """
913
+ Convert XG move notation to readable format with compound move combination and hit detection.
914
+
915
+ XG binary uses 0-based indexing for board points in move notation, while standard
916
+ backgammon notation uses 1-based indexing. This method adds 1 to all board point
917
+ numbers during conversion.
918
+
919
+ XG binary stores compound moves as separate sub-moves (e.g., 20/16 16/15), but
920
+ standard notation combines them (e.g., 20/15*). This function:
921
+ 1. Converts 0-based to 1-based point numbering
922
+ 2. Combines consecutive sub-moves into compound moves
923
+ 3. Detects and marks hits with *
924
+ 4. Detects duplicate moves and uses (2), (3), (4) notation for doublets
925
+
926
+ XG format: [from1, to1, from2, to2, from3, to3, from4, to4]
927
+ Special values:
928
+ - -1: End of move list OR bearing off (when used as destination)
929
+ - 24: Bar (both players when entering)
930
+ - 0-23: Board points (0-based, add 1 for standard notation)
931
+
932
+ Args:
933
+ xg_moves: Tuple of 8 integers
934
+ position: Position object for hit detection (optional)
935
+ on_roll: Player making the move (optional)
936
+
937
+ Returns:
938
+ Move notation string (e.g., "20/15*", "bar/22", "15/9(2)")
939
+ Returns "Cannot move" for illegal/blocked positions (all zeros)
940
+ """
941
+ if not xg_moves or len(xg_moves) < 2:
942
+ return ""
943
+
944
+ # Check for illegal/blocked move (all zeros)
945
+ if all(x == 0 for x in xg_moves):
946
+ return "Cannot move"
947
+
948
+ # Pass 1: Parse all sub-moves
949
+ sub_moves = []
950
+ for i in range(0, len(xg_moves), 2):
951
+ from_point = xg_moves[i]
952
+
953
+ # -1 indicates end of move
954
+ if from_point == -1:
955
+ break
956
+
957
+ if i + 1 >= len(xg_moves):
958
+ break
959
+
960
+ to_point = xg_moves[i + 1]
961
+ sub_moves.append((from_point, to_point))
962
+
963
+ if not sub_moves:
964
+ return ""
965
+
966
+ # Pass 2: Build adjacency map for chain detection
967
+ # Map from to_point -> list of indices that start from that point
968
+ from_point_map = {}
969
+ for idx, (from_point, to_point) in enumerate(sub_moves):
970
+ if from_point not in from_point_map:
971
+ from_point_map[from_point] = []
972
+ from_point_map[from_point].append(idx)
973
+
974
+ # Pass 3: Build chains with intermediate hit detection.
975
+ # Stop chain building at intermediate hits to preserve hit markers in notation.
976
+ used = [False] * len(sub_moves)
977
+ combined_moves = []
978
+
979
+ # Track destination points that have been hit.
980
+ # Only the first checker to land on a point can hit a blot.
981
+ destinations_hit = set()
982
+
983
+ # Sort sub-moves by from_point descending to process in order
984
+ sorted_indices = sorted(range(len(sub_moves)),
985
+ key=lambda i: sub_moves[i][0],
986
+ reverse=True)
987
+
988
+ for start_idx in sorted_indices:
989
+ if used[start_idx]:
990
+ continue
991
+
992
+ # Start a new chain
993
+ from_point, to_point = sub_moves[start_idx]
994
+ used[start_idx] = True
995
+
996
+ # Build a chain of intermediate points for hit checking
997
+ chain_points = [from_point, to_point]
998
+
999
+ # Extend the chain as far as possible, checking for hits at each step
1000
+ while to_point in from_point_map:
1001
+ # Find an unused move that starts from current to_point
1002
+ extended = False
1003
+ for next_idx in from_point_map[to_point]:
1004
+ if not used[next_idx]:
1005
+ # Check for hit at current destination before extending chain.
1006
+ # Only mark as hit if this is the first checker to this destination.
1007
+ hit_at_current = False
1008
+ if position and on_roll and 0 <= to_point <= 23:
1009
+ if to_point not in destinations_hit:
1010
+ checker_count = position.points[to_point + 1]
1011
+ if checker_count == 1:
1012
+ hit_at_current = True
1013
+ # Don't add to destinations_hit here - let final check handle it
1014
+
1015
+ if hit_at_current:
1016
+ # Stop extending to preserve hit marker at this point.
1017
+ break
1018
+
1019
+ _, next_to = sub_moves[next_idx]
1020
+ to_point = next_to
1021
+ chain_points.append(to_point)
1022
+ used[next_idx] = True
1023
+ extended = True
1024
+ break
1025
+
1026
+ if not extended:
1027
+ break
1028
+
1029
+ # Check for hit at the final destination.
1030
+ # Only mark as hit if this is the first checker to this destination.
1031
+ hit = False
1032
+ if position and on_roll and 0 <= to_point <= 23:
1033
+ if to_point not in destinations_hit:
1034
+ # Convert 0-based to 1-based for position lookup
1035
+ checker_count = position.points[to_point + 1]
1036
+ # Hit occurs if opponent has exactly 1 checker at destination.
1037
+ # After perspective transform, opponent checkers are always positive.
1038
+ if checker_count == 1:
1039
+ hit = True
1040
+ destinations_hit.add(to_point)
1041
+
1042
+ combined_moves.append((from_point, to_point, hit))
1043
+
1044
+ # Pass 4: Count duplicates and format
1045
+ from collections import Counter
1046
+
1047
+ # Count occurrences of each move (excluding hit marker for counting)
1048
+ move_counts = Counter((from_point, to_point) for from_point, to_point, _ in combined_moves)
1049
+
1050
+ # Track how many of each move we've seen (for numbering)
1051
+ move_seen = {}
1052
+
1053
+ # Sort combined moves for consistent output
1054
+ combined_moves.sort(key=lambda m: m[0], reverse=True)
1055
+
1056
+ # Format as notation strings
1057
+ parts = []
1058
+ for from_point, to_point, hit in combined_moves:
1059
+ move_key = (from_point, to_point)
1060
+ count = move_counts[move_key]
1061
+
1062
+ # Track this occurrence
1063
+ if move_key not in move_seen:
1064
+ move_seen[move_key] = 0
1065
+ move_seen[move_key] += 1
1066
+ occurrence = move_seen[move_key]
1067
+
1068
+ # Convert special values to standard backgammon notation
1069
+ # Handle from_point
1070
+ if from_point == 24:
1071
+ from_str = "bar" # Bar for both players
1072
+ else:
1073
+ from_str = str(from_point + 1) # Convert 0-based to 1-based (0→1, 23→24)
1074
+
1075
+ # Handle to_point
1076
+ if to_point == -1:
1077
+ to_str = "off" # Bearing off
1078
+ elif to_point == 24:
1079
+ to_str = "bar" # Opponent hit and sent to bar
1080
+ else:
1081
+ to_str = str(to_point + 1) # Convert 0-based to 1-based (0→1, 23→24)
1082
+
1083
+ # Build notation
1084
+ notation = f"{from_str}/{to_str}"
1085
+ if hit:
1086
+ notation += "*"
1087
+
1088
+ # Add doublet notation if this is the first occurrence and count > 1
1089
+ if occurrence == 1 and count > 1:
1090
+ notation += f"({count})"
1091
+ elif occurrence > 1:
1092
+ # Skip duplicate occurrences (already counted in first one)
1093
+ continue
1094
+
1095
+ parts.append(notation)
1096
+
1097
+ return " ".join(parts) if parts else ""