Skip to content

Commit f56f706

Browse files
noobpwnftwclaude
andcommitted
chesstb: support frozen opposing-pawn-pair ('p') tables in the prober
Mirror the generator/probe additions for the lowercase-'p' material: an opposing pawn pair (white below black on one file) indexed jointly so a large table shrinks to a partial 'p' variant. - PairGroup: the 120-placement same-file opposing enumeration, with index_of / find_canonical matching src/egtb/pair_group.h exactly (the on-disk pawn-slice ids depend on the enumeration order). - PawnSliceManager: 3-way pair x free_white x free_black product. - PositionIndexConfig: force file-mirror symmetry when a pair is present, collect the pair pawns even with no free-pawn class, and split the canonical opposing pair out of the all-pawns placement at index time. - PieceConfig: has_pair flag; min_key stays pair-stripped (on-disk header), cache_key adds the pair bit so a 'p'-material and its free-pieces twin (e.g. KQpKp vs KQK) don't collide in the in-memory caches; name() emits 'p' on both sides (KQpKp). - probe(): prefer the 'p' table for an opposing-pair board when it is on disk, else fall back to the full material. Cross-checked 500 random KQpKp/KpKp positions against the C++ probe_fen (WDL + DTC) with zero mismatches. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 88b35fa commit f56f706

1 file changed

Lines changed: 172 additions & 28 deletions

File tree

chess/chesstb.py

Lines changed: 172 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -159,11 +159,15 @@ def _composition_key(pieces: List[Tuple[int, int]], color: int) -> List[int]:
159159

160160

161161
class PieceConfig:
162-
"""Canonical material config. `pieces` is a list of (cpp_color, type)."""
162+
"""Canonical material config. `pieces` is a list of (cpp_color, type) of the
163+
FREE pieces. `has_pair` marks a frozen opposing pawn pair (lowercase 'p'):
164+
one white + one black pawn locked on a file, indexed jointly and excluded
165+
from `pieces` (mirrors src/chess/piece_config.h)."""
163166

164-
def __init__(self, pieces: List[Tuple[int, int]]):
167+
def __init__(self, pieces: List[Tuple[int, int]], has_pair: bool = False):
165168
# Determine side ordering by total strength; stronger side -> WHITE.
166-
# Input may be in any orientation; we canonicalize.
169+
# Input may be in any orientation; we canonicalize. The pair is
170+
# strength-neutral (one pawn each side), so it never affects the swap.
167171
ws = sum(_STRENGTH[t] for c, t in pieces if c == CPP_WHITE)
168172
bs = sum(_STRENGTH[t] for c, t in pieces if c == CPP_BLACK)
169173
swap = bs > ws
@@ -174,17 +178,38 @@ def __init__(self, pieces: List[Tuple[int, int]]):
174178
# sort: white side first then black; within side by type order.
175179
pieces = sorted(pieces, key=lambda ct: (ct[0], _TYPE_ORDER[ct[1]]))
176180
self.pieces = pieces
181+
self.has_pair = has_pair
177182
self.base_key = material_key_of(pieces)
178183
self.mirr_key = material_key_of(
179184
[(CPP_BLACK if c == CPP_WHITE else CPP_WHITE, t) for c, t in pieces])
180185

181186
@property
182187
def min_key(self) -> int:
188+
# Pair-stripped 30-bit value, as serialized in the on-disk header.
183189
return min(self.base_key, self.mirr_key)
184190

