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