""" ChessSLM — Play against FlameF0X/ChessSLM Hugging Face Space | Gradio app Move selection: ChessSLM generates 10–20 candidate moves via sampling, Stockfish evaluates each and selects the best one. """ import re import random import chess import chess.svg import chess.pgn import chess.engine import gradio as gr import torch from transformers import GPT2LMHeadModel, GPT2Tokenizer # ────────────────────────────────────────────────────────────────────────────── # Model loading (once at startup) # ────────────────────────────────────────────────────────────────────────────── MODEL_ID = "FlameF0X/ChessSLM-RL" print(f"Loading {MODEL_ID}...") device = torch.device("cuda" if torch.cuda.is_available() else "cpu") tokenizer = GPT2Tokenizer.from_pretrained(MODEL_ID) tokenizer.pad_token = tokenizer.eos_token model = GPT2LMHeadModel.from_pretrained(MODEL_ID) model.to(device) model.eval() model.config.use_cache = True print(f"✓ Model ready on {device}") # Stockfish engine path — standard location on HuggingFace Spaces / Ubuntu STOCKFISH_PATH = "/usr/games/stockfish" STOCKFISH_DEPTH = 12 # evaluation depth per candidate # ────────────────────────────────────────────────────────────────────────────── # Chess / model logic # ────────────────────────────────────────────────────────────────────────────── def board_to_prompt(board: chess.Board) -> str: game = chess.pgn.Game() node = game for move in board.move_stack: node = node.add_variation(move) exporter = chess.pgn.StringExporter(headers=False, variations=False, comments=False) pgn = game.accept(exporter).strip() pgn = re.sub(r"\s*[\*\d][-\d/]*\s*$", "", pgn).strip() full_move = board.fullmove_number pgn += f" {full_move}." if board.turn == chess.WHITE else f" {full_move}..." return f"<|endoftext|>{pgn}" def extract_move(text: str, board: chess.Board): text = re.sub(r"^\s*\d+\.+\s*", "", text).strip() for token in text.split()[:5]: clean = re.sub(r"[!?+#,;]+$", "", token) try: move = board.parse_san(clean) if move in board.legal_moves: return move except Exception: pass try: move = chess.Move.from_uci(clean.lower()[:4]) if move in board.legal_moves: return move except Exception: pass return None @torch.no_grad() def _sample_candidate_moves(board: chess.Board, n: int = 15, max_attempts: int = 90): """ Sample up to `n` distinct legal moves from ChessSLM using temperature sampling. Returns a list of chess.Move objects. """ prompt = board_to_prompt(board) inputs = tokenizer(prompt, return_tensors="pt").to(device) candidates: list[chess.Move] = [] seen: set[str] = set() attempts = 0 while len(candidates) < n and attempts < max_attempts: outputs = model.generate( inputs.input_ids, max_new_tokens=12, do_sample=True, temperature=0.85, # higher temp → more diverse candidates top_k=60, top_p=0.95, repetition_penalty=1.1, pad_token_id=tokenizer.eos_token_id, eos_token_id=tokenizer.eos_token_id, ) new_tokens = outputs[0][inputs.input_ids.shape[1]:] generated = tokenizer.decode(new_tokens, skip_special_tokens=True) move = extract_move(generated, board) attempts += 1 if move is not None and move.uci() not in seen: seen.add(move.uci()) candidates.append(move) return candidates def _stockfish_best(board: chess.Board, candidates: list[chess.Move]): """ Use Stockfish to evaluate each candidate move and return (best_move, scores_dict, stockfish_available). scores_dict maps move-UCI → centipawn score (from the side to move). """ if not candidates: return None, {}, False scores: dict[str, int] = {} try: with chess.engine.SimpleEngine.popen_uci(STOCKFISH_PATH) as engine: for move in candidates: test_board = board.copy() test_board.push(move) info = engine.analyse(test_board, chess.engine.Limit(depth=STOCKFISH_DEPTH)) score = info["score"].pov(board.turn) # from the perspective of the side to move # Represent as centipawns (mate scores → ±9999) if score.is_mate(): cp = 9999 if score.mate() > 0 else -9999 else: cp = score.score() scores[move.uci()] = cp # Best move = highest cp from the side to move best_uci = max(scores, key=lambda u: scores[u]) best_move = chess.Move.from_uci(best_uci) return best_move, scores, True except FileNotFoundError: # Stockfish not installed — fall back to first candidate return candidates[0], {}, False except Exception: return candidates[0], {}, False def get_model_move(board: chess.Board): """ High-level entry point. Returns (chosen_move, was_model_legal, candidates, scores, stockfish_used). """ n_samples = random.randint(10, 20) candidates = _sample_candidate_moves(board, n=n_samples, max_attempts=n_samples * 6) if not candidates: # Model produced nothing valid; pure random fallback fallback = random.choice(list(board.legal_moves)) return fallback, False, [], {}, False best, scores, sf_used = _stockfish_best(board, candidates) if best is None: best = candidates[0] return best, True, candidates, scores, sf_used # ────────────────────────────────────────────────────────────────────────────── # Board rendering # ────────────────────────────────────────────────────────────────────────────── PIECE_COLORS = { "square light": "#f0d9b5", "square dark": "#b58863", "square light lastmove": "#cdd16e", "square dark lastmove": "#aaa23a", } def render_board_html(board: chess.Board, last_move=None, flipped=False, size=480): check_square = board.king(board.turn) if board.is_check() else None svg = chess.svg.board( board, lastmove=last_move, check=check_square, flipped=flipped, size=size, colors=PIECE_COLORS, ) return f"""
{svg}
""" def get_legal_moves_san(board: chess.Board): moves = [] for move in board.legal_moves: try: moves.append(board.san(move)) except Exception: pass return sorted(moves) def format_move_history(board: chess.Board): if not board.move_stack: return "No moves yet." temp = chess.Board() lines = [] moves = list(board.move_stack) i = 0 while i < len(moves): move_num = temp.fullmove_number white_san = temp.san(moves[i]) temp.push(moves[i]) i += 1 if i < len(moves): black_san = temp.san(moves[i]) temp.push(moves[i]) i += 1 lines.append( f"{move_num}. " f"{white_san} " f"{black_san}" ) else: lines.append( f"{move_num}. " f"{white_san}" ) visible = lines[-10:] html = "
" html += "
".join(visible) html += "
" return html def format_candidates_html(board: chess.Board, candidates: list, scores: dict, chosen_move, sf_used: bool) -> str: """ Render a small table showing all ChessSLM candidates with their Stockfish evaluation scores and which one was chosen. """ if not candidates: return "" rows = [] for move in candidates: san = board.san(move) if move in board.legal_moves else move.uci() cp = scores.get(move.uci()) is_best = (chosen_move is not None and move.uci() == chosen_move.uci()) if cp is None: score_str = "" elif cp >= 9999: score_str = "+M" elif cp <= -9999: score_str = "−M" elif cp > 0: score_str = f"+{cp/100:.2f}" elif cp < 0: score_str = f"{cp/100:.2f}" else: score_str = "0.00" star = "⭐ " if is_best else "   " row = ( f"" f"{star}" f"{san}" f"{score_str}" f"" ) rows.append(row) sf_label = ( "✔ Stockfish" if sf_used else "⚠ Stockfish N/A — first candidate used" ) return ( f"
" f"ChessSLM Candidates" f" {sf_label}" f"" + "".join(rows) + "
" ) def game_status(board: chess.Board, player_color: str): if board.is_checkmate(): winner = "Black" if board.turn == chess.WHITE else "White" if (winner == "White") == (player_color == "white"): return "♟ Checkmate — You win! 🎉", "win" else: return "♟ Checkmate — ChessSLM wins!", "loss" if board.is_stalemate(): return "½ Stalemate — Draw", "draw" if board.is_insufficient_material(): return "½ Insufficient material — Draw", "draw" if board.is_seventyfive_moves(): return "½ 75-move rule — Draw", "draw" if board.is_fivefold_repetition(): return "½ Fivefold repetition — Draw", "draw" if board.is_check(): return "⚠ Check!", "check" whose = "Your turn" if (board.turn == chess.WHITE) == (player_color == "white") else "ChessSLM is thinking..." return whose, "playing" # ────────────────────────────────────────────────────────────────────────────── # Gradio callbacks # ────────────────────────────────────────────────────────────────────────────── def new_game(player_color_choice: str): board = chess.Board() player_color = "white" if player_color_choice == "⬜ White (move first)" else "black" flipped = (player_color == "black") last_move = None log_lines = [] candidates_html = "" if player_color == "black": move, legal, candidates, scores, sf_used = get_model_move(board) san = board.san(move) # Build candidate table before pushing cand_html = format_candidates_html(board, candidates, scores, move, sf_used) board.push(move) last_move = move log_lines.append(f"ChessSLM opens with {san}") candidates_html = cand_html legal_moves = get_legal_moves_san(board) status_text, _ = game_status(board, player_color) board_html = render_board_html(board, last_move=last_move, flipped=flipped) history_html = format_move_history(board) if log_lines: log_html = ( "
".join(f"{l}" for l in log_lines) + candidates_html ) else: log_html = "Game started." state = { "fen": board.fen(), "move_stack": [m.uci() for m in board.move_stack], "player_color": player_color, "last_move_uci": last_move.uci() if last_move else None, "game_over": False, } return ( board_html, gr.Dropdown(choices=legal_moves, value=None, interactive=True, label="Your move"), status_text, history_html, log_html, state, ) def make_player_move(move_san: str, state: dict): if not state or state.get("game_over"): return gr.update(), gr.update(), "Game is over. Start a new game.", gr.update(), gr.update(), state if not move_san: return gr.update(), gr.update(), "Please select a move first.", gr.update(), gr.update(), state board = chess.Board() for uci in state["move_stack"]: board.push(chess.Move.from_uci(uci)) player_color = state["player_color"] flipped = (player_color == "black") log_lines = [] candidates_html = "" try: player_move = board.parse_san(move_san) except Exception: return gr.update(), gr.update(), f"Invalid move: {move_san}", gr.update(), gr.update(), state board.push(player_move) log_lines.append(f"You played {move_san}") last_move = player_move status_text, status_key = game_status(board, player_color) game_over = status_key in ("win", "loss", "draw") if not game_over: move, legal, candidates, scores, sf_used = get_model_move(board) model_san = board.san(move) # Build candidate table before pushing candidates_html = format_candidates_html(board, candidates, scores, move, sf_used) board.push(move) last_move = move flag = "" if legal else " (random fallback)" log_lines.append(f"ChessSLM plays {model_san}{flag}") status_text, status_key = game_status(board, player_color) game_over = status_key in ("win", "loss", "draw") state = { "fen": board.fen(), "move_stack": [m.uci() for m in board.move_stack], "player_color": player_color, "last_move_uci": last_move.uci() if last_move else None, "game_over": game_over, } legal_moves = [] if game_over else get_legal_moves_san(board) board_html = render_board_html(board, last_move=last_move, flipped=flipped) history_html = format_move_history(board) log_html = ( "
".join(f"{l}" for l in log_lines) + candidates_html ) return ( board_html, gr.Dropdown(choices=legal_moves, value=None, interactive=not game_over, label="Your move"), status_text, history_html, log_html, state, ) # ────────────────────────────────────────────────────────────────────────────── # CSS # ────────────────────────────────────────────────────────────────────────────── CSS = """ @import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap'); body, .gradio-container { background: #0d0d0d !important; color: #e8d5a3 !important; } .gradio-container { max-width: 1100px !important; margin: 0 auto !important; font-family: 'Crimson Text', Georgia, serif !important; } h1, h2, h3 { font-family: 'Cinzel', serif !important; letter-spacing: 0.08em; } #title-block { text-align: center; padding: 2rem 0 1rem; border-bottom: 1px solid #3d2b0e; margin-bottom: 1.5rem; } .panel { background: #141008 !important; border: 1px solid #3d2b0e !important; border-radius: 8px !important; padding: 1rem !important; } #status-bar { text-align: center; font-family: 'Cinzel', serif; font-size: 1.1em; letter-spacing: 0.05em; padding: 0.6rem 1rem; border-radius: 6px; background: #1a1208; border: 1px solid #4a3520; color: #f0c060; } button.primary { background: linear-gradient(135deg, #8b6914 0%, #c4922a 50%, #8b6914 100%) !important; border: 1px solid #d4a843 !important; color: #fff8e8 !important; font-family: 'Cinzel', serif !important; letter-spacing: 0.06em !important; font-size: 0.9em !important; border-radius: 4px !important; transition: all 0.2s ease !important; } button.primary:hover { background: linear-gradient(135deg, #a07820 0%, #d4a843 50%, #a07820 100%) !important; box-shadow: 0 0 16px rgba(212,168,67,0.4) !important; } button.secondary { background: #1e1810 !important; border: 1px solid #5a4020 !important; color: #c8a96e !important; font-family: 'Cinzel', serif !important; letter-spacing: 0.04em !important; border-radius: 4px !important; } select, .gr-dropdown select { background: #1a1208 !important; border: 1px solid #5a4020 !important; color: #e8d5a3 !important; font-family: 'Crimson Text', serif !important; font-size: 1em !important; } #move-log { background: #0f0c06 !important; border: 1px solid #3d2b0e !important; border-radius: 6px; padding: 0.8rem 1rem; font-family: 'Crimson Text', serif; font-size: 0.95em; line-height: 1.8; min-height: 80px; color: #c8a96e; } #history-panel { background: #0f0c06 !important; border: 1px solid #3d2b0e !important; border-radius: 6px; padding: 0.8rem 1rem; min-height: 200px; max-height: 320px; overflow-y: auto; } .gr-radio label { color: #e8d5a3 !important; font-family: 'Crimson Text', serif !important; } label span { color: #a08050 !important; font-family: 'Cinzel', serif !important; font-size: 0.8em !important; letter-spacing: 0.06em !important; text-transform: uppercase !important; } ::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: #0d0d0d; } ::-webkit-scrollbar-thumb { background: #5a4020; border-radius: 3px; } """ # ────────────────────────────────────────────────────────────────────────────── # Layout # ────────────────────────────────────────────────────────────────────────────── with gr.Blocks(css=CSS, title="ChessSLM — Play vs AI") as demo: state = gr.State({}) gr.HTML("""

