@@ -159,11 +159,15 @@ def _composition_key(pieces: List[Tuple[int, int]], color: int) -> List[int]:
159159
160160
161161class 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+
360470class 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
546702def 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