Upload folder using huggingface_hub
Browse files- Dockerfile +81 -0
- README.md +250 -5
- __init__.py +16 -0
- client.py +108 -0
- inference.py +259 -0
- models.py +73 -0
- openenv.yaml +14 -0
- pyproject.toml +46 -0
- server/__init__.py +11 -0
- server/app.py +84 -0
- server/finenv_environment.py +199 -0
- server/requirements.txt +7 -0
- server/reward.py +122 -0
- server/stock_price.py +29 -0
- tutorial/01-environments.md +1260 -0
- tutorial/02-deployment.md +427 -0
- tutorial/03-scaling.md +457 -0
- tutorial/04-training.md +632 -0
- uv.lock +0 -0
- validate.sh +185 -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=finenv
|
| 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: Finenv
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Finenv Environment Server
|
| 3 |
+
emoji: 📻
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: pink
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
+
app_port: 8000
|
| 9 |
+
base_path: /web
|
| 10 |
+
tags:
|
| 11 |
+
- openenv
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# Finenv 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 Finenv environment is through the `FinenvEnv` class:
|
| 21 |
+
|
| 22 |
+
```python
|
| 23 |
+
from finenv import FinenvAction, FinenvEnv
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
# Create environment from Docker image
|
| 27 |
+
finenvenv = FinenvEnv.from_docker_image("finenv-env:latest")
|
| 28 |
+
|
| 29 |
+
# Reset
|
| 30 |
+
result = finenvenv.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 = finenvenv.step(FinenvAction(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 |
+
finenvenv.close()
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
That's it! The `FinenvEnv.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 finenv-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 |
+
**FinenvAction**: Contains a single field
|
| 123 |
+
- `message` (str) - The message to echo back
|
| 124 |
+
|
| 125 |
+
### Observation
|
| 126 |
+
**FinenvObservation**: 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 Finenv environment server running, you can connect directly:
|
| 144 |
+
|
| 145 |
+
```python
|
| 146 |
+
from finenv import FinenvEnv
|
| 147 |
+
|
| 148 |
+
# Connect to existing server
|
| 149 |
+
finenvenv = FinenvEnv(base_url="<ENV_HTTP_URL_HERE>")
|
| 150 |
+
|
| 151 |
+
# Use as normal
|
| 152 |
+
result = finenvenv.reset()
|
| 153 |
+
result = finenvenv.step(FinenvAction(message="Hello!"))
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
Note: When connecting to an existing server, `finenvenv.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 finenv import FinenvAction, FinenvEnv
|
| 164 |
+
|
| 165 |
+
# Connect with context manager (auto-connects and closes)
|
| 166 |
+
with FinenvEnv(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(FinenvAction(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 |
+
FinenvEnvironment, # Pass class, not instance
|
| 189 |
+
FinenvAction,
|
| 190 |
+
FinenvObservation,
|
| 191 |
+
max_concurrent_envs=4, # Allow 4 concurrent sessions
|
| 192 |
+
)
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
Then multiple clients can connect simultaneously:
|
| 196 |
+
|
| 197 |
+
```python
|
| 198 |
+
from finenv import FinenvAction, FinenvEnv
|
| 199 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 200 |
+
|
| 201 |
+
def run_episode(client_id: int):
|
| 202 |
+
with FinenvEnv(base_url="http://localhost:8000") as env:
|
| 203 |
+
result = env.reset()
|
| 204 |
+
for i in range(10):
|
| 205 |
+
result = env.step(FinenvAction(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/finenv_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 |
+
finenv/
|
| 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 # FinenvEnv client
|
| 249 |
+
├── models.py # Action and Observation models
|
| 250 |
+
└── server/
|
| 251 |
+
├── __init__.py # Server module exports
|
| 252 |
+
├── finenv_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,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
"""Finenv Environment."""
|
| 8 |
+
|
| 9 |
+
from .client import FinenvEnv
|
| 10 |
+
from .models import FinenvAction, FinenvObservation
|
| 11 |
+
|
| 12 |
+
__all__ = [
|
| 13 |
+
"FinenvAction",
|
| 14 |
+
"FinenvObservation",
|
| 15 |
+
"FinenvEnv",
|
| 16 |
+
]
|
client.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
"""Finenv Environment Client."""
|
| 8 |
+
|
| 9 |
+
from typing import Dict
|
| 10 |
+
|
| 11 |
+
from openenv.core import EnvClient
|
| 12 |
+
from openenv.core.client_types import StepResult
|
| 13 |
+
from openenv.core.env_server.types import State
|
| 14 |
+
|
| 15 |
+
from .models import FinenvAction, FinenvObservation
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class FinenvEnv(
|
| 19 |
+
EnvClient[FinenvAction, FinenvObservation, State]
|
| 20 |
+
):
|
| 21 |
+
"""
|
| 22 |
+
Client for the Finenv Environment.
|
| 23 |
+
|
| 24 |
+
This client maintains a persistent WebSocket connection to the environment server,
|
| 25 |
+
enabling efficient multi-step interactions with lower latency.
|
| 26 |
+
Each client instance has its own dedicated environment session on the server.
|
| 27 |
+
|
| 28 |
+
Example:
|
| 29 |
+
>>> # Connect to a running server
|
| 30 |
+
>>> with FinenvEnv(base_url="http://localhost:8000") as client:
|
| 31 |
+
... result = client.reset()
|
| 32 |
+
... print(result.observation.echoed_message)
|
| 33 |
+
...
|
| 34 |
+
... result = client.step(FinenvAction(message="Hello!"))
|
| 35 |
+
... print(result.observation.echoed_message)
|
| 36 |
+
|
| 37 |
+
Example with Docker:
|
| 38 |
+
>>> # Automatically start container and connect
|
| 39 |
+
>>> client = FinenvEnv.from_docker_image("finenv-env:latest")
|
| 40 |
+
>>> try:
|
| 41 |
+
... result = client.reset()
|
| 42 |
+
... result = client.step(FinenvAction(message="Test"))
|
| 43 |
+
... finally:
|
| 44 |
+
... client.close()
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
def _step_payload(self, action: FinenvAction) -> Dict:
|
| 48 |
+
"""
|
| 49 |
+
Convert FinenvAction to JSON payload for step message.
|
| 50 |
+
|
| 51 |
+
Args:
|
| 52 |
+
action: FinenvAction instance
|
| 53 |
+
|
| 54 |
+
Returns:
|
| 55 |
+
Dictionary representation suitable for JSON encoding
|
| 56 |
+
"""
|
| 57 |
+
return {
|
| 58 |
+
"type": action.type,
|
| 59 |
+
"quantity": action.quantity,
|
| 60 |
+
"stock": action.stock,
|
| 61 |
+
"market": action.market,
|
| 62 |
+
"initial_cash": action.initial_cash,
|
| 63 |
+
"max_steps": action.max_steps,
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
def _parse_result(self, payload: Dict) -> StepResult[FinenvObservation]:
|
| 67 |
+
"""
|
| 68 |
+
Parse server response into StepResult[FinenvObservation].
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
payload: JSON response data from server
|
| 72 |
+
|
| 73 |
+
Returns:
|
| 74 |
+
StepResult with FinenvObservation
|
| 75 |
+
"""
|
| 76 |
+
obs = payload.get("observation", {})
|
| 77 |
+
|
| 78 |
+
observation = FinenvObservation(
|
| 79 |
+
stock=obs.get("stock"),
|
| 80 |
+
market=obs.get("market"),
|
| 81 |
+
price=obs.get("price"),
|
| 82 |
+
shares=obs.get("shares"),
|
| 83 |
+
cash=obs.get("cash"),
|
| 84 |
+
done=payload.get("done", False),
|
| 85 |
+
reward=payload.get("reward"),
|
| 86 |
+
metadata=obs.get("metadata", {}),
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
return StepResult(
|
| 90 |
+
observation=observation,
|
| 91 |
+
reward=payload.get("reward"),
|
| 92 |
+
done=payload.get("done", False),
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
def _parse_state(self, payload: Dict) -> State:
|
| 96 |
+
"""
|
| 97 |
+
Parse server response into State object.
|
| 98 |
+
|
| 99 |
+
Args:
|
| 100 |
+
payload: JSON response from state request
|
| 101 |
+
|
| 102 |
+
Returns:
|
| 103 |
+
State object with episode_id and step_count
|
| 104 |
+
"""
|
| 105 |
+
return State(
|
| 106 |
+
episode_id=payload.get("episode_id"),
|
| 107 |
+
step_count=payload.get("step_count", 0),
|
| 108 |
+
)
|
inference.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Inference Script Example
|
| 3 |
+
===================================
|
| 4 |
+
MANDATORY
|
| 5 |
+
- Before submitting, ensure the following variables are defined in your environment configuration:
|
| 6 |
+
API_BASE_URL The API endpoint for the LLM.
|
| 7 |
+
MODEL_NAME The model identifier to use for inference.
|
| 8 |
+
HF_TOKEN Your Hugging Face / API key.
|
| 9 |
+
LOCAL_IMAGE_NAME The name of the local image to use for the environment if you are using from_docker_image()
|
| 10 |
+
method
|
| 11 |
+
|
| 12 |
+
- Defaults are set only for API_BASE_URL and MODEL_NAME
|
| 13 |
+
(and should reflect your active inference setup):
|
| 14 |
+
API_BASE_URL = os.getenv("API_BASE_URL", "<your-active-endpoint>")
|
| 15 |
+
MODEL_NAME = os.getenv("MODEL_NAME", "<your-active-model>")
|
| 16 |
+
|
| 17 |
+
- The inference script must be named `inference.py` and placed in the root directory of the project
|
| 18 |
+
- Participants must use OpenAI Client for all LLM calls using above variables
|
| 19 |
+
|
| 20 |
+
STDOUT FORMAT
|
| 21 |
+
- The script must emit exactly three line types to stdout, in this order:
|
| 22 |
+
|
| 23 |
+
[START] task=<task_name> env=<benchmark> model=<model_name>
|
| 24 |
+
[STEP] step=<n> action=<action_str> reward=<0.00> done=<true|false> error=<msg|null>
|
| 25 |
+
[END] success=<true|false> steps=<n> score=<score> rewards=<r1,r2,...,rn>
|
| 26 |
+
|
| 27 |
+
Rules:
|
| 28 |
+
- One [START] line at episode begin.
|
| 29 |
+
- One [STEP] line per step, immediately after env.step() returns.
|
| 30 |
+
- One [END] line after env.close(), always emitted (even on exception).
|
| 31 |
+
- reward and rewards are formatted to 2 decimal places.
|
| 32 |
+
- done and success are lowercase booleans: true or false.
|
| 33 |
+
- error is the raw last_action_error string, or null if none.
|
| 34 |
+
- All fields on a single line with no newlines within a line.
|
| 35 |
+
- Each tasks should return score in [0, 1]
|
| 36 |
+
|
| 37 |
+
Example:
|
| 38 |
+
[START] task=click-test env=miniwob model=Qwen3-VL-30B
|
| 39 |
+
[STEP] step=1 action=click('123') reward=0.00 done=false error=null
|
| 40 |
+
[STEP] step=2 action=fill('456','text') reward=0.00 done=false error=null
|
| 41 |
+
[STEP] step=3 action=click('789') reward=1.00 done=true error=null
|
| 42 |
+
[END] success=true steps=3 score=1.00 rewards=0.00,0.00,1.00
|
| 43 |
+
"""
|
| 44 |
+
import asyncio
|
| 45 |
+
import os
|
| 46 |
+
import textwrap
|
| 47 |
+
from typing import List, Optional
|
| 48 |
+
|
| 49 |
+
from openai import OpenAI
|
| 50 |
+
|
| 51 |
+
from finenv.client import FinenvEnv
|
| 52 |
+
from finenv.models import FinenvAction
|
| 53 |
+
|
| 54 |
+
# ==============================
|
| 55 |
+
# ENV VARIABLES (MANDATORY)
|
| 56 |
+
# ==============================
|
| 57 |
+
IMAGE_NAME = os.getenv("LOCAL_IMAGE_NAME", "finenv")
|
| 58 |
+
API_KEY = os.getenv("HF_TOKEN") or os.getenv("OPENAI_API_KEY")
|
| 59 |
+
|
| 60 |
+
API_BASE_URL = os.getenv("API_BASE_URL")
|
| 61 |
+
MODEL_NAME = os.getenv("MODEL_NAME")
|
| 62 |
+
|
| 63 |
+
TASK_NAME = os.getenv("FINENV_TASK", "easy")
|
| 64 |
+
BENCHMARK = "finenv"
|
| 65 |
+
|
| 66 |
+
MAX_STEPS = 15
|
| 67 |
+
SUCCESS_SCORE_THRESHOLD = 0.2 # normalized score in [0, 1]
|
| 68 |
+
|
| 69 |
+
SYSTEM_PROMPT = textwrap.dedent(
|
| 70 |
+
"""
|
| 71 |
+
You are interacting with a stock trading environment.
|
| 72 |
+
Your goal is to maximize profit by buying, selling, or holding shares of a stock over a series of steps.
|
| 73 |
+
At each step, you can choose one of the following actions:
|
| 74 |
+
- buy: purchase 1 share of the stock at the current price
|
| 75 |
+
- sell: sell 1 share of the stock at the current price (only if you have shares to sell)
|
| 76 |
+
- hold: take no action
|
| 77 |
+
The environment will provide feedback in the form of rewards based on the change in your portfolio value.
|
| 78 |
+
Your objective is to achieve the highest possible return by the end of the episode.
|
| 79 |
+
"""
|
| 80 |
+
).strip()
|
| 81 |
+
|
| 82 |
+
# ==============================
|
| 83 |
+
# LOGGING (STRICT FORMAT)
|
| 84 |
+
# ==============================
|
| 85 |
+
def log_start(task: str, env: str, model: str) -> None:
|
| 86 |
+
print(f"[START] task={task} env={env} model={model}", flush=True)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
|
| 90 |
+
error_val = error if error else "null"
|
| 91 |
+
done_val = str(done).lower()
|
| 92 |
+
print(
|
| 93 |
+
f"[STEP] step={step} action={action} reward={reward:.2f} done={done_val} error={error_val}",
|
| 94 |
+
flush=True,
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
|
| 99 |
+
rewards_str = ",".join(f"{r:.2f}" for r in rewards)
|
| 100 |
+
print(
|
| 101 |
+
f"[END] success={str(success).lower()} steps={steps} score={score:.3f} rewards={rewards_str}",
|
| 102 |
+
flush=True,
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ==============================
|
| 107 |
+
# MODEL DECISION LOGIC
|
| 108 |
+
# ==============================
|
| 109 |
+
def get_model_action(client: OpenAI, step: int, last_reward: float, history: List[str]) -> str:
|
| 110 |
+
"""
|
| 111 |
+
Uses LLM to decide trading action.
|
| 112 |
+
"""
|
| 113 |
+
|
| 114 |
+
prompt = f"""
|
| 115 |
+
You are a trading agent.
|
| 116 |
+
|
| 117 |
+
Step: {step}
|
| 118 |
+
Last reward: {last_reward}
|
| 119 |
+
Recent history:
|
| 120 |
+
{history[-3:] if history else "None"}
|
| 121 |
+
|
| 122 |
+
Choose ONE:
|
| 123 |
+
buy
|
| 124 |
+
sell
|
| 125 |
+
hold
|
| 126 |
+
|
| 127 |
+
Respond with only one word.
|
| 128 |
+
"""
|
| 129 |
+
|
| 130 |
+
try:
|
| 131 |
+
response = client.chat.completions.create(
|
| 132 |
+
model=MODEL_NAME,
|
| 133 |
+
messages=[{"role": "user", "content": prompt}],
|
| 134 |
+
temperature=0.2,
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
action = (response.choices[0].message.content or "").strip().lower()
|
| 138 |
+
|
| 139 |
+
if action not in ["buy", "sell", "hold"]:
|
| 140 |
+
return "hold"
|
| 141 |
+
|
| 142 |
+
return action
|
| 143 |
+
|
| 144 |
+
except Exception as exc:
|
| 145 |
+
print(f"[DEBUG] Model request failed: {exc}", flush=True)
|
| 146 |
+
return "hold"
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# ==============================
|
| 150 |
+
# MAIN EXECUTION
|
| 151 |
+
# ==============================
|
| 152 |
+
async def main() -> None:
|
| 153 |
+
client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
|
| 154 |
+
|
| 155 |
+
env = await FinenvEnv.from_docker_image(IMAGE_NAME)
|
| 156 |
+
|
| 157 |
+
history: List[str] = []
|
| 158 |
+
rewards: List[float] = []
|
| 159 |
+
|
| 160 |
+
steps_taken = 0
|
| 161 |
+
score = 0.0
|
| 162 |
+
success = False
|
| 163 |
+
|
| 164 |
+
log_start(task=TASK_NAME, env=BENCHMARK, model=MODEL_NAME)
|
| 165 |
+
|
| 166 |
+
try:
|
| 167 |
+
# Reset environment
|
| 168 |
+
result = await env.reset()
|
| 169 |
+
last_reward = 0.0
|
| 170 |
+
|
| 171 |
+
# INIT STEP (MANDATORY for your env)
|
| 172 |
+
init_action = FinenvAction(
|
| 173 |
+
type="init",
|
| 174 |
+
stock="RELIANCE",
|
| 175 |
+
market="NSE",
|
| 176 |
+
initial_cash=10000,
|
| 177 |
+
max_steps=MAX_STEPS
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
result = await env.step(init_action)
|
| 181 |
+
|
| 182 |
+
# Log init step as step 0
|
| 183 |
+
log_step(
|
| 184 |
+
step=0,
|
| 185 |
+
action="init",
|
| 186 |
+
reward=0.00,
|
| 187 |
+
done=False,
|
| 188 |
+
error=None
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
# ==============================
|
| 192 |
+
# MAIN LOOP
|
| 193 |
+
# ==============================
|
| 194 |
+
for step in range(1, MAX_STEPS + 1):
|
| 195 |
+
if result.done:
|
| 196 |
+
break
|
| 197 |
+
|
| 198 |
+
action_type = get_model_action(client, step, last_reward, history)
|
| 199 |
+
|
| 200 |
+
action = FinenvAction(
|
| 201 |
+
type=action_type,
|
| 202 |
+
quantity=1
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
error = None
|
| 206 |
+
|
| 207 |
+
try:
|
| 208 |
+
result = await env.step(action)
|
| 209 |
+
except Exception as e:
|
| 210 |
+
error = str(e)
|
| 211 |
+
result = result # keep last safe state
|
| 212 |
+
|
| 213 |
+
reward = float(result.reward or 0.0)
|
| 214 |
+
done = result.done
|
| 215 |
+
|
| 216 |
+
rewards.append(reward)
|
| 217 |
+
steps_taken = step
|
| 218 |
+
last_reward = reward
|
| 219 |
+
|
| 220 |
+
log_step(
|
| 221 |
+
step=step,
|
| 222 |
+
action=action_type,
|
| 223 |
+
reward=reward,
|
| 224 |
+
done=done,
|
| 225 |
+
error=error
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
history.append(f"{action_type}:{reward:.2f}")
|
| 229 |
+
|
| 230 |
+
if done:
|
| 231 |
+
break
|
| 232 |
+
|
| 233 |
+
# ==============================
|
| 234 |
+
# SCORE CALCULATION
|
| 235 |
+
# ==============================
|
| 236 |
+
if len(rewards) > 0:
|
| 237 |
+
score = sum(rewards) / len(rewards)
|
| 238 |
+
else:
|
| 239 |
+
score = 0.0
|
| 240 |
+
|
| 241 |
+
score = min(max(score, 0.0), 1.0)
|
| 242 |
+
success = score >= SUCCESS_SCORE_THRESHOLD
|
| 243 |
+
|
| 244 |
+
finally:
|
| 245 |
+
try:
|
| 246 |
+
await env.close()
|
| 247 |
+
except Exception as e:
|
| 248 |
+
print(f"[DEBUG] env.close() error: {e}", flush=True)
|
| 249 |
+
|
| 250 |
+
log_end(
|
| 251 |
+
success=success,
|
| 252 |
+
steps=steps_taken,
|
| 253 |
+
score=score,
|
| 254 |
+
rewards=rewards
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
if __name__ == "__main__":
|
| 259 |
+
asyncio.run(main())
|
models.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
"""Data models for the Finenv environment."""
|
| 8 |
+
|
| 9 |
+
from enum import Enum
|
| 10 |
+
from typing import Any, Dict, Optional
|
| 11 |
+
|
| 12 |
+
from openenv.core.env_server.types import Action, Observation
|
| 13 |
+
|
| 14 |
+
TASKS = {
|
| 15 |
+
"easy": {
|
| 16 |
+
"goal": "Make profit in 5 steps",
|
| 17 |
+
"target_profit": 0.01
|
| 18 |
+
},
|
| 19 |
+
"medium": {
|
| 20 |
+
"goal": "Maximize portfolio value",
|
| 21 |
+
"target_profit": 0.05
|
| 22 |
+
},
|
| 23 |
+
"hard": {
|
| 24 |
+
"goal": "Achieve 10% return",
|
| 25 |
+
"target_return": 0.1
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
class StockExchangeMarket(Enum):
|
| 30 |
+
BSE = "bse"
|
| 31 |
+
NSE = "nse"
|
| 32 |
+
|
| 33 |
+
class task_type(str, Enum):
|
| 34 |
+
Easy = "easy"
|
| 35 |
+
Medium = "medium"
|
| 36 |
+
Hard = "hard"
|
| 37 |
+
|
| 38 |
+
class FinenvAction(Action):
|
| 39 |
+
"""
|
| 40 |
+
Action model for Finenv environment.
|
| 41 |
+
|
| 42 |
+
Supports:
|
| 43 |
+
- init → initialize environment
|
| 44 |
+
- buy → buy shares
|
| 45 |
+
- sell → sell shares
|
| 46 |
+
- hold → no action
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
type: str # "init", "buy", "sell", "hold"
|
| 50 |
+
|
| 51 |
+
# Trading fields
|
| 52 |
+
quantity: Optional[int] = 0
|
| 53 |
+
|
| 54 |
+
task_type: Optional[task_type] = None
|
| 55 |
+
|
| 56 |
+
# Initialization fields
|
| 57 |
+
stock: Optional[str] = None
|
| 58 |
+
market: Optional[str] = None
|
| 59 |
+
initial_cash: Optional[float] = None
|
| 60 |
+
max_steps: Optional[int] = None
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class FinenvObservation(Observation):
|
| 64 |
+
"""Observation from the Finenv environment - the echoed message."""
|
| 65 |
+
stock: Optional[str] = None
|
| 66 |
+
market: Optional[StockExchangeMarket] = None
|
| 67 |
+
shares: int
|
| 68 |
+
cash: float
|
| 69 |
+
price: float
|
| 70 |
+
reward: str
|
| 71 |
+
done: bool
|
| 72 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 73 |
+
|
openenv.yaml
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
spec_version: 1
|
| 2 |
+
name: finenv
|
| 3 |
+
description: Stock trading environment using real NSE/BSE data
|
| 4 |
+
type: space
|
| 5 |
+
runtime: fastapi
|
| 6 |
+
app: server.app:app
|
| 7 |
+
port: 8000
|
| 8 |
+
tasks:
|
| 9 |
+
- name: easy
|
| 10 |
+
description: Achieve 1% return
|
| 11 |
+
- name: medium
|
| 12 |
+
description: Achieve 5% return
|
| 13 |
+
- name: hard
|
| 14 |
+
description: Achieve 10% return
|
pyproject.toml
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-finenv"
|
| 13 |
+
version = "0.1.0"
|
| 14 |
+
description = "Finenv environment for OpenEnv"
|
| 15 |
+
requires-python = ">=3.10"
|
| 16 |
+
dependencies = [
|
| 17 |
+
# Core OpenEnv runtime (provides FastAPI server + HTTP client types)
|
| 18 |
+
# install from github
|
| 19 |
+
# "openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git",
|
| 20 |
+
"openenv-core[core]>=0.2.2",
|
| 21 |
+
"yfinance>=0.2.0"
|
| 22 |
+
# Environment-specific dependencies
|
| 23 |
+
# Add all dependencies needed for your environment here
|
| 24 |
+
# Examples:
|
| 25 |
+
# "numpy>=1.19.0",
|
| 26 |
+
# "torch>=2.0.0",
|
| 27 |
+
# "gymnasium>=0.29.0",
|
| 28 |
+
# "openspiel>=1.0.0",
|
| 29 |
+
# "smolagents>=1.22.0,<2",
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
[project.optional-dependencies]
|
| 33 |
+
dev = [
|
| 34 |
+
"pytest>=8.0.0",
|
| 35 |
+
"pytest-cov>=4.0.0",
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
[project.scripts]
|
| 39 |
+
# Server entry point - enables running via: uv run --project . server
|
| 40 |
+
# or: python -m finenv.server.app
|
| 41 |
+
server = "finenv.server.app:main"
|
| 42 |
+
|
| 43 |
+
[tool.setuptools]
|
| 44 |
+
include-package-data = true
|
| 45 |
+
packages = ["finenv", "finenv.server"]
|
| 46 |
+
package-dir = { "finenv" = ".", "finenv.server" = "server" }
|
server/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
"""Finenv environment server components."""
|
| 8 |
+
|
| 9 |
+
from .finenv_environment import FinenvEnvironment
|
| 10 |
+
|
| 11 |
+
__all__ = ["FinenvEnvironment"]
|
server/app.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
"""
|
| 8 |
+
FastAPI application for the Finenv Environment.
|
| 9 |
+
|
| 10 |
+
This module creates an HTTP server that exposes the FinenvEnvironment
|
| 11 |
+
over HTTP and WebSocket endpoints, compatible with EnvClient.
|
| 12 |
+
|
| 13 |
+
Endpoints:
|
| 14 |
+
- POST /reset: Reset the environment
|
| 15 |
+
- POST /step: Execute an action
|
| 16 |
+
- GET /state: Get current environment state
|
| 17 |
+
- GET /schema: Get action/observation schemas
|
| 18 |
+
- WS /ws: WebSocket endpoint for persistent sessions
|
| 19 |
+
|
| 20 |
+
Usage:
|
| 21 |
+
# Development (with auto-reload):
|
| 22 |
+
uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
|
| 23 |
+
|
| 24 |
+
# Production:
|
| 25 |
+
uvicorn server.app:app --host 0.0.0.0 --port 8000 --workers 4
|
| 26 |
+
|
| 27 |
+
# Or run directly:
|
| 28 |
+
python -m server.app
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
from openenv.core.env_server.http_server import create_app
|
| 33 |
+
except Exception as e: # pragma: no cover
|
| 34 |
+
raise ImportError(
|
| 35 |
+
"openenv is required for the web interface. Install dependencies with '\n uv sync\n'"
|
| 36 |
+
) from e
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
from ..models import FinenvAction, FinenvObservation
|
| 40 |
+
from .finenv_environment import FinenvEnvironment
|
| 41 |
+
except ModuleNotFoundError:
|
| 42 |
+
from models import FinenvAction, FinenvObservation
|
| 43 |
+
from server.finenv_environment import FinenvEnvironment
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# Create the app with web interface and README integration
|
| 47 |
+
app = create_app(
|
| 48 |
+
FinenvEnvironment,
|
| 49 |
+
FinenvAction,
|
| 50 |
+
FinenvObservation,
|
| 51 |
+
env_name="finenv",
|
| 52 |
+
max_concurrent_envs=1, # increase this number to allow more concurrent WebSocket sessions
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def main(host: str = "0.0.0.0", port: int = 8000):
|
| 57 |
+
"""
|
| 58 |
+
Entry point for direct execution via uv run or python -m.
|
| 59 |
+
|
| 60 |
+
This function enables running the server without Docker:
|
| 61 |
+
uv run --project . server
|
| 62 |
+
uv run --project . server --port 8001
|
| 63 |
+
python -m finenv.server.app
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
host: Host address to bind to (default: "0.0.0.0")
|
| 67 |
+
port: Port number to listen on (default: 8000)
|
| 68 |
+
|
| 69 |
+
For production deployments, consider using uvicorn directly with
|
| 70 |
+
multiple workers:
|
| 71 |
+
uvicorn finenv.server.app:app --workers 4
|
| 72 |
+
"""
|
| 73 |
+
import uvicorn
|
| 74 |
+
|
| 75 |
+
uvicorn.run(app, host=host, port=port)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
if __name__ == "__main__":
|
| 79 |
+
import argparse
|
| 80 |
+
|
| 81 |
+
parser = argparse.ArgumentParser()
|
| 82 |
+
parser.add_argument("--port", type=int, default=8000)
|
| 83 |
+
args = parser.parse_args()
|
| 84 |
+
main()
|
server/finenv_environment.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
Finenv Environment Implementation (Dynamic Stock via First Action)
|
| 8 |
+
|
| 9 |
+
This environment simulates stock trading.
|
| 10 |
+
The stock and market are NOT fixed — they are initialized dynamically
|
| 11 |
+
using the first action of type "init".
|
| 12 |
+
|
| 13 |
+
Actions supported:
|
| 14 |
+
- init → initialize stock, market, capital
|
| 15 |
+
- buy → buy shares
|
| 16 |
+
- sell → sell shares
|
| 17 |
+
- hold → do nothing
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from uuid import uuid4
|
| 21 |
+
|
| 22 |
+
from openenv.core.env_server.interfaces import Environment
|
| 23 |
+
from openenv.core.env_server.types import State
|
| 24 |
+
try:
|
| 25 |
+
from ..models import FinenvAction, FinenvObservation, StockExchangeMarket
|
| 26 |
+
from .reward import reward_message
|
| 27 |
+
from .stock_price import get_stock_price
|
| 28 |
+
except ImportError:
|
| 29 |
+
from models import FinenvAction, FinenvObservation, StockExchangeMarket
|
| 30 |
+
from reward import reward_message
|
| 31 |
+
from stock_price import get_stock_price
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class FinenvEnvironment(Environment):
|
| 35 |
+
|
| 36 |
+
SUPPORTS_CONCURRENT_SESSIONS: bool = True
|
| 37 |
+
|
| 38 |
+
def __init__(self):
|
| 39 |
+
"""
|
| 40 |
+
Initialize environment with default values.
|
| 41 |
+
Actual stock configuration will be done using 'init' action.
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
self._state = State(episode_id=str(uuid4()), step_count=0)
|
| 45 |
+
|
| 46 |
+
# Default placeholders (will be overwritten)
|
| 47 |
+
self.stock = None
|
| 48 |
+
self.market = None
|
| 49 |
+
self.initial_cash = 10000.0
|
| 50 |
+
self.max_steps = 100
|
| 51 |
+
self.task = "easy"
|
| 52 |
+
|
| 53 |
+
self.cash = self.initial_cash
|
| 54 |
+
self.shares = 0
|
| 55 |
+
|
| 56 |
+
self.price_history = []
|
| 57 |
+
self.stock_price = 0.0
|
| 58 |
+
|
| 59 |
+
# Flag to check if environment is initialized
|
| 60 |
+
self.initialized = False
|
| 61 |
+
|
| 62 |
+
def reset(self) -> FinenvObservation:
|
| 63 |
+
"""
|
| 64 |
+
Reset environment WITHOUT changing stock.
|
| 65 |
+
|
| 66 |
+
Stock will be set using first action (type="init")
|
| 67 |
+
"""
|
| 68 |
+
|
| 69 |
+
self._state = State(episode_id=str(uuid4()), step_count=0)
|
| 70 |
+
|
| 71 |
+
self.cash = self.initial_cash
|
| 72 |
+
self.shares = 0
|
| 73 |
+
|
| 74 |
+
return FinenvObservation(
|
| 75 |
+
stock=self.stock,
|
| 76 |
+
market=self.market,
|
| 77 |
+
price=self.stock_price,
|
| 78 |
+
shares=self.shares,
|
| 79 |
+
cash=self.cash,
|
| 80 |
+
done=False,
|
| 81 |
+
reward=reward_message(0),
|
| 82 |
+
metadata={"message": "Send init action to start trading"}
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
def step(self, action: FinenvAction) -> FinenvObservation:
|
| 86 |
+
"""
|
| 87 |
+
Execute one step.
|
| 88 |
+
|
| 89 |
+
First action MUST be:
|
| 90 |
+
{
|
| 91 |
+
"type": "init",
|
| 92 |
+
"stock": "RELIANCE",
|
| 93 |
+
"market": "NSE"
|
| 94 |
+
}
|
| 95 |
+
"""
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
if action.type == "init":
|
| 99 |
+
self.stock = action.stock
|
| 100 |
+
self.market = StockExchangeMarket(action.market.lower())
|
| 101 |
+
|
| 102 |
+
self.initial_cash = action.initial_cash or 10000.0
|
| 103 |
+
self.max_steps = action.max_steps or 100
|
| 104 |
+
self.task = action.task_type.value if action.task_type else "easy"
|
| 105 |
+
|
| 106 |
+
self.cash = self.initial_cash
|
| 107 |
+
self.shares = 0
|
| 108 |
+
|
| 109 |
+
# Fetch stock data
|
| 110 |
+
self.price_history = get_stock_price(
|
| 111 |
+
self.stock,
|
| 112 |
+
self.market.value,
|
| 113 |
+
self.max_steps
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
if not self.price_history:
|
| 117 |
+
raise ValueError("Invalid stock or no data available")
|
| 118 |
+
|
| 119 |
+
self.stock_price = self.price_history[0]
|
| 120 |
+
|
| 121 |
+
self.initialized = True
|
| 122 |
+
|
| 123 |
+
return FinenvObservation(
|
| 124 |
+
stock=self.stock,
|
| 125 |
+
market=self.market,
|
| 126 |
+
price=self.stock_price,
|
| 127 |
+
shares=self.shares,
|
| 128 |
+
cash=self.cash,
|
| 129 |
+
done=False,
|
| 130 |
+
reward=reward_message(0),
|
| 131 |
+
metadata={"message": "Environment initialized"}
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
if not self.initialized:
|
| 136 |
+
raise ValueError("Environment not initialized. Send init action first.")
|
| 137 |
+
|
| 138 |
+
# Increment step count
|
| 139 |
+
self._state.step_count += 1
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
old_value = self.cash + self.shares * self.stock_price
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
if action.type == "buy":
|
| 146 |
+
cost = action.quantity * self.stock_price
|
| 147 |
+
if self.cash >= cost:
|
| 148 |
+
self.cash -= cost
|
| 149 |
+
self.shares += action.quantity
|
| 150 |
+
|
| 151 |
+
elif action.type == "sell":
|
| 152 |
+
if self.shares >= action.quantity:
|
| 153 |
+
self.cash += action.quantity * self.stock_price
|
| 154 |
+
self.shares -= action.quantity
|
| 155 |
+
|
| 156 |
+
# hold → no action
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
index = self._state.step_count % len(self.price_history)
|
| 160 |
+
self.stock_price = self.price_history[index]
|
| 161 |
+
|
| 162 |
+
new_value = self.cash + self.shares * self.stock_price
|
| 163 |
+
reward = (new_value - old_value) / self.initial_cash
|
| 164 |
+
reward = max(min(reward, 1.0), -1.0)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
done = self._state.step_count >= self.max_steps
|
| 168 |
+
if done:
|
| 169 |
+
final_value = self.cash + self.shares * self.stock_price
|
| 170 |
+
profit = final_value - self.initial_cash
|
| 171 |
+
|
| 172 |
+
if self.task == "easy":
|
| 173 |
+
score = min(max(profit / 0.01*self.initial_cash, 0), 1)
|
| 174 |
+
|
| 175 |
+
elif self.task == "medium":
|
| 176 |
+
score = min(max(profit / 0.05*self.initial_cash, 0), 1)
|
| 177 |
+
|
| 178 |
+
elif self.task == "hard":
|
| 179 |
+
score = min(max((profit / self.initial_cash) / 0.1, 0), 1)
|
| 180 |
+
|
| 181 |
+
reward = score
|
| 182 |
+
|
| 183 |
+
return FinenvObservation(
|
| 184 |
+
stock=self.stock,
|
| 185 |
+
market=self.market,
|
| 186 |
+
shares=self.shares,
|
| 187 |
+
cash=self.cash,
|
| 188 |
+
price=self.stock_price,
|
| 189 |
+
done=done,
|
| 190 |
+
reward=reward_message(reward),
|
| 191 |
+
metadata={
|
| 192 |
+
"step": self._state.step_count,
|
| 193 |
+
"portfolio_value": new_value
|
| 194 |
+
},
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
@property
|
| 198 |
+
def state(self) -> State:
|
| 199 |
+
return self._state
|
server/requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openenv[core]>=0.2.0
|
| 2 |
+
fastapi>=0.115.0
|
| 3 |
+
uvicorn>=0.24.0
|
| 4 |
+
yfinance>=0.2.0
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
|
server/reward.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""This is a simple reward function for the Finenv environment.
|
| 2 |
+
It developes a sentence based in the reward value.
|
| 3 |
+
Positive rewards generate encouraging messages, negative rewards generate constructive feedback, and zero rewards generate neutral prompts to motivate improvement."""
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
import random
|
| 7 |
+
|
| 8 |
+
from sympy import Integer
|
| 9 |
+
|
| 10 |
+
import random
|
| 11 |
+
|
| 12 |
+
def reward_message(num:float) -> str:
|
| 13 |
+
positive_msgs = [
|
| 14 |
+
f"Awesome work! You scored {num}, keep pushing forward!",
|
| 15 |
+
f"Fantastic! {num} shows your great progress!",
|
| 16 |
+
f"Great going! {num} is a strong positive number!",
|
| 17 |
+
f"Brilliant! You achieved {num}, success is yours!",
|
| 18 |
+
f"Excellent! {num} reflects your hard work!",
|
| 19 |
+
f"Superb! A score of {num} is impressive!",
|
| 20 |
+
f"Keep it up! {num} is a great result!",
|
| 21 |
+
f"Well done! {num} shows you're improving!",
|
| 22 |
+
f"Amazing! {num} is a fantastic score!",
|
| 23 |
+
f"Outstanding! {num} proves your capability!",
|
| 24 |
+
f"Nice work! {num} is a positive step!",
|
| 25 |
+
f"Incredible! {num} shows your potential!",
|
| 26 |
+
f"Strong performance! {num} looks great!",
|
| 27 |
+
f"You're doing great! {num} is excellent!",
|
| 28 |
+
f"Fantastic effort! {num} is rewarding!",
|
| 29 |
+
f"Impressive! {num} shows growth!",
|
| 30 |
+
f"Great job! {num} is a win!",
|
| 31 |
+
f"Positive vibes! {num} is awesome!",
|
| 32 |
+
f"Keep shining! {num} is brilliant!",
|
| 33 |
+
f"Top work! {num} is fantastic!",
|
| 34 |
+
f"Excellent progress with {num}!",
|
| 35 |
+
f"Winning moment! {num} looks great!",
|
| 36 |
+
f"You nailed it with {num}!",
|
| 37 |
+
f"{num} is a sign of success!",
|
| 38 |
+
f"Keep rising! {num} is strong!",
|
| 39 |
+
f"That’s a solid {num}!",
|
| 40 |
+
f"You're on fire with {num}!",
|
| 41 |
+
f"Powerful result: {num}!",
|
| 42 |
+
f"You're unstoppable at {num}!",
|
| 43 |
+
f"Success shines in {num}!",
|
| 44 |
+
f"{num} is a proud achievement!",
|
| 45 |
+
f"Great energy with {num}!",
|
| 46 |
+
f"Keep winning with {num}!",
|
| 47 |
+
f"Victory feels like {num}!",
|
| 48 |
+
f"Big win: {num}!",
|
| 49 |
+
f"You're excelling at {num}!",
|
| 50 |
+
f"That’s impressive: {num}!",
|
| 51 |
+
f"Keep thriving with {num}!",
|
| 52 |
+
f"{num} is a milestone!",
|
| 53 |
+
f"Wonderful result: {num}!",
|
| 54 |
+
f"You're progressing with {num}!",
|
| 55 |
+
f"Sharp performance: {num}!",
|
| 56 |
+
f"Keep growing: {num}!",
|
| 57 |
+
f"That’s a boost: {num}!",
|
| 58 |
+
f"You're achieving big with {num}!",
|
| 59 |
+
f"Great momentum: {num}!",
|
| 60 |
+
f"That’s a bright {num}!",
|
| 61 |
+
f"You’re crushing it with {num}!",
|
| 62 |
+
f"Keep soaring: {num}!",
|
| 63 |
+
f"Fantastic number: {num}!"
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
zero_msgs = [
|
| 67 |
+
"Your score is 0, try to move forward.",
|
| 68 |
+
"0 means no progress, take action.",
|
| 69 |
+
"You got 0, push for improvement.",
|
| 70 |
+
"0 feels neutral, aim higher.",
|
| 71 |
+
"Stuck at 0, time to grow.",
|
| 72 |
+
"0 is just a starting point.",
|
| 73 |
+
"Move ahead from 0.",
|
| 74 |
+
"0 shows no change, act now.",
|
| 75 |
+
"Break the 0 barrier.",
|
| 76 |
+
"0 needs momentum.",
|
| 77 |
+
"Rise above 0.",
|
| 78 |
+
"0 is not the end.",
|
| 79 |
+
"Push beyond 0.",
|
| 80 |
+
"0 needs effort.",
|
| 81 |
+
"Start fresh from 0.",
|
| 82 |
+
"0 is a reset point.",
|
| 83 |
+
"Climb up from 0.",
|
| 84 |
+
"0 is a pause, not stop.",
|
| 85 |
+
"Turn 0 into progress.",
|
| 86 |
+
"0 needs a push.",
|
| 87 |
+
"Go beyond 0.",
|
| 88 |
+
"0 is neutral ground.",
|
| 89 |
+
"Advance from 0.",
|
| 90 |
+
"0 needs action.",
|
| 91 |
+
"Step up from 0.",
|
| 92 |
+
"0 is just beginning.",
|
| 93 |
+
"Grow past 0.",
|
| 94 |
+
"0 needs direction.",
|
| 95 |
+
"Break out of 0.",
|
| 96 |
+
"0 is waiting for effort.",
|
| 97 |
+
"Push ahead from 0.",
|
| 98 |
+
"0 is your base.",
|
| 99 |
+
"Move upward from 0.",
|
| 100 |
+
"0 needs energy.",
|
| 101 |
+
"Turn 0 positive.",
|
| 102 |
+
"0 is not enough.",
|
| 103 |
+
"Climb higher than 0.",
|
| 104 |
+
"0 needs change.",
|
| 105 |
+
"Start moving from 0.",
|
| 106 |
+
"0 is temporary.",
|
| 107 |
+
"Build from 0.",
|
| 108 |
+
"0 needs drive.",
|
| 109 |
+
"Progress after 0.",
|
| 110 |
+
"0 is your launch point.",
|
| 111 |
+
"0 needs momentum.",
|
| 112 |
+
"Don't stay at 0.",
|
| 113 |
+
"0 is a fresh start.",
|
| 114 |
+
"Act beyond 0.",
|
| 115 |
+
"0 needs growth.",
|
| 116 |
+
"Move forward from 0."
|
| 117 |
+
]
|
| 118 |
+
|
| 119 |
+
if num == 0:
|
| 120 |
+
return random.choice(zero_msgs)
|
| 121 |
+
elif num > 0:
|
| 122 |
+
return random.choice(positive_msgs)
|
server/stock_price.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fetch stock price data for a given symbol and exchange."""
|
| 2 |
+
|
| 3 |
+
import yfinance as yf
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def get_stock_price(symbol, exchange, days=None):
|
| 7 |
+
try:
|
| 8 |
+
exchange_normalized = str(exchange).lower()
|
| 9 |
+
if exchange_normalized == "nse":
|
| 10 |
+
ticker = symbol + ".NS"
|
| 11 |
+
elif exchange_normalized == "bse":
|
| 12 |
+
ticker = symbol + ".BO"
|
| 13 |
+
else:
|
| 14 |
+
return []
|
| 15 |
+
|
| 16 |
+
stock = yf.Ticker(ticker)
|
| 17 |
+
data = stock.history(period="1d") if days is None else stock.history(period=f"{days}d")
|
| 18 |
+
|
| 19 |
+
if data.empty:
|
| 20 |
+
return []
|
| 21 |
+
|
| 22 |
+
prices = data['Close'].tolist()
|
| 23 |
+
|
| 24 |
+
return prices
|
| 25 |
+
|
| 26 |
+
except Exception:
|
| 27 |
+
return []
|
| 28 |
+
|
| 29 |
+
# print(get_stock_price("RELIANCE","NSE",days=5))
|
tutorial/01-environments.md
ADDED
|
@@ -0,0 +1,1260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# OpenEnv: Production RL Made Simple
|
| 2 |
+
|
| 3 |
+
<div align="center">
|
| 4 |
+
|
| 5 |
+
<img src="https://upload.wikimedia.org/wikipedia/commons/1/10/PyTorch_logo_icon.svg" width="200" alt="PyTorch">
|
| 6 |
+
|
| 7 |
+
### *From "Hello World" to RL Training in 5 Minutes* ✨
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
**What if RL environments were as easy to use as REST APIs?**
|
| 12 |
+
|
| 13 |
+
That's OpenEnv. Type-safe. Isolated. Production-ready. 🎯
|
| 14 |
+
|
| 15 |
+
[](https://colab.research.google.com/github/meta-pytorch/OpenEnv/blob/main/examples/OpenEnv_Tutorial.ipynb)
|
| 16 |
+
[](https://github.com/meta-pytorch/OpenEnv)
|
| 17 |
+
[](https://opensource.org/licenses/BSD-3-Clause)
|
| 18 |
+
[](https://pytorch.org/)
|
| 19 |
+
|
| 20 |
+
Author: [Sanyam Bhutani](http://twitter.com/bhutanisanyam1/)
|
| 21 |
+
|
| 22 |
+
</div>
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## Why OpenEnv?
|
| 27 |
+
|
| 28 |
+
Let's take a trip down memory lane:
|
| 29 |
+
|
| 30 |
+
It's 2016, RL is popular. You read some papers, it looks promising.
|
| 31 |
+
|
| 32 |
+
But in real world: Cartpole is the best you can run on a gaming GPU.
|
| 33 |
+
|
| 34 |
+
What do you do beyond Cartpole?
|
| 35 |
+
|
| 36 |
+
Fast-forward to 2025, GRPO is awesome and this time it's not JUST in theory, it works well in practise and is really here!
|
| 37 |
+
|
| 38 |
+
The problem still remains, how do you take these RL algorithms and take them beyond Cartpole?
|
| 39 |
+
|
| 40 |
+
A huge part of RL is giving your algorithms environment access to learn.
|
| 41 |
+
|
| 42 |
+
We are excited to introduce an Environment Spec for adding Open Environments for RL Training. This will allow you to focus on your experiments and allow everyone to bring their environments.
|
| 43 |
+
|
| 44 |
+
Focus on experiments, use OpenEnvironments, and build agents that go beyond Cartpole on a single spec.
|
| 45 |
+
|
| 46 |
+
---
|
| 47 |
+
|
| 48 |
+
## 📋 What You'll Learn
|
| 49 |
+
|
| 50 |
+
<table>
|
| 51 |
+
<tr>
|
| 52 |
+
<td width="50%">
|
| 53 |
+
|
| 54 |
+
**🎯 Part 1-2: The Fundamentals**
|
| 55 |
+
|
| 56 |
+
- ⚡ RL in 60 seconds
|
| 57 |
+
- 🤔 Why existing solutions fall short
|
| 58 |
+
- 💡 The OpenEnv solution
|
| 59 |
+
|
| 60 |
+
</td>
|
| 61 |
+
<td width="50%">
|
| 62 |
+
|
| 63 |
+
**🏗️ Part 3-5: The Architecture**
|
| 64 |
+
|
| 65 |
+
- 🔧 How OpenEnv works
|
| 66 |
+
- 🔍 Exploring real code
|
| 67 |
+
- 🎮 OpenSpiel integration example
|
| 68 |
+
|
| 69 |
+
</td>
|
| 70 |
+
</tr>
|
| 71 |
+
<tr>
|
| 72 |
+
<td width="50%">
|
| 73 |
+
|
| 74 |
+
**🎮 Part 6-8: Hands-On Demo**
|
| 75 |
+
|
| 76 |
+
- 🔌 Use existing OpenSpiel environment
|
| 77 |
+
- 🤖 Test 4 different policies
|
| 78 |
+
- 👀 Watch learning happen live
|
| 79 |
+
|
| 80 |
+
</td>
|
| 81 |
+
<td width="50%">
|
| 82 |
+
|
| 83 |
+
**🔧 Part 9-10: Going Further**
|
| 84 |
+
|
| 85 |
+
- 🎮 Switch to other OpenSpiel games
|
| 86 |
+
- ✨ Build your own integration
|
| 87 |
+
- 🌐 Deploy to production
|
| 88 |
+
|
| 89 |
+
</td>
|
| 90 |
+
</tr>
|
| 91 |
+
</table>
|
| 92 |
+
|
| 93 |
+
!!! tip "Pro Tip"
|
| 94 |
+
This notebook is designed to run top-to-bottom in Google Colab with zero setup!
|
| 95 |
+
|
| 96 |
+
⏱️ **Time**: ~5 minutes | 📊 **Difficulty**: Beginner-friendly | 🎯 **Outcome**: Production-ready RL knowledge
|
| 97 |
+
|
| 98 |
+
---
|
| 99 |
+
|
| 100 |
+
## 📑 Table of Contents
|
| 101 |
+
|
| 102 |
+
### Foundation
|
| 103 |
+
|
| 104 |
+
- [Part 1: RL in 60 Seconds ⏱️](#part-1-rl-in-60-seconds)
|
| 105 |
+
- [Part 2: The Problem with Traditional RL 😤](#part-2-the-problem-with-traditional-rl)
|
| 106 |
+
- [Part 3: Setup 🛠️](#part-3-setup)
|
| 107 |
+
|
| 108 |
+
### Architecture
|
| 109 |
+
|
| 110 |
+
- [Part 4: The OpenEnv Pattern 🏗️](#part-4-the-openenv-pattern)
|
| 111 |
+
- [Part 5: Example Integration - OpenSpiel 🎮](#part-5-example-integration---openspiel)
|
| 112 |
+
|
| 113 |
+
### Hands-On Demo
|
| 114 |
+
|
| 115 |
+
- [Part 6: Interactive Demo 🎮](#part-6-using-real-openspiel)
|
| 116 |
+
- [Part 7: Four Policies 🤖](#part-7-four-policies)
|
| 117 |
+
- [Part 8: Policy Competition! 🏆](#part-8-policy-competition)
|
| 118 |
+
|
| 119 |
+
### Advanced
|
| 120 |
+
|
| 121 |
+
- [Part 9: Using Real OpenSpiel 🎮](#part-9-switching-to-other-games)
|
| 122 |
+
- [Part 10: Create Your Own Integration 🛠️](#part-10-create-your-own-integration)
|
| 123 |
+
|
| 124 |
+
### Wrap Up
|
| 125 |
+
|
| 126 |
+
- [Summary: Your Journey 🎓](#summary-your-journey)
|
| 127 |
+
- [Resources 📚](#resources)
|
| 128 |
+
|
| 129 |
+
---
|
| 130 |
+
|
| 131 |
+
## Part 1: RL in 60 Seconds ⏱️
|
| 132 |
+
|
| 133 |
+
**Reinforcement Learning is simpler than you think.**
|
| 134 |
+
|
| 135 |
+
It's just a loop:
|
| 136 |
+
|
| 137 |
+
```python
|
| 138 |
+
while not done:
|
| 139 |
+
observation = environment.observe()
|
| 140 |
+
action = policy.choose(observation)
|
| 141 |
+
reward = environment.step(action)
|
| 142 |
+
policy.learn(reward)
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
That's it. That's RL.
|
| 146 |
+
|
| 147 |
+
Let's see it in action:
|
| 148 |
+
|
| 149 |
+
```python
|
| 150 |
+
import random
|
| 151 |
+
|
| 152 |
+
print("🎲 " + "="*58 + " 🎲")
|
| 153 |
+
print(" Number Guessing Game - The Simplest RL Example")
|
| 154 |
+
print("🎲 " + "="*58 + " 🎲")
|
| 155 |
+
|
| 156 |
+
# Environment setup
|
| 157 |
+
target = random.randint(1, 10)
|
| 158 |
+
guesses_left = 3
|
| 159 |
+
|
| 160 |
+
print(f"\n🎯 I'm thinking of a number between 1 and 10...")
|
| 161 |
+
print(f"💭 You have {guesses_left} guesses. Let's see how random guessing works!\n")
|
| 162 |
+
|
| 163 |
+
# The RL Loop - Pure random policy (no learning!)
|
| 164 |
+
while guesses_left > 0:
|
| 165 |
+
# Policy: Random guessing (no learning yet!)
|
| 166 |
+
guess = random.randint(1, 10)
|
| 167 |
+
guesses_left -= 1
|
| 168 |
+
|
| 169 |
+
print(f"💭 Guess #{3-guesses_left}: {guess}", end=" → ")
|
| 170 |
+
|
| 171 |
+
# Reward signal (but we're not using it!)
|
| 172 |
+
if guess == target:
|
| 173 |
+
print("🎉 Correct! +10 points")
|
| 174 |
+
break
|
| 175 |
+
elif abs(guess - target) <= 2:
|
| 176 |
+
print("🔥 Warm! (close)")
|
| 177 |
+
else:
|
| 178 |
+
print("❄️ Cold! (far)")
|
| 179 |
+
else:
|
| 180 |
+
print(f"\n💔 Out of guesses. The number was {target}.")
|
| 181 |
+
|
| 182 |
+
print("\n" + "="*62)
|
| 183 |
+
print("💡 This is RL: Observe → Act → Reward → Repeat")
|
| 184 |
+
print(" But this policy is terrible! It doesn't learn from rewards.")
|
| 185 |
+
print("="*62 + "\n")
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
**Output:**
|
| 189 |
+
```
|
| 190 |
+
🎲 ========================================================== 🎲
|
| 191 |
+
Number Guessing Game - The Simplest RL Example
|
| 192 |
+
🎲 ========================================================== 🎲
|
| 193 |
+
|
| 194 |
+
🎯 I'm thinking of a number between 1 and 10...
|
| 195 |
+
💭 You have 3 guesses. Let's see how random guessing works!
|
| 196 |
+
|
| 197 |
+
💭 Guess #1: 2 → ❄️ Cold! (far)
|
| 198 |
+
💭 Guess #2: 10 → 🎉 Correct! +10 points
|
| 199 |
+
|
| 200 |
+
==============================================================
|
| 201 |
+
💡 This is RL: Observe → Act → Reward → Repeat
|
| 202 |
+
But this policy is terrible! It doesn't learn from rewards.
|
| 203 |
+
==============================================================
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
---
|
| 207 |
+
|
| 208 |
+
## Part 2: The Problem with Traditional RL 😤
|
| 209 |
+
|
| 210 |
+
### 🤔 Why Can't We Just Use OpenAI Gym?
|
| 211 |
+
|
| 212 |
+
Good question! Gym is great for research, but production needs more...
|
| 213 |
+
|
| 214 |
+
| Challenge | Traditional Approach | OpenEnv Solution |
|
| 215 |
+
|-----------|---------------------|------------------|
|
| 216 |
+
| **Type Safety** | ❌ `obs[0][3]` - what is this? | ✅ `obs.info_state` - IDE knows! |
|
| 217 |
+
| **Isolation** | ❌ Same process (can crash your training) | ✅ Docker containers (fully isolated) |
|
| 218 |
+
| **Deployment** | ❌ "Works on my machine" 🤷 | ✅ Same container everywhere 🐳 |
|
| 219 |
+
| **Scaling** | ❌ Hard to distribute | ✅ Deploy to Kubernetes ☸️ |
|
| 220 |
+
| **Language** | ❌ Python only | ✅ Any language (HTTP API) 🌐 |
|
| 221 |
+
| **Debugging** | ❌ Cryptic numpy errors | ✅ Clear type errors 🐛 |
|
| 222 |
+
|
| 223 |
+
### 💡 The OpenEnv Philosophy
|
| 224 |
+
|
| 225 |
+
**"RL environments should be like microservices"**
|
| 226 |
+
|
| 227 |
+
Think of it like this: You don't run your database in the same process as your web server, right? Same principle!
|
| 228 |
+
|
| 229 |
+
- 🔒 **Isolated**: Run in containers (security + stability)
|
| 230 |
+
- 🌐 **Standard**: HTTP API, works everywhere
|
| 231 |
+
- 📦 **Versioned**: Docker images (reproducibility!)
|
| 232 |
+
- 🚀 **Scalable**: Deploy to cloud with one command
|
| 233 |
+
- 🛡️ **Type-safe**: Catch bugs before they happen
|
| 234 |
+
- 🔄 **Portable**: Works on Mac, Linux, Windows, Cloud
|
| 235 |
+
|
| 236 |
+
### The Architecture
|
| 237 |
+
|
| 238 |
+
```
|
| 239 |
+
┌────────────────────────────────────────────────────────────┐
|
| 240 |
+
│ YOUR TRAINING CODE │
|
| 241 |
+
│ │
|
| 242 |
+
│ env = OpenSpielEnv(...) ← Import the client │
|
| 243 |
+
│ result = env.reset() ← Type-safe! │
|
| 244 |
+
│ result = env.step(action) ← Type-safe! │
|
| 245 |
+
│ │
|
| 246 |
+
└─────────────────┬──────────────────────────────────────────┘
|
| 247 |
+
│
|
| 248 |
+
│ HTTP/JSON (Language-Agnostic)
|
| 249 |
+
│ POST /reset, POST /step, GET /state
|
| 250 |
+
│
|
| 251 |
+
┌─────────────────▼──────────────────────────────────────────┐
|
| 252 |
+
│ DOCKER CONTAINER │
|
| 253 |
+
│ │
|
| 254 |
+
│ ┌──────────────────────────────────────────────┐ │
|
| 255 |
+
│ │ FastAPI Server │ │
|
| 256 |
+
│ │ └─ Environment (reset, step, state) │ │
|
| 257 |
+
│ │ └─ Your Game/Simulation Logic │ │
|
| 258 |
+
│ └──────────────────────────────────────────────┘ │
|
| 259 |
+
│ │
|
| 260 |
+
│ Isolated • Reproducible • Secure │
|
| 261 |
+
└────────────────────────────────────────────────────────────┘
|
| 262 |
+
```
|
| 263 |
+
|
| 264 |
+
!!! info "Key Insight"
|
| 265 |
+
You never see HTTP details - just clean Python methods!
|
| 266 |
+
|
| 267 |
+
```python
|
| 268 |
+
env.reset() # Under the hood: HTTP POST to /reset
|
| 269 |
+
env.step(...) # Under the hood: HTTP POST to /step
|
| 270 |
+
env.state() # Under the hood: HTTP GET to /state
|
| 271 |
+
```
|
| 272 |
+
|
| 273 |
+
The magic? OpenEnv handles all the plumbing. You focus on RL! ✨
|
| 274 |
+
|
| 275 |
+
---
|
| 276 |
+
|
| 277 |
+
## Part 3: Setup 🛠️
|
| 278 |
+
|
| 279 |
+
**Running in Colab?** This cell will clone OpenEnv and install dependencies automatically.
|
| 280 |
+
|
| 281 |
+
**Running locally?** Make sure you're in the OpenEnv directory.
|
| 282 |
+
|
| 283 |
+
```python
|
| 284 |
+
# Detect environment
|
| 285 |
+
try:
|
| 286 |
+
import google.colab
|
| 287 |
+
IN_COLAB = True
|
| 288 |
+
print("🌐 Running in Google Colab - Perfect!")
|
| 289 |
+
except ImportError:
|
| 290 |
+
IN_COLAB = False
|
| 291 |
+
print("💻 Running locally - Nice!")
|
| 292 |
+
|
| 293 |
+
if IN_COLAB:
|
| 294 |
+
print("\n📦 Cloning OpenEnv repository...")
|
| 295 |
+
!git clone https://github.com/meta-pytorch/OpenEnv.git > /dev/null 2>&1
|
| 296 |
+
%cd OpenEnv
|
| 297 |
+
|
| 298 |
+
print("📚 Installing dependencies (this takes ~10 seconds)...")
|
| 299 |
+
!pip install -q fastapi uvicorn requests
|
| 300 |
+
|
| 301 |
+
import sys
|
| 302 |
+
sys.path.insert(0, './src')
|
| 303 |
+
print("\n✅ Setup complete! Everything is ready to go! 🎉")
|
| 304 |
+
else:
|
| 305 |
+
import sys
|
| 306 |
+
from pathlib import Path
|
| 307 |
+
sys.path.insert(0, str(Path.cwd().parent / 'src'))
|
| 308 |
+
print("✅ Using local OpenEnv installation")
|
| 309 |
+
|
| 310 |
+
print("\n🚀 Ready to explore OpenEnv and build amazing things!")
|
| 311 |
+
print("💡 Tip: Run cells top-to-bottom for the best experience.\n")
|
| 312 |
+
```
|
| 313 |
+
|
| 314 |
+
**Output:**
|
| 315 |
+
```
|
| 316 |
+
💻 Running locally - Nice!
|
| 317 |
+
✅ Using local OpenEnv installation
|
| 318 |
+
|
| 319 |
+
🚀 Ready to explore OpenEnv and build amazing things!
|
| 320 |
+
💡 Tip: Run cells top-to-bottom for the best experience.
|
| 321 |
+
```
|
| 322 |
+
|
| 323 |
+
---
|
| 324 |
+
|
| 325 |
+
## Part 4: The OpenEnv Pattern 🏗️
|
| 326 |
+
|
| 327 |
+
### Every OpenEnv Environment Has 3 Components:
|
| 328 |
+
|
| 329 |
+
```
|
| 330 |
+
src/envs/your_env/
|
| 331 |
+
├── 📝 models.py ← Type-safe contracts
|
| 332 |
+
│ (Action, Observation, State)
|
| 333 |
+
│
|
| 334 |
+
├── 📱 client.py ← What YOU import
|
| 335 |
+
│ (HTTPEnvClient implementation)
|
| 336 |
+
│
|
| 337 |
+
└── 🖥️ server/
|
| 338 |
+
├── environment.py ← Game/simulation logic
|
| 339 |
+
├── app.py ← FastAPI server
|
| 340 |
+
└── Dockerfile ← Container definition
|
| 341 |
+
```
|
| 342 |
+
|
| 343 |
+
Let's explore the actual OpenEnv code to see how this works:
|
| 344 |
+
|
| 345 |
+
```python
|
| 346 |
+
# Import OpenEnv's core abstractions
|
| 347 |
+
from core.env_server import Environment, Action, Observation, State
|
| 348 |
+
from core.http_env_client import HTTPEnvClient
|
| 349 |
+
|
| 350 |
+
print("="*70)
|
| 351 |
+
print(" 🧩 OPENENV CORE ABSTRACTIONS")
|
| 352 |
+
print("="*70)
|
| 353 |
+
|
| 354 |
+
print("""
|
| 355 |
+
🖥️ SERVER SIDE (runs in Docker):
|
| 356 |
+
|
| 357 |
+
class Environment(ABC):
|
| 358 |
+
'''Base class for all environment implementations'''
|
| 359 |
+
|
| 360 |
+
@abstractmethod
|
| 361 |
+
def reset(self) -> Observation:
|
| 362 |
+
'''Start new episode'''
|
| 363 |
+
|
| 364 |
+
@abstractmethod
|
| 365 |
+
def step(self, action: Action) -> Observation:
|
| 366 |
+
'''Execute action, return observation'''
|
| 367 |
+
|
| 368 |
+
@property
|
| 369 |
+
def state(self) -> State:
|
| 370 |
+
'''Get episode metadata'''
|
| 371 |
+
|
| 372 |
+
📱 CLIENT SIDE (your training code):
|
| 373 |
+
|
| 374 |
+
class HTTPEnvClient(ABC):
|
| 375 |
+
'''Base class for HTTP clients'''
|
| 376 |
+
|
| 377 |
+
def reset(self) -> StepResult:
|
| 378 |
+
# HTTP POST /reset
|
| 379 |
+
|
| 380 |
+
def step(self, action) -> StepResult:
|
| 381 |
+
# HTTP POST /step
|
| 382 |
+
|
| 383 |
+
def state(self) -> State:
|
| 384 |
+
# HTTP GET /state
|
| 385 |
+
""")
|
| 386 |
+
|
| 387 |
+
print("="*70)
|
| 388 |
+
print("\n✨ Same interface on both sides - communication via HTTP!")
|
| 389 |
+
print("🎯 You focus on RL, OpenEnv handles the infrastructure.\n")
|
| 390 |
+
```
|
| 391 |
+
|
| 392 |
+
**Output:**
|
| 393 |
+
```
|
| 394 |
+
======================================================================
|
| 395 |
+
🧩 OPENENV CORE ABSTRACTIONS
|
| 396 |
+
======================================================================
|
| 397 |
+
|
| 398 |
+
🖥️ SERVER SIDE (runs in Docker):
|
| 399 |
+
|
| 400 |
+
class Environment(ABC):
|
| 401 |
+
'''Base class for all environment implementations'''
|
| 402 |
+
|
| 403 |
+
@abstractmethod
|
| 404 |
+
def reset(self) -> Observation:
|
| 405 |
+
'''Start new episode'''
|
| 406 |
+
|
| 407 |
+
@abstractmethod
|
| 408 |
+
def step(self, action: Action) -> Observation:
|
| 409 |
+
'''Execute action, return observation'''
|
| 410 |
+
|
| 411 |
+
@property
|
| 412 |
+
def state(self) -> State:
|
| 413 |
+
'''Get episode metadata'''
|
| 414 |
+
|
| 415 |
+
📱 CLIENT SIDE (your training code):
|
| 416 |
+
|
| 417 |
+
class HTTPEnvClient(ABC):
|
| 418 |
+
'''Base class for HTTP clients'''
|
| 419 |
+
|
| 420 |
+
def reset(self) -> StepResult:
|
| 421 |
+
# HTTP POST /reset
|
| 422 |
+
|
| 423 |
+
def step(self, action) -> StepResult:
|
| 424 |
+
# HTTP POST /step
|
| 425 |
+
|
| 426 |
+
def state(self) -> State:
|
| 427 |
+
# HTTP GET /state
|
| 428 |
+
|
| 429 |
+
======================================================================
|
| 430 |
+
|
| 431 |
+
✨ Same interface on both sides - communication via HTTP!
|
| 432 |
+
🎯 You focus on RL, OpenEnv handles the infrastructure.
|
| 433 |
+
```
|
| 434 |
+
|
| 435 |
+
---
|
| 436 |
+
|
| 437 |
+
## Part 5: Example Integration - OpenSpiel 🎮
|
| 438 |
+
|
| 439 |
+
### What is OpenSpiel?
|
| 440 |
+
|
| 441 |
+
**OpenSpiel** is a library from DeepMind with **70+ game environments** for RL research.
|
| 442 |
+
|
| 443 |
+
### OpenEnv's Integration
|
| 444 |
+
|
| 445 |
+
We've wrapped **6 OpenSpiel games** following the OpenEnv pattern:
|
| 446 |
+
|
| 447 |
+
| **🎯 Single-Player** | **👥 Multi-Player** |
|
| 448 |
+
|---------------------|---------------------|
|
| 449 |
+
| 1. **Catch** - Catch falling ball | 5. **Tic-Tac-Toe** - Classic 3×3 |
|
| 450 |
+
| 2. **Cliff Walking** - Navigate grid | 6. **Kuhn Poker** - Imperfect info poker |
|
| 451 |
+
| 3. **2048** - Tile puzzle | |
|
| 452 |
+
| 4. **Blackjack** - Card game | |
|
| 453 |
+
|
| 454 |
+
This shows how OpenEnv can wrap **any** existing RL library!
|
| 455 |
+
|
| 456 |
+
```python
|
| 457 |
+
from envs.openspiel_env.client import OpenSpielEnv
|
| 458 |
+
|
| 459 |
+
print("="*70)
|
| 460 |
+
print(" 🔌 HOW OPENENV WRAPS OPENSPIEL")
|
| 461 |
+
print("="*70)
|
| 462 |
+
|
| 463 |
+
print("""
|
| 464 |
+
class OpenSpielEnv(HTTPEnvClient[OpenSpielAction, OpenSpielObservation]):
|
| 465 |
+
|
| 466 |
+
def _step_payload(self, action: OpenSpielAction) -> dict:
|
| 467 |
+
'''Convert typed action to JSON for HTTP'''
|
| 468 |
+
return {
|
| 469 |
+
"action_id": action.action_id,
|
| 470 |
+
"game_name": action.game_name,
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
def _parse_result(self, payload: dict) -> StepResult:
|
| 474 |
+
'''Parse HTTP JSON response into typed observation'''
|
| 475 |
+
return StepResult(
|
| 476 |
+
observation=OpenSpielObservation(...),
|
| 477 |
+
reward=payload['reward'],
|
| 478 |
+
done=payload['done']
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
""")
|
| 482 |
+
|
| 483 |
+
print("─" * 70)
|
| 484 |
+
print("\n✨ Usage (works for ALL OpenEnv environments):")
|
| 485 |
+
print("""
|
| 486 |
+
env = OpenSpielEnv(base_url="http://localhost:8000")
|
| 487 |
+
|
| 488 |
+
result = env.reset()
|
| 489 |
+
# Returns StepResult[OpenSpielObservation] - Type safe!
|
| 490 |
+
|
| 491 |
+
result = env.step(OpenSpielAction(action_id=2, game_name="catch"))
|
| 492 |
+
# Type checker knows this is valid!
|
| 493 |
+
|
| 494 |
+
state = env.state()
|
| 495 |
+
# Returns OpenSpielState
|
| 496 |
+
""")
|
| 497 |
+
|
| 498 |
+
print("─" * 70)
|
| 499 |
+
print("\n🎯 This pattern works for ANY environment you want to wrap!\n")
|
| 500 |
+
```
|
| 501 |
+
|
| 502 |
+
**Output:**
|
| 503 |
+
```
|
| 504 |
+
======================================================================
|
| 505 |
+
🔌 HOW OPENENV WRAPS OPENSPIEL
|
| 506 |
+
======================================================================
|
| 507 |
+
|
| 508 |
+
class OpenSpielEnv(HTTPEnvClient[OpenSpielAction, OpenSpielObservation]):
|
| 509 |
+
|
| 510 |
+
def _step_payload(self, action: OpenSpielAction) -> dict:
|
| 511 |
+
'''Convert typed action to JSON for HTTP'''
|
| 512 |
+
return {
|
| 513 |
+
"action_id": action.action_id,
|
| 514 |
+
"game_name": action.game_name,
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
def _parse_result(self, payload: dict) -> StepResult:
|
| 518 |
+
'''Parse HTTP JSON response into typed observation'''
|
| 519 |
+
return StepResult(
|
| 520 |
+
observation=OpenSpielObservation(...),
|
| 521 |
+
reward=payload['reward'],
|
| 522 |
+
done=payload['done']
|
| 523 |
+
)
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
──────────────────────────────────────────────────────────────────────
|
| 527 |
+
|
| 528 |
+
✨ Usage (works for ALL OpenEnv environments):
|
| 529 |
+
|
| 530 |
+
env = OpenSpielEnv(base_url="http://localhost:8000")
|
| 531 |
+
|
| 532 |
+
result = env.reset()
|
| 533 |
+
# Returns StepResult[OpenSpielObservation] - Type safe!
|
| 534 |
+
|
| 535 |
+
result = env.step(OpenSpielAction(action_id=2, game_name="catch"))
|
| 536 |
+
# Type checker knows this is valid!
|
| 537 |
+
|
| 538 |
+
state = env.state()
|
| 539 |
+
# Returns OpenSpielState
|
| 540 |
+
|
| 541 |
+
──────────────────────────────────────────────────────────────────────
|
| 542 |
+
|
| 543 |
+
🎯 This pattern works for ANY environment you want to wrap!
|
| 544 |
+
```
|
| 545 |
+
|
| 546 |
+
### Type-Safe Models
|
| 547 |
+
|
| 548 |
+
```python
|
| 549 |
+
# Import OpenSpiel integration models
|
| 550 |
+
from envs.openspiel_env.models import (
|
| 551 |
+
OpenSpielAction,
|
| 552 |
+
OpenSpielObservation,
|
| 553 |
+
OpenSpielState
|
| 554 |
+
)
|
| 555 |
+
from dataclasses import fields
|
| 556 |
+
|
| 557 |
+
print("="*70)
|
| 558 |
+
print(" 🎮 OPENSPIEL INTEGRATION - TYPE-SAFE MODELS")
|
| 559 |
+
print("="*70)
|
| 560 |
+
|
| 561 |
+
print("\n📤 OpenSpielAction (what you send):")
|
| 562 |
+
print(" " + "─" * 64)
|
| 563 |
+
for field in fields(OpenSpielAction):
|
| 564 |
+
print(f" • {field.name:20s} : {field.type}")
|
| 565 |
+
|
| 566 |
+
print("\n📥 OpenSpielObservation (what you receive):")
|
| 567 |
+
print(" " + "─" * 64)
|
| 568 |
+
for field in fields(OpenSpielObservation):
|
| 569 |
+
print(f" • {field.name:20s} : {field.type}")
|
| 570 |
+
|
| 571 |
+
print("\n📊 OpenSpielState (episode metadata):")
|
| 572 |
+
print(" " + "─" * 64)
|
| 573 |
+
for field in fields(OpenSpielState):
|
| 574 |
+
print(f" • {field.name:20s} : {field.type}")
|
| 575 |
+
|
| 576 |
+
print("\n" + "="*70)
|
| 577 |
+
print("\n💡 Type safety means:")
|
| 578 |
+
print(" ✅ Your IDE autocompletes these fields")
|
| 579 |
+
print(" ✅ Typos are caught before running")
|
| 580 |
+
print(" ✅ Refactoring is safe")
|
| 581 |
+
print(" ✅ Self-documenting code\n")
|
| 582 |
+
```
|
| 583 |
+
|
| 584 |
+
**Output:**
|
| 585 |
+
```
|
| 586 |
+
======================================================================
|
| 587 |
+
🎮 OPENSPIEL INTEGRATION - TYPE-SAFE MODELS
|
| 588 |
+
======================================================================
|
| 589 |
+
|
| 590 |
+
📤 OpenSpielAction (what you send):
|
| 591 |
+
────────────────────────────────────────────────────────────────
|
| 592 |
+
• metadata : typing.Dict[str, typing.Any]
|
| 593 |
+
• action_id : int
|
| 594 |
+
• game_name : str
|
| 595 |
+
• game_params : Dict[str, Any]
|
| 596 |
+
|
| 597 |
+
📥 OpenSpielObservation (what you receive):
|
| 598 |
+
────────────────────────────────────────────────────────────────
|
| 599 |
+
• done : <class 'bool'>
|
| 600 |
+
• reward : typing.Union[bool, int, float, NoneType]
|
| 601 |
+
• metadata : typing.Dict[str, typing.Any]
|
| 602 |
+
• info_state : List[float]
|
| 603 |
+
• legal_actions : List[int]
|
| 604 |
+
• game_phase : str
|
| 605 |
+
• current_player_id : int
|
| 606 |
+
• opponent_last_action : Optional[int]
|
| 607 |
+
|
| 608 |
+
📊 OpenSpielState (episode metadata):
|
| 609 |
+
────────────────────────────��───────────────────────────────────
|
| 610 |
+
• episode_id : typing.Optional[str]
|
| 611 |
+
• step_count : <class 'int'>
|
| 612 |
+
• game_name : str
|
| 613 |
+
• agent_player : int
|
| 614 |
+
• opponent_policy : str
|
| 615 |
+
• game_params : Dict[str, Any]
|
| 616 |
+
• num_players : int
|
| 617 |
+
|
| 618 |
+
======================================================================
|
| 619 |
+
|
| 620 |
+
💡 Type safety means:
|
| 621 |
+
✅ Your IDE autocompletes these fields
|
| 622 |
+
✅ Typos are caught before running
|
| 623 |
+
✅ Refactoring is safe
|
| 624 |
+
✅ Self-documenting code
|
| 625 |
+
```
|
| 626 |
+
|
| 627 |
+
### How the Client Works
|
| 628 |
+
|
| 629 |
+
The client **inherits from HTTPEnvClient** and implements 3 methods:
|
| 630 |
+
|
| 631 |
+
1. `_step_payload()` - Convert action → JSON
|
| 632 |
+
2. `_parse_result()` - Parse JSON → typed observation
|
| 633 |
+
3. `_parse_state()` - Parse JSON → state
|
| 634 |
+
|
| 635 |
+
That's it! The base class handles all HTTP communication.
|
| 636 |
+
|
| 637 |
+
---
|
| 638 |
+
|
| 639 |
+
## Part 6: Using Real OpenSpiel 🎮
|
| 640 |
+
|
| 641 |
+
<div style="text-align: center; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; border-radius: 15px; margin: 30px 0;">
|
| 642 |
+
|
| 643 |
+
### Now let's USE a production environment!
|
| 644 |
+
|
| 645 |
+
We'll play **Catch** using OpenEnv's **OpenSpiel integration** 🎯
|
| 646 |
+
|
| 647 |
+
This is a REAL environment running in production at companies!
|
| 648 |
+
|
| 649 |
+
**Get ready for:**
|
| 650 |
+
|
| 651 |
+
- 🔌 Using existing environments (not building)
|
| 652 |
+
- 🤖 Testing policies against real games
|
| 653 |
+
- 📊 Live gameplay visualization
|
| 654 |
+
- 🎯 Production-ready patterns
|
| 655 |
+
|
| 656 |
+
</div>
|
| 657 |
+
|
| 658 |
+
### The Game: Catch 🔴🏓
|
| 659 |
+
|
| 660 |
+
```
|
| 661 |
+
⬜ ⬜ 🔴 ⬜ ⬜
|
| 662 |
+
⬜ ⬜ ⬜ ⬜ ⬜
|
| 663 |
+
⬜ ⬜ ⬜ ⬜ ⬜ Ball
|
| 664 |
+
⬜ ⬜ ⬜ ⬜ ⬜
|
| 665 |
+
⬜ ⬜ ⬜ ⬜ ⬜ falls
|
| 666 |
+
⬜ ⬜ ⬜ ⬜ ⬜
|
| 667 |
+
⬜ ⬜ ⬜ ⬜ ⬜ down
|
| 668 |
+
⬜ ⬜ ⬜ ⬜ ⬜
|
| 669 |
+
⬜ ⬜ ⬜ ⬜ ⬜
|
| 670 |
+
⬜ ⬜ 🏓 ⬜ ⬜
|
| 671 |
+
Paddle
|
| 672 |
+
```
|
| 673 |
+
|
| 674 |
+
**Rules:**
|
| 675 |
+
|
| 676 |
+
- 10×5 grid
|
| 677 |
+
- Ball falls from random column
|
| 678 |
+
- Move paddle left/right to catch it
|
| 679 |
+
|
| 680 |
+
**Actions:**
|
| 681 |
+
|
| 682 |
+
- `0` = Move LEFT ⬅️
|
| 683 |
+
- `1` = STAY 🛑
|
| 684 |
+
- `2` = Move RIGHT ➡️
|
| 685 |
+
|
| 686 |
+
**Reward:**
|
| 687 |
+
|
| 688 |
+
- `+1` if caught 🎉
|
| 689 |
+
- `0` if missed 😢
|
| 690 |
+
|
| 691 |
+
!!! note "Why Catch?"
|
| 692 |
+
- Simple rules (easy to understand)
|
| 693 |
+
- Fast episodes (~5 steps)
|
| 694 |
+
- Clear success/failure
|
| 695 |
+
- Part of OpenSpiel's 70+ games!
|
| 696 |
+
|
| 697 |
+
**💡 The Big Idea:**
|
| 698 |
+
Instead of building this from scratch, we'll USE OpenEnv's existing OpenSpiel integration. Same interface, but production-ready!
|
| 699 |
+
|
| 700 |
+
```python
|
| 701 |
+
from envs.openspiel_env import OpenSpielEnv
|
| 702 |
+
from envs.openspiel_env.models import (
|
| 703 |
+
OpenSpielAction,
|
| 704 |
+
OpenSpielObservation,
|
| 705 |
+
OpenSpielState
|
| 706 |
+
)
|
| 707 |
+
from dataclasses import fields
|
| 708 |
+
|
| 709 |
+
print("🎮 " + "="*64 + " 🎮")
|
| 710 |
+
print(" ✅ Importing Real OpenSpiel Environment!")
|
| 711 |
+
print("🎮 " + "="*64 + " 🎮\n")
|
| 712 |
+
|
| 713 |
+
print("📦 What we just imported:")
|
| 714 |
+
print(" • OpenSpielEnv - HTTP client for OpenSpiel games")
|
| 715 |
+
print(" • OpenSpielAction - Type-safe actions")
|
| 716 |
+
print(" • OpenSpielObservation - Type-safe observations")
|
| 717 |
+
print(" • OpenSpielState - Episode metadata\n")
|
| 718 |
+
|
| 719 |
+
print("📋 OpenSpielObservation fields:")
|
| 720 |
+
print(" " + "─" * 60)
|
| 721 |
+
for field in fields(OpenSpielObservation):
|
| 722 |
+
print(f" • {field.name:25s} : {field.type}")
|
| 723 |
+
|
| 724 |
+
print("\n" + "="*70)
|
| 725 |
+
print("\n💡 This is REAL OpenEnv code - used in production!")
|
| 726 |
+
print(" • Wraps 6 OpenSpiel games (Catch, Tic-Tac-Toe, Poker, etc.)")
|
| 727 |
+
print(" • Type-safe actions and observations")
|
| 728 |
+
print(" • Works via HTTP (we'll see that next!)\n")
|
| 729 |
+
```
|
| 730 |
+
|
| 731 |
+
**Output:**
|
| 732 |
+
```
|
| 733 |
+
🎮 ================================================================ 🎮
|
| 734 |
+
✅ Importing Real OpenSpiel Environment!
|
| 735 |
+
🎮 ================================================================ 🎮
|
| 736 |
+
|
| 737 |
+
📦 What we just imported:
|
| 738 |
+
• OpenSpielEnv - HTTP client for OpenSpiel games
|
| 739 |
+
• OpenSpielAction - Type-safe actions
|
| 740 |
+
• OpenSpielObservation - Type-safe observations
|
| 741 |
+
• OpenSpielState - Episode metadata
|
| 742 |
+
|
| 743 |
+
📋 OpenSpielObservation fields:
|
| 744 |
+
────────────────────────────────────────────────────────────
|
| 745 |
+
• done : <class 'bool'>
|
| 746 |
+
• reward : typing.Union[bool, int, float, NoneType]
|
| 747 |
+
• metadata : typing.Dict[str, typing.Any]
|
| 748 |
+
• info_state : List[float]
|
| 749 |
+
• legal_actions : List[int]
|
| 750 |
+
• game_phase : str
|
| 751 |
+
• current_player_id : int
|
| 752 |
+
• opponent_last_action : Optional[int]
|
| 753 |
+
|
| 754 |
+
======================================================================
|
| 755 |
+
|
| 756 |
+
💡 This is REAL OpenEnv code - used in production!
|
| 757 |
+
• Wraps 6 OpenSpiel games (Catch, Tic-Tac-Toe, Poker, etc.)
|
| 758 |
+
• Type-safe actions and observations
|
| 759 |
+
• Works via HTTP (we'll see that next!)
|
| 760 |
+
```
|
| 761 |
+
|
| 762 |
+
---
|
| 763 |
+
|
| 764 |
+
## Part 7: Four Policies 🤖
|
| 765 |
+
|
| 766 |
+
Let's test 4 different AI strategies:
|
| 767 |
+
|
| 768 |
+
| Policy | Strategy | Expected Performance |
|
| 769 |
+
|--------|----------|----------------------|
|
| 770 |
+
| **🎲 Random** | Pick random action every step | ~20% (pure luck) |
|
| 771 |
+
| **🛑 Always Stay** | Never move, hope ball lands in center | ~20% (terrible!) |
|
| 772 |
+
| **🧠 Smart** | Move paddle toward ball | 100% (optimal!) |
|
| 773 |
+
| **📈 Learning** | Start random, learn smart strategy | ~85% (improves over time) |
|
| 774 |
+
|
| 775 |
+
**💡 These policies work with ANY OpenSpiel game!**
|
| 776 |
+
|
| 777 |
+
```python
|
| 778 |
+
import random
|
| 779 |
+
|
| 780 |
+
# ============================================================================
|
| 781 |
+
# POLICIES - Different AI strategies (adapted for OpenSpiel)
|
| 782 |
+
# ============================================================================
|
| 783 |
+
|
| 784 |
+
class RandomPolicy:
|
| 785 |
+
"""Baseline: Pure random guessing."""
|
| 786 |
+
name = "🎲 Random Guesser"
|
| 787 |
+
|
| 788 |
+
def select_action(self, obs: OpenSpielObservation) -> int:
|
| 789 |
+
return random.choice(obs.legal_actions)
|
| 790 |
+
|
| 791 |
+
|
| 792 |
+
class AlwaysStayPolicy:
|
| 793 |
+
"""Bad strategy: Never moves."""
|
| 794 |
+
name = "🛑 Always Stay"
|
| 795 |
+
|
| 796 |
+
def select_action(self, obs: OpenSpielObservation) -> int:
|
| 797 |
+
return 1 # STAY
|
| 798 |
+
|
| 799 |
+
|
| 800 |
+
class SmartPolicy:
|
| 801 |
+
"""Optimal: Move paddle toward ball."""
|
| 802 |
+
name = "🧠 Smart Heuristic"
|
| 803 |
+
|
| 804 |
+
def select_action(self, obs: OpenSpielObservation) -> int:
|
| 805 |
+
# Parse OpenSpiel observation
|
| 806 |
+
# For Catch: info_state is a flattened 10x5 grid
|
| 807 |
+
# Ball position and paddle position encoded in the vector
|
| 808 |
+
info_state = obs.info_state
|
| 809 |
+
|
| 810 |
+
# Find ball and paddle positions from info_state
|
| 811 |
+
# Catch uses a 10x5 grid, so 50 values
|
| 812 |
+
grid_size = 5
|
| 813 |
+
|
| 814 |
+
# Find positions (ball = 1.0 in the flattened grid, paddle = 1.0 in the last row of the flattened grid)
|
| 815 |
+
ball_col = None
|
| 816 |
+
paddle_col = None
|
| 817 |
+
|
| 818 |
+
for idx, val in enumerate(info_state):
|
| 819 |
+
if abs(val - 1.0) < 0.01: # Ball
|
| 820 |
+
ball_col = idx % grid_size
|
| 821 |
+
break
|
| 822 |
+
|
| 823 |
+
last_row = info_state[-grid_size:]
|
| 824 |
+
paddle_col = last_row.index(1.0) # Paddle
|
| 825 |
+
|
| 826 |
+
if ball_col is not None and paddle_col is not None:
|
| 827 |
+
if paddle_col < ball_col:
|
| 828 |
+
return 2 # Move RIGHT
|
| 829 |
+
elif paddle_col > ball_col:
|
| 830 |
+
return 0 # Move LEFT
|
| 831 |
+
|
| 832 |
+
return 1 # STAY (fallback)
|
| 833 |
+
|
| 834 |
+
|
| 835 |
+
class LearningPolicy:
|
| 836 |
+
"""Simulated RL: Epsilon-greedy exploration."""
|
| 837 |
+
name = "📈 Learning Agent"
|
| 838 |
+
|
| 839 |
+
def __init__(self):
|
| 840 |
+
self.steps = 0
|
| 841 |
+
self.smart_policy = SmartPolicy()
|
| 842 |
+
|
| 843 |
+
def select_action(self, obs: OpenSpielObservation) -> int:
|
| 844 |
+
self.steps += 1
|
| 845 |
+
|
| 846 |
+
# Decay exploration rate over time
|
| 847 |
+
epsilon = max(0.1, 1.0 - (self.steps / 100))
|
| 848 |
+
|
| 849 |
+
if random.random() < epsilon:
|
| 850 |
+
# Explore: random action
|
| 851 |
+
return random.choice(obs.legal_actions)
|
| 852 |
+
else:
|
| 853 |
+
# Exploit: use smart strategy
|
| 854 |
+
return self.smart_policy.select_action(obs)
|
| 855 |
+
|
| 856 |
+
|
| 857 |
+
print("🤖 " + "="*64 + " 🤖")
|
| 858 |
+
print(" ✅ 4 Policies Created (Adapted for OpenSpiel)!")
|
| 859 |
+
print("🤖 " + "="*64 + " 🤖\n")
|
| 860 |
+
|
| 861 |
+
policies = [RandomPolicy(), AlwaysStayPolicy(), SmartPolicy(), LearningPolicy()]
|
| 862 |
+
for i, policy in enumerate(policies, 1):
|
| 863 |
+
print(f" {i}. {policy.name}")
|
| 864 |
+
|
| 865 |
+
print("\n💡 These policies work with OpenSpielObservation!")
|
| 866 |
+
print(" • Read info_state (flattened grid)")
|
| 867 |
+
print(" • Use legal_actions")
|
| 868 |
+
print(" • Work with ANY OpenSpiel game that exposes these!\n")
|
| 869 |
+
```
|
| 870 |
+
|
| 871 |
+
**Output:**
|
| 872 |
+
```
|
| 873 |
+
🤖 ================================================================ 🤖
|
| 874 |
+
✅ 4 Policies Created (Adapted for OpenSpiel)!
|
| 875 |
+
🤖 ================================================================ 🤖
|
| 876 |
+
|
| 877 |
+
1. 🎲 Random Guesser
|
| 878 |
+
2. 🛑 Always Stay
|
| 879 |
+
3. 🧠 Smart Heuristic
|
| 880 |
+
4. 📈 Learning Agent
|
| 881 |
+
|
| 882 |
+
💡 These policies work with OpenSpielObservation!
|
| 883 |
+
• Read info_state (flattened grid)
|
| 884 |
+
• Use legal_actions
|
| 885 |
+
• Work with ANY OpenSpiel game that exposes these!
|
| 886 |
+
```
|
| 887 |
+
|
| 888 |
+
---
|
| 889 |
+
|
| 890 |
+
## Part 8: Policy Competition! 🏆
|
| 891 |
+
|
| 892 |
+
Let's run **50 episodes** for each policy against **REAL OpenSpiel** and see who wins!
|
| 893 |
+
|
| 894 |
+
This is production code - every action is an HTTP call to the OpenSpiel server!
|
| 895 |
+
|
| 896 |
+
```python
|
| 897 |
+
def evaluate_policies(env, num_episodes=50):
|
| 898 |
+
"""Compare all policies over many episodes using real OpenSpiel."""
|
| 899 |
+
policies = [
|
| 900 |
+
RandomPolicy(),
|
| 901 |
+
AlwaysStayPolicy(),
|
| 902 |
+
SmartPolicy(),
|
| 903 |
+
LearningPolicy(),
|
| 904 |
+
]
|
| 905 |
+
|
| 906 |
+
print("\n🏆 " + "="*66 + " 🏆")
|
| 907 |
+
print(f" POLICY SHOWDOWN - {num_episodes} Episodes Each")
|
| 908 |
+
print(f" Playing against REAL OpenSpiel Catch!")
|
| 909 |
+
print("🏆 " + "="*66 + " 🏆\n")
|
| 910 |
+
|
| 911 |
+
results = []
|
| 912 |
+
for policy in policies:
|
| 913 |
+
print(f"⚡ Testing {policy.name}...", end=" ")
|
| 914 |
+
successes = sum(run_episode(env, policy, visualize=False)
|
| 915 |
+
for _ in range(num_episodes))
|
| 916 |
+
success_rate = (successes / num_episodes) * 100
|
| 917 |
+
results.append((policy.name, success_rate, successes))
|
| 918 |
+
print(f"✓ Done!")
|
| 919 |
+
|
| 920 |
+
print("\n" + "="*70)
|
| 921 |
+
print(" 📊 FINAL RESULTS")
|
| 922 |
+
print("="*70 + "\n")
|
| 923 |
+
|
| 924 |
+
# Sort by success rate (descending)
|
| 925 |
+
results.sort(key=lambda x: x[1], reverse=True)
|
| 926 |
+
|
| 927 |
+
# Award medals to top 3
|
| 928 |
+
medals = ["🥇", "🥈", "🥉", " "]
|
| 929 |
+
|
| 930 |
+
for i, (name, rate, successes) in enumerate(results):
|
| 931 |
+
medal = medals[i]
|
| 932 |
+
bar = "█" * int(rate / 2)
|
| 933 |
+
print(f"{medal} {name:25s} [{bar:<50}] {rate:5.1f}% ({successes}/{num_episodes})")
|
| 934 |
+
|
| 935 |
+
print("\n" + "="*70)
|
| 936 |
+
print("\n✨ Key Insights:")
|
| 937 |
+
print(" • Random (~20%): Baseline - pure luck 🎲")
|
| 938 |
+
print(" • Always Stay (~20%): Bad strategy - stays center 🛑")
|
| 939 |
+
print(" • Smart (100%): Optimal - perfect play! 🧠")
|
| 940 |
+
print(" • Learning (~85%): Improves over time 📈")
|
| 941 |
+
print("\n🎓 This is Reinforcement Learning + OpenEnv in action:")
|
| 942 |
+
print(" 1. We USED existing OpenSpiel environment (didn't build it)")
|
| 943 |
+
print(" 2. Type-safe communication over HTTP")
|
| 944 |
+
print(" 3. Same code works for ANY OpenSpiel game")
|
| 945 |
+
print(" 4. Production-ready architecture\n")
|
| 946 |
+
|
| 947 |
+
# Run the epic competition!
|
| 948 |
+
print("🎮 Starting the showdown against REAL OpenSpiel...\n")
|
| 949 |
+
evaluate_policies(client, num_episodes=50)
|
| 950 |
+
```
|
| 951 |
+
|
| 952 |
+
---
|
| 953 |
+
|
| 954 |
+
## Part 9: Switching to Other Games 🎮
|
| 955 |
+
|
| 956 |
+
### What We Just Used: Real OpenSpiel! 🎉
|
| 957 |
+
|
| 958 |
+
In Parts 6-8, we **USED** the existing OpenSpiel Catch environment:
|
| 959 |
+
|
| 960 |
+
| What We Did | How It Works |
|
| 961 |
+
|-------------|--------------|
|
| 962 |
+
| **Imported** | OpenSpielEnv client (pre-built) |
|
| 963 |
+
| **Started** | OpenSpiel server via uvicorn |
|
| 964 |
+
| **Connected** | HTTP client to server |
|
| 965 |
+
| **Played** | Real OpenSpiel Catch game |
|
| 966 |
+
|
| 967 |
+
**🎯 This is production code!** Every action was an HTTP call to a real OpenSpiel environment.
|
| 968 |
+
|
| 969 |
+
### 🎮 6 Games Available - Same Interface!
|
| 970 |
+
|
| 971 |
+
The beauty of OpenEnv? **Same code, different games!**
|
| 972 |
+
|
| 973 |
+
```python
|
| 974 |
+
# We just used Catch
|
| 975 |
+
env = OpenSpielEnv(base_url="http://localhost:8000")
|
| 976 |
+
# game_name="catch" was set via environment variable
|
| 977 |
+
|
| 978 |
+
# Want Tic-Tac-Toe instead? Just change the game!
|
| 979 |
+
# Start server with: OPENSPIEL_GAME=tic_tac_toe uvicorn ...
|
| 980 |
+
# Same client code works!
|
| 981 |
+
```
|
| 982 |
+
|
| 983 |
+
**🎮 All 6 Games:**
|
| 984 |
+
|
| 985 |
+
1. ✅ **`catch`** - What we just used!
|
| 986 |
+
2. **`tic_tac_toe`** - Classic 3×3
|
| 987 |
+
3. **`kuhn_poker`** - Imperfect information poker
|
| 988 |
+
4. **`cliff_walking`** - Grid navigation
|
| 989 |
+
5. **`2048`** - Tile puzzle
|
| 990 |
+
6. **`blackjack`** - Card game
|
| 991 |
+
|
| 992 |
+
**All use the exact same OpenSpielEnv client!**
|
| 993 |
+
|
| 994 |
+
### Try Another Game (Optional):
|
| 995 |
+
|
| 996 |
+
```python
|
| 997 |
+
# Stop the current server (kill the server_process)
|
| 998 |
+
# Then start a new game:
|
| 999 |
+
|
| 1000 |
+
server_process = subprocess.Popen(
|
| 1001 |
+
[sys.executable, "-m", "uvicorn",
|
| 1002 |
+
"envs.openspiel_env.server.app:app",
|
| 1003 |
+
"--host", "0.0.0.0",
|
| 1004 |
+
"--port", "8000"],
|
| 1005 |
+
env={**os.environ,
|
| 1006 |
+
"PYTHONPATH": f"{work_dir}/src",
|
| 1007 |
+
"OPENSPIEL_GAME": "tic_tac_toe", # Changed!
|
| 1008 |
+
"OPENSPIEL_AGENT_PLAYER": "0",
|
| 1009 |
+
"OPENSPIEL_OPPONENT_POLICY": "random"},
|
| 1010 |
+
# ... rest of config
|
| 1011 |
+
)
|
| 1012 |
+
|
| 1013 |
+
# Same client works!
|
| 1014 |
+
client = OpenSpielEnv(base_url="http://localhost:8000")
|
| 1015 |
+
result = client.reset() # Now playing Tic-Tac-Toe!
|
| 1016 |
+
```
|
| 1017 |
+
|
| 1018 |
+
**💡 Key Insight**: You don't rebuild anything - you just USE different games with the same client!
|
| 1019 |
+
|
| 1020 |
+
---
|
| 1021 |
+
|
| 1022 |
+
## Part 10: Create Your Own Integration 🛠️
|
| 1023 |
+
|
| 1024 |
+
### The 5-Step Pattern
|
| 1025 |
+
|
| 1026 |
+
Want to wrap your own environment in OpenEnv? Here's how:
|
| 1027 |
+
|
| 1028 |
+
### Step 1: Define Types (`models.py`)
|
| 1029 |
+
|
| 1030 |
+
```python
|
| 1031 |
+
from dataclasses import dataclass
|
| 1032 |
+
from core.env_server import Action, Observation, State
|
| 1033 |
+
|
| 1034 |
+
@dataclass
|
| 1035 |
+
class YourAction(Action):
|
| 1036 |
+
action_value: int
|
| 1037 |
+
# Add your action fields
|
| 1038 |
+
|
| 1039 |
+
@dataclass
|
| 1040 |
+
class YourObservation(Observation):
|
| 1041 |
+
state_data: List[float]
|
| 1042 |
+
done: bool
|
| 1043 |
+
reward: float
|
| 1044 |
+
# Add your observation fields
|
| 1045 |
+
|
| 1046 |
+
@dataclass
|
| 1047 |
+
class YourState(State):
|
| 1048 |
+
episode_id: str
|
| 1049 |
+
step_count: int
|
| 1050 |
+
# Add your state fields
|
| 1051 |
+
```
|
| 1052 |
+
|
| 1053 |
+
### Step 2: Implement Environment (`server/environment.py`)
|
| 1054 |
+
|
| 1055 |
+
```python
|
| 1056 |
+
from core.env_server import Environment
|
| 1057 |
+
|
| 1058 |
+
class YourEnvironment(Environment):
|
| 1059 |
+
def reset(self) -> Observation:
|
| 1060 |
+
# Initialize your game/simulation
|
| 1061 |
+
return YourObservation(...)
|
| 1062 |
+
|
| 1063 |
+
def step(self, action: Action) -> Observation:
|
| 1064 |
+
# Execute action, update state
|
| 1065 |
+
return YourObservation(...)
|
| 1066 |
+
|
| 1067 |
+
@property
|
| 1068 |
+
def state(self) -> State:
|
| 1069 |
+
return self._state
|
| 1070 |
+
```
|
| 1071 |
+
|
| 1072 |
+
### Step 3: Create Client (`client.py`)
|
| 1073 |
+
|
| 1074 |
+
```python
|
| 1075 |
+
from core.http_env_client import HTTPEnvClient
|
| 1076 |
+
from core.types import StepResult
|
| 1077 |
+
|
| 1078 |
+
class YourEnv(HTTPEnvClient[YourAction, YourObservation]):
|
| 1079 |
+
def _step_payload(self, action: YourAction) -> dict:
|
| 1080 |
+
"""Convert action to JSON"""
|
| 1081 |
+
return {"action_value": action.action_value}
|
| 1082 |
+
|
| 1083 |
+
def _parse_result(self, payload: dict) -> StepResult:
|
| 1084 |
+
"""Parse JSON to observation"""
|
| 1085 |
+
return StepResult(
|
| 1086 |
+
observation=YourObservation(...),
|
| 1087 |
+
reward=payload['reward'],
|
| 1088 |
+
done=payload['done']
|
| 1089 |
+
)
|
| 1090 |
+
|
| 1091 |
+
def _parse_state(self, payload: dict) -> YourState:
|
| 1092 |
+
return YourState(...)
|
| 1093 |
+
```
|
| 1094 |
+
|
| 1095 |
+
### Step 4: Create Server (`server/app.py`)
|
| 1096 |
+
|
| 1097 |
+
```python
|
| 1098 |
+
from core.env_server import create_fastapi_app
|
| 1099 |
+
from .your_environment import YourEnvironment
|
| 1100 |
+
|
| 1101 |
+
env = YourEnvironment()
|
| 1102 |
+
app = create_fastapi_app(env)
|
| 1103 |
+
|
| 1104 |
+
# That's it! OpenEnv creates all endpoints for you.
|
| 1105 |
+
```
|
| 1106 |
+
|
| 1107 |
+
### Step 5: Dockerize (`server/Dockerfile`)
|
| 1108 |
+
|
| 1109 |
+
```dockerfile
|
| 1110 |
+
FROM python:3.11-slim
|
| 1111 |
+
|
| 1112 |
+
WORKDIR /app
|
| 1113 |
+
COPY requirements.txt .
|
| 1114 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 1115 |
+
|
| 1116 |
+
COPY . .
|
| 1117 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
|
| 1118 |
+
```
|
| 1119 |
+
|
| 1120 |
+
### 🎓 Examples to Study
|
| 1121 |
+
|
| 1122 |
+
OpenEnv includes 3 complete examples:
|
| 1123 |
+
|
| 1124 |
+
1. **`src/envs/echo_env/`**
|
| 1125 |
+
- Simplest possible environment
|
| 1126 |
+
- Great for testing and learning
|
| 1127 |
+
|
| 1128 |
+
2. **`src/envs/openspiel_env/`**
|
| 1129 |
+
- Wraps external library (OpenSpiel)
|
| 1130 |
+
- Shows integration pattern
|
| 1131 |
+
- 6 games in one integration
|
| 1132 |
+
|
| 1133 |
+
3. **`src/envs/coding_env/`**
|
| 1134 |
+
- Python code execution environment
|
| 1135 |
+
- Shows complex use case
|
| 1136 |
+
- Security considerations
|
| 1137 |
+
|
| 1138 |
+
**💡 Study these to understand the patterns!**
|
| 1139 |
+
|
| 1140 |
+
---
|
| 1141 |
+
|
| 1142 |
+
## 🎓 Summary: Your Journey
|
| 1143 |
+
|
| 1144 |
+
### What You Learned
|
| 1145 |
+
|
| 1146 |
+
<table>
|
| 1147 |
+
<tr>
|
| 1148 |
+
<td width="50%" style="vertical-align: top;">
|
| 1149 |
+
|
| 1150 |
+
### 📚 Concepts
|
| 1151 |
+
|
| 1152 |
+
✅ **RL Fundamentals**
|
| 1153 |
+
|
| 1154 |
+
- The observe-act-reward loop
|
| 1155 |
+
- What makes good policies
|
| 1156 |
+
- Exploration vs exploitation
|
| 1157 |
+
|
| 1158 |
+
✅ **OpenEnv Architecture**
|
| 1159 |
+
|
| 1160 |
+
- Client-server separation
|
| 1161 |
+
- Type-safe contracts
|
| 1162 |
+
- HTTP communication layer
|
| 1163 |
+
|
| 1164 |
+
✅ **Production Patterns**
|
| 1165 |
+
|
| 1166 |
+
- Docker isolation
|
| 1167 |
+
- API design
|
| 1168 |
+
- Reproducible deployments
|
| 1169 |
+
|
| 1170 |
+
</td>
|
| 1171 |
+
<td width="50%" style="vertical-align: top;">
|
| 1172 |
+
|
| 1173 |
+
### 🛠️ Skills
|
| 1174 |
+
|
| 1175 |
+
✅ **Using Environments**
|
| 1176 |
+
|
| 1177 |
+
- Import OpenEnv clients
|
| 1178 |
+
- Call reset/step/state
|
| 1179 |
+
- Work with typed observations
|
| 1180 |
+
|
| 1181 |
+
✅ **Building Environments**
|
| 1182 |
+
|
| 1183 |
+
- Define type-safe models
|
| 1184 |
+
- Implement Environment class
|
| 1185 |
+
- Create HTTPEnvClient
|
| 1186 |
+
|
| 1187 |
+
✅ **Testing & Debugging**
|
| 1188 |
+
|
| 1189 |
+
- Compare policies
|
| 1190 |
+
- Visualize episodes
|
| 1191 |
+
- Measure performance
|
| 1192 |
+
|
| 1193 |
+
</td>
|
| 1194 |
+
</tr>
|
| 1195 |
+
</table>
|
| 1196 |
+
|
| 1197 |
+
### OpenEnv vs Traditional RL
|
| 1198 |
+
|
| 1199 |
+
| Feature | Traditional (Gym) | OpenEnv | Winner |
|
| 1200 |
+
|---------|------------------|---------|--------|
|
| 1201 |
+
| **Type Safety** | ❌ Arrays, dicts | ✅ Dataclasses | 🏆 OpenEnv |
|
| 1202 |
+
| **Isolation** | ❌ Same process | ✅ Docker | 🏆 OpenEnv |
|
| 1203 |
+
| **Deployment** | ❌ Manual setup | ✅ K8s-ready | 🏆 OpenEnv |
|
| 1204 |
+
| **Language** | ❌ Python only | ✅ Any (HTTP) | 🏆 OpenEnv |
|
| 1205 |
+
| **Reproducibility** | ❌ "Works on my machine" | ✅ Same everywhere | 🏆 OpenEnv |
|
| 1206 |
+
| **Community** | ✅ Large ecosystem | 🟡 Growing | 🤝 Both! |
|
| 1207 |
+
|
| 1208 |
+
!!! success "The Bottom Line"
|
| 1209 |
+
OpenEnv brings **production engineering** to RL:
|
| 1210 |
+
|
| 1211 |
+
- Same environments work locally and in production
|
| 1212 |
+
- Type safety catches bugs early
|
| 1213 |
+
- Docker isolation prevents conflicts
|
| 1214 |
+
- HTTP API works with any language
|
| 1215 |
+
|
| 1216 |
+
**It's RL for 2024 and beyond.**
|
| 1217 |
+
|
| 1218 |
+
---
|
| 1219 |
+
|
| 1220 |
+
## 📚 Resources
|
| 1221 |
+
|
| 1222 |
+
### 🔗 Essential Links
|
| 1223 |
+
|
| 1224 |
+
- **🏠 OpenEnv GitHub**: https://github.com/meta-pytorch/OpenEnv
|
| 1225 |
+
- **🎮 OpenSpiel**: https://github.com/google-deepmind/open_spiel
|
| 1226 |
+
- **⚡ FastAPI Docs**: https://fastapi.tiangolo.com/
|
| 1227 |
+
- **🐳 Docker Guide**: https://docs.docker.com/get-started/
|
| 1228 |
+
- **🔥 PyTorch**: https://pytorch.org/
|
| 1229 |
+
|
| 1230 |
+
### 📖 Documentation Deep Dives
|
| 1231 |
+
|
| 1232 |
+
- **Environment Creation Guide**: `src/envs/README.md`
|
| 1233 |
+
- **OpenSpiel Integration**: `src/envs/openspiel_env/README.md`
|
| 1234 |
+
- **Example Scripts**: `examples/`
|
| 1235 |
+
- **RFC 001**: [Baseline API Specs](https://github.com/meta-pytorch/OpenEnv/pull/26)
|
| 1236 |
+
|
| 1237 |
+
### 🎓 Community & Support
|
| 1238 |
+
|
| 1239 |
+
**Supported by amazing organizations:**
|
| 1240 |
+
|
| 1241 |
+
- 🔥 Meta PyTorch
|
| 1242 |
+
- 🤗 Hugging Face
|
| 1243 |
+
- ⚡ Unsloth AI
|
| 1244 |
+
- 🌟 Reflection AI
|
| 1245 |
+
- 🚀 And many more!
|
| 1246 |
+
|
| 1247 |
+
**License**: BSD 3-Clause (very permissive!)
|
| 1248 |
+
|
| 1249 |
+
**Contributions**: Always welcome! Check out the issues tab.
|
| 1250 |
+
|
| 1251 |
+
---
|
| 1252 |
+
|
| 1253 |
+
### 🌈 What's Next?
|
| 1254 |
+
|
| 1255 |
+
1. ⭐ **Star the repo** to show support and stay updated
|
| 1256 |
+
2. 🔄 **Try modifying** the Catch game (make it harder? bigger grid?)
|
| 1257 |
+
3. 🎮 **Explore** other OpenSpiel games
|
| 1258 |
+
4. 🛠️ **Build** your own environment integration
|
| 1259 |
+
5. 💬 **Share** what you build with the community!
|
| 1260 |
+
|
tutorial/02-deployment.md
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 2. Deploying an OpenEnv environment
|
| 2 |
+
|
| 3 |
+
This section covers deploying OpenEnv environments locally, on clusters, and on Hugging Face Spaces.
|
| 4 |
+
|
| 5 |
+
**Contents:**
|
| 6 |
+
- [Local Development with Uvicorn](#local-development-with-uvicorn)
|
| 7 |
+
- [Docker Deployment](#docker-deployment)
|
| 8 |
+
- [Hugging Face Spaces](#hugging-face-spaces)
|
| 9 |
+
- [Best Practices](#best-practices)
|
| 10 |
+
|
| 11 |
+
## HF Spaces are the infrastructure for OpenEnv environments
|
| 12 |
+
|
| 13 |
+
Every HF Space provides three things that OpenEnv environments need:
|
| 14 |
+
|
| 15 |
+
| Component | What it provides | How to access | Used as |
|
| 16 |
+
|-----------|------------------|---------------|-----------|
|
| 17 |
+
| **Server** | Running environment endpoint | `https://<username>-<space-name>.hf.space` | Agent and Public API |
|
| 18 |
+
| **Repository** | Installable Python package | `pip install git+https://huggingface.co/spaces/<username>-<space-name>` | Code and client |
|
| 19 |
+
| **Registry** | Docker container image | `docker pull registry.hf.space/<username>-<space-name>:latest` | Deployment |
|
| 20 |
+
|
| 21 |
+
This means a single Space deployment gives you all the components you need to use an environment in training.
|
| 22 |
+
|
| 23 |
+
### 1. Server: A running environment endpoint
|
| 24 |
+
|
| 25 |
+
When you deploy to HF Spaces, your environment runs as a server. The client connects via **WebSocket** (`/ws`) for a persistent session:
|
| 26 |
+
|
| 27 |
+
```python
|
| 28 |
+
from echo_env import EchoEnv, EchoAction
|
| 29 |
+
|
| 30 |
+
# Connect directly to the running Space (WebSocket under the hood)
|
| 31 |
+
# Async (recommended):
|
| 32 |
+
async with EchoEnv(base_url="https://openenv-echo-env.hf.space") as client:
|
| 33 |
+
result = await client.reset()
|
| 34 |
+
result = await client.step(EchoAction(message="Hello"))
|
| 35 |
+
|
| 36 |
+
# Sync (using .sync() wrapper):
|
| 37 |
+
with EchoEnv(base_url="https://openenv-echo-env.hf.space").sync() as client:
|
| 38 |
+
result = client.reset()
|
| 39 |
+
result = client.step(EchoAction(message="Hello"))
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
**Endpoints available:**
|
| 43 |
+
|
| 44 |
+
| Endpoint | Protocol | Description |
|
| 45 |
+
|----------|----------|-------------|
|
| 46 |
+
| `/ws` | **WebSocket** | Persistent session (used by client) |
|
| 47 |
+
| `/health` | HTTP GET | Health check |
|
| 48 |
+
| `/reset` | HTTP POST | Reset environment (stateless) |
|
| 49 |
+
| `/step` | HTTP POST | Execute action (stateless) |
|
| 50 |
+
| `/state` | HTTP GET | Get current state |
|
| 51 |
+
| `/docs` | HTTP GET | OpenAPI documentation |
|
| 52 |
+
| `/web` | HTTP GET | Interactive web UI |
|
| 53 |
+
|
| 54 |
+
> **Note:** The Python client uses the `/ws` WebSocket endpoint by default. HTTP endpoints are available for debugging or stateless use cases.
|
| 55 |
+
|
| 56 |
+
**Example: Check if a Space is running**
|
| 57 |
+
|
| 58 |
+
```bash
|
| 59 |
+
curl https://openenv-echo-env.hf.space/health
|
| 60 |
+
# {"status": "healthy"}
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
### 2. Repository: Installable Python package
|
| 64 |
+
|
| 65 |
+
Every Space is a Git repository. OpenEnv environments include a `pyproject.toml`, making them pip-installable directly from the Space URL.
|
| 66 |
+
|
| 67 |
+
```bash
|
| 68 |
+
# Install client package from Space
|
| 69 |
+
pip install git+https://huggingface.co/spaces/openenv/echo-env
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
This installs:
|
| 73 |
+
- **Client class** (`EchoEnv`) — Handles HTTP/WebSocket communication
|
| 74 |
+
- **Models** (`EchoAction`, `EchoObservation`) — Typed action and observation classes
|
| 75 |
+
- **Utilities** — Any helper functions the environment provides
|
| 76 |
+
|
| 77 |
+
**After installation:**
|
| 78 |
+
|
| 79 |
+
```python
|
| 80 |
+
from envs.echo_env import EchoEnv, EchoAction, EchoObservation
|
| 81 |
+
|
| 82 |
+
# Now you have typed classes for the environment
|
| 83 |
+
action = EchoAction(message="Hello")
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
### 3. Registry: Docker container image
|
| 87 |
+
|
| 88 |
+
Every Docker-based Space has a container registry. You can pull and run the environment locally.
|
| 89 |
+
|
| 90 |
+
```bash
|
| 91 |
+
# Pull the image
|
| 92 |
+
docker pull registry.hf.space/openenv-echo-env:latest
|
| 93 |
+
|
| 94 |
+
# Run locally on port 8001
|
| 95 |
+
docker run -d -p 8001:8000 registry.hf.space/openenv-echo-env:latest
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
**Find the registry URL for any Space:**
|
| 99 |
+
|
| 100 |
+
1. Go to the Space page (e.g., [openenv/echo-env](https://huggingface.co/spaces/openenv/echo-env))
|
| 101 |
+
2. Click **⋮** (three dots) → **"Run locally"**
|
| 102 |
+
3. Copy the `docker run` command
|
| 103 |
+
|
| 104 |
+
### Choosing an access method
|
| 105 |
+
|
| 106 |
+
| Method | Use when | Pros | Cons |
|
| 107 |
+
|--------|----------|------|------|
|
| 108 |
+
| **Server** | Quick testing, low volume | Zero setup | Network latency, rate limits |
|
| 109 |
+
| **Repository** | Need typed classes | Type safety, IDE support | Still need a server |
|
| 110 |
+
| **Docker** | Local dev, high throughput | Full control, no network | Requires Docker |
|
| 111 |
+
|
| 112 |
+
**Typical workflow:**
|
| 113 |
+
|
| 114 |
+
```python
|
| 115 |
+
import asyncio
|
| 116 |
+
from echo_env import EchoEnv, EchoAction
|
| 117 |
+
|
| 118 |
+
async def main():
|
| 119 |
+
# Development: connect to remote Space
|
| 120 |
+
async with EchoEnv(base_url="https://openenv-echo-env.hf.space") as client:
|
| 121 |
+
result = await client.reset()
|
| 122 |
+
|
| 123 |
+
# Production: run locally for speed
|
| 124 |
+
# docker run -d -p 8001:8000 registry.hf.space/openenv-echo-env:latest
|
| 125 |
+
async with EchoEnv(base_url="http://localhost:8001") as client:
|
| 126 |
+
result = await client.reset()
|
| 127 |
+
|
| 128 |
+
# Or let the client manage Docker for you
|
| 129 |
+
client = await EchoEnv.from_env("openenv/echo-env") # Auto-pulls and runs
|
| 130 |
+
async with client:
|
| 131 |
+
result = await client.reset()
|
| 132 |
+
|
| 133 |
+
asyncio.run(main())
|
| 134 |
+
|
| 135 |
+
# For sync usage, use the .sync() wrapper:
|
| 136 |
+
with EchoEnv(base_url="http://localhost:8001").sync() as client:
|
| 137 |
+
result = client.reset()
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
> **Reference:** [HF Spaces Documentation](https://huggingface.co/docs/hub/spaces) | [Environment Hub Collection](https://huggingface.co/collections/openenv/environment-hub)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
## Local Development with Uvicorn
|
| 144 |
+
|
| 145 |
+
The fastest way to iterate on environment logic is running directly with Uvicorn.
|
| 146 |
+
|
| 147 |
+
## Clone and run the environment locally
|
| 148 |
+
|
| 149 |
+
```bash
|
| 150 |
+
# Clone from HF Space
|
| 151 |
+
git clone https://huggingface.co/spaces/burtenshaw/openenv-benchmark
|
| 152 |
+
cd openenv-benchmark
|
| 153 |
+
|
| 154 |
+
# Install in editable mode
|
| 155 |
+
uv sync
|
| 156 |
+
|
| 157 |
+
# Start server
|
| 158 |
+
uv run server
|
| 159 |
+
|
| 160 |
+
# Run isolated from remote Space
|
| 161 |
+
uv run --isolated --project https://huggingface.co/spaces/burtenshaw/openenv-benchmark server
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
## Uvicorn directly in python
|
| 165 |
+
|
| 166 |
+
```bash
|
| 167 |
+
# Full control over uvicorn options
|
| 168 |
+
uvicorn benchmark.server.app:app --host "$HOST" --port "$PORT" --workers "$WORKERS"
|
| 169 |
+
|
| 170 |
+
# With reload for development
|
| 171 |
+
uvicorn benchmark.server.app:app --host 0.0.0.0 --port 8000 --reload
|
| 172 |
+
|
| 173 |
+
# Multi-Worker Mode For better concurrency:
|
| 174 |
+
uvicorn benchmark.server.app:app --host 0.0.0.0 --port 8000 --workers 4
|
| 175 |
+
```
|
| 176 |
+
|
| 177 |
+
| Flag | Purpose |
|
| 178 |
+
|------|---------|
|
| 179 |
+
| `--reload` | Auto-restart on code changes |
|
| 180 |
+
| `--workers N` | Run N worker processes |
|
| 181 |
+
| `--log-level debug` | Verbose logging |
|
| 182 |
+
|
| 183 |
+
## Docker Deployment
|
| 184 |
+
|
| 185 |
+
Docker provides isolation and reproducibility for production use.
|
| 186 |
+
|
| 187 |
+
### Run the environment locally from the space
|
| 188 |
+
|
| 189 |
+
```bash
|
| 190 |
+
# Run the environment locally from the space
|
| 191 |
+
docker run -d -p 8000:8000 registry.hf.space/openenv-echo-env:latest
|
| 192 |
+
```
|
| 193 |
+
|
| 194 |
+
### Build Image
|
| 195 |
+
|
| 196 |
+
```bash
|
| 197 |
+
# Clone from HF Space
|
| 198 |
+
git clone https://huggingface.co/spaces/burtenshaw/openenv-benchmark
|
| 199 |
+
cd openenv-benchmark
|
| 200 |
+
|
| 201 |
+
# Using OpenEnv CLI (recommended)
|
| 202 |
+
openenv build -t openenv-benchmark:latest
|
| 203 |
+
|
| 204 |
+
# Or with Docker directly
|
| 205 |
+
docker build -t openenv-benchmark:latest -f server/Dockerfile .
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
### Run Container
|
| 209 |
+
|
| 210 |
+
```bash
|
| 211 |
+
# Basic run
|
| 212 |
+
docker run -d -p 8000:8000 my-env:latest
|
| 213 |
+
|
| 214 |
+
# With environment variables
|
| 215 |
+
docker run -d -p 8000:8000 \
|
| 216 |
+
-e WORKERS=4 \
|
| 217 |
+
-e MAX_CONCURRENT_ENVS=100 \
|
| 218 |
+
my-env:latest
|
| 219 |
+
|
| 220 |
+
# Named container for easy management
|
| 221 |
+
docker run -d --name my-env -p 8000:8000 my-env:latest
|
| 222 |
+
```
|
| 223 |
+
|
| 224 |
+
### Connect from Python
|
| 225 |
+
|
| 226 |
+
```python
|
| 227 |
+
import asyncio
|
| 228 |
+
from echo_env import EchoEnv, EchoAction
|
| 229 |
+
|
| 230 |
+
async def main():
|
| 231 |
+
# Async usage (recommended)
|
| 232 |
+
async with EchoEnv(base_url="http://localhost:8000") as client:
|
| 233 |
+
result = await client.reset()
|
| 234 |
+
result = await client.step(EchoAction(message="Hello"))
|
| 235 |
+
print(result.observation)
|
| 236 |
+
|
| 237 |
+
# From Docker image
|
| 238 |
+
client = await EchoEnv.from_docker_image("<local_docker_image>")
|
| 239 |
+
async with client:
|
| 240 |
+
result = await client.reset()
|
| 241 |
+
print(result.observation)
|
| 242 |
+
|
| 243 |
+
asyncio.run(main())
|
| 244 |
+
|
| 245 |
+
# Sync usage (using .sync() wrapper)
|
| 246 |
+
with EchoEnv(base_url="http://localhost:8000").sync() as client:
|
| 247 |
+
result = client.reset()
|
| 248 |
+
result = client.step(EchoAction(message="Hello"))
|
| 249 |
+
print(result.observation)
|
| 250 |
+
```
|
| 251 |
+
|
| 252 |
+
### Container Lifecycle
|
| 253 |
+
|
| 254 |
+
| Method | Container | WebSocket | On `close()` |
|
| 255 |
+
|--------|-----------|-----------|--------------|
|
| 256 |
+
| `from_hub(repo_id)` | Starts | Connects | Stops container |
|
| 257 |
+
| `from_hub(repo_id, use_docker=False)` | None (UV) | Connects | Stops UV server |
|
| 258 |
+
| `from_docker_image(image)` | Starts | Connects | Stops container |
|
| 259 |
+
| `MyEnv(base_url=...)` | None | Connects | Disconnects only |
|
| 260 |
+
|
| 261 |
+
Find Docker Commands for Any Space
|
| 262 |
+
|
| 263 |
+
1. Open the Space on HuggingFace Hub
|
| 264 |
+
2. Click **⋮ (three dots)** menu
|
| 265 |
+
3. Select **"Run locally"**
|
| 266 |
+
4. Copy the provided `docker run` command
|
| 267 |
+
|
| 268 |
+
## Deploy with CLI
|
| 269 |
+
|
| 270 |
+
```bash
|
| 271 |
+
cd my_env
|
| 272 |
+
|
| 273 |
+
# Deploy to your namespace
|
| 274 |
+
openenv push
|
| 275 |
+
|
| 276 |
+
# Deploy to specific repo
|
| 277 |
+
openenv push --repo-id username/my-env
|
| 278 |
+
|
| 279 |
+
# Deploy as private
|
| 280 |
+
openenv push --repo-id username/my-env --private
|
| 281 |
+
```
|
| 282 |
+
|
| 283 |
+
### Space Configuration
|
| 284 |
+
|
| 285 |
+
The `openenv.yaml` manifest controls Space settings:
|
| 286 |
+
|
| 287 |
+
```yaml
|
| 288 |
+
# openenv.yaml
|
| 289 |
+
name: my_env
|
| 290 |
+
version: "1.0.0"
|
| 291 |
+
description: My custom environment
|
| 292 |
+
```
|
| 293 |
+
|
| 294 |
+
Hardware Options:
|
| 295 |
+
|
| 296 |
+
| Tier | vCPU | RAM | Cost |
|
| 297 |
+
|------|------|-----|------|
|
| 298 |
+
| CPU Basic (Free) | 2 | 16GB | Free |
|
| 299 |
+
| CPU Upgrade | 8 | 32GB | $0.03/hr |
|
| 300 |
+
|
| 301 |
+
OpenEnv environments support configuration via environment variables.
|
| 302 |
+
|
| 303 |
+
| Variable | Default | Description |
|
| 304 |
+
|----------|---------|-------------|
|
| 305 |
+
| `WORKERS` | 4 | Uvicorn worker processes |
|
| 306 |
+
| `PORT` | 8000 | Server port |
|
| 307 |
+
| `HOST` | 0.0.0.0 | Bind address |
|
| 308 |
+
| `MAX_CONCURRENT_ENVS` | 100 | Max WebSocket sessions |
|
| 309 |
+
| `ENABLE_WEB_INTERFACE` | Auto | Enable web UI |
|
| 310 |
+
|
| 311 |
+
### Environment-Specific Variables
|
| 312 |
+
|
| 313 |
+
Some environments have custom variables:
|
| 314 |
+
|
| 315 |
+
**TextArena:**
|
| 316 |
+
```bash
|
| 317 |
+
TEXTARENA_ENV_ID=Wordle-v0
|
| 318 |
+
TEXTARENA_NUM_PLAYERS=1
|
| 319 |
+
TEXTARENA_MAX_TURNS=6
|
| 320 |
+
```
|
| 321 |
+
|
| 322 |
+
**Coding Environment:**
|
| 323 |
+
```bash
|
| 324 |
+
SANDBOX_TIMEOUT=30
|
| 325 |
+
MAX_OUTPUT_LENGTH=10000
|
| 326 |
+
```
|
| 327 |
+
|
| 328 |
+
# DEMO: Deploying to Hugging Face Spaces
|
| 329 |
+
|
| 330 |
+
This demo walks through the full workflow: create an environment, test locally, deploy to HF Spaces, and use it.
|
| 331 |
+
|
| 332 |
+
## Step 1: Initialize a new environment
|
| 333 |
+
|
| 334 |
+
```bash
|
| 335 |
+
openenv init my_env
|
| 336 |
+
cd my_env
|
| 337 |
+
```
|
| 338 |
+
|
| 339 |
+
This creates the standard OpenEnv structure:
|
| 340 |
+
|
| 341 |
+
```
|
| 342 |
+
my_env/
|
| 343 |
+
├── server/
|
| 344 |
+
│ ├── app.py # FastAPI server
|
| 345 |
+
│ ├── environment.py # Your environment logic
|
| 346 |
+
│ └── Dockerfile
|
| 347 |
+
├── models.py # Action/Observation types
|
| 348 |
+
├── client.py # HTTP client
|
| 349 |
+
├── openenv.yaml # Manifest
|
| 350 |
+
└── pyproject.toml
|
| 351 |
+
```
|
| 352 |
+
|
| 353 |
+
## Step 2: Run locally
|
| 354 |
+
|
| 355 |
+
```bash
|
| 356 |
+
# Start the server
|
| 357 |
+
uv run server
|
| 358 |
+
|
| 359 |
+
# Or with uvicorn directly
|
| 360 |
+
uvicorn server.app:app --host 0.0.0.0 --port 8000 --reload
|
| 361 |
+
```
|
| 362 |
+
|
| 363 |
+
Test the health endpoint:
|
| 364 |
+
|
| 365 |
+
```bash
|
| 366 |
+
curl http://localhost:8000/health
|
| 367 |
+
# {"status": "healthy"}
|
| 368 |
+
```
|
| 369 |
+
|
| 370 |
+
## Step 3: Deploy to HF Spaces
|
| 371 |
+
|
| 372 |
+
```bash
|
| 373 |
+
openenv push --repo-id username/my-env
|
| 374 |
+
```
|
| 375 |
+
|
| 376 |
+
Your environment is now live at:
|
| 377 |
+
- Web UI: https://username-my-env.hf.space/web
|
| 378 |
+
- API Docs: https://username-my-env.hf.space/docs
|
| 379 |
+
- Health: https://username-my-env.hf.space/health
|
| 380 |
+
|
| 381 |
+
```bash
|
| 382 |
+
curl https://openenv-echo-env.hf.space/health
|
| 383 |
+
# {"status": "healthy"}
|
| 384 |
+
```
|
| 385 |
+
|
| 386 |
+
## Step 4: install the environment
|
| 387 |
+
|
| 388 |
+
```bash
|
| 389 |
+
uv pip install git+https://huggingface.co/spaces/openenv/echo_env
|
| 390 |
+
```
|
| 391 |
+
|
| 392 |
+
## Step 5: Run locally via Docker (optional)
|
| 393 |
+
|
| 394 |
+
Pull and run the container from the HF registry, or open the [browser](https://huggingface.co/spaces/openenv/echo_env?docker=true):
|
| 395 |
+
|
| 396 |
+
```bash
|
| 397 |
+
# Pull from HF Spaces registry
|
| 398 |
+
docker pull registry.hf.space/openenv-echo-env:latest
|
| 399 |
+
|
| 400 |
+
# Run locally
|
| 401 |
+
docker run -it -p 7860:7860 --platform=linux/amd64 \
|
| 402 |
+
registry.hf.space/openenv-echo-env:latest
|
| 403 |
+
```
|
| 404 |
+
|
| 405 |
+
Now connect to your local instance:
|
| 406 |
+
|
| 407 |
+
```python
|
| 408 |
+
import asyncio
|
| 409 |
+
from echo_env import EchoEnv, EchoAction
|
| 410 |
+
|
| 411 |
+
# Async (recommended)
|
| 412 |
+
async def main():
|
| 413 |
+
async with EchoEnv(base_url="http://localhost:8000") as env:
|
| 414 |
+
result = await env.reset()
|
| 415 |
+
print(result.observation)
|
| 416 |
+
result = await env.step(EchoAction(message="Hello"))
|
| 417 |
+
print(result.observation)
|
| 418 |
+
|
| 419 |
+
asyncio.run(main())
|
| 420 |
+
|
| 421 |
+
# Sync (using .sync() wrapper)
|
| 422 |
+
with EchoEnv(base_url="http://localhost:8000").sync() as env:
|
| 423 |
+
result = env.reset()
|
| 424 |
+
print(result.observation)
|
| 425 |
+
result = env.step(EchoAction(message="Hello"))
|
| 426 |
+
print(result.observation)
|
| 427 |
+
```
|
tutorial/03-scaling.md
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 3. How OpenEnv environments scale
|
| 2 |
+
|
| 3 |
+
This section covers benchmarking and scaling OpenEnv environments.
|
| 4 |
+
|
| 5 |
+
**Contents:**
|
| 6 |
+
- [Provider Scaling](#provider-scaling)
|
| 7 |
+
- [WebSocket-based Scaling](#websocket-based-scaling)
|
| 8 |
+
- [Microservice Scaling](#microservice-scaling)
|
| 9 |
+
- [Scaling Experiments](#scaling-experiments)
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## Provider Scaling
|
| 14 |
+
|
| 15 |
+
The easiest way to scale an OpenEnv environment is to use a `provider` these are abstractions based on runtimes like Uvicorn, Docker Swarm, or Kubernetes.
|
| 16 |
+
|
| 17 |
+
```python
|
| 18 |
+
from openenv.providers import UVProvider, DockerSwarmProvider, LocalDockerProvider
|
| 19 |
+
|
| 20 |
+
docker_provider = LocalDockerProvider() # default
|
| 21 |
+
uvicorn_provider = UVProvider() # python only
|
| 22 |
+
swarm_provider = DockerSwarmProvider()
|
| 23 |
+
|
| 24 |
+
with EchoEnv.from_hub(
|
| 25 |
+
repo_id="openenv/echo-env",
|
| 26 |
+
provider=swarm_provider,
|
| 27 |
+
replicas=4,
|
| 28 |
+
) as env:
|
| 29 |
+
result = env.reset()
|
| 30 |
+
result = env.step(EchoAction(message="Hello"))
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
## WebSocket-based Scaling
|
| 34 |
+
|
| 35 |
+
OpenEnv uses WebSocket connections (`/ws`) instead of stateless HTTP for environment interactions. This design enables efficient scaling within a single container.
|
| 36 |
+
|
| 37 |
+
### What are WebSockets?
|
| 38 |
+
|
| 39 |
+
WebSocket is a communication protocol that provides a persistent, bidirectional connection between client and server. Unlike HTTP—where each request opens a new connection, sends data, receives a response, and closes—a WebSocket connection stays open for the duration of a session.
|
| 40 |
+
|
| 41 |
+

|
| 42 |
+
|
| 43 |
+
For RL environments, this matters because a typical episode involves dozens to thousands of sequential `step()` calls. With HTTP, each step incurs TCP handshake overhead (~10-50ms). With WebSocket, messages are sent as lightweight frames (~0.1ms overhead) over the existing connection.
|
| 44 |
+
|
| 45 |
+
Also, with HTTP, long running sessions require logic to manage session state, which is not necessary with WebSocket.
|
| 46 |
+
|
| 47 |
+
### Multiple sessions per container
|
| 48 |
+
|
| 49 |
+
With HTTP, maintaining session state requires cookies or session IDs with every request. Each isolated environment instance typically needs its own container:
|
| 50 |
+
|
| 51 |
+
```
|
| 52 |
+
HTTP approach: N parallel episodes → N containers
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
> [!NOTE]
|
| 56 |
+
> This is completely fine (and ideal) for larger deployments where containers can be scaled. But if your resources are constrained, this add loads of overhead.
|
| 57 |
+
|
| 58 |
+
With WebSocket, **one container handles many isolated sessions**. Each WebSocket connection gets its own environment instance server-side:
|
| 59 |
+
|
| 60 |
+
```python
|
| 61 |
+
# Single container serving multiple concurrent sessions
|
| 62 |
+
# docker run -d -p 8000:8000 my-env:latest
|
| 63 |
+
|
| 64 |
+
# Each client gets an isolated environment instance
|
| 65 |
+
with MyEnv(base_url="http://localhost:8000") as env1: # Session 1
|
| 66 |
+
result = env1.reset()
|
| 67 |
+
|
| 68 |
+
with MyEnv(base_url="http://localhost:8000") as env2: # Session 2
|
| 69 |
+
result = env2.reset()
|
| 70 |
+
|
| 71 |
+
with MyEnv(base_url="http://localhost:8000") as env3: # Session 3
|
| 72 |
+
result = env3.reset()
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
> [!NOTE]
|
| 76 |
+
> This has its own advantages and disadvantages. For example: Separation of concerns and fault tolerance in environments like coding or terminal.
|
| 77 |
+
|
| 78 |
+
### Server-side session state
|
| 79 |
+
|
| 80 |
+
The server maintains environment state per WebSocket connection which means that the environment builder does not need to worry about session state.
|
| 81 |
+
|
| 82 |
+
- No session IDs because Connection itself is the session
|
| 83 |
+
- Automatic cleanup because Environment instance destroyed when connection closes
|
| 84 |
+
- Isolation guaranteed because Each connection has dedicated state
|
| 85 |
+
|
| 86 |
+
```python
|
| 87 |
+
# Server creates new environment instance per WebSocket connection
|
| 88 |
+
@app.websocket("/ws")
|
| 89 |
+
async def websocket_endpoint(websocket: WebSocket):
|
| 90 |
+
env = MyEnvironment() # Fresh instance per connection
|
| 91 |
+
await websocket.accept()
|
| 92 |
+
|
| 93 |
+
while True:
|
| 94 |
+
data = await websocket.receive_json()
|
| 95 |
+
if data["type"] == "reset":
|
| 96 |
+
result = env.reset()
|
| 97 |
+
elif data["type"] == "step":
|
| 98 |
+
result = env.step(data["action"])
|
| 99 |
+
await websocket.send_json(result)
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
### Resource efficiency
|
| 103 |
+
|
| 104 |
+
| Approach | Containers | Memory | Startup | Max parallel |
|
| 105 |
+
|----------|------------|--------|---------|--------------|
|
| 106 |
+
| HTTP (1 env = 1 container) | N | N × ~100MB | N × ~5s | Limited by containers |
|
| 107 |
+
| WebSocket (N sessions = 1 container) | 1 | ~200MB | ~5s | Limited by `MAX_CONCURRENT_ENVS` |
|
| 108 |
+
|
| 109 |
+
Configure session limits via environment variable:
|
| 110 |
+
|
| 111 |
+
```bash
|
| 112 |
+
docker run -d -p 8000:8000 -e MAX_CONCURRENT_ENVS=100 registry.hf.space/openenv-echo-env:latest
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
## Scaling a Single Container
|
| 116 |
+
|
| 117 |
+
Before adding more containers, maximize the capacity of a single deployment. The key parameters are **workers** (CPU parallelism) and **MAX_CONCURRENT_ENVS** (session limit).
|
| 118 |
+
|
| 119 |
+
### Uvicorn workers
|
| 120 |
+
|
| 121 |
+
Each Uvicorn worker is a separate process that can handle requests independently. More workers = more CPU cores utilized.
|
| 122 |
+
|
| 123 |
+
```bash
|
| 124 |
+
# Clone and run locally
|
| 125 |
+
git clone https://huggingface.co/spaces/burtenshaw/openenv-benchmark
|
| 126 |
+
cd openenv-benchmark
|
| 127 |
+
pip install -e .
|
| 128 |
+
|
| 129 |
+
# Run with 8 workers
|
| 130 |
+
WORKERS=8 uvicorn benchmark.server.app:app --host 0.0.0.0 --port 8000 --workers 8
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
The above example will use 8 workers and each worker will be able to handle 100 concurrent sessions. **For simple environments, like text games, it's possible to get to 2000 concurrent sessions with 8 workers.**
|
| 134 |
+
|
| 135 |
+
> **Note:** More workers consume more memory. Each worker loads a full copy of the environment code.
|
| 136 |
+
|
| 137 |
+
### Docker with environment variables
|
| 138 |
+
|
| 139 |
+
Pass scaling parameters when starting the container:
|
| 140 |
+
|
| 141 |
+
```bash
|
| 142 |
+
# Pull from HF Spaces registry
|
| 143 |
+
docker pull registry.hf.space/burtenshaw-openenv-benchmark:latest
|
| 144 |
+
|
| 145 |
+
# Run with custom configuration
|
| 146 |
+
docker run -d -p 8000:8000 \
|
| 147 |
+
-e WORKERS=8 \
|
| 148 |
+
-e MAX_CONCURRENT_ENVS=400 \
|
| 149 |
+
--name openenv-benchmark \
|
| 150 |
+
registry.hf.space/burtenshaw-openenv-benchmark:latest
|
| 151 |
+
```
|
| 152 |
+
|
| 153 |
+
| Variable | Default | Description |
|
| 154 |
+
|----------|---------|-------------|
|
| 155 |
+
| `WORKERS` | 4 | Uvicorn worker processes |
|
| 156 |
+
| `MAX_CONCURRENT_ENVS` | 100 | Max WebSocket sessions per worker |
|
| 157 |
+
| `PORT` | 8000 | Server port |
|
| 158 |
+
| `HOST` | 0.0.0.0 | Bind address |
|
| 159 |
+
|
| 160 |
+
### HF Spaces configuration
|
| 161 |
+
|
| 162 |
+
Now, let's deploy the environment to HF Spaces so that we can interact with the server from the client. Configure scaling via Space Settings > Variables:
|
| 163 |
+
|
| 164 |
+
1. Go to your Space settings page
|
| 165 |
+
2. Add environment variables:
|
| 166 |
+
- `WORKERS=4` (max 4 on free tier, 8 on CPU Upgrade)
|
| 167 |
+
- `MAX_CONCURRENT_ENVS=100`
|
| 168 |
+
3. Restart the Space
|
| 169 |
+
|
| 170 |
+
| Tier | vCPU | Recommended workers | Expected max batch (textarena) |
|
| 171 |
+
|------|------|--------------------|--------------------|
|
| 172 |
+
| CPU Basic (Free) | 2 | 2 | ~128 |
|
| 173 |
+
| CPU Upgrade | 8 | 4-8 | ~512 |
|
| 174 |
+
|
| 175 |
+
> **Limitation:** HF Spaces free users tier caps at ~128 concurrent sessions regardless of configuration. See [Scaling Experiments](#scaling-experiments) for measured limits.
|
| 176 |
+
|
| 177 |
+
### Scaling limits
|
| 178 |
+
|
| 179 |
+
The experiments below found that even on larger instances, a single container eventually fails to scale and we need multiple containers to handle the load. For example, on a CPU Upgrade instance with 8 workers, the max batch was 1024 concurrent sessions:
|
| 180 |
+
|
| 181 |
+
- Success rate drops to 92%
|
| 182 |
+
- P99 latency exceeds 2× the expected step time
|
| 183 |
+
- Connection errors increase under load
|
| 184 |
+
|
| 185 |
+
When this happens, we need to scale to multiple containers and use a load balancer.
|
| 186 |
+
|
| 187 |
+
For high-throughput workloads, scale horizontally by running multiple environment containers behind a load balancer.
|
| 188 |
+
|
| 189 |
+
| Scenario | Recommended approach |
|
| 190 |
+
|----------|---------------------|
|
| 191 |
+
| Development / testing | Single container with WebSocket sessions |
|
| 192 |
+
| Moderate load (< 100 concurrent) | Single container, increase `MAX_CONCURRENT_ENVS` |
|
| 193 |
+
| High load (100+ concurrent) | Multiple containers + load balancer |
|
| 194 |
+
| GPU environments | One container per GPU |
|
| 195 |
+
|
| 196 |
+
We explored this in detail in the [Scaling Experiments](https://github.com/burtenshaw/openenv-scaling) repository.
|
| 197 |
+
|
| 198 |
+
<details>
|
| 199 |
+
<summary>Envoy configuration</summary>
|
| 200 |
+
|
| 201 |
+
```yaml
|
| 202 |
+
static_resources:
|
| 203 |
+
listeners:
|
| 204 |
+
- name: listener_0
|
| 205 |
+
address:
|
| 206 |
+
socket_address:
|
| 207 |
+
address: 0.0.0.0
|
| 208 |
+
port_value: 8080
|
| 209 |
+
filter_chains:
|
| 210 |
+
- filters:
|
| 211 |
+
- name: envoy.filters.network.http_connection_manager
|
| 212 |
+
typed_config:
|
| 213 |
+
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
|
| 214 |
+
stat_prefix: ingress_http
|
| 215 |
+
upgrade_configs:
|
| 216 |
+
- upgrade_type: websocket
|
| 217 |
+
route_config:
|
| 218 |
+
name: local_route
|
| 219 |
+
virtual_hosts:
|
| 220 |
+
- name: openenv_service
|
| 221 |
+
domains: ["*"]
|
| 222 |
+
routes:
|
| 223 |
+
- match:
|
| 224 |
+
prefix: "/"
|
| 225 |
+
route:
|
| 226 |
+
cluster: openenv_cluster
|
| 227 |
+
http_filters:
|
| 228 |
+
- name: envoy.filters.http.router
|
| 229 |
+
typed_config:
|
| 230 |
+
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
|
| 231 |
+
|
| 232 |
+
clusters:
|
| 233 |
+
- name: openenv_cluster
|
| 234 |
+
connect_timeout: 30s
|
| 235 |
+
type: STRICT_DNS
|
| 236 |
+
lb_policy: ROUND_ROBIN
|
| 237 |
+
load_assignment:
|
| 238 |
+
cluster_name: openenv_cluster
|
| 239 |
+
endpoints:
|
| 240 |
+
- lb_endpoints:
|
| 241 |
+
- endpoint:
|
| 242 |
+
address:
|
| 243 |
+
socket_address:
|
| 244 |
+
address: host.docker.internal
|
| 245 |
+
port_value: 8001
|
| 246 |
+
- endpoint:
|
| 247 |
+
address:
|
| 248 |
+
socket_address:
|
| 249 |
+
address: host.docker.internal
|
| 250 |
+
port_value: 8002
|
| 251 |
+
- endpoint:
|
| 252 |
+
address:
|
| 253 |
+
socket_address:
|
| 254 |
+
address: host.docker.internal
|
| 255 |
+
port_value: 8003
|
| 256 |
+
- endpoint:
|
| 257 |
+
address:
|
| 258 |
+
socket_address:
|
| 259 |
+
address: host.docker.internal
|
| 260 |
+
port_value: 8004
|
| 261 |
+
```
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
Start Envoy:
|
| 265 |
+
|
| 266 |
+
```bash
|
| 267 |
+
docker run -d \
|
| 268 |
+
-p 8080:8080 \
|
| 269 |
+
-v $(pwd)/envoy.yaml:/etc/envoy/envoy.yaml \
|
| 270 |
+
--add-host=host.docker.internal:host-gateway \
|
| 271 |
+
envoyproxy/envoy:v1.28.0
|
| 272 |
+
```
|
| 273 |
+
|
| 274 |
+
Connect through the load balancer:
|
| 275 |
+
|
| 276 |
+
```python
|
| 277 |
+
# Clients connect to Envoy, which distributes to backend containers
|
| 278 |
+
with MyEnv(base_url="http://localhost:8080") as env:
|
| 279 |
+
result = env.reset()
|
| 280 |
+
```
|
| 281 |
+
|
| 282 |
+
</details>
|
| 283 |
+
|
| 284 |
+
### Scaling expectations
|
| 285 |
+
|
| 286 |
+

|
| 287 |
+
|
| 288 |
+
| Setup | Containers | Sessions/container | Total capacity | Throughput |
|
| 289 |
+
|-------|------------|-------------------|----------------|------------|
|
| 290 |
+
| Single | 1 | 100 | 100 | ~100 req/s |
|
| 291 |
+
| 4× containers | 4 | 100 | 400 | ~350 req/s |
|
| 292 |
+
| 8× containers | 8 | 100 | 800 | ~600 req/s |
|
| 293 |
+
|
| 294 |
+
> **Note:** Actual throughput depends on environment complexity and hardware. Benchmark your specific workload.
|
| 295 |
+
|
| 296 |
+
## Experiments Results
|
| 297 |
+
|
| 298 |
+
This section documents experiments measuring OpenEnv scaling characteristics across five infrastructure configurations. Full experiment data and code available at [burtenshaw/openenv-scaling](https://github.com/burtenshaw/openenv-scaling).
|
| 299 |
+
|
| 300 |
+
### Experiment setup
|
| 301 |
+
|
| 302 |
+
**Benchmark environment:** A minimal OpenEnv environment with configurable wait time (simulates computation). Each `step()` call sleeps for the specified duration, isolating infrastructure overhead from environment logic.
|
| 303 |
+
|
| 304 |
+
**Infrastructure tested:**
|
| 305 |
+
|
| 306 |
+
| Infrastructure | Cores | Configuration |
|
| 307 |
+
|----------------|-------|---------------|
|
| 308 |
+
| local-uvicorn | 8 | Direct Uvicorn, 8 workers |
|
| 309 |
+
| local-docker | 8 | Docker container from HF Spaces image |
|
| 310 |
+
| hf-spaces | 2 | HF Spaces free tier (cpu-basic) |
|
| 311 |
+
| slurm-single | 48 | Single AWS HPC node |
|
| 312 |
+
| slurm-multi | 96 | Two AWS HPC nodes + Envoy load balancer |
|
| 313 |
+
|
| 314 |
+
**Protocol:** WebSocket (`/ws`) and HTTP (`/reset`, `/step`) compared where available.
|
| 315 |
+
|
| 316 |
+
**Metrics:**
|
| 317 |
+
- **Max batch:** Largest concurrent request count with ≥95% success rate
|
| 318 |
+
- **Batch/core:** Max batch divided by available cores (efficiency metric)
|
| 319 |
+
- **P99 latency:** 99th percentile total request time
|
| 320 |
+
- **RPS:** Requests per second at max batch
|
| 321 |
+
|
| 322 |
+
### Results summary
|
| 323 |
+
|
| 324 |
+
| Infrastructure | Max Batch (WS) | Cores | Batch/Core | P99 Latency | RPS |
|
| 325 |
+
|----------------|----------------|-------|------------|-------------|-----|
|
| 326 |
+
| slurm-multi | 16,384 | 96 | 170.7 | 29.8s | 518 |
|
| 327 |
+
| local-uvicorn | 2,048 | 8 | 256.0 | 1.97s | 932 |
|
| 328 |
+
| local-docker | 2,048 | 8 | 256.0 | 2.90s | 682 |
|
| 329 |
+
| slurm-single | 512 | 48 | 10.7 | 1.45s | 358 |
|
| 330 |
+
| hf-spaces | 128 | 2 | 64.0 | 2.68s | 48 |
|
| 331 |
+
|
| 332 |
+
All results measured with `wait=10.0s` step duration.
|
| 333 |
+
|
| 334 |
+

|
| 335 |
+
*Maximum batch size by infrastructure (95% success threshold)*
|
| 336 |
+
|
| 337 |
+
### Finding 1: Local deployments have highest per-core efficiency
|
| 338 |
+
|
| 339 |
+
Single instance of Python and Docker both achieve **256 concurrent sessions per core**—the highest efficiency observed. With 8 workers, both reach 2,048 concurrent sessions before degradation begins.
|
| 340 |
+
|
| 341 |
+
This makes sense because the environment is running in a single process and the overhead of the environment is relatively low. But it's ideal for hackers and developers who want to test their environment quickly or train on a single machine.
|
| 342 |
+
|
| 343 |
+
| Batch Size | Success Rate | P99 Latency | Notes |
|
| 344 |
+
|------------|--------------|-------------|-------|
|
| 345 |
+
| 32 | 100% | 1.05s | Perfect scaling |
|
| 346 |
+
| 128 | 100% | 1.07s | Perfect scaling |
|
| 347 |
+
| 512 | 100% | 1.33s | Perfect scaling |
|
| 348 |
+
| 2,048 | 96.5% | 1.97s | Max reliable batch |
|
| 349 |
+
| 4,096 | 63.8% | 3.20s | Connection failures begin |
|
| 350 |
+
| 8,192 | 36.9% | 5.75s | Above capacity |
|
| 351 |
+
|
| 352 |
+
Beyond 2,048 concurrent connections, success rate drops sharply. The failure mode is connection rejection, not timeout—the server saturates its connection pool.
|
| 353 |
+
|
| 354 |
+

|
| 355 |
+
*Per-core efficiency comparison across infrastructures*
|
| 356 |
+
|
| 357 |
+
### Finding 2: HF Spaces works reliably up to 128 concurrent sessions
|
| 358 |
+
|
| 359 |
+
HF Spaces free tier (cpu-basic) provides 2 workers and achieves 128 concurrent WebSocket sessions with 100% success. This translates to **64 sessions per core**.
|
| 360 |
+
|
| 361 |
+
**HF Spaces scaling behavior (WebSocket):**
|
| 362 |
+
|
| 363 |
+
| Batch Size | Success Rate | P99 Latency | Notes |
|
| 364 |
+
|------------|--------------|-------------|-------|
|
| 365 |
+
| 1 | 100% | 1.64s | Baseline |
|
| 366 |
+
| 32 | 100% | 1.80s | Perfect scaling |
|
| 367 |
+
| 64 | 100% | 2.14s | Perfect scaling |
|
| 368 |
+
| 128 | 100% | 2.68s | Max reliable batch |
|
| 369 |
+
| 256 | ~33% | 4.41s | Inconsistent (some runs 0%, some 100%) |
|
| 370 |
+
| 512 | 0% | — | Complete failure |
|
| 371 |
+
|
| 372 |
+
At 256 concurrent connections, results become unstable. At 512+, connections fail entirely due to HF Spaces connection limits.
|
| 373 |
+
|
| 374 |
+
**HTTP mode does not work on HF Spaces.** The `/reset` and `/step` HTTP endpoints are not accessible on the deployed Space—all HTTP requests fail. Use WebSocket mode exclusively.
|
| 375 |
+
|
| 376 |
+
### Finding 3: Multi-node scaling works
|
| 377 |
+
|
| 378 |
+
Multi-node SLURM (96 cores across 2 nodes) achieves **16,384 concurrent sessions** with 100% success rate—the highest absolute throughput tested.
|
| 379 |
+
|
| 380 |
+
**SLURM multi-node scaling behavior:**
|
| 381 |
+
|
| 382 |
+
| Batch Size | Success Rate | P99 Latency | Notes |
|
| 383 |
+
|------------|--------------|-------------|-------|
|
| 384 |
+
| 32 | 100% | 1.05s | Perfect scaling |
|
| 385 |
+
| 512 | 100% | 1.59s | Perfect scaling |
|
| 386 |
+
| 2,048 | 100% | 3.48s | Perfect scaling |
|
| 387 |
+
| 4,096 | 100% | 6.97s | Perfect scaling |
|
| 388 |
+
| 8,192 | 100% | 13.7s | Perfect scaling |
|
| 389 |
+
| 16,384 | 100% | 29.8s | Max tested batch |
|
| 390 |
+
|
| 391 |
+
The batch/core ratio (170.7) is lower than local deployments (256) but provides the highest absolute capacity for large-scale workloads.
|
| 392 |
+
|
| 393 |
+

|
| 394 |
+
|
| 395 |
+
*Multi-node vs single-node scaling behavior*
|
| 396 |
+
|
| 397 |
+
### Latency breakdown
|
| 398 |
+
|
| 399 |
+
At max load (`wait=1.0s`), latency breaks down as:
|
| 400 |
+
|
| 401 |
+
| Infrastructure | Connect P50 | Reset P50 | Step P50 | Total P99 |
|
| 402 |
+
|----------------|-------------|-----------|----------|-----------|
|
| 403 |
+
| slurm-single | 0.26s | 0.04s | 1.00s | 1.33s |
|
| 404 |
+
| local-uvicorn | 0.58s | 0.08s | 1.05s | 1.95s |
|
| 405 |
+
| hf-spaces | 0.79s | 0.10s | 1.10s | 2.48s |
|
| 406 |
+
| local-docker | 1.38s | 0.19s | 1.05s | 2.90s |
|
| 407 |
+
| slurm-multi | 17.5s | 2.25s | 2.42s | 26.3s |
|
| 408 |
+
|
| 409 |
+
**Observations:**
|
| 410 |
+
- **Step latency** is consistent across infrastructures (~1.0s for 1.0s wait), confirming the benchmark measures infrastructure overhead accurately
|
| 411 |
+
- **Connect latency** varies significantly—local Docker shows higher connect time at load (1.38s), likely due to container networking
|
| 412 |
+
- **Multi-node has high connect latency** (17.5s) at 16,384 batch due to queuing at the load balancer; this is the cost of handling 16× more connections than single-node
|
| 413 |
+
|
| 414 |
+

|
| 415 |
+
*P99 latency across configurations and batch sizes*
|
| 416 |
+
|
| 417 |
+

|
| 418 |
+
*Success rate vs batch size for all infrastructures*
|
| 419 |
+
|
| 420 |
+
### Test methodology
|
| 421 |
+
|
| 422 |
+
```bash
|
| 423 |
+
# Clone benchmark environment
|
| 424 |
+
git clone https://huggingface.co/spaces/burtenshaw/openenv-scaling
|
| 425 |
+
cd openenv-scaling
|
| 426 |
+
|
| 427 |
+
# Run scaling test
|
| 428 |
+
python tests/test_scaling.py \
|
| 429 |
+
--url http://localhost:8000 \
|
| 430 |
+
--requests-grid 32,128,512,2048,4096,8192,16384 \
|
| 431 |
+
--wait-grid 1.0,5.0,10.0 \
|
| 432 |
+
--reps 3 \
|
| 433 |
+
--mode ws \
|
| 434 |
+
--output-dir experiments/results/
|
| 435 |
+
```
|
| 436 |
+
|
| 437 |
+
Each configuration was tested with 3 repetitions. Max batch is defined as the largest batch size achieving ≥95% success rate across all repetitions.
|
| 438 |
+
|
| 439 |
+
---
|
| 440 |
+
|
| 441 |
+
## Summary
|
| 442 |
+
|
| 443 |
+
| Infrastructure | Best for | Max concurrent | Batch/core |
|
| 444 |
+
|----------------|----------|----------------|------------|
|
| 445 |
+
| local-uvicorn | Development, <2K sessions | 2,048 | 256 |
|
| 446 |
+
| local-docker | Same as uvicorn, containerized | 2,048 | 256 |
|
| 447 |
+
| hf-spaces | Demos, moderate load | 128 | 64 |
|
| 448 |
+
| slurm-single | HPC, single-node jobs | 512 | 10.7 |
|
| 449 |
+
| slurm-multi | Large-scale training | 16,384 | 170.7 |
|
| 450 |
+
|
| 451 |
+
**Recommendations:**
|
| 452 |
+
|
| 453 |
+
1. **For development and moderate workloads (<2,000 concurrent):** Use single node Uvicorn or Docker depending software environment. These provide the best per-core efficiency (256 sessions/core).
|
| 454 |
+
|
| 455 |
+
2. **For demos, testing, and published environments:** HF Spaces free tier works reliably up to 128 concurrent sessions.
|
| 456 |
+
|
| 457 |
+
3. **For large-scale training (>2,000 concurrent):** Deploy multi-node with proper load balancing. Expect ~170 sessions per core, but much higher absolute throughput.
|
tutorial/04-training.md
ADDED
|
@@ -0,0 +1,632 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# OpenEnv Wordle with GRPO using TRL
|
| 2 |
+
|
| 3 |
+
[](https://github.com/huggingface/trl/blob/main/examples/notebooks/openenv_wordle_grpo.ipynb)
|
| 4 |
+
|
| 5 |
+

|
| 6 |
+
|
| 7 |
+
With [**Transformers Reinforcement Learning (TRL)**](https://github.com/huggingface/trl), you can train a model that learns to **play Wordle**, a word-guessing game, through interaction and reinforcement.
|
| 8 |
+
|
| 9 |
+
- [TRL GitHub Repository](https://github.com/huggingface/trl)
|
| 10 |
+
- [Official TRL Examples](https://huggingface.co/docs/trl/example_overview)
|
| 11 |
+
- [Community Tutorials](https://huggingface.co/docs/trl/community_tutorials)
|
| 12 |
+
- [OpenEnv](https://github.com/meta-pytorch/OpenEnv)
|
| 13 |
+
|
| 14 |
+
An **agentic environment** is a setting where a model can take actions, observe outcomes, and adjust its behavior based on feedback, similar to how humans learn from trial and error.
|
| 15 |
+
In this case, the agent interacts with the **Wordle** environment through the [**OpenEnv**](https://github.com/meta-pytorch/OpenEnv) framework, which standardizes multi-agent and RL-style text environments.
|
| 16 |
+
|
| 17 |
+
[Wordle](https://en.wikipedia.org/wiki/Wordle) is a popular word puzzle where the player must guess a secret five-letter word within six tries.
|
| 18 |
+
After each guess, feedback indicates whether each letter is:
|
| 19 |
+
|
| 20 |
+
- 🟩 **Correct and in the right position**
|
| 21 |
+
- 🟨 **Present but in the wrong position**
|
| 22 |
+
- ⬛ **Not in the word**
|
| 23 |
+
|
| 24 |
+
This feedback loop makes Wordle a perfect environment for **RL with LLMs**, where the goal is to maximize the probability of guessing the correct word efficiently.
|
| 25 |
+
|
| 26 |
+
We will fine-tune a model using **GRPO** (Group Relative Policy Optimization) via TRL.
|
| 27 |
+
The agent will:
|
| 28 |
+
|
| 29 |
+
1. Generate guesses based on the game state and feedback.
|
| 30 |
+
2. Receive structured feedback from the environment after each guess.
|
| 31 |
+
3. Learn to improve its guessing strategy over time through reward signals.
|
| 32 |
+
|
| 33 |
+
---
|
| 34 |
+
|
| 35 |
+
## Install dependencies
|
| 36 |
+
|
| 37 |
+
We will start by installing **TRL**, which automatically includes the main dependencies like **Transformers**.
|
| 38 |
+
We will also install the **OpenEnv** framework (for the environment), **trackio** (for logging and monitoring training runs), and **vLLM** (for efficient generation).
|
| 39 |
+
|
| 40 |
+
```python
|
| 41 |
+
!pip install -Uq git+https://github.com/huggingface/trl.git git+https://github.com/meta-pytorch/OpenEnv.git trackio vllm==0.10.2 bitsandbytes
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
---
|
| 45 |
+
|
| 46 |
+
## Log in to Hugging Face
|
| 47 |
+
|
| 48 |
+
Log in to your **Hugging Face** account to save your fine-tuned model, track your experiment results directly on the Hub or access gated models. You can find your **access token** on your [account settings page](https://huggingface.co/settings/tokens).
|
| 49 |
+
|
| 50 |
+
```python
|
| 51 |
+
from huggingface_hub import notebook_login
|
| 52 |
+
|
| 53 |
+
notebook_login()
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
---
|
| 57 |
+
|
| 58 |
+
## Initialize the Environment
|
| 59 |
+
|
| 60 |
+
Let us begin by setting up the environment that will be used during training.
|
| 61 |
+
For this task, we will rely on the **TextArena** environment from **OpenEnv**, which exposes a familiar Gymnasium-style API (`reset()`, `step()`, etc.) to simplify interaction.
|
| 62 |
+
|
| 63 |
+
In this example, we will connect to the hosted environment at [burtenshaw/textarena](https://huggingface.co/spaces/burtenshaw/textarena).
|
| 64 |
+
For production use or custom configurations, we **strongly recommend** running the environment locally via Docker. The hosted versions on the Hub currently have limited concurrency support, so duplicating the Space to your own account is the preferred approach in those cases.
|
| 65 |
+
|
| 66 |
+
For more information, refer to the [TRL-OpenEnv documentation](https://huggingface.co/docs/trl/main/en/openenv).
|
| 67 |
+
|
| 68 |
+
```python
|
| 69 |
+
from envs.textarena_env import TextArenaEnv
|
| 70 |
+
|
| 71 |
+
textarena_url = "https://burtenshaw-textarena.hf.space" # Duplicate the Space and update this!
|
| 72 |
+
env = TextArenaEnv(base_url=textarena_url)
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
---
|
| 76 |
+
|
| 77 |
+
## Init model and tokenizer
|
| 78 |
+
|
| 79 |
+
We will use [Qwen/Qwen3-1.7B](https://huggingface.co/Qwen/Qwen3-1.7B), a lightweight instruction-tuned model that works well for quick experiments.
|
| 80 |
+
Despite its small size, it can still learn interesting strategies during fine-tuning.
|
| 81 |
+
If you have stronger hardware, you can easily scale up to larger models.
|
| 82 |
+
|
| 83 |
+
```python
|
| 84 |
+
from transformers import AutoTokenizer
|
| 85 |
+
|
| 86 |
+
model_name = "Qwen/Qwen3-1.7B"
|
| 87 |
+
tokenizer = AutoTokenizer.from_pretrained(model_name)
|
| 88 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
---
|
| 92 |
+
|
| 93 |
+
## Rollout function with helpers
|
| 94 |
+
|
| 95 |
+
The **rollout function** defines how the agent interacts with the environment during GRPO training.
|
| 96 |
+
It is responsible for generating model completions, collecting feedback (rewards), and returning all necessary information for optimization.
|
| 97 |
+
|
| 98 |
+
In this setup:
|
| 99 |
+
|
| 100 |
+
- The function is called automatically by the **GRPOTrainer** during each training step.
|
| 101 |
+
- It uses the trainer's built-in `generate_rollout_completions()` method for efficient generation with vLLM in colocate mode.
|
| 102 |
+
- Each rollout represents a full interaction loop. The model guesses, receives feedback from Wordle, and updates based on reward signals.
|
| 103 |
+
|
| 104 |
+
### System Prompt
|
| 105 |
+
|
| 106 |
+
First, we define the `system_prompt` that guides the model's behavior as an expert Wordle solver with strategic reasoning and structured responses.
|
| 107 |
+
|
| 108 |
+
```python
|
| 109 |
+
system_prompt = """
|
| 110 |
+
You are an expert Wordle solver with deep knowledge of English vocabulary, letter frequency patterns, and optimal guessing strategies.
|
| 111 |
+
|
| 112 |
+
## GAME RULES
|
| 113 |
+
|
| 114 |
+
1. The target is a 5-letter English word
|
| 115 |
+
2. You have 6 attempts to guess the correct word
|
| 116 |
+
3. After each guess, you receive color-coded feedback:
|
| 117 |
+
- GREEN: Letter is correct and in the correct position
|
| 118 |
+
- YELLOW: Letter is in the word but in the wrong position
|
| 119 |
+
- GRAY: Letter is not in the word at all
|
| 120 |
+
4. All guesses must be valid 5-letter English words
|
| 121 |
+
5. You cannot reuse a word you've already guessed
|
| 122 |
+
|
| 123 |
+
## RESPONSE FORMAT
|
| 124 |
+
|
| 125 |
+
Only respond with your next guess in square brackets, e.g., [crane].
|
| 126 |
+
|
| 127 |
+
## STRATEGIC APPROACH
|
| 128 |
+
|
| 129 |
+
Do not repeat the same guess twice.
|
| 130 |
+
|
| 131 |
+
### Opening Strategy
|
| 132 |
+
- Start with words rich in common vowels (A, E, I, O, U) and consonants (R, S, T, L, N)
|
| 133 |
+
- Optimal starters: CRANE, SLATE, STARE, AROSE, IRATE
|
| 134 |
+
|
| 135 |
+
### Mid-Game Strategy
|
| 136 |
+
- Use confirmed GREEN letters in their correct positions
|
| 137 |
+
- Place YELLOW letters in different positions than where they appeared
|
| 138 |
+
- Eliminate GRAY letters from consideration
|
| 139 |
+
|
| 140 |
+
## YOUR GOAL
|
| 141 |
+
|
| 142 |
+
Solve the Wordle in as few guesses as possible by strategically using feedback to eliminate impossible words and narrow down the solution space efficiently.
|
| 143 |
+
"""
|
| 144 |
+
```
|
| 145 |
+
|
| 146 |
+
### Rollout Function
|
| 147 |
+
|
| 148 |
+
```python
|
| 149 |
+
def rollout_func(prompts, trainer=None):
|
| 150 |
+
"""
|
| 151 |
+
Rollout function for GRPO training with environment interaction.
|
| 152 |
+
"""
|
| 153 |
+
episode_prompt_ids = []
|
| 154 |
+
episode_completion_ids = []
|
| 155 |
+
episode_logprobs = []
|
| 156 |
+
correctness_rewards = []
|
| 157 |
+
green_rewards = []
|
| 158 |
+
yellow_rewards = []
|
| 159 |
+
repetition_rewards = []
|
| 160 |
+
|
| 161 |
+
for prompt_text in prompts:
|
| 162 |
+
episode = rollout_once(
|
| 163 |
+
trainer=trainer,
|
| 164 |
+
env=env,
|
| 165 |
+
tokenizer=tokenizer,
|
| 166 |
+
dataset_prompt=prompt_text,
|
| 167 |
+
system_prompt=system_prompt,
|
| 168 |
+
max_turns=6,
|
| 169 |
+
)
|
| 170 |
+
episode_prompt_ids.append(episode["prompt_ids"])
|
| 171 |
+
episode_completion_ids.append(episode["completion_ids"])
|
| 172 |
+
episode_logprobs.append(episode["logprobs"])
|
| 173 |
+
correctness_rewards.append(episode["correct_reward"])
|
| 174 |
+
green_rewards.append(episode["green_reward"])
|
| 175 |
+
yellow_rewards.append(episode["yellow_reward"])
|
| 176 |
+
repetition_rewards.append(episode["repetition_reward"])
|
| 177 |
+
|
| 178 |
+
return {
|
| 179 |
+
"prompt_ids": episode_prompt_ids,
|
| 180 |
+
"completion_ids": episode_completion_ids,
|
| 181 |
+
"logprobs": episode_logprobs,
|
| 182 |
+
"correct_reward": correctness_rewards,
|
| 183 |
+
"green_reward": green_rewards,
|
| 184 |
+
"yellow_reward": yellow_rewards,
|
| 185 |
+
"repetition_reward": repetition_rewards,
|
| 186 |
+
}
|
| 187 |
+
```
|
| 188 |
+
|
| 189 |
+
---
|
| 190 |
+
|
| 191 |
+
## Define rollout_once
|
| 192 |
+
|
| 193 |
+
The `rollout_once` function runs **one full interaction loop** between the model and the Wordle environment using the trainer's generation method.
|
| 194 |
+
|
| 195 |
+
```python
|
| 196 |
+
from collections import defaultdict
|
| 197 |
+
from envs.textarena_env import TextArenaAction
|
| 198 |
+
from envs.textarena_env.rewards import extract_feedback_counts, extract_guess, extract_wordle_feedback
|
| 199 |
+
from trl.experimental.openenv import generate_rollout_completions
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def rollout_once(trainer, env, tokenizer, dataset_prompt, system_prompt, max_turns):
|
| 203 |
+
"""
|
| 204 |
+
Execute one full Wordle episode with the model.
|
| 205 |
+
"""
|
| 206 |
+
result = env.reset()
|
| 207 |
+
observation = result.observation
|
| 208 |
+
|
| 209 |
+
prompt_ids = []
|
| 210 |
+
completion_ids = []
|
| 211 |
+
logprobs = []
|
| 212 |
+
raw_rewards = []
|
| 213 |
+
green_scores = []
|
| 214 |
+
yellow_scores = []
|
| 215 |
+
repetition_scores = []
|
| 216 |
+
correct_scores = []
|
| 217 |
+
guess_counts = defaultdict(int)
|
| 218 |
+
|
| 219 |
+
for _turn in range(max_turns):
|
| 220 |
+
if result.done:
|
| 221 |
+
break
|
| 222 |
+
|
| 223 |
+
base_prompt = observation.prompt or dataset_prompt
|
| 224 |
+
user_prompt = make_user_prompt(base_prompt, observation.messages)
|
| 225 |
+
messages = [
|
| 226 |
+
{"role": "system", "content": system_prompt},
|
| 227 |
+
{"role": "user", "content": user_prompt},
|
| 228 |
+
]
|
| 229 |
+
prompt_text = tokenizer.apply_chat_template(
|
| 230 |
+
messages,
|
| 231 |
+
add_generation_prompt=True,
|
| 232 |
+
tokenize=False,
|
| 233 |
+
enable_thinking=False,
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
rollout_outputs = generate_rollout_completions(trainer, [prompt_text])[0]
|
| 237 |
+
prompt_ids.extend(rollout_outputs["prompt_ids"])
|
| 238 |
+
completion_ids.extend(rollout_outputs["completion_ids"])
|
| 239 |
+
logprobs.extend(rollout_outputs["logprobs"])
|
| 240 |
+
completion_text = rollout_outputs.get("text") or tokenizer.decode(
|
| 241 |
+
rollout_outputs["completion_ids"], skip_special_tokens=True
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
guess = extract_guess(completion_text)
|
| 245 |
+
result = env.step(TextArenaAction(message=guess))
|
| 246 |
+
raw_rewards.append(float(result.reward or 0.0))
|
| 247 |
+
observation = result.observation
|
| 248 |
+
correct_score = float(result.reward or 0.0)
|
| 249 |
+
feedback = extract_wordle_feedback(observation)
|
| 250 |
+
|
| 251 |
+
previous_occurrences = guess_counts[guess]
|
| 252 |
+
repetition_score = scale_repetition_score(previous_occurrences, len(guess_counts))
|
| 253 |
+
guess_counts[guess] += 1
|
| 254 |
+
|
| 255 |
+
if not feedback:
|
| 256 |
+
green_score = 0.0
|
| 257 |
+
yellow_score = 0.0
|
| 258 |
+
else:
|
| 259 |
+
green_count, yellow_count = extract_feedback_counts(feedback)
|
| 260 |
+
green_score = green_count / 5.0
|
| 261 |
+
yellow_score = yellow_count / 5.0
|
| 262 |
+
|
| 263 |
+
repetition_scores.append(repetition_score)
|
| 264 |
+
green_scores.append(green_score)
|
| 265 |
+
yellow_scores.append(yellow_score)
|
| 266 |
+
correct_scores.append(correct_score)
|
| 267 |
+
|
| 268 |
+
correct_reward_value = correct_scores[-1] if correct_scores else (raw_rewards[-1] if raw_rewards else 0.0)
|
| 269 |
+
|
| 270 |
+
return {
|
| 271 |
+
"prompt_ids": prompt_ids,
|
| 272 |
+
"completion_ids": completion_ids,
|
| 273 |
+
"logprobs": logprobs,
|
| 274 |
+
"raw_rewards": raw_rewards,
|
| 275 |
+
"correct_reward": correct_reward_value,
|
| 276 |
+
"green_reward": green_scores[-1] if green_scores else 0.0,
|
| 277 |
+
"yellow_reward": yellow_scores[-1] if yellow_scores else 0.0,
|
| 278 |
+
"repetition_reward": repetition_scores[-1] if repetition_scores else 0.0,
|
| 279 |
+
}
|
| 280 |
+
```
|
| 281 |
+
|
| 282 |
+
---
|
| 283 |
+
|
| 284 |
+
## Helper functions
|
| 285 |
+
|
| 286 |
+
```python
|
| 287 |
+
def make_user_prompt(prompt_text, messages):
|
| 288 |
+
"""Builds a structured user prompt combining the task description and message history"""
|
| 289 |
+
history = format_history(messages)
|
| 290 |
+
prompt_section = prompt_text.strip() if prompt_text.strip() else "Wordle-v0"
|
| 291 |
+
history_section = history if history else "[PROMPT] Awaiting first feedback."
|
| 292 |
+
return (
|
| 293 |
+
f"Game prompt:\n{prompt_section}\n\n"
|
| 294 |
+
f"Conversation so far:\n{history_section}\n\n"
|
| 295 |
+
"Reply with your next guess enclosed in square brackets."
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
def format_history(messages):
|
| 299 |
+
"""Formats the message history with tags for clear conversational context"""
|
| 300 |
+
lines = []
|
| 301 |
+
for message in messages:
|
| 302 |
+
tag = message.category or "MESSAGE"
|
| 303 |
+
content = message.content.strip()
|
| 304 |
+
if not content:
|
| 305 |
+
continue
|
| 306 |
+
lines.append(f"[{tag}] {content}")
|
| 307 |
+
return "\n".join(lines)
|
| 308 |
+
|
| 309 |
+
def scale_repetition_score(previous_occurrences, max_occurrences):
|
| 310 |
+
"""Scale the repetition score based on the number of previous occurrences from 0 to 1"""
|
| 311 |
+
if max_occurrences == 0:
|
| 312 |
+
return 0.0
|
| 313 |
+
return (max_occurrences - previous_occurrences) / max_occurrences
|
| 314 |
+
```
|
| 315 |
+
|
| 316 |
+
---
|
| 317 |
+
|
| 318 |
+
## Define reward functions
|
| 319 |
+
|
| 320 |
+
```python
|
| 321 |
+
def reward_correct(completions, **kwargs):
|
| 322 |
+
rewards = kwargs.get("correct_reward") if kwargs else None
|
| 323 |
+
if rewards is None:
|
| 324 |
+
return [0.0 for _ in completions]
|
| 325 |
+
return [float(r) for r in rewards]
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def reward_greens(completions, **kwargs):
|
| 329 |
+
rewards = kwargs.get("green_reward") if kwargs else None
|
| 330 |
+
if rewards is None:
|
| 331 |
+
return [0.0 for _ in completions]
|
| 332 |
+
return [float(r) for r in rewards]
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
def reward_yellows(completions, **kwargs):
|
| 336 |
+
rewards = kwargs.get("yellow_reward") if kwargs else None
|
| 337 |
+
if rewards is None:
|
| 338 |
+
return [0.0 for _ in completions]
|
| 339 |
+
return [float(r) for r in rewards]
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def reward_repetition(completions, **kwargs):
|
| 343 |
+
rewards = kwargs.get("repetition_reward") if kwargs else None
|
| 344 |
+
if rewards is None:
|
| 345 |
+
return [0.0 for _ in completions]
|
| 346 |
+
return [float(r) for r in rewards]
|
| 347 |
+
```
|
| 348 |
+
|
| 349 |
+
---
|
| 350 |
+
|
| 351 |
+
## Create dataset
|
| 352 |
+
|
| 353 |
+
```python
|
| 354 |
+
from datasets import Dataset
|
| 355 |
+
|
| 356 |
+
dataset_size = 1000
|
| 357 |
+
dataset_prompt = "Play Wordle like an expert."
|
| 358 |
+
|
| 359 |
+
dataset = Dataset.from_dict({"prompt": [dataset_prompt] * dataset_size})
|
| 360 |
+
```
|
| 361 |
+
|
| 362 |
+
---
|
| 363 |
+
|
| 364 |
+
## Set GRPO Config
|
| 365 |
+
|
| 366 |
+
```python
|
| 367 |
+
from trl import GRPOConfig
|
| 368 |
+
|
| 369 |
+
output_dir = "wordle-grpo-Qwen3-1.7B"
|
| 370 |
+
|
| 371 |
+
grpo_config = GRPOConfig(
|
| 372 |
+
num_train_epochs = 1,
|
| 373 |
+
learning_rate = 5e-6,
|
| 374 |
+
gradient_accumulation_steps = 64,
|
| 375 |
+
per_device_train_batch_size = 1,
|
| 376 |
+
warmup_steps = 20,
|
| 377 |
+
num_generations = 2,
|
| 378 |
+
max_completion_length = 8,
|
| 379 |
+
max_prompt_length = 1400,
|
| 380 |
+
use_vllm = True,
|
| 381 |
+
vllm_mode = "colocate",
|
| 382 |
+
vllm_gpu_memory_utilization = 0.1,
|
| 383 |
+
output_dir = output_dir,
|
| 384 |
+
report_to="trackio",
|
| 385 |
+
trackio_space_id = output_dir,
|
| 386 |
+
logging_steps = 1,
|
| 387 |
+
save_steps = 10,
|
| 388 |
+
gradient_checkpointing = True,
|
| 389 |
+
gradient_checkpointing_kwargs = {"use_reentrant": False},
|
| 390 |
+
push_to_hub = True,
|
| 391 |
+
)
|
| 392 |
+
```
|
| 393 |
+
|
| 394 |
+
---
|
| 395 |
+
|
| 396 |
+
## Create GRPOTrainer and start training
|
| 397 |
+
|
| 398 |
+
```python
|
| 399 |
+
from trl import GRPOTrainer
|
| 400 |
+
|
| 401 |
+
trainer = GRPOTrainer(
|
| 402 |
+
model=model_name,
|
| 403 |
+
processing_class=tokenizer,
|
| 404 |
+
reward_funcs=[
|
| 405 |
+
reward_correct,
|
| 406 |
+
reward_greens,
|
| 407 |
+
reward_yellows,
|
| 408 |
+
reward_repetition,
|
| 409 |
+
],
|
| 410 |
+
train_dataset=dataset,
|
| 411 |
+
args=grpo_config,
|
| 412 |
+
rollout_func=rollout_func,
|
| 413 |
+
)
|
| 414 |
+
```
|
| 415 |
+
|
| 416 |
+
### Memory stats before training
|
| 417 |
+
|
| 418 |
+
```python
|
| 419 |
+
import torch
|
| 420 |
+
gpu_stats = torch.cuda.get_device_properties(0)
|
| 421 |
+
start_gpu_memory = round(torch.cuda.max_memory_reserved() / 1024 / 1024 / 1024, 3)
|
| 422 |
+
max_memory = round(gpu_stats.total_memory / 1024 / 1024 / 1024, 3)
|
| 423 |
+
|
| 424 |
+
print(f"GPU = {gpu_stats.name}. Max memory = {max_memory} GB.")
|
| 425 |
+
print(f"{start_gpu_memory} GB of memory reserved.")
|
| 426 |
+
```
|
| 427 |
+
|
| 428 |
+
**Output:**
|
| 429 |
+
```
|
| 430 |
+
GPU = NVIDIA A100-SXM4-40GB. Max memory = 39.557 GB.
|
| 431 |
+
10.516 GB of memory reserved.
|
| 432 |
+
```
|
| 433 |
+
|
| 434 |
+
### Train!
|
| 435 |
+
|
| 436 |
+
```python
|
| 437 |
+
trainer_stats = trainer.train()
|
| 438 |
+
```
|
| 439 |
+
|
| 440 |
+
**Training Progress:**
|
| 441 |
+
|
| 442 |
+
| Step | Training Loss |
|
| 443 |
+
|------|---------------|
|
| 444 |
+
| 1 | 0.008300 |
|
| 445 |
+
| 2 | 0.001900 |
|
| 446 |
+
| 3 | 0.015100 |
|
| 447 |
+
| 4 | 0.008700 |
|
| 448 |
+
| 5 | 0.009800 |
|
| 449 |
+
| 6 | 0.006700 |
|
| 450 |
+
| 7 | 0.006100 |
|
| 451 |
+
| 8 | 0.004400 |
|
| 452 |
+
| 9 | -0.002100 |
|
| 453 |
+
| 10 | 0.007500 |
|
| 454 |
+
| 11 | 0.008400 |
|
| 455 |
+
| 12 | 0.008000 |
|
| 456 |
+
| 13 | 0.007800 |
|
| 457 |
+
| 14 | -0.002400 |
|
| 458 |
+
| 15 | -0.003200 |
|
| 459 |
+
| 16 | -0.006000 |
|
| 460 |
+
| 17 | -0.008300 |
|
| 461 |
+
| 18 | -0.011000 |
|
| 462 |
+
| 19 | -0.004200 |
|
| 463 |
+
| 20 | -0.001700 |
|
| 464 |
+
| 21 | -0.004100 |
|
| 465 |
+
| 22 | -0.011600 |
|
| 466 |
+
| 23 | -0.006400 |
|
| 467 |
+
| 24 | -0.009100 |
|
| 468 |
+
| 25 | 0.003200 |
|
| 469 |
+
| 26 | 0.005100 |
|
| 470 |
+
| 27 | -0.002800 |
|
| 471 |
+
| 28 | 0.001400 |
|
| 472 |
+
| 29 | 0.011500 |
|
| 473 |
+
| 30 | -0.010500 |
|
| 474 |
+
| 31 | -0.006400 |
|
| 475 |
+
|
| 476 |
+
### Memory stats after training
|
| 477 |
+
|
| 478 |
+
```python
|
| 479 |
+
used_memory = round(torch.cuda.max_memory_reserved() / 1024 / 1024 / 1024, 3)
|
| 480 |
+
used_memory_for_training = round(used_memory - start_gpu_memory, 3)
|
| 481 |
+
used_percentage = round(used_memory / max_memory * 100, 3)
|
| 482 |
+
training_memory_percentage = round(used_memory_for_training / max_memory * 100, 3)
|
| 483 |
+
|
| 484 |
+
print(f"{trainer_stats.metrics['train_runtime']} seconds used for training.")
|
| 485 |
+
print(f"{round(trainer_stats.metrics['train_runtime']/60, 2)} minutes used for training.")
|
| 486 |
+
print(f"Peak reserved memory = {used_memory} GB.")
|
| 487 |
+
print(f"Peak reserved memory for training = {used_memory_for_training} GB.")
|
| 488 |
+
print(f"Peak reserved memory % of max memory = {used_percentage} %.")
|
| 489 |
+
print(f"Peak reserved memory for training % of max memory = {training_memory_percentage} %.")
|
| 490 |
+
```
|
| 491 |
+
|
| 492 |
+
**Output:**
|
| 493 |
+
```
|
| 494 |
+
5231.7046 seconds used for training.
|
| 495 |
+
87.2 minutes used for training.
|
| 496 |
+
Peak reserved memory = 36.68 GB.
|
| 497 |
+
Peak reserved memory for training = 26.164 GB.
|
| 498 |
+
Peak reserved memory % of max memory = 92.727 %.
|
| 499 |
+
Peak reserved memory for training % of max memory = 66.143 %.
|
| 500 |
+
```
|
| 501 |
+
|
| 502 |
+
### Save and push to Hub
|
| 503 |
+
|
| 504 |
+
```python
|
| 505 |
+
env.close()
|
| 506 |
+
trainer.save_model(output_dir)
|
| 507 |
+
trainer.push_to_hub()
|
| 508 |
+
```
|
| 509 |
+
|
| 510 |
+
---
|
| 511 |
+
|
| 512 |
+
## Load the Fine-Tuned Model and Run Inference
|
| 513 |
+
|
| 514 |
+
```python
|
| 515 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 516 |
+
|
| 517 |
+
model_name = "sergiopaniego/wordle-grpo-Qwen3-1.7B" # Replace with your HF username
|
| 518 |
+
|
| 519 |
+
fine_tuned_model = AutoModelForCausalLM.from_pretrained(model_name, dtype="auto", device_map="auto")
|
| 520 |
+
tokenizer = AutoTokenizer.from_pretrained(model_name)
|
| 521 |
+
```
|
| 522 |
+
|
| 523 |
+
```python
|
| 524 |
+
MAX_TURNS=6
|
| 525 |
+
|
| 526 |
+
def play_wordle(env, model, tokenizer):
|
| 527 |
+
result = env.reset()
|
| 528 |
+
observation = result.observation
|
| 529 |
+
|
| 530 |
+
print("Initial Prompt:\n" + observation.prompt)
|
| 531 |
+
|
| 532 |
+
for turn in range(MAX_TURNS):
|
| 533 |
+
if result.done:
|
| 534 |
+
break
|
| 535 |
+
|
| 536 |
+
user_prompt = make_user_prompt(observation.prompt, observation.messages)
|
| 537 |
+
messages = [
|
| 538 |
+
{"role": "system", "content": system_prompt},
|
| 539 |
+
{"role": "user", "content": user_prompt},
|
| 540 |
+
]
|
| 541 |
+
prompt_text = tokenizer.apply_chat_template(
|
| 542 |
+
messages,
|
| 543 |
+
add_generation_prompt=True,
|
| 544 |
+
tokenize=False,
|
| 545 |
+
enable_thinking=False,
|
| 546 |
+
)
|
| 547 |
+
|
| 548 |
+
model_inputs = tokenizer([prompt_text], return_tensors="pt").to(model.device)
|
| 549 |
+
|
| 550 |
+
generated_ids = model.generate(
|
| 551 |
+
**model_inputs,
|
| 552 |
+
max_new_tokens=512
|
| 553 |
+
)
|
| 554 |
+
output_ids = generated_ids[0][len(model_inputs.input_ids[0]):]
|
| 555 |
+
|
| 556 |
+
generated_text = tokenizer.decode(output_ids, skip_special_tokens=True)
|
| 557 |
+
guess = extract_guess(generated_text)
|
| 558 |
+
|
| 559 |
+
print(f"\nTurn {turn}: model replied with -> {generated_text}")
|
| 560 |
+
print(f" Parsed guess: {guess}")
|
| 561 |
+
|
| 562 |
+
result = env.step(TextArenaAction(message=guess))
|
| 563 |
+
observation = result.observation
|
| 564 |
+
|
| 565 |
+
print(" Feedback messages:")
|
| 566 |
+
for message in observation.messages:
|
| 567 |
+
print(f" [{message.category}] {message.content}")
|
| 568 |
+
|
| 569 |
+
print("\nGame finished")
|
| 570 |
+
print(f" Reward: {result.reward}")
|
| 571 |
+
print(f" Done: {result.done}")
|
| 572 |
+
```
|
| 573 |
+
|
| 574 |
+
### Let us play the game!
|
| 575 |
+
|
| 576 |
+
```python
|
| 577 |
+
try:
|
| 578 |
+
play_wordle(env, fine_tuned_model, tokenizer)
|
| 579 |
+
finally:
|
| 580 |
+
env.close()
|
| 581 |
+
```
|
| 582 |
+
|
| 583 |
+
**Output:**
|
| 584 |
+
```
|
| 585 |
+
Initial Prompt:
|
| 586 |
+
You are Player 0 in Wordle.
|
| 587 |
+
A secret 5-letter word has been chosen. You have 6 attempts to guess it.
|
| 588 |
+
For each guess, wrap your word in square brackets (e.g., [apple]).
|
| 589 |
+
Feedback for each letter will be given as follows:
|
| 590 |
+
- G (green): correct letter in the correct position
|
| 591 |
+
- Y (yellow): letter exists in the word but in the wrong position
|
| 592 |
+
- X (wrong): letter is not in the word
|
| 593 |
+
Enter your guess to begin.
|
| 594 |
+
|
| 595 |
+
Turn 0: model replied with -> [crane]
|
| 596 |
+
Parsed guess: [crane]
|
| 597 |
+
Feedback messages:
|
| 598 |
+
[MESSAGE] [crane]
|
| 599 |
+
[MESSAGE] Player 0 submitted [crane].
|
| 600 |
+
Feedback:
|
| 601 |
+
C R A N E
|
| 602 |
+
X Y X X X
|
| 603 |
+
|
| 604 |
+
You have 5 guesses left.
|
| 605 |
+
|
| 606 |
+
Turn 1: model replied with -> [spare]
|
| 607 |
+
Parsed guess: [spare]
|
| 608 |
+
Feedback messages:
|
| 609 |
+
[MESSAGE] [spare]
|
| 610 |
+
[MESSAGE] Player 0 submitted [spare].
|
| 611 |
+
Feedback:
|
| 612 |
+
C R A N E
|
| 613 |
+
X Y X X X
|
| 614 |
+
|
| 615 |
+
S P A R E
|
| 616 |
+
G X X G X
|
| 617 |
+
|
| 618 |
+
You have 4 guesses left.
|
| 619 |
+
|
| 620 |
+
...
|
| 621 |
+
|
| 622 |
+
Game finished
|
| 623 |
+
Reward: 0.0
|
| 624 |
+
Done: True
|
| 625 |
+
```
|
| 626 |
+
|
| 627 |
+
> **Note:** The model has learned some good opening strategies (starting with "crane", then "spare"), but still tends to repeat guesses. This is a common challenge in RL training that can be improved with:
|
| 628 |
+
>
|
| 629 |
+
> - Longer training runs
|
| 630 |
+
> - Stronger repetition penalties
|
| 631 |
+
> - Better reward shaping
|
| 632 |
+
> - Larger models
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
validate.sh
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
#
|
| 3 |
+
# validate-submission.sh — OpenEnv Submission Validator
|
| 4 |
+
#
|
| 5 |
+
# Checks that your HF Space is live, Docker image builds, and openenv validate passes.
|
| 6 |
+
#
|
| 7 |
+
# Prerequisites:
|
| 8 |
+
# - Docker: https://docs.docker.com/get-docker/
|
| 9 |
+
# - openenv-core: pip install openenv-core
|
| 10 |
+
# - curl (usually pre-installed)
|
| 11 |
+
#
|
| 12 |
+
# Run:
|
| 13 |
+
# curl -fsSL https://raw.githubusercontent.com/<owner>/<repo>/main/scripts/validate-submission.sh | bash -s -- <ping_url> [repo_dir]
|
| 14 |
+
#
|
| 15 |
+
# Or download and run locally:
|
| 16 |
+
# chmod +x validate-submission.sh
|
| 17 |
+
# ./validate-submission.sh <ping_url> [repo_dir]
|
| 18 |
+
#
|
| 19 |
+
# Arguments:
|
| 20 |
+
# ping_url Your HuggingFace Space URL (e.g. https://your-space.hf.space)
|
| 21 |
+
# repo_dir Path to your repo (default: current directory)
|
| 22 |
+
#
|
| 23 |
+
# Examples:
|
| 24 |
+
# ./validate-submission.sh https://my-team.hf.space
|
| 25 |
+
# ./validate-submission.sh https://my-team.hf.space ./my-repo
|
| 26 |
+
#
|
| 27 |
+
|
| 28 |
+
set -uo pipefail
|
| 29 |
+
|
| 30 |
+
DOCKER_BUILD_TIMEOUT=600
|
| 31 |
+
if [ -t 1 ]; then
|
| 32 |
+
RED='\033[0;31m'
|
| 33 |
+
GREEN='\033[0;32m'
|
| 34 |
+
YELLOW='\033[1;33m'
|
| 35 |
+
BOLD='\033[1m'
|
| 36 |
+
NC='\033[0m'
|
| 37 |
+
else
|
| 38 |
+
RED='' GREEN='' YELLOW='' BOLD='' NC=''
|
| 39 |
+
fi
|
| 40 |
+
|
| 41 |
+
run_with_timeout() {
|
| 42 |
+
local secs="$1"; shift
|
| 43 |
+
if command -v timeout &>/dev/null; then
|
| 44 |
+
timeout "$secs" "$@"
|
| 45 |
+
elif command -v gtimeout &>/dev/null; then
|
| 46 |
+
gtimeout "$secs" "$@"
|
| 47 |
+
else
|
| 48 |
+
"$@" &
|
| 49 |
+
local pid=$!
|
| 50 |
+
( sleep "$secs" && kill "$pid" 2>/dev/null ) &
|
| 51 |
+
local watcher=$!
|
| 52 |
+
wait "$pid" 2>/dev/null
|
| 53 |
+
local rc=$?
|
| 54 |
+
kill "$watcher" 2>/dev/null
|
| 55 |
+
wait "$watcher" 2>/dev/null
|
| 56 |
+
return $rc
|
| 57 |
+
fi
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
portable_mktemp() {
|
| 61 |
+
local prefix="${1:-validate}"
|
| 62 |
+
mktemp "${TMPDIR:-/tmp}/${prefix}-XXXXXX" 2>/dev/null || mktemp
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
CLEANUP_FILES=()
|
| 66 |
+
cleanup() { rm -f "${CLEANUP_FILES[@]+"${CLEANUP_FILES[@]}"}"; }
|
| 67 |
+
trap cleanup EXIT
|
| 68 |
+
|
| 69 |
+
PING_URL="${1:-}"
|
| 70 |
+
REPO_DIR="${2:-.}"
|
| 71 |
+
|
| 72 |
+
if [ -z "$PING_URL" ]; then
|
| 73 |
+
printf "Usage: %s <ping_url> [repo_dir]\n" "$0"
|
| 74 |
+
printf "\n"
|
| 75 |
+
printf " ping_url Your HuggingFace Space URL (e.g. https://your-space.hf.space)\n"
|
| 76 |
+
printf " repo_dir Path to your repo (default: current directory)\n"
|
| 77 |
+
exit 1
|
| 78 |
+
fi
|
| 79 |
+
|
| 80 |
+
if ! REPO_DIR="$(cd "$REPO_DIR" 2>/dev/null && pwd)"; then
|
| 81 |
+
printf "Error: directory '%s' not found\n" "${2:-.}"
|
| 82 |
+
exit 1
|
| 83 |
+
fi
|
| 84 |
+
PING_URL="${PING_URL%/}"
|
| 85 |
+
export PING_URL
|
| 86 |
+
PASS=0
|
| 87 |
+
|
| 88 |
+
log() { printf "[%s] %b\n" "$(date -u +%H:%M:%S)" "$*"; }
|
| 89 |
+
pass() { log "${GREEN}PASSED${NC} -- $1"; PASS=$((PASS + 1)); }
|
| 90 |
+
fail() { log "${RED}FAILED${NC} -- $1"; }
|
| 91 |
+
hint() { printf " ${YELLOW}Hint:${NC} %b\n" "$1"; }
|
| 92 |
+
stop_at() {
|
| 93 |
+
printf "\n"
|
| 94 |
+
printf "${RED}${BOLD}Validation stopped at %s.${NC} Fix the above before continuing.\n" "$1"
|
| 95 |
+
exit 1
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
printf "\n"
|
| 99 |
+
printf "${BOLD}========================================${NC}\n"
|
| 100 |
+
printf "${BOLD} OpenEnv Submission Validator${NC}\n"
|
| 101 |
+
printf "${BOLD}========================================${NC}\n"
|
| 102 |
+
log "Repo: $REPO_DIR"
|
| 103 |
+
log "Ping URL: $PING_URL"
|
| 104 |
+
printf "\n"
|
| 105 |
+
|
| 106 |
+
log "${BOLD}Step 1/3: Pinging HF Space${NC} ($PING_URL/reset) ..."
|
| 107 |
+
|
| 108 |
+
CURL_OUTPUT=$(portable_mktemp "validate-curl")
|
| 109 |
+
CLEANUP_FILES+=("$CURL_OUTPUT")
|
| 110 |
+
HTTP_CODE=$(curl -s -o "$CURL_OUTPUT" -w "%{http_code}" -X POST \
|
| 111 |
+
-H "Content-Type: application/json" -d '{}' \
|
| 112 |
+
"$PING_URL/reset" --max-time 30 2>"$CURL_OUTPUT" || printf "000")
|
| 113 |
+
|
| 114 |
+
if [ "$HTTP_CODE" = "200" ]; then
|
| 115 |
+
pass "HF Space is live and responds to /reset"
|
| 116 |
+
elif [ "$HTTP_CODE" = "000" ]; then
|
| 117 |
+
fail "HF Space not reachable (connection failed or timed out)"
|
| 118 |
+
hint "Check your network connection and that the Space is running."
|
| 119 |
+
hint "Try: curl -s -o /dev/null -w '%%{http_code}' -X POST $PING_URL/reset"
|
| 120 |
+
stop_at "Step 1"
|
| 121 |
+
else
|
| 122 |
+
fail "HF Space /reset returned HTTP $HTTP_CODE (expected 200)"
|
| 123 |
+
hint "Make sure your Space is running and the URL is correct."
|
| 124 |
+
hint "Try opening $PING_URL in your browser first."
|
| 125 |
+
stop_at "Step 1"
|
| 126 |
+
fi
|
| 127 |
+
|
| 128 |
+
log "${BOLD}Step 2/3: Running docker build${NC} ..."
|
| 129 |
+
|
| 130 |
+
if ! command -v docker &>/dev/null; then
|
| 131 |
+
fail "docker command not found"
|
| 132 |
+
hint "Install Docker: https://docs.docker.com/get-docker/"
|
| 133 |
+
stop_at "Step 2"
|
| 134 |
+
fi
|
| 135 |
+
|
| 136 |
+
if [ -f "$REPO_DIR/Dockerfile" ]; then
|
| 137 |
+
DOCKER_CONTEXT="$REPO_DIR"
|
| 138 |
+
elif [ -f "$REPO_DIR/server/Dockerfile" ]; then
|
| 139 |
+
DOCKER_CONTEXT="$REPO_DIR/server"
|
| 140 |
+
else
|
| 141 |
+
fail "No Dockerfile found in repo root or server/ directory"
|
| 142 |
+
stop_at "Step 2"
|
| 143 |
+
fi
|
| 144 |
+
|
| 145 |
+
log " Found Dockerfile in $DOCKER_CONTEXT"
|
| 146 |
+
|
| 147 |
+
BUILD_OK=false
|
| 148 |
+
BUILD_OUTPUT=$(run_with_timeout "$DOCKER_BUILD_TIMEOUT" docker build "$DOCKER_CONTEXT" 2>&1) && BUILD_OK=true
|
| 149 |
+
|
| 150 |
+
if [ "$BUILD_OK" = true ]; then
|
| 151 |
+
pass "Docker build succeeded"
|
| 152 |
+
else
|
| 153 |
+
fail "Docker build failed (timeout=${DOCKER_BUILD_TIMEOUT}s)"
|
| 154 |
+
printf "%s\n" "$BUILD_OUTPUT" | tail -20
|
| 155 |
+
stop_at "Step 2"
|
| 156 |
+
fi
|
| 157 |
+
|
| 158 |
+
log "${BOLD}Step 3/3: Running openenv validate${NC} ..."
|
| 159 |
+
|
| 160 |
+
if ! command -v openenv &>/dev/null; then
|
| 161 |
+
fail "openenv command not found"
|
| 162 |
+
hint "Install it: pip install openenv-core"
|
| 163 |
+
stop_at "Step 3"
|
| 164 |
+
fi
|
| 165 |
+
|
| 166 |
+
VALIDATE_OK=false
|
| 167 |
+
VALIDATE_OUTPUT=$(cd "$REPO_DIR" && openenv validate 2>&1) && VALIDATE_OK=true
|
| 168 |
+
|
| 169 |
+
if [ "$VALIDATE_OK" = true ]; then
|
| 170 |
+
pass "openenv validate passed"
|
| 171 |
+
[ -n "$VALIDATE_OUTPUT" ] && log " $VALIDATE_OUTPUT"
|
| 172 |
+
else
|
| 173 |
+
fail "openenv validate failed"
|
| 174 |
+
printf "%s\n" "$VALIDATE_OUTPUT"
|
| 175 |
+
stop_at "Step 3"
|
| 176 |
+
fi
|
| 177 |
+
|
| 178 |
+
printf "\n"
|
| 179 |
+
printf "${BOLD}========================================${NC}\n"
|
| 180 |
+
printf "${GREEN}${BOLD} All 3/3 checks passed!${NC}\n"
|
| 181 |
+
printf "${GREEN}${BOLD} Your submission is ready to submit.${NC}\n"
|
| 182 |
+
printf "${BOLD}========================================${NC}\n"
|
| 183 |
+
printf "\n"
|
| 184 |
+
|
| 185 |
+
exit 0
|