Chess on the AlphaZero Framework Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Implement chess on the existing AlphaZero framework + parallel self-play infrastructure, then train an agent that wins ≥50% of 100 games against Stockfish ELO=1500.

Architecture: Add Chess(Game) wrapping python-chess + AlphaZero 8×8×73 move encoding + 20-plane input + StockfishOpponent via UCI. New parallel_selfplay module with process pool + central NN-server batching inference. Trainer extended to dispatch parallel path when num_workers > 1. All framework code (MCTS, network, replay buffer, etc.) reused unchanged.

Tech Stack: Same as previous sub-projects + python-chess>=1.10 (game rules) + stockfish binary (eval baseline, system-installed).

Spec: docs/superpowers/specs/2026-05-16-chess-design.md


File Structure

src/alphazero/games/chess_game.py                 ← new (~300 lines) Chess(Game)
src/alphazero/games/chess_move_encoding.py        ← new (~250 lines) 8×8×73 encoding
src/alphazero/opponents/stockfish.py              ← new (~80 lines) StockfishOpponent
src/alphazero/parallel_selfplay.py                ← new (~400 lines) worker pool + NN-server
src/alphazero/trainer.py                          ← small extend (dispatch parallel)
src/alphazero/cli.py                              ← small extend (--game chess)
configs/chess.toml                                ← new
bin/setup.sh                                      ← new (portable install)
Dockerfile                                        ← new (optional, for cloud reproducibility)
tests/test_move_encoding.py                       ← new
tests/test_game_chess.py                          ← new
tests/test_chess_minimax.py                       ← new
tests/test_parallel_selfplay.py                   ← new
tests/test_invariants_chess.py                    ← new
tests/test_e2e_chess.py                           ← new (@pytest.mark.slow)

Tasks proceed bottom-up: dependencies + scaffolding first, then move encoding (foundation for Chess), then Chess game, then Stockfish opponent, then parallel self-play (the big new piece), then integration (Trainer + CLI), then tests + E2E kill criterion.


Conventions


Task 1: Dependencies + Stockfish smoke test

Files: - Modify: pyproject.toml - Create: bin/setup.sh

Edit pyproject.toml, locate the dependencies = [...] list, add "python-chess>=1.10", so it reads:

dependencies = [
    "torch>=2.2",
    "numpy>=1.26",
    "tomli>=2.0",
    "tqdm>=4.66",
    "tensorboard>=2.16",
    "python-chess>=1.10",
]
mkdir -p bin
cat > bin/setup.sh <<'EOF'
#!/usr/bin/env bash
# Install all dependencies for AlphaZero (incl. Stockfish for chess eval).
# Idempotent; safe to re-run.
set -euo pipefail

# Detect platform and install Stockfish binary
if [[ "$(uname)" == "Darwin" ]]; then
    if ! command -v stockfish &>/dev/null; then
        echo "Installing Stockfish via Homebrew..."
        brew install stockfish
    fi
elif [[ -f /etc/debian_version ]]; then
    if ! dpkg -s stockfish &>/dev/null; then
        echo "Installing Stockfish via apt..."
        sudo apt-get update && sudo apt-get install -y stockfish
    fi
else
    echo "Unknown platform; please install stockfish manually." >&2
fi

# Python deps
pip install -e ".[dev]"

# Verify Stockfish is usable from python-chess
python3 -c "
import chess.engine
eng = chess.engine.SimpleEngine.popen_uci('stockfish')
eng.configure({'UCI_LimitStrength': True, 'UCI_Elo': 1500})
print('✓ Stockfish OK; UCI_Elo configurable')
eng.quit()
"
EOF
chmod +x bin/setup.sh
bash bin/setup.sh

Expected: ends with ✓ Stockfish OK; UCI_Elo configurable. If Stockfish isn't installable on your platform, install it manually per its README before continuing.

python3 -c "import chess; print('chess version:', chess.__version__)"

Expected: prints a version like 1.10.0 or higher.

git add pyproject.toml bin/setup.sh
git commit -m "$(cat <<'EOF'
Add python-chess dependency + bin/setup.sh installer

Sub-project 3 prep: chess needs python-chess for game rules and the
stockfish binary for eval. bin/setup.sh is portable across macOS
(brew), Debian/Ubuntu (apt), and verifies Stockfish supports the
UCI_LimitStrength + UCI_Elo configuration we'll use as the eval bar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 2: Move encoding — queen-like moves

Files: - Create: src/alphazero/games/chess_move_encoding.py - Create: tests/test_move_encoding.py

This is the trickiest piece of chess. We implement the AlphaZero 8×8×73 = 4672 action space. Task 2 covers the 56 queen-like move types; Tasks 3 and 4 add knights and underpromotions; Task 5 ties them together with round-trip tests.

# tests/test_move_encoding.py
import chess
import pytest

from alphazero.games.chess_move_encoding import (
    ACTION_SIZE,
    move_to_index,
    index_to_move,
)


def test_action_size_is_4672():
    assert ACTION_SIZE == 4672  # 64 squares × 73 move types


def test_white_pawn_e2_e4_encodes():
    """Famous opening: e2-e4 = queen-direction N, distance 2, source e2."""
    board = chess.Board()
    move = chess.Move.from_uci("e2e4")
    idx = move_to_index(board, move)
    assert 0 <= idx < ACTION_SIZE


def test_white_pawn_e2_e4_round_trip():
    board = chess.Board()
    move = chess.Move.from_uci("e2e4")
    idx = move_to_index(board, move)
    back = index_to_move(board, idx)
    assert back == move


def test_white_rook_a1_a8_round_trip():
    """Rook move along a-file (7 squares N)."""
    board = chess.Board("4k3/8/8/8/8/8/8/R3K3 w - - 0 1")
    move = chess.Move.from_uci("a1a8")
    idx = move_to_index(board, move)
    back = index_to_move(board, idx)
    assert back == move


def test_white_bishop_diagonal_round_trip():
    """Bishop move along the long diagonal."""
    board = chess.Board("8/8/8/8/8/8/8/B6k w - - 0 1")
    move = chess.Move.from_uci("a1h8")
    idx = move_to_index(board, move)
    back = index_to_move(board, idx)
    assert back == move


def test_white_queen_all_8_directions_round_trip():
    """A queen in the middle can move in 8 directions. All encode/decode."""
    board = chess.Board("8/8/8/4Q3/8/8/8/4K2k w - - 0 1")
    # 8 destinations, one per direction
    destinations = ["e6", "f6", "f5", "f4", "e4", "d4", "d5", "d6"]
    for dst in destinations:
        move = chess.Move.from_uci(f"e5{dst}")
        idx = move_to_index(board, move)
        back = index_to_move(board, idx)
        assert back == move, f"Failed for queen move e5-{dst}"


def test_white_king_one_square_each_direction_round_trip():
    """King is queen-distance-1 in our encoding."""
    board = chess.Board("8/8/8/8/8/4K3/8/4k3 w - - 0 1")
    destinations = ["e4", "f4", "f3", "f2", "e2", "d2", "d3", "d4"]
    for dst in destinations:
        move = chess.Move.from_uci(f"e3{dst}")
        idx = move_to_index(board, move)
        back = index_to_move(board, idx)
        assert back == move, f"Failed for king move e3-{dst}"
pytest tests/test_move_encoding.py -v

Expected: ImportError on alphazero.games.chess_move_encoding.

"""AlphaZero 8×8×73 = 4672 action-space encoding for chess.

Each chess move is encoded as (source_square, move_type) where:
- source_square ∈ [0, 64): standard python-chess square indexing (a1=0, ..., h8=63)
- move_type ∈ [0, 73): one of three categories
    - Queen-like (8 directions × 7 distances): indices [0, 56)
    - Knight moves (8 L-shapes):                 indices [56, 64)
    - Underpromotions (3 pieces × 3 file dirs):  indices [64, 73)

Flat action index = source_square * 73 + move_type, range [0, 4672).

NOTE: this module currently implements only queen-like moves. Knight moves
and underpromotions are added in Tasks 3 and 4.
"""
from __future__ import annotations

import chess

# 8 compass directions as (drank, dfile) deltas
# Order matters: matches the original AlphaZero paper for reproducibility
QUEEN_DIRECTIONS: list[tuple[int, int]] = [
    ( 1,  0),  # N    (rank increases)
    ( 1,  1),  # NE
    ( 0,  1),  # E    (file increases)
    (-1,  1),  # SE
    (-1,  0),  # S
    (-1, -1),  # SW
    ( 0, -1),  # W
    ( 1, -1),  # NW
]

ACTION_SIZE = 64 * 73  # 4672


def _queen_move_to_type(drank: int, dfile: int) -> int | None:
    """Return move_type ∈ [0, 56) for a queen-like move, or None if not queen-like."""
    if drank == 0 and dfile == 0:
        return None  # null move
    distance = max(abs(drank), abs(dfile))
    if distance > 7:
        return None
    # Direction sign (normalized)
    dr_sign = (drank > 0) - (drank < 0)
    df_sign = (dfile > 0) - (dfile < 0)
    # Must move in a straight line (rank, file, or diagonal)
    if drank != 0 and dfile != 0 and abs(drank) != abs(dfile):
        return None  # not queen-like
    try:
        direction_idx = QUEEN_DIRECTIONS.index((dr_sign, df_sign))
    except ValueError:
        return None
    return direction_idx * 7 + (distance - 1)


def _type_to_queen_move(move_type: int) -> tuple[int, int] | None:
    """Inverse of _queen_move_to_type. Returns (drank, dfile) or None if not queen-like."""
    if not (0 <= move_type < 56):
        return None
    direction_idx, distance_idx = divmod(move_type, 7)
    distance = distance_idx + 1
    dr_sign, df_sign = QUEEN_DIRECTIONS[direction_idx]
    return dr_sign * distance, df_sign * distance


