Publish full project code and checkpoint
Browse files- .claude/settings.local.json +8 -0
- .gitignore +29 -0
- LICENSE +21 -0
- README.md +211 -3
- checkpoints/connectx_checkpoint.pt +3 -0
- connectx/__init__.py +0 -0
- connectx/adversarial_search.py +387 -0
- connectx/env.py +417 -0
- connectx/environment.py +70 -0
- connectx/episodic_memory.py +136 -0
- connectx/lora.py +63 -0
- connectx/memory_build.py +55 -0
- connectx/model.py +104 -0
- connectx/search.py +179 -0
- connectx/train_utils.py +205 -0
- connectx/verifier.py +136 -0
- docs/WHITEPAPER.pdf +0 -0
- docs/build_whitepaper.py +336 -0
- scripts/__init__.py +0 -0
- scripts/build_submission.py +843 -0
- scripts/lora_selfplay_finetune.py +122 -0
- scripts/train.py +212 -0
- submission.py +0 -0
- submission_pre_deeper_escalation_backup.py +0 -0
- tests/__init__.py +0 -0
- tests/submission_test.py +183 -0
.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 |
-
|
| 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)
|