hdpbilly commited on
Commit
7ebf280
·
0 Parent(s):

Initial release of STOK-SpatialNet-v3: model weights, config, and inference

Browse files
Files changed (5) hide show
  1. .gitattributes +1 -0
  2. README.md +109 -0
  3. config.json +60 -0
  4. model.safetensors +0 -0
  5. modeling_stok.py +197 -0
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ language:
3
+ - en
4
+ license: mit
5
+ library_name: generic
6
+ tags:
7
+ - reinforcement-learning
8
+ - reward-free-rl
9
+ - state-time-option-kernels
10
+ - stok
11
+ - game-ai
12
+ - spatial-attention
13
+ - theory-of-mind
14
+ - empowerment
15
+ - simultaneous-turn
16
+ pipeline_tag: tabular-classification
17
+ ---
18
+
19
+ # STOK-SpatialNet-v3: A Reward-Free Spatial-Attention Amortizer
20
+
21
+ **STOK-SpatialNet-v3** is a compact, multi-task spatial neural network for **reward-free reinforcement learning and simultaneous-turn planning**.
22
+
23
+ Instead of regressing a single scalar reward or value function, it computes the quantities of the **State-Time Option Kernel (STOK)** framework ([Ringstrom & Schrater, 2025](https://arxiv.org/abs/2506.09499)):
24
+ - **Feasibility ($\hat{\kappa}$)**: Probability of reaching a target goal without constraint violation.
25
+ - **Time Urgency ($\hat{\eta}$)**: Arrival time distribution $1 / (1 + \mathbb{E}[t_f])$.
26
+ - **Valence ($\Delta\hat{E}$)**: Signed change in action-space empowerment (preserving future degrees of freedom).
27
+ - **Survivability ($\hat{\text{lose}}$)**: Predicted adversarial lose rate.
28
+ - **Theory of Mind**: Softmax distributions predicting the opponent's active tactical concept and goal family.
29
+
30
+ ---
31
+
32
+ ## Architecture: `spatial-attn2`
33
+
34
+ The network operates on a 2D spatial field with relational goal conditioning:
35
+
36
+ ```text
37
+ Board Cells (T tiles x 39 ch) ──► Embed (39 → 32) + ReLU
38
+ │
39
+ ▼
40
+ Tile ⟷ Tile Self-Attention (Residual)
41
+ │
42
+ ▼
43
+ Relational Goal (8 dims) ───────► Goal-Query Cross-Attention
44
+ │
45
+ ▼
46
+ Context Vector (32) ─────┐
47
+ High-level Features (80) ┼──────► Dense Trunk (138 → 96) + ReLU
48
+ Intent History (18) ─────┘ │
49
+ ▼
50
+ 37 Output Heads (96 → 37)
51
+ ```
52
+
53
+ ### Input Channels (39 per tile)
54
+ 1. **Geometry**: Normalized absolute coordinates, viewer- and opponent-relative offsets, Chebyshev distances.
55
+ 2. **Occupancy & Stats**: Self/ally/enemy presence, normalized HP.
56
+ 3. **Terrain & Content**: Substrate, walls, rocks, hazardous elements, blocking flags.
57
+ 4. **Path-Aware Reachability**: BFS arrival steps for both agents under dynamic obstacle rules.
58
+ 5. **Plan Grounding**: Footprints of candidate movement, strikes, and terrain alterations.
59
+
60
+ ### 37 Multi-Task Output Heads
61
+ - **Heads 0–3 (The Core Quartet)**: Feasibility $\hat{\kappa}$, urgency $\hat{\eta}$, signed valence $\Delta\hat{E}$, survivability $\hat{\text{lose}}$.
62
+ - **Heads 4–8 (Goal-Setting Feasibilities)**: Predicted feasibility for 5 goal families: `Strike`, `Engage`, `Evade`, `Turtle`, `Zone`.
63
+ - **Heads 9–11 (Arrival ETA Histogram)**: Softmax distribution over $[\text{kill@t1}, \text{kill@t2}, \text{not in window}]$.
64
+ - **Heads 12–16 (Opponent Goal Prior)**: Predicted distribution over which goal the opponent will execute this turn.
65
+ - **Heads 17–18 (Decomposed Valence)**: Own option-capacity growth vs denial of opponent option-capacity.
66
+ - **Heads 19–26 (Tactical Concept Tagging)**: Distribution over 8 human-annotated tactical concepts (`trap`, `spacing`, `punish`, `bait`, `coverage`, `conversion`, `denial`, `tempo`).
67
+ - **Heads 27–34 (Theory of Mind)**: Predicted tactical concept the **adversary** is currently playing.
68
+ - **Heads 35–36 (Trajectory Valence)**: Long-horizon realized empowerment flow ($V_{\text{traj}}$, $V_{\text{past}}$).
69
+
70
+ ---
71
+
72
+ ## Quickstart (Zero External Dependencies)
73
+
74
+ Requires only standard `numpy`. No PyTorch or C++ runtime required.
75
+
76
+ ```python
77
+ import numpy as np
78
+ from modeling_stok import STOKSpatialAmortizer
79
+
80
+ # Load model from local files or Hugging Face Hub
81
+ model = STOKSpatialAmortizer.from_pretrained(".")
82
+
83
+ # Prepare inputs for a 10x10 grid (100 tiles x 39 channels)
84
+ spatial_field = np.zeros((100, 39), dtype=np.float32)
85
+ goal_vector = np.zeros(8, dtype=np.float32) # 5-way one-hot + (dx, dz, has_target)
86
+ goal_vector[0] = 1.0 # Strike goal
87
+ flat_features = np.zeros(106, dtype=np.float32) # High-level features + goal + history
88
+
89
+ # Forward inference
90
+ pred = model.forward(spatial_field, goal_vector, flat_features)
91
+
92
+ print(f"Goal Feasibility (kappa): {pred.kappa:.4f}")
93
+ print(f"Empowerment Valence (dE): {pred.valence:.4f}")
94
+ print(f"Expected time-to-goal: {pred.expected_time():.2f} turns")
95
+ print(f"Opponent Intent (Argmax): {max(pred.opp_concept.items(), key=lambda kv: kv[1])}")
96
+ ```
97
+
98
+ ---
99
+
100
+ ## Citation & References
101
+
102
+ ```bibtex
103
+ @article{ringstrom2025stok,
104
+ title = {A Unified Theory of Compositionality, Modularity, and Interpretability in Markov Decision Processes},
105
+ author = {Ringstrom, Tyler and Schrater, Paul},
106
+ journal = {arXiv preprint arXiv:2506.09499},
107
+ year = {2025}
108
+ }
109
+ ```
config.json ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "STOKSpatialAmortizer"
4
+ ],
5
+ "model_type": "stok_spatial_amortizer",
6
+ "spatial_channels": 39,
7
+ "attn_dim": 32,
8
+ "goal_dim": 8,
9
+ "flat_dim": 106,
10
+ "hidden_dim": 96,
11
+ "num_heads": 37,
12
+ "config_digest": "daf428ff935666b8",
13
+ "goal_families": [
14
+ "Strike",
15
+ "Engage",
16
+ "Evade",
17
+ "Turtle",
18
+ "Zone"
19
+ ],
20
+ "tactical_concepts": [
21
+ "trap",
22
+ "spacing",
23
+ "punish",
24
+ "bait",
25
+ "coverage",
26
+ "conversion",
27
+ "denial",
28
+ "tempo"
29
+ ],
30
+ "head_slices": {
31
+ "kappa": 0,
32
+ "urgency": 1,
33
+ "valence": 2,
34
+ "lose": 3,
35
+ "goal_kappa": [
36
+ 4,
37
+ 9
38
+ ],
39
+ "eta": [
40
+ 9,
41
+ 12
42
+ ],
43
+ "opp_prior": [
44
+ 12,
45
+ 17
46
+ ],
47
+ "valence_own": 17,
48
+ "valence_deny": 18,
49
+ "concept": [
50
+ 19,
51
+ 27
52
+ ],
53
+ "opp_concept": [
54
+ 27,
55
+ 35
56
+ ],
57
+ "v_traj": 35,
58
+ "v_past": 36
59
+ }
60
+ }
model.safetensors ADDED
Binary file (96.4 kB). View file
 
