Skip to content

Commit f3703e7

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 f3703e7

1 file changed

Lines changed: 193 additions & 30 deletions

File tree

chess/chesstb.py

Lines changed: 193 additions & 30 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,138 @@ 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: Optional[Tuple[Tuple[int, int, int], int, int]] = 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+
@staticmethod
462+
def canonical_pair(white_sqs: List[int], black_sqs: List[int]) -> Tuple[int, int]:
463+
"""For callers that know an opposing pair is present (indexing a stored
464+
pair-table position): the canonical pair, asserting one exists."""
465+
found = PairGroup.find_canonical(white_sqs, black_sqs)
466+
assert found is not None
467+
return found
468+
469+
360470
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
471+
def __init__(self, pair_group: Optional[PairGroup],
472+
white_group: Optional[PieceGroup], black_group: Optional[PieceGroup]):
473+
self.pair_group = pair_group
363474
self.white_group = white_group
364475
self.black_group = black_group
476+
self.has_pawns = (pair_group is not None
477+
or white_group is not None or black_group is not None)
365478
if not self.has_pawns:
366479
self.num_slices = 1
480+
self.pair_table_size = 1
367481
self.white_table_size = 1
368482
self.black_table_size = 1
369483
self.storage_by_cartesian = [0]
370484
self.cartesian_by_storage = [0]
371485
return
486+
self.pair_table_size = pair_group.table_size if pair_group else 1
372487
self.white_table_size = white_group.table_size if white_group else 1
373488
self.black_table_size = black_group.table_size if black_group else 1
374-
n_cart = self.white_table_size * self.black_table_size
489+
# Mixed radix: cart = pair_idx + w_idx*pair_size + b_idx*pair_size*white_size.
490+
n_cart = self.pair_table_size * self.white_table_size * self.black_table_size
375491
self.storage_by_cartesian = [-1] * n_cart
376492
self.cartesian_by_storage = []
377493
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):
494+
pair_idx = cart % self.pair_table_size
495+
rem = cart // self.pair_table_size
496+
w_idx = rem % self.white_table_size
497+
b_idx = rem // self.white_table_size
498+
sqs: List[int] = []
499+
if pair_group:
500+
sqs.append(pair_group.white_square(pair_idx))
501+
sqs.append(pair_group.black_square(pair_idx))
502+
if white_group:
503+
sqs.extend(white_group.squares(w_idx))
504+
if black_group:
505+
sqs.extend(black_group.squares(b_idx))
506+
if len(set(sqs)) != len(sqs):
383507
continue
508+
if pair_group:
509+
pair_w = pair_group.white_square(pair_idx)
510+
pair_b = pair_group.black_square(pair_idx)
511+
wsqs = [pair_w]
512+
bsqs = [pair_b]
513+
if white_group:
514+
wsqs.extend(white_group.squares(w_idx))
515+
if black_group:
516+
bsqs.extend(black_group.squares(b_idx))
517+
cw, cb = PairGroup.canonical_pair(wsqs, bsqs)
518+
if cw != pair_w or cb != pair_b:
519+
continue
384520
self.storage_by_cartesian[cart] = len(self.cartesian_by_storage)
385521
self.cartesian_by_storage.append(cart)
386522
self.num_slices = len(self.cartesian_by_storage)
387523

388-
def compose(self, w_idx: int, b_idx: int) -> int:
524+
def compose(self, pair_idx: int, w_idx: int, b_idx: int) -> int:
389525
if not self.has_pawns:
390526
return 0
391-
cart = w_idx + b_idx * self.white_table_size
527+
cart = (pair_idx
528+
+ w_idx * self.pair_table_size
529+
+ b_idx * self.pair_table_size * self.white_table_size)
392530
return self.storage_by_cartesian[cart]
393531

394-
def lookup_from_squares(self, white_pawn_sqs: List[int], black_pawn_sqs: List[int]) -> int:
532+
def lookup_from_squares(self, pair_w: int, pair_b: int,
533+
white_pawn_sqs: List[int], black_pawn_sqs: List[int]) -> int:
395534
if not self.has_pawns:
396535
return 0
536+
pair_idx = self.pair_group.index_of(pair_w, pair_b) if self.pair_group else 0
397537
w_idx = self.white_group.compound_index(white_pawn_sqs) if self.white_group else 0
398538
b_idx = self.black_group.compound_index(black_pawn_sqs) if self.black_group else 0
399-
return self.compose(w_idx, b_idx)
539+
return self.compose(pair_idx, w_idx, b_idx)
400540

401541

402542
# ---------------------------------------------------------------------------
@@ -432,7 +572,10 @@ def __init__(self, cfg: PieceConfig):
432572
counts: Dict[Tuple[int, int], int] = {}
433573
for c, t in cfg.pieces:
434574
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
575+
# A frozen pair is two pawns on the board, so it breaks symmetry to
576+
# file-mirror like any pawn even with no free pawns.
577+
has_pawns = (counts.get((CPP_WHITE, PAWN), 0) > 0
578+
or counts.get((CPP_BLACK, PAWN), 0) > 0 or cfg.has_pair)
436579
self.sym = SYM_FILE_MIRROR if has_pawns else SYM_DIHEDRAL_8
437580
self.ksm = king_slice_mgr(self.sym)
438581

@@ -446,10 +589,12 @@ def __init__(self, cfg: PieceConfig):
446589
pcl = make_piece_class(c, t)
447590
self.groups[pcl] = PieceGroup(t, n)
448591