def move_to_index(board: chess.Board, move: chess.Move) -> int:
    """Convert a python-chess Move to a flat action index in [0, 4672).

    Only handles queen-like moves so far. Knights and underpromotions raise.
    """
    src = move.from_square
    dst = move.to_square
    drank = chess.square_rank(dst) - chess.square_rank(src)
    dfile = chess.square_file(dst) - chess.square_file(src)

    queen_type = _queen_move_to_type(drank, dfile)
    if queen_type is not None and move.promotion in (None, chess.QUEEN):
        # Queen promotion is encoded as a queen-direction move (not as an underpromotion)
        return src * 73 + queen_type

    raise NotImplementedError(
        f"Knight and underpromotion encoding not implemented yet "
        f"(move={move}, drank={drank}, dfile={dfile})"
    )


def index_to_move(board: chess.Board, index: int) -> chess.Move:
    """Convert a flat action index back to a python-chess Move.

    Only handles queen-like moves so far.
    """
    if not (0 <= index < ACTION_SIZE):
        raise ValueError(f"index {index} out of range [0, {ACTION_SIZE})")
    src, move_type = divmod(index, 73)
    delta = _type_to_queen_move(move_type)
    if delta is None:
        raise NotImplementedError(
            f"index {index} decodes to non-queen-like move_type {move_type}"
        )
    drank, dfile = delta
    dst_rank = chess.square_rank(src) + drank
    dst_file = chess.square_file(src) + dfile
    if not (0 <= dst_rank < 8 and 0 <= dst_file < 8):
        raise ValueError(f"index {index} decodes to off-board destination")
    dst = chess.square(dst_file, dst_rank)
    # Detect promotion: a pawn moving onto rank 0 or 7 (white pawn → rank 7, black pawn → rank 0)
    promotion = None
    piece = board.piece_at(src)
    if piece is not None and piece.piece_type == chess.PAWN:
        if (piece.color == chess.WHITE and dst_rank == 7) or \
           (piece.color == chess.BLACK and dst_rank == 0):
            promotion = chess.QUEEN  # default; underpromotions handled separately
    return chess.Move(src, dst, promotion=promotion)
pytest tests/test_move_encoding.py -v

Expected: 7 tests pass.

git add src/alphazero/games/__init__.py 2>/dev/null || true
git add src/alphazero/games/chess_move_encoding.py tests/test_move_encoding.py
git commit -m "$(cat <<'EOF'
Add chess move encoding (queen-like moves: 56 of 73 move types)

8×8×73 = 4672 action space. Queen-like moves cover sliding pieces
(rooks, bishops, queens), king moves, all pawn pushes/captures, and
queen promotions. Knights and underpromotions in subsequent tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 3: Move encoding — knight moves

Files: - Modify: src/alphazero/games/chess_move_encoding.py - Modify: tests/test_move_encoding.py

def test_knight_round_trip_all_8_directions():
    """A knight at e4 has 8 possible destinations."""
    board = chess.Board("8/8/8/8/4N3/8/8/4K2k w - - 0 1")
    destinations = ["g5", "f6", "d6", "c5", "c3", "d2", "f2", "g3"]
    for dst in destinations:
        move = chess.Move.from_uci(f"e4{dst}")
        idx = move_to_index(board, move)
        back = index_to_move(board, idx)
        assert back == move, f"Failed for knight move e4-{dst}"


def test_knight_at_corner_partial_round_trip():
    """A knight at a1 has fewer legal moves but encoding must still work."""
    board = chess.Board("4k3/8/8/8/8/8/8/N6K w - - 0 1")
    for dst in ["b3", "c2"]:
        move = chess.Move.from_uci(f"a1{dst}")
        idx = move_to_index(board, move)
        back = index_to_move(board, idx)
        assert back == move
pytest tests/test_move_encoding.py -v

In src/alphazero/games/chess_move_encoding.py, add the knight delta table just after QUEEN_DIRECTIONS:

# 8 knight L-shapes as (drank, dfile) deltas
KNIGHT_DELTAS: list[tuple[int, int]] = [
    ( 2,  1),  # NNE
    ( 1,  2),  # ENE
    (-1,  2),  # ESE
    (-2,  1),  # SSE
    (-2, -1),  # SSW
    (-1, -2),  # WSW
    ( 1, -2),  # WNW
    ( 2, -1),  # NNW
]

Replace move_to_index and index_to_move with versions that handle knights:

def move_to_index(board: chess.Board, move: chess.Move) -> int:
    src = move.from_square
    dst = move.to_square
    drank = chess.square_rank(dst) - chess.square_rank(src)
    dfile = chess.square_file(dst) - chess.square_file(src)

    # Queen-like (and queen-promotions)
    queen_type = _queen_move_to_type(drank, dfile)
    if queen_type is not None and move.promotion in (None, chess.QUEEN):
        return src * 73 + queen_type

    # Knight
    if (drank, dfile) in KNIGHT_DELTAS:
        knight_idx = KNIGHT_DELTAS.index((drank, dfile))
        return src * 73 + (56 + knight_idx)

    raise NotImplementedError(
        f"Underpromotion encoding not implemented yet "
        f"(move={move}, drank={drank}, dfile={dfile})"
    )


def index_to_move(board: chess.Board, index: int) -> chess.Move:
    if not (0 <= index < ACTION_SIZE):
        raise ValueError(f"index {index} out of range [0, {ACTION_SIZE})")
    src, move_type = divmod(index, 73)

    if 0 <= move_type < 56:
        delta = _type_to_queen_move(move_type)
        drank, dfile = delta
        return _build_move_with_promotion(board, src, drank, dfile)

    if 56 <= move_type < 64:
        knight_idx = move_type - 56
        drank, dfile = KNIGHT_DELTAS[knight_idx]
        dst_rank = chess.square_rank(src) + drank
        dst_file = chess.square_file(src) + dfile
        if not (0 <= dst_rank < 8 and 0 <= dst_file < 8):
            raise ValueError(f"index {index} decodes off-board")
        dst = chess.square(dst_file, dst_rank)
        return chess.Move(src, dst)

    raise NotImplementedError(f"index {index} (move_type {move_type}) is an underpromotion (not impl yet)")


def _build_move_with_promotion(board: chess.Board, src: int, drank: int, dfile: int) -> chess.Move:
    """Build a queen-like move from src + delta, auto-detecting queen promotion."""
    dst_rank = chess.square_rank(src) + drank
    dst_file = chess.square_file(src) + dfile
    if not (0 <= dst_rank < 8 and 0 <= dst_file < 8):
        raise ValueError("off-board destination")
    dst = chess.square(dst_file, dst_rank)
    promotion = None
    piece = board.piece_at(src)
    if piece is not None and piece.piece_type == chess.PAWN:
        if (piece.color == chess.WHITE and dst_rank == 7) or \
           (piece.color == chess.BLACK and dst_rank == 0):
            promotion = chess.QUEEN
    return chess.Move(src, dst, promotion=promotion)
pytest tests/test_move_encoding.py -v
git add src/alphazero/games/chess_move_encoding.py tests/test_move_encoding.py
git commit -m "$(cat <<'EOF'
Add knight moves to chess encoding (64 of 73 move types covered)

8 knight L-shapes occupy move_type indices [56, 64). Underpromotions
in [64, 73) come next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 4: Move encoding — underpromotions + bijective round-trip

Files: - Modify: src/alphazero/games/chess_move_encoding.py - Modify: tests/test_move_encoding.py

def test_underpromotion_round_trip_all_combinations():
    """Pawn on 7th rank: 3 promotion pieces × 3 file directions = 9 underpromotions."""
    # White pawn on e7; possible promotions: knight, bishop, rook (queen is auto via queen direction)
    # File directions: capture-left (d8), forward (e8), capture-right (f8)
    board = chess.Board("3rkr2/4P3/8/8/8/8/8/4K3 w - - 0 1")
    for piece in (chess.KNIGHT, chess.BISHOP, chess.ROOK):
        for dst_file in ("d", "e", "f"):
            try:
                move = chess.Move.from_uci(f"e7{dst_file}8{chess.piece_symbol(piece)}")
            except Exception:
                continue
            if move not in board.legal_moves:
                continue
            idx = move_to_index(board, move)
            back = index_to_move(board, idx)
            assert back == move, f"Underpromotion round-trip failed for {move.uci()}"


def test_bijective_every_legal_move_in_initial_position():
    """Every legal move in the starting position round-trips perfectly."""
    board = chess.Board()
    for move in board.legal_moves:
        idx = move_to_index(board, move)
        back = index_to_move(board, idx)
        assert back == move, f"Round-trip failed for {move.uci()}"


def test_bijective_after_random_play():
    """100 moves of random play; every legal move at every position round-trips."""
    import random
    rng = random.Random(42)
    board = chess.Board()
    for _ in range(100):
        if board.is_game_over():
            break
        for move in board.legal_moves:
            idx = move_to_index(board, move)
            back = index_to_move(board, idx)
            assert back == move, f"Round-trip failed for {move.uci()} at fen={board.fen()}"
        moves = list(board.legal_moves)
        board.push(rng.choice(moves))


def test_index_to_move_rejects_out_of_range():
    board = chess.Board()
    with pytest.raises(ValueError):
        index_to_move(board, -1)
    with pytest.raises(ValueError):
        index_to_move(board, ACTION_SIZE)
pytest tests/test_move_encoding.py -v

