File size: 21,161 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
# One-off build script for WHITEPAPER.pdf -- not part of the codebase
# itself, run once to (re)generate the PDF from this content. Requires
# `pip install fpdf2`. Run from the docs/ directory: `python build_whitepaper.py`.
from fpdf import FPDF

TITLE = "Latent-Space Simulation Meets a Real Adversary: A World-Model Approach to Kaggle ConnectX"
AUTHOR = "Alexandros Titonis"

pdf = FPDF(format="A4")
pdf.set_auto_page_break(auto=True, margin=20)
pdf.set_margins(20, 20, 20)
pdf.add_page()
pdf.set_font("Helvetica", "B", 18)
pdf.multi_cell(0, 9, TITLE)
pdf.ln(2)
pdf.set_font("Helvetica", "", 11)
pdf.set_text_color(90, 90, 90)
pdf.multi_cell(0, 6, "A reinforcement-learning world model, real adversarial search, and an exact "
                      "endgame solver applied to Kaggle's ConnectX competition.")
pdf.ln(1)
pdf.multi_cell(0, 6, f"{AUTHOR}  |  Competition: https://kaggle.com/competitions/connectx")
pdf.set_text_color(0, 0, 0)
pdf.ln(4)


def h1(text):
    pdf.set_font("Helvetica", "B", 14)
    pdf.ln(4)
    pdf.multi_cell(0, 8, text)
    pdf.ln(1)


def h2(text):
    pdf.set_font("Helvetica", "B", 12)
    pdf.ln(2)
    pdf.multi_cell(0, 7, text)
    pdf.ln(1)


def p(text):
    pdf.set_font("Helvetica", "", 10.5)
    pdf.multi_cell(0, 5.6, text)
    pdf.ln(1.5)


def bullet(text):
    pdf.set_font("Helvetica", "", 10.5)
    pdf.set_x(pdf.l_margin + 4)
    pdf.multi_cell(0, 5.6, "-  " + text)
    pdf.ln(0.5)


def table(headers, rows, widths, body_size=9):
    """Proper multi-line table: wraps each cell's text to its column width
    FIRST (via a dry-run multi_cell), takes the max line count in the row,
    then draws every cell's border/text at that shared row height -- avoids
    fpdf2's plain `cell()` silently misaligning columns when text embeds a
    newline or wraps."""
    line_h = 5.0
    pdf.set_font("Helvetica", "B", 9.5)
    header_h = 7.0
    x_start, y_start = pdf.get_x(), pdf.get_y()
    x = x_start
    for htext, w in zip(headers, widths):
        pdf.set_xy(x, y_start)
        pdf.cell(w, header_h, htext, border=1)
        x += w
    pdf.set_xy(x_start, y_start + header_h)

    pdf.set_font("Helvetica", "", body_size)
    for row in rows:
        cell_lines = []
        for text, w in zip(row, widths):
            lines = pdf.multi_cell(w - 2, line_h, text, dry_run=True, output="LINES")
            cell_lines.append(lines)
        n_lines = max(len(lns) for lns in cell_lines)
        row_h = n_lines * line_h
        x_start, y_start = pdf.get_x(), pdf.get_y()
        if y_start + row_h > pdf.page_break_trigger:
            pdf.add_page()
            x_start, y_start = pdf.get_x(), pdf.get_y()
        x = x_start
        for lines, w in zip(cell_lines, widths):
            pdf.rect(x, y_start, w, row_h)
            for i, line in enumerate(lines):
                pdf.set_xy(x + 1, y_start + i * line_h)
                pdf.cell(w - 2, line_h, line)
            x += w
        pdf.set_xy(x_start, y_start + row_h)
    pdf.ln(4)


# ---------------------------------------------------------------------------
h1("1. What this is, and what it isn't")
p("The core idea: instead of generating a move token-by-token or via a hand-written heuristic, "
  "a small learned model (encoder + latent dynamics + value head + a diagnostic decoder) rolls "
  "forward candidate moves in latent space, and a search procedure picks the move whose imagined "
  "future looks best -- MuZero-style planning [1].")
