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: Validate the AlphaZero framework on Connect 4 — train a network that beats a from-scratch minimax-depth-8 opponent in ≥80% of 200 games (alternating colors), reusing the entire Sub-project 1 framework unchanged.
Architecture: Add one new Game subclass (Connect4), one new evaluation opponent (Connect4MinimaxOpponent with α-β pruning), and small generalizations to Trainer.run() (eval-opponent injection) and the CLI (--game flag). The four-layer framework (Game / NN / MCTS / Trainer) is unchanged.
Tech Stack: Same as Sub-project 1 — Python 3.11+, PyTorch 2.2+ (MPS/CUDA portable), NumPy, pytest. No new dependencies.
Spec: docs/superpowers/specs/2026-05-15-connect4-design.md
File Structure
Files this plan creates or modifies:
src/alphazero/games/connect4.py ← new (~200 lines)
src/alphazero/opponents/__init__.py ← new (empty package marker)
src/alphazero/opponents/connect4_minimax.py ← new (~200 lines)
src/alphazero/trainer.py ← small change (eval-opponent injection)
src/alphazero/cli.py ← small change (--game flag)
configs/connect4.toml ← new
tests/test_game_connect4.py ← new (~250 lines)
tests/test_connect4_minimax.py ← new (~150 lines)
tests/test_e2e_connect4.py ← new (~50 lines)
Tasks proceed in dependency order: game first, then opponent (which uses the game's terminal detection), then the framework wiring (Trainer/CLI), then the kill criterion.
Conventions
- Commit messages use HEREDOC with
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>footer (per~/.claude/CLAUDE.md). Commit subjects in this plan show only the subject; executor adds the footer. - All tests live under
tests/. Run withpytest. Use-m "not slow"to skip the E2E test. - TDD: red → green → commit. Each task is one cohesive change with its own commit.
Task 1: Connect4 game scaffolding — state, current_player, apply, legal_actions_mask
Files:
- Create: src/alphazero/games/connect4.py
- Test: tests/test_game_connect4.py
- [ ] Step 1: Write the failing tests
tests/test_game_connect4.py:
import numpy as np
import pytest
from alphazero.games.connect4 import Connect4
@pytest.fixture
def c4() -> Connect4:
return Connect4()
def test_input_shape_and_action_size(c4: Connect4):
assert c4.input_shape == (3, 6, 7)
assert c4.action_size == 7
def test_initial_state_is_empty(c4: Connect4):
s = c4.initial_state()
assert s.shape == (6, 7)
assert s.dtype == np.int8
assert np.all(s == 0)
def test_current_player_starts_at_plus_one(c4: Connect4):
assert c4.current_player(c4.initial_state()) == 1
def test_apply_drops_piece_to_bottom_row_of_column(c4: Connect4):
"""First piece in a column should land at row 5 (the bottom)."""
s = c4.initial_state()
s = c4.apply(s, action=3) # player +1 drops in column 3
assert s[5, 3] == 1
# All other cells still empty
assert np.count_nonzero(s) == 1
# Next player is -1
assert c4.current_player(s) == -1
def test_apply_stacks_pieces_within_a_column(c4: Connect4):
s = c4.initial_state()
s = c4.apply(s, action=3) # +1 at (5, 3)
s = c4.apply(s, action=3) # -1 stacks on top at (4, 3)
s = c4.apply(s, action=3) # +1 at (3, 3)
assert s[5, 3] == 1
assert s[4, 3] == -1
assert s[3, 3] == 1
assert s[2, 3] == 0 # next slot is still empty
def test_apply_does_not_mutate_input(c4: Connect4):
s = c4.initial_state()
s_copy = s.copy()
_ = c4.apply(s, 0)
np.testing.assert_array_equal(s, s_copy)
def test_apply_raises_on_full_column(c4: Connect4):
s = c4.initial_state()
# Fill column 0 (6 pieces)
for _ in range(6):
s = c4.apply(s, 0)
assert s[0, 0] != 0 # top of column 0 is occupied
with pytest.raises(ValueError, match="full"):
c4.apply(s, 0)
def test_legal_actions_mask_initial_state_all_seven_legal(c4: Connect4):
mask = c4.legal_actions_mask(c4.initial_state())
assert mask.shape == (7,)
assert mask.dtype == bool
assert mask.all()
def test_legal_actions_mask_after_filling_one_column(c4: Connect4):
s = c4.initial_state()
for _ in range(6):
s = c4.apply(s, 3)
mask = c4.legal_actions_mask(s)
assert mask.sum() == 6
assert not mask[3] # column 3 is full
- [ ] Step 2: Run tests to verify they fail
pytest tests/test_game_connect4.py -v
Expected: ImportError on alphazero.games.connect4.
- [ ] Step 3: Implement Connect4 core in
src/alphazero/games/connect4.py
"""Connect 4: 6×7 board with gravity. Pieces drop to the lowest empty cell of the
chosen column.
State representation:
np.ndarray shape (6, 7), dtype int8, values in {+1, 0, -1}.
Row 0 is the top of the board; row 5 is the bottom (where pieces land).
+1 = first player (red); -1 = second player (yellow); 0 = empty.
Action space: integers 0..6 — the column to drop a piece into.
"""
from __future__ import annotations
import numpy as np
from .base import Game
State = np.ndarray
ROWS = 6
COLS = 7
class Connect4(Game):
@property
def input_shape(self) -> tuple[int, ...]:
return (3, ROWS, COLS)
@property
def action_size(self) -> int:
return COLS
def initial_state(self) -> State:
return np.zeros((ROWS, COLS), dtype=np.int8)
def current_player(self, state: State) -> int:
moves = int(np.count_nonzero(state))
return 1 if moves % 2 == 0 else -1
def legal_actions_mask(self, state: State) -> np.ndarray:
# A column is legal iff its top cell (row 0) is empty.
return state[0, :] == 0
def apply(self, state: State, action: int) -> State:
if state[0, action] != 0:
raise ValueError(f"Illegal action {action}: column {action} is full")
new_state = state.copy()
# Drop: find the lowest empty cell in this column.
for row in range(ROWS - 1, -1, -1):
if new_state[row, action] == 0:
new_state[row, action] = self.current_player(state)
break
return new_state
# Stubs filled in by subsequent tasks
def terminal_value(self, state: State) -> float | None:
raise NotImplementedError # Task 2
def encode(self, state: State) -> np.ndarray:
raise NotImplementedError # Task 3
def canonical_state(self, state: State) -> State:
raise NotImplementedError # Task 3
- [ ] Step 4: Run tests to verify they pass
pytest tests/test_game_connect4.py -v
Expected: 9 passed.
- [ ] Step 5: Commit
git add src/alphazero/games/connect4.py tests/test_game_connect4.py
git commit -m "$(cat <<'EOF'
Add Connect4 game scaffolding: state, apply (gravity), legal mask
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 2: Connect4 terminal_value (4-in-a-row detection)
Files:
- Modify: src/alphazero/games/connect4.py (implement terminal_value and _find_winner)
- Modify: tests/test_game_connect4.py (append tests)
- [ ] Step 1: Append the failing tests
def _board(rows: list[list[int]]) -> np.ndarray:
"""Helper: literal board from a 2D list, row 0 at top."""
return np.array(rows, dtype=np.int8)
def test_terminal_value_none_for_initial(c4: Connect4):
assert c4.terminal_value(c4.initial_state()) is None
def test_terminal_value_none_when_in_progress(c4: Connect4):
s = c4.initial_state()
s = c4.apply(s, 3)
s = c4.apply(s, 4)
assert c4.terminal_value(s) is None
def test_terminal_value_horizontal_4_in_a_row(c4: Connect4):
# +1 has 4 in a row at row 5, columns 0-3. -1 has 3 scattered pieces.
# Total nonzero = 7 (odd) → current_player = -1 (the loser POV).
state = _board([
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[-1, -1, -1, 0, 0, 0, 0],
[1, 1, 1, 1, 0, 0, 0],
])
assert c4.current_player(state) == -1
assert c4.terminal_value(state) == -1.0
def test_terminal_value_vertical_4_in_a_row(c4: Connect4):
# +1 has 4 stacked in column 0. -1 has 3 scattered.
# Nonzero = 7, current_player = -1.
state = _board([
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0],
[1, -1, -1, -1, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0],
])
assert c4.current_player(state) == -1
assert c4.terminal_value(state) == -1.0
def test_terminal_value_diagonal_down_right(c4: Connect4):
# +1 has 4 on the ↘ diagonal: (2,0), (3,1), (4,2), (5,3).
state = _board([
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0],
[-1, 1, 0, 0, 0, 0, 0],
[-1, -1, 1, 0, 0, 0, 0],
[-1, -1, -1, 1, 0, 0, 0],
])
# +1 pieces: 4 ; -1 pieces: 6 ; total 10 (even) → current_player = +1
# Wait, that means it's X's turn next → X (the winner) is current, returns +1.
# Actually for +1 to have just won, it's now -1's turn → current_player must be -1.
# Recount: 4 X + 6 O = 10 nonzero, parity even → +1 to move.
# But that means O just moved. We need X to have just moved → odd parity.
# Re-construct with +1 winning on the LAST move (odd parity).
state = _board([
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0],
[-1, 1, 0, 0, 0, 0, 0],
[-1, -1, 1, 0, 0, 0, 0],
[-1, -1, 0, 1, 0, 0, 0],
])
# Count: 4 X + 5 O = 9 nonzero → odd → current_player = -1 (correct, O's turn after X won)
assert c4.current_player(state) == -1
assert c4.terminal_value(state) == -1.0
def test_terminal_value_diagonal_up_right(c4: Connect4):
# +1 has 4 on the ↗ diagonal: (5,0), (4,1), (3,2), (2,3).
state = _board([
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0],
[-1, -1, 1, 0, 0, 0, 0],
[-1, 1, -1, 0, 0, 0, 0],
[1, -1, -1, 0, 0, 0, 0],
])
# 4 X + 5 O = 9 nonzero → -1's turn. X just won.
assert c4.current_player(state) == -1
assert c4.terminal_value(state) == -1.0
def test_terminal_value_draw_on_full_board_no_winner(c4: Connect4):
"""Construct a full board with no 4-in-a-row. (Easiest: alternate columns.)"""
state = _board([
[-1, 1, -1, 1, -1, 1, -1],
[ 1, -1, 1, -1, 1, -1, 1],
[-1, 1, -1, 1, -1, 1, -1],
[ 1, -1, 1, -1, 1, -1, 1],
[-1, 1, -1, 1, -1, 1, -1],
[ 1, -1, 1, -1, 1, -1, 1],
])
# Verify no 4-in-a-row exists in this configuration
# (this striped pattern has no horizontal/vertical/diagonal 4s)
assert c4.terminal_value(state) == 0.0
- [ ] Step 2: Run tests, verify the 7 new ones fail
pytest tests/test_game_connect4.py -v
Expected: 7 fail with NotImplementedError.
- [ ] Step 3: Implement
terminal_value+_find_winner
Replace the stub in src/alphazero/games/connect4.py:
def terminal_value(self, state: State) -> float | None:
winner = self._find_winner(state)
if winner is not None:
return 1.0 if winner == self.current_player(state) else -1.0
if np.all(state != 0):
return 0.0 # full board, no winner → draw
return None
def _find_winner(self, state: State) -> int | None:
"""Return +1 / -1 if either player has 4 in a row, else None."""
for player in (1, -1):
# Horizontal — 4 consecutive in a row
for r in range(ROWS):
for c in range(COLS - 3):
if np.all(state[r, c:c+4] == player):
return player
# Vertical — 4 consecutive in a column
for c in range(COLS):
for r in range(ROWS - 3):
if np.all(state[r:r+4, c] == player):
return player
# Diagonal ↘ (top-left to bottom-right)
for r in range(ROWS - 3):
for c in range(COLS - 3):
if all(state[r+i, c+i] == player for i in range(4)):
return player
# Diagonal ↗ (bottom-left to top-right)
for r in range(3, ROWS):
for c in range(COLS - 3):
if all(state[r-i, c+i] == player for i in range(4)):
return player
return None
- [ ] Step 4: Run tests, verify all pass
pytest tests/test_game_connect4.py -v
Expected: 16 passed (9 from Task 1 + 7 new).
- [ ] Step 5: Commit
git add src/alphazero/games/connect4.py tests/test_game_connect4.py
git commit -m "$(cat <<'EOF'
Add Connect4 terminal_value (4-in-a-row in 4 directions, draws)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 3: Connect4 encode + canonical_state
Files:
- Modify: src/alphazero/games/connect4.py
- Modify: tests/test_game_connect4.py
- [ ] Step 1: Append failing tests
def test_canonical_state_is_identity_when_current_player_plus_one(c4: Connect4):
s = c4.initial_state()
np.testing.assert_array_equal(c4.canonical_state(s), s)
def test_canonical_state_inverts_when_current_player_minus_one(c4: Connect4):
s = c4.apply(c4.initial_state(), 3) # +1 drops in col 3; now -1's turn
canon = c4.canonical_state(s)
expected = np.zeros((6, 7), dtype=np.int8)
expected[5, 3] = -1 # +1's piece becomes -1 in O's canonical view
np.testing.assert_array_equal(canon, expected)
def test_encode_shape_and_dtype(c4: Connect4):
enc = c4.encode(c4.canonical_state(c4.initial_state()))
assert enc.shape == (3, 6, 7)
assert enc.dtype == np.float32
def test_encode_initial_state_planes(c4: Connect4):
enc = c4.encode(c4.canonical_state(c4.initial_state()))
assert enc[0].sum() == 0 # my plane empty
assert enc[1].sum() == 0 # opp plane empty
np.testing.assert_array_equal(enc[2], np.ones((6, 7), dtype=np.float32)) # ones plane
def test_encode_treats_canonical_player_as_my_pieces(c4: Connect4):
"""After +1 plays col 3, -1's canonical view: +1's piece appears on the opp plane."""
s = c4.apply(c4.initial_state(), 3)
canon = c4.canonical_state(s)
enc = c4.encode(canon)
# my (O's pieces): nothing
assert enc[0].sum() == 0
# opp (X's piece): at (5, 3)
assert enc[1, 5, 3] == 1
assert enc[1].sum() == 1
-
[ ] Step 2: Run tests, verify 5 fail (NotImplementedError)
-
[ ] Step 3: Implement
encodeandcanonical_state
Replace the stubs in src/alphazero/games/connect4.py:
def canonical_state(self, state: State) -> State:
"""Rewrite so the current player's pieces are +1, opponent's are -1."""
return (state * self.current_player(state)).astype(np.int8)
def encode(self, state: State) -> np.ndarray:
"""Three planes: my pieces (+1 in canonical state), opp pieces (-1), ones.
Caller is expected to pass a canonical state.
"""
my = (state == 1).astype(np.float32)
opp = (state == -1).astype(np.float32)
ones = np.ones((ROWS, COLS), dtype=np.float32)
return np.stack([my, opp, ones], axis=0)
- [ ] Step 4: Run tests, verify all 21 pass
pytest tests/test_game_connect4.py -v
Expected: 21 passed.
- [ ] Step 5: Commit
git add src/alphazero/games/connect4.py tests/test_game_connect4.py
git commit -m "$(cat <<'EOF'
Add Connect4 encode + canonical_state (perspective transform)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 4: Connect4 symmetries (horizontal mirror)
Files:
- Modify: src/alphazero/games/connect4.py
- Modify: tests/test_game_connect4.py
- [ ] Step 1: Append failing tests
def test_symmetries_yields_two_tuples(c4: Connect4):
"""Connect 4 has 2 symmetries: identity + horizontal (left-right) mirror."""
s = c4.apply(c4.initial_state(), 0) # asymmetric: piece in column 0
enc = c4.encode(c4.canonical_state(s))
policy = np.array([0.5, 0.1, 0.1, 0.1, 0.1, 0.05, 0.05], dtype=np.float32)
syms = c4.symmetries(enc, policy)
assert len(syms) == 2
def test_symmetries_first_is_identity(c4: Connect4):
enc = c4.encode(c4.canonical_state(c4.initial_state()))
policy = np.full(7, 1 / 7, dtype=np.float32)
syms = c4.symmetries(enc, policy)
np.testing.assert_array_equal(syms[0][0], enc)
np.testing.assert_array_equal(syms[0][1], policy)
def test_symmetries_mirror_flips_columns(c4: Connect4):
"""Mirror reflects across the vertical axis: column 0 ↔ column 6."""
s = c4.apply(c4.initial_state(), 0) # +1's piece at (5, 0)
enc = c4.encode(c4.canonical_state(s))
policy = np.zeros(7, dtype=np.float32)
policy[0] = 1.0 # all mass on leftmost column
syms = c4.symmetries(enc, policy)
mirror_enc, mirror_policy = syms[1]
# In the mirrored encoding, the piece is at the rightmost column
# (column 6 instead of 0). Plane 1 = opp = X's pieces (from O's POV).
assert mirror_enc[1, 5, 6] == 1
assert mirror_enc[1, 5, 0] == 0
# Mirror policy now puts mass on column 6
np.testing.assert_array_equal(mirror_policy, [0, 0, 0, 0, 0, 0, 1])
def test_symmetries_preserve_policy_sum(c4: Connect4):
enc = c4.encode(c4.canonical_state(c4.initial_state()))
policy = np.full(7, 1 / 7, dtype=np.float32)
for _, p in c4.symmetries(enc, policy):
np.testing.assert_allclose(p.sum(), 1.0, rtol=1e-6)
- [ ] Step 2: Run tests — 3 fail (default
symmetriesreturns 1 element, not 2)
pytest tests/test_game_connect4.py -v
- [ ] Step 3: Add
symmetriesoverride inConnect4
def symmetries(
self, encoded: np.ndarray, policy: np.ndarray
) -> list[tuple[np.ndarray, np.ndarray]]:
"""Connect 4 has 2 D-group elements: identity + horizontal mirror.
(Unlike TTT's 8, Connect 4 has no vertical symmetry because gravity
is asymmetric — pieces only fall downward.)
Mirror flips columns left↔right in both the board planes (axis=2 of
the encoded tensor) and the policy (a 1D array of column indices).
"""
identity = (encoded.copy(), policy.copy())
mirrored = (
np.flip(encoded, axis=2).copy(),
np.flip(policy, axis=0).copy(),
)
return [identity, mirrored]
- [ ] Step 4: Run tests, verify all 25 pass
pytest tests/test_game_connect4.py -v
Expected: 25 passed.
- [ ] Step 5: Commit
git add src/alphazero/games/connect4.py tests/test_game_connect4.py
git commit -m "$(cat <<'EOF'
Add Connect4 symmetries (identity + horizontal mirror)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 5: Connect4MinimaxOpponent (α-β pruning with heuristic eval)
Files:
- Create: src/alphazero/opponents/__init__.py (empty marker)
- Create: src/alphazero/opponents/connect4_minimax.py
- Test: tests/test_connect4_minimax.py
- [ ] Step 1: Create empty package marker
mkdir -p src/alphazero/opponents
touch src/alphazero/opponents/__init__.py
- [ ] Step 2: Write failing tests
tests/test_connect4_minimax.py:
import numpy as np
import pytest
from alphazero.games.connect4 import Connect4
from alphazero.opponents.connect4_minimax import Connect4MinimaxOpponent
@pytest.fixture
def c4() -> Connect4:
return Connect4()
def _board(rows: list[list[int]]) -> np.ndarray:
return np.array(rows, dtype=np.int8)
def test_only_plays_legal_moves(c4: Connect4):
"""Minimax must never propose an illegal action."""
opp = Connect4MinimaxOpponent(depth=4)
s = c4.initial_state()
# Fill column 0 entirely
for _ in range(6):
s = c4.apply(s, 0)
action = opp(c4, s)
legal = c4.legal_actions_mask(s)
assert legal[action], f"Minimax returned illegal action {action}"
def test_takes_immediate_winning_move(c4: Connect4):
"""+1 can win at column 3 by completing the bottom row 4-in-a-row.
Setup: +1 has pieces at (5, 0..2), and -1 has 3 pieces stacked elsewhere.
Total: 3+3 = 6 nonzero → even parity → current_player = +1.
"""
state = _board([
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[-1, 0, 0, 0, 0, 0, 0],
[-1, 0, 0, 0, 0, 0, 0],
[1, 1, 1, 0, -1, 0, 0],
])
assert c4.current_player(state) == 1
opp = Connect4MinimaxOpponent(depth=4)
action = opp(c4, state)
assert action == 3, f"Should play winning move (col 3), got {action}"
def test_blocks_immediate_opponent_threat(c4: Connect4):
"""-1 has 3 in a row at the bottom (cols 0-2). +1 to move MUST block at col 3.
Setup: -1 has bottom-row 0..2; +1 has 3 random pieces. Total 6, even → +1's turn.
"""
state = _board([
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0],
[-1, -1, -1, 0, 1, 0, 0],
])
assert c4.current_player(state) == 1
opp = Connect4MinimaxOpponent(depth=4)
action = opp(c4, state)
assert action == 3, f"Must block at col 3, got {action}"
def test_prefers_center_on_empty_board(c4: Connect4):
"""Minimax-with-center-bias should pick column 3 on the first move."""
opp = Connect4MinimaxOpponent(depth=4)
action = opp(c4, c4.initial_state())
assert action == 3, f"Expected center (col 3) opening, got {action}"
def test_depth_8_beats_depth_2(c4: Connect4):
"""Deeper search should produce stronger play."""
from alphazero.arena import play_match
strong = Connect4MinimaxOpponent(depth=8)
weak = Connect4MinimaxOpponent(depth=2)
# 10 games is enough for a strong-vs-weak signal in Connect 4.
np.random.seed(0)
result = play_match(c4, strong, weak, num_games=10)
# win_rate counts draws as 0.5; strong should be clearly ahead.
assert result.win_rate >= 0.80, (
f"depth-8 only achieved win_rate {result.win_rate:.2%} vs depth-2"
)
- [ ] Step 3: Run tests — all 5 fail with ImportError
pytest tests/test_connect4_minimax.py -v
- [ ] Step 4: Implement
Connect4MinimaxOpponent
src/alphazero/opponents/connect4_minimax.py:
"""Connect 4 α-β minimax opponent with heuristic position evaluation.
Used as the eval ground truth for Sub-project 2 (we don't have a perfect
solver for Connect 4 from scratch — depth-8 minimax is a strong amateur).
Heuristic eval at non-terminal leaves:
- Score sliding 4-cell windows in all 4 directions
4-of-mine → +1000 (effectively a forced win)
3-of-mine + 1 empty → +5
2-of-mine + 2 empties → +2
symmetric negatives for opponent
opp 3-in-a-row gets -50 (block aggressively even before center bias)
- +3 per piece in the center column (mine); -3 per opponent center piece
"""
from __future__ import annotations
import numpy as np
from ..games.base import Game, State
from ..games.connect4 import COLS, ROWS, Connect4
CENTER_COL = COLS // 2 # column 3 (the center of a 7-col board)
WIN_SCORE = 1000.0 # heuristic value of having 4-in-a-row in a leaf window
BLOCK_SCORE = -50.0 # extra emphasis for blocking opp's 3-in-a-row
class Connect4MinimaxOpponent:
"""α-β minimax to a fixed depth with a center-bias position heuristic."""
def __init__(self, depth: int = 8):
self.depth = depth
self._game = Connect4()
def __call__(self, game: Game, state: State) -> int:
"""Return the best action for current_player at this state."""
legal = game.legal_actions_mask(state)
legal_actions = np.where(legal)[0]
if len(legal_actions) == 0:
raise ValueError("No legal actions at this state")
best_action = int(legal_actions[0])
best_value = -float("inf")
alpha = -float("inf")
beta = float("inf")
for action in legal_actions:
next_state = game.apply(state, int(action))
# Negamax: value from child's POV is negated to get parent's POV.
value = -self._minimax(next_state, self.depth - 1, -beta, -alpha)
if value > best_value:
best_value = value
best_action = int(action)
alpha = max(alpha, value)
return best_action
def _minimax(self, state: State, depth: int, alpha: float, beta: float) -> float:
"""Return the negamax value from current_player(state)'s POV."""
terminal = self._game.terminal_value(state)
if terminal is not None:
return float(terminal) * WIN_SCORE if terminal != 0 else 0.0
if depth == 0:
return self._evaluate(state)
legal = self._game.legal_actions_mask(state)
best_value = -float("inf")
for action in np.where(legal)[0]:
next_state = self._game.apply(state, int(action))
value = -self._minimax(next_state, depth - 1, -beta, -alpha)
best_value = max(best_value, value)
alpha = max(alpha, value)
if alpha >= beta:
break # β-cutoff
return best_value
def _evaluate(self, state: State) -> float:
"""Heuristic score from current_player's POV."""
player = self._game.current_player(state)
score = 0.0
for window in _all_windows(state):
score += _window_score(window, player)
# Center column bonus
score += 3 * int((state[:, CENTER_COL] == player).sum())
score -= 3 * int((state[:, CENTER_COL] == -player).sum())
return score
def _all_windows(state: State) -> list[np.ndarray]:
"""Generate all length-4 windows in all 4 directions."""
windows: list[np.ndarray] = []
# Horizontal
for r in range(ROWS):
for c in range(COLS - 3):
windows.append(state[r, c:c + 4])
# Vertical
for c in range(COLS):
for r in range(ROWS - 3):
windows.append(state[r:r + 4, c])
# Diagonal ↘
for r in range(ROWS - 3):
for c in range(COLS - 3):
windows.append(np.array([state[r + i, c + i] for i in range(4)]))
# Diagonal ↗
for r in range(3, ROWS):
for c in range(COLS - 3):
windows.append(np.array([state[r - i, c + i] for i in range(4)]))
return windows
def _window_score(window: np.ndarray, player: int) -> float:
"""Score a single 4-cell window from `player`'s POV."""
my = int((window == player).sum())
opp = int((window == -player).sum())
if my == 4:
return WIN_SCORE
if opp == 4:
return -WIN_SCORE
if opp > 0 and my > 0:
return 0.0 # mixed window — no threat either way
if my == 3:
return 5.0
if my == 2:
return 2.0
if opp == 3:
return BLOCK_SCORE
if opp == 2:
return -2.0
return 0.0
- [ ] Step 5: Run tests
pytest tests/test_connect4_minimax.py -v
Expected: 5 passed. (Note: the depth_8_beats_depth_2 test is the slowest — ~5-10 seconds.)
- [ ] Step 6: Commit
git add src/alphazero/opponents/__init__.py src/alphazero/opponents/connect4_minimax.py tests/test_connect4_minimax.py
git commit -m "$(cat <<'EOF'
Add Connect4MinimaxOpponent (α-β depth-8 + center-bias heuristic)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 6: Generalize Trainer eval hook (_eval_vs_opponent)
Files:
- Modify: src/alphazero/trainer.py
- Modify: tests/test_trainer.py
- [ ] Step 1: Append the failing test to
tests/test_trainer.py
def test_eval_vs_opponent_uses_provided_agent(tiny_config):
"""Trainer._eval_vs_opponent should play best_net vs an arbitrary agent."""
from alphazero.games.tictactoe import TicTacToe
trainer = Trainer(TicTacToe(), tiny_config)
# Sentinel opponent: always plays the first legal action.
def sentinel_agent(game, state):
import numpy as np
legal = game.legal_actions_mask(state)
return int(np.where(legal)[0][0])
result = trainer._eval_vs_opponent(sentinel_agent, num_games=4)
assert set(result.keys()) == {"wins", "draws", "losses"}
assert result["wins"] + result["draws"] + result["losses"] == 4
- [ ] Step 2: Run the test — fails (AttributeError:
_eval_vs_opponentmissing)
pytest tests/test_trainer.py::test_eval_vs_opponent_uses_provided_agent -v
- [ ] Step 3: Add
_eval_vs_opponentand refactor_eval_vs_solverto use it
Replace the existing _eval_vs_solver in src/alphazero/trainer.py with:
def _eval_vs_opponent(self, opponent, num_games: int) -> dict:
"""Play current best_net vs an arbitrary opponent agent.
Generic alternative to `_eval_vs_solver` — used for games (like
Connect 4) where the eval baseline is a minimax opponent, not a
perfect-play solver.
Args:
opponent: callable `(game, state) -> int` returning chosen action.
num_games: how many games to play (alternating colors).
Returns:
dict with keys "wins", "draws", "losses" from best_net's POV.
"""
from .arena import play_match
net_agent = self._make_argmax_mcts_agent(self.best_net)
result = play_match(self.game, net_agent, opponent, num_games=num_games)
return {
"wins": result.wins_a,
"draws": result.draws,
"losses": result.losses_a,
}
def _eval_vs_solver(self) -> dict:
"""Play current best_net vs the perfect TTT solver. Thin wrapper over
_eval_vs_opponent for backward-compatibility with the Sub-project 1
E2E test."""
from .solvers.tictactoe_solver import solve_tictactoe_action
def solver_agent(_game, state):
return solve_tictactoe_action(state)
return self._eval_vs_opponent(solver_agent, self.config.eval_games)
- [ ] Step 4: Modify
Trainer.run()to accept an optionaleval_opponentparameter
In src/alphazero/trainer.py, change the run signature and the eval block. Replace:
def run(self, verbose: bool = True) -> None:
with:
def run(self, verbose: bool = True, eval_opponent=None) -> None:
"""Train for num_iterations.
Args:
verbose: print per-iteration progress.
eval_opponent: optional callable `(game, state) -> int`. If given,
eval-vs-solver hook is replaced by eval-vs-this-opponent.
Used by Connect 4 (Sub-project 2) to inject the minimax baseline.
"""
And replace the existing eval block:
if self.iteration % self.config.eval_interval == 0:
if verbose:
print(f" eval vs solver ({self.config.eval_games} games)...", flush=True)
result = self._eval_vs_solver()
if verbose:
print(f" eval: wins={result['wins']} draws={result['draws']} "
f"losses={result['losses']}", flush=True)
with:
if self.iteration % self.config.eval_interval == 0:
if eval_opponent is not None:
if verbose:
print(f" eval vs opponent ({self.config.eval_games} games)...",
flush=True)
result = self._eval_vs_opponent(eval_opponent, self.config.eval_games)
else:
if verbose:
print(f" eval vs solver ({self.config.eval_games} games)...",
flush=True)
result = self._eval_vs_solver()
if verbose:
print(f" eval: wins={result['wins']} draws={result['draws']} "
f"losses={result['losses']}", flush=True)
- [ ] Step 5: Run all trainer tests, verify all pass
pytest tests/test_trainer.py -v
Expected: 10 passed (9 existing + 1 new).
- [ ] Step 6: Commit
git add src/alphazero/trainer.py tests/test_trainer.py
git commit -m "$(cat <<'EOF'
Generalize Trainer eval hook to accept arbitrary opponent agent
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 7: CLI --game flag
Files:
- Modify: src/alphazero/cli.py
- [ ] Step 1: Add a
GAMESregistry and--gameflag
Edit src/alphazero/cli.py. After the imports, add:
from .games.connect4 import Connect4
GAMES = {
"tictactoe": TicTacToe,
"connect4": Connect4,
}
Then in each subcommand function (_cmd_train, _cmd_eval, _cmd_play), replace the hardcoded game = TicTacToe() with:
game_cls = GAMES[args.game]
game = game_cls()
And in main(), add the --game flag to each subparser:
p_train = subs.add_parser("train", help="Run training loop")
p_train.add_argument("--game", choices=list(GAMES), default="tictactoe")
p_train.add_argument("--config", type=Path, default=Path("configs/tictactoe.toml"))
p_train.set_defaults(func=_cmd_train)
p_eval = subs.add_parser("eval", help="Evaluate a checkpoint vs perfect solver")
p_eval.add_argument("--game", choices=list(GAMES), default="tictactoe")
p_eval.add_argument("--checkpoint", type=Path, required=True)
p_eval.add_argument("--config", type=Path, default=None,
help="Override architecture config (needed for older checkpoints "
"that didn't save their config inline).")
p_eval.add_argument("--num-games", type=int, default=200)
p_eval.set_defaults(func=_cmd_eval)
p_play = subs.add_parser("play", help="Play interactively against best_net")
p_play.add_argument("--game", choices=list(GAMES), default="tictactoe")
p_play.add_argument("--checkpoint", type=Path, required=True)
p_play.add_argument("--config", type=Path, default=None,
help="Override architecture config (needed for older checkpoints "
"that didn't save their config inline).")
p_play.add_argument("--num-simulations", type=int, default=None,
help="Override MCTS simulations per move at play time.")
p_play.add_argument("--as", dest="as_player", choices=["x", "o", "X", "O"], default="x",
help="Play as X (moves first) or O (moves second). Default: x.")
p_play.set_defaults(func=_cmd_play)
In _cmd_eval, also generalize the eval logic. Currently it hardcodes the TTT solver as opponent. Change it to dispatch by --game:
def _cmd_eval(args: argparse.Namespace) -> int:
config = _config_for_checkpoint(args.checkpoint, args.config)
game_cls = GAMES[args.game]
game = game_cls()
trainer = Trainer(game, config)
ckpt = torch.load(args.checkpoint, map_location=trainer.device, weights_only=False)
trainer.best_net.load_state_dict(ckpt["best_net"])
trainer.best_net.eval()
agent = trainer._make_argmax_mcts_agent(trainer.best_net)
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"
else:
raise ValueError(f"No eval opponent defined for game={args.game}")
result = play_match(game, agent, opponent, num_games=args.num_games)
print(
f"vs {label} ({args.num_games} games): "
f"wins={result.wins_a} draws={result.draws} losses={result.losses_a}"
)
return 0 if result.losses_a == 0 else 1
- [ ] Step 2: Smoke-test the CLI
python3 -m alphazero --help
python3 -m alphazero train --help
python3 -m alphazero eval --help
python3 -m alphazero play --help
Expected: all four exit 0 and show the --game flag.
- [ ] Step 3: Run the full unit test suite to confirm nothing broke
pytest -m "not slow" -v 2>&1 | tail -5
Expected: all tests pass (no test added in this task; we're verifying nothing regressed).
- [ ] Step 4: Commit
git add src/alphazero/cli.py
git commit -m "$(cat <<'EOF'
Add --game {tictactoe,connect4} flag to CLI; route to right opponent
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 8: Connect 4 training config
Files:
- Create: configs/connect4.toml
- [ ] Step 1: Create the config
# Connect 4 training config — Sub-project 2.
# Defaults to AlphaZero paper conventions; tuned down for M4 Pro CPU/MPS.
# Network — bigger than TTT (richer position features needed for 6×7 board)
n_blocks = 6
n_channels = 64
# MCTS
num_simulations = 100
c_puct = 2.5
dirichlet_alpha = 0.5
dirichlet_weight = 0.25
# Self-play
games_per_iteration = 100
temperature_threshold = 15 # Connect 4 games are ~20-30 plies; cover the opening
# Training
training_steps_per_iteration = 1000
batch_size = 64
learning_rate = 1e-3
weight_decay = 1e-4
# Replay buffer
replay_buffer_capacity = 200_000
min_buffer_size = 20_000
# Eval (vs minimax-depth-8)
eval_interval = 5
eval_games = 200
# Schedule
num_iterations = 50
seed = 42
device = "auto"
checkpoint_dir = "checkpoints"
log_dir = "runs"
- [ ] Step 2: Verify the config loads cleanly
python3 -c "from alphazero.config import load_config; print(load_config('configs/connect4.toml'))"
Expected: prints a TrainingConfig(...) with the values above.
- [ ] Step 3: Commit
git add configs/connect4.toml
git commit -m "$(cat <<'EOF'
Add Connect 4 training config (configs/connect4.toml)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 9: E2E kill-criterion test (the success bar)
Files:
- Create: tests/test_e2e_connect4.py
- [ ] Step 1: Write the E2E test
tests/test_e2e_connect4.py:
"""End-to-end test: train an AlphaZero agent on Connect 4 and verify it beats
the minimax-depth-8 opponent in ≥80% of 200 games.
This is the kill criterion for Sub-project 2. Marked slow — a full training
run takes ~3-6 hours on M4 Pro MPS / ~12-20 hours on CPU.
Run with:
pytest -m slow tests/test_e2e_connect4.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.connect4 import Connect4
from alphazero.opponents.connect4_minimax import Connect4MinimaxOpponent
from alphazero.trainer import Trainer
REPO_ROOT = Path(__file__).resolve().parent.parent
@pytest.mark.slow
def test_e2e_connect4_beats_minimax_depth_8(tmp_path, monkeypatch):
"""Train, then play 200 games vs the minimax-depth-8 opponent.
Pass criterion: win_rate >= 0.80 (alternating colors)."""
monkeypatch.chdir(tmp_path)
config = load_config(REPO_ROOT / "configs/connect4.toml")
opponent = Connect4MinimaxOpponent(depth=8)
trainer = Trainer(Connect4(), config)
trainer.run(eval_opponent=opponent)
agent = trainer._make_argmax_mcts_agent(trainer.best_net)
result = play_match(Connect4(), agent, opponent, num_games=200)
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.80, (
f"Agent win rate {result.win_rate:.2%} below 0.80 target. "
"Increase num_iterations or num_simulations in configs/connect4.toml."
)
- [ ] Step 2: Verify the test is properly marked slow
pytest --collect-only -m "not slow" 2>&1 | grep test_e2e_connect4 || echo "(excluded ✓)"
pytest --collect-only -m slow tests/test_e2e_connect4.py 2>&1 | tail -3
Expected: first command prints "(excluded ✓)" (test not in default collection); second collects 1 test.
- [ ] Step 3: Commit
git add tests/test_e2e_connect4.py
git commit -m "$(cat <<'EOF'
Add E2E kill-criterion test (win_rate ≥ 0.80 vs minimax-depth-8)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 10: Final unit-test sweep + push
- [ ] Step 1: Run all non-slow tests
pytest -m "not slow" -v 2>&1 | tail -10
Expected: all tests pass. Note the total count — should be ~100+ (84 from Sub-project 1 + ~25 new game tests + 5 new minimax tests + 1 new trainer test = ~115).
- [ ] Step 2: Run linter
ruff check src tests 2>&1 | tail -10
Note any issues. If there are real errors, fix them before continuing. Ignore minor style suggestions.
- [ ] Step 3: Push everything
git push 2>&1 | tail -3
- [ ] Step 4 (Optional but recommended): Run the kill-criterion test
This is the formal Sub-project 2 success bar. It takes 3-6 hours on MPS.
pytest -m slow tests/test_e2e_connect4.py -v -s
Pass criterion: win_rate ≥ 0.80 against minimax-depth-8 over 200 games.
If it passes: Sub-project 2 is officially done. Sub-project 3 (chess) can begin.
If it doesn't reach 80%, candidate causes to investigate (in order of likelihood):
1. Undertraining — bump num_iterations from 50 to 80 or 100.
2. Too-shallow MCTS — bump num_simulations from 100 to 200.
3. Wrong network sizing — try 8 blocks × 96 channels.
4. Heuristic too weak — debug Connect4MinimaxOpponent._evaluate by playing it against a known stronger heuristic.
Final state
After Task 9, the repo has:
- A new
Connect4(Game)class insrc/alphazero/games/connect4.py - A new
Connect4MinimaxOpponentwith α-β pruning insrc/alphazero/opponents/ - An extended Trainer that accepts an arbitrary
eval_opponentcallable - A
--gameflag on every CLI subcommand configs/connect4.tomlwith reasonable starting hyperparameters- ~25 new game tests, 5 new minimax tests, 1 new trainer test — all passing in seconds
- 1 new E2E test (marked slow) that defines Sub-project 2 success
Crucial property: the framework code from Sub-project 1 (Game ABC, AlphaZeroNet, MCTS, SelfPlayWorker, ReplayBuffer, Arena) remains completely unchanged. The only modifications are: (1) Trainer's run() signature gains an eval_opponent parameter; (2) _eval_vs_solver becomes a thin wrapper around the new _eval_vs_opponent; (3) CLI dispatches on --game. These are extensions, not breaking changes.
Sub-project 2 is done when pytest -m slow tests/test_e2e_connect4.py reaches win_rate >= 0.80. Then Sub-project 3 (chess) can begin — and again, only the Game subclass changes.