191+
@property
192+
def cache_key(self) -> int:
193+
# In-memory key distinguishing a 'p'-material from its free-pieces twin
194+
# (which share min_key). Not for disk; canonical min-keys are < 2^31.
195+
return self.min_key | (0x80000000 if self.has_pair else 0)
196+
185197
def name(self) -> str:
198+
# The pair contributes one pawn to each side; emit a 'p' at the end of
199+
# the white side (before the 2nd king) and another at the very end, e.g.
200+
# "KQpKp" -- so a board-derived pair config resolves the right filename.
186201
letters = {KING: "K", QUEEN: "Q", ROOK: "R", BISHOP: "B", KNIGHT: "N", PAWN: "P"}
187-
return "".join(letters[t] for c, t in self.pieces)
202+
s = []
203+
seen_white_king = False
204+
for c, t in self.pieces:
205+
if t == KING:
206+
if seen_white_king and self.has_pair:
207+
s.append("p")
208+
seen_white_king = True
209+
s.append(letters[t])
210+
if self.has_pair:
211+
s.append("p")
212+
return "".join(s)
188213

189214
@property
190215
def num_pieces(self) -> int:
@@ -208,6 +233,29 @@ def piece_config_from_board(board: chess.Board) -> Tuple["PieceConfig", bool]:
208233
return cfg, mirrored
209234

210235

