amer224 commited on
Commit
dc1d0bc
·
verified ·
1 Parent(s): 3528a5c

Add brains/sovereign-nano/model_nano.py

Browse files
Files changed (1) hide show
  1. brains/sovereign-nano/model_nano.py +958 -0
brains/sovereign-nano/model_nano.py ADDED
@@ -0,0 +1,958 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SOVEREIGN-nano — <=5M-param policy for territorial.io (last-survivor).
3
+
4
+ Role (pipeline brief, 2026-08-14):
5
+ 1. FAST ROLLOUT ENGINE — ~90x cheaper than the 290M teacher, so the
6
+ PPO / league / wake-sleep loop gets ~10x more sim experience per
7
+ GPU-hour.
8
+ 2. DISTILLATION STUDENT — the teacher distills into it (distill.py);
9
+ the contract below is identical to v3 so KL-on-heatmaps is trivial.
10
+ 3. CPU-TRAINABLE SANITY BASELINE — smoke-trainable on Kaggle CPU.
11
+
12
+ Contract (identical to SOVEREIGN v3):
13
+ forward(rgb (B,3,256,256) float [0,1], nums (B,8), rtg (B,)|None,
14
+ grid=32) -> (click (B,1024), kind_logits (B,3), pct (B,),
15
+ value (B,))
16
+ act(...) -> {kind, cell, yx, pct, logprob, value, win_prob, entropy,
17
+ state} with logprob/entropy STRICTLY (B,) at any batch.
18
+
19
+ Kept from v3 (scaled down): return-conditioning (rtg), gate head, intent
20
+ mirrors (threat/expand), next_seg dynamics aux, win head, econ forecaster,
21
+ Beta pct, cell-value aux, tiny GRU memory.
22
+ Dropped (stated honestly): the 77M transformer cortex -> pooled-MLP global
23
+ head; the MCTS planning block -> OUT; SE attention -> OUT.
24
+
25
+ REAL SHARD SCHEMA (measured 2026-08-14 on shard_v2_1786707781):
26
+ rgb (354,256,256,3) uint8 frames every 2 ticks
27
+ lab (354,256,256) uint8 0 water / 1 neutral / 2 me / 3 enemy
28
+ nums (354,8) float32 [bal_log,me_frac,red,e1,e2,tick_frac,
29
+ income,kills]
30
+ kind (354,) int64 0 expand / 1 attack / 2 bank
31
+ cell (354,) int64 32x32 grid idx 0..1023
32
+ pct (354,) float32 troops fraction in [0,1]
33
+ logp (354,) float32 TRUE behavior logprob (old shards -2.0)
34
+ reward (708,) float32 PER TICK (2x frames), -1.3 death terminal
35
+ alive (4,) int64 PER EPISODE 0/1 (survived timeout)
36
+ lens (4,) int64 PER EPISODE TICK counts (frames=lens//2)
37
+
38
+ Every bug from the v3 review is fixed here AND asserted in the smoke test
39
+ (see __main__). Zero synthetic-only claims: the smoke loads the REAL shard
40
+ via $HF_TOKEN when available and prints which path it ran.
41
+
42
+ torch + numpy only. Run: python model_nano.py
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import os
48
+ from dataclasses import dataclass
49
+ from typing import Optional, Tuple
50
+
51
+ import numpy as np
52
+ import torch
53
+ import torch.nn as nn
54
+ import torch.nn.functional as F
55
+ from torch.distributions import Beta, Categorical
56
+
57
+ EPS = 1e-6
58
+ PARAM_LIMIT = 5_000_000
59
+ HF_DATASET = "amer224/territorial-bot-data"
60
+ HF_SHARD = "rl/shards_v2/shard_v2_1786707781_27_23.npz"
61
+
62
+
63
+ # --------------------------------------------------------------------------
64
+ # Config
65
+ # --------------------------------------------------------------------------
66
+
67
+ @dataclass
68
+ class NanoConfig:
69
+ map_size: int = 256
70
+ in_c: int = 3
71
+ ctx_dim: int = 8
72
+ rtg_dim: int = 1
73
+ num_kinds: int = 3
74
+ # enc_ch[0]=stem out; enc_ch[1..4]=stage outputs (@64,32,16,8 px)
75
+ enc_ch: Tuple[int, ...] = (16, 32, 64, 128, 192)
76
+ enc_blocks: Tuple[int, ...] = (1, 2, 2, 1)
77
+ # dec_plan: (out_ch, skip_ch or None, res_blocks); last out = dec_out_ch
78
+ dec_plan: Tuple[Tuple[int, Optional[int], int], ...] = (
79
+ (128, 128, 1), # dec0 @16px (skip e3)
80
+ (64, 64, 1), # dec1 @32px (skip e2) -> cell_value source
81
+ (48, 32, 1), # dec2 @64px (skip e1)
82
+ (32, None, 1), # dec3 @128px
83
+ (32, None, 0), # dec4 @256px
84
+ )
85
+ dec_out_ch: int = 32
86
+ heat_hidden: int = 48
87
+ tower_hidden: int = 48
88
+ heat_res: Optional[int] = None # run the heat tower at this res and
89
+ # upsample the logits (nuclear option
90
+ # for CPU rollouts — ablate before
91
+ # trusting; bench.py measures it)
92
+ proj_hidden: int = 512 # shared context projection (pooled global)
93
+ head_hidden: int = 256
94
+ pct_hidden: int = 128
95
+ grid_default: int = 32
96
+ use_mem: bool = True # tiny GRU (state in act())
97
+ mem_hidden: int = 96
98
+ use_coord: bool = True
99
+ use_gate: bool = True
100
+ use_aux_seg: bool = True
101
+ use_dynamics: bool = True # next_seg / threat / expand
102
+ use_econ: bool = True
103
+ use_cell_value: bool = True
104
+ aux_seg_classes: int = 4
105
+ dropout_mlp: float = 0.05
106
+
107
+ @property
108
+ def ctx_full_dim(self) -> int:
109
+ return self.ctx_dim + self.rtg_dim
110
+
111
+
112
+ # --------------------------------------------------------------------------
113
+ # Blocks (v3 patterns, scaled down; SE dropped — honest simplification)
114
+ # --------------------------------------------------------------------------
115
+
116
+ class ResBlock(nn.Module):
117
+ def __init__(self, c: int):
118
+ super().__init__()
119
+ self.conv1 = nn.Conv2d(c, c, 3, padding=1, bias=False)
120
+ self.bn1 = nn.BatchNorm2d(c)
121
+ self.conv2 = nn.Conv2d(c, c, 3, padding=1, bias=False)
122
+ self.bn2 = nn.BatchNorm2d(c)
123
+
124
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
125
+ y = F.silu(self.bn1(self.conv1(x)))
126
+ y = self.bn2(self.conv2(y))
127
+ return F.silu(x + y)
128
+
129
+
130
+ class DownStage(nn.Module):
131
+ def __init__(self, cin: int, cout: int, blocks: int):
132
+ super().__init__()
133
+ layers = [nn.Conv2d(cin, cout, 3, stride=2, padding=1, bias=False),
134
+ nn.BatchNorm2d(cout), nn.SiLU()]
135
+ layers += [ResBlock(cout) for _ in range(blocks)]
136
+ self.net = nn.Sequential(*layers)
137
+
138
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
139
+ return self.net(x)
140
+
141
+
142
+ class UpStage(nn.Module):
143
+ def __init__(self, cin: int, cout: int, skip_c: Optional[int] = None,
144
+ blocks: int = 1):
145
+ super().__init__()
146
+ self.skip = (nn.Conv2d(skip_c, cin, 1, bias=False)
147
+ if skip_c is not None else None)
148
+ self.up = nn.Conv2d(cin, cout, 3, padding=1, bias=False)
149
+ self.bn = nn.BatchNorm2d(cout)
150
+ self.blocks = nn.Sequential(*[ResBlock(cout)
151
+ for _ in range(blocks)])
152
+
153
+ def forward(self, x: torch.Tensor,
154
+ skip: Optional[torch.Tensor] = None) -> torch.Tensor:
155
+ # interpolate needs NCHW — convert if the caller fed NHWC
156
+ if x.is_contiguous(memory_format=torch.channels_last):
157
+ x = x.contiguous(memory_format=torch.contiguous_format)
158
+ x = F.interpolate(x, scale_factor=2, mode="bilinear",
159
+ align_corners=False)
160
+ if skip is not None:
161
+ x = x + self.skip(skip)
162
+ x = F.silu(self.bn(self.up(x)))
163
+ return self.blocks(x)
164
+
165
+
166
+ class FiLM(nn.Module):
167
+ def __init__(self, ctx_dim: int, c: int):
168
+ super().__init__()
169
+ self.gamma = nn.Linear(ctx_dim, c)
170
+ self.beta = nn.Linear(ctx_dim, c)
171
+
172
+ def forward(self, x: torch.Tensor, ctx: torch.Tensor) -> torch.Tensor:
173
+ g = self.gamma(ctx).view(ctx.shape[0], -1, 1, 1) + 1.0
174
+ b = self.beta(ctx).view(ctx.shape[0], -1, 1, 1)
175
+ return x * g + b
176
+
177
+
178
+ class _MLP(nn.Module):
179
+ def __init__(self, cin: int, hidden: int, cout: int, dropout: float):
180
+ super().__init__()
181
+ self.net = nn.Sequential(
182
+ nn.Linear(cin, hidden), nn.SiLU(), nn.Dropout(dropout),
183
+ nn.Linear(hidden, cout))
184
+
185
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
186
+ return self.net(x)
187
+
188
+
189
+ class BetaHead(nn.Module):
190
+ """pct in [0,1] as Beta(alpha,beta); outputs STRICTLY (B,) for both
191
+ params (the v3 review's (B,B) broadcast bug was a (B,1) param shape)."""
192
+
193
+ def __init__(self, in_c: int, ctx_dim: int, num_kinds: int,
194
+ hidden: int = 128, dropout: float = 0.05):
195
+ super().__init__()
196
+ self.net = nn.Sequential(
197
+ nn.Linear(in_c + ctx_dim + num_kinds, hidden),
198
+ nn.SiLU(), nn.Dropout(dropout),
199
+ nn.Linear(hidden, hidden), nn.SiLU(), nn.Dropout(dropout),
200
+ nn.Linear(hidden, 2))
201
+
202
+ def forward(self, feat: torch.Tensor, ctx: torch.Tensor,
203
+ kind_onehot: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
204
+ h = torch.cat([feat, ctx, kind_onehot], dim=1)
205
+ a, b = self.net(h).chunk(2, dim=1)
206
+ a = F.softplus(a) + 1.0
207
+ b = F.softplus(b) + 1.0
208
+ return a.squeeze(-1), b.squeeze(-1) # (B,) — no broadcast
209
+
210
+
211
+ class _ConvBn(nn.Module):
212
+ def __init__(self, cin: int, cout: int, k: int):
213
+ super().__init__()
214
+ self.seq = nn.Sequential(
215
+ nn.Conv2d(cin, cout, k, padding=k // 2, bias=False),
216
+ nn.BatchNorm2d(cout), nn.SiLU())
217
+
218
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
219
+ return self.seq(x)
220
+
221
+
222
+ # --------------------------------------------------------------------------
223
+ # Model
224
+ # --------------------------------------------------------------------------
225
+
226
+ def _init_weights(m: nn.Module) -> None:
227
+ if isinstance(m, nn.Conv2d):
228
+ nn.init.kaiming_normal_(m.weight, mode="fan_out",
229
+ nonlinearity="relu")
230
+ elif isinstance(m, nn.Linear):
231
+ nn.init.trunc_normal_(m.weight, std=0.02)
232
+ if m.bias is not None:
233
+ nn.init.zeros_(m.bias)
234
+
235
+
236
+ class Nano(nn.Module):
237
+ """SOVEREIGN-nano — see module docstring for the contract."""
238
+
239
+ def __init__(self, cfg: Optional[NanoConfig] = None):
240
+ super().__init__()
241
+ self.cfg = cfg or NanoConfig()
242
+ c = self.cfg
243
+ H = c.map_size
244
+ assert c.dec_plan[-1][0] == c.dec_out_ch, \
245
+ "dec_plan[-1][0] must equal dec_out_ch"
246
+
247
+ # stem
248
+ in_c = c.in_c + (2 if c.use_coord else 0)
249
+ self.stem = nn.Sequential(
250
+ nn.Conv2d(in_c, c.enc_ch[0], 3, stride=2, padding=1, bias=False),
251
+ nn.BatchNorm2d(c.enc_ch[0]), nn.SiLU())
252
+ if c.use_coord:
253
+ ys = torch.linspace(-1, 1, H).view(1, 1, H, 1).repeat(1, 1, 1, H)
254
+ xs = torch.linspace(-1, 1, H).view(1, 1, 1, H).repeat(1, 1, H, 1)
255
+ self.register_buffer("coords", torch.cat([ys, xs], dim=1))
256
+
257
+ # encoder
258
+ ch = c.enc_ch[0]
259
+ self.enc = nn.ModuleList()
260
+ for i, blocks in enumerate(c.enc_blocks):
261
+ self.enc.append(DownStage(ch, c.enc_ch[i + 1], blocks))
262
+ ch = c.enc_ch[i + 1]
263
+
264
+ self.film = FiLM(c.ctx_full_dim, ch) # on bottleneck @8px
265
+
266
+ # decoder
267
+ self.dec = nn.ModuleList()
268
+ for cout, skip_c, blocks in c.dec_plan:
269
+ self.dec.append(UpStage(ch, cout, skip_c, blocks))
270
+ ch = cout
271
+
272
+ # heat heads — SHARED tower (speed pass): the old 3 per-kind towers
273
+ # ran ~8.2G MACs at 256² (65% of the whole network's compute).
274
+ # ONE tower emitting all 3 channels cuts heat cost ~3× while
275
+ # keeping a full-res 3×3 so the kinds still cross-talk spatially.
276
+ self.heat_mixer = nn.Sequential(
277
+ _ConvBn(c.dec_out_ch, c.heat_hidden, 3),
278
+ _ConvBn(c.heat_hidden, c.heat_hidden, 3))
279
+ self.heat_tower = nn.Sequential(
280
+ _ConvBn(c.heat_hidden, c.tower_hidden, 3),
281
+ _ConvBn(c.tower_hidden, c.tower_hidden, 3),
282
+ nn.Conv2d(c.tower_hidden, c.num_kinds, 1))
283
+
284
+ # spatial aux heads on e1 (@H/4)
285
+ if c.use_gate:
286
+ self.gate_head = nn.Conv2d(c.enc_ch[1], c.num_kinds, 1)
287
+ if c.use_aux_seg:
288
+ self.seg_head = nn.Conv2d(c.enc_ch[1], c.aux_seg_classes, 1)
289
+ if c.use_dynamics:
290
+ self.next_seg_head = nn.Conv2d(c.enc_ch[1], c.aux_seg_classes, 1)
291
+ self.threat_head = nn.Conv2d(c.enc_ch[1], 1, 1)
292
+ self.expand_head = nn.Conv2d(c.enc_ch[1], 1, 1)
293
+
294
+ # global heads (pooled-MLP replaces the v3 transformer cortex)
295
+ pool_dim = c.enc_ch[-1] + c.ctx_full_dim # 192 + 9 = 201
296
+ self.proj = nn.Linear(pool_dim, c.proj_hidden)
297
+ self.kind_head = _MLP(c.proj_hidden, c.head_hidden, c.num_kinds,
298
+ c.dropout_mlp)
299
+ self.value_head = _MLP(c.proj_hidden, c.head_hidden, 1,
300
+ c.dropout_mlp)
301
+ self.win_head = _MLP(c.proj_hidden, c.head_hidden, 2,
302
+ c.dropout_mlp)
303
+ self.econ_head = (_MLP(c.proj_hidden, c.head_hidden,
304
+ c.ctx_full_dim, c.dropout_mlp)
305
+ if c.use_econ else None)
306
+ self.pct_head = BetaHead(c.dec_out_ch, c.ctx_full_dim, c.num_kinds,
307
+ hidden=c.pct_hidden, dropout=c.dropout_mlp)
308
+ self.cell_value = (
309
+ nn.Sequential(_ConvBn(c.dec_plan[1][0], 32, 1),
310
+ nn.Conv2d(32, 1, 1))
311
+ if c.use_cell_value else None) # from dec1 @32px
312
+
313
+ # memory
314
+ self.mem = None
315
+ if c.use_mem:
316
+ self.mem = nn.GRU(pool_dim, c.mem_hidden, 1, batch_first=True)
317
+ self.mem_proj = nn.Linear(c.mem_hidden, c.enc_ch[-1])
318
+
319
+ self.apply(_init_weights)
320
+
321
+ # -- helpers ------------------------------------------------------------
322
+
323
+ @staticmethod
324
+ def _coord_norm(yx: torch.Tensor, size: int) -> torch.Tensor:
325
+ return (yx.float() / max(size - 1, 1)) * 2.0 - 1.0
326
+
327
+ @staticmethod
328
+ def _gather(feat: torch.Tensor, yx: torch.Tensor) -> torch.Tensor:
329
+ # grid_sample needs NCHW — convert if the caller fed NHWC
330
+ if feat.is_contiguous(memory_format=torch.channels_last):
331
+ feat = feat.contiguous(memory_format=torch.contiguous_format)
332
+ coords = Nano._coord_norm(yx, feat.shape[-1]).flip(-1)
333
+ return F.grid_sample(feat, coords.view(-1, 1, 1, 2),
334
+ align_corners=False).squeeze(-1).squeeze(-1)
335
+
336
+ def _ctx_full(self, nums: torch.Tensor,
337
+ rtg: Optional[torch.Tensor]) -> torch.Tensor:
338
+ B = nums.shape[0]
339
+ if rtg is not None:
340
+ r = rtg.view(B, -1).float()
341
+ else:
342
+ r = torch.zeros(B, self.cfg.rtg_dim, device=nums.device)
343
+ return torch.cat([nums, r], dim=1)
344
+
345
+ def _gate_logit(self, feat_q: torch.Tensor, H: int) -> torch.Tensor:
346
+ g = torch.sigmoid(self.gate_head(feat_q))
347
+ logg = torch.log(g.clamp_min(EPS))
348
+ if logg.shape[-1] != H:
349
+ logg = F.interpolate(logg, size=(H, H), mode="bilinear",
350
+ align_corners=False)
351
+ return logg
352
+
353
+ # -- forward ------------------------------------------------------------
354
+
355
+ def forward(self, rgb: torch.Tensor, nums: Optional[torch.Tensor] = None,
356
+ rtg: Optional[torch.Tensor] = None,
357
+ mask: Optional[torch.Tensor] = None,
358
+ grid: Optional[int] = None,
359
+ cell: Optional[torch.Tensor] = None,
360
+ state: Optional[torch.Tensor] = None,
361
+ return_all: bool = False):
362
+ """Contract forward. rtg=None means zero aspiration (contract-safe).
363
+
364
+ Returns (click (B,g*g), kind_logits (B,3), pct (B,), value (B,))
365
+ unless return_all=True -> full dict.
366
+ """
367
+ cfg = self.cfg
368
+ B, C, H, W = rgb.shape
369
+ assert H == W == cfg.map_size, \
370
+ f"expected {cfg.map_size}x{cfg.map_size}, got {H}x{W}"
371
+ if nums is None:
372
+ nums = torch.zeros(B, cfg.ctx_dim, device=rgb.device)
373
+ ctx_full = self._ctx_full(nums, rtg) # (B, 9)
374
+
375
+ x = rgb
376
+ if cfg.use_coord:
377
+ x = torch.cat([x, self.coords.expand(B, -1, -1, -1)], dim=1)
378
+
379
+ skips = []
380
+ x = self.stem(x)
381
+ for stage in self.enc:
382
+ x = stage(x)
383
+ skips.append(x) # [@64,@32,@16,@8]
384
+
385
+ bot = self.film(x, ctx_full) # bottleneck @8px
386
+ g = F.adaptive_avg_pool2d(bot, 1).flatten(1) # (B,192)
387
+
388
+ mem_state = None
389
+ if self.mem is not None:
390
+ xin = torch.cat([g, ctx_full], dim=1).unsqueeze(1) # (B,1,201)
391
+ h0 = state if state is not None else torch.zeros(
392
+ 1, B, cfg.mem_hidden, device=rgb.device)
393
+ o, hn = self.mem(xin, h0)
394
+ g = self.mem_proj(o.squeeze(1)) # (B,192)
395
+ mem_state = hn
396
+
397
+ # decoder: dec0<-e3(@16), dec1<-e2(@32), dec2<-e1(@64)
398
+ d = self.dec[0](bot, skips[2])
399
+ d32 = self.dec[1](d, skips[1])
400
+ d64 = self.dec[2](d32, skips[0])
401
+ d128 = self.dec[3](d64, None)
402
+ dres = self.dec[4](d128, None) # @256 (dec_out_ch)
403
+
404
+ hmix = self.heat_mixer(dres)
405
+ if cfg.heat_res is not None and cfg.heat_res < H:
406
+ hres = cfg.heat_res
407
+ hmix = F.interpolate(hmix, size=(hres, hres), mode="bilinear",
408
+ align_corners=False)
409
+ heat_raw = self.heat_tower(hmix)
410
+ heat_raw = F.interpolate(heat_raw, size=(H, H), mode="bilinear",
411
+ align_corners=False)
412
+ else:
413
+ heat_raw = self.heat_tower(hmix) # (B,3,H,W)
414
+ heat = heat_raw
415
+ if cfg.use_gate:
416
+ heat = heat + self._gate_logit(skips[0], H)
417
+ if mask is not None:
418
+ assert mask.shape[-2:] == (H, W)
419
+ heat = heat + torch.log(mask.unsqueeze(1).clamp_min(EPS))
420
+
421
+ gd = grid if grid is not None else cfg.grid_default
422
+ heat_pool = F.avg_pool2d(heat, H // gd).view(B, cfg.num_kinds, -1)
423
+ click = torch.logsumexp(heat_pool, dim=1) # (B, g*g)
424
+
425
+ feat = torch.cat([g, ctx_full], dim=1) # (B,201)
426
+ hg = F.silu(self.proj(feat))
427
+ kind_logits = self.kind_head(hg)
428
+ value = self.value_head(hg).squeeze(-1)
429
+ win_logits = self.win_head(hg)
430
+ econ = self.econ_head(hg) if self.econ_head is not None else None
431
+ kind_soft = F.softmax(kind_logits, dim=1)
432
+
433
+ if cell is not None:
434
+ y = (cell // gd) * (H // gd) + (H // gd) // 2
435
+ xc = (cell % gd) * (H // gd) + (H // gd) // 2
436
+ yx = torch.stack([y, xc], dim=1).to(rgb.device)
437
+ else:
438
+ best = torch.argmax(click, dim=1)
439
+ y = (best // gd) * (H // gd) + (H // gd) // 2
440
+ xc = (best % gd) * (H // gd) + (H // gd) // 2
441
+ yx = torch.stack([y, xc], dim=1).to(rgb.device)
442
+ pct_feat = self._gather(dres, yx)
443
+ alpha, beta_p = self.pct_head(pct_feat, ctx_full, kind_soft)
444
+ pct = alpha / (alpha + beta_p) # (B,)
445
+
446
+ out = dict(
447
+ kind_logits=kind_logits,
448
+ kind_probs=kind_soft,
449
+ heat=heat,
450
+ heat_raw=heat_raw,
451
+ cell_logits=heat_pool, # (B,3,g*g)
452
+ click=click,
453
+ pct=pct,
454
+ pct_params=(alpha, beta_p), # ((B,),(B,))
455
+ value=value,
456
+ win_logits=win_logits,
457
+ win_prob=torch.softmax(win_logits, dim=1)[:, 1],
458
+ econ=econ,
459
+ gate=torch.sigmoid(self.gate_head(skips[0])) if cfg.use_gate
460
+ else None,
461
+ seg=self.seg_head(skips[0]) if cfg.use_aux_seg else None,
462
+ next_seg=self.next_seg_head(skips[0]) if cfg.use_dynamics
463
+ else None,
464
+ threat=torch.sigmoid(self.threat_head(skips[0])).squeeze(1)
465
+ if cfg.use_dynamics else None,
466
+ expand=torch.sigmoid(self.expand_head(skips[0])).squeeze(1)
467
+ if cfg.use_dynamics else None,
468
+ cell_value=(self.cell_value(d32).squeeze(1)
469
+ if self.cell_value is not None else None),
470
+ dec_feat=dres,
471
+ mem_state=mem_state,
472
+ )
473
+ if return_all:
474
+ return out
475
+ return out["click"], kind_logits, pct, value
476
+
477
+ # -- acting -------------------------------------------------------------
478
+
479
+ @torch.no_grad()
480
+ def act(self, rgb: torch.Tensor, nums: Optional[torch.Tensor] = None,
481
+ rtg: Optional[torch.Tensor] = None,
482
+ mask: Optional[torch.Tensor] = None,
483
+ grid: Optional[int] = None,
484
+ state: Optional[torch.Tensor] = None,
485
+ deterministic: bool = False,
486
+ temperature: float = 1.0) -> dict:
487
+ """Sample a full action. logprob and entropy are STRICTLY (B,)."""
488
+ cfg = self.cfg
489
+ out = self.forward(rgb, nums, rtg=rtg, mask=mask, grid=grid,
490
+ state=state, return_all=True)
491
+ B = rgb.shape[0]
492
+ gd = grid if grid is not None else cfg.grid_default
493
+ H = cfg.map_size
494
+
495
+ kp = out["kind_probs"] / max(temperature, EPS)
496
+ kd = Categorical(logits=torch.log(kp.clamp_min(EPS)))
497
+ kind = kd.sample() if not deterministic else torch.argmax(kp, dim=1)
498
+
499
+ heat_k = out["heat"][torch.arange(B, device=rgb.device), kind]
500
+ heat_pool_k = F.avg_pool2d(heat_k, H // gd).view(B, -1)
501
+ cd = Categorical(logits=heat_pool_k / max(temperature, EPS))
502
+ cell = cd.sample() if not deterministic else torch.argmax(
503
+ heat_pool_k, dim=1)
504
+
505
+ y = (cell // gd) * (H // gd) + (H // gd) // 2
506
+ xc = (cell % gd) * (H // gd) + (H // gd) // 2
507
+ yx = torch.stack([y, xc], dim=1).to(rgb.device)
508
+ pct_feat = self._gather(out["dec_feat"], yx)
509
+ kind_oh = F.one_hot(kind, cfg.num_kinds).float()
510
+ ctx_full = self._ctx_full(
511
+ nums if nums is not None
512
+ else torch.zeros(B, cfg.ctx_dim, device=rgb.device), rtg)
513
+ alpha, beta_p = self.pct_head(pct_feat, ctx_full, kind_oh) # (B,)
514
+ beta_d = Beta(alpha, beta_p)
515
+ pct = beta_d.sample() if not deterministic else beta_d.mean # (B,)
516
+
517
+ logprob = (kd.log_prob(kind) + cd.log_prob(cell)
518
+ + beta_d.log_prob(pct.clamp(EPS, 1 - EPS)))
519
+ entropy = (kd.entropy() + cd.entropy() + beta_d.entropy())
520
+ # v3 review bug 4: these broadcast to (B,B) — now asserted
521
+ assert logprob.shape == (B,), f"logprob {logprob.shape} != {(B,)}"
522
+ assert entropy.shape == (B,), f"entropy {entropy.shape} != {(B,)}"
523
+
524
+ return dict(kind=kind, cell=cell, yx=yx, pct=pct, logprob=logprob,
525
+ value=out["value"], win_prob=out["win_prob"],
526
+ entropy=entropy, kind_probs=out["kind_probs"],
527
+ state=out["mem_state"])
528
+
529
+
530
+ # --------------------------------------------------------------------------
531
+ # Stage-A loss — every v3 review bug fixed
532
+ # --------------------------------------------------------------------------
533
+
534
+ def cat_entropy(logits: torch.Tensor, dim: int = -1) -> torch.Tensor:
535
+ """Manual categorical entropy (v3 review bug 5: F.categorical_entropy
536
+ does not exist)."""
537
+ p = F.softmax(logits, dim=dim)
538
+ return -(p * torch.log(p + EPS)).sum(dim=dim)
539
+
540
+
541
+ def stage_a_loss(out: dict, kind: torch.Tensor, cell: torch.Tensor,
542
+ pct_t: torch.Tensor,
543
+ ret: Optional[torch.Tensor] = None,
544
+ win_lab: Optional[torch.Tensor] = None,
545
+ lab64: Optional[torch.Tensor] = None,
546
+ lab64_next: Optional[torch.Tensor] = None,
547
+ gate_mask: Optional[torch.Tensor] = None,
548
+ gate_valid: Optional[torch.Tensor] = None,
549
+ threat: Optional[torch.Tensor] = None,
550
+ expand: Optional[torch.Tensor] = None,
551
+ nums_next: Optional[torch.Tensor] = None,
552
+ w: Optional[torch.Tensor] = None,
553
+ lamb: dict = None) -> Tuple[torch.Tensor, dict]:
554
+ """Weighted filtered-BC + value + win + gate + seg + dynamics + econ.
555
+
556
+ `out` MUST come from forward(..., cell=cell, rtg=rtg, return_all=True)
557
+ so pct_params sit at the recorded cell. Every auxiliary CE is reduced
558
+ to a per-sample (B,) before weighting — no silent broadcasts.
559
+ """
560
+ lamb = lamb or dict(kind=0.5, cell=1.0, pct=0.25, val=0.1, win=0.1,
561
+ gate=0.5, seg=0.5, next_seg=0.4, threat=0.2,
562
+ expand=0.2, econ=0.05, ent=0.003)
563
+ B = kind.shape[0]
564
+ w = w if w is not None else torch.ones(B, device=kind.device)
565
+ terms = {}
566
+
567
+ kind_nll = F.cross_entropy(out["kind_logits"], kind, reduction="none",
568
+ label_smoothing=0.05)
569
+ # v3 review bug 2: CE((B,3,g*g),(B,)) crashes — select the taken-kind row
570
+ cell_nll = F.cross_entropy(
571
+ out["cell_logits"][torch.arange(B, device=kind.device), kind.long()],
572
+ cell.long(), reduction="none")
573
+ pct_nll = -(Beta(out["pct_params"][0], out["pct_params"][1])
574
+ .log_prob(pct_t.clamp(EPS, 1 - EPS)))
575
+ pct_nll = pct_nll * (kind != 2).float() # bank: pct undefined
576
+
577
+ loss = (lamb["kind"] * kind_nll + lamb["cell"] * cell_nll
578
+ + lamb["pct"] * pct_nll)
579
+ terms["kind_nll"] = kind_nll.mean().detach()
580
+ terms["cell_nll"] = cell_nll.mean().detach()
581
+ terms["pct_nll"] = pct_nll.mean().detach()
582
+
583
+ if ret is not None:
584
+ val_loss = F.huber_loss(out["value"], ret, reduction="none")
585
+ loss = loss + lamb["val"] * val_loss
586
+ terms["val"] = val_loss.mean().detach()
587
+
588
+ if win_lab is not None:
589
+ win_nll = F.cross_entropy(out["win_logits"], win_lab.long(),
590
+ reduction="none", label_smoothing=0.05)
591
+ loss = loss + lamb["win"] * win_nll
592
+ terms["win"] = win_nll.mean().detach()
593
+
594
+ if out["gate"] is not None and gate_mask is not None:
595
+ g_bce = F.binary_cross_entropy(out["gate"], gate_mask,
596
+ reduction="none").mean(dim=(2, 3))
597
+ g_bce = g_bce * gate_valid.float() # (B,3)
598
+ denom = gate_valid.float().sum(dim=1).clamp_min(1.0) # (B,)
599
+ gate_loss = g_bce.sum(dim=1) / denom # (B,)
600
+ loss = loss + lamb["gate"] * gate_loss
601
+ terms["gate"] = gate_loss.mean().detach()
602
+
603
+ # v3 review bug 3: seg/next_seg CE is (B,H,W) — reduce per-sample
604
+ if out["seg"] is not None and lab64 is not None:
605
+ seg_ce = F.cross_entropy(out["seg"], lab64.long(),
606
+ reduction="none").mean(dim=(1, 2))
607
+ loss = loss + lamb["seg"] * seg_ce
608
+ terms["seg"] = seg_ce.mean().detach()
609
+
610
+ if out["next_seg"] is not None and lab64_next is not None:
611
+ nseg_ce = F.cross_entropy(out["next_seg"], lab64_next.long(),
612
+ reduction="none").mean(dim=(1, 2))
613
+ loss = loss + lamb["next_seg"] * nseg_ce
614
+ terms["next_seg"] = nseg_ce.mean().detach()
615
+
616
+ if out["threat"] is not None and threat is not None:
617
+ th = F.binary_cross_entropy(out["threat"], threat,
618
+ reduction="none").mean(dim=(1, 2))
619
+ loss = loss + lamb["threat"] * th
620
+ terms["threat"] = th.mean().detach()
621
+
622
+ if out["expand"] is not None and expand is not None:
623
+ ex = F.binary_cross_entropy(out["expand"], expand,
624
+ reduction="none").mean(dim=(1, 2))
625
+ loss = loss + lamb["expand"] * ex
626
+ terms["expand"] = ex.mean().detach()
627
+
628
+ if out["econ"] is not None and nums_next is not None:
629
+ econ_l1 = F.smooth_l1_loss(out["econ"], nums_next,
630
+ reduction="none").mean(dim=1)
631
+ loss = loss + lamb["econ"] * econ_l1
632
+ terms["econ"] = econ_l1.mean().detach()
633
+
634
+ marg = torch.logsumexp(out["cell_logits"], dim=1)
635
+ ent = (cat_entropy(out["kind_logits"]) +
636
+ cat_entropy(marg) +
637
+ Beta(out["pct_params"][0], out["pct_params"][1]).entropy())
638
+ loss = loss - lamb["ent"] * ent
639
+ terms["entropy"] = ent.mean().detach()
640
+
641
+ total = (loss * w).mean()
642
+ terms["total"] = total.detach()
643
+ return total, terms
644
+
645
+
646
+ # --------------------------------------------------------------------------
647
+ # Real-shard prep (THE schema — not the v3 assumption)
648
+ # --------------------------------------------------------------------------
649
+
650
+ def downscale_labels(lab: np.ndarray, size: int) -> np.ndarray:
651
+ """(N,H,W) uint8 labels -> (N,size,size) int64 majority vote."""
652
+ N, H, W = lab.shape
653
+ gh, gw = H // size, W // size
654
+ out = np.zeros((N, size, size), dtype=np.int64)
655
+ for gy in range(size):
656
+ for gx in range(size):
657
+ blk = lab[:, gy * gh:(gy + 1) * gh, gx * gw:(gx + 1) * gw]
658
+ blk = blk.reshape(N, -1).astype(np.int64)
659
+ counts = np.stack([(blk == cls).sum(axis=1)
660
+ for cls in range(4)], axis=1)
661
+ out[:, gy, gx] = counts.argmax(axis=1)
662
+ return out
663
+
664
+
665
+ def gate_masks_from_lab(lab64: np.ndarray, adj: int = 2
666
+ ) -> Tuple[np.ndarray, np.ndarray]:
667
+ """(N,3,64,64) kind0/1 legality masks + (N,3) validity (kind2 invalid)."""
668
+ N, S, _ = lab64.shape
669
+ masks = np.zeros((N, 3, S, S), dtype=np.float32)
670
+ valid = np.zeros((N, 3), dtype=np.float32)
671
+ valid[:, 0] = 1.0
672
+ valid[:, 1] = 1.0
673
+ me = lab64 == 2
674
+ enemy = lab64 == 3
675
+ neutral = lab64 == 1
676
+ pad = adj
677
+ mp = np.pad(me, ((0, 0), (pad, pad), (pad, pad)), mode="constant")
678
+ d = np.zeros_like(me)
679
+ for dy in range(-pad, pad + 1):
680
+ for dx in range(-pad, pad + 1):
681
+ d |= mp[:, pad + dy:pad + dy + S, pad + dx:pad + dx + S]
682
+ masks[:, 0] = (d & neutral).astype(np.float32)
683
+ masks[:, 1] = enemy.astype(np.float32)
684
+ return masks, valid
685
+
686
+
687
+ def prep_shard(d: dict, max_frames: int = 32,
688
+ gate_size: int = 64) -> dict:
689
+ """Prep the REAL shard schema into training tensors.
690
+
691
+ Key schema facts (the v3 adapter got these wrong — fixed here):
692
+ reward is PER TICK (len = sum(lens) = 2 x frames),
693
+ alive is PER EPISODE, lens counts TICKS (frames per ep = lens//2).
694
+ """
695
+ rgb = d["rgb"]
696
+ lab = d["lab"]
697
+ nums = d["nums"].astype(np.float32)
698
+ kind = d["kind"].astype(np.int64)
699
+ cell = d["cell"].astype(np.int64)
700
+ pct = d["pct"].astype(np.float32)
701
+ reward = d["reward"].astype(np.float32)
702
+ alive = d["alive"].astype(np.int64)
703
+ lens = d["lens"].astype(np.int64)
704
+ N = len(rgb)
705
+
706
+ assert len(reward) == int(lens.sum()), \
707
+ "reward must be PER TICK with len == sum(lens)"
708
+ flens = (lens // 2).tolist() # frames per episode
709
+ diff = N - sum(flens)
710
+ if diff != 0:
711
+ flens[-1] += diff # absorb odd-tick rounding
712
+ assert sum(flens) == N
713
+
714
+ # per-frame reward: each frame covers 2 ticks. Episodes with ODD tick
715
+ # counts leave one stray final tick — it folds into that episode's
716
+ # last frame. The rounding case (diff absorbed into the last episode)
717
+ # is padded with zeros so the reshape can never fail on any shard.
718
+ fr = np.zeros(N, dtype=np.float32)
719
+ tpos, fpos = 0, 0
720
+ for e, Lt in enumerate(lens.tolist()):
721
+ fl = flens[e]
722
+ ep_ticks = np.concatenate([
723
+ reward[tpos:tpos + Lt],
724
+ np.zeros(max(0, 2 * fl - Lt), dtype=np.float32)])
725
+ fr[fpos:fpos + fl] = ep_ticks[:2 * fl].reshape(fl, 2).sum(axis=1)
726
+ if Lt % 2 == 1:
727
+ fr[fpos + fl - 1] += ep_ticks[2 * fl]
728
+ tpos += Lt
729
+ fpos += fl
730
+
731
+ # per-frame alive (episode survival broadcast over its frames)
732
+ frame_alive = np.concatenate(
733
+ [np.full(fl, int(alive[e]), dtype=bool)
734
+ for e, fl in enumerate(flens)])[:N]
735
+
736
+ # per-episode MC returns over frames (gamma^1 per frame step)
737
+ ret = np.zeros(N, dtype=np.float32)
738
+ s = 0
739
+ for fl in flens:
740
+ e = s + fl
741
+ r = fr[s:e].copy()
742
+ if frame_alive[e - 1]:
743
+ r[-1] += 2.0 # survival bonus (shaping-agnostic)
744
+ acc = 0.0
745
+ for i in range(e - 1, s - 1, -1):
746
+ acc = r[i - s] + 0.995 * acc
747
+ ret[i] = acc
748
+ s = e
749
+ ret = (ret - ret.mean()) / max(ret.std(), 1e-6)
750
+ rtg = ret[:, None].astype(np.float32)
751
+
752
+ # next-frame targets (episode-safe shifts)
753
+ rtg_next = np.empty_like(rtg)
754
+ nums_next = np.empty_like(nums)
755
+ s = 0
756
+ for fl in flens:
757
+ e = s + fl
758
+ rtg_next[s:e - 1] = rtg[s + 1:e]
759
+ rtg_next[e - 1] = rtg[e - 1]
760
+ nums_next[s:e - 1] = nums[s + 1:e]
761
+ nums_next[e - 1] = nums[e - 1]
762
+ s = e
763
+ econ_t = np.concatenate([nums_next, rtg_next], axis=1) # (N,9)
764
+
765
+ win_lab = frame_alive.astype(np.int64)
766
+
767
+ # AWR weights: exp(return) clip + survivor x4 + kill-window x2
768
+ w = np.clip(np.exp(ret / 1.0), 0.2, 8.0).astype(np.float32)
769
+ w *= (1.0 + 3.0 * frame_alive.astype(np.float32))
770
+ dk = np.diff(nums[:, 7], prepend=nums[0, 7] - 0.0)
771
+ for i in np.where(dk > 1e-3)[0]:
772
+ w[max(0, i - 2):min(N, i + 3)] *= 2.0
773
+ w = w / (w.mean() + 1e-8)
774
+
775
+ # label-derived supervision (lab64, next, intent mirrors, gate)
776
+ lab64 = downscale_labels(lab, gate_size)
777
+ lab_next = np.empty_like(lab)
778
+ lab_next[:-1] = lab[1:]
779
+ lab_next[-1] = lab[-1]
780
+ s = 0
781
+ for fl in flens:
782
+ lab_next[s + fl - 1] = lab[s + fl - 1] # episode boundary repeats
783
+ s += fl
784
+ lab64_next = downscale_labels(lab_next, gate_size)
785
+ threat = ((lab64_next == 3) & (lab64 != 3)).astype(np.float32)
786
+ expand = ((lab64 == 1) & (lab64_next == 2)).astype(np.float32)
787
+ gmask, gvalid = gate_masks_from_lab(lab64)
788
+
789
+ sel = np.arange(min(max_frames, N))
790
+ t = torch.from_numpy
791
+ return dict(
792
+ rgb=t(rgb[sel].astype(np.float32).transpose(0, 3, 1, 2) / 255.0),
793
+ nums=t(nums[sel]), rtg=t(rtg[sel]),
794
+ kind=t(kind[sel]), cell=t(cell[sel]), pct=t(pct[sel]),
795
+ ret=t(ret[sel]), win_lab=t(win_lab[sel]), w=t(w[sel]),
796
+ lab64=t(lab64[sel]), lab64_next=t(lab64_next[sel]),
797
+ threat=t(threat[sel]), expand=t(expand[sel]),
798
+ gate_mask=t(gmask[sel]), gate_valid=t(gvalid[sel]),
799
+ econ_t=t(econ_t[sel]),
800
+ n_sel=len(sel), real_episodes=len(lens), real_frames=N,
801
+ )
802
+
803
+
804
+ # --------------------------------------------------------------------------
805
+ # Utils
806
+ # --------------------------------------------------------------------------
807
+
808
+ def count_params(net: nn.Module) -> int:
809
+ return sum(p.numel() for p in net.parameters())
810
+
811
+
812
+ def param_breakdown(net: Nano) -> dict:
813
+ n = lambda m: sum(p.numel() for p in m.parameters())
814
+ spine = n(net.stem) + sum(n(m) for m in net.enc) + n(net.film)
815
+ decoder = sum(n(m) for m in net.dec)
816
+ heads = (n(net.heat_mixer) + n(net.heat_tower) +
817
+ n(net.proj) + n(net.kind_head) + n(net.value_head) +
818
+ n(net.win_head) + n(net.pct_head))
819
+ if net.cfg.use_econ:
820
+ heads += n(net.econ_head)
821
+ if net.cfg.use_gate:
822
+ heads += n(net.gate_head)
823
+ if net.cfg.use_aux_seg:
824
+ heads += n(net.seg_head)
825
+ if net.cfg.use_dynamics:
826
+ heads += (n(net.next_seg_head) + n(net.threat_head) +
827
+ n(net.expand_head))
828
+ if net.cfg.use_cell_value:
829
+ heads += n(net.cell_value)
830
+ mem = (n(net.mem) + n(net.mem_proj)) if net.mem is not None else 0
831
+ return dict(spine=spine, decoder=decoder, heads=heads, mem=mem)
832
+
833
+
834
+ def make_nano(cfg: Optional[NanoConfig] = None,
835
+ seed: Optional[int] = None) -> Nano:
836
+ if seed is not None:
837
+ torch.manual_seed(seed)
838
+ return Nano(cfg)
839
+
840
+
841
+ def load_real_shard():
842
+ """Try the REAL shard first (via $HF_TOKEN), then a local copy."""
843
+ for candidate in ("shard_v2_1786707781_27_23.npz",
844
+ "rl/shards_v2/shard_v2_1786707781_27_23.npz"):
845
+ if os.path.exists(candidate):
846
+ return np.load(candidate)
847
+ try:
848
+ from huggingface_hub import hf_hub_download
849
+ token = os.environ.get("HF_TOKEN")
850
+ if not token:
851
+ return None
852
+ path = hf_hub_download(HF_DATASET, HF_SHARD, repo_type="dataset",
853
+ token=token, local_dir="/tmp/nano_real")
854
+ return np.load(path)
855
+ except Exception as e:
856
+ print(f"real shard fetch failed ({str(e)[:100]}) — using replica")
857
+ return None
858
+
859
+
860
+ def synthetic_shard() -> dict:
861
+ """Schema-EXACT replica (per-episode alive/lens in ticks, per-tick
862
+ reward) — used ONLY when the real shard cannot be fetched."""
863
+ rng = np.random.default_rng(0)
864
+ lens = np.array([120, 140, 160, 288], dtype=np.int64) # ticks
865
+ flens = (lens // 2).tolist()
866
+ N = sum(flens)
867
+ alive = np.array([1, 0, 1, 0], dtype=np.int64)
868
+ reward = rng.uniform(0.003, 0.012, size=int(lens.sum())).astype(
869
+ np.float32)
870
+ # death terminal (-1.3) on the last tick of dead episodes
871
+ pos = 0
872
+ for e, L in enumerate(lens.tolist()):
873
+ if alive[e] == 0:
874
+ reward[pos + L - 1] = -1.3
875
+ pos += L
876
+ lab = np.zeros((N, 256, 256), dtype=np.uint8)
877
+ lab[:, 10:200, 10:200] = 1
878
+ lab[:, 20:80, 20:80] = 2
879
+ lab[:, 100:180, 100:180] = 3
880
+ rgb = (lab[:, :, :, None].astype(np.float32) * 40).astype(np.uint8)
881
+ rgb = np.concatenate([rgb] * 3, axis=-1)
882
+ nums = rng.uniform(0.05, 0.6, size=(N, 8)).astype(np.float32)
883
+ nums[:, 7] = np.floor(np.linspace(0, 3, N)) # kills channel
884
+ kind = rng.integers(0, 3, size=N).astype(np.int64)
885
+ cell = rng.integers(0, 1024, size=N).astype(np.int64)
886
+ pct = rng.uniform(0.1, 0.6, size=N).astype(np.float32)
887
+ logp = np.full(N, -2.0, dtype=np.float32)
888
+ return dict(rgb=rgb, lab=lab, nums=nums, kind=kind, cell=cell, pct=pct,
889
+ logp=logp, reward=reward, alive=alive, lens=lens)
890
+
891
+
892
+ # --------------------------------------------------------------------------
893
+ # Smoke test (the pipeline runs this on the REAL shard)
894
+ # --------------------------------------------------------------------------
895
+
896
+ if __name__ == "__main__":
897
+ print("== SOVEREIGN-nano smoke ==")
898
+ torch.manual_seed(0)
899
+
900
+ net = make_nano()
901
+ total = count_params(net)
902
+ bd = param_breakdown(net)
903
+ assert total <= PARAM_LIMIT, f"{total:,} > {PARAM_LIMIT:,}"
904
+ print(f"params: {total:,} (limit {PARAM_LIMIT:,}) — PASS "
905
+ f"[spine {bd['spine']:,} | decoder {bd['decoder']:,} | "
906
+ f"heads {bd['heads']:,} | mem {bd['mem']:,}]")
907
+
908
+ d = load_real_shard()
909
+ if d is not None:
910
+ print("REAL shard loaded — schema asserts below:")
911
+ print(f" keys: {sorted(k for k in d.keys())}")
912
+ print(f" rgb {d['rgb'].shape} | reward {d['reward'].shape} "
913
+ f"(per tick) | alive {d['alive'].shape} (per ep) | "
914
+ f"lens {d['lens'].shape} sum={int(d['lens'].sum())}")
915
+ else:
916
+ print("WARN: real shard unavailable (no HF_TOKEN/network) — "
917
+ "building a schema-EXACT synthetic replica")
918
+ d = synthetic_shard()
919
+
920
+ prep = prep_shard(d, max_frames=16)
921
+ B = prep["n_sel"]
922
+ print(f"prep: {B} frames from {prep['real_episodes']} episodes "
923
+ f"({prep['real_frames']} total frames in shard)")
924
+
925
+ # forward (contract)
926
+ click, kind_logits, pct, value = net(prep["rgb"], prep["nums"],
927
+ rtg=prep["rtg"])
928
+ assert click.shape == (B, 1024), click.shape
929
+ assert kind_logits.shape == (B, 3), kind_logits.shape
930
+ assert pct.shape == (B,), pct.shape
931
+ assert value.shape == (B,), value.shape
932
+ print(f"forward: {tuple(click.shape)} {tuple(kind_logits.shape)} "
933
+ f"{tuple(pct.shape)} {tuple(value.shape)} — PASS")
934
+
935
+ # act at B=4 — logprob/entropy MUST be (B,)
936
+ a = net.act(prep["rgb"][:4], prep["nums"][:4], rtg=prep["rtg"][:4])
937
+ assert a["logprob"].shape == (4,), a["logprob"].shape
938
+ assert a["entropy"].shape == (4,), a["entropy"].shape
939
+ print(f"act(B=4): logprob {tuple(a['logprob'].shape)} | entropy "
940
+ f"{tuple(a['entropy'].shape)} | state "
941
+ f"{tuple(a['state'].shape)} — PASS")
942
+
943
+ # stage-a loss + backward
944
+ out = net(prep["rgb"], prep["nums"], rtg=prep["rtg"],
945
+ cell=prep["cell"], return_all=True)
946
+ loss, terms = stage_a_loss(
947
+ out, prep["kind"], prep["cell"], prep["pct"],
948
+ ret=prep["ret"], win_lab=prep["win_lab"],
949
+ lab64=prep["lab64"], lab64_next=prep["lab64_next"],
950
+ gate_mask=prep["gate_mask"], gate_valid=prep["gate_valid"],
951
+ threat=prep["threat"], expand=prep["expand"],
952
+ nums_next=prep["econ_t"], w=prep["w"])
953
+ assert torch.isfinite(loss), "non-finite loss"
954
+ loss.backward()
955
+ print(f"loss: {loss.item():.4f} — backward OK — PASS")
956
+ print("terms: " + " ".join(f"{k}={float(v):.3f}" for k, v in
957
+ terms.items()))
958
+ print("NANO SMOKE OK")