File size: 38,645 Bytes
9ede8c0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 | """
Packages connectx_checkpoint.pt into a SINGLE self-contained submission.py
Kaggle can actually run -- REWRITTEN 2026-08-10, per explicit user
direction ("let's combine all these in the submission file with
memories, the simulations, the weak learner etc") to fold in THREE
confirmed pieces from this session's follow-up work, replacing the
previous latent-beam-search-only version entirely:
1. **Real adversarial search** (was: latent beam search). Per
connectx_adversarial_search.py's confirmed result (95-100%/48.3% vs
random/weak/stronger, beating latent search on every metric by a wide
margin, confirmed on 2 checkpoints): `env.step()` bundles agent+
opponent-reply into one transition, so the trained dynamics model was
never shown "the board right after my move, before their reply" --
it structurally can't imagine that state. Since ConnectX's rules ARE
exactly known, this ply is done in REAL board space instead (exact
enumeration of our moves, exact enumeration of the opponent's
worst-case real reply), with the learned value head used ONLY as the
leaf evaluator. This means `dynamics`/`decoder` are NO LONGER NEEDED
at all (the old latent search's neurosymbolic decode-gate is
structurally unnecessary once every ply is real, not imagined) --
only `encoder`+`value` weights are embedded now, a smaller submission.
2. **Episodic memory (positive + negative)**, built OFFLINE (this
script, at build time) from self-play games against a MIXED opponent
(weak heuristic + random + the stronger 1-ply-deeper heuristic, per
explicit user caution -- "so if the opponent is weak it doesn't learn
the bad ways too" -- see memory_build.py). Won games
stored as positive (low remaining-steps) examples, lost/drawn games
as negative (high, fixed-penalty) examples -- one EpisodicMemory,
blended into every leaf evaluation via the exact same k-NN
inverse-distance/trust-scaled formula as episodic_memory.py's
`query_batch`, replicated here in plain torch (no project import,
this file must stay standalone).
3. **Best-effort online learning ("the weak learner")** -- value-head-
ONLY updates (matching this session's own confirmed finding: decoder
updates regressed structured-opponent performance at this data scale,
so the decoder is excluded entirely here, consistent with "the
working side only"), applied incrementally as real games are played,
mirroring continuous_learner.py's confirmed-safe recipe (small
replay buffer, EMA-updated value_target_mean/std, a few Adam steps
per update, lr=1e-5) -- reimplemented here in plain torch since this
file can't import continuous_learner.py.
**Honest, load-bearing caveat, stated plainly rather than oversold**:
Kaggle's `agent(observation, configuration)` interface gives no
direct "episode ended, here's the result" callback -- this file
infers a completed episode two ways, both using ONLY information
actually available across calls: (a) our own move immediately wins
or draws (directly observable -- we know the board we just produced),
or (b) the NEXT call arrives with a completely empty board while a
previous episode's trajectory is still buffered -- inferred as a LOSS
(we didn't win/draw it ourselves, so it must have ended on the
opponent's move). This whole mechanism is a NO-OP, gracefully, unless
Kaggle's real evaluation infrastructure reuses the same process across
multiple episodes for this submission over time (its own rules page,
read earlier this session, doesn't confirm or deny this -- see
[[project_connectx_kaggle]]) -- if each episode gets a fresh process,
this buffer simply starts empty every time and nothing is lost, no
crash, no wasted budget beyond one negligible check.
"""
import base64
import io
import torch
CKPT_PATH = "checkpoints/connectx_checkpoint.pt"
OUT_PATH = "submission.py"
def _encode_tensor_blob(ck, memory_zs, memory_outcomes):
"""encoder+value weights only (see module docstring -- dynamics/
decoder are no longer needed by the real adversarial search), plus
value_target_mean/std (top-level buffers, not nested under a
submodule prefix) and the offline-built episodic memory's raw
(z, remaining_steps) pairs."""
keep = {k: v for k, v in ck["model_state"].items()
if k.startswith("encoder.") or k.startswith("value.")
or k in ("value_target_mean", "value_target_std")}
payload = {
"weights": keep,
"norm_mean": ck["norm_mean"],
"norm_std": ck["norm_std"],
"state_dim": ck["state_dim"],
"num_actions": ck["num_actions"],
"latent_dim": ck["latent_dim"],
"hidden_dim": ck["hidden_dim"],
"board_width": ck["board_width"],
"board_height": ck["board_height"],
"win_len": ck["win_len"],
"memory_zs": torch.stack(memory_zs) if memory_zs else torch.zeros(0, ck["latent_dim"]),
"memory_outcomes": torch.tensor(memory_outcomes, dtype=torch.float32),
}
buf = io.BytesIO()
torch.save(payload, buf)
return base64.b64encode(buf.getvalue()).decode("ascii")
SUBMISSION_TEMPLATE = '''\
"""
Auto-generated by build_submission.py -- DO NOT hand-edit
(regenerate instead). Self-contained Kaggle ConnectX submission: no
imports beyond torch/base64/io, so it runs standalone in Kaggle's
evaluation sandbox.
Policy: ONE ROUND of REAL adversarial search (exact enumeration of our
legal moves, exact enumeration of the opponent's real legal replies,
worst-case-for-us selected -- a genuine minimax over EXACTLY KNOWN board
dynamics, not an imagined latent transition) -- the learned value head
is used ONLY as the leaf evaluator on a real, never-imagined state,
optionally blended with an offline-built episodic memory (won AND lost
self-play games, see module docstring). A best-effort online value-head
update also runs across real games as they're played -- see module
docstring's honest caveat about when this can/can't actually do
anything, given Kaggle's evaluation interface.
**Honest, named limitation** (see connectx_env.py / [[project_connectx_kaggle]]):
the base checkpoint was trained via self-play against a small set of
fixed/self-generated opponents, not against Kaggle's real matchmaking
pool -- see that project's memory entry for the full picture, including
this session's confirmed numbers against synthetic test opponents.
"""
import base64
import collections
import io
import time
import torch
_MEMORY_WEIGHT = {memory_weight}
_MEMORY_K = {memory_k}
_ONLINE_LR = {online_lr}
_ONLINE_UPDATES_PER_EPISODE = {online_updates_per_episode}
_ONLINE_BATCH_SIZE = {online_batch_size}
_UNSOLVED_PENALTY_MULT = {unsolved_penalty_mult} # x max_steps, matches this session's convention
_ADV_ROUNDS = {adv_rounds} # real adversarial search rounds -- see _adversarial_plan_action's docstring for timing
# `_ENDGAME_MAX_COLS`/`_ENDGAME_TIME_BUDGET` (added 2026-08-11): below
# this many legal columns, `_exact_endgame_solve` (a real, no-NN,
# alpha-beta minimax to the true end of the game) is tried FIRST and used
# directly if it finishes in time -- see that function's own docstring
# for the calibration and the exact failure mode (a zugzwang/parity trap
# invisible to any bounded-depth search) this targets. `_ENDGAME_MAX_COLS
# = 0` disables this path entirely.
_ENDGAME_MAX_COLS = {endgame_max_cols}
_ENDGAME_TIME_BUDGET = {endgame_time_budget}
# `_DEEPER_ROUNDS`/`_DEEPER_MAX_BRANCHING`/`_DEEPER_TIME_BUDGET`: real,
# mined-from-real-games evidence showed `_ADV_ROUNDS` sometimes sees ZERO
# danger on a position (every column looks equally safe) 2-4 plies before
# a trap that one round DEEPER already narrows down to exactly one safe
# column -- `_ADV_ROUNDS` isn't wrong about what it can see, it just can't
# see far enough to avoid a fork the opponent is setting up. A deeper
# search is provably too slow to run on EVERY move (measured 8-13s at a
# 6-7-legal-column branching factor) -- so this is a SAFE, opportunistic
# escalation, not a blanket depth increase: `_DEEPER_ROUNDS = None`
# disables it entirely, reproducing the original `_ADV_ROUNDS`-only
# behavior byte-for-byte. When enabled, AFTER computing the normal-
# `_ADV_ROUNDS` answer (always -- the guaranteed-safe fallback), a
# `_DEEPER_ROUNDS`-round search is attempted under a hard
# `_DEEPER_TIME_BUDGET` deadline; if it finishes in time its answer is
# used instead (strictly more information, never less), if it times out
# the original answer is returned completely unchanged. Calibrated via a
# 180-game regression suite (random/weak/stronger opponents): zero
# win-rate regression, max observed single-move time 1.641s --
# comfortably under Kaggle's 2s budget.
_DEEPER_ROUNDS = {deeper_rounds}
_DEEPER_MAX_BRANCHING = {deeper_max_branching}
_DEEPER_TIME_BUDGET = {deeper_time_budget}
# `_ONLINE_ENABLED` (added 2026-08-10, right before submitting -- explicit
# user decision after reading the competition's own rule "An Agent's sole
# purpose is to generate an action. Activities/code which do not directly
# contribute to this will be considered malicious...": the online "weak
# learner"'s gradient updates are arguably in service of generating BETTER
# actions, not unrelated activity, but it's a genuine judgment call with
# real (if likely small) risk, not a zero-risk one -- played safe rather
# than assume it's fine. False disables it CLEANLY (no buffer/episode-
# tracking side-state at all when off, not just a no-op update call) so
# a disabled submission's `agent()` genuinely does nothing but generate
# an action, matching the rule as literally as possible.
_ONLINE_ENABLED = {online_enabled}
_BLOB_B64 = (
{blob_literal}
)
def _load():
payload = torch.load(io.BytesIO(base64.b64decode(_BLOB_B64)), map_location="cpu")
return payload
_P = _load()
_W = _P["weights"]
_NORM_MEAN = _P["norm_mean"]
_NORM_STD = _P["norm_std"]
_LATENT_DIM = _P["latent_dim"]
_NUM_ACTIONS = _P["num_actions"] # includes the training-time PASS action (index WIDTH)
_WIDTH = _P["board_width"]
_HEIGHT = _P["board_height"]
_WIN_LEN = _P["win_len"]
_PASS_ACTION = _WIDTH
_CELL_WIDTH = 3
_EMPTY, _AGENT, _OPPONENT = 0, 1, 2
_MAX_STEPS = (_WIDTH * _HEIGHT) // 2 + 2
# `_UNSOLVED_PENALTY` (used ONLY by the online learner's episode-ending
# label, matching continuous_learner.py's own 1x-max_steps convention)
# and `_LOSS_PENALTY` (used ONLY by the adversarial search's "opponent
# wins" terminal case) are DELIBERATELY SEPARATE constants -- a real bug
# found and fixed 2026-08-10, right after this build was already live:
# an earlier version used _UNSOLVED_PENALTY (1x max_steps) for BOTH,
# which meant the search scored "the opponent wins outright" EXACTLY
# THE SAME as "it's a mere draw" -- losing must be unambiguously worse
# than a draw for the search to reliably prioritize blocking a real
# threat over a merely-mediocre move, matching connectx_adversarial_search.py's
# original, correct 2x convention. Confirmed as the direct, mechanistic
# cause of a real observed failure: the deployed agent missed blocking
# an opponent's obvious 3-in-a-column vertical threat, scoring the
# blocking move WORSE (23.463) than a non-blocking move that let the
# opponent win outright (23.000, since the loss was scored at only
# max_steps=23, indistinguishable from ordinary mediocre play).
_UNSOLVED_PENALTY = _UNSOLVED_PENALTY_MULT * _MAX_STEPS
_LOSS_PENALTY = 2 * _MAX_STEPS
# Memory tensors (offline-built, see module docstring) -- fixed, never
# grow at runtime (only the ONLINE value-head buffer below does).
_MEMORY_Z = _P["memory_zs"]
_MEMORY_OUTCOMES = _P["memory_outcomes"]
if _MEMORY_Z.shape[0] >= 2:
_d = torch.cdist(_MEMORY_Z, _MEMORY_Z)
_d = torch.where(_d > 1e-6, _d, torch.full_like(_d, float("inf")))
_nn = _d.min(dim=1).values
_nn = _nn[torch.isfinite(_nn)]
_MEMORY_TRUST_SCALE = _nn.median().item() if len(_nn) > 0 else 1.0
else:
_MEMORY_TRUST_SCALE = 1.0
# --- Value head params made trainable for the online "weak learner"
# (see module docstring's honest caveat) -- encoder stays FROZEN
# (never in this optimizer), matching continuous_learner.py's confirmed
# recipe: only the value head updates online. When `_ONLINE_ENABLED` is
# False, NONE of this setup happens at all (no optimizer, no
# requires_grad, no buffers) -- `agent()` genuinely does nothing but
# generate an action in that case, not just a disabled-but-present
# mechanism. ---
if _ONLINE_ENABLED:
_VALUE_PARAM_KEYS = [k for k in _W if k.startswith("value.")]
for _k in _VALUE_PARAM_KEYS:
_W[_k].requires_grad_(True)
# Buffers, not trained parameters (EMA-updated in-place under
# no_grad, matching continuous_learner.py's own convention) -- never
# added to the optimizer below.
_VALUE_TARGET_MEAN = _W.get("value_target_mean", torch.tensor(0.0)).clone()
_VALUE_TARGET_STD = _W.get("value_target_std", torch.tensor(1.0)).clone()
_ONLINE_OPT = torch.optim.Adam([_W[k] for k in _VALUE_PARAM_KEYS], lr=_ONLINE_LR)
_REPLAY_BUFFER = collections.deque(maxlen=2000) # (state_vec: list[float], label: float)
_EPISODE_STATES = [] # real one-hot state vectors seen/produced so far THIS episode
_EPISODE_LAST_PIECES = None # total board piece count as of our last recorded state THIS episode
else:
_VALUE_TARGET_MEAN = _W.get("value_target_mean", torch.tensor(0.0))
_VALUE_TARGET_STD = _W.get("value_target_std", torch.tensor(1.0))
def _linear(x, w_key, b_key):
return torch.nn.functional.linear(x, _W[w_key], _W[b_key])
def _mlp3(x, prefix):
"""Replicates model.py's `mlp([in, hidden, hidden, out])`: Linear ->
ReLU -> Linear -> ReLU -> Linear (params at Sequential indices
0/2/4, confirmed against the actual saved state_dict keys)."""
h = torch.relu(_linear(x, f"{{prefix}}.net.0.weight", f"{{prefix}}.net.0.bias"))
h = torch.relu(_linear(h, f"{{prefix}}.net.2.weight", f"{{prefix}}.net.2.bias"))
return _linear(h, f"{{prefix}}.net.4.weight", f"{{prefix}}.net.4.bias")
def _encode(state_vec):
return _mlp3(state_vec, "encoder")
def _value_raw(z):
return _mlp3(z, "value").squeeze(-1)
def _value(z):
"""Real-scale value estimate (remaining steps), see model.py's
WorldModel.evaluate -- denormalizes the network's raw prediction."""
return _value_raw(z) * _VALUE_TARGET_STD + _VALUE_TARGET_MEAN
def _memory_blend(z_batch, raw_values):
"""Same k-NN inverse-distance/trust-scaled blend as
episodic_memory.py's EpisodicMemory.query_batch -- replicated here
in plain torch (this file can't import that module)."""
if _MEMORY_Z.shape[0] == 0 or _MEMORY_WEIGHT <= 0:
return raw_values
dists = torch.cdist(z_batch, _MEMORY_Z) # [B, N]
k = min(_MEMORY_K, _MEMORY_Z.shape[0])
topk_dists, topk_idx = torch.topk(dists, k, largest=False, dim=1)
topk_outcomes = _MEMORY_OUTCOMES[topk_idx]
weights = 1.0 / (topk_dists + 1e-2)
weights = weights / weights.sum(dim=1, keepdim=True)
blended = (weights * topk_outcomes).sum(dim=1)
mean_dist = topk_dists.mean(dim=1)
trust = torch.exp(-mean_dist / _MEMORY_TRUST_SCALE)
w = _MEMORY_WEIGHT * trust
return (1 - w) * raw_values + w * blended
# --- Plain-Python board helpers (no torch) -- mirrors connectx_env.py's
# free functions exactly, duplicated here (not imported) since this file
# must be standalone. ---
def _onehot(idx, n):
v = [0] * n
v[idx] = 1
return v
def _rc(row, col):
return row * _WIDTH + col
def _encode_board(cells):
out = []
for c in cells:
out.extend(_onehot(c, _CELL_WIDTH))
return out
def _lowest_empty_row(cells, col):
for row in range(_HEIGHT - 1, -1, -1):
if cells[_rc(row, col)] == _EMPTY:
return row
return None
def _legal_columns(cells):
return [c for c in range(_WIDTH) if _lowest_empty_row(cells, c) is not None]
def _wins_for(cells, mark):
for row in range(_HEIGHT):
for col in range(_WIDTH):
if cells[_rc(row, col)] != mark:
continue
for dr, dc in ((0, 1), (1, 0), (1, 1), (1, -1)):
er, ec = row + dr * (_WIN_LEN - 1), col + dc * (_WIN_LEN - 1)
if not (0 <= er < _HEIGHT and 0 <= ec < _WIDTH):
continue
if all(cells[_rc(row + dr * k, col + dc * k)] == mark for k in range(_WIN_LEN)):
return True
return False
def _board_full(cells):
return all(c != _EMPTY for c in cells)
def _apply_move(cells, col, mark):
row = _lowest_empty_row(cells, col)
new_cells = list(cells)
new_cells[_rc(row, col)] = mark
return new_cells
def _kaggle_board_to_cells(board, mark):
"""Kaggle's board: flat list, row-major, 0=empty/1=P1/2=P2, row 0 =
top -- SAME convention connectx_env.py already uses, confirmed
against kaggle_environments' own connectx.json. `mark` tells us
which of Kaggle's 1/2 is US."""
opponent_mark = 2 if mark == 1 else 1
cells = []
for v in board:
if v == 0:
cells.append(_EMPTY)
elif v == mark:
cells.append(_AGENT)
else:
assert v == opponent_mark
cells.append(_OPPONENT)
return cells
def _leaf_batch_values(states):
if not states:
return {{}}
state_t = torch.tensor(states, dtype=torch.float32)
norm_t = (state_t - _NORM_MEAN) / _NORM_STD
z = _encode(norm_t)
vals = _memory_blend(z, _value(z))
return dict(zip(states, vals.tolist()))
def _narrow_to_center(legal_cols, max_branching):
"""Prunes a legal-column list down to `max_branching` columns closest
to the board's center -- free, real Connect-4 domain knowledge (a
center column touches more potential 4-in-a-row lines than an edge
one, same theory as the empty-board opening hint). `max_branching=
None` is a no-op -- exact, unpruned enumeration. Only ever applied to
OUR OWN follow-up move choices at the deeper-escalation's round 2+
(see `_DEEPER_ROUNDS`'s docstring) -- never to `_ADV_ROUNDS`'s own
(always-unpruned) path, and never to the opponent's reply enumeration
at ANY round (that's what makes this a genuine worst-case
guarantee -- narrowing it would mean silently ignoring some of the
opponent's real threats)."""
if max_branching is None or len(legal_cols) <= max_branching:
return legal_cols
center = (_WIDTH - 1) / 2
return sorted(legal_cols, key=lambda c: abs(c - center))[:max_branching]
class _RoundSearchTimeout(Exception):
pass
def _check_deadline(deadline):
if deadline is not None and time.time() > deadline:
raise _RoundSearchTimeout()
def _collect_leaves(cells1, remaining_rounds, leaf_cache, max_branching=None, deadline=None):
_check_deadline(deadline)
if _board_full(cells1):
return
for opp_col in _legal_columns(cells1):
cells2 = _apply_move(cells1, opp_col, _OPPONENT)
if _wins_for(cells2, _OPPONENT) or _board_full(cells2):
continue
if remaining_rounds <= 1:
leaf_cache[tuple(_encode_board(cells2))] = None
else:
for a2 in _narrow_to_center(_legal_columns(cells2), max_branching):
cells3 = _apply_move(cells2, a2, _AGENT)
if _wins_for(cells3, _AGENT):
continue
_collect_leaves(cells3, remaining_rounds - 1, leaf_cache, max_branching, deadline)
def _score_after_our_move(cells1, remaining_rounds, leaf_cache, max_branching=None, deadline=None):
"""cells1: real board right after OUR move (caller already ruled out
an immediate win here). Returns our worst-case score -- opponent
picks whichever real reply hurts us most. Reads leaf values from
`leaf_cache` (already populated by ONE upfront batched call over the
WHOLE tree -- see _adversarial_plan_action) instead of calling the
value head again at every node."""
if _board_full(cells1):
return float(_MAX_STEPS)
vals = []
for opp_col in _legal_columns(cells1):
cells2 = _apply_move(cells1, opp_col, _OPPONENT)
if _wins_for(cells2, _OPPONENT):
vals.append(float(_LOSS_PENALTY)) # opponent wins -- worse than a mere draw, see _LOSS_PENALTY's comment
elif _board_full(cells2):
vals.append(float(_MAX_STEPS))
elif remaining_rounds <= 1:
vals.append(leaf_cache[tuple(_encode_board(cells2))])
else:
vals.append(_score_after_opponent_move(cells2, remaining_rounds - 1, leaf_cache, max_branching, deadline))
return max(vals)
def _score_after_opponent_move(cells2, remaining_rounds, leaf_cache, max_branching=None, deadline=None):
"""cells2: real board after the opponent's move, our turn again.
Returns OUR best achievable worst-case score from here."""
_check_deadline(deadline)
our_legal = _narrow_to_center(_legal_columns(cells2), max_branching)
if not our_legal:
return float(_MAX_STEPS)
best = None
for a in our_legal:
cells3 = _apply_move(cells2, a, _AGENT)
if _wins_for(cells3, _AGENT):
return -float(_MAX_STEPS) # a forced win exists deeper -- short-circuit
s = _score_after_our_move(cells3, remaining_rounds, leaf_cache, max_branching, deadline)
if best is None or s < best:
best = s
return best
class _EndgameTimeout(Exception):
pass
def _exact_endgame_solve(cells0, mover, deadline):
"""Exact (no NN) alpha-beta minimax to the true end of the game --
see adversarial_search.py's identical function for
the full docstring/calibration; this is a plain-torch-free, standalone
port (same convention as every other function in this file) so the
packaged submission never imports the project. Returns
`(best_action, value)` (value from `mover`'s own perspective, +1/-1/0)
or `(None, None)` if `deadline` was hit first."""
memo = {{}}
center = (_WIDTH - 1) / 2
def solve(cells, to_move, alpha, beta):
if time.time() > deadline:
raise _EndgameTimeout()
key = (tuple(cells), to_move)
cached = memo.get(key)
if cached is not None:
return cached
other = _OPPONENT if to_move == _AGENT else _AGENT
legal = sorted(_legal_columns(cells), key=lambda c: abs(c - center))
if not legal:
memo[key] = 0.0
return 0.0
if to_move == _AGENT:
best = -2.0
for c in legal:
nxt = _apply_move(cells, c, to_move)
if _wins_for(nxt, to_move):
val = 1.0
elif _board_full(nxt):
val = 0.0
else:
val = solve(nxt, other, alpha, beta)
best = max(best, val)
alpha = max(alpha, best)
if alpha >= beta:
break
else:
best = 2.0
for c in legal:
nxt = _apply_move(cells, c, to_move)
if _wins_for(nxt, to_move):
val = -1.0
elif _board_full(nxt):
val = 0.0
else:
val = solve(nxt, other, alpha, beta)
best = min(best, val)
beta = min(beta, best)
if alpha >= beta:
break
memo[key] = best
return best
root_legal = _legal_columns(cells0)
if not root_legal:
return None, None
root_legal = sorted(root_legal, key=lambda c: abs(c - center))
other = _OPPONENT if mover == _AGENT else _AGENT
try:
best_a, best_val = None, None
for c in root_legal:
nxt = _apply_move(cells0, c, mover)
if _wins_for(nxt, mover):
val = 1.0 if mover == _AGENT else -1.0
elif _board_full(nxt):
val = 0.0
else:
val = solve(nxt, other, -1.0, 1.0)
if best_val is None or (mover == _AGENT and val > best_val) or (mover == _OPPONENT and val < best_val):
best_a, best_val = c, val
if (mover == _AGENT and best_val == 1.0) or (mover == _OPPONENT and best_val == -1.0):
break
return best_a, best_val
except _EndgameTimeout:
return None, None
def _run_search(surviving_actions, action_cells1, search_rounds, max_branching=None, deadline=None):
"""One full leaf-collect + batched-eval + minimax pass at a given
(rounds, max_branching) setting -- factored out so it can be called
at two different depths, see `_DEEPER_ROUNDS`'s docstring above.
`deadline`: propagated into `_collect_leaves`/`_score_after_opponent_
move` (checked at both exponential-blowup recursion points) AND
checked again here, immediately around the ONE batched NN forward
pass -- that call is otherwise UNGUARDED/uninterruptible once
started, so bailing out right before it (rather than only inside the
pure-Python recursion) avoids ever starting an expensive tensor op
with no time budget left for it."""
leaf_cache = {{}}
for a in surviving_actions:
_collect_leaves(action_cells1[a], search_rounds, leaf_cache, max_branching, deadline)
_check_deadline(deadline)
if leaf_cache:
leaf_cache.update(_leaf_batch_values(list(leaf_cache.keys())))
_check_deadline(deadline) # don't walk the tree on a stale/over-budget result either
best_a, best_score = None, None
for a in surviving_actions:
s = _score_after_our_move(action_cells1[a], search_rounds, leaf_cache, max_branching, deadline)
if best_score is None or s < best_score:
best_a, best_score = a, s
return best_a
@torch.no_grad()
def _adversarial_plan_action(cells0):
"""`_ADV_ROUNDS` real adversarial rounds (our move, then the
opponent's worst-case real reply, repeated) before falling back to
the learned value head + memory blend as the leaf evaluator -- every
transition at every round is EXACT (real board simulation, never
imagined). Root action never returns PASS.
**Two-phase, GLOBALLY batched leaf evaluation** (fixed 2026-08-10,
same day, right before submitting -- a real timing bug caught just
in time, see connectx_adversarial_search.py's identical fix for the
full story): calling the leaf evaluator separately at every node in
the tree (the first version of `rounds>1`) measured up to 2.3s/move
against the offline-built ~2600-state memory -- OVER Kaggle's 2s
budget. Fixed by walking the tree TWICE (pure Python, cheap): once
to collect every non-terminal leaf across the WHOLE tree into one
deduplicated set (transpositions collapse for free), then ONE single
batched value+memory call, then a second walk doing the actual
minimax from the precomputed lookup. Re-measured after the fix
across 60 diverse positions (including the maximal-branching empty-
board case): rounds=1 max 0.427s, rounds=2 max 0.375s -- comfortably
(~5x) under budget again."""
root_legal = _legal_columns(cells0)
if not root_legal:
return None
if _ENDGAME_MAX_COLS and len(root_legal) <= _ENDGAME_MAX_COLS:
exact_a, _exact_val = _exact_endgame_solve(cells0, _AGENT, deadline=time.time() + _ENDGAME_TIME_BUDGET)
if exact_a is not None:
return exact_a
# else: timed out -- fall through to the round-based search below
# exactly as if this check had never happened.
# Center-out root ordering -- NOT a pruning change (every legal column
# is still considered, nothing narrowed), only fixes which column wins
# a TIE. The scoring loop below uses strict `<`, so the first action
# seen at a given score silently wins ties; left-to-right order made
# that default to the LEFTMOST column, an arbitrary, exploitable bias
# with no game-theoretic basis (unlike the player-1 opening hint,
# which deliberately picks center for a real reason). Center columns
# are the real stronger choice under a tie (more potential 4-in-a-row
# lines pass through them, same fact `_narrow_to_center` already uses
# for pruning) -- found from a direct user-observed pattern in real
# play ("when we are second we put in left going to right").
_center = (_WIDTH - 1) / 2
root_legal = sorted(root_legal, key=lambda c: abs(c - _center))
surviving_actions, action_cells1 = [], {{}}
for a in root_legal:
cells1 = _apply_move(cells0, a, _AGENT)
if _wins_for(cells1, _AGENT):
return a # immediate win -- take it, no need to consider anything else
surviving_actions.append(a)
action_cells1[a] = cells1
base_a = _run_search(surviving_actions, action_cells1, _ADV_ROUNDS) # always computed -- guaranteed-safe fallback
if _DEEPER_ROUNDS is not None:
try:
return _run_search(surviving_actions, action_cells1, _DEEPER_ROUNDS,
max_branching=_DEEPER_MAX_BRANCHING,
deadline=time.time() + _DEEPER_TIME_BUDGET)
except _RoundSearchTimeout:
pass # didn't finish in time -- fall back to base_a exactly as if _DEEPER_ROUNDS were None
return base_a
def _online_update(path_states, label):
"""A FEW Adam steps on a mixed old+new batch from the persisted
replay buffer -- value head ONLY (encoder frozen), mirrors
continuous_learner.py's confirmed-safe recipe exactly (small
updates, EMA-scaled value targets, never a full retrain on just the
latest episode). `label`: either "steps" (a real win -- each state
labeled with its real remaining-step count) or a fixed penalty
(loss/draw -- every state in the walk labeled uniformly bad, same
convention as this session's `unsolved_penalty`). Only ever called
from `agent()`'s `_ONLINE_ENABLED`-guarded blocks, but a defensive
no-op guard here too -- never trust a single call site alone for
something this load-bearing."""
global _VALUE_TARGET_MEAN, _VALUE_TARGET_STD
if not _ONLINE_ENABLED:
return
if label == "steps":
T = len(path_states) - 1
for t, s in enumerate(path_states):
_REPLAY_BUFFER.append((list(s), float(T - t)))
else:
for s in path_states:
_REPLAY_BUFFER.append((list(s), float(label)))
if len(_REPLAY_BUFFER) < 8:
return
pool = list(_REPLAY_BUFFER)
states_t = torch.tensor([s for s, _r in pool], dtype=torch.float32)
returns_t = torch.tensor([r for _s, r in pool], dtype=torch.float32)
momentum = 0.98
new_mean, new_std = returns_t.mean(), returns_t.std().clamp(min=1e-3)
with torch.no_grad():
_VALUE_TARGET_MEAN.mul_(momentum).add_(new_mean, alpha=1 - momentum)
_VALUE_TARGET_STD.mul_(momentum).add_(new_std, alpha=1 - momentum)
returns_norm = (returns_t - _VALUE_TARGET_MEAN) / _VALUE_TARGET_STD
norm_states_t = (states_t - _NORM_MEAN) / _NORM_STD
with torch.no_grad():
z_all = _encode(norm_states_t)
n = len(pool)
bs = min(_ONLINE_BATCH_SIZE, n)
for _ in range(_ONLINE_UPDATES_PER_EPISODE):
idx = torch.randperm(n)[:bs]
pred = _value_raw(z_all[idx])
loss = torch.nn.functional.mse_loss(pred, returns_norm[idx])
_ONLINE_OPT.zero_grad()
loss.backward()
_ONLINE_OPT.step()
def agent(observation, configuration):
global _EPISODE_STATES, _EPISODE_LAST_PIECES
board = list(observation.board)
mark = observation.mark
cells = _kaggle_board_to_cells(board, mark)
# See _ONLINE_ENABLED's own comment above -- when False, NONE of the
# episode-tracking/online-update machinery below runs at all, not
# just a no-op call: `agent()` genuinely does nothing but pick a
# move in that case.
if _ONLINE_ENABLED:
cur_pieces = sum(1 for v in board if v != 0)
# See module docstring's honest caveat -- detecting "a previous
# episode ended without us ever winning/drawing it ourselves"
# needs care: checking for an ALL-EMPTY board only works when we
# happen to be the FIRST mover in the new episode -- as the
# second mover, the very first board we see already has the
# opponent's first piece on it, so that check would silently
# miss the boundary and keep appending to a STALE trajectory
# from the already-ended previous episode (a real bug, caught
# before submission: our own test harness alternates which side
# we play, exactly the condition that triggers it). Robust fix:
# within one genuinely continuing episode, the board's total
# piece count increases by EXACTLY 1 between our own consecutive
# calls (one opponent move happened since we last acted) -- any
# other delta means a new episode has started, whichever side we
# were on. Infer a LOSS (the only remaining possibility -- our
# own win/draw is caught below, right after our own move).
#
# `_LOSS_PENALTY`, NOT `_UNSOLVED_PENALTY` (fixed 2026-08-10,
# follow-up session -- found from a direct user-observed real-game
# pattern, "one move before losing, ours plays leftmost"): this is
# the exact same mistake as the already-fixed "attacks but never
# defends" search bug, just unfixed in a SECOND place. The two
# penalties were introduced specifically so the SEARCH treats an
# opponent win as worse than a mere draw -- but the online
# learner's own training label here used `_UNSOLVED_PENALTY` (the
# DRAW value) for a genuine LOSS too, teaching the value head that
# losing and drawing are equally bad. Confirmed via real losses
# mined from actual Kaggle replays: the fresh (never-online-
# updated) search correctly blocks in all 3 traced cases, but the
# live, online-drifted process played the losing move instead --
# this conflated label is the direct mechanism.
if _EPISODE_STATES and cur_pieces != _EPISODE_LAST_PIECES + 1:
_online_update(_EPISODE_STATES, float(_LOSS_PENALTY))
_EPISODE_STATES = []
if not _EPISODE_STATES:
_EPISODE_STATES.append(tuple(_encode_board(cells)))
legal_cols = _legal_columns(cells)
if not legal_cols:
return 0 # should never happen -- Kaggle only calls us on a non-terminal state
# Free, EXACT domain knowledge (same "neurosymbolic gate" philosophy
# as every other domain's hand-given hint in this project): on a
# completely empty board, the center column is the known-best
# Connect-4 opening. Costs nothing, never worse than guessing.
if all(c == _EMPTY for c in cells):
best_action = _WIDTH // 2
else:
best_action = _adversarial_plan_action(cells)
if best_action is None:
return legal_cols[0]
if not _ONLINE_ENABLED:
return int(best_action)
post_cells = _apply_move(cells, best_action, _AGENT)
_EPISODE_STATES.append(tuple(_encode_board(post_cells)))
_EPISODE_LAST_PIECES = sum(1 for v in board if v != 0) + 1
if _wins_for(post_cells, _AGENT):
_online_update(_EPISODE_STATES, "steps")
_EPISODE_STATES = []
elif _board_full(post_cells):
_online_update(_EPISODE_STATES, float(_UNSOLVED_PENALTY))
_EPISODE_STATES = []
return int(best_action)
'''
def main(ckpt_path=CKPT_PATH, memory_ckpt_path=None, n_memory_games=500,
memory_opponent_epsilon=0.2, memory_opponent_strong_epsilon=0.3,
memory_weight=0.25, memory_k=5, online_lr=1e-5, online_updates_per_episode=4,
online_batch_size=256, unsolved_penalty_mult=1.0, adv_rounds=2, seed=0,
online_enabled=False, endgame_max_cols=5, endgame_time_budget=1.2,
deeper_rounds=None, deeper_max_branching=4, deeper_time_budget=0.6):
import random
from connectx.env import ConnectXEnv
from connectx.memory_build import build_episodic_memory
from connectx.search import load_checkpoint
ck = torch.load(ckpt_path, map_location="cpu")
# Memory is built using the SAME real adversarial search (rounds=
# adv_rounds) the deployed submission actually plays with, so the
# stored trajectories are representative of the real deployed agent's
# own play, not a different/weaker search's games.
print(f"Building offline episodic memory ({n_memory_games} self-play games, mixed opponent, "
f"real adversarial search rounds={adv_rounds})...")
mem_ckpt = memory_ckpt_path or ckpt_path
model, normalizer = load_checkpoint(mem_ckpt)
env = ConnectXEnv(width=ck["board_width"], height=ck["board_height"], win_len=ck["win_len"])
rng = random.Random(seed)
# env.py's opponent_epsilon/opponent_strong_epsilon rolls read Python's
# GLOBAL random module directly, not this `rng` object -- without this,
# "same seed" memory-building runs are silently NOT reproducible.
random.seed(seed)
memory = build_episodic_memory(env, model, normalizer, rng, n_games=n_memory_games,
opponent_epsilon=memory_opponent_epsilon,
opponent_strong_epsilon=memory_opponent_strong_epsilon,
adversarial_rounds=adv_rounds)
memory_zs = [z.detach().cpu() for z in memory._zs]
memory_outcomes = list(memory._outcomes)
blob = _encode_tensor_blob(ck, memory_zs, memory_outcomes)
width = 100
chunks = [blob[i:i + width] for i in range(0, len(blob), width)]
blob_literal = "\n".join(f' "{c}"' for c in chunks)
out = SUBMISSION_TEMPLATE.format(
blob_literal=blob_literal, memory_weight=memory_weight, memory_k=memory_k,
online_lr=online_lr, online_updates_per_episode=online_updates_per_episode,
online_batch_size=online_batch_size, unsolved_penalty_mult=unsolved_penalty_mult,
adv_rounds=adv_rounds, online_enabled=online_enabled,
endgame_max_cols=endgame_max_cols, endgame_time_budget=endgame_time_budget,
deeper_rounds=deeper_rounds, deeper_max_branching=deeper_max_branching,
deeper_time_budget=deeper_time_budget,
)
with open(OUT_PATH, "w") as f:
f.write(out)
size_kb = len(out.encode("utf-8")) / 1024
print(f"Wrote {OUT_PATH} ({len(memory_zs)} memory states, {size_kb:.1f} KB)")
if __name__ == "__main__":
main()
|