File size: 10,387 Bytes
25faada
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f74b7d
 
 
 
 
 
 
 
25faada
 
 
 
 
 
 
7f74b7d
25faada
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""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"