In src/alphazero/games/chess_move_encoding.py, add the underpromotion constants near KNIGHT_DELTAS:

# Underpromotion pieces (in this order): index 0=knight, 1=bishop, 2=rook
UNDERPROMO_PIECES: list[int] = [chess.KNIGHT, chess.BISHOP, chess.ROOK]
# Underpromotion file deltas (relative to forward direction of the moving pawn)
# index 0 = capture-left, 1 = forward (push), 2 = capture-right
UNDERPROMO_FILE_DELTAS: list[int] = [-1, 0, 1]

Replace move_to_index to handle underpromotions:

def move_to_index(board: chess.Board, move: chess.Move) -> int:
    src = move.from_square
    dst = move.to_square
    drank = chess.square_rank(dst) - chess.square_rank(src)
    dfile = chess.square_file(dst) - chess.square_file(src)

    # Underpromotion (knight, bishop, rook only — queen is encoded as queen move)
    if move.promotion is not None and move.promotion != chess.QUEEN:
        piece = board.piece_at(src)
        # Black pawns move "down" (drank=-1); flip drank to canonical "forward" for index
        forward_drank = drank if piece.color == chess.WHITE else -drank
        forward_dfile = dfile if piece.color == chess.WHITE else -dfile
        assert forward_drank == 1, f"Expected pawn promotion drank=1, got {forward_drank}"
        try:
            piece_idx = UNDERPROMO_PIECES.index(move.promotion)
        except ValueError:
            raise ValueError(f"Bad underpromotion piece: {move.promotion}")
        try:
            file_idx = UNDERPROMO_FILE_DELTAS.index(forward_dfile)
        except ValueError:
            raise ValueError(f"Bad underpromotion file delta: {forward_dfile}")
        move_type = 64 + piece_idx * 3 + file_idx
        return src * 73 + move_type

    # Queen-like (and queen-promotions)
    queen_type = _queen_move_to_type(drank, dfile)
    if queen_type is not None and move.promotion in (None, chess.QUEEN):
        return src * 73 + queen_type

    # Knight
    if (drank, dfile) in KNIGHT_DELTAS:
        knight_idx = KNIGHT_DELTAS.index((drank, dfile))
        return src * 73 + (56 + knight_idx)

    raise ValueError(f"Cannot encode move {move} (drank={drank}, dfile={dfile})")

Replace index_to_move to handle underpromotions:

def index_to_move(board: chess.Board, index: int) -> chess.Move:
    if not (0 <= index < ACTION_SIZE):
        raise ValueError(f"index {index} out of range [0, {ACTION_SIZE})")
    src, move_type = divmod(index, 73)

    if 0 <= move_type < 56:
        delta = _type_to_queen_move(move_type)
        drank, dfile = delta
        return _build_move_with_promotion(board, src, drank, dfile)

    if 56 <= move_type < 64:
        knight_idx = move_type - 56
        drank, dfile = KNIGHT_DELTAS[knight_idx]
        dst_rank = chess.square_rank(src) + drank
        dst_file = chess.square_file(src) + dfile
        if not (0 <= dst_rank < 8 and 0 <= dst_file < 8):
            raise ValueError(f"index {index} decodes off-board")
        dst = chess.square(dst_file, dst_rank)
        return chess.Move(src, dst)

    if 64 <= move_type < 73:
        underpromo = move_type - 64
        piece_idx, file_idx = divmod(underpromo, 3)
        promotion_piece = UNDERPROMO_PIECES[piece_idx]
        forward_dfile = UNDERPROMO_FILE_DELTAS[file_idx]
        # Determine pawn color from source square's piece
        piece = board.piece_at(src)
        if piece is None or piece.piece_type != chess.PAWN:
            raise ValueError(f"Underpromotion at square {src} but no pawn there")
        if piece.color == chess.WHITE:
            drank = 1
            dfile = forward_dfile
        else:
            drank = -1
            dfile = -forward_dfile  # flip for black pawn moving "down"
        dst_rank = chess.square_rank(src) + drank
        dst_file = chess.square_file(src) + dfile
        if not (0 <= dst_rank < 8 and 0 <= dst_file < 8):
            raise ValueError(f"index {index} decodes off-board")
        dst = chess.square(dst_file, dst_rank)
        return chess.Move(src, dst, promotion=promotion_piece)

    raise ValueError(f"Unreachable: move_type {move_type}")
pytest tests/test_move_encoding.py -v

Expected: 13 tests pass. The bijective tests cover starting position + 100 random plies.

git add src/alphazero/games/chess_move_encoding.py tests/test_move_encoding.py
git commit -m "$(cat <<'EOF'
Add underpromotion encoding + bijective round-trip property test

All 73 move types (56 queen + 8 knight + 9 underpromotion) now covered.
Property test exercises every legal move at every position over 100
random plies — confirms bijective invariant in practice, not just
hand-picked cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Files: - Create: src/alphazero/games/chess_game.py - Create: tests/test_game_chess.py

# tests/test_game_chess.py
import chess
import numpy as np
import pytest

from alphazero.games.chess_game import Chess


@pytest.fixture
def game():
    return Chess()


def test_input_shape_and_action_size(game):
    assert game.input_shape == (20, 8, 8)
    assert game.action_size == 4672


def test_initial_state_is_starting_position(game):
    s = game.initial_state()
    assert isinstance(s, chess.Board)
    assert s.fen() == chess.STARTING_FEN


def test_current_player_white_first(game):
    assert game.current_player(game.initial_state()) == 1


def test_apply_advances_state(game):
    s = game.initial_state()
    # e2-e4 = action index 12*73 + 1 = 877 (queen direction N, distance 2 from e2)
    from alphazero.games.chess_move_encoding import move_to_index
    move = chess.Move.from_uci("e2e4")
    idx = move_to_index(s, move)
    s_after = game.apply(s, idx)
    assert s_after.turn == chess.BLACK
    assert s.turn == chess.WHITE  # original not mutated
    assert s_after.piece_at(chess.E4) is not None
    assert s_after.piece_at(chess.E4).piece_type == chess.PAWN


def test_legal_actions_mask_initial_position_has_20_legal(game):
    """At the starting position, white has 20 legal moves (16 pawn + 4 knight)."""
    s = game.initial_state()
    mask = game.legal_actions_mask(s)
    assert mask.shape == (4672,)
    assert mask.dtype == bool
    assert mask.sum() == 20
pytest tests/test_game_chess.py -v
"""Chess: AlphaZero-on-chess Game implementation.

Wraps python-chess for rules; uses AlphaZero's 8×8×73 = 4672 action space.
State is a chess.Board (mutable; we copy + push to advance).
"""
from __future__ import annotations

import chess
import numpy as np

from .base import Game
from .chess_move_encoding import ACTION_SIZE, index_to_move, move_to_index

State = chess.Board

NUM_PLANES = 20


class Chess(Game):
    @property
    def input_shape(self) -> tuple[int, int, int]:
        return (NUM_PLANES, 8, 8)

    @property
    def action_size(self) -> int:
        return ACTION_SIZE

    def initial_state(self) -> State:
        return chess.Board()

    def current_player(self, state: State) -> int:
        return 1 if state.turn == chess.WHITE else -1

    def legal_actions_mask(self, state: State) -> np.ndarray:
        mask = np.zeros(ACTION_SIZE, dtype=bool)
        for move in state.legal_moves:
            try:
                idx = move_to_index(state, move)
            except ValueError:
                continue  # shouldn't happen for any legal move; safe fallback
            mask[idx] = True
        return mask

    def apply(self, state: State, action: int) -> State:
        move = index_to_move(state, action)
        new_state = state.copy()
        new_state.push(move)
        return new_state

    # Filled in by later tasks
    def terminal_value(self, state: State) -> float | None:
        raise NotImplementedError  # Task 6

    def encode(self, state: State) -> np.ndarray:
        raise NotImplementedError  # Task 7

    def canonical_state(self, state: State) -> State:
        raise NotImplementedError  # Task 8
pytest tests/test_game_chess.py -v
git add src/alphazero/games/chess_game.py tests/test_game_chess.py
git commit -m "$(cat <<'EOF'
Add Chess(Game) core: state, current_player, apply, legal_actions_mask

Wraps python-chess for rules. terminal_value, encode, and canonical_state
are stubs filled in by Tasks 6-8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 6: Chess — terminal_value (all draw types)

Files: - Modify: src/alphazero/games/chess_game.py - Modify: tests/test_game_chess.py

def test_terminal_value_initial_in_progress(game):
    assert game.terminal_value(game.initial_state()) is None


def test_terminal_value_white_checkmate_returns_minus_one_for_black(game):
    """Fool's mate: white plays f3, g4; black plays Qh4# winning.
    The terminal position has white to move (and being checkmated)."""
    s = chess.Board("rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3")
    assert s.is_checkmate()
    # current player (white) is the one being mated
    assert game.terminal_value(s) == -1.0


def test_terminal_value_stalemate_returns_zero(game):
    """Classic stalemate position."""
    s = chess.Board("7k/5Q2/6K1/8/8/8/8/8 b - - 0 1")
    assert s.is_stalemate()
    assert game.terminal_value(s) == 0.0


def test_terminal_value_insufficient_material_returns_zero(game):
    """King vs King: insufficient material, must be a draw."""
    s = chess.Board("8/8/8/4k3/8/8/8/4K3 w - - 0 1")
    assert s.is_insufficient_material()
    assert game.terminal_value(s) == 0.0


