ansarzeinulla's picture
proto2
7f74b7d
Raw History Blame Contribute Delete
10.4 kB
"""Query the Bestemshe endgame oracle on Hugging Face via HTTP Range requests.
No local tablebase download, no C++ compile step — each lookup fetches only
the 4MiB compressed block that contains the bit it needs.
Needs only: pip install requests zstandard
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from functools import lru_cache
from typing import Literal, NamedTuple
import requests
import zstandard
TABLEBASE_DATASET = os.environ.get("BESTEMSHE_DATASET", "ansarzeinulla/bestemshe-tablebase")
BASE_URL = os.environ.get(
"BESTEMSHE_ORACLE_BASE_URL",
f"https://huggingface.co/datasets/{TABLEBASE_DATASET}/resolve/main/layers/compressed",
)
TOTAL_STONES = 50 # conserved: k1 + k2 + sum(pits)
NUM_PITS = 5 # pits per side
MAX_KAZAN = 24 # largest kazan the dataset stores
WIN_THRESHOLD = 26 # a kazan this large has already won
BYTES_PER_BLOCK = 33_554_432 // 8 # 4 MiB decompressed per block
Verdict = Literal["win", "loss", "draw", "unknown"]
@dataclass(frozen=True)
class Position:
"""Side-to-move relative. pits[0:5] are the mover's own pits."""
k1: int
k2: int
pits: tuple[int, ...] # length 10
def is_valid(self) -> bool:
if not (0 <= self.k1 <= MAX_KAZAN and self.k1 % 2 == 0):
return False
if not (0 <= self.k2 <= MAX_KAZAN and self.k2 % 2 == 0):
return False
if len(self.pits) != 10 or any(not 0 <= p < TOTAL_STONES for p in self.pits):
return False
return self.k1 + self.k2 + sum(self.pits) == TOTAL_STONES
# ---------------------------------------------------------------- indexing
@lru_cache(maxsize=None)
def n_c_r(n: int, r: int) -> int:
"""Exact binomial; 0 outside the domain, matching StateIndex.h's nCr."""
if n < 0 or r < 0 or n < r:
return 0
if r == 0 or n == r:
return 1
r = min(r, n - r)
result = 1
for i in range(r):
result = result * (n - i) // (i + 1)
return result
def min_kazan(m: int) -> int:
"""Smallest possible side-to-move kazan given m = k1 + k2 captured."""
return max(0, m - MAX_KAZAN)
def index_state(pos: Position) -> int:
"""The position's rank WITHIN its (k1, k2) layer file.
Each layer_<k1>_<k2>_*.bin file already isolates one kazan slice, so the
on-disk bit index is just I_B — the board composition's stars-and-bars
rank (a multiset rank). This mirrors Oracle.h's TablebaseOracle::query(),
which computes the same global IndexState (I_K * b_count + I_B) as the
monolithic in-memory tablebase would use, then reduces it mod b_count to
land on the per-file offset — equivalent to using I_B alone here.
"""
i_b = 0
running = 0
for i in range(9): # 9, not 10: the last pit is implied
running += pos.pits[i]
i_b += n_c_r(i + running, i + 1)
return i_b
class BitLocation(NamedTuple):
block_index: int
local_byte: int # byte offset inside the DECOMPRESSED block
bit_offset: int # 0..7, little-endian within the byte
def bit_location(rank: int) -> BitLocation:
byte_index = rank // 8
return BitLocation(
block_index=byte_index // BYTES_PER_BLOCK,
local_byte=byte_index % BYTES_PER_BLOCK,
bit_offset=rank % 8,
)
def layer_urls(pos: Position, base_url: str = BASE_URL) -> tuple[str, str]:
"""(win_url, draw_url) for this position's layer."""
return (f"{base_url}/layer_{pos.k1}_{pos.k2}_win.bin",
f"{base_url}/layer_{pos.k1}_{pos.k2}_draw.bin")
# ------------------------------------------------------------------ moves
class Child(NamedTuple):
pit: int # 0..4, the mover's own pit
land: int # 0..9, where the last sown stone landed (pre-flip board)
position: "Position" # the flipped board after the move (always populated,
# even when the move ends the game outright)
capture: bool
wins_immediately: bool # opponent row emptied, or kazan >= 26
def legal_pits(pos: Position) -> list[int]:
return [i for i in range(NUM_PITS) if pos.pits[i] > 0]
def apply_move(pos: Position, pit: int) -> Child:
"""Sow one pit and return the child ALREADY FLIPPED to the opponent's view."""
pits = list(pos.pits)
stones = pits[pit]
if stones == 0:
raise ValueError(f"pit {pit} is empty")
pits[pit] = 0
if stones == 1:
last = (pit + 1) % 10
pits[last] += 1
else:
pits[pit] = 1
last = pit
for _ in range(stones - 1):
last = (last + 1) % 10
pits[last] += 1
captured = 0
capture = False
if last >= NUM_PITS and pits[last] % 2 == 0 and pits[last] > 0:
captured, pits[last] = pits[last], 0
capture = True
mover_kazan = pos.k1 + captured
opponent_starved = all(p == 0 for p in pits[NUM_PITS:])
child = Position(
k1=pos.k2,
k2=mover_kazan,
pits=tuple(pits[NUM_PITS:] + pits[:NUM_PITS]),
)
wins_immediately = opponent_starved or mover_kazan >= WIN_THRESHOLD
return Child(pit=pit, land=last, position=child, capture=capture, wins_immediately=wins_immediately)
# ----------------------------------------------------------------- oracle
INVERSE: dict[Verdict, Verdict] = {
"win": "loss", "loss": "win", "draw": "draw", "unknown": "unknown",
}
class Oracle:
"""Reads single bits out of the remote .bin layers via HTTP Range."""
def __init__(self, base_url: str = BASE_URL, session: requests.Session | None = None):
self.base_url = base_url
self.session = session or requests.Session()
self._headers: dict[str, list[int] | None] = {}
self._blocks: dict[tuple[str, int], bytes] = {}
def _range_get(self, url: str, start: int, end: int) -> bytes | None:
resp = self.session.get(url, headers={"Range": f"bytes={start}-{end}"}, timeout=30)
if resp.status_code == 404:
return None
resp.raise_for_status()
return resp.content
def offsets(self, url: str) -> list[int] | None:
if url in self._headers:
return self._headers[url]
head = self._range_get(url, 0, 4095)
if head is None:
self._headers[url] = None
return None
num_blocks = int.from_bytes(head[:4], "little")
need = 4 + 4 * (num_blocks + 1)
if len(head) < need:
head = self._range_get(url, 0, need - 1)
table = [int.from_bytes(head[4 + 4 * i: 8 + 4 * i], "little")
for i in range(num_blocks + 1)]
self._headers[url] = table
return table
def block(self, url: str, block_index: int) -> bytes:
cached = self._blocks.get((url, block_index))
if cached is not None:
return cached
table = self.offsets(url)
if table is None or block_index + 1 >= len(table):
return b""
start, end = table[block_index], table[block_index + 1] - 1
compressed = self._range_get(url, start, end) or b""
data = zstandard.ZstdDecompressor().decompress(
compressed, max_output_size=BYTES_PER_BLOCK)
self._blocks[(url, block_index)] = data
return data
def locate(self, pos: Position) -> dict:
"""Resolve a position to its exact file + byte range without downloading."""
rank = index_state(pos)
loc = bit_location(rank)
win_url, draw_url = layer_urls(pos, self.base_url)
plan = {"rank": rank, "block_index": loc.block_index,
"local_byte": loc.local_byte, "bit_offset": loc.bit_offset,
"files": {}}
for name, url in (("win", win_url), ("draw", draw_url)):
table = self.offsets(url)
if table is None:
plan["files"][name] = {"url": url, "exists": False}
continue
plan["files"][name] = {
"url": url,
"exists": True,
"byte_range": (table[loc.block_index],
table[loc.block_index + 1] - 1),
"compressed_size": table[loc.block_index + 1] - table[loc.block_index],
}
return plan
def value(self, pos: Position) -> Verdict:
"""Verdict from the SIDE TO MOVE's perspective."""
if not pos.is_valid():
return "unknown"
rank = index_state(pos)
loc = bit_location(rank)
win_url, draw_url = layer_urls(pos, self.base_url)
if self.offsets(draw_url) is None or self.offsets(win_url) is None:
return self._derive(pos)
def read_bit(url: str) -> int:
data = self.block(url, loc.block_index)
if loc.local_byte >= len(data):
return 0
return (data[loc.local_byte] >> loc.bit_offset) & 1
if read_bit(draw_url):
return "draw"
return "win" if read_bit(win_url) else "loss"
def children_values(self, pos: Position) -> list[dict]:
"""Evaluate all legal child moves."""
out = []
for pit in legal_pits(pos):
child = apply_move(pos, pit)
if child.wins_immediately:
out.append({"pit": pit, "land": child.land, "position": child.position,
"capture": child.capture,
"value": "loss", "value_for_mover": "win",
"terminal": True})
continue
child_value = self.value(child.position)
out.append({"pit": pit, "land": child.land, "position": child.position,
"capture": child.capture,
"value": child_value,
"value_for_mover": INVERSE[child_value],
"terminal": False})
return out
def _derive(self, pos: Position) -> Verdict:
children = self.children_values(pos)
if not children:
return "loss"
if any(c["value_for_mover"] == "win" for c in children):
return "win"
if any(c["value"] == "unknown" for c in children):
return "unknown"
return "draw" if any(c["value"] == "draw" for c in children) else "loss"