p("This is one instance of a GENERAL latent-space-simulation architecture, not a Connect-4-specific "
  "system: the same encoder/dynamics/value/decoder + search pattern used here has also been applied "
  "to algebraic equation solving, constraint-satisfaction-style logic puzzles, and route-planning "
  "problems, with zero domain-specific changes to the core training/search code -- swapping domains "
  "means implementing one small interface (see `environment.py`), not rewriting the architecture. "
  "ConnectX is the instance documented in this paper because it is the first genuinely ADVERSARIAL "
  "two-player domain the architecture was applied to: a single-agent puzzle solver only ever has to "
  "be right, while a Connect-4 agent has to be right against an opponent actively trying to make it "
  "wrong. That one change breaks assumptions the rest of the pipeline had never been asked to "
  "question, and is the actual subject of this write-up.")
p("This is NOT a hand-written Connect-4 bot. The board simulator and rules are exact (no need to "
  "make a neural network learn something 40 lines of Python computes for free), but every DECISION "
  "-- what a position is worth, which past games are relevant, when the value head should update "
  "online -- comes from a learned model trained without a Connect-4-specific oracle.")

h2("1.1 How this compares to typical RL game-playing systems")
p("Systems in the AlphaZero/MuZero lineage [1][2] are normally trained at large scale: thousands of "
  "self-play games generated in parallel across many machines (often TPU or GPU clusters), running "
  "for days. This project's result was trained on a single consumer laptop -- see Section 1.2 for "
  "the exact specs -- with a full training-pipeline run (stage 1 + stage 2 + self-play) confirmed at "
  "roughly one hour of wall-clock time, and on the order of tens of thousands of self-play games, not "
  "millions. This is presented honestly as evidence that the ARCHITECTURE (real search over exactly-"
  "known rules, substituting for an expensive learned-world-model rollout) is a genuinely efficient "
  "way to reach competent play in a domain with known rules -- not a claim that this matches "
  "AlphaZero-level strength; Section 6 states plainly where this still falls short of a fully-solved "
  "or superhuman opponent.")

h2("1.2 Training hardware")
p("Every result in this paper was produced on a single machine: an RTX 4060 laptop GPU, a Ryzen AI "
  "9 HX 370 CPU, and 32GB RAM. No cluster, no cloud training run, no multi-GPU parallelism -- "
  "everything in this repository, including a full from-scratch training run, was reproduced on that "
  "one laptop.")

h1("2. Architecture")

h2("2.1 The easy path: collapse the adversary into an ordinary MDP")
p("The cheapest way to test whether this works at all: bake a fixed, non-learning opponent policy "
  "directly into the environment's step() function. From the agent's perspective this is then "
  "indistinguishable from a single-agent puzzle -- it picks an action, the environment returns the "
  "resulting state (already reflecting the opponent's reply). Zero changes needed to the rest of "
  "the pipeline. At the real 7x6 board scale there is no exact oracle (the game tree is too large "
  "for brute-force search), so the value head is trained via on-policy Monte Carlo returns instead "
  "of regression against oracle labels.")

h2("2.2 Why the dynamics model can't imagine \"after my move, before theirs\"")
p("step() bundles the agent's move and the fixed opponent's reply into ONE transition, so the "
  "trained dynamics model only ever saw full round-trips as single training examples -- it "
  "structurally cannot represent \"the board immediately after my move, before their reply\" as a "
  "state, because it never saw that state shape. Asking it to imagine that intermediate state would "
  "be extrapolating outside its training distribution.")

h2("2.3 The fix: real search over the real board, learned value only at the leaf")
p("Since Connect-4's rules are exactly known, there's no need to make the network imagine anything "
  "mid-ply. The deployed search does the adversarial ply in REAL board space: enumerate the agent's "
  "real legal moves; for each, enumerate the opponent's real legal replies and assume they pick "
  "whichever hurts the agent most (a genuine minimax, not a single guess). The learned value head is "
  "used only as the LEAF evaluator, on a real, never-imagined state. This is structurally closer to "
  "AlphaZero's design (real search + a learned value net, viable because the rules are exactly "
  "known) [2] than MuZero's (search over a LEARNED model, needed specifically when true dynamics "
  "are not available) [1].")