236+
def pair_config_from_board(board: chess.Board) -> Optional[Tuple["PieceConfig", bool]]:
237+
"""If `board` has an opposing pawn pair, return (frozen-pair PieceConfig,
238+
mirrored?); else None. The pair's two pawns are excluded from the config and
239+
flagged via has_pair, mirroring src/probe/probe.cpp pair_config_from_position.
240+
Used to prefer a 'p' table over the full material when one is on disk."""
241+
wp = list(board.pieces(chess.PAWN, WHITE))
242+
bp = list(board.pieces(chess.PAWN, BLACK))
243+
found = PairGroup.find_canonical(wp, bp)
244+
if found is None:
245+
return None
246+
pw, pb = found
247+
pieces = []
248+
for sq in range(64):
249+
if sq == pw or sq == pb:
250+
continue
251+
p = board.piece_at(sq)
252+
if p:
253+
pieces.append((cpp_color(p.color), _PC_TO_CPP[p.piece_type]))
254+
cfg = PieceConfig(pieces, has_pair=True)
255+
mirrored = material_key_of(pieces) != cfg.base_key
256+
return cfg, mirrored
257+
258+
211259
# ---------------------------------------------------------------------------
212260
# Piece_Class enum (src/chess/piece_config.h)
213261
# ---------------------------------------------------------------------------
@@ -357,46 +405,118 @@ def king_slice_mgr(sym: int) -> KingSliceManager:
357405
# ---------------------------------------------------------------------------
358406
# Pawn_Slice_Manager
359407
# ---------------------------------------------------------------------------
408+
class PairGroup:
409+
"""Opposing pawn pair (lowercase 'p'): white pawn on rank r, black on rank s,
410+
r < s, same file. Enumeration / index_of / find_canonical must match
411+
src/egtb/pair_group.h exactly -- the on-disk pawn-slice ids depend on them.
412+
White ranks 2..6, black 3..7: C(6,2)=15 rank pairs x 8 files = 120."""
413+
414+
def __init__(self) -> None:
415+
self.white: List[int] = []
416+
self.black: List[int] = []
417+
self._inv: Dict[Tuple[int, int], int] = {}
418+
for f in range(8):
419+
for wr in range(1, 6): # ranks 2..6
420+
for br in range(wr + 1, 7): # ranks 3..7
421+
w = sq_make(wr, f)
422+
b = sq_make(br, f)
423+
self._inv[(w, b)] = len(self.white)
424+
self.white.append(w)
425+
self.black.append(b)
426+
427+
@property
428+
def table_size(self) -> int:
429+
return len(self.white)
430+
431+
def white_square(self, i: int) -> int:
432+
return self.white[i]
433+
434+
def black_square(self, i: int) -> int:
435+
return self.black[i]
436+
437+
def index_of(self, w: int, b: int) -> int:
438+
return self._inv[(w, b)]
439+
440+
@staticmethod
441+
def is_opposing_pair(w: int, b: int) -> bool:
442+
return (sq_file(w) == sq_file(b)
443+
and sq_rank(w) >= 1 and sq_rank(b) <= 6
444+
and sq_rank(w) < sq_rank(b))
445+
446+
@staticmethod
447+
def find_canonical(white_sqs: List[int], black_sqs: List[int]
448+
) -> Optional[Tuple[int, int]]:
449+
"""The opposing pair minimal by (file, white_rank, black_rank), or None.
450+
Both the generator's prune and this lookup use this one rule."""
451+
best = None
452+
for w in white_sqs:
453+
for b in black_sqs:
454+
if not PairGroup.is_opposing_pair(w, b):
455+
continue
456+
key = (sq_file(w), sq_rank(w), sq_rank(b))
457+
if best is None or key < best[0]:
458+
best = (key, w, b)
459+
return None if best is None else (best[1], best[2])
460+
461+
360462
class PawnSliceManager:
361-
def __init__(self, white_group: Optional[PieceGroup], black_group: Optional[PieceGroup]):
362-
self.has_pawns = white_group is not None or black_group is not None
463+
def __init__(self, pair_group: Optional[PairGroup],
464+
white_group: Optional[PieceGroup], black_group: Optional[PieceGroup]):
465+
self.pair_group = pair_group
363466
self.white_group = white_group
364467
self.black_group = black_group
468+
self.has_pawns = (pair_group is not None
469+
or white_group is not None or black_group is not None)
365470
if not self.has_pawns:
366471
self.num_slices = 1
472+
self.pair_table_size = 1
367473
self.white_table_size = 1
368474
self.black_table_size = 1
369475
self.storage_by_cartesian = [0]
370476
self.cartesian_by_storage = [0]
371477
return
478+
self.pair_table_size = pair_group.table_size if pair_group else 1
372479
self.white_table_size = white_group.table_size if white_group else 1
373480
self.black_table_size = black_group.table_size if black_group else 1
374-
n_cart = self.white_table_size * self.black_table_size
481+
# Mixed radix: cart = pair_idx + w_idx*pair_size + b_idx*pair_size*white_size.
482+
n_cart = self.pair_table_size * self.white_table_size * self.black_table_size
375483
self.storage_by_cartesian = [-1] * n_cart
376484
self.cartesian_by_storage = []
377485
for cart in range(n_cart):
378-
w_idx = cart % self.white_table_size
379-
b_idx = cart // self.white_table_size
380-
w_pl = white_group.squares(w_idx) if white_group else []
381-
b_pl = black_group.squares(b_idx) if black_group else []
382-
if set(w_pl) & set(b_pl):
486+
pair_idx = cart % self.pair_table_size
487+
rem = cart // self.pair_table_size
488+
w_idx = rem % self.white_table_size
489+
b_idx = rem // self.white_table_size
490+
sqs: List[int] = []
491+
if pair_group:
492+
sqs.append(pair_group.white_square(pair_idx))
493+
sqs.append(pair_group.black_square(pair_idx))
494+
if white_group:
495+
sqs.extend(white_group.squares(w_idx))
496+
if black_group:
497+
sqs.extend(black_group.squares(b_idx))
498+
if len(set(sqs)) != len(sqs):
383499
continue
384500
self.storage_by_cartesian[cart] = len(self.cartesian_by_storage)
385501
self.cartesian_by_storage.append(cart)
386502
self.num_slices = len(self.cartesian_by_storage)
387503

388-
def compose(self, w_idx: int, b_idx: int) -> int:
504+
def compose(self, pair_idx: int, w_idx: int, b_idx: int) -> int:
389505
if not self.has_pawns:
390506
return 0
391-
cart = w_idx + b_idx * self.white_table_size
507+
cart = (pair_idx
508+
+ w_idx * self.pair_table_size
509+
+ b_idx * self.pair_table_size * self.white_table_size)
392510
return self.storage_by_cartesian[cart]
393511