def test_terminal_value_50_move_rule_returns_zero(game):
    """Halfmove clock at 100 (50 full moves without capture/pawn move) is a draw."""
    s = chess.Board("4k3/8/8/8/8/8/8/4K3 w - - 100 75")
    # Note: chess.Board considers this a draw under can_claim_draw
    assert game.terminal_value(s) == 0.0
    def terminal_value(self, state: State) -> float | None:
        if not state.is_game_over(claim_draw=True):
            return None
        outcome = state.outcome(claim_draw=True)
        if outcome is None:
            # is_game_over said True but no outcome: edge case, treat as draw
            return 0.0
        if outcome.winner is None:
            return 0.0  # draw (stalemate, 3-fold rep, 50-move, insufficient material)
        # winner is chess.WHITE or chess.BLACK; compare against current player to move
        winner_is_current = (outcome.winner == state.turn)
        return 1.0 if winner_is_current else -1.0
pytest tests/test_game_chess.py -v
git add src/alphazero/games/chess_game.py tests/test_game_chess.py
git commit -m "$(cat <<'EOF'
Add Chess.terminal_value (mate, stalemate, insufficient material, 50-move)

Uses python-chess's outcome(claim_draw=True) which handles all draw
conditions: 3-fold repetition, 50-move rule, insufficient material,
stalemate. Returns from current-player POV per Game ABC convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 7: Chess — encode (20-plane input)

Files: - Modify: src/alphazero/games/chess_game.py - Modify: tests/test_game_chess.py

def test_encode_shape_and_dtype(game):
    s = game.initial_state()
    enc = game.encode(s)
    assert enc.shape == (20, 8, 8)
    assert enc.dtype == np.float32


def test_encode_initial_position_my_pawns_on_rank_2(game):
    """White to move at start: my pawns (plane 0) on rank 2 (board row index 1)."""
    s = game.initial_state()
    enc = game.encode(s)
    # Plane 0 = my pawns. python-chess square_rank(): a2=8 → rank 1 (0-indexed)
    # We pack squares into (8, 8) as enc[plane][rank][file]
    assert (enc[0, 1, :] == 1).all()           # all 8 files of rank 2 have my pawns
    assert (enc[0, 0, :] == 0).all()           # rank 1 has no my-pawns


def test_encode_initial_position_opp_pawns_on_rank_7(game):
    s = game.initial_state()
    enc = game.encode(s)
    # Plane 6 = opponent's pawns. Rank 7 = board index 6.
    assert (enc[6, 6, :] == 1).all()


def test_encode_ones_plane_all_ones(game):
    s = game.initial_state()
    enc = game.encode(s)
    # Plane 12 = ones plane
    assert (enc[12] == 1).all()


def test_encode_castling_rights_initial_all_one(game):
    """At the starting position, all 4 castling rights are present."""
    s = game.initial_state()
    enc = game.encode(s)
    # Planes 13-16: my-K, my-Q, opp-K, opp-Q castling
    for plane in (13, 14, 15, 16):
        assert (enc[plane] == 1).all()


def test_encode_castling_rights_after_white_kingside_zero(game):
    """After Ke1-e2, white has lost both castling rights."""
    s = game.initial_state()
    s.push(chess.Move.from_uci("e2e3"))
    s.push(chess.Move.from_uci("e7e6"))
    s.push(chess.Move.from_uci("e1e2"))
    s.push(chess.Move.from_uci("e8e7"))
    # Now back to white; current player's K and Q castling are both lost
    enc = game.encode(s)
    assert (enc[13] == 0).all()
    assert (enc[14] == 0).all()

Replace the encode stub in src/alphazero/games/chess_game.py:

    def encode(self, state: State) -> np.ndarray:
        """Encode the board as a 20-plane (channels, 8, 8) float32 tensor.

        Planes:
            0-5:   my pieces (pawn, knight, bishop, rook, queen, king)
            6-11:  opp pieces (same order)
            12:    ones plane (constant 1)
            13-16: castling rights (my-K, my-Q, opp-K, opp-Q)
            17:    en-passant target square (1-hot at the en-passant square, if any)
            18:    halfmove clock for 50-move rule (normalized: clock / 100)
            19:    fullmove number normalized: fullmove_number / 100
        """
        enc = np.zeros((NUM_PLANES, 8, 8), dtype=np.float32)
        my_color = state.turn  # current player
        opp_color = not my_color
        piece_types = [chess.PAWN, chess.KNIGHT, chess.BISHOP,
                       chess.ROOK, chess.QUEEN, chess.KING]

        # Planes 0-5: my pieces
        for plane_idx, pt in enumerate(piece_types):
            for sq in state.pieces(pt, my_color):
                r, f = chess.square_rank(sq), chess.square_file(sq)
                enc[plane_idx, r, f] = 1.0
        # Planes 6-11: opp pieces
        for plane_idx, pt in enumerate(piece_types):
            for sq in state.pieces(pt, opp_color):
                r, f = chess.square_rank(sq), chess.square_file(sq)
                enc[6 + plane_idx, r, f] = 1.0
        # Plane 12: ones
        enc[12, :, :] = 1.0
        # Planes 13-16: castling rights
        if state.has_kingside_castling_rights(my_color):
            enc[13, :, :] = 1.0
        if state.has_queenside_castling_rights(my_color):
            enc[14, :, :] = 1.0
        if state.has_kingside_castling_rights(opp_color):
            enc[15, :, :] = 1.0
        if state.has_queenside_castling_rights(opp_color):
            enc[16, :, :] = 1.0
        # Plane 17: en passant target
        if state.ep_square is not None:
            r, f = chess.square_rank(state.ep_square), chess.square_file(state.ep_square)
            enc[17, r, f] = 1.0
        # Plane 18: halfmove clock normalized
        enc[18, :, :] = min(state.halfmove_clock / 100.0, 1.0)
        # Plane 19: fullmove number normalized
        enc[19, :, :] = min(state.fullmove_number / 100.0, 1.0)
        return enc
pytest tests/test_game_chess.py -v
git add src/alphazero/games/chess_game.py tests/test_game_chess.py
git commit -m "$(cat <<'EOF'
Add Chess.encode (20-plane input)

12 piece planes (6 my + 6 opp) + ones + 4 castling + en passant +
halfmove clock + fullmove number. NO history planes (paper uses 8 prev
positions × 14 planes = 112 more). Threefold repetition handled by
terminal_value via python-chess; history can be added in Sub-project 4
if needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 8: Chess — canonical_state (mirror) + symmetries (identity)

Files: - Modify: src/alphazero/games/chess_game.py - Modify: tests/test_game_chess.py

def test_canonical_state_identity_when_white_to_move(game):
    s = game.initial_state()
    canon = game.canonical_state(s)
    assert canon.fen() == s.fen()


def test_canonical_state_mirrors_when_black_to_move(game):
    """After white plays e2-e4, it's black to move. Canonical state should be the
    board mirrored (white pawn at e4 → black pawn at e5)."""
    s = game.initial_state()
    s.push(chess.Move.from_uci("e2e4"))
    assert s.turn == chess.BLACK
    canon = game.canonical_state(s)
    # In the mirrored board, the previously-white pawn at e4 is now a (canonical-)
    # "white" pawn at e5 (mirrored row 4 → row 5).
    assert canon.turn == chess.WHITE
    assert canon.piece_at(chess.E5) is not None
    assert canon.piece_at(chess.E5).color == chess.WHITE


def test_symmetries_identity_only(game):
    """Chess has no symmetries."""
    s = game.initial_state()
    enc = game.encode(s)
    policy = np.full(4672, 1.0 / 4672, dtype=np.float32)
    syms = game.symmetries(enc, policy)
    assert len(syms) == 1
    np.testing.assert_array_equal(syms[0][0], enc)
    np.testing.assert_array_equal(syms[0][1], policy)

Replace the canonical_state stub in src/alphazero/games/chess_game.py:

    def canonical_state(self, state: State) -> State:
        """Return state from the current player's POV.

        For chess: when Black is to move, mirror the board so the network
        always sees the to-move player as "White at the bottom." python-chess's
        Board.mirror() handles castling rights and en passant correctly.
        """
        if state.turn == chess.BLACK:
            return state.mirror()
        return state

The Game ABC already defines symmetries as lambda encoded, policy: [(encoded, policy)] by default, so we don't need to override it for chess. But override anyway for explicitness:

    def symmetries(self, encoded, policy):
        """Chess has no symmetries (castling rights and pawn direction
        break left-right mirror; player asymmetry breaks rotation)."""
        return [(encoded, policy)]
pytest tests/test_game_chess.py -v
git add src/alphazero/games/chess_game.py tests/test_game_chess.py
git commit -m "$(cat <<'EOF'
Add Chess.canonical_state (mirror when Black to move) + explicit symmetries=identity

Chess has no exploitable symmetries (castling rights and pawn direction
break left-right; player asymmetry breaks rotation). The canonical_state
mirror normalizes "Black to move" boards into a "White at bottom" view,
which halves the effective state space the NN must model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 9: StockfishOpponent

Files: - Create: src/alphazero/opponents/stockfish.py - Create: tests/test_chess_minimax.py

# tests/test_chess_minimax.py
import chess
import pytest

from alphazero.games.chess_game import Chess
from alphazero.opponents.stockfish import StockfishOpponent


@pytest.fixture
def game():
    return Chess()


@pytest.fixture
def opp():
    o = StockfishOpponent(elo=1500, time_per_move=0.1)
    yield o
    o.close()


def test_stockfish_plays_legal_move(game, opp):
    s = game.initial_state()
    action = opp(game, s)
    mask = game.legal_actions_mask(s)
    assert mask[action], f"Stockfish proposed illegal action {action}"