♛ ChessSLM

GPT-2 generates 10–20 candidate moves · Stockfish picks the best

""") with gr.Row(): with gr.Column(scale=3): board_display = gr.HTML( value=render_board_html(chess.Board()), label="Board" ) status_display = gr.HTML( value="
Choose your colour and press New Game
" ) with gr.Column(scale=2): gr.HTML("

NEW GAME

") color_choice = gr.Radio( choices=["⬜ White (move first)", "⬛ Black (move second)"], value="⬜ White (move first)", label="Play as", ) new_game_btn = gr.Button("♟ New Game", variant="primary", size="lg") gr.HTML("
") gr.HTML("

YOUR MOVE

") move_dropdown = gr.Dropdown( choices=[], value=None, label="Select move (SAN notation)", interactive=False, ) move_btn = gr.Button("▶ Make Move", variant="secondary") gr.HTML("
") gr.HTML("

MOVE LOG

") log_display = gr.HTML( value="
Start a new game to begin.
", ) gr.HTML("
") gr.HTML("

GAME HISTORY

") history_display = gr.HTML( value="
No moves yet.
", ) gr.HTML("""
Model: FlameF0X/ChessSLM  ·  GPT-2 samples 10–20 candidates (temp=0.85) · Stockfish depth=12 selects the best
""") new_game_btn.click( fn=new_game, inputs=[color_choice], outputs=[board_display, move_dropdown, status_display, history_display, log_display, state], ) move_btn.click( fn=make_player_move, inputs=[move_dropdown, state], outputs=[board_display, move_dropdown, status_display, history_display, log_display, state], ) move_dropdown.select( fn=make_player_move, inputs=[move_dropdown, state], outputs=[board_display, move_dropdown, status_display, history_display, log_display, state], ) if __name__ == "__main__": demo.launch()