| """ |
| ConnectX environment. Two board sizes, same code: |
| - Small (`ConnectXEnv()`, 4x4, win_len=3): scaled down so an exact BFS |
| oracle stays tractable -- used for the domain's own self-test below. |
| - Real (`ConnectXEnv(width=7, height=6, win_len=4)`, Kaggle's actual board): |
| `bfs_solve` returns None unconditionally -- the full game tree isn't |
| exhaustively searchable at this scale. Trained via on-policy Monte Carlo |
| value learning instead (see verifier.py / train.py), not oracle regression. |
| |
| State: the WIDTH*HEIGHT board, each cell one-hot over {EMPTY, AGENT, |
| OPPONENT}. Row 0 = top; dropping into a column fills the lowest (highest |
| row index) empty cell, standard Connect-4 gravity. |
| |
| Actions: DROP(column) for each column, plus one always-legal PASS action |
| (guarantees `always_legal_actions` is a real, non-empty, unconditional |
| subset). num_actions = WIDTH + 1. |
| |
| The fixed training opponent (deterministic unless the epsilon knobs below |
| are set): after the agent's move, if the agent didn't already win or fill |
| the board, the opponent (1) takes an immediate win if one exists, (2) else |
| blocks the agent's immediate win if one exists, (3) else plays the leftmost |
| legal column. This is this domain's one honest, named limitation for real |
| Kaggle play: the model is trained against THIS specific opponent shape (plus |
| diversification, see below), not whatever real opponent Kaggle's matchmaking |
| actually pairs it against. |
| |
| Reward: -1 per agent action. "Solved" = the AGENT has win_len in a row after |
| its own move. A loss (opponent wins) or a draw is terminal (`done=True`) but |
| NOT solved -- this distinction matters: see search.py's comment on why |
| trusting `done` alone as "solved" is a real bug for an adversarial domain. |
| """ |
| import random |
| from collections import deque |
|
|
| from .environment import Environment |
|
|
| DEFAULT_WIDTH = 4 |
| DEFAULT_HEIGHT = 4 |
| DEFAULT_WIN_LEN = 3 |
|
|
| EMPTY, AGENT, OPPONENT = 0, 1, 2 |
| CELL_WIDTH = 3 |
|
|
| |
| |
| |
| BFS_MAX_CELLS = 20 |
|
|
|
|
| def _onehot(idx, n): |
| v = [0] * n |
| v[idx] = 1 |
| return tuple(v) |
|
|
|
|
| def _onehot_index(bits): |
| """Robust to a search-time DECODED state whose slot isn't cleanly |
| one-hot (real states, built via `_encode_board`, never hit the |
| fallback).""" |
| return bits.index(1) if 1 in bits else 0 |
|
|
|
|
| def _encode_board(cells): |
| return tuple(b for c in cells for b in _onehot(c, CELL_WIDTH)) |
|
|
|
|
| def _decode_board(state): |
| cells = [] |
| off = 0 |
| while off < len(state): |
| cells.append(_onehot_index(state[off:off + CELL_WIDTH])) |
| off += CELL_WIDTH |
| return cells |
|
|
|
|
| def _rc(row, col, width): |
| return row * width + col |
|
|
|
|
| def _lowest_empty_row(cells, col, width, height): |
| """Gravity: the row closest to the bottom that's still empty in this |
| column, or None if the column is full.""" |
| for row in range(height - 1, -1, -1): |
| if cells[_rc(row, col, width)] == EMPTY: |
| return row |
| return None |
|
|
|
|
| def _legal_columns(cells, width, height): |
| return [c for c in range(width) if _lowest_empty_row(cells, c, width, height) is not None] |
|
|
|
|
| def _wins_for(cells, value, width, height, win_len): |
| """Whether `value` (AGENT or OPPONENT) has win_len in a row anywhere -- |
| horizontal, vertical, or either diagonal.""" |
| for row in range(height): |
| for col in range(width): |
| if cells[_rc(row, col, width)] != value: |
| continue |
| for dr, dc in ((0, 1), (1, 0), (1, 1), (1, -1)): |
| end_row = row + dr * (win_len - 1) |
| end_col = col + dc * (win_len - 1) |
| if not (0 <= end_row < height and 0 <= end_col < width): |
| continue |
| if all(cells[_rc(row + dr * k, col + dc * k, width)] == value for k in range(win_len)): |
| return True |
| return False |
|
|
|
|
| def _board_full(cells): |
| return all(c != EMPTY for c in cells) |
|
|
|
|
| def _stronger_opponent_move(cells, width, height, win_len): |
| """A second, deliberately stronger deterministic opponent -- same |
| win-now/block-immediate-win base as `_fixed_opponent_move`, plus one |
| more ply: among moves that survive those two checks, avoid any that |
| would hand the AGENT an immediate winning reply next turn, if a safer |
| alternative exists. Mixing this into TRAINING (see |
| `opponent_strong_epsilon` below) gives the value head real exposure to |
| a harder-to-punish opponent, not just noise around the weak one.""" |
| legal = _legal_columns(cells, width, height) |
| for col in legal: |
| row = _lowest_empty_row(cells, col, width, height) |
| trial = list(cells) |
| trial[_rc(row, col, width)] = OPPONENT |
| if _wins_for(trial, OPPONENT, width, height, win_len): |
| return col |
| for col in legal: |
| row = _lowest_empty_row(cells, col, width, height) |
| trial = list(cells) |
| trial[_rc(row, col, width)] = AGENT |
| if _wins_for(trial, AGENT, width, height, win_len): |
| return col |
| safe = [] |
| for col in legal: |
| row = _lowest_empty_row(cells, col, width, height) |
| nxt = list(cells) |
| nxt[_rc(row, col, width)] = OPPONENT |
| if _board_full(nxt): |
| safe.append(col) |
| continue |
| agent_can_win = False |
| for col2 in _legal_columns(nxt, width, height): |
| row2 = _lowest_empty_row(nxt, col2, width, height) |
| trial2 = list(nxt) |
| trial2[_rc(row2, col2, width)] = AGENT |
| if _wins_for(trial2, AGENT, width, height, win_len): |
| agent_can_win = True |
| break |
| if not agent_can_win: |
| safe.append(col) |
| return random.choice(safe) if safe else legal[0] |
|
|
|
|
| def _fixed_opponent_move(cells, width, height, win_len, opponent_epsilon=0.0, opponent_strong_epsilon=0.0, |
| opponent_selfplay_epsilon=0.0, opponent_policy_fn=None): |
| """Deterministic base heuristic: win now if possible, else block the |
| agent's immediate win, else leftmost legal column. |
| |
| `opponent_epsilon`: with this probability, ignore the heuristic and |
| play a uniformly random legal column instead -- diversifies training |
| trajectories (a fully deterministic opponent means every training walk |
| from a matching starting side is the SAME exact game). |
| |
| `opponent_strong_epsilon`: with this probability (checked after the |
| roll above), delegate the whole move to `_stronger_opponent_move` |
| instead -- direct training exposure to a harder opponent, not just |
| noise around the weak one. |
| |
| `opponent_selfplay_epsilon` / `opponent_policy_fn`: with this |
| probability (checked last), delegate to an arbitrary caller-supplied |
| move function -- in practice, a frozen snapshot of this same model's |
| own move choice, viewed from the opponent's side (see |
| `train.make_selfplay_pool_opponent_fn`). This is the actual |
| "self-play" mechanism: every opponent above is a fixed, non-learning |
| heuristic the trained policy eventually plateaus against; self-play |
| is what lets it face something that keeps getting better.""" |
| legal = _legal_columns(cells, width, height) |
| if opponent_epsilon > 0.0 and random.random() < opponent_epsilon: |
| return random.choice(legal) |
| if opponent_strong_epsilon > 0.0 and random.random() < opponent_strong_epsilon: |
| return _stronger_opponent_move(cells, width, height, win_len) |
| if opponent_selfplay_epsilon > 0.0 and opponent_policy_fn is not None \ |
| and random.random() < opponent_selfplay_epsilon: |
| col = opponent_policy_fn(cells) |
| if col in legal: |
| return col |
| for col in legal: |
| row = _lowest_empty_row(cells, col, width, height) |
| trial = list(cells) |
| trial[_rc(row, col, width)] = OPPONENT |
| if _wins_for(trial, OPPONENT, width, height, win_len): |
| return col |
| for col in legal: |
| row = _lowest_empty_row(cells, col, width, height) |
| trial = list(cells) |
| trial[_rc(row, col, width)] = AGENT |
| if _wins_for(trial, AGENT, width, height, win_len): |
| return col |
| return legal[0] |
|
|
|
|
| def is_solved(state, width, height, win_len): |
| return _wins_for(_decode_board(state), AGENT, width, height, win_len) |
|
|
|
|
| def is_legal(state, action_idx, width, height): |
| pass_action = width |
| if action_idx == pass_action: |
| return True |
| if not (0 <= action_idx < width): |
| return False |
| return _lowest_empty_row(_decode_board(state), action_idx, width, height) is not None |
|
|
|
|
| def step(state, action_idx, width, height, win_len, opponent_epsilon=0.0, opponent_strong_epsilon=0.0, |
| opponent_selfplay_epsilon=0.0, opponent_policy_fn=None): |
| pass_action = width |
| cells = list(_decode_board(state)) |
| reward = -1.0 |
|
|
| if action_idx != pass_action: |
| row = _lowest_empty_row(cells, action_idx, width, height) |
| cells[_rc(row, action_idx, width)] = AGENT |
|
|
| if _wins_for(cells, AGENT, width, height, win_len): |
| return _encode_board(cells), reward, True |
| if _board_full(cells): |
| return _encode_board(cells), reward, True |
|
|
| opp_col = _fixed_opponent_move(cells, width, height, win_len, opponent_epsilon=opponent_epsilon, |
| opponent_strong_epsilon=opponent_strong_epsilon, |
| opponent_selfplay_epsilon=opponent_selfplay_epsilon, |
| opponent_policy_fn=opponent_policy_fn) |
| opp_row = _lowest_empty_row(cells, opp_col, width, height) |
| cells[_rc(opp_row, opp_col, width)] = OPPONENT |
|
|
| if _wins_for(cells, OPPONENT, width, height, win_len): |
| return _encode_board(cells), reward, True |
| done = _board_full(cells) |
| return _encode_board(cells), reward, done |
|
|
|
|
| def random_problem(rng, width, height): |
| """Every game starts from an empty board.""" |
| return _encode_board([EMPTY] * (width * height)), None |
|
|
|
|
| def bfs_solve(state, width, height, win_len, max_depth=8): |
| """Exact BFS for a forced win against the fixed opponent baked into |
| `step` -- not a general Connect-4 solver. Returns None above |
| BFS_MAX_CELLS (the real 7x6 board is never attempted).""" |
| if width * height > BFS_MAX_CELLS: |
| return None |
| if is_solved(state, width, height, win_len): |
| return [] |
| frontier = deque([state]) |
| parent = {state: None} |
| action_taken = {} |
| depth = {state: 0} |
| num_actions = width + 1 |
| while frontier: |
| cur = frontier.popleft() |
| if depth[cur] >= max_depth: |
| continue |
| for a_idx in range(num_actions): |
| if not is_legal(cur, a_idx, width, height): |
| continue |
| nxt, _reward, done = step(cur, a_idx, width, height, win_len) |
| if nxt in parent: |
| continue |
| parent[nxt] = cur |
| action_taken[nxt] = a_idx |
| depth[nxt] = depth[cur] + 1 |
| if is_solved(nxt, width, height, win_len): |
| path = [] |
| node = nxt |
| while parent[node] is not None: |
| path.append(action_taken[node]) |
| node = parent[node] |
| path.reverse() |
| return path |
| if not done: |
| frontier.append(nxt) |
| return None |
|
|
|
|
| _CELL_CHAR = {EMPTY: ".", AGENT: "A", OPPONENT: "O"} |
|
|
|
|
| def format_state(state, width, height): |
| cells = _decode_board(state) |
| rows = [" ".join(_CELL_CHAR[cells[_rc(row, col, width)]] for col in range(width)) for row in range(height)] |
| return "\n" + "\n".join(rows) |
|
|
|
|
| def format_action(action_idx, width): |
| return "PASS" if action_idx == width else f"DROP(col={action_idx})" |
|
|
|
|
| class ConnectXEnv(Environment): |
| """width/height/win_len fixed per instance -- state_dim/num_actions |
| depend on them. Default (4x4, win_len=3) is the small, BFS-checkable |
| board this module's own self-test uses; pass width=7, height=6, |
| win_len=4 for the real Kaggle board.""" |
|
|
| def __init__(self, width=DEFAULT_WIDTH, height=DEFAULT_HEIGHT, win_len=DEFAULT_WIN_LEN, |
| opponent_epsilon=0.0, opponent_strong_epsilon=0.0, |
| opponent_selfplay_epsilon=0.0, opponent_policy_fn=None): |
| self.width = width |
| self.height = height |
| self.win_len = win_len |
| |
| |
| |
| |
| |
| self.opponent_epsilon = opponent_epsilon |
| self.opponent_strong_epsilon = opponent_strong_epsilon |
| self.opponent_selfplay_epsilon = opponent_selfplay_epsilon |
| self.opponent_policy_fn = opponent_policy_fn |
|
|
| @property |
| def state_dim(self): |
| return self.width * self.height * CELL_WIDTH |
|
|
| @property |
| def num_actions(self): |
| return self.width + 1 |
|
|
| @property |
| def always_legal_actions(self): |
| return [self.width] |
|
|
| def is_solved(self, state): |
| return is_solved(state, self.width, self.height, self.win_len) |
|
|
| def is_legal(self, state, action_idx): |
| return is_legal(state, action_idx, self.width, self.height) |
|
|
| def step(self, state, action_idx): |
| return step(state, action_idx, self.width, self.height, self.win_len, |
| opponent_epsilon=self.opponent_epsilon, |
| opponent_strong_epsilon=self.opponent_strong_epsilon, |
| opponent_selfplay_epsilon=self.opponent_selfplay_epsilon, |
| opponent_policy_fn=self.opponent_policy_fn) |
|
|
| def random_problem(self, rng, **kwargs): |
| return random_problem(rng, self.width, self.height) |
|
|
| def bfs_solve(self, state, max_depth=8): |
| return bfs_solve(state, self.width, self.height, self.win_len, max_depth=max_depth) |
|
|
| def format_state(self, state): |
| return format_state(state, self.width, self.height) |
|
|
| def format_action(self, action_idx): |
| return format_action(action_idx, self.width) |
|
|
|
|
| if __name__ == "__main__": |
| env = ConnectXEnv() |
| print(f"state_dim={env.state_dim} num_actions={env.num_actions} " |
| f"board={env.width}x{env.height} win_len={env.win_len}\n") |
|
|
| start, _ = env.random_problem(random.Random(0)) |
| print(f"Empty board: {format_state(start, env.width, env.height)}") |
| path = env.bfs_solve(start) |
| assert path is not None, "no forced win found against the fixed opponent from an empty board" |
| print(f"Oracle's forced-win path: {[env.format_action(a) for a in path]} (len={len(path)})") |
| cur = start |
| for a in path: |
| cur, r, done = env.step(cur, a) |
| print(f" after {env.format_action(a)} (reward={r:.0f}, done={done}): {env.format_state(cur)}") |
| assert env.is_solved(cur), "oracle path did not reach a solved (agent-won) state" |
| print("\nCONFIRMED: the exact oracle finds a genuine forced win against the fixed opponent.\n") |
|
|
| print("=== Random-legal-play smoke test (30 games, no crashes, always terminates) ===") |
| rng = random.Random(1) |
| solved_count, loss_count, draw_count = 0, 0, 0 |
| for _i in range(30): |
| state, _ = env.random_problem(rng) |
| for _ in range(env.width * env.height + 1): |
| legal = [a for a in range(env.num_actions) if env.is_legal(state, a)] |
| assert legal, "always_legal_actions guarantee violated -- PASS should always be legal" |
| a = rng.choice([a for a in legal if a != env.width] or legal) |
| state, _r, done = env.step(state, a) |
| if done: |
| break |
| else: |
| raise AssertionError("game did not terminate within the move cap") |
| if env.is_solved(state): |
| solved_count += 1 |
| elif _board_full(_decode_board(state)): |
| draw_count += 1 |
| else: |
| loss_count += 1 |
| print(f"agent wins={solved_count} losses={loss_count} draws={draw_count} (out of 30, random legal play)") |
| print("\nAll games terminated cleanly, always_legal_actions held in every state, no crashes.") |
|
|
| print("\n=== Real-board smoke test (7x6, win_len=4, no BFS oracle at this scale) ===") |
| real_env = ConnectXEnv(width=7, height=6, win_len=4) |
| print(f"state_dim={real_env.state_dim} num_actions={real_env.num_actions}") |
| assert real_env.bfs_solve(real_env.random_problem(random.Random(0))[0]) is None, \ |
| "bfs_solve should return None at real-board scale (no oracle by design)" |
| rng = random.Random(2) |
| state, _ = real_env.random_problem(rng) |
| for _ in range(real_env.width * real_env.height + 1): |
| legal = [a for a in range(real_env.num_actions) if real_env.is_legal(state, a)] |
| assert legal |
| a = rng.choice([a for a in legal if a != real_env.width] or legal) |
| state, _r, done = real_env.step(state, a) |
| if done: |
| break |
| print(real_env.format_state(state)) |
| print("Real-board game ran to completion with no crashes; bfs_solve correctly returns None.") |
|
|