File size: 7,246 Bytes
9ede8c0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
"""
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)