def test_stockfish_makes_different_moves_at_different_elos(game):
    """Sanity: Stockfish at 1320 vs 2000 should disagree at least sometimes."""
    # Use a position with multiple reasonable moves
    s = chess.Board("rnbqkb1r/pppppppp/5n2/8/2P5/8/PP1PPPPP/RNBQKBNR w KQkq - 1 2")
    weak = StockfishOpponent(elo=1320, time_per_move=0.1)
    strong = StockfishOpponent(elo=2400, time_per_move=0.1)
    try:
        # Different ELOs may produce different moves; not guaranteed each call but
        # likely over several positions. Just verify both return valid actions.
        weak_action = weak(game, s)
        strong_action = strong(game, s)
        mask = game.legal_actions_mask(s)
        assert mask[weak_action]
        assert mask[strong_action]
    finally:
        weak.close()
        strong.close()


def test_stockfish_clean_close(game):
    """Calling close() twice should not crash."""
    o = StockfishOpponent(elo=1500, time_per_move=0.1)
    o(game, game.initial_state())  # use it
    o.close()
    o.close()  # second close is no-op
pytest tests/test_chess_minimax.py -v
"""StockfishOpponent — UCI wrapper for the Stockfish engine, used as the
chess eval baseline at a configured ELO.

Requires 'stockfish' binary on PATH. See bin/setup.sh for install instructions.
"""
from __future__ import annotations

import chess
import chess.engine

from ..games.chess_game import Chess
from ..games.chess_move_encoding import move_to_index


class StockfishOpponent:
    def __init__(
        self,
        elo: int = 1500,
        time_per_move: float = 0.5,
        stockfish_path: str = "stockfish",
    ):
        self.engine = chess.engine.SimpleEngine.popen_uci(stockfish_path)
        self.engine.configure({
            "UCI_LimitStrength": True,
            "UCI_Elo": elo,
        })
        self.time_per_move = time_per_move
        self._closed = False

    def __call__(self, game: Chess, state: chess.Board) -> int:
        result = self.engine.play(state, chess.engine.Limit(time=self.time_per_move))
        return move_to_index(state, result.move)

    def reset(self) -> None:
        # Stockfish handles fresh games internally; no per-game setup needed.
        pass

    def close(self) -> None:
        if self._closed:
            return
        try:
            self.engine.quit()
        except (chess.engine.EngineTerminatedError, BrokenPipeError):
            pass
        self._closed = True

    def __del__(self):
        try:
            self.close()
        except Exception:
            pass
pytest tests/test_chess_minimax.py -v
git add src/alphazero/opponents/stockfish.py tests/test_chess_minimax.py
git commit -m "$(cat <<'EOF'
Add StockfishOpponent (UCI wrapper, ELO-configurable)

Used as the chess eval baseline. Configure UCI_LimitStrength + UCI_Elo
to play at a target rating (1500 for the SP3 kill criterion).

Requires stockfish binary on PATH; see bin/setup.sh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 10: Verify chess works in Trainer (serial fallback)

Before building parallel self-play, confirm the chess code works with the existing serial Trainer. This validates Tasks 5-9 end-to-end before adding the complexity of parallel execution.

Files: - No new files; just a smoke test

Run this from the repo root (a brief test, not a full training):

python3 -c "
from alphazero.config import TrainingConfig
from alphazero.games.chess_game import Chess
from alphazero.trainer import Trainer
from dataclasses import replace

# Tiny config so this finishes in ~1-2 minutes
cfg = TrainingConfig(
    n_blocks=1, n_channels=4,
    num_simulations=5, num_iterations=1,
    games_per_iteration=2, batch_size=4,
    min_buffer_size=4, replay_buffer_capacity=100,
    training_steps_per_iteration=2,
    temperature_threshold=3,
    eval_interval=999,  # skip eval
    device='cpu', checkpoint_dir='/tmp/chess_smoke',
)
t = Trainer(Chess(), cfg)
t.run(verbose=False)
print('✓ Chess serial training smoke test passed')
"

Expected: prints ✓ Chess serial training smoke test passed. Wall time: 1-3 minutes on M4 Pro.

If this fails, the error tells you where the Chess wiring went wrong — fix before continuing. Most likely failure modes: - Tensor dtype mismatch in encode → wrong return type - Illegal-move mask wrong → MCTS picks invalid actions - Move encoding non-bijective → trained policy doesn't decode back to legal moves


Task 11: parallel_selfplay — NN-server skeleton

Now the big new module. We break it into three tasks: server (this one) + workers (Task 12) + orchestrator (Task 13). Plus stress tests in Task 14.

Files: - Create: src/alphazero/parallel_selfplay.py - Create: tests/test_parallel_selfplay.py

# tests/test_parallel_selfplay.py
import numpy as np
import pytest
import torch
import torch.multiprocessing as mp

from alphazero.games.tictactoe import TicTacToe   # smaller game for fast tests
from alphazero.network import AlphaZeroNet
from alphazero.parallel_selfplay import (
    InferenceRequest,
    InferenceResponse,
    nn_server_loop,
)


def test_nn_server_responds_to_a_single_request():
    """Spin up an NN-server in a thread, send 1 request, get 1 response."""
    request_q = mp.Queue()
    response_qs = {0: mp.Queue()}   # one worker (id=0)
    shutdown = mp.Event()

    net = AlphaZeroNet(input_shape=(3, 3, 3), action_size=9, n_blocks=1, n_channels=4)
    net.eval()
    state_dict = {k: v.clone() for k, v in net.state_dict().items()}

    # Run server in a separate thread for testing (avoid spawn overhead)
    import threading
    t = threading.Thread(
        target=nn_server_loop,
        args=(state_dict, (3, 3, 3), 9, 1, 4, request_q, response_qs, shutdown),
        kwargs={"batch_size": 1, "wait_timeout_ms": 5, "device": "cpu"},
    )
    t.start()

    encoded = np.zeros((3, 3, 3), dtype=np.float32)
    request_q.put(InferenceRequest(worker_id=0, request_id=42, encoded=encoded))
    response: InferenceResponse = response_qs[0].get(timeout=5.0)

    assert response.request_id == 42
    assert response.priors.shape == (9,)
    assert isinstance(response.value, float)

    shutdown.set()
    t.join(timeout=5.0)
pytest tests/test_parallel_selfplay.py -v

Expected: ImportError on alphazero.parallel_selfplay.

"""Parallel self-play with central NN-server batching inference.

Architecture:
    N worker processes each run MCTS + Game logic on different self-play games.
    1 central NN-server process owns the GPU and batches inference requests
    from workers. Workers send (encoded_state, request_id) to a shared request
    queue; the server pulls batches up to `batch_size`, runs one forward pass,
    and routes (priors, value) back to each worker's reply queue.

This decouples Python-bound MCTS work (parallelized across CPU cores) from
GPU-bound NN inference (batched for utilization). Required for chess at
scale — serial self-play is wall-time infeasible.
"""
from __future__ import annotations

import time
from dataclasses import dataclass
from typing import Any

import numpy as np
import torch
import torch.multiprocessing as mp

from .network import AlphaZeroNet


@dataclass
class InferenceRequest:
    worker_id: int
    request_id: int
    encoded: np.ndarray   # shape: (C, H, W); the canonical encoded state


@dataclass
class InferenceResponse:
    request_id: int
    priors: np.ndarray    # shape: (action_size,)
    value: float          # scalar


def nn_server_loop(
    state_dict: dict,
    input_shape: tuple[int, int, int],
    action_size: int,
    n_blocks: int,
    n_channels: int,
    request_q: mp.Queue,
    response_qs: dict[int, mp.Queue],
    shutdown_event: Any,                    # mp.Event or threading.Event
    *,
    batch_size: int = 64,
    wait_timeout_ms: int = 5,
    device: str = "cpu",
) -> None:
    """Run as a thread or process: serve batched inference until shutdown_event is set.

    Workers send InferenceRequest objects to request_q. The server collects
    up to `batch_size` requests OR waits `wait_timeout_ms`, runs one batched
    forward pass, and routes InferenceResponse back via response_qs[worker_id].
    """
    dev = torch.device(device)
    net = AlphaZeroNet(
        input_shape=input_shape,
        action_size=action_size,
        n_blocks=n_blocks,
        n_channels=n_channels,
    )
    net.load_state_dict(state_dict)
    net.to(dev)
    net.eval()

    wait_timeout_s = wait_timeout_ms / 1000.0
    while not shutdown_event.is_set():
        batch: list[InferenceRequest] = []
        # Block briefly for first request; non-blocking for the rest
        try:
            first = request_q.get(timeout=wait_timeout_s)
            batch.append(first)
        except Exception:
            continue
        while len(batch) < batch_size:
            try:
                req = request_q.get_nowait()
                batch.append(req)
            except Exception:
                break

        # Stack and run
        x = torch.from_numpy(np.stack([r.encoded for r in batch])).to(dev)
        with torch.inference_mode():
            logits, values = net(x)
            priors = torch.softmax(logits, dim=-1).cpu().numpy()
            values = values.cpu().numpy()

        # Route back
        for i, req in enumerate(batch):
            response_qs[req.worker_id].put(
                InferenceResponse(
                    request_id=req.request_id,
                    priors=priors[i].astype(np.float32),
                    value=float(values[i]),
                )
            )
pytest tests/test_parallel_selfplay.py -v
git add src/alphazero/parallel_selfplay.py tests/test_parallel_selfplay.py
git commit -m "$(cat <<'EOF'
Add parallel_selfplay NN-server skeleton

Central NN-server that batches inference requests from worker processes.
Owns the GPU; workers submit encoded states via mp.Queue and block on
their reply queue. Server collects up to batch_size requests OR waits
wait_timeout_ms before running one batched forward pass.