p("Result of this one architectural change: 95-100% win rate vs. random play, 100% vs. the fixed "
  "weak training heuristic, up to 66.7% vs. a stronger 1-ply-deeper heuristic never seen during "
  "training -- beating every latent-search configuration tried on every metric simultaneously (the "
  "best latent-search result had been 75% / 50% / 11.7%).")

h2("2.4 The exact endgame solver")
p("Real search over real board states opens a door latent-space search never could: once a position "
  "narrows down to a handful of legal columns -- which happens naturally as the board fills -- the "
  "remaining game tree is small regardless of how many plies are left, and can be SOLVED EXACTLY, "
  "no learned value head, no guessing. The solver is a memoized alpha-beta minimax with center-out "
  "move ordering, triggered whenever 5 or fewer columns remain legal (calibrated empirically against "
  "real Kaggle replay data: 5 columns / 24 empty cells solves in under half a second; 6 columns did "
  "not finish inside a 5-second cap with this plain-Python implementation). It is bounded by a hard "
  "wall-clock deadline as an independent safety net -- a position outside the calibrated safe zone "
  "falls through to the round-based search rather than risking the real move-time budget.")
p("The insight that made this cheap: branching factor is controlled by how many columns are LEGAL, "
  "not by how many cells are EMPTY. A position can have twenty or more empty cells and still be "
  "trivial to solve exactly if only a few columns remain open -- exactly the shape a real Connect-4 "
  "endgame takes once most of the board has filled.")

h2("2.5 Episodic memory, LoRA fine-tuning, and online learning")
p("Three more general-purpose mechanisms were applied on top of the search:")
bullet("Episodic memory: a k-NN lookup over real self-play trajectories, both won and lost (a "
       "negative/repulsion signal, not just positive examples), blended into the value estimate at "
       "decision time [3][4]. Small but real positive effect vs. random play; no measurable effect "
       "vs. either fixed heuristic opponent.")
bullet("LoRA self-play fine-tuning: capacity-constrained low-rank deltas applied to the value head "
       "during self-play [5], rather than an unconstrained further gradient update -- 75.0% to "
       "81.7% win rate vs. the stronger heuristic.")
bullet("Curriculum self-play: fixing a real gap where the stronger heuristic was never actually "
       "mixed into self-play training at all -- 81.7% to 85.0%, the best-confirmed result for this "
       "domain.")
bullet("Best-effort online learning (value head only, updated live as real games are played): "
       "isolated via a clean A/B test (the same submission loaded twice, one copy's update call "
       "disabled) -- currently measures exactly 0.0% effect against the synthetic test-opponent set "
       "at evaluation scale. Not evidence it is useless in general -- Kaggle's real opponent pool is "
       "far wider than three fixed synthetic opponents -- just an honest, now-measured answer.")

h1("3. Every bug found and fixed")
p("Seven real, generic-class bugs were found and fixed during development. Each is recorded here "
  "because several are the kind of mistake that is easy to make again in a different adversarial "
  "domain.")
table(
    ["#", "Bug", "Found via"],
    [
        ["1", "Internal eval reported a false 100% win rate (done was assumed to imply a win)",
         "Tracing one \"solved\" game through an independent alternating-turn harness"],
        ["2", "The value head never saw a losing example (unsolved walks were silently discarded)",
         "Root-cause tracing after bug 1"],
        ["3", "Every prediction in the packaged submission was silently corrupted (a spurious extra ReLU)",
         "Diffing the packaged output directly against the real model, node by node"],
        ["4", "\"Same seed\" training runs were not actually reproducible (unseeded global RNG)",
         "Two same-seed runs produced different results"],
        ["5", "A whole session's A/B comparisons were confounded (one shared RNG across a batch)",
         "An opening-move-only change swung the overall win rate by double digits"],
        ["6", "A left-column tie-break bias with no game-theoretic basis",
         "Direct user-observed pattern in real play"],
        ["7", "The online learner taught the value head that losing and drawing are equally bad",
         "Mining real Kaggle replay data via the Kaggle API for missed blocks"],
    ],
    [10, 88, 72],
)
p("The generalizable lesson: bugs 2 and 7 are the same mistake, made twice, in two different places. "
  "\"Treat a loss and a draw identically\" is a natural default everywhere ELSE in a single-agent "
  "pipeline (every prior puzzle domain has exactly one failure mode -- unsolved -- with no "
  "distinction worth making), and it takes deliberate effort to remember that an adversarial domain "
  "has two qualitatively different bad outcomes.")

