File size: 10,307 Bytes
a5f2e46
 
 
 
 
 
 
 
 
 
 
 
 
 
c5a3c49
a5f2e46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c5a3c49
2377b60
 
c5a3c49
a5f2e46
 
c5a3c49
2377b60
 
 
 
a5f2e46
 
 
 
 
 
 
 
c5a3c49
 
 
 
 
 
 
a5f2e46
 
2377b60
a5f2e46
 
c5a3c49
 
 
a5f2e46
 
 
 
 
 
 
c5a3c49
 
 
a5f2e46
 
 
 
 
 
 
 
 
 
c5a3c49
a5f2e46
 
c5a3c49
 
a5f2e46
 
c5a3c49
 
 
2377b60
c5a3c49
2377b60
a5f2e46
 
 
 
 
 
 
 
 
 
 
 
c5a3c49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a5f2e46
c5a3c49
a5f2e46
 
 
 
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
"""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'<div style="height:{white_pct}%;background:#f5f5f5;"></div>'
    black_div = f'<div style="height:{round(100 - white_pct, 1)}%;background:#3a3a3a;"></div>'
    # 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'<div title="Model eval (White POV): {wv:+.2f}" '
        'style="display:flex;flex-direction:column;width:26px;height:460px;'
        'border:1px solid #999;border-radius:4px;overflow:hidden;">'
        f'{segments}</div>'
    )


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()