yashu2000 commited on
Commit
a03a89b
·
verified ·
1 Parent(s): 476e628

Upload folder using huggingface_hub

Browse files
Dockerfile ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ # Multi-stage build using openenv-base
8
+ # This Dockerfile is flexible and works for both:
9
+ # - In-repo environments (with local OpenEnv sources)
10
+ # - Standalone environments (with openenv from PyPI/Git)
11
+ # The build script (openenv build) handles context detection and sets appropriate build args.
12
+
13
+ ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
14
+ FROM ${BASE_IMAGE} AS builder
15
+
16
+ WORKDIR /app
17
+
18
+ # Ensure git is available (required for installing dependencies from VCS)
19
+ RUN apt-get update && \
20
+ apt-get install -y --no-install-recommends git && \
21
+ rm -rf /var/lib/apt/lists/*
22
+
23
+ # Build argument to control whether we're building standalone or in-repo
24
+ ARG BUILD_MODE=in-repo
25
+ ARG ENV_NAME=MiniGridEnv
26
+
27
+ # Copy environment code (always at root of build context)
28
+ COPY . /app/env
29
+
30
+ # For in-repo builds, openenv is already vendored in the build context
31
+ # For standalone builds, openenv will be installed via pyproject.toml
32
+ WORKDIR /app/env
33
+
34
+ # Ensure uv is available (for local builds where base image lacks it)
35
+ RUN if ! command -v uv >/dev/null 2>&1; then \
36
+ curl -LsSf https://astral.sh/uv/install.sh | sh && \
37
+ mv /root/.local/bin/uv /usr/local/bin/uv && \
38
+ mv /root/.local/bin/uvx /usr/local/bin/uvx; \
39
+ fi
40
+
41
+ # Install dependencies using uv sync
42
+ # If uv.lock exists, use it; otherwise resolve on the fly
43
+ RUN --mount=type=cache,target=/root/.cache/uv \
44
+ if [ -f uv.lock ]; then \
45
+ uv sync --frozen --no-install-project --no-editable; \
46
+ else \
47
+ uv sync --no-install-project --no-editable; \
48
+ fi
49
+
50
+ RUN --mount=type=cache,target=/root/.cache/uv \
51
+ if [ -f uv.lock ]; then \
52
+ uv sync --frozen --no-editable; \
53
+ else \
54
+ uv sync --no-editable; \
55
+ fi
56
+
57
+ # Final runtime stage
58
+ FROM ${BASE_IMAGE}
59
+
60
+ WORKDIR /app
61
+
62
+ # Copy the virtual environment from builder
63
+ COPY --from=builder /app/env/.venv /app/.venv
64
+
65
+ # Copy the environment code
66
+ COPY --from=builder /app/env /app/env
67
+
68
+ # Set PATH to use the virtual environment
69
+ ENV PATH="/app/.venv/bin:$PATH"
70
+
71
+ # Set PYTHONPATH so imports work correctly
72
+ ENV PYTHONPATH="/app/env:$PYTHONPATH"
73
+
74
+ # Health check
75
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
76
+ CMD curl -f http://localhost:8000/health || exit 1
77
+
78
+ # Run the FastAPI server
79
+ # The module path is constructed to work with the /app/env structure
80
+ ENV ENABLE_WEB_INTERFACE=true
81
+ CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
README.md CHANGED
@@ -1,10 +1,255 @@
1
  ---
2
- title: MiniGridEnv
3
- emoji: 🐢
4
- colorFrom: pink
5
- colorTo: red
6
  sdk: docker
7
  pinned: false
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Minigridenv Environment Server
3
+ emoji: 🔉
4
+ colorFrom: purple
5
+ colorTo: indigo
6
  sdk: docker
7
  pinned: false
8
+ app_port: 8000
9
+ base_path: /web
10
+ tags:
11
+ - openenv
12
  ---
13
 
14
+ # Minigridenv Environment
15
+
16
+ A simple test environment that echoes back messages. Perfect for testing the env APIs as well as demonstrating environment usage patterns.
17
+
18
+ ## Quick Start
19
+
20
+ The simplest way to use the Minigridenv environment is through the `MinigridenvEnv` class:
21
+
22
+ ```python
23
+ from MiniGridEnv import MinigridenvAction, MinigridenvEnv
24
+
25
+ try:
26
+ # Create environment from Docker image
27
+ MiniGridEnvenv = MinigridenvEnv.from_docker_image("MiniGridEnv-env:latest")
28
+
29
+ # Reset
30
+ result = MiniGridEnvenv.reset()
31
+ print(f"Reset: {result.observation.echoed_message}")
32
+
33
+ # Send multiple messages
34
+ messages = ["Hello, World!", "Testing echo", "Final message"]
35
+
36
+ for msg in messages:
37
+ result = MiniGridEnvenv.step(MinigridenvAction(message=msg))
38
+ print(f"Sent: '{msg}'")
39
+ print(f" → Echoed: '{result.observation.echoed_message}'")
40
+ print(f" → Length: {result.observation.message_length}")
41
+ print(f" → Reward: {result.reward}")
42
+
43
+ finally:
44
+ # Always clean up
45
+ MiniGridEnvenv.close()
46
+ ```
47
+
48
+ That's it! The `MinigridenvEnv.from_docker_image()` method handles:
49
+ - Starting the Docker container
50
+ - Waiting for the server to be ready
51
+ - Connecting to the environment
52
+ - Container cleanup when you call `close()`
53
+
54
+ ## Building the Docker Image
55
+
56
+ Before using the environment, you need to build the Docker image:
57
+
58
+ ```bash
59
+ # From project root
60
+ docker build -t MiniGridEnv-env:latest -f server/Dockerfile .
61
+ ```
62
+
63
+ ## Deploying to Hugging Face Spaces
64
+
65
+ You can easily deploy your OpenEnv environment to Hugging Face Spaces using the `openenv push` command:
66
+
67
+ ```bash
68
+ # From the environment directory (where openenv.yaml is located)
69
+ openenv push
70
+
71
+ # Or specify options
72
+ openenv push --namespace my-org --private
73
+ ```
74
+
75
+ The `openenv push` command will:
76
+ 1. Validate that the directory is an OpenEnv environment (checks for `openenv.yaml`)
77
+ 2. Prepare a custom build for Hugging Face Docker space (enables web interface)
78
+ 3. Upload to Hugging Face (ensuring you're logged in)
79
+
80
+ ### Prerequisites
81
+
82
+ - Authenticate with Hugging Face: The command will prompt for login if not already authenticated
83
+
84
+ ### Options
85
+
86
+ - `--directory`, `-d`: Directory containing the OpenEnv environment (defaults to current directory)
87
+ - `--repo-id`, `-r`: Repository ID in format 'username/repo-name' (defaults to 'username/env-name' from openenv.yaml)
88
+ - `--base-image`, `-b`: Base Docker image to use (overrides Dockerfile FROM)
89
+ - `--private`: Deploy the space as private (default: public)
90
+
91
+ ### Examples
92
+
93
+ ```bash
94
+ # Push to your personal namespace (defaults to username/env-name from openenv.yaml)
95
+ openenv push
96
+
97
+ # Push to a specific repository
98
+ openenv push --repo-id my-org/my-env
99
+
100
+ # Push with a custom base image
101
+ openenv push --base-image ghcr.io/meta-pytorch/openenv-base:latest
102
+
103
+ # Push as a private space
104
+ openenv push --private
105
+
106
+ # Combine options
107
+ openenv push --repo-id my-org/my-env --base-image custom-base:latest --private
108
+ ```
109
+
110
+ After deployment, your space will be available at:
111
+ `https://huggingface.co/spaces/<repo-id>`
112
+
113
+ The deployed space includes:
114
+ - **Web Interface** at `/web` - Interactive UI for exploring the environment
115
+ - **API Documentation** at `/docs` - Full OpenAPI/Swagger interface
116
+ - **Health Check** at `/health` - Container health monitoring
117
+ - **WebSocket** at `/ws` - Persistent session endpoint for low-latency interactions
118
+
119
+ ## Environment Details
120
+
121
+ ### Action
122
+ **MinigridenvAction**: Contains a single field
123
+ - `message` (str) - The message to echo back
124
+
125
+ ### Observation
126
+ **MinigridenvObservation**: Contains the echo response and metadata
127
+ - `echoed_message` (str) - The message echoed back
128
+ - `message_length` (int) - Length of the message
129
+ - `reward` (float) - Reward based on message length (length × 0.1)
130
+ - `done` (bool) - Always False for echo environment
131
+ - `metadata` (dict) - Additional info like step count
132
+
133
+ ### Reward
134
+ The reward is calculated as: `message_length × 0.1`
135
+ - "Hi" → reward: 0.2
136
+ - "Hello, World!" → reward: 1.3
137
+ - Empty message → reward: 0.0
138
+
139
+ ## Advanced Usage
140
+
141
+ ### Connecting to an Existing Server
142
+
143
+ If you already have a Minigridenv environment server running, you can connect directly:
144
+
145
+ ```python
146
+ from MiniGridEnv import MinigridenvEnv
147
+
148
+ # Connect to existing server
149
+ MiniGridEnvenv = MinigridenvEnv(base_url="<ENV_HTTP_URL_HERE>")
150
+
151
+ # Use as normal
152
+ result = MiniGridEnvenv.reset()
153
+ result = MiniGridEnvenv.step(MinigridenvAction(message="Hello!"))
154
+ ```
155
+
156
+ Note: When connecting to an existing server, `MiniGridEnvenv.close()` will NOT stop the server.
157
+
158
+ ### Using the Context Manager
159
+
160
+ The client supports context manager usage for automatic connection management:
161
+
162
+ ```python
163
+ from MiniGridEnv import MinigridenvAction, MinigridenvEnv
164
+
165
+ # Connect with context manager (auto-connects and closes)
166
+ with MinigridenvEnv(base_url="http://localhost:8000") as env:
167
+ result = env.reset()
168
+ print(f"Reset: {result.observation.echoed_message}")
169
+ # Multiple steps with low latency
170
+ for msg in ["Hello", "World", "!"]:
171
+ result = env.step(MinigridenvAction(message=msg))
172
+ print(f"Echoed: {result.observation.echoed_message}")
173
+ ```
174
+
175
+ The client uses WebSocket connections for:
176
+ - **Lower latency**: No HTTP connection overhead per request
177
+ - **Persistent session**: Server maintains your environment state
178
+ - **Efficient for episodes**: Better for many sequential steps
179
+
180
+ ### Concurrent WebSocket Sessions
181
+
182
+ The server supports multiple concurrent WebSocket connections. To enable this,
183
+ modify `server/app.py` to use factory mode:
184
+
185
+ ```python
186
+ # In server/app.py - use factory mode for concurrent sessions
187
+ app = create_app(
188
+ MinigridenvEnvironment, # Pass class, not instance
189
+ MinigridenvAction,
190
+ MinigridenvObservation,
191
+ max_concurrent_envs=4, # Allow 4 concurrent sessions
192
+ )
193
+ ```
194
+
195
+ Then multiple clients can connect simultaneously:
196
+
197
+ ```python
198
+ from MiniGridEnv import MinigridenvAction, MinigridenvEnv
199
+ from concurrent.futures import ThreadPoolExecutor
200
+
201
+ def run_episode(client_id: int):
202
+ with MinigridenvEnv(base_url="http://localhost:8000") as env:
203
+ result = env.reset()
204
+ for i in range(10):
205
+ result = env.step(MinigridenvAction(message=f"Client {client_id}, step {i}"))
206
+ return client_id, result.observation.message_length
207
+
208
+ # Run 4 episodes concurrently
209
+ with ThreadPoolExecutor(max_workers=4) as executor:
210
+ results = list(executor.map(run_episode, range(4)))
211
+ ```
212
+
213
+ ## Development & Testing
214
+
215
+ ### Direct Environment Testing
216
+
217
+ Test the environment logic directly without starting the HTTP server:
218
+
219
+ ```bash
220
+ # From the server directory
221
+ python3 server/MiniGridEnv_environment.py
222
+ ```
223
+
224
+ This verifies that:
225
+ - Environment resets correctly
226
+ - Step executes actions properly
227
+ - State tracking works
228
+ - Rewards are calculated correctly
229
+
230
+ ### Running Locally
231
+
232
+ Run the server locally for development:
233
+
234
+ ```bash
235
+ uvicorn server.app:app --reload
236
+ ```
237
+
238
+ ## Project Structure
239
+
240
+ ```
241
+ MiniGridEnv/
242
+ ├── .dockerignore # Docker build exclusions
243
+ ├── __init__.py # Module exports
244
+ ├── README.md # This file
245
+ ├── openenv.yaml # OpenEnv manifest
246
+ ├── pyproject.toml # Project metadata and dependencies
247
+ ├── uv.lock # Locked dependencies (generated)
248
+ ├── client.py # MinigridenvEnv client
249
+ ├── models.py # Action and Observation models
250
+ └── server/
251
+ ├── __init__.py # Server module exports
252
+ ├── MiniGridEnv_environment.py # Core environment logic
253
+ ├── app.py # FastAPI application (HTTP + WebSocket endpoints)
254
+ └── Dockerfile # Container image definition
255
+ ```
__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """MiniGridEnv package exports."""
2
+
3
+ from .client import MiniGridEnvClient
4
+ from .models import MiniGridAction, MiniGridObservation, MiniGridState
5
+
6
+ __all__ = ["MiniGridAction", "MiniGridObservation", "MiniGridState", "MiniGridEnvClient"]
baselines/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """Baseline agent implementations for MiniGridEnv."""
2
+
3
+ from .heuristic_bot import BabyAIBotBaseline
4
+ from .llm_zeroshot import LLMZeroShotBaseline
5
+ from .random_action import RandomActionBaseline
6
+
7
+ __all__ = ["RandomActionBaseline", "BabyAIBotBaseline", "LLMZeroShotBaseline"]
baselines/heuristic_bot.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BabyAI built-in bot baseline (near-optimal upper bound)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ try:
6
+ from ..env.models import MiniGridAction, MiniGridObservation
7
+ except ImportError:
8
+ from env.models import MiniGridAction, MiniGridObservation
9
+
10
+ INT_TO_TEXT = {
11
+ 0: "turn left",
12
+ 1: "turn right",
13
+ 2: "go forward",
14
+ 3: "pickup",
15
+ 4: "drop",
16
+ 5: "toggle",
17
+ 6: "done",
18
+ }
19
+
20
+
21
+ class BabyAIBotBaseline:
22
+ """Adapter for BabyAI's symbolic planner bot."""
23
+
24
+ def __init__(self, gym_env):
25
+ from minigrid.envs.babyai import BotAgent # type: ignore
26
+
27
+ self._bot = BotAgent(gym_env.unwrapped)
28
+
29
+ def select_action(
30
+ self, obs: MiniGridObservation, raw_obs: dict
31
+ ) -> MiniGridAction:
32
+ del obs
33
+ action_int = self._bot.act(raw_obs)
34
+ command = INT_TO_TEXT.get(int(action_int), "done")
35
+ return MiniGridAction(command=command)
baselines/llm_zeroshot.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Zero-shot LLM baseline for MiniGridEnv."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Callable, Optional
6
+
7
+ try:
8
+ from ..env.models import MiniGridAction, MiniGridObservation
9
+ except ImportError:
10
+ from env.models import MiniGridAction, MiniGridObservation
11
+
12
+
13
+ class LLMZeroShotBaseline:
14
+ """Use an external LLM completion function with no RL fine-tuning."""
15
+
16
+ def __init__(
17
+ self,
18
+ model: str = "gpt-4o-mini",
19
+ provider: str = "openai",
20
+ completion_fn: Optional[Callable[[str], str]] = None,
21
+ ):
22
+ self._model = model
23
+ self._provider = provider
24
+ self._completion_fn = completion_fn
25
+
26
+ def select_action(self, obs: MiniGridObservation) -> MiniGridAction:
27
+ if self._completion_fn is None:
28
+ raise RuntimeError(
29
+ "LLMZeroShotBaseline requires completion_fn(prompt) -> text."
30
+ )
31
+ prompt = self._format_prompt(obs)
32
+ response = self._completion_fn(prompt)
33
+ return MiniGridAction(command=response.strip(), thought=None)
34
+
35
+ def _format_prompt(self, obs: MiniGridObservation) -> str:
36
+ return (
37
+ "You are navigating a grid world. Respond with exactly one action.\n\n"
38
+ "Valid actions: turn left, turn right, go forward, pickup, drop, toggle, done\n\n"
39
+ f"{obs.text}\n\n"
40
+ f"Step {obs.step_idx}/{obs.max_steps}. What is your next action?\n"
41
+ "Action:"
42
+ )
baselines/random_action.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Random-action baseline for MiniGridEnv."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+
7
+ try:
8
+ from ..env.models import MiniGridAction, MiniGridObservation
9
+ except ImportError:
10
+ from env.models import MiniGridAction, MiniGridObservation
11
+
12
+ CANONICAL_ACTIONS = [
13
+ "turn left",
14
+ "turn right",
15
+ "go forward",
16
+ "pickup",
17
+ "drop",
18
+ "toggle",
19
+ "done",
20
+ ]
21
+
22
+
23
+ class RandomActionBaseline:
24
+ """Uniform random baseline with movement bias."""
25
+
26
+ def select_action(
27
+ self, obs: MiniGridObservation, rng: random.Random | None = None
28
+ ) -> MiniGridAction:
29
+ del obs
30
+ chooser = rng or random
31
+ # Slightly prefer movement to avoid stalling.
32
+ weights = [1.0, 1.0, 3.0, 1.0, 0.5, 1.0, 0.1]
33
+ command = chooser.choices(CANONICAL_ACTIONS, weights=weights, k=1)[0]
34
+ return MiniGridAction(command=command)
client.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Typed OpenEnv client for MiniGridEnv."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict
6
+
7
+ from openenv.core.client_types import StepResult
8
+ from openenv.core.env_client import EnvClient
9
+
10
+ try:
11
+ from .env.models import MiniGridAction, MiniGridObservation, MiniGridState
12
+ except ImportError:
13
+ from env.models import MiniGridAction, MiniGridObservation, MiniGridState
14
+
15
+
16
+ class MiniGridEnvClient(EnvClient[MiniGridAction, MiniGridObservation, MiniGridState]):
17
+ """WebSocket client for interacting with a MiniGridEnv server."""
18
+
19
+ def _step_payload(self, action: MiniGridAction) -> Dict[str, Any]:
20
+ payload: Dict[str, Any] = {"command": action.command}
21
+ if action.thought:
22
+ payload["thought"] = action.thought
23
+ return payload
24
+
25
+ def _parse_result(self, payload: Dict[str, Any]) -> StepResult[MiniGridObservation]:
26
+ obs_data = payload.get("observation")
27
+ if not isinstance(obs_data, dict):
28
+ obs_data = payload if isinstance(payload, dict) else {}
29
+ done = bool(payload.get("done", obs_data.get("done", False)))
30
+ reward = payload.get("reward", obs_data.get("reward"))
31
+ observation = MiniGridObservation(
32
+ text=obs_data.get("text", ""),
33
+ mission=obs_data.get("mission", ""),
34
+ step_idx=obs_data.get("step_idx", 0),
35
+ steps_remaining=obs_data.get("steps_remaining", 0),
36
+ max_steps=obs_data.get("max_steps", 1),
37
+ history=obs_data.get("history", []),
38
+ level_name=obs_data.get("level_name", ""),
39
+ last_action=obs_data.get("last_action"),
40
+ action_success=obs_data.get("action_success"),
41
+ done=done,
42
+ reward=reward,
43
+ metadata=obs_data.get("metadata", {}),
44
+ )
45
+ return StepResult(observation=observation, reward=reward, done=done)
46
+
47
+ def _parse_state(self, payload: Dict[str, Any]) -> MiniGridState:
48
+ state_data = payload.get("state")
49
+ if not isinstance(state_data, dict):
50
+ state_data = payload if isinstance(payload, dict) else {}
51
+ return MiniGridState(
52
+ episode_id=state_data.get("episode_id"),
53
+ step_count=state_data.get("step_count", 0),
54
+ level_name=state_data.get("level_name", ""),
55
+ level_difficulty=state_data.get("level_difficulty", 0),
56
+ completed=state_data.get("completed", False),
57
+ truncated=state_data.get("truncated", False),
58
+ total_reward=state_data.get("total_reward", 0.0),
59
+ steps_taken=state_data.get("steps_taken", 0),
60
+ optimal_steps=state_data.get("optimal_steps"),
61
+ efficiency_ratio=state_data.get("efficiency_ratio"),
62
+ valid_actions=state_data.get("valid_actions", 0),
63
+ invalid_actions=state_data.get("invalid_actions", 0),
64
+ action_distribution=state_data.get("action_distribution", {}),
65
+ )
66
+
67
+
68
+ MiniGridEnv = MiniGridEnvClient
env/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MiniGridEnv core environment package."""
2
+
3
+ from .config import EnvConfig, RewardConfig
4
+ from .minigrid_env import MiniGridEnvironment
5
+ from .models import MiniGridAction, MiniGridObservation, MiniGridState
6
+
7
+ __all__ = [
8
+ "MiniGridAction",
9
+ "MiniGridObservation",
10
+ "MiniGridState",
11
+ "RewardConfig",
12
+ "EnvConfig",
13
+ "MiniGridEnvironment",
14
+ ]
env/action_parser.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parse free-form model text into MiniGrid discrete actions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ CANONICAL_ACTION_TO_INDEX: dict[str, int] = {
8
+ "turn left": 0,
9
+ "turn right": 1,
10
+ "go forward": 2,
11
+ "pickup": 3,
12
+ "drop": 4,
13
+ "toggle": 5,
14
+ "done": 6,
15
+ }
16
+
17
+ ACTION_MAP: dict[str, str] = {
18
+ "turn left": "turn left",
19
+ "turn right": "turn right",
20
+ "go forward": "go forward",
21
+ "move forward": "go forward",
22
+ "forward": "go forward",
23
+ "pickup": "pickup",
24
+ "pick up": "pickup",
25
+ "grab": "pickup",
26
+ "drop": "drop",
27
+ "toggle": "toggle",
28
+ "open": "toggle",
29
+ "close": "toggle",
30
+ "done": "done",
31
+ "wait": "done",
32
+ "noop": "done",
33
+ }
34
+
35
+ ALIASES: dict[str, str] = {
36
+ "left": "turn left",
37
+ "right": "turn right",
38
+ "ahead": "go forward",
39
+ "step": "go forward",
40
+ "walk": "go forward",
41
+ "take": "pickup",
42
+ "get": "pickup",
43
+ "release": "drop",
44
+ "put down": "drop",
45
+ "unlock": "toggle",
46
+ "switch": "toggle",
47
+ "stop": "done",
48
+ }
49
+
50
+ _ACTION_PATTERN = re.compile(r"action\s*:\s*(.+)", re.IGNORECASE)
51
+
52
+
53
+ def _extract_structured_action(text: str) -> str:
54
+ """Extract action payload from `Action: ...` format when present."""
55
+ match = _ACTION_PATTERN.search(text)
56
+ if not match:
57
+ return text
58
+ candidate = match.group(1).strip()
59
+ return candidate.splitlines()[0].strip()
60
+
61
+
62
+ def _match_from_map(cleaned: str, mapping: dict[str, str]) -> str | None:
63
+ if cleaned in mapping:
64
+ return mapping[cleaned]
65
+
66
+ best_key = None
67
+ best_len = -1
68
+ for key in mapping:
69
+ if key in cleaned and len(key) > best_len:
70
+ best_key = key
71
+ best_len = len(key)
72
+ if best_key is None:
73
+ return None
74
+ return mapping[best_key]
75
+
76
+
77
+ def parse_action(text: str) -> tuple[int, str, bool]:
78
+ """Parse model output text into `(action_index, canonical_action, is_valid)`."""
79
+ cleaned = (text or "").strip().lower()
80
+ if not cleaned:
81
+ return CANONICAL_ACTION_TO_INDEX["go forward"], "go forward", False
82
+
83
+ cleaned = _extract_structured_action(cleaned)
84
+
85
+ canonical = _match_from_map(cleaned, ACTION_MAP)
86
+ if canonical is not None:
87
+ return CANONICAL_ACTION_TO_INDEX[canonical], canonical, True
88
+
89
+ canonical = _match_from_map(cleaned, ALIASES)
90
+ if canonical is not None:
91
+ return CANONICAL_ACTION_TO_INDEX[canonical], canonical, True
92
+
93
+ return CANONICAL_ACTION_TO_INDEX["go forward"], "go forward", False
env/config.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration objects for MiniGridEnv."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class RewardConfig:
9
+ """Reward-shaping controls for environment step rewards."""
10
+
11
+ mode: str = "binary" # "binary" | "shaped" | "efficiency"
12
+ completion_reward: float = 1.0
13
+ failure_reward: float = 0.0
14
+ step_penalty: float = 0.0
15
+ invalid_action_penalty: float = 0.0
16
+ efficiency_bonus_weight: float = 0.5
17
+
18
+
19
+ @dataclass
20
+ class EnvConfig:
21
+ """Episode, observation, and reproducibility settings."""
22
+
23
+ # Level selection
24
+ level_name: str = "GoToRedBall"
25
+
26
+ # Episode limits
27
+ max_steps_override: Optional[int] = None
28
+
29
+ # Reward configuration
30
+ reward: RewardConfig = field(default_factory=RewardConfig)
31
+
32
+ # Observation configuration
33
+ include_history: bool = True
34
+ max_history_length: int = 20
35
+ include_raw_grid: bool = False
36
+
37
+ # Reproducibility and optional metrics
38
+ seed: int = 42
39
+ compute_optimal: bool = False
40
+ track_carrying: bool = True
env/grid_to_text.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Convert MiniGrid/BabyAI observations into rich natural language text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+
9
+ OBJECT_TYPES = {
10
+ 0: "unseen",
11
+ 1: "empty",
12
+ 2: "wall",
13
+ 3: "floor",
14
+ 4: "door",
15
+ 5: "key",
16
+ 6: "ball",
17
+ 7: "box",
18
+ 8: "goal",
19
+ 9: "lava",
20
+ 10: "agent",
21
+ }
22
+
23
+ COLORS = {
24
+ 0: "red",
25
+ 1: "green",
26
+ 2: "blue",
27
+ 3: "purple",
28
+ 4: "yellow",
29
+ 5: "grey",
30
+ }
31
+
32
+ DOOR_STATES = {0: "open", 1: "closed", 2: "locked"}
33
+ DIRECTION_NAMES = {0: "east", 1: "south", 2: "west", 3: "north"}
34
+
35
+ _AGENT_ROW = 6
36
+ _AGENT_COL = 3
37
+
38
+
39
+ def _format_object_name(obj_type: str, color: str | None, state: str | None = None) -> str:
40
+ if obj_type == "door":
41
+ prefix = f"{state} {color}".strip() if color else (state or "door")
42
+ return f"a {prefix} door".replace(" ", " ").strip()
43
+ if color:
44
+ return f"a {color} {obj_type}"
45
+ return f"a {obj_type}"
46
+
47
+
48
+ def _relative_position_phrase(rel_row: int, rel_col: int) -> str:
49
+ parts: list[str] = []
50
+
51
+ if rel_row < 0:
52
+ steps_ahead = abs(rel_row)
53
+ parts.append(f"{steps_ahead} step{'s' if steps_ahead != 1 else ''} ahead")
54
+ elif rel_row > 0:
55
+ steps_behind = rel_row
56
+ parts.append(f"{steps_behind} step{'s' if steps_behind != 1 else ''} behind")
57
+
58
+ if rel_col < 0:
59
+ steps_left = abs(rel_col)
60
+ parts.append(f"{steps_left} to your left")
61
+ elif rel_col > 0:
62
+ steps_right = rel_col
63
+ parts.append(f"{steps_right} to your right")
64
+
65
+ if not parts:
66
+ return "at your position"
67
+ if len(parts) == 1:
68
+ return parts[0]
69
+ return f"{parts[0]} and {parts[1]}"
70
+
71
+
72
+ def _describe_cell(grid: np.ndarray, row: int, col: int) -> str:
73
+ if row < 0 or row >= grid.shape[0] or col < 0 or col >= grid.shape[1]:
74
+ return "a wall boundary"
75
+
76
+ obj_idx = int(grid[row, col, 0])
77
+ color_idx = int(grid[row, col, 1])
78
+ state_idx = int(grid[row, col, 2])
79
+
80
+ obj_type = OBJECT_TYPES.get(obj_idx, "unknown")
81
+ color = COLORS.get(color_idx)
82
+
83
+ if obj_type in {"empty", "floor"}:
84
+ return "empty space"
85
+ if obj_type == "unseen":
86
+ return "unseen area"
87
+ if obj_type == "wall":
88
+ return "a wall"
89
+ if obj_type == "door":
90
+ return _format_object_name("door", color, DOOR_STATES.get(state_idx, "closed"))
91
+ if obj_type == "lava":
92
+ return "lava"
93
+ return _format_object_name(obj_type, color)
94
+
95
+
96
+ def _scan_objects(grid: np.ndarray) -> list[dict[str, Any]]:
97
+ """Extract notable interactive objects with relative positions."""
98
+ objects: list[dict[str, Any]] = []
99
+
100
+ for row in range(grid.shape[0]):
101
+ for col in range(grid.shape[1]):
102
+ obj_idx = int(grid[row, col, 0])
103
+ color_idx = int(grid[row, col, 1])
104
+ state_idx = int(grid[row, col, 2])
105
+
106
+ obj_type = OBJECT_TYPES.get(obj_idx, "unknown")
107
+ if obj_type in {"unseen", "empty", "wall", "floor", "agent"}:
108
+ continue
109
+
110
+ rel_row = row - _AGENT_ROW
111
+ rel_col = col - _AGENT_COL
112
+ state = DOOR_STATES.get(state_idx) if obj_type == "door" else None
113
+ color = COLORS.get(color_idx)
114
+ objects.append(
115
+ {
116
+ "type": obj_type,
117
+ "color": color,
118
+ "state": state,
119
+ "row": row,
120
+ "col": col,
121
+ "rel_row": rel_row,
122
+ "rel_col": rel_col,
123
+ "distance": abs(rel_row) + abs(rel_col),
124
+ "direction_desc": _relative_position_phrase(rel_row, rel_col),
125
+ }
126
+ )
127
+
128
+ objects.sort(key=lambda item: (item["distance"], item["row"], item["col"]))
129
+ return objects
130
+
131
+
132
+ def _describe_immediate_surroundings(grid: np.ndarray) -> str:
133
+ """Describe the nearest cells around the agent."""
134
+ ahead = _describe_cell(grid, _AGENT_ROW - 1, _AGENT_COL)
135
+ left = _describe_cell(grid, _AGENT_ROW, _AGENT_COL - 1)
136
+ right = _describe_cell(grid, _AGENT_ROW, _AGENT_COL + 1)
137
+ return (
138
+ f"Directly ahead: {ahead}.\n"
139
+ f"To your left: {left}.\n"
140
+ f"To your right: {right}."
141
+ )
142
+
143
+
144
+ def _describe_path_ahead(grid: np.ndarray) -> str:
145
+ """Describe what appears in the straight-ahead lane."""
146
+ segments: list[str] = []
147
+ empty_run = 0
148
+
149
+ for row in range(_AGENT_ROW - 1, -1, -1):
150
+ cell_desc = _describe_cell(grid, row, _AGENT_COL)
151
+ if cell_desc == "empty space":
152
+ empty_run += 1
153
+ continue
154
+
155
+ if empty_run > 0:
156
+ segments.append(
157
+ f"empty space for {empty_run} step{'s' if empty_run != 1 else ''}"
158
+ )
159
+ empty_run = 0
160
+ segments.append(cell_desc)
161
+ if cell_desc in {"a wall", "a wall boundary", "unseen area"}:
162
+ break
163
+
164
+ if empty_run > 0:
165
+ segments.append(
166
+ f"empty space for {empty_run} step{'s' if empty_run != 1 else ''}"
167
+ )
168
+
169
+ if not segments:
170
+ return "Looking ahead: no clear information."
171
+ if len(segments) == 1:
172
+ return f"Looking ahead: {segments[0]}."
173
+ return f"Looking ahead: {', then '.join(segments)}."
174
+
175
+
176
+ def _describe_notable_objects(objects: list[dict[str, Any]]) -> str:
177
+ """List visible interactive objects with positions."""
178
+ if not objects:
179
+ return "Notable objects: none visible."
180
+
181
+ lines = ["Notable objects:"]
182
+ for obj in objects:
183
+ name = _format_object_name(obj["type"], obj.get("color"), obj.get("state"))
184
+ lines.append(f"- {name} ({obj['direction_desc']}).")
185
+ return "\n".join(lines)
186
+
187
+
188
+ def _describe_carrying_status(carrying: Any) -> str:
189
+ """Describe what the agent is currently carrying."""
190
+ if carrying is None:
191
+ return "You are carrying: nothing."
192
+
193
+ if isinstance(carrying, dict):
194
+ obj_type = carrying.get("type")
195
+ color = carrying.get("color")
196
+ else:
197
+ obj_type = getattr(carrying, "type", None)
198
+ color = getattr(carrying, "color", None)
199
+
200
+ if obj_type is None:
201
+ return "You are carrying: an object."
202
+ if color:
203
+ return f"You are carrying: a {color} {obj_type}."
204
+ return f"You are carrying: a {obj_type}."
205
+
206
+
207
+ def _render_ascii_grid(grid: np.ndarray) -> str:
208
+ """Render a compact ASCII view for debugging."""
209
+ glyphs = {
210
+ "unseen": "?",
211
+ "empty": ".",
212
+ "wall": "#",
213
+ "floor": ".",
214
+ "door": "D",
215
+ "key": "K",
216
+ "ball": "B",
217
+ "box": "X",
218
+ "goal": "G",
219
+ "lava": "L",
220
+ "agent": "A",
221
+ }
222
+ rows: list[str] = []
223
+ for row in range(grid.shape[0]):
224
+ chars: list[str] = []
225
+ for col in range(grid.shape[1]):
226
+ obj_type = OBJECT_TYPES.get(int(grid[row, col, 0]), "unknown")
227
+ chars.append(glyphs.get(obj_type, "!"))
228
+ rows.append("".join(chars))
229
+ return "\n".join(rows)
230
+
231
+
232
+ def grid_to_text(
233
+ obs: dict[str, Any], carrying: Any = None, include_raw_grid: bool = False
234
+ ) -> str:
235
+ """Convert MiniGrid raw observation dict to a rich language description."""
236
+ grid = obs.get("image")
237
+ if grid is None:
238
+ return "Mission: unknown.\nObservation is missing grid image."
239
+
240
+ mission = str(obs.get("mission", "")).strip() or "unknown mission"
241
+ direction = int(obs.get("direction", 0))
242
+ direction_name = DIRECTION_NAMES.get(direction, "unknown")
243
+
244
+ if not isinstance(grid, np.ndarray):
245
+ grid = np.asarray(grid)
246
+
247
+ objects = _scan_objects(grid)
248
+ parts = [
249
+ f"Mission: {mission}",
250
+ f"You are facing {direction_name}.",
251
+ "",
252
+ _describe_immediate_surroundings(grid),
253
+ _describe_path_ahead(grid),
254
+ _describe_notable_objects(objects),
255
+ _describe_carrying_status(carrying),
256
+ ]
257
+
258
+ if include_raw_grid:
259
+ parts.extend(["", "Raw grid (debug):", _render_ascii_grid(grid)])
260
+
261
+ return "\n".join(part for part in parts if part is not None)
env/levels.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BabyAI level registry and curriculum definitions for MiniGridEnv."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class LevelConfig:
10
+ """Configuration for a single BabyAI level."""
11
+
12
+ gym_id: str
13
+ name: str
14
+ description: str
15
+ difficulty: int
16
+ max_steps: int
17
+ expected_optimal_steps: int
18
+ requires_interaction: bool
19
+ num_objects: int
20
+ involves_language_composition: bool
21
+ fallback_gym_ids: tuple[str, ...] = ()
22
+
23
+ @property
24
+ def candidate_gym_ids(self) -> list[str]:
25
+ return [self.gym_id, *self.fallback_gym_ids]
26
+
27
+
28
+ LEVEL_REGISTRY: list[LevelConfig] = [
29
+ # Stage 0: simple navigation
30
+ LevelConfig(
31
+ gym_id="BabyAI-GoToRedBallGrey-v0",
32
+ fallback_gym_ids=("BabyAI-GoToRedBall-v0",),
33
+ name="GoToRedBall",
34
+ description="Navigate to the red ball in a single room.",
35
+ difficulty=0,
36
+ max_steps=64,
37
+ expected_optimal_steps=10,
38
+ requires_interaction=False,
39
+ num_objects=1,
40
+ involves_language_composition=False,
41
+ ),
42
+ LevelConfig(
43
+ gym_id="BabyAI-GoToObj-v0",
44
+ name="GoToObj",
45
+ description="Navigate to a specific colored object.",
46
+ difficulty=1,
47
+ max_steps=64,
48
+ expected_optimal_steps=12,
49
+ requires_interaction=False,
50
+ num_objects=2,
51
+ involves_language_composition=False,
52
+ ),
53
+ LevelConfig(
54
+ gym_id="BabyAI-GoToLocal-v0",
55
+ name="GoToLocal",
56
+ description="Navigate to a specific object with distractors.",
57
+ difficulty=2,
58
+ max_steps=64,
59
+ expected_optimal_steps=15,
60
+ requires_interaction=False,
61
+ num_objects=4,
62
+ involves_language_composition=False,
63
+ ),
64
+ # Stage 1: object interaction
65
+ LevelConfig(
66
+ gym_id="BabyAI-PickupLoc-v0",
67
+ name="PickupLoc",
68
+ description="Pick up a specific object in a single room.",
69
+ difficulty=3,
70
+ max_steps=64,
71
+ expected_optimal_steps=14,
72
+ requires_interaction=True,
73
+ num_objects=4,
74
+ involves_language_composition=False,
75
+ ),
76
+ LevelConfig(
77
+ gym_id="BabyAI-OpenDoor-v0",
78
+ name="OpenDoor",
79
+ description="Open a door of a specified color.",
80
+ difficulty=3,
81
+ max_steps=64,
82
+ expected_optimal_steps=12,
83
+ requires_interaction=True,
84
+ num_objects=1,
85
+ involves_language_composition=False,
86
+ ),
87
+ LevelConfig(
88
+ gym_id="BabyAI-UnlockLocal-v0",
89
+ name="UnlockLocal",
90
+ description="Unlock a local door with the matching key.",
91
+ difficulty=4,
92
+ max_steps=128,
93
+ expected_optimal_steps=25,
94
+ requires_interaction=True,
95
+ num_objects=3,
96
+ involves_language_composition=False,
97
+ ),
98
+ # Stage 2: multi-room and compositional
99
+ LevelConfig(
100
+ gym_id="BabyAI-GoTo-v0",
101
+ name="GoTo",
102
+ description="Navigate to a specified object across rooms.",
103
+ difficulty=5,
104
+ max_steps=128,
105
+ expected_optimal_steps=30,
106
+ requires_interaction=True,
107
+ num_objects=6,
108
+ involves_language_composition=False,
109
+ ),
110
+ LevelConfig(
111
+ gym_id="BabyAI-PutNextLocal-v0",
112
+ name="PutNextLocal",
113
+ description="Place one object next to another local object.",
114
+ difficulty=6,
115
+ max_steps=128,
116
+ expected_optimal_steps=20,
117
+ requires_interaction=True,
118
+ num_objects=4,
119
+ involves_language_composition=True,
120
+ ),
121
+ # Stage 3: hardest compositional tasks
122
+ LevelConfig(
123
+ gym_id="BabyAI-Synth-v0",
124
+ name="Synth",
125
+ description="Random compositional instructions.",
126
+ difficulty=7,
127
+ max_steps=256,
128
+ expected_optimal_steps=40,
129
+ requires_interaction=True,
130
+ num_objects=8,
131
+ involves_language_composition=True,
132
+ ),
133
+ LevelConfig(
134
+ gym_id="BabyAI-BossLevel-v0",
135
+ name="BossLevel",
136
+ description="Hardest compositional BabyAI level.",
137
+ difficulty=8,
138
+ max_steps=512,
139
+ expected_optimal_steps=80,
140
+ requires_interaction=True,
141
+ num_objects=10,
142
+ involves_language_composition=True,
143
+ ),
144
+ ]
145
+
146
+
147
+ def get_level(name: str) -> LevelConfig:
148
+ """Return a level by short name (case-insensitive)."""
149
+ for level in LEVEL_REGISTRY:
150
+ if level.name.lower() == name.lower():
151
+ return level
152
+ available = ", ".join(level.name for level in LEVEL_REGISTRY)
153
+ raise ValueError(f"Unknown level '{name}'. Available levels: {available}")
154
+
155
+
156
+ def get_levels_by_difficulty(max_difficulty: int) -> list[LevelConfig]:
157
+ """Return levels with difficulty <= max_difficulty."""
158
+ return [level for level in LEVEL_REGISTRY if level.difficulty <= max_difficulty]
159
+
160
+
161
+ @dataclass
162
+ class CurriculumConfig:
163
+ """Curriculum stages and advancement settings."""
164
+
165
+ stages: list[list[str]] = field(
166
+ default_factory=lambda: [
167
+ ["GoToRedBall"],
168
+ ["GoToObj", "GoToLocal"],
169
+ ["PickupLoc", "OpenDoor", "UnlockLocal"],
170
+ ["GoTo", "PutNextLocal"],
171
+ ["Synth", "BossLevel"],
172
+ ]
173
+ )
174
+ advance_threshold: float = 0.8
175
+ episodes_per_eval: int = 100
env/minigrid_env.py ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core OpenEnv environment for text-based MiniGrid/BabyAI interaction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from typing import Any, Optional
7
+
8
+ import gymnasium as gym
9
+ import numpy as np
10
+
11
+ try:
12
+ from .action_parser import parse_action
13
+ from .config import EnvConfig
14
+ from .grid_to_text import grid_to_text
15
+ from .levels import LevelConfig, get_level
16
+ from .models import MiniGridAction, MiniGridObservation, MiniGridState
17
+ from .reward import compute_step_reward
18
+ except ImportError:
19
+ from env.action_parser import parse_action
20
+ from env.config import EnvConfig
21
+ from env.grid_to_text import grid_to_text
22
+ from env.levels import LevelConfig, get_level
23
+ from env.models import MiniGridAction, MiniGridObservation, MiniGridState
24
+ from env.reward import compute_step_reward
25
+
26
+ try:
27
+ from openenv.core.env_server.interfaces import Environment
28
+ except ImportError:
29
+ from abc import ABC, abstractmethod
30
+ from typing import Generic, TypeVar
31
+
32
+ ActT = TypeVar("ActT")
33
+ ObsT = TypeVar("ObsT")
34
+ StateT = TypeVar("StateT")
35
+
36
+ class Environment(ABC, Generic[ActT, ObsT, StateT]):
37
+ @abstractmethod
38
+ def reset(self, seed=None, episode_id=None, **kwargs): ...
39
+
40
+ @abstractmethod
41
+ def step(self, action, timeout_s=None, **kwargs): ...
42
+
43
+ @property
44
+ @abstractmethod
45
+ def state(self): ...
46
+
47
+
48
+ class MiniGridEnvironment(Environment[MiniGridAction, MiniGridObservation, MiniGridState]):
49
+ """OpenEnv wrapper around BabyAI levels in MiniGrid."""
50
+
51
+ SUPPORTS_CONCURRENT_SESSIONS = True
52
+
53
+ def __init__(self, config: Optional[EnvConfig] = None, **kwargs: Any):
54
+ super().__init__(**kwargs)
55
+ self._config = config or EnvConfig()
56
+ self._level: LevelConfig = get_level(self._config.level_name)
57
+ self._max_steps = self._config.max_steps_override or self._level.max_steps
58
+
59
+ self._gym_env: Optional[gym.Env] = None
60
+ self._episode_id: str = ""
61
+ self._active_seed: int = self._config.seed
62
+ self._step_idx: int = 0
63
+ self._done: bool = False
64
+ self._history: list[dict[str, Any]] = []
65
+ self._total_reward: float = 0.0
66
+ self._completed: bool = False
67
+ self._truncated: bool = False
68
+ self._valid_actions: int = 0
69
+ self._invalid_actions: int = 0
70
+ self._action_counts: dict[str, int] = {}
71
+ self._last_obs: Optional[dict[str, Any]] = None
72
+ self._carrying: Any = None
73
+ self._optimal_steps: Optional[int] = None
74
+
75
+ def _make_gym_env(self) -> gym.Env:
76
+ # Import registers BabyAI env IDs in gymnasium.
77
+ import minigrid # noqa: F401
78
+
79
+ last_error: Optional[Exception] = None
80
+ for gym_id in self._level.candidate_gym_ids:
81
+ try:
82
+ return gym.make(gym_id)
83
+ except Exception as exc: # pragma: no cover - depends on installed minigrid
84
+ last_error = exc
85
+ raise RuntimeError(
86
+ f"Failed to create level '{self._level.name}' with ids {self._level.candidate_gym_ids}"
87
+ ) from last_error
88
+
89
+ def reset(
90
+ self,
91
+ seed: Optional[int] = None,
92
+ episode_id: Optional[str] = None,
93
+ **kwargs: Any,
94
+ ) -> MiniGridObservation:
95
+ del kwargs
96
+ if self._gym_env is not None:
97
+ try:
98
+ self._gym_env.close()
99
+ except Exception:
100
+ pass
101
+
102
+ self._episode_id = episode_id or str(uuid.uuid4())
103
+ self._active_seed = self._config.seed if seed is None else seed
104
+ self._gym_env = self._make_gym_env()
105
+ raw_obs, _info = self._gym_env.reset(seed=self._active_seed)
106
+
107
+ self._step_idx = 0
108
+ self._done = False
109
+ self._history = []
110
+ self._total_reward = 0.0
111
+ self._completed = False
112
+ self._truncated = False
113
+ self._valid_actions = 0
114
+ self._invalid_actions = 0
115
+ self._action_counts = {}
116
+ self._last_obs = raw_obs
117
+ self._carrying = getattr(self._gym_env.unwrapped, "carrying", None)
118
+
119
+ self._optimal_steps = self._compute_optimal_steps() if self._config.compute_optimal else None
120
+
121
+ text = grid_to_text(
122
+ raw_obs,
123
+ carrying=self._carrying,
124
+ include_raw_grid=self._config.include_raw_grid,
125
+ )
126
+ return MiniGridObservation(
127
+ text=text,
128
+ mission=raw_obs.get("mission", ""),
129
+ step_idx=0,
130
+ steps_remaining=self._max_steps,
131
+ max_steps=self._max_steps,
132
+ history=[],
133
+ level_name=self._level.name,
134
+ last_action=None,
135
+ action_success=None,
136
+ done=False,
137
+ reward=None,
138
+ )
139
+
140
+ def step(
141
+ self,
142
+ action: MiniGridAction,
143
+ timeout_s: Optional[float] = None,
144
+ **kwargs: Any,
145
+ ) -> MiniGridObservation:
146
+ del timeout_s, kwargs
147
+ if self._done:
148
+ raise RuntimeError("Episode is finished. Call reset() before stepping again.")
149
+ if self._gym_env is None:
150
+ raise RuntimeError("Environment is not initialized. Call reset() first.")
151
+
152
+ action_idx, canonical_action, was_valid = parse_action(action.command)
153
+ if was_valid:
154
+ self._valid_actions += 1
155
+ else:
156
+ self._invalid_actions += 1
157
+ self._action_counts[canonical_action] = self._action_counts.get(canonical_action, 0) + 1
158
+
159
+ prev_obs = self._last_obs
160
+ prev_carrying = self._carrying
161
+ raw_obs, _gym_reward, terminated, truncated, _info = self._gym_env.step(action_idx)
162
+
163
+ self._step_idx += 1
164
+ if self._step_idx >= self._max_steps and not terminated:
165
+ truncated = True
166
+
167
+ done = bool(terminated or truncated)
168
+ self._carrying = getattr(self._gym_env.unwrapped, "carrying", None)
169
+
170
+ reward, _reward_breakdown = compute_step_reward(
171
+ terminated=bool(terminated),
172
+ truncated=bool(truncated),
173
+ action_valid=was_valid,
174
+ step_idx=self._step_idx,
175
+ max_steps=self._max_steps,
176
+ optimal_steps=self._optimal_steps,
177
+ config=self._config.reward,
178
+ )
179
+ self._total_reward += reward
180
+
181
+ self._history.append(
182
+ {
183
+ "step": self._step_idx - 1,
184
+ "action": canonical_action,
185
+ "action_raw": action.command,
186
+ "action_valid": was_valid,
187
+ "reward": reward,
188
+ **({"thought": action.thought} if action.thought else {}),
189
+ }
190
+ )
191
+
192
+ visible_history: list[dict[str, Any]] = []
193
+ if self._config.include_history:
194
+ if self._config.max_history_length > 0:
195
+ visible_history = self._history[-self._config.max_history_length :]
196
+ else:
197
+ visible_history = []
198
+
199
+ self._done = done
200
+ self._completed = bool(terminated)
201
+ self._truncated = bool(truncated)
202
+ self._last_obs = raw_obs
203
+
204
+ text = grid_to_text(
205
+ raw_obs,
206
+ carrying=self._carrying,
207
+ include_raw_grid=self._config.include_raw_grid,
208
+ )
209
+ action_success = self._detect_action_success(
210
+ canonical_action=canonical_action,
211
+ prev_obs=prev_obs,
212
+ next_obs=raw_obs,
213
+ prev_carrying=prev_carrying,
214
+ terminated=bool(terminated),
215
+ )
216
+
217
+ return MiniGridObservation(
218
+ text=text,
219
+ mission=raw_obs.get("mission", ""),
220
+ step_idx=self._step_idx,
221
+ steps_remaining=max(0, self._max_steps - self._step_idx),
222
+ max_steps=self._max_steps,
223
+ history=visible_history,
224
+ level_name=self._level.name,
225
+ last_action=canonical_action,
226
+ action_success=action_success,
227
+ done=done,
228
+ reward=reward,
229
+ )
230
+
231
+ def _compute_optimal_steps(self) -> Optional[int]:
232
+ """Attempt to compute near-optimal length with BabyAI's built-in bot."""
233
+ try:
234
+ from minigrid.envs.babyai import BotAgent # type: ignore
235
+ except Exception:
236
+ return None
237
+
238
+ eval_env: Optional[gym.Env] = None
239
+ try:
240
+ eval_env = self._make_gym_env()
241
+ obs, _info = eval_env.reset(seed=self._active_seed)
242
+ bot = BotAgent(eval_env.unwrapped)
243
+
244
+ steps = 0
245
+ done = False
246
+ while not done and steps < self._max_steps:
247
+ action = bot.act(obs)
248
+ obs, _rew, terminated, truncated, _info = eval_env.step(action)
249
+ done = bool(terminated or truncated)
250
+ steps += 1
251
+ return steps if done else None
252
+ except Exception:
253
+ return None
254
+ finally:
255
+ if eval_env is not None:
256
+ try:
257
+ eval_env.close()
258
+ except Exception:
259
+ pass
260
+
261
+ @staticmethod
262
+ def _images_equal(obs_a: Optional[dict[str, Any]], obs_b: Optional[dict[str, Any]]) -> bool:
263
+ if not obs_a or not obs_b:
264
+ return False
265
+ img_a = obs_a.get("image")
266
+ img_b = obs_b.get("image")
267
+ if img_a is None or img_b is None:
268
+ return False
269
+ return bool(np.array_equal(np.asarray(img_a), np.asarray(img_b)))
270
+
271
+ def _detect_action_success(
272
+ self,
273
+ *,
274
+ canonical_action: str,
275
+ prev_obs: Optional[dict[str, Any]],
276
+ next_obs: Optional[dict[str, Any]],
277
+ prev_carrying: Any,
278
+ terminated: bool,
279
+ ) -> Optional[bool]:
280
+ if terminated:
281
+ return True
282
+ if canonical_action == "pickup":
283
+ return prev_carrying is None and self._carrying is not None
284
+ if canonical_action == "drop":
285
+ return prev_carrying is not None and self._carrying is None
286
+ if canonical_action in {"turn left", "turn right"}:
287
+ if not prev_obs or not next_obs:
288
+ return None
289
+ return prev_obs.get("direction") != next_obs.get("direction")
290
+ if canonical_action == "go forward":
291
+ return not self._images_equal(prev_obs, next_obs)
292
+ if canonical_action == "toggle":
293
+ return not self._images_equal(prev_obs, next_obs)
294
+ return None
295
+
296
+ @property
297
+ def state(self) -> MiniGridState:
298
+ efficiency = None
299
+ if self._optimal_steps and self._step_idx > 0:
300
+ efficiency = float(self._optimal_steps) / float(self._step_idx)
301
+
302
+ return MiniGridState(
303
+ episode_id=self._episode_id,
304
+ step_count=self._step_idx,
305
+ level_name=self._level.name,
306
+ level_difficulty=self._level.difficulty,
307
+ completed=self._completed,
308
+ truncated=self._truncated,
309
+ total_reward=self._total_reward,
310
+ steps_taken=self._step_idx,
311
+ optimal_steps=self._optimal_steps,
312
+ efficiency_ratio=efficiency,
313
+ valid_actions=self._valid_actions,
314
+ invalid_actions=self._invalid_actions,
315
+ action_distribution=dict(self._action_counts),
316
+ )
317
+
318
+
319
+ MiniGridEnv = MiniGridEnvironment
env/models.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenEnv Pydantic models for MiniGridEnv."""
2
+
3
+ from typing import Any, Optional
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ try:
8
+ from openenv.core.env_server.types import Action as _ActionBase
9
+ from openenv.core.env_server.types import Observation as _ObservationBase
10
+ from openenv.core.env_server.types import State as _StateBase
11
+ except ImportError:
12
+ _ActionBase = BaseModel
13
+ _ObservationBase = BaseModel
14
+ _StateBase = BaseModel
15
+
16
+
17
+ class MiniGridAction(_ActionBase):
18
+ """Agent action represented as a natural language command."""
19
+
20
+ if _ActionBase is BaseModel:
21
+ model_config = ConfigDict(extra="forbid")
22
+ metadata: dict[str, Any] = Field(default_factory=dict)
23
+
24
+ command: str = Field(
25
+ ...,
26
+ description="Natural language command (for example: 'go forward', 'turn left', 'pickup').",
27
+ )
28
+ thought: Optional[str] = Field(
29
+ default=None,
30
+ description="Optional reasoning trace for logging/analysis.",
31
+ )
32
+
33
+
34
+ class MiniGridObservation(_ObservationBase):
35
+ """Text observation derived from MiniGrid's egocentric 7x7 view."""
36
+
37
+ if _ObservationBase is BaseModel:
38
+ model_config = ConfigDict(extra="forbid")
39
+ done: bool = Field(default=False)
40
+ reward: Optional[float] = Field(default=None)
41
+ metadata: dict[str, Any] = Field(default_factory=dict)
42
+
43
+ text: str = Field(..., description="Natural-language environment description.")
44
+ mission: str = Field(..., description="Current mission instruction.")
45
+ step_idx: int = Field(..., ge=0, description="Current 0-indexed step number.")
46
+ steps_remaining: int = Field(..., ge=0, description="Steps left before truncation.")
47
+ max_steps: int = Field(..., ge=1, description="Maximum number of steps in this episode.")
48
+ history: list[dict[str, Any]] = Field(
49
+ default_factory=list,
50
+ description="Recent step history entries for prompt reconstruction.",
51
+ )
52
+ level_name: str = Field(default="", description="Selected BabyAI level short name.")
53
+ last_action: Optional[str] = Field(default=None, description="Canonical last action string.")
54
+ action_success: Optional[bool] = Field(
55
+ default=None,
56
+ description="Whether the last action had an observable effect.",
57
+ )
58
+
59
+
60
+ class MiniGridState(_StateBase):
61
+ """Episode-level state metrics for logging and debugging."""
62
+
63
+ if _StateBase is BaseModel:
64
+ model_config = ConfigDict(extra="allow")
65
+ episode_id: Optional[str] = Field(default=None)
66
+ step_count: int = Field(default=0, ge=0)
67
+
68
+ level_name: str = Field(default="", description="Selected level name.")
69
+ level_difficulty: int = Field(default=0, ge=0, description="Difficulty stage index.")
70
+ completed: bool = Field(default=False, description="True when mission succeeds.")
71
+ truncated: bool = Field(default=False, description="True when max step budget is exhausted.")
72
+ total_reward: float = Field(default=0.0, description="Cumulative reward this episode.")
73
+ steps_taken: int = Field(default=0, ge=0, description="Total steps executed.")
74
+ optimal_steps: Optional[int] = Field(
75
+ default=None,
76
+ ge=0,
77
+ description="Optional best-path length from the BabyAI bot.",
78
+ )
79
+ efficiency_ratio: Optional[float] = Field(
80
+ default=None,
81
+ description="optimal_steps / steps_taken when available.",
82
+ )
83
+ valid_actions: int = Field(default=0, ge=0, description="Count of valid parsed actions.")
84
+ invalid_actions: int = Field(default=0, ge=0, description="Count of invalid parsed actions.")
85
+ action_distribution: dict[str, int] = Field(
86
+ default_factory=dict,
87
+ description="Histogram of canonical actions used so far.",
88
+ )
env/reward.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reward helpers for MiniGridEnv episodes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ try:
6
+ from .config import RewardConfig
7
+ except ImportError:
8
+ from env.config import RewardConfig
9
+
10
+
11
+ def compute_step_reward(
12
+ *,
13
+ terminated: bool,
14
+ truncated: bool,
15
+ action_valid: bool,
16
+ step_idx: int,
17
+ max_steps: int,
18
+ optimal_steps: int | None,
19
+ config: RewardConfig,
20
+ ) -> tuple[float, dict[str, float]]:
21
+ """Compute reward and breakdown for a single environment step."""
22
+ del optimal_steps # Reserved for future ratio-based shaping.
23
+
24
+ mode = config.mode.lower().strip()
25
+ if mode not in {"binary", "shaped", "efficiency"}:
26
+ mode = "binary"
27
+
28
+ breakdown: dict[str, float] = {}
29
+ total = 0.0
30
+
31
+ if terminated:
32
+ total += config.completion_reward
33
+ breakdown["completion"] = config.completion_reward
34
+ if mode == "efficiency":
35
+ efficiency = max(0.0, 1.0 - (float(step_idx) / float(max_steps)))
36
+ bonus = config.efficiency_bonus_weight * efficiency
37
+ total += bonus
38
+ breakdown["efficiency_bonus"] = bonus
39
+ elif truncated:
40
+ total += config.failure_reward
41
+ breakdown["timeout"] = config.failure_reward
42
+
43
+ if mode in {"shaped", "efficiency"}:
44
+ total += config.step_penalty
45
+ breakdown["step_penalty"] = config.step_penalty
46
+ if not action_valid:
47
+ total += config.invalid_action_penalty
48
+ breakdown["invalid_action"] = config.invalid_action_penalty
49
+
50
+ breakdown["total"] = total
51
+ return total, breakdown
eval/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Evaluation helpers for MiniGridEnv."""
eval/evaluate.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Baseline evaluation harness for MiniGridEnv."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import math
7
+ from collections import Counter
8
+ from statistics import mean
9
+ from typing import Any, Callable
10
+
11
+ try:
12
+ from ..env.config import EnvConfig
13
+ from ..env.minigrid_env import MiniGridEnvironment
14
+ except ImportError:
15
+ from env.config import EnvConfig
16
+ from env.minigrid_env import MiniGridEnvironment
17
+
18
+
19
+ def _sem(values: list[float]) -> float:
20
+ if len(values) < 2:
21
+ return 0.0
22
+ avg = mean(values)
23
+ variance = sum((value - avg) ** 2 for value in values) / (len(values) - 1)
24
+ return math.sqrt(variance / len(values))
25
+
26
+
27
+ def _resolve_episode_baseline(
28
+ baseline: Any, env: MiniGridEnvironment
29
+ ) -> Any:
30
+ if hasattr(baseline, "select_action"):
31
+ return baseline
32
+ if callable(baseline):
33
+ try:
34
+ return baseline(env._gym_env) # type: ignore[attr-defined]
35
+ except TypeError:
36
+ return baseline()
37
+ raise TypeError("baseline must be an agent object or a callable factory")
38
+
39
+
40
+ def _select_action(agent: Any, obs: Any, raw_obs: dict | None):
41
+ select_action = getattr(agent, "select_action")
42
+ sig = inspect.signature(select_action)
43
+ if len(sig.parameters) >= 2:
44
+ return select_action(obs, raw_obs)
45
+ return select_action(obs)
46
+
47
+
48
+ def evaluate_baseline(
49
+ baseline: Any,
50
+ level_name: str,
51
+ n_episodes: int = 100,
52
+ seed: int = 42,
53
+ ) -> dict[str, Any]:
54
+ """Evaluate one baseline on one level and return aggregate metrics."""
55
+ completed_flags: list[float] = []
56
+ completed_steps: list[float] = []
57
+ rewards: list[float] = []
58
+ efficiencies: list[float] = []
59
+ action_counter: Counter[str] = Counter()
60
+ total_valid = 0
61
+ total_invalid = 0
62
+
63
+ for episode_offset in range(n_episodes):
64
+ env = MiniGridEnvironment(
65
+ config=EnvConfig(level_name=level_name, seed=seed + episode_offset)
66
+ )
67
+ obs = env.reset(seed=seed + episode_offset)
68
+ agent = _resolve_episode_baseline(baseline, env)
69
+
70
+ while not obs.done:
71
+ raw_obs = env._last_obs # type: ignore[attr-defined]
72
+ action = _select_action(agent, obs, raw_obs)
73
+ obs = env.step(action)
74
+
75
+ state = env.state
76
+ completed = 1.0 if state.completed else 0.0
77
+ completed_flags.append(completed)
78
+ rewards.append(float(state.total_reward))
79
+ total_valid += int(state.valid_actions)
80
+ total_invalid += int(state.invalid_actions)
81
+ action_counter.update(state.action_distribution)
82
+ if state.completed:
83
+ completed_steps.append(float(state.steps_taken))
84
+ if state.efficiency_ratio is not None:
85
+ efficiencies.append(float(state.efficiency_ratio))
86
+
87
+ total_actions = total_valid + total_invalid
88
+ return {
89
+ "level": level_name,
90
+ "episodes": n_episodes,
91
+ "completion_rate": mean(completed_flags) if completed_flags else 0.0,
92
+ "completion_rate_sem": _sem(completed_flags),
93
+ "mean_steps_completed": mean(completed_steps) if completed_steps else 0.0,
94
+ "mean_steps_completed_sem": _sem(completed_steps),
95
+ "mean_reward": mean(rewards) if rewards else 0.0,
96
+ "mean_reward_sem": _sem(rewards),
97
+ "efficiency": mean(efficiencies) if efficiencies else 0.0,
98
+ "efficiency_sem": _sem(efficiencies),
99
+ "action_parse_rate": (float(total_valid) / float(total_actions)) if total_actions else 0.0,
100
+ "action_distribution": dict(action_counter),
101
+ }
102
+
103
+
104
+ def evaluate_across_levels(
105
+ baseline: Any,
106
+ level_names: list[str],
107
+ n_episodes_per_level: int = 100,
108
+ seed: int = 42,
109
+ ) -> dict[str, dict[str, Any]]:
110
+ """Run evaluate_baseline for all level names."""
111
+ return {
112
+ level_name: evaluate_baseline(
113
+ baseline=baseline,
114
+ level_name=level_name,
115
+ n_episodes=n_episodes_per_level,
116
+ seed=seed,
117
+ )
118
+ for level_name in level_names
119
+ }
120
+
121
+
122
+ def print_comparison_table(results: dict[str, dict[str, dict[str, Any]]]) -> None:
123
+ """Pretty-print baseline comparison by completion rate."""
124
+ all_levels: set[str] = set()
125
+ for baseline_results in results.values():
126
+ all_levels.update(baseline_results.keys())
127
+ ordered_levels = sorted(all_levels)
128
+ baseline_names = list(results.keys())
129
+
130
+ header = ["Level", *baseline_names]
131
+ row_sep = "| " + " | ".join(["---"] * len(header)) + " |"
132
+ print("| " + " | ".join(header) + " |")
133
+ print(row_sep)
134
+ for level in ordered_levels:
135
+ row = [level]
136
+ for baseline_name in baseline_names:
137
+ metrics = results.get(baseline_name, {}).get(level, {})
138
+ value = float(metrics.get("completion_rate", 0.0)) * 100.0
139
+ row.append(f"{value:.1f}%")
140
+ print("| " + " | ".join(row) + " |")
models.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """OpenEnv models at package root for CLI/schema compatibility."""
2
+
3
+ try:
4
+ from .env.models import MiniGridAction, MiniGridObservation, MiniGridState
5
+ except ImportError:
6
+ from env.models import MiniGridAction, MiniGridObservation, MiniGridState
7
+
8
+ __all__ = ["MiniGridAction", "MiniGridObservation", "MiniGridState"]
openenv.yaml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: MiniGridEnv
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 8000
7
+
openenv_MiniGridEnv.egg-info/PKG-INFO ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Metadata-Version: 2.4
2
+ Name: openenv-MiniGridEnv
3
+ Version: 0.1.0
4
+ Summary: MiniGrid/BabyAI text-grounded environment for OpenEnv
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: openenv-core[core]>=0.2.0
7
+ Requires-Dist: minigrid>=2.3.0
8
+ Requires-Dist: gymnasium>=0.29.0
9
+ Requires-Dist: numpy>=1.24.0
10
+ Requires-Dist: pydantic>=2.0.0
11
+ Requires-Dist: fastapi>=0.100.0
12
+ Requires-Dist: uvicorn>=0.23.0
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
15
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
openenv_MiniGridEnv.egg-info/SOURCES.txt ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ README.md
2
+ pyproject.toml
3
+ ./__init__.py
4
+ ./client.py
5
+ ./models.py
6
+ baselines/__init__.py
7
+ baselines/heuristic_bot.py
8
+ baselines/llm_zeroshot.py
9
+ baselines/random_action.py
10
+ env/__init__.py
11
+ env/action_parser.py
12
+ env/config.py
13
+ env/grid_to_text.py
14
+ env/levels.py
15
+ env/minigrid_env.py
16
+ env/models.py
17
+ env/reward.py
18
+ eval/__init__.py
19
+ eval/evaluate.py
20
+ openenv_MiniGridEnv.egg-info/PKG-INFO
21
+ openenv_MiniGridEnv.egg-info/SOURCES.txt
22
+ openenv_MiniGridEnv.egg-info/dependency_links.txt
23
+ openenv_MiniGridEnv.egg-info/entry_points.txt
24
+ openenv_MiniGridEnv.egg-info/requires.txt
25
+ openenv_MiniGridEnv.egg-info/top_level.txt
26
+ server/__init__.py
27
+ server/app.py
28
+ tests/__init__.py
29
+ tests/test_action_parser.py
30
+ tests/test_contract.py
31
+ tests/test_env.py
32
+ tests/test_grid_to_text.py
33
+ tests/test_reward.py
openenv_MiniGridEnv.egg-info/dependency_links.txt ADDED
@@ -0,0 +1 @@
 
 
1
+
openenv_MiniGridEnv.egg-info/entry_points.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [console_scripts]
2
+ server = MiniGridEnv.server.app:main
openenv_MiniGridEnv.egg-info/requires.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ openenv-core[core]>=0.2.0
2
+ minigrid>=2.3.0
3
+ gymnasium>=0.29.0
4
+ numpy>=1.24.0
5
+ pydantic>=2.0.0
6
+ fastapi>=0.100.0
7
+ uvicorn>=0.23.0
8
+
9
+ [dev]
10
+ pytest>=8.0.0
11
+ pytest-cov>=4.0.0
openenv_MiniGridEnv.egg-info/top_level.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ MiniGridEnv
pyproject.toml ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ [build-system]
8
+ requires = ["setuptools>=45", "wheel"]
9
+ build-backend = "setuptools.build_meta"
10
+
11
+ [project]
12
+ name = "openenv-MiniGridEnv"
13
+ version = "0.1.0"
14
+ description = "MiniGrid/BabyAI text-grounded environment for OpenEnv"
15
+ requires-python = ">=3.10"
16
+ dependencies = [
17
+ "openenv-core[core]>=0.2.0",
18
+ "minigrid>=2.3.0",
19
+ "gymnasium>=0.29.0",
20
+ "numpy>=1.24.0",
21
+ "pydantic>=2.0.0",
22
+ "fastapi>=0.100.0",
23
+ "uvicorn>=0.23.0",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ dev = [
28
+ "pytest>=8.0.0",
29
+ "pytest-cov>=4.0.0",
30
+ ]
31
+
32
+ [project.scripts]
33
+ server = "MiniGridEnv.server.app:main"
34
+
35
+ [tool.setuptools]
36
+ include-package-data = true
37
+ packages = [
38
+ "MiniGridEnv",
39
+ "MiniGridEnv.server",
40
+ "MiniGridEnv.env",
41
+ "MiniGridEnv.baselines",
42
+ "MiniGridEnv.eval",
43
+ "MiniGridEnv.tests",
44
+ ]
45
+
46
+ [tool.setuptools.package-dir]
47
+ "MiniGridEnv" = "."
48
+ "MiniGridEnv.server" = "server"
49
+ "MiniGridEnv.env" = "env"
50
+ "MiniGridEnv.baselines" = "baselines"
51
+ "MiniGridEnv.eval" = "eval"
52
+ "MiniGridEnv.tests" = "tests"
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ openenv-core[core]>=0.2.0
2
+ minigrid>=2.3.0
3
+ gymnasium>=0.29.0
4
+ numpy>=1.24.0
5
+ pydantic>=2.0.0
6
+ fastapi>=0.100.0
7
+ uvicorn>=0.23.0
server/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """MiniGridEnv server package."""
2
+
3
+ try:
4
+ from ..env.minigrid_env import MiniGridEnvironment
5
+ except ImportError:
6
+ from env.minigrid_env import MiniGridEnvironment
7
+
8
+ __all__ = ["MiniGridEnvironment"]
server/app.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI app exposing MiniGridEnv through OpenEnv."""
2
+
3
+ try:
4
+ from ..env.config import EnvConfig
5
+ from ..env.minigrid_env import MiniGridEnvironment
6
+ from ..env.models import MiniGridAction, MiniGridObservation
7
+ except ImportError:
8
+ from env.config import EnvConfig
9
+ from env.minigrid_env import MiniGridEnvironment
10
+ from env.models import MiniGridAction, MiniGridObservation
11
+
12
+ try:
13
+ from openenv.core.env_server import create_app
14
+ except ImportError:
15
+ create_app = None # type: ignore
16
+
17
+
18
+ def _env_factory() -> MiniGridEnvironment:
19
+ """Create a fresh env instance per client session."""
20
+ return MiniGridEnvironment(config=EnvConfig())
21
+
22
+
23
+ if create_app is not None:
24
+ app = create_app(
25
+ _env_factory,
26
+ MiniGridAction,
27
+ MiniGridObservation,
28
+ env_name="MiniGridEnv",
29
+ max_concurrent_envs=256,
30
+ )
31
+ else:
32
+ from fastapi import FastAPI
33
+
34
+ app = FastAPI(title="MiniGridEnv")
35
+ app.get("/health")(lambda: {"status": "ok"})
36
+
37
+
38
+ def main() -> None:
39
+ """Entry point for `uv run server` or `python -m`."""
40
+ import uvicorn
41
+
42
+ uvicorn.run(app, host="0.0.0.0", port=8000)
43
+
44
+
45
+ if __name__ == "__main__":
46
+ main()
server/requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ openenv-core[core]>=0.2.0
2
+ minigrid>=2.3.0
3
+ gymnasium>=0.29.0
4
+ numpy>=1.24.0
5
+ pydantic>=2.0.0
6
+ fastapi>=0.100.0
7
+ uvicorn>=0.23.0
tests/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Test package for MiniGridEnv."""
tests/test_action_parser.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for text-to-action parsing."""
2
+
3
+ from MiniGridEnv.env.action_parser import parse_action
4
+
5
+
6
+ def test_exact_match():
7
+ assert parse_action("go forward") == (2, "go forward", True)
8
+
9
+
10
+ def test_case_insensitive():
11
+ assert parse_action("Turn Left") == (0, "turn left", True)
12
+
13
+
14
+ def test_structured_format():
15
+ action_idx, canonical, valid = parse_action("Thought: I see a key.\nAction: pickup")
16
+ assert (action_idx, canonical, valid) == (3, "pickup", True)
17
+
18
+
19
+ def test_alias():
20
+ assert parse_action("left") == (0, "turn left", True)
21
+
22
+
23
+ def test_substring():
24
+ assert parse_action("I should go forward now") == (2, "go forward", True)
25
+
26
+
27
+ def test_invalid_fallback():
28
+ action_idx, canonical, valid = parse_action("fly to the moon")
29
+ assert (action_idx, canonical, valid) == (2, "go forward", False)
30
+
31
+
32
+ def test_all_canonical_actions():
33
+ expected = [
34
+ ("turn left", 0),
35
+ ("turn right", 1),
36
+ ("go forward", 2),
37
+ ("pickup", 3),
38
+ ("drop", 4),
39
+ ("toggle", 5),
40
+ ("done", 6),
41
+ ]
42
+ for command, index in expected:
43
+ assert parse_action(command)[0] == index
44
+
45
+
46
+ def test_whitespace_handling():
47
+ assert parse_action(" go forward \n") == (2, "go forward", True)
tests/test_contract.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Contract-style tests for MiniGridEnv reset/step/state behavior."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ minigrid = pytest.importorskip("minigrid")
8
+ assert minigrid is not None
9
+
10
+ from MiniGridEnv.env.config import EnvConfig
11
+ from MiniGridEnv.env.minigrid_env import MiniGridEnvironment
12
+ from MiniGridEnv.env.models import MiniGridAction, MiniGridObservation
13
+
14
+
15
+ def test_reset_returns_valid_observation():
16
+ env = MiniGridEnvironment(config=EnvConfig(level_name="GoToRedBall"))
17
+ obs = env.reset(seed=123)
18
+ assert isinstance(obs, MiniGridObservation)
19
+ assert obs.step_idx == 0
20
+ assert obs.history == []
21
+ assert obs.done is False
22
+ assert isinstance(obs.text, str) and len(obs.text.strip()) > 0
23
+
24
+
25
+ def test_observation_text_is_natural_language():
26
+ env = MiniGridEnvironment(config=EnvConfig(level_name="GoToRedBall"))
27
+ obs = env.reset(seed=123)
28
+ assert "Mission:" in obs.text
29
+ assert "You are facing" in obs.text
30
+
31
+
32
+ def test_mission_is_populated():
33
+ env = MiniGridEnvironment(config=EnvConfig(level_name="GoToRedBall"))
34
+ obs = env.reset(seed=123)
35
+ assert isinstance(obs.mission, str) and len(obs.mission.strip()) > 0
36
+
37
+
38
+ def test_step_accepts_valid_command():
39
+ env = MiniGridEnvironment(config=EnvConfig(level_name="GoToRedBall"))
40
+ env.reset(seed=123)
41
+ obs = env.step(MiniGridAction(command="go forward"))
42
+ assert isinstance(obs, MiniGridObservation)
43
+ assert obs.step_idx == 1
44
+
45
+
46
+ def test_step_handles_invalid_command():
47
+ env = MiniGridEnvironment(config=EnvConfig(level_name="GoToRedBall"))
48
+ env.reset(seed=123)
49
+ obs = env.step(MiniGridAction(command="fly away"))
50
+ assert isinstance(obs, MiniGridObservation)
51
+ assert obs.last_action == "go forward"
52
+ assert env.state.invalid_actions == 1
53
+
54
+
55
+ def test_episode_terminates_on_success():
56
+ try:
57
+ from minigrid.envs.babyai import BotAgent # type: ignore
58
+ except Exception:
59
+ pytest.skip("BotAgent unavailable for success-path test")
60
+
61
+ int_to_text = {
62
+ 0: "turn left",
63
+ 1: "turn right",
64
+ 2: "go forward",
65
+ 3: "pickup",
66
+ 4: "drop",
67
+ 5: "toggle",
68
+ 6: "done",
69
+ }
70
+
71
+ env = MiniGridEnvironment(config=EnvConfig(level_name="GoToRedBall"))
72
+ obs = env.reset(seed=123)
73
+ bot = BotAgent(env._gym_env.unwrapped) # type: ignore[attr-defined]
74
+
75
+ while not obs.done and obs.step_idx < obs.max_steps:
76
+ raw_obs = env._last_obs # type: ignore[attr-defined]
77
+ action = bot.act(raw_obs)
78
+ obs = env.step(MiniGridAction(command=int_to_text.get(int(action), "done")))
79
+
80
+ assert obs.done is True
81
+ assert env.state.completed is True
82
+ assert env.state.total_reward > 0.0
83
+
84
+
85
+ def test_episode_truncates_on_max_steps():
86
+ env = MiniGridEnvironment(
87
+ config=EnvConfig(level_name="GoToRedBall", max_steps_override=1)
88
+ )
89
+ env.reset(seed=123)
90
+ obs = env.step(MiniGridAction(command="go forward"))
91
+ assert obs.done is True
92
+ assert env.state.truncated is True
93
+
94
+
95
+ def test_deterministic_with_seed():
96
+ actions = ["turn left", "turn right", "go forward", "go forward"]
97
+
98
+ env_a = MiniGridEnvironment(config=EnvConfig(level_name="GoToRedBall"))
99
+ env_b = MiniGridEnvironment(config=EnvConfig(level_name="GoToRedBall"))
100
+ obs_a = env_a.reset(seed=777)
101
+ obs_b = env_b.reset(seed=777)
102
+
103
+ assert obs_a.text == obs_b.text
104
+ for command in actions:
105
+ if obs_a.done or obs_b.done:
106
+ assert obs_a.done == obs_b.done
107
+ break
108
+ obs_a = env_a.step(MiniGridAction(command=command))
109
+ obs_b = env_b.step(MiniGridAction(command=command))
110
+ assert obs_a.text == obs_b.text
111
+ assert obs_a.done == obs_b.done
112
+
113
+
114
+ def test_state_tracks_metrics():
115
+ env = MiniGridEnvironment(config=EnvConfig(level_name="GoToRedBall"))
116
+ env.reset(seed=123)
117
+ env.step(MiniGridAction(command="go forward"))
118
+ state = env.state
119
+ assert state.steps_taken == 1
120
+ assert state.valid_actions == 1
121
+ assert state.action_distribution.get("go forward", 0) == 1
122
+
123
+
124
+ def test_all_seven_actions_accepted():
125
+ commands = [
126
+ "turn left",
127
+ "turn right",
128
+ "go forward",
129
+ "pickup",
130
+ "drop",
131
+ "toggle",
132
+ "done",
133
+ ]
134
+ env = MiniGridEnvironment(config=EnvConfig(level_name="GoToRedBall"))
135
+ for idx, command in enumerate(commands):
136
+ env.reset(seed=100 + idx)
137
+ obs = env.step(MiniGridAction(command=command))
138
+ assert isinstance(obs, MiniGridObservation)
139
+
140
+
141
+ def test_history_grows_each_step():
142
+ env = MiniGridEnvironment(config=EnvConfig(level_name="GoToRedBall"))
143
+ env.reset(seed=123)
144
+ obs = env.step(MiniGridAction(command="go forward"))
145
+ assert len(obs.history) == 1
146
+ obs = env.step(MiniGridAction(command="turn left"))
147
+ assert len(obs.history) == 2
148
+
149
+
150
+ def test_different_levels_produce_different_missions():
151
+ env_a = MiniGridEnvironment(config=EnvConfig(level_name="GoToRedBall"))
152
+ env_b = MiniGridEnvironment(config=EnvConfig(level_name="GoToObj"))
153
+ obs_a = env_a.reset(seed=123)
154
+ obs_b = env_b.reset(seed=123)
155
+ assert obs_a.mission != obs_b.mission
tests/test_env.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Additional environment smoke tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ minigrid = pytest.importorskip("minigrid")
8
+ assert minigrid is not None
9
+
10
+ from MiniGridEnv.env.config import EnvConfig
11
+ from MiniGridEnv.env.minigrid_env import MiniGridEnvironment
12
+ from MiniGridEnv.env.models import MiniGridAction
13
+
14
+
15
+ def test_step_before_reset_raises():
16
+ env = MiniGridEnvironment(config=EnvConfig(level_name="GoToRedBall"))
17
+ with pytest.raises(RuntimeError):
18
+ env.step(MiniGridAction(command="go forward"))
19
+
20
+
21
+ def test_step_after_done_raises():
22
+ env = MiniGridEnvironment(
23
+ config=EnvConfig(level_name="GoToRedBall", max_steps_override=1)
24
+ )
25
+ env.reset(seed=1)
26
+ env.step(MiniGridAction(command="go forward"))
27
+ with pytest.raises(RuntimeError):
28
+ env.step(MiniGridAction(command="go forward"))
29
+
30
+
31
+ def test_reset_creates_new_episode_id():
32
+ env = MiniGridEnvironment(config=EnvConfig(level_name="GoToRedBall"))
33
+ env.reset(seed=1)
34
+ episode_one = env.state.episode_id
35
+ env.reset(seed=2)
36
+ episode_two = env.state.episode_id
37
+ assert episode_one != episode_two
38
+
39
+
40
+ def test_reset_honors_explicit_episode_id():
41
+ env = MiniGridEnvironment(config=EnvConfig(level_name="GoToRedBall"))
42
+ env.reset(seed=1, episode_id="episode-123")
43
+ assert env.state.episode_id == "episode-123"
44
+
45
+
46
+ def test_history_can_be_disabled():
47
+ env = MiniGridEnvironment(
48
+ config=EnvConfig(level_name="GoToRedBall", include_history=False)
49
+ )
50
+ env.reset(seed=5)
51
+ obs = env.step(MiniGridAction(command="go forward"))
52
+ assert obs.history == []
tests/test_grid_to_text.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for MiniGrid observation text rendering."""
2
+
3
+ from types import SimpleNamespace
4
+
5
+ import numpy as np
6
+
7
+ from MiniGridEnv.env.grid_to_text import grid_to_text
8
+
9
+
10
+ def _empty_obs():
11
+ grid = np.ones((7, 7, 3), dtype=np.int64) # object type: empty
12
+ grid[:, :, 1] = 0 # default color red (unused for empties)
13
+ grid[:, :, 2] = 0
14
+ return {"image": grid, "direction": 0, "mission": "go to the red ball"}
15
+
16
+
17
+ def test_empty_room_description():
18
+ text = grid_to_text(_empty_obs())
19
+ assert "Mission: go to the red ball" in text
20
+ assert "You are facing east." in text
21
+ assert "Notable objects: none visible." in text
22
+
23
+
24
+ def test_object_detection():
25
+ obs = _empty_obs()
26
+ obs["image"][4, 2, 0] = 6 # ball
27
+ obs["image"][4, 2, 1] = 0 # red
28
+ text = grid_to_text(obs)
29
+ assert "red ball" in text
30
+
31
+
32
+ def test_door_states():
33
+ obs = _empty_obs()
34
+ obs["image"][5, 3, 0] = 4 # door ahead
35
+ obs["image"][5, 3, 1] = 2 # blue
36
+ obs["image"][5, 3, 2] = 1 # closed
37
+ text = grid_to_text(obs)
38
+ assert "closed blue door" in text
39
+
40
+
41
+ def test_wall_boundaries():
42
+ obs = _empty_obs()
43
+ obs["image"][5, 3, 0] = 2 # wall
44
+ text = grid_to_text(obs)
45
+ assert "Directly ahead: a wall." in text
46
+
47
+
48
+ def test_carrying_object():
49
+ carrying = SimpleNamespace(type="key", color="blue")
50
+ text = grid_to_text(_empty_obs(), carrying=carrying)
51
+ assert "You are carrying: a blue key." in text
52
+
53
+
54
+ def test_egocentric_directions():
55
+ obs = _empty_obs()
56
+ obs["image"][1, 3, 0] = 5 # key
57
+ obs["image"][1, 3, 1] = 1 # green
58
+ text = grid_to_text(obs)
59
+ assert "5 steps ahead" in text
60
+
61
+
62
+ def test_multiple_objects():
63
+ obs = _empty_obs()
64
+ obs["image"][5, 2, 0] = 5 # key
65
+ obs["image"][5, 2, 1] = 2 # blue
66
+ obs["image"][4, 4, 0] = 6 # ball
67
+ obs["image"][4, 4, 1] = 0 # red
68
+ obs["image"][5, 3, 0] = 4 # door
69
+ obs["image"][5, 3, 1] = 4 # yellow
70
+ obs["image"][5, 3, 2] = 0 # open
71
+ text = grid_to_text(obs)
72
+ assert "blue key" in text
73
+ assert "red ball" in text
74
+ assert "open yellow door" in text
75
+
76
+
77
+ def test_unseen_cells_ignored():
78
+ obs = _empty_obs()
79
+ obs["image"][1, 1, 0] = 0 # unseen
80
+ text = grid_to_text(obs)
81
+ assert "Notable objects: none visible." in text
tests/test_reward.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reward computation tests."""
2
+
3
+ from MiniGridEnv.env.config import RewardConfig
4
+ from MiniGridEnv.env.reward import compute_step_reward
5
+
6
+
7
+ def test_binary_completion():
8
+ reward, _ = compute_step_reward(
9
+ terminated=True,
10
+ truncated=False,
11
+ action_valid=True,
12
+ step_idx=5,
13
+ max_steps=64,
14
+ optimal_steps=None,
15
+ config=RewardConfig(mode="binary"),
16
+ )
17
+ assert reward == 1.0
18
+
19
+
20
+ def test_binary_truncation():
21
+ reward, _ = compute_step_reward(
22
+ terminated=False,
23
+ truncated=True,
24
+ action_valid=True,
25
+ step_idx=64,
26
+ max_steps=64,
27
+ optimal_steps=None,
28
+ config=RewardConfig(mode="binary"),
29
+ )
30
+ assert reward == 0.0
31
+
32
+
33
+ def test_shaped_penalties():
34
+ reward, breakdown = compute_step_reward(
35
+ terminated=False,
36
+ truncated=False,
37
+ action_valid=False,
38
+ step_idx=3,
39
+ max_steps=64,
40
+ optimal_steps=None,
41
+ config=RewardConfig(mode="shaped", step_penalty=-0.01, invalid_action_penalty=-0.05),
42
+ )
43
+ assert abs(reward - (-0.06)) < 1e-9
44
+ assert breakdown["step_penalty"] == -0.01
45
+ assert breakdown["invalid_action"] == -0.05
46
+
47
+
48
+ def test_efficiency_bonus_on_success():
49
+ reward, breakdown = compute_step_reward(
50
+ terminated=True,
51
+ truncated=False,
52
+ action_valid=True,
53
+ step_idx=5,
54
+ max_steps=64,
55
+ optimal_steps=10,
56
+ config=RewardConfig(mode="efficiency", efficiency_bonus_weight=0.5),
57
+ )
58
+ assert reward > 1.0
59
+ assert breakdown["efficiency_bonus"] > 0.0
uv.lock ADDED
The diff for this file is too large to render. See raw diff