amer224 commited on
Commit
8d46921
·
verified ·
1 Parent(s): b0ddd1d

Add brains/sovereign-v3/model.py

Browse files
Files changed (1) hide show
  1. brains/sovereign-v3/model.py +831 -0
brains/sovereign-v3/model.py ADDED
@@ -0,0 +1,831 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SOVEREIGN v3 — ~272M-param return-conditioned spatial policy for
3
+ territorial.io (last-survivor elimination).
4
+
5
+ = DESIGN SUMMARY (full reasoning: ARCHITECTURE.md, training: train_notes.md)
6
+ Size mandate: >=200M, <400M (user-specified). Four modules:
7
+
8
+ VISION SPINE ~70M 5 down stages 96..832ch @ 64..4 px, ResBlock+SE
9
+ STRATEGIC CORTEX ~77M multi-scale transformer: 81 tokens (1 GLOBAL +
10
+ 16 coarse @4px + 64 fine @8px), 6 layers,
11
+ d=1024, FFN=4096, 16 heads
12
+ TACTICAL DECODER ~66M 6 up stages w/ skips, 1024ch @8px -> 64ch @256px
13
+ COMMAND HEADS ~59M kind/value/win/econ 3072-wide MLPs, heat towers,
14
+ pct-Beta, gate/seg/dynamics aux, GRU memory
15
+
16
+ = THE 10x LEVERS OVER v2 (each grounded in literature / free labels)
17
+ 1. RETURN-CONDITIONED POLICY (RPO, ICLR'23): all heads are conditioned on
18
+ remaining return-to-go `rtg`. Stage A teaches p(a|s,rtg); PPO computes
19
+ advantages vs V(s,rtg); at play time set an aspiration rtg -> the
20
+ policy *demands* winning. Default rtg=0 keeps the contract.
21
+ 2. OPPONENT-INTENT MIRROR HEADS: shards are consecutive frames, so
22
+ lab[t+1] is free supervision. `threat` predicts where enemies strike
23
+ next; `expand` predicts where my frontier grows. This is implicit
24
+ opponent modeling from data (TWISTER/Dreamer-class prediction aux).
25
+ 3. ONE-STEP SPATIAL DYNAMICS: `next_seg` predicts lab[t+1] @64x64 — a
26
+ partial world model in the policy (seed for future MCTS).
27
+ 4. WIN-PROBABILITY VALUE: second value head outputs P(alive at end).
28
+ GAE uses the RTG value; win-prob drives eval + late-game shaping.
29
+ 5. MEMORY CORE (GRU 768): optional recurrent state over the cortex pool
30
+ (contract-safe default off; truncated-BPTT recipe documented).
31
+ 6. IQL-STYLE EXPECTILE VALUE option in Stage A for robustness on the
32
+ weak farmer buffer, next to the AWR-weighted imitation.
33
+ 7. LEAGUE-LITE snapshots: PPO trains against frozen past selves + bots
34
+ in the sim (fictitious self-play lite), scheduled in train_notes.
35
+ 8. WAKE-SLEEP distill loop: PPO on the big net -> distill into a small
36
+ student for faster rollouts -> retrain. Documented recipe.
37
+
38
+ = CONTRACT (kept verbatim, see README.md)
39
+ INPUT : rgb (B,3,256,256) float [0,1] + nums (B,8) [+ rtg (B,) + state]
40
+ OUTPUT: click heatmap >=16x16 (native 256x256, pooled via grid=),
41
+ kind logits (B,3), pct in [0,1], value (B,)
42
+ `lab` is supervision-only during pretraining; never a play-time input.
43
+
44
+ = MEMORY CHECK (2xT4 16GB, fp16, bs16/GPU): weights 1.09GB + grads 1.09GB +
45
+ Adam 2.18GB + activations ~4GB + ctx ~1GB ~= 9.4GB/GPU. Optional gradient
46
+ checkpointing on the 4 biggest stages (flag `grad_ckpt`) halves activation
47
+ cost if needed. Play speed ~150-200 GFLOPs/frame < 30ms vs 560ms ticks.
48
+
49
+ torch only. Run `python model.py` (CPU smoke: params + shapes),
50
+ `python model.py --fast` for the 128px path.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import sys
56
+ from dataclasses import dataclass
57
+ from typing import Optional, Tuple
58
+
59
+ import torch
60
+ import torch.nn as nn
61
+ import torch.nn.functional as F
62
+ from torch.distributions import Beta, Categorical
63
+ from torch.utils.checkpoint import checkpoint as _ckpt
64
+
65
+ EPS = 1e-6
66
+
67
+
68
+ # --------------------------------------------------------------------------
69
+ # Config
70
+ # --------------------------------------------------------------------------
71
+
72
+ @dataclass
73
+ class SovereignConfig:
74
+ map_size: int = 256
75
+ in_c: int = 3 # contract default: single rgb frame
76
+ ctx_dim: int = 8 # nums vector
77
+ rtg_dim: int = 1 # return-conditioning channel (0 disables)
78
+ num_kinds: int = 3 # 0 expand / 1 attack / 2 bank
79
+ # vision spine: enc_ch[0]=stem out; enc_ch[1..5]=stage outs; blocks=res
80
+ enc_ch: Tuple[int, ...] = (96, 96, 160, 288, 512, 832)
81
+ enc_blocks: Tuple[int, ...] = (2, 3, 4, 4, 3)
82
+ # cortex
83
+ cortex_d: int = 1024
84
+ cortex_heads: int = 16
85
+ cortex_ffn: int = 4096
86
+ cortex_layers: int = 6
87
+ cortex_max_tokens: int = 512 # 1 global + 16 coarse + 64 fine = 81
88
+ # decoder: (out_ch, skip_ch or None, res_blocks) — input starts at
89
+ # enc_ch[-1]; dec5 output channel == dec_out_ch
90
+ dec_plan: Tuple[Tuple[int, Optional[int], int], ...] = (
91
+ (1024, 512, 2), # dec0: 832->1024 @H/32 (skip e4)
92
+ (512, 288, 2), # dec1 @H/16 (skip e3)
93
+ (288, 160, 2), # dec2 @H/8 (skip e2) [d32 -> cell_value]
94
+ (160, 96, 2), # dec3 @H/4 (skip e1) [d64 -> gate/seg sources]
95
+ (96, None, 1), # dec4 @H/2
96
+ (64, None, 1), # dec5 @H (dec_out_ch = 64)
97
+ )
98
+ dec_out_ch: int = 64 # must equal dec_plan[-1][0]
99
+ heat_hidden: int = 128 # shared heat mixer before per-kind towers
100
+ tower_hidden: int = 192 # per-kind tower width
101
+ head_hidden: int = 3072 # kind/value/win/econ MLP width
102
+ pct_hidden: int = 1024 # Beta head width
103
+ grid_default: int = 32
104
+ # memory core
105
+ use_mem: bool = True # GRU over cortex pool + context
106
+ mem_hidden: int = 768
107
+ # features
108
+ use_coord: bool = True
109
+ use_gate: bool = True
110
+ use_aux_seg: bool = True
111
+ use_dynamics: bool = True # next_seg / threat / expand aux heads
112
+ use_econ: bool = True # next-context forecaster
113
+ use_cell_value: bool = True # per-cell utility grid @32x32
114
+ use_se: bool = True # squeeze-excite channel attention
115
+ aux_seg_classes: int = 4 # water / neutral / me / enemy
116
+ dropout_mlp: float = 0.1
117
+ grad_ckpt: bool = False # checkpoint the 4 biggest stages
118
+
119
+ @property
120
+ def ctx_full_dim(self) -> int:
121
+ return self.ctx_dim + self.rtg_dim
122
+
123
+
124
+ # --------------------------------------------------------------------------
125
+ # Blocks
126
+ # --------------------------------------------------------------------------
127
+
128
+ class _SE(nn.Module):
129
+ """Squeeze-excite channel attention."""
130
+
131
+ def __init__(self, c: int, r: int = 16):
132
+ super().__init__()
133
+ h = max(c // r, 8)
134
+ self.fc = nn.Sequential(
135
+ nn.Conv2d(c, h, 1, bias=False), nn.SiLU(),
136
+ nn.Conv2d(h, c, 1, bias=False), nn.Sigmoid())
137
+
138
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
139
+ return x * self.fc(F.adaptive_avg_pool2d(x, 1))
140
+
141
+
142
+ class ResBlock(nn.Module):
143
+ """conv3x3-BN-SiLU, conv3x3-BN, (+SE), residual."""
144
+
145
+ def __init__(self, c: int, se: bool = True):
146
+ super().__init__()
147
+ self.conv1 = nn.Conv2d(c, c, 3, padding=1, bias=False)
148
+ self.bn1 = nn.BatchNorm2d(c)
149
+ self.conv2 = nn.Conv2d(c, c, 3, padding=1, bias=False)
150
+ self.bn2 = nn.BatchNorm2d(c)
151
+ self.se = _SE(c) if se else nn.Identity()
152
+
153
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
154
+ y = F.silu(self.bn1(self.conv1(x)))
155
+ y = self.bn2(self.conv2(y))
156
+ return F.silu(x + self.se(y))
157
+
158
+
159
+ class DownStage(nn.Module):
160
+ """stride-2 conv + n residual blocks."""
161
+
162
+ def __init__(self, cin: int, cout: int, blocks: int, se: bool = True):
163
+ super().__init__()
164
+ layers = [nn.Conv2d(cin, cout, 3, stride=2, padding=1, bias=False),
165
+ nn.BatchNorm2d(cout), nn.SiLU()]
166
+ layers += [ResBlock(cout, se) for _ in range(blocks)]
167
+ self.net = nn.Sequential(*layers)
168
+
169
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
170
+ return self.net(x)
171
+
172
+
173
+ class UpStage(nn.Module):
174
+ """bilinear x2 + skip merge + n residual blocks."""
175
+
176
+ def __init__(self, cin: int, cout: int, skip_c: Optional[int] = None,
177
+ blocks: int = 1, se: bool = True):
178
+ super().__init__()
179
+ self.skip = (nn.Conv2d(skip_c, cin, 1, bias=False)
180
+ if skip_c is not None else None)
181
+ self.up = nn.Conv2d(cin, cout, 3, padding=1, bias=False)
182
+ self.bn = nn.BatchNorm2d(cout)
183
+ self.blocks = nn.Sequential(*[ResBlock(cout, se)
184
+ for _ in range(blocks)])
185
+
186
+ def forward(self, x: torch.Tensor,
187
+ skip: Optional[torch.Tensor] = None) -> torch.Tensor:
188
+ x = F.interpolate(x, scale_factor=2, mode="bilinear",
189
+ align_corners=False)
190
+ if skip is not None:
191
+ x = x + self.skip(skip)
192
+ x = F.silu(self.bn(self.up(x)))
193
+ return self.blocks(x)
194
+
195
+
196
+ class FiLM(nn.Module):
197
+ """Per-channel scale/shift of the bottleneck from ctx (+rtg)."""
198
+
199
+ def __init__(self, ctx_dim: int, c: int):
200
+ super().__init__()
201
+ self.gamma = nn.Linear(ctx_dim, c)
202
+ self.beta = nn.Linear(ctx_dim, c)
203
+
204
+ def forward(self, x: torch.Tensor, ctx: torch.Tensor) -> torch.Tensor:
205
+ g = self.gamma(ctx).view(ctx.shape[0], -1, 1, 1) + 1.0
206
+ b = self.beta(ctx).view(ctx.shape[0], -1, 1, 1)
207
+ return x * g + b
208
+
209
+
210
+ # --------------------------------------------------------------------------
211
+ # Multi-scale strategic cortex
212
+ # --------------------------------------------------------------------------
213
+
214
+ class _Attn(nn.Module):
215
+ def __init__(self, d: int, heads: int):
216
+ super().__init__()
217
+ assert d % heads == 0
218
+ self.heads, self.dh = heads, d // heads
219
+ self.qkv = nn.Linear(d, 3 * d, bias=False)
220
+ self.proj = nn.Linear(d, d, bias=False)
221
+ self.scale = self.dh ** -0.5
222
+
223
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
224
+ B, T, _ = x.shape
225
+ q, k, v = self.qkv(x).chunk(3, dim=-1)
226
+ q = q.view(B, T, self.heads, self.dh).transpose(1, 2)
227
+ k = k.view(B, T, self.heads, self.dh).transpose(1, 2)
228
+ v = v.view(B, T, self.heads, self.dh).transpose(1, 2)
229
+ a = torch.softmax((q @ k.transpose(-2, -1)) * self.scale, dim=-1)
230
+ o = (a @ v).transpose(1, 2).contiguous().view(B, T, -1)
231
+ return self.proj(o)
232
+
233
+
234
+ class _TxBlock(nn.Module):
235
+ def __init__(self, d: int, heads: int, ffn: int, dropout: float = 0.0):
236
+ super().__init__()
237
+ self.ln1 = nn.LayerNorm(d)
238
+ self.attn = _Attn(d, heads)
239
+ self.ln2 = nn.LayerNorm(d)
240
+ self.mlp = nn.Sequential(
241
+ nn.Linear(d, ffn), nn.GELU(), nn.Dropout(dropout),
242
+ nn.Linear(ffn, d))
243
+
244
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
245
+ x = x + self.attn(self.ln1(x))
246
+ x = x + self.mlp(self.ln2(x))
247
+ return x
248
+
249
+
250
+ class Cortex(nn.Module):
251
+ """Multi-scale patch tokens: [GLOBAL] + 16 coarse (@4px) + 64 fine
252
+ (@8px) -> 6 pre-norm attention layers. The FFNs carry the strategic
253
+ capacity; attention over 81 tokens is nearly free compute."""
254
+
255
+ def __init__(self, cfg: SovereignConfig):
256
+ super().__init__()
257
+ self.coarse_embed = nn.Conv2d(cfg.enc_ch[-1], cfg.cortex_d, 1,
258
+ bias=False)
259
+ self.fine_embed = nn.Conv2d(cfg.enc_ch[-2], cfg.cortex_d, 1,
260
+ bias=False)
261
+ self.global_tok = nn.Parameter(torch.zeros(1, 1, cfg.cortex_d))
262
+ self.pos = nn.Parameter(torch.zeros(1, cfg.cortex_max_tokens,
263
+ cfg.cortex_d))
264
+ nn.init.trunc_normal_(self.global_tok, std=0.02)
265
+ nn.init.trunc_normal_(self.pos, std=0.02)
266
+ self.blocks = nn.ModuleList([
267
+ _TxBlock(cfg.cortex_d, cfg.cortex_heads, cfg.cortex_ffn,
268
+ dropout=cfg.dropout_mlp)
269
+ for _ in range(cfg.cortex_layers)])
270
+ self.ln_f = nn.LayerNorm(cfg.cortex_d)
271
+
272
+ def forward(self, bot: torch.Tensor,
273
+ fine: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
274
+ B = bot.shape[0]
275
+ t_c = self.coarse_embed(bot).flatten(2).transpose(1, 2) # (B,16,d)
276
+ t_f = self.fine_embed(fine).flatten(2).transpose(1, 2) # (B,64,d)
277
+ g = self.global_tok.expand(B, -1, -1)
278
+ t = torch.cat([g, t_c, t_f], dim=1) # (B,81,d)
279
+ t = t + self.pos[:, :t.shape[1]]
280
+ for blk in self.blocks:
281
+ t = blk(t)
282
+ t = self.ln_f(t)
283
+ return t, t[:, 0] # tokens, global
284
+
285
+
286
+ # --------------------------------------------------------------------------
287
+ # Heads
288
+ # --------------------------------------------------------------------------
289
+
290
+ class _MLP(nn.Module):
291
+ def __init__(self, cin: int, hidden: int, cout: int, dropout: float):
292
+ super().__init__()
293
+ self.net = nn.Sequential(
294
+ nn.Linear(cin, hidden), nn.SiLU(), nn.Dropout(dropout),
295
+ nn.Linear(hidden, hidden), nn.SiLU(), nn.Dropout(dropout),
296
+ nn.Linear(hidden, cout))
297
+
298
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
299
+ return self.net(x)
300
+
301
+
302
+ class BetaHead(nn.Module):
303
+ """pct in [0,1] as Beta(alpha,beta); alpha,beta >= 1 via softplus+1."""
304
+
305
+ def __init__(self, in_c: int, ctx_dim: int, num_kinds: int,
306
+ hidden: int = 1024, dropout: float = 0.1):
307
+ super().__init__()
308
+ self.net = nn.Sequential(
309
+ nn.Linear(in_c + ctx_dim + num_kinds, hidden),
310
+ nn.SiLU(), nn.Dropout(dropout),
311
+ nn.Linear(hidden, hidden), nn.SiLU(), nn.Dropout(dropout),
312
+ nn.Linear(hidden, 2))
313
+
314
+ def forward(self, feat: torch.Tensor, ctx: torch.Tensor,
315
+ kind_onehot: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
316
+ h = torch.cat([feat, ctx, kind_onehot], dim=1)
317
+ a, b = self.net(h).chunk(2, dim=1)
318
+ return F.softplus(a) + 1.0, F.softplus(b) + 1.0
319
+
320
+
321
+ class _ConvBn(nn.Module):
322
+ def __init__(self, cin: int, cout: int, k: int):
323
+ super().__init__()
324
+ self.seq = nn.Sequential(
325
+ nn.Conv2d(cin, cout, k, padding=k // 2, bias=False),
326
+ nn.BatchNorm2d(cout), nn.SiLU())
327
+
328
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
329
+ return self.seq(x)
330
+
331
+
332
+ class _KindTower(nn.Module):
333
+ """Per-kind heat tower (one policy per action kind)."""
334
+
335
+ def __init__(self, cin: int, hidden: int):
336
+ super().__init__()
337
+ self.net = nn.Sequential(
338
+ _ConvBn(cin, hidden, 3), _ConvBn(hidden, hidden, 3),
339
+ nn.Conv2d(hidden, 1, 1))
340
+
341
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
342
+ return self.net(x).squeeze(1) # (B,H,W)
343
+
344
+
345
+ # --------------------------------------------------------------------------
346
+ # Model
347
+ # --------------------------------------------------------------------------
348
+
349
+ def _init_weights(m: nn.Module) -> None:
350
+ if isinstance(m, nn.Conv2d):
351
+ nn.init.kaiming_normal_(m.weight, mode="fan_out", nonlinearity="relu")
352
+ elif isinstance(m, nn.Linear):
353
+ nn.init.trunc_normal_(m.weight, std=0.02)
354
+ if m.bias is not None:
355
+ nn.init.zeros_(m.bias)
356
+
357
+
358
+ class Sovereign(nn.Module):
359
+ """SOVEREIGN v3 — see module docstring for the contract."""
360
+
361
+ def __init__(self, cfg: Optional[SovereignConfig] = None):
362
+ super().__init__()
363
+ self.cfg = cfg or SovereignConfig()
364
+ c = self.cfg
365
+ H = c.map_size
366
+ assert c.dec_plan[-1][0] == c.dec_out_ch, \
367
+ "dec_plan[-1][0] must equal dec_out_ch"
368
+
369
+ # -- stem -----------------------------------------------------------
370
+ in_c = c.in_c + (2 if c.use_coord else 0)
371
+ self.stem = nn.Sequential(
372
+ nn.Conv2d(in_c, c.enc_ch[0], 3, stride=2, padding=1, bias=False),
373
+ nn.BatchNorm2d(c.enc_ch[0]), nn.SiLU())
374
+ if c.use_coord:
375
+ ys = torch.linspace(-1, 1, H).view(1, 1, H, 1).repeat(1, 1, 1, H)
376
+ xs = torch.linspace(-1, 1, H).view(1, 1, 1, H).repeat(1, 1, H, 1)
377
+ self.register_buffer("coords", torch.cat([ys, xs], dim=1))
378
+
379
+ # -- vision spine ---------------------------------------------------
380
+ ch = c.enc_ch[0]
381
+ self.enc = nn.ModuleList()
382
+ for i, blocks in enumerate(c.enc_blocks):
383
+ self.enc.append(DownStage(ch, c.enc_ch[i + 1], blocks, c.use_se))
384
+ ch = c.enc_ch[i + 1]
385
+
386
+ self.film = FiLM(c.ctx_full_dim, ch) # on bottleneck (e5)
387
+ self.cortex = Cortex(c)
388
+
389
+ # -- decoder --------------------------------------------------------
390
+ self.dec = nn.ModuleList()
391
+ for cout, skip_c, blocks in c.dec_plan:
392
+ self.dec.append(UpStage(ch, cout, skip_c, blocks, c.use_se))
393
+ ch = cout
394
+
395
+ # -- heat heads ------------------------------------------------------
396
+ self.heat_mixer = nn.Sequential(
397
+ _ConvBn(c.dec_out_ch, c.heat_hidden, 3),
398
+ _ConvBn(c.heat_hidden, c.heat_hidden, 3))
399
+ self.kind_towers = nn.ModuleList([
400
+ _KindTower(c.heat_hidden, c.tower_hidden)
401
+ for _ in range(c.num_kinds)])
402
+
403
+ # -- spatial aux heads (on e1 @H/4) ---------------------------------
404
+ if c.use_gate:
405
+ self.gate_head = nn.Conv2d(c.enc_ch[1], c.num_kinds, 1)
406
+ if c.use_aux_seg:
407
+ self.seg_head = nn.Conv2d(c.enc_ch[1], c.aux_seg_classes, 1)
408
+ if c.use_dynamics:
409
+ self.next_seg_head = nn.Conv2d(c.enc_ch[1], c.aux_seg_classes, 1)
410
+ self.threat_head = nn.Conv2d(c.enc_ch[1], 1, 1)
411
+ self.expand_head = nn.Conv2d(c.enc_ch[1], 1, 1)
412
+
413
+ # -- global heads ----------------------------------------------------
414
+ pool_dim = c.cortex_d + c.ctx_full_dim
415
+ self.kind_head = _MLP(pool_dim, c.head_hidden, c.num_kinds,
416
+ c.dropout_mlp)
417
+ self.value_head = _MLP(pool_dim, c.head_hidden, 1, c.dropout_mlp)
418
+ self.win_head = _MLP(pool_dim, c.head_hidden, 2, c.dropout_mlp)
419
+ self.econ_head = (_MLP(pool_dim, c.head_hidden, c.ctx_full_dim,
420
+ c.dropout_mlp) if c.use_econ else None)
421
+ self.pct_head = BetaHead(c.dec_out_ch, c.ctx_full_dim, c.num_kinds,
422
+ hidden=c.pct_hidden, dropout=c.dropout_mlp)
423
+ self.cell_value = (
424
+ nn.Sequential(_ConvBn(c.dec_plan[2][0], 128, 1),
425
+ nn.Conv2d(128, 1, 1))
426
+ if c.use_cell_value else None)
427
+
428
+ # -- memory core -----------------------------------------------------
429
+ self.mem = None
430
+ if c.use_mem:
431
+ self.mem = nn.GRU(pool_dim, c.mem_hidden, 1, batch_first=True)
432
+ self.mem_proj = nn.Linear(c.mem_hidden, c.cortex_d)
433
+
434
+ self.apply(_init_weights)
435
+
436
+ # -- helpers ------------------------------------------------------------
437
+
438
+ def _staged(self, m: nn.Module, x: torch.Tensor) -> torch.Tensor:
439
+ """Optional gradient checkpointing on the heavy stages."""
440
+ if self.cfg.grad_ckpt and torch.is_grad_enabled():
441
+ return _ckpt(m, x, use_reentrant=False)
442
+ return m(x)
443
+
444
+ @staticmethod
445
+ def _coord_norm(yx: torch.Tensor, size: int) -> torch.Tensor:
446
+ return (yx.float() / max(size - 1, 1)) * 2.0 - 1.0
447
+
448
+ @staticmethod
449
+ def _gather(feat: torch.Tensor, yx: torch.Tensor) -> torch.Tensor:
450
+ coords = Sovereign._coord_norm(yx, feat.shape[-1]).flip(-1)
451
+ return F.grid_sample(feat, coords.view(-1, 1, 1, 2),
452
+ align_corners=False).squeeze(-1).squeeze(-1)
453
+
454
+ def _ctx_full(self, nums: torch.Tensor,
455
+ rtg: Optional[torch.Tensor]) -> torch.Tensor:
456
+ B = nums.shape[0]
457
+ if rtg is not None:
458
+ r = rtg.view(B, -1).float()
459
+ else:
460
+ r = torch.zeros(B, self.cfg.rtg_dim, device=nums.device)
461
+ return torch.cat([nums, r], dim=1)
462
+
463
+ def _gate_logit(self, feat_q: torch.Tensor, H: int) -> torch.Tensor:
464
+ g = torch.sigmoid(self.gate_head(feat_q))
465
+ logg = torch.log(g.clamp_min(EPS))
466
+ if logg.shape[-1] != H:
467
+ logg = F.interpolate(logg, size=(H, H), mode="bilinear",
468
+ align_corners=False)
469
+ return logg
470
+
471
+ # -- forward ------------------------------------------------------------
472
+
473
+ def forward(self, rgb: torch.Tensor, nums: Optional[torch.Tensor] = None,
474
+ mask: Optional[torch.Tensor] = None,
475
+ grid: Optional[int] = None,
476
+ cell: Optional[torch.Tensor] = None,
477
+ rtg: Optional[torch.Tensor] = None,
478
+ state: Optional[torch.Tensor] = None,
479
+ return_all: bool = False):
480
+ """Contract forward.
481
+
482
+ rgb: (B, C, H, W) float [0,1], H=W=map_size.
483
+ nums: (B, 8) context (zeros if None).
484
+ rtg: (B,) return-to-go conditioning (RPO); zeros if None.
485
+ state: (1, B, mem_hidden) optional GRU state (returned in out dict).
486
+ mask: (B, H, W) external legality mask, log-summed on top.
487
+ grid: pooled cell grid for the compat heatmap (default 32).
488
+ cell: (B,) grid idx — pct params evaluated there (training).
489
+ """
490
+ cfg = self.cfg
491
+ B, C, H, W = rgb.shape
492
+ assert H == W == cfg.map_size, \
493
+ f"expected {cfg.map_size}x{cfg.map_size}, got {H}x{W}"
494
+ if nums is None:
495
+ nums = torch.zeros(B, cfg.ctx_dim, device=rgb.device)
496
+ ctx_full = self._ctx_full(nums, rtg) # (B, ctx+rtg)
497
+
498
+ x = rgb
499
+ if cfg.use_coord:
500
+ x = torch.cat([x, self.coords.expand(B, -1, -1, -1)], dim=1)
501
+
502
+ # vision spine
503
+ skips = []
504
+ x = self.stem(x)
505
+ for i, stage in enumerate(self.enc):
506
+ x = self._staged(stage, x) if i >= len(self.enc) - 2 else stage(x)
507
+ skips.append(x) # [@H/4, H/8, H/16, H/32, H/64]
508
+
509
+ # cortex
510
+ bot = self.film(x, ctx_full)
511
+ tokens, g = self.cortex(bot, skips[-2])
512
+
513
+ # memory core (optional)
514
+ mem_state = None
515
+ if self.mem is not None:
516
+ xin = torch.cat([g, ctx_full], dim=1).unsqueeze(1) # (B,1,d')
517
+ h0 = state if state is not None else torch.zeros(
518
+ 1, B, cfg.mem_hidden, device=rgb.device)
519
+ o, hn = self.mem(xin, h0)
520
+ g = self.mem_proj(o.squeeze(1)) # (B,d)
521
+ mem_state = hn
522
+
523
+ # decoder
524
+ d = (self._staged(lambda t: self.dec[0](t, skips[-2]), bot)
525
+ if cfg.grad_ckpt else self.dec[0](bot, skips[-2])) # @H/32
526
+ if cfg.grad_ckpt:
527
+ d = self._staged(
528
+ lambda t: self.dec[1](t, skips[-3]), d)
529
+ else:
530
+ d = self.dec[1](d, skips[-3]) # @H/16
531
+ d32 = self.dec[2](d, skips[-4]) # @H/8
532
+ d64 = self.dec[3](d32, skips[-5]) # @H/4
533
+ d128 = self.dec[4](d64, None) # @H/2
534
+ dres = self.dec[5](d128, None) # @H (dec_out_ch)
535
+
536
+ # heat: mixer + per-kind towers + gate (+ external mask)
537
+ hmix = self.heat_mixer(dres)
538
+ heat_raw = torch.stack([tower(hmix) for tower in self.kind_towers],
539
+ dim=1) # (B,3,H,W)
540
+ heat = heat_raw
541
+ if cfg.use_gate:
542
+ heat = heat + self._gate_logit(skips[0], H)
543
+ if mask is not None:
544
+ assert mask.shape[-2:] == (H, W)
545
+ heat = heat + torch.log(mask.unsqueeze(1).clamp_min(EPS))
546
+
547
+ gd = grid if grid is not None else cfg.grid_default
548
+ heat_pool = F.avg_pool2d(heat, H // gd).view(B, cfg.num_kinds, -1)
549
+ click_marginal = torch.logsumexp(heat_pool, dim=1) # (B, g*g)
550
+
551
+ # global heads
552
+ feat = torch.cat([g, ctx_full], dim=1) # (B, d+9)
553
+ kind_logits = self.kind_head(feat)
554
+ value = self.value_head(feat).squeeze(-1)
555
+ win_logits = self.win_head(feat)
556
+ econ = self.econ_head(feat) if self.econ_head is not None else None
557
+ kind_soft = F.softmax(kind_logits, dim=1)
558
+
559
+ # pct params at requested cell, else at argmax of the marginal
560
+ if cell is not None:
561
+ y = (cell // gd) * (H // gd) + (H // gd) // 2
562
+ xc = (cell % gd) * (H // gd) + (H // gd) // 2
563
+ yx = torch.stack([y, xc], dim=1).to(rgb.device)
564
+ else:
565
+ best = torch.argmax(click_marginal, dim=1)
566
+ y = (best // gd) * (H // gd) + (H // gd) // 2
567
+ xc = (best % gd) * (H // gd) + (H // gd) // 2
568
+ yx = torch.stack([y, xc], dim=1).to(rgb.device)
569
+ pct_feat = self._gather(dres, yx)
570
+ alpha, beta_p = self.pct_head(pct_feat, ctx_full, kind_soft)
571
+ pct = alpha / (alpha + beta_p)
572
+
573
+ out = dict(
574
+ kind_logits=kind_logits,
575
+ kind_probs=kind_soft,
576
+ heat=heat,
577
+ heat_raw=heat_raw,
578
+ cell_logits=heat_pool,
579
+ click=click_marginal,
580
+ pct=pct,
581
+ pct_params=(alpha, beta_p),
582
+ value=value,
583
+ win_logits=win_logits,
584
+ win_prob=torch.softmax(win_logits, dim=1)[:, 1],
585
+ econ=econ,
586
+ gate=torch.sigmoid(self.gate_head(skips[0])) if cfg.use_gate
587
+ else None,
588
+ seg=self.seg_head(skips[0]) if cfg.use_aux_seg else None,
589
+ next_seg=self.next_seg_head(skips[0]) if cfg.use_dynamics
590
+ else None,
591
+ threat=torch.sigmoid(self.threat_head(skips[0])).squeeze(1)
592
+ if cfg.use_dynamics else None,
593
+ expand=torch.sigmoid(self.expand_head(skips[0])).squeeze(1)
594
+ if cfg.use_dynamics else None,
595
+ cell_value=(self.cell_value(d32).squeeze(1)
596
+ if self.cell_value is not None else None),
597
+ dec_feat=dres,
598
+ mem_state=mem_state,
599
+ )
600
+ if return_all:
601
+ return out
602
+ return out["click"], kind_logits, pct, value
603
+
604
+ # -- acting -------------------------------------------------------------
605
+
606
+ @torch.no_grad()
607
+ def act(self, rgb: torch.Tensor, nums: Optional[torch.Tensor] = None,
608
+ mask: Optional[torch.Tensor] = None,
609
+ grid: Optional[int] = None,
610
+ rtg: Optional[torch.Tensor] = None,
611
+ state: Optional[torch.Tensor] = None,
612
+ deterministic: bool = False, temperature: float = 1.0) -> dict:
613
+ """Sample a full action with the correct factorization:
614
+ p(kind) · p(cell | kind) · Beta(pct | kind, cell). Returns state."""
615
+ cfg = self.cfg
616
+ out = self.forward(rgb, nums, mask=mask, grid=grid, rtg=rtg,
617
+ state=state, return_all=True)
618
+ B = rgb.shape[0]
619
+ gd = grid if grid is not None else cfg.grid_default
620
+ H = cfg.map_size
621
+
622
+ kp = out["kind_probs"] / max(temperature, EPS)
623
+ kd = Categorical(logits=torch.log(kp.clamp_min(EPS)))
624
+ kind = kd.sample() if not deterministic else torch.argmax(kp, dim=1)
625
+
626
+ heat_k = out["heat"][torch.arange(B, device=rgb.device), kind]
627
+ heat_pool_k = F.avg_pool2d(heat_k, H // gd).view(B, -1)
628
+ cd = Categorical(logits=heat_pool_k / max(temperature, EPS))
629
+ cell = cd.sample() if not deterministic else torch.argmax(
630
+ heat_pool_k, dim=1)
631
+
632
+ y = (cell // gd) * (H // gd) + (H // gd) // 2
633
+ xc = (cell % gd) * (H // gd) + (H // gd) // 2
634
+ yx = torch.stack([y, xc], dim=1).to(rgb.device)
635
+ pct_feat = self._gather(out["dec_feat"], yx)
636
+ kind_oh = F.one_hot(kind, cfg.num_kinds).float()
637
+ ctx_full = self._ctx_full(
638
+ nums if nums is not None
639
+ else torch.zeros(B, cfg.ctx_dim, device=rgb.device), rtg)
640
+ alpha, beta_p = self.pct_head(pct_feat, ctx_full, kind_oh)
641
+ beta_d = Beta(alpha, beta_p)
642
+ pct = beta_d.sample() if not deterministic else beta_d.mean
643
+
644
+ logprob = (kd.log_prob(kind) + cd.log_prob(cell)
645
+ + beta_d.log_prob(pct.clamp(EPS, 1 - EPS)))
646
+ entropy = (kd.entropy() + cd.entropy() + beta_d.entropy())
647
+ return dict(kind=kind, cell=cell, yx=yx, pct=pct, logprob=logprob,
648
+ value=out["value"], win_prob=out["win_prob"],
649
+ entropy=entropy, kind_probs=out["kind_probs"],
650
+ state=out["mem_state"])
651
+
652
+
653
+ # --------------------------------------------------------------------------
654
+ # Stage-A (offline pretraining) loss — used with data_adapters.py
655
+ # --------------------------------------------------------------------------
656
+
657
+ def stage_a_loss(out: dict, kind: torch.Tensor, cell: torch.Tensor,
658
+ pct_t: torch.Tensor, ret: Optional[torch.Tensor] = None,
659
+ win_lab: Optional[torch.Tensor] = None,
660
+ lab64: Optional[torch.Tensor] = None,
661
+ lab64_next: Optional[torch.Tensor] = None,
662
+ gate_mask: Optional[torch.Tensor] = None,
663
+ gate_valid: Optional[torch.Tensor] = None,
664
+ threat: Optional[torch.Tensor] = None,
665
+ expand: Optional[torch.Tensor] = None,
666
+ nums_next: Optional[torch.Tensor] = None,
667
+ w: Optional[torch.Tensor] = None,
668
+ lamb: dict = None) -> Tuple[torch.Tensor, dict]:
669
+ """Weighted filtered-BC + value + win + gate + seg + dynamics + econ.
670
+
671
+ IMPORTANT: `out` must come from forward(..., cell=cell, rtg=rtg_target,
672
+ return_all=True) so pct_params sit at the recorded cell and every head
673
+ is RTG-conditioned. `w` = AWR/survivor/kill weights from
674
+ data_adapters.frame_weights. All aux targets derive from free shard
675
+ labels (lab[t+1], episode outcomes) — see data_adapters.py.
676
+ """
677
+ lamb = lamb or dict(kind=0.5, cell=1.0, pct=0.25, val=0.1, win=0.1,
678
+ gate=0.5, seg=0.5, next_seg=0.4, threat=0.2,
679
+ expand=0.2, econ=0.05, ent=0.003)
680
+ B = kind.shape[0]
681
+ w = w if w is not None else torch.ones(B, device=kind.device)
682
+ terms = {}
683
+
684
+ kind_nll = F.cross_entropy(out["kind_logits"], kind, reduction="none",
685
+ label_smoothing=0.05)
686
+ cell_nll = F.cross_entropy(out["cell_logits"], cell, reduction="none")
687
+ pct_nll = -(Beta(out["pct_params"][0], out["pct_params"][1])
688
+ .log_prob(pct_t.clamp(EPS, 1 - EPS)))
689
+ pct_nll = pct_nll * (kind != 2).float() # bank: pct undefined
690
+
691
+ loss = (lamb["kind"] * kind_nll + lamb["cell"] * cell_nll
692
+ + lamb["pct"] * pct_nll)
693
+ terms["kind_nll"] = kind_nll.mean().detach()
694
+ terms["cell_nll"] = cell_nll.mean().detach()
695
+ terms["pct_nll"] = pct_nll.mean().detach()
696
+
697
+ if ret is not None:
698
+ val_loss = F.huber_loss(out["value"], ret, reduction="none")
699
+ loss = loss + lamb["val"] * val_loss
700
+ terms["val"] = val_loss.mean().detach()
701
+
702
+ if win_lab is not None:
703
+ win_nll = F.cross_entropy(out["win_logits"], win_lab,
704
+ reduction="none", label_smoothing=0.05)
705
+ loss = loss + lamb["win"] * win_nll
706
+ terms["win"] = win_nll.mean().detach()
707
+
708
+ if out["gate"] is not None and gate_mask is not None:
709
+ g_bce = F.binary_cross_entropy(out["gate"], gate_mask,
710
+ reduction="none").mean(dim=(2, 3))
711
+ g_bce = g_bce * gate_valid.float()
712
+ denom = gate_valid.float().sum() + EPS
713
+ loss = loss + lamb["gate"] * (g_bce.sum() / denom).expand(B)
714
+ terms["gate"] = (g_bce.sum() / denom).detach()
715
+
716
+ if out["seg"] is not None and lab64 is not None:
717
+ seg_ce = F.cross_entropy(out["seg"], lab64, reduction="none")
718
+ loss = loss + lamb["seg"] * seg_ce
719
+ terms["seg"] = seg_ce.mean().detach()
720
+
721
+ if out["next_seg"] is not None and lab64_next is not None:
722
+ nseg_ce = F.cross_entropy(out["next_seg"], lab64_next,
723
+ reduction="none")
724
+ loss = loss + lamb["next_seg"] * nseg_ce
725
+ terms["next_seg"] = nseg_ce.mean().detach()
726
+
727
+ if out["threat"] is not None and threat is not None:
728
+ th = F.binary_cross_entropy(out["threat"], threat,
729
+ reduction="none").mean(dim=(1, 2))
730
+ loss = loss + lamb["threat"] * th
731
+ terms["threat"] = th.mean().detach()
732
+
733
+ if out["expand"] is not None and expand is not None:
734
+ ex = F.binary_cross_entropy(out["expand"], expand,
735
+ reduction="none").mean(dim=(1, 2))
736
+ loss = loss + lamb["expand"] * ex
737
+ terms["expand"] = ex.mean().detach()
738
+
739
+ if out["econ"] is not None and nums_next is not None:
740
+ econ_l1 = F.smooth_l1_loss(out["econ"], nums_next, reduction="none")
741
+ econ_l1 = econ_l1.mean(dim=1)
742
+ loss = loss + lamb["econ"] * econ_l1
743
+ terms["econ"] = econ_l1.mean().detach()
744
+
745
+ marg = torch.logsumexp(out["cell_logits"], dim=1)
746
+ ent = (F.categorical_entropy(out["kind_probs"]) +
747
+ F.categorical_entropy(F.softmax(marg, dim=1)) +
748
+ Beta(out["pct_params"][0], out["pct_params"][1]).entropy())
749
+ loss = loss - lamb["ent"] * ent
750
+ terms["entropy"] = ent.mean().detach()
751
+
752
+ total = (loss * w).mean()
753
+ terms["total"] = total.detach()
754
+ return total, terms
755
+
756
+
757
+ # --------------------------------------------------------------------------
758
+ # Utils
759
+ # --------------------------------------------------------------------------
760
+
761
+ def count_params(net: nn.Module) -> int:
762
+ return sum(p.numel() for p in net.parameters())
763
+
764
+
765
+ def param_breakdown(net: Sovereign) -> dict:
766
+ """Params per design module."""
767
+ n = lambda m: sum(p.numel() for p in m.parameters())
768
+ spine = n(net.stem) + sum(n(m) for m in net.enc)
769
+ cortex = n(net.cortex) + n(net.film)
770
+ decoder = sum(n(m) for m in net.dec)
771
+ heads = (n(net.heat_mixer) + sum(n(m) for m in net.kind_towers) +
772
+ n(net.kind_head) + n(net.value_head) + n(net.win_head) +
773
+ n(net.pct_head))
774
+ if net.cfg.use_gate:
775
+ heads += n(net.gate_head)
776
+ if net.cfg.use_aux_seg:
777
+ heads += n(net.seg_head)
778
+ if net.cfg.use_dynamics:
779
+ heads += (n(net.next_seg_head) + n(net.threat_head) +
780
+ n(net.expand_head))
781
+ if net.cfg.use_econ:
782
+ heads += n(net.econ_head)
783
+ if net.cfg.use_cell_value:
784
+ heads += n(net.cell_value)
785
+ mem = (n(net.mem) + n(net.mem_proj)) if net.mem is not None else 0
786
+ return dict(spine=spine, cortex=cortex, decoder=decoder, heads=heads,
787
+ mem=mem)
788
+
789
+
790
+ def make_model(cfg: Optional[SovereignConfig] = None,
791
+ seed: Optional[int] = None) -> Sovereign:
792
+ if seed is not None:
793
+ torch.manual_seed(seed)
794
+ return Sovereign(cfg)
795
+
796
+
797
+ if __name__ == "__main__":
798
+ # CPU smoke test: instantiate, count, run a fake frame through every path.
799
+ fast = "--fast" in sys.argv
800
+ cfg = SovereignConfig(map_size=128) if fast else SovereignConfig()
801
+ m = make_model(cfg)
802
+ total = count_params(m)
803
+ bd = param_breakdown(m)
804
+ print(f"params: {total/1e6:.2f}M (spine {bd['spine']/1e6:.1f}M | "
805
+ f"cortex {bd['cortex']/1e6:.1f}M | decoder {bd['decoder']/1e6:.1f}M "
806
+ f"| heads {bd['heads']/1e6:.1f}M | mem {bd['mem']/1e6:.1f}M)")
807
+ H = cfg.map_size
808
+ rgb = torch.rand(2, 3, H, H)
809
+ nums = torch.rand(2, 8)
810
+ rtg = torch.randn(2)
811
+ # compat path
812
+ click, kind, pct, value = m(rgb, nums, rtg=rtg)
813
+ print("compat out:", click.shape, kind.shape, pct.shape, value.shape)
814
+ # full dict
815
+ out = m(rgb, nums, rtg=rtg, cell=torch.tensor([120, 400]),
816
+ return_all=True)
817
+ print("full dict keys:", sorted(out.keys()))
818
+ # act + memory
819
+ a = m.act(rgb, nums, rtg=rtg)
820
+ print("act:", {k: (v.shape if hasattr(v, "shape") else v)
821
+ for k, v in a.items() if k not in ("kind_probs",)})
822
+ a2 = m.act(rgb, nums, rtg=rtg, state=a["state"])
823
+ print("mem carry ok:", a2["state"].shape)
824
+ # no-rtg path (contract default)
825
+ _, _, _, _ = m(rgb, nums)
826
+ print("no-rtg contract path OK")
827
+ if not fast:
828
+ rgb256 = torch.rand(1, 3, 256, 256)
829
+ _, _, _, _ = m(rgb256, torch.rand(1, 8), rtg=torch.randn(1))
830
+ print("256x256 contract path OK")
831
+ print("smoke OK")