Spaces:
Running
Running
File size: 96,776 Bytes
c03d895 | 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 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 | """
app.py: ChessTB Explorer back-end
A single-file Flask application that probes a ChessTB endgame tablebase
for a submitted FEN position and returns every legal move ranked by
Distance to Zeroing (DTZ), Distance to Conversion (DTC), Distance to
Mate (DTM), and DTM under the 50-move rule (DTM50).
DTC is 50-move-rule-aware in the same way DTM50 is (it is always probed
at the position's own halfmove clock), but unlike DTM50 it has no
"flat"/clock-independent sibling column of its own -- there is only ever
one DTC value for a position. A ``.lzdtc`` table only exists for material
with at least one pawn; for pawnless material `chess.chesstb` derives an
equivalent answer from DTZ/WDL instead (see chess.chesstb.ProbeResult),
so `has_dtc` is False only when a pawnful material's own
`.lzdtc` file genuinely hasn't been generated/shipped.
Routes
------
POST /probe - evaluate a FEN position (JSON)
POST /probe/stream - evaluate with SSE streaming progress
GET / - main UI
GET /health - readiness check
GET /admin - cache dashboard HTML page, or a login form if not authenticated
POST /admin/login - start an admin session (JSON {token})
POST /admin/logout - end the admin session
POST /admin/cache/clear - purge both LRU caches
GET /admin/cache/stats - cache hit-rate statistics
GET /openapi.yaml - OpenAPI 3.0 specification
/admin and /admin/cache/* require admin authentication -- either a
session cookie started via /admin/login, or an "Authorization: Bearer
<ADMIN_TOKEN>" header. See config.py's "Admin" section and README.md's
"Security notes" for how ADMIN_TOKEN is configured.
/probe and /probe/stream are rate-limited per client IP (config.py's
PROBE_RATE_LIMIT); a client over the limit gets a 429 with a JSON body.
/admin/login has its own, separate limit (config.py's
ADMIN_LOGIN_RATE_LIMIT), covering both failed and successful attempts.
Each move entry includes a child_fen so the client can pre-fetch the next
probe, and each response includes a summary of wins/draws/losses/unknown
across all legal moves. A position not covered by the loaded tablebase
raises MissingTableError, which is returned as structured JSON
({error_code, piece_count}). Root-JSON and child-probe cache sizes are set
by config.EVALUATE_CACHE_SIZE / config.PROBE_CACHE_SIZE.
Every request to /probe and /probe/stream carries an explicit "variant"
field ("standard" or "chess960", defaulting to "standard" when omitted)
alongside "fen" -- the two rulesets are never inferred from a FEN's
contents, since the orthodox starting arrangement is itself a legal
Chess960 starting position and would otherwise be ambiguous. A submitted
FEN's castling-availability field accepts "-" or any combination of "K",
"Q", "k", "q" for variant="standard", plus rook-file letters ("a"-"h" /
"A"-"H") for variant="chess960"; a right with no matching king/rook pair
is rejected as invalid input before the board is ever probed (see
_validate_and_build_board). A position with castling rights is otherwise
probed like any other: `chess.chesstb` resolves it when the loaded
tablebase set carries a table for that material (see config.py's
TABLEBASE_PATH and tablebase_router.py -- unaffected by variant,
since a Chess960 board's clean_castling_rights() reports the same way a
Standard board's does), and reports it as not covered when it doesn't.
Running this file directly (`python app.py`) serves the app via waitress
(a production-grade WSGI server) by default. Set DEBUG = True in
config.py to use Flask's own dev server instead (auto-reload +
interactive debugger) for local development. See README.md
"Installation" / "Running in production" for details.
"""
from __future__ import annotations
from collections import OrderedDict, namedtuple
from concurrent.futures import (
ThreadPoolExecutor,
wait as futures_wait,
ALL_COMPLETED,
FIRST_COMPLETED,
)
from dataclasses import dataclass
from flask import (
Flask, render_template, request, jsonify, Response,
stream_with_context, send_file, session,
)
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from functools import lru_cache, wraps
from typing import TypedDict
from waitress import serve as waitress_serve
from werkzeug.middleware.proxy_fix import ProxyFix
import atexit
import chess
import config
import dtz_anchor
import hmac
import json
import os
import re
import logging
import secrets
import tablebase_router
import threading
import time
# ── Logging ───────────────────────────────────────────────────────────────────
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger(__name__)
# ── ChessTB backend ───────────────────────────────────────────────────────────
# chess.chesstb comes from noobpwnftw's add-chesstb-tablebases fork of
# python-chess (see requirements.txt) - open_tablebase, ProbeResult's raw
# wdl/dtz/dtc/dtm/dtm50 fields, MissingTableError, and the WIN/CURSED_WIN/
# DRAW/BLESSED_LOSS/LOSE constants all come from that module.
import chess.chesstb as chesstb
log.info("chess.chesstb backend: noobpwnftw/python-chess (add-chesstb-tablebases)")
# Remote (HTTP) tablebase support, in two flavours -- config.REMOTE_MODE
# picks one (see tablebase_router.open_one):
# remote/remote_direct.py ("direct") probe in place over byte ranges
# remote/remote_fallback.py ("download") whole-file download, disk-cached
# See each file's module docstring for the design and the trade-off.
# looks_like_remote()/RemoteSourceError come from remote/remote_source.py.
from remote import remote_direct, remote_fallback, remote_source
# ── Type aliases ──────────────────────────────────────────────────────────────
class MoveEntry(TypedDict):
san: str
plies: int
order: int # DTC only: pawn pushes still owed before a conversion (0 for every other metric)
is_mate: bool
outcome: str # "win"|"cursed_win"|"draw"|"blessed_loss"|"loss"|"unknown"|"not_available"
available: bool # False when this move's own metric table isn't present (outcome is then "not_available")
child_fen: str | None # FEN after this move; None for terminal / unknown
draw_reason: str | None # "stalemate"|"insufficient_material"|None
class DtzMoveEntry(MoveEntry, total=False):
rounded: bool # DTZ rows only, when available: plies may be a ply off
@dataclass
class _DtzMove:
row: int # index into the DTZ rows
my_wdl: int
keeps_class: bool
option: dtz_anchor.MoveOption
MoveList = list[MoveEntry]
# ── Config validation helpers ─────────────────────────────────────────────────
# Centralise the "read a config.py value, check its type, validate range"
# pattern that AppConfig.from_config() below needs once per setting.
def _validated_int(name: str, value, *, min_val: int = 1, max_val: int | None = None) -> int:
if isinstance(value, str):
# PORT and TRUSTED_PROXY_COUNT are the two config.py settings read
# as a raw string rather than pre-converted at import time (see
# config.py's own comments on each) -- specifically so a bad
# value raises here, inside AppConfig.from_config()'s try/except,
# rather than as an uncaught ValueError from a bare int() call
# during module import, before this module has an exception handler
# in place for it.
try:
value = int(value.strip())
except ValueError:
raise ValueError(f"config.{name} must be an integer, got {value!r}") from None
if not isinstance(value, int) or isinstance(value, bool):
raise ValueError(f"config.{name} must be an integer, got {value!r}")
if max_val is None:
if value < min_val:
raise ValueError(f"config.{name} must be >= {min_val}, got {value}")
elif not (min_val <= value <= max_val):
raise ValueError(f"config.{name} must be {min_val}-{max_val}, got {value}")
return value
def _validated_float(name: str, value) -> float:
if not isinstance(value, (int, float)) or isinstance(value, bool):
raise ValueError(f"config.{name} must be a number, got {value!r}")
value = float(value)
if value <= 0:
raise ValueError(f"config.{name} must be > 0, got {value}")
return value
def _validated_str(name: str, value) -> str:
if not isinstance(value, str):
raise ValueError(f"config.{name} must be a string, got {value!r}")
return value
def _validated_path_spec(name: str, value) -> str | list[str]:
"""TABLEBASE_PATH: a
single path (local directory or http(s):// URL) or a list of them for
a hybrid source (see tablebase_router.py). Each list entry must
itself be a string -- a nested list isn't a valid hybrid source."""
if isinstance(value, str):
return value
if isinstance(value, (list, tuple)) and all(isinstance(v, str) for v in value):
return list(value)
raise ValueError(f"config.{name} must be a string or a list of strings, got {value!r}")
def _validated_choice(name: str, value, allowed: tuple) -> str:
if value not in allowed:
raise ValueError(
f"config.{name} must be one of {', '.join(map(repr, allowed))}, got {value!r}")
return value
def _validated_bool(name: str, value) -> bool:
if not isinstance(value, bool):
raise ValueError(f"config.{name} must be True or False, got {value!r}")
return value
# ── Validated configuration ───────────────────────────────────────────────────
@dataclass(frozen=True)
class AppConfig:
tablebase_path: str | list[str]
probe_threads: int
parallel_threshold: int
probe_timeout: float
host: str
port: int
evaluate_cache_size: int
probe_cache_size: int
block_cache_bytes: int
debug: bool
waitress_threads: int
# Only used for an http(s):// URL entry in tablebase_path (see "ChessTB
# backend" above and remote/remote_source.py) -- ignored entirely for
# a local-directory entry. Bounds an on-disk LRU of whole downloaded
# table files cached by remote/remote_fallback.py (see that file's
# module docstring for the full design).
remote_mode: str
remote_page_cache_bytes: int
remote_page_size_bytes: int
remote_timeout_secs: float
remote_max_retries: int
remote_pool_maxsize: int
admin_token: str
secret_key: str
source_repository_url: str
probe_rate_limit: str
prefetch_rate_limit: str
admin_login_rate_limit: str
trusted_proxy_count: int
session_cookie_secure: bool
# Hard-coded limit, not one of config.py's settings -- kept as a
# dataclass field (rather than a bare module constant) so it reads
# alongside every other request-validation knob above.
max_fen_length: int = 100
@classmethod
def from_config(cls) -> "AppConfig":
# PROBE_THREADS is the one setting with a computed (not literal)
# default, so a None left in config.py falls back to a
# CPU-count-based figure instead of going through _validated_int.
threads = (
_validated_int("PROBE_THREADS", config.PROBE_THREADS)
if config.PROBE_THREADS is not None
else min(16, (os.cpu_count() or 4) * 2)
)
# Same "None means auto" shape as PROBE_THREADS above, computed
# from it: a connection pool smaller than the probe thread count
# gives up connection reuse for whichever requests land once the
# pool is exhausted (see remote/remote_source.py).
pool_maxsize = (
_validated_int("REMOTE_POOL_MAXSIZE", config.REMOTE_POOL_MAXSIZE)
if config.REMOTE_POOL_MAXSIZE is not None
else max(threads * 2, 20)
)
return cls(
tablebase_path=_validated_path_spec("TABLEBASE_PATH", config.TABLEBASE_PATH),
probe_threads=threads,
parallel_threshold=_validated_int("PROBE_PARALLEL_THRESHOLD", config.PROBE_PARALLEL_THRESHOLD),
probe_timeout=_validated_float("PROBE_TIMEOUT_SECS", config.PROBE_TIMEOUT_SECS),
host=_validated_str("HOST", config.HOST),
port=_validated_int("PORT", config.PORT, max_val=65535),
evaluate_cache_size=_validated_int("EVALUATE_CACHE_SIZE", config.EVALUATE_CACHE_SIZE),
probe_cache_size=_validated_int("PROBE_CACHE_SIZE", config.PROBE_CACHE_SIZE),
block_cache_bytes=_validated_int("BLOCK_CACHE_BYTES", config.BLOCK_CACHE_BYTES),
# DEBUG also selects the server used by
# `if __name__ == "__main__"` below: the Flask dev server when
# True, waitress otherwise (see README.md "Running in production").
debug=_validated_bool("DEBUG", config.DEBUG),
waitress_threads=_validated_int("WAITRESS_THREADS", config.WAITRESS_THREADS),
remote_mode=_validated_choice("REMOTE_MODE", config.REMOTE_MODE, ("direct", "download")),
remote_page_cache_bytes=_validated_int(
"REMOTE_PAGE_CACHE_BYTES", config.REMOTE_PAGE_CACHE_BYTES),
remote_page_size_bytes=_validated_int(
"REMOTE_PAGE_SIZE_BYTES", config.REMOTE_PAGE_SIZE_BYTES),
remote_timeout_secs=_validated_float("REMOTE_TIMEOUT_SECS", config.REMOTE_TIMEOUT_SECS),
remote_max_retries=_validated_int("REMOTE_MAX_RETRIES", config.REMOTE_MAX_RETRIES),
remote_pool_maxsize=pool_maxsize,
admin_token=_validated_str("ADMIN_TOKEN", config.ADMIN_TOKEN),
secret_key=_validated_str("FLASK_SECRET_KEY", config.FLASK_SECRET_KEY),
source_repository_url=_validated_str("SOURCE_REPOSITORY_URL", config.SOURCE_REPOSITORY_URL),
probe_rate_limit=_validated_str("PROBE_RATE_LIMIT", config.PROBE_RATE_LIMIT),
prefetch_rate_limit=_validated_str("PREFETCH_RATE_LIMIT", config.PREFETCH_RATE_LIMIT),
admin_login_rate_limit=_validated_str("ADMIN_LOGIN_RATE_LIMIT", config.ADMIN_LOGIN_RATE_LIMIT),
trusted_proxy_count=_validated_int(
"TRUSTED_PROXY_COUNT", config.TRUSTED_PROXY_COUNT, min_val=0, max_val=10),
session_cookie_secure=_validated_bool("SESSION_COOKIE_SECURE", config.SESSION_COOKIE_SECURE),
)
try:
cfg = AppConfig.from_config()
except (ValueError, AttributeError) as e:
log.error("Configuration error: %s — fix config.py and restart.", e)
raise SystemExit(1) from e
# Exception type to treat as "couldn't reach the remote tablebase" in the
# /probe and /probe/stream handlers below.
_REMOTE_SOURCE_ERROR = remote_source.RemoteSourceError
if not cfg.tablebase_path:
log.warning("TABLEBASE_PATH is not set — tablebase lookups will fail.")
if not cfg.admin_token:
log.warning(
"ADMIN_TOKEN is not set — /admin and /admin/cache/* will respond "
"503 until it's configured (see config.py's \"Admin\" section)."
)
# ── Flask app ─────────────────────────────────────────────────────────────────
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 4 * 1024 # 4 KB
# secrets.token_hex(32) generated fresh at process startup when
# FLASK_SECRET_KEY isn't set (see config.py) -- every admin session is then
# invalidated by a restart, requiring a new login; it never
# breaks anything the app itself depends on, since sessions here hold
# nothing but the admin_ok flag set by /admin/login.
app.secret_key = cfg.secret_key or secrets.token_hex(32)
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
# Independent of cfg.debug -- see config.py's SESSION_COOKIE_SECURE for
# why: DEBUG is a development/runtime behavior switch, not a reliable
# indicator of whether the browser reaches this deployment over HTTPS.
app.config["SESSION_COOKIE_SECURE"] = cfg.session_cookie_secure
# Without this, request.remote_addr -- and therefore get_remote_address()
# below, which keys both rate limiters -- is whichever host made the TCP
# connection to this process. Behind a reverse proxy (Render's own
# edge/load-balancer in front of the render.yaml deployment this project
# ships with, or any other reverse proxy placed in front of it) that's the
# proxy's own address, not the actual client's, so every client sharing
# that proxy would collapse onto one rate-limit key. ProxyFix instead
# trusts exactly cfg.trusted_proxy_count X-Forwarded-For hops closest to
# this app and reads the real client IP from ahead of them -- set to 0
# this is a no-op, appropriate only for a deployment with no reverse proxy
# in front of it at all (an untrusted client's own X-Forwarded-For header
# would otherwise be trusted, letting it spoof its rate-limit key).
#
# x_proto does the equivalent correction for X-Forwarded-Proto, which a
# TLS-terminating reverse proxy sets to tell this app the scheme the
# client actually connected with -- without it, request.scheme /
# request.is_secure (and any URL this app builds with
# url_for(..., _external=True)) would read as plain http:// even when the
# client's own connection was https://, since Flask only ever sees the
# proxy's own (typically-HTTP) hop to this app. Same trusted-hop count as
# x_for, for the same reason: a value here that doesn't match the real
# proxy chain either misreads the scheme or lets a client spoof it.
#
# This only actually matters for cfg.debug's Flask-dev-server path below --
# under waitress (cfg.debug False, the production path), waitress parses
# X-Forwarded-For and corrects REMOTE_ADDR itself before this app ever
# sees the request, taking priority over -- and making redundant, but
# harmlessly so -- the correction this makes. See the waitress_serve(...)
# call below for that side of it.
if cfg.trusted_proxy_count > 0:
app.wsgi_app = ProxyFix(
app.wsgi_app, x_for=cfg.trusted_proxy_count, x_proto=cfg.trusted_proxy_count)
@app.errorhandler(413)
def request_too_large(e: Exception) -> tuple:
return jsonify({"error": "Request body too large (max 4 KB)."}), 413
# ── Rate limiting ────────────────────────────────────────────────────────────
# Per-IP limits on /probe, /probe/stream (config.py's PROBE_RATE_LIMIT, or
# PREFETCH_RATE_LIMIT for a request tagged "X-Prefetch: 1" -- see
# _request_probe_limit below) and, separately, /admin/login (config.py's
# ADMIN_LOGIN_RATE_LIMIT -- see README.md's "Security notes").
# default_limits=[] means nothing is limited except the routes explicitly
# decorated with @_probe_rate_limit / @_admin_login_rate_limit below; the
# in-memory storage is per-process, which is enough for a single-container
# deployment but doesn't share state across multiple replicas. key_func
# reads request.remote_addr, which ProxyFix (see the Flask app section
# above) has already corrected for cfg.trusted_proxy_count reverse-proxy
# hops.
limiter = Limiter(
key_func=get_remote_address, app=app, default_limits=[], storage_uri="memory://",
headers_enabled=True)
def _request_probe_limit() -> str:
# X-Prefetch is client-asserted and therefore untrusted as a basis for
# relaxing enforcement -- it only ever selects a separate, tighter
# budget than the default, never bypasses PROBE_RATE_LIMIT. A request
# without the header, or when PREFETCH_RATE_LIMIT is unset, always
# falls back to the normal probe budget.
if request.headers.get("X-Prefetch") == "1" and cfg.prefetch_rate_limit:
return cfg.prefetch_rate_limit
return cfg.probe_rate_limit
def _probe_rate_limit(view):
# A plain pass-through when neither limit is configured, rather than
# a zero/near-zero limiter.limit() call, so "disabled" actually means
# unlimited rather than "limited to approximately nothing." A
# callable limit_value (rather than the plain string
# _admin_login_rate_limit below uses) is what lets a single decorator
# route a hover-prefetch request to its own, tighter budget -- see
# _request_probe_limit above -- flask-limiter re-evaluates it on
# every request rather than once at decoration time.
if not cfg.probe_rate_limit and not cfg.prefetch_rate_limit:
return view
return limiter.limit(_request_probe_limit)(view)
def _admin_login_rate_limit(view):
# Independent of _probe_rate_limit above -- /admin/login is a
# brute-forceable shared-secret check, not a CPU-bound probe, so it
# gets its own (much tighter) limit rather than reusing or being
# covered by PROBE_RATE_LIMIT.
if not cfg.admin_login_rate_limit:
return view
return limiter.limit(cfg.admin_login_rate_limit)(view)
@app.errorhandler(429)
def rate_limit_exceeded(e: Exception) -> tuple:
return jsonify({
"error": "Too many requests. Please slow down and try again shortly.",
}), 429
# ── Tablebase + thread pool ───────────────────────────────────────────────────
# All material, including castling and recursive dependencies, uses the
# same ordered source list. The adapter shares one decoded-block cache.
try:
_tb_source = tablebase_router.open_many(
cfg.tablebase_path, label="TABLEBASE_PATH", cfg=cfg,
remote_source=remote_source, remote_direct=remote_direct,
remote_download=remote_fallback,
)
except Exception as e:
log.warning("Failed to open TABLEBASE_PATH: %s", e)
_tb_source = None
TB = (
tablebase_router.SourcedTablebase(
_tb_source, remote_source=remote_source,
block_cache_bytes=cfg.block_cache_bytes,
)
if _tb_source is not None else None
)
_executor = ThreadPoolExecutor(max_workers=cfg.probe_threads, thread_name_prefix="tb_probe")
log.info(
"Thread pool: %d workers, parallel_threshold=%d, timeout=%.0fs, "
"eval_cache=%d, probe_cache=%d, block_cache_bytes=%d",
cfg.probe_threads, cfg.parallel_threshold, cfg.probe_timeout,
cfg.evaluate_cache_size, cfg.probe_cache_size, cfg.block_cache_bytes,
)
@atexit.register
def _close_tb() -> None:
try:
if TB is not None:
TB.close()
except Exception as e:
log.warning("Error closing tablebase: %s", e)
@atexit.register
def _shutdown_executor() -> None:
try:
_executor.shutdown(wait=True, cancel_futures=True)
except Exception as e:
log.warning("Error shutting down thread pool: %s", e)
# ── Security headers ──────────────────────────────────────────────────────────
@app.after_request
def set_security_headers(response: Response) -> Response:
# base-uri and form-action are set explicitly since default-src doesn't
# cover them; object-src, though covered, is set to 'none' since the app
# has no use for <object>/<embed>. Allow Hugging Face's Space page
# to embed the application; CSP controls the permitted ancestors.
response.headers["Content-Security-Policy"] = (
"default-src 'self'; script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"font-src 'self'; img-src 'self' data:; connect-src 'self'; "
"object-src 'none'; base-uri 'self'; form-action 'self'; "
"frame-ancestors 'self' https://huggingface.co;"
)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
return response
# ── Admin authentication ────────────────────────────────────────────────────
# Gates /admin and /admin/cache/* behind cfg.admin_token (see config.py's
# "Admin" section). Two ways to authenticate:
# - Browser: POST /admin/login with the token starts a signed session
# cookie (see app.secret_key above); subsequent requests from the same
# browser are authenticated via that cookie automatically.
# - API client: send "Authorization: Bearer <ADMIN_TOKEN>" directly,
# no session/cookie needed.
# If cfg.admin_token is empty, every admin route responds 503 rather than
# running with no password -- there is no "admin panel open to everyone"
# fallback.
def _admin_token_matches(candidate: str) -> bool:
# hmac.compare_digest for constant-time comparison -- a plain `==`
# would leak the token's length/prefix through response-timing
# differences to an attacker making repeated guesses.
return bool(cfg.admin_token) and hmac.compare_digest(candidate, cfg.admin_token)
def _is_admin_authenticated() -> bool:
if session.get("admin_ok"):
return True
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
return _admin_token_matches(auth_header[len("Bearer "):])
return False
def _require_admin(view):
@wraps(view)
def wrapped(*args, **kwargs):
if not cfg.admin_token:
return jsonify({
"error": "Admin panel disabled: ADMIN_TOKEN is not configured.",
}), 503
if not _is_admin_authenticated():
return jsonify({"error": "Admin authentication required."}), 401
return view(*args, **kwargs)
return wrapped
# ── FEN normalisation ─────────────────────────────────────────────────────────
_FEN_FIELD_DEFAULTS = ("8/8/8/8/8/8/8/8", "w", "-", "-", "0", "1")
def _normalize_fen(fen: str) -> str:
parts = fen.split()
while len(parts) < 6:
parts.append(_FEN_FIELD_DEFAULTS[len(parts)])
return " ".join(parts[:6])
# ── Sort helpers ──────────────────────────────────────────────────────────────
_OUTCOME_RANK: dict[int, int] = {2: 0, 1: 1, 0: 2, -1: 3, -2: 4}
_PROMOTION_RANK = {"Q": 0, "R": 1, "B": 2, "N": 3}
def _move_tiebreak(san: str) -> tuple[int, str, int, str]:
"""Prefer immediate mate, then SAN order with Q/R/B/N promotions.
The SAN prefix groups promotions along the same path. This key is
used only after the metric's outcome, DTC order, and distance, so a
better underpromotion still beats a worse queen promotion. Immediate
mate must beat a tied zeroing/conversion move (DTZ/DTC can score both
as one ply), including when the mating move is an underpromotion.
"""
mate_rank = 0 if san.endswith("#") else 1
prefix, separator, promotion = san.partition("=")
if separator:
return mate_rank, prefix, _PROMOTION_RANK[promotion[0]], promotion[1:]
return mate_rank, san, 0, ""
def _outcome_rank(wdl_val: int) -> int:
return _OUTCOME_RANK[wdl_val]
def _ply_rank(wdl_val: int, eff_val: int) -> int:
if wdl_val > 0: return eff_val
if wdl_val < 0: return -abs(eff_val)
return 0
def _order_rank(wdl_val: int, order: int) -> int:
"""Sort key for DTC's "pushes still owed" component. DTC's own notion
of a better line is pushes first, then plies (see ProbeResult's
docstring in chess.chesstb): fewer pushes owed is better while
winning, more is better while losing (it's more delay bought before
the opponent's own clock forces a conversion). order is already an
unsigned count, so - unlike _ply_rank - there's no magnitude to
unwrap, only a direction to apply."""
if wdl_val > 0: return order
if wdl_val < 0: return -order
return 0
_WDL_TO_OUTCOME_LABEL: dict[int, str] = {
2: "win", 1: "cursed_win", 0: "draw", -1: "blessed_loss", -2: "loss",
}
def _outcome_label(wdl_val: int) -> str:
return _WDL_TO_OUTCOME_LABEL[wdl_val]
def _effective_move_wdl(move_wdl: int, root_wdl: int, eff_distance: int) -> int:
"""The win/cursed-win (or loss/blessed-loss) bucket a move belongs in,
used for both its outcome label and its sort-key rank. A raw win/loss
(move_wdl 2/-2) downgrades to cursed_win/blessed_loss (1/-1) when its
own eff_distance exceeds chesstb.MAX_NON_CURSED_DTZ. The result is then capped
at root_wdl, since a move can never be better than the root position's
own optimal value - this cap is one-directional, as a move can be
worse than root_wdl (e.g. a blunder turning a blessed_loss root into a
pure -2 loss). Draws pass through unchanged."""
if abs(eff_distance) > chesstb.MAX_NON_CURSED_DTZ:
if move_wdl == 2:
move_wdl = 1
elif move_wdl == -2:
move_wdl = -1
return _capped_wdl(move_wdl, root_wdl)
def _effective_distance(wdl: int, raw_distance: int) -> int:
if wdl != 0 and raw_distance == 0:
return 1 if wdl > 0 else -1
if raw_distance > 0:
return raw_distance + 1
if raw_distance < 0:
return raw_distance - 1
return 0
def _is_cursed_exit(parent: chess.Board, child: chess.Board, child_wdl: int) -> bool:
"""True when a move into a cursed or blessed child (signed child_wdl)
gives up castling rights."""
return (abs(child_wdl) == 1
and chess.popcount(child.clean_castling_rights())
< chess.popcount(parent.clean_castling_rights()))
def _capped_wdl(move_wdl: int, root_wdl: int) -> int:
"""move_wdl capped at root_wdl: a move can never be better than the root
position's own optimal value (see _effective_move_wdl). Used by the
clock-independent DTZ/DTM columns."""
return root_wdl if move_wdl > root_wdl else move_wdl
# ── Variant ────────────────────────────────────────────────────────────────
#
# "standard" | "chess960". Chess960 changes only the interpretation of
# castling rights/moves -- board geometry, piece movement, and tablebase
# material selection are unaffected (see tablebase_router.py, unchanged by
# variant). The two rulesets are never inferred from a FEN's contents: the
# orthodox starting array is itself one of the 960 legal Chess960 starting
# arrangements, so the same FEN text is genuinely ambiguous without this
# explicit field. An omitted variant defaults to "standard".
_ALLOWED_VARIANTS = frozenset({"standard", "chess960"})
def _normalize_variant(value: object) -> str:
if value is None:
return "standard"
if not isinstance(value, str):
raise ValueError("variant must be a string.")
normalized = value.strip().lower()
if normalized not in _ALLOWED_VARIANTS:
raise ValueError("Unsupported variant: expected 'standard' or 'chess960'.")
return normalized
def _build_board(variant: str, fen: str) -> chess.Board:
"""The single place a chess.Board is constructed from a request- or
move-generation-supplied FEN string. chess960=True changes only how
the fork parses/serializes the castling field and how it represents
castling moves -- everything else about the resulting Board (piece
movement, check/mate detection, piece_map, etc.) behaves identically
to the variant="standard" path."""
return chess.Board(fen, chess960=(variant == "chess960"))
# ── FEN pre-validation ────────────────────────────────────────────────────────
_EP_PATTERN = re.compile(r"^(-|[a-h][36])$")
_CASTLING_PATTERN_STANDARD = re.compile(r"^K?Q?k?q?$")
# Chess960 castling rights use conventional K/Q/k/q where applicable and
# rook-file letters for explicit rook identification. Semantic validity is
# checked after board construction.
_CASTLING_PATTERN_CHESS960 = re.compile(r"^[A-Ha-hKQkq]{1,4}$")
def _validate_fen_parts(variant: str, parts: list[str]) -> str | None:
if len(parts) < 2:
return "FEN must have at least a piece-placement field and a side-to-move field."
if parts[1] not in ("w", "b"):
return f"Invalid side to move: '{parts[1]}' (expected 'w' or 'b')."
if len(parts) >= 3 and parts[2] != "-":
if variant == "chess960":
if not _CASTLING_PATTERN_CHESS960.match(parts[2]):
return (
f"Invalid castling availability: '{parts[2]}' (expected some combination "
"of 'K', 'Q', 'k', 'q', rook-file letters 'a'-'h'/'A'-'H', or '-')."
)
elif not _CASTLING_PATTERN_STANDARD.match(parts[2]):
return (
f"Invalid castling availability: '{parts[2]}' "
"(expected some combination of 'K', 'Q', 'k', 'q', or '-')."
)
if len(parts) >= 4 and not _EP_PATTERN.match(parts[3]):
return (
f"Invalid en-passant square: '{parts[3]}' "
"(expected '-' or a valid target square such as 'e3' or 'd6')."
)
if len(parts) >= 5:
try:
hmc = int(parts[4])
if hmc < 0:
return "Halfmove clock must be a non-negative integer."
except ValueError:
return f"Invalid halfmove clock: '{parts[4]}' (expected a non-negative integer)."
if len(parts) >= 6:
try:
fmc = int(parts[5])
if fmc < 1:
return "Fullmove number must be a positive integer."
except ValueError:
return f"Invalid fullmove number: '{parts[5]}' (expected a positive integer)."
return None
# ── Signed-value helpers ──────────────────────────────────────────────────────
#
# WIN/CURSED_WIN/DRAW/BLESSED_LOSS/LOSE -> +2/+1/0/-1/-2, matching
# chess.syzygy's convention -- built from chess.chesstb's public class
# constants rather than its private _WDL_SIGNED, since this app has no
# reason to depend on a leading-underscore upstream symbol for a five-entry
# map it can just as easily own. This is what _probe_board below builds its
# signed wdl/dtc_wdl/dtm50_wdl fields on.
_SIGNED_WDL: dict[int, int] = {
chesstb.WIN: 2, chesstb.CURSED_WIN: 1, chesstb.DRAW: 0,
chesstb.BLESSED_LOSS: -1, chesstb.LOSE: -2,
}
def _signed_wdl(wdl: int) -> int | None:
"""Signed WDL for a raw wdl/dtc_wdl/dtm50_wdl class value."""
return _SIGNED_WDL.get(wdl)
# ── Raw probe ─────────────────────────────────────────────────────────────────
def _probe_board(board: chess.Board) -> chesstb.ProbeResult | None:
# A single combined probe() call computes WDL + DTZ + DTC + DTM + DTM50
# together. The raw ProbeResult is returned as-is: its constructor
# zero-initialises dtz/dtc_order/dtc/dtm/dtm50 and sets
# wdl/dtc_wdl/dtm50_wdl to ILLEGAL whenever the matching has_* flag is
# False, and _signed_wdl() maps ILLEGAL to None. WDL is the only metric
# required - a position whose WDL can't be resolved is not probeable and
# returns None. DTZ, DTC, DTM, and DTM50 are each independently
# optional; has_dtz/has_dtc/has_dtm/has_dtm50 report which of the four
# the loaded tables answered for this position.
r = TB.probe(board)
if r.status != "ok" or _signed_wdl(r.wdl) is None:
return None
return r
# ── Child-position probe cache ────────────────────────────────────────────────
class ProbeInFlightTimeout(Exception):
"""A probe of this FEN was already running and didn't finish in time.
Retryable, exactly like the probe having timed out directly."""
class _InFlightProbe:
"""Slot for one probe in progress. The thread that creates it does the
probing; every other thread asking for the same FEN waits on it."""
__slots__ = ("done", "result", "error")
def __init__(self) -> None:
self.done = threading.Event()
self.result: chesstb.ProbeResult | None = None
self.error: BaseException | None = None
_inflight_lock = threading.Lock()
_inflight: dict[tuple[str, str], _InFlightProbe] = {} # (variant, fen) -> slot
# Set (per calling thread) by _probe_fen_with_deadline below, so
# _probe_fen_deduped's in-flight wait can use a request's own remaining
# deadline instead of a fresh cfg.probe_timeout -- see that function's
# docstring for why a plain module-level value wouldn't work here. .board
# is set the same way, for the same reason, when the caller already has a
# chess.Board built from this exact fen in scope -- see
# _probe_fen_with_deadline's board parameter.
_probe_tls = threading.local()
def _probe_fen_deduped(variant: str, fen: str) -> chesstb.ProbeResult | None:
"""Runs at most one probe per (variant, fen) at a time, however many
callers ask.
_probe_fen's lru_cache only dedupes probes that have already finished:
concurrent callers all miss it and all probe the same position. That
happens routinely: /probe/stream pre-warms a child FEN that
evaluate_all_moves then asks for again, and a retry after a timeout
re-asks for a FEN whose first probe is still running. Each duplicate costs
a full remote probe and occupies a pool worker.
A non-owner's wait is bounded by the calling thread's own remaining
deadline (see _probe_fen_with_deadline) so one hung probe can't pin a
waiter beyond its own request's budget -- falling back to a fresh
cfg.probe_timeout when no deadline was set, for a caller reached
outside a deadline-aware path. The wait expiring raises, which reads
downstream as a retryable failure just like the probe itself timing out.
The owner builds the chess.Board to probe from (variant, fen) itself
unless the calling thread already left one in _probe_tls.board (see
_probe_fen_with_deadline's board parameter) -- for a root-fen probe,
the caller has usually already built one anyway (to validate the FEN,
render the board, etc.), and re-parsing the same string a second time
here is pure waste. variant is required even when board is supplied,
since it is also this function's in-flight/dedup key -- the same FEN
text probes to a different (and possibly incompatible) result under
Standard vs Chess960 rules (see the module-level variant comment), so
a key of fen alone would let one variant's probe be served to the
other's caller.
"""
deadline = getattr(_probe_tls, "deadline", None)
if deadline is not None and time.monotonic() >= deadline:
raise ProbeInFlightTimeout("Probe deadline expired before work started")
key = (variant, fen)
with _inflight_lock:
slot = _inflight.get(key)
if slot is None:
slot = _inflight[key] = _InFlightProbe()
owner = True
else:
owner = False
if not owner:
deadline = getattr(_probe_tls, "deadline", None)
timeout = (deadline - time.monotonic()) if deadline is not None else cfg.probe_timeout
if timeout <= 0 or not slot.done.wait(max(0, timeout)):
raise ProbeInFlightTimeout(f"Timed out waiting on an in-flight probe of variant={variant} {fen}")
if slot.error is not None:
raise slot.error
return slot.result
try:
board = getattr(_probe_tls, "board", None)
slot.result = _probe_board(board if board is not None else _build_board(variant, fen))
return slot.result
except BaseException as exc:
slot.error = exc
raise
finally:
# Drop the slot before waking the waiters, so a caller arriving in
# between starts a fresh probe rather than joining a finished one.
with _inflight_lock:
_inflight.pop(key, None)
slot.done.set()
@lru_cache(maxsize=cfg.probe_cache_size)
def _probe_fen(variant: str, fen: str) -> chesstb.ProbeResult | None:
return _probe_fen_deduped(variant, fen)
def _probe_fen_with_deadline(
variant: str, fen: str, deadline: float | None, board: chess.Board | None = None,
) -> chesstb.ProbeResult | None:
"""Calls _probe_fen(variant, fen), but first makes `deadline` (and,
optionally, `board`) visible to _probe_fen_deduped's in-flight wait /
owner branch on this thread.
_probe_fen must stay a (variant, fen)-only lru_cache -- adding deadline
as a real parameter would make it part of the cache key, and since a
deadline is different on every call, that would turn the cache into a
permanent miss. A plain thread-local set by the caller doesn't work
either when this runs via _executor.submit(_probe_fen_with_deadline,
variant, fen, ...): submit() runs the call on a pool worker thread, not
the submitting thread, so a thread-local the submitter set wouldn't be
visible there. Passing deadline as a real argument to this wrapper --
called either directly or as the submitted callable itself -- sidesteps
both problems: it's a normal argument up to here, then becomes this
worker thread's own thread-local for the duration of the call.
board is optional and exists purely to avoid re-parsing a FEN this
thread already has a chess.Board for -- pass it only when `board` was
itself built from this exact (variant, fen) pair, right here, in this
same function call's caller, moments before this call. _probe_fen_deduped
has no way to verify the two actually match (board.fen() isn't
reliably equal to a shortened/unpadded input fen string, so comparing
them isn't a safety net either), so passing a board that doesn't
correspond to (variant, fen) silently returns a wrong probe result.
Child boards copied by _collect_move_info() satisfy the same contract.
"""
_probe_tls.deadline = deadline
_probe_tls.board = board
try:
return _probe_fen(variant, fen)
finally:
_probe_tls.deadline = None
_probe_tls.board = None
# ── Move evaluation ───────────────────────────────────────────────────────────
def _apply_sign(magnitude: int, sign_wdl: int) -> int:
"""Gives an unsigned ply magnitude the sign implied by a signed WDL
value: positive for a winning sign_wdl, negative for a losing one,
zero for a draw. Shared by every DTZ/DTC/DTM/DTM50 assembly step in
evaluate_all_moves below, so the four metrics can't drift apart on
how a magnitude gets its sign."""
if sign_wdl > 0:
return magnitude
if sign_wdl < 0:
return -magnitude
return 0
def _collect_move_info(board: chess.Board) -> list[tuple[str, bool, bool, bool, str | None, str | None, chess.Board | None]]:
move_info: list[tuple[str, bool, bool, bool, str | None, str | None, chess.Board | None]] = []
for move in board.legal_moves:
san = board.san(move)
# Bypasses expensive Board.is_checkmate() checks by parsing the SAN string directly
move_is_mate = san.endswith("#")
move_is_zeroing = board.is_zeroing(move)
# DTC's own notion of "conversion" (a capture or a promotion) is
# narrower than move_is_zeroing (capture OR any pawn move): a quiet
# pawn push zeroes the clock too, but chess.chesstb.Tablebase.
# _dtc_move_kind does not treat it as a conversion, only as one of
# the pushes the winning side still owes (see the DTC row below,
# this field's only consumer). is_capture() rather than a bare
# to-square occupancy check so an
# en passant capture - whose destination square is empty - still
# counts as a conversion here.
move_is_conversion = board.is_capture(move) or move.promotion is not None
draw_reason: str | None = None
child_board: chess.Board | None = None
board.push(move)
try:
if not move_is_mate:
# Bypasses Board.is_stalemate() by stopping at the first legal move found
if not any(board.generate_legal_moves()):
draw_reason = "stalemate"
elif board.is_insufficient_material():
draw_reason = "insufficient_material"
child_fen: str | None = None if (move_is_mate or draw_reason) else board.fen()
if child_fen is not None:
# Upstream probes positions, not repetition history. Preserve
# the clock, en passant and Chess960 state without the stack.
child_board = board.copy(stack=False)
finally:
board.pop()
move_info.append((san, move_is_mate, move_is_zeroing, move_is_conversion, draw_reason, child_fen, child_board))
return move_info
def evaluate_all_moves(
board: chess.Board,
root_wdl: int,
variant: str = "standard",
bypass_parallel: bool = False,
precomputed_move_info: list[tuple[str, bool, bool, bool, str | None, str | None, chess.Board | None]] | None = None,
deadline: float | None = None,
root_dtz: int | None = None,
root_dtz_rounded: bool = False,
) -> tuple[MoveList, MoveList, MoveList, MoveList, bool, dict[str, int], int | None]:
"""Returns the four ranked move lists (DTZ, DTC, DTM, DTM50, in that
order), a "complete" flag: False when at least one move shows "unknown"
for a retryable reason (probe timeout or error) rather than because its
table is genuinely absent (callers use it to decide whether the result
is worth caching - see evaluate_fen()), the wins/draws/losses/unknown
summary across all legal moves, and the root's exact DTZ when
cross-checking the moves resolved it (None otherwise).
root_dtz/root_dtz_rounded are the root's own DTZ, None when its material
has no DTZ table. The DTZ rows' rounded values are resolved against them
by dtz_anchor.resolve().
variant applies to every child position probed below, matching the
ruleset `board` itself was built under (see _build_board) -- board.push()/
pop() inside _collect_move_info already preserves board.chess960 for
move generation and child.fen() serialization for free, since it's the
same Board object throughout; variant only needs to be threaded through
explicitly here because child positions are (re-)probed from their FEN
string, not from the Board object itself (see _probe_fen_with_deadline).
deadline is an absolute time.monotonic() value bounding child probing."""
effective_deadline = deadline if deadline is not None else time.monotonic() + cfg.probe_timeout
# Phase 1: collect move metadata (no TB calls) - reuses the caller's own
# pass over board.legal_moves() when one is supplied, instead of
# re-walking every legal move a second time for the same board.
move_info = precomputed_move_info if precomputed_move_info is not None else _collect_move_info(board)
# Phase 2: probe child positions
unique_fens: set[str] = {child_fen for _, _, _, _, _, child_fen, _ in move_info if child_fen is not None}
probe_cache: dict[str, chesstb.ProbeResult | None] = {}
child_boards: dict[str, chess.Board] = {
child_fen: child_board
for _, _, _, _, _, child_fen, child_board in move_info
if child_fen is not None and child_board is not None
}
# Child FENs that failed for a retryable reason. A probe that timed out is
# still running in the pool and will populate _probe_fen's own cache, so a
# later attempt at this position resolves it; a probe that raised may
# succeed on a retry too. Neither is the same as _probe_fen returning None,
# which means tb_not_found and won't change however often it's re-probed.
transient_failures: set[str] = set()
# Probe sufficiently large uncached batches through the thread pool.
if len(unique_fens) >= cfg.parallel_threshold and not bypass_parallel:
futures = {
fen: _executor.submit(
_probe_fen_with_deadline, variant, fen, effective_deadline, child_boards.get(fen)
)
for fen in unique_fens
}
remaining = max(0.0, effective_deadline - time.monotonic())
done, _ = futures_wait(
futures.values(), timeout=remaining, return_when=ALL_COMPLETED
)
for fen, fut in futures.items():
if fut in done:
try:
probe_cache[fen] = fut.result()
except Exception as exc:
log.warning("Probe error for variant=%s %s: %s", variant, fen, exc)
probe_cache[fen] = None
transient_failures.add(fen)
else:
fut.cancel() # Drop queued work; running probes can still warm the cache.
log.warning("Probe timed out for variant=%s %s", variant, fen)
probe_cache[fen] = None
transient_failures.add(fen)
else:
# Probe smaller batches, and batches whose children are already cached,
# sequentially to avoid unnecessary thread-pool overhead.
for fen in unique_fens:
try:
probe_cache[fen] = _probe_fen_with_deadline(
variant, fen, effective_deadline, child_boards.get(fen)
)
except Exception as exc:
log.warning("Probe error for variant=%s %s: %s", variant, fen, exc)
probe_cache[fen] = None
transient_failures.add(fen)
# Phase 3: assemble and sort
dtz_rows: list[tuple] = []
dtc_rows: list[tuple] = []
dtm_rows: list[tuple] = []
dtm50_rows: list[tuple] = []
# Summary outcomes respect each child's halfmove clock. DTM50 and DTC
# already resolve the rule; clock-independent WDL cannot establish a
# rule50 win or loss when both of those metrics are unavailable.
move_outcomes: list[str] = []
# Sort key/entry pair for a move whose own metric table doesn't cover
# it: flatly "Not Available", the same shape for DTZ, DTC, DTM, and
# DTM50 alike rather than a value borrowed from another metric.
# has_order adds DTC's extra pushes-owed slot to the sort key.
def _not_available(
san: str, child_fen: str | None, move_is_mate: bool,
draw_reason: str | None, has_order: bool = False,
) -> tuple[tuple, MoveEntry]:
key = (
(_outcome_rank(0) + 10, 0, 0, _move_tiebreak(san)) if has_order
else (_outcome_rank(0) + 10, 0, _move_tiebreak(san))
)
entry: MoveEntry = {
"san": san, "plies": 0, "order": 0, "is_mate": move_is_mate, "available": False,
"outcome": "not_available", "child_fen": child_fen, "draw_reason": draw_reason,
}
return key, entry
def _dtz_row(
san: str, my_wdl: int, eff_dtz: int, rounded: bool, move_is_mate: bool,
child_fen: str | None, draw_reason: str | None,
) -> tuple[tuple, DtzMoveEntry, int]:
eff_wdl = _effective_move_wdl(my_wdl, root_wdl, eff_dtz)
key = (_outcome_rank(eff_wdl), _ply_rank(eff_wdl, eff_dtz), _move_tiebreak(san))
entry: DtzMoveEntry = {
"san": san, "plies": abs(eff_dtz), "order": 0, "is_mate": move_is_mate, "available": True,
"outcome": _outcome_label(eff_wdl), "child_fen": child_fen, "draw_reason": draw_reason,
"rounded": rounded,
}
return key, entry, eff_wdl
dtz_moves: list[_DtzMove] = []
dtz_complete = True
for san, move_is_mate, move_is_zeroing, move_is_conversion, draw_reason, child_fen, child_board in move_info:
if move_is_mate:
opp_wdl = opp_dtc_wdl = opp_dtm50_wdl = -2
opp_dtz = opp_dtc = opp_dtc_order = opp_dtm = opp_dtm50 = 0
opp_has_dtz = opp_has_dtc = opp_has_dtm = opp_has_dtm50 = True
opp_dtz_rounded = False
elif draw_reason:
opp_wdl = opp_dtc_wdl = opp_dtm50_wdl = 0
opp_dtz = opp_dtc = opp_dtc_order = opp_dtm = opp_dtm50 = 0
opp_has_dtz = opp_has_dtc = opp_has_dtm = opp_has_dtm50 = True
opp_dtz_rounded = False
else:
probe_result = probe_cache.get(child_fen)
if probe_result is None:
log.warning("No probe result for %s (%s); showing unknown.", child_fen, san)
stub: MoveEntry = {
"san": san, "plies": 0, "order": 0, "is_mate": False, "available": False,
"outcome": "unknown", "child_fen": child_fen, "draw_reason": None,
}
stub_key = (_outcome_rank(0) + 10, 0, _move_tiebreak(san))
dtc_stub_key = (_outcome_rank(0) + 10, 0, 0, _move_tiebreak(san))
dtz_rows.append((stub_key, stub))
dtc_rows.append((dtc_stub_key, stub))
dtm_rows.append((stub_key, stub))
dtm50_rows.append((stub_key, stub))
move_outcomes.append("unknown")
dtz_complete = False
continue
opp_wdl = _signed_wdl(probe_result.wdl)
opp_has_dtz, opp_dtz = probe_result.has_dtz, probe_result.dtz
opp_dtz_rounded = probe_result.dtz_rounded
opp_has_dtc = probe_result.has_dtc
opp_dtc_wdl = _signed_wdl(probe_result.dtc_wdl)
opp_dtc_order, opp_dtc = probe_result.dtc_order, probe_result.dtc
opp_has_dtm, opp_dtm = probe_result.has_dtm, probe_result.dtm
opp_has_dtm50 = probe_result.has_dtm50
opp_dtm50_wdl = _signed_wdl(probe_result.dtm50_wdl)
opp_dtm50 = probe_result.dtm50
my_wdl = -opp_wdl
if opp_has_dtm50:
summary_wdl = -opp_dtm50_wdl
elif opp_has_dtc:
summary_wdl = -opp_dtc_wdl
else:
# Draws, cursed wins and blessed losses are draws under the
# rule even without a clock-aware table. True wins/losses
# need one of those tables to resolve the remaining budget.
summary_wdl = 0 if abs(my_wdl) <= 1 else None
move_outcomes.append(
"win" if summary_wdl == 2 else
"loss" if summary_wdl == -2 else
"unknown" if summary_wdl is None else "draw"
)
# DTZ row.
if opp_has_dtz:
my_dtz = _apply_sign(opp_dtz, my_wdl)
# If this move is itself a zeroing move (capture/pawn-push/
# promotion), it is the zeroing move, so its DTZ is 1 ply - not
# 1 + the child position's own distance to its next
# zeroing move, which is what feeding my_dtz straight into
# _effective_distance would compute (double-counting a second
# zeroing event nobody asked for). _effective_distance
# already has a branch for exactly this ("no further distance
# needed, the move itself resolves it"), so call it with
# raw_distance=0 instead of reimplementing that branch here.
cursed_exit = (not move_is_zeroing and child_board is not None
and _is_cursed_exit(board, child_board, opp_wdl))
option = dtz_anchor.move_option(opp_dtz, opp_dtz_rounded, move_is_zeroing, cursed_exit)
if move_is_zeroing:
eff_dtz = _effective_distance(my_wdl, 0)
elif cursed_exit:
# CORRECTNESS.md Theorem 12.15: a rounded derived child
# can be even. Its true exit values then span y+1..y+3;
# report y+2 so the displayed error stays within one ply.
# option.value still carries the possible true distances
# for the anchor, rather than this representative value.
exit_dtz = opp_dtz + 2 if opp_dtz_rounded else option.value[0]
eff_dtz = _apply_sign(exit_dtz, my_wdl)
else:
eff_dtz = _effective_distance(my_wdl, my_dtz)
dtz_key, dtz_entry, eff_wdl = _dtz_row(
san, my_wdl, eff_dtz, len(set(option.value)) > 1, move_is_mate, child_fen, draw_reason,
)
outcome = dtz_entry["outcome"]
dtz_rows.append((dtz_key, dtz_entry))
dtz_moves.append(_DtzMove(len(dtz_rows) - 1, my_wdl, eff_wdl == root_wdl, option))
else:
dtz_rows.append(_not_available(san, child_fen, move_is_mate, draw_reason))
dtz_complete = False
# DTC row. DTC's own notion of a better move is pushes-owed first, then
# plies (see _order_rank's docstring), so the sort key carries both,
# unlike DTZ/DTM/DTM50 above and below which only ever compare
# plies.
#
# A capture or a promotion (move_is_conversion) is the conversion,
# at this move: 0 pushes still owed, 1 ply to get there - the
# child's own pushes/plies describe its next, later conversion,
# which is irrelevant once this one has already happened.
#
# A quiet pawn push zeroes the clock too (move_is_zeroing) but is
# not a conversion - chess.chesstb.Tablebase._dtc_move_kind treats
# it as spending one of the pushes still owed rather than settling
# the count, so the wait still resets to 1 ply the same way a
# conversion's does, but pushes owed does not reset to 0.
#
# The child is probed at its own halfmove clock (see _probe_board)
# and its dtc_order counts pushes owed from that position onward,
# so it does not include the push spent getting there. For a root
# reporting dtc_order=N whose best move is a push, the child
# reports dtc_order=N-1; the +1 below restores the push this move
# spent.
#
# Whether the +1 applies depends on which side is pushing: dtc_order
# counts the pushes still owed by whichever side is winning, and
# only the WINNING side's own push spends one of them. A defensive
# push by the losing side leaves that budget untouched, so it
# carries the child's order over unchanged, same as a quiet move;
# hence the my_dtc_wdl > 0 guard below.
if opp_has_dtc:
my_dtc_wdl = -opp_dtc_wdl
if move_is_mate or draw_reason or move_is_conversion:
# A capture, a promotion, checkmate, or an immediate draw
# are all fully resolved right here: 0 pushes still owed,
# 1 ply to get there. move_is_mate/draw_reason already
# hardwire opp_dtc_order/opp_dtc to 0 above, so this is a
# no-op arithmetically for those in the ordinary case - it
# matters for the case where the move is also a quiet
# pawn push, e.g. checkmate delivered by discovered check
# from a pawn advance that neither captures nor promotes.
# Without this guard that would fall into the push branch
# below and pick up a spurious +1: mate ends the game, so
# nothing is owed no matter how the pawn got there.
raw_order = 0
eff_dtc = _effective_distance(my_dtc_wdl, 0)
elif move_is_zeroing: # a quiet pawn push, not a conversion
raw_order = opp_dtc_order + (1 if my_dtc_wdl > 0 else 0)
eff_dtc = _effective_distance(my_dtc_wdl, 0)
else:
raw_order = opp_dtc_order
my_dtc = _apply_sign(opp_dtc, my_dtc_wdl)
eff_dtc = _effective_distance(my_dtc_wdl, my_dtc)
# A draw has nothing owed regardless of the raw push count
# above - e.g. a defensive push into a dead draw (stalemate,
# insufficient material, or a budget that no longer fits the
# clock) must not report a nonzero push count just because it
# happened to be a push; _order_rank sorts it correctly either
# way (it treats wdl_val == 0 as rank 0 regardless), but the
# value shown to the client should read 0 like plies does.
eff_order = raw_order if my_dtc_wdl != 0 else 0
dtc_key = (
_outcome_rank(my_dtc_wdl),
_order_rank(my_dtc_wdl, eff_order),
_ply_rank(my_dtc_wdl, eff_dtc),
_move_tiebreak(san),
)
dtc_rows.append((dtc_key, {
"san": san, "plies": abs(eff_dtc), "order": eff_order, "is_mate": move_is_mate,
"available": True, "outcome": _outcome_label(my_dtc_wdl),
"child_fen": child_fen, "draw_reason": draw_reason,
}))
else:
dtc_rows.append(_not_available(san, child_fen, move_is_mate, draw_reason, has_order=True))
# DTM row.
if opp_has_dtm:
my_dtm = _apply_sign(opp_dtm, my_wdl)
eff_dtm = _effective_distance(my_wdl, my_dtm)
if opp_has_dtz:
# DTZ and DTM share the same win/cursed-win (loss/blessed-
# loss) bucket and label as the DTZ row above, since that
# status is a single fact about eff_dtz and root_wdl rather
# than a separate one per metric.
dtm_key = (_outcome_rank(eff_wdl), _ply_rank(eff_wdl, eff_dtm), _move_tiebreak(san))
dtm_outcome = outcome
else:
# Without DTZ the bucket comes from WDL alone.
dtm_eff_wdl = _capped_wdl(my_wdl, root_wdl)
dtm_key = (_outcome_rank(dtm_eff_wdl), _ply_rank(dtm_eff_wdl, eff_dtm), _move_tiebreak(san))
dtm_outcome = _outcome_label(dtm_eff_wdl)
dtm_rows.append((dtm_key, {
"san": san, "plies": abs(eff_dtm), "order": 0, "is_mate": move_is_mate, "available": True,
"outcome": dtm_outcome, "child_fen": child_fen, "draw_reason": draw_reason,
}))
else:
dtm_rows.append(_not_available(san, child_fen, move_is_mate, draw_reason))
# DTM50 row.
if opp_has_dtm50:
my_dtm50_wdl = -opp_dtm50_wdl
my_dtm50 = _apply_sign(opp_dtm50, my_dtm50_wdl)
eff_dtm50 = _effective_distance(my_dtm50_wdl, my_dtm50)
dtm50_key = (_outcome_rank(my_dtm50_wdl), _ply_rank(my_dtm50_wdl, eff_dtm50), _move_tiebreak(san))
dtm50_rows.append((dtm50_key, {
"san": san, "plies": abs(eff_dtm50), "order": 0, "is_mate": move_is_mate, "available": True,
"outcome": _outcome_label(my_dtm50_wdl), "child_fen": child_fen, "draw_reason": draw_reason,
}))
else:
dtm50_rows.append(_not_available(san, child_fen, move_is_mate, draw_reason))
# Cross-check rounded DTZ values against each other; skipped when any move
# lacks a DTZ, since that move could be the one supplying the root's value.
resolved_dtz: int | None = None
if dtz_complete and root_dtz is not None and root_wdl != 0:
keepers = [m for m in dtz_moves if m.keeps_class]
if root_dtz_rounded or any(len(set(m.option.value)) > 1 for m in keepers):
resolution = dtz_anchor.resolve(
root_dtz, root_dtz_rounded, [m.option for m in keepers], root_wdl > 0,
)
resolved_dtz = resolution.root
for k, value in resolution.pinned.items():
move = keepers[k]
row = dtz_rows[move.row][1]
dtz_rows[move.row] = _dtz_row(
row["san"], move.my_wdl, _apply_sign(value, move.my_wdl), False,
row["is_mate"], row["child_fen"], row["draw_reason"],
)[:2]
dtz_rows.sort(key=lambda r: r[0])
dtc_rows.sort(key=lambda r: r[0])
dtm_rows.sort(key=lambda r: r[0])
dtm50_rows.sort(key=lambda r: r[0])
summary = {
"wins": sum(1 for o in move_outcomes if o in ("win", "cursed_win")),
"draws": sum(1 for o in move_outcomes if o == "draw"),
"losses": sum(1 for o in move_outcomes if o in ("loss", "blessed_loss")),
"unknown": sum(1 for o in move_outcomes if o == "unknown"),
}
return (
[r[1] for r in dtz_rows],
[r[1] for r in dtc_rows],
[r[1] for r in dtm_rows],
[r[1] for r in dtm50_rows],
not transient_failures,
summary,
resolved_dtz,
)
# ── Root Probe Calculation ────────────────────────────────────────────────────
_evaluate_tls = threading.local()
def _validate_and_build_board(variant: str, fen: str) -> chess.Board:
"""
Single authoritative implementation of root-FEN validation, shared by
/probe (via evaluate_fen) and /probe/stream (which needs a validated
board up front, before it can start emitting SSE progress events).
Raises RuntimeError if the tablebase isn't loaded, or ValueError for any
FEN/board problem - callers are expected to catch and format those.
Legality (castling rights matching an actual king/rook pair, no pawns
on the back rank, exactly one king per side, etc.) is entirely
delegated to board.is_valid() for both variants -- this function adds
no Standard-only assumptions of its own (e.g. requiring castling rooks
on a1/h1/a8/h8) that would misclassify a legal Chess960 position as
invalid. See _build_board for how the variant affects construction.
"""
if TB is None:
raise RuntimeError(
"Tablebase not initialised. Check TABLEBASE_PATH.")
parts = fen.split()
fmt_error = _validate_fen_parts(variant, parts)
if fmt_error:
raise ValueError(fmt_error)
try:
board = _build_board(variant, fen)
except ValueError as e:
raise ValueError(f"Malformed FEN: {e}") from e
castling_field = parts[2] if len(parts) >= 3 else "-"
if variant == "chess960" and castling_field != "-":
if board.castling_rights.bit_count() != len(castling_field):
raise ValueError("Invalid castling availability: duplicate castling rights.")
if not board.is_valid():
raise ValueError(
"Invalid board position (missing kings, adjacent kings, pawn on "
"back rank, a castling right with no matching king/rook pair, etc.)."
)
return board
def _missing_table_error_payload(variant: str, fen: str) -> dict:
"""
Single authoritative shape for the "position not covered by the loaded
tablebase" error, shared by /probe and /probe/stream so the two
transports (plain JSON vs SSE) can't drift out of sync on wording,
error_code, or how piece_count is computed.
"""
try:
piece_count = len(_build_board(variant, fen).piece_map())
except Exception:
piece_count = None
return {
"error": "Position not covered by the loaded tablebase.",
"error_code": "missing_table",
"piece_count": piece_count,
}
class _IncompleteResult(Exception):
"""Carries a probe result that must not be cached - see evaluate_fen()."""
def __init__(self, json_str: str) -> None:
super().__init__("probe incomplete")
self.json_str = json_str
def _evaluate_fen_impl(variant: str, fen: str) -> str:
"""
Probes the tablebase for a root FEN and returns a pre-serialized JSON string.
Routes the root probe through the same shared _probe_fen cache used for
child positions, so a position that was just probed as a child (e.g. the
position the user just moved into) is served from cache instead of
re-hitting the tablebase.
(variant, fen) are the cache key, so a caller that already built a
board/move_info for this exact pair -- e.g. probe_stream()'s SSE handler,
which needs both up front to size its progress bar -- hands them over via _evaluate_tls
instead, letting this call skip redoing that work. Ignored on a cache
hit, since the function body below never runs in that case.
Raises _IncompleteResult (carrying the same JSON) when a move came back
unknown for a retryable reason, so evaluate_fen() can serve it without
caching it.
"""
precomputed_board = getattr(_evaluate_tls, "board", None)
precomputed_move_info = getattr(_evaluate_tls, "move_info", None)
board = precomputed_board if precomputed_board is not None else _validate_and_build_board(variant, fen)
# A fresh cfg.probe_timeout-sized budget for the root probe alone,
# independent of how long any earlier phase of the same request (e.g.
# probe_stream()'s own pre-warm step) already took, so a slow earlier
# phase cannot erode the time this one gets. The child batch below
# carries its own equally-sized fresh budget (see child_deadline),
# matching the independent-budget-per-phase pattern probe_stream()
# applies between its pre-warm step and the root probe above it. The
# two independently-budgeted phases put a single /probe request's
# worst case at up to 2 * cfg.probe_timeout.
root_deadline = time.monotonic() + cfg.probe_timeout
if board.is_checkmate():
return json.dumps({
"wdl": -2, "dtz": 0, "dtz_rounded": False, "dtc": [-2, 0, 0], "dtm": 0, "dtm50": [-2, 0],
"dtz_available": True, "dtc_available": True, "dtm_available": True, "dtm50_available": True,
"moves_dtz": [], "moves_dtc": [], "moves_dtm": [], "moves_dtm50": [],
"summary": {"wins": 0, "draws": 0, "losses": 0, "unknown": 0},
"draw_reason": None,
})
if board.is_stalemate() or board.is_insufficient_material():
return json.dumps({
"wdl": 0, "dtz": 0, "dtz_rounded": False, "dtc": [0, 0, 0], "dtm": 0, "dtm50": [0, 0],
"dtz_available": True, "dtc_available": True, "dtm_available": True, "dtm50_available": True,
"moves_dtz": [], "moves_dtc": [], "moves_dtm": [], "moves_dtm50": [],
"summary": {"wins": 0, "draws": 0, "losses": 0, "unknown": 0},
"draw_reason": "stalemate" if board.is_stalemate() else "insufficient_material",
})
# Route the root probe through the shared child-probe cache. board was
# either handed over via _evaluate_tls or just built above from this
# exact fen, so it's passed through here too rather than making
# _probe_fen_deduped re-parse the same string a second time.
root = _probe_fen_with_deadline(variant, fen, root_deadline, board)
if root is None:
raise chesstb.MissingTableError(f"Position not in tablebase: variant={variant} {fen}")
bypass_parallel = getattr(_evaluate_tls, "bypass_parallel", False)
# Own fresh budget, not the remainder of root_deadline -- see the
# comment above root_deadline.
child_deadline = time.monotonic() + cfg.probe_timeout
moves_dtz, moves_dtc, moves_dtm, moves_dtm50, complete, summary, resolved_dtz = evaluate_all_moves(
board, _signed_wdl(root.wdl), variant, bypass_parallel=bypass_parallel,
precomputed_move_info=precomputed_move_info, deadline=child_deadline,
root_dtz=root.dtz if root.has_dtz else None, root_dtz_rounded=root.dtz_rounded,
)
root_dtz, root_dtz_rounded = (
(root.dtz, root.dtz_rounded) if resolved_dtz is None else (resolved_dtz, False)
)
# dtz/dtc/dtm/dtm50 are the root's own metrics, each independently
# unavailable when this material has no DTZ/DTC/DTM/DTM50 table - see
# _probe_board. The paired *_available flag is the authoritative signal
# for that; the value fields fall back to null/[null, null(, null)]
# rather than being omitted, so every response has the same fixed shape
# regardless of coverage. dtc is a 3-tuple [wdl, pushes_owed, plies]
# rather than dtm50's pair, since - unlike dtm50 - its distance is
# priced in two components, not one; see chess.chesstb.ProbeResult and
# the DTC row comment in evaluate_all_moves. root is the raw
# ProbeResult _probe_board returns, so its wdl/dtc_wdl/dtm50_wdl are
# still the unsigned chess.chesstb class values here - each goes
# through _signed_wdl() below, same as every other consumer of a raw
# ProbeResult in this file.
json_str = json.dumps({
"wdl": _signed_wdl(root.wdl),
"dtz": root_dtz if root.has_dtz else None,
"dtz_available": root.has_dtz,
"dtz_rounded": root_dtz_rounded if root.has_dtz else None,
"dtc": [_signed_wdl(root.dtc_wdl), root.dtc_order, root.dtc] if root.has_dtc else [None, None, None],
"dtc_available": root.has_dtc,
"dtm": root.dtm if root.has_dtm else None,
"dtm_available": root.has_dtm,
"dtm50": [_signed_wdl(root.dtm50_wdl), root.dtm50] if root.has_dtm50 else [None, None],
"dtm50_available": root.has_dtm50,
"moves_dtz": moves_dtz,
"moves_dtc": moves_dtc,
"moves_dtm": moves_dtm,
"moves_dtm50": moves_dtm50,
"summary": summary,
"draw_reason": None, # root always has legal moves here, so never terminal
})
if not complete:
raise _IncompleteResult(json_str)
return json_str
# An explicit LRU lets SSE check for a complete response before building
# boards, walking legal moves or submitting child probes. Failed/incomplete
# evaluations never enter this cache. Computation stays outside the lock.
_evaluation_cache: OrderedDict[tuple[str, str], str] = OrderedDict()
_evaluation_cache_lock = threading.Lock()
_evaluation_hits = _evaluation_misses = 0
_EvaluationCacheInfo = namedtuple("CacheInfo", "hits misses maxsize currsize")
def _cached_evaluation(variant: str, fen: str, *, count_miss: bool = True) -> str | None:
global _evaluation_hits, _evaluation_misses
with _evaluation_cache_lock:
key = (variant, fen)
result = _evaluation_cache.get(key)
if result is not None:
_evaluation_cache.move_to_end(key)
_evaluation_hits += 1
elif count_miss:
_evaluation_misses += 1
return result
def evaluate_fen(variant: str, fen: str) -> str:
cached = _cached_evaluation(variant, fen)
if cached is not None:
return cached
try:
result = _evaluate_fen_impl(variant, fen)
except _IncompleteResult as incomplete:
return incomplete.json_str
with _evaluation_cache_lock:
key = (variant, fen)
_evaluation_cache[key] = result
_evaluation_cache.move_to_end(key)
while len(_evaluation_cache) > cfg.evaluate_cache_size:
_evaluation_cache.popitem(last=False)
return result
def _clear_evaluation_cache() -> None:
global _evaluation_hits, _evaluation_misses
with _evaluation_cache_lock:
_evaluation_cache.clear()
_evaluation_hits = _evaluation_misses = 0
def _evaluation_cache_info():
with _evaluation_cache_lock:
return _EvaluationCacheInfo(
_evaluation_hits, _evaluation_misses, cfg.evaluate_cache_size, len(_evaluation_cache),
)
evaluate_fen.cache_clear = _clear_evaluation_cache # type: ignore[attr-defined]
evaluate_fen.cache_info = _evaluation_cache_info # type: ignore[attr-defined]
# ── Common (variant, FEN) extraction + validation helper ──────────────────────
def _extract_probe_request(data: dict | None) -> tuple[str, str, Response | tuple[Response, int] | None]:
"""Single authoritative request-body parser shared by /probe and
/probe/stream, so the two transports can't drift on what counts as a
valid variant/FEN or how each is rejected. Returns (variant, fen,
error_response); error_response is None on success and variant/fen are
both "" when it isn't."""
if not data or "fen" not in data:
return "", "", (jsonify({"error": "Missing FEN in request body."}), 400)
fen_raw = data["fen"]
if not isinstance(fen_raw, str):
return "", "", (jsonify({"error": "FEN must be a string."}), 400)
fen = fen_raw.strip()
if not fen:
return "", "", (jsonify({"error": "FEN string is empty."}), 400)
if len(fen) > cfg.max_fen_length:
return "", "", (jsonify({"error": f"FEN string too long (max {cfg.max_fen_length})."}), 400)
try:
variant = _normalize_variant(data.get("variant"))
except ValueError as e:
return "", "", (jsonify({"error": str(e)}), 400)
return variant, _normalize_fen(fen), None
# ── Routes ────────────────────────────────────────────────────────────────────
@app.route("/")
def index() -> str:
return render_template("index.html", source_repository_url=cfg.source_repository_url)
@app.route("/probe", methods=["POST"])
@_probe_rate_limit
def probe() -> Response | tuple[Response, int]:
body = request.get_json(silent=True)
variant, fen, err = _extract_probe_request(body)
if err:
return err
try:
json_str = evaluate_fen(variant, fen)
return app.response_class(json_str, status=200, mimetype="application/json")
except chesstb.MissingTableError:
return jsonify(_missing_table_error_payload(variant, fen)), 400
except RuntimeError as e:
# Tablebase not initialised (TABLEBASE_PATH failed to load) --
# same condition and status code as /health's own "degraded"
# response for this, and the same message _validate_and_build_board
# raises for /probe/stream to report over SSE.
return jsonify({"error": str(e)}), 503
except ValueError as e:
return jsonify({"error": str(e)}), 400
except _REMOTE_SOURCE_ERROR as e:
log.warning("Remote tablebase fetch failed probing variant=%s FEN %s: %s", variant, fen, e)
return jsonify({
"error": "Could not reach the remote tablebase. Try again shortly.",
"error_code": "remote_unavailable",
}), 502
except ProbeInFlightTimeout as e:
log.warning("%s", e)
return jsonify({
"error": "Probe timed out. Try again shortly.",
"error_code": "probe_timeout",
}), 503
except Exception:
log.exception("Unhandled error probing variant=%s FEN: %s", variant, fen)
return jsonify({"error": "Internal server error."}), 500
def _stream_error_payload(variant: str, fen: str, exc: BaseException) -> dict:
"""SSE error payload for an exception raised while probing (variant, fen),
keeping wording, error_code, and logging identical no matter which probe
step inside probe_stream() raised it."""
if isinstance(exc, chesstb.MissingTableError):
return {"status": "error", **_missing_table_error_payload(variant, fen)}
if isinstance(exc, _REMOTE_SOURCE_ERROR):
log.warning("Remote tablebase fetch failed in probe/stream for variant=%s %s: %s", variant, fen, exc)
return {
"status": "error",
"error": "Could not reach the remote tablebase. Try again shortly.",
"error_code": "remote_unavailable",
}
if isinstance(exc, ProbeInFlightTimeout):
log.warning("%s", exc)
return {
"status": "error",
"error": "Probe timed out. Try again shortly.",
"error_code": "probe_timeout",
}
log.exception("Error in probe/stream for variant=%s %s", variant, fen)
return {"status": "error", "error": str(exc)}
@app.route("/probe/stream", methods=["POST"])
@_probe_rate_limit
def probe_stream() -> Response | tuple[Response, int]:
body = request.get_json(silent=True)
variant, fen, err = _extract_probe_request(body)
if err:
return err
def _sse(obj: dict) -> str:
return f"data: {json.dumps(obj)}\n\n"
def generate():
cached = _cached_evaluation(variant, fen, count_miss=False)
if cached is not None:
yield 'data: {"status":"done",' + cached[1:] + '\n\n'
return
# Validated up front (rather than only inside evaluate_fen below) so
# the validation error can be reported immediately over SSE instead of
# only finding out after pre-warming child positions. Uses the same
# _validate_and_build_board() that evaluate_fen() itself calls, so
# there's one authoritative implementation of "what makes a FEN
# probeable" instead of two hand-written copies that could disagree.
try:
root_board = _validate_and_build_board(variant, fen)
except (RuntimeError, ValueError) as e:
yield _sse({"status": "error", "error": str(e)})
return
# A terminal root (checkmate, stalemate, insufficient material) never
# reaches the tablebase - see evaluate_fen()'s own early return for
# these - so only a non-terminal root needs its coverage confirmed
# here. Checking the root before pre-warming any child means a root
# the tablebase doesn't cover is caught immediately: no child of an
# uncovered root is coverable either, so pre-warming first would only
# probe the entire move list to discover that after the fact. This
# reads through the same cache evaluate_fen() itself reads below, so
# a covered root costs nothing extra downstream.
terminal = (
root_board.is_checkmate()
or root_board.is_stalemate()
or root_board.is_insufficient_material()
)
if not terminal:
try:
# root_board was just built above from this exact (variant,
# fen), so it's passed through here too rather than making
# _probe_fen_deduped re-parse the same string a second
# time -- see _probe_fen_with_deadline's board parameter.
root_probe = _probe_fen_with_deadline(
variant, fen, time.monotonic() + cfg.probe_timeout, root_board,
)
except Exception as e:
yield _sse(_stream_error_payload(variant, fen, e))
return
if root_probe is None:
yield _sse({"status": "error", **_missing_table_error_payload(variant, fen)})
return
# Insufficient-material draws can still have legal moves, but
# evaluate_fen returns a terminal response without using children.
move_info = [] if terminal else _collect_move_info(root_board)
child_fens: set[str] = {
child_fen for _, _, _, _, _, child_fen, _ in move_info if child_fen is not None
}
child_boards: dict[str, chess.Board] = {
child_fen: child_board
for _, _, _, _, _, child_fen, child_board in move_info
if child_fen is not None and child_board is not None
}
total = len(child_fens)
yield _sse({"status": "probing", "completed": 0, "total": total})
# Pre-warm child FENs in parallel using the native non-blocking cache.
# Always via the thread pool (regardless of cfg.parallel_threshold, which
# only governs evaluate_all_moves' own sequential/parallel choice below):
# a bare sequential loop here would have no way to bound a single hung
# _probe_fen_with_deadline() call, since there'd be no worker thread to
# poll against; only a background thread lets this deadline actually
# cut the wait short. This budget is independent of the root probe's
# own, above - a slow root probe doesn't eat into pre-warm's time.
deadline = time.monotonic() + cfg.probe_timeout
futures = {
cf: _executor.submit(
_probe_fen_with_deadline, variant, cf, deadline, child_boards.get(cf)
)
for cf in child_fens
}
remaining = set(futures.values())
done_count = 0
prewarm_complete = True
last_event = time.monotonic()
try:
while done_count < total:
time_left = deadline - time.monotonic()
if time_left <= 0:
log.warning(
"Probe pre-warm timed out for variant=%s %s (%d/%d child positions still pending)",
variant, fen, total - done_count, total,
)
prewarm_complete = False
break
newly_done, remaining = futures_wait(
remaining, timeout=min(0.3, time_left), return_when=FIRST_COMPLETED
)
if any(f.cancelled() or f.exception() is not None for f in newly_done):
prewarm_complete = False
if newly_done:
done_count += len(newly_done)
yield _sse({"status": "probing", "completed": done_count, "total": total})
last_event = time.monotonic()
elif time.monotonic() - last_event >= 1.0:
# Keep the stream live and detect disconnects without
# making the browser render identical progress updates.
yield ": keep-alive\n\n"
last_event = time.monotonic()
finally:
# Also runs when the streaming client disconnects. Running probes
# may finish, but queued work should not delay newer requests.
for future in remaining:
future.cancel()
# If pre-warming above finished, every child FEN is already cached, so
# evaluate_fen() below can fetch them all as cache hits sequentially,
# without the overhead of resubmitting each one to the thread pool. If
# pre-warming timed out instead, skip that shortcut: evaluate_all_moves()
# inside evaluate_fen() then takes its own parallel path, with its own
# fresh cfg.probe_timeout budget (see _evaluate_fen_impl), and reports
# "unknown" for whatever's still unresolved once that budget passes.
_evaluate_tls.bypass_parallel = prewarm_complete
# Already built above (root_board, move_info) to size the progress
# bar and know which child FENs to pre-warm - handing them to
# evaluate_fen() here means a cache-miss FEN doesn't pay for a
# second board build + legal-move walk right after this one.
_evaluate_tls.board = root_board
_evaluate_tls.move_info = move_info
try:
json_str = evaluate_fen(variant, fen)
yield 'data: {"status":"done",' + json_str[1:] + '\n\n'
except Exception as e:
yield _sse(_stream_error_payload(variant, fen, e))
finally:
_evaluate_tls.bypass_parallel = False
_evaluate_tls.board = None
_evaluate_tls.move_info = None
return Response(
stream_with_context(generate()),
content_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@app.route("/health")
def health() -> Response:
if TB is None:
return jsonify({"status": "degraded", "reason": "Tablebase not initialised"}), 503
return jsonify({"status": "ok"}), 200
def _cache_info_dict(info) -> dict:
hits, misses = info.hits, info.misses
maxsize, currsize = info.maxsize, info.currsize
total = hits + misses
return {
"hits": hits,
"misses": misses,
"hit_rate": round(hits / total, 4) if total else 0.0,
"maxsize": maxsize,
"currsize": currsize,
}
@app.route("/admin/cache/clear", methods=["POST"])
@_require_admin
def clear_cache() -> Response:
evaluate_fen.cache_clear()
_probe_fen.cache_clear()
# The two LRUs above are app.py-level caches keyed on (variant, FEN)
# pairs. They are not the only cache in the probing pipeline: each tier's
# underlying tablebase can keep its own internal decoded-block cache
# and, in remote mode, a downloaded-file cache (remote/remote_fallback.py).
# SourcedTablebase.clear_caches()/cache_stats() are exposed on TB
# itself, but only actually clear/report something for a tier backed
# by a remote/remote_fallback.py Tablebase -- a plain local-directory
# Tablebase has no such cache. cache_stats() coming back empty is what
# distinguishes "nothing to clear" from "cleared something", since
# every tier is asked unconditionally either way.
tb_cache_info: dict = {}
if TB is not None:
TB.clear_caches()
tb_cache_info = TB.cache_stats()
tb_cache_cleared = bool(tb_cache_info)
log.info(
"All probe caches cleared (tablebase-internal cache %s).",
"cleared" if tb_cache_cleared else "not applicable for this tablebase",
)
response = {
"status": "ok",
"message": "All probe caches cleared (root JSON cache + child probe cache"
+ (" + tablebase-internal caches)." if tb_cache_cleared else ")."),
"evaluate_fen_cache": _cache_info_dict(evaluate_fen.cache_info()),
"probe_fen_cache": _cache_info_dict(_probe_fen.cache_info()),
"tablebase_internal_cache_cleared": tb_cache_cleared,
}
if tb_cache_info:
response["tablebase_cache"] = tb_cache_info
return jsonify(response)
@app.route("/admin/cache/stats", methods=["GET"])
@_require_admin
def cache_stats() -> Response:
stats = {
"evaluate_fen_cache": _cache_info_dict(evaluate_fen.cache_info()),
"probe_fen_cache": _cache_info_dict(_probe_fen.cache_info()),
"thread_pool": {
"max_workers": cfg.probe_threads,
"parallel_threshold": cfg.parallel_threshold,
"probe_timeout_secs": cfg.probe_timeout,
},
"config": {
"evaluate_cache_size": cfg.evaluate_cache_size,
"probe_cache_size": cfg.probe_cache_size,
"block_cache_bytes": cfg.block_cache_bytes,
},
"tablebase": {
"configured": TB is not None,
},
}
# SourcedTablebase.cache_stats() is exposed on TB and reports the configured source
# -- a source contributes no key when it has no remote-backed cache to
# report (see clear_cache() above for why a plain local-directory
# Tablebase has none).
if TB is not None:
tb_cache = TB.cache_stats()
if tb_cache:
stats["tablebase_cache"] = tb_cache
return jsonify(stats)
@app.route("/admin/login", methods=["POST"])
@_admin_login_rate_limit
def admin_login() -> Response:
if not cfg.admin_token:
return jsonify({"error": "Admin panel disabled: ADMIN_TOKEN is not configured."}), 503
body = request.get_json(silent=True) or {}
token = body.get("token", "")
if not isinstance(token, str) or not _admin_token_matches(token):
return jsonify({"error": "Incorrect admin token."}), 401
session["admin_ok"] = True
return jsonify({"status": "ok"})
@app.route("/admin/logout", methods=["POST"])
def admin_logout() -> Response:
session.pop("admin_ok", None)
return jsonify({"status": "ok"})
@app.route("/admin", methods=["GET"])
def admin_dashboard() -> str | tuple[str, int]:
# Renders the dashboard once authenticated (session cookie from
# /admin/login); a login form otherwise. Unlike /admin/cache/* below,
# this route can't just use _require_admin -- a browser visiting /admin
# needs the login form itself, not a bare 401/503 JSON body. Matches
# /admin/login and /admin/cache/*'s own 503 when ADMIN_TOKEN is unset,
# rather than serving the dashboard shell with a 200.
if not cfg.admin_token:
return render_template(
"admin.html", authenticated=False, admin_configured=False,
source_repository_url=cfg.source_repository_url,
), 503
return render_template(
"admin.html",
authenticated=_is_admin_authenticated(),
admin_configured=True,
source_repository_url=cfg.source_repository_url,
)
# ── OpenAPI specification ─────────────────────────────────────────────────────
# The specification is maintained in openapi.yaml (project root).
_OPENAPI_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "openapi.yaml")
@app.route("/openapi.yaml")
def openapi_spec() -> Response:
return send_file(_OPENAPI_PATH, mimetype="application/yaml")
if __name__ == "__main__":
if cfg.debug:
# Flask's own dev server: auto-reload + interactive debugger, for
# local development only. `threaded=True` is what lets a long-lived
# /probe/stream SSE connection coexist with other concurrent
# requests on this single-process server (see README.md "Running in
# production").
log.info("DEBUG = True (config.py) — starting Flask development server on %s:%s", cfg.host, cfg.port)
app.run(debug=True, host=cfg.host, port=cfg.port, threaded=True)
else:
# Production: serve via waitress, a pure-Python production-grade
# WSGI server. `threads` gives waitress its own pool of
# request-handling threads, playing the same role `threaded=True`
# plays for the Flask dev server above - without it, a single
# open /probe/stream connection could starve every other request
# (see README.md "Running in production").
#
# trusted_proxy=* / trusted_proxy_count / trusted_proxy_headers:
# waitress parses X-Forwarded-For and X-Forwarded-Proto and
# rewrites REMOTE_ADDR / wsgi.url_scheme itself, before the
# request ever reaches the Flask app object -- so it sits in front
# of, and takes priority over, the ProxyFix layer wired onto
# app.wsgi_app above. Without this, waitress's default
# (trusted_proxy=None) strips both headers entirely as untrusted,
# silently discarding them before ProxyFix could ever see them --
# ProxyFix alone is not sufficient here. `"*"` trusts whichever
# peer connects, rather than one specific IP, because Render's
# edge/load-balancer's own address isn't a fixed, documented
# value; this is safe under the same assumption
# TRUSTED_PROXY_COUNT's docstring already states -- that nothing
# but that one proxy hop can reach this process directly. Skipped
# entirely when cfg.trusted_proxy_count is 0 (no reverse proxy in
# front at all), leaving waitress's own default in place.
log.info(
"Starting waitress production server on %s:%s (threads=%s)",
cfg.host, cfg.port, cfg.waitress_threads,
)
waitress_kwargs = {}
if cfg.trusted_proxy_count > 0:
waitress_kwargs.update(
trusted_proxy="*",
trusted_proxy_count=cfg.trusted_proxy_count,
trusted_proxy_headers={"x-forwarded-for", "x-forwarded-proto"},
)
waitress_serve(
app, host=cfg.host, port=cfg.port, threads=cfg.waitress_threads, **waitress_kwargs)
|