BitBully 0.0.76
Loading...
Searching...
No Matches
solver.py
1"""This module provides the Connect Four AI agent "BitBully" with opening book support."""
2
3from __future__ import annotations
4
5import operator
6import os
7import random
8from pathlib import Path
9from typing import (
10 Literal,
11 TypeAlias,
12 TypeGuard,
13 get_args,
14)
15
16from . import Board, bitbully_core
17
18OpeningBookName: TypeAlias = Literal["default", "8-ply", "12-ply", "12-ply-dist"]
19"""Name of the opening book used by the BitBully engine.
20
21Possible values:
22- ``"default"``: Alias for ``"12-ply-dist"``.
23- ``"8-ply"``: 8-ply opening book (win/loss only).
24- ``"12-ply"``: 12-ply opening book (win/loss only).
25- ``"12-ply-dist"``: 12-ply opening book with distance-to-win information.
26"""
27
28TieBreakStrategy: TypeAlias = Literal["center", "leftmost", "random"]
29"""Strategy for breaking ties between equally good moves.
30
31Possible values:
32- ``"center"``: Prefer moves closer to the center column.
33- ``"leftmost"``: Prefer the leftmost among the best moves.
34- ``"random"``: Choose randomly among the best moves.
35"""
36
37
38def _is_opening_book_name(x: object) -> TypeGuard[OpeningBookName]:
39 return x in get_args(OpeningBookName)
40
41
43 """A Connect Four AI agent with optional opening book support.
44
45 Todo:
46 - We have to describe the scoring scheme (range of values and their meaning).
47
48 This class is a high-level Python wrapper around
49 [`bitbully_core.BitBullyCore`][src.bitbully.bitbully_core.BitBullyCore].
50 It integrates the packaged *BitBully Databases* opening books and
51 operates on [`bitbully.Board`][src.bitbully.board.Board] objects.
52
53 Notes:
54 - If an opening book is enabled, it is used automatically for
55 early-game positions.
56 - For deeper positions or positions outside the database horizon,
57 the engine falls back to search-based evaluation.
58
59 Example:
60 ```python
61 from bitbully import BitBully, Board
62
63 agent = BitBully()
64 board, _ = Board.random_board(n_ply=14, forbid_direct_win=True)
65 print(board)
66
67 # All three search methods should agree on the score
68 score_mtdf = agent.mtdf(board)
69 score_negamax = agent.negamax(board)
70 score_null_window = agent.null_window(board)
71 assert score_negamax == score_null_window == score_mtdf
72 ```
73
74 Example:
75 ```python
76 from bitbully import BitBully, Board
77
78 board = Board() # empty board
79 agent = BitBully()
80 scores = agent.score_all_moves(board) # get scores for all moves
81 assert len(scores) == 7 # there are 7 columns
82 assert scores == {3: 1, 2: 0, 4: 0, 1: -1, 5: -1, 0: -2, 6: -2} # center column is best
83 print(scores)
84 ```
85
86 Expected Output:
87 ```
88 {3: 1, 2: 0, 4: 0, 1: -1, 5: -1, 0: -2, 6: -2}
89 ```
90
91
92 """
93
95 self,
96 opening_book: OpeningBookName | None = "default",
97 *,
98 tie_break: TieBreakStrategy | None = None,
99 rng: random.Random | None = None,
100 max_depth: int = -1,
101 ) -> None:
102 """Initialize the BitBully agent.
103
104 Args:
105 opening_book (OpeningBookName | None):
106 Which opening book to load.
107
108 - ``"default"``: Alias for ``"12-ply-dist"``.
109 - ``"8-ply"``: 8-ply book with win/loss values.
110 - ``"12-ply"``: 12-ply book with win/loss values.
111 - ``"12-ply-dist"``: 12-ply book with win/loss *and distance* values.
112 - ``None``: Disable opening-book usage entirely.
113 tie_break (TieBreakStrategy | None):
114 Default strategy for breaking ties between equally scoring moves.
115 If ``None``, defaults to ``"center"``.
116 rng (random.Random | None):
117 Optional RNG for reproducible "random" tie-breaking.
118 max_depth (int):
119 Maximum search depth in plies. When reached, a rollout using
120 non-losing moves is performed instead of continuing the search.
121 ``-1`` (default) means unlimited depth (full search).
122
123 TODO: Example for initialization with different books.
124
125 """
126 self.opening_book_type: OpeningBookName | None = opening_book
127 self.tie_break = tie_break if tie_break is not None else "center"
128 self.rng = rng if rng is not None else random.Random()
129 self.max_depth = max_depth
130
131 if opening_book is None:
132 self._core = bitbully_core.BitBullyCore()
133 return
134
135 import bitbully_databases as bbd
136
137 db_path = bbd.BitBullyDatabases.get_database_path(opening_book)
138 self._core = bitbully_core.BitBullyCore(Path(db_path))
139
140 def __repr__(self) -> str:
141 """Return a concise string representation of the BitBully agent."""
142 return f"BitBully(opening_book={self.opening_book_type!r}, book_loaded={self.is_book_loaded()})"
143
144 def is_book_loaded(self) -> bool:
145 """Check whether an opening book is loaded.
146
147 Returns:
148 bool: ``True`` if an opening book is loaded, otherwise ``False``.
149
150 Example:
151 ```python
152 from bitbully import BitBully
153
154 agent = BitBully() # per default, the 12-ply-dist book is loaded
155 assert agent.is_book_loaded() is True
156
157 # Unload the book
158 agent.reset_book()
159 assert agent.is_book_loaded() is False
160 ```
161 """
162 return bool(self._core.isBookLoaded())
163
164 def reset_transposition_table(self) -> None:
165 """Clear the internal transposition table."""
166 self._core.resetTranspositionTable()
167
168 def get_node_counter(self) -> int:
169 """Return the number of nodes visited since the last reset.
170
171 Returns:
172 int: Number of visited nodes.
173
174 Example:
175 ```python
176 from bitbully import BitBully, Board
177
178 agent = BitBully()
179 board = Board()
180 _ = agent.score_all_moves(board)
181 print(f"Nodes visited: {agent.get_node_counter()}")
182
183 # Note that has to be reset manually:
184 agent.reset_node_counter()
185 assert agent.get_node_counter() == 0
186 ```
187 """
188 return int(self._core.getNodeCounter())
189
190 def reset_node_counter(self) -> None:
191 """Reset the internal node counter.
192
193 See Also: [`get_node_counter`][src.bitbully.solver.BitBully.get_node_counter] for usage.
194 """
195 self._core.resetNodeCounter()
196
197 def score_move(self, board: Board, column: int, first_guess: int = 0, *, max_depth: int | None = None) -> int:
198 """Evaluate a single move for the given board state.
199
200 Args:
201 board (Board): The current board state.
202 column (int): Column index (0-6) of the move to evaluate.
203 first_guess (int): Initial guess for the score (often 0).
204 max_depth (int | None): Maximum search depth override.
205 ``None`` uses the instance default (``self.max_depth``).
206
207 Returns:
208 int: The evaluation score of the move.
209
210 Example:
211 ```python
212 from bitbully import BitBully, Board
213
214 agent = BitBully()
215 board = Board()
216 score = agent.score_move(board, column=3)
217 assert score == 1 # Score for the center column on an empty board
218 ```
219
220 Example:
221 Score a move using depth-limited search (faster, approximate):
222 ```python
223 from bitbully import BitBully, Board
224
225 agent = BitBully()
226 board = Board()
227 score = agent.score_move(board, column=3, max_depth=12)
228 assert isinstance(score, int)
229 ```
230
231 Raises:
232 ValueError: If the column is outside the valid range or if the column is full.
233
234 Notes:
235 - This is a wrapper around
236 [`bitbully_core.BitBullyCore.scoreMove`][src.bitbully.bitbully_core.BitBullyCore.scoreMove].
237 """
238 if not board.is_legal_move(column):
239 raise ValueError(f"Column {column} is either full or invalid; cannot score move.")
240
241 effective_max_depth = max_depth if max_depth is not None else self.max_depth
242 return int(self._core.scoreMove(board.native, column, first_guess, max_depth=effective_max_depth))
243
244 def score_all_moves(self, board: Board, *, max_depth: int | None = None) -> dict[int, int]:
245 """Score all legal moves for the given board state.
246
247 Args:
248 board (Board): The current board state.
249 max_depth (int | None): Maximum search depth override.
250 ``None`` uses the instance default (``self.max_depth``).
251
252 Returns:
253 dict[int, int]:
254 A dictionary of up to 7 column-value pairs, one per reachable column (0-6).
255 Higher values generally indicate better moves for the player to move. If a
256 column is full, it will not be listed in the returned dictionary.
257
258 Example:
259 ```python
260 from bitbully import BitBully, Board
261
262 agent = BitBully()
263 board = Board()
264 scores = agent.score_all_moves(board)
265 assert scores == {3: 1, 2: 0, 4: 0, 1: -1, 5: -1, 0: -2, 6: -2} # Center column is best on an empty board
266 ```
267
268 Example:
269 When a column is full, it is omitted from the results:
270 ```python
271 from bitbully import BitBully, Board
272
273 agent = BitBully()
274 board = Board(6 * "3") # fill center column
275 scores = agent.score_all_moves(board)
276 assert scores == {2: 1, 4: 1, 1: 0, 5: 0, 0: -1, 6: -1} # Column 3 is full and thus omitted
277 ```
278
279 Example:
280 Use depth-limited search for faster (approximate) scoring:
281 ```python
282 from bitbully import BitBully, Board
283
284 agent = BitBully()
285 board = Board()
286 scores = agent.score_all_moves(board, max_depth=12)
287 assert len(scores) == 7 # all columns playable on an empty board
288 ```
289 """
290 effective_max_depth = max_depth if max_depth is not None else self.max_depth
291 scores = self._core.scoreMoves(board.native, max_depth=effective_max_depth)
292 column_values = {
293 col: val for (col, val) in enumerate(scores) if val > -100
294 } # invalid moves have score less than -100
295 return dict(sorted(column_values.items(), key=operator.itemgetter(1), reverse=True))
296
298 self,
299 board: Board,
300 *,
301 tie_break: TieBreakStrategy | None = None,
302 rng: random.Random | None = None,
303 max_depth: int | None = None,
304 ) -> int:
305 """Return the best legal move (column index) for the current player.
306
307 All legal moves are scored using :meth:`score_all_moves`. The move(s)
308 with the highest score are considered best, and ties are resolved
309 according to ``tie_break``.
310
311 Tie-breaking strategies:
312 - ``None`` (default): Use the agent's default tie-breaking strategy (`self.tie_break`).
313 - ``"center"`` (default):
314 Prefer the move closest to the center column (3). If still tied,
315 choose the smaller column index.
316 - ``"leftmost"``:
317 Choose the smallest column index among tied moves.
318 - ``"random"``:
319 Choose uniformly at random among tied moves. An optional
320 ``rng`` can be provided for reproducibility.
321
322 Args:
323 board (Board): The current board state.
324 tie_break (TieBreakStrategy | None):
325 Strategy used to resolve ties between equally scoring moves.
326 rng (random.Random | None):
327 Random number generator used when ``tie_break="random"``.
328 If ``None``, the agent's (`self.rng`) RNG is used.
329 max_depth (int | None): Maximum search depth override.
330 ``None`` uses the instance default (``self.max_depth``).
331
332 Returns:
333 int: The selected column index (0-6).
334
335 Raises:
336 ValueError: If there are no legal moves (board is full) or
337 if an unknown tie-breaking strategy is specified.
338
339 Example:
340 ```python
341 from bitbully import BitBully, Board
342 import random
343
344 agent = BitBully()
345 board = Board()
346 best_col = agent.best_move(board)
347 assert best_col == 3 # Center column is best on an empty board
348 ```
349
350 Example:
351 ```python
352 from bitbully import BitBully, Board
353 import random
354
355 agent = BitBully()
356 board = Board("341") # some arbitrary position
357 print(board)
358 assert agent.best_move(board, tie_break="center") == 3 # Several moves are tied; center is preferred
359 assert agent.best_move(board, tie_break="leftmost") == 1 # Leftmost among tied moves
360 assert agent.best_move(board, tie_break="random") in {1, 3, 4} # Random among tied moves
361
362 rng = random.Random(42) # use own random number generator
363 assert agent.best_move(board, tie_break="random", rng=rng) in {1, 3, 4}
364 ```
365 Expected Output:
366 ```
367 _ _ _ _ _ _ _
368 _ _ _ _ _ _ _
369 _ _ _ _ _ _ _
370 _ _ _ _ _ _ _
371 _ _ _ _ _ _ _
372 _ X _ X O _ _
373 ```
374
375 Example:
376 Use depth-limited search for a fast (approximate) best move:
377 ```python
378 from bitbully import BitBully, Board
379
380 agent = BitBully()
381 board = Board()
382 col = agent.best_move(board, max_depth=12)
383 assert 0 <= col <= 6
384 ```
385 """
386 scores = self.score_all_moves(board, max_depth=max_depth)
387 if not scores:
388 raise ValueError("No legal moves available (board appears to be full).")
389
390 best_score = max(scores.values())
391 best_cols = [c for c, s in scores.items() if s == best_score]
392
393 if len(best_cols) == 1:
394 return best_cols[0]
395
396 if tie_break is None:
397 tie_break = self.tie_break
398 if rng is None:
399 rng = self.rng
400
401 if tie_break == "center":
402 # Prefer center column (3), then smaller index for stability.
403 return min(best_cols, key=lambda c: (abs(c - 3), c))
404
405 if tie_break == "leftmost":
406 return min(best_cols)
407
408 if tie_break == "random":
409 if rng is None:
410 return random.choice(best_cols)
411 return rng.choice(best_cols)
412
413 raise ValueError(f"Unknown tie-breaking strategy: {tie_break!r}")
414
416 self, board: Board, alpha: int = -1000, beta: int = 1000, depth: int = 0, *, max_depth: int | None = None
417 ) -> int:
418 """Evaluate a position using negamax search.
419
420 Args:
421 board (Board): The board position to evaluate.
422 alpha (int): Alpha bound.
423 beta (int): Beta bound.
424 depth (int): Search depth in plies.
425 max_depth (int | None): Maximum search depth override.
426 ``None`` uses the instance default (``self.max_depth``).
427
428 Returns:
429 int: The evaluation score returned by the engine.
430
431 Example:
432 ```python
433 from bitbully import BitBully, Board
434
435 agent = BitBully()
436 board = Board()
437 score = agent.negamax(board)
438 assert score == 1 # Expected score for an empty board
439 ```
440
441 Example:
442 Depth-limited negamax (rollout at depth 12):
443 ```python
444 from bitbully import BitBully, Board
445
446 agent = BitBully()
447 board = Board("334411")
448 score = agent.negamax(board, max_depth=12)
449 assert isinstance(score, int)
450 ```
451 """
452 effective_max_depth = max_depth if max_depth is not None else self.max_depth
453 return int(
454 self._core.negamax(
455 board.native,
456 alpha=alpha,
457 beta=beta,
458 depth=depth,
459 max_depth=effective_max_depth,
460 )
461 )
462
463 def null_window(self, board: Board, *, max_depth: int | None = None) -> int:
464 """Evaluate a position using a null-window search.
465
466 Args:
467 board (Board): The board position to evaluate.
468 max_depth (int | None): Maximum search depth override.
469 ``None`` uses the instance default (``self.max_depth``).
470
471 Returns:
472 int: The evaluation score.
473
474 Example:
475 ```python
476 from bitbully import BitBully, Board
477
478 agent = BitBully()
479 board = Board()
480 score = agent.null_window(board)
481 assert score == 1 # Expected score for an empty board
482 ```
483
484 Example:
485 Depth-limited null-window search:
486 ```python
487 from bitbully import BitBully, Board
488
489 agent = BitBully()
490 board = Board("334411")
491 score = agent.null_window(board, max_depth=12)
492 assert isinstance(score, int)
493 ```
494 """
495 effective_max_depth = max_depth if max_depth is not None else self.max_depth
496 return int(self._core.nullWindow(board.native, max_depth=effective_max_depth))
497
498 def mtdf(self, board: Board, first_guess: int = 0, *, max_depth: int | None = None) -> int:
499 """Evaluate a position using the MTD(f) algorithm.
500
501 Args:
502 board (Board): The board position to evaluate.
503 first_guess (int): Initial guess for the score (often 0).
504 max_depth (int | None): Maximum search depth override.
505 ``None`` uses the instance default (``self.max_depth``).
506
507 Returns:
508 int: The evaluation score.
509
510 Example:
511 ```python
512 from bitbully import BitBully, Board
513
514 agent = BitBully()
515 board = Board()
516 score = agent.mtdf(board)
517 assert score == 1 # Expected score for an empty board
518 ```
519
520 Example:
521 Depth-limited MTD(f) (rollout at depth 12):
522 ```python
523 from bitbully import BitBully, Board
524
525 agent = BitBully()
526 board = Board("334411")
527 score = agent.mtdf(board, max_depth=12)
528 assert isinstance(score, int)
529 ```
530 """
531 effective_max_depth = max_depth if max_depth is not None else self.max_depth
532 return int(self._core.mtdf(board.native, first_guess=first_guess, max_depth=effective_max_depth))
533
534 @staticmethod
535 def score_to_moves_left(score: int, board: Board) -> int:
536 """Convert a solver score to the number of moves until the game ends.
537
538 Given a score returned by the solver (e.g., from
539 :meth:`mtdf` or :meth:`score_all_moves`) and the board state,
540 returns how many moves remain until the game concludes under
541 perfect play.
542
543 Args:
544 score (int): The solver score. Positive means the current player
545 wins; negative means the current player loses; 0 means draw.
546 board (Board): The board state for which the score was computed.
547
548 Returns:
549 int: Number of moves remaining until the game ends.
550
551 Example:
552 A draw uses all remaining moves:
553 ```python
554 from bitbully import BitBully, Board
555
556 board = Board() # empty board, 42 moves left
557 assert BitBully.score_to_moves_left(0, board) == 42
558 ```
559
560 Example:
561 Maximum score means the current player wins on the very next move:
562 ```python
563 from bitbully import BitBully, Board
564
565 board = Board()
566 assert BitBully.score_to_moves_left(21, board) == 1
567 ```
568
569 Example:
570 Combining with the solver to find how many moves until
571 the game is decided:
572 ```python
573 from bitbully import BitBully, Board
574
575 agent = BitBully()
576 board = Board()
577 score = agent.mtdf(board)
578 moves_until_end = BitBully.score_to_moves_left(score, board)
579 assert moves_until_end == 41 # yellow barely wins
580 ```
581 """
582 return int(bitbully_core.BitBullyCore.scoreToMovesLeft(score, board.native))
583
584 def load_book(self, book: OpeningBookName | os.PathLike[str] | str) -> None:
585 """Load an opening book from a file path.
586
587 This is a thin wrapper around
588 [`bitbully_core.BitBullyCore.loadBook`][src.bitbully.bitbully_core.BitBullyCore.loadBook].
589
590 Args:
591 book (OpeningBookName | os.PathLike[str] | str):
592 Name/Identifier (see [`available_opening_books`][src.bitbully.solver.BitBully.available_opening_books])
593 or path of the opening book to load.
594
595 Raises:
596 ValueError:
597 If the book identifier/path is invalid or if loading the book fails.
598
599 Example:
600 ```python
601 from bitbully import BitBully
602 from pathlib import Path
603
604 which_book = BitBully.available_opening_books()[0] # e.g., "default"
605
606 agent = BitBully(opening_book=None) # start without book
607 assert agent.is_book_loaded() is False
608 agent.load_book(which_book) # load "default" book
609 assert agent.is_book_loaded() is True
610 ```
611
612 Example:
613 ```python
614 from bitbully import BitBully
615 from pathlib import Path
616 import bitbully_databases as bbd
617
618 which_book = BitBully.available_opening_books()[2] # e.g., "12-ply"
619 db_path = bbd.BitBullyDatabases.get_database_path(which_book)
620
621 agent = BitBully(opening_book=None) # start without book
622 assert agent.is_book_loaded() is False
623 agent.load_book(db_path)
624 assert agent.is_book_loaded() is True
625 ```
626 """
627 self._core.resetBook()
628 if _is_opening_book_name(book):
629 import bitbully_databases as bbd
630
631 db_path = bbd.BitBullyDatabases.get_database_path(book)
632 self.opening_book_type = book
633 elif isinstance(book, (os.PathLike, str)):
634 if isinstance(book, str) and not book.strip():
635 raise ValueError(f"Invalid book path: {book!r}")
636 db_path = Path(book)
637 self.opening_book_type = None
638 else:
639 raise ValueError(f"Invalid book identifier or path: {book!r}")
640
641 if not self._core.loadBook(db_path):
642 self.opening_book_type = None
643 self._core.resetBook()
644 raise ValueError(f"Failed to load opening book from path: {db_path}")
645
646 def reset_book(self) -> None:
647 """Unload the currently loaded opening book (if any).
648
649 This resets the engine to *search-only* mode until another
650 opening book is loaded.
651
652 Example:
653 ```python
654 from bitbully import BitBully
655
656 agent = BitBully() # per default, the 12-ply-dist book is loaded
657 assert agent.is_book_loaded() is True
658 agent.reset_book()
659 assert agent.is_book_loaded() is False
660 ```
661 """
662 self._core.resetBook()
663 self.opening_book_type = None
664
665 @classmethod
666 def available_opening_books(cls) -> tuple[OpeningBookName, ...]:
667 """Return the available opening book identifiers.
668
669 Returns:
670 tuple[OpeningBookName, ...]:
671 All supported opening book names, including ``"default"``.
672
673 Example:
674 ```python
675 from bitbully import BitBully
676
677 books = BitBully.available_opening_books()
678 print(books) # ('default', '8-ply', '12-ply', '12-ply-dist')
679 ```
680
681 """
682 return get_args(OpeningBookName)
int score_move(self, Board board, int column, int first_guess=0, *, int|None max_depth=None)
Definition solver.py:197
int negamax(self, Board board, int alpha=-1000, int beta=1000, int depth=0, *, int|None max_depth=None)
Definition solver.py:417
bool is_book_loaded(self)
Definition solver.py:144
int score_to_moves_left(int score, Board board)
Definition solver.py:535
int null_window(self, Board board, *, int|None max_depth=None)
Definition solver.py:463
int best_move(self, Board board, *, TieBreakStrategy|None tie_break=None, random.Random|None rng=None, int|None max_depth=None)
Definition solver.py:304
OpeningBookName|None opening_book_type
Definition solver.py:126
None reset_book(self)
Definition solver.py:646
None reset_transposition_table(self)
Definition solver.py:164
dict[int, int] score_all_moves(self, Board board, *, int|None max_depth=None)
Definition solver.py:244
None load_book(self, OpeningBookName|os.PathLike[str]|str book)
Definition solver.py:584
int get_node_counter(self)
Definition solver.py:168
None reset_node_counter(self)
Definition solver.py:190
None __init__(self, OpeningBookName|None opening_book="default", *, TieBreakStrategy|None tie_break=None, random.Random|None rng=None, int max_depth=-1)
Definition solver.py:101
tuple[OpeningBookName,...] available_opening_books(cls)
Definition solver.py:666
int mtdf(self, Board board, int first_guess=0, *, int|None max_depth=None)
Definition solver.py:498