394-
def lookup_from_squares(self, white_pawn_sqs: List[int], black_pawn_sqs: List[int]) -> int:
512+
def lookup_from_squares(self, pair_w: int, pair_b: int,
513+
white_pawn_sqs: List[int], black_pawn_sqs: List[int]) -> int:
395514
if not self.has_pawns:
396515
return 0
516+
pair_idx = self.pair_group.index_of(pair_w, pair_b) if self.pair_group else 0
397517
w_idx = self.white_group.compound_index(white_pawn_sqs) if self.white_group else 0
398518
b_idx = self.black_group.compound_index(black_pawn_sqs) if self.black_group else 0
399-
return self.compose(w_idx, b_idx)
519+
return self.compose(pair_idx, w_idx, b_idx)
400520

401521

402522
# ---------------------------------------------------------------------------
@@ -432,7 +552,10 @@ def __init__(self, cfg: PieceConfig):
432552
counts: Dict[Tuple[int, int], int] = {}
433553
for c, t in cfg.pieces:
434554
counts[(c, t)] = counts.get((c, t), 0) + 1
435-
has_pawns = counts.get((CPP_WHITE, PAWN), 0) > 0 or counts.get((CPP_BLACK, PAWN), 0) > 0
555+
# A frozen pair is two pawns on the board, so it breaks symmetry to
556+
# file-mirror like any pawn even with no free pawns.
557+
has_pawns = (counts.get((CPP_WHITE, PAWN), 0) > 0
558+
or counts.get((CPP_BLACK, PAWN), 0) > 0 or cfg.has_pair)
436559
self.sym = SYM_FILE_MIRROR if has_pawns else SYM_DIHEDRAL_8
437560
self.ksm = king_slice_mgr(self.sym)
438561

@@ -446,10 +569,12 @@ def __init__(self, cfg: PieceConfig):
446569
pcl = make_piece_class(c, t)
447570
self.groups[pcl] = PieceGroup(t, n)
448571

572+
self.pair_group = PairGroup() if cfg.has_pair else None
449573
if has_pawns:
450-
self.psm = PawnSliceManager(self.groups.get(WHITE_PAWNS), self.groups.get(BLACK_PAWNS))
574+
self.psm = PawnSliceManager(self.pair_group,
575+
self.groups.get(WHITE_PAWNS), self.groups.get(BLACK_PAWNS))
451576
else:
452-
self.psm = PawnSliceManager(None, None)
577+
self.psm = PawnSliceManager(None, None, None)
453578
self.num_pawn_slices = self.psm.num_slices
454579

455580
# populated non-pawn classes in ascending class-id order; native weights.
@@ -489,10 +614,12 @@ def _placements_from_board(self, board: chess.Board) -> Dict[int, List[int]]:
489614
cc, tt = class_to_piece(c)
490615
color = WHITE if cc == CPP_WHITE else BLACK
491616
pl[c] = list(board.pieces(_CPP_TO_PC[tt], color))
617+
# Collect pawns when a free-pawn class is populated OR a frozen pair adds
618+
# pawns with no free-pawn class (e.g. KpKp). The pair members are folded
619+
# in here and split out by find_canonical at index time.
492620
for c in (WHITE_PAWNS, BLACK_PAWNS):
493-
if c in self.groups:
494-
cc, tt = class_to_piece(c)
495-
color = WHITE if cc == CPP_WHITE else BLACK
621+
if c in self.groups or self.pair_group is not None:
622+
color = WHITE if c == WHITE_PAWNS else BLACK
496623
pl[c] = list(board.pieces(chess.PAWN, color))
497624
return pl
498625

