"""ChessTransformer — Hugging Face Space (Gradio). Play the transformer chess engine in the browser. Runs on CPU: the model is only 11.7M params, so MCTS at a modest sim count returns a move in ~1-2s. Weights are resolved in this order: 1. ``MODEL_PATH`` env var (a checkpoint dir with model.safetensors + config) 2. a ``model/`` dir next to this file (how ``prepare.sh`` ships the Space) 3. the repo's ``data/models/pos2move_v2.1`` (for local dev runs) 4. download from GitHub (LFS media) as a last resort """ from __future__ import annotations import itertools import os import sys from pathlib import Path import chess import gradio as gr from gradio_chessboard import Chessboard # ── make `chesstransformer` importable ────────────────────────────────────── # On the Space the package is vendored next to this file (cwd is on sys.path). # For local dev runs from the repo, add the repo's src/ to the path. _repo_src = Path(__file__).resolve().parents[1] / "src" if (_repo_src / "chesstransformer").exists(): sys.path.insert(0, str(_repo_src)) from chesstransformer.bots import Pos2MoveV2MctsBot # noqa: E402 START_FEN = chess.STARTING_FEN DEFAULT_SIMS = int(os.environ.get("MCTS_SIMS", "400")) # GitHub locations for the last-resort weight download. _GH = "tchauffi/ChessTransformer" _BRANCH = os.environ.get("MODEL_BRANCH", "main") _RAW = f"https://raw.githubusercontent.com/{_GH}/{_BRANCH}/data/models/pos2move_v2.1" _LFS = f"https://media.githubusercontent.com/media/{_GH}/{_BRANCH}/data/models/pos2move_v2.1" def _download_weights() -> str: import urllib.request dest = Path("/tmp/pos2move_v2.1") dest.mkdir(parents=True, exist_ok=True) for fname, url in [("model_config.json", f"{_RAW}/model_config.json"), ("model.safetensors", f"{_LFS}/model.safetensors")]: out = dest / fname if not out.exists(): print(f"Downloading {fname} …") urllib.request.urlretrieve(url, out) return str(dest) def resolve_model_dir() -> str: env = os.environ.get("MODEL_PATH") if env and (Path(env) / "model.safetensors").exists(): return env for cand in ( Path(__file__).resolve().parent / "model", Path("model"), Path(__file__).resolve().parents[1] / "data" / "models" / "pos2move_v2.1", ): if (cand / "model.safetensors").exists(): return str(cand) return _download_weights() # ── load the bot once (CPU, no compile) ───────────────────────────────────── MODEL_DIR = resolve_model_dir() bot = Pos2MoveV2MctsBot( model_dir=MODEL_DIR, num_simulations=DEFAULT_SIMS, device="cpu", compile=False, time_limit=0.0, # Sample the opening moves (∝ visit count) so games aren't identical every # time; switches to deterministic best-move play after the opening. move_temp=1.0, move_temp_plies=12, ) def _human_color(human_color: str) -> bool: return chess.BLACK if human_color == "black" else chess.WHITE def _white_pov_value(board: chess.Board) -> float: """Value-head eval in [-1, 1] from White's perspective (+ = White better).""" if board.is_game_over(): if board.is_checkmate(): return -1.0 if board.turn == chess.WHITE else 1.0 return 0.0 # stalemate / draw v = bot.get_value(board) if v is None: return 0.0 return v if board.turn == chess.WHITE else -v def eval_bar_html(board: chess.Board, orientation: str) -> str: """A vertical eval bar (White share fills from the bottom of White's side). Flipped to match the board orientation so the viewer's side is at the bottom.""" wv = _white_pov_value(board) white_pct = round(max(0.0, min(1.0, (wv + 1) / 2)) * 100, 1) white_div = f'
' black_div = f'
' # orientation "white": Black on top, White on bottom; flipped when "black". segments = black_div + white_div if orientation == "white" else white_div + black_div return ( f'
' f'{segments}
' ) def _result_text(board: chess.Board, human_color: str) -> str: outcome = board.outcome() if outcome is None: return "" if outcome.winner is None: return f"Draw ({outcome.termination.name.lower().replace('_', ' ')}). New game?" who = "You win! 🎉" if outcome.winner == _human_color(human_color) else "ChessTransformer wins." return f"Checkmate — {who} New game?" def _bot_move(board: chess.Board, sims: int): """Play the side to move with the bot; return its SAN and value.""" bot.num_simulations = int(sims) uci, value = bot.predict(board) san = board.san(chess.Move.from_uci(uci)) board.push_uci(uci) return san, value def on_move(fen: str, sims: int, human_color: str): """Human just moved; reply with the bot's move (whichever side is to move). The board orientation matches the human's color, so the eval bar does too. A generator: first yields a "thinking" status (so the player sees the bot is working during the ~1-3s CPU search), then yields the bot's reply.""" board = chess.Board(fen) if board.is_game_over(): yield board.fen(), _result_text(board, human_color), eval_bar_html(board, human_color) return # Immediate feedback while the search runs (board/eval unchanged for now). yield gr.update(), "⏳ ChessTransformer is thinking…", gr.update() san, value = _bot_move(board, sims) if board.is_game_over(): status = f"Bot played **{san}**. {_result_text(board, human_color)}" else: eval_str = f" (eval {value:+.2f})" if value is not None else "" status = f"Bot played **{san}**{eval_str}. Your move." yield board.fen(), status, eval_bar_html(board, human_color) # Monotonic id so every New game yields a *distinct* setup dict — otherwise a # same-colour replay returns an identical dict and the gr.render block, seeing no # change, wouldn't rebuild (and reset) the board. _game_counter = itertools.count(1) def new_game(play_as: str, sims: int): """Start a fresh game. If the human plays Black, the bot (White) opens. Returns (setup, eval_bar). The setup dict drives a gr.render block — changing it rebuilds the board so its orientation (chessboard.js reads it only at construction) actually flips to the human's side.""" human_color = "black" if play_as == "Black" else "white" board = chess.Board() if human_color == "black": san, _ = _bot_move(board, sims) status = f"New game — you are **Black**. Bot opened with **{san}**. Your move." else: status = "New game — you are **White**. Make your move." setup = {"fen": board.fen(), "orientation": human_color, "color": human_color, "status": status, "nonce": next(_game_counter)} return setup, eval_bar_html(board, human_color) with gr.Blocks(title="ChessTransformer", theme=gr.themes.Soft()) as demo: gr.Markdown( "# ♟️ ChessTransformer\n" "An **11.7M-parameter transformer trained only on human games** " "(no self-play, no RL), playing via AlphaZero-style MCTS. " "It reaches **~2100 Elo** vs Stockfish at full strength; this CPU demo " "runs at a lower sim count so moves come back quickly.\n\n" "**Pick your color and hit New game.** Drag a piece to move — the bot " "replies automatically. " "[GitHub repo →](https://github.com/tchauffi/ChessTransformer)" ) setup = gr.State({"fen": START_FEN, "orientation": "white", "color": "white", "status": "You are **White**. Make your move.", "nonce": 0}) with gr.Row(): # Solid min_widths so the board container always has a size when the # gr.render block (re)mounts it — chessboard.js reloads the whole page if # it inits at 0 width, which is what caused the mobile flicker/reload loop. eval_col = gr.Column(scale=0, min_width=44) board_col = gr.Column(scale=3, min_width=340) ctrl_col = gr.Column(scale=1, min_width=200) with eval_col: eval_bar = gr.HTML(eval_bar_html(chess.Board(), "white"), visible=False) with ctrl_col: play_as = gr.Radio(["White", "Black"], value="White", label="Play as") sims = gr.Slider(32, 1200, value=DEFAULT_SIMS, step=8, label="Engine strength (MCTS sims/move)", info="Higher = stronger but slower on CPU") show_eval = gr.Checkbox(False, label="Show evaluation bar") new_btn = gr.Button("New game", variant="primary") # The board lives in a gr.render so switching color rebuilds it with the new # orientation (chessboard.js reads orientation only at construction). The # min_width above keeps the container sized so the remount can't init at 0. with board_col: @gr.render(inputs=setup) def render_board(cfg): board = Chessboard(value=cfg["fen"], game_mode=True, min_width=320, orientation=cfg["orientation"], label="Drag to move") status = gr.Markdown(cfg["status"]) color = cfg["color"] def handle(fen, s): # generator → streams the "thinking" update first yield from on_move(fen, s, color) # show_progress="hidden": our "⏳ thinking…" status is the signal, so # Gradio's default overlay doesn't sit over the board. board.move(handle, inputs=[board, sims], outputs=[board, status, eval_bar], show_progress="hidden") show_eval.change(lambda show: gr.update(visible=show), inputs=show_eval, outputs=eval_bar) new_btn.click(new_game, inputs=[play_as, sims], outputs=[setup, eval_bar]) if __name__ == "__main__": demo.launch()