modeling_stok.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ STOK-SpatialNet-v3: State-Time Option Kernel Spatial-Attention Amortizer
3
+ Reference: Ringstrom & Schrater (2025), arXiv:2506.09499
4
+
5
+ Zero-dependency Python / NumPy implementation of the spatial amortizer.
6
+ Compatible with PyTorch if available, but requires only standard NumPy.
7
+ """
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Dict, List, Optional, Union
11
+ import json
12
+ import os
13
+ import struct
14
+ import numpy as np
15
+
16
+
17
+ @dataclass
18
+ class AmortizedPrediction:
19
+ """Structured predictions from the 37 output heads of STOK-SpatialNet."""
20
+ kappa: float # Feasibility in [0, 1]
21
+ urgency: float # Time-to-kill urgency: 1 / (1 + E[t_f])
22
+ valence: float # Normalized signed empowerment change: delta_E / 32
23
+ lose: float # Predicted lose rate (survivability constraint)
24
+ goal_kappa: Dict[str, float] # Feasibility per goal family (Strike, Engage, Evade, Turtle, Zone)
25
+ eta: List[float] # [kill@t1, kill@t2, not-in-window] probability mass
26
+ opp_prior: Dict[str, float] # Predicted distribution over opponent goal family
27
+ valence_own: float # Decomposed own option-capacity growth
28
+ valence_deny: float # Decomposed denial of opponent option-capacity
29
+ concept: Dict[str, float] # Tactical concept distribution (trap, spacing, punish, ...)
30
+ opp_concept: Dict[str, float] # Theory of Mind: predicted opponent concept distribution
31
+ v_traj: float # Long-horizon realized trajectory empowerment flow
32
+ v_past: float # Inherited past trajectory flow
33
+
34
+ def expected_time(self) -> float:
35
+ """Expected time-to-kill under eta, with not-in-window priced at 8 turns."""
36
+ return self.eta[0] + 2.0 * self.eta[1] + 8.0 * self.eta[2]
37
+
38
+
39
+ class STOKSpatialAmortizer:
40
+ """
41
+ Spatial-Attention Amortizer for State-Time Option Kernels (STOK).
42
+
43
+ Architecture:
44
+ 1. Tile embedding (channels=39 -> attn=32) + ReLU
45
+ 2. Residual tile-to-tile single-head self-attention
46
+ 3. Goal-query cross-attention read (query=8 -> keys/values=32)
47
+ 4. Dense trunk on [flat (106) || context (32)] -> hidden (96) + ReLU
48
+ 5. Multi-head projection (37 heads)
49
+ """
50
+
51
+ def __init__(self, weights: Dict[str, np.ndarray], config: dict):
52
+ self.weights = weights
53
+ self.config = config
54
+ self.channels = config.get("spatial_channels", 39)
55
+ self.attn = config.get("attn_dim", 32)
56
+ self.goal_dim = config.get("goal_dim", 8)
57
+ self.flat_dim = config.get("flat_dim", 106)
58
+ self.hidden = config.get("hidden_dim", 96)
59
+ self.heads = config.get("num_heads", 37)
60
+ self.goal_families = config.get(
61
+ "goal_families", ["Strike", "Engage", "Evade", "Turtle", "Zone"]
62
+ )
63
+ self.concepts = config.get(
64
+ "tactical_concepts",
65
+ ["trap", "spacing", "punish", "bait", "coverage", "conversion", "denial", "tempo"],
66
+ )
67
+
68
+ @classmethod
69
+ def from_pretrained(cls, model_dir_or_path: str):
70
+ """Load model from a directory containing config.json and model.safetensors."""
71
+ if os.path.isfile(model_dir_or_path):
72
+ model_dir = os.path.dirname(model_dir_or_path)
73
+ weights_path = model_dir_or_path
74
+ else:
75
+ model_dir = model_dir_or_path
76
+ weights_path = os.path.join(model_dir, "model.safetensors")
77
+
78
+ config_path = os.path.join(model_dir, "config.json")
79
+ with open(config_path, "r", encoding="utf-8") as f:
80
+ config = json.load(f)
81
+
82
+ weights = cls._load_safetensors(weights_path)
83
+ return cls(weights, config)
84
+
85
+ @staticmethod
86
+ def _load_safetensors(path: str) -> Dict[str, np.ndarray]:
87
+ with open(path, "rb") as f:
88
+ header_len = struct.unpack("<Q", f.read(8))[0]
89
+ header_json = f.read(header_len).decode("utf-8")
90
+ header = json.loads(header_json)
91
+ raw_data = f.read()
92
+
93
+ tensors = {}
94
+ for name, meta in header.items():
95
+ if name == "__metadata__":
96
+ continue
97
+ start, end = meta["data_offsets"]
98
+ shape = meta["shape"]
99
+ dtype = meta["dtype"]
100
+ assert dtype == "F32", f"Unsupported dtype: {dtype}"
101
+ arr = np.frombuffer(raw_data[start:end], dtype=np.float32).reshape(shape)
102
+ tensors[name] = arr
103
+ return tensors
104
+
105
+ @staticmethod
106
+ def _softmax(x: np.ndarray, axis: int = -1) -> np.ndarray:
107
+ e_x = np.exp(x - np.max(x, axis=axis, keepdims=True))
108
+ return e_x / np.sum(e_x, axis=axis, keepdims=True)
109
+
110
+ def forward(
111
+ self,
112
+ spatial_field: np.ndarray,
113
+ goal_vector: np.ndarray,
114
+ flat_features: np.ndarray,
115
+ ) -> AmortizedPrediction:
116
+ """
117
+ Run forward inference.
118
+
119
+ Args:
120
+ spatial_field: Array of shape [num_tiles, 39] (e.g. 100 tiles for 10x10).
121
+ goal_vector: Array of shape [8] (5-way one-hot + [dx, dz, has_target]).
122
+ flat_features: Array of shape [106] (high-level features + goal + history).
123
+
124
+ Returns:
125
+ AmortizedPrediction with all 37 heads decoded and labeled.
126
+ """
127
+ assert spatial_field.shape[-1] == self.channels
128
+ assert goal_vector.shape[-1] == self.goal_dim
129
+ assert flat_features.shape[-1] == self.flat_dim
130
+
131
+ tiles = spatial_field.astype(np.float32)
132
+ scale = np.sqrt(self.attn, dtype=np.float32)
133
+
134
+ # 1. Tile Embedding: [T, 39] @ W_embed.T + b -> [T, 32]
135
+ h0 = np.maximum(0.0, np.dot(tiles, self.weights["embed.weight"].T) + self.weights["embed.bias"])
136
+
137
+ # 2. Residual Tile-to-Tile Self-Attention
138
+ q = np.dot(h0, self.weights["sa_q.weight"].T) + self.weights["sa_q.bias"]
139
+ k = np.dot(h0, self.weights["sa_k.weight"].T) + self.weights["sa_k.bias"]
140
+ v = np.dot(h0, self.weights["sa_v.weight"].T) + self.weights["sa_v.bias"]
141
+
142
+ attn_scores = np.dot(q, k.T) / scale # [T, T]
143
+ attn_weights = self._softmax(attn_scores, axis=-1) # [T, T]
144
+ h = h0 + np.dot(attn_weights, v) # [T, 32]
145
+
146
+ # 3. Goal-Query Cross-Attention Read
147
+ gq = np.dot(goal_vector, self.weights["query.weight"].T) + self.weights["query.bias"] # [32]
148
+ k2 = np.dot(h, self.weights["key.weight"].T) + self.weights["key.bias"] # [T, 32]
149
+ v2 = np.dot(h, self.weights["value.weight"].T) + self.weights["value.bias"] # [T, 32]
150
+
151
+ read_scores = np.dot(k2, gq) / scale # [T]
152
+ read_weights = self._softmax(read_scores, axis=-1) # [T]
153
+ ctx = np.dot(read_weights, v2) # [32]
154
+
155
+ # 4. Dense Trunk
156
+ x = np.concatenate([flat_features, ctx], axis=-1) # [138]
157
+ hid = np.maximum(0.0, np.dot(x, self.weights["l1.weight"].T) + self.weights["l1.bias"]) # [96]
158
+ out = np.dot(hid, self.weights["l2.weight"].T) + self.weights["l2.bias"] # [37]
159
+
160
+ # 5. Decode Heads
161
+ kappa = float(out[0])
162
+ urgency = float(out[1])
163
+ valence = float(out[2])
164
+ lose = float(out[3])
165
+
166
+ goal_kappa = {fam: float(out[4 + i]) for i, fam in enumerate(self.goal_families)}
167
+ eta = self._softmax(out[9:12]).tolist()
168
+ opp_prior_vals = self._softmax(out[12:17])
169
+ opp_prior = {fam: float(opp_prior_vals[i]) for i, fam in enumerate(self.goal_families)}
170
+
171
+ valence_own = float(out[17])
172
+ valence_deny = float(out[18])
173
+
174
+ concept_vals = self._softmax(out[19:27])
175
+ concept = {c: float(concept_vals[i]) for i, c in enumerate(self.concepts)}
176
+
177
+ opp_concept_vals = self._softmax(out[27:35])
178
+ opp_concept = {c: float(opp_concept_vals[i]) for i, c in enumerate(self.concepts)}
179
+
180
+ v_traj = float(out[35])
181
+ v_past = float(out[36])
182
+
183
+ return AmortizedPrediction(
184
+ kappa=kappa,
185
+ urgency=urgency,
186
+ valence=valence,
187
+ lose=lose,
188
+ goal_kappa=goal_kappa,
189
+ eta=eta,
190
+ opp_prior=opp_prior,
191
+ valence_own=valence_own,
192
+ valence_deny=valence_deny,
193
+ concept=concept,
194
+ opp_concept=opp_concept,
195
+ v_traj=v_traj,
196
+ v_past=v_past,
197
+ )