Workers + orchestrator come in next tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 12: parallel_selfplay — worker process

Files: - Modify: src/alphazero/parallel_selfplay.py - Modify: tests/test_parallel_selfplay.py

def test_worker_plays_one_complete_game():
    """A worker process plays one self-play game and returns training tuples.

    Use TicTacToe for speed. The worker uses a remote eval_fn that sends
    requests to a server thread.
    """
    import threading
    from alphazero.parallel_selfplay import worker_play_one_game

    request_q = mp.Queue()
    response_qs = {0: mp.Queue()}
    shutdown = mp.Event()

    net = AlphaZeroNet(input_shape=(3, 3, 3), action_size=9, n_blocks=1, n_channels=4)
    net.eval()
    state_dict = {k: v.clone() for k, v in net.state_dict().items()}

    server_thread = threading.Thread(
        target=nn_server_loop,
        args=(state_dict, (3, 3, 3), 9, 1, 4, request_q, response_qs, shutdown),
        kwargs={"batch_size": 4, "wait_timeout_ms": 2, "device": "cpu"},
        daemon=True,
    )
    server_thread.start()

    examples = worker_play_one_game(
        worker_id=0,
        game_name="tictactoe",
        num_simulations=5,
        temperature_threshold=6,
        c_puct=1.5,
        dirichlet_alpha=1.0,
        dirichlet_weight=0.25,
        request_q=request_q,
        response_q=response_qs[0],
        augment=True,
    )

    shutdown.set()
    server_thread.join(timeout=2.0)

    assert len(examples) > 0
    for s, pi, z in examples:
        assert s.shape == (3, 3, 3)
        assert pi.shape == (9,)
        assert z in (-1.0, 0.0, 1.0)
# Game registry — extend as new games are added
def _make_game(game_name: str):
    if game_name == "tictactoe":
        from .games.tictactoe import TicTacToe
        return TicTacToe()
    if game_name == "connect4":
        from .games.connect4 import Connect4
        return Connect4()
    if game_name == "chess":
        from .games.chess_game import Chess
        return Chess()
    raise ValueError(f"Unknown game: {game_name}")


def worker_play_one_game(
    *,
    worker_id: int,
    game_name: str,
    num_simulations: int,
    temperature_threshold: int,
    c_puct: float,
    dirichlet_alpha: float,
    dirichlet_weight: float,
    request_q: mp.Queue,
    response_q: mp.Queue,
    augment: bool = True,
    request_id_offset: int = 0,
) -> list[tuple[np.ndarray, np.ndarray, float]]:
    """Run one self-play game using a remote NN-server for inference.

    Returns a list of (state, π, z) training tuples (symmetry-augmented if
    augment=True and the game has symmetries).
    """
    from .mcts import MCTS
    from .selfplay import _assign_z

    game = _make_game(game_name)
    request_counter = [request_id_offset]

    def eval_fn(encoded: np.ndarray) -> tuple[np.ndarray, float]:
        rid = request_counter[0]
        request_counter[0] += 1
        request_q.put(InferenceRequest(worker_id=worker_id, request_id=rid, encoded=encoded))
        # Wait for the response with our exact request_id (the server returns
        # ID-tagged responses; the queue is per-worker so we just pull next)
        response: InferenceResponse = response_q.get(timeout=60.0)
        assert response.request_id == rid, (
            f"Worker {worker_id} expected request_id {rid}, got {response.request_id}"
        )
        return response.priors, response.value

    mcts = MCTS(
        game, eval_fn,
        c_puct=c_puct,
        dirichlet_alpha=dirichlet_alpha,
        dirichlet_weight=dirichlet_weight,
    )

    state = game.initial_state()
    history: list[tuple[np.ndarray, np.ndarray, int]] = []
    move_idx = 0
    while game.terminal_value(state) is None:
        pi = mcts.search(state, num_simulations=num_simulations, add_root_noise=True)
        if move_idx < temperature_threshold:
            action = int(np.random.choice(len(pi), p=pi))
        else:
            action = int(np.argmax(pi))
        canon = game.canonical_state(state)
        encoded = game.encode(canon)
        history.append((encoded, pi.astype(np.float32), game.current_player(state)))
        state = game.apply(state, action)
        move_idx += 1

    z_per_ply = _assign_z(history, game.terminal_value(state), state, game)
    examples = []
    for (encoded, pi, _player), z in zip(history, z_per_ply):
        if augment:
            for sym_enc, sym_pi in game.symmetries(encoded, pi):
                examples.append((sym_enc.astype(np.float32), sym_pi.astype(np.float32), z))
        else:
            examples.append((encoded, pi, z))
    return examples
git add src/alphazero/parallel_selfplay.py tests/test_parallel_selfplay.py
git commit -m "$(cat <<'EOF'
Add parallel_selfplay worker_play_one_game

Worker runs MCTS + Game locally; sends encoded states to a remote
NN-server via mp.Queue and blocks on the reply. Returns training
tuples (s, π, z) just like the serial run_one_game.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 13: parallel_selfplay — orchestrator (run_parallel_self_play)

Files: - Modify: src/alphazero/parallel_selfplay.py - Modify: tests/test_parallel_selfplay.py

def test_run_parallel_self_play_returns_tuples():
    """End-to-end: spawn 2 workers, play 4 games of TTT, get all tuples back."""
    from alphazero.parallel_selfplay import run_parallel_self_play

    net = AlphaZeroNet(input_shape=(3, 3, 3), action_size=9, n_blocks=1, n_channels=4)
    state_dict = {k: v.clone() for k, v in net.state_dict().items()}

    examples = run_parallel_self_play(
        game_name="tictactoe",
        state_dict=state_dict,
        input_shape=(3, 3, 3),
        action_size=9,
        n_blocks=1,
        n_channels=4,
        num_games=4,
        num_workers=2,
        inference_batch_size=4,
        num_simulations=5,
        temperature_threshold=6,
        c_puct=1.5,
        dirichlet_alpha=1.0,
        dirichlet_weight=0.25,
        device="cpu",
    )
    assert len(examples) > 0
    for s, pi, z in examples:
        assert s.shape == (3, 3, 3)
        assert pi.shape == (9,)
        assert z in (-1.0, 0.0, 1.0)
def run_parallel_self_play(
    *,
    game_name: str,
    state_dict: dict,
    input_shape: tuple[int, int, int],
    action_size: int,
    n_blocks: int,
    n_channels: int,
    num_games: int,
    num_workers: int,
    inference_batch_size: int,
    num_simulations: int,
    temperature_threshold: int,
    c_puct: float,
    dirichlet_alpha: float,
    dirichlet_weight: float,
    device: str = "cpu",
) -> list[tuple[np.ndarray, np.ndarray, float]]:
    """Run num_games self-play games across num_workers processes.

    Spawns one NN-server thread (owns the model) and num_workers worker
    processes (each runs one game at a time until num_games are done).
    Workers report back via a result queue; orchestrator collects.

    Returns a flat list of (state, π, z) training tuples.
    """
    import threading
    import queue as queue_mod
    import multiprocessing as std_mp

    # Use spawn (safer with CUDA + cleaner than fork)
    ctx = mp.get_context("spawn")
    request_q = ctx.Queue()
    response_qs = {wid: ctx.Queue() for wid in range(num_workers)}
    result_q = ctx.Queue()
    work_q = ctx.Queue()   # holds "play one game" job tokens

    # Pre-load work queue with num_games jobs (just dummy tokens)
    for game_idx in range(num_games):
        work_q.put(game_idx)
    # Sentinel: each worker exits when it pulls one
    for _ in range(num_workers):
        work_q.put(None)

    shutdown_event = ctx.Event()

    # Start the NN-server in a thread (in this process — server uses GPU)
    server_thread = threading.Thread(
        target=nn_server_loop,
        args=(state_dict, input_shape, action_size, n_blocks, n_channels,
              request_q, response_qs, shutdown_event),
        kwargs={"batch_size": inference_batch_size,
                "wait_timeout_ms": 5, "device": device},
        daemon=True,
    )
    server_thread.start()

    # Start workers
    workers = []
    for wid in range(num_workers):
        p = ctx.Process(
            target=_worker_loop,
            args=(wid, game_name, num_simulations, temperature_threshold,
                  c_puct, dirichlet_alpha, dirichlet_weight,
                  work_q, request_q, response_qs[wid], result_q),
        )
        p.start()
        workers.append(p)

    # Collect num_games batches of training tuples
    all_examples: list[tuple[np.ndarray, np.ndarray, float]] = []
    games_collected = 0
    while games_collected < num_games:
        batch = result_q.get(timeout=600.0)
        all_examples.extend(batch)
        games_collected += 1

    # Shutdown
    shutdown_event.set()
    for p in workers:
        p.join(timeout=10.0)
        if p.is_alive():
            p.terminate()
    server_thread.join(timeout=5.0)

    return all_examples


def _worker_loop(
    worker_id: int,
    game_name: str,
    num_simulations: int,
    temperature_threshold: int,
    c_puct: float,
    dirichlet_alpha: float,
    dirichlet_weight: float,
    work_q: mp.Queue,
    request_q: mp.Queue,
    response_q: mp.Queue,
    result_q: mp.Queue,
) -> None:
    """Worker entry point. Pulls 'play a game' tokens from work_q, runs one
    game using worker_play_one_game, pushes results back via result_q. Exits
    when it pulls a None sentinel."""
    request_id_offset = worker_id * 1_000_000  # avoid request_id collisions across workers
    while True:
        token = work_q.get()
        if token is None:
            break
        examples = worker_play_one_game(
            worker_id=worker_id,
            game_name=game_name,
            num_simulations=num_simulations,
            temperature_threshold=temperature_threshold,
            c_puct=c_puct,
            dirichlet_alpha=dirichlet_alpha,
            dirichlet_weight=dirichlet_weight,
            request_q=request_q,
            response_q=response_q,
            augment=True,
            request_id_offset=request_id_offset,
        )
        request_id_offset += 100_000   # bump for next game from this worker
        result_q.put(examples)
