Code0ut commited on
Commit
01a29cc
·
verified ·
1 Parent(s): a617110

Upload folder using huggingface_hub

Browse files
Dockerfile ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ # Multi-stage build using openenv-base
8
+ # This Dockerfile is flexible and works for both:
9
+ # - In-repo environments (with local OpenEnv sources)
10
+ # - Standalone environments (with openenv from PyPI/Git)
11
+ # The build script (openenv build) handles context detection and sets appropriate build args.
12
+
13
+ ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
14
+ FROM ${BASE_IMAGE} AS builder
15
+
16
+ WORKDIR /app
17
+
18
+ # Ensure git is available (required for installing dependencies from VCS)
19
+ RUN apt-get update && \
20
+ apt-get install -y --no-install-recommends git && \
21
+ rm -rf /var/lib/apt/lists/*
22
+
23
+ # Build argument to control whether we're building standalone or in-repo
24
+ ARG BUILD_MODE=in-repo
25
+ ARG ENV_NAME=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: gray
5
- colorTo: yellow
6
  sdk: docker
7
  pinned: false
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: 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
+ [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/meta-pytorch/OpenEnv/blob/main/examples/OpenEnv_Tutorial.ipynb)
16
+ [![GitHub](https://img.shields.io/badge/GitHub-meta--pytorch%2FOpenEnv-blue?logo=github)](https://github.com/meta-pytorch/OpenEnv)
17
+ [![License](https://img.shields.io/badge/License-BSD%203--Clause-green.svg)](https://opensource.org/licenses/BSD-3-Clause)
18
+ [![PyTorch](https://img.shields.io/badge/PyTorch-EE4C2C?logo=pytorch&logoColor=white)](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
+ ![WebSocket vs HTTP](../images/websocket.png)
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
+ ![Scaling Expectations](../images/scaling.png)
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
+ ![Max Batch Comparison](https://raw.githubusercontent.com/burtenshaw/openenv-scaling/main/experiments/reports/figures/max_batch_comparison.png)
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
+ ![Batch Per Core](https://raw.githubusercontent.com/burtenshaw/openenv-scaling/main/experiments/reports/figures/batch_per_core.png)
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
+ ![Scaling Comparison](https://raw.githubusercontent.com/burtenshaw/openenv-scaling/main/experiments/reports/figures/scaling_comparison.png)
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
+ ![Latency Heatmap](https://raw.githubusercontent.com/burtenshaw/openenv-scaling/main/experiments/reports/figures/latency_heatmap.png)
415
+ *P99 latency across configurations and batch sizes*
416
+
417
+ ![Scaling Curves](https://raw.githubusercontent.com/burtenshaw/openenv-scaling/main/experiments/reports/figures/scaling_curves.png)
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
+ [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://github.com/huggingface/trl/blob/main/examples/notebooks/openenv_wordle_grpo.ipynb)
4
+
5
+ ![trl banner](https://huggingface.co/datasets/trl-lib/documentation-images/resolve/main/trl_banner_dark.png)
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