Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- Dockerfile +81 -0
- README.md +250 -5
- __init__.py +6 -0
- baselines/__init__.py +7 -0
- baselines/heuristic_bot.py +35 -0
- baselines/llm_zeroshot.py +42 -0
- baselines/random_action.py +34 -0
- client.py +68 -0
- env/__init__.py +14 -0
- env/action_parser.py +93 -0
- env/config.py +40 -0
- env/grid_to_text.py +261 -0
- env/levels.py +175 -0
- env/minigrid_env.py +319 -0
- env/models.py +88 -0
- env/reward.py +51 -0
- eval/__init__.py +1 -0
- eval/evaluate.py +140 -0
- models.py +8 -0
- openenv.yaml +7 -0
- openenv_MiniGridEnv.egg-info/PKG-INFO +15 -0
- openenv_MiniGridEnv.egg-info/SOURCES.txt +33 -0
- openenv_MiniGridEnv.egg-info/dependency_links.txt +1 -0
- openenv_MiniGridEnv.egg-info/entry_points.txt +2 -0
- openenv_MiniGridEnv.egg-info/requires.txt +11 -0
- openenv_MiniGridEnv.egg-info/top_level.txt +1 -0
- pyproject.toml +52 -0
- requirements.txt +7 -0
- server/__init__.py +8 -0
- server/app.py +46 -0
- server/requirements.txt +7 -0
- tests/__init__.py +1 -0
- tests/test_action_parser.py +47 -0
- tests/test_contract.py +155 -0
- tests/test_env.py +52 -0
- tests/test_grid_to_text.py +81 -0
- tests/test_reward.py +59 -0
- uv.lock +0 -0
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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|