orchid_env / notebook.md
cytoe's picture
Upload folder using huggingface_hub
0473e28 verified
|
Raw
History Blame Contribute Delete
12.4 kB
# Module 4: Build a Word Game Environment
Build a letter-guessing (Hangman-style) environment from scratch using the OpenEnv pattern.
**Time:** ~30 min · **Difficulty:** Intermediate · **GPU:** Not required
```python
!pip install -q openenv-core
!git clone --depth=1 -q https://github.com/meta-pytorch/OpenEnv.git 2>/dev/null || true
import sys, os
repo = os.path.abspath('OpenEnv')
for p in [repo, os.path.join(repo, 'src')]:
if p not in sys.path:
sys.path.insert(0, p)
print("Setup complete!")
```
## 1. Define the Types
Every OpenEnv environment starts with its data contracts: what actions can you take, what do you observe, what metadata exists?
```python
from dataclasses import dataclass, field
from typing import List, Optional, Dict, Any
# These would normally go in models.py
@dataclass
class WordGameAction:
"""Player guesses a single letter."""
guess: str
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
class WordGameObservation:
"""What the player sees after each guess."""
done: bool
reward: Optional[float]
masked_word: str # e.g., "p_th_n"
guessed_letters: List[str] # All letters tried
attempts_remaining: int
message: str # Feedback text
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
class WordGameState:
"""Episode metadata."""
episode_id: Optional[str] = None
step_count: int = 0
target_word: str = ""
max_attempts: int = 6
print("Types defined: WordGameAction, WordGameObservation, WordGameState")
```
## 2. Implement the Environment
The environment implements three methods: `reset()`, `step()`, and `state`. This is where the game logic lives.
```python
import random
import uuid
WORDS = [
"python", "neural", "tensor", "matrix", "vector",
"kernel", "lambda", "signal", "binary", "cipher",
"model", "layer", "epoch", "batch", "token",
]
class WordGameEnvironment:
"""A letter-guessing game environment following the OpenEnv pattern."""
def __init__(self):
self._state = WordGameState()
self._target = ""
self._guessed = set()
self._remaining = 6
def reset(self) -> WordGameObservation:
"""Start a new episode with a random word."""
self._target = random.choice(WORDS)
self._guessed = set()
self._remaining = 10
self._state = WordGameState(
episode_id=str(uuid.uuid4()),
step_count=0,
target_word=self._target,
max_attempts=10,
)
return WordGameObservation(
done=False,
reward=None,
masked_word=self._mask(),
guessed_letters=[],
attempts_remaining=self._remaining,
message=f"Guess letters in a {len(self._target)}-letter word!",
)
def step(self, action: WordGameAction) -> WordGameObservation:
"""Process a letter guess."""
letter = action.guess.lower().strip()
self._state.step_count += 1
# Already guessed?
if letter in self._guessed:
return WordGameObservation(
done=False,
reward=0.0,
masked_word=self._mask(),
guessed_letters=sorted(self._guessed),
attempts_remaining=self._remaining,
message=f"Already guessed '{letter}'. Try another.",
)
self._guessed.add(letter)
if letter in self._target:
message = f"'{letter}' is in the word!"
else:
self._remaining -= 1
message = f"'{letter}' is not in the word."
# Check win/lose
masked = self._mask()
won = "_" not in masked
lost = self._remaining <= 0
done = won or lost
if won:
reward = 1.0
message = f"You got it! The word was '{self._target}'."
elif lost:
reward = 0.0
message = f"Out of attempts. The word was '{self._target}'."
else:
reward = 0.0
return WordGameObservation(
done=done,
reward=reward,
masked_word=masked,
guessed_letters=sorted(self._guessed),
attempts_remaining=self._remaining,
message=message,
)
@property
def state(self) -> WordGameState:
return self._state
def _mask(self) -> str:
"""Show guessed letters, hide the rest."""
return "".join(c if c in self._guessed else "_" for c in self._target)
print("WordGameEnvironment defined.")
```
## 3. Test the Environment Directly
Before wiring up HTTP, test the pure game logic.
```python
env = WordGameEnvironment()
obs = env.reset()
print(f"Word: {obs.masked_word} ({len(obs.masked_word)} letters)")
print(f"Message: {obs.message}")
print(f"Attempts: {obs.attempts_remaining}")
print()
# Play with common letters
for letter in ["e", "a", "t", "n", "o", "r", "s", "i", "l"]:
if obs.done:
break
obs = env.step(WordGameAction(guess=letter))
print(f" Guess '{letter}': {obs.masked_word} ({obs.message})")
print(f"\nFinal: reward={obs.reward}, done={obs.done}")
print(f"State: episode={env.state.episode_id[:8]}..., steps={env.state.step_count}")
```
## 4. Write Policies
Let's write two policies and compare them.
```python
import string
class RandomLetterPolicy:
"""Guess random unused letters."""
name = "Random"
def select_action(self, obs: WordGameObservation) -> WordGameAction:
available = [c for c in string.ascii_lowercase if c not in obs.guessed_letters]
return WordGameAction(guess=random.choice(available))
class FrequencyPolicy:
"""Guess by English letter frequency."""
name = "Frequency"
FREQ_ORDER = "etaoinshrdlcumwfgypbvkjxqz"
def select_action(self, obs: WordGameObservation) -> WordGameAction:
for letter in self.FREQ_ORDER:
if letter not in obs.guessed_letters:
return WordGameAction(guess=letter)
return WordGameAction(guess="a") # fallback
def evaluate(env, policy, episodes=100):
wins = 0
total_steps = 0
for _ in range(episodes):
obs = env.reset()
while not obs.done:
action = policy.select_action(obs)
obs = env.step(action)
if obs.reward and obs.reward > 0:
wins += 1
total_steps += env.state.step_count
return wins / episodes, total_steps / episodes
env = WordGameEnvironment()
for policy in [RandomLetterPolicy(), FrequencyPolicy()]:
win_rate, avg_steps = evaluate(env, policy)
print(f"{policy.name:15s} — Win rate: {win_rate*100:.1f}%, Avg steps: {avg_steps:.1f}")
```
Frequency should significantly outperform random. With technical vocabulary and individual letter guessing, both win rates are modest — but Frequency is typically 5–10× better than Random. Increase `max_attempts` in `WordGameEnvironment` (e.g. to 15) to see higher absolute win rates.
## 5. Wire Up FastAPI
In a real deployment, you'd create `server/app.py` with:
```python
from openenv.core.env_server import create_fastapi_app
from environment import WordGameEnvironment
app = create_fastapi_app(WordGameEnvironment)
```
That single call creates all endpoints: `/ws`, `/reset`, `/step`, `/state`, `/health`, `/web`, `/docs`.
Let's simulate the server locally to demonstrate the full stack.
```python
# Write the environment files to disk for deployment
import os
os.makedirs('word_game/server', exist_ok=True)
# models.py — uses Pydantic (Action, Observation, State are Pydantic BaseModel subclasses)
models_code = '''
from typing import List, Optional
from openenv.core.env_server import Action, Observation, State
class WordGameAction(Action):
"""Player guesses a single letter."""
guess: str
class WordGameObservation(Observation):
"""What the player sees after each guess.
Note: done and reward are inherited from Observation.
"""
masked_word: str # e.g. "p_th_n"
guessed_letters: List[str] # All letters tried
attempts_remaining: int
message: str # Feedback text
class WordGameState(State):
"""Episode metadata.
Note: episode_id and step_count are inherited from State.
"""
target_word: str = ""
max_attempts: int = 6
'''
with open('word_game/models.py', 'w') as f:
f.write(models_code)
# client.py — uses EnvClient (WebSocket-based)
client_code = '''
from openenv.core.env_client import EnvClient
from openenv.core.client_types import StepResult
from .models import WordGameAction, WordGameObservation, WordGameState
class WordGameEnv(EnvClient[WordGameAction, WordGameObservation, WordGameState]):
def _step_payload(self, action: WordGameAction) -> dict:
return {"guess": action.guess}
def _parse_result(self, payload: dict) -> StepResult:
obs_data = payload.get("observation", {})
return StepResult(
observation=WordGameObservation(
done=payload.get("done", False),
reward=payload.get("reward"),
masked_word=obs_data.get("masked_word", ""),
guessed_letters=obs_data.get("guessed_letters", []),
attempts_remaining=obs_data.get("attempts_remaining", 0),
message=obs_data.get("message", ""),
),
reward=payload.get("reward"),
done=payload.get("done", False),
)
def _parse_state(self, payload: dict) -> WordGameState:
return WordGameState(
episode_id=payload.get("episode_id"),
step_count=payload.get("step_count", 0),
target_word=payload.get("target_word", ""),
max_attempts=payload.get("max_attempts", 6),
)
'''
with open('word_game/client.py', 'w') as f:
f.write(client_code)
# server/app.py
app_code = '''
from openenv.core.env_server import create_fastapi_app
from ..models import WordGameAction, WordGameObservation
from .environment import WordGameEnvironment
app = create_fastapi_app(WordGameEnvironment, WordGameAction, WordGameObservation)
'''
with open('word_game/server/app.py', 'w') as f:
f.write(app_code)
print('Created word_game/models.py (Pydantic models)')
print('Created word_game/client.py (EnvClient subclass)')
print('Created word_game/server/app.py')
print()
print('Next steps:')
print(' 1. Add server/environment.py with WordGameEnvironment class')
print(' 2. Test locally: uvicorn word_game.server.app:app --reload')
print(' 3. Deploy: openenv push --repo-id username/word-game')
```
## 6. The Client
The client translates between your typed models and JSON over the wire. Three methods:
```python
class WordGameEnv(EnvClient[WordGameAction, WordGameObservation, WordGameState]):
def _step_payload(self, action):
return {"guess": action.guess}
def _parse_result(self, payload):
return StepResult(
observation=WordGameObservation(**payload),
reward=payload.get("reward", 0),
done=payload["done"],
)
def _parse_state(self, payload):
return WordGameState(**payload)
```
Users of your environment would then write:
```python
from word_game import WordGameEnv, WordGameAction
with WordGameEnv(base_url="https://username-word-game.hf.space").sync() as env:
result = env.reset()
result = env.step(WordGameAction(guess="e"))
print(result.observation.masked_word)
```
## 7. Scaffold with `openenv init`
Instead of writing everything by hand, use the CLI:
```bash
openenv init word_game
cd word_game
# Edit models.py, server/environment.py, client.py
uv run server # Test locally
openenv push # Deploy to HF Spaces
```
This creates the full directory structure. You fill in your types and game logic.
## Summary
You built a complete OpenEnv environment:
| File | What it does | Lines of code |
|------|-------------|---------------|
| `models.py` | Action, Observation, State types | ~30 |
| `server/environment.py` | Game logic (reset, step, state) | ~60 |
| `client.py` | HTTP client (3 parsing methods) | ~25 |
| `server/app.py` | FastAPI wiring | ~3 |
The pattern is always the same: **types → server logic → client → container**.
**Next:** [Module 5](../module-5/README.md) — Training a model to play games with GRPO.