pytest tests/test_parallel_selfplay.py -v

This may take 30-90 seconds because each test spawns workers.

git add src/alphazero/parallel_selfplay.py tests/test_parallel_selfplay.py
git commit -m "$(cat <<'EOF'
Add run_parallel_self_play orchestrator

Spawns NN-server thread + N worker processes; distributes num_games
via a work queue; collects training tuples. Uses 'spawn' start method
(CUDA-safe). Clean shutdown via sentinels + Event.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 14: parallel_selfplay — invariant tests

Files: - Create: tests/test_invariants_chess.py

# tests/test_invariants_chess.py
"""Invariant tests for chess-specific behaviors.

Inherits the 6 framework invariants from tests/test_invariants.py
(those operate on TicTacToe but cover the framework code which is
unchanged for chess). Adds 4 chess-specific guards.
"""
from __future__ import annotations

import chess
import numpy as np
import pytest


def test_invariant_7_move_encoding_bijective_in_initial_position():
    """Round-trip: every legal move at the starting position encodes/decodes correctly."""
    from alphazero.games.chess_move_encoding import move_to_index, index_to_move
    board = chess.Board()
    for move in board.legal_moves:
        idx = move_to_index(board, move)
        back = index_to_move(board, idx)
        assert back == move, f"Round-trip failed for {move.uci()}"


def test_invariant_7_move_encoding_bijective_after_random_play():
    """Round-trip holds after 100 plies of random play."""
    import random
    from alphazero.games.chess_move_encoding import move_to_index, index_to_move
    rng = random.Random(42)
    board = chess.Board()
    for _ in range(100):
        if board.is_game_over():
            break
        for move in board.legal_moves:
            idx = move_to_index(board, move)
            back = index_to_move(board, idx)
            assert back == move, f"Round-trip failed for {move.uci()} at {board.fen()}"
        moves = list(board.legal_moves)
        board.push(rng.choice(moves))


def test_invariant_8_workers_dont_share_state():
    """Run 2 workers playing 4 games. Confirm independence of game state objects."""
    import torch.multiprocessing as mp
    from alphazero.network import AlphaZeroNet
    from alphazero.parallel_selfplay import run_parallel_self_play

    net = AlphaZeroNet(input_shape=(3, 3, 3), action_size=9, n_blocks=1, n_channels=4)
    state_dict = {k: v.clone() for k, v in net.state_dict().items()}

    examples = run_parallel_self_play(
        game_name="tictactoe",
        state_dict=state_dict,
        input_shape=(3, 3, 3),
        action_size=9,
        n_blocks=1, n_channels=4,
        num_games=4, num_workers=2, inference_batch_size=4,
        num_simulations=5, temperature_threshold=6,
        c_puct=1.5, dirichlet_alpha=1.0, dirichlet_weight=0.25,
        device="cpu",
    )
    # Sanity: each game produced reasonable training tuples
    assert len(examples) > 4   # at least 1 tuple per game
    # Sanity: outcomes span all 3 possible z values OR show a sensible distribution
    z_values = {e[2] for e in examples}
    assert z_values.issubset({-1.0, 0.0, 1.0})


def test_invariant_9_nn_server_preserves_batch_ordering():
    """Send 4 requests with different request_ids; verify responses come back
    paired with the correct ID (this is the bug surface in the NN-server)."""
    import threading
    import torch.multiprocessing as mp
    from alphazero.network import AlphaZeroNet
    from alphazero.parallel_selfplay import (
        InferenceRequest, InferenceResponse, nn_server_loop,
    )

    request_q = mp.Queue()
    response_qs = {0: mp.Queue(), 1: mp.Queue()}
    shutdown = mp.Event()

    net = AlphaZeroNet(input_shape=(3, 3, 3), action_size=9, n_blocks=1, n_channels=4)
    state_dict = {k: v.clone() for k, v in net.state_dict().items()}

    server_thread = threading.Thread(
        target=nn_server_loop,
        args=(state_dict, (3, 3, 3), 9, 1, 4, request_q, response_qs, shutdown),
        kwargs={"batch_size": 4, "wait_timeout_ms": 5, "device": "cpu"},
        daemon=True,
    )
    server_thread.start()

    # 4 distinct requests, 2 per worker
    requests = [
        InferenceRequest(worker_id=0, request_id=100, encoded=np.zeros((3, 3, 3), dtype=np.float32)),
        InferenceRequest(worker_id=1, request_id=200, encoded=np.zeros((3, 3, 3), dtype=np.float32)),
        InferenceRequest(worker_id=0, request_id=101, encoded=np.zeros((3, 3, 3), dtype=np.float32)),
        InferenceRequest(worker_id=1, request_id=201, encoded=np.zeros((3, 3, 3), dtype=np.float32)),
    ]
    for r in requests:
        request_q.put(r)

    # Collect from each reply queue. Worker 0 should get IDs 100 + 101; worker 1 → 200 + 201.
    w0_ids = sorted([response_qs[0].get(timeout=5.0).request_id for _ in range(2)])
    w1_ids = sorted([response_qs[1].get(timeout=5.0).request_id for _ in range(2)])

    shutdown.set()
    server_thread.join(timeout=2.0)

    assert w0_ids == [100, 101]
    assert w1_ids == [200, 201]


def test_invariant_10_stockfish_clean_shutdown():
    """Open and close StockfishOpponent multiple times; no zombies left."""
    from alphazero.opponents.stockfish import StockfishOpponent

    for _ in range(3):
        opp = StockfishOpponent(elo=1500, time_per_move=0.05)
        opp.close()
    # If clean_shutdown were broken, after-close calls would block or crash
    opp = StockfishOpponent(elo=1500, time_per_move=0.05)
    opp.close()
    opp.close()  # second close is no-op
pytest tests/test_invariants_chess.py -v