592+
self.pair_group = PairGroup() if cfg.has_pair else None
449593
if has_pawns:
450-
self.psm = PawnSliceManager(self.groups.get(WHITE_PAWNS), self.groups.get(BLACK_PAWNS))
594+
self.psm = PawnSliceManager(self.pair_group,
595+
self.groups.get(WHITE_PAWNS), self.groups.get(BLACK_PAWNS))
451596
else:
452-
self.psm = PawnSliceManager(None, None)
597+
self.psm = PawnSliceManager(None, None, None)
453598
self.num_pawn_slices = self.psm.num_slices
454599

455600
# populated non-pawn classes in ascending class-id order; native weights.
@@ -489,10 +634,12 @@ def _placements_from_board(self, board: chess.Board) -> Dict[int, List[int]]:
489634
cc, tt = class_to_piece(c)
490635
color = WHITE if cc == CPP_WHITE else BLACK
491636
pl[c] = list(board.pieces(_CPP_TO_PC[tt], color))
637+
# Collect pawns when a free-pawn class is populated OR a frozen pair adds
638+
# pawns with no free-pawn class (e.g. KpKp). The pair members are folded
639+
# in here and split out by find_canonical at index time.
492640
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
641+
if c in self.groups or self.pair_group is not None:
642+
color = WHITE if c == WHITE_PAWNS else BLACK
496643
pl[c] = list(board.pieces(chess.PAWN, color))
497644
return pl
498645

@@ -529,7 +676,16 @@ def board_index(self, board: chess.Board, order: List[int], radix: List[int]) ->
529676
return None
530677
pawn_slice = 0
531678
if self.psm.has_pawns:
532-
pawn_slice = self.psm.lookup_from_squares(pl[WHITE_PAWNS], pl[BLACK_PAWNS])
679+
w_pl, b_pl = pl[WHITE_PAWNS], pl[BLACK_PAWNS]
680+
if self.pair_group is not None:
681+
# Identify the pair (canonical opposing pair); the rest are free.
682+
pw, pb = PairGroup.canonical_pair(w_pl, b_pl)
683+
free_w = [s for s in w_pl if s != pw]
684+
free_b = [s for s in b_pl if s != pb]
685+
else:
686+
pw = pb = -1
687+
free_w, free_b = w_pl, b_pl
688+
pawn_slice = self.psm.lookup_from_squares(pw, pb, free_w, free_b)
533689
within_idx = {c: self.groups[c].compound_index(pl[c]) for c in self.populated}
534690
within = 0
535691
w = 1
@@ -544,7 +700,7 @@ def board_index(self, board: chess.Board, order: List[int], radix: List[int]) ->
544700

545701

546702
def position_index_config(cfg: PieceConfig) -> PositionIndexConfig:
547-
k = cfg.base_key
703+
k = cfg.cache_key
548704
if k not in _INDEX_CFG_CACHE:
549705
_INDEX_CFG_CACHE[k] = PositionIndexConfig(cfg)
550706
return _INDEX_CFG_CACHE[k]
@@ -1650,29 +1806,28 @@ def _find(self, kind: str, name: str, ext: str) -> Optional[str]:
16501806
return None
16511807

16521808
def _open_wdl(self, cfg: PieceConfig) -> Optional[WDLFile]:
1653-
k = cfg.min_key
1809+
k = cfg.cache_key
16541810
if k not in self._wdl_cache:
16551811
p = self._find("wdl", cfg.name(), WDLFile.EXT)
16561812
self._wdl_cache[k] = WDLFile(cfg, p, self._block_cache) if p else None
16571813
return self._wdl_cache[k]
16581814

16591815
def _open_dtc(self, cfg: PieceConfig) -> Optional[DTCFile]:
1660-
k = cfg.min_key
1816+
k = cfg.cache_key
16611817
if k not in self._dtc_cache:
16621818
p = self._find("dtc", cfg.name(), DTCFile.EXT)
16631819
self._dtc_cache[k] = DTCFile(cfg, p, self._block_cache) if p else None
16641820
return self._dtc_cache[k]
16651821

16661822
def _open_dtm50(self, cfg: PieceConfig) -> Optional[DTM50File]:
1667-
k = cfg.min_key
1823+
k = cfg.cache_key
16681824
if k not in self._dtm50_cache:
16691825
p = self._find("dtm50", cfg.name(), DTM50File.EXT)
16701826
self._dtm50_cache[k] = DTM50File(cfg, p, self._block_cache) if p else None
16711827
return self._dtm50_cache[k]
16721828

16731829
def _has_any_table(self, cfg: PieceConfig) -> bool:
1674-
return (self._open_wdl(cfg) is not None or self._open_dtc(cfg) is not None
1675-
or self._open_dtm50(cfg) is not None)
1830+
return self._open_wdl(cfg) is not None
16761831

16771832
# --- child construction for the derive / overlay paths ---
16781833
def _make_child(self, parent: chess.Board, move: chess.Move
@@ -2020,7 +2175,15 @@ def probe(self, board: chess.Board, rule50: int = 0) -> Optional[ProbeResult]:
20202175
"""Full probe of `board`. `rule50` (the halfmove clock) selects the
20212176
DTM50 layer. Returns a :class:`ProbeResult`, or ``None`` if no table is
20222177
available for the material."""
2023-
cfg, mirrored = piece_config_from_board(board)
2178+
# Prefer a frozen-pair table: if the board has an opposing pawn pair and
2179+
# that 'p' table is on disk, route there; else fall back to the full
2180+
# material. The pair table is partial and a position has the same value
2181+
# in either, so preferring the pair table when present is safe.
2182+
paired = pair_config_from_board(board)
2183+
if paired is not None and self._has_any_table(paired[0]):
2184+
cfg, mirrored = paired
2185+
else:
2186+
cfg, mirrored = piece_config_from_board(board)
20242187
if mirrored:
20252188
cboard = mirror_for_canonical(board)
20262189
ep = sq_rank_mirror(board.ep_square) if board.ep_square is not None else None

0 commit comments

Comments
 (0)