-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathChessAI.py
More file actions
3018 lines (2618 loc) · 114 KB
/
Copy pathChessAI.py
File metadata and controls
3018 lines (2618 loc) · 114 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Search & Evaluation
"""
"""
Handling the AI moves.
Scores - Chess.com
Depth=4, Time/move=30: Won vs 1000 rated bot
Depth=4, Time/move=30: Won vs 1300 rated bot
Depth=4, Time/move=30: Won vs 1400 rated bot - 38 moves
Depth=4, Time/move=30: Lost vs 1500 rated bot - 33 moves
-- Added more evaluation enhancements (13 in total) --
Depth=4, Time/move=30: Won vs 1500 rated bot - 60 moves
-- Added more evaluation enhancements (18 in total), added new class to make things more efficient --
Depth=4, Time/move=30: Draw (Stalemate) vs 1700 rated bot - 100 moves
-- Added more evaluation enhancements (21 in total), big improvements to all the files in efficiency and functionality --
Depth=10, Time/move=30: Draw (Insufficient Material) vs 1800 rated bot - 88 moves (Threw winning game) (Reached depth 5-6 on average, max 7)
-- Added more evaluation enhancements (23 in total), improved values of the previous functions --
Depth=10, Time/move=30: Lost vs 1900 rated bot - <50 moves (Played terrible due to drop in depth and got checkmated) (Reached depth 4-5 on average, max 6)
-- Rework in calculation going from decimals to integers (cp score) --
Depth=10, Time/move=30: Won vs 1900 rated bot - 47 moves (Almost a domination) (Depth: 6.57 on average, max 8, min 6)
Depth=10, Time/move=30: Won vs 2200 rated bot - 57 moves (Comfortable win) (Depth: 5.5 on average, max 7, min 4)
Depth=10, Time/move=30: Lost vs 2400 rated bot - 28 moves (Got destroyed) (Depth: 5.77 on average, max 7, min 4)
-- Added a Opening Book --
Depth=10, Time/move=30: Won vs 2400 rated bot - 69 moves (Well played) (Depth: 6.16 on average, max 8, min 5)
Depth=10, Time/move=30: Lost vs 2700 rated bot - 63 moves (Some clear mistakes) (Depth: 7.2 on average, max 9, min 5)
Depth=30, Time/move=30: Lost vs 2600 rated bot - 81 moves (Threw a completely Drawn endgame) (Depth: 8.0 on average, max 14, min 2)
Depth=30, Time/move=30: Draw vs 2600 rated bot - 22 moves () (Depth: on average, max , min )
"""
import random
import time
import chess
from opening_book import BookManager
from collections import defaultdict, namedtuple
piece_score = {"K": 0.0, "Q": 9.75, "R": 5.0, "B": 3.25, "N": 3.25, "p": 1.0}
# Knights: center good, but modest. Max ≈ +0.40
knight_scores = [
[ 0.00, 0.00, 0.05, 0.10, 0.10, 0.05, 0.00, 0.00],
[ 0.00, 0.10, 0.20, 0.25, 0.25, 0.20, 0.10, 0.00],
[ 0.05, 0.20, 0.30, 0.35, 0.35, 0.30, 0.20, 0.05],
[ 0.10, 0.25, 0.35, 0.43, 0.43, 0.35, 0.25, 0.10],
[ 0.10, 0.25, 0.35, 0.43, 0.43, 0.35, 0.25, 0.10],
[ 0.05, 0.20, 0.30, 0.35, 0.35, 0.30, 0.20, 0.05],
[ 0.00, 0.10, 0.20, 0.25, 0.25, 0.20, 0.10, 0.00],
[ 0.00, 0.00, 0.05, 0.10, 0.10, 0.05, 0.00, 0.00],
]
# Bishops: prefer long diagonals/center. Max ≈ +0.30
bishop_scores = [
[0.00, 0.05, 0.10, 0.15, 0.15, 0.10, 0.05, 0.00],
[0.05, 0.10, 0.15, 0.20, 0.20, 0.15, 0.10, 0.05],
[0.05, 0.15, 0.20, 0.25, 0.25, 0.20, 0.15, 0.05],
[0.10, 0.20, 0.25, 0.30, 0.30, 0.25, 0.20, 0.10],
[0.10, 0.20, 0.25, 0.30, 0.30, 0.25, 0.20, 0.10],
[0.05, 0.15, 0.20, 0.25, 0.25, 0.20, 0.15, 0.05],
[0.05, 0.10, 0.15, 0.20, 0.20, 0.15, 0.10, 0.05],
[0.00, 0.05, 0.10, 0.15, 0.15, 0.10, 0.05, 0.00],
]
# Rooks: very light PST (real bonuses come from open/semi-open/7th-rank features)
rook_scores = [
[0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
[0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
[0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
[0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
[0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
[0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
[0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
[0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
]
# Queens: keep flat; queen play is handled by other features.
queen_scores = [
[0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
[0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
[0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
[0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
[0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
[0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
[0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
[0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
]
# Pawns: reward healthy advance (small), instead of anchoring on rank 2.
pawn_scores = [
[0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], # 8
[0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35], # 7
[0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25], # 6
[0.18, 0.18, 0.18, 0.18, 0.18, 0.18, 0.18, 0.18], # 5
[0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12], # 4
[0.08, 0.08, 0.08, 0.08, 0.08, 0.08, 0.08, 0.08], # 3
[0.04, 0.04, 0.04, 0.04, 0.04, 0.04, 0.04, 0.04], # 2
[0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], # 1
]
piece_position_scores = {"wN": knight_scores,
"bN": knight_scores[::-1],
"wB": bishop_scores,
"bB": bishop_scores[::-1],
"wQ": queen_scores,
"bQ": queen_scores[::-1],
"wR": rook_scores,
"bR": rook_scores[::-1],
"wp": pawn_scores,
"bp": pawn_scores[::-1]}
BISHOP_PAIR = 50
ROOK_OPEN = 20
# a zero table for kings
king_zero = [[0.0]*8 for _ in range(8)]
piece_position_scores['wK'] = king_zero
piece_position_scores['bK'] = [row[:] for row in king_zero[::-1]]
# flattened PSTs for O(1) indexed access (values unchanged)
def _flatten2d(mat):
return tuple(v for row in mat for v in row)
PST_FLAT = {k: _flatten2d(v) for k, v in piece_position_scores.items()}
# --- Unit conversion helpers (pawns → centipawns) ---
PAWN = 100
def to_cp(pawn_value: float) -> int:
"""Convert white-positive pawn units (float) to centipawns (int)."""
return int(round(pawn_value * PAWN))
def pc_cp(pt: str) -> int:
"""Piece value in cp from piece_score dict (kept in pawns by user request)."""
return int(round(piece_score[pt] * PAWN))
# Derived absolute piece magnitudes in cp (for ordering like MVV-LVA)
PIECE_CP = {k: int(round(v * PAWN)) for k, v in piece_score.items()}
def sq_index(row, col):
""" Map (r,c) -> 0..63."""
return (row << 3) | col
def mirror_vert(sq):
"""Flip square vertically (for quick black/white mirroring if needed)."""
return sq ^ 56
# Transposition Table with Zobrist Hashing: to avoid re-searching identical positions - shaves 30-50% off the node count.
# Zobrist hashing (deterministic RNG)
PIECES = ['wp','wN','wB','wR','wQ','wK','bp','bN','bB','bR','bQ','bK']
# Use a dedicated RNG so Zobrist is stable across runs and doesn't perturb global random.
_zrnd = random.Random(0x9E3779B97F4A7C15) # arbitrary fixed seed
ZOBRIST_PIECE = {p: [_zrnd.getrandbits(64) for _ in range(64)] for p in PIECES}
ZOBRIST_SIDE = _zrnd.getrandbits(64)
ZOBRIST_CASTLING = [_zrnd.getrandbits(64) for _ in range(4)] # Four castling‐rights bits: wK, wQ, bK, bQ
ZOBRIST_ENPASSANT = [_zrnd.getrandbits(64) for _ in range(8)] # Eight en‐passant files a–h
# --- Transposition Table (fixed-size, dict-compatible wrapper) ---
EXACT, LOWERBOUND, UPPERBOUND = 0, 1, 2
TTEntry = namedtuple('TTEntry', 'depth flag value best_move')
TT_SIZE = 1 << 20 # ~1M buckets
class TranspositionTable:
__slots__ = ('table', 'mask')
def __init__(self, size=TT_SIZE):
assert size & (size - 1) == 0, "TT size must be a power of two"
self.table = [None] * size
self.mask = size - 1
def get(self, zkey, default=None):
slot = self.table[zkey & self.mask]
if slot is None:
return default
key, depth, flag, value, best_move = slot
return TTEntry(depth, flag, value, best_move) if key == zkey else default
def __getitem__(self, zkey):
e = self.get(zkey)
if e is None:
raise KeyError(zkey)
return e
def __setitem__(self, zkey, entry):
if not isinstance(entry, TTEntry):
entry = TTEntry(*entry)
idx = zkey & self.mask
cur = self.table[idx]
if cur is None:
self.table[idx] = (zkey, entry.depth, entry.flag, entry.value, entry.best_move)
return
_, d, f, _, _ = cur
pri = lambda flag: 2 if flag == EXACT else (1 if flag == LOWERBOUND else 0)
if (entry.depth > d) or (entry.depth == d and pri(entry.flag) > pri(f)):
self.table[idx] = (zkey, entry.depth, entry.flag, entry.value, entry.best_move)
def clear(self):
self.table[:] = [None] * len(self.table)
# Instantiate TT with same variable name used elsewhere
TT = TranspositionTable()
# History table (array-backed, dict-compatible)
MAX_MOVES = 1 << 16 # adjust if your moveIDs exceed this
class HistoryTable:
__slots__ = ('arr', 'overflow', 'size')
def __init__(self, size=MAX_MOVES):
self.size = size
self.arr = [0] * size
self.overflow = defaultdict(int) # fallback for non-int/large IDs
def __getitem__(self, k):
if isinstance(k, int) and 0 <= k < self.size:
return self.arr[k]
return self.overflow[k]
def __setitem__(self, k, v):
if isinstance(k, int) and 0 <= k < self.size:
self.arr[k] = v
else:
self.overflow[k] = v
def get(self, k, default=0):
if isinstance(k, int) and 0 <= k < self.size:
return self.arr[k]
return self.overflow.get(k, default)
def add(self, k, delta):
if isinstance(k, int) and 0 <= k < self.size:
self.arr[k] += delta
else:
self.overflow[k] += delta
def clear(self):
self.arr[:] = [0] * self.size
self.overflow.clear()
history_scores = HistoryTable()
# Search timing / globals
CHECKMATE = 30000 # score for checkmate
STALEMATE = 0 # score for stalemate. Better than losing, worse than winning. So, White checkmate = +30000 and Black checkmate = -30000
DEPTH = 30 # depth of the search tree aka how many moves ahead the AI will look
TIME_PER_MOVE = 30.0 # seconds per move
# --- Light pruning knobs (centipawns) ---
RAZOR_MARGIN = 165 # only at depth==1 and not in check
STATIC_BETA_MARGIN = 110 # depth<=3: prune when static_cp - d*margin >= beta
FUTILITY_BASE_MARGIN = 100 # depth<=2: skip very-late quiets if static far below alpha
# --- Null-move pruning knobs ---
NULLMOVE_MIN_DEPTH = 3 # only try when depth >= 3
NULLMOVE_R_BASE = 2 # reduction R = 2 or 3 (adaptive)
NULLMOVE_R_BONUS_D = 6 # add +1 reduction if depth >= this
# --- Internal Iterative Deepening (IID) ---
IID_MIN_DEPTH = 4 # only try when depth >= 4
IID_REDUCE = 2 # search at depth - 2 to find a TT move
# tuneables
QCHECKS_ENABLE = True
QCHECKS_MAX_PER_NODE = 1 # max quiet checks to try per node (often +30–60 Elo for tactical stability)
QCHECKS_SKIP_QUEEN = True # queen checks are often noisy/expensive
CONTEMPT = 0.10 # Tiny root-only draw bias (pawns). 0.10 is plenty.
CONTEMPT_CP = to_cp(CONTEMPT)
DEBUG_SEARCH = False
nodes_searched = 0 # global counter for nodes searched
now = time.perf_counter # high-resolution, monotonic timer
next_move = None
# Opening book config (single book)
# make sure at the top of the file you have: from opening_book import BookManager
BOOK_ENABLED = True
BOOK_MAIN_PATH = "mainbook.bin"
BOOK_RANDOM = False # set True if you want variety
BOOK_TEMPERATURE = 0.75 # used only when BOOK_RANDOM=True
BOOK_TOP_K = None # 0 = no restriction; else sample from top-k moves
DEBUG_BOOK = False # set True only when debugging book integration
FILE_LETTERS = "abcdefgh"
# Single global manager (safe for your AI worker thread)
try:
BOOK = BookManager(BOOK_MAIN_PATH)
except Exception as e:
print("[BOOK] load failed:", e)
BOOK = None
# --- “out of book” tracker for current game ---
BOOK_PROBE_ACTIVE = True # when False, skip book probes entirely
_BOOK_ZERO_HITS = 0 # count of consecutive zero-entry probes this game
def _maybe_reset_book_probe(gs):
"""Reset the book probe state at the start of a game (empty move log)."""
global BOOK_PROBE_ACTIVE, _BOOK_ZERO_HITS
if not gs.move_log: # new game or custom FEN with no moves played
BOOK_PROBE_ACTIVE = True
_BOOK_ZERO_HITS = 0
class SearchTimeout(Exception):
"""Raised when the allotted time per move is exceeded."""
pass
class _PrunedQuiet(Exception):
"""Internal control-flow to skip a pruned quiet move inside the move loop."""
__slots__ = ()
# Iteration deadline helpers (set by the search driver)
_deadline = None
def set_move_time(seconds: float):
global _deadline
_deadline = now() + seconds
def check_time():
if _deadline is not None and now() >= _deadline:
raise SearchTimeout
# --- Mate score (de)normalisation for TT ---
MATE_SCORE = CHECKMATE
MATE_MIN = MATE_SCORE - 1000 # band near mate
def score_to_tt(score: int, ply: int) -> int:
"""Normalise mate scores with ply so they remain comparable across depths."""
if score >= MATE_MIN:
return score + ply
if score <= -MATE_MIN:
return score - ply
return score
def score_from_tt(score: int, ply: int) -> int:
"""Restore mate scores from TT normalised values."""
if score >= MATE_MIN:
return score - ply
if score <= -MATE_MIN:
return score + ply
return score
# --- MVV-LVA capture ordering (uses your material scaled to cp) ---
PIECE_TO_IDX = {"p":0, "N":1, "B":2, "R":3, "Q":4, "K":5}
MVV_LVA = [[0]*6 for _ in range(6)]
for atk, ai in PIECE_TO_IDX.items():
for vic, vi in PIECE_TO_IDX.items():
victim_cp = PIECE_CP[vic]
attacker_cp = PIECE_CP[atk]
MVV_LVA[ai][vi] = victim_cp * 16 - attacker_cp # larger is better
def capture_score(m) -> int:
"""Positive ordering score for captures (bigger is better). Safe if no capture."""
if m.piece_captured == "--":
return 0
return MVV_LVA[PIECE_TO_IDX[m.piece_moved[1]]][PIECE_TO_IDX[m.piece_captured[1]]]
# --- Killers and counter-moves ---
MAX_PLY = 128
killers = [[0, 0] for _ in range(MAX_PLY)] # moveIDs per ply
counter_move = defaultdict(int) # key: prev_moveID -> moveID
def score_move(m, tt_move_id: int, ply: int, prev_move_id: int | None = None) -> int:
"""
Unified move ordering score:
TT > good captures (MVV-LVA, with SEE-lite penalty for losing trades)
> castles > killers > promotions > counter-move > history.
"""
# TT move always first
if m.moveID == tt_move_id:
return 1_000_000_000
score = 0
# Captures (MVV-LVA already scaled via capture_score)
if getattr(m, "is_capture", False):
score += 1_000_000 + capture_score(m)
# SEE-lite: penalize obviously losing captures (by static piece values),
# unless it's a promotion (which can compensate tactically).
if m.piece_captured != "--":
victim_cp = PIECE_CP[m.piece_captured[1]]
attacker_cp = PIECE_CP[m.piece_moved[1]]
if (victim_cp - attacker_cp) < 0 and not getattr(m, "is_pawn_promotion", False):
score -= 120_000
# Castling: search it early so the engine actually castles
if getattr(m, 'is_castle_move', False) or getattr(m, 'isCastleMove', False):
score += 850_000
# Killer moves
k0, k1 = killers[ply]
if m.moveID == k0:
score += 900_000
elif m.moveID == k1:
score += 800_000
# Promotions (quiet promotions get a bump too)
if getattr(m, "is_pawn_promotion", False):
promo = getattr(m, "promotion_choice", "Q")
promo_bonus = {"Q": 300_000, "R": 200_000, "N": 180_000, "B": 150_000}.get(promo, 250_000)
score += promo_bonus
# Counter-move heuristic (boost reply that refuted prev opp move before)
if prev_move_id is not None:
cm = counter_move.get(prev_move_id, 0)
if cm and m.moveID == cm:
score += 700_000
# History last
score += history_scores.get(m.moveID, 0)
return score
# EvalContext cache (bounded)
_eval_ctx_cache = {} # Cache EvalContext objects to avoid rebuilding them on identical positions
EVAL_CTX_CACHE_MAX = 100_000 # simple cap; tune as needed
EVAL_VERSION = 7
EVAL_USE_FAST_FROM_DEPTH = 3 # use fast features when depth is deep
EVAL_CACHE = {} # (zobrist, fast_bool) -> eval score
PROFILE = {"ctx_builds_fast":0, "ctx_builds_full":0, "score_calls_fast":0, "score_calls_full":0}
def _phase_hint(gs):
"""Cheap material phase in [0..1]; identical to ChessEngine._phase."""
bb = gs.bitboards
cnt = (bb['wN'].bit_count() + bb['bN'].bit_count()
+ bb['wB'].bit_count() + bb['bB'].bit_count()
+ 2 * (bb['wR'].bit_count() + bb['bR'].bit_count())
+ 4 * (bb['wQ'].bit_count() + bb['bQ'].bit_count()))
return min(24, cnt) / 24.0
def _use_fast_eval(gs, depth, is_pv, is_root=False):
"""
Policy:
- FULL at root, PV nodes, shallow leaves (depth<=2), and EG-ish.
- FAST otherwise.
"""
# FULL at root and PV
if is_root or is_pv:
return False
# FULL for shallow leaves
if depth <= 2:
return False
# FULL for EG-ish if you want (optional)
try:
if getattr(gs, 'small_endgame', False):
return False
except Exception:
pass
# otherwise FAST
return True
def _fmt_white_pawns(score_cp: int, white_to_move: bool) -> str:
"""Format centipawn score from White's perspective as ±X.XX pawns."""
white_cp = score_cp if white_to_move else -score_cp
return f"{white_cp/100:.2f}" if white_cp >= 0 else f"-{abs(white_cp)/100:.2f}"
def evaluate_cp(gs) -> int:
"""
White-positive evaluation in centipawns.
Uses GameState.current_eval (maintained incrementally in pawns) + fast features (cp).
Negate at the call site if you use negamax with side-to-move.
"""
# base material + PST in pawns → cp
score = to_cp(gs.current_eval)
return score
def _has_non_pawn_material(gs, white: bool) -> bool: # Some zugzwangy endings hate null moves; skip NMP (Null-Move Pruning) if side has only king+pawns.
bb = gs.bitboards
if white:
return bool(bb['wQ'] | bb['wR'] | bb['wB'] | bb['wN'])
else:
return bool(bb['bQ'] | bb['bR'] | bb['bB'] | bb['bN'])
def _king_danger_flag(gs):
# Immediate check is always "danger"
if gs.inCheck():
return True
phase = _phase(gs)
# Ring-based danger is a middlegame concept; skip it in late EG
if phase <= 0.35:
return False
def ring_hits(kr, kc, by_white, opp_kr, opp_kc):
h = 0
for rr in (kr-1, kr, kr+1):
if 0 <= rr < 8:
for cc in (kc-1, kc, kc+1):
if 0 <= cc < 8:
if rr == kr and cc == kc:
continue # skip the king’s own square
# Don't count the enemy king’s inherent adjacency attacks
if abs(rr - opp_kr) <= 1 and abs(cc - opp_kc) <= 1:
continue
if gs.is_square_attacked_bb(rr*8 + cc, by_white=by_white):
h += 1
return h
wkr, wkc = gs.white_king_location
bkr, bkc = gs.black_king_location
w_hits = ring_hits(wkr, wkc, by_white=False, opp_kr=bkr, opp_kc=bkc)
b_hits = ring_hits(bkr, bkc, by_white=True, opp_kr=wkr, opp_kc=wkc)
# Slightly stricter in MG
if w_hits >= 5 or b_hits >= 5:
return True
# e-pawn shield only matters early
if phase > 0.6:
board = gs.board
if board[6][4] != 'wp' and board[7][4] == 'wK': # white king still e1, e-pawn moved
return True
if board[1][4] != 'bp' and board[0][4] == 'bK': # black king still e8, e-pawn moved
return True
return False
def _gives_check(gs, m):
"""True if m gives check (without altering state long-term)."""
gs.makeMove(m)
try:
return gs.inCheck() # after move, opponent to move; if they're in check, our move gave check
finally:
gs.undoMove()
def get_eval_context(gs, is_white, fast=False):
"""
Cached EvalContext builder. If fast=True, builds a light context (skips heavy maps).
"""
key = (EVAL_VERSION, id(gs), gs.zobrist_key, is_white, bool(fast))
ctx = _eval_ctx_cache.get(key)
if ctx is None:
ctx = EvalContext(gs, is_white, light=fast)
if 'PROFILE' in globals():
if fast: PROFILE["ctx_builds_fast"] += 1
else: PROFILE["ctx_builds_full"] += 1
_eval_ctx_cache[key] = ctx
if len(_eval_ctx_cache) > EVAL_CTX_CACHE_MAX:
_eval_ctx_cache.clear()
_eval_ctx_cache[key] = ctx
return ctx
def _mate_score(ply: int) -> int:
"""
Return a mate score that prefers faster mates.
For the side to move, mate in N plies should be +(CHECKMATE - ply).
"""
return CHECKMATE - ply
def _is_mate_after(gs, m) -> bool:
"""True if playing m delivers immediate checkmate."""
gs.makeMove(m)
try:
return (not gs.getValidMoves()) and gs.inCheck()
finally:
gs.undoMove()
def _score_root_move(gs, m):
"""
Heuristic root-only ordering (centipawns).
Prioritizes checks, safe captures, promotions, castling, and early development.
Penalizes obviously losing captures and premature queen sorties.
"""
# Instant mate? Always first.
if _is_mate_after(gs, m):
return 1_000_000
# --- small helpers ---
def _dest_sq(move):
return move.end_row * 8 + move.end_col
def _mover_is_white_after_make():
# after makeMove(), side-to-move flips, so mover was the previous side
return not gs.white_to_move
# rough full-move number (white starts at FM1; every black move completes one full move)
fullmove = 1 + sum(1 for x in gs.move_log if x.piece_moved[0] == 'b')
phase = _phase(gs)
sc = 0
# --- base tactical terms ---
# Captures: MVV-LVA-ish bump
if m.piece_captured != "--":
sc += 1000 + (PIECE_CP[m.piece_captured[1]] - PIECE_CP[m.piece_moved[1]])
# Promotions (to anything): big bump
if getattr(m, "is_pawn_promotion", False):
sc += 800
# Castling: help engine do it sooner at root
if m.piece_moved[1] == 'K' and getattr(m, "is_castle_move", False):
sc += 400
# --- quick make/undo to evaluate check and destination safety for SEE-lite ---
gs.makeMove(m)
try:
gives_check = gs.inCheck()
if gives_check:
sc += 2000
mover_is_white = _mover_is_white_after_make()
dest_sq = _dest_sq(m)
# Is our landing square attacked/defended after the move?
attacked_by_opp = gs.is_square_attacked_bb(dest_sq, by_white=(not mover_is_white))
defended_by_us = gs.is_square_attacked_bb(dest_sq, by_white=mover_is_white)
# Lightweight SEE gate for captures: if it's a clearly losing capture, push it down.
if m.piece_captured != "--":
# material swing (captured - attacker) in cp
mat_gain = PIECE_CP[m.piece_captured[1]] - PIECE_CP[m.piece_moved[1]]
# losing capture: square attacked and not defended, or negative material swing
if (attacked_by_opp and not defended_by_us) or (mat_gain < 0):
sc -= 1500 # big enough to outrank the generic capture bonus
# Penalize “hanging” quiet moves (move lands on attacked square with no defense)
if m.piece_captured == "--" and attacked_by_opp and not defended_by_us:
sc -= 250
# Endgame micro-preferences for rook activity (ordering-only, cheap):
if phase < 0.45 and m.piece_moved[1] == 'R':
# prefer rook checks a bit in EG (on top of the generic check bonus)
if gives_check:
sc += 150
finally:
gs.undoMove()
# --- opening/early-game nudges (ordering only; eval stays authoritative) ---
# Early development: moving a minor off the back rank in the first ~8 full moves
if fullmove <= 8 and m.piece_moved[1] in ('N', 'B'):
home_row = 7 if gs.white_to_move else 0
if m.start_row == home_row:
sc += 120
# Premature queen development without check/capture in first ~8 full moves
if fullmove <= 8 and m.piece_moved[1] == 'Q' and m.piece_captured == "--":
# only penalize if it didn't already get a "gives_check" boost
# (gives_check is defined above inside the make/undo block and is in scope)
try:
if not gives_check:
sc -= 150
except NameError:
# safety: if something changes above, keep original behavior
sc -= 150
# Early f/g-pawn push before castling (ordering nudge; eval has the real penalty)
if fullmove <= 10 and m.piece_moved[1] == 'p' and m.piece_captured == "--":
# If king still on e1/e8 and not castled, nudge down f/g pawn pushes
if gs.white_to_move:
kr, kc = gs.white_king_location
castled = (kr, kc) in ((7, 6), (7, 2))
if not castled and (m.start_col in (5, 6)): # f or g file
sc -= 80
else:
kr, kc = gs.black_king_location
castled = (kr, kc) in ((0, 6), (0, 2))
if not castled and (m.start_col in (5, 6)):
sc -= 80
# Endgame: small nudge for rook to 7th/2nd (strictly ordering, phase-gated)
if phase < 0.45 and m.piece_moved[1] == 'R':
if (gs.white_to_move and m.end_row == 1) or ((not gs.white_to_move) and m.end_row == 6):
sc += 140
return sc
def find_forced_mate_k(gs, k, soft_time_ms=30):
"""
Try to prove a forced mate for the side-to-move within k plies
using a checks-only DFS (defender: evasions only).
Returns a PV as a list of Move objects [m0, m1, m2, ...] if found,
else None. Budgeted by soft_time_ms.
"""
start = time.perf_counter()
def time_up():
return (time.perf_counter() - start) * 1000.0 > soft_time_ms
# Quick terminal
root_moves = gs.getValidMoves()
if not root_moves:
return None # mate/stalemate is handled by main search
# Generate only checking moves at root
check_moves = [m for m in root_moves if _gives_check(gs, m)]
if not check_moves:
return None
# Prefer “heavier” checks first (MVV-LVA bias + promotions), in cp
def _root_check_key(m):
cap = PIECE_CP[m.piece_captured[1]] if m.piece_captured != "--" else 0
att = PIECE_CP[m.piece_moved[1]]
promo = 50 if getattr(m, "is_pawn_promotion", False) else 0
return (cap - att) + promo
check_moves.sort(key=_root_check_key, reverse=True)
# Inner recursive proof: after our checking move, defender to move and in check.
def prove_after_our_check(depth, ply_acc):
"""
Defender is in check. We must show: for EVERY legal evasion, there exists
a checking reply that continues the proof to mate within 'depth' plies.
"""
if time_up():
return None
evasions = gs.getValidMoves() # when in check, these are evasions
if not evasions:
# side to move is checkmated
return [] # empty tail; caller will prepend the previous move
if depth <= 0:
return None # ran out of proving depth
# For each evasion, we must find some checking reply that keeps the proof alive
for ev in evasions:
gs.makeMove(ev)
try:
# Our turn: must reply with a checking move
replies = [m for m in gs.getValidMoves() if _gives_check(gs, m)]
if not replies:
return None # this evasion breaks the force
ok = False
for rep in replies:
gs.makeMove(rep)
try:
# If opponent has no move and is in check → mate
if not gs.getValidMoves() and gs.inCheck():
ok = True
break
tail = prove_after_our_check(depth - 1, ply_acc + 2)
if tail is not None:
ok = True
break
finally:
gs.undoMove()
if not ok:
return None
finally:
gs.undoMove()
# All evasions are handled by at least one checking reply → success
return []
# Try each root checking move
for m0 in check_moves:
if time_up():
break
gs.makeMove(m0)
try:
# Immediate mate?
if not gs.getValidMoves() and gs.inCheck():
return [m0]
tail = prove_after_our_check(k - 1, 1)
if tail is not None:
return [m0] # You can extend to reconstruct full PV if desired
finally:
gs.undoMove()
return None
def findBestMove(game_state, valid_moves, return_queue):
"""
Iterative deepening with aspiration windows (phase-aware), strong root ordering,
quick checks-only mate probe, and a deterministic safe fallback on timeout.
Adds an opening-book probe before search (instant move if hit).
Hard cap: never spend more than TIME_PER_MOVE on a move.
"""
global next_move, nodes_searched
# also touch book probe guards here
global _BOOK_ZERO_HITS, BOOK_PROBE_ACTIVE
turn_multiplier = 1 if game_state.white_to_move else -1
# Set a single global deadline for this move (enforced via check_time())
set_move_time(TIME_PER_MOVE)
start_time = now() # only for logging/NPS
# Root move list (once)
original_root_moves = valid_moves if valid_moves is not None else game_state.getValidMoves()
if not original_root_moves:
return_queue.put(None)
return
# Reset the “out of book” guard at game start
_maybe_reset_book_probe(game_state)
# --- Opening book probe (before any search) ---
try:
if BOOK_ENABLED and BOOK_PROBE_ACTIVE and (BOOK is not None) and BOOK.available():
pyb = _gs_to_pyboard(game_state)
# Probe entries exactly once
entries = BOOK.entries(pyb)
n = len(entries)
# Debug printing rules:
# - If entries > 0, print (if DEBUG_BOOK) and try to play from book
# - If entries == 0, print it only the FIRST time; from the second time on, be silent
if n == 0:
_BOOK_ZERO_HITS += 1
# print the zero-entry line only for the first miss
if 'DEBUG_BOOK' in globals() and DEBUG_BOOK and _BOOK_ZERO_HITS < 2:
key = chess.polyglot.zobrist_hash(pyb)
print(f"[BOOK] fen={pyb.fen()} key={hex(key)} entries=0")
# after the second consecutive zero, disable further book probes this game
if _BOOK_ZERO_HITS >= 2:
BOOK_PROBE_ACTIVE = False
else:
# Found book moves → reset the zero streak
_BOOK_ZERO_HITS = 0
if 'DEBUG_BOOK' in globals() and DEBUG_BOOK:
key = chess.polyglot.zobrist_hash(pyb)
es = BOOK.entries(pyb)
print(f"[BOOK] fen={pyb.fen()} key={hex(key)} entries={len(es)}")
for e in sorted(es, key=lambda x: -x.weight)[:10]:
try:
print(" ", pyb.san(e.move), e.move.uci(), e.weight)
except Exception:
print(" ", e.move.uci(), e.weight)
poly_mv = BOOK.pick(
pyb,
randomize=BOOK_RANDOM,
temperature=BOOK_TEMPERATURE,
top_k=BOOK_TOP_K,
)
if poly_mv:
mv = _polyglot_to_engine_move(game_state, poly_mv)
if mv is not None:
return_queue.put((
'book',
mv.start_row, mv.start_col, mv.end_row, mv.end_col,
mv.promotion_choice if getattr(mv, 'is_pawn_promotion', False) else None
))
return
except Exception as e:
if 'DEBUG_SEARCH' in globals() and DEBUG_SEARCH:
print("Book probe failed:", e)
# --- Instant mate-in-1 chooser (no time slice) ---
for mv in original_root_moves:
if _is_mate_after(game_state, mv):
return_queue.put(mv)
return
id_to_move = {m.moveID: m for m in original_root_moves}
best_move_id = None
last_safe = original_root_moves[0] # always-valid fallback
# Last score used to center the aspiration window (centipawns)
last_score = 0
for current_depth in range(1, DEPTH + 1):
try:
check_time() # respect hard cap at depth start
next_move = None
# Strong tactical ordering at the root
root_moves = list(original_root_moves)
root_moves.sort(key=lambda m: _score_root_move(game_state, m), reverse=True)
# --- QUICK FORCED-MATE PROBE (depth 1 only) ---
if current_depth == 1:
probe_ms = int(min(40, TIME_PER_MOVE * 1000 * 0.25)) # ≤40ms or ≤25% of budget
mate_line = find_forced_mate_k(game_state, k=5, soft_time_ms=probe_ms)
if mate_line:
mv0 = mate_line[0]
if 'DEBUG_SEARCH' in globals() and DEBUG_SEARCH:
print("Forced mate found quickly:", mv0)
return_queue.put(mv0)
return
nodes_searched = 0
depth_start = now()
# --- Aspiration window around last_score (phase-aware width) ---
phase_here = _phase(game_state)
base_window = 30 # cp
window = base_window + int(20 * phase_here) # EG ~30, MG ~50
alpha = last_score - window
beta = last_score + window
# For robust timeout logging if we time out before any score is set
score_for_log = last_score
# Fail-low/high re-search loop (expands window on demand)
while True:
check_time() # hard cap during re-search loop
score = findMoveNegaMaxAlphaBeta(
game_state, root_moves,
current_depth,
max(-CHECKMATE, alpha), min(CHECKMATE, beta),
turn_multiplier,
start_time,
# root node is PV by definition
is_pv=True,
is_root=True,
ply=0
)
score_for_log = score # safe for timeout log
if score <= alpha:
window *= 2
alpha = score - window
continue
if score >= beta:
window *= 2
beta = score + window
continue
break # inside window → accept
best_move_id = next_move
if best_move_id in id_to_move:
last_safe = id_to_move[best_move_id]
elapsed = max(1e-6, now() - depth_start)
nps = int(nodes_searched / elapsed)
last_score = score # recenter next depth
white_perspective = _fmt_white_pawns(score, game_state.white_to_move)
if 'DEBUG_SEARCH' in globals() and DEBUG_SEARCH:
print(f"depth {current_depth}: best={last_safe}, score={white_perspective}, nps={nps}")
except SearchTimeout:
elapsed = max(1e-6, now() - depth_start)
nps = int(nodes_searched / elapsed)
white_perspective = _fmt_white_pawns(score_for_log, game_state.white_to_move)
print(f"Timeout at depth {current_depth}, returning best={last_safe}, last score={white_perspective}, nps={nps}")
break
# Optional: stop if we’re close to the cap even after finishing this depth
try:
check_time()
except SearchTimeout:
if 'DEBUG_SEARCH' in globals() and DEBUG_SEARCH:
print("Overall time budget reached after completing this depth; stopping ID.")
break
return_queue.put(last_safe)
def quiescenceSearch(game_state, alpha, beta, turn_multiplier, start_time, qply=0, qply_max=32, ply=0):
"""
Fast quiescence: captures + promotions only; allow *one* quiet rook check
in rookish endgames; otherwise allow at most one quiet check in MG when
both queens are on. Uses incremental current_eval for stand-pat and delta
pruning.
"""
global nodes_searched
nodes_searched += 1
check_time()
make = game_state.makeMove
undo = game_state.undoMove
in_check = game_state.inCheck
get_valid = game_state.getValidMoves
get_pseudo = game_state.getAllPossibleMoves
# If in check: full evasions (not cut by QS tail caps)
if in_check():
evasions = get_valid()
if not evasions:
return -_mate_score(ply)
best = -CHECKMATE
a = alpha
for mv in evasions:
make(mv)
try:
score = -quiescenceSearch(game_state, -beta, -a, -turn_multiplier, start_time,
qply=qply+1, qply_max=qply_max, ply=ply+1)
finally:
undo()
if score >= beta:
return beta
if score > best:
best = score
if score > a:
a = score
return best
# ---------------- Phase-aware local QS tail cap (shorter in EG) ----------------
phase = _phase(game_state) # 1 → MG, 0 → EG
local_qply_cap = 24 if phase > 0.45 else 8
effective_qply_max = min(qply_max, local_qply_cap)
if qply >= effective_qply_max:
return alpha
# ---------- STAND-PAT: use static_eval with FAST/FULL policy ----------
# We don't have PV info at QS nodes here, so pass is_pv=False, at_root=False.
fast_flag = _use_fast_eval(game_state, depth=0, is_pv=False, is_root=False)
stand_pat_pawns = game_state.static_eval(fast=fast_flag) # white-positive, in pawns
stand_pat = to_cp(turn_multiplier * stand_pat_pawns) # convert to side-to-move CP
if stand_pat >= beta:
return stand_pat
if stand_pat > alpha:
alpha = stand_pat