WorldModel-ConnectX / tests /submission_test.py
alextoti1's picture
Publish full project code and checkpoint
9ede8c0 verified
Raw
History Blame Contribute Delete
7.25 kB
"""
Validates connectx_submission.py against a REAL, independent, single-ply
Connect-4 game engine -- deliberately NOT reusing connectx_env.py's
`step` (which bundles our move and its own fixed opponent's reply into
one call, exactly the shortcut this test needs to NOT rely on, since the
whole point is to check the submission's `agent(observation,
configuration)` interface the way Kaggle's real engine actually drives
it: one call per ply, alternating sides, real `board`/`mark`/
`configuration` objects).
Three opponents, since a single opponent can't tell us much:
1. Random-legal-column opponent (a floor -- if this loses to random, the
translation from Kaggle's board format is definitely broken).
2. The SAME fixed heuristic connectx_env.py trains against (win-now /
block / leftmost) -- a sanity check that connectx_train.py's own
100% internal solve-rate result actually reproduces when the
submission is driven through a REAL alternating-turn engine instead
of the training env's bundled step().
3. A slightly stronger heuristic (look-two-ahead: also block if leaving
the opponent a forced win one ply later) -- a first, cheap check of
what this project's own docs already name as the honest limitation
(trained against one fixed weak opponent, no guarantee against a
different/stronger one).
"""
import importlib.util
import random
import sys
from types import SimpleNamespace
WIDTH, HEIGHT, WIN_LEN = 7, 6, 4
def _rc(row, col):
return row * WIDTH + col
def _lowest_empty_row(board, col):
for row in range(HEIGHT - 1, -1, -1):
if board[_rc(row, col)] == 0:
return row
return None
def legal_columns(board):
return [c for c in range(WIDTH) if _lowest_empty_row(board, c) is not None]
def wins_for(board, mark):
for row in range(HEIGHT):
for col in range(WIDTH):
if board[_rc(row, col)] != mark:
continue
for dr, dc in ((0, 1), (1, 0), (1, 1), (1, -1)):
er, ec = row + dr * (WIN_LEN - 1), col + dc * (WIN_LEN - 1)
if not (0 <= er < HEIGHT and 0 <= ec < WIDTH):
continue
if all(board[_rc(row + dr * k, col + dc * k)] == mark for k in range(WIN_LEN)):
return True
return False
def board_full(board):
return all(c != 0 for c in board)
def drop(board, col, mark):
row = _lowest_empty_row(board, col)
board = list(board)
board[_rc(row, col)] = mark
return board
def random_opponent(board, mark, rng):
return rng.choice(legal_columns(board))
def weak_heuristic_opponent(board, mark, rng):
"""The exact policy connectx_env.py bakes into training (win-now /
block / leftmost) -- reimplemented independently here (not imported)
so this test doesn't share a bug with the thing it's checking."""
opp = 2 if mark == 1 else 1
legal = legal_columns(board)
for col in legal:
if wins_for(drop(board, col, mark), mark):
return col
for col in legal:
if wins_for(drop(board, col, opp), opp):
return col
return legal[0]
def stronger_heuristic_opponent(board, mark, rng):
"""weak_heuristic_opponent plus a 1-ply-deeper check: among moves that
pass the first two checks, avoid any move that hands the OPPONENT an
immediate winning reply next turn, if a safer alternative exists."""
opp = 2 if mark == 1 else 1
legal = legal_columns(board)
for col in legal:
if wins_for(drop(board, col, mark), mark):
return col
for col in legal:
if wins_for(drop(board, col, opp), opp):
return col
safe = []
for col in legal:
nxt = drop(board, col, mark)
if board_full(nxt):
safe.append(col)
continue
opp_can_win = any(wins_for(drop(nxt, c2, opp), opp) for c2 in legal_columns(nxt))
if not opp_can_win:
safe.append(col)
return rng.choice(safe) if safe else legal[0]
def _load_agent(path):
spec = importlib.util.spec_from_file_location("connectx_submission", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod.agent
def play_game(our_agent, opponent_fn, our_mark, rng, max_plies=WIDTH * HEIGHT):
board = [0] * (WIDTH * HEIGHT)
opp_mark = 2 if our_mark == 1 else 1
config = SimpleNamespace(columns=WIDTH, rows=HEIGHT, inarow=WIN_LEN)
current = 1 # P1 always moves first
for _ply in range(max_plies):
if current == our_mark:
obs = SimpleNamespace(board=list(board), mark=our_mark)
col = our_agent(obs, config)
if col not in legal_columns(board): # safety net -- must never trigger
col = legal_columns(board)[0]
board = drop(board, col, our_mark)
if wins_for(board, our_mark):
return "win"
else:
col = opponent_fn(board, opp_mark, rng)
board = drop(board, col, opp_mark)
if wins_for(board, opp_mark):
return "loss"
if board_full(board):
return "draw"
current = opp_mark if current == our_mark else our_mark
return "draw"
def run_match(our_agent, opponent_fn, opponent_name, n_games, seed):
"""Each game gets its OWN independently-seeded rng (`seed`, `i`) --
NOT one shared rng consumed sequentially across all `n_games` (the
original version of this function). That original design meant a
different agent taking even one different early action would shift
every subsequent random draw for the REST of the batch, silently
turning "compare agent A vs agent B on the same 60 games" into two
barely-related sequences of games -- confirmed as a real confound,
not a hypothetical one: testing an opening-move change that only
ever affects the very first ply still swung the OVERALL 60-game
win rate by double digits, which a change isolated to ply 0 has no
honest mechanism to cause on its own against a fixed opponent
POLICY (only via this exact RNG-cascade artifact). Per-game seeding
makes different agents' results directly, fairly comparable game-by-
game."""
results = {"win": 0, "loss": 0, "draw": 0}
for i in range(n_games):
rng = random.Random(seed * 100_003 + i) # large odd multiplier, cheap decorrelation across games
our_mark = 1 if i % 2 == 0 else 2 # alternate who moves first
outcome = play_game(our_agent, opponent_fn, our_mark, rng)
results[outcome] += 1
n = n_games
print(f"vs {opponent_name:28s} win={results['win']}/{n} ({results['win']/n:.1%}) "
f"loss={results['loss']}/{n} draw={results['draw']}/{n}")
return results
if __name__ == "__main__":
path = sys.argv[1] if len(sys.argv) > 1 else "submission.py"
our_agent = _load_agent(path)
print(f"Loaded agent from {path}\n")
n_games = 60
run_match(our_agent, random_opponent, "random-legal-play", n_games, seed=0)
run_match(our_agent, weak_heuristic_opponent, "weak heuristic (=training opp)", n_games, seed=1)
run_match(our_agent, stronger_heuristic_opponent, "stronger heuristic (1-ply deeper)", n_games, seed=2)