Expected: 5 tests pass. (4 invariants but #7 has two tests — initial + random.)

git add tests/test_invariants_chess.py
git commit -m "$(cat <<'EOF'
Add chess-specific invariant tests (#7–#10)

Inv 7: move encoding is bijective (initial + random play)
Inv 8: parallel workers don't share state
Inv 9: NN-server preserves batch ordering across worker reply queues
Inv 10: Stockfish opponent clean shutdown (no zombies)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 15: Trainer — dispatch parallel self-play when num_workers > 1

Files: - Modify: src/alphazero/config.py - Modify: src/alphazero/trainer.py - Modify: tests/conftest.py - Modify: tests/test_trainer.py

Edit src/alphazero/config.py and add to the TrainingConfig dataclass (just after num_iterations):

    # NEW: parallel self-play (scale-invariant knobs)
    num_workers: int = 1                 # 1 = serial fallback (existing behavior)
    inference_batch_size: int = 64       # NN-server batches up to N states per forward

Edit tests/conftest.py's tiny_config fixture, add to the TrainingConfig(...) call:

        num_workers=1,
        inference_batch_size=4,

In tests/test_trainer.py:

def test_trainer_dispatches_parallel_when_num_workers_gt_1(tiny_config, tmp_path, monkeypatch):
    """When num_workers > 1, Trainer.run uses parallel_selfplay instead of run_one_game."""
    from dataclasses import replace
    monkeypatch.chdir(tmp_path)
    cfg = replace(tiny_config, num_workers=2, games_per_iteration=4,
                  inference_batch_size=4, num_iterations=1, eval_interval=999)
    trainer = Trainer(TicTacToe(), cfg)
    trainer.run(verbose=False)
    # If we got here, the parallel path ran. Verify checkpoint exists.
    assert (tmp_path / cfg.checkpoint_dir / "iter_0001.pt").exists()
pytest tests/test_trainer.py::test_trainer_dispatches_parallel_when_num_workers_gt_1 -v

In src/alphazero/trainer.py, find the _run_self_play_iteration method. Replace its body:

    def _run_self_play_iteration(self, verbose: bool = True) -> None:
        """Generate games_per_iteration games using best_net; push to buffer.

        When config.num_workers > 1, dispatches to parallel_selfplay. Otherwise
        falls back to serial run_one_game.
        """
        if self.config.num_workers > 1:
            self._run_parallel_self_play_iteration(verbose=verbose)
            return
        # Serial fallback (existing behavior, unchanged)
        eval_fn = self._make_eval_fn(self.best_net)
        iterator = range(self.config.games_per_iteration)
        if verbose:
            iterator = tqdm(
                iterator,
                desc=f"  self-play (iter {self.iteration})",
                leave=False,
            )
        for _ in iterator:
            examples = run_one_game(
                self.game, eval_fn,
                num_simulations=self.config.num_simulations,
                temperature_threshold=self.config.temperature_threshold,
                c_puct=self.config.c_puct,
                dirichlet_alpha=self.config.dirichlet_alpha,
                dirichlet_weight=self.config.dirichlet_weight,
                augment=True,
            )
            self.replay_buffer.add(examples)

    def _run_parallel_self_play_iteration(self, verbose: bool = True) -> None:
        """Use parallel_selfplay with N workers + central NN-server."""
        from .parallel_selfplay import run_parallel_self_play

        # game_name registry (the parallel module needs a string to import the game)
        game_class_to_name = {
            "TicTacToe": "tictactoe",
            "Connect4": "connect4",
            "Chess": "chess",
        }
        game_name = game_class_to_name.get(type(self.game).__name__)
        if game_name is None:
            raise ValueError(
                f"Parallel self-play not configured for game {type(self.game).__name__}"
            )

        if verbose:
            print(
                f"  parallel self-play (iter {self.iteration}): "
                f"{self.config.num_workers} workers × "
                f"{self.config.games_per_iteration} games",
                flush=True,
            )

        examples = run_parallel_self_play(
            game_name=game_name,
            state_dict={k: v.cpu() for k, v in self.best_net.state_dict().items()},
            input_shape=self.game.input_shape,
            action_size=self.game.action_size,
            n_blocks=self.config.n_blocks,
            n_channels=self.config.n_channels,
            num_games=self.config.games_per_iteration,
            num_workers=self.config.num_workers,
            inference_batch_size=self.config.inference_batch_size,
            num_simulations=self.config.num_simulations,
            temperature_threshold=self.config.temperature_threshold,
            c_puct=self.config.c_puct,
            dirichlet_alpha=self.config.dirichlet_alpha,
            dirichlet_weight=self.config.dirichlet_weight,
            device=str(self.device),
        )
        self.replay_buffer.add(examples)
pytest tests/test_trainer.py -v
git add src/alphazero/config.py src/alphazero/trainer.py tests/conftest.py tests/test_trainer.py
git commit -m "$(cat <<'EOF'
Trainer: dispatch parallel_selfplay when num_workers > 1

Adds num_workers and inference_batch_size to TrainingConfig. When
num_workers > 1, Trainer._run_self_play_iteration delegates to
run_parallel_self_play; otherwise the existing serial path runs.

This is the scale-invariance hinge: same code, more workers = more
parallel game-playing throughput. No refactoring needed to push to
Sub-project 4's higher target.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 16: CLI — --game chess dispatch + config

Files: - Modify: src/alphazero/cli.py - Create: configs/chess.toml - Modify: tests/test_cli.py

In src/alphazero/cli.py, near the top where TTT and Connect4 are imported, add:

from .games.chess_game import Chess

Update the GAMES dict:

GAMES = {
    "tictactoe": TicTacToe,
    "connect4": Connect4,
    "chess": Chess,
}

Locate the eval-opponent construction in _cmd_train. Extend the if-chain:

    # Build the per-game eval opponent.
    eval_opponent = None
    if args.game == "connect4":
        from .opponents.connect4_minimax import Connect4MinimaxOpponent
        eval_opponent = Connect4MinimaxOpponent(depth=8)
    elif args.game == "chess":
        from .opponents.stockfish import StockfishOpponent
        eval_opponent = StockfishOpponent(elo=1500, time_per_move=0.5)
    # For tictactoe, leave eval_opponent=None.

Do the same in _cmd_eval:

    if args.game == "tictactoe":
        def opponent(_g, state):
            return solve_tictactoe_action(state)
        label = "perfect solver"
    elif args.game == "connect4":
        from .opponents.connect4_minimax import Connect4MinimaxOpponent
        opponent = Connect4MinimaxOpponent(depth=8)
        label = "minimax-depth-8"
    elif args.game == "chess":
        from .opponents.stockfish import StockfishOpponent
        opponent = StockfishOpponent(elo=1500, time_per_move=0.5)
        label = "Stockfish ELO=1500"
    else:
        raise ValueError(f"No eval opponent defined for game={args.game}")

In tests/test_cli.py:

def test_cmd_train_wires_chess_eval_opponent(tmp_path, monkeypatch):
    """Regression test: --game chess must construct a StockfishOpponent."""
    from alphazero.opponents.stockfish import StockfishOpponent
    monkeypatch.chdir(tmp_path)

    captured: dict = {}
    def fake_run(self, verbose=True, eval_opponent=None):
        captured["eval_opponent"] = eval_opponent

    with patch("alphazero.trainer.Trainer.run", fake_run):
        exit_code = _cmd_train(_stub_args(game="chess"))

    assert exit_code == 0
    assert isinstance(captured["eval_opponent"], StockfishOpponent)
    captured["eval_opponent"].close()
pytest tests/test_cli.py -v
# Chess training config — Sub-project 3.
# Target: ≥50% win rate vs Stockfish ELO=1500 over 100 games.

# Network — ~5M params. Scale n_blocks/n_channels for Sub-project 4.
n_blocks = 10
n_channels = 128

# MCTS
num_simulations = 200
c_puct = 2.5
dirichlet_alpha = 0.3
dirichlet_weight = 0.25

# Self-play
games_per_iteration = 100
temperature_threshold = 30

# Training
training_steps_per_iteration = 2000
batch_size = 256
learning_rate = 1e-3
weight_decay = 1e-4

# Replay buffer
replay_buffer_capacity = 500_000
min_buffer_size = 50_000

# Eval (vs Stockfish ELO=1500)
eval_interval = 5
eval_games = 100

# Schedule
num_iterations = 80

# Parallel self-play
num_workers = 8
inference_batch_size = 64

# Misc
seed = 42
device = "auto"
checkpoint_dir = "checkpoints"
log_dir = "runs"
python3 -c "from alphazero.config import load_config; c = load_config('configs/chess.toml'); print(f'n_blocks={c.n_blocks} n_channels={c.n_channels} num_workers={c.num_workers}')"

Expected: prints n_blocks=10 n_channels=128 num_workers=8.

git add src/alphazero/cli.py configs/chess.toml tests/test_cli.py
git commit -m "$(cat <<'EOF'
Add --game chess CLI dispatch + chess config + integration test

Wires StockfishOpponent(elo=1500) into _cmd_train and _cmd_eval when
args.game == 'chess'. The same regression test pattern from Connect 4
guards against forgetting to wire the eval opponent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 17: E2E kill-criterion test

Files: - Create: tests/test_e2e_chess.py

"""End-to-end test: train + ≥50% win rate vs Stockfish ELO=1500 over 100 games.

This is the kill criterion for Sub-project 3. Marked slow — a full
training run takes 3-8 hours on M4 Pro MPS / Colab L4 / Lambda A6000.

Run with:
    pytest -m slow tests/test_e2e_chess.py -v -s
"""
from __future__ import annotations

from pathlib import Path

import pytest

from alphazero.arena import play_match
from alphazero.config import load_config
from alphazero.games.chess_game import Chess
from alphazero.opponents.stockfish import StockfishOpponent
from alphazero.trainer import Trainer

REPO_ROOT = Path(__file__).resolve().parent.parent


@pytest.mark.slow
def test_e2e_chess_beats_stockfish_1500(tmp_path, monkeypatch):
    """Train an AlphaZero chess agent. Play 100 games vs Stockfish ELO=1500.
    Pass criterion: win_rate >= 0.50 (alternating colors)."""
    monkeypatch.chdir(tmp_path)

    config = load_config(REPO_ROOT / "configs/chess.toml")
    opponent = StockfishOpponent(elo=1500, time_per_move=0.5)

    trainer = Trainer(Chess(), config)
    trainer.run(eval_opponent=opponent)

    agent = trainer._make_argmax_mcts_agent(trainer.best_net)
    result = play_match(Chess(), agent, opponent, num_games=100)
    opponent.close()

    print(
        f"\nFinal: wins={result.wins_a} draws={result.draws} "
        f"losses={result.losses_a} win_rate={result.win_rate:.2%}"
    )
    assert result.win_rate >= 0.50, (
        f"Agent win rate {result.win_rate:.2%} below 0.50 target. "
        "Increase num_iterations or num_simulations in configs/chess.toml."
    )
pytest --collect-only -m "not slow" 2>&1 | grep test_e2e_chess || echo "(excluded ✓)"
pytest --collect-only -m slow tests/test_e2e_chess.py 2>&1 | tail -3

Expected: first prints (excluded ✓); second collects 1 test.

git add tests/test_e2e_chess.py
git commit -m "$(cat <<'EOF'
Add E2E kill-criterion test (win_rate ≥ 0.50 vs Stockfish ELO=1500)

This is the success bar for Sub-project 3. Marked slow — a full
training run takes 3-8 hours on consumer hardware.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 18: Final sweep + push

pytest -m "not slow" 2>&1 | tail -10

Expected: all tests pass. Estimated count: ~120 baseline + ~40 new chess tests = ~160 total.

ruff check src tests 2>&1 | tail -10

If there are real errors, fix before continuing. Ignore minor style suggestions.

git push 2>&1 | tail -3

This is the formal Sub-project 3 success bar. Takes 3-8 hours.

pytest -m slow tests/test_e2e_chess.py -v -s

Pass criterion: win_rate ≥ 0.50 against Stockfish ELO=1500 over 100 games.

If it fails, investigate in order: 1. Are training iterations actually completing? ls checkpoints/ should show ~80 files. 2. Are eval cycles showing improvement over time? Read iter_NNNN's stdout for the eval line. 3. Is best_net actually changing between iterations? (Diagnostic from SP1: compare iter_0010 vs iter_0050's first weight.) 4. Increase num_iterations or num_simulations in configs/chess.toml and resume.


Final state

After Task 17, the repo has:

The framework's "only the Game class changes" property is preserved — MCTS, AlphaZeroNet, ReplayBuffer, Arena, Trainer are reused unchanged. The new infrastructure (parallel_selfplay, Stockfish opponent) is purely additive.

Sub-project 3 is done when pytest -m slow tests/test_e2e_chess.py reaches win_rate ≥ 0.50 vs Stockfish ELO=1500. Then Sub-project 4 (productionize: UCI engine, scale to 2000+ ELO) can begin — and again, only config knobs need to change (n_blocks=19, n_channels=256, num_simulations=800, num_iterations=200), no code rewrites.