| """RDTChess as a UCI engine, to plug into `lichess-bot`. |
| |
| python uci.py --model RDTChess.pt |
| |
| UCI assumes an engine that *searches*: it gets a time budget, thinks, and |
| answers when it is done. There is no search here — one forward pass and the |
| move is out, in about 7 ms on two CPU threads. Every `go` parameter (wtime, |
| btime, depth, movetime) is therefore parsed and ignored: the move does not |
| depend on the time available, and the clock cannot run out. |
| |
| Two consequences: |
| |
| * `ponder` is meaningless and declared absent — there is nothing to precompute |
| while the opponent thinks; |
| * the reported depth is **1**, which is the truth rather than modesty: the |
| network unrolls no variation. |
| |
| The only half-ply of search is `FinishPlies`, which looks one move ahead solely |
| to avoid missing a mate or offering a stalemate. It is a policy improvement |
| operator, not the beginning of a search engine. |
| """ |
|
|
| import argparse |
| import math |
| import sys |
|
|
| import chess |
|
|
| DEFAULT_MODEL = "RDTChess.pt" |
|
|
|
|
| def say(line: str) -> None: |
| """Write one UCI response. |
| |
| The explicit flush is not cosmetic: when stdout is a pipe to `lichess-bot` |
| rather than a terminal, Python switches to block buffering and `bestmove` |
| can sit in the buffer until 8 KB accumulate. The bot then waits forever for |
| a move that was computed instantly. |
| """ |
| sys.stdout.write(line + "\n") |
| sys.stdout.flush() |
|
|
|
|
| def to_centipawns(value: float) -> int: |
| """Map the network's value in [-1, 1] to centipawns. |
| |
| The value head estimates an outcome probability, not a material imbalance; |
| the two scales are not linearly related. This is the Leela Chess Zero |
| transform, which stretches the ends so a position that is "95% winning" |
| does not display as a modest +2. |
| """ |
| clamped = max(-0.99, min(0.99, value)) |
| return int(111.714640912 * math.tan(1.5620688421 * clamped)) |
|
|
|
|
| class Session: |
| """One UCI session: a current position and a loaded model.""" |
|
|
| def __init__(self, model_path: str): |
| self.model_path = model_path |
| self.board = chess.Board() |
| self.player = None |
| self.finish_plies = 1 |
| self.threads = 2 |
| self.device = "auto" |
|
|
| def load(self) -> None: |
| """Load the network, at the latest on `isready`. |
| |
| Importing torch and reading the weights takes a second or two. Doing it |
| at startup would delay the answer to `uci`, which some interfaces time; |
| UCI guarantees that `isready` may take its time, so that is the place. |
| """ |
| if self.player is not None: |
| return |
|
|
| import torch |
|
|
| torch.set_num_threads(max(1, self.threads)) |
| from player import ModelPlayer |
|
|
| self.player = ModelPlayer( |
| self.model_path, device=self.device, finish_plies=self.finish_plies |
| ) |
|
|
| def set_option(self, name: str, value: str) -> None: |
| key = name.lower() |
| if key == "finishplies": |
| self.finish_plies = max(0, min(2, int(value))) |
| if self.player is not None: |
| self.player.finish_plies = self.finish_plies |
| elif key == "threads": |
| self.threads = max(1, int(value)) |
| elif key == "device": |
| self.device = value.strip().lower() |
|
|
| def set_position(self, tokens: list) -> None: |
| """Apply `position [startpos | fen <6 fields>] [moves ...]`.""" |
| if not tokens: |
| return |
| if tokens[0] == "startpos": |
| self.board = chess.Board() |
| rest = tokens[1:] |
| elif tokens[0] == "fen": |
| |
| end = tokens.index("moves") if "moves" in tokens else len(tokens) |
| self.board = chess.Board(" ".join(tokens[1:end])) |
| rest = tokens[end:] |
| else: |
| return |
|
|
| if rest and rest[0] == "moves": |
| for uci in rest[1:]: |
| move = chess.Move.from_uci(uci) |
| if move in self.board.legal_moves: |
| self.board.push(move) |
|
|
| def best_move(self) -> None: |
| """Pick a move and announce it, with an honest `info` line.""" |
| self.load() |
|
|
| from engine import ChessEngine |
|
|
| engine = ChessEngine(self.board.copy()) |
| moves, probabilities, value = self.player.evaluate_position(engine) |
| if not moves: |
| say("bestmove 0000") |
| return |
|
|
| move = self.player.get_move(engine) |
| if move is None or move == chess.Move.null(): |
| move = moves[0] |
|
|
| |
| |
| probe = self.board.copy() |
| probe.push(move) |
| score = "mate 1" if probe.is_checkmate() else f"cp {to_centipawns(value)}" |
|
|
| confidence = float(max(probabilities)) if len(probabilities) else 0.0 |
| say( |
| f"info depth 1 nodes 1 score {score} " |
| f"string policy {confidence * 100:.1f}% over {len(moves)} legal moves " |
| f"pv {move.uci()}" |
| ) |
| say(f"bestmove {move.uci()}") |
|
|
|
|
| def main() -> int: |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument("--model", default=DEFAULT_MODEL) |
| args, _ = parser.parse_known_args() |
|
|
| session = Session(args.model) |
|
|
| for raw in sys.stdin: |
| tokens = raw.split() |
| if not tokens: |
| continue |
| command = tokens[0] |
|
|
| if command == "uci": |
| say("id name RDTChess 128x8 5.5M") |
| say("id author Theo CHARLET") |
| say(f"option name FinishPlies type spin default {session.finish_plies} min 0 max 2") |
| say(f"option name Threads type spin default {session.threads} min 1 max 32") |
| say("option name Device type combo default auto var auto var cpu var cuda") |
| say("uciok") |
|
|
| elif command == "isready": |
| session.load() |
| say("readyok") |
|
|
| elif command == "setoption": |
| |
| if "name" in tokens: |
| start = tokens.index("name") + 1 |
| if "value" in tokens: |
| split = tokens.index("value") |
| session.set_option(" ".join(tokens[start:split]), " ".join(tokens[split + 1:])) |
| else: |
| session.set_option(" ".join(tokens[start:]), "") |
|
|
| elif command == "ucinewgame": |
| session.board = chess.Board() |
|
|
| elif command == "position": |
| session.set_position(tokens[1:]) |
|
|
| elif command == "go": |
| |
| session.best_move() |
|
|
| elif command == "stop": |
| session.best_move() |
|
|
| elif command in ("quit", "exit"): |
| break |
|
|
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|