Upload 29 files
Browse files- .gitignore +8 -0
- LICENSE +21 -0
- README.md +61 -3
- agents/__init__.py +1 -0
- agents/wayfinder/__init__.py +15 -0
- agents/wayfinder/action_head.py +256 -0
- agents/wayfinder/agent.py +280 -0
- agents/wayfinder/cli.py +101 -0
- agents/wayfinder/intrinsic_reward.py +176 -0
- agents/wayfinder/memory_graph.py +233 -0
- agents/wayfinder/perception.py +158 -0
- agents/wayfinder/planner.py +290 -0
- agents/wayfinder/world_model.py +320 -0
- eval/metrics.py +198 -0
- eval/run_local_eval.py +233 -0
- main.py +10 -0
- notebooks/kaggle_submission.ipynb +72 -0
- pyproject.toml +89 -0
- tests/__init__.py +1 -0
- tests/test_action_head.py +77 -0
- tests/test_agent.py +104 -0
- tests/test_intrinsic_reward.py +63 -0
- tests/test_memory_graph.py +102 -0
- tests/test_perception.py +54 -0
- tests/test_planner.py +91 -0
- tests/test_world_model.py +76 -0
- training/configs/default.yaml +54 -0
- training/replay_buffer.py +199 -0
- training/train_world_model.py +211 -0
.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 |
-
|
| 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())
|