h1("4. Case study: diagnosing a real loss (zugzwang), and a first attempt at a fix")
p("A live, observed loss -- an opponent slowly building what looked, in hindsight, like an "
  "obviously winning diagonal, with the agent apparently ignoring it -- was root-caused not by "
  "guesswork but by pulling the actual Kaggle replay and solving the real endgame exhaustively, "
  "working backward through the game.")
p("Finding: the position was already a forced loss with 23 empty cells still on the board -- at "
  "least 15 real plies before the deployed 2-round search could possibly have seen it coming. The "
  "mechanism is a genuine, textbook Connect-4 ZUGZWANG / PARITY TRAP (classic odd/even threat "
  "theory [6]): once only two columns remained legal for an extended stretch, which player is "
  "forced to place the fatal piece is decided purely by the parity of total remaining cells across "
  "those two columns -- invisible to any bounded-depth positional search, and not addressed by the "
  "exact endgame solver above (its own calibrated safe zone starts well inside the window where "
  "this trap was already unavoidable).")

h2("4.1 A parity-heuristic attempt -- tried, measured, and honestly a mixed result")
p("A column-parity feature was built and tested directly, rather than left purely theoretical: for "
  "each still-open column with r empty cells, under naive same-column-only alternation the player "
  "who would place the TOP piece is the mover if r is odd, the other player if r is even. This gives "
  "a cheap, real-valued \"parity cost\" per board position -- more even-parity open columns scored as "
  "worse for the agent -- blended into the leaf value estimate at a tunable weight.")
p("This is explicitly NOT full Claimeven: a genuine claimeven strategy requires REACTIVE move-"
  "pairing enforced across an entire game, not a one-shot column count at a single position. It was "
  "built and measured as a testable nudge, not assumed to work because the theory motivates it.")
p("Measured against the trusted test harness at several weights: the feature produces a REAL, "
  "repeatable effect, but not a clean win. At weight 0.5, win rate vs. the stronger heuristic rose "
  "from 70.0% to 77.5% -- but win rate vs. random-legal play fell from 100.0% to 90.0% in the same "
  "run. Smaller weights (0.1-0.3) showed the same pattern at reduced magnitude in both directions. "
  "Net honest verdict: this specific heuristic trades performance against weaker opponents for a "
  "partial, not fully consistent gain against a stronger one -- a real, measured signal in the "
  "direction the theory predicts, but not yet a confirmed fix. It ships in this repository as an "
  "opt-in, OFF-by-default parameter (`parity_weight=0.0` in `adversarial_search.py`) rather than a "
  "new default, exactly so a reader can reproduce this exact finding rather than take it on faith.")
p("Two angles remain genuinely untried: a value head deliberately trained on zugzwang-rich self-play "
  "positions (so the pattern becomes an implicit learned feature rather than a hand-tuned scalar "
  "nudge), and a full, correctly reactive Claimeven implementation rather than a single-position "
  "heuristic proxy.")

h1("5. Results")
p("All win rates below are against an independent, trusted test harness (an alternating-turn engine "
  "that does NOT reuse the environment's own bundled step() -- exactly the shortcut a submission "
  "validator needs to avoid). Three opponents throughout: random-legal play, the fixed weak "
  "heuristic the model trained against, and a 1-ply-deeper \"stronger\" heuristic never seen during "
  "training.")
