BitBully 0.0.71
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
94 def __init__(self, opening_book: OpeningBookName | None = "default") -> None:
95 """Initialize the BitBully agent.
96
97 Args:
98 opening_book (OpeningBookName | None):
99 Which opening book to load.
100
101 - ``"default"``: Alias for ``"12-ply-dist"``.
102 - ``"8-ply"``: 8-ply book with win/loss values.
103 - ``"12-ply"``: 12-ply book with win/loss values.
104 - ``"12-ply-dist"``: 12-ply book with win/loss *and distance* values.
105 - ``None``: Disable opening-book usage entirely.
106
107 TODO: Example for initialization with different books.
108
109 """
110 self.opening_book_type: OpeningBookName | None = opening_book
111
112 if opening_book is None:
113 self._core = bitbully_core.BitBullyCore()
114 return
115
116 import bitbully_databases as bbd
117
118 db_path = bbd.BitBullyDatabases.get_database_path(opening_book)
119 self._core = bitbully_core.BitBullyCore(Path(db_path))
120
121 def __repr__(self) -> str:
122 """Return a concise string representation of the BitBully agent."""
123 return f"BitBully(opening_book={self.opening_book_type!r}, book_loaded={self.is_book_loaded()})"
124
125 def is_book_loaded(self) -> bool:
126 """Check whether an opening book is loaded.
127
128 Returns:
129 bool: ``True`` if an opening book is loaded, otherwise ``False``.
130
131 Example:
132 ```python
133 from bitbully import BitBully
134
135 agent = BitBully() # per default, the 12-ply-dist book is loaded
136 assert agent.is_book_loaded() is True
137
138 # Unload the book
139 agent.reset_book()
140 assert agent.is_book_loaded() is False
141 ```
142 """
143 return bool(self._core.isBookLoaded())
144
145 def reset_transposition_table(self) -> None:
146 """Clear the internal transposition table."""
147 self._core.resetTranspositionTable()
148
149 def get_node_counter(self) -> int:
150 """Return the number of nodes visited since the last reset.
151
152 Returns:
153 int: Number of visited nodes.
154
155 Example:
156 ```python
157 from bitbully import BitBully, Board
158
159 agent = BitBully()
160 board = Board()
161 _ = agent.score_all_moves(board)
162 print(f"Nodes visited: {agent.get_node_counter()}")
163
164 # Note that has to be reset manually:
165 agent.reset_node_counter()
166 assert agent.get_node_counter() == 0
167 ```
168 """
169 return int(self._core.getNodeCounter())
170
171 def reset_node_counter(self) -> None:
172 """Reset the internal node counter.
173
174 See Also: [`get_node_counter`][src.bitbully.solver.BitBully.get_node_counter] for usage.
175 """
176 self._core.resetNodeCounter()
177
178 def score_move(self, board: Board, column: int, first_guess: int = 0) -> int:
179 """Evaluate a single move for the given board state.
180
181 Args:
182 board (Board): The current board state.
183 column (int): Column index (0-6) of the move to evaluate.
184 first_guess (int): Initial guess for the score (often 0).
185
186 Returns:
187 int: The evaluation score of the move.
188
189 Example:
190 ```python
191 from bitbully import BitBully, Board
192
193 agent = BitBully()
194 board = Board()
195 score = agent.score_move(board, column=3)
196 assert score == 1 # Score for the center column on an empty board
197 ```
198
199 Raises:
200 ValueError: If the column is outside the valid range or if the column is full.
201
202 Notes:
203 - This is a wrapper around
204 [`bitbully_core.BitBullyCore.scoreMove`][src.bitbully.bitbully_core.BitBullyCore.scoreMove].
205 """
206 if not board.is_legal_move(column):
207 raise ValueError(f"Column {column} is either full or invalid; cannot score move.")
208
209 return int(self._core.scoreMove(board.native, column, first_guess))
210
211 def score_all_moves(self, board: Board) -> dict[int, int]:
212 """Score all legal moves for the given board state.
213
214 Args:
215 board (Board): The current board state.
216
217 Returns:
218 dict[int, int]:
219 A dictionary of up to 7 column-value pairs, one per reachable column (0-6).
220 Higher values generally indicate better moves for the player to move. If a
221 column is full, it will not be listed in the returned dictionary.
222
223 Example:
224 ```python
225 from bitbully import BitBully, Board
226
227 agent = BitBully()
228 board = Board()
229 scores = agent.score_all_moves(board)
230 assert scores == {3: 1, 2: 0, 4: 0, 1: -1, 5: -1, 0: -2, 6: -2} # Center column is best on an empty board
231 ```
232
233 Example:
234 When a column is full, it is omitted from the results:
235 ```python
236 from bitbully import BitBully, Board
237
238 agent = BitBully()
239 board = Board(6 * "3") # fill center column
240 scores = agent.score_all_moves(board)
241 assert scores == {2: 1, 4: 1, 1: 0, 5: 0, 0: -1, 6: -1} # Column 3 is full and thus omitted
242 ```
243 """
244 scores = self._core.scoreMoves(board.native)
245 column_values = {
246 col: val for (col, val) in enumerate(scores) if val > -100
247 } # invalid moves have score less than -100
248 return dict(sorted(column_values.items(), key=operator.itemgetter(1), reverse=True))
249
251 self,
252 board: Board,
253 *,
254 tie_break: TieBreakStrategy = "center",
255 rng: random.Random | None = None,
256 ) -> int:
257 """Return the best legal move (column index) for the current player.
258
259 All legal moves are scored using :meth:`score_all_moves`. The move(s)
260 with the highest score are considered best, and ties are resolved
261 according to ``tie_break``.
262
263 Tie-breaking strategies:
264 - ``"center"`` (default):
265 Prefer the move closest to the center column (3). If still tied,
266 choose the smaller column index.
267 - ``"leftmost"``:
268 Choose the smallest column index among tied moves.
269 - ``"random"``:
270 Choose uniformly at random among tied moves. An optional
271 ``rng`` can be provided for reproducibility.
272
273 Args:
274 board (Board): The current board state.
275 tie_break (TieBreakStrategy):
276 Strategy used to resolve ties between equally scoring moves.
277 rng (random.Random | None):
278 Random number generator used when ``tie_break="random"``.
279 If ``None``, the global RNG is used.
280
281 Returns:
282 int: The selected column index (0-6).
283
284 Raises:
285 ValueError: If there are no legal moves (board is full) or
286 if an unknown tie-breaking strategy is specified.
287
288 Example:
289 ```python
290 from bitbully import BitBully, Board
291 import random
292
293 agent = BitBully()
294 board = Board()
295 best_col = agent.best_move(board)
296 assert best_col == 3 # Center column is best on an empty board
297 ```
298
299 Example:
300 ```python
301 from bitbully import BitBully, Board
302 import random
303
304 agent = BitBully()
305 board = Board("341") # some arbitrary position
306 print(board)
307 assert agent.best_move(board, tie_break="center") == 3 # Several moves are tied; center is preferred
308 assert agent.best_move(board, tie_break="leftmost") == 1 # Leftmost among tied moves
309 assert agent.best_move(board, tie_break="random") in {1, 3, 4} # Random among tied moves
310
311 rng = random.Random(42) # use own random number generator
312 assert agent.best_move(board, tie_break="random", rng=rng) in {1, 3, 4}
313 ```
314 Expected Output:
315 ```
316 _ _ _ _ _ _ _
317 _ _ _ _ _ _ _
318 _ _ _ _ _ _ _
319 _ _ _ _ _ _ _
320 _ _ _ _ _ _ _
321 _ X _ X O _ _
322 ```
323 """
324 scores = self.score_all_moves(board)
325 if not scores:
326 raise ValueError("No legal moves available (board appears to be full).")
327
328 best_score = max(scores.values())
329 best_cols = [c for c, s in scores.items() if s == best_score]
330
331 if len(best_cols) == 1:
332 return best_cols[0]
333
334 if tie_break == "center":
335 # Prefer center column (3), then smaller index for stability.
336 return min(best_cols, key=lambda c: (abs(c - 3), c))
337
338 if tie_break == "leftmost":
339 return min(best_cols)
340
341 if tie_break == "random":
342 if rng is None:
343 return random.choice(best_cols)
344 return rng.choice(best_cols)
345
346 raise ValueError(f"Unknown tie-breaking strategy: {tie_break!r}")
347
348 def negamax(self, board: Board, alpha: int = -1000, beta: int = 1000, depth: int = 0) -> int:
349 """Evaluate a position using negamax search.
350
351 Args:
352 board (Board): The board position to evaluate.
353 alpha (int): Alpha bound.
354 beta (int): Beta bound.
355 depth (int): Search depth in plies.
356
357 Returns:
358 int: The evaluation score returned by the engine.
359
360 Example:
361 ```python
362 from bitbully import BitBully, Board
363
364 agent = BitBully()
365 board = Board()
366 score = agent.negamax(board)
367 assert score == 1 # Expected score for an empty board
368 ```
369 """
370 return int(
371 self._core.negamax(
372 board.native,
373 alpha=alpha,
374 beta=beta,
375 depth=depth,
376 )
377 )
378
379 def null_window(self, board: Board) -> int:
380 """Evaluate a position using a null-window search.
381
382 Args:
383 board (Board): The board position to evaluate.
384
385 Returns:
386 int: The evaluation score.
387
388 Example:
389 ```python
390 from bitbully import BitBully, Board
391
392 agent = BitBully()
393 board = Board()
394 score = agent.null_window(board)
395 assert score == 1 # Expected score for an empty board
396 ```
397 """
398 return int(self._core.nullWindow(board.native))
399
400 def mtdf(self, board: Board, first_guess: int = 0) -> int:
401 """Evaluate a position using the MTD(f) algorithm.
402
403 Args:
404 board (Board): The board position to evaluate.
405 first_guess (int): Initial guess for the score (often 0).
406
407 Returns:
408 int: The evaluation score.
409
410 Example:
411 ```python
412 from bitbully import BitBully, Board
413
414 agent = BitBully()
415 board = Board()
416 score = agent.mtdf(board)
417 assert score == 1 # Expected score for an empty board
418 ```
419 """
420 return int(self._core.mtdf(board.native, first_guess=first_guess))
421
422 def load_book(self, book: OpeningBookName | os.PathLike[str] | str) -> None:
423 """Load an opening book from a file path.
424
425 This is a thin wrapper around
426 [`bitbully_core.BitBullyCore.loadBook`][src.bitbully.bitbully_core.BitBullyCore.loadBook].
427
428 Args:
429 book (OpeningBookName | os.PathLike[str] | str):
430 Name/Identifier (see [`available_opening_books`][src.bitbully.solver.BitBully.available_opening_books])
431 or path of the opening book to load.
432
433 Raises:
434 ValueError:
435 If the book identifier/path is invalid or if loading the book fails.
436
437 Example:
438 ```python
439 from bitbully import BitBully
440 from pathlib import Path
441
442 which_book = BitBully.available_opening_books()[0] # e.g., "default"
443
444 agent = BitBully(opening_book=None) # start without book
445 assert agent.is_book_loaded() is False
446 agent.load_book(which_book) # load "default" book
447 assert agent.is_book_loaded() is True
448 ```
449
450 Example:
451 ```python
452 from bitbully import BitBully
453 from pathlib import Path
454 import bitbully_databases as bbd
455
456 which_book = BitBully.available_opening_books()[2] # e.g., "12-ply"
457 db_path = bbd.BitBullyDatabases.get_database_path(which_book)
458
459 agent = BitBully(opening_book=None) # start without book
460 assert agent.is_book_loaded() is False
461 agent.load_book(db_path)
462 assert agent.is_book_loaded() is True
463 ```
464 """
465 self._core.resetBook()
466 if _is_opening_book_name(book):
467 import bitbully_databases as bbd
468
469 db_path = bbd.BitBullyDatabases.get_database_path(book)
470 self.opening_book_type = book
471 elif isinstance(book, (os.PathLike, str)):
472 if isinstance(book, str) and not book.strip():
473 raise ValueError(f"Invalid book path: {book!r}")
474 db_path = Path(book)
475 self.opening_book_type = None
476 else:
477 raise ValueError(f"Invalid book identifier or path: {book!r}")
478
479 if not self._core.loadBook(db_path):
480 self.opening_book_type = None
481 self._core.resetBook()
482 raise ValueError(f"Failed to load opening book from path: {db_path}")
483
484 def reset_book(self) -> None:
485 """Unload the currently loaded opening book (if any).
486
487 This resets the engine to *search-only* mode until another
488 opening book is loaded.
489
490 Example:
491 ```python
492 from bitbully import BitBully
493
494 agent = BitBully() # per default, the 12-ply-dist book is loaded
495 assert agent.is_book_loaded() is True
496 agent.reset_book()
497 assert agent.is_book_loaded() is False
498 ```
499 """
500 self._core.resetBook()
501 self.opening_book_type = None
502
503 @classmethod
504 def available_opening_books(cls) -> tuple[OpeningBookName, ...]:
505 """Return the available opening book identifiers.
506
507 Returns:
508 tuple[OpeningBookName, ...]:
509 All supported opening book names, including ``"default"``.
510
511 Example:
512 ```python
513 from bitbully import BitBully
514
515 books = BitBully.available_opening_books()
516 print(books) # ('default', '8-ply', '12-ply', '12-ply-dist')
517 ```
518
519 """
520 return get_args(OpeningBookName)
bool is_book_loaded(self)
Definition solver.py:125
None __init__(self, OpeningBookName|None opening_book="default")
Definition solver.py:94
OpeningBookName|None opening_book_type
Definition solver.py:110
None reset_book(self)
Definition solver.py:484
None reset_transposition_table(self)
Definition solver.py:145
dict[int, int] score_all_moves(self, Board board)
Definition solver.py:211
None load_book(self, OpeningBookName|os.PathLike[str]|str book)
Definition solver.py:422
int get_node_counter(self)
Definition solver.py:149
None reset_node_counter(self)
Definition solver.py:171
int best_move(self, Board board, *, TieBreakStrategy tie_break="center", random.Random|None rng=None)
Definition solver.py:256
int mtdf(self, Board board, int first_guess=0)
Definition solver.py:400
int negamax(self, Board board, int alpha=-1000, int beta=1000, int depth=0)
Definition solver.py:348
tuple[OpeningBookName,...] available_opening_books(cls)
Definition solver.py:504
int score_move(self, Board board, int column, int first_guess=0)
Definition solver.py:178
int null_window(self, Board board)
Definition solver.py:379