alextoti1 commited on
Commit
9ede8c0
·
verified ·
1 Parent(s): 0fddfce

Publish full project code and checkpoint

Browse files
.claude/settings.local.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(hf auth *)",
5
+ "Bash(hf upload *)"
6
+ ]
7
+ }
8
+ }
.gitignore ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.egg-info/
8
+
9
+ # Virtual environments
10
+ .venv/
11
+ venv/
12
+ env/
13
+
14
+ # Trained checkpoints -- regenerable via scripts/train.py, deliberately NOT
15
+ # blanket-ignored: checkpoints/connectx_checkpoint.pt (the one actually
16
+ # shipped/deployed) is explicitly kept, everything else here is not.
17
+ checkpoints/*.pt
18
+ !checkpoints/connectx_checkpoint.pt
19
+
20
+ # Local run logs / scratch output
21
+ *.log
22
+ *.out
23
+
24
+ # OS/editor cruft
25
+ .DS_Store
26
+ Thumbs.db
27
+ .idea/
28
+ .vscode/
29
+ *.swp
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexandros Titonis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,3 +1,211 @@
1
- ---
2
- license: mit
3
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ConnectX-WorldModel
2
+
3
+ A reinforcement-learning world model (encoder + latent dynamics + value head)
4
+ combined with real adversarial search and an exact endgame solver, applied to
5
+ Kaggle's **[ConnectX competition](https://kaggle.com/competitions/connectx)**
6
+ (ranked Connect-4, 7-wide x 6-tall board, evergreen/Knowledge-only, no
7
+ deadline).
8
+
9
+ Instead of a hand-written Connect-4 bot, this trains a small model to predict
10
+ what happens next in latent space, then searches over that prediction to pick
11
+ a move — MuZero/AlphaZero-style planning [1][2], applied to a real,
12
+ Kaggle-ranked adversarial game, and trained end to end on a single laptop (RTX
13
+ 4060 laptop GPU, Ryzen AI 9 HX 370, 32GB RAM — no cluster, no cloud run). Full
14
+ write-up, architecture rationale, every bug found along the way, an attempted
15
+ fix for a diagnosed zugzwang weakness, and honest limitations:
16
+ **[`docs/WHITEPAPER.pdf`](docs/WHITEPAPER.pdf)**.
17
+
18
+ This is also not a Connect-4-only architecture — it's one instance of a
19
+ general latent-space-simulation pattern (the same encoder/dynamics/value/
20
+ decoder + search code has been applied to other domains — equation solving,
21
+ constraint-satisfaction logic puzzles, route planning — with zero core-code
22
+ changes, just a new implementation of the small interface in
23
+ `connectx/environment.py`). ConnectX is documented here because it's the
24
+ first genuinely *adversarial* two-player domain this architecture was applied
25
+ to — see the whitepaper's Section 1 for the full framing.
26
+
27
+ ## How it works, briefly
28
+
29
+ 1. **A learned world model** (`connectx/model.py`) — an encoder maps a board
30
+ to a latent vector, a dynamics model predicts the next latent + reward
31
+ given an action, a value head estimates cost-to-go, and a diagnostic
32
+ decoder checks the latent space isn't collapsing. Trained self-supervised
33
+ (`scripts/train.py`, `connectx/train_utils.py`) with **no oracle** — the
34
+ real 7x6 board's game tree is too large to brute-force, so the value head
35
+ trains via on-policy Monte Carlo returns (`connectx/verifier.py`) instead
36
+ of regression against exact labels.
37
+ 2. **Real adversarial search, not latent imagination**
38
+ (`connectx/adversarial_search.py`) — the board's rules are exactly known,
39
+ so rather than asking the trained model to *imagine* the opponent's reply,
40
+ the search enumerates the agent's real legal moves and the opponent's real
41
+ legal replies directly against the actual board simulator
42
+ (`connectx/env.py`), and uses the learned value head only as the leaf
43
+ evaluator. This is the single biggest lever: it alone took the agent from
44
+ struggling against even a fixed heuristic to reliably beating one it never
45
+ trained against.
46
+ 3. **An exact endgame solver**, folded into the same search — once a
47
+ position narrows to a handful of legal columns (which happens naturally
48
+ as a real game fills up), the remaining game tree is small regardless of
49
+ how many moves are left, and gets solved exactly instead of estimated.
50
+ 4. **Episodic memory + LoRA self-play fine-tuning**
51
+ (`connectx/episodic_memory.py`, `connectx/lora.py`,
52
+ `scripts/lora_selfplay_finetune.py`) layered on top, each confirmed to
53
+ help before being kept.
54
+ 5. **A first, honestly-reported attempt at a real diagnosed weakness**
55
+ (Connect-4 zugzwang/parity traps) — built, measured, and shipped as an
56
+ opt-in experimental parameter rather than oversold as solved. See the
57
+ whitepaper's Section 4.
58
+
59
+ ## Results
60
+
61
+ Win rate against an independent, trusted test harness (`tests/submission_test.py`
62
+ — an alternating-turn engine that does **not** reuse the environment's own
63
+ bundled `step()`, deliberately avoiding the shortcut a real submission
64
+ validator needs to avoid). Three opponents: random-legal play, the fixed
65
+ weak heuristic trained against, and a stronger 1-ply-deeper heuristic never
66
+ seen during training.
67
+
68
+ | Configuration | vs random | vs weak heuristic | vs stronger heuristic |
69
+ |---|---|---|---|
70
+ | Latent beam search (original baseline) | 63.3% | 0.0% | 6.7% |
71
+ | Real adversarial search (1 round) | 96.7% | 100.0% | 66.7% |
72
+ | + episodic memory + online learner | 98.3% | 78.3%¹ | 50.0% |
73
+ | + a real bug fixed (loss ≠ draw) | 100.0% | 100.0% | 80.0% |
74
+ | + LoRA self-play fine-tune | — | — | 81.7% |
75
+ | + curriculum self-play (best-ever) | — | — | **85.0%** |
76
+ | + exact endgame solver (current, deployed) | 100.0% | 100.0% | 83.3% |
77
+
78
+ ¹ Explained, not a mystery — that harness ran all opponent blocks
79
+ sequentially in one process, so the online learner had already drifted from
80
+ 60 preceding random-opponent games by the time it reached this block. See
81
+ the whitepaper for the full table, the diagnosis, and every real bug found
82
+ along the way (7 of them — a couple are worth knowing if you build on this).
83
+
84
+ On Kaggle's own real rating: this is a TrueSkill-style score that starts
85
+ uncertain and converges over dozens of real games — don't read a
86
+ freshly-uploaded rating as a verdict. 7x6 Connect-4 is a mathematically
87
+ **solved** game, so the real competitive pool likely includes near-perfect
88
+ solvers; these results demonstrate the architecture works on this domain,
89
+ not a leaderboard-rating prediction.
90
+
91
+ ## Running locally
92
+
93
+ Requires Python 3.10+ and PyTorch (CPU is fine — nothing here needs a GPU;
94
+ the reference results above were produced on a laptop GPU but nothing in the
95
+ code requires one).
96
+
97
+ ```bash
98
+ pip install torch
99
+
100
+ # Self-test the environment (small board, exact BFS oracle) + a real-board smoke test
101
+ python -m connectx.env
102
+
103
+ # Verify the already-trained, shipped agent against the trusted harness
104
+ python -m tests.submission_test
105
+
106
+ # Train a fresh checkpoint from scratch (real 7x6 board, no oracle at this scale, ~1hr on a laptop)
107
+ python -m scripts.train
108
+
109
+ # LoRA self-play fine-tune an existing checkpoint (this is the step that produced the deployed one)
110
+ python -m scripts.lora_selfplay_finetune
111
+
112
+ # Package a checkpoint + offline self-play memory into a self-contained Kaggle submission.py
113
+ python -c "from scripts.build_submission import main; main()"
114
+ ```
115
+
116
+ Run everything from the repository root (not from inside `connectx/` or
117
+ `scripts/`) — every command above is `python -m <package>.<module>`, matching
118
+ the layout below.
119
+
120
+ `submission.py` (repo root) is the actual file to upload to Kaggle as-is —
121
+ it has zero dependencies beyond `torch`/`base64`/`io`/`time`, with the
122
+ trained weights and episodic memory embedded directly in the file.
123
+
124
+ ## Training this for a different game
125
+
126
+ Nothing here is Connect-4-specific beyond `connectx/env.py`. Every other
127
+ file only depends on the small interface `connectx/environment.py` defines:
128
+
129
+ - `state_dim`, `num_actions`, `always_legal_actions` (properties)
130
+ - `is_solved(state)`, `is_legal(state, action_idx)`
131
+ - `step(state, action_idx) -> (next_state, reward, done)`
132
+ - `random_problem(rng) -> (state, answer)`
133
+ - `bfs_solve(state, max_depth=8) -> path or None` (an exact oracle, if one
134
+ exists at a scale small enough to brute-force — return `None`
135
+ unconditionally if it doesn't, the way `env.py` does above `BFS_MAX_CELLS`)
136
+
137
+ To point this at a new game or puzzle:
138
+
139
+ 1. Implement `Environment` for your domain (see `connectx/env.py` for a full
140
+ worked example, including the small-board-with-an-oracle / large-board-
141
+ without-one pattern).
142
+ 2. Run `connectx.train_utils.train_stage1` on your new environment to get a
143
+ working encoder/dynamics/decoder — no oracle needed for this stage in any
144
+ domain.
145
+ 3. **If your domain is single-agent** (a puzzle, not a two-player game):
146
+ the value head can train directly against `bfs_solve` labels if you have
147
+ an oracle, or via `connectx.verifier.train_mc_value_onpolicy` (drop
148
+ `unsolved_penalty`, since a single-agent domain never has a "loss," only
149
+ "unsolved") if you don't.
150
+ 4. **If your domain is adversarial** (two players, like this one): bake a
151
+ fixed opponent into your `step()` first (the "easy path" — see `env.py`'s
152
+ own module docstring) to get the pipeline working end to end, THEN set
153
+ `train_mc_value_onpolicy(..., unsolved_penalty=<something>)` — this is
154
+ the one setting that matters most for an adversarial domain and is
155
+ exactly what bug #2 in the whitepaper was about: without it, the value
156
+ head never sees a single example of "this leads to losing."
157
+ 5. If your domain's rules are exactly known (not something that needs to be
158
+ learned) and the state space is too large for `bfs_solve` to search
159
+ exhaustively at decision time, real search over the real environment
160
+ (`connectx/adversarial_search.py`'s pattern, or the plain single-agent
161
+ search in `connectx/search.py`) will almost always beat asking the
162
+ dynamics model to imagine ahead — that was this project's single biggest
163
+ result.
164
+
165
+ ## Layout
166
+
167
+ ```
168
+ connectx-opensource/
169
+ connectx/ The importable package -- everything domain-agnostic + ConnectX itself
170
+ environment.py The generic Environment interface everything else depends on
171
+ env.py ConnectX itself: board, rules, the fixed training opponent
172
+ model.py Encoder / dynamics / value head / decoder
173
+ train_utils.py Self-supervised stage-1 training (no oracle, no labels)
174
+ verifier.py On-policy Monte Carlo value-head training (no oracle)
175
+ search.py Latent-space search (comparison baseline) + load_checkpoint
176
+ adversarial_search.py The REAL search actually deployed: minimax + exact endgame solver
177
+ + the experimental parity-heuristic attempt (Section 4.1)
178
+ episodic_memory.py k-NN memory over real self-play trajectories (won + lost)
179
+ memory_build.py Builds that memory offline, at packaging time
180
+ lora.py Generic LoRA wrapper
181
+ scripts/ Executable entry points (run as `python -m scripts.<name>`)
182
+ train.py Full training pipeline (stage 1 + stage 2 + self-play)
183
+ lora_selfplay_finetune.py LoRA self-play fine-tune of an existing checkpoint
184
+ build_submission.py Packages a checkpoint + memory into one self-contained submission.py
185
+ tests/
186
+ submission_test.py The trusted, independent test harness
187
+ checkpoints/
188
+ connectx_checkpoint.pt Trained weights
189
+ docs/
190
+ WHITEPAPER.pdf Full write-up: architecture, every bug, all results, limitations
191
+ build_whitepaper.py Regenerates WHITEPAPER.pdf (requires `pip install fpdf2`)
192
+ submission.py THE deployed file — upload this to Kaggle as-is
193
+ LICENSE
194
+ .gitignore
195
+ ```
196
+
197
+ ## Citations
198
+
199
+ Full reference list with page/venue detail is in `docs/WHITEPAPER.pdf`.
200
+ Headline credits: latent-space planning follows MuZero [1] / AlphaZero [2];
201
+ episodic memory follows Model-Free Episodic Control [3] and Neural Episodic
202
+ Control [4]; LoRA fine-tuning follows Hu et al. [5]; the zugzwang/parity
203
+ endgame theory referenced in the whitepaper's case study traces to Victor
204
+ Allis's 1988 solution of Connect-4 [6]; the board/config schema and
205
+ fixed-opponent-in-`step` convention are ported from Kaggle's own ConnectX
206
+ competition [8] and the `kaggle_environments` package [9], not from any
207
+ published agent's code.
208
+
209
+ ## License
210
+
211
+ [MIT](LICENSE).
checkpoints/connectx_checkpoint.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2de769eb71c0c10e917f07fd609210502dc194304a0466630255d11200fbdb83
3
+ size 1831357
connectx/__init__.py ADDED
File without changes
connectx/adversarial_search.py ADDED
@@ -0,0 +1,387 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Real (not latent-imagined) minimax search over the actual board, plus an
3
+ exact endgame solver -- the search this project actually deploys, and the
4
+ main reason it beats the latent-search baseline in `search.py` decisively.
5
+
6
+ Why real board space, not latent imagination: `env.step()` bundles the
7
+ agent's move and the fixed opponent's reply into ONE transition, so the
8
+ trained dynamics model was only ever shown full round-trips as single
9
+ training examples -- it structurally cannot represent "the board right
10
+ after my move, before their reply" as a state, because it never saw that
11
+ state shape. But Connect-4's rules are exactly known (there's a full plain-
12
+ Python board simulator right here), so there's no need to make a neural
13
+ network imagine something 40 lines of Python computes for free. This
14
+ module does the adversarial ply in REAL board space (enumerate the agent's
15
+ real legal moves; for each, enumerate the opponent's real legal replies and
16
+ assume they pick whichever hurts the agent most -- a genuine minimax, not a
17
+ guess) and uses the learned value head ONLY as the leaf evaluator, on a
18
+ real, never-imagined state.
19
+
20
+ The exact endgame solver (`_exact_endgame_solve`) goes one step further:
21
+ once a position narrows down to a handful of legal columns -- which happens
22
+ naturally as a real board fills up -- the remaining game tree is small
23
+ regardless of how many plies are left, and can be solved exactly with no
24
+ learned value head at all. Branching factor is controlled by how many
25
+ columns are legal, not by how many cells are empty, which is what makes
26
+ this cheap even fairly late in a real game.
27
+ """
28
+ import time
29
+
30
+ import torch
31
+
32
+ from .env import EMPTY, AGENT, OPPONENT, _decode_board, _encode_board, _rc, _lowest_empty_row, _legal_columns, _wins_for, _board_full
33
+ from .train_utils import states_to_tensor
34
+ from .search import _evaluate_with_memory
35
+
36
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
37
+
38
+
39
+ def _apply_move(cells, col, mark, width, height):
40
+ row = _lowest_empty_row(cells, col, width, height)
41
+ new_cells = list(cells)
42
+ new_cells[_rc(row, col, width)] = mark
43
+ return new_cells
44
+
45
+
46
+ class _EndgameTimeout(Exception):
47
+ pass
48
+
49
+
50
+ def _exact_endgame_solve(cells0, mover, width, height, win_len, deadline):
51
+ """Memoized alpha-beta minimax to the TRUE end of the game, with
52
+ center-out move ordering for stronger pruning. Bounded by a hard
53
+ wall-clock `deadline` (not a node/depth budget), so a position outside
54
+ the calibrated safe zone (see the caller's `endgame_max_cols` gate)
55
+ fails loudly here (raises internally, caught below, reported as
56
+ `(None, None)`) rather than silently blowing the real move-time
57
+ budget -- the caller is expected to fall back to the round-based
58
+ search whenever this returns `(None, None)`.
59
+
60
+ Calibrated empirically against real Kaggle replay data: 5 legal
61
+ columns / 24 empty cells solves in ~0.4s; 6+ legal columns can take
62
+ 5+ seconds with this plain-Python (no bitboard/transposition-table)
63
+ implementation -- callers should gate on `len(legal_columns) <= 5`
64
+ before calling this at all; the deadline is a second, independent
65
+ safety net, not the only guard.
66
+
67
+ Returns `(best_action, value)`, value from `mover`'s own perspective
68
+ (+1 win / -1 loss / 0 draw), or `(None, None)` if the deadline hit
69
+ before a definite answer was found."""
70
+ memo = {}
71
+ center = (width - 1) / 2
72
+
73
+ def solve(cells, to_move, alpha, beta):
74
+ if time.time() > deadline:
75
+ raise _EndgameTimeout()
76
+ key = (tuple(cells), to_move)
77
+ cached = memo.get(key)
78
+ if cached is not None:
79
+ return cached
80
+ other = OPPONENT if to_move == AGENT else AGENT
81
+ legal = sorted(_legal_columns(cells, width, height), key=lambda c: abs(c - center))
82
+ if not legal:
83
+ memo[key] = 0.0
84
+ return 0.0
85
+ if to_move == AGENT:
86
+ best = -2.0
87
+ for c in legal:
88
+ nxt = _apply_move(cells, c, to_move, width, height)
89
+ if _wins_for(nxt, to_move, width, height, win_len):
90
+ val = 1.0
91
+ elif _board_full(nxt):
92
+ val = 0.0
93
+ else:
94
+ val = solve(nxt, other, alpha, beta)
95
+ best = max(best, val)
96
+ alpha = max(alpha, best)
97
+ if alpha >= beta:
98
+ break
99
+ else:
100
+ best = 2.0
101
+ for c in legal:
102
+ nxt = _apply_move(cells, c, to_move, width, height)
103
+ if _wins_for(nxt, to_move, width, height, win_len):
104
+ val = -1.0
105
+ elif _board_full(nxt):
106
+ val = 0.0
107
+ else:
108
+ val = solve(nxt, other, alpha, beta)
109
+ best = min(best, val)
110
+ beta = min(beta, best)
111
+ if alpha >= beta:
112
+ break
113
+ memo[key] = best
114
+ return best
115
+
116
+ root_legal = _legal_columns(cells0, width, height)
117
+ if not root_legal:
118
+ return None, None
119
+ root_legal = sorted(root_legal, key=lambda c: abs(c - center))
120
+ other = OPPONENT if mover == AGENT else AGENT
121
+ try:
122
+ best_a, best_val = None, None
123
+ for c in root_legal:
124
+ nxt = _apply_move(cells0, c, mover, width, height)
125
+ if _wins_for(nxt, mover, width, height, win_len):
126
+ val = 1.0 if mover == AGENT else -1.0
127
+ elif _board_full(nxt):
128
+ val = 0.0
129
+ else:
130
+ val = solve(nxt, other, -1.0, 1.0)
131
+ if best_val is None or (mover == AGENT and val > best_val) or (mover == OPPONENT and val < best_val):
132
+ best_a, best_val = c, val
133
+ if (mover == AGENT and best_val == 1.0) or (mover == OPPONENT and best_val == -1.0):
134
+ break # proven best-possible outcome -- no need to keep searching
135
+ return best_a, best_val
136
+ except _EndgameTimeout:
137
+ return None, None
138
+
139
+
140
+ def _parity_cost(cells, width, height):
141
+ """A heuristic column-parity feature, in the spirit of classical
142
+ Connect-4 zugzwang/odd-even threat theory (Allis 1988) -- an
143
+ EXPERIMENTAL, opt-in attempt at the "avoid walking into a zugzwang"
144
+ problem this project's whitepaper documents as diagnosed-but-unsolved.
145
+
146
+ For each still-open column with `r` empty cells remaining, under
147
+ NAIVE same-column-only alternation starting with the player about to
148
+ move, the player who ends up placing the TOP piece is: the mover if
149
+ `r` is odd, the other player if `r` is even. Every leaf state this is
150
+ computed on follows a full agent-move-then-opponent-reply round (see
151
+ `real_adversarial_plan_action`'s two-phase leaf collection), so the
152
+ player "about to move" at every leaf is always AGENT -- no need to
153
+ track whose turn it is separately.
154
+
155
+ This is NOT a proof, NOT a guarantee, and NOT full Claimeven (a real
156
+ claimeven strategy requires REACTIVE move-pairing enforced across an
157
+ entire game, not a one-shot column count at a single position) -- it
158
+ is a cheap, directionally-motivated NUDGE: positions with more
159
+ even-parity open columns are scored as costlier (worse for the
160
+ agent), consistent with the theory's own prediction that parity
161
+ structure matters, without claiming this fully captures it. Returns
162
+ a COST (higher = worse for the agent), meant to be ADDED to the
163
+ leaf's existing value estimate, scaled by a small `parity_weight`
164
+ -- see `real_adversarial_plan_action`'s own docstring for the
165
+ honest, measured verdict on whether this actually helps."""
166
+ cost = 0.0
167
+ for c in range(width):
168
+ remaining = sum(1 for row in range(height) if cells[_rc(row, c, width)] == EMPTY)
169
+ if remaining == 0:
170
+ continue
171
+ cost += 1.0 if remaining % 2 == 0 else -1.0
172
+ return cost
173
+
174
+
175
+ def _narrow_to_center(legal_cols, width, max_branching):
176
+ """Prunes a legal-column list down to `max_branching` columns closest
177
+ to center -- real Connect-4 domain knowledge (a center column touches
178
+ more potential 4-in-a-row lines than an edge one). `max_branching=None`
179
+ is a no-op; deeper (rounds=3+) search needs this to stay inside a real
180
+ time budget."""
181
+ if max_branching is None or len(legal_cols) <= max_branching:
182
+ return legal_cols
183
+ center = (width - 1) / 2
184
+ return sorted(legal_cols, key=lambda c: abs(c - center))[:max_branching]
185
+
186
+
187
+ class _RoundSearchTimeout(Exception):
188
+ pass
189
+
190
+
191
+ @torch.no_grad()
192
+ def real_adversarial_plan_action(env, model, normalizer, real_state, memory=None,
193
+ memory_weight=0.25, memory_k=5, max_steps=None, rounds=1,
194
+ max_branching=None, endgame_max_cols=5, endgame_time_budget=1.2,
195
+ parity_weight=0.0, deeper_rounds=None, deeper_max_branching=4,
196
+ deeper_time_budget=0.6):
197
+ """`deeper_rounds`/`deeper_max_branching`/`deeper_time_budget`: a
198
+ real, mined-from-real-games gap this was built for -- `rounds` can
199
+ see zero danger on a position (every column looks equally safe) just
200
+ a couple of plies before a trap that one round DEEPER already
201
+ narrows down to exactly one safe column. A deeper search is provably
202
+ too slow to run on EVERY move (measured 8-13s at a 6-7-legal-column
203
+ branching factor) -- so this is a SAFE, opportunistic escalation, not
204
+ a blanket depth increase: `deeper_rounds=None` (default) reproduces
205
+ the original `rounds`-only behavior byte-for-byte. When set, AFTER
206
+ computing the normal-`rounds` answer (always -- the guaranteed-safe
207
+ fallback), a `deeper_rounds`-round search is attempted under a hard
208
+ `deeper_time_budget` deadline (`_RoundSearchTimeout`, same pattern as
209
+ `_exact_endgame_solve`'s own deadline -- checked at both exponential-
210
+ blowup recursion points AND immediately around the one batched NN
211
+ leaf-evaluation call, which is otherwise uninterruptible once
212
+ started). If it finishes in time, its answer is used instead
213
+ (strictly more information, never less); if it times out, the
214
+ original `rounds`-answer is returned completely unchanged -- this can
215
+ only ever help or be a no-op, never make the chosen move worse or
216
+ blow the real move-time budget by more than `deeper_time_budget`.
217
+ Calibrated to `deeper_time_budget=0.6s` against a 180-game regression
218
+ suite (random/weak/stronger opponents): zero win-rate regression, max
219
+ observed combined move time 1.641s -- comfortably under a 2s budget.
220
+
221
+ `parity_weight` (default 0.0, OFF -- byte-for-byte the original
222
+ behavior unless explicitly enabled): blends `_parity_cost` into every
223
+ leaf's value estimate, scaled by this weight. EXPERIMENTAL -- see
224
+ `_parity_cost`'s own docstring for exactly what this does and doesn't
225
+ claim. Measured (not just theorized) against the trusted test harness
226
+ at a few weights before shipping any non-zero default; see the
227
+ whitepaper for the honest result.
228
+
229
+ `rounds` real adversarial rounds (our move, then the opponent's
230
+ worst-case real reply, repeated) before falling back to the learned
231
+ value head as the leaf evaluator. `rounds=1` is the deployed default.
232
+ Root action never returns PASS.
233
+
234
+ `endgame_max_cols`/`endgame_time_budget`: before running the round-
235
+ based search at all, check whether the position already has few
236
+ enough legal columns for `_exact_endgame_solve` to solve it exactly,
237
+ well within budget. If it finishes in time, its answer is used
238
+ directly (provably optimal); otherwise this falls straight through to
239
+ the round-based search below, unchanged. `endgame_max_cols=0` disables
240
+ this path entirely.
241
+
242
+ Terminal-outcome convention: an opponent win scores `2 * max_steps`
243
+ (worse than merely running out of steps); a draw scores `max_steps` --
244
+ both far above any real achievable remaining-steps value, so they
245
+ never get confused with a genuine near-solved position.
246
+
247
+ Two-phase, globally batched leaf evaluation: rather than calling the
248
+ value head separately at every node in the search tree (expensive --
249
+ each call pays its own tensor-creation/memory-blend overhead), this
250
+ walks the tree TWICE: once (pure Python) to collect every non-
251
+ terminal leaf across the WHOLE tree into one deduplicated set
252
+ (transpositions collapse for free), then ONE batched value+memory
253
+ call, then a second walk doing the actual minimax from the
254
+ precomputed lookup."""
255
+ width, height, win_len = env.width, env.height, env.win_len
256
+ max_steps = max_steps if max_steps is not None else (width * height) // 2 + 2
257
+
258
+ cells_now = list(_decode_board(real_state))
259
+ now_legal = _legal_columns(cells_now, width, height)
260
+ if now_legal and endgame_max_cols and len(now_legal) <= endgame_max_cols:
261
+ exact_a, _exact_val = _exact_endgame_solve(
262
+ cells_now, AGENT, width, height, win_len, deadline=time.time() + endgame_time_budget,
263
+ )
264
+ if exact_a is not None:
265
+ return exact_a
266
+
267
+ def leaf_batch_values(states):
268
+ if not states:
269
+ return {}
270
+ states_t = states_to_tensor([env.observe(s) for s in states]).to(DEVICE)
271
+ norm_t = normalizer.normalize(states_t)
272
+ z = model.encode(norm_t)
273
+ vals = _evaluate_with_memory(model, z, memory, memory_weight, memory_k).tolist()
274
+ if parity_weight:
275
+ vals = [v + parity_weight * _parity_cost(list(_decode_board(s)), width, height)
276
+ for v, s in zip(vals, states)]
277
+ return dict(zip(states, vals))
278
+
279
+ def run_search(search_rounds, search_max_branching, deadline):
280
+ """One full root-to-leaf search at a given (rounds, max_branching)
281
+ setting -- factored out so it can be called at two different
282
+ depths, see `deeper_rounds` above. `deadline` (optional): checked
283
+ at both exponential-blowup recursion points (leaf collection, our
284
+ own follow-up enumeration) AND immediately around the one batched
285
+ NN leaf-evaluation call (otherwise uninterruptible once started)
286
+ -- raises `_RoundSearchTimeout` the instant it's exceeded, letting
287
+ the caller safely abandon this attempt."""
288
+
289
+ def check_deadline():
290
+ if deadline is not None and time.time() > deadline:
291
+ raise _RoundSearchTimeout()
292
+
293
+ def collect_leaves(cells1, remaining_rounds, leaf_cache):
294
+ check_deadline()
295
+ if _board_full(cells1):
296
+ return
297
+ for opp_col in _legal_columns(cells1, width, height):
298
+ cells2 = _apply_move(cells1, opp_col, OPPONENT, width, height)
299
+ if _wins_for(cells2, OPPONENT, width, height, win_len) or _board_full(cells2):
300
+ continue
301
+ if remaining_rounds <= 1:
302
+ leaf_cache[tuple(_encode_board(cells2))] = None
303
+ else:
304
+ for a2 in _narrow_to_center(_legal_columns(cells2, width, height), width, search_max_branching):
305
+ cells3 = _apply_move(cells2, a2, AGENT, width, height)
306
+ if _wins_for(cells3, AGENT, width, height, win_len):
307
+ continue
308
+ collect_leaves(cells3, remaining_rounds - 1, leaf_cache)
309
+
310
+ def score_after_our_move(cells1, remaining_rounds, leaf_cache):
311
+ """cells1: real board right after OUR move. Returns our worst-case
312
+ score -- the opponent picks whichever real reply hurts us most."""
313
+ if _board_full(cells1):
314
+ return float(max_steps)
315
+ vals = []
316
+ for opp_col in _legal_columns(cells1, width, height):
317
+ cells2 = _apply_move(cells1, opp_col, OPPONENT, width, height)
318
+ if _wins_for(cells2, OPPONENT, width, height, win_len):
319
+ vals.append(float(2 * max_steps))
320
+ elif _board_full(cells2):
321
+ vals.append(float(max_steps))
322
+ elif remaining_rounds <= 1:
323
+ vals.append(leaf_cache[tuple(_encode_board(cells2))])
324
+ else:
325
+ vals.append(score_after_opponent_move(cells2, remaining_rounds - 1, leaf_cache))
326
+ return max(vals)
327
+
328
+ def score_after_opponent_move(cells2, remaining_rounds, leaf_cache):
329
+ """cells2: real board after the opponent's move, our turn again.
330
+ Returns our best achievable worst-case score from here."""
331
+ check_deadline()
332
+ our_legal = _narrow_to_center(_legal_columns(cells2, width, height), width, search_max_branching)
333
+ if not our_legal:
334
+ return float(max_steps)
335
+ best = None
336
+ for a in our_legal:
337
+ cells3 = _apply_move(cells2, a, AGENT, width, height)
338
+ if _wins_for(cells3, AGENT, width, height, win_len):
339
+ return -float(max_steps) # a forced win exists deeper -- short-circuit
340
+ s = score_after_our_move(cells3, remaining_rounds, leaf_cache)
341
+ if best is None or s < best:
342
+ best = s
343
+ return best
344
+
345
+ leaf_cache = {}
346
+ for a in surviving_actions:
347
+ collect_leaves(action_cells1[a], search_rounds, leaf_cache)
348
+ check_deadline() # right before the one batched NN call -- don't start it with no budget left
349
+ if leaf_cache:
350
+ leaf_cache.update(leaf_batch_values(list(leaf_cache.keys())))
351
+ check_deadline() # and once more right after -- don't walk the tree on a stale/over-budget result
352
+
353
+ best_a, best_score = None, None
354
+ for a in surviving_actions:
355
+ s = score_after_our_move(action_cells1[a], search_rounds, leaf_cache)
356
+ if best_score is None or s < best_score:
357
+ best_a, best_score = a, s
358
+ return best_a
359
+
360
+ root_legal = _legal_columns(cells_now, width, height)
361
+ if not root_legal:
362
+ return None
363
+ # Center-out root ordering: NOT a pruning change (every legal column is
364
+ # still considered), only fixes which column wins a TIE. Left-to-right
365
+ # order otherwise defaults ties to the LEFTMOST column, an arbitrary
366
+ # bias with no game-theoretic basis -- center columns are the real
367
+ # stronger choice under a tie.
368
+ center = (width - 1) / 2
369
+ root_legal = sorted(root_legal, key=lambda c: abs(c - center))
370
+
371
+ surviving_actions, action_cells1 = [], {}
372
+ for a in root_legal:
373
+ cells1 = _apply_move(cells_now, a, AGENT, width, height)
374
+ if _wins_for(cells1, AGENT, width, height, win_len):
375
+ return a # immediate win -- take it, no need to consider anything else
376
+ surviving_actions.append(a)
377
+ action_cells1[a] = cells1
378
+
379
+ base_a = run_search(rounds, max_branching, deadline=None) # always computed -- the guaranteed-safe fallback
380
+
381
+ if deeper_rounds is not None:
382
+ try:
383
+ return run_search(deeper_rounds, deeper_max_branching, deadline=time.time() + deeper_time_budget)
384
+ except _RoundSearchTimeout:
385
+ pass # deeper attempt didn't finish in time -- fall back to base_a exactly as if deeper_rounds=None
386
+
387
+ return base_a
connectx/env.py ADDED
@@ -0,0 +1,417 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ConnectX environment. Two board sizes, same code:
3
+ - Small (`ConnectXEnv()`, 4x4, win_len=3): scaled down so an exact BFS
4
+ oracle stays tractable -- used for the domain's own self-test below.
5
+ - Real (`ConnectXEnv(width=7, height=6, win_len=4)`, Kaggle's actual board):
6
+ `bfs_solve` returns None unconditionally -- the full game tree isn't
7
+ exhaustively searchable at this scale. Trained via on-policy Monte Carlo
8
+ value learning instead (see verifier.py / train.py), not oracle regression.
9
+
10
+ State: the WIDTH*HEIGHT board, each cell one-hot over {EMPTY, AGENT,
11
+ OPPONENT}. Row 0 = top; dropping into a column fills the lowest (highest
12
+ row index) empty cell, standard Connect-4 gravity.
13
+
14
+ Actions: DROP(column) for each column, plus one always-legal PASS action
15
+ (guarantees `always_legal_actions` is a real, non-empty, unconditional
16
+ subset). num_actions = WIDTH + 1.
17
+
18
+ The fixed training opponent (deterministic unless the epsilon knobs below
19
+ are set): after the agent's move, if the agent didn't already win or fill
20
+ the board, the opponent (1) takes an immediate win if one exists, (2) else
21
+ blocks the agent's immediate win if one exists, (3) else plays the leftmost
22
+ legal column. This is this domain's one honest, named limitation for real
23
+ Kaggle play: the model is trained against THIS specific opponent shape (plus
24
+ diversification, see below), not whatever real opponent Kaggle's matchmaking
25
+ actually pairs it against.
26
+
27
+ Reward: -1 per agent action. "Solved" = the AGENT has win_len in a row after
28
+ its own move. A loss (opponent wins) or a draw is terminal (`done=True`) but
29
+ NOT solved -- this distinction matters: see search.py's comment on why
30
+ trusting `done` alone as "solved" is a real bug for an adversarial domain.
31
+ """
32
+ import random
33
+ from collections import deque
34
+
35
+ from .environment import Environment
36
+
37
+ DEFAULT_WIDTH = 4
38
+ DEFAULT_HEIGHT = 4
39
+ DEFAULT_WIN_LEN = 3
40
+
41
+ EMPTY, AGENT, OPPONENT = 0, 1, 2
42
+ CELL_WIDTH = 3
43
+
44
+ # Above this many cells, exhaustive BFS is not attempted. The small-board
45
+ # default (16 cells) stays well under this; the real board (42 cells) is
46
+ # always above it.
47
+ BFS_MAX_CELLS = 20
48
+
49
+
50
+ def _onehot(idx, n):
51
+ v = [0] * n
52
+ v[idx] = 1
53
+ return tuple(v)
54
+
55
+
56
+ def _onehot_index(bits):
57
+ """Robust to a search-time DECODED state whose slot isn't cleanly
58
+ one-hot (real states, built via `_encode_board`, never hit the
59
+ fallback)."""
60
+ return bits.index(1) if 1 in bits else 0
61
+
62
+
63
+ def _encode_board(cells):
64
+ return tuple(b for c in cells for b in _onehot(c, CELL_WIDTH))
65
+
66
+
67
+ def _decode_board(state):
68
+ cells = []
69
+ off = 0
70
+ while off < len(state):
71
+ cells.append(_onehot_index(state[off:off + CELL_WIDTH]))
72
+ off += CELL_WIDTH
73
+ return cells
74
+
75
+
76
+ def _rc(row, col, width):
77
+ return row * width + col
78
+
79
+
80
+ def _lowest_empty_row(cells, col, width, height):
81
+ """Gravity: the row closest to the bottom that's still empty in this
82
+ column, or None if the column is full."""
83
+ for row in range(height - 1, -1, -1):
84
+ if cells[_rc(row, col, width)] == EMPTY:
85
+ return row
86
+ return None
87
+
88
+
89
+ def _legal_columns(cells, width, height):
90
+ return [c for c in range(width) if _lowest_empty_row(cells, c, width, height) is not None]
91
+
92
+
93
+ def _wins_for(cells, value, width, height, win_len):
94
+ """Whether `value` (AGENT or OPPONENT) has win_len in a row anywhere --
95
+ horizontal, vertical, or either diagonal."""
96
+ for row in range(height):
97
+ for col in range(width):
98
+ if cells[_rc(row, col, width)] != value:
99
+ continue
100
+ for dr, dc in ((0, 1), (1, 0), (1, 1), (1, -1)):
101
+ end_row = row + dr * (win_len - 1)
102
+ end_col = col + dc * (win_len - 1)
103
+ if not (0 <= end_row < height and 0 <= end_col < width):
104
+ continue
105
+ if all(cells[_rc(row + dr * k, col + dc * k, width)] == value for k in range(win_len)):
106
+ return True
107
+ return False
108
+
109
+
110
+ def _board_full(cells):
111
+ return all(c != EMPTY for c in cells)
112
+
113
+
114
+ def _stronger_opponent_move(cells, width, height, win_len):
115
+ """A second, deliberately stronger deterministic opponent -- same
116
+ win-now/block-immediate-win base as `_fixed_opponent_move`, plus one
117
+ more ply: among moves that survive those two checks, avoid any that
118
+ would hand the AGENT an immediate winning reply next turn, if a safer
119
+ alternative exists. Mixing this into TRAINING (see
120
+ `opponent_strong_epsilon` below) gives the value head real exposure to
121
+ a harder-to-punish opponent, not just noise around the weak one."""
122
+ legal = _legal_columns(cells, width, height)
123
+ for col in legal:
124
+ row = _lowest_empty_row(cells, col, width, height)
125
+ trial = list(cells)
126
+ trial[_rc(row, col, width)] = OPPONENT
127
+ if _wins_for(trial, OPPONENT, width, height, win_len):
128
+ return col
129
+ for col in legal:
130
+ row = _lowest_empty_row(cells, col, width, height)
131
+ trial = list(cells)
132
+ trial[_rc(row, col, width)] = AGENT
133
+ if _wins_for(trial, AGENT, width, height, win_len):
134
+ return col
135
+ safe = []
136
+ for col in legal:
137
+ row = _lowest_empty_row(cells, col, width, height)
138
+ nxt = list(cells)
139
+ nxt[_rc(row, col, width)] = OPPONENT
140
+ if _board_full(nxt):
141
+ safe.append(col)
142
+ continue
143
+ agent_can_win = False
144
+ for col2 in _legal_columns(nxt, width, height):
145
+ row2 = _lowest_empty_row(nxt, col2, width, height)
146
+ trial2 = list(nxt)
147
+ trial2[_rc(row2, col2, width)] = AGENT
148
+ if _wins_for(trial2, AGENT, width, height, win_len):
149
+ agent_can_win = True
150
+ break
151
+ if not agent_can_win:
152
+ safe.append(col)
153
+ return random.choice(safe) if safe else legal[0]
154
+
155
+
156
+ def _fixed_opponent_move(cells, width, height, win_len, opponent_epsilon=0.0, opponent_strong_epsilon=0.0,
157
+ opponent_selfplay_epsilon=0.0, opponent_policy_fn=None):
158
+ """Deterministic base heuristic: win now if possible, else block the
159
+ agent's immediate win, else leftmost legal column.
160
+
161
+ `opponent_epsilon`: with this probability, ignore the heuristic and
162
+ play a uniformly random legal column instead -- diversifies training
163
+ trajectories (a fully deterministic opponent means every training walk
164
+ from a matching starting side is the SAME exact game).
165
+
166
+ `opponent_strong_epsilon`: with this probability (checked after the
167
+ roll above), delegate the whole move to `_stronger_opponent_move`
168
+ instead -- direct training exposure to a harder opponent, not just
169
+ noise around the weak one.
170
+
171
+ `opponent_selfplay_epsilon` / `opponent_policy_fn`: with this
172
+ probability (checked last), delegate to an arbitrary caller-supplied
173
+ move function -- in practice, a frozen snapshot of this same model's
174
+ own move choice, viewed from the opponent's side (see
175
+ `train.make_selfplay_pool_opponent_fn`). This is the actual
176
+ "self-play" mechanism: every opponent above is a fixed, non-learning
177
+ heuristic the trained policy eventually plateaus against; self-play
178
+ is what lets it face something that keeps getting better."""
179
+ legal = _legal_columns(cells, width, height)
180
+ if opponent_epsilon > 0.0 and random.random() < opponent_epsilon:
181
+ return random.choice(legal)
182
+ if opponent_strong_epsilon > 0.0 and random.random() < opponent_strong_epsilon:
183
+ return _stronger_opponent_move(cells, width, height, win_len)
184
+ if opponent_selfplay_epsilon > 0.0 and opponent_policy_fn is not None \
185
+ and random.random() < opponent_selfplay_epsilon:
186
+ col = opponent_policy_fn(cells)
187
+ if col in legal:
188
+ return col
189
+ for col in legal:
190
+ row = _lowest_empty_row(cells, col, width, height)
191
+ trial = list(cells)
192
+ trial[_rc(row, col, width)] = OPPONENT
193
+ if _wins_for(trial, OPPONENT, width, height, win_len):
194
+ return col
195
+ for col in legal:
196
+ row = _lowest_empty_row(cells, col, width, height)
197
+ trial = list(cells)
198
+ trial[_rc(row, col, width)] = AGENT
199
+ if _wins_for(trial, AGENT, width, height, win_len):
200
+ return col
201
+ return legal[0]
202
+
203
+
204
+ def is_solved(state, width, height, win_len):
205
+ return _wins_for(_decode_board(state), AGENT, width, height, win_len)
206
+
207
+
208
+ def is_legal(state, action_idx, width, height):
209
+ pass_action = width
210
+ if action_idx == pass_action:
211
+ return True
212
+ if not (0 <= action_idx < width):
213
+ return False
214
+ return _lowest_empty_row(_decode_board(state), action_idx, width, height) is not None
215
+
216
+
217
+ def step(state, action_idx, width, height, win_len, opponent_epsilon=0.0, opponent_strong_epsilon=0.0,
218
+ opponent_selfplay_epsilon=0.0, opponent_policy_fn=None):
219
+ pass_action = width
220
+ cells = list(_decode_board(state))
221
+ reward = -1.0
222
+
223
+ if action_idx != pass_action:
224
+ row = _lowest_empty_row(cells, action_idx, width, height)
225
+ cells[_rc(row, action_idx, width)] = AGENT
226
+
227
+ if _wins_for(cells, AGENT, width, height, win_len):
228
+ return _encode_board(cells), reward, True
229
+ if _board_full(cells):
230
+ return _encode_board(cells), reward, True # draw -- terminal, not solved
231
+
232
+ opp_col = _fixed_opponent_move(cells, width, height, win_len, opponent_epsilon=opponent_epsilon,
233
+ opponent_strong_epsilon=opponent_strong_epsilon,
234
+ opponent_selfplay_epsilon=opponent_selfplay_epsilon,
235
+ opponent_policy_fn=opponent_policy_fn)
236
+ opp_row = _lowest_empty_row(cells, opp_col, width, height)
237
+ cells[_rc(opp_row, opp_col, width)] = OPPONENT
238
+
239
+ if _wins_for(cells, OPPONENT, width, height, win_len):
240
+ return _encode_board(cells), reward, True # loss -- terminal, not solved
241
+ done = _board_full(cells) # draw after opponent's move
242
+ return _encode_board(cells), reward, done
243
+
244
+
245
+ def random_problem(rng, width, height):
246
+ """Every game starts from an empty board."""
247
+ return _encode_board([EMPTY] * (width * height)), None
248
+
249
+
250
+ def bfs_solve(state, width, height, win_len, max_depth=8):
251
+ """Exact BFS for a forced win against the fixed opponent baked into
252
+ `step` -- not a general Connect-4 solver. Returns None above
253
+ BFS_MAX_CELLS (the real 7x6 board is never attempted)."""
254
+ if width * height > BFS_MAX_CELLS:
255
+ return None
256
+ if is_solved(state, width, height, win_len):
257
+ return []
258
+ frontier = deque([state])
259
+ parent = {state: None}
260
+ action_taken = {}
261
+ depth = {state: 0}
262
+ num_actions = width + 1
263
+ while frontier:
264
+ cur = frontier.popleft()
265
+ if depth[cur] >= max_depth:
266
+ continue
267
+ for a_idx in range(num_actions):
268
+ if not is_legal(cur, a_idx, width, height):
269
+ continue
270
+ nxt, _reward, done = step(cur, a_idx, width, height, win_len)
271
+ if nxt in parent:
272
+ continue
273
+ parent[nxt] = cur
274
+ action_taken[nxt] = a_idx
275
+ depth[nxt] = depth[cur] + 1
276
+ if is_solved(nxt, width, height, win_len):
277
+ path = []
278
+ node = nxt
279
+ while parent[node] is not None:
280
+ path.append(action_taken[node])
281
+ node = parent[node]
282
+ path.reverse()
283
+ return path
284
+ if not done: # loss/draw states are terminal dead ends, don't expand
285
+ frontier.append(nxt)
286
+ return None
287
+
288
+
289
+ _CELL_CHAR = {EMPTY: ".", AGENT: "A", OPPONENT: "O"}
290
+
291
+
292
+ def format_state(state, width, height):
293
+ cells = _decode_board(state)
294
+ rows = [" ".join(_CELL_CHAR[cells[_rc(row, col, width)]] for col in range(width)) for row in range(height)]
295
+ return "\n" + "\n".join(rows)
296
+
297
+
298
+ def format_action(action_idx, width):
299
+ return "PASS" if action_idx == width else f"DROP(col={action_idx})"
300
+
301
+
302
+ class ConnectXEnv(Environment):
303
+ """width/height/win_len fixed per instance -- state_dim/num_actions
304
+ depend on them. Default (4x4, win_len=3) is the small, BFS-checkable
305
+ board this module's own self-test uses; pass width=7, height=6,
306
+ win_len=4 for the real Kaggle board."""
307
+
308
+ def __init__(self, width=DEFAULT_WIDTH, height=DEFAULT_HEIGHT, win_len=DEFAULT_WIN_LEN,
309
+ opponent_epsilon=0.0, opponent_strong_epsilon=0.0,
310
+ opponent_selfplay_epsilon=0.0, opponent_policy_fn=None):
311
+ self.width = width
312
+ self.height = height
313
+ self.win_len = win_len
314
+ # Default 0.0 keeps this instance's `step` fully deterministic
315
+ # (required by the small-board self-test's BFS oracle). The real-
316
+ # board TRAINING env sets these > 0; its EVAL env keeps them at
317
+ # the default so it's still graded against the originally-defined
318
+ # fixed opponent.
319
+ self.opponent_epsilon = opponent_epsilon
320
+ self.opponent_strong_epsilon = opponent_strong_epsilon
321
+ self.opponent_selfplay_epsilon = opponent_selfplay_epsilon
322
+ self.opponent_policy_fn = opponent_policy_fn
323
+
324
+ @property
325
+ def state_dim(self):
326
+ return self.width * self.height * CELL_WIDTH
327
+
328
+ @property
329
+ def num_actions(self):
330
+ return self.width + 1
331
+
332
+ @property
333
+ def always_legal_actions(self):
334
+ return [self.width] # PASS
335
+
336
+ def is_solved(self, state):
337
+ return is_solved(state, self.width, self.height, self.win_len)
338
+
339
+ def is_legal(self, state, action_idx):
340
+ return is_legal(state, action_idx, self.width, self.height)
341
+
342
+ def step(self, state, action_idx):
343
+ return step(state, action_idx, self.width, self.height, self.win_len,
344
+ opponent_epsilon=self.opponent_epsilon,
345
+ opponent_strong_epsilon=self.opponent_strong_epsilon,
346
+ opponent_selfplay_epsilon=self.opponent_selfplay_epsilon,
347
+ opponent_policy_fn=self.opponent_policy_fn)
348
+
349
+ def random_problem(self, rng, **kwargs):
350
+ return random_problem(rng, self.width, self.height)
351
+
352
+ def bfs_solve(self, state, max_depth=8):
353
+ return bfs_solve(state, self.width, self.height, self.win_len, max_depth=max_depth)
354
+
355
+ def format_state(self, state):
356
+ return format_state(state, self.width, self.height)
357
+
358
+ def format_action(self, action_idx):
359
+ return format_action(action_idx, self.width)
360
+
361
+
362
+ if __name__ == "__main__":
363
+ env = ConnectXEnv()
364
+ print(f"state_dim={env.state_dim} num_actions={env.num_actions} "
365
+ f"board={env.width}x{env.height} win_len={env.win_len}\n")
366
+
367
+ start, _ = env.random_problem(random.Random(0))
368
+ print(f"Empty board: {format_state(start, env.width, env.height)}")
369
+ path = env.bfs_solve(start)
370
+ assert path is not None, "no forced win found against the fixed opponent from an empty board"
371
+ print(f"Oracle's forced-win path: {[env.format_action(a) for a in path]} (len={len(path)})")
372
+ cur = start
373
+ for a in path:
374
+ cur, r, done = env.step(cur, a)
375
+ print(f" after {env.format_action(a)} (reward={r:.0f}, done={done}): {env.format_state(cur)}")
376
+ assert env.is_solved(cur), "oracle path did not reach a solved (agent-won) state"
377
+ print("\nCONFIRMED: the exact oracle finds a genuine forced win against the fixed opponent.\n")
378
+
379
+ print("=== Random-legal-play smoke test (30 games, no crashes, always terminates) ===")
380
+ rng = random.Random(1)
381
+ solved_count, loss_count, draw_count = 0, 0, 0
382
+ for _i in range(30):
383
+ state, _ = env.random_problem(rng)
384
+ for _ in range(env.width * env.height + 1):
385
+ legal = [a for a in range(env.num_actions) if env.is_legal(state, a)]
386
+ assert legal, "always_legal_actions guarantee violated -- PASS should always be legal"
387
+ a = rng.choice([a for a in legal if a != env.width] or legal)
388
+ state, _r, done = env.step(state, a)
389
+ if done:
390
+ break
391
+ else:
392
+ raise AssertionError("game did not terminate within the move cap")
393
+ if env.is_solved(state):
394
+ solved_count += 1
395
+ elif _board_full(_decode_board(state)):
396
+ draw_count += 1
397
+ else:
398
+ loss_count += 1
399
+ print(f"agent wins={solved_count} losses={loss_count} draws={draw_count} (out of 30, random legal play)")
400
+ print("\nAll games terminated cleanly, always_legal_actions held in every state, no crashes.")
401
+
402
+ print("\n=== Real-board smoke test (7x6, win_len=4, no BFS oracle at this scale) ===")
403
+ real_env = ConnectXEnv(width=7, height=6, win_len=4)
404
+ print(f"state_dim={real_env.state_dim} num_actions={real_env.num_actions}")
405
+ assert real_env.bfs_solve(real_env.random_problem(random.Random(0))[0]) is None, \
406
+ "bfs_solve should return None at real-board scale (no oracle by design)"
407
+ rng = random.Random(2)
408
+ state, _ = real_env.random_problem(rng)
409
+ for _ in range(real_env.width * real_env.height + 1):
410
+ legal = [a for a in range(real_env.num_actions) if real_env.is_legal(state, a)]
411
+ assert legal
412
+ a = rng.choice([a for a in legal if a != real_env.width] or legal)
413
+ state, _r, done = real_env.step(state, a)
414
+ if done:
415
+ break
416
+ print(real_env.format_state(state))
417
+ print("Real-board game ran to completion with no crashes; bfs_solve correctly returns None.")
connectx/environment.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Minimal common interface for a "domain" this codebase can train on. A state
3
+ is any fixed-length tuple of numbers; how those numbers are interpreted is
4
+ entirely up to the implementation below (`connectx_env.py`). Nothing in
5
+ `model.py` / `train_utils.py` / `search.py` needs to change to support a new
6
+ domain that implements this interface.
7
+ """
8
+ from abc import ABC, abstractmethod
9
+
10
+
11
+ class Environment(ABC):
12
+ # Every state built by this codebase is a discrete, exactly-hashable
13
+ # tuple (a one-hot-encoded Connect-4 board) -- kept as a class attribute
14
+ # rather than hardcoded into `search.py`'s cycle-detection logic so a
15
+ # future continuous-state domain could override it there without
16
+ # touching this interface.
17
+ discrete_state = True
18
+
19
+ def observe(self, state):
20
+ """What the model is allowed to see, as a function of the true
21
+ state. Default: full observability (identity). ConnectX is fully
22
+ observable, so this is never overridden -- kept as an explicit
23
+ extension point rather than removed, since every place that feeds
24
+ a state to the model calls this first, not the raw state."""
25
+ return state
26
+
27
+ @property
28
+ @abstractmethod
29
+ def state_dim(self):
30
+ """Length of the fixed-size numeric tuple representing a state."""
31
+
32
+ @property
33
+ @abstractmethod
34
+ def num_actions(self):
35
+ """Size of the fixed, discrete action space."""
36
+
37
+ @property
38
+ @abstractmethod
39
+ def always_legal_actions(self):
40
+ """Action indices legal from EVERY state, unconditionally -- used
41
+ by search's neurosymbolic decode-gate to fall back on safely."""
42
+
43
+ @abstractmethod
44
+ def is_solved(self, state):
45
+ ...
46
+
47
+ @abstractmethod
48
+ def is_legal(self, state, action_idx):
49
+ ...
50
+
51
+ @abstractmethod
52
+ def step(self, state, action_idx):
53
+ """Returns (next_state, reward, done). Assumes legality."""
54
+
55
+ @abstractmethod
56
+ def random_problem(self, rng, **kwargs):
57
+ """Returns (state, answer) -- answer is domain-specific (unused for
58
+ ConnectX, every game starts from the same empty board)."""
59
+
60
+ @abstractmethod
61
+ def bfs_solve(self, state, max_depth=8):
62
+ """Exact oracle: shortest forced win, or None if not found within
63
+ max_depth (or if the state space is too large to search -- see
64
+ connectx_env.py's BFS_MAX_CELLS)."""
65
+
66
+ def format_state(self, state):
67
+ return str(state)
68
+
69
+ def format_action(self, action_idx):
70
+ return str(action_idx)
connectx/episodic_memory.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ A k-NN lookup table over real (encoded state, outcome) pairs from actually-
3
+ played self-play games, consulted alongside the learned value head at
4
+ decision time (see `search._evaluate_with_memory` / `adversarial_search.py`).
5
+ Distinct from the trained model itself -- nothing here is learned, it's a
6
+ cache of real experience the model can fall back on when its own value
7
+ estimate might be shaky.
8
+
9
+ Precedented by episodic control (Blundell et al., "Model-Free Episodic
10
+ Control"; Pritzel et al., "Neural Episodic Control") and case-based
11
+ reasoning, not a novel mechanism.
12
+ """
13
+ import torch
14
+
15
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
16
+
17
+
18
+ class EpisodicMemory:
19
+ """Stores (z, outcome) pairs -- z is a REAL encoded state's latent
20
+ (never a predicted/imagined one, so memory never compounds its own
21
+ errors), outcome is that state's real remaining-steps-to-win (for a
22
+ won game) or a fixed penalty (for a lost/drawn one) -- same label scale
23
+ as the value head, so blending stays consistent."""
24
+
25
+ def __init__(self):
26
+ self._zs = []
27
+ self._outcomes = []
28
+ self._state_keys = []
29
+ self._seen = {} # state_key -> index, for exact-match dedup
30
+ self._trust_scale = None
31
+
32
+ def __len__(self):
33
+ return len(self._zs)
34
+
35
+ def add(self, z, outcome, state_key=None):
36
+ """`state_key`: an optional hashable identity for the real state
37
+ this (z, outcome) came from. When given and already stored, this
38
+ is a revisit of the exact same state -- keep whichever copy has
39
+ the better (smaller) outcome instead of appending a duplicate."""
40
+ if state_key is not None and state_key in self._seen:
41
+ idx = self._seen[state_key]
42
+ if outcome < self._outcomes[idx]:
43
+ self._zs[idx] = z.detach().to("cpu")
44
+ self._outcomes[idx] = float(outcome)
45
+ self._trust_scale = None
46
+ return
47
+ self._zs.append(z.detach().to("cpu"))
48
+ self._outcomes.append(float(outcome))
49
+ self._state_keys.append(state_key)
50
+ if state_key is not None:
51
+ self._seen[state_key] = len(self._zs) - 1
52
+ self._trust_scale = None
53
+
54
+ def _stacked(self):
55
+ return torch.stack(self._zs).to(DEVICE), torch.tensor(self._outcomes, device=DEVICE)
56
+
57
+ @torch.no_grad()
58
+ def trust_scale(self, sample_size=300):
59
+ """A self-calibrating distance scale for this memory's own latent
60
+ space: the median nearest-OTHER-neighbor distance among a random
61
+ subsample of stored points. A query distance much smaller than
62
+ this means "genuinely close match found"; much larger means
63
+ "nothing like this was ever stored." Self-calibrating per
64
+ checkpoint/latent-dim rather than a hand-picked constant."""
65
+ if self._trust_scale is not None:
66
+ return self._trust_scale
67
+ n = len(self._zs)
68
+ if n < 2:
69
+ self._trust_scale = 1.0
70
+ return self._trust_scale
71
+ Z, _outcomes = self._stacked()
72
+ if n > sample_size:
73
+ idx = torch.randperm(n, device=DEVICE)[:sample_size]
74
+ sample = Z[idx]
75
+ else:
76
+ sample = Z
77
+ dists = torch.cdist(sample, Z)
78
+ dists = torch.where(dists > 1e-6, dists, torch.full_like(dists, float("inf")))
79
+ nn_dist = dists.min(dim=1).values
80
+ nn_dist = nn_dist[torch.isfinite(nn_dist)]
81
+ self._trust_scale = nn_dist.median().item() if len(nn_dist) > 0 else 1.0
82
+ return self._trust_scale
83
+
84
+ @torch.no_grad()
85
+ def query_batch(self, zs, k=5):
86
+ """zs: [B, latent_dim]. Returns (blended_estimates [B], trust [B]).
87
+ Each row's blend weights its k nearest stored neighbors by inverse
88
+ distance. `trust` is `exp(-mean_distance / trust_scale)` -- a 0..1
89
+ confidence already normalized against this memory's own typical
90
+ spacing, so callers can scale their blend weight by it directly."""
91
+ Z, outcomes = self._stacked()
92
+ zq = zs.detach().to(DEVICE)
93
+ dists = torch.cdist(zq, Z)
94
+ k = min(k, len(self._zs))
95
+ topk_dists, topk_idx = torch.topk(dists, k, largest=False, dim=1)
96
+ topk_outcomes = outcomes[topk_idx]
97
+ weights = 1.0 / (topk_dists + 1e-2)
98
+ weights = weights / weights.sum(dim=1, keepdim=True)
99
+ blended = (weights * topk_outcomes).sum(dim=1)
100
+ mean_dist = topk_dists.mean(dim=1)
101
+ trust = torch.exp(-mean_dist / self.trust_scale())
102
+ return blended, trust
103
+
104
+
105
+ @torch.no_grad()
106
+ def add_trajectory_from_real_path(model, normalizer, memory, path_states, env=None):
107
+ """Adds every state of an actually-played, WON trajectory as a positive
108
+ (attraction) example -- outcome = real distance to the end of this
109
+ path."""
110
+ from .train_utils import states_to_tensor
111
+
112
+ dedup = env is not None and env.discrete_state
113
+ T = len(path_states) - 1
114
+ for t, s in enumerate(path_states):
115
+ observed = env.observe(s) if env is not None else s
116
+ z = normalizer.normalize(states_to_tensor([observed]).to(DEVICE))
117
+ z = model.encode(z)[0]
118
+ memory.add(z, T - t, state_key=s if dedup else None)
119
+
120
+
121
+ @torch.no_grad()
122
+ def add_negative_trajectory_from_real_path(model, normalizer, memory, path_states, penalty, env=None):
123
+ """Adds every state of a LOST/drawn trajectory as a negative
124
+ (repulsion) example -- every state gets the SAME fixed penalty label,
125
+ deliberately uniform across the whole walk. No new retrieval mechanism
126
+ needed: `EpisodicMemory.query_batch`'s existing k-NN blend already
127
+ treats a nearby HIGH-outcome entry as repulsion by construction, the
128
+ exact mirror of how a low one acts as attraction."""
129
+ from .train_utils import states_to_tensor
130
+
131
+ dedup = env is not None and env.discrete_state
132
+ for s in path_states:
133
+ observed = env.observe(s) if env is not None else s
134
+ z = normalizer.normalize(states_to_tensor([observed]).to(DEVICE))
135
+ z = model.encode(z)[0]
136
+ memory.add(z, float(penalty), state_key=s if dedup else None)
connectx/lora.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Generic LoRA (Low-Rank Adaptation, Hu et al. 2021) wrapper -- module-agnostic,
3
+ works on any nn.Module built from nn.Linear layers. Used here to fine-tune
4
+ the value head under self-play with a capacity-constrained update instead of
5
+ a full-parameter one, which empirically fine-tunes more reliably on a small,
6
+ self-generated data distribution without regressing.
7
+ """
8
+ import torch
9
+ from torch import nn
10
+
11
+
12
+ class LoRALinear(nn.Module):
13
+ """Wraps an existing nn.Linear, freezing its original weight/bias and
14
+ adding a trainable low-rank delta:
15
+ output = frozen_linear(x) + scaling * (x @ A^T) @ B^T
16
+ `B` is initialized to ZERO, so the wrapped layer's output is byte-
17
+ identical to the original before any training happens."""
18
+
19
+ def __init__(self, linear, rank=4, alpha=1.0):
20
+ super().__init__()
21
+ assert isinstance(linear, nn.Linear), f"LoRALinear only wraps nn.Linear, got {type(linear)}"
22
+ self.linear = linear
23
+ for p in self.linear.parameters():
24
+ p.requires_grad = False
25
+ self.rank = rank
26
+ self.scaling = alpha / rank
27
+ device = linear.weight.device
28
+ dtype = linear.weight.dtype
29
+ self.lora_A = nn.Parameter(torch.randn(rank, linear.in_features, device=device, dtype=dtype) * 0.01)
30
+ self.lora_B = nn.Parameter(torch.zeros(linear.out_features, rank, device=device, dtype=dtype))
31
+
32
+ def forward(self, x):
33
+ base = self.linear(x)
34
+ delta = (x @ self.lora_A.t()) @ self.lora_B.t()
35
+ return base + self.scaling * delta
36
+
37
+ def lora_parameters(self):
38
+ return [self.lora_A, self.lora_B]
39
+
40
+ def merge_into_base(self):
41
+ """Fold the current LoRA delta into the frozen base weight and
42
+ reset B to zero -- used to "commit" a trained adapter back into a
43
+ plain nn.Linear-equivalent state so the saved checkpoint is an
44
+ ordinary state_dict, loadable with zero LoRA-awareness downstream."""
45
+ with torch.no_grad():
46
+ delta_w = self.scaling * (self.lora_B @ self.lora_A)
47
+ self.linear.weight.add_(delta_w)
48
+ self.lora_B.zero_()
49
+
50
+
51
+ def apply_lora(module, rank=4, alpha=1.0):
52
+ """Recursively replace every nn.Linear submodule of `module` with a
53
+ LoRALinear wrapper. Returns the new LoRA parameters for the caller's
54
+ optimizer. Modifies `module` in place."""
55
+ lora_params = []
56
+ for name, child in list(module.named_children()):
57
+ if isinstance(child, nn.Linear):
58
+ wrapped = LoRALinear(child, rank=rank, alpha=alpha)
59
+ setattr(module, name, wrapped)
60
+ lora_params.extend(wrapped.lora_parameters())
61
+ else:
62
+ lora_params.extend(apply_lora(child, rank=rank, alpha=alpha))
63
+ return lora_params
connectx/memory_build.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Builds an EpisodicMemory offline, at packaging time, by playing self-play
3
+ games against a MIXED opponent (weak heuristic + random + the stronger
4
+ 1-ply-deeper heuristic) using the model's own real adversarial search.
5
+ WON games are stored as positive (attraction) examples; LOST/drawn games
6
+ are ALSO stored, as negative (repulsion) examples -- deliberately mixed
7
+ opposition, not just the single fixed weak training opponent, so a loss
8
+ has to have actually lost to a real mix of opposition before it gets
9
+ stored as "this is bad," rather than encoding one narrow opponent's
10
+ particular blind spots as universal truth.
11
+ """
12
+ import torch
13
+
14
+ from .env import ConnectXEnv
15
+ from .adversarial_search import real_adversarial_plan_action
16
+ from .episodic_memory import EpisodicMemory, add_trajectory_from_real_path, add_negative_trajectory_from_real_path
17
+
18
+
19
+ @torch.no_grad()
20
+ def build_episodic_memory(env, model, normalizer, rng, n_games=500, opponent_epsilon=0.2,
21
+ opponent_strong_epsilon=0.3, adversarial_rounds=2, penalty=None):
22
+ """`penalty` (default 2x max_steps, deliberately bigger than the value
23
+ head's own unsolved_penalty): these are discrete stored memory points,
24
+ not a training-loss target, so being a bit more emphatic buys sharper
25
+ repulsion without the overfitting risk more gradient steps would
26
+ carry."""
27
+ memory = EpisodicMemory()
28
+ diverse_env = ConnectXEnv(width=env.width, height=env.height, win_len=env.win_len,
29
+ opponent_epsilon=opponent_epsilon, opponent_strong_epsilon=opponent_strong_epsilon)
30
+ max_steps = (env.width * env.height) // 2 + 2
31
+ if penalty is None:
32
+ penalty = 2 * max_steps
33
+ wins, losses = 0, 0
34
+ for _ in range(n_games):
35
+ state, _ = diverse_env.random_problem(rng)
36
+ path_states = [state]
37
+ for _ in range(max_steps):
38
+ if diverse_env.is_solved(state):
39
+ break
40
+ a = real_adversarial_plan_action(diverse_env, model, normalizer, state, rounds=adversarial_rounds)
41
+ if a is None:
42
+ break
43
+ state, _r, done = diverse_env.step(state, a)
44
+ path_states.append(state)
45
+ if done:
46
+ break
47
+ if diverse_env.is_solved(state):
48
+ wins += 1
49
+ add_trajectory_from_real_path(model, normalizer, memory, path_states, env=diverse_env)
50
+ else:
51
+ losses += 1
52
+ add_negative_trajectory_from_real_path(model, normalizer, memory, path_states, penalty, env=diverse_env)
53
+ print(f" built memory from {wins} won + {losses} lost self-play games "
54
+ f"(mixed opponent) -> {len(memory)} stored states")
55
+ return memory
connectx/model.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ The four learned pieces: an encoder (real state -> latent), a dynamics model
3
+ (latent + action -> predicted next latent + reward), a value head (latent ->
4
+ estimated cost-to-go), and a diagnostic decoder (latent -> reconstructed
5
+ state, used only to sanity-check the latent isn't collapsing -- never
6
+ consulted for a real decision at inference time).
7
+
8
+ Config-driven: nothing here is hardcoded to Connect-4 beyond `state_dim`/
9
+ `num_actions`, so this file would work for any domain with a fixed-length
10
+ tuple state and a fixed discrete action space.
11
+ """
12
+ import torch
13
+ import torch.nn as nn
14
+
15
+
16
+ def mlp(dims, out_activation=None):
17
+ layers = []
18
+ for i in range(len(dims) - 1):
19
+ layers.append(nn.Linear(dims[i], dims[i + 1]))
20
+ is_last = i == len(dims) - 2
21
+ if not is_last:
22
+ layers.append(nn.ReLU())
23
+ elif out_activation is not None:
24
+ layers.append(out_activation)
25
+ return nn.Sequential(*layers)
26
+
27
+
28
+ class Encoder(nn.Module):
29
+ def __init__(self, state_dim, latent_dim, hidden_dim=128):
30
+ super().__init__()
31
+ self.net = mlp([state_dim, hidden_dim, hidden_dim, latent_dim])
32
+
33
+ def forward(self, state):
34
+ return self.net(state)
35
+
36
+
37
+ class DynamicsModel(nn.Module):
38
+ def __init__(self, latent_dim, num_actions, hidden_dim=128):
39
+ super().__init__()
40
+ self.num_actions = num_actions
41
+ self.trunk = mlp([latent_dim + num_actions, hidden_dim, hidden_dim])
42
+ self.next_latent_head = nn.Linear(hidden_dim, latent_dim)
43
+ self.reward_head = nn.Linear(hidden_dim, 1)
44
+
45
+ def forward(self, z, action_idx):
46
+ action_onehot = nn.functional.one_hot(action_idx, self.num_actions).float()
47
+ h = self.trunk(torch.cat([z, action_onehot], dim=-1))
48
+ next_z = self.next_latent_head(h)
49
+ reward = self.reward_head(h).squeeze(-1)
50
+ return next_z, reward
51
+
52
+
53
+ class ValueHead(nn.Module):
54
+ def __init__(self, latent_dim, hidden_dim=128):
55
+ super().__init__()
56
+ self.net = mlp([latent_dim, hidden_dim, hidden_dim, 1])
57
+
58
+ def forward(self, z):
59
+ return self.net(z).squeeze(-1)
60
+
61
+
62
+ class Decoder(nn.Module):
63
+ def __init__(self, latent_dim, state_dim, hidden_dim=128):
64
+ super().__init__()
65
+ self.net = mlp([latent_dim, hidden_dim, hidden_dim, state_dim])
66
+
67
+ def forward(self, z):
68
+ return self.net(z)
69
+
70
+
71
+ class WorldModel(nn.Module):
72
+ def __init__(self, state_dim, num_actions, latent_dim=64, hidden_dim=128):
73
+ super().__init__()
74
+ self.state_dim = state_dim
75
+ self.num_actions = num_actions
76
+ self.latent_dim = latent_dim
77
+ self.encoder = Encoder(state_dim, latent_dim, hidden_dim)
78
+ self.dynamics = DynamicsModel(latent_dim, num_actions, hidden_dim)
79
+ self.value = ValueHead(latent_dim, hidden_dim)
80
+ self.decoder = Decoder(latent_dim, state_dim, hidden_dim)
81
+ # Value-target normalization stats: NOT learned, set once by
82
+ # whichever value-training pass runs (see verifier.py's
83
+ # train_mc_value_onpolicy) from the actual label distribution it
84
+ # sees. Buffers (not plain attributes) so they save/load with the
85
+ # checkpoint automatically.
86
+ self.register_buffer("value_target_mean", torch.tensor(0.0))
87
+ self.register_buffer("value_target_std", torch.tensor(1.0))
88
+
89
+ def encode(self, state):
90
+ return self.encoder(state)
91
+
92
+ def imagine_step(self, z, action_idx):
93
+ return self.dynamics(z, action_idx)
94
+
95
+ def evaluate(self, z):
96
+ """Always returns real-scale value estimates (remaining cost, same
97
+ units `imagine_step`'s predicted reward uses) -- the head internally
98
+ predicts a normalized target, denormalized here so no caller needs
99
+ to know normalization is happening."""
100
+ raw = self.value(z)
101
+ return raw * self.value_target_std + self.value_target_mean
102
+
103
+ def reconstruct(self, z):
104
+ return self.decoder(z)
connectx/search.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Latent-space lookahead solver ("Baseline A" at depth=1, real branching
3
+ search at depth>1) -- this is the ORIGINAL search approach this codebase
4
+ started with, kept here as the comparison baseline `adversarial_search.py`
5
+ (the real approach actually deployed) is measured against. See the
6
+ whitepaper's results table for why real board-space search decisively beats
7
+ this for an adversarial domain.
8
+
9
+ The neurosymbolic decode-gate (`caution` below) exists because an earlier
10
+ version that let the dynamics model imagine arbitrarily deep with no
11
+ legality check at all let it extrapolate to state/action combinations it
12
+ never saw during training, corrupting even the very first move's score.
13
+ With probability `caution`, each beam entry's latent is decoded back to an
14
+ estimated real state (via the trained decoder) and the REAL legal-action
15
+ mask is computed from that -- decoding is used only to filter which moves
16
+ are allowed, never to make the value judgement itself, which stays fully
17
+ latent.
18
+ """
19
+ import random
20
+
21
+ import torch
22
+
23
+ from .model import WorldModel
24
+ from .train_utils import StateNormalizer, states_to_tensor
25
+
26
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
27
+
28
+
29
+ def load_checkpoint(path):
30
+ ckpt = torch.load(path, map_location=DEVICE, weights_only=False)
31
+ model = WorldModel(
32
+ state_dim=ckpt["state_dim"],
33
+ num_actions=ckpt["num_actions"],
34
+ latent_dim=ckpt["latent_dim"],
35
+ hidden_dim=ckpt.get("hidden_dim", 256),
36
+ ).to(DEVICE)
37
+ model.load_state_dict(ckpt["model_state"], strict=False)
38
+ model.eval()
39
+
40
+ normalizer = StateNormalizer.__new__(StateNormalizer)
41
+ normalizer.mean = ckpt["norm_mean"].to(DEVICE)
42
+ normalizer.std = ckpt["norm_std"].to(DEVICE)
43
+ return model, normalizer
44
+
45
+
46
+ class VisitedStates:
47
+ """Plain hash-set cycle detector -- ConnectX is fully discrete, so this
48
+ is always an O(1) membership check."""
49
+
50
+ def __init__(self, initial_state):
51
+ self._set = {initial_state}
52
+
53
+ def __contains__(self, state):
54
+ return state in self._set
55
+
56
+ def add(self, state):
57
+ self._set.add(state)
58
+
59
+
60
+ def _evaluate_with_memory(model, cand_z, memory, memory_weight, memory_k):
61
+ """model.evaluate(cand_z), optionally blended with an EpisodicMemory's
62
+ k-NN lookup at the same latents. `memory=None` is the exact original
63
+ behavior with zero overhead. Blend weight is scaled by the memory's own
64
+ self-calibrated `trust` (~1 for a genuinely close match, ~0 for nothing
65
+ similar ever stored), so a distant, irrelevant neighbor doesn't get
66
+ blended in at the same weight as a close one."""
67
+ values = model.evaluate(cand_z)
68
+ if memory is not None and len(memory) > 0 and memory_weight > 0:
69
+ blended, trust = memory.query_batch(cand_z, k=memory_k)
70
+ w = memory_weight * trust
71
+ values = (1 - w) * values + w * blended
72
+ return values
73
+
74
+
75
+ @torch.no_grad()
76
+ def plan_action(env, model, normalizer, real_state, depth=3, beam_width=8, caution=1.0, rng=None,
77
+ memory=None, memory_weight=0.25, memory_k=5):
78
+ """Best first action for real_state, chosen by beam search purely in
79
+ latent space. depth=1 reduces exactly to Baseline A (no lookahead
80
+ beyond the immediate predicted next state)."""
81
+ rng = rng or random
82
+
83
+ root_legal = [a for a in range(env.num_actions) if env.is_legal(real_state, a)]
84
+ if not root_legal:
85
+ return None
86
+
87
+ z0 = normalizer.normalize(states_to_tensor([env.observe(real_state)]).to(DEVICE))
88
+ z0 = model.encode(z0)[0]
89
+
90
+ # beam entries: (predicted_z, action_seq, cumulative_predicted_reward)
91
+ beam = [(z0, [], 0.0)]
92
+
93
+ for step in range(depth):
94
+ if step == 0:
95
+ allowed_per_entry = [root_legal]
96
+ elif rng.random() < caution:
97
+ zs = torch.stack([z for z, _seq, _r in beam])
98
+ decoded = normalizer.denormalize(model.reconstruct(zs)).round()
99
+ allowed_per_entry = []
100
+ for row in decoded.tolist():
101
+ decoded_state = tuple(int(x) for x in row)
102
+ legal = [a for a in range(env.num_actions) if env.is_legal(decoded_state, a)]
103
+ allowed_per_entry.append(legal if legal else env.always_legal_actions)
104
+ else:
105
+ allowed_per_entry = [env.always_legal_actions] * len(beam)
106
+
107
+ candidates = []
108
+ for (z, seq, cum_r), allowed in zip(beam, allowed_per_entry):
109
+ z_batch = z.unsqueeze(0).repeat(len(allowed), 1)
110
+ a_batch = torch.tensor(allowed, dtype=torch.long, device=DEVICE)
111
+ next_z_batch, reward_batch = model.imagine_step(z_batch, a_batch)
112
+ for i, a in enumerate(allowed):
113
+ candidates.append((next_z_batch[i], seq + [a], cum_r + reward_batch[i].item()))
114
+
115
+ if not candidates:
116
+ break
117
+
118
+ cand_z = torch.stack([c[0] for c in candidates])
119
+ values = _evaluate_with_memory(model, cand_z, memory, memory_weight, memory_k)
120
+ cum_rewards = torch.tensor([c[2] for c in candidates], dtype=torch.float32, device=DEVICE)
121
+ scores = -cum_rewards + values # value is a cost estimate; combine with accumulated reward
122
+
123
+ k = min(beam_width, len(candidates))
124
+ top_idx = torch.topk(scores, k, largest=False).indices.tolist()
125
+ beam = [(candidates[i][0], candidates[i][1], candidates[i][2]) for i in top_idx]
126
+
127
+ final_z = torch.stack([b[0] for b in beam])
128
+ final_values = _evaluate_with_memory(model, final_z, memory, memory_weight, memory_k)
129
+ final_cum_rewards = torch.tensor([b[2] for b in beam], dtype=torch.float32, device=DEVICE)
130
+ final_scores = -final_cum_rewards + final_values
131
+ best_idx = torch.argmin(final_scores).item()
132
+ return beam[best_idx][1][0]
133
+
134
+
135
+ def solve_with_search_counted(env, model, normalizer, state, depth, beam_width, caution=1.0, max_total_steps=12,
136
+ memory=None, memory_weight=0.25, memory_k=5):
137
+ """Runs `plan_action` step by step against the real environment,
138
+ tracking visited states to fail fast on a cycle.
139
+
140
+ The `is_solved` check on a `done` step is NOT a redundant safety net --
141
+ it's a real, previously-fixed bug class: `done=True` fires on a LOSS or
142
+ a DRAW in this domain, not just a win (unlike every single-agent puzzle
143
+ domain, where a dead end is simply never marked done, so `done` and
144
+ `is_solved` always agreed). Trusting `done` alone here silently counted
145
+ real losses as wins."""
146
+ cur = state
147
+ visited = VisitedStates(state)
148
+ for i in range(max_total_steps):
149
+ if env.is_solved(cur):
150
+ return True, i
151
+ a = plan_action(env, model, normalizer, cur, depth=depth, beam_width=beam_width, caution=caution,
152
+ memory=memory, memory_weight=memory_weight, memory_k=memory_k)
153
+ if a is None:
154
+ return False, None
155
+ next_state, _, done = env.step(cur, a)
156
+ if next_state in visited:
157
+ return False, None
158
+ visited.add(next_state)
159
+ cur = next_state
160
+ if done:
161
+ return env.is_solved(cur), (i + 1 if env.is_solved(cur) else None)
162
+ return env.is_solved(cur), (max_total_steps if env.is_solved(cur) else None)
163
+
164
+
165
+ def evaluate(env, model, normalizer, problems, depth, beam_width, caution=1.0, max_total_steps=12, label="",
166
+ memory=None, memory_weight=0.25, memory_k=5):
167
+ solved = 0
168
+ total_steps = 0
169
+ for state, _answer in problems:
170
+ ok, steps = solve_with_search_counted(env, model, normalizer, state, depth, beam_width, caution,
171
+ max_total_steps, memory=memory, memory_weight=memory_weight,
172
+ memory_k=memory_k)
173
+ if ok:
174
+ solved += 1
175
+ total_steps += steps
176
+ n = len(problems)
177
+ avg_steps = total_steps / solved if solved else float("nan")
178
+ print(f"{label:30s} solve_rate={solved/n:.3f} ({solved}/{n}) avg_steps_when_solved={avg_steps:.2f}")
179
+ return solved / n, avg_steps
connectx/train_utils.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Stage 1: self-supervised training of encoder + dynamics + decoder on random
3
+ (state, action, next_state, reward) transitions and multi-step rollouts --
4
+ no labels, no oracle, works for any domain implementing `environment.py`'s
5
+ `Environment` interface.
6
+
7
+ (Value-head training -- "stage 2" -- lives in `verifier.py`, since ConnectX
8
+ uses on-policy Monte Carlo value learning rather than an oracle-labeled
9
+ regression: there's no tractable exact solver at the real 7x6 board scale.)
10
+ """
11
+ import torch
12
+ import torch.nn as nn
13
+
14
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
15
+
16
+
17
+ def sample_legal_action(env, rng, state):
18
+ legal = [i for i in range(env.num_actions) if env.is_legal(state, i)]
19
+ return rng.choice(legal)
20
+
21
+
22
+ def generate_transitions(env, rng, n_problems=4000, walk_len=6, **problem_kwargs):
23
+ """Random-walk from random problem starts, collecting (s, a, s', r)
24
+ along the way -- covers realistic reachable states, not just problem
25
+ starts."""
26
+ transitions = []
27
+ for _ in range(n_problems):
28
+ state, _ = env.random_problem(rng, **problem_kwargs)
29
+ for _ in range(walk_len):
30
+ if env.is_solved(state):
31
+ break
32
+ a_idx = sample_legal_action(env, rng, state)
33
+ next_state, reward, done = env.step(state, a_idx)
34
+ transitions.append((state, a_idx, next_state, reward))
35
+ state = next_state
36
+ if done:
37
+ break
38
+ return transitions
39
+
40
+
41
+ def generate_rollout_sequences(env, rng, n_problems=4000, k=3, **problem_kwargs):
42
+ """K-step (states, actions) sequences for the unrolled/open-loop
43
+ training objective below."""
44
+ sequences = []
45
+ for _ in range(n_problems):
46
+ state, _ = env.random_problem(rng, **problem_kwargs)
47
+ states = [state]
48
+ actions = []
49
+ cur = state
50
+ for _ in range(k):
51
+ a_idx = sample_legal_action(env, rng, cur)
52
+ cur, _, _ = env.step(cur, a_idx)
53
+ actions.append(a_idx)
54
+ states.append(cur)
55
+ sequences.append((states, actions))
56
+ return sequences
57
+
58
+
59
+ class StateNormalizer:
60
+ def __init__(self, states):
61
+ t = torch.tensor(states, dtype=torch.float32)
62
+ self.mean = t.mean(dim=0)
63
+ self.std = t.std(dim=0).clamp_min(1e-3)
64
+
65
+ def to(self, device):
66
+ self.mean = self.mean.to(device)
67
+ self.std = self.std.to(device)
68
+ return self
69
+
70
+ def normalize(self, state_tensor):
71
+ return (state_tensor - self.mean) / self.std
72
+
73
+ def denormalize(self, norm_tensor):
74
+ return norm_tensor * self.std + self.mean
75
+
76
+
77
+ def states_to_tensor(states):
78
+ return torch.tensor([list(s) for s in states], dtype=torch.float32)
79
+
80
+
81
+ def train_stage1(model, normalizer, transitions, sequences=None, k=3,
82
+ epochs=400, batch_size=256, lr=1e-3):
83
+ """`sequences` (see generate_rollout_sequences) adds a k-step UNROLLED
84
+ loss: the dynamics model is chained k times, feeding its own predicted
85
+ latent back in as input at each step (never re-encoding the real
86
+ intermediate state). This matches how the model is actually used at
87
+ inference (chained multi-step search) -- training on 1-step transitions
88
+ alone leaves compounding rollout error unaddressed."""
89
+ state_dim = normalizer.mean.shape[0]
90
+ states = states_to_tensor([t[0] for t in transitions]).to(DEVICE)
91
+ actions = torch.tensor([t[1] for t in transitions], dtype=torch.long).to(DEVICE)
92
+ next_states = states_to_tensor([t[2] for t in transitions]).to(DEVICE)
93
+ rewards = torch.tensor([t[3] for t in transitions], dtype=torch.float32).to(DEVICE)
94
+
95
+ norm_states = normalizer.normalize(states)
96
+ norm_next_states = normalizer.normalize(next_states)
97
+
98
+ if sequences is not None:
99
+ seq_states_raw = torch.stack([states_to_tensor(s) for s, _ in sequences]).to(DEVICE) # [N, k+1, D]
100
+ seq_actions = torch.tensor([a for _, a in sequences], dtype=torch.long).to(DEVICE) # [N, k]
101
+ norm_seq_states = normalizer.normalize(seq_states_raw.view(-1, state_dim)).view(seq_states_raw.shape)
102
+
103
+ n = states.shape[0]
104
+ opt = torch.optim.Adam(
105
+ list(model.encoder.parameters()) + list(model.dynamics.parameters()) + list(model.decoder.parameters()),
106
+ lr=lr,
107
+ )
108
+ sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs)
109
+ mse = nn.MSELoss()
110
+
111
+ for epoch in range(epochs):
112
+ perm = torch.randperm(n, device=DEVICE)
113
+ total_loss = 0.0
114
+ for i in range(0, n, batch_size):
115
+ idx = perm[i:i + batch_size]
116
+ s, a, s_next, r = norm_states[idx], actions[idx], norm_next_states[idx], rewards[idx]
117
+
118
+ z = model.encode(s)
119
+ z_next_target = model.encode(s_next)
120
+ pred_next_z, pred_reward = model.imagine_step(z, a)
121
+
122
+ recon = model.reconstruct(z)
123
+ recon_next_from_dynamics = model.reconstruct(pred_next_z)
124
+
125
+ loss = (
126
+ mse(recon, s)
127
+ + mse(pred_next_z, z_next_target)
128
+ + mse(recon_next_from_dynamics, s_next)
129
+ + mse(pred_reward, r)
130
+ )
131
+
132
+ opt.zero_grad()
133
+ loss.backward()
134
+ opt.step()
135
+ total_loss += loss.item() * idx.shape[0]
136
+
137
+ if sequences is not None:
138
+ n_seq = norm_seq_states.shape[0]
139
+ seq_perm = torch.randperm(n_seq, device=DEVICE)
140
+ unrolled_total = 0.0
141
+ for i in range(0, n_seq, batch_size):
142
+ sidx = seq_perm[i:i + batch_size]
143
+ seq_s = norm_seq_states[sidx] # [B, k+1, D]
144
+ seq_a = seq_actions[sidx] # [B, k]
145
+
146
+ z = model.encode(seq_s[:, 0, :])
147
+ loss_unrolled = 0.0
148
+ for step in range(k):
149
+ z, _pred_r = model.imagine_step(z, seq_a[:, step])
150
+ target = seq_s[:, step + 1, :]
151
+ target_z = model.encode(target)
152
+ loss_unrolled = loss_unrolled + mse(z, target_z) + mse(model.reconstruct(z), target)
153
+ loss_unrolled = loss_unrolled / k
154
+
155
+ opt.zero_grad()
156
+ loss_unrolled.backward()
157
+ opt.step()
158
+ unrolled_total += loss_unrolled.item() * sidx.shape[0]
159
+
160
+ sched.step()
161
+ if (epoch + 1) % 20 == 0 or epoch == 0:
162
+ msg = f" [stage1] epoch {epoch+1:3d}/{epochs} loss={total_loss/n:.4f}"
163
+ if sequences is not None:
164
+ msg += f" unrolled_loss={unrolled_total/n_seq:.4f}"
165
+ print(msg)
166
+
167
+
168
+ @torch.no_grad()
169
+ def eval_stage1(model, normalizer, transitions):
170
+ """Decoder reconstruction / dynamics-rollout exact-match accuracy (after
171
+ rounding to the nearest integer) -- the diagnostic that checks the
172
+ latent isn't collapsing to something the decoder can't read back out."""
173
+ states = states_to_tensor([t[0] for t in transitions]).to(DEVICE)
174
+ actions = torch.tensor([t[1] for t in transitions], dtype=torch.long).to(DEVICE)
175
+ next_states = states_to_tensor([t[2] for t in transitions]).to(DEVICE)
176
+
177
+ norm_states = normalizer.normalize(states)
178
+ z = model.encode(norm_states)
179
+ recon = normalizer.denormalize(model.reconstruct(z))
180
+ pred_next_z, _ = model.imagine_step(z, actions)
181
+ recon_next = normalizer.denormalize(model.reconstruct(pred_next_z))
182
+
183
+ recon_acc = (recon.round() == states).all(dim=1).float().mean().item()
184
+ dyn_acc = (recon_next.round() == next_states).all(dim=1).float().mean().item()
185
+ return {"decoder_recon_exact_acc": recon_acc, "dynamics_rollout_exact_acc": dyn_acc}
186
+
187
+
188
+ @torch.no_grad()
189
+ def eval_multistep_rollout(model, normalizer, sequences, k):
190
+ """Chained (open-loop) rollout exact-match accuracy at each step 1..k --
191
+ exposes compounding error the way a single-step eval can't."""
192
+ state_dim = normalizer.mean.shape[0]
193
+ seq_states_raw = torch.stack([states_to_tensor(s) for s, _ in sequences]).to(DEVICE)
194
+ seq_actions = torch.tensor([a for _, a in sequences], dtype=torch.long).to(DEVICE)
195
+ norm_seq_states = normalizer.normalize(seq_states_raw.view(-1, state_dim)).view(seq_states_raw.shape)
196
+
197
+ z = model.encode(norm_seq_states[:, 0, :])
198
+ results = {}
199
+ for step in range(k):
200
+ z, _ = model.imagine_step(z, seq_actions[:, step])
201
+ recon = normalizer.denormalize(model.reconstruct(z))
202
+ real = seq_states_raw[:, step + 1, :]
203
+ acc = (recon.round() == real).all(dim=1).float().mean().item()
204
+ results[f"k={step+1}"] = acc
205
+ return results
connectx/verifier.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ On-policy Monte Carlo value-head training -- no oracle, no bootstrapping,
3
+ no target network. Walk the environment with the current value head's own
4
+ epsilon-greedy policy, and label every visited state along a walk that
5
+ actually reached a solved state with its REALIZED return (what happened,
6
+ not a network's own possibly-wrong estimate of what happens next). This is
7
+ the only value-training method used for ConnectX, since the real 7x6 board
8
+ has no tractable exact solver to regress against.
9
+
10
+ `unsolved_penalty` is what makes this work for an ADVERSARIAL domain
11
+ specifically: `env.step` can return `done=True` on a LOSS (the opponent
12
+ won) or a draw, not just our own win. Discarding every one of those walks
13
+ (the natural default for a single-agent puzzle, where "unsolved" just means
14
+ "further away") would mean the value head never sees a single labeled
15
+ example of "this leads to losing" -- exactly the signal an adversarial
16
+ domain needs to learn to avoid bad moves.
17
+ """
18
+ import collections
19
+
20
+ import torch
21
+ import torch.nn as nn
22
+
23
+ from .train_utils import states_to_tensor, DEVICE
24
+
25
+
26
+ def _observed_states_to_tensor(env, states):
27
+ return states_to_tensor([env.observe(s) for s in states])
28
+
29
+
30
+ def _greedy_walk_action(env, model, normalizer, state, rng, epsilon):
31
+ """Epsilon-greedy action choice using the value head's own 1-step
32
+ lookahead, scored the same way `search.plan_action`'s depth=1 does
33
+ (`-reward + value`, using the REAL reward/next-state from `env.step`,
34
+ not an imagined one) -- so training optimizes for the exact decision
35
+ rule actually used at inference."""
36
+ legal = [a for a in range(env.num_actions) if env.is_legal(state, a)]
37
+ if rng.random() < epsilon:
38
+ return rng.choice(legal)
39
+ next_states, rewards = [], []
40
+ for a in legal:
41
+ ns, r, _done = env.step(state, a)
42
+ next_states.append(ns)
43
+ rewards.append(r)
44
+ z = model.encode(normalizer.normalize(_observed_states_to_tensor(env, next_states).to(DEVICE)))
45
+ values = model.evaluate(z)
46
+ rewards_t = torch.tensor(rewards, dtype=torch.float32, device=DEVICE)
47
+ scores = -rewards_t + values
48
+ return legal[torch.argmin(scores).item()]
49
+
50
+
51
+ def generate_mc_walks(env, model, normalizer, rng, n_problems, max_steps, epsilon,
52
+ unsolved_penalty=None, **problem_kwargs):
53
+ """Complete walks (up to max_steps) using the current value head's
54
+ epsilon-greedy policy. A walk that reaches `is_solved` labels every
55
+ visited state with its real steps-remaining. A walk that doesn't
56
+ (a loss, a draw, or simply running out of steps) is discarded UNLESS
57
+ `unsolved_penalty` is set, in which case every state along it gets that
58
+ fixed, uniform label instead -- deliberately not scaled by how early or
59
+ late the walk went wrong; a state that's actually fine keeps
60
+ reappearing in OTHER (solved) walks too, so its label self-corrects
61
+ over many rounds rather than staying pinned to one bad walk's worst
62
+ case."""
63
+ labeled = []
64
+ for _ in range(n_problems):
65
+ state, _ = env.random_problem(rng, **problem_kwargs)
66
+ path_states = [state]
67
+ for _ in range(max_steps):
68
+ if env.is_solved(state):
69
+ break
70
+ a = _greedy_walk_action(env, model, normalizer, state, rng, epsilon)
71
+ state, _reward, done = env.step(state, a)
72
+ path_states.append(state)
73
+ if done:
74
+ break
75
+ if env.is_solved(path_states[-1]):
76
+ T = len(path_states) - 1
77
+ for t, s in enumerate(path_states):
78
+ labeled.append((s, float(T - t)))
79
+ elif unsolved_penalty is not None:
80
+ for s in path_states:
81
+ labeled.append((s, float(unsolved_penalty)))
82
+ return labeled
83
+
84
+
85
+ def train_mc_value_onpolicy(env, model, normalizer, rng, n_rounds=15, n_problems_per_round=400,
86
+ max_steps=10, epochs_per_round=40, lr=1e-3,
87
+ epsilon_start=1.0, epsilon_end=0.05, warmup_rounds=3,
88
+ replay_capacity=20000, min_replay_before_train=30,
89
+ unsolved_penalty=None, verbose_every=5, **problem_kwargs):
90
+ """`epsilon_start=1.0` + `warmup_rounds`: a freshly-initialized value
91
+ head's "greedy" choice is pure noise, which can be WORSE than uniform
92
+ random at stumbling into a solved state by chance. Holding epsilon at
93
+ 1.0 (pure random walk) for the first `warmup_rounds` guarantees a
94
+ baseline solve rate to bootstrap training from, before annealing toward
95
+ exploitation."""
96
+ opt = torch.optim.Adam(model.value.parameters(), lr=lr)
97
+ buffer = collections.deque(maxlen=replay_capacity)
98
+
99
+ for round_idx in range(n_rounds):
100
+ if round_idx < warmup_rounds:
101
+ epsilon = 1.0
102
+ else:
103
+ progress = (round_idx - warmup_rounds) / max(1, n_rounds - 1 - warmup_rounds)
104
+ epsilon = epsilon_start + (epsilon_end - epsilon_start) * progress
105
+ labeled = generate_mc_walks(env, model, normalizer, rng, n_problems_per_round, max_steps, epsilon,
106
+ unsolved_penalty=unsolved_penalty, **problem_kwargs)
107
+ buffer.extend(labeled)
108
+ if len(buffer) < min_replay_before_train:
109
+ if verbose_every:
110
+ print(f" [mc-onpolicy] round {round_idx+1}/{n_rounds} epsilon={epsilon:.2f} "
111
+ f"only {len(buffer)} labeled states so far (need {min_replay_before_train}) -- skipping fit")
112
+ continue
113
+
114
+ all_data = list(buffer)
115
+ states_t = _observed_states_to_tensor(env, [s for s, _ in all_data]).to(DEVICE)
116
+ returns_t = torch.tensor([r for _, r in all_data], dtype=torch.float32, device=DEVICE)
117
+
118
+ model.value_target_mean.copy_(returns_t.mean())
119
+ model.value_target_std.copy_(returns_t.std().clamp(min=1e-3))
120
+ returns_norm = (returns_t - model.value_target_mean) / model.value_target_std
121
+
122
+ with torch.no_grad():
123
+ z_states = model.encode(normalizer.normalize(states_t))
124
+
125
+ n = len(all_data)
126
+ for _epoch in range(epochs_per_round):
127
+ perm = torch.randperm(n, device=DEVICE)
128
+ pred_norm = model.value(z_states[perm])
129
+ loss = nn.functional.mse_loss(pred_norm, returns_norm[perm])
130
+ opt.zero_grad()
131
+ loss.backward()
132
+ opt.step()
133
+
134
+ if verbose_every and (round_idx + 1) % verbose_every == 0:
135
+ print(f" [mc-onpolicy] round {round_idx+1}/{n_rounds} epsilon={epsilon:.2f} "
136
+ f"buffer_size={n} loss={loss.item():.4f} return_mean={model.value_target_mean.item():.2f}")
docs/WHITEPAPER.pdf ADDED
Binary file (14.7 kB). View file
 
docs/build_whitepaper.py ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # One-off build script for WHITEPAPER.pdf -- not part of the codebase
2
+ # itself, run once to (re)generate the PDF from this content. Requires
3
+ # `pip install fpdf2`. Run from the docs/ directory: `python build_whitepaper.py`.
4
+ from fpdf import FPDF
5
+
6
+ TITLE = "Latent-Space Simulation Meets a Real Adversary: A World-Model Approach to Kaggle ConnectX"
7
+ AUTHOR = "Alexandros Titonis"
8
+
9
+ pdf = FPDF(format="A4")
10
+ pdf.set_auto_page_break(auto=True, margin=20)
11
+ pdf.set_margins(20, 20, 20)
12
+ pdf.add_page()
13
+ pdf.set_font("Helvetica", "B", 18)
14
+ pdf.multi_cell(0, 9, TITLE)
15
+ pdf.ln(2)
16
+ pdf.set_font("Helvetica", "", 11)
17
+ pdf.set_text_color(90, 90, 90)
18
+ pdf.multi_cell(0, 6, "A reinforcement-learning world model, real adversarial search, and an exact "
19
+ "endgame solver applied to Kaggle's ConnectX competition.")
20
+ pdf.ln(1)
21
+ pdf.multi_cell(0, 6, f"{AUTHOR} | Competition: https://kaggle.com/competitions/connectx")
22
+ pdf.set_text_color(0, 0, 0)
23
+ pdf.ln(4)
24
+
25
+
26
+ def h1(text):
27
+ pdf.set_font("Helvetica", "B", 14)
28
+ pdf.ln(4)
29
+ pdf.multi_cell(0, 8, text)
30
+ pdf.ln(1)
31
+
32
+
33
+ def h2(text):
34
+ pdf.set_font("Helvetica", "B", 12)
35
+ pdf.ln(2)
36
+ pdf.multi_cell(0, 7, text)
37
+ pdf.ln(1)
38
+
39
+
40
+ def p(text):
41
+ pdf.set_font("Helvetica", "", 10.5)
42
+ pdf.multi_cell(0, 5.6, text)
43
+ pdf.ln(1.5)
44
+
45
+
46
+ def bullet(text):
47
+ pdf.set_font("Helvetica", "", 10.5)
48
+ pdf.set_x(pdf.l_margin + 4)
49
+ pdf.multi_cell(0, 5.6, "- " + text)
50
+ pdf.ln(0.5)
51
+
52
+
53
+ def table(headers, rows, widths, body_size=9):
54
+ """Proper multi-line table: wraps each cell's text to its column width
55
+ FIRST (via a dry-run multi_cell), takes the max line count in the row,
56
+ then draws every cell's border/text at that shared row height -- avoids
57
+ fpdf2's plain `cell()` silently misaligning columns when text embeds a
58
+ newline or wraps."""
59
+ line_h = 5.0
60
+ pdf.set_font("Helvetica", "B", 9.5)
61
+ header_h = 7.0
62
+ x_start, y_start = pdf.get_x(), pdf.get_y()
63
+ x = x_start
64
+ for htext, w in zip(headers, widths):
65
+ pdf.set_xy(x, y_start)
66
+ pdf.cell(w, header_h, htext, border=1)
67
+ x += w
68
+ pdf.set_xy(x_start, y_start + header_h)
69
+
70
+ pdf.set_font("Helvetica", "", body_size)
71
+ for row in rows:
72
+ cell_lines = []
73
+ for text, w in zip(row, widths):
74
+ lines = pdf.multi_cell(w - 2, line_h, text, dry_run=True, output="LINES")
75
+ cell_lines.append(lines)
76
+ n_lines = max(len(lns) for lns in cell_lines)
77
+ row_h = n_lines * line_h
78
+ x_start, y_start = pdf.get_x(), pdf.get_y()
79
+ if y_start + row_h > pdf.page_break_trigger:
80
+ pdf.add_page()
81
+ x_start, y_start = pdf.get_x(), pdf.get_y()
82
+ x = x_start
83
+ for lines, w in zip(cell_lines, widths):
84
+ pdf.rect(x, y_start, w, row_h)
85
+ for i, line in enumerate(lines):
86
+ pdf.set_xy(x + 1, y_start + i * line_h)
87
+ pdf.cell(w - 2, line_h, line)
88
+ x += w
89
+ pdf.set_xy(x_start, y_start + row_h)
90
+ pdf.ln(4)
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ h1("1. What this is, and what it isn't")
95
+ p("The core idea: instead of generating a move token-by-token or via a hand-written heuristic, "
96
+ "a small learned model (encoder + latent dynamics + value head + a diagnostic decoder) rolls "
97
+ "forward candidate moves in latent space, and a search procedure picks the move whose imagined "
98
+ "future looks best -- MuZero-style planning [1].")
99
+ p("This is one instance of a GENERAL latent-space-simulation architecture, not a Connect-4-specific "
100
+ "system: the same encoder/dynamics/value/decoder + search pattern used here has also been applied "
101
+ "to algebraic equation solving, constraint-satisfaction-style logic puzzles, and route-planning "
102
+ "problems, with zero domain-specific changes to the core training/search code -- swapping domains "
103
+ "means implementing one small interface (see `environment.py`), not rewriting the architecture. "
104
+ "ConnectX is the instance documented in this paper because it is the first genuinely ADVERSARIAL "
105
+ "two-player domain the architecture was applied to: a single-agent puzzle solver only ever has to "
106
+ "be right, while a Connect-4 agent has to be right against an opponent actively trying to make it "
107
+ "wrong. That one change breaks assumptions the rest of the pipeline had never been asked to "
108
+ "question, and is the actual subject of this write-up.")
109
+ p("This is NOT a hand-written Connect-4 bot. The board simulator and rules are exact (no need to "
110
+ "make a neural network learn something 40 lines of Python computes for free), but every DECISION "
111
+ "-- what a position is worth, which past games are relevant, when the value head should update "
112
+ "online -- comes from a learned model trained without a Connect-4-specific oracle.")
113
+
114
+ h2("1.1 How this compares to typical RL game-playing systems")
115
+ p("Systems in the AlphaZero/MuZero lineage [1][2] are normally trained at large scale: thousands of "
116
+ "self-play games generated in parallel across many machines (often TPU or GPU clusters), running "
117
+ "for days. This project's result was trained on a single consumer laptop -- see Section 1.2 for "
118
+ "the exact specs -- with a full training-pipeline run (stage 1 + stage 2 + self-play) confirmed at "
119
+ "roughly one hour of wall-clock time, and on the order of tens of thousands of self-play games, not "
120
+ "millions. This is presented honestly as evidence that the ARCHITECTURE (real search over exactly-"
121
+ "known rules, substituting for an expensive learned-world-model rollout) is a genuinely efficient "
122
+ "way to reach competent play in a domain with known rules -- not a claim that this matches "
123
+ "AlphaZero-level strength; Section 6 states plainly where this still falls short of a fully-solved "
124
+ "or superhuman opponent.")
125
+
126
+ h2("1.2 Training hardware")
127
+ p("Every result in this paper was produced on a single machine: an RTX 4060 laptop GPU, a Ryzen AI "
128
+ "9 HX 370 CPU, and 32GB RAM. No cluster, no cloud training run, no multi-GPU parallelism -- "
129
+ "everything in this repository, including a full from-scratch training run, was reproduced on that "
130
+ "one laptop.")
131
+
132
+ h1("2. Architecture")
133
+
134
+ h2("2.1 The easy path: collapse the adversary into an ordinary MDP")
135
+ p("The cheapest way to test whether this works at all: bake a fixed, non-learning opponent policy "
136
+ "directly into the environment's step() function. From the agent's perspective this is then "
137
+ "indistinguishable from a single-agent puzzle -- it picks an action, the environment returns the "
138
+ "resulting state (already reflecting the opponent's reply). Zero changes needed to the rest of "
139
+ "the pipeline. At the real 7x6 board scale there is no exact oracle (the game tree is too large "
140
+ "for brute-force search), so the value head is trained via on-policy Monte Carlo returns instead "
141
+ "of regression against oracle labels.")
142
+
143
+ h2("2.2 Why the dynamics model can't imagine \"after my move, before theirs\"")
144
+ p("step() bundles the agent's move and the fixed opponent's reply into ONE transition, so the "
145
+ "trained dynamics model only ever saw full round-trips as single training examples -- it "
146
+ "structurally cannot represent \"the board immediately after my move, before their reply\" as a "
147
+ "state, because it never saw that state shape. Asking it to imagine that intermediate state would "
148
+ "be extrapolating outside its training distribution.")
149
+
150
+ h2("2.3 The fix: real search over the real board, learned value only at the leaf")
151
+ p("Since Connect-4's rules are exactly known, there's no need to make the network imagine anything "
152
+ "mid-ply. The deployed search does the adversarial ply in REAL board space: enumerate the agent's "
153
+ "real legal moves; for each, enumerate the opponent's real legal replies and assume they pick "
154
+ "whichever hurts the agent most (a genuine minimax, not a single guess). The learned value head is "
155
+ "used only as the LEAF evaluator, on a real, never-imagined state. This is structurally closer to "
156
+ "AlphaZero's design (real search + a learned value net, viable because the rules are exactly "
157
+ "known) [2] than MuZero's (search over a LEARNED model, needed specifically when true dynamics "
158
+ "are not available) [1].")
159
+ p("Result of this one architectural change: 95-100% win rate vs. random play, 100% vs. the fixed "
160
+ "weak training heuristic, up to 66.7% vs. a stronger 1-ply-deeper heuristic never seen during "
161
+ "training -- beating every latent-search configuration tried on every metric simultaneously (the "
162
+ "best latent-search result had been 75% / 50% / 11.7%).")
163
+
164
+ h2("2.4 The exact endgame solver")
165
+ p("Real search over real board states opens a door latent-space search never could: once a position "
166
+ "narrows down to a handful of legal columns -- which happens naturally as the board fills -- the "
167
+ "remaining game tree is small regardless of how many plies are left, and can be SOLVED EXACTLY, "
168
+ "no learned value head, no guessing. The solver is a memoized alpha-beta minimax with center-out "
169
+ "move ordering, triggered whenever 5 or fewer columns remain legal (calibrated empirically against "
170
+ "real Kaggle replay data: 5 columns / 24 empty cells solves in under half a second; 6 columns did "
171
+ "not finish inside a 5-second cap with this plain-Python implementation). It is bounded by a hard "
172
+ "wall-clock deadline as an independent safety net -- a position outside the calibrated safe zone "
173
+ "falls through to the round-based search rather than risking the real move-time budget.")
174
+ p("The insight that made this cheap: branching factor is controlled by how many columns are LEGAL, "
175
+ "not by how many cells are EMPTY. A position can have twenty or more empty cells and still be "
176
+ "trivial to solve exactly if only a few columns remain open -- exactly the shape a real Connect-4 "
177
+ "endgame takes once most of the board has filled.")
178
+
179
+ h2("2.5 Episodic memory, LoRA fine-tuning, and online learning")
180
+ p("Three more general-purpose mechanisms were applied on top of the search:")
181
+ bullet("Episodic memory: a k-NN lookup over real self-play trajectories, both won and lost (a "
182
+ "negative/repulsion signal, not just positive examples), blended into the value estimate at "
183
+ "decision time [3][4]. Small but real positive effect vs. random play; no measurable effect "
184
+ "vs. either fixed heuristic opponent.")
185
+ bullet("LoRA self-play fine-tuning: capacity-constrained low-rank deltas applied to the value head "
186
+ "during self-play [5], rather than an unconstrained further gradient update -- 75.0% to "
187
+ "81.7% win rate vs. the stronger heuristic.")
188
+ bullet("Curriculum self-play: fixing a real gap where the stronger heuristic was never actually "
189
+ "mixed into self-play training at all -- 81.7% to 85.0%, the best-confirmed result for this "
190
+ "domain.")
191
+ bullet("Best-effort online learning (value head only, updated live as real games are played): "
192
+ "isolated via a clean A/B test (the same submission loaded twice, one copy's update call "
193
+ "disabled) -- currently measures exactly 0.0% effect against the synthetic test-opponent set "
194
+ "at evaluation scale. Not evidence it is useless in general -- Kaggle's real opponent pool is "
195
+ "far wider than three fixed synthetic opponents -- just an honest, now-measured answer.")
196
+
197
+ h1("3. Every bug found and fixed")
198
+ p("Seven real, generic-class bugs were found and fixed during development. Each is recorded here "
199
+ "because several are the kind of mistake that is easy to make again in a different adversarial "
200
+ "domain.")
201
+ table(
202
+ ["#", "Bug", "Found via"],
203
+ [
204
+ ["1", "Internal eval reported a false 100% win rate (done was assumed to imply a win)",
205
+ "Tracing one \"solved\" game through an independent alternating-turn harness"],
206
+ ["2", "The value head never saw a losing example (unsolved walks were silently discarded)",
207
+ "Root-cause tracing after bug 1"],
208
+ ["3", "Every prediction in the packaged submission was silently corrupted (a spurious extra ReLU)",
209
+ "Diffing the packaged output directly against the real model, node by node"],
210
+ ["4", "\"Same seed\" training runs were not actually reproducible (unseeded global RNG)",
211
+ "Two same-seed runs produced different results"],
212
+ ["5", "A whole session's A/B comparisons were confounded (one shared RNG across a batch)",
213
+ "An opening-move-only change swung the overall win rate by double digits"],
214
+ ["6", "A left-column tie-break bias with no game-theoretic basis",
215
+ "Direct user-observed pattern in real play"],
216
+ ["7", "The online learner taught the value head that losing and drawing are equally bad",
217
+ "Mining real Kaggle replay data via the Kaggle API for missed blocks"],
218
+ ],
219
+ [10, 88, 72],
220
+ )
221
+ p("The generalizable lesson: bugs 2 and 7 are the same mistake, made twice, in two different places. "
222
+ "\"Treat a loss and a draw identically\" is a natural default everywhere ELSE in a single-agent "
223
+ "pipeline (every prior puzzle domain has exactly one failure mode -- unsolved -- with no "
224
+ "distinction worth making), and it takes deliberate effort to remember that an adversarial domain "
225
+ "has two qualitatively different bad outcomes.")
226
+
227
+ h1("4. Case study: diagnosing a real loss (zugzwang), and a first attempt at a fix")
228
+ p("A live, observed loss -- an opponent slowly building what looked, in hindsight, like an "
229
+ "obviously winning diagonal, with the agent apparently ignoring it -- was root-caused not by "
230
+ "guesswork but by pulling the actual Kaggle replay and solving the real endgame exhaustively, "
231
+ "working backward through the game.")
232
+ p("Finding: the position was already a forced loss with 23 empty cells still on the board -- at "
233
+ "least 15 real plies before the deployed 2-round search could possibly have seen it coming. The "
234
+ "mechanism is a genuine, textbook Connect-4 ZUGZWANG / PARITY TRAP (classic odd/even threat "
235
+ "theory [6]): once only two columns remained legal for an extended stretch, which player is "
236
+ "forced to place the fatal piece is decided purely by the parity of total remaining cells across "
237
+ "those two columns -- invisible to any bounded-depth positional search, and not addressed by the "
238
+ "exact endgame solver above (its own calibrated safe zone starts well inside the window where "
239
+ "this trap was already unavoidable).")
240
+
241
+ h2("4.1 A parity-heuristic attempt -- tried, measured, and honestly a mixed result")
242
+ p("A column-parity feature was built and tested directly, rather than left purely theoretical: for "
243
+ "each still-open column with r empty cells, under naive same-column-only alternation the player "
244
+ "who would place the TOP piece is the mover if r is odd, the other player if r is even. This gives "
245
+ "a cheap, real-valued \"parity cost\" per board position -- more even-parity open columns scored as "
246
+ "worse for the agent -- blended into the leaf value estimate at a tunable weight.")
247
+ p("This is explicitly NOT full Claimeven: a genuine claimeven strategy requires REACTIVE move-"
248
+ "pairing enforced across an entire game, not a one-shot column count at a single position. It was "
249
+ "built and measured as a testable nudge, not assumed to work because the theory motivates it.")
250
+ p("Measured against the trusted test harness at several weights: the feature produces a REAL, "
251
+ "repeatable effect, but not a clean win. At weight 0.5, win rate vs. the stronger heuristic rose "
252
+ "from 70.0% to 77.5% -- but win rate vs. random-legal play fell from 100.0% to 90.0% in the same "
253
+ "run. Smaller weights (0.1-0.3) showed the same pattern at reduced magnitude in both directions. "
254
+ "Net honest verdict: this specific heuristic trades performance against weaker opponents for a "
255
+ "partial, not fully consistent gain against a stronger one -- a real, measured signal in the "
256
+ "direction the theory predicts, but not yet a confirmed fix. It ships in this repository as an "
257
+ "opt-in, OFF-by-default parameter (`parity_weight=0.0` in `adversarial_search.py`) rather than a "
258
+ "new default, exactly so a reader can reproduce this exact finding rather than take it on faith.")
259
+ p("Two angles remain genuinely untried: a value head deliberately trained on zugzwang-rich self-play "
260
+ "positions (so the pattern becomes an implicit learned feature rather than a hand-tuned scalar "
261
+ "nudge), and a full, correctly reactive Claimeven implementation rather than a single-position "
262
+ "heuristic proxy.")
263
+
264
+ h1("5. Results")
265
+ p("All win rates below are against an independent, trusted test harness (an alternating-turn engine "
266
+ "that does NOT reuse the environment's own bundled step() -- exactly the shortcut a submission "
267
+ "validator needs to avoid). Three opponents throughout: random-legal play, the fixed weak "
268
+ "heuristic the model trained against, and a 1-ply-deeper \"stronger\" heuristic never seen during "
269
+ "training.")
270
+ table(
271
+ ["Configuration", "vs random", "vs weak heur.", "vs stronger heur."],
272
+ [
273
+ ["Latent beam search, depth=1 (original)", "63.3%", "0.0%", "6.7%"],
274
+ ["Latent beam search, depth=3", "55.0%", "0.0%", "1.7%"],
275
+ ["Real adversarial search (1 round)", "96.7%", "100.0%", "66.7%"],
276
+ ["+ memory + online learner (bug 7 present)", "98.3%", "78.3%*", "50.0%"],
277
+ ["+ bug 7 (loss-penalty) fixed", "100.0%", "100.0%", "80.0%"],
278
+ ["+ LoRA self-play fine-tune", "-", "-", "81.7%"],
279
+ ["+ curriculum self-play (best-ever)", "-", "-", "85.0%"],
280
+ ["+ exact endgame solver (current, deployed)", "100.0%", "100.0%", "83.3%"],
281
+ ],
282
+ [95, 30, 32, 33],
283
+ )
284
+ p("* The weak-heuristic dip in that one row is explained, not a mystery: that harness ran all "
285
+ "opponent blocks sequentially in one process, so the online learner had already drifted from 60 "
286
+ "preceding random-opponent games by the time it reached this block.")
287
+ p("On Kaggle's own rating (a TrueSkill-style Gaussian score that starts uncertain and converges "
288
+ "over dozens of real games against other real submissions): a freshly-uploaded submission's "
289
+ "rating is not comparable to one that has had a day to settle. Confirmed directly during "
290
+ "development -- a previously-deployed submission itself started near 300 right after upload and "
291
+ "climbed past 450 only after 27 real games. 7x6 Connect-4 is a mathematically SOLVED game (the "
292
+ "first player wins with perfect play) [6], so the real competitive pool likely includes near-"
293
+ "perfect solvers -- the results above demonstrate the ARCHITECTURE works on this domain; they "
294
+ "should not be read as a leaderboard-rating prediction.")
295
+
296
+ h1("6. Honest limitations")
297
+ bullet("The zugzwang-avoidance problem (Section 4) is diagnosed and a first fix attempt is measured, "
298
+ "but not solved -- see Section 4.1's own honest verdict.")
299
+ bullet("Online learning's real-world value is unconfirmed -- it measures zero effect against three "
300
+ "fixed synthetic opponents, which is not the same as zero effect against Kaggle's actual, "
301
+ "much wider real pool.")
302
+ bullet("Deeper search beyond 2 rounds (without the endgame solver) was tried and found WORSE, not "
303
+ "just unexplored -- unpruned 3-round search blows the time budget, and the only pruning "
304
+ "width that stayed safe tested worse than plain 2-round search.")
305
+ bullet("The real Kaggle competitive pool likely includes near-perfect solvers, since 7x6 Connect-4 "
306
+ "is a solved game -- see Section 5's closing note.")
307
+
308
+ h1("References")
309
+ refs = [
310
+ "[1] Schrittwieser, J. et al. \"Mastering Atari, Go, Chess and Shogi by Planning with a Learned "
311
+ "Model.\" Nature, 2020.",
312
+ "[2] Silver, D. et al. \"A general reinforcement learning algorithm that masters chess, shogi, "
313
+ "and Go through self-play.\" Science, 2018.",
314
+ "[3] Blundell, C. et al. \"Model-Free Episodic Control.\" arXiv:1606.04460, 2016.",
315
+ "[4] Pritzel, A. et al. \"Neural Episodic Control.\" ICML, 2017.",
316
+ "[5] Hu, E. J. et al. \"LoRA: Low-Rank Adaptation of Large Language Models.\" "
317
+ "arXiv:2106.09685, 2021.",
318
+ "[6] Allis, L. V. \"A Knowledge-Based Approach of Connect-Four.\" M.Sc. thesis, Vrije "
319
+ "Universiteit Amsterdam, 1988. (First complete game-theoretic solution of Connect-4; the "
320
+ "classical source for odd/even threat and zugzwang theory referenced in Section 4.)",
321
+ "[7] Mnih, V. et al. \"Human-level control through deep reinforcement learning.\" Nature, 2015. "
322
+ "(Experience-replay precedent used by the on-policy Monte Carlo value-training loop.)",
323
+ "[8] Adam, Addison Howard, and Bovard Doerschuk-Tiberi. \"Connect X.\" "
324
+ "https://kaggle.com/competitions/connectx, 2020. Kaggle.",
325
+ "[9] Kaggle. \"kaggle_environments.\" https://github.com/Kaggle/kaggle-environments. "
326
+ "(Board/config schema and fixed-opponent-baked-into-step convention are ported from this "
327
+ "package's own design, not from any published agent's code.)",
328
+ ]
329
+ pdf.set_font("Helvetica", "", 9.5)
330
+ for r in refs:
331
+ pdf.set_x(pdf.l_margin)
332
+ pdf.multi_cell(0, 5.2, r)
333
+ pdf.ln(1.5)
334
+
335
+ pdf.output("WHITEPAPER.pdf")
336
+ print("Wrote WHITEPAPER.pdf")
scripts/__init__.py ADDED
File without changes
scripts/build_submission.py ADDED
@@ -0,0 +1,843 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Packages connectx_checkpoint.pt into a SINGLE self-contained submission.py
3
+ Kaggle can actually run -- REWRITTEN 2026-08-10, per explicit user
4
+ direction ("let's combine all these in the submission file with
5
+ memories, the simulations, the weak learner etc") to fold in THREE
6
+ confirmed pieces from this session's follow-up work, replacing the
7
+ previous latent-beam-search-only version entirely:
8
+
9
+ 1. **Real adversarial search** (was: latent beam search). Per
10
+ connectx_adversarial_search.py's confirmed result (95-100%/48.3% vs
11
+ random/weak/stronger, beating latent search on every metric by a wide
12
+ margin, confirmed on 2 checkpoints): `env.step()` bundles agent+
13
+ opponent-reply into one transition, so the trained dynamics model was
14
+ never shown "the board right after my move, before their reply" --
15
+ it structurally can't imagine that state. Since ConnectX's rules ARE
16
+ exactly known, this ply is done in REAL board space instead (exact
17
+ enumeration of our moves, exact enumeration of the opponent's
18
+ worst-case real reply), with the learned value head used ONLY as the
19
+ leaf evaluator. This means `dynamics`/`decoder` are NO LONGER NEEDED
20
+ at all (the old latent search's neurosymbolic decode-gate is
21
+ structurally unnecessary once every ply is real, not imagined) --
22
+ only `encoder`+`value` weights are embedded now, a smaller submission.
23
+ 2. **Episodic memory (positive + negative)**, built OFFLINE (this
24
+ script, at build time) from self-play games against a MIXED opponent
25
+ (weak heuristic + random + the stronger 1-ply-deeper heuristic, per
26
+ explicit user caution -- "so if the opponent is weak it doesn't learn
27
+ the bad ways too" -- see memory_build.py). Won games
28
+ stored as positive (low remaining-steps) examples, lost/drawn games
29
+ as negative (high, fixed-penalty) examples -- one EpisodicMemory,
30
+ blended into every leaf evaluation via the exact same k-NN
31
+ inverse-distance/trust-scaled formula as episodic_memory.py's
32
+ `query_batch`, replicated here in plain torch (no project import,
33
+ this file must stay standalone).
34
+ 3. **Best-effort online learning ("the weak learner")** -- value-head-
35
+ ONLY updates (matching this session's own confirmed finding: decoder
36
+ updates regressed structured-opponent performance at this data scale,
37
+ so the decoder is excluded entirely here, consistent with "the
38
+ working side only"), applied incrementally as real games are played,
39
+ mirroring continuous_learner.py's confirmed-safe recipe (small
40
+ replay buffer, EMA-updated value_target_mean/std, a few Adam steps
41
+ per update, lr=1e-5) -- reimplemented here in plain torch since this
42
+ file can't import continuous_learner.py.
43
+
44
+ **Honest, load-bearing caveat, stated plainly rather than oversold**:
45
+ Kaggle's `agent(observation, configuration)` interface gives no
46
+ direct "episode ended, here's the result" callback -- this file
47
+ infers a completed episode two ways, both using ONLY information
48
+ actually available across calls: (a) our own move immediately wins
49
+ or draws (directly observable -- we know the board we just produced),
50
+ or (b) the NEXT call arrives with a completely empty board while a
51
+ previous episode's trajectory is still buffered -- inferred as a LOSS
52
+ (we didn't win/draw it ourselves, so it must have ended on the
53
+ opponent's move). This whole mechanism is a NO-OP, gracefully, unless
54
+ Kaggle's real evaluation infrastructure reuses the same process across
55
+ multiple episodes for this submission over time (its own rules page,
56
+ read earlier this session, doesn't confirm or deny this -- see
57
+ [[project_connectx_kaggle]]) -- if each episode gets a fresh process,
58
+ this buffer simply starts empty every time and nothing is lost, no
59
+ crash, no wasted budget beyond one negligible check.
60
+ """
61
+ import base64
62
+ import io
63
+
64
+ import torch
65
+
66
+ CKPT_PATH = "checkpoints/connectx_checkpoint.pt"
67
+ OUT_PATH = "submission.py"
68
+
69
+
70
+ def _encode_tensor_blob(ck, memory_zs, memory_outcomes):
71
+ """encoder+value weights only (see module docstring -- dynamics/
72
+ decoder are no longer needed by the real adversarial search), plus
73
+ value_target_mean/std (top-level buffers, not nested under a
74
+ submodule prefix) and the offline-built episodic memory's raw
75
+ (z, remaining_steps) pairs."""
76
+ keep = {k: v for k, v in ck["model_state"].items()
77
+ if k.startswith("encoder.") or k.startswith("value.")
78
+ or k in ("value_target_mean", "value_target_std")}
79
+ payload = {
80
+ "weights": keep,
81
+ "norm_mean": ck["norm_mean"],
82
+ "norm_std": ck["norm_std"],
83
+ "state_dim": ck["state_dim"],
84
+ "num_actions": ck["num_actions"],
85
+ "latent_dim": ck["latent_dim"],
86
+ "hidden_dim": ck["hidden_dim"],
87
+ "board_width": ck["board_width"],
88
+ "board_height": ck["board_height"],
89
+ "win_len": ck["win_len"],
90
+ "memory_zs": torch.stack(memory_zs) if memory_zs else torch.zeros(0, ck["latent_dim"]),
91
+ "memory_outcomes": torch.tensor(memory_outcomes, dtype=torch.float32),
92
+ }
93
+ buf = io.BytesIO()
94
+ torch.save(payload, buf)
95
+ return base64.b64encode(buf.getvalue()).decode("ascii")
96
+
97
+
98
+ SUBMISSION_TEMPLATE = '''\
99
+ """
100
+ Auto-generated by build_submission.py -- DO NOT hand-edit
101
+ (regenerate instead). Self-contained Kaggle ConnectX submission: no
102
+ imports beyond torch/base64/io, so it runs standalone in Kaggle's
103
+ evaluation sandbox.
104
+
105
+ Policy: ONE ROUND of REAL adversarial search (exact enumeration of our
106
+ legal moves, exact enumeration of the opponent's real legal replies,
107
+ worst-case-for-us selected -- a genuine minimax over EXACTLY KNOWN board
108
+ dynamics, not an imagined latent transition) -- the learned value head
109
+ is used ONLY as the leaf evaluator on a real, never-imagined state,
110
+ optionally blended with an offline-built episodic memory (won AND lost
111
+ self-play games, see module docstring). A best-effort online value-head
112
+ update also runs across real games as they're played -- see module
113
+ docstring's honest caveat about when this can/can't actually do
114
+ anything, given Kaggle's evaluation interface.
115
+
116
+ **Honest, named limitation** (see connectx_env.py / [[project_connectx_kaggle]]):
117
+ the base checkpoint was trained via self-play against a small set of
118
+ fixed/self-generated opponents, not against Kaggle's real matchmaking
119
+ pool -- see that project's memory entry for the full picture, including
120
+ this session's confirmed numbers against synthetic test opponents.
121
+ """
122
+ import base64
123
+ import collections
124
+ import io
125
+ import time
126
+
127
+ import torch
128
+
129
+ _MEMORY_WEIGHT = {memory_weight}
130
+ _MEMORY_K = {memory_k}
131
+ _ONLINE_LR = {online_lr}
132
+ _ONLINE_UPDATES_PER_EPISODE = {online_updates_per_episode}
133
+ _ONLINE_BATCH_SIZE = {online_batch_size}
134
+ _UNSOLVED_PENALTY_MULT = {unsolved_penalty_mult} # x max_steps, matches this session's convention
135
+ _ADV_ROUNDS = {adv_rounds} # real adversarial search rounds -- see _adversarial_plan_action's docstring for timing
136
+ # `_ENDGAME_MAX_COLS`/`_ENDGAME_TIME_BUDGET` (added 2026-08-11): below
137
+ # this many legal columns, `_exact_endgame_solve` (a real, no-NN,
138
+ # alpha-beta minimax to the true end of the game) is tried FIRST and used
139
+ # directly if it finishes in time -- see that function's own docstring
140
+ # for the calibration and the exact failure mode (a zugzwang/parity trap
141
+ # invisible to any bounded-depth search) this targets. `_ENDGAME_MAX_COLS
142
+ # = 0` disables this path entirely.
143
+ _ENDGAME_MAX_COLS = {endgame_max_cols}
144
+ _ENDGAME_TIME_BUDGET = {endgame_time_budget}
145
+ # `_DEEPER_ROUNDS`/`_DEEPER_MAX_BRANCHING`/`_DEEPER_TIME_BUDGET`: real,
146
+ # mined-from-real-games evidence showed `_ADV_ROUNDS` sometimes sees ZERO
147
+ # danger on a position (every column looks equally safe) 2-4 plies before
148
+ # a trap that one round DEEPER already narrows down to exactly one safe
149
+ # column -- `_ADV_ROUNDS` isn't wrong about what it can see, it just can't
150
+ # see far enough to avoid a fork the opponent is setting up. A deeper
151
+ # search is provably too slow to run on EVERY move (measured 8-13s at a
152
+ # 6-7-legal-column branching factor) -- so this is a SAFE, opportunistic
153
+ # escalation, not a blanket depth increase: `_DEEPER_ROUNDS = None`
154
+ # disables it entirely, reproducing the original `_ADV_ROUNDS`-only
155
+ # behavior byte-for-byte. When enabled, AFTER computing the normal-
156
+ # `_ADV_ROUNDS` answer (always -- the guaranteed-safe fallback), a
157
+ # `_DEEPER_ROUNDS`-round search is attempted under a hard
158
+ # `_DEEPER_TIME_BUDGET` deadline; if it finishes in time its answer is
159
+ # used instead (strictly more information, never less), if it times out
160
+ # the original answer is returned completely unchanged. Calibrated via a
161
+ # 180-game regression suite (random/weak/stronger opponents): zero
162
+ # win-rate regression, max observed single-move time 1.641s --
163
+ # comfortably under Kaggle's 2s budget.
164
+ _DEEPER_ROUNDS = {deeper_rounds}
165
+ _DEEPER_MAX_BRANCHING = {deeper_max_branching}
166
+ _DEEPER_TIME_BUDGET = {deeper_time_budget}
167
+ # `_ONLINE_ENABLED` (added 2026-08-10, right before submitting -- explicit
168
+ # user decision after reading the competition's own rule "An Agent's sole
169
+ # purpose is to generate an action. Activities/code which do not directly
170
+ # contribute to this will be considered malicious...": the online "weak
171
+ # learner"'s gradient updates are arguably in service of generating BETTER
172
+ # actions, not unrelated activity, but it's a genuine judgment call with
173
+ # real (if likely small) risk, not a zero-risk one -- played safe rather
174
+ # than assume it's fine. False disables it CLEANLY (no buffer/episode-
175
+ # tracking side-state at all when off, not just a no-op update call) so
176
+ # a disabled submission's `agent()` genuinely does nothing but generate
177
+ # an action, matching the rule as literally as possible.
178
+ _ONLINE_ENABLED = {online_enabled}
179
+
180
+ _BLOB_B64 = (
181
+ {blob_literal}
182
+ )
183
+
184
+
185
+ def _load():
186
+ payload = torch.load(io.BytesIO(base64.b64decode(_BLOB_B64)), map_location="cpu")
187
+ return payload
188
+
189
+
190
+ _P = _load()
191
+ _W = _P["weights"]
192
+ _NORM_MEAN = _P["norm_mean"]
193
+ _NORM_STD = _P["norm_std"]
194
+ _LATENT_DIM = _P["latent_dim"]
195
+ _NUM_ACTIONS = _P["num_actions"] # includes the training-time PASS action (index WIDTH)
196
+ _WIDTH = _P["board_width"]
197
+ _HEIGHT = _P["board_height"]
198
+ _WIN_LEN = _P["win_len"]
199
+ _PASS_ACTION = _WIDTH
200
+ _CELL_WIDTH = 3
201
+ _EMPTY, _AGENT, _OPPONENT = 0, 1, 2
202
+ _MAX_STEPS = (_WIDTH * _HEIGHT) // 2 + 2
203
+ # `_UNSOLVED_PENALTY` (used ONLY by the online learner's episode-ending
204
+ # label, matching continuous_learner.py's own 1x-max_steps convention)
205
+ # and `_LOSS_PENALTY` (used ONLY by the adversarial search's "opponent
206
+ # wins" terminal case) are DELIBERATELY SEPARATE constants -- a real bug
207
+ # found and fixed 2026-08-10, right after this build was already live:
208
+ # an earlier version used _UNSOLVED_PENALTY (1x max_steps) for BOTH,
209
+ # which meant the search scored "the opponent wins outright" EXACTLY
210
+ # THE SAME as "it's a mere draw" -- losing must be unambiguously worse
211
+ # than a draw for the search to reliably prioritize blocking a real
212
+ # threat over a merely-mediocre move, matching connectx_adversarial_search.py's
213
+ # original, correct 2x convention. Confirmed as the direct, mechanistic
214
+ # cause of a real observed failure: the deployed agent missed blocking
215
+ # an opponent's obvious 3-in-a-column vertical threat, scoring the
216
+ # blocking move WORSE (23.463) than a non-blocking move that let the
217
+ # opponent win outright (23.000, since the loss was scored at only
218
+ # max_steps=23, indistinguishable from ordinary mediocre play).
219
+ _UNSOLVED_PENALTY = _UNSOLVED_PENALTY_MULT * _MAX_STEPS
220
+ _LOSS_PENALTY = 2 * _MAX_STEPS
221
+
222
+ # Memory tensors (offline-built, see module docstring) -- fixed, never
223
+ # grow at runtime (only the ONLINE value-head buffer below does).
224
+ _MEMORY_Z = _P["memory_zs"]
225
+ _MEMORY_OUTCOMES = _P["memory_outcomes"]
226
+ if _MEMORY_Z.shape[0] >= 2:
227
+ _d = torch.cdist(_MEMORY_Z, _MEMORY_Z)
228
+ _d = torch.where(_d > 1e-6, _d, torch.full_like(_d, float("inf")))
229
+ _nn = _d.min(dim=1).values
230
+ _nn = _nn[torch.isfinite(_nn)]
231
+ _MEMORY_TRUST_SCALE = _nn.median().item() if len(_nn) > 0 else 1.0
232
+ else:
233
+ _MEMORY_TRUST_SCALE = 1.0
234
+
235
+ # --- Value head params made trainable for the online "weak learner"
236
+ # (see module docstring's honest caveat) -- encoder stays FROZEN
237
+ # (never in this optimizer), matching continuous_learner.py's confirmed
238
+ # recipe: only the value head updates online. When `_ONLINE_ENABLED` is
239
+ # False, NONE of this setup happens at all (no optimizer, no
240
+ # requires_grad, no buffers) -- `agent()` genuinely does nothing but
241
+ # generate an action in that case, not just a disabled-but-present
242
+ # mechanism. ---
243
+ if _ONLINE_ENABLED:
244
+ _VALUE_PARAM_KEYS = [k for k in _W if k.startswith("value.")]
245
+ for _k in _VALUE_PARAM_KEYS:
246
+ _W[_k].requires_grad_(True)
247
+ # Buffers, not trained parameters (EMA-updated in-place under
248
+ # no_grad, matching continuous_learner.py's own convention) -- never
249
+ # added to the optimizer below.
250
+ _VALUE_TARGET_MEAN = _W.get("value_target_mean", torch.tensor(0.0)).clone()
251
+ _VALUE_TARGET_STD = _W.get("value_target_std", torch.tensor(1.0)).clone()
252
+ _ONLINE_OPT = torch.optim.Adam([_W[k] for k in _VALUE_PARAM_KEYS], lr=_ONLINE_LR)
253
+ _REPLAY_BUFFER = collections.deque(maxlen=2000) # (state_vec: list[float], label: float)
254
+ _EPISODE_STATES = [] # real one-hot state vectors seen/produced so far THIS episode
255
+ _EPISODE_LAST_PIECES = None # total board piece count as of our last recorded state THIS episode
256
+ else:
257
+ _VALUE_TARGET_MEAN = _W.get("value_target_mean", torch.tensor(0.0))
258
+ _VALUE_TARGET_STD = _W.get("value_target_std", torch.tensor(1.0))
259
+
260
+
261
+ def _linear(x, w_key, b_key):
262
+ return torch.nn.functional.linear(x, _W[w_key], _W[b_key])
263
+
264
+
265
+ def _mlp3(x, prefix):
266
+ """Replicates model.py's `mlp([in, hidden, hidden, out])`: Linear ->
267
+ ReLU -> Linear -> ReLU -> Linear (params at Sequential indices
268
+ 0/2/4, confirmed against the actual saved state_dict keys)."""
269
+ h = torch.relu(_linear(x, f"{{prefix}}.net.0.weight", f"{{prefix}}.net.0.bias"))
270
+ h = torch.relu(_linear(h, f"{{prefix}}.net.2.weight", f"{{prefix}}.net.2.bias"))
271
+ return _linear(h, f"{{prefix}}.net.4.weight", f"{{prefix}}.net.4.bias")
272
+
273
+
274
+ def _encode(state_vec):
275
+ return _mlp3(state_vec, "encoder")
276
+
277
+
278
+ def _value_raw(z):
279
+ return _mlp3(z, "value").squeeze(-1)
280
+
281
+
282
+ def _value(z):
283
+ """Real-scale value estimate (remaining steps), see model.py's
284
+ WorldModel.evaluate -- denormalizes the network's raw prediction."""
285
+ return _value_raw(z) * _VALUE_TARGET_STD + _VALUE_TARGET_MEAN
286
+
287
+
288
+ def _memory_blend(z_batch, raw_values):
289
+ """Same k-NN inverse-distance/trust-scaled blend as
290
+ episodic_memory.py's EpisodicMemory.query_batch -- replicated here
291
+ in plain torch (this file can't import that module)."""
292
+ if _MEMORY_Z.shape[0] == 0 or _MEMORY_WEIGHT <= 0:
293
+ return raw_values
294
+ dists = torch.cdist(z_batch, _MEMORY_Z) # [B, N]
295
+ k = min(_MEMORY_K, _MEMORY_Z.shape[0])
296
+ topk_dists, topk_idx = torch.topk(dists, k, largest=False, dim=1)
297
+ topk_outcomes = _MEMORY_OUTCOMES[topk_idx]
298
+ weights = 1.0 / (topk_dists + 1e-2)
299
+ weights = weights / weights.sum(dim=1, keepdim=True)
300
+ blended = (weights * topk_outcomes).sum(dim=1)
301
+ mean_dist = topk_dists.mean(dim=1)
302
+ trust = torch.exp(-mean_dist / _MEMORY_TRUST_SCALE)
303
+ w = _MEMORY_WEIGHT * trust
304
+ return (1 - w) * raw_values + w * blended
305
+
306
+
307
+ # --- Plain-Python board helpers (no torch) -- mirrors connectx_env.py's
308
+ # free functions exactly, duplicated here (not imported) since this file
309
+ # must be standalone. ---
310
+
311
+ def _onehot(idx, n):
312
+ v = [0] * n
313
+ v[idx] = 1
314
+ return v
315
+
316
+
317
+ def _rc(row, col):
318
+ return row * _WIDTH + col
319
+
320
+
321
+ def _encode_board(cells):
322
+ out = []
323
+ for c in cells:
324
+ out.extend(_onehot(c, _CELL_WIDTH))
325
+ return out
326
+
327
+
328
+ def _lowest_empty_row(cells, col):
329
+ for row in range(_HEIGHT - 1, -1, -1):
330
+ if cells[_rc(row, col)] == _EMPTY:
331
+ return row
332
+ return None
333
+
334
+
335
+ def _legal_columns(cells):
336
+ return [c for c in range(_WIDTH) if _lowest_empty_row(cells, c) is not None]
337
+
338
+
339
+ def _wins_for(cells, mark):
340
+ for row in range(_HEIGHT):
341
+ for col in range(_WIDTH):
342
+ if cells[_rc(row, col)] != mark:
343
+ continue
344
+ for dr, dc in ((0, 1), (1, 0), (1, 1), (1, -1)):
345
+ er, ec = row + dr * (_WIN_LEN - 1), col + dc * (_WIN_LEN - 1)
346
+ if not (0 <= er < _HEIGHT and 0 <= ec < _WIDTH):
347
+ continue
348
+ if all(cells[_rc(row + dr * k, col + dc * k)] == mark for k in range(_WIN_LEN)):
349
+ return True
350
+ return False
351
+
352
+
353
+ def _board_full(cells):
354
+ return all(c != _EMPTY for c in cells)
355
+
356
+
357
+ def _apply_move(cells, col, mark):
358
+ row = _lowest_empty_row(cells, col)
359
+ new_cells = list(cells)
360
+ new_cells[_rc(row, col)] = mark
361
+ return new_cells
362
+
363
+
364
+ def _kaggle_board_to_cells(board, mark):
365
+ """Kaggle's board: flat list, row-major, 0=empty/1=P1/2=P2, row 0 =
366
+ top -- SAME convention connectx_env.py already uses, confirmed
367
+ against kaggle_environments' own connectx.json. `mark` tells us
368
+ which of Kaggle's 1/2 is US."""
369
+ opponent_mark = 2 if mark == 1 else 1
370
+ cells = []
371
+ for v in board:
372
+ if v == 0:
373
+ cells.append(_EMPTY)
374
+ elif v == mark:
375
+ cells.append(_AGENT)
376
+ else:
377
+ assert v == opponent_mark
378
+ cells.append(_OPPONENT)
379
+ return cells
380
+
381
+
382
+ def _leaf_batch_values(states):
383
+ if not states:
384
+ return {{}}
385
+ state_t = torch.tensor(states, dtype=torch.float32)
386
+ norm_t = (state_t - _NORM_MEAN) / _NORM_STD
387
+ z = _encode(norm_t)
388
+ vals = _memory_blend(z, _value(z))
389
+ return dict(zip(states, vals.tolist()))
390
+
391
+
392
+ def _narrow_to_center(legal_cols, max_branching):
393
+ """Prunes a legal-column list down to `max_branching` columns closest
394
+ to the board's center -- free, real Connect-4 domain knowledge (a
395
+ center column touches more potential 4-in-a-row lines than an edge
396
+ one, same theory as the empty-board opening hint). `max_branching=
397
+ None` is a no-op -- exact, unpruned enumeration. Only ever applied to
398
+ OUR OWN follow-up move choices at the deeper-escalation's round 2+
399
+ (see `_DEEPER_ROUNDS`'s docstring) -- never to `_ADV_ROUNDS`'s own
400
+ (always-unpruned) path, and never to the opponent's reply enumeration
401
+ at ANY round (that's what makes this a genuine worst-case
402
+ guarantee -- narrowing it would mean silently ignoring some of the
403
+ opponent's real threats)."""
404
+ if max_branching is None or len(legal_cols) <= max_branching:
405
+ return legal_cols
406
+ center = (_WIDTH - 1) / 2
407
+ return sorted(legal_cols, key=lambda c: abs(c - center))[:max_branching]
408
+
409
+
410
+ class _RoundSearchTimeout(Exception):
411
+ pass
412
+
413
+
414
+ def _check_deadline(deadline):
415
+ if deadline is not None and time.time() > deadline:
416
+ raise _RoundSearchTimeout()
417
+
418
+
419
+ def _collect_leaves(cells1, remaining_rounds, leaf_cache, max_branching=None, deadline=None):
420
+ _check_deadline(deadline)
421
+ if _board_full(cells1):
422
+ return
423
+ for opp_col in _legal_columns(cells1):
424
+ cells2 = _apply_move(cells1, opp_col, _OPPONENT)
425
+ if _wins_for(cells2, _OPPONENT) or _board_full(cells2):
426
+ continue
427
+ if remaining_rounds <= 1:
428
+ leaf_cache[tuple(_encode_board(cells2))] = None
429
+ else:
430
+ for a2 in _narrow_to_center(_legal_columns(cells2), max_branching):
431
+ cells3 = _apply_move(cells2, a2, _AGENT)
432
+ if _wins_for(cells3, _AGENT):
433
+ continue
434
+ _collect_leaves(cells3, remaining_rounds - 1, leaf_cache, max_branching, deadline)
435
+
436
+
437
+ def _score_after_our_move(cells1, remaining_rounds, leaf_cache, max_branching=None, deadline=None):
438
+ """cells1: real board right after OUR move (caller already ruled out
439
+ an immediate win here). Returns our worst-case score -- opponent
440
+ picks whichever real reply hurts us most. Reads leaf values from
441
+ `leaf_cache` (already populated by ONE upfront batched call over the
442
+ WHOLE tree -- see _adversarial_plan_action) instead of calling the
443
+ value head again at every node."""
444
+ if _board_full(cells1):
445
+ return float(_MAX_STEPS)
446
+ vals = []
447
+ for opp_col in _legal_columns(cells1):
448
+ cells2 = _apply_move(cells1, opp_col, _OPPONENT)
449
+ if _wins_for(cells2, _OPPONENT):
450
+ vals.append(float(_LOSS_PENALTY)) # opponent wins -- worse than a mere draw, see _LOSS_PENALTY's comment
451
+ elif _board_full(cells2):
452
+ vals.append(float(_MAX_STEPS))
453
+ elif remaining_rounds <= 1:
454
+ vals.append(leaf_cache[tuple(_encode_board(cells2))])
455
+ else:
456
+ vals.append(_score_after_opponent_move(cells2, remaining_rounds - 1, leaf_cache, max_branching, deadline))
457
+ return max(vals)
458
+
459
+
460
+ def _score_after_opponent_move(cells2, remaining_rounds, leaf_cache, max_branching=None, deadline=None):
461
+ """cells2: real board after the opponent's move, our turn again.
462
+ Returns OUR best achievable worst-case score from here."""
463
+ _check_deadline(deadline)
464
+ our_legal = _narrow_to_center(_legal_columns(cells2), max_branching)
465
+ if not our_legal:
466
+ return float(_MAX_STEPS)
467
+ best = None
468
+ for a in our_legal:
469
+ cells3 = _apply_move(cells2, a, _AGENT)
470
+ if _wins_for(cells3, _AGENT):
471
+ return -float(_MAX_STEPS) # a forced win exists deeper -- short-circuit
472
+ s = _score_after_our_move(cells3, remaining_rounds, leaf_cache, max_branching, deadline)
473
+ if best is None or s < best:
474
+ best = s
475
+ return best
476
+
477
+
478
+ class _EndgameTimeout(Exception):
479
+ pass
480
+
481
+
482
+ def _exact_endgame_solve(cells0, mover, deadline):
483
+ """Exact (no NN) alpha-beta minimax to the true end of the game --
484
+ see adversarial_search.py's identical function for
485
+ the full docstring/calibration; this is a plain-torch-free, standalone
486
+ port (same convention as every other function in this file) so the
487
+ packaged submission never imports the project. Returns
488
+ `(best_action, value)` (value from `mover`'s own perspective, +1/-1/0)
489
+ or `(None, None)` if `deadline` was hit first."""
490
+ memo = {{}}
491
+ center = (_WIDTH - 1) / 2
492
+
493
+ def solve(cells, to_move, alpha, beta):
494
+ if time.time() > deadline:
495
+ raise _EndgameTimeout()
496
+ key = (tuple(cells), to_move)
497
+ cached = memo.get(key)
498
+ if cached is not None:
499
+ return cached
500
+ other = _OPPONENT if to_move == _AGENT else _AGENT
501
+ legal = sorted(_legal_columns(cells), key=lambda c: abs(c - center))
502
+ if not legal:
503
+ memo[key] = 0.0
504
+ return 0.0
505
+ if to_move == _AGENT:
506
+ best = -2.0
507
+ for c in legal:
508
+ nxt = _apply_move(cells, c, to_move)
509
+ if _wins_for(nxt, to_move):
510
+ val = 1.0
511
+ elif _board_full(nxt):
512
+ val = 0.0
513
+ else:
514
+ val = solve(nxt, other, alpha, beta)
515
+ best = max(best, val)
516
+ alpha = max(alpha, best)
517
+ if alpha >= beta:
518
+ break
519
+ else:
520
+ best = 2.0
521
+ for c in legal:
522
+ nxt = _apply_move(cells, c, to_move)
523
+ if _wins_for(nxt, to_move):
524
+ val = -1.0
525
+ elif _board_full(nxt):
526
+ val = 0.0
527
+ else:
528
+ val = solve(nxt, other, alpha, beta)
529
+ best = min(best, val)
530
+ beta = min(beta, best)
531
+ if alpha >= beta:
532
+ break
533
+ memo[key] = best
534
+ return best
535
+
536
+ root_legal = _legal_columns(cells0)
537
+ if not root_legal:
538
+ return None, None
539
+ root_legal = sorted(root_legal, key=lambda c: abs(c - center))
540
+ other = _OPPONENT if mover == _AGENT else _AGENT
541
+ try:
542
+ best_a, best_val = None, None
543
+ for c in root_legal:
544
+ nxt = _apply_move(cells0, c, mover)
545
+ if _wins_for(nxt, mover):
546
+ val = 1.0 if mover == _AGENT else -1.0
547
+ elif _board_full(nxt):
548
+ val = 0.0
549
+ else:
550
+ val = solve(nxt, other, -1.0, 1.0)
551
+ if best_val is None or (mover == _AGENT and val > best_val) or (mover == _OPPONENT and val < best_val):
552
+ best_a, best_val = c, val
553
+ if (mover == _AGENT and best_val == 1.0) or (mover == _OPPONENT and best_val == -1.0):
554
+ break
555
+ return best_a, best_val
556
+ except _EndgameTimeout:
557
+ return None, None
558
+
559
+
560
+ def _run_search(surviving_actions, action_cells1, search_rounds, max_branching=None, deadline=None):
561
+ """One full leaf-collect + batched-eval + minimax pass at a given
562
+ (rounds, max_branching) setting -- factored out so it can be called
563
+ at two different depths, see `_DEEPER_ROUNDS`'s docstring above.
564
+ `deadline`: propagated into `_collect_leaves`/`_score_after_opponent_
565
+ move` (checked at both exponential-blowup recursion points) AND
566
+ checked again here, immediately around the ONE batched NN forward
567
+ pass -- that call is otherwise UNGUARDED/uninterruptible once
568
+ started, so bailing out right before it (rather than only inside the
569
+ pure-Python recursion) avoids ever starting an expensive tensor op
570
+ with no time budget left for it."""
571
+ leaf_cache = {{}}
572
+ for a in surviving_actions:
573
+ _collect_leaves(action_cells1[a], search_rounds, leaf_cache, max_branching, deadline)
574
+ _check_deadline(deadline)
575
+ if leaf_cache:
576
+ leaf_cache.update(_leaf_batch_values(list(leaf_cache.keys())))
577
+ _check_deadline(deadline) # don't walk the tree on a stale/over-budget result either
578
+
579
+ best_a, best_score = None, None
580
+ for a in surviving_actions:
581
+ s = _score_after_our_move(action_cells1[a], search_rounds, leaf_cache, max_branching, deadline)
582
+ if best_score is None or s < best_score:
583
+ best_a, best_score = a, s
584
+ return best_a
585
+
586
+
587
+ @torch.no_grad()
588
+ def _adversarial_plan_action(cells0):
589
+ """`_ADV_ROUNDS` real adversarial rounds (our move, then the
590
+ opponent's worst-case real reply, repeated) before falling back to
591
+ the learned value head + memory blend as the leaf evaluator -- every
592
+ transition at every round is EXACT (real board simulation, never
593
+ imagined). Root action never returns PASS.
594
+
595
+ **Two-phase, GLOBALLY batched leaf evaluation** (fixed 2026-08-10,
596
+ same day, right before submitting -- a real timing bug caught just
597
+ in time, see connectx_adversarial_search.py's identical fix for the
598
+ full story): calling the leaf evaluator separately at every node in
599
+ the tree (the first version of `rounds>1`) measured up to 2.3s/move
600
+ against the offline-built ~2600-state memory -- OVER Kaggle's 2s
601
+ budget. Fixed by walking the tree TWICE (pure Python, cheap): once
602
+ to collect every non-terminal leaf across the WHOLE tree into one
603
+ deduplicated set (transpositions collapse for free), then ONE single
604
+ batched value+memory call, then a second walk doing the actual
605
+ minimax from the precomputed lookup. Re-measured after the fix
606
+ across 60 diverse positions (including the maximal-branching empty-
607
+ board case): rounds=1 max 0.427s, rounds=2 max 0.375s -- comfortably
608
+ (~5x) under budget again."""
609
+ root_legal = _legal_columns(cells0)
610
+ if not root_legal:
611
+ return None
612
+
613
+ if _ENDGAME_MAX_COLS and len(root_legal) <= _ENDGAME_MAX_COLS:
614
+ exact_a, _exact_val = _exact_endgame_solve(cells0, _AGENT, deadline=time.time() + _ENDGAME_TIME_BUDGET)
615
+ if exact_a is not None:
616
+ return exact_a
617
+ # else: timed out -- fall through to the round-based search below
618
+ # exactly as if this check had never happened.
619
+
620
+ # Center-out root ordering -- NOT a pruning change (every legal column
621
+ # is still considered, nothing narrowed), only fixes which column wins
622
+ # a TIE. The scoring loop below uses strict `<`, so the first action
623
+ # seen at a given score silently wins ties; left-to-right order made
624
+ # that default to the LEFTMOST column, an arbitrary, exploitable bias
625
+ # with no game-theoretic basis (unlike the player-1 opening hint,
626
+ # which deliberately picks center for a real reason). Center columns
627
+ # are the real stronger choice under a tie (more potential 4-in-a-row
628
+ # lines pass through them, same fact `_narrow_to_center` already uses
629
+ # for pruning) -- found from a direct user-observed pattern in real
630
+ # play ("when we are second we put in left going to right").
631
+ _center = (_WIDTH - 1) / 2
632
+ root_legal = sorted(root_legal, key=lambda c: abs(c - _center))
633
+
634
+ surviving_actions, action_cells1 = [], {{}}
635
+ for a in root_legal:
636
+ cells1 = _apply_move(cells0, a, _AGENT)
637
+ if _wins_for(cells1, _AGENT):
638
+ return a # immediate win -- take it, no need to consider anything else
639
+ surviving_actions.append(a)
640
+ action_cells1[a] = cells1
641
+
642
+ base_a = _run_search(surviving_actions, action_cells1, _ADV_ROUNDS) # always computed -- guaranteed-safe fallback
643
+
644
+ if _DEEPER_ROUNDS is not None:
645
+ try:
646
+ return _run_search(surviving_actions, action_cells1, _DEEPER_ROUNDS,
647
+ max_branching=_DEEPER_MAX_BRANCHING,
648
+ deadline=time.time() + _DEEPER_TIME_BUDGET)
649
+ except _RoundSearchTimeout:
650
+ pass # didn't finish in time -- fall back to base_a exactly as if _DEEPER_ROUNDS were None
651
+
652
+ return base_a
653
+
654
+
655
+ def _online_update(path_states, label):
656
+ """A FEW Adam steps on a mixed old+new batch from the persisted
657
+ replay buffer -- value head ONLY (encoder frozen), mirrors
658
+ continuous_learner.py's confirmed-safe recipe exactly (small
659
+ updates, EMA-scaled value targets, never a full retrain on just the
660
+ latest episode). `label`: either "steps" (a real win -- each state
661
+ labeled with its real remaining-step count) or a fixed penalty
662
+ (loss/draw -- every state in the walk labeled uniformly bad, same
663
+ convention as this session's `unsolved_penalty`). Only ever called
664
+ from `agent()`'s `_ONLINE_ENABLED`-guarded blocks, but a defensive
665
+ no-op guard here too -- never trust a single call site alone for
666
+ something this load-bearing."""
667
+ global _VALUE_TARGET_MEAN, _VALUE_TARGET_STD
668
+ if not _ONLINE_ENABLED:
669
+ return
670
+ if label == "steps":
671
+ T = len(path_states) - 1
672
+ for t, s in enumerate(path_states):
673
+ _REPLAY_BUFFER.append((list(s), float(T - t)))
674
+ else:
675
+ for s in path_states:
676
+ _REPLAY_BUFFER.append((list(s), float(label)))
677
+
678
+ if len(_REPLAY_BUFFER) < 8:
679
+ return
680
+ pool = list(_REPLAY_BUFFER)
681
+ states_t = torch.tensor([s for s, _r in pool], dtype=torch.float32)
682
+ returns_t = torch.tensor([r for _s, r in pool], dtype=torch.float32)
683
+
684
+ momentum = 0.98
685
+ new_mean, new_std = returns_t.mean(), returns_t.std().clamp(min=1e-3)
686
+ with torch.no_grad():
687
+ _VALUE_TARGET_MEAN.mul_(momentum).add_(new_mean, alpha=1 - momentum)
688
+ _VALUE_TARGET_STD.mul_(momentum).add_(new_std, alpha=1 - momentum)
689
+ returns_norm = (returns_t - _VALUE_TARGET_MEAN) / _VALUE_TARGET_STD
690
+
691
+ norm_states_t = (states_t - _NORM_MEAN) / _NORM_STD
692
+ with torch.no_grad():
693
+ z_all = _encode(norm_states_t)
694
+
695
+ n = len(pool)
696
+ bs = min(_ONLINE_BATCH_SIZE, n)
697
+ for _ in range(_ONLINE_UPDATES_PER_EPISODE):
698
+ idx = torch.randperm(n)[:bs]
699
+ pred = _value_raw(z_all[idx])
700
+ loss = torch.nn.functional.mse_loss(pred, returns_norm[idx])
701
+ _ONLINE_OPT.zero_grad()
702
+ loss.backward()
703
+ _ONLINE_OPT.step()
704
+
705
+
706
+ def agent(observation, configuration):
707
+ global _EPISODE_STATES, _EPISODE_LAST_PIECES
708
+ board = list(observation.board)
709
+ mark = observation.mark
710
+ cells = _kaggle_board_to_cells(board, mark)
711
+
712
+ # See _ONLINE_ENABLED's own comment above -- when False, NONE of the
713
+ # episode-tracking/online-update machinery below runs at all, not
714
+ # just a no-op call: `agent()` genuinely does nothing but pick a
715
+ # move in that case.
716
+ if _ONLINE_ENABLED:
717
+ cur_pieces = sum(1 for v in board if v != 0)
718
+ # See module docstring's honest caveat -- detecting "a previous
719
+ # episode ended without us ever winning/drawing it ourselves"
720
+ # needs care: checking for an ALL-EMPTY board only works when we
721
+ # happen to be the FIRST mover in the new episode -- as the
722
+ # second mover, the very first board we see already has the
723
+ # opponent's first piece on it, so that check would silently
724
+ # miss the boundary and keep appending to a STALE trajectory
725
+ # from the already-ended previous episode (a real bug, caught
726
+ # before submission: our own test harness alternates which side
727
+ # we play, exactly the condition that triggers it). Robust fix:
728
+ # within one genuinely continuing episode, the board's total
729
+ # piece count increases by EXACTLY 1 between our own consecutive
730
+ # calls (one opponent move happened since we last acted) -- any
731
+ # other delta means a new episode has started, whichever side we
732
+ # were on. Infer a LOSS (the only remaining possibility -- our
733
+ # own win/draw is caught below, right after our own move).
734
+ #
735
+ # `_LOSS_PENALTY`, NOT `_UNSOLVED_PENALTY` (fixed 2026-08-10,
736
+ # follow-up session -- found from a direct user-observed real-game
737
+ # pattern, "one move before losing, ours plays leftmost"): this is
738
+ # the exact same mistake as the already-fixed "attacks but never
739
+ # defends" search bug, just unfixed in a SECOND place. The two
740
+ # penalties were introduced specifically so the SEARCH treats an
741
+ # opponent win as worse than a mere draw -- but the online
742
+ # learner's own training label here used `_UNSOLVED_PENALTY` (the
743
+ # DRAW value) for a genuine LOSS too, teaching the value head that
744
+ # losing and drawing are equally bad. Confirmed via real losses
745
+ # mined from actual Kaggle replays: the fresh (never-online-
746
+ # updated) search correctly blocks in all 3 traced cases, but the
747
+ # live, online-drifted process played the losing move instead --
748
+ # this conflated label is the direct mechanism.
749
+ if _EPISODE_STATES and cur_pieces != _EPISODE_LAST_PIECES + 1:
750
+ _online_update(_EPISODE_STATES, float(_LOSS_PENALTY))
751
+ _EPISODE_STATES = []
752
+ if not _EPISODE_STATES:
753
+ _EPISODE_STATES.append(tuple(_encode_board(cells)))
754
+
755
+ legal_cols = _legal_columns(cells)
756
+ if not legal_cols:
757
+ return 0 # should never happen -- Kaggle only calls us on a non-terminal state
758
+
759
+ # Free, EXACT domain knowledge (same "neurosymbolic gate" philosophy
760
+ # as every other domain's hand-given hint in this project): on a
761
+ # completely empty board, the center column is the known-best
762
+ # Connect-4 opening. Costs nothing, never worse than guessing.
763
+ if all(c == _EMPTY for c in cells):
764
+ best_action = _WIDTH // 2
765
+ else:
766
+ best_action = _adversarial_plan_action(cells)
767
+ if best_action is None:
768
+ return legal_cols[0]
769
+
770
+ if not _ONLINE_ENABLED:
771
+ return int(best_action)
772
+
773
+ post_cells = _apply_move(cells, best_action, _AGENT)
774
+ _EPISODE_STATES.append(tuple(_encode_board(post_cells)))
775
+ _EPISODE_LAST_PIECES = sum(1 for v in board if v != 0) + 1
776
+
777
+ if _wins_for(post_cells, _AGENT):
778
+ _online_update(_EPISODE_STATES, "steps")
779
+ _EPISODE_STATES = []
780
+ elif _board_full(post_cells):
781
+ _online_update(_EPISODE_STATES, float(_UNSOLVED_PENALTY))
782
+ _EPISODE_STATES = []
783
+
784
+ return int(best_action)
785
+ '''
786
+
787
+
788
+ def main(ckpt_path=CKPT_PATH, memory_ckpt_path=None, n_memory_games=500,
789
+ memory_opponent_epsilon=0.2, memory_opponent_strong_epsilon=0.3,
790
+ memory_weight=0.25, memory_k=5, online_lr=1e-5, online_updates_per_episode=4,
791
+ online_batch_size=256, unsolved_penalty_mult=1.0, adv_rounds=2, seed=0,
792
+ online_enabled=False, endgame_max_cols=5, endgame_time_budget=1.2,
793
+ deeper_rounds=None, deeper_max_branching=4, deeper_time_budget=0.6):
794
+ import random
795
+ from connectx.env import ConnectXEnv
796
+ from connectx.memory_build import build_episodic_memory
797
+ from connectx.search import load_checkpoint
798
+
799
+ ck = torch.load(ckpt_path, map_location="cpu")
800
+
801
+ # Memory is built using the SAME real adversarial search (rounds=
802
+ # adv_rounds) the deployed submission actually plays with, so the
803
+ # stored trajectories are representative of the real deployed agent's
804
+ # own play, not a different/weaker search's games.
805
+ print(f"Building offline episodic memory ({n_memory_games} self-play games, mixed opponent, "
806
+ f"real adversarial search rounds={adv_rounds})...")
807
+ mem_ckpt = memory_ckpt_path or ckpt_path
808
+ model, normalizer = load_checkpoint(mem_ckpt)
809
+ env = ConnectXEnv(width=ck["board_width"], height=ck["board_height"], win_len=ck["win_len"])
810
+ rng = random.Random(seed)
811
+ # env.py's opponent_epsilon/opponent_strong_epsilon rolls read Python's
812
+ # GLOBAL random module directly, not this `rng` object -- without this,
813
+ # "same seed" memory-building runs are silently NOT reproducible.
814
+ random.seed(seed)
815
+ memory = build_episodic_memory(env, model, normalizer, rng, n_games=n_memory_games,
816
+ opponent_epsilon=memory_opponent_epsilon,
817
+ opponent_strong_epsilon=memory_opponent_strong_epsilon,
818
+ adversarial_rounds=adv_rounds)
819
+ memory_zs = [z.detach().cpu() for z in memory._zs]
820
+ memory_outcomes = list(memory._outcomes)
821
+
822
+ blob = _encode_tensor_blob(ck, memory_zs, memory_outcomes)
823
+ width = 100
824
+ chunks = [blob[i:i + width] for i in range(0, len(blob), width)]
825
+ blob_literal = "\n".join(f' "{c}"' for c in chunks)
826
+
827
+ out = SUBMISSION_TEMPLATE.format(
828
+ blob_literal=blob_literal, memory_weight=memory_weight, memory_k=memory_k,
829
+ online_lr=online_lr, online_updates_per_episode=online_updates_per_episode,
830
+ online_batch_size=online_batch_size, unsolved_penalty_mult=unsolved_penalty_mult,
831
+ adv_rounds=adv_rounds, online_enabled=online_enabled,
832
+ endgame_max_cols=endgame_max_cols, endgame_time_budget=endgame_time_budget,
833
+ deeper_rounds=deeper_rounds, deeper_max_branching=deeper_max_branching,
834
+ deeper_time_budget=deeper_time_budget,
835
+ )
836
+ with open(OUT_PATH, "w") as f:
837
+ f.write(out)
838
+ size_kb = len(out.encode("utf-8")) / 1024
839
+ print(f"Wrote {OUT_PATH} ({len(memory_zs)} memory states, {size_kb:.1f} KB)")
840
+
841
+
842
+ if __name__ == "__main__":
843
+ main()
scripts/lora_selfplay_finetune.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LoRA self-play fine-tune of an ALREADY-TRAINED checkpoint's value head --
3
+ this is the step that actually produced the deployed checkpoint, not a
4
+ fresh training run. Starts from the real, working checkpoint (encoder/
5
+ dynamics/decoder frozen, matching `train.py`'s own self-play stage, which
6
+ never touches them either) and fine-tunes the value head under self-play
7
+ through a LoRA-constrained delta rather than an unconstrained further Adam
8
+ update -- directly tests whether constraining HOW MUCH the value head can
9
+ move (not just how much data/how many rounds) helps it absorb self-play
10
+ signal without regressing calibration.
11
+
12
+ `opponent_strong_epsilon` (the curriculum variant): mixes the stronger
13
+ 1-ply-deeper heuristic into training ALONGSIDE the self-play snapshots and
14
+ the original weak heuristic -- without this, self-play rounds only ever
15
+ train against [self-play snapshots, weak heuristic], and never the harder
16
+ fixed opponent at all. This is what produced this project's best-confirmed
17
+ result (see the whitepaper's results table).
18
+
19
+ rank=4/alpha=4.0: found to work well for a value head elsewhere in this
20
+ project's development on this same architecture; not independently
21
+ re-tuned for ConnectX specifically.
22
+ """
23
+ import copy
24
+ import random
25
+
26
+ import torch
27
+
28
+ from connectx.env import ConnectXEnv
29
+ from scripts.train import make_selfplay_pool_opponent_fn, random_baseline_win_rate
30
+ from connectx.verifier import train_mc_value_onpolicy
31
+ from connectx.lora import LoRALinear, apply_lora
32
+ from connectx.search import load_checkpoint, evaluate
33
+
34
+
35
+ def _merge_and_unwrap(module):
36
+ """Merge every LoRALinear's delta into its frozen base weight, then
37
+ replace the wrapper with the plain (now-merged) nn.Linear -- so the
38
+ saved checkpoint is an ORDINARY WorldModel state_dict, loadable by
39
+ every existing caller with zero LoRA-awareness needed downstream."""
40
+ for name, child in list(module.named_children()):
41
+ if isinstance(child, LoRALinear):
42
+ child.merge_into_base()
43
+ setattr(module, name, child.linear)
44
+ else:
45
+ _merge_and_unwrap(child)
46
+
47
+
48
+ def main(ckpt_path="checkpoints/connectx_checkpoint.pt", seed=0, rank=4, alpha=4.0,
49
+ selfplay_rounds=5, selfplay_epsilon=0.4, selfplay_mc_rounds_per_iter=15,
50
+ mc_problems_per_round=400, selfplay_pool_size=5,
51
+ opponent_epsilon=0.15, opponent_strong_epsilon=0.0,
52
+ save_path="checkpoints/connectx_checkpoint_lora_selfplay.pt"):
53
+ raw_ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
54
+ model, normalizer = load_checkpoint(ckpt_path)
55
+
56
+ rng = random.Random(seed)
57
+ torch.manual_seed(seed)
58
+ random.seed(seed) # env.py's opponent stochasticity reads the GLOBAL
59
+ # random module, not this local rng -- see train.py's own note
60
+
61
+ env = ConnectXEnv(width=raw_ckpt["board_width"], height=raw_ckpt["board_height"],
62
+ win_len=raw_ckpt["win_len"])
63
+ max_steps = (env.width * env.height) // 2 + 2
64
+
65
+ lora_params = apply_lora(model.value, rank=rank, alpha=alpha)
66
+ n_lora = sum(p.numel() for p in lora_params)
67
+ n_frozen = sum(p.numel() for p in model.value.parameters()) - n_lora
68
+ print(f"LoRA rank={rank} alpha={alpha} on model.value: "
69
+ f"{n_lora} trainable params, {n_frozen} frozen (base value head)")
70
+
71
+ frozen_pool = []
72
+ for sp_round in range(selfplay_rounds):
73
+ frozen_model = copy.deepcopy(model).eval()
74
+ for p in frozen_model.parameters():
75
+ p.requires_grad_(False)
76
+ frozen_pool.append(frozen_model)
77
+ if len(frozen_pool) > selfplay_pool_size:
78
+ frozen_pool.pop(0)
79
+ print(f"\nSelf-play LoRA fine-tune round {sp_round + 1}/{selfplay_rounds} "
80
+ f"(opponent_selfplay_epsilon={selfplay_epsilon}, pool_size={len(frozen_pool)})...")
81
+ opponent_env = ConnectXEnv(width=env.width, height=env.height, win_len=env.win_len)
82
+ selfplay_fn = make_selfplay_pool_opponent_fn(frozen_pool, normalizer, opponent_env)
83
+ selfplay_train_env = ConnectXEnv(width=env.width, height=env.height, win_len=env.win_len,
84
+ opponent_epsilon=opponent_epsilon,
85
+ opponent_strong_epsilon=opponent_strong_epsilon,
86
+ opponent_selfplay_epsilon=selfplay_epsilon,
87
+ opponent_policy_fn=selfplay_fn)
88
+ # Adam(model.value.parameters()) inside train_mc_value_onpolicy
89
+ # naturally trains ONLY the LoRA deltas here: the wrapped base
90
+ # linears have requires_grad=False (set by apply_lora), so their
91
+ # .grad stays None and Adam's step() skips them.
92
+ train_mc_value_onpolicy(selfplay_train_env, model, normalizer, rng,
93
+ n_rounds=selfplay_mc_rounds_per_iter,
94
+ n_problems_per_round=mc_problems_per_round,
95
+ max_steps=max_steps, unsolved_penalty=max_steps)
96
+
97
+ _merge_and_unwrap(model.value)
98
+
99
+ torch.save({
100
+ "model_state": model.state_dict(),
101
+ "norm_mean": normalizer.mean.cpu(),
102
+ "norm_std": normalizer.std.cpu(),
103
+ "state_dim": raw_ckpt["state_dim"],
104
+ "num_actions": raw_ckpt["num_actions"],
105
+ "latent_dim": raw_ckpt["latent_dim"],
106
+ "hidden_dim": raw_ckpt["hidden_dim"],
107
+ "board_width": env.width,
108
+ "board_height": env.height,
109
+ "win_len": env.win_len,
110
+ }, save_path)
111
+ print(f"\nSaved LoRA-self-play-fine-tuned checkpoint to {save_path}")
112
+
113
+ print("\n" + "=" * 20 + " EVALUATION (no oracle -- vs. random-legal-play baseline only) " + "=" * 20)
114
+ eval_rng = random.Random(999)
115
+ problems = [env.random_problem(eval_rng) for _ in range(150)]
116
+ random_baseline_win_rate(env, problems, max_steps, random.Random(1000))
117
+ evaluate(env, model, normalizer, problems, depth=1, beam_width=8, max_total_steps=max_steps,
118
+ label="Baseline A (model, depth=1)")
119
+
120
+
121
+ if __name__ == "__main__":
122
+ main()
scripts/train.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Trains a WorldModel checkpoint on the real Kaggle ConnectX board (7x6,
3
+ win_len=4). No oracle exists at this scale (see env.py's BFS_MAX_CELLS), so
4
+ this is a genuinely no-oracle recipe throughout:
5
+
6
+ 1. Stage 1 (dynamics + decoder): self-supervised, `train_utils.train_stage1`
7
+ on random transitions/rollouts -- never depends on an oracle for any
8
+ domain.
9
+ 2. Stage 2 (value head): `verifier.train_mc_value_onpolicy`, on-policy
10
+ Monte Carlo, `unsolved_penalty=max_steps` -- this is an adversarial
11
+ domain, where a training walk can end in a LOSS, not just run out of
12
+ steps; without this penalty every losing walk is silently discarded and
13
+ the value head never learns to avoid losing moves at all (confirmed as
14
+ the direct cause of a real 0%, worse-than-random win rate before this
15
+ was added).
16
+ 3. Self-play fine-tune: each round freezes the current model as an
17
+ opponent (viewed from the opponent's side, via `_swap_agent_opponent`)
18
+ and trains further against a small, capped POOL of past frozen
19
+ snapshots (not just the latest one) mixed with the original fixed
20
+ heuristic -- every opponent used above is fixed and non-learning, which
21
+ is the actual ceiling on how strong a non-self-play policy can get.
22
+
23
+ Evaluation has no oracle-ceiling comparison to report against (none exists
24
+ at this scale) -- instead: win rate against the ORIGINAL fixed opponent
25
+ (deterministic, `opponent_epsilon=0`) the model is ultimately graded
26
+ against, plus a random-legal-play win rate as the required floor.
27
+ """
28
+ import copy
29
+ import random
30
+
31
+ import torch
32
+
33
+ from connectx.env import ConnectXEnv, AGENT, OPPONENT, EMPTY, _encode_board
34
+ from connectx.model import WorldModel
35
+ from connectx.train_utils import DEVICE, StateNormalizer, generate_transitions, generate_rollout_sequences, \
36
+ train_stage1, eval_stage1, eval_multistep_rollout
37
+ from connectx.verifier import train_mc_value_onpolicy
38
+ from connectx.search import evaluate
39
+ from connectx.adversarial_search import real_adversarial_plan_action
40
+
41
+
42
+ def _swap_agent_opponent(cells):
43
+ """AGENT<->OPPONENT relabeling, EMPTY unchanged -- lets a model that
44
+ was only ever trained to play as AGENT evaluate a position from the
45
+ OTHER side's perspective, by pretending that side is AGENT instead."""
46
+ return [c if c == EMPTY else (OPPONENT if c == AGENT else AGENT) for c in cells]
47
+
48
+
49
+ def make_selfplay_pool_opponent_fn(frozen_pool, frozen_normalizer, opponent_env):
50
+ """Builds an `opponent_policy_fn(cells) -> column` (see env.py's
51
+ `opponent_policy_fn` extension point) that plays using a frozen
52
+ snapshot of this same architecture's own trained judgment, not a
53
+ hand-written heuristic. Each call picks a snapshot uniformly at random
54
+ from `frozen_pool` (a small population of past snapshots, not just the
55
+ latest one -- a coarse approximation of real population-based self-
56
+ play/fictitious play, so the live policy can't narrowly overfit to
57
+ counter-play against whatever the single latest snapshot happens to
58
+ do) and uses the real adversarial search (rounds=1 -- this function is
59
+ called on the order of 100K+ times across a training run, so a slower
60
+ multi-round search would balloon total training time)."""
61
+
62
+ def opponent_policy_fn(cells):
63
+ frozen_model = random.choice(frozen_pool)
64
+ swapped_state = tuple(_encode_board(_swap_agent_opponent(cells)))
65
+ with torch.no_grad():
66
+ a = real_adversarial_plan_action(opponent_env, frozen_model, frozen_normalizer, swapped_state, rounds=1)
67
+ return a
68
+
69
+ return opponent_policy_fn
70
+
71
+
72
+ def random_baseline_win_rate(env, problems, max_steps, rng):
73
+ """Required control: an agent picking uniformly among its legal
74
+ non-PASS columns. The floor the trained model needs to beat -- not a
75
+ certified ceiling (no oracle exists at this scale), just the honest
76
+ "did training do anything at all" check."""
77
+ wins = 0
78
+ for state, _ in problems:
79
+ cur = state
80
+ for _ in range(max_steps):
81
+ if env.is_solved(cur):
82
+ break
83
+ legal = [a for a in range(env.num_actions) if env.is_legal(cur, a)]
84
+ non_pass = [a for a in legal if a != env.width]
85
+ a = rng.choice(non_pass or legal)
86
+ cur, _r, done = env.step(cur, a)
87
+ if done:
88
+ break
89
+ if env.is_solved(cur):
90
+ wins += 1
91
+ n = len(problems)
92
+ print(f"{'Random-legal-play baseline':30s} win_rate={wins/n:.3f} ({wins}/{n})")
93
+ return wins / n
94
+
95
+
96
+ def main(seed=0, ckpt_path="checkpoints/connectx_checkpoint.pt", latent_dim=96, hidden_dim=256,
97
+ n_problems=2000, walk_len=8, mc_rounds=25, mc_problems_per_round=400,
98
+ opponent_epsilon=0.15, opponent_strong_epsilon=0.0,
99
+ selfplay_rounds=5, selfplay_epsilon=0.4, selfplay_mc_rounds_per_iter=15,
100
+ selfplay_pool_size=5):
101
+ # Two env instances, same board, different opponent determinism: `env`
102
+ # (opponent_epsilon=0, the pure deterministic opponent) is what
103
+ # evaluation is graded against. `train_env` (opponent_epsilon>0) is
104
+ # used ONLY for generating training data -- a perfectly deterministic
105
+ # opponent means every training walk from a matching starting side is
106
+ # the SAME exact game, a real, diagnosed weakness (the model
107
+ # reproducibly lost as first player against this exact opponent while
108
+ # winning as second player).
109
+ env = ConnectXEnv(width=7, height=6, win_len=4)
110
+ train_env = ConnectXEnv(width=7, height=6, win_len=4, opponent_epsilon=opponent_epsilon,
111
+ opponent_strong_epsilon=opponent_strong_epsilon)
112
+ rng = random.Random(seed)
113
+ torch.manual_seed(seed)
114
+ # Also seed the GLOBAL random module: env.py's opponent_epsilon/
115
+ # opponent_strong_epsilon rolls read `random.random()`/`random.choice()`
116
+ # directly, not this function's own seeded `rng` -- without this,
117
+ # "same seed" runs are silently not reproducible whenever opponent
118
+ # stochasticity is enabled.
119
+ random.seed(seed)
120
+ max_steps = (env.width * env.height) // 2 + 2
121
+
122
+ print(f"Domain: ConnectX (real board), {env.width}x{env.height}, win_len={env.win_len}, "
123
+ f"num_actions={env.num_actions}, state_dim={env.state_dim}, "
124
+ f"train opponent_epsilon={opponent_epsilon}, opponent_strong_epsilon={opponent_strong_epsilon}\n")
125
+ assert env.bfs_solve(env.random_problem(rng)[0]) is None, \
126
+ "expected no oracle at real-board scale -- see env.py's BFS_MAX_CELLS"
127
+
128
+ print("Generating stage-1 (dynamics) data...")
129
+ train_transitions = generate_transitions(train_env, rng, n_problems=n_problems, walk_len=walk_len)
130
+ val_transitions = generate_transitions(env, rng, n_problems=300, walk_len=walk_len)
131
+ print(f" {len(train_transitions)} train transitions, {len(val_transitions)} val transitions")
132
+
133
+ unroll_k = 4
134
+ train_sequences = generate_rollout_sequences(train_env, rng, n_problems=n_problems, k=unroll_k)
135
+ val_sequences = generate_rollout_sequences(env, rng, n_problems=300, k=unroll_k)
136
+ print(f" {len(train_sequences)} train sequences, {len(val_sequences)} val sequences (k={unroll_k})")
137
+
138
+ all_states_for_norm = [t[0] for t in train_transitions] + [t[2] for t in train_transitions]
139
+ normalizer = StateNormalizer(all_states_for_norm).to(DEVICE)
140
+
141
+ model = WorldModel(env.state_dim, env.num_actions, latent_dim=latent_dim, hidden_dim=hidden_dim).to(DEVICE)
142
+
143
+ print("\nStage 1: training encoder + dynamics + decoder...")
144
+ train_stage1(model, normalizer, train_transitions, sequences=train_sequences, k=unroll_k)
145
+
146
+ print("\nStage-1 val metrics:")
147
+ print(" ", eval_stage1(model, normalizer, val_transitions))
148
+ print(" ", eval_multistep_rollout(model, normalizer, val_sequences, k=unroll_k))
149
+
150
+ print("\nStage 2: no-oracle value training (on-policy Monte Carlo, bfs_solve never called)...")
151
+ train_mc_value_onpolicy(train_env, model, normalizer, rng, n_rounds=mc_rounds,
152
+ n_problems_per_round=mc_problems_per_round, max_steps=max_steps,
153
+ unsolved_penalty=max_steps)
154
+
155
+ # Self-play fine-tune: only the value head trains during
156
+ # train_mc_value_onpolicy (encoder/dynamics/decoder stay fixed from
157
+ # stage 1), so each frozen snapshot's encoder/dynamics are identical
158
+ # to the live model's -- only the value judgment (and therefore the
159
+ # self-play opponent's move choices) differs round to round.
160
+ # `selfplay_pool_size` keeps a capped, small population of past
161
+ # snapshots (drops the oldest once full) rather than only the single
162
+ # latest one, or an unbounded pool that would let early, still-weak
163
+ # snapshots dominate forever.
164
+ frozen_pool = []
165
+ for sp_round in range(selfplay_rounds):
166
+ frozen_model = copy.deepcopy(model).eval()
167
+ for p in frozen_model.parameters():
168
+ p.requires_grad_(False)
169
+ frozen_pool.append(frozen_model)
170
+ if len(frozen_pool) > selfplay_pool_size:
171
+ frozen_pool.pop(0)
172
+ print(f"\nSelf-play fine-tune round {sp_round + 1}/{selfplay_rounds} "
173
+ f"(opponent_selfplay_epsilon={selfplay_epsilon}, pool_size={len(frozen_pool)})...")
174
+ opponent_env = ConnectXEnv(width=7, height=6, win_len=4) # plain -- used only for is_legal/num_actions
175
+ selfplay_fn = make_selfplay_pool_opponent_fn(frozen_pool, normalizer, opponent_env)
176
+ selfplay_train_env = ConnectXEnv(width=7, height=6, win_len=4,
177
+ opponent_epsilon=opponent_epsilon,
178
+ opponent_selfplay_epsilon=selfplay_epsilon,
179
+ opponent_policy_fn=selfplay_fn)
180
+ train_mc_value_onpolicy(selfplay_train_env, model, normalizer, rng, n_rounds=selfplay_mc_rounds_per_iter,
181
+ n_problems_per_round=mc_problems_per_round, max_steps=max_steps,
182
+ unsolved_penalty=max_steps)
183
+
184
+ if ckpt_path:
185
+ torch.save({
186
+ "model_state": model.state_dict(),
187
+ "norm_mean": normalizer.mean.cpu(),
188
+ "norm_std": normalizer.std.cpu(),
189
+ "state_dim": env.state_dim,
190
+ "num_actions": env.num_actions,
191
+ "latent_dim": latent_dim,
192
+ "hidden_dim": hidden_dim,
193
+ "board_width": env.width,
194
+ "board_height": env.height,
195
+ "win_len": env.win_len,
196
+ }, ckpt_path)
197
+ print(f"\nSaved checkpoint to {ckpt_path}")
198
+
199
+ print("\n" + "=" * 20 + " EVALUATION (no oracle -- vs. random-legal-play baseline only) " + "=" * 20)
200
+ eval_rng = random.Random(999)
201
+ problems = [env.random_problem(eval_rng) for _ in range(150)]
202
+ random_baseline_win_rate(env, problems, max_steps, random.Random(1000))
203
+ evaluate(env, model, normalizer, problems, depth=1, beam_width=8, max_total_steps=max_steps,
204
+ label="Baseline A (model, depth=1)")
205
+ evaluate(env, model, normalizer, problems, depth=3, beam_width=8, max_total_steps=max_steps,
206
+ label="Search (model, depth=3)")
207
+
208
+ return model, normalizer
209
+
210
+
211
+ if __name__ == "__main__":
212
+ main()
submission.py ADDED
The diff for this file is too large to render. See raw diff
 
submission_pre_deeper_escalation_backup.py ADDED
The diff for this file is too large to render. See raw diff
 
tests/__init__.py ADDED
File without changes
tests/submission_test.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Validates connectx_submission.py against a REAL, independent, single-ply
3
+ Connect-4 game engine -- deliberately NOT reusing connectx_env.py's
4
+ `step` (which bundles our move and its own fixed opponent's reply into
5
+ one call, exactly the shortcut this test needs to NOT rely on, since the
6
+ whole point is to check the submission's `agent(observation,
7
+ configuration)` interface the way Kaggle's real engine actually drives
8
+ it: one call per ply, alternating sides, real `board`/`mark`/
9
+ `configuration` objects).
10
+
11
+ Three opponents, since a single opponent can't tell us much:
12
+ 1. Random-legal-column opponent (a floor -- if this loses to random, the
13
+ translation from Kaggle's board format is definitely broken).
14
+ 2. The SAME fixed heuristic connectx_env.py trains against (win-now /
15
+ block / leftmost) -- a sanity check that connectx_train.py's own
16
+ 100% internal solve-rate result actually reproduces when the
17
+ submission is driven through a REAL alternating-turn engine instead
18
+ of the training env's bundled step().
19
+ 3. A slightly stronger heuristic (look-two-ahead: also block if leaving
20
+ the opponent a forced win one ply later) -- a first, cheap check of
21
+ what this project's own docs already name as the honest limitation
22
+ (trained against one fixed weak opponent, no guarantee against a
23
+ different/stronger one).
24
+ """
25
+ import importlib.util
26
+ import random
27
+ import sys
28
+ from types import SimpleNamespace
29
+
30
+ WIDTH, HEIGHT, WIN_LEN = 7, 6, 4
31
+
32
+
33
+ def _rc(row, col):
34
+ return row * WIDTH + col
35
+
36
+
37
+ def _lowest_empty_row(board, col):
38
+ for row in range(HEIGHT - 1, -1, -1):
39
+ if board[_rc(row, col)] == 0:
40
+ return row
41
+ return None
42
+
43
+
44
+ def legal_columns(board):
45
+ return [c for c in range(WIDTH) if _lowest_empty_row(board, c) is not None]
46
+
47
+
48
+ def wins_for(board, mark):
49
+ for row in range(HEIGHT):
50
+ for col in range(WIDTH):
51
+ if board[_rc(row, col)] != mark:
52
+ continue
53
+ for dr, dc in ((0, 1), (1, 0), (1, 1), (1, -1)):
54
+ er, ec = row + dr * (WIN_LEN - 1), col + dc * (WIN_LEN - 1)
55
+ if not (0 <= er < HEIGHT and 0 <= ec < WIDTH):
56
+ continue
57
+ if all(board[_rc(row + dr * k, col + dc * k)] == mark for k in range(WIN_LEN)):
58
+ return True
59
+ return False
60
+
61
+
62
+ def board_full(board):
63
+ return all(c != 0 for c in board)
64
+
65
+
66
+ def drop(board, col, mark):
67
+ row = _lowest_empty_row(board, col)
68
+ board = list(board)
69
+ board[_rc(row, col)] = mark
70
+ return board
71
+
72
+
73
+ def random_opponent(board, mark, rng):
74
+ return rng.choice(legal_columns(board))
75
+
76
+
77
+ def weak_heuristic_opponent(board, mark, rng):
78
+ """The exact policy connectx_env.py bakes into training (win-now /
79
+ block / leftmost) -- reimplemented independently here (not imported)
80
+ so this test doesn't share a bug with the thing it's checking."""
81
+ opp = 2 if mark == 1 else 1
82
+ legal = legal_columns(board)
83
+ for col in legal:
84
+ if wins_for(drop(board, col, mark), mark):
85
+ return col
86
+ for col in legal:
87
+ if wins_for(drop(board, col, opp), opp):
88
+ return col
89
+ return legal[0]
90
+
91
+
92
+ def stronger_heuristic_opponent(board, mark, rng):
93
+ """weak_heuristic_opponent plus a 1-ply-deeper check: among moves that
94
+ pass the first two checks, avoid any move that hands the OPPONENT an
95
+ immediate winning reply next turn, if a safer alternative exists."""
96
+ opp = 2 if mark == 1 else 1
97
+ legal = legal_columns(board)
98
+ for col in legal:
99
+ if wins_for(drop(board, col, mark), mark):
100
+ return col
101
+ for col in legal:
102
+ if wins_for(drop(board, col, opp), opp):
103
+ return col
104
+ safe = []
105
+ for col in legal:
106
+ nxt = drop(board, col, mark)
107
+ if board_full(nxt):
108
+ safe.append(col)
109
+ continue
110
+ opp_can_win = any(wins_for(drop(nxt, c2, opp), opp) for c2 in legal_columns(nxt))
111
+ if not opp_can_win:
112
+ safe.append(col)
113
+ return rng.choice(safe) if safe else legal[0]
114
+
115
+
116
+ def _load_agent(path):
117
+ spec = importlib.util.spec_from_file_location("connectx_submission", path)
118
+ mod = importlib.util.module_from_spec(spec)
119
+ spec.loader.exec_module(mod)
120
+ return mod.agent
121
+
122
+
123
+ def play_game(our_agent, opponent_fn, our_mark, rng, max_plies=WIDTH * HEIGHT):
124
+ board = [0] * (WIDTH * HEIGHT)
125
+ opp_mark = 2 if our_mark == 1 else 1
126
+ config = SimpleNamespace(columns=WIDTH, rows=HEIGHT, inarow=WIN_LEN)
127
+ current = 1 # P1 always moves first
128
+ for _ply in range(max_plies):
129
+ if current == our_mark:
130
+ obs = SimpleNamespace(board=list(board), mark=our_mark)
131
+ col = our_agent(obs, config)
132
+ if col not in legal_columns(board): # safety net -- must never trigger
133
+ col = legal_columns(board)[0]
134
+ board = drop(board, col, our_mark)
135
+ if wins_for(board, our_mark):
136
+ return "win"
137
+ else:
138
+ col = opponent_fn(board, opp_mark, rng)
139
+ board = drop(board, col, opp_mark)
140
+ if wins_for(board, opp_mark):
141
+ return "loss"
142
+ if board_full(board):
143
+ return "draw"
144
+ current = opp_mark if current == our_mark else our_mark
145
+ return "draw"
146
+
147
+
148
+ def run_match(our_agent, opponent_fn, opponent_name, n_games, seed):
149
+ """Each game gets its OWN independently-seeded rng (`seed`, `i`) --
150
+ NOT one shared rng consumed sequentially across all `n_games` (the
151
+ original version of this function). That original design meant a
152
+ different agent taking even one different early action would shift
153
+ every subsequent random draw for the REST of the batch, silently
154
+ turning "compare agent A vs agent B on the same 60 games" into two
155
+ barely-related sequences of games -- confirmed as a real confound,
156
+ not a hypothetical one: testing an opening-move change that only
157
+ ever affects the very first ply still swung the OVERALL 60-game
158
+ win rate by double digits, which a change isolated to ply 0 has no
159
+ honest mechanism to cause on its own against a fixed opponent
160
+ POLICY (only via this exact RNG-cascade artifact). Per-game seeding
161
+ makes different agents' results directly, fairly comparable game-by-
162
+ game."""
163
+ results = {"win": 0, "loss": 0, "draw": 0}
164
+ for i in range(n_games):
165
+ rng = random.Random(seed * 100_003 + i) # large odd multiplier, cheap decorrelation across games
166
+ our_mark = 1 if i % 2 == 0 else 2 # alternate who moves first
167
+ outcome = play_game(our_agent, opponent_fn, our_mark, rng)
168
+ results[outcome] += 1
169
+ n = n_games
170
+ print(f"vs {opponent_name:28s} win={results['win']}/{n} ({results['win']/n:.1%}) "
171
+ f"loss={results['loss']}/{n} draw={results['draw']}/{n}")
172
+ return results
173
+
174
+
175
+ if __name__ == "__main__":
176
+ path = sys.argv[1] if len(sys.argv) > 1 else "submission.py"
177
+ our_agent = _load_agent(path)
178
+ print(f"Loaded agent from {path}\n")
179
+
180
+ n_games = 60
181
+ run_match(our_agent, random_opponent, "random-legal-play", n_games, seed=0)
182
+ run_match(our_agent, weak_heuristic_opponent, "weak heuristic (=training opp)", n_games, seed=1)
183
+ run_match(our_agent, stronger_heuristic_opponent, "stronger heuristic (1-ply deeper)", n_games, seed=2)