samyakbayar commited on
Commit
d3a24e0
·
verified ·
1 Parent(s): 5de67f7

Upload 29 files

Browse files
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ipynb_checkpoints/
7
+ *Scaffold.html
8
+ *.html
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wayfinder Team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,3 +1,61 @@
1
- ---
2
- license: apache-2.0
3
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ARC-AGI-3 Wayfinder Agent
2
+
3
+ Competition agent for the **ARC Prize 2026 — ARC-AGI-3 track** (Kaggle Code Competition).
4
+
5
+ ## Architecture
6
+
7
+ The agent uses a **hybrid world-model + planning** architecture combining four
8
+ modules mapped to the benchmark's core capabilities:
9
+
10
+ | Module | Role | Capability |
11
+ |--------|------|------------|
12
+ | Perception Encoder | CNN over one-hot 64×64×16 frames → compact latent | — |
13
+ | World/Transition Model | Self-supervised P(frame changes | state, action) + forward model | Modeling |
14
+ | State Memory Graph | Hash-deduplicated directed graph of observed states | Exploration |
15
+ | Intrinsic Reward | Extrinsic Δscore + graph novelty + prediction-error curiosity | Goal-setting |
16
+ | Planner | Short-horizon tree search using world model as simulator | Planning |
17
+ | Action Head | Hierarchical: action-type softmax + conv coordinate head for ACTION6 | — |
18
+
19
+ ## Quick Start
20
+
21
+ ```bash
22
+ # Install
23
+ uv pip install -e ".[dev]"
24
+
25
+ # Run against a public game
26
+ uv run main.py --agent=wayfinder --game=ls20
27
+
28
+ # Run tests
29
+ uv run pytest
30
+
31
+ # Offline evaluation
32
+ uv run python eval/run_local_eval.py --agent=wayfinder --games=ls20,ls21,ls22
33
+ ```
34
+
35
+ ## Repository Layout
36
+
37
+ ```
38
+ agents/wayfinder/ # Core agent modules (perception, world_model, memory_graph, ...)
39
+ training/ # Replay buffer, training loops, configs
40
+ eval/ # Offline evaluation harness, metrics
41
+ notebooks/ # Kaggle submission notebook
42
+ tests/ # Unit + integration tests
43
+ ```
44
+
45
+ ## Key Constraints
46
+
47
+ - **No internet at inference time** — Kaggle scoring sessions disable network access.
48
+ - **MIT/CC0 license** — all authored code; third-party deps must be permissively licensed.
49
+ - **Action budget** — agent self-terminates stuck levels (~5× human median actions).
50
+ - **No per-game hardcoding** — same code runs against all unseen games.
51
+
52
+ ## Reproducing Results
53
+
54
+ 1. Install dependencies: `uv pip install -e ".[dev]"`
55
+ 2. Download public games via the SDK's local mode.
56
+ 3. Run evaluation: `uv run python eval/run_local_eval.py --agent=wayfinder`
57
+ 4. Results are logged to `eval/results/` with per-game/level breakdowns.
58
+
59
+ ## License
60
+
61
+ MIT — see [LICENSE](LICENSE).
agents/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """ARC-AGI-3 agent package."""
agents/wayfinder/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Wayfinder agent for ARC-AGI-3.
2
+
3
+ A hybrid world-model + planning agent that combines:
4
+ - A learned perception encoder (CNN)
5
+ - A self-supervised world/transition model
6
+ - A hash-deduplicated state memory graph
7
+ - An intrinsic reward module (extrinsic + novelty + curiosity)
8
+ - A short-horizon tree-search planner
9
+ - A hierarchical action head (type selection + coordinate prediction)
10
+ """
11
+
12
+ from agents.wayfinder.agent import WayfinderAgent
13
+
14
+ __all__ = ["WayfinderAgent"]
15
+ __version__ = "0.1.0"
agents/wayfinder/action_head.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Action head — Module F.
2
+
3
+ Hierarchical action selection: first choose an action type (RESET,
4
+ ACTION1–ACTION5, ACTION6), then for ACTION6 choose a click coordinate
5
+ via a conv-based coordinate head producing a 64×64 heatmap.
6
+
7
+ The action type is selected via softmax over per-type change-probabilities
8
+ (from the world model) with epsilon-greedy exploration. The coordinate
9
+ head uses a small CNN to produce a spatial heatmap over the 64×64 grid.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+
16
+ import numpy as np
17
+ import torch
18
+ import torch.nn as nn
19
+ import torch.nn.functional as F
20
+
21
+ from agents.wayfinder.world_model import WorldModel
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ GRID_SIZE = 64
26
+ NUM_COLORS = 16
27
+
28
+
29
+ class CoordinateHead(nn.Module):
30
+ """Conv-based coordinate prediction head for ACTION6.
31
+
32
+ Takes the one-hot frame + latent as input and produces a 64×64
33
+ heatmap over click targets. This is more sample-efficient than a
34
+ flat 4096-way softmax because it exploits spatial structure.
35
+
36
+ Architecture:
37
+ Input: (one_hot[16, 64, 64] + latent_tile[1, 64, 64]) → 17 channels
38
+ Conv2d(17, 32, 3, padding=1) → ReLU
39
+ Conv2d(32, 16, 3, padding=1) → ReLU
40
+ Conv2d(16, 1, 1) → Sigmoid → 64×64 heatmap
41
+ """
42
+
43
+ def __init__(self, latent_dim: int = 256) -> None:
44
+ """Initialize the coordinate head.
45
+
46
+ Args:
47
+ latent_dim: Dimension of the input latent vector.
48
+ """
49
+ super().__init__()
50
+ self.latent_dim = latent_dim
51
+
52
+ self.conv = nn.Sequential(
53
+ nn.Conv2d(NUM_COLORS + 1, 32, kernel_size=3, padding=1),
54
+ nn.ReLU(inplace=True),
55
+ nn.Conv2d(32, 16, kernel_size=3, padding=1),
56
+ nn.ReLU(inplace=True),
57
+ nn.Conv2d(16, 1, kernel_size=1),
58
+ )
59
+
60
+ def forward(self, one_hot: torch.Tensor, latent: torch.Tensor) -> torch.Tensor:
61
+ """Forward pass.
62
+
63
+ Args:
64
+ one_hot: (B, 16, 64, 64) one-hot frame.
65
+ latent: (B, latent_dim) latent vector.
66
+
67
+ Returns:
68
+ (B, 64, 64) heatmap with values in [0, 1].
69
+ """
70
+ # Broadcast latent to spatial dimensions
71
+ latent_map = latent[:, :1].unsqueeze(-1).unsqueeze(-1) # (B, 1, 1, 1)
72
+ latent_map = latent_map.expand(-1, -1, GRID_SIZE, GRID_SIZE) # (B, 1, 64, 64)
73
+
74
+ x = torch.cat([one_hot, latent_map], dim=1) # (B, 17, 64, 64)
75
+ heatmap = self.conv(x).squeeze(1) # (B, 64, 64)
76
+ return torch.sigmoid(heatmap)
77
+
78
+
79
+ class ActionHead:
80
+ """Hierarchical action selection head.
81
+
82
+ Phase 1: Choose action type via softmax over per-type change-probabilities.
83
+ Phase 2: For ACTION6, use the coordinate head to pick a click target.
84
+
85
+ Attributes:
86
+ latent_dim: Input latent dimension.
87
+ device: Torch device.
88
+ coord_head: Coordinate prediction CNN.
89
+ """
90
+
91
+ def __init__(self, latent_dim: int = 256, device: str = "cpu") -> None:
92
+ """Initialize the action head.
93
+
94
+ Args:
95
+ latent_dim: Latent dimension from the perception encoder.
96
+ device: Torch device.
97
+ """
98
+ self.latent_dim = latent_dim
99
+ self.device = torch.device(device)
100
+ self.coord_head = CoordinateHead(latent_dim=latent_dim).to(self.device)
101
+ self.coord_head.eval()
102
+
103
+ logger.info("ActionHead initialized (latent_dim=%d, device=%s)", latent_dim, device)
104
+
105
+ def select(
106
+ self,
107
+ latent: np.ndarray,
108
+ diff_mask: np.ndarray,
109
+ available_actions: list[str],
110
+ world_model: WorldModel,
111
+ epsilon: float = 0.1,
112
+ ) -> dict[str, object]:
113
+ """Select an action hierarchically.
114
+
115
+ Args:
116
+ latent: Current state latent vector.
117
+ diff_mask: 64×64 boolean diff mask (where frame changed).
118
+ available_actions: Actions available this step.
119
+ world_model: World model for change prediction.
120
+ epsilon: Exploration probability for epsilon-greedy.
121
+
122
+ Returns:
123
+ Action dict with "action" (str) and optional "data" (dict).
124
+ """
125
+ # --- Epsilon-greedy: random exploration ---
126
+ if np.random.random() < epsilon:
127
+ action_name = str(np.random.choice(available_actions))
128
+ if action_name == "ACTION6":
129
+ return self._select_coordinate(latent)
130
+ return {"action": action_name}
131
+
132
+ # --- Phase 1: Choose action type ---
133
+ # Compute P(frame changes) for each available action
134
+ change_probs = []
135
+ for action_name in available_actions:
136
+ if action_name == "RESET":
137
+ change_probs.append(0.1) # RESET always changes the frame
138
+ continue
139
+ prob = world_model.predict_change(latent, {"action": action_name})
140
+ change_probs.append(prob)
141
+
142
+ change_probs_arr = np.array(change_probs, dtype=np.float32)
143
+
144
+ # Softmax with temperature
145
+ temperature = 0.5
146
+ logits = np.log(change_probs_arr + 1e-7) / temperature
147
+ probs = np.exp(logits - np.max(logits))
148
+ probs = probs / probs.sum()
149
+
150
+ selected_idx = np.random.choice(len(available_actions), p=probs)
151
+ action_name = available_actions[selected_idx]
152
+
153
+ # --- Phase 2: For ACTION6, select coordinates ---
154
+ if action_name == "ACTION6":
155
+ return self._select_coordinate(latent)
156
+
157
+ return {"action": action_name}
158
+
159
+ def _select_coordinate(self, latent: np.ndarray) -> dict[str, object]:
160
+ """Select a click coordinate for ACTION6.
161
+
162
+ Uses the coordinate head to produce a 64×64 heatmap, then samples
163
+ from it as a categorical distribution. Falls back to uniform random
164
+ if the heatmap is degenerate.
165
+
166
+ Args:
167
+ latent: Current state latent vector.
168
+
169
+ Returns:
170
+ Action dict with "action": "ACTION6" and "data": {"x": int, "y": int}.
171
+ """
172
+ # Build a dummy one-hot from the latent (in practice, we'd use the
173
+ # actual frame; here we create a placeholder since the action head
174
+ # receives latents, not raw frames — in the full implementation,
175
+ # we'd pass the frame through or cache it)
176
+ one_hot = torch.zeros(1, NUM_COLORS, GRID_SIZE, GRID_SIZE, device=self.device)
177
+ # Place the latent-derived signal in channel 0 as a spatial broadcast
178
+ lat_tensor = torch.from_numpy(latent).unsqueeze(0).to(self.device)
179
+ one_hot[:, 0] = lat_tensor[:, :1].unsqueeze(-1).expand(-1, -1, GRID_SIZE).squeeze(0).unsqueeze(0)
180
+
181
+ with torch.no_grad():
182
+ heatmap = self.coord_head(one_hot, lat_tensor) # (1, 64, 64)
183
+ heatmap = heatmap.squeeze(0).cpu().numpy() # (64, 64)
184
+
185
+ # Flatten and sample
186
+ flat = heatmap.flatten()
187
+ flat = flat - flat.min()
188
+ total = flat.sum()
189
+
190
+ if total < 1e-7:
191
+ # Degenerate heatmap — uniform random
192
+ x = int(np.random.randint(0, GRID_SIZE))
193
+ y = int(np.random.randint(0, GRID_SIZE))
194
+ else:
195
+ probs = flat / total
196
+ idx = np.random.choice(GRID_SIZE * GRID_SIZE, p=probs)
197
+ y, x = divmod(idx, GRID_SIZE)
198
+
199
+ return {"action": "ACTION6", "data": {"x": int(x), "y": int(y)}}
200
+
201
+ def train_coordinate_head(
202
+ self,
203
+ frames: np.ndarray,
204
+ latents: np.ndarray,
205
+ targets: np.ndarray,
206
+ lr: float = 1e-3,
207
+ epochs: int = 10,
208
+ ) -> float:
209
+ """Train the coordinate head on labeled click targets.
210
+
211
+ Used during offline training when click data is available.
212
+
213
+ Args:
214
+ frames: (B, 64, 64) uint8 frames.
215
+ latents: (B, latent_dim) latent vectors.
216
+ targets: (B, 2) array of (x, y) click coordinates.
217
+ lr: Learning rate.
218
+ epochs: Number of training epochs.
219
+
220
+ Returns:
221
+ Final training loss.
222
+ """
223
+ self.coord_head.train()
224
+ optimizer = torch.optim.Adam(self.coord_head.parameters(), lr=lr)
225
+
226
+ # Convert frames to one-hot
227
+ one_hot = np.zeros((len(frames), NUM_COLORS, GRID_SIZE, GRID_SIZE), dtype=np.float32)
228
+ for i, frame in enumerate(frames):
229
+ for c in range(NUM_COLORS):
230
+ one_hot[i, c] = (frame == c).astype(np.float32)
231
+
232
+ one_hot_t = torch.from_numpy(one_hot).to(self.device)
233
+ latents_t = torch.from_numpy(latents.astype(np.float32)).to(self.device)
234
+
235
+ # Create target heatmaps (Gaussian around target)
236
+ target_maps = torch.zeros(len(frames), GRID_SIZE, GRID_SIZE, device=self.device)
237
+ for i, (x, y) in enumerate(targets):
238
+ for dy in range(-3, 4):
239
+ for dx in range(-3, 4):
240
+ ny, nx = int(y) + dy, int(x) + dx
241
+ if 0 <= ny < GRID_SIZE and 0 <= nx < GRID_SIZE:
242
+ target_maps[i, ny, nx] = np.exp(-(dx**2 + dy**2) / 2.0)
243
+
244
+ loss_fn = nn.BCELoss()
245
+ final_loss = 0.0
246
+
247
+ for epoch in range(epochs):
248
+ optimizer.zero_grad()
249
+ pred = self.coord_head(one_hot_t, latents_t)
250
+ loss = loss_fn(pred, target_maps)
251
+ loss.backward()
252
+ optimizer.step()
253
+ final_loss = loss.item()
254
+
255
+ self.coord_head.eval()
256
+ return final_loss
agents/wayfinder/agent.py ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Main agent class — glue connecting all modules.
2
+
3
+ Subclasses the official ARC-AGI-3 SDK Agent base class, wiring together
4
+ perception, world model, memory graph, intrinsic reward, planner, and
5
+ action head into a single play loop.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+
15
+ from agents.wayfinder.action_head import ActionHead
16
+ from agents.wayfinder.intrinsic_reward import IntrinsicReward
17
+ from agents.wayfinder.memory_graph import MemoryGraph
18
+ from agents.wayfinder.perception import PerceptionEncoder
19
+ from agents.wayfinder.planner import Planner
20
+ from agents.wayfinder.world_model import WorldModel
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class WayfinderAgent:
26
+ """Hybrid world-model + planning agent for ARC-AGI-3.
27
+
28
+ The agent maintains:
29
+ - A perception encoder that converts raw 64×64×4-bit frames into latents.
30
+ - A world model that predicts frame changes and forward dynamics.
31
+ - A memory graph that tracks visited states for novelty.
32
+ - An intrinsic reward module combining extrinsic score, novelty, curiosity.
33
+ - A planner that does short-horizon tree search using the world model.
34
+ - An action head with hierarchical action-type + coordinate selection.
35
+
36
+ The main loop per step:
37
+ 1. Encode current frame → latent + diff mask
38
+ 2. Update memory graph with the new state
39
+ 3. Train world model on the latest transition (online)
40
+ 4. Compute intrinsic reward for the current state
41
+ 5. If world model confidence is high, use planner; else use reactive policy
42
+ 6. Select action via action head (hierarchical)
43
+ 7. Return the action and reasoning blob for audit
44
+
45
+ Attributes:
46
+ max_actions: Maximum actions before self-terminating a level.
47
+ action_count: Actions taken in the current level attempt.
48
+ _encoder: Perception encoder module.
49
+ _world_model: World/transition model.
50
+ _memory: State memory graph.
51
+ _reward: Intrinsic reward calculator.
52
+ _planner: Short-horizon tree search planner.
53
+ _action_head: Hierarchical action selection head.
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ max_actions: int = 1000,
59
+ latent_dim: int = 256,
60
+ buffer_size: int = 200_000,
61
+ device: str = "cpu",
62
+ ) -> None:
63
+ """Initialize the Wayfinder agent.
64
+
65
+ Args:
66
+ max_actions: Safety cap on actions per level attempt.
67
+ latent_dim: Size of the perception encoder's output latent.
68
+ buffer_size: Maximum transitions stored in the replay buffer.
69
+ device: Torch device ("cpu" or "cuda").
70
+ """
71
+ self.max_actions = max_actions
72
+ self.action_count = 0
73
+ self.current_score: float = 0.0
74
+ self.win_threshold: float = 1.0
75
+ self._device = device
76
+
77
+ # Module A: Perception encoder
78
+ self._encoder = PerceptionEncoder(latent_dim=latent_dim, device=device)
79
+
80
+ # Module B: World/transition model
81
+ self._world_model = WorldModel(
82
+ latent_dim=latent_dim,
83
+ buffer_size=buffer_size,
84
+ device=device,
85
+ )
86
+
87
+ # Module C: State memory graph
88
+ self._memory = MemoryGraph()
89
+
90
+ # Module D: Intrinsic reward
91
+ self._reward = IntrinsicReward()
92
+
93
+ # Module E: Planner
94
+ self._planner = Planner(
95
+ world_model=self._world_model,
96
+ reward_module=self._reward,
97
+ max_depth=5,
98
+ max_simulations=50,
99
+ )
100
+
101
+ # Module F: Action head
102
+ self._action_head = ActionHead(
103
+ latent_dim=latent_dim,
104
+ device=device,
105
+ )
106
+
107
+ # Previous state tracking
108
+ self._prev_latent: np.ndarray | None = None
109
+ self._prev_frame_hash: str | None = None
110
+ self._is_done = False
111
+
112
+ logger.info("WayfinderAgent initialized (device=%s, max_actions=%d)", device, max_actions)
113
+
114
+ def is_done(self, frames: list[np.ndarray], state: str, **kwargs: Any) -> bool:
115
+ """Check if the agent should stop playing the current level.
116
+
117
+ The agent self-terminates when:
118
+ - The game state is WIN or GAME_OVER
119
+ - The action budget is exhausted
120
+ - The session is NOT_STARTED (needs RESET)
121
+
122
+ Args:
123
+ frames: List of 64×64 frames from the latest step.
124
+ state: Current game state string.
125
+ **kwargs: Additional context (score, etc.).
126
+
127
+ Returns:
128
+ True if the agent should stop, False to continue.
129
+ """
130
+ if state in ("WIN", "GAME_OVER", "NOT_STARTED"):
131
+ self._is_done = True
132
+ return True
133
+
134
+ if self.action_count >= self.max_actions:
135
+ logger.warning(
136
+ "Action budget exhausted (%d/%d) — self-terminating level",
137
+ self.action_count,
138
+ self.max_actions,
139
+ )
140
+ self._is_done = True
141
+ return True
142
+
143
+ return False
144
+
145
+ def act(
146
+ self,
147
+ frames: list[np.ndarray],
148
+ state: str,
149
+ score: float,
150
+ win_score: float,
151
+ available_actions: list[str],
152
+ **kwargs: Any,
153
+ ) -> dict[str, Any]:
154
+ """Select the next action given the current observation.
155
+
156
+ This is the main per-step entry point called by the SDK harness.
157
+
158
+ Args:
159
+ frames: List of 64×64 numpy arrays (4-bit color indices 0–15).
160
+ state: Game state string ("NOT_FINISHED", etc.).
161
+ score: Current running score.
162
+ win_score: Score threshold to win the level.
163
+ available_actions: List of action names available this step.
164
+ **kwargs: Additional SDK fields (levels_completed, etc.).
165
+
166
+ Returns:
167
+ Dict with "action" (str), "data" (dict, for ACTION6 x/y),
168
+ and "reasoning" (dict, for audit/logging).
169
+ """
170
+ self.action_count += 1
171
+ self.current_score = score
172
+ self.win_threshold = win_score
173
+
174
+ # Use the last frame if multiple are returned (animation settled)
175
+ current_frame = frames[-1] if frames else np.zeros((64, 64), dtype=np.uint8)
176
+
177
+ # --- Step 1: Encode frame ---
178
+ latent, diff_mask = self._encoder.encode(current_frame)
179
+ frame_hash = self._memory.hash_frame(current_frame)
180
+
181
+ # --- Step 2: Update memory graph ---
182
+ score_delta = score - self.current_score if self._prev_latent is not None else 0.0
183
+ if self._prev_frame_hash is not None:
184
+ # We don't know the action yet — we'll record the edge after
185
+ # the action is chosen. For now, register the node.
186
+ pass
187
+ self._memory.add_node(frame_hash, latent, score)
188
+
189
+ novelty = self._memory.novelty(frame_hash)
190
+
191
+ # --- Step 3: Online world-model update ---
192
+ if self._prev_latent is not None and self._last_action is not None:
193
+ self._world_model.add_transition(
194
+ state_latent=self._prev_latent,
195
+ action=self._last_action,
196
+ next_latent=latent,
197
+ frame_changed=bool(np.any(diff_mask)),
198
+ )
199
+ self._world_model.train_step()
200
+
201
+ # --- Step 4: Intrinsic reward ---
202
+ prediction_error = self._world_model.prediction_error(
203
+ self._prev_latent, self._last_action, latent
204
+ ) if self._prev_latent is not None and self._last_action else 0.0
205
+
206
+ utility = self._reward.compute(
207
+ extrinsic_delta=score_delta,
208
+ novelty=novelty,
209
+ prediction_error=prediction_error,
210
+ )
211
+
212
+ # --- Step 5: Plan or react ---
213
+ model_confidence = self._world_model.confidence()
214
+ if model_confidence > 0.65 and self.action_count > 10:
215
+ # Use planner when the world model is confident
216
+ action = self._planner.plan(
217
+ latent=latent,
218
+ available_actions=available_actions,
219
+ action_budget_remaining=self.max_actions - self.action_count,
220
+ utility_fn=self._reward.compute,
221
+ )
222
+ else:
223
+ # Reactive policy via action head
224
+ action = self._action_head.select(
225
+ latent=latent,
226
+ diff_mask=diff_mask,
227
+ available_actions=available_actions,
228
+ world_model=self._world_model,
229
+ epsilon=0.15 if novelty > 0.5 else 0.05,
230
+ )
231
+
232
+ # --- Step 6: Record transition in memory graph ---
233
+ if self._prev_frame_hash is not None:
234
+ self._memory.add_edge(
235
+ from_hash=self._prev_frame_hash,
236
+ to_hash=frame_hash,
237
+ action=action["action"],
238
+ action_data=action.get("data", {}),
239
+ )
240
+
241
+ # --- Step 7: Update previous state ---
242
+ self._prev_latent = latent
243
+ self._prev_frame_hash = frame_hash
244
+ self._last_action = action
245
+
246
+ # Build reasoning blob for audit (≤16 KB)
247
+ reasoning = {
248
+ "step": self.action_count,
249
+ "score": score,
250
+ "win_score": win_score,
251
+ "model_confidence": model_confidence,
252
+ "novelty": novelty,
253
+ "prediction_error": prediction_error,
254
+ "utility": utility,
255
+ "mode": "plan" if model_confidence > 0.65 else "react",
256
+ "frame_hash": frame_hash[:16],
257
+ }
258
+
259
+ logger.debug("Step %d: action=%s, reasoning=%s", self.action_count, action["action"], reasoning)
260
+
261
+ return {"action": action["action"], "data": action.get("data", {}), "reasoning": reasoning}
262
+
263
+ def reset(self) -> None:
264
+ """Reset agent state for a new level attempt.
265
+
266
+ Clears per-level state: action count, memory graph, previous latents.
267
+ The world model and perception encoder retain their learned weights
268
+ across resets (they generalize across levels within a game).
269
+ """
270
+ self.action_count = 0
271
+ self.current_score = 0.0
272
+ self._prev_latent = None
273
+ self._prev_frame_hash = None
274
+ self._last_action: dict[str, Any] | None = None
275
+ self._is_done = False
276
+ self._memory.reset()
277
+ logger.info("Agent reset for new level attempt")
278
+
279
+ # Expose _last_action with a default for the first step
280
+ _last_action: dict[str, Any] | None = None
agents/wayfinder/cli.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CLI entry point for the Wayfinder agent.
2
+
3
+ Wraps the SDK's main.py interface so the agent can be run via:
4
+ uv run main.py --agent=wayfinder --game=ls20
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import logging
11
+ import sys
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def main() -> int:
17
+ """CLI entry point.
18
+
19
+ Returns:
20
+ Exit code (0 for success).
21
+ """
22
+ parser = argparse.ArgumentParser(
23
+ description="Wayfinder agent for ARC-AGI-3"
24
+ )
25
+ parser.add_argument(
26
+ "--agent", default="wayfinder",
27
+ help="Agent name (default: wayfinder)"
28
+ )
29
+ parser.add_argument(
30
+ "--game", default="ls20",
31
+ help="Game ID to play (default: ls20)"
32
+ )
33
+ parser.add_argument(
34
+ "--max-actions", type=int, default=1000,
35
+ help="Max actions per level (default: 1000)"
36
+ )
37
+ parser.add_argument(
38
+ "--device", default="cpu",
39
+ help="Torch device (default: cpu)"
40
+ )
41
+ parser.add_argument(
42
+ "--verbose", "-v", action="store_true",
43
+ help="Enable verbose logging"
44
+ )
45
+
46
+ args = parser.parse_args()
47
+
48
+ logging.basicConfig(
49
+ level=logging.DEBUG if args.verbose else logging.INFO,
50
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
51
+ )
52
+
53
+ # Try to use the SDK's runner
54
+ try:
55
+ from arc_agi_3 import main as sdk_main # type: ignore[import]
56
+ logger.info("Using SDK runner with agent=%s, game=%s", args.agent, args.game)
57
+ # The SDK's main.py handles the game loop
58
+ sdk_main(agent_name=args.agent, game_id=args.game)
59
+ except ImportError:
60
+ logger.error(
61
+ "arc-agi-3 SDK not found. Install with: uv pip install arc-agi-3"
62
+ )
63
+ logger.info("Running in standalone mode (no SDK)...")
64
+ _run_standalone(args)
65
+
66
+ return 0
67
+
68
+
69
+ def _run_standalone(args: argparse.Namespace) -> None:
70
+ """Run the agent without the SDK (for testing/scaffolding).
71
+
72
+ Args:
73
+ args: Parsed CLI arguments.
74
+ """
75
+ import numpy as np
76
+
77
+ from agents.wayfinder.agent import WayfinderAgent
78
+
79
+ agent = WayfinderAgent(
80
+ max_actions=args.max_actions,
81
+ device=args.device,
82
+ )
83
+
84
+ # Simulate a few steps with random frames
85
+ logger.info("Running standalone simulation (5 steps)...")
86
+ for step in range(5):
87
+ frame = np.random.randint(0, 16, size=(64, 64), dtype=np.uint8)
88
+ result = agent.act(
89
+ frames=[frame],
90
+ state="NOT_FINISHED",
91
+ score=0.0,
92
+ win_score=1.0,
93
+ available_actions=["ACTION1", "ACTION2", "ACTION3", "ACTION4", "ACTION5"],
94
+ )
95
+ logger.info("Step %d: %s", step + 1, result)
96
+
97
+ logger.info("Standalone simulation complete.")
98
+
99
+
100
+ if __name__ == "__main__":
101
+ sys.exit(main())
agents/wayfinder/intrinsic_reward.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Intrinsic reward module — Module D.
2
+
3
+ Combines extrinsic score delta, graph novelty, and world-model
4
+ prediction error into a single scalar utility. Weights are
5
+ configurable via a YAML config file (not hard-coded).
6
+
7
+ The utility function guides both the reactive policy (via action
8
+ selection) and the planner (as the value function for rollouts).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ import yaml
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ @dataclass
23
+ class RewardConfig:
24
+ """Configuration for the intrinsic reward module.
25
+
26
+ Attributes:
27
+ w_extrinsic: Weight for extrinsic score delta.
28
+ w_novelty: Weight for graph novelty signal.
29
+ w_curiosity: Weight for world-model prediction error (curiosity).
30
+ novelty_decay: Multiplicative decay per visit (reduces novelty
31
+ of repeatedly-visited states faster).
32
+ curiosity_clip: Maximum curiosity value (prevents runaway).
33
+ score_baseline: Baseline score for normalizing extrinsic signal.
34
+ """
35
+
36
+ w_extrinsic: float = 1.0
37
+ w_novelty: float = 0.3
38
+ w_curiosity: float = 0.2
39
+ novelty_decay: float = 0.95
40
+ curiosity_clip: float = 10.0
41
+ score_baseline: float = 0.0
42
+
43
+ @classmethod
44
+ def from_yaml(cls, path: str | Path) -> "RewardConfig":
45
+ """Load config from a YAML file.
46
+
47
+ Args:
48
+ path: Path to the YAML config file.
49
+
50
+ Returns:
51
+ RewardConfig instance.
52
+ """
53
+ with open(path) as f:
54
+ data = yaml.safe_load(f)
55
+ return cls(**data.get("intrinsic_reward", {}))
56
+
57
+ @classmethod
58
+ def default(cls) -> "RewardConfig":
59
+ """Return default configuration."""
60
+ return cls()
61
+
62
+
63
+ class IntrinsicReward:
64
+ """Computes intrinsic reward combining extrinsic + novelty + curiosity.
65
+
66
+ The utility function is:
67
+ utility = w_extrinsic * Δscore + w_novelty * novelty + w_curiosity * curiosity
68
+
69
+ This maps to the "Goal-setting" benchmark capability — the agent
70
+ learns to value states that increase score, explore new territory,
71
+ and reduce its model's prediction error.
72
+
73
+ Attributes:
74
+ config: Reward configuration with tunable weights.
75
+ """
76
+
77
+ def __init__(self, config: RewardConfig | None = None) -> None:
78
+ """Initialize the intrinsic reward module.
79
+
80
+ Args:
81
+ config: Reward configuration. If None, uses defaults.
82
+ """
83
+ self.config = config or RewardConfig.default()
84
+ self._running_baseline = self.config.score_baseline
85
+ self._update_count = 0
86
+
87
+ logger.info(
88
+ "IntrinsicReward initialized (w_ext=%.2f, w_nov=%.2f, w_cur=%.2f)",
89
+ self.config.w_extrinsic,
90
+ self.config.w_novelty,
91
+ self.config.w_curiosity,
92
+ )
93
+
94
+ def compute(
95
+ self,
96
+ extrinsic_delta: float = 0.0,
97
+ novelty: float = 0.0,
98
+ prediction_error: float = 0.0,
99
+ **kwargs: float,
100
+ ) -> float:
101
+ """Compute the intrinsic utility for a state transition.
102
+
103
+ Args:
104
+ extrinsic_delta: Change in game score (score_t - score_{t-1}).
105
+ novelty: Novelty score from the memory graph (0–1).
106
+ prediction_error: World model's prediction error (curiosity).
107
+ **kwargs: Additional signals that could be incorporated.
108
+
109
+ Returns:
110
+ Scalar utility value.
111
+ """
112
+ # Clip curiosity to prevent runaway values
113
+ curiosity = min(prediction_error, self.config.curiosity_clip)
114
+
115
+ # Normalize extrinsic by running baseline (helps across games
116
+ # with different score scales)
117
+ normalized_extrinsic = extrinsic_delta
118
+ if self._running_baseline > 0:
119
+ normalized_extrinsic = extrinsic_delta / max(abs(self._running_baseline), 1.0)
120
+
121
+ # Update running baseline (exponential moving average)
122
+ if extrinsic_delta != 0:
123
+ self._running_baseline = (
124
+ 0.99 * self._running_baseline + 0.01 * abs(extrinsic_delta)
125
+ )
126
+ self._update_count += 1
127
+
128
+ utility = (
129
+ self.config.w_extrinsic * normalized_extrinsic
130
+ + self.config.w_novelty * novelty
131
+ + self.config.w_curiosity * curiosity
132
+ )
133
+
134
+ return float(utility)
135
+
136
+ def compute_for_latent(
137
+ self,
138
+ novelty: float,
139
+ prediction_error: float,
140
+ score_delta: float = 0.0,
141
+ ) -> float:
142
+ """Compute utility for a latent state (used by the planner).
143
+
144
+ Args:
145
+ novelty: Novelty of the state.
146
+ prediction_error: Model's prediction error for reaching this state.
147
+ score_delta: Score change when entering this state.
148
+
149
+ Returns:
150
+ Utility value.
151
+ """
152
+ return self.compute(
153
+ extrinsic_delta=score_delta,
154
+ novelty=novelty,
155
+ prediction_error=prediction_error,
156
+ )
157
+
158
+ def update_weights(self, **new_weights: float) -> None:
159
+ """Dynamically update reward weights (e.g. via meta-learning).
160
+
161
+ Args:
162
+ **new_weights: Keyword arguments matching RewardConfig fields.
163
+ """
164
+ if "w_extrinsic" in new_weights:
165
+ self.config.w_extrinsic = new_weights["w_extrinsic"]
166
+ if "w_novelty" in new_weights:
167
+ self.config.w_novelty = new_weights["w_novelty"]
168
+ if "w_curiosity" in new_weights:
169
+ self.config.w_curiosity = new_weights["w_curiosity"]
170
+
171
+ logger.debug("Updated reward weights: %s", new_weights)
172
+
173
+ def reset(self) -> None:
174
+ """Reset per-level state (running baseline)."""
175
+ self._running_baseline = self.config.score_baseline
176
+ self._update_count = 0
agents/wayfinder/memory_graph.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """State memory graph — Module C.
2
+
3
+ A hash-deduplicated directed graph of observed game states. Nodes are
4
+ hashed frames; edges are actions taken. Tracks visit counts for novelty
5
+ detection and supports backtracking/loop avoidance.
6
+
7
+ Inspired by Blind Squirrel's graph-based exploration, but integrated
8
+ with the learned perception encoder for efficient similarity checks.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import logging
15
+ from collections import defaultdict
16
+
17
+ import numpy as np
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class MemoryGraph:
23
+ """Directed graph of observed game states.
24
+
25
+ Nodes are identified by frame hashes (MD5 of the raw 64×64 frame).
26
+ Each node stores:
27
+ - A latent vector (from the perception encoder) for similarity checks.
28
+ - The score observed at that state.
29
+ - A visit count (incremented each time the state is seen).
30
+
31
+ Edges store the action that caused the transition.
32
+
33
+ The graph resets per RESET (per level attempt), but the agent can
34
+ optionally carry summary statistics across attempts.
35
+
36
+ Attributes:
37
+ nodes: Dict mapping frame_hash → node data.
38
+ edges: Dict mapping (from_hash, to_hash) → edge data.
39
+ adjacency: Dict mapping from_hash → list of (action, to_hash).
40
+ """
41
+
42
+ def __init__(self) -> None:
43
+ """Initialize an empty memory graph."""
44
+ self.nodes: dict[str, dict] = {}
45
+ self.edges: dict[tuple[str, str], dict] = {}
46
+ self.adjacency: dict[str, list[tuple[str, str]]] = defaultdict(list)
47
+
48
+ @staticmethod
49
+ def hash_frame(frame: np.ndarray) -> str:
50
+ """Compute a stable hash for a 64×64 frame.
51
+
52
+ Args:
53
+ frame: 64×64 uint8 array of color indices.
54
+
55
+ Returns:
56
+ MD5 hex string of the frame data.
57
+ """
58
+ return hashlib.md5(frame.tobytes()).hexdigest()
59
+
60
+ def add_node(
61
+ self,
62
+ frame_hash: str,
63
+ latent: np.ndarray,
64
+ score: float = 0.0,
65
+ ) -> None:
66
+ """Add or update a node in the graph.
67
+
68
+ If the node already exists, increment its visit count.
69
+
70
+ Args:
71
+ frame_hash: Hash of the frame.
72
+ latent: Latent vector from the perception encoder.
73
+ score: Game score at this state.
74
+ """
75
+ if frame_hash in self.nodes:
76
+ self.nodes[frame_hash]["visit_count"] += 1
77
+ self.nodes[frame_hash]["score"] = score
78
+ else:
79
+ self.nodes[frame_hash] = {
80
+ "latent": latent.copy(),
81
+ "score": score,
82
+ "visit_count": 1,
83
+ }
84
+
85
+ def add_edge(
86
+ self,
87
+ from_hash: str,
88
+ to_hash: str,
89
+ action: str,
90
+ action_data: dict | None = None,
91
+ ) -> None:
92
+ """Add a directed edge to the graph.
93
+
94
+ Args:
95
+ from_hash: Source node hash.
96
+ to_hash: Destination node hash.
97
+ action: Action name that caused this transition.
98
+ action_data: Optional action data (e.g. coordinates for ACTION6).
99
+ """
100
+ edge_key = (from_hash, to_hash)
101
+ if edge_key in self.edges:
102
+ self.edges[edge_key]["count"] += 1
103
+ else:
104
+ self.edges[edge_key] = {
105
+ "action": action,
106
+ "action_data": action_data,
107
+ "count": 1,
108
+ }
109
+ self.adjacency[from_hash].append((action, to_hash))
110
+
111
+ def novelty(self, frame_hash: str) -> float:
112
+ """Compute novelty score for a frame.
113
+
114
+ Novelty is high for unvisited or rarely-visited states, low for
115
+ frequently-visited ones. Used by the intrinsic reward module.
116
+
117
+ Formula: novelty = 1.0 / (1.0 + visit_count)
118
+
119
+ Args:
120
+ frame_hash: Hash of the frame to evaluate.
121
+
122
+ Returns:
123
+ Novelty score in (0, 1]. New states return 1.0.
124
+ """
125
+ if frame_hash not in self.nodes:
126
+ return 1.0
127
+ visit_count = self.nodes[frame_hash]["visit_count"]
128
+ return 1.0 / (1.0 + visit_count)
129
+
130
+ def shortest_path_to_unexplored(self, from_hash: str) -> list[str] | None:
131
+ """Find shortest path from a node to any unexplored action.
132
+
133
+ Uses BFS to find the nearest node that has untried actions
134
+ (actions not yet taken from that node).
135
+
136
+ Args:
137
+ from_hash: Starting node hash.
138
+
139
+ Returns:
140
+ List of action names forming the path, or None if no
141
+ unexplored action is reachable.
142
+ """
143
+ if from_hash not in self.nodes:
144
+ return None
145
+
146
+ # BFS
147
+ from collections import deque
148
+
149
+ queue: deque[tuple[str, list[str]]] = deque([(from_hash, [])])
150
+ visited: set[str] = {from_hash}
151
+
152
+ all_actions = {"RESET", "ACTION1", "ACTION2", "ACTION3",
153
+ "ACTION4", "ACTION5", "ACTION6"}
154
+
155
+ while queue:
156
+ current_hash, path = queue.popleft()
157
+
158
+ # Check if current node has unexplored actions
159
+ tried_actions = {
160
+ edge_action
161
+ for edge_action, _ in self.adjacency.get(current_hash, [])
162
+ }
163
+ unexplored = all_actions - tried_actions
164
+
165
+ if unexplored and len(path) > 0:
166
+ return path
167
+ if unexplored:
168
+ # We're at the start node with unexplored actions
169
+ return []
170
+
171
+ # Expand neighbors
172
+ for edge_action, neighbor_hash in self.adjacency.get(current_hash, []):
173
+ if neighbor_hash not in visited:
174
+ visited.add(neighbor_hash)
175
+ queue.append((neighbor_hash, path + [edge_action]))
176
+
177
+ return None
178
+
179
+ def get_visited_states(self) -> set[str]:
180
+ """Return the set of all visited node hashes.
181
+
182
+ Returns:
183
+ Set of frame hashes that have been visited.
184
+ """
185
+ return set(self.nodes.keys())
186
+
187
+ def get_transition_count(self) -> int:
188
+ """Return total number of unique transitions (edges).
189
+
190
+ Returns:
191
+ Number of edges in the graph.
192
+ """
193
+ return len(self.edges)
194
+
195
+ def stats(self) -> dict:
196
+ """Return summary statistics about the graph.
197
+
198
+ Returns:
199
+ Dict with node_count, edge_count, avg_visits, max_visits.
200
+ """
201
+ visit_counts = [n["visit_count"] for n in self.nodes.values()]
202
+ return {
203
+ "node_count": len(self.nodes),
204
+ "edge_count": len(self.edges),
205
+ "avg_visits": np.mean(visit_counts) if visit_counts else 0.0,
206
+ "max_visits": max(visit_counts) if visit_counts else 0,
207
+ }
208
+
209
+ def reset(self) -> None:
210
+ """Clear all nodes and edges.
211
+
212
+ Called when the agent RESETs a level or starts a new one.
213
+ """
214
+ self.nodes.clear()
215
+ self.edges.clear()
216
+ self.adjacency.clear()
217
+ logger.debug("Memory graph reset")
218
+
219
+ def serialize(self) -> dict:
220
+ """Serialize the graph for checkpointing.
221
+
222
+ Returns:
223
+ Dict representation of the graph.
224
+ """
225
+ return {
226
+ "nodes": {
227
+ h: {"score": n["score"], "visit_count": n["visit_count"]}
228
+ for h, n in self.nodes.items()
229
+ },
230
+ "edges": {
231
+ f"{k[0]}->{k[1]}": v for k, v in self.edges.items()
232
+ },
233
+ }
agents/wayfinder/perception.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Perception encoder — Module A.
2
+
3
+ Converts a raw 64×64 grid of 4-bit color indices (0–15) into a compact
4
+ latent representation using a small from-scratch CNN.
5
+
6
+ The encoder also computes a binary diff mask against the previous frame,
7
+ which is used by the world model to predict frame changes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+
14
+ import numpy as np
15
+ import torch
16
+ import torch.nn as nn
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ GRID_SIZE = 64
21
+ NUM_COLORS = 16
22
+ LATENT_DIM_DEFAULT = 256
23
+
24
+
25
+ class PerceptionEncoder(nn.Module):
26
+ """CNN encoder for 64×64×16 one-hot frames.
27
+
28
+ Architecture (3 conv layers + 1 FC head):
29
+ Conv2d(16, 32, 3, padding=1) → ReLU → MaxPool2d(2) # 64→32
30
+ Conv2d(32, 64, 3, padding=1) → ReLU → MaxPool2d(2) # 32→16
31
+ Conv2d(64, 128, 3, padding=1) → ReLU → MaxPool2d(2) # 16→8
32
+ Flatten → Linear(128*8*8, latent_dim)
33
+
34
+ No pretrained weights exist for this domain — trained from scratch.
35
+
36
+ Attributes:
37
+ latent_dim: Output latent vector dimensionality.
38
+ device: Torch device for inference.
39
+ """
40
+
41
+ def __init__(self, latent_dim: int = LATENT_DIM_DEFAULT, device: str = "cpu") -> None:
42
+ """Initialize the encoder.
43
+
44
+ Args:
45
+ latent_dim: Output latent dimension.
46
+ device: Torch device ("cpu" or "cuda").
47
+ """
48
+ super().__init__()
49
+ self.latent_dim = latent_dim
50
+ self.device = torch.device(device)
51
+
52
+ self.conv = nn.Sequential(
53
+ nn.Conv2d(NUM_COLORS, 32, kernel_size=3, padding=1),
54
+ nn.ReLU(inplace=True),
55
+ nn.MaxPool2d(2), # 64 → 32
56
+ nn.Conv2d(32, 64, kernel_size=3, padding=1),
57
+ nn.ReLU(inplace=True),
58
+ nn.MaxPool2d(2), # 32 → 16
59
+ nn.Conv2d(64, 128, kernel_size=3, padding=1),
60
+ nn.ReLU(inplace=True),
61
+ nn.MaxPool2d(2), # 16 → 8
62
+ )
63
+ self.fc = nn.Sequential(
64
+ nn.Flatten(),
65
+ nn.Linear(128 * 8 * 8, 512),
66
+ nn.ReLU(inplace=True),
67
+ nn.Linear(512, latent_dim),
68
+ )
69
+
70
+ self.to(self.device)
71
+ self.eval()
72
+ logger.info("PerceptionEncoder initialized (latent_dim=%d, device=%s)", latent_dim, device)
73
+
74
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
75
+ """Forward pass: one-hot frame → latent vector.
76
+
77
+ Args:
78
+ x: Tensor of shape (B, 16, 64, 64) — one-hot encoded frames.
79
+
80
+ Returns:
81
+ Latent tensor of shape (B, latent_dim).
82
+ """
83
+ features = self.conv(x)
84
+ latent = self.fc(features)
85
+ return latent
86
+
87
+ def encode(self, frame: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
88
+ """Encode a single frame into a latent vector + diff mask.
89
+
90
+ Args:
91
+ frame: 64×64 numpy array of uint8 color indices (0–15).
92
+
93
+ Returns:
94
+ Tuple of (latent, diff_mask):
95
+ - latent: float32 array of shape (latent_dim,).
96
+ - diff_mask: 64×64 bool array — True where frame differs from previous.
97
+ """
98
+ one_hot = self._to_one_hot(frame)
99
+ with torch.no_grad():
100
+ tensor = torch.from_numpy(one_hot).unsqueeze(0).to(self.device)
101
+ latent = self.forward(tensor).squeeze(0).cpu().numpy()
102
+
103
+ # Compute diff mask against previous frame
104
+ diff_mask = self._compute_diff(frame)
105
+
106
+ # Store current frame for next step's diff
107
+ self._prev_frame = frame.copy()
108
+
109
+ return latent.astype(np.float32), diff_mask
110
+
111
+ def _to_one_hot(self, frame: np.ndarray) -> np.ndarray:
112
+ """Convert a 64×64 integer frame to 16×64×64 one-hot float.
113
+
114
+ Args:
115
+ frame: 64×64 uint8 array with values in [0, 15].
116
+
117
+ Returns:
118
+ 16×64×64 float32 one-hot array.
119
+ """
120
+ one_hot = np.zeros((NUM_COLORS, GRID_SIZE, GRID_SIZE), dtype=np.float32)
121
+ for c in range(NUM_COLORS):
122
+ one_hot[c] = (frame == c).astype(np.float32)
123
+ return one_hot
124
+
125
+ def _compute_diff(self, frame: np.ndarray) -> np.ndarray:
126
+ """Compute binary diff mask against the previous frame.
127
+
128
+ Args:
129
+ frame: Current 64×64 frame.
130
+
131
+ Returns:
132
+ 64×64 bool array — True where pixels changed.
133
+ """
134
+ if not hasattr(self, "_prev_frame"):
135
+ return np.zeros((GRID_SIZE, GRID_SIZE), dtype=bool)
136
+ return frame != self._prev_frame
137
+
138
+ def encode_batch(self, frames: np.ndarray) -> np.ndarray:
139
+ """Encode a batch of frames into latent vectors.
140
+
141
+ Used during offline training of the world model.
142
+
143
+ Args:
144
+ frames: (B, 64, 64) uint8 array.
145
+
146
+ Returns:
147
+ (B, latent_dim) float32 array.
148
+ """
149
+ one_hot = np.zeros((len(frames), NUM_COLORS, GRID_SIZE, GRID_SIZE), dtype=np.float32)
150
+ for i, frame in enumerate(frames):
151
+ for c in range(NUM_COLORS):
152
+ one_hot[i, c] = (frame == c).astype(np.float32)
153
+
154
+ with torch.no_grad():
155
+ tensor = torch.from_numpy(one_hot).to(self.device)
156
+ latents = self.forward(tensor).cpu().numpy()
157
+
158
+ return latents.astype(np.float32)
agents/wayfinder/planner.py ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Planner — Module E.
2
+
3
+ Short-horizon tree search using the world model as a cheap simulator
4
+ and the intrinsic reward module as the value function.
5
+
6
+ Adapts the rollout/backup structure from the Topdeck ISMCTS agent,
7
+ simplified since ARC-AGI-3 frames are fully observed (no information-set
8
+ handling needed). The search budget scales with the remaining action
9
+ allowance — more budget early in a level, less when running low.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import math
16
+ from typing import Any, Callable
17
+
18
+ import numpy as np
19
+
20
+ from agents.wayfinder.world_model import WorldModel
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Type alias for the utility function
25
+ UtilityFn = Callable[..., float]
26
+
27
+
28
+ class PlannerNode:
29
+ """A node in the search tree.
30
+
31
+ Attributes:
32
+ latent: State latent vector at this node.
33
+ action: Action that led to this node (None for root).
34
+ parent: Parent node (None for root).
35
+ children: List of child PlannerNodes.
36
+ visits: Number of times this node has been visited in search.
37
+ total_value: Accumulated utility from rollouts through this node.
38
+ is_terminal: Whether this is a terminal state.
39
+ """
40
+
41
+ __slots__ = ("latent", "action", "action_data", "parent", "children",
42
+ "visits", "total_value", "is_terminal")
43
+
44
+ def __init__(
45
+ self,
46
+ latent: np.ndarray,
47
+ action: str | None = None,
48
+ action_data: dict | None = None,
49
+ parent: "PlannerNode | None" = None,
50
+ is_terminal: bool = False,
51
+ ) -> None:
52
+ self.latent = latent
53
+ self.action = action
54
+ self.action_data = action_data
55
+ self.parent = parent
56
+ self.children: list[PlannerNode] = []
57
+ self.visits = 0
58
+ self.total_value = 0.0
59
+ self.is_terminal = is_terminal
60
+
61
+ @property
62
+ def mean_value(self) -> float:
63
+ """Mean utility value from rollouts through this node."""
64
+ return self.total_value / self.visits if self.visits > 0 else 0.0
65
+
66
+ def ucb1(self, exploration: float = 1.414) -> float:
67
+ """UCB1 selection score.
68
+
69
+ Args:
70
+ exploration: Exploration constant (sqrt(2) by default).
71
+
72
+ Returns:
73
+ UCB1 value for node selection.
74
+ """
75
+ if self.visits == 0:
76
+ return float("inf")
77
+ parent_visits = self.parent.visits if self.parent else self.visits
78
+ exploit = self.mean_value
79
+ explore = exploration * math.sqrt(math.log(parent_visits) / self.visits)
80
+ return exploit + explore
81
+
82
+ def best_child(self, exploration: float = 1.414) -> "PlannerNode | None":
83
+ """Select the child with the highest UCB1 score.
84
+
85
+ Args:
86
+ exploration: UCB1 exploration constant.
87
+
88
+ Returns:
89
+ Best child node, or None if no children.
90
+ """
91
+ if not self.children:
92
+ return None
93
+ return max(self.children, key=lambda c: c.ucb1(exploration))
94
+
95
+
96
+ class Planner:
97
+ """Short-horizon tree search planner.
98
+
99
+ Uses the world model to simulate forward, the intrinsic reward to
100
+ evaluate states, and UCB1-based selection (like MCTS/ISMCTS) to
101
+ balance exploration and exploitation in the search tree.
102
+
103
+ The search depth and number of simulations scale with the remaining
104
+ action budget — more budget means deeper search.
105
+
106
+ Attributes:
107
+ world_model: The world/transition model (used as simulator).
108
+ max_depth: Maximum search depth per simulation.
109
+ max_simulations: Number of MCTS simulations per planning step.
110
+ """
111
+
112
+ ALL_ACTIONS = ["ACTION1", "ACTION2", "ACTION3", "ACTION4", "ACTION5"]
113
+
114
+ def __init__(
115
+ self,
116
+ world_model: WorldModel,
117
+ reward_module: Any,
118
+ max_depth: int = 5,
119
+ max_simulations: int = 50,
120
+ ) -> None:
121
+ """Initialize the planner.
122
+
123
+ Args:
124
+ world_model: World model for forward simulation.
125
+ reward_module: Intrinsic reward module for value estimation.
126
+ max_depth: Max rollout depth.
127
+ max_simulations: Number of MCTS simulations per plan() call.
128
+ """
129
+ self.world_model = world_model
130
+ self.reward_module = reward_module
131
+ self.max_depth = max_depth
132
+ self.max_simulations = max_simulations
133
+
134
+ logger.info(
135
+ "Planner initialized (max_depth=%d, max_sims=%d)",
136
+ max_depth,
137
+ max_simulations,
138
+ )
139
+
140
+ def plan(
141
+ self,
142
+ latent: np.ndarray,
143
+ available_actions: list[str],
144
+ action_budget_remaining: int,
145
+ utility_fn: UtilityFn,
146
+ **kwargs: Any,
147
+ ) -> dict[str, Any]:
148
+ """Plan the next action using tree search.
149
+
150
+ Args:
151
+ latent: Current state latent.
152
+ available_actions: Actions available this step.
153
+ action_budget_remaining: Remaining action budget.
154
+ utility_fn: Function to compute utility of a state.
155
+ **kwargs: Additional context.
156
+
157
+ Returns:
158
+ Action dict with "action" and optional "data" keys.
159
+ """
160
+ # Scale simulations with remaining budget
161
+ budget_factor = min(1.0, action_budget_remaining / 100.0)
162
+ num_sims = max(5, int(self.max_simulations * budget_factor))
163
+ depth = max(2, int(self.max_depth * budget_factor))
164
+
165
+ # Filter to simple actions for tree search (ACTION6's 4096-position
166
+ # space is too large for full tree search — handled by action head)
167
+ search_actions = [a for a in available_actions if a in self.ALL_ACTIONS]
168
+ if not search_actions:
169
+ search_actions = self.ALL_ACTIONS[:3]
170
+
171
+ root = PlannerNode(latent=latent)
172
+
173
+ # Run simulations
174
+ for _ in range(num_sims):
175
+ self._simulate(root, search_actions, depth, utility_fn)
176
+
177
+ # Select the best action from root's children
178
+ best = root.best_child(exploration=0.0) # Greedy at root
179
+ if best is None or best.action is None:
180
+ # Fallback: pick first available action
181
+ return {"action": available_actions[0]}
182
+
183
+ result: dict[str, Any] = {"action": best.action}
184
+ if best.action_data:
185
+ result["data"] = best.action_data
186
+ return result
187
+
188
+ def _simulate(
189
+ self,
190
+ root: PlannerNode,
191
+ actions: list[str],
192
+ max_depth: int,
193
+ utility_fn: UtilityFn,
194
+ ) -> float:
195
+ """Run one MCTS simulation: select → expand → rollout → backup.
196
+
197
+ Args:
198
+ root: Root of the search tree.
199
+ actions: Available actions for expansion.
200
+ max_depth: Maximum rollout depth.
201
+ utility_fn: Utility function for leaf evaluation.
202
+
203
+ Returns:
204
+ Accumulated utility from the rollout.
205
+ """
206
+ # --- Selection ---
207
+ node = root
208
+ depth = 0
209
+ while node.children and not node.is_terminal and depth < max_depth:
210
+ node = node.best_child()
211
+ if node is None:
212
+ break
213
+ depth += 1
214
+
215
+ # --- Expansion ---
216
+ if node is not None and not node.is_terminal and depth < max_depth:
217
+ # Expand: add children for all actions
218
+ for action in actions:
219
+ action_dict = {"action": action}
220
+ # Use world model to predict next latent
221
+ next_latent = self.world_model.predict_next_latent(
222
+ node.latent, action_dict
223
+ )
224
+ child = PlannerNode(
225
+ latent=next_latent,
226
+ action=action,
227
+ parent=node,
228
+ )
229
+ node.children.append(child)
230
+ # Pick a child for rollout
231
+ if node.children:
232
+ node = np.random.choice(node.children)
233
+
234
+ # --- Rollout (random forward simulation) ---
235
+ if node is not None:
236
+ total_utility = self._rollout(
237
+ node.latent, actions, max_depth - depth, utility_fn
238
+ )
239
+ else:
240
+ total_utility = 0.0
241
+
242
+ # --- Backup ---
243
+ while node is not None:
244
+ node.visits += 1
245
+ node.total_value += total_utility
246
+ node = node.parent
247
+
248
+ return total_utility
249
+
250
+ def _rollout(
251
+ self,
252
+ latent: np.ndarray,
253
+ actions: list[str],
254
+ depth: int,
255
+ utility_fn: UtilityFn,
256
+ ) -> float:
257
+ """Random rollout from a latent state.
258
+
259
+ Args:
260
+ latent: Starting latent.
261
+ actions: Available actions.
262
+ depth: Remaining rollout depth.
263
+ utility_fn: Utility function.
264
+
265
+ Returns:
266
+ Accumulated utility.
267
+ """
268
+ total = 0.0
269
+ current = latent
270
+
271
+ for _ in range(max(depth, 1)):
272
+ action = np.random.choice(actions)
273
+ action_dict = {"action": action}
274
+
275
+ # Simulate forward
276
+ next_latent = self.world_model.predict_next_latent(current, action_dict)
277
+
278
+ # Estimate utility (novelty ≈ 0 in simulation since we're
279
+ # predicting latents, not real frames; use prediction error
280
+ # as a proxy for curiosity)
281
+ pred_error = float(np.linalg.norm(next_latent - current))
282
+ utility = utility_fn(
283
+ extrinsic_delta=0.0,
284
+ novelty=0.0,
285
+ prediction_error=pred_error,
286
+ )
287
+ total += utility * (0.95 ** _) # Discount
288
+ current = next_latent
289
+
290
+ return total
agents/wayfinder/world_model.py ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """World/transition model — Module B.
2
+
3
+ A self-supervised model that learns two things from played transitions:
4
+ 1. P(frame changes | state, action) — a binary change-prediction head.
5
+ 2. A forward model ŝ_{t+1} = f(s_t, a_t) predicting the next latent.
6
+
7
+ Trained online from a deduplicated replay buffer. The change-prediction
8
+ head is the primary signal for the reactive policy (like StochasticGoose),
9
+ while the forward model enables the planner's tree search.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import logging
16
+ from collections import deque
17
+ from dataclasses import dataclass
18
+
19
+ import numpy as np
20
+ import torch
21
+ import torch.nn as nn
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ @dataclass
27
+ class Transition:
28
+ """A single (state, action, next_state, changed) transition.
29
+
30
+ Attributes:
31
+ state_latent: Latent vector of the state before the action.
32
+ action: Action name string (e.g. "ACTION1", "ACTION6").
33
+ action_data: Optional action data (e.g. {"x": 32, "y": 32} for ACTION6).
34
+ next_latent: Latent vector of the state after the action.
35
+ frame_changed: Whether the frame changed as a result of the action.
36
+ """
37
+
38
+ state_latent: np.ndarray
39
+ action: str
40
+ action_data: dict | None
41
+ next_latent: np.ndarray
42
+ frame_changed: bool
43
+
44
+
45
+ class WorldModel(nn.Module):
46
+ """World/transition model for ARC-AGI-3.
47
+
48
+ Architecture:
49
+ - Action embedding: 7 action types → 32-dim embedding.
50
+ - Change predictor: MLP(latent_dim + action_emb) → 1 (sigmoid).
51
+ - Forward model: MLP(latent_dim + action_emb) → latent_dim.
52
+
53
+ The change predictor is trained with BCE loss + entropy regularization.
54
+ The forward model is trained with MSE loss on the latent difference.
55
+
56
+ Attributes:
57
+ latent_dim: Dimension of the perception encoder's output.
58
+ buffer_size: Max transitions in the replay buffer.
59
+ device: Torch device.
60
+ """
61
+
62
+ ACTION_TYPES = ["RESET", "ACTION1", "ACTION2", "ACTION3", "ACTION4", "ACTION5", "ACTION6"]
63
+ ACTION_TO_IDX = {a: i for i, a in enumerate(ACTION_TYPES)}
64
+
65
+ def __init__(
66
+ self,
67
+ latent_dim: int = 256,
68
+ buffer_size: int = 200_000,
69
+ device: str = "cpu",
70
+ lr: float = 1e-4,
71
+ ) -> None:
72
+ """Initialize the world model.
73
+
74
+ Args:
75
+ latent_dim: Input latent dimensionality.
76
+ buffer_size: Maximum transitions stored.
77
+ device: Torch device.
78
+ lr: Learning rate for the optimizer.
79
+ """
80
+ super().__init__()
81
+ self.latent_dim = latent_dim
82
+ self.buffer_size = buffer_size
83
+ self.device = torch.device(device)
84
+ self.lr = lr
85
+
86
+ # Action embedding
87
+ self.action_embed = nn.Embedding(len(self.ACTION_TYPES), 32)
88
+
89
+ # Change prediction head: P(frame changes | state, action)
90
+ self.change_head = nn.Sequential(
91
+ nn.Linear(latent_dim + 32, 128),
92
+ nn.ReLU(inplace=True),
93
+ nn.Linear(128, 64),
94
+ nn.ReLU(inplace=True),
95
+ nn.Linear(64, 1),
96
+ )
97
+
98
+ # Forward model: predict next latent
99
+ self.forward_head = nn.Sequential(
100
+ nn.Linear(latent_dim + 32, 256),
101
+ nn.ReLU(inplace=True),
102
+ nn.Linear(256, 256),
103
+ nn.ReLU(inplace=True),
104
+ nn.Linear(256, latent_dim),
105
+ )
106
+
107
+ self.optimizer = torch.optim.Adam(self.parameters(), lr=lr)
108
+ self.to(self.device)
109
+
110
+ # Replay buffer (deduplicated by state hash)
111
+ self._buffer: deque[Transition] = deque(maxlen=buffer_size)
112
+ self._seen_hashes: set[str] = set()
113
+
114
+ # Training stats
115
+ self._train_steps = 0
116
+ self._change_loss_avg = 0.0
117
+ self._forward_loss_avg = 0.0
118
+
119
+ logger.info("WorldModel initialized (latent_dim=%d, buffer=%d)", latent_dim, buffer_size)
120
+
121
+ def forward(self, latent: torch.Tensor, action_idx: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
122
+ """Forward pass.
123
+
124
+ Args:
125
+ latent: (B, latent_dim) state latent.
126
+ action_idx: (B,) action index tensor.
127
+
128
+ Returns:
129
+ Tuple of (change_prob, next_latent_pred):
130
+ - change_prob: (B, 1) sigmoid probability of frame change.
131
+ - next_latent_pred: (B, latent_dim) predicted next latent.
132
+ """
133
+ act_emb = self.action_embed(action_idx)
134
+ x = torch.cat([latent, act_emb], dim=-1)
135
+ change_prob = torch.sigmoid(self.change_head(x))
136
+ next_latent = self.forward_head(x)
137
+ return change_prob, next_latent
138
+
139
+ def add_transition(
140
+ self,
141
+ state_latent: np.ndarray,
142
+ action: dict[str, Any],
143
+ next_latent: np.ndarray,
144
+ frame_changed: bool,
145
+ ) -> None:
146
+ """Add a transition to the replay buffer (with deduplication).
147
+
148
+ Args:
149
+ state_latent: Latent before the action.
150
+ action: Action dict with "action" key and optional "data".
151
+ next_latent: Latent after the action.
152
+ frame_changed: Whether the frame visually changed.
153
+ """
154
+ # Deduplicate by hashing the state+action combination
155
+ h = hashlib.md5(
156
+ state_latent.tobytes() + action["action"].encode()
157
+ ).hexdigest()
158
+
159
+ if h in self._seen_hashes:
160
+ return # Skip duplicate
161
+ self._seen_hashes.add(h)
162
+
163
+ self._buffer.append(
164
+ Transition(
165
+ state_latent=state_latent.copy(),
166
+ action=action["action"],
167
+ action_data=action.get("data"),
168
+ next_latent=next_latent.copy(),
169
+ frame_changed=frame_changed,
170
+ )
171
+ )
172
+
173
+ def train_step(self, batch_size: int = 64) -> float:
174
+ """Perform one gradient step on a random batch from the buffer.
175
+
176
+ Uses BCE loss for change prediction + MSE for forward model
177
+ + entropy regularization on the change prediction.
178
+
179
+ Args:
180
+ batch_size: Mini-batch size.
181
+
182
+ Returns:
183
+ Total loss value.
184
+ """
185
+ if len(self._buffer) < batch_size:
186
+ return 0.0
187
+
188
+ # Sample random batch
189
+ indices = np.random.choice(len(self._buffer), size=batch_size, replace=False)
190
+ batch = [self._buffer[i] for i in indices]
191
+
192
+ latents = torch.from_numpy(np.stack([t.state_latent for t in batch])).to(self.device)
193
+ next_latents = torch.from_numpy(np.stack([t.next_latent for t in batch])).to(self.device)
194
+ action_indices = torch.tensor(
195
+ [self.ACTION_TO_IDX.get(t.action, 0) for t in batch],
196
+ dtype=torch.long,
197
+ device=self.device,
198
+ )
199
+ changed = torch.tensor(
200
+ [float(t.frame_changed) for t in batch],
201
+ dtype=torch.float32,
202
+ device=self.device,
203
+ ).unsqueeze(1)
204
+
205
+ self.train()
206
+ self.optimizer.zero_grad()
207
+
208
+ change_prob, next_latent_pred = self.forward(latents, action_indices)
209
+
210
+ # BCE loss for change prediction
211
+ bce_loss = nn.functional.binary_cross_entropy(change_prob, changed)
212
+
213
+ # Entropy regularization (encourage calibrated probabilities)
214
+ eps = 1e-7
215
+ entropy = -(change_prob * torch.log(change_prob + eps) +
216
+ (1 - change_prob) * torch.log(1 - change_prob + eps))
217
+ entropy_reg = -0.01 * entropy.mean()
218
+
219
+ # MSE loss for forward model
220
+ fwd_loss = nn.functional.mse_loss(next_latent_pred, next_latents)
221
+
222
+ total_loss = bce_loss + entropy_reg + 0.5 * fwd_loss
223
+ total_loss.backward()
224
+ torch.nn.utils.clip_grad_norm_(self.parameters(), 1.0)
225
+ self.optimizer.step()
226
+
227
+ self._train_steps += 1
228
+ self._change_loss_avg = 0.99 * self._change_loss_avg + 0.01 * bce_loss.item()
229
+ self._forward_loss_avg = 0.99 * self._forward_loss_avg + 0.01 * fwd_loss.item()
230
+
231
+ self.eval()
232
+ return total_loss.item()
233
+
234
+ def predict_change(
235
+ self, latent: np.ndarray, action: dict[str, Any]
236
+ ) -> float:
237
+ """Predict P(frame changes | state, action).
238
+
239
+ Args:
240
+ latent: State latent vector.
241
+ action: Action dict.
242
+
243
+ Returns:
244
+ Probability of frame change (0–1).
245
+ """
246
+ with torch.no_grad():
247
+ lat = torch.from_numpy(latent).unsqueeze(0).to(self.device)
248
+ act_idx = torch.tensor(
249
+ [self.ACTION_TO_IDX.get(action["action"], 0)],
250
+ dtype=torch.long,
251
+ device=self.device,
252
+ )
253
+ change_prob, _ = self.forward(lat, act_idx)
254
+ return change_prob.item()
255
+
256
+ def predict_next_latent(
257
+ self, latent: np.ndarray, action: dict[str, Any]
258
+ ) -> np.ndarray:
259
+ """Predict the next latent given current state and action.
260
+
261
+ Used by the planner as a cheap simulator.
262
+
263
+ Args:
264
+ latent: Current state latent.
265
+ action: Action dict.
266
+
267
+ Returns:
268
+ Predicted next latent vector.
269
+ """
270
+ with torch.no_grad():
271
+ lat = torch.from_numpy(latent).unsqueeze(0).to(self.device)
272
+ act_idx = torch.tensor(
273
+ [self.ACTION_TO_IDX.get(action["action"], 0)],
274
+ dtype=torch.long,
275
+ device=self.device,
276
+ )
277
+ _, next_latent = self.forward(lat, act_idx)
278
+ return next_latent.squeeze(0).cpu().numpy()
279
+
280
+ def prediction_error(
281
+ self,
282
+ state_latent: np.ndarray | None,
283
+ action: dict[str, Any] | None,
284
+ actual_next_latent: np.ndarray,
285
+ ) -> float:
286
+ """Compute prediction error (curiosity signal).
287
+
288
+ Args:
289
+ state_latent: Latent before action (None if first step).
290
+ action: Action taken (None if first step).
291
+ actual_next_latent: Actual next latent.
292
+
293
+ Returns:
294
+ L2 distance between predicted and actual next latent.
295
+ """
296
+ if state_latent is None or action is None:
297
+ return 0.0
298
+ predicted = self.predict_next_latent(state_latent, action)
299
+ return float(np.linalg.norm(predicted - actual_next_latent))
300
+
301
+ def confidence(self) -> float:
302
+ """Estimate the world model's confidence.
303
+
304
+ Returns a value in [0, 1] based on training progress and
305
+ average change-prediction loss. Used by the agent to decide
306
+ whether to use the planner or the reactive policy.
307
+
308
+ Returns:
309
+ Confidence score in [0, 1].
310
+ """
311
+ if self._train_steps < 10:
312
+ return 0.0
313
+ # Lower loss → higher confidence
314
+ confidence = 1.0 / (1.0 + self._change_loss_avg * 5)
315
+ return min(confidence, 1.0)
316
+
317
+ @property
318
+ def buffer_size_current(self) -> int:
319
+ """Current number of transitions in the buffer."""
320
+ return len(self._buffer)
eval/metrics.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluation metrics for ARC-AGI-3 agent performance.
2
+
3
+ Tracks per-game/level scores, action efficiency vs. human baseline,
4
+ and world-model prediction quality (AUC, calibration).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from collections import defaultdict
11
+
12
+ import numpy as np
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class EvalMetrics:
18
+ """Tracks and computes evaluation metrics.
19
+
20
+ Records per-step data during gameplay and computes aggregate
21
+ metrics: score progression, action distribution, efficiency,
22
+ state coverage, and model quality.
23
+
24
+ Attributes:
25
+ steps: List of per-step records.
26
+ """
27
+
28
+ def __init__(self) -> None:
29
+ """Initialize an empty metrics tracker."""
30
+ self.steps: list[dict] = []
31
+
32
+ def record_step(
33
+ self,
34
+ score: float,
35
+ action: str,
36
+ **extra: object,
37
+ ) -> None:
38
+ """Record a single step's metrics.
39
+
40
+ Args:
41
+ score: Current game score.
42
+ action: Action taken.
43
+ **extra: Additional metrics (novelty, confidence, etc.).
44
+ """
45
+ self.steps.append({
46
+ "score": score,
47
+ "action": action,
48
+ **extra,
49
+ })
50
+
51
+ def compute(self) -> dict:
52
+ """Compute aggregate metrics.
53
+
54
+ Returns:
55
+ Dict with:
56
+ - total_steps: Number of actions taken.
57
+ - final_score: Last recorded score.
58
+ - action_distribution: Dict of action → count.
59
+ - unique_actions: Number of distinct actions used.
60
+ - score_delta: Final score - initial score.
61
+ """
62
+ if not self.steps:
63
+ return {
64
+ "total_steps": 0,
65
+ "final_score": 0.0,
66
+ "action_distribution": {},
67
+ "unique_actions": 0,
68
+ "score_delta": 0.0,
69
+ }
70
+
71
+ action_counts: dict[str, int] = defaultdict(int)
72
+ for step in self.steps:
73
+ action_counts[step["action"]] += 1
74
+
75
+ scores = [s["score"] for s in self.steps]
76
+ initial_score = scores[0] if scores else 0.0
77
+ final_score = scores[-1] if scores else 0.0
78
+
79
+ return {
80
+ "total_steps": len(self.steps),
81
+ "final_score": final_score,
82
+ "action_distribution": dict(action_counts),
83
+ "unique_actions": len(action_counts),
84
+ "score_delta": final_score - initial_score,
85
+ }
86
+
87
+
88
+ def compute_efficiency(metrics: dict, max_actions: int) -> dict:
89
+ """Compute action efficiency relative to the action budget.
90
+
91
+ ARC-AGI-3 scores are squared and action budgets are capped at
92
+ ~5× human median. This function computes how efficiently the agent
93
+ used its budget.
94
+
95
+ Args:
96
+ metrics: Output of EvalMetrics.compute().
97
+ max_actions: The action budget for this level.
98
+
99
+ Returns:
100
+ Dict with:
101
+ - budget_used: Fraction of budget used (0–1).
102
+ - actions_per_score: Actions per unit of score gained.
103
+ - efficiency_score: Composite efficiency metric (0–1, higher is better).
104
+ """
105
+ total_steps = metrics.get("total_steps", 0)
106
+ score_delta = metrics.get("score_delta", 0.0)
107
+
108
+ budget_used = total_steps / max(1, max_actions)
109
+ actions_per_score = total_steps / max(abs(score_delta), 0.001)
110
+
111
+ # Efficiency: high score with low budget usage is best
112
+ if score_delta > 0:
113
+ efficiency_score = score_delta * (1.0 - 0.5 * budget_used)
114
+ else:
115
+ efficiency_score = 0.0
116
+
117
+ return {
118
+ "budget_used": budget_used,
119
+ "actions_per_score": actions_per_score,
120
+ "efficiency_score": min(efficiency_score, 1.0),
121
+ }
122
+
123
+
124
+ def compute_change_prediction_auc(
125
+ predictions: np.ndarray,
126
+ labels: np.ndarray,
127
+ ) -> float:
128
+ """Compute AUC for the world model's change-prediction head.
129
+
130
+ Args:
131
+ predictions: Predicted probabilities (float array).
132
+ labels: Binary labels (0 or 1).
133
+
134
+ Returns:
135
+ ROC AUC score (0–1).
136
+ """
137
+ if len(predictions) == 0 or len(np.unique(labels)) < 2:
138
+ return 0.5
139
+
140
+ # Sort by prediction descending
141
+ order = np.argsort(-predictions)
142
+ labels_sorted = labels[order]
143
+
144
+ # Compute ROC AUC via rank-based formula
145
+ n_pos = labels.sum()
146
+ n_neg = len(labels) - n_pos
147
+
148
+ if n_pos == 0 or n_neg == 0:
149
+ return 0.5
150
+
151
+ # Rank sum
152
+ ranks = np.zeros(len(predictions))
153
+ for i, idx in enumerate(order):
154
+ ranks[idx] = len(predictions) - i
155
+
156
+ sum_ranks_pos = ranks[labels == 1].sum()
157
+ auc = (sum_ranks_pos - n_pos * (n_pos + 1) / 2) / (n_pos * n_neg)
158
+
159
+ return float(auc)
160
+
161
+
162
+ def compare_agents(
163
+ baseline: dict,
164
+ candidate: dict,
165
+ ) -> dict:
166
+ """Compare two agents' evaluation results.
167
+
168
+ Args:
169
+ baseline: Baseline agent results.
170
+ candidate: Candidate agent results.
171
+
172
+ Returns:
173
+ Dict with per-game and aggregate comparisons.
174
+ """
175
+ comparison = {
176
+ "games": {},
177
+ "aggregate": {},
178
+ }
179
+
180
+ for game_id in baseline.get("games", {}):
181
+ if game_id in candidate.get("games", {}):
182
+ b = baseline["games"][game_id]
183
+ c = candidate["games"][game_id]
184
+ comparison["games"][game_id] = {
185
+ "score_delta": c["score"] - b["score"],
186
+ "action_delta": c["total_actions"] - b["total_actions"],
187
+ "win_delta": c["levels_won"] - b["levels_won"],
188
+ }
189
+
190
+ b_agg = baseline.get("aggregate", {})
191
+ c_agg = candidate.get("aggregate", {})
192
+ comparison["aggregate"] = {
193
+ "score_delta": c_agg.get("total_score", 0) - b_agg.get("total_score", 0),
194
+ "win_rate_delta": c_agg.get("win_rate", 0) - b_agg.get("win_rate", 0),
195
+ "action_delta": c_agg.get("total_actions", 0) - b_agg.get("total_actions", 0),
196
+ }
197
+
198
+ return comparison
eval/run_local_eval.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Offline evaluation harness — runs the agent against local games.
2
+
3
+ Uses the toolkit's local-execution mode so iteration doesn't burn API
4
+ quota. Produces per-game/level score breakdowns and action-efficiency
5
+ metrics compared against random and human baselines.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import logging
13
+ import time
14
+ from pathlib import Path
15
+
16
+ import numpy as np
17
+
18
+ from agents.wayfinder.agent import WayfinderAgent
19
+ from eval.metrics import EvalMetrics, compute_efficiency
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ OUTPUT_DIR = Path("eval/results")
24
+
25
+
26
+ def run_local_eval(
27
+ agent_name: str = "wayfinder",
28
+ games: list[str] | None = None,
29
+ max_actions_per_level: int = 1000,
30
+ device: str = "cpu",
31
+ output_dir: Path | None = None,
32
+ ) -> dict:
33
+ """Run offline evaluation against local games.
34
+
35
+ Args:
36
+ agent_name: Name of the agent to evaluate.
37
+ games: List of game IDs to evaluate. If None, uses defaults.
38
+ max_actions_per_level: Max actions per level.
39
+ device: Torch device.
40
+ output_dir: Directory for output files.
41
+
42
+ Returns:
43
+ Evaluation results dict.
44
+ """
45
+ if games is None:
46
+ games = ["ls20", "ls21", "ls22"]
47
+
48
+ if output_dir is None:
49
+ output_dir = OUTPUT_DIR
50
+ output_dir.mkdir(parents=True, exist_ok=True)
51
+
52
+ agent = WayfinderAgent(
53
+ max_actions=max_actions_per_level,
54
+ device=device,
55
+ )
56
+
57
+ all_results = {}
58
+
59
+ for game_id in games:
60
+ logger.info("Evaluating game: %s", game_id)
61
+ game_result = _evaluate_game(agent, game_id, max_actions_per_level)
62
+ all_results[game_id] = game_result
63
+
64
+ # Compute aggregate metrics
65
+ total_score = sum(r["score"] for r in all_results.values())
66
+ total_actions = sum(r["total_actions"] for r in all_results.values())
67
+ total_levels = sum(r["levels_attempted"] for r in all_results.values())
68
+ levels_won = sum(r["levels_won"] for r in all_results.values())
69
+
70
+ summary = {
71
+ "agent": agent_name,
72
+ "games": all_results,
73
+ "aggregate": {
74
+ "total_score": total_score,
75
+ "total_actions": total_actions,
76
+ "total_levels": total_levels,
77
+ "levels_won": levels_won,
78
+ "win_rate": levels_won / max(total_levels, 1),
79
+ "avg_actions_per_level": total_actions / max(total_levels, 1),
80
+ },
81
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
82
+ }
83
+
84
+ # Save results
85
+ results_path = output_dir / f"eval_{agent_name}_{int(time.time())}.json"
86
+ with open(results_path, "w") as f:
87
+ json.dump(summary, f, indent=2)
88
+ logger.info("Results saved to %s", results_path)
89
+
90
+ return summary
91
+
92
+
93
+ def _evaluate_game(
94
+ agent: WayfinderAgent,
95
+ game_id: str,
96
+ max_actions: int,
97
+ ) -> dict:
98
+ """Evaluate the agent on a single game.
99
+
100
+ In production, this uses the SDK's local execution mode. For
101
+ scaffolding/testing, it simulates with random frames.
102
+
103
+ Args:
104
+ agent: The agent to evaluate.
105
+ game_id: Game identifier.
106
+ max_actions: Max actions per level.
107
+
108
+ Returns:
109
+ Dict with score, actions, levels, etc.
110
+ """
111
+ agent.reset()
112
+
113
+ metrics = EvalMetrics()
114
+ levels_attempted = 0
115
+ levels_won = 0
116
+ total_score = 0.0
117
+ total_actions = 0
118
+
119
+ try:
120
+ # Try to use the SDK's local execution
121
+ from arc_agi_3 import LocalEnvironment # type: ignore[import]
122
+
123
+ env = LocalEnvironment(game_id=game_id)
124
+ frames, state, score, win_score, available = env.reset()
125
+
126
+ while state == "NOT_FINISHED":
127
+ result = agent.act(
128
+ frames=frames,
129
+ state=state,
130
+ score=score,
131
+ win_score=win_score,
132
+ available_actions=available,
133
+ )
134
+ frames, state, score, win_score, available = env.step(result)
135
+ metrics.record_step(score, result["action"])
136
+ total_actions += 1
137
+
138
+ if agent.is_done(frames, state):
139
+ break
140
+
141
+ levels_attempted = 1
142
+ levels_won = 1 if state == "WIN" else 0
143
+ total_score = score
144
+
145
+ except ImportError:
146
+ # SDK not available — simulate
147
+ logger.warning("SDK not available — running simulation for %s", game_id)
148
+
149
+ for level in range(3): # Simulate 3 levels
150
+ agent.reset()
151
+ levels_attempted += 1
152
+ level_score = 0.0
153
+
154
+ for step in range(max_actions):
155
+ frame = np.random.randint(0, 16, size=(64, 64), dtype=np.uint8)
156
+ state = "NOT_FINISHED"
157
+
158
+ result = agent.act(
159
+ frames=[frame],
160
+ state=state,
161
+ score=level_score,
162
+ win_score=1.0,
163
+ available_actions=["ACTION1", "ACTION2", "ACTION3", "ACTION4", "ACTION5"],
164
+ )
165
+ metrics.record_step(level_score, result["action"])
166
+ total_actions += 1
167
+
168
+ # Random chance of "winning" for simulation
169
+ if np.random.random() < 0.01:
170
+ levels_won += 1
171
+ level_score = 1.0
172
+ break
173
+
174
+ if agent.is_done([frame], state):
175
+ break
176
+
177
+ total_score += level_score
178
+
179
+ stats = metrics.compute()
180
+ stats["efficiency"] = compute_efficiency(stats, max_actions)
181
+
182
+ return {
183
+ "score": total_score,
184
+ "total_actions": total_actions,
185
+ "levels_attempted": levels_attempted,
186
+ "levels_won": levels_won,
187
+ "metrics": stats,
188
+ }
189
+
190
+
191
+ def main() -> int:
192
+ """CLI entry point for evaluation."""
193
+ parser = argparse.ArgumentParser(description="Run local evaluation")
194
+ parser.add_argument("--agent", default="wayfinder")
195
+ parser.add_argument("--games", default="ls20,ls21,ls22", help="Comma-separated game IDs")
196
+ parser.add_argument("--max-actions", type=int, default=1000)
197
+ parser.add_argument("--device", default="cpu")
198
+ parser.add_argument("--output-dir", default="eval/results")
199
+ parser.add_argument("-v", "--verbose", action="store_true")
200
+
201
+ args = parser.parse_args()
202
+
203
+ logging.basicConfig(
204
+ level=logging.DEBUG if args.verbose else logging.INFO,
205
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
206
+ )
207
+
208
+ results = run_local_eval(
209
+ agent_name=args.agent,
210
+ games=args.games.split(","),
211
+ max_actions_per_level=args.max_actions,
212
+ device=args.device,
213
+ output_dir=Path(args.output_dir),
214
+ )
215
+
216
+ # Print summary
217
+ agg = results["aggregate"]
218
+ print("\n" + "=" * 60)
219
+ print(f"Agent: {results['agent']}")
220
+ print(f"Games: {len(results['games'])}")
221
+ print(f"Levels: {agg['total_levels']} (won: {agg['levels_won']})")
222
+ print(f"Win rate: {agg['win_rate']:.1%}")
223
+ print(f"Total score: {agg['total_score']:.2f}")
224
+ print(f"Total actions: {agg['total_actions']}")
225
+ print(f"Avg actions/level: {agg['avg_actions_per_level']:.1f}")
226
+ print("=" * 60)
227
+
228
+ return 0
229
+
230
+
231
+ if __name__ == "__main__":
232
+ import sys
233
+ sys.exit(main())
main.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """Entry point for running the Wayfinder agent via the SDK.
2
+
3
+ Usage:
4
+ uv run main.py --agent=wayfinder --game=ls20
5
+ """
6
+
7
+ from agents.wayfinder.cli import main
8
+
9
+ if __name__ == "__main__":
10
+ main()
notebooks/kaggle_submission.ipynb ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Kaggle submission notebook (rendered as Python for scaffolding).
2
+
3
+ In the actual Kaggle environment, this is a Jupyter notebook. Here we
4
+ provide the cell-by-cell Python equivalent that can be pasted into
5
+ a notebook.
6
+
7
+ Cell 1: Setup and installation
8
+ Cell 2: Import agent
9
+ Cell 3: Run agent against all games
10
+ Cell 4: Submit results
11
+ """
12
+
13
+ # === Cell 1: Setup ===
14
+ # Install dependencies (internet is disabled during scoring, but available
15
+ # during notebook setup before committing)
16
+ # !pip install -q arc-agi-3 torch numpy pyyaml
17
+
18
+ import os
19
+ import sys
20
+ import logging
21
+ import numpy as np
22
+
23
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
24
+ logger = logging.getLogger("kaggle_submission")
25
+
26
+ DEVICE = "cuda" if os.environ.get("KAGGLE_GPU_TYPE") else "cpu"
27
+ logger.info("Device: %s", DEVICE)
28
+
29
+
30
+ # === Cell 2: Import agent ===
31
+ # The agent code is included in the notebook's environment (uploaded as
32
+ # a dataset or pip package).
33
+ sys.path.insert(0, "/kaggle/input/wayfinder-agent")
34
+
35
+ from agents.wayfinder.agent import WayfinderAgent
36
+
37
+ agent = WayfinderAgent(
38
+ max_actions=1000,
39
+ latent_dim=256,
40
+ buffer_size=200_000,
41
+ device=DEVICE,
42
+ )
43
+ logger.info("Agent initialized")
44
+
45
+
46
+ # === Cell 3: Run against all games ===
47
+ # The SDK provides the game list and harness.
48
+ try:
49
+ from arc_agi_3 import run_agent # type: ignore[import]
50
+
51
+ results = run_agent(
52
+ agent=agent,
53
+ games="all", # or a specific list
54
+ local_mode=True, # No internet needed
55
+ )
56
+ logger.info("Evaluation complete: %s", results)
57
+ except ImportError:
58
+ logger.error("arc-agi-3 SDK not available. Ensure it's installed.")
59
+
60
+
61
+ # === Cell 4: Output results ===
62
+ # The Kaggle harness expects results in a specific format.
63
+ # This cell ensures the output is written correctly.
64
+ import json
65
+
66
+ output_path = "/kaggle/working/results.json"
67
+ try:
68
+ with open(output_path, "w") as f:
69
+ json.dump(results, f, indent=2)
70
+ logger.info("Results written to %s", output_path)
71
+ except NameError:
72
+ logger.warning("No results to write (SDK not available)")
pyproject.toml ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "arc-agi-3-wayfinder"
7
+ version = "0.1.0"
8
+ description = "ARC Prize 2026 — ARC-AGI-3 track competition agent"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "Wayfinder Team" }]
13
+ keywords = ["arc-agi", "reinforcement-learning", "game-ai"]
14
+
15
+ dependencies = [
16
+ "arc-agi-3>=0.9.0",
17
+ "torch>=2.2.0",
18
+ "numpy>=1.26.0",
19
+ "pyyaml>=6.0",
20
+ "rich>=13.0.0",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ dev = [
25
+ "pytest>=8.0.0",
26
+ "pytest-cov>=5.0.0",
27
+ "ruff>=0.4.0",
28
+ "mypy>=1.10.0",
29
+ ]
30
+ training = [
31
+ "wandb>=0.17.0",
32
+ "tensorboard>=2.16.0",
33
+ ]
34
+
35
+ [project.scripts]
36
+ wayfinder = "agents.wayfinder.cli:main"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["agents"]
40
+
41
+ [tool.ruff]
42
+ target-version = "py311"
43
+ line-length = 100
44
+ src = ["agents", "training", "eval", "tests"]
45
+
46
+ [tool.ruff.lint]
47
+ select = [
48
+ "E", # pycodestyle errors
49
+ "W", # pycodestyle warnings
50
+ "F", # pyflakes
51
+ "I", # isort
52
+ "B", # flake8-bugbear
53
+ "C4", # flake8-comprehensions
54
+ "UP", # pyupgrade
55
+ "SIM", # flake8-simplify
56
+ "TCH", # flake8-type-checking
57
+ ]
58
+ ignore = [
59
+ "E501", # line too long — handled by formatter
60
+ "B008", # function call in default argument
61
+ ]
62
+
63
+ [tool.ruff.lint.isort]
64
+ known-first-party = ["agents", "training", "eval"]
65
+
66
+ [tool.ruff.format]
67
+ quote-style = "double"
68
+ indent-style = "space"
69
+ skip-magic-trailing-comma = false
70
+
71
+ [tool.pytest.ini_options]
72
+ minversion = "8.0"
73
+ testpaths = ["tests"]
74
+ addopts = "-v --tb=short --strict-markers"
75
+ markers = [
76
+ "slow: marks tests as slow (deselect with '-m \"not slow\"')",
77
+ "integration: marks tests requiring the SDK environment",
78
+ ]
79
+
80
+ [tool.mypy]
81
+ python_version = "3.11"
82
+ strict = true
83
+ warn_return_any = true
84
+ warn_unused_configs = true
85
+ disallow_untyped_defs = true
86
+
87
+ [[tool.mypy.overrides]]
88
+ module = "arc_agi_3.*"
89
+ ignore_missing_imports = true
tests/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Test package for Wayfinder agent."""
tests/test_action_head.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the action head module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import pytest
7
+
8
+ from agents.wayfinder.action_head import ActionHead
9
+ from agents.wayfinder.world_model import WorldModel
10
+
11
+
12
+ class TestActionHead:
13
+ """Test cases for ActionHead."""
14
+
15
+ @pytest.fixture
16
+ def action_head(self) -> ActionHead:
17
+ """Create a test action head."""
18
+ return ActionHead(latent_dim=32, device="cpu")
19
+
20
+ @pytest.fixture
21
+ def world_model(self) -> WorldModel:
22
+ """Create a test world model."""
23
+ return WorldModel(latent_dim=32, device="cpu")
24
+
25
+ @pytest.fixture
26
+ def latent(self) -> np.ndarray:
27
+ """Create a sample latent."""
28
+ return np.random.randn(32).astype(np.float32)
29
+
30
+ def test_select_returns_valid_action(
31
+ self, action_head: ActionHead, world_model: WorldModel, latent: np.ndarray
32
+ ) -> None:
33
+ """Test that select returns a valid action."""
34
+ result = action_head.select(
35
+ latent=latent,
36
+ diff_mask=np.zeros((64, 64), dtype=bool),
37
+ available_actions=["ACTION1", "ACTION2", "ACTION3"],
38
+ world_model=world_model,
39
+ epsilon=0.0,
40
+ )
41
+ assert "action" in result
42
+ assert result["action"] in ["ACTION1", "ACTION2", "ACTION3"]
43
+
44
+ def test_select_action6_includes_coordinates(
45
+ self, action_head: ActionHead, world_model: WorldModel, latent: np.ndarray
46
+ ) -> None:
47
+ """Test that ACTION6 includes x, y coordinates."""
48
+ result = action_head.select(
49
+ latent=latent,
50
+ diff_mask=np.zeros((64, 64), dtype=bool),
51
+ available_actions=["ACTION6"],
52
+ world_model=world_model,
53
+ epsilon=0.0,
54
+ )
55
+ assert result["action"] == "ACTION6"
56
+ assert "data" in result
57
+ assert "x" in result["data"]
58
+ assert "y" in result["data"]
59
+ assert 0 <= result["data"]["x"] < 64
60
+ assert 0 <= result["data"]["y"] < 64
61
+
62
+ def test_epsilon_greedy_can_explore(
63
+ self, action_head: ActionHead, world_model: WorldModel, latent: np.ndarray
64
+ ) -> None:
65
+ """Test that epsilon > 0 allows random exploration."""
66
+ actions_taken: set[str] = set()
67
+ for _ in range(50):
68
+ result = action_head.select(
69
+ latent=latent,
70
+ diff_mask=np.zeros((64, 64), dtype=bool),
71
+ available_actions=["ACTION1", "ACTION2", "ACTION3"],
72
+ world_model=world_model,
73
+ epsilon=1.0, # Always explore
74
+ )
75
+ actions_taken.add(result["action"])
76
+ # Should have tried multiple different actions
77
+ assert len(actions_taken) > 1
tests/test_agent.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Integration tests for the WayfinderAgent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import pytest
7
+
8
+ from agents.wayfinder.agent import WayfinderAgent
9
+
10
+
11
+ class TestWayfinderAgent:
12
+ """Integration test cases for WayfinderAgent."""
13
+
14
+ @pytest.fixture
15
+ def agent(self) -> WayfinderAgent:
16
+ """Create a test agent with small dimensions."""
17
+ return WayfinderAgent(
18
+ max_actions=100,
19
+ latent_dim=32,
20
+ buffer_size=1000,
21
+ device="cpu",
22
+ )
23
+
24
+ @pytest.fixture
25
+ def frame(self) -> np.ndarray:
26
+ """Create a sample frame."""
27
+ return np.random.randint(0, 16, size=(64, 64), dtype=np.uint8)
28
+
29
+ def test_act_returns_valid_action(self, agent: WayfinderAgent, frame: np.ndarray) -> None:
30
+ """Test that act returns a valid action dict."""
31
+ result = agent.act(
32
+ frames=[frame],
33
+ state="NOT_FINISHED",
34
+ score=0.0,
35
+ win_score=1.0,
36
+ available_actions=["ACTION1", "ACTION2", "ACTION3", "ACTION4", "ACTION5"],
37
+ )
38
+ assert "action" in result
39
+ assert "reasoning" in result
40
+ assert result["action"] in ["ACTION1", "ACTION2", "ACTION3", "ACTION4", "ACTION5"]
41
+
42
+ def test_is_done_on_win(self, agent: WayfinderAgent) -> None:
43
+ """Test that is_done returns True on WIN state."""
44
+ assert agent.is_done([], "WIN")
45
+
46
+ def test_is_done_on_game_over(self, agent: WayfinderAgent) -> None:
47
+ """Test that is_done returns True on GAME_OVER state."""
48
+ assert agent.is_done([], "GAME_OVER")
49
+
50
+ def test_is_done_on_budget_exhausted(self, agent: WayfinderAgent) -> None:
51
+ """Test that is_done returns True when action budget is exhausted."""
52
+ agent.action_count = agent.max_actions
53
+ assert agent.is_done([], "NOT_FINISHED")
54
+
55
+ def test_is_done_false_during_play(self, agent: WayfinderAgent) -> None:
56
+ """Test that is_done returns False during normal play."""
57
+ agent.action_count = 5
58
+ assert not agent.is_done([], "NOT_FINISHED")
59
+
60
+ def test_reset_clears_state(self, agent: WayfinderAgent, frame: np.ndarray) -> None:
61
+ """Test that reset clears per-level state."""
62
+ agent.act(
63
+ frames=[frame],
64
+ state="NOT_FINISHED",
65
+ score=0.0,
66
+ win_score=1.0,
67
+ available_actions=["ACTION1"],
68
+ )
69
+ assert agent.action_count > 0
70
+
71
+ agent.reset()
72
+ assert agent.action_count == 0
73
+ assert agent._prev_latent is None
74
+
75
+ def test_multiple_steps_accumulate_transitions(
76
+ self, agent: WayfinderAgent, frame: np.ndarray
77
+ ) -> None:
78
+ """Test that multiple steps build up the world model buffer."""
79
+ for i in range(5):
80
+ f = np.random.randint(0, 16, size=(64, 64), dtype=np.uint8)
81
+ agent.act(
82
+ frames=[f],
83
+ state="NOT_FINISHED",
84
+ score=0.0,
85
+ win_score=1.0,
86
+ available_actions=["ACTION1", "ACTION2", "ACTION3"],
87
+ )
88
+ # After 5 steps, at least 3 transitions should be in the buffer
89
+ assert agent._world_model.buffer_size_current >= 3
90
+
91
+ def test_reasoning_blob_has_required_fields(self, agent: WayfinderAgent, frame: np.ndarray) -> None:
92
+ """Test that the reasoning blob contains required audit fields."""
93
+ result = agent.act(
94
+ frames=[frame],
95
+ state="NOT_FINISHED",
96
+ score=0.0,
97
+ win_score=1.0,
98
+ available_actions=["ACTION1"],
99
+ )
100
+ reasoning = result["reasoning"]
101
+ assert "step" in reasoning
102
+ assert "score" in reasoning
103
+ assert "model_confidence" in reasoning
104
+ assert "novelty" in reasoning
tests/test_intrinsic_reward.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the intrinsic reward module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from agents.wayfinder.intrinsic_reward import IntrinsicReward, RewardConfig
8
+
9
+
10
+ class TestIntrinsicReward:
11
+ """Test cases for IntrinsicReward."""
12
+
13
+ @pytest.fixture
14
+ def reward(self) -> IntrinsicReward:
15
+ """Create a test intrinsic reward module."""
16
+ return IntrinsicReward(RewardConfig(w_extrinsic=1.0, w_novelty=0.3, w_curiosity=0.2))
17
+
18
+ def test_compute_returns_float(self, reward: IntrinsicReward) -> None:
19
+ """Test that compute returns a float."""
20
+ result = reward.compute(extrinsic_delta=1.0, novelty=0.5, prediction_error=2.0)
21
+ assert isinstance(result, float)
22
+
23
+ def test_higher_extrinsic_gives_higher_utility(self, reward: IntrinsicReward) -> None:
24
+ """Test that higher extrinsic delta increases utility."""
25
+ low = reward.compute(extrinsic_delta=0.0, novelty=0.5, prediction_error=1.0)
26
+ high = reward.compute(extrinsic_delta=1.0, novelty=0.5, prediction_error=1.0)
27
+ assert high > low
28
+
29
+ def test_higher_novelty_gives_higher_utility(self, reward: IntrinsicReward) -> None:
30
+ """Test that higher novelty increases utility."""
31
+ low = reward.compute(extrinsic_delta=0.5, novelty=0.0, prediction_error=1.0)
32
+ high = reward.compute(extrinsic_delta=0.5, novelty=1.0, prediction_error=1.0)
33
+ assert high > low
34
+
35
+ def test_curiosity_clip(self) -> None:
36
+ """Test that curiosity is clipped."""
37
+ reward = IntrinsicReward(RewardConfig(w_curiosity=1.0, curiosity_clip=5.0))
38
+ clipped = reward.compute(extrinsic_delta=0.0, novelty=0.0, prediction_error=100.0)
39
+ # Curiosity is clipped to 5.0, and w_curiosity=1.0, so utility should be 5.0
40
+ assert abs(clipped - 5.0) < 0.1
41
+
42
+ def test_update_weights(self, reward: IntrinsicReward) -> None:
43
+ """Test that weights can be updated."""
44
+ reward.update_weights(w_extrinsic=2.0)
45
+ assert reward.config.w_extrinsic == 2.0
46
+
47
+ def test_reset(self, reward: IntrinsicReward) -> None:
48
+ """Test that reset clears running baseline."""
49
+ reward.compute(extrinsic_delta=10.0)
50
+ reward.reset()
51
+ assert reward._running_baseline == 0.0
52
+
53
+ def test_config_from_yaml(self, tmp_path) -> None:
54
+ """Test loading config from YAML."""
55
+ import yaml
56
+ config_data = {"intrinsic_reward": {"w_extrinsic": 2.0, "w_novelty": 0.5}}
57
+ config_path = tmp_path / "config.yaml"
58
+ with open(config_path, "w") as f:
59
+ yaml.dump(config_data, f)
60
+
61
+ config = RewardConfig.from_yaml(config_path)
62
+ assert config.w_extrinsic == 2.0
63
+ assert config.w_novelty == 0.5
tests/test_memory_graph.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the memory graph module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import pytest
7
+
8
+ from agents.wayfinder.memory_graph import MemoryGraph
9
+ from agents.wayfinder.perception import GRID_SIZE
10
+
11
+
12
+ class TestMemoryGraph:
13
+ """Test cases for MemoryGraph."""
14
+
15
+ @pytest.fixture
16
+ def graph(self) -> MemoryGraph:
17
+ """Create a test memory graph."""
18
+ return MemoryGraph()
19
+
20
+ @pytest.fixture
21
+ def sample_frame(self) -> np.ndarray:
22
+ """Create a sample frame."""
23
+ return np.random.randint(0, 16, size=(GRID_SIZE, GRID_SIZE), dtype=np.uint8)
24
+
25
+ def test_hash_frame_stable(self, graph: MemoryGraph, sample_frame: np.ndarray) -> None:
26
+ """Test that hashing is deterministic."""
27
+ h1 = graph.hash_frame(sample_frame)
28
+ h2 = graph.hash_frame(sample_frame)
29
+ assert h1 == h2
30
+
31
+ def test_hash_frame_different_for_different_frames(self, graph: MemoryGraph) -> None:
32
+ """Test that different frames have different hashes."""
33
+ f1 = np.zeros((GRID_SIZE, GRID_SIZE), dtype=np.uint8)
34
+ f2 = np.ones((GRID_SIZE, GRID_SIZE), dtype=np.uint8)
35
+ assert graph.hash_frame(f1) != graph.hash_frame(f2)
36
+
37
+ def test_add_node_new(self, graph: MemoryGraph, sample_frame: np.ndarray) -> None:
38
+ """Test adding a new node."""
39
+ h = graph.hash_frame(sample_frame)
40
+ latent = np.random.randn(64).astype(np.float32)
41
+ graph.add_node(h, latent, score=0.5)
42
+ assert h in graph.nodes
43
+ assert graph.nodes[h]["visit_count"] == 1
44
+ assert graph.nodes[h]["score"] == 0.5
45
+
46
+ def test_add_node_existing_increments_visits(self, graph: MemoryGraph, sample_frame: np.ndarray) -> None:
47
+ """Test that re-adding a node increments its visit count."""
48
+ h = graph.hash_frame(sample_frame)
49
+ latent = np.random.randn(64).astype(np.float32)
50
+ graph.add_node(h, latent)
51
+ graph.add_node(h, latent)
52
+ graph.add_node(h, latent)
53
+ assert graph.nodes[h]["visit_count"] == 3
54
+
55
+ def test_novelty_new_node(self, graph: MemoryGraph) -> None:
56
+ """Test novelty is 1.0 for unseen nodes."""
57
+ assert graph.novelty("nonexistent") == 1.0
58
+
59
+ def test_novelty_decreases_with_visits(self, graph: MemoryGraph, sample_frame: np.ndarray) -> None:
60
+ """Test that novelty decreases as visit count increases."""
61
+ h = graph.hash_frame(sample_frame)
62
+ latent = np.random.randn(64).astype(np.float32)
63
+ graph.add_node(h, latent)
64
+ n1 = graph.novelty(h)
65
+ graph.add_node(h, latent)
66
+ n2 = graph.novelty(h)
67
+ assert n2 < n1
68
+ assert 0 < n2 < 1
69
+
70
+ def test_add_edge(self, graph: MemoryGraph, sample_frame: np.ndarray) -> None:
71
+ """Test adding an edge between nodes."""
72
+ h1 = graph.hash_frame(sample_frame)
73
+ h2 = graph.hash_frame(sample_frame + 1)
74
+ latent = np.random.randn(64).astype(np.float32)
75
+ graph.add_node(h1, latent)
76
+ graph.add_node(h2, latent)
77
+ graph.add_edge(h1, h2, "ACTION1")
78
+
79
+ assert (h1, h2) in graph.edges
80
+ assert graph.edges[(h1, h2)]["action"] == "ACTION1"
81
+ assert len(graph.adjacency[h1]) == 1
82
+
83
+ def test_reset_clears_graph(self, graph: MemoryGraph, sample_frame: np.ndarray) -> None:
84
+ """Test that reset clears all nodes and edges."""
85
+ h = graph.hash_frame(sample_frame)
86
+ latent = np.random.randn(64).astype(np.float32)
87
+ graph.add_node(h, latent)
88
+ graph.add_edge(h, h, "ACTION1")
89
+
90
+ graph.reset()
91
+ assert len(graph.nodes) == 0
92
+ assert len(graph.edges) == 0
93
+
94
+ def test_stats(self, graph: MemoryGraph, sample_frame: np.ndarray) -> None:
95
+ """Test stats returns correct summary."""
96
+ h = graph.hash_frame(sample_frame)
97
+ latent = np.random.randn(64).astype(np.float32)
98
+ graph.add_node(h, latent)
99
+ graph.add_node(h, latent)
100
+ stats = graph.stats()
101
+ assert stats["node_count"] == 1
102
+ assert stats["avg_visits"] == 2.0
tests/test_perception.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the perception encoder module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import pytest
7
+
8
+ from agents.wayfinder.perception import PerceptionEncoder, GRID_SIZE, NUM_COLORS
9
+
10
+
11
+ class TestPerceptionEncoder:
12
+ """Test cases for PerceptionEncoder."""
13
+
14
+ @pytest.fixture
15
+ def encoder(self) -> PerceptionEncoder:
16
+ """Create a test encoder instance."""
17
+ return PerceptionEncoder(latent_dim=64, device="cpu")
18
+
19
+ @pytest.fixture
20
+ def sample_frame(self) -> np.ndarray:
21
+ """Create a sample 64×64 frame."""
22
+ return np.random.randint(0, NUM_COLORS, size=(GRID_SIZE, GRID_SIZE), dtype=np.uint8)
23
+
24
+ def test_encode_returns_correct_shapes(self, encoder: PerceptionEncoder, sample_frame: np.ndarray) -> None:
25
+ """Test that encode returns latent and diff_mask with correct shapes."""
26
+ latent, diff_mask = encoder.encode(sample_frame)
27
+ assert latent.shape == (64,)
28
+ assert diff_mask.shape == (GRID_SIZE, GRID_SIZE)
29
+ assert diff_mask.dtype == bool
30
+
31
+ def test_first_frame_diff_is_zero(self, encoder: PerceptionEncoder, sample_frame: np.ndarray) -> None:
32
+ """Test that the first frame has an all-zero diff mask."""
33
+ _, diff_mask = encoder.encode(sample_frame)
34
+ assert not np.any(diff_mask)
35
+
36
+ def test_second_frame_diff_detects_changes(self, encoder: PerceptionEncoder, sample_frame: np.ndarray) -> None:
37
+ """Test that diff mask detects changes between frames."""
38
+ encoder.encode(sample_frame)
39
+ modified = sample_frame.copy()
40
+ modified[0, 0] = (modified[0, 0] + 1) % NUM_COLORS
41
+ _, diff_mask = encoder.encode(modified)
42
+ assert diff_mask[0, 0]
43
+ assert not diff_mask[1, 1] # Unchanged pixel
44
+
45
+ def test_encode_batch(self, encoder: PerceptionEncoder) -> None:
46
+ """Test batch encoding."""
47
+ frames = np.random.randint(0, NUM_COLORS, size=(4, GRID_SIZE, GRID_SIZE), dtype=np.uint8)
48
+ latents = encoder.encode_batch(frames)
49
+ assert latents.shape == (4, 64)
50
+
51
+ def test_latent_is_float32(self, encoder: PerceptionEncoder, sample_frame: np.ndarray) -> None:
52
+ """Test that the latent is float32."""
53
+ latent, _ = encoder.encode(sample_frame)
54
+ assert latent.dtype == np.float32
tests/test_planner.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the planner module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import pytest
7
+
8
+ from agents.wayfinder.planner import Planner, PlannerNode
9
+ from agents.wayfinder.world_model import WorldModel
10
+ from agents.wayfinder.intrinsic_reward import IntrinsicReward
11
+
12
+
13
+ class TestPlannerNode:
14
+ """Test cases for PlannerNode."""
15
+
16
+ def test_ucb1_infinite_for_unvisited(self) -> None:
17
+ """Test that UCB1 is infinite for unvisited nodes."""
18
+ node = PlannerNode(latent=np.zeros(32))
19
+ assert node.ucb1() == float("inf")
20
+
21
+ def test_ucb1_finite_after_visit(self) -> None:
22
+ """Test that UCB1 is finite after a visit."""
23
+ parent = PlannerNode(latent=np.zeros(32))
24
+ parent.visits = 10
25
+ node = PlannerNode(latent=np.zeros(32), parent=parent)
26
+ node.visits = 1
27
+ node.total_value = 0.5
28
+ assert node.ucb1() < float("inf")
29
+
30
+ def test_mean_value(self) -> None:
31
+ """Test mean value computation."""
32
+ node = PlannerNode(latent=np.zeros(32))
33
+ node.visits = 4
34
+ node.total_value = 2.0
35
+ assert node.mean_value == 0.5
36
+
37
+ def test_mean_value_zero_visits(self) -> None:
38
+ """Test mean value is 0 for unvisited nodes."""
39
+ node = PlannerNode(latent=np.zeros(32))
40
+ assert node.mean_value == 0.0
41
+
42
+
43
+ class TestPlanner:
44
+ """Test cases for Planner."""
45
+
46
+ @pytest.fixture
47
+ def planner(self) -> Planner:
48
+ """Create a test planner."""
49
+ world_model = WorldModel(latent_dim=32, device="cpu")
50
+ reward_module = IntrinsicReward()
51
+ return Planner(
52
+ world_model=world_model,
53
+ reward_module=reward_module,
54
+ max_depth=3,
55
+ max_simulations=10,
56
+ )
57
+
58
+ def test_plan_returns_action_dict(self, planner: Planner) -> None:
59
+ """Test that plan returns a valid action dict."""
60
+ latent = np.random.randn(32).astype(np.float32)
61
+ result = planner.plan(
62
+ latent=latent,
63
+ available_actions=["ACTION1", "ACTION2", "ACTION3"],
64
+ action_budget_remaining=100,
65
+ utility_fn=lambda **kw: 0.0,
66
+ )
67
+ assert "action" in result
68
+ assert result["action"] in ["ACTION1", "ACTION2", "ACTION3"]
69
+
70
+ def test_plan_fallback_on_no_actions(self, planner: Planner) -> None:
71
+ """Test that plan falls back when no valid actions."""
72
+ latent = np.random.randn(32).astype(np.float32)
73
+ result = planner.plan(
74
+ latent=latent,
75
+ available_actions=["ACTION1"],
76
+ action_budget_remaining=100,
77
+ utility_fn=lambda **kw: 0.0,
78
+ )
79
+ assert "action" in result
80
+
81
+ def test_plan_scales_with_budget(self, planner: Planner) -> None:
82
+ """Test that planning scales with remaining budget."""
83
+ latent = np.random.randn(32).astype(np.float32)
84
+ # Should not crash with very low budget
85
+ result = planner.plan(
86
+ latent=latent,
87
+ available_actions=["ACTION1", "ACTION2"],
88
+ action_budget_remaining=1,
89
+ utility_fn=lambda **kw: 0.0,
90
+ )
91
+ assert "action" in result
tests/test_world_model.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the world model module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import pytest
7
+
8
+ from agents.wayfinder.world_model import WorldModel, Transition
9
+
10
+
11
+ class TestWorldModel:
12
+ """Test cases for WorldModel."""
13
+
14
+ @pytest.fixture
15
+ def model(self) -> WorldModel:
16
+ """Create a test world model."""
17
+ return WorldModel(latent_dim=32, buffer_size=1000, device="cpu")
18
+
19
+ @pytest.fixture
20
+ def sample_latent(self) -> np.ndarray:
21
+ """Create a sample latent vector."""
22
+ return np.random.randn(32).astype(np.float32)
23
+
24
+ def test_predict_change_returns_probability(self, model: WorldModel, sample_latent: np.ndarray) -> None:
25
+ """Test that predict_change returns a valid probability."""
26
+ prob = model.predict_change(sample_latent, {"action": "ACTION1"})
27
+ assert 0.0 <= prob <= 1.0
28
+
29
+ def test_predict_next_latent_shape(self, model: WorldModel, sample_latent: np.ndarray) -> None:
30
+ """Test that predict_next_latent returns correct shape."""
31
+ next_latent = model.predict_next_latent(sample_latent, {"action": "ACTION1"})
32
+ assert next_latent.shape == (32,)
33
+
34
+ def test_add_transition_increases_buffer(self, model: WorldModel, sample_latent: np.ndarray) -> None:
35
+ """Test that add_transition adds to the buffer."""
36
+ assert model.buffer_size_current == 0
37
+ model.add_transition(
38
+ state_latent=sample_latent,
39
+ action={"action": "ACTION1"},
40
+ next_latent=sample_latent + 0.1,
41
+ frame_changed=True,
42
+ )
43
+ assert model.buffer_size_current == 1
44
+
45
+ def test_add_transition_deduplicates(self, model: WorldModel, sample_latent: np.ndarray) -> None:
46
+ """Test that identical transitions are deduplicated."""
47
+ for _ in range(3):
48
+ model.add_transition(
49
+ state_latent=sample_latent,
50
+ action={"action": "ACTION1"},
51
+ next_latent=sample_latent + 0.1,
52
+ frame_changed=True,
53
+ )
54
+ assert model.buffer_size_current == 1
55
+
56
+ def test_train_step_returns_loss(self, model: WorldModel, sample_latent: np.ndarray) -> None:
57
+ """Test that train_step returns a loss value after enough data."""
58
+ # Add enough transitions for a batch
59
+ for i in range(70):
60
+ model.add_transition(
61
+ state_latent=sample_latent + i * 0.01,
62
+ action={"action": f"ACTION{i % 4 + 1}"},
63
+ next_latent=sample_latent + (i + 1) * 0.01,
64
+ frame_changed=(i % 2 == 0),
65
+ )
66
+ loss = model.train_step(batch_size=32)
67
+ assert loss >= 0.0
68
+
69
+ def test_confidence_starts_low(self, model: WorldModel) -> None:
70
+ """Test that confidence is 0 before training."""
71
+ assert model.confidence() == 0.0
72
+
73
+ def test_prediction_error_zero_on_first_step(self, model: WorldModel) -> None:
74
+ """Test that prediction error is 0 on the first step."""
75
+ latent = np.random.randn(32).astype(np.float32)
76
+ assert model.prediction_error(None, None, latent) == 0.0
training/configs/default.yaml ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Default configuration for Wayfinder agent training
2
+
3
+ agent:
4
+ max_actions: 1000
5
+ latent_dim: 256
6
+ buffer_size: 200000
7
+ device: cpu # or cuda
8
+
9
+ perception:
10
+ latent_dim: 256
11
+ conv_channels: [32, 64, 128]
12
+ conv_kernel: 3
13
+
14
+ world_model:
15
+ latent_dim: 256
16
+ action_embed_dim: 32
17
+ hidden_dim: 256
18
+ lr: 0.0001
19
+ buffer_size: 200000
20
+ batch_size: 64
21
+ entropy_reg: 0.01
22
+ fwd_loss_weight: 0.5
23
+
24
+ intrinsic_reward:
25
+ w_extrinsic: 1.0
26
+ w_novelty: 0.3
27
+ w_curiosity: 0.2
28
+ novelty_decay: 0.95
29
+ curiosity_clip: 10.0
30
+ score_baseline: 0.0
31
+
32
+ planner:
33
+ max_depth: 5
34
+ max_simulations: 50
35
+ exploration_constant: 1.414
36
+ discount: 0.95
37
+
38
+ action_head:
39
+ temperature: 0.5
40
+ epsilon: 0.15
41
+ coord_head_channels: [32, 16]
42
+
43
+ training:
44
+ mode: online # or offline
45
+ epochs: 50
46
+ batch_size: 64
47
+ lr: 0.0001
48
+ eval_interval: 10
49
+ save_interval: 20
50
+
51
+ eval:
52
+ games: ["ls20", "ls21", "ls22"]
53
+ max_actions_per_level: 1000
54
+ output_dir: "eval/results"
training/replay_buffer.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deduplicated replay buffer for offline + online training.
2
+
3
+ Stores transitions with frame-level deduplication (hash-based) to
4
+ avoid wasting training capacity on near-identical states. Supports
5
+ random sampling, prioritized sampling, and buffer persistence.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import logging
12
+ from collections import deque
13
+ from dataclasses import dataclass, field
14
+ from typing import Iterator
15
+
16
+ import numpy as np
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ @dataclass
22
+ class BufferedTransition:
23
+ """A transition stored in the replay buffer.
24
+
25
+ Attributes:
26
+ frame: 64×64 uint8 frame before the action.
27
+ action: Action name string.
28
+ action_data: Optional action data (e.g. coordinates).
29
+ next_frame: 64×64 uint8 frame after the action.
30
+ reward: Extrinsic reward (score delta).
31
+ frame_changed: Whether the frame visually changed.
32
+ frame_hash: MD5 hash of the frame (for dedup).
33
+ """
34
+
35
+ frame: np.ndarray
36
+ action: str
37
+ action_data: dict | None
38
+ next_frame: np.ndarray
39
+ reward: float
40
+ frame_changed: bool
41
+ frame_hash: str = ""
42
+
43
+ def __post_init__(self) -> None:
44
+ if not self.frame_hash:
45
+ self.frame_hash = hashlib.md5(self.frame.tobytes()).hexdigest()
46
+
47
+
48
+ class ReplayBuffer:
49
+ """Deduplicated replay buffer for transition storage.
50
+
51
+ Features:
52
+ - Frame-level deduplication (stores unique frames only once).
53
+ - Transition-level deduplication (same state+action → skip).
54
+ - Random and prioritized sampling.
55
+ - Configurable maximum size.
56
+
57
+ Attributes:
58
+ max_size: Maximum number of transitions.
59
+ frames: Dict mapping frame_hash → frame array (deduplicated storage).
60
+ transitions: Deque of BufferedTransition objects.
61
+ """
62
+
63
+ def __init__(self, max_size: int = 200_000) -> None:
64
+ """Initialize the replay buffer.
65
+
66
+ Args:
67
+ max_size: Maximum number of transitions to store.
68
+ """
69
+ self.max_size = max_size
70
+ self.frames: dict[str, np.ndarray] = {}
71
+ self.transitions: deque[BufferedTransition] = deque(maxlen=max_size)
72
+ self._seen_keys: set[str] = set()
73
+
74
+ logger.info("ReplayBuffer initialized (max_size=%d)", max_size)
75
+
76
+ def add(
77
+ self,
78
+ frame: np.ndarray,
79
+ action: str,
80
+ action_data: dict | None,
81
+ next_frame: np.ndarray,
82
+ reward: float,
83
+ frame_changed: bool,
84
+ ) -> bool:
85
+ """Add a transition to the buffer.
86
+
87
+ Args:
88
+ frame: Frame before action.
89
+ action: Action name.
90
+ action_data: Optional action data.
91
+ next_frame: Frame after action.
92
+ reward: Extrinsic reward.
93
+ frame_changed: Whether frame visually changed.
94
+
95
+ Returns:
96
+ True if the transition was added, False if deduplicated.
97
+ """
98
+ frame_hash = hashlib.md5(frame.tobytes()).hexdigest()
99
+ next_hash = hashlib.md5(next_frame.tobytes()).hexdigest()
100
+ dedup_key = f"{frame_hash}:{action}:{action_data}"
101
+
102
+ if dedup_key in self._seen_keys:
103
+ return False
104
+
105
+ self._seen_keys.add(dedup_key)
106
+
107
+ # Store unique frames
108
+ if frame_hash not in self.frames:
109
+ self.frames[frame_hash] = frame.copy()
110
+ if next_hash not in self.frames:
111
+ self.frames[next_hash] = next_frame.copy()
112
+
113
+ self.transitions.append(
114
+ BufferedTransition(
115
+ frame=frame.copy(),
116
+ action=action,
117
+ action_data=action_data,
118
+ next_frame=next_frame.copy(),
119
+ reward=reward,
120
+ frame_changed=frame_changed,
121
+ frame_hash=frame_hash,
122
+ )
123
+ )
124
+
125
+ return True
126
+
127
+ def sample(self, batch_size: int) -> list[BufferedTransition]:
128
+ """Sample a random batch of transitions.
129
+
130
+ Args:
131
+ batch_size: Number of transitions to sample.
132
+
133
+ Returns:
134
+ List of BufferedTransition objects.
135
+ """
136
+ if len(self.transitions) < batch_size:
137
+ return list(self.transitions)
138
+ indices = np.random.choice(len(self.transitions), size=batch_size, replace=False)
139
+ return [self.transitions[i] for i in indices]
140
+
141
+ def sample_prioritized(
142
+ self,
143
+ batch_size: int,
144
+ alpha: float = 0.6,
145
+ ) -> list[BufferedTransition]:
146
+ """Sample a batch with prioritization toward changed frames.
147
+
148
+ Prioritizes transitions where the frame changed (more informative
149
+ for training the world model).
150
+
151
+ Args:
152
+ batch_size: Number of transitions.
153
+ alpha: Prioritization exponent (0=uniform, 1=full priority).
154
+
155
+ Returns:
156
+ List of BufferedTransition objects.
157
+ """
158
+ if len(self.transitions) < batch_size:
159
+ return list(self.transitions)
160
+
161
+ priorities = np.array([
162
+ (1.0 if t.frame_changed else 0.1) ** alpha
163
+ for t in self.transitions
164
+ ])
165
+ probs = priorities / priorities.sum()
166
+ indices = np.random.choice(len(self.transitions), size=batch_size, p=probs, replace=False)
167
+ return [self.transitions[i] for i in indices]
168
+
169
+ def __len__(self) -> int:
170
+ """Return the number of transitions in the buffer."""
171
+ return len(self.transitions)
172
+
173
+ def __iter__(self) -> Iterator[BufferedTransition]:
174
+ """Iterate over all transitions."""
175
+ return iter(self.transitions)
176
+
177
+ @property
178
+ def num_unique_frames(self) -> int:
179
+ """Number of unique frames stored."""
180
+ return len(self.frames)
181
+
182
+ def stats(self) -> dict:
183
+ """Return buffer statistics.
184
+
185
+ Returns:
186
+ Dict with count, unique_frames, changed_ratio.
187
+ """
188
+ changed_count = sum(1 for t in self.transitions if t.frame_changed)
189
+ return {
190
+ "count": len(self.transitions),
191
+ "unique_frames": len(self.frames),
192
+ "changed_ratio": changed_count / max(len(self.transitions), 1),
193
+ }
194
+
195
+ def clear(self) -> None:
196
+ """Clear all stored data."""
197
+ self.frames.clear()
198
+ self.transitions.clear()
199
+ self._seen_keys.clear()
training/train_world_model.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Offline + online training loop for the world model.
2
+
3
+ This script can run in two modes:
4
+ 1. Offline: Train on pre-collected transitions from a replay buffer file.
5
+ 2. Online: Play games and train the world model incrementally.
6
+
7
+ Usage:
8
+ uv run python training/train_world_model.py --mode offline --buffer data/buffer.pkl
9
+ uv run python training/train_world_model.py --mode online --games ls20,ls21
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import logging
16
+ import pickle
17
+ from pathlib import Path
18
+
19
+ import numpy as np
20
+
21
+ from agents.wayfinder.perception import PerceptionEncoder
22
+ from agents.wayfinder.world_model import WorldModel
23
+ from training.replay_buffer import ReplayBuffer
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ def train_offline(
29
+ buffer_path: str,
30
+ latent_dim: int = 256,
31
+ epochs: int = 50,
32
+ batch_size: int = 64,
33
+ lr: float = 1e-4,
34
+ device: str = "cpu",
35
+ save_path: str = "models/world_model.pt",
36
+ ) -> None:
37
+ """Train the world model offline on a pre-collected buffer.
38
+
39
+ Args:
40
+ buffer_path: Path to a pickled ReplayBuffer.
41
+ latent_dim: Latent dimension.
42
+ epochs: Number of training epochs.
43
+ batch_size: Training batch size.
44
+ lr: Learning rate.
45
+ device: Torch device.
46
+ save_path: Where to save the trained model.
47
+ """
48
+ logger.info("Loading replay buffer from %s", buffer_path)
49
+ with open(buffer_path, "rb") as f:
50
+ buffer: ReplayBuffer = pickle.load(f)
51
+
52
+ logger.info("Buffer loaded: %d transitions, %d unique frames", len(buffer), buffer.num_unique_frames)
53
+
54
+ encoder = PerceptionEncoder(latent_dim=latent_dim, device=device)
55
+ world_model = WorldModel(latent_dim=latent_dim, device=device, lr=lr)
56
+
57
+ # Pre-encode all unique frames
58
+ logger.info("Encoding unique frames...")
59
+ frame_hashes = list(buffer.frames.keys())
60
+ frame_arrays = np.stack([buffer.frames[h] for h in frame_hashes])
61
+ latents = encoder.encode_batch(frame_arrays)
62
+ hash_to_latent = {h: lat for h, lat in zip(frame_hashes, latents)}
63
+
64
+ logger.info("Training for %d epochs...", epochs)
65
+ for epoch in range(epochs):
66
+ batch = buffer.sample_prioritized(batch_size)
67
+ total_loss = 0.0
68
+
69
+ for t in batch:
70
+ state_latent = hash_to_latent.get(t.frame_hash)
71
+ next_hash = hash_to_latent.get(
72
+ __import__("hashlib").md5(t.next_frame.tobytes()).hexdigest()
73
+ )
74
+ if state_latent is None or next_hash is None:
75
+ continue
76
+
77
+ world_model.add_transition(
78
+ state_latent=state_latent,
79
+ action={"action": t.action, "data": t.action_data},
80
+ next_latent=next_hash,
81
+ frame_changed=t.frame_changed,
82
+ )
83
+ loss = world_model.train_step(batch_size=min(batch_size, len(world_model._buffer)))
84
+ total_loss += loss
85
+
86
+ avg_loss = total_loss / max(len(batch), 1)
87
+ if (epoch + 1) % 5 == 0:
88
+ logger.info(
89
+ "Epoch %d/%d: avg_loss=%.4f, buffer=%d, confidence=%.3f",
90
+ epoch + 1, epochs, avg_loss,
91
+ world_model.buffer_size_current,
92
+ world_model.confidence(),
93
+ )
94
+
95
+ # Save model
96
+ save_dir = Path(save_path).parent
97
+ save_dir.mkdir(parents=True, exist_ok=True)
98
+ import torch
99
+ torch.save({
100
+ "world_model": world_model.state_dict(),
101
+ "encoder": encoder.state_dict(),
102
+ "latent_dim": latent_dim,
103
+ }, save_path)
104
+ logger.info("Model saved to %s", save_path)
105
+
106
+
107
+ def train_online(
108
+ games: list[str],
109
+ max_actions_per_game: int = 500,
110
+ latent_dim: int = 256,
111
+ device: str = "cpu",
112
+ save_path: str = "models/world_model_online.pt",
113
+ ) -> None:
114
+ """Train the world model online by playing games.
115
+
116
+ Args:
117
+ games: List of game IDs to play.
118
+ max_actions_per_game: Max actions per game.
119
+ latent_dim: Latent dimension.
120
+ device: Torch device.
121
+ save_path: Where to save the model.
122
+ """
123
+ from agents.wayfinder.agent import WayfinderAgent
124
+
125
+ agent = WayfinderAgent(
126
+ max_actions=max_actions_per_game,
127
+ latent_dim=latent_dim,
128
+ device=device,
129
+ )
130
+
131
+ for game_id in games:
132
+ logger.info("Playing game %s...", game_id)
133
+ agent.reset()
134
+
135
+ # In real usage, this would use the SDK to play the game.
136
+ # For now, we simulate with random frames.
137
+ for step in range(max_actions_per_game):
138
+ frame = np.random.randint(0, 16, size=(64, 64), dtype=np.uint8)
139
+ result = agent.act(
140
+ frames=[frame],
141
+ state="NOT_FINISHED",
142
+ score=0.0,
143
+ win_score=1.0,
144
+ available_actions=["ACTION1", "ACTION2", "ACTION3", "ACTION4", "ACTION5"],
145
+ )
146
+
147
+ if agent.is_done([frame], "NOT_FINISHED"):
148
+ break
149
+
150
+ logger.info(
151
+ "Game %s: %d actions, buffer=%d, confidence=%.3f",
152
+ game_id, agent.action_count,
153
+ agent._world_model.buffer_size_current,
154
+ agent._world_model.confidence(),
155
+ )
156
+
157
+ import torch
158
+ torch.save({
159
+ "world_model": agent._world_model.state_dict(),
160
+ "encoder": agent._encoder.state_dict(),
161
+ "latent_dim": latent_dim,
162
+ }, save_path)
163
+ logger.info("Online model saved to %s", save_path)
164
+
165
+
166
+ def main() -> int:
167
+ """CLI entry point for training."""
168
+ parser = argparse.ArgumentParser(description="Train the world model")
169
+ parser.add_argument("--mode", choices=["offline", "online"], default="online")
170
+ parser.add_argument("--buffer", default="data/buffer.pkl", help="Path to replay buffer (offline mode)")
171
+ parser.add_argument("--games", default="ls20,ls21,ls22", help="Comma-separated game IDs (online mode)")
172
+ parser.add_argument("--epochs", type=int, default=50)
173
+ parser.add_argument("--batch-size", type=int, default=64)
174
+ parser.add_argument("--lr", type=float, default=1e-4)
175
+ parser.add_argument("--latent-dim", type=int, default=256)
176
+ parser.add_argument("--device", default="cpu")
177
+ parser.add_argument("--save-path", default="models/world_model.pt")
178
+ parser.add_argument("-v", "--verbose", action="store_true")
179
+
180
+ args = parser.parse_args()
181
+
182
+ logging.basicConfig(
183
+ level=logging.DEBUG if args.verbose else logging.INFO,
184
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
185
+ )
186
+
187
+ if args.mode == "offline":
188
+ train_offline(
189
+ buffer_path=args.buffer,
190
+ latent_dim=args.latent_dim,
191
+ epochs=args.epochs,
192
+ batch_size=args.batch_size,
193
+ lr=args.lr,
194
+ device=args.device,
195
+ save_path=args.save_path,
196
+ )
197
+ else:
198
+ train_online(
199
+ games=args.games.split(","),
200
+ max_actions_per_game=500,
201
+ latent_dim=args.latent_dim,
202
+ device=args.device,
203
+ save_path=args.save_path,
204
+ )
205
+
206
+ return 0
207
+
208
+
209
+ if __name__ == "__main__":
210
+ import sys
211
+ sys.exit(main())