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,590 @@
1
+ """
2
+ GNU Backgammon command-line interface wrapper.
3
+
4
+ Provides functionality to analyze backgammon positions using gnubg-cli.exe.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ import re
10
+ import subprocess
11
+ import tempfile
12
+ import multiprocessing
13
+ from pathlib import Path
14
+ from typing import Tuple, List, Callable, Optional
15
+ from concurrent.futures import ProcessPoolExecutor, as_completed
16
+
17
+ from ankigammon.models import DecisionType
18
+ from ankigammon.utils.xgid import parse_xgid
19
+
20
+
21
+ class GNUBGAnalyzer:
22
+ """Wrapper for gnubg-cli.exe command-line interface."""
23
+
24
+ def __init__(self, gnubg_path: str, analysis_ply: int = 3):
25
+ """
26
+ Initialize GnuBG analyzer.
27
+
28
+ Args:
29
+ gnubg_path: Path to gnubg-cli.exe executable
30
+ analysis_ply: Analysis depth in plies (default: 3)
31
+ """
32
+ self.gnubg_path = gnubg_path
33
+ self.analysis_ply = analysis_ply
34
+ self._current_process = None
35
+
36
+ if not Path(gnubg_path).exists():
37
+ raise FileNotFoundError(f"GnuBG executable not found: {gnubg_path}")
38
+
39
+ def terminate(self):
40
+ """Terminate any running GnuBG process."""
41
+ if self._current_process is not None:
42
+ try:
43
+ self._current_process.kill()
44
+ self._current_process = None
45
+ except:
46
+ pass
47
+
48
+ def analyze_position(self, position_id: str) -> Tuple[str, DecisionType]:
49
+ """
50
+ Analyze a position from XGID or GNUID.
51
+
52
+ Args:
53
+ position_id: Position identifier (XGID or GNUID format)
54
+
55
+ Returns:
56
+ Tuple of (gnubg_output_text, decision_type)
57
+
58
+ Raises:
59
+ ValueError: If position_id format is invalid
60
+ subprocess.CalledProcessError: If gnubg execution fails
61
+ """
62
+ if position_id is None:
63
+ raise ValueError("position_id cannot be None. Decision object must have xgid field populated.")
64
+
65
+ decision_type = self._determine_decision_type(position_id)
66
+ command_file = self._create_command_file(position_id, decision_type)
67
+
68
+ try:
69
+ output = self._run_gnubg(command_file)
70
+ return output, decision_type
71
+ finally:
72
+ try:
73
+ os.unlink(command_file)
74
+ except OSError:
75
+ pass
76
+
77
+ def analyze_positions_parallel(
78
+ self,
79
+ position_ids: List[str],
80
+ max_workers: Optional[int] = None,
81
+ progress_callback: Optional[Callable[[int, int], None]] = None
82
+ ) -> List[Tuple[str, DecisionType]]:
83
+ """
84
+ Analyze multiple positions in parallel.
85
+
86
+ Args:
87
+ position_ids: List of position identifiers (XGID or GNUID format)
88
+ max_workers: Maximum number of parallel workers (default: min(cpu_count, 8))
89
+ progress_callback: Optional callback for progress updates: callback(completed, total)
90
+
91
+ Returns:
92
+ List of tuples (gnubg_output_text, decision_type) in same order as position_ids
93
+
94
+ Raises:
95
+ ValueError: If any position_id format is invalid
96
+ subprocess.CalledProcessError: If any gnubg execution fails
97
+ """
98
+ if not position_ids:
99
+ return []
100
+
101
+ if max_workers is None:
102
+ max_workers = min(multiprocessing.cpu_count(), 8)
103
+
104
+ if len(position_ids) <= 2:
105
+ results = []
106
+ for i, pos_id in enumerate(position_ids):
107
+ result = self.analyze_position(pos_id)
108
+ results.append(result)
109
+ if progress_callback:
110
+ progress_callback(i + 1, len(position_ids))
111
+ return results
112
+
113
+ args_list = [(self.gnubg_path, self.analysis_ply, pos_id) for pos_id in position_ids]
114
+ results = [None] * len(position_ids)
115
+ completed = 0
116
+
117
+ with ProcessPoolExecutor(max_workers=max_workers) as executor:
118
+ future_to_idx = {
119
+ executor.submit(_analyze_position_worker, *args): idx
120
+ for idx, args in enumerate(args_list)
121
+ }
122
+
123
+ for future in as_completed(future_to_idx):
124
+ idx = future_to_idx[future]
125
+ try:
126
+ results[idx] = future.result()
127
+ completed += 1
128
+ if progress_callback:
129
+ progress_callback(completed, len(position_ids))
130
+ except Exception as e:
131
+ raise RuntimeError(f"Failed to analyze position {idx} ({position_ids[idx]}): {e}") from e
132
+
133
+ return results
134
+
135
+ def analyze_match_file(
136
+ self,
137
+ mat_file_path: str,
138
+ max_moves: int = 8,
139
+ progress_callback: Optional[Callable[[str], None]] = None
140
+ ) -> List[str]:
141
+ """
142
+ Analyze entire match file using gnubg and export to text files.
143
+
144
+ Supports both .mat (Jellyfish) and .sgf (Smart Game Format) files.
145
+
146
+ Args:
147
+ mat_file_path: Path to match file (.mat or .sgf)
148
+ max_moves: Maximum number of candidate moves to show (default: 8)
149
+ progress_callback: Optional callback(status_message) for progress updates
150
+
151
+ Returns:
152
+ List of paths to exported text files (one per game)
153
+ Caller is responsible for cleaning up these temp files after parsing.
154
+
155
+ Raises:
156
+ FileNotFoundError: If match file not found
157
+ subprocess.CalledProcessError: If gnubg execution fails
158
+ RuntimeError: If export files were not created
159
+ """
160
+ mat_path = Path(mat_file_path)
161
+ if not mat_path.exists():
162
+ raise FileNotFoundError(f"Match file not found: {mat_file_path}")
163
+
164
+ temp_dir = Path(tempfile.mkdtemp(prefix="gnubg_match_"))
165
+ output_base = temp_dir / "analyzed_match.txt"
166
+
167
+ if progress_callback:
168
+ progress_callback("Preparing analysis...")
169
+
170
+ mat_path_str = str(mat_path)
171
+ output_path_str = str(output_base)
172
+
173
+ if ' ' in mat_path_str:
174
+ mat_path_str = f'"{mat_path_str}"'
175
+ if ' ' in output_path_str:
176
+ output_path_str = f'"{output_path_str}"'
177
+
178
+ file_ext = mat_path.suffix.lower()
179
+ if file_ext == '.sgf':
180
+ import_cmd = f"load match {mat_path_str}"
181
+ else:
182
+ import_cmd = f"import mat {mat_path_str}"
183
+
184
+ commands = [
185
+ "set automatic game off",
186
+ "set automatic roll off",
187
+ f"set analysis chequerplay evaluation plies {self.analysis_ply}",
188
+ f"set analysis cubedecision evaluation plies {self.analysis_ply}",
189
+ f"set export moves number {max_moves}",
190
+ import_cmd,
191
+ "analyse match",
192
+ f"export match text {output_path_str}",
193
+ ]
194
+
195
+ command_file = self._create_command_file_from_list(commands)
196
+
197
+ import logging
198
+ logger = logging.getLogger(__name__)
199
+ with open(command_file, 'r') as f:
200
+ logger.info(f"GnuBG command file:\n{f.read()}")
201
+
202
+ try:
203
+ if progress_callback:
204
+ progress_callback(f"Analyzing match with GnuBG ({self.analysis_ply}-ply)...")
205
+
206
+ kwargs = {
207
+ 'stdout': subprocess.PIPE,
208
+ 'stderr': subprocess.PIPE,
209
+ 'text': True,
210
+ }
211
+ if sys.platform == 'win32':
212
+ kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW
213
+
214
+ self._current_process = subprocess.Popen(
215
+ [self.gnubg_path, "-t", "-c", command_file],
216
+ **kwargs
217
+ )
218
+
219
+ try:
220
+ stdout, stderr = self._current_process.communicate(timeout=600)
221
+ returncode = self._current_process.returncode
222
+ except subprocess.TimeoutExpired:
223
+ self._current_process.kill()
224
+ stdout, stderr = self._current_process.communicate()
225
+ self._current_process = None
226
+ raise subprocess.CalledProcessError(
227
+ -1,
228
+ [self.gnubg_path, "-t", "-c", command_file],
229
+ output="Process timed out after 10 minutes",
230
+ stderr=""
231
+ )
232
+ finally:
233
+ self._current_process = None
234
+
235
+ import logging
236
+ logger = logging.getLogger(__name__)
237
+ if stdout:
238
+ logger.info(f"GnuBG stdout (first 1000 chars):\n{stdout[:1000]}")
239
+ if stderr:
240
+ logger.warning(f"GnuBG stderr:\n{stderr}")
241
+
242
+ if returncode != 0:
243
+ raise subprocess.CalledProcessError(
244
+ returncode,
245
+ [self.gnubg_path, "-t", "-c", command_file],
246
+ output=stdout,
247
+ stderr=stderr
248
+ )
249
+
250
+ if progress_callback:
251
+ progress_callback("Finding exported files...")
252
+
253
+ temp_files = list(temp_dir.glob("*"))
254
+ logger.info(f"Files in temp dir {temp_dir}: {[f.name for f in temp_files]}")
255
+
256
+ exported_files = []
257
+
258
+ if output_base.exists():
259
+ exported_files.append(str(output_base))
260
+
261
+ game_num = 2
262
+ while True:
263
+ next_file = temp_dir / f"analyzed_match_{game_num:03d}.txt"
264
+ if next_file.exists():
265
+ exported_files.append(str(next_file))
266
+ game_num += 1
267
+ else:
268
+ break
269
+
270
+ if not exported_files:
271
+ error_msg = (
272
+ f"GnuBG did not create any export files.\n"
273
+ f"Expected files in: {temp_dir}\n"
274
+ f"Files found: {[f.name for f in temp_files]}\n\n"
275
+ )
276
+ if stdout:
277
+ error_msg += f"GnuBG output:\n{stdout[:500]}\n"
278
+ if stderr:
279
+ error_msg += f"GnuBG errors:\n{stderr[:500]}"
280
+
281
+ raise RuntimeError(error_msg)
282
+
283
+ if exported_files:
284
+ with open(exported_files[0], 'r', encoding='utf-8') as f:
285
+ content = f.read(5000)
286
+ has_analysis = bool(re.search(r'Rolled \d\d \([+-]?\d+\.\d+\):', content))
287
+ if not has_analysis:
288
+ logger.warning("GnuBG exported files but no analysis found")
289
+ logger.warning(f"Expected to find 'Rolled XX (±error):' pattern")
290
+ logger.warning(f"First file preview:\n{content[:800]}")
291
+ raise RuntimeError(
292
+ "GnuBG exported the match but did not include analysis.\n"
293
+ "The 'analyse match' command may have failed.\n\n"
294
+ f"Check logs for GnuBG output."
295
+ )
296
+
297
+ if progress_callback:
298
+ progress_callback(f"Analysis complete. {len(exported_files)} game(s) exported.")
299
+
300
+ if exported_files:
301
+ import shutil
302
+ debug_path = Path(__file__).parent.parent.parent / "debug_gnubg_output.txt"
303
+ shutil.copy2(exported_files[0], debug_path)
304
+ logger.info(f"Copied first export file to {debug_path}")
305
+
306
+ return exported_files
307
+
308
+ finally:
309
+ # Cleanup command file
310
+ try:
311
+ os.unlink(command_file)
312
+ except OSError:
313
+ pass
314
+
315
+ def _create_command_file_from_list(self, commands: List[str]) -> str:
316
+ """
317
+ Create temporary command file from list of commands.
318
+
319
+ Args:
320
+ commands: List of gnubg commands
321
+
322
+ Returns:
323
+ Path to temporary command file
324
+ """
325
+ fd, temp_path = tempfile.mkstemp(suffix=".txt", prefix="gnubg_commands_")
326
+ try:
327
+ with os.fdopen(fd, 'w') as f:
328
+ f.write('\n'.join(commands))
329
+ f.write('\n')
330
+ except:
331
+ os.close(fd)
332
+ raise
333
+ return temp_path
334
+
335
+ def _determine_decision_type(self, position_id: str) -> DecisionType:
336
+ """
337
+ Determine the decision type from position ID.
338
+
339
+ Args:
340
+ position_id: XGID or GNUID string
341
+
342
+ Returns:
343
+ DecisionType.CHECKER_PLAY or DecisionType.CUBE_ACTION
344
+
345
+ Raises:
346
+ ValueError: If position_id format is invalid
347
+ """
348
+ if position_id.startswith("XGID=") or ":" in position_id:
349
+ try:
350
+ _, metadata = parse_xgid(position_id)
351
+
352
+ dice = metadata.get('dice', None)
353
+ if dice is None:
354
+ return metadata.get('decision_type', DecisionType.CUBE_ACTION)
355
+ else:
356
+ return DecisionType.CHECKER_PLAY
357
+
358
+ except (ValueError, KeyError) as e:
359
+ raise ValueError(f"Invalid XGID format: {e}")
360
+ else:
361
+ return DecisionType.CHECKER_PLAY
362
+
363
+ def _create_command_file(self, position_id: str, decision_type: DecisionType) -> str:
364
+ """
365
+ Create a temporary command file for gnubg.
366
+
367
+ Args:
368
+ position_id: XGID or GNUID string
369
+ decision_type: Type of decision to analyze
370
+
371
+ Returns:
372
+ Path to temporary command file
373
+ """
374
+ if position_id.startswith("XGID="):
375
+ set_command = f"set xgid {position_id}"
376
+ elif ":" in position_id and not position_id.startswith("XGID="):
377
+ set_command = f"set xgid XGID={position_id}"
378
+ else:
379
+ set_command = f"set gnubgid {position_id}"
380
+
381
+ commands = [
382
+ "set automatic game off",
383
+ "set automatic roll off",
384
+ set_command,
385
+ f"set analysis chequerplay evaluation plies {self.analysis_ply}",
386
+ f"set analysis cubedecision evaluation plies {self.analysis_ply}",
387
+ "set output matchpc off",
388
+ ]
389
+
390
+ if decision_type == DecisionType.CHECKER_PLAY:
391
+ commands.append("hint")
392
+ else:
393
+ commands.append("hint")
394
+
395
+ fd, temp_path = tempfile.mkstemp(suffix=".txt", prefix="gnubg_commands_")
396
+ try:
397
+ with os.fdopen(fd, 'w') as f:
398
+ f.write('\n'.join(commands))
399
+ f.write('\n')
400
+ except:
401
+ os.close(fd)
402
+ raise
403
+
404
+ return temp_path
405
+
406
+ def _run_gnubg(self, command_file: str) -> str:
407
+ """
408
+ Execute gnubg-cli.exe with the command file.
409
+
410
+ Args:
411
+ command_file: Path to command file
412
+
413
+ Returns:
414
+ Output text from gnubg
415
+
416
+ Raises:
417
+ subprocess.CalledProcessError: If gnubg execution fails
418
+ """
419
+ cmd = [self.gnubg_path, "-t", "-c", command_file]
420
+
421
+ kwargs = {
422
+ 'capture_output': True,
423
+ 'text': True,
424
+ 'timeout': 120,
425
+ }
426
+ if sys.platform == 'win32':
427
+ kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW
428
+
429
+ result = subprocess.run(cmd, **kwargs)
430
+
431
+ if result.returncode != 0:
432
+ raise subprocess.CalledProcessError(
433
+ result.returncode,
434
+ cmd,
435
+ output=result.stdout,
436
+ stderr=result.stderr
437
+ )
438
+
439
+ output = result.stdout
440
+ if result.stderr:
441
+ output += "\n" + result.stderr
442
+
443
+ return output
444
+
445
+ def analyze_cube_at_score(
446
+ self,
447
+ position_id: str,
448
+ match_length: int,
449
+ player_away: int,
450
+ opponent_away: int
451
+ ) -> dict:
452
+ """
453
+ Analyze cube decision at a specific match score.
454
+
455
+ Args:
456
+ position_id: XGID position string
457
+ match_length: Match length (e.g., 7 for 7-point match)
458
+ player_away: Points away from match for player on roll
459
+ opponent_away: Points away from match for opponent
460
+
461
+ Returns:
462
+ Dictionary with cube analysis results:
463
+ - best_action: Best cube action (e.g., "D/T", "N/T", "D/P")
464
+ - equity_no_double: Equity for no double
465
+ - equity_double_take: Equity for double/take
466
+ - equity_double_pass: Equity for double/pass
467
+ - error_no_double: Error if don't double
468
+ - error_double: Error if double
469
+ - error_pass: Error if pass
470
+
471
+ Raises:
472
+ ValueError: If position_id format is invalid or analysis fails
473
+ """
474
+ from ankigammon.utils.xgid import parse_xgid, encode_xgid
475
+
476
+ position, metadata = parse_xgid(position_id)
477
+
478
+ score_on_roll = match_length - player_away
479
+ score_opponent = match_length - opponent_away
480
+
481
+ from ankigammon.models import Player
482
+ on_roll = metadata.get('on_roll')
483
+
484
+ if on_roll == Player.O:
485
+ score_o = score_on_roll
486
+ score_x = score_opponent
487
+ else:
488
+ score_x = score_on_roll
489
+ score_o = score_opponent
490
+
491
+ modified_xgid = encode_xgid(
492
+ position=position,
493
+ cube_value=metadata.get('cube_value', 1),
494
+ cube_owner=metadata.get('cube_owner'),
495
+ dice=None,
496
+ on_roll=on_roll,
497
+ score_x=score_x,
498
+ score_o=score_o,
499
+ match_length=match_length,
500
+ crawford_jacoby=metadata.get('crawford_jacoby', 0),
501
+ max_cube=metadata.get('max_cube', 256)
502
+ )
503
+
504
+ output, decision_type = self.analyze_position(modified_xgid)
505
+
506
+ from ankigammon.parsers.gnubg_parser import GNUBGParser
507
+ moves = GNUBGParser._parse_cube_decision(output)
508
+
509
+ if not moves:
510
+ raise ValueError(f"Could not parse cube decision from GnuBG output")
511
+
512
+ equity_map = {m.notation: m.equity for m in moves}
513
+
514
+ best_move = next((m for m in moves if m.rank == 1), None)
515
+ if not best_move:
516
+ raise ValueError("Could not determine best cube action")
517
+
518
+ no_double_eq = equity_map.get("No Double/Take", None)
519
+ double_take_eq = equity_map.get("Double/Take", equity_map.get("Redouble/Take", None))
520
+ double_pass_eq = equity_map.get("Double/Pass", equity_map.get("Redouble/Pass", None))
521
+
522
+ best_action_simplified = self._simplify_cube_notation(best_move.notation)
523
+
524
+ best_equity = best_move.equity
525
+ error_no_double = None
526
+ error_double = None
527
+ error_pass = None
528
+
529
+ if no_double_eq is not None:
530
+ error_no_double = abs(best_equity - no_double_eq) if best_action_simplified != "N/T" else 0.0
531
+ if double_take_eq is not None:
532
+ error_double = abs(best_equity - double_take_eq) if best_action_simplified not in ["D/T", "TG/T"] else 0.0
533
+ if double_pass_eq is not None:
534
+ error_pass = abs(best_equity - double_pass_eq) if best_action_simplified != "D/P" else 0.0
535
+
536
+ return {
537
+ 'best_action': best_action_simplified,
538
+ 'equity_no_double': no_double_eq,
539
+ 'equity_double_take': double_take_eq,
540
+ 'equity_double_pass': double_pass_eq,
541
+ 'error_no_double': error_no_double,
542
+ 'error_double': error_double,
543
+ 'error_pass': error_pass
544
+ }
545
+
546
+ @staticmethod
547
+ def _simplify_cube_notation(notation: str) -> str:
548
+ """
549
+ Simplify cube notation for display in score matrix.
550
+
551
+ Args:
552
+ notation: Full notation (e.g., "No Double/Take", "Double/Take")
553
+
554
+ Returns:
555
+ Simplified notation (e.g., "N/T", "D/T", "D/P", "TG/T", "TG/P")
556
+ """
557
+ notation_lower = notation.lower()
558
+
559
+ if "too good" in notation_lower:
560
+ if "take" in notation_lower:
561
+ return "TG/T"
562
+ elif "pass" in notation_lower:
563
+ return "TG/P"
564
+ elif "no double" in notation_lower or "no redouble" in notation_lower:
565
+ return "N/T"
566
+ elif "double" in notation_lower or "redouble" in notation_lower:
567
+ if "take" in notation_lower:
568
+ return "D/T"
569
+ elif "pass" in notation_lower or "drop" in notation_lower:
570
+ return "D/P"
571
+
572
+ return notation
573
+
574
+
575
+ def _analyze_position_worker(gnubg_path: str, analysis_ply: int, position_id: str) -> Tuple[str, DecisionType]:
576
+ """
577
+ Worker function for parallel position analysis.
578
+
579
+ This is a module-level function to support pickling for multiprocessing.
580
+
581
+ Args:
582
+ gnubg_path: Path to gnubg-cli.exe executable
583
+ analysis_ply: Analysis depth in plies
584
+ position_id: Position identifier (XGID or GNUID format)
585
+
586
+ Returns:
587
+ Tuple of (gnubg_output_text, decision_type)
588
+ """
589
+ analyzer = GNUBGAnalyzer(gnubg_path, analysis_ply)
590
+ return analyzer.analyze_position(position_id)