table(
    ["Configuration", "vs random", "vs weak heur.", "vs stronger heur."],
    [
        ["Latent beam search, depth=1 (original)", "63.3%", "0.0%", "6.7%"],
        ["Latent beam search, depth=3", "55.0%", "0.0%", "1.7%"],
        ["Real adversarial search (1 round)", "96.7%", "100.0%", "66.7%"],
        ["+ memory + online learner (bug 7 present)", "98.3%", "78.3%*", "50.0%"],
        ["+ bug 7 (loss-penalty) fixed", "100.0%", "100.0%", "80.0%"],
        ["+ LoRA self-play fine-tune", "-", "-", "81.7%"],
        ["+ curriculum self-play (best-ever)", "-", "-", "85.0%"],
        ["+ exact endgame solver (current, deployed)", "100.0%", "100.0%", "83.3%"],
    ],
    [95, 30, 32, 33],
)
p("* The weak-heuristic dip in that one row is explained, not a mystery: that harness ran all "
  "opponent blocks sequentially in one process, so the online learner had already drifted from 60 "
  "preceding random-opponent games by the time it reached this block.")
p("On Kaggle's own rating (a TrueSkill-style Gaussian score that starts uncertain and converges "
  "over dozens of real games against other real submissions): a freshly-uploaded submission's "
  "rating is not comparable to one that has had a day to settle. Confirmed directly during "
  "development -- a previously-deployed submission itself started near 300 right after upload and "
  "climbed past 450 only after 27 real games. 7x6 Connect-4 is a mathematically SOLVED game (the "
  "first player wins with perfect play) [6], so the real competitive pool likely includes near-"
  "perfect solvers -- the results above demonstrate the ARCHITECTURE works on this domain; they "
  "should not be read as a leaderboard-rating prediction.")

h1("6. Honest limitations")
bullet("The zugzwang-avoidance problem (Section 4) is diagnosed and a first fix attempt is measured, "
       "but not solved -- see Section 4.1's own honest verdict.")
bullet("Online learning's real-world value is unconfirmed -- it measures zero effect against three "
       "fixed synthetic opponents, which is not the same as zero effect against Kaggle's actual, "
       "much wider real pool.")
bullet("Deeper search beyond 2 rounds (without the endgame solver) was tried and found WORSE, not "
       "just unexplored -- unpruned 3-round search blows the time budget, and the only pruning "
       "width that stayed safe tested worse than plain 2-round search.")
bullet("The real Kaggle competitive pool likely includes near-perfect solvers, since 7x6 Connect-4 "
       "is a solved game -- see Section 5's closing note.")

h1("References")
refs = [
    "[1] Schrittwieser, J. et al. \"Mastering Atari, Go, Chess and Shogi by Planning with a Learned "
    "Model.\" Nature, 2020.",
    "[2] Silver, D. et al. \"A general reinforcement learning algorithm that masters chess, shogi, "
    "and Go through self-play.\" Science, 2018.",
    "[3] Blundell, C. et al. \"Model-Free Episodic Control.\" arXiv:1606.04460, 2016.",
    "[4] Pritzel, A. et al. \"Neural Episodic Control.\" ICML, 2017.",
    "[5] Hu, E. J. et al. \"LoRA: Low-Rank Adaptation of Large Language Models.\" "
    "arXiv:2106.09685, 2021.",
    "[6] Allis, L. V. \"A Knowledge-Based Approach of Connect-Four.\" M.Sc. thesis, Vrije "
    "Universiteit Amsterdam, 1988. (First complete game-theoretic solution of Connect-4; the "
    "classical source for odd/even threat and zugzwang theory referenced in Section 4.)",
    "[7] Mnih, V. et al. \"Human-level control through deep reinforcement learning.\" Nature, 2015. "
    "(Experience-replay precedent used by the on-policy Monte Carlo value-training loop.)",
    "[8] Adam, Addison Howard, and Bovard Doerschuk-Tiberi. \"Connect X.\" "
    "https://kaggle.com/competitions/connectx, 2020. Kaggle.",
    "[9] Kaggle. \"kaggle_environments.\" https://github.com/Kaggle/kaggle-environments. "
    "(Board/config schema and fixed-opponent-baked-into-step convention are ported from this "
    "package's own design, not from any published agent's code.)",
]
pdf.set_font("Helvetica", "", 9.5)
for r in refs:
    pdf.set_x(pdf.l_margin)
    pdf.multi_cell(0, 5.2, r)
    pdf.ln(1.5)

pdf.output("WHITEPAPER.pdf")
print("Wrote WHITEPAPER.pdf")