Spaces:
Running
Running
| """Play chess against ChessMamba (TobiasLogic/chessmamba). | |
| ChessMamba is a ~16.8M-parameter selective state-space (Mamba / S6) chess engine | |
| trained from scratch: it reads a game as a *sequence of moves*, keeps a compact | |
| recurrent hidden state, and exposes three heads off that state — policy | |
| (4096-way from-square x to-square), promotion (5-way) and value (scalar, tanh). | |
| On top of that sits a policy-guided negamax with alpha-beta + quiescence. | |
| This Space wires the author's own `model.py` / `search.py` / `chess_io.py` | |
| (vendored verbatim from the model repo) into an interactive board, and surfaces | |
| the raw policy / value heads so you can see what the state-space model actually | |
| thinks before the search runs. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import re | |
| import tempfile | |
| import time | |
| import chess | |
| import chess.pgn | |
| import chess.svg | |
| import cairosvg | |
| import gradio as gr | |
| from huggingface_hub import hf_hub_download | |
| from search import Searcher, DEFAULT_MAX_QDEPTH, DEFAULT_QS_TOP_K | |
| MODEL_ID = "TobiasLogic/chessmamba" | |
| # --------------------------------------------------------------------------- # | |
| # Model loading (once, at module scope). ChessMamba is a tiny 16.8M-param model | |
| # whose search is a Python-driven alpha-beta over batch-1 recurrent steps — it is | |
| # latency-bound on tiny ops, so CPU is the right (and the author's own) device: | |
| # `search.py` defaults to device="cpu" and torch.set_num_threads(1). | |
| # --------------------------------------------------------------------------- # | |
| CKPT_PATH = hf_hub_download(MODEL_ID, "ckpt/model.pt") | |
| _BASE = Searcher(checkpoint_path=CKPT_PATH, device="cpu") | |
| MODEL = _BASE.model | |
| N_PARAMS = sum(p.numel() for p in MODEL.parameters()) | |
| print(f"[chessmamba] loaded {N_PARAMS:,} params from {CKPT_PATH}", flush=True) | |
| def make_searcher(top_k: int = 10, max_depth: int = 6) -> Searcher: | |
| """A fresh Searcher per request (its root_node cache is mutable state), | |
| sharing the single read-only model instance.""" | |
| s = Searcher( | |
| checkpoint_path=None, | |
| top_k=int(top_k), | |
| max_depth=int(max_depth), | |
| max_qdepth=DEFAULT_MAX_QDEPTH, | |
| qs_top_k=DEFAULT_QS_TOP_K, | |
| device="cpu", | |
| ) | |
| s.model = MODEL | |
| return s | |
| # --------------------------------------------------------------------------- # | |
| # Board rendering + click -> square mapping | |
| # --------------------------------------------------------------------------- # | |
| BOARD_PX = 520 | |
| # python-chess SVG layout: 8 * SQUARE_SIZE(45) + 2 * MARGIN(20) = 400 units. | |
| _UNITS = 400.0 | |
| _SCALE = BOARD_PX / _UNITS | |
| MARGIN_PX = 20 * _SCALE | |
| SQUARE_PX = 45 * _SCALE | |
| SEL_COLOR = "#f6b26b99" | |
| DEST_COLOR = "#3d85c655" | |
| def render_board( | |
| board: chess.Board, | |
| flip: bool = False, | |
| selected: str = "", | |
| arrow: chess.Move | None = None, | |
| ) -> str: | |
| """Render `board` to a PNG on disk and return its path.""" | |
| fill: dict[int, str] = {} | |
| if selected: | |
| try: | |
| sq = chess.parse_square(selected) | |
| except ValueError: | |
| sq = None | |
| if sq is not None and board.piece_at(sq) is not None: | |
| fill[sq] = SEL_COLOR | |
| for mv in board.legal_moves: | |
| if mv.from_square == sq: | |
| fill.setdefault(mv.to_square, DEST_COLOR) | |
| arrows = [] | |
| if arrow is not None: | |
| arrows.append(chess.svg.Arrow(arrow.from_square, arrow.to_square, color="#1f6feb99")) | |
| svg = chess.svg.board( | |
| board=board, | |
| orientation=chess.BLACK if flip else chess.WHITE, | |
| lastmove=board.peek() if board.move_stack else None, | |
| check=board.king(board.turn) if board.is_check() else None, | |
| fill=fill, | |
| arrows=arrows, | |
| size=BOARD_PX, | |
| coordinates=True, | |
| ).encode("utf-8") | |
| png = cairosvg.svg2png(bytestring=svg, output_width=BOARD_PX, output_height=BOARD_PX) | |
| f = tempfile.NamedTemporaryFile(suffix=".png", delete=False) | |
| f.write(png) | |
| f.close() | |
| return f.name | |
| def xy_to_square(x: float, y: float, flip: bool) -> str: | |
| """Map a click on the rendered board image to a square name ('' if off-board).""" | |
| fx = (x - MARGIN_PX) / SQUARE_PX | |
| fy = (y - MARGIN_PX) / SQUARE_PX | |
| if not (0 <= fx < 8 and 0 <= fy < 8): | |
| return "" | |
| col, row = int(fx), int(fy) | |
| file_index = 7 - col if flip else col | |
| rank_index = row if flip else 7 - row | |
| return chess.square_name(chess.square(file_index, rank_index)) | |
| # --------------------------------------------------------------------------- # | |
| # Game-state helpers. All state lives in (hidden) Gradio components so every | |
| # handler is a pure function of its inputs. | |
| # --------------------------------------------------------------------------- # | |
| def board_from_history(history: str) -> chess.Board: | |
| board = chess.Board() | |
| for uci in (history or "").split(): | |
| try: | |
| board.push_uci(uci) | |
| except Exception: | |
| break | |
| return board | |
| def history_of(board: chess.Board) -> str: | |
| return " ".join(m.uci() for m in board.move_stack) | |
| def movetext(board: chess.Board) -> str: | |
| if not board.move_stack: | |
| return "*(no moves yet)*" | |
| game = chess.pgn.Game.from_board(board) | |
| exporter = chess.pgn.StringExporter(headers=False, variations=False, comments=False) | |
| text = game.accept(exporter) | |
| return re.sub(r"\s*(1-0|0-1|1/2-1/2|\*)\s*$", "", text).strip() | |
| def outcome_text(board: chess.Board) -> str: | |
| if board.is_checkmate(): | |
| return f"♚ **Checkmate — {'White' if board.turn == chess.BLACK else 'Black'} wins.**" | |
| if board.is_stalemate(): | |
| return "½ **Stalemate — draw.**" | |
| if board.is_insufficient_material(): | |
| return "½ **Draw — insufficient material.**" | |
| if board.is_seventyfive_moves() or board.is_fivefold_repetition(): | |
| return "½ **Draw.**" | |
| return "" | |
| def turn_text(board: chess.Board, human_white: bool) -> str: | |
| over = outcome_text(board) | |
| if over: | |
| return over | |
| check = " — **check!**" if board.is_check() else "" | |
| human_turn = board.turn == (chess.WHITE if human_white else chess.BLACK) | |
| return ("Your move." if human_turn else "ChessMamba is thinking…") + check | |
| def legal_sans(board: chess.Board) -> list[str]: | |
| return sorted(board.san(m) for m in board.legal_moves) | |
| # --------------------------------------------------------------------------- # | |
| # Reading the model's heads directly (policy priors + value) | |
| # --------------------------------------------------------------------------- # | |
| def analysis_md(board: chess.Board, played: str | None, elapsed: float | None, | |
| top_k: int = 10) -> str: | |
| """Markdown panel showing the raw S6 policy/value heads at `board`.""" | |
| if board.is_game_over(): | |
| return f"### Engine\n\n{outcome_text(board)}" | |
| s = make_searcher(top_k=top_k) | |
| s.sync(board) | |
| priors, value = s._policy_value(board, s.root_node) | |
| if not priors: | |
| return "### Engine\n\n*(no candidate moves — the position is drawn or over)*" | |
| ranked = sorted(priors.items(), key=lambda kv: -kv[1])[: max(3, min(int(top_k), 8))] | |
| mover = "White" if board.turn == chess.WHITE else "Black" | |
| rows = ["| move | policy prior |", "| --- | --- |"] | |
| for mv, p in ranked: | |
| rows.append(f"| `{board.san(mv)}` | {p * 100:5.1f}% |") | |
| head = "### ChessMamba's read of this position\n\n" | |
| head += f"**Value head:** `{value:+.3f}` (+1 = winning for {mover}, −1 = losing)\n\n" | |
| if played is not None: | |
| timing = f" in {elapsed:.1f}s" if elapsed is not None else "" | |
| head = ( | |
| f"### ChessMamba played `{played}`{timing}\n\n" | |
| f"**Value head (now, {mover} to move):** `{value:+.3f}`\n\n" | |
| ) | |
| head += "**Policy head — top candidate replies:**\n\n" | |
| return head + "\n".join(rows) | |
| # --------------------------------------------------------------------------- # | |
| # Engine move | |
| # --------------------------------------------------------------------------- # | |
| def engine_move(board: chess.Board, movetime: float, top_k: int, max_depth: int): | |
| """Run the repo's policy-guided negamax and return (move, seconds).""" | |
| s = make_searcher(top_k=top_k, max_depth=max_depth) | |
| t0 = time.perf_counter() | |
| mv = s.choose_move(board, ["go", "movetime", str(int(float(movetime) * 1000))]) | |
| return mv, time.perf_counter() - t0 | |
| # --------------------------------------------------------------------------- # | |
| # Handlers | |
| # --------------------------------------------------------------------------- # | |
| PROMO_MAP = {"Queen": chess.QUEEN, "Rook": chess.ROOK, "Bishop": chess.BISHOP, | |
| "Knight": chess.KNIGHT} | |
| def _pack(board: chess.Board, human_white: bool, selected: str = "", | |
| analysis: str = "", arrow: chess.Move | None = None, note: str = ""): | |
| status = note or turn_text(board, human_white) | |
| return ( | |
| render_board(board, flip=not human_white, selected=selected, arrow=arrow), | |
| history_of(board), | |
| selected, | |
| status, | |
| movetext(board), | |
| analysis, | |
| gr.update(choices=legal_sans(board), value=None), | |
| ) | |
| def _apply_engine(board: chess.Board, human_white: bool, movetime: float, | |
| top_k: int, max_depth: int): | |
| mv, dt = engine_move(board, movetime, top_k, max_depth) | |
| if mv is None: | |
| return _pack(board, human_white, analysis=analysis_md(board, None, None, top_k)) | |
| san = board.san(mv) | |
| board.push(mv) | |
| return _pack( | |
| board, human_white, | |
| analysis=analysis_md(board, san, dt, top_k), | |
| arrow=mv, | |
| ) | |
| def _parse_move(board: chess.Board, text: str) -> chess.Move | None: | |
| text = (text or "").strip() | |
| if not text: | |
| return None | |
| for parser in (board.parse_san, board.parse_uci): | |
| try: | |
| mv = parser(text) | |
| if mv in board.legal_moves: | |
| return mv | |
| except Exception: | |
| continue | |
| return None | |
| def new_game(side: str, movetime: float, top_k: int, max_depth: int): | |
| """Start a fresh game; if the human plays Black, ChessMamba opens as White.""" | |
| human_white = side == "Play as White" | |
| board = chess.Board() | |
| if human_white: | |
| return _pack(board, True, analysis=analysis_md(board, None, None, top_k)) | |
| return _apply_engine(board, False, movetime, top_k, max_depth) | |
| def play_move(move_text: str, dropdown: str, history: str, side: str, | |
| movetime: float, top_k: int, max_depth: int, promo: str): | |
| """Play the human's move (SAN or UCI), then let ChessMamba reply. | |
| Args: | |
| move_text: the human move in SAN (`Nf3`, `e4`, `O-O`) or UCI (`g1f3`). | |
| dropdown: a legal move picked from the dropdown (used if move_text is empty). | |
| history: space-separated UCI moves of the game so far. | |
| side: "Play as White" or "Play as Black". | |
| movetime: seconds of search time ChessMamba gets per move. | |
| top_k: how many policy-ranked moves the search explores per node. | |
| max_depth: maximum negamax depth. | |
| promo: piece to promote to. | |
| """ | |
| human_white = side == "Play as White" | |
| board = board_from_history(history) | |
| if board.is_game_over(): | |
| yield _pack(board, human_white, analysis=analysis_md(board, None, None, top_k)) | |
| return | |
| chosen = move_text if (move_text or "").strip() else dropdown | |
| mv = _parse_move(board, chosen) | |
| if mv is None: | |
| hint = ", ".join(legal_sans(board)[:5]) | |
| yield _pack(board, human_white, | |
| note=f"⚠️ `{chosen or '∅'}` is not a legal move here. Try e.g. {hint} …") | |
| return | |
| board.push(mv) | |
| yield _pack(board, human_white, note="ChessMamba is thinking… 🐍") | |
| if board.is_game_over(): | |
| yield _pack(board, human_white, analysis=analysis_md(board, None, None, top_k)) | |
| return | |
| yield _apply_engine(board, human_white, movetime, top_k, max_depth) | |
| def board_click(history: str, selected: str, side: str, movetime: float, | |
| top_k: int, max_depth: int, promo: str, evt: gr.SelectData): | |
| """Click a piece, then click its destination square.""" | |
| human_white = side == "Play as White" | |
| board = board_from_history(history) | |
| x, y = float(evt.index[0]), float(evt.index[1]) | |
| sq_name = xy_to_square(x, y, flip=not human_white) | |
| if board.is_game_over() or not sq_name: | |
| yield _pack(board, human_white, selected="") | |
| return | |
| human_turn = board.turn == (chess.WHITE if human_white else chess.BLACK) | |
| if not human_turn: | |
| yield _pack(board, human_white, selected="") | |
| return | |
| sq = chess.parse_square(sq_name) | |
| if not selected: | |
| piece = board.piece_at(sq) | |
| if piece is None or piece.color != board.turn: | |
| yield _pack(board, human_white, selected="", note="Pick one of your own pieces.") | |
| return | |
| yield _pack(board, human_white, selected=sq_name, | |
| note=f"`{sq_name}` selected — now click a destination.") | |
| return | |
| if sq_name == selected: | |
| yield _pack(board, human_white, selected="") | |
| return | |
| from_sq = chess.parse_square(selected) | |
| mv = chess.Move(from_sq, sq) | |
| if mv not in board.legal_moves: | |
| promoted = chess.Move(from_sq, sq, promotion=PROMO_MAP.get(promo, chess.QUEEN)) | |
| if promoted in board.legal_moves: | |
| mv = promoted | |
| else: | |
| piece = board.piece_at(sq) | |
| if piece is not None and piece.color == board.turn: | |
| yield _pack(board, human_white, selected=sq_name, | |
| note=f"`{sq_name}` selected — now click a destination.") | |
| else: | |
| yield _pack(board, human_white, selected="", | |
| note=f"`{selected}{sq_name}` is not legal. Pick a piece again.") | |
| return | |
| board.push(mv) | |
| yield _pack(board, human_white, note="ChessMamba is thinking… 🐍") | |
| if board.is_game_over(): | |
| yield _pack(board, human_white, analysis=analysis_md(board, None, None, top_k)) | |
| return | |
| yield _apply_engine(board, human_white, movetime, top_k, max_depth) | |
| def undo(history: str, side: str, top_k: int): | |
| """Take back the last full move (yours + ChessMamba's).""" | |
| human_white = side == "Play as White" | |
| board = board_from_history(history) | |
| for _ in range(2): | |
| if board.move_stack: | |
| board.pop() | |
| return _pack(board, human_white, analysis=analysis_md(board, None, None, top_k)) | |
| def play_opening(first_move: str, movetime: float = 3.0, top_k: int = 10, | |
| max_depth: int = 6): | |
| """Start a new game as White with `first_move`, then show ChessMamba's reply. | |
| Args: | |
| first_move: the opening move in SAN (`e4`, `d4`, `Nf3`, `c4`). | |
| movetime: seconds of search time ChessMamba gets. | |
| top_k: how many policy-ranked moves the search explores per node. | |
| max_depth: maximum negamax depth. | |
| """ | |
| board = chess.Board() | |
| mv = _parse_move(board, first_move) | |
| if mv is None: | |
| return _pack(board, True, analysis=analysis_md(board, None, None, top_k))[:6] + \ | |
| ("Play as White",) | |
| board.push(mv) | |
| return _apply_engine(board, True, movetime, top_k, max_depth)[:6] + ("Play as White",) | |
| # --------------------------------------------------------------------------- # | |
| # UI | |
| # --------------------------------------------------------------------------- # | |
| INTRO = f""" | |
| # 🐍♟️ Play ChessMamba | |
| [`{MODEL_ID}`](https://huggingface.co/{MODEL_ID}) is a **{N_PARAMS/1e6:.1f}M-parameter selective | |
| state-space model (Mamba / S6)** trained from scratch to play chess — no alpha-beta eval | |
| function, no NNUE, no CNN+MCTS, and no Stockfish anywhere in its training. It reads the game | |
| as a *sequence of moves*, advancing one compact recurrent hidden state per ply, and reads | |
| **policy**, **promotion** and **value** heads straight off that state. A policy-guided | |
| negamax with alpha-beta + quiescence sits on top. | |
| **Click a piece, then click where it should go** (or type a move). ChessMamba replies, and the | |
| panel on the right shows the raw policy/value heads behind its choice. | |
| """ | |
| CSS = """ | |
| #col-container { max-width: 1200px; margin: 0 auto; } | |
| .dark .gradio-container { color: var(--body-text-color); } | |
| #board-img img { image-rendering: auto; } | |
| """ | |
| _START = chess.Board() | |
| with gr.Blocks(title="Play ChessMamba") as demo: | |
| with gr.Column(elem_id="col-container"): | |
| gr.Markdown(INTRO) | |
| history = gr.Textbox(value="", visible=False) | |
| selected = gr.Textbox(value="", visible=False) | |
| with gr.Row(): | |
| with gr.Column(scale=3): | |
| board_img = gr.Image( | |
| value=render_board(_START), | |
| type="filepath", | |
| label="Board — click a piece, then its destination", | |
| elem_id="board-img", | |
| interactive=False, | |
| buttons=[], | |
| height=BOARD_PX, | |
| ) | |
| status_md = gr.Markdown("Your move.") | |
| with gr.Column(scale=2): | |
| with gr.Row(): | |
| move_in = gr.Textbox( | |
| show_label=False, | |
| placeholder="…or type a move: Nf3, e4, O-O, g1f3", | |
| container=False, | |
| scale=4, | |
| ) | |
| play_btn = gr.Button("Play", variant="primary", scale=1) | |
| legal_dd = gr.Dropdown( | |
| label="…or pick a legal move", | |
| choices=legal_sans(_START), | |
| value=None, | |
| ) | |
| analysis_out = gr.Markdown( | |
| analysis_md(_START, None, None, 10), label="Engine" | |
| ) | |
| moves_md = gr.Markdown("*(no moves yet)*", label="Moves") | |
| with gr.Row(): | |
| new_btn = gr.Button("New game", variant="secondary") | |
| undo_btn = gr.Button("Undo", variant="secondary") | |
| with gr.Accordion("Engine settings", open=False): | |
| with gr.Row(): | |
| side_radio = gr.Radio( | |
| ["Play as White", "Play as Black"], | |
| value="Play as White", | |
| label="Your side", | |
| ) | |
| promo_radio = gr.Radio( | |
| ["Queen", "Rook", "Bishop", "Knight"], | |
| value="Queen", | |
| label="Promote pawns to", | |
| ) | |
| movetime = gr.Slider(0.5, 8.0, value=3.0, step=0.5, | |
| label="Think time per move (s)") | |
| top_k = gr.Slider(3, 20, value=10, step=1, | |
| label="Policy top-K explored per node") | |
| max_depth = gr.Slider(1, 8, value=6, step=1, label="Max negamax depth") | |
| gr.Markdown( | |
| "The engine stops at whichever comes first: the think time, or the depth " | |
| "limit. In endgames `search.py` extends the budget by 1.6× (author's rule)." | |
| ) | |
| OUT = [board_img, history, selected, status_md, moves_md, analysis_out, legal_dd] | |
| gr.Examples( | |
| examples=[["e4"], ["d4"], ["Nf3"], ["c4"], ["g3"]], | |
| inputs=[move_in], | |
| outputs=OUT[:6] + [side_radio], | |
| fn=play_opening, | |
| cache_examples=True, | |
| cache_mode="lazy", | |
| label="Open with a classic first move and see how ChessMamba answers", | |
| ) | |
| with gr.Accordion("A full ChessMamba game (author's replay, ends in checkmate)", | |
| open=False): | |
| gr.Video(value="replay.mp4", label="replay.mp4 — from the 4-0 match", | |
| interactive=False) | |
| gr.Markdown( | |
| "Sources: model, weights, engine code and replay from " | |
| f"[`{MODEL_ID}`](https://huggingface.co/{MODEL_ID}) (MIT). " | |
| "`model.py`, `search.py` and `chess_io.py` are vendored **verbatim** from " | |
| "that repo, so the moves you see here are the reference engine's." | |
| ) | |
| board_img.select( | |
| board_click, | |
| inputs=[history, selected, side_radio, movetime, top_k, max_depth, promo_radio], | |
| outputs=OUT, | |
| ) | |
| play_btn.click( | |
| play_move, | |
| inputs=[move_in, legal_dd, history, side_radio, movetime, top_k, max_depth, | |
| promo_radio], | |
| outputs=OUT, | |
| api_name="play_move", | |
| ).then(lambda: "", outputs=move_in) | |
| move_in.submit( | |
| play_move, | |
| inputs=[move_in, legal_dd, history, side_radio, movetime, top_k, max_depth, | |
| promo_radio], | |
| outputs=OUT, | |
| ).then(lambda: "", outputs=move_in) | |
| new_btn.click( | |
| new_game, | |
| inputs=[side_radio, movetime, top_k, max_depth], | |
| outputs=OUT, | |
| api_name="new_game", | |
| ) | |
| side_radio.change( | |
| new_game, | |
| inputs=[side_radio, movetime, top_k, max_depth], | |
| outputs=OUT, | |
| ) | |
| undo_btn.click(undo, inputs=[history, side_radio, top_k], outputs=OUT) | |
| if __name__ == "__main__": | |
| demo.launch(mcp_server=True, theme=gr.themes.Citrus(), css=CSS, show_error=True) | |