@@ -529,7 +656,16 @@ def board_index(self, board: chess.Board, order: List[int], radix: List[int]) ->
529656
return None
530657
pawn_slice = 0
531658
if self.psm.has_pawns:
532-
pawn_slice = self.psm.lookup_from_squares(pl[WHITE_PAWNS], pl[BLACK_PAWNS])
659+
w_pl, b_pl = pl[WHITE_PAWNS], pl[BLACK_PAWNS]
660+
if self.pair_group is not None:
661+
# Identify the pair (canonical opposing pair); the rest are free.
662+
pw, pb = PairGroup.find_canonical(w_pl, b_pl)
663+
free_w = [s for s in w_pl if s != pw]
664+
free_b = [s for s in b_pl if s != pb]
665+
else:
666+
pw = pb = -1
667+
free_w, free_b = w_pl, b_pl
668+
pawn_slice = self.psm.lookup_from_squares(pw, pb, free_w, free_b)
533669
within_idx = {c: self.groups[c].compound_index(pl[c]) for c in self.populated}
534670
within = 0
535671
w = 1
@@ -544,7 +680,7 @@ def board_index(self, board: chess.Board, order: List[int], radix: List[int]) ->
544680

545681

546682
def position_index_config(cfg: PieceConfig) -> PositionIndexConfig:
547-
k = cfg.base_key
683+
k = cfg.cache_key
548684
if k not in _INDEX_CFG_CACHE:
549685
_INDEX_CFG_CACHE[k] = PositionIndexConfig(cfg)
550686
return _INDEX_CFG_CACHE[k]
@@ -1650,21 +1786,21 @@ def _find(self, kind: str, name: str, ext: str) -> Optional[str]:
16501786
return None
16511787

16521788
def _open_wdl(self, cfg: PieceConfig) -> Optional[WDLFile]:
1653-
k = cfg.min_key
1789+
k = cfg.cache_key
16541790
if k not in self._wdl_cache:
16551791
p = self._find("wdl", cfg.name(), WDLFile.EXT)
16561792
self._wdl_cache[k] = WDLFile(cfg, p, self._block_cache) if p else None
16571793
return self._wdl_cache[k]
16581794

16591795
def _open_dtc(self, cfg: PieceConfig) -> Optional[DTCFile]:
1660-
k = cfg.min_key
1796+
k = cfg.cache_key
16611797
if k not in self._dtc_cache:
16621798
p = self._find("dtc", cfg.name(), DTCFile.EXT)
16631799
self._dtc_cache[k] = DTCFile(cfg, p, self._block_cache) if p else None
16641800
return self._dtc_cache[k]
16651801

16661802
def _open_dtm50(self, cfg: PieceConfig) -> Optional[DTM50File]:
1667-
k = cfg.min_key
1803+
k = cfg.cache_key
16681804
if k not in self._dtm50_cache:
16691805
p = self._find("dtm50", cfg.name(), DTM50File.EXT)
16701806
self._dtm50_cache[k] = DTM50File(cfg, p, self._block_cache) if p else None
@@ -2020,7 +2156,15 @@ def probe(self, board: chess.Board, rule50: int = 0) -> Optional[ProbeResult]:
20202156
"""Full probe of `board`. `rule50` (the halfmove clock) selects the
20212157
DTM50 layer. Returns a :class:`ProbeResult`, or ``None`` if no table is
20222158
available for the material."""
2023-
cfg, mirrored = piece_config_from_board(board)
2159+
# Prefer a frozen-pair table: if the board has an opposing pawn pair and
2160+
# that 'p' table is on disk, route there; else fall back to the full
2161+
# material. The pair table is partial and a position has the same value
2162+
# in either, so preferring the pair table when present is safe.
2163+
paired = pair_config_from_board(board)
2164+
if paired is not None and self._has_any_table(paired[0]):
2165+
cfg, mirrored = paired
2166+
else:
2167+
cfg, mirrored = piece_config_from_board(board)
20242168
if mirrored:
20252169
cboard = mirror_for_canonical(board)
20262170
ep = sq_rank_mirror(board.ep_square) if board.ep_square is not None else None

0 commit comments

Comments
 (0)