Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- CLAUDE.md +247 -0
- Dockerfile +81 -0
- README.md +443 -10
- __init__.py +23 -0
- client.py +109 -0
- inference.py +383 -0
- models.py +201 -0
- openenv.yaml +7 -0
- pyproject.toml +47 -0
- server/StoreManager_environment.py +681 -0
- server/__init__.py +11 -0
- server/app.py +104 -0
- server/requirements.txt +9 -0
- tasks.py +139 -0
- uv.lock +0 -0
CLAUDE.md
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CLAUDE.md
|
| 2 |
+
|
| 3 |
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## Hackathon Submission Requirements
|
| 8 |
+
|
| 9 |
+
This project is being submitted to a hackathon. Every change must keep the following requirements satisfied β never regress them.
|
| 10 |
+
|
| 11 |
+
### Mandatory env variables (inference.py must use exactly these names)
|
| 12 |
+
|
| 13 |
+
| Variable | Purpose |
|
| 14 |
+
|---|---|
|
| 15 |
+
| `HF_TOKEN` | API key passed to OpenAI client (`api_key=`) |
|
| 16 |
+
| `API_BASE_URL` | LLM base URL passed to OpenAI client (`base_url=`) |
|
| 17 |
+
| `MODEL_NAME` | Model identifier used for all LLM calls |
|
| 18 |
+
|
| 19 |
+
`STORE_ENV_URL` is a separate optional variable for the environment server URL (default: `http://localhost:8000`). Do not confuse it with `API_BASE_URL`.
|
| 20 |
+
|
| 21 |
+
### Structured log format (exact field names required β do not rename)
|
| 22 |
+
|
| 23 |
+
```
|
| 24 |
+
[START] {"task": "...", "seed": N, "model": "...", "max_steps": N, "profit_target": N}
|
| 25 |
+
[STEP] {"task": "...", "step": N, "action": {...}, "reward": ..., "cumulative_profit": ...}
|
| 26 |
+
[END] {"task": "...", "cumulative_profit": ..., "score": ..., "profit_target": ...}
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
Extra fields in `[STEP]` (e.g. `holding_cost`, `placement_cost`, `restock_cost`) are allowed but the above fields must always be present with exact names.
|
| 30 |
+
|
| 31 |
+
### Pre-submission checklist
|
| 32 |
+
|
| 33 |
+
- [ ] `HF_TOKEN`, `API_BASE_URL`, `MODEL_NAME` read by inference.py β
|
| 34 |
+
- [ ] `inference.py` in root directory β
|
| 35 |
+
- [ ] `[START]`/`[STEP]`/`[END]` logs emitted to stdout β
|
| 36 |
+
- [ ] 3 tasks (easy/medium/hard) with deterministic graders scoring 0.0β1.0 β
|
| 37 |
+
- [ ] `openenv.yaml` present and valid β
|
| 38 |
+
- [ ] `server/Dockerfile` builds cleanly β
|
| 39 |
+
- [ ] `reset()`, `step()`, `state()` endpoints exposed via `create_app()` β
|
| 40 |
+
- [ ] Typed Pydantic models for Action, Observation, State β
|
| 41 |
+
- [ ] Meaningful reward function (non-sparse, multiple components) β
|
| 42 |
+
- [ ] README has: env description, action/obs space, task descriptions, setup instructions, baseline scores β
|
| 43 |
+
- [ ] Inference runtime < 20 min (3 tasks Γ β€30 steps Γ fast model) β
|
| 44 |
+
- [ ] Runs on vcpu=2, memory=8gb (pure Python, no GPU dependencies) β
|
| 45 |
+
|
| 46 |
+
### Observation extraction in inference.py (do not regress)
|
| 47 |
+
|
| 48 |
+
The WebSocket server sends: `{"type": "observation", "data": {"observation": {...}, "done": bool, "reward": float}}`.
|
| 49 |
+
|
| 50 |
+
The `_unpack(msg)` helper in `inference.py` correctly extracts the inner observation and injects `done`/`reward` at the top level. **Never replace this with `msg.get("data", msg)` directly** β that returns the outer wrapper and `obs.get("inventory", [])` would always be `[]`, scoring every task 0.0.
|
| 51 |
+
|
| 52 |
+
### Scoring weights (for prioritizing what to improve)
|
| 53 |
+
|
| 54 |
+
| Criterion | Weight |
|
| 55 |
+
|---|---|
|
| 56 |
+
| Real-world utility | 30% |
|
| 57 |
+
| Task & grader quality | 25% |
|
| 58 |
+
| Environment design | 20% |
|
| 59 |
+
| Code quality & spec compliance | 15% |
|
| 60 |
+
| Creativity & novelty | 10% |
|
| 61 |
+
|
| 62 |
+
---
|
| 63 |
+
|
| 64 |
+
## Project Goal
|
| 65 |
+
|
| 66 |
+
This is an **OpenEnv** reinforcement learning environment simulating a retail store manager. The RL task (see [docs/PROBLEM.md](docs/PROBLEM.md)) involves an LLM agent that manages store inventory to maximize profit. The agent can apply discounts, restock inventory, and move products between shelf zones. The environment is fully implemented in [server/StoreManager_environment.py](server/StoreManager_environment.py).
|
| 67 |
+
|
| 68 |
+
## Common Commands
|
| 69 |
+
|
| 70 |
+
```bash
|
| 71 |
+
# Install dependencies
|
| 72 |
+
uv sync
|
| 73 |
+
|
| 74 |
+
# Run dev server (with auto-reload)
|
| 75 |
+
uvicorn server.app:app --reload
|
| 76 |
+
|
| 77 |
+
# Run server directly
|
| 78 |
+
uv run --project . server
|
| 79 |
+
python -m StoreManager.server.app --port 8000
|
| 80 |
+
|
| 81 |
+
# Test environment logic directly (no HTTP server needed)
|
| 82 |
+
python3 server/StoreManager_environment.py
|
| 83 |
+
|
| 84 |
+
# Build Docker image
|
| 85 |
+
docker build -t StoreManager-env:latest -f server/Dockerfile .
|
| 86 |
+
|
| 87 |
+
# Deploy to Hugging Face Spaces
|
| 88 |
+
openenv push
|
| 89 |
+
openenv push --repo-id my-org/my-env --private
|
| 90 |
+
|
| 91 |
+
# Run tests
|
| 92 |
+
pytest
|
| 93 |
+
pytest --cov
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
## Architecture
|
| 97 |
+
|
| 98 |
+
The codebase follows the OpenEnv pattern: a FastAPI server wraps a Python environment class, and a typed client communicates via WebSocket.
|
| 99 |
+
|
| 100 |
+
**Data flow:**
|
| 101 |
+
1. Client (`client.py` β `StoremanagerEnv`) sends actions over a persistent WebSocket
|
| 102 |
+
2. Server (`server/app.py`) receives them via `openenv.core.env_server.http_server.create_app`
|
| 103 |
+
3. Environment (`server/StoreManager_environment.py` β `StoremanagerEnvironment`) processes actions and returns observations
|
| 104 |
+
4. Models (`models.py`) define the Pydantic schemas for `StoremanagerAction` and `StoremanagerObservation`
|
| 105 |
+
|
| 106 |
+
**Concurrency:** `SUPPORTS_CONCURRENT_SESSIONS = True` in the environment class; `max_concurrent_envs=1` in `server/app.py` (increase for parallel sessions).
|
| 107 |
+
|
| 108 |
+
**Package layout:** The root directory is both the Python package root (`StoreManager/`) and the Docker build context. `PYTHONPATH=/app/env` is set in the container so imports resolve correctly.
|
| 109 |
+
|
| 110 |
+
---
|
| 111 |
+
|
| 112 |
+
## Implemented Mechanics
|
| 113 |
+
|
| 114 |
+
### Core: Discount + Customer Simulation + Expiry
|
| 115 |
+
|
| 116 |
+
The base mechanic. Each step the agent discounts one product; customers sample products weighted by `base_probability Γ zone_multiplier Γ (1 + discount_pct / 100)`. Products that reach their `expiry_step` are removed and trigger a penalty of `remaining_quantity Γ cost_price`.
|
| 117 |
+
|
| 118 |
+
### Feature 1: Restocking / Procurement
|
| 119 |
+
|
| 120 |
+
**What it models:** The agent can replenish stock by placing a purchase order. Stock doesn't arrive instantly β there is a configurable lead time. Holding inventory costs money every step regardless of whether it sells.
|
| 121 |
+
|
| 122 |
+
**Action format:**
|
| 123 |
+
```json
|
| 124 |
+
{ "action_type": "restock", "product_id": 1, "restock_quantity": 15 }
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
**How it works in the environment (`server/StoreManager_environment.py`):**
|
| 128 |
+
|
| 129 |
+
- `_pending_orders` is a list of dicts `{product_id, quantity, arrive_at_step}` maintained on the environment instance.
|
| 130 |
+
- At the **start** of every `step()` call, pending orders whose `arrive_at_step <= _current_step` are delivered: `product.quantity += order["quantity"]` and the pending fields are cleared.
|
| 131 |
+
- When a `"restock"` action is dispatched, a new entry is appended to `_pending_orders` with `arrive_at_step = _current_step + product.restock_lead_time`. The cost `restock_quantity Γ restock_cost_per_unit` is charged immediately from this step's profit.
|
| 132 |
+
- `holding_cost` is computed every step for all active products: `sum(p.quantity Γ p.cost_price Γ p.holding_cost_rate for all active p)`. This is always charged, regardless of action type.
|
| 133 |
+
|
| 134 |
+
**Per-product restock parameters (set randomly in `reset()`):**
|
| 135 |
+
|
| 136 |
+
| Field | Range | Description |
|
| 137 |
+
|---|---|---|
|
| 138 |
+
| `restock_lead_time` | 1β3 steps | Steps until a placed order is delivered |
|
| 139 |
+
| `restock_cost_per_unit` | `cost_price Γ 1.00β1.25` | Cost to order one additional unit |
|
| 140 |
+
| `holding_cost_rate` | 0.01β0.04 | Fraction of `cost_price` charged per unit per step |
|
| 141 |
+
|
| 142 |
+
**Observation fields added:**
|
| 143 |
+
|
| 144 |
+
| Field | Description |
|
| 145 |
+
|---|---|
|
| 146 |
+
| `restock_cost_this_step` | Ordering cost charged this step (0 if no restock) |
|
| 147 |
+
| `holding_cost_this_step` | Total holding cost charged this step |
|
| 148 |
+
| `pending_restock_quantity` (on ProductState) | Units currently on order |
|
| 149 |
+
| `pending_restock_arrives_at_step` (on ProductState) | Step when pending order delivers |
|
| 150 |
+
|
| 151 |
+
**Error cases:** Returns an error observation (step does not advance) if:
|
| 152 |
+
- `restock_quantity` is None or β€ 0
|
| 153 |
+
- The product already has a pending order (`pending_restock_quantity > 0`)
|
| 154 |
+
|
| 155 |
+
**Strategic tension:** Over-restocking inflates holding costs and risks expiry penalties if the new stock can't sell through. Under-restocking leads to stockouts on high-demand products.
|
| 156 |
+
|
| 157 |
+
---
|
| 158 |
+
|
| 159 |
+
### Feature 2: Zone / Shelf Placement
|
| 160 |
+
|
| 161 |
+
**What it models:** The physical location of a product on the shelf affects how often customers pick it. Premium zones (entrance, eye-level) get more foot traffic. Moving products costs staff labor.
|
| 162 |
+
|
| 163 |
+
**Action format:**
|
| 164 |
+
```json
|
| 165 |
+
{ "action_type": "place", "product_id": 3, "target_zone": 1 }
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
**How it works in the environment:**
|
| 169 |
+
|
| 170 |
+
- Each `ProductState` carries a `zone: int` field (1, 2, or 3).
|
| 171 |
+
- When a `"place"` action is dispatched, the product's zone is updated and `PLACEMENT_COST = $3.00` is deducted from this step's profit.
|
| 172 |
+
- The pick-probability formula uses the zone multiplier: `effective_weight[i] = base_probability[i] Γ ZONE_MULTIPLIERS[zone[i]] Γ (1 + discount_pct[i] / 100)`.
|
| 173 |
+
|
| 174 |
+
**Zone constants (defined at the top of `StoreManager_environment.py`):**
|
| 175 |
+
|
| 176 |
+
```python
|
| 177 |
+
ZONE_MULTIPLIERS = {1: 1.5, 2: 1.0, 3: 0.6}
|
| 178 |
+
ZONE_CAPACITY = {1: 2, 2: 4, 3: 99}
|
| 179 |
+
PLACEMENT_COST = 3.0
|
| 180 |
+
```
|
| 181 |
+
|
| 182 |
+
**Initial zone assignment in `reset()`:** Products are assigned zones greedily in order β first 2 products go to zone 1, next 4 to zone 2, remainder to zone 3. This means zone 1 is always full at episode start.
|
| 183 |
+
|
| 184 |
+
**Observation fields added:**
|
| 185 |
+
|
| 186 |
+
| Field | Description |
|
| 187 |
+
|---|---|
|
| 188 |
+
| `zone_capacity` | `{1: 2, 2: 4, 3: 99}` β max products per zone |
|
| 189 |
+
| `zone_occupancy` | `{1: N, 2: N, 3: N}` β active products currently in each zone |
|
| 190 |
+
| `zone_multipliers` | `{1: 1.5, 2: 1.0, 3: 0.6}` β pick-prob multipliers |
|
| 191 |
+
| `placement_cost_this_step` | $3.00 if a placement was made, else $0.00 |
|
| 192 |
+
| `zone` (on ProductState) | Current zone of the product |
|
| 193 |
+
|
| 194 |
+
**Error cases:** Returns an error observation (step does not advance) if:
|
| 195 |
+
- `target_zone` is None or not in {1, 2, 3}
|
| 196 |
+
- The product is already in `target_zone`
|
| 197 |
+
- `target_zone` is already at capacity (`zone_occupancy[target_zone] >= ZONE_CAPACITY[target_zone]`)
|
| 198 |
+
|
| 199 |
+
**Strategic tension:** Zone 1 has only 2 slots but gives a 1.5Γ boost β the agent must decide which products deserve premium placement at any given moment. Moving a product out to make room for a more urgent one costs $3.00 each way.
|
| 200 |
+
|
| 201 |
+
---
|
| 202 |
+
|
| 203 |
+
## Reward Formula (full)
|
| 204 |
+
|
| 205 |
+
```
|
| 206 |
+
step_reward = step_revenue
|
| 207 |
+
β cost_of_goods_sold
|
| 208 |
+
β expiry_penalty # remaining_qty Γ cost_price for expired products
|
| 209 |
+
β holding_cost # Ξ£(qty Γ cost_price Γ holding_cost_rate) all active products
|
| 210 |
+
β placement_cost # $3.00 flat, only if action_type == "place"
|
| 211 |
+
β restock_cost # restock_qty Γ restock_cost_per_unit, only if action_type == "restock"
|
| 212 |
+
```
|
| 213 |
+
|
| 214 |
+
All components are available individually in the `metadata` dict of each observation.
|
| 215 |
+
|
| 216 |
+
---
|
| 217 |
+
|
| 218 |
+
## Key Files
|
| 219 |
+
|
| 220 |
+
| File | Role |
|
| 221 |
+
|---|---|
|
| 222 |
+
| [models.py](models.py) | Pydantic schemas: `ProductState`, `StoremanagerAction`, `StoremanagerObservation`, `StoremanagerState` |
|
| 223 |
+
| [server/StoreManager_environment.py](server/StoreManager_environment.py) | Full environment logic: `reset()`, `step()`, zone constants, pending orders |
|
| 224 |
+
| [client.py](client.py) | WebSocket client: `_step_payload()`, `_parse_result()`, `_parse_state()` |
|
| 225 |
+
| [inference.py](inference.py) | LLM agent runner: system prompt, inventory table, action parsing for all 3 types |
|
| 226 |
+
| [tasks.py](tasks.py) | Task configs (easy/medium/hard) + `grade()` function |
|
| 227 |
+
|
| 228 |
+
---
|
| 229 |
+
|
| 230 |
+
## OpenEnv Framework
|
| 231 |
+
|
| 232 |
+
- Environments inherit from `openenv.core.env_server.interfaces.Environment`
|
| 233 |
+
- Actions/Observations inherit from `openenv.core.env_server.types.Action` / `Observation`
|
| 234 |
+
- `openenv.yaml` declares the runtime config; `create_app()` auto-generates `/reset`, `/step`, `/state`, `/schema` HTTP endpoints and a `/ws` WebSocket endpoint
|
| 235 |
+
- The client base class `EnvClient` (from `openenv.core`) requires implementing `_step_payload()`, `_parse_result()`, and `_parse_state()`
|
| 236 |
+
|
| 237 |
+
## Reference Implementation: Wordle
|
| 238 |
+
|
| 239 |
+
[Examples/wordle/](Examples/wordle/) is a complete, working OpenEnv environment. Key things to learn from it:
|
| 240 |
+
|
| 241 |
+
- **Richer models** ([Examples/wordle/models.py](Examples/wordle/models.py)): Shows how to add a `State` class with custom fields and nested sub-models inside an `Observation`.
|
| 242 |
+
- **Non-trivial environment logic** ([Examples/wordle/server/environment.py](Examples/wordle/server/environment.py)): Shows how to manage external state, clear accumulated state on `reset()`, compute rewards, and snapshot internal state for the `state` property.
|
| 243 |
+
- **Reward providers** ([Examples/wordle/rewards.py](Examples/wordle/rewards.py)): A clean pattern for separating reward computation into pluggable `RewardProvider` classes.
|
| 244 |
+
- **Factory mode for concurrency** ([Examples/wordle/server/app.py](Examples/wordle/server/app.py)): Passes a factory function instead of a class to `create_app()` when environment construction needs arguments.
|
| 245 |
+
- **OpenEnv core source** ([Examples/wordle/src/core/](Examples/wordle/src/core/)): The full source of `openenv-core` is vendored here.
|
| 246 |
+
|
| 247 |
+
Also see [.claude/docs/PATTERNS.md](.claude/docs/PATTERNS.md) for the canonical OpenEnv coding conventions.
|
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=StoreManager
|
| 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,443 @@
|
|
| 1 |
-
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: docker
|
| 7 |
-
pinned: false
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Store Manager Environment
|
| 3 |
+
emoji: π
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
app_port: 8000
|
| 9 |
+
base_path: /web
|
| 10 |
+
tags:
|
| 11 |
+
- openenv
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# Store Manager Environment
|
| 15 |
+
|
| 16 |
+
A retail store simulator where an LLM agent acts as the store manager. The agent observes full inventory state each step and chooses one action β discount a product, restock inventory, or move a product to a better shelf zone β to influence customer demand and maximize cumulative profit over a fixed episode horizon.
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
## What Makes This Environment Interesting
|
| 21 |
+
|
| 22 |
+
This environment models three interconnected real-world retail mechanics that require the agent to reason about **compounding consequences**:
|
| 23 |
+
|
| 24 |
+
- **Discounts are permanent within an episode.** Every discount cuts the current (already-discounted) price further. A 20% discount applied twice yields 36% off the original price. The agent must decide how aggressively to discount β deep cuts clear stock fast but permanently erode margin.
|
| 25 |
+
- **Restocking has a time lag.** Stock doesn't arrive instantly. Each product has a `restock_lead_time` (1β3 steps). The agent must plan ahead β ordering too late leaves the shelf empty; ordering too much risks expiry penalties and inflated holding costs.
|
| 26 |
+
- **Zone placement directly multiplies pick probability.** Zone 1 (premium) boosts visibility 1.5Γ, but only holds 2 products. The agent must continuously rotate which products deserve premium placement.
|
| 27 |
+
|
| 28 |
+
These three mechanics combine into a rich multi-step decision problem where each action affects future state: a discount today changes tomorrow's revenue; a restock today affects holding costs for the next several steps; a zone move today costs $3 but can double a product's sell-through rate.
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## Task Overview
|
| 33 |
+
|
| 34 |
+
| Component | Description |
|
| 35 |
+
|---|---|
|
| 36 |
+
| **Products** | N products, each with cost price, selling price, quantity, expiry step, shelf zone, and a base pick probability |
|
| 37 |
+
| **Customers** | Fixed number of customers per step pick products according to a normalized probability distribution |
|
| 38 |
+
| **Agent Action** | Exactly one of three action types per step: `discount`, `restock`, or `place` |
|
| 39 |
+
| **Reward** | Normalized sigmoid reward in `[0.0, 1.0]` based on step profit vs. per-step target |
|
| 40 |
+
| **Expiry penalty** | `remaining_quantity Γ cost_price` charged when a product reaches its expiry step |
|
| 41 |
+
| **Episode end** | After `max_steps` steps, or when all products are inactive |
|
| 42 |
+
|
| 43 |
+
### Pick Probability Formula
|
| 44 |
+
|
| 45 |
+
Customer pick probability combines zone placement and discount multiplier:
|
| 46 |
+
|
| 47 |
+
```
|
| 48 |
+
effective_weight[i] = base_probability[i] Γ zone_multiplier[zone[i]] Γ (1 + discount_pct[i] / 100)
|
| 49 |
+
pick_prob[i] = effective_weight[i] / sum(effective_weight for all active products)
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
---
|
| 53 |
+
|
| 54 |
+
## Actions
|
| 55 |
+
|
| 56 |
+
The agent submits exactly one action per step.
|
| 57 |
+
|
| 58 |
+
### 1. Discount
|
| 59 |
+
|
| 60 |
+
Reduce the selling price of a product. Discounts **compound across steps within an episode** β each discount cuts the current price further, not the original base price.
|
| 61 |
+
|
| 62 |
+
```json
|
| 63 |
+
{ "action_type": "discount", "product_id": 2, "discount_pct": 20 }
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
| Field | Type | Values |
|
| 67 |
+
|---|---|---|
|
| 68 |
+
| `action_type` | string | `"discount"` |
|
| 69 |
+
| `product_id` | int | any valid product ID |
|
| 70 |
+
| `discount_pct` | int | `0`, `10`, `20`, or `50` |
|
| 71 |
+
|
| 72 |
+
**Compounding example** (base price $10.00):
|
| 73 |
+
|
| 74 |
+
| Step | Discount | Selling Price | Total off base |
|
| 75 |
+
|------|----------|--------------|----------------|
|
| 76 |
+
| 1 | 20% | $8.00 | β20% |
|
| 77 |
+
| 2 | 20% | $6.40 | β36% |
|
| 78 |
+
| 3 | 20% | $5.12 | β49% |
|
| 79 |
+
| 4 | 50% | $2.56 | β74% |
|
| 80 |
+
|
| 81 |
+
- `base_selling_price` is never modified β it serves as a reference for how far prices have been cut.
|
| 82 |
+
- `current_discount_pct` reflects only the discount tier applied **this step** (used in pick-probability formula).
|
| 83 |
+
- Prices reset to `base_selling_price` at the start of each new episode (`reset()`).
|
| 84 |
+
- A `0%` discount leaves the price unchanged but still advances the step.
|
| 85 |
+
|
| 86 |
+
**When to use:** Apply deep discounts to near-expiry stock to clear inventory before the expiry penalty hits. Use light discounts on high-margin products to drive volume without destroying margin.
|
| 87 |
+
|
| 88 |
+
---
|
| 89 |
+
|
| 90 |
+
### 2. Restock
|
| 91 |
+
|
| 92 |
+
Place a purchase order for more units of a product. Stock arrives after `restock_lead_time` steps. The ordering cost is charged **immediately** this step.
|
| 93 |
+
|
| 94 |
+
```json
|
| 95 |
+
{ "action_type": "restock", "product_id": 1, "restock_quantity": 15 }
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
| Field | Type | Values |
|
| 99 |
+
|---|---|---|
|
| 100 |
+
| `action_type` | string | `"restock"` |
|
| 101 |
+
| `product_id` | int | any valid product ID |
|
| 102 |
+
| `restock_quantity` | int | any positive integer |
|
| 103 |
+
|
| 104 |
+
**How it works:**
|
| 105 |
+
- On restock: `pending_restock_quantity = restock_quantity`, `pending_restock_arrives_at_step = current_step + restock_lead_time`
|
| 106 |
+
- When delivery step is reached: `quantity += restock_quantity`, pending fields cleared
|
| 107 |
+
- Only **one pending order per product** at a time β wait for delivery before reordering
|
| 108 |
+
|
| 109 |
+
**Costs:**
|
| 110 |
+
- `restock_cost = restock_quantity Γ restock_cost_per_unit` charged upfront this step
|
| 111 |
+
- `holding_cost = quantity Γ cost_price Γ holding_cost_rate` charged every step on all stock regardless of action
|
| 112 |
+
|
| 113 |
+
**Per-product restock parameters (randomised at episode start):**
|
| 114 |
+
|
| 115 |
+
| Field | Range | Description |
|
| 116 |
+
|---|---|---|
|
| 117 |
+
| `restock_lead_time` | 1β3 steps | Steps until delivery |
|
| 118 |
+
| `restock_cost_per_unit` | `cost_price Γ 1.00β1.25` | Ordering cost per unit |
|
| 119 |
+
| `holding_cost_rate` | 0.01β0.04 | Fraction of `cost_price` charged per unit per step |
|
| 120 |
+
|
| 121 |
+
**Error cases:** Invalid if `restock_quantity β€ 0` or product already has a pending order.
|
| 122 |
+
|
| 123 |
+
**When to use:** Replenish a fast-selling product before it stocks out, only when enough steps remain to sell through the new inventory before expiry.
|
| 124 |
+
|
| 125 |
+
---
|
| 126 |
+
|
| 127 |
+
### 3. Place (Zone Placement)
|
| 128 |
+
|
| 129 |
+
Move a product to a different shelf zone. Takes effect immediately. Charges a flat **labor fee of $3.00** per move.
|
| 130 |
+
|
| 131 |
+
```json
|
| 132 |
+
{ "action_type": "place", "product_id": 3, "target_zone": 1 }
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
| Field | Type | Values |
|
| 136 |
+
|---|---|---|
|
| 137 |
+
| `action_type` | string | `"place"` |
|
| 138 |
+
| `product_id` | int | any valid product ID |
|
| 139 |
+
| `target_zone` | int | `1`, `2`, or `3` |
|
| 140 |
+
|
| 141 |
+
**Zone multipliers and capacity:**
|
| 142 |
+
|
| 143 |
+
| Zone | Location | Pick Probability Multiplier | Max Products |
|
| 144 |
+
|------|----------|-----------------------------|--------------|
|
| 145 |
+
| 1 | Premium / entrance / eye-level | **Γ1.5** | 2 |
|
| 146 |
+
| 2 | Standard shelves | **Γ1.0** | 4 |
|
| 147 |
+
| 3 | Back / low-traffic | **Γ0.6** | unlimited |
|
| 148 |
+
|
| 149 |
+
**Initial zone assignment at episode start:** products fill zone 1 first (up to capacity 2), then zone 2, remainder in zone 3.
|
| 150 |
+
|
| 151 |
+
**Error cases:** Invalid if `target_zone` is not in {1, 2, 3}, product is already in `target_zone`, or target zone is at capacity.
|
| 152 |
+
|
| 153 |
+
**When to use:** Rotate zone 1 to whichever product most needs visibility β near-expiry items benefit most since clearance speed directly avoids penalty. Demote slow-movers to zone 3 to free up premium space.
|
| 154 |
+
|
| 155 |
+
---
|
| 156 |
+
|
| 157 |
+
## Reward Function
|
| 158 |
+
|
| 159 |
+
Each step returns a **normalized reward in `[0.0, 1.0]`** using a sigmoid centred at break-even:
|
| 160 |
+
|
| 161 |
+
```
|
| 162 |
+
step_profit = step_revenue β cost_of_goods_sold β expiry_penalty
|
| 163 |
+
β holding_cost β placement_cost β restock_cost
|
| 164 |
+
|
| 165 |
+
x = step_profit / per_step_target
|
| 166 |
+
reward = sigmoid(2x) = 1 / (1 + exp(β2x))
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
+
| Step profit vs. target | reward |
|
| 170 |
+
|---|---|
|
| 171 |
+
| Catastrophic loss (ββ) | β 0.0 |
|
| 172 |
+
| Break-even (0) | = 0.50 |
|
| 173 |
+
| On target | β 0.88 |
|
| 174 |
+
| 2Γ target | β 0.98 |
|
| 175 |
+
|
| 176 |
+
The raw per-step cost breakdown is available in `metadata`:
|
| 177 |
+
|
| 178 |
+
```json
|
| 179 |
+
{
|
| 180 |
+
"step_revenue": 45.20,
|
| 181 |
+
"step_cost": 18.40,
|
| 182 |
+
"expiry_penalty": 0.0,
|
| 183 |
+
"holding_cost": 12.35,
|
| 184 |
+
"placement_cost": 3.0,
|
| 185 |
+
"restock_cost": 0.0,
|
| 186 |
+
"step_profit": 11.45
|
| 187 |
+
}
|
| 188 |
+
```
|
| 189 |
+
|
| 190 |
+
---
|
| 191 |
+
|
| 192 |
+
## Difficulty Tasks
|
| 193 |
+
|
| 194 |
+
Four pre-configured tasks with increasing complexity. Each task has a fixed seed for deterministic, reproducible grading.
|
| 195 |
+
|
| 196 |
+
| Task | Products | Steps | Customers/step | Expiry range | Profit target | Dominant mechanic |
|
| 197 |
+
|------|----------|-------|----------------|--------------|--------------|-------------------|
|
| 198 |
+
| **easy** | 4 | 20 | 15 | 18β30 steps | $120 | Discount |
|
| 199 |
+
| **medium** | 8 | 28 | 12 | 12β22 steps | $200 | Discount + zone placement |
|
| 200 |
+
| **hard** | 10 | 30 | 10 | 8β18 steps | $240 | Discount + zone + expiry timing |
|
| 201 |
+
| **expert** | 12 | 25 | 8 | 4β10 steps | $190 | All three actions coordinated |
|
| 202 |
+
|
| 203 |
+
### Task Descriptions
|
| 204 |
+
|
| 205 |
+
**easy** β Products expire well after the episode ends; no expiry pressure. The agent learns to identify high-margin products and apply targeted discounts to boost their pick probability.
|
| 206 |
+
|
| 207 |
+
**medium** β Several products expire mid-episode. Zone 1 has only 2 slots for 8 competing products β the agent must combine discounts with smart zone rotation to clear stock before expiry.
|
| 208 |
+
|
| 209 |
+
**hard** β Most products expire in 8β18 steps. Expiry management is critical: rotate zone 1 frequently to accelerate clearance, and consider restocking fast-sellers that run out early.
|
| 210 |
+
|
| 211 |
+
**expert** β Aggressive expiry (4β10 steps) with limited initial stock. All three actions must be coordinated: discount near-expiry items heavily, restock high-margin products before they stock out, and rotate zone 1 continuously.
|
| 212 |
+
|
| 213 |
+
### Grading
|
| 214 |
+
|
| 215 |
+
```
|
| 216 |
+
score = clamp(cumulative_profit / profit_target, 0.0, 1.0)
|
| 217 |
+
```
|
| 218 |
+
|
| 219 |
+
---
|
| 220 |
+
|
| 221 |
+
## Baseline Scores
|
| 222 |
+
|
| 223 |
+
Expected performance using `gpt-4o-mini` (deterministic, fixed seeds):
|
| 224 |
+
|
| 225 |
+
| Task | Profit Target | Expected Profit | Expected Score |
|
| 226 |
+
|------|--------------|-----------------|----------------|
|
| 227 |
+
| easy | $120 | ~$140β180 | ~0.90β1.0 |
|
| 228 |
+
| medium | $200 | ~$160β220 | ~0.80β1.0 |
|
| 229 |
+
| hard | $240 | ~$180β260 | ~0.75οΏ½οΏ½οΏ½1.0 |
|
| 230 |
+
| expert | $190 | ~$100β170 | ~0.50β0.90 |
|
| 231 |
+
|
| 232 |
+
Scores are fully reproducible. Re-run `inference.py` with the same model and seeds to verify.
|
| 233 |
+
|
| 234 |
+
---
|
| 235 |
+
|
| 236 |
+
## Quick Start
|
| 237 |
+
|
| 238 |
+
```python
|
| 239 |
+
from StoreManager import StoremanagerAction, StoremanagerEnv
|
| 240 |
+
|
| 241 |
+
with StoremanagerEnv(base_url="http://localhost:8000") as env:
|
| 242 |
+
result = env.reset()
|
| 243 |
+
print([p.name for p in result.observation.inventory])
|
| 244 |
+
|
| 245 |
+
# Discount: compounds on current price each time applied
|
| 246 |
+
result = env.step(StoremanagerAction(action_type="discount", product_id=0, discount_pct=20))
|
| 247 |
+
print(f"Reward: {result.reward:.2f}")
|
| 248 |
+
|
| 249 |
+
# Restock: stock arrives after restock_lead_time steps
|
| 250 |
+
result = env.step(StoremanagerAction(action_type="restock", product_id=1, restock_quantity=10))
|
| 251 |
+
print(f"Restock cost: {result.observation.restock_cost_this_step:.2f}")
|
| 252 |
+
|
| 253 |
+
# Place: move product to premium zone (costs $3.00 labor)
|
| 254 |
+
result = env.step(StoremanagerAction(action_type="place", product_id=2, target_zone=1))
|
| 255 |
+
print(f"Placement cost: {result.observation.placement_cost_this_step:.2f}")
|
| 256 |
+
print(f"Zone occupancy: {result.observation.zone_occupancy}")
|
| 257 |
+
```
|
| 258 |
+
|
| 259 |
+
---
|
| 260 |
+
|
| 261 |
+
## Running Locally
|
| 262 |
+
|
| 263 |
+
```bash
|
| 264 |
+
# Install dependencies
|
| 265 |
+
uv sync
|
| 266 |
+
|
| 267 |
+
# Run dev server (auto-reload, default config)
|
| 268 |
+
uvicorn server.app:app --reload
|
| 269 |
+
|
| 270 |
+
# Run with a specific difficulty task baked in at startup
|
| 271 |
+
STORE_TASK=hard uvicorn server.app:app --reload
|
| 272 |
+
|
| 273 |
+
# Test environment logic directly (no server needed)
|
| 274 |
+
python server/StoreManager_environment.py
|
| 275 |
+
|
| 276 |
+
# Run the WebSocket test suite (server must be running)
|
| 277 |
+
python test_ws.py
|
| 278 |
+
```
|
| 279 |
+
|
| 280 |
+
---
|
| 281 |
+
|
| 282 |
+
## Running the Inference Script
|
| 283 |
+
|
| 284 |
+
The `inference.py` script runs an LLM agent through all four tasks sequentially and reports a score for each.
|
| 285 |
+
|
| 286 |
+
### Required Environment Variables
|
| 287 |
+
|
| 288 |
+
| Variable | Description |
|
| 289 |
+
|---|---|
|
| 290 |
+
| `HF_TOKEN` | API key β your Hugging Face token or OpenAI API key |
|
| 291 |
+
| `API_BASE_URL` | LLM API base URL (e.g. `https://api-inference.huggingface.co/v1/`) |
|
| 292 |
+
| `MODEL_NAME` | Model identifier (e.g. `meta-llama/Llama-3.1-8B-Instruct` or `gpt-4o-mini`) |
|
| 293 |
+
|
| 294 |
+
```bash
|
| 295 |
+
# Server must be running first
|
| 296 |
+
uvicorn server.app:app &
|
| 297 |
+
|
| 298 |
+
# Run with Hugging Face Inference API
|
| 299 |
+
HF_TOKEN=hf_... \
|
| 300 |
+
API_BASE_URL=https://api-inference.huggingface.co/v1/ \
|
| 301 |
+
MODEL_NAME=meta-llama/Llama-3.1-8B-Instruct \
|
| 302 |
+
python inference.py
|
| 303 |
+
|
| 304 |
+
# Run with OpenAI (API_BASE_URL defaults to OpenAI when unset)
|
| 305 |
+
HF_TOKEN=sk-... MODEL_NAME=gpt-4o-mini python inference.py
|
| 306 |
+
|
| 307 |
+
# Override the environment server URL
|
| 308 |
+
HF_TOKEN=sk-... MODEL_NAME=gpt-4o-mini STORE_ENV_URL=http://localhost:8000 python inference.py
|
| 309 |
+
```
|
| 310 |
+
|
| 311 |
+
### Structured Log Format
|
| 312 |
+
|
| 313 |
+
```
|
| 314 |
+
[START] {"task": "easy", "seed": 42, "model": "gpt-4o-mini", "max_steps": 20, "profit_target": 120.0}
|
| 315 |
+
[STEP] {"task": "easy", "step": 1, "action": {"action_type": "discount", "product_id": 2, "discount_pct": 20}, "reward": 0.88, "cumulative_profit": 12.5, "holding_cost": 8.2, "placement_cost": 0, "restock_cost": 0}
|
| 316 |
+
...
|
| 317 |
+
[END] {"task": "easy", "cumulative_profit": 145.3, "score": 1.0, "profit_target": 120.0}
|
| 318 |
+
```
|
| 319 |
+
|
| 320 |
+
A single server instance handles all four tasks β the task config is passed in the `reset` message, so no server restart is needed between tasks.
|
| 321 |
+
|
| 322 |
+
---
|
| 323 |
+
|
| 324 |
+
## Action & Observation Reference
|
| 325 |
+
|
| 326 |
+
### Action: `StoremanagerAction`
|
| 327 |
+
|
| 328 |
+
| Field | Type | Default | Description |
|
| 329 |
+
|---|---|---|---|
|
| 330 |
+
| `action_type` | `"discount"` \| `"restock"` \| `"place"` | `"discount"` | Type of action |
|
| 331 |
+
| `product_id` | int | required | ID of the product to act on |
|
| 332 |
+
| `discount_pct` | `0` \| `10` \| `20` \| `50` | `0` | Discount tier (for `discount` actions) |
|
| 333 |
+
| `restock_quantity` | int | `None` | Units to order (for `restock` actions) |
|
| 334 |
+
| `target_zone` | `1` \| `2` \| `3` | `None` | Destination zone (for `place` actions) |
|
| 335 |
+
|
| 336 |
+
### Observation: `StoremanagerObservation`
|
| 337 |
+
|
| 338 |
+
| Field | Type | Description |
|
| 339 |
+
|---|---|---|
|
| 340 |
+
| `inventory` | `List[ProductState]` | Full state of all products (including inactive) |
|
| 341 |
+
| `current_step` | int | Current step number (0 at reset, increments after each valid step) |
|
| 342 |
+
| `max_steps` | int | Total steps in this episode |
|
| 343 |
+
| `steps_remaining` | int | Steps left in the episode |
|
| 344 |
+
| `cumulative_profit` | float | Total profit accumulated so far |
|
| 345 |
+
| `last_step_profit` | float | Net profit from the most recent step |
|
| 346 |
+
| `last_expired_products` | `List[str]` | Names of products that expired this step |
|
| 347 |
+
| `zone_capacity` | `Dict[int, int]` | Max products per zone: `{1: 2, 2: 4, 3: 99}` |
|
| 348 |
+
| `zone_occupancy` | `Dict[int, int]` | Active products currently in each zone |
|
| 349 |
+
| `zone_multipliers` | `Dict[int, float]` | Pick-prob multiplier per zone: `{1: 1.5, 2: 1.0, 3: 0.6}` |
|
| 350 |
+
| `holding_cost_this_step` | float | Total holding cost charged this step |
|
| 351 |
+
| `placement_cost_this_step` | float | `3.0` if a `place` action was taken, else `0.0` |
|
| 352 |
+
| `restock_cost_this_step` | float | Ordering cost if a `restock` was placed, else `0.0` |
|
| 353 |
+
| `task_name` | str | Active task: `easy` / `medium` / `hard` / `expert` / `custom` |
|
| 354 |
+
| `error` | str \| None | Error message if the action was invalid (step does **not** advance) |
|
| 355 |
+
| `metadata` | dict | Per-step breakdown: `step_revenue`, `step_cost`, `expiry_penalty`, `holding_cost`, `placement_cost`, `restock_cost`, `step_profit` |
|
| 356 |
+
|
| 357 |
+
### ProductState Fields (inside `inventory`)
|
| 358 |
+
|
| 359 |
+
| Field | Description |
|
| 360 |
+
|---|---|
|
| 361 |
+
| `product_id` | Unique integer ID |
|
| 362 |
+
| `name` | Product name (e.g. `"Milk"`) |
|
| 363 |
+
| `cost_price` | Per-unit cost to the store |
|
| 364 |
+
| `selling_price` | Current selling price (decreases each time a discount is applied) |
|
| 365 |
+
| `base_selling_price` | Original price β never modified, reference for total discount depth |
|
| 366 |
+
| `quantity` | Units currently in stock |
|
| 367 |
+
| `expiry_step` | Episode step at which this product expires |
|
| 368 |
+
| `zone` | Current shelf zone: `1` (premium), `2` (standard), `3` (back) |
|
| 369 |
+
| `effective_pick_prob` | Normalized pick probability this step (sums to 1.0 across all active products) |
|
| 370 |
+
| `current_discount_pct` | Discount tier applied **this step only** (0 if no discount action taken) |
|
| 371 |
+
| `holding_cost_rate` | Holding cost per unit per step as a fraction of `cost_price` |
|
| 372 |
+
| `restock_cost_per_unit` | Cost to order one additional unit |
|
| 373 |
+
| `restock_lead_time` | Steps until a placed order is delivered |
|
| 374 |
+
| `pending_restock_quantity` | Units currently on order (not yet delivered) |
|
| 375 |
+
| `pending_restock_arrives_at_step` | Step number when the pending order will arrive |
|
| 376 |
+
| `is_active` | `False` if the product is sold out or expired |
|
| 377 |
+
|
| 378 |
+
---
|
| 379 |
+
|
| 380 |
+
## Error Handling
|
| 381 |
+
|
| 382 |
+
When an action is invalid, the step does **not** advance. The returned observation contains an `error` string and reflects the pre-action state. Common error cases:
|
| 383 |
+
|
| 384 |
+
| Scenario | Error |
|
| 385 |
+
|---|---|
|
| 386 |
+
| Discount on inactive / sold-out product | `"Product '...' is no longer active"` |
|
| 387 |
+
| Restock with `quantity β€ 0` | `"restock_quantity must be a positive integer"` |
|
| 388 |
+
| Restock when pending order exists | `"already has a pending restock order"` |
|
| 389 |
+
| Place to same zone product is already in | `"is already in zone N"` |
|
| 390 |
+
| Place to zone at capacity | `"Zone N is at capacity"` |
|
| 391 |
+
| Invalid `product_id` | `"Invalid product_id"` |
|
| 392 |
+
|
| 393 |
+
Always check `obs.get("error")` before processing the observation.
|
| 394 |
+
|
| 395 |
+
---
|
| 396 |
+
|
| 397 |
+
## Configuration
|
| 398 |
+
|
| 399 |
+
Control the server via environment variables:
|
| 400 |
+
|
| 401 |
+
| Variable | Default | Description |
|
| 402 |
+
|---|---|---|
|
| 403 |
+
| `STORE_TASK` | _(none)_ | Pre-load a task config at startup: `easy`, `medium`, `hard`, or `expert` |
|
| 404 |
+
| `STORE_NUM_PRODUCTS` | 8 | Number of products (overridden by `STORE_TASK`) |
|
| 405 |
+
| `STORE_MAX_STEPS` | 30 | Episode length in steps (overridden by `STORE_TASK`) |
|
| 406 |
+
| `STORE_NUM_CUSTOMERS` | 10 | Customers simulated per step (overridden by `STORE_TASK`) |
|
| 407 |
+
| `STORE_MAX_CONCURRENT_ENVS` | 4 | Max parallel WebSocket sessions |
|
| 408 |
+
|
| 409 |
+
**Task switching at runtime:** A single server instance can serve all four tasks. Pass `"task": "<name>"` in the WebSocket `reset` message and the environment reconfigures immediately without a server restart.
|
| 410 |
+
|
| 411 |
+
---
|
| 412 |
+
|
| 413 |
+
## Deploying to Hugging Face Spaces
|
| 414 |
+
|
| 415 |
+
```bash
|
| 416 |
+
openenv push
|
| 417 |
+
openenv push --repo-id my-org/store-manager --private
|
| 418 |
+
```
|
| 419 |
+
|
| 420 |
+
After deployment, your space includes:
|
| 421 |
+
- **Web Interface** at `/web`
|
| 422 |
+
- **API Documentation** at `/docs`
|
| 423 |
+
- **WebSocket** at `/ws` β persistent session for low-latency agent interactions
|
| 424 |
+
|
| 425 |
+
---
|
| 426 |
+
|
| 427 |
+
## Project Structure
|
| 428 |
+
|
| 429 |
+
```
|
| 430 |
+
StoreManager/
|
| 431 |
+
βββ __init__.py # Module exports
|
| 432 |
+
βββ models.py # Pydantic schemas: Action, Observation, State
|
| 433 |
+
βββ client.py # StoremanagerEnv WebSocket client
|
| 434 |
+
βββ tasks.py # Task configs (easy/medium/hard/expert) + grader
|
| 435 |
+
βββ inference.py # LLM agent runner with structured logs
|
| 436 |
+
βββ test_ws.py # WebSocket integration test suite (7 test groups)
|
| 437 |
+
βββ openenv.yaml # OpenEnv manifest
|
| 438 |
+
βββ pyproject.toml # Dependencies
|
| 439 |
+
βββ server/
|
| 440 |
+
βββ StoreManager_environment.py # Core RL environment logic
|
| 441 |
+
βββ app.py # FastAPI server (HTTP + WebSocket)
|
| 442 |
+
βββ Dockerfile # Container image
|
| 443 |
+
```
|
__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
"""Store Manager Environment."""
|
| 8 |
+
|
| 9 |
+
from .client import StoremanagerEnv
|
| 10 |
+
from .models import (
|
| 11 |
+
ProductState,
|
| 12 |
+
StoremanagerAction,
|
| 13 |
+
StoremanagerObservation,
|
| 14 |
+
StoremanagerState,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
__all__ = [
|
| 18 |
+
"ProductState",
|
| 19 |
+
"StoremanagerAction",
|
| 20 |
+
"StoremanagerObservation",
|
| 21 |
+
"StoremanagerState",
|
| 22 |
+
"StoremanagerEnv",
|
| 23 |
+
]
|
client.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
"""Store Manager Environment Client."""
|
| 8 |
+
|
| 9 |
+
from typing import Any, Dict
|
| 10 |
+
|
| 11 |
+
from openenv.core import EnvClient
|
| 12 |
+
from openenv.core.client_types import StepResult
|
| 13 |
+
|
| 14 |
+
from .models import (
|
| 15 |
+
ProductState,
|
| 16 |
+
StoremanagerAction,
|
| 17 |
+
StoremanagerObservation,
|
| 18 |
+
StoremanagerState,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class StoremanagerEnv(
|
| 23 |
+
EnvClient[StoremanagerAction, StoremanagerObservation, StoremanagerState]
|
| 24 |
+
):
|
| 25 |
+
"""
|
| 26 |
+
Client for the Store Manager Environment.
|
| 27 |
+
|
| 28 |
+
Maintains a persistent WebSocket connection to the environment server,
|
| 29 |
+
enabling efficient multi-step interactions with lower latency.
|
| 30 |
+
|
| 31 |
+
Example:
|
| 32 |
+
>>> with StoremanagerEnv(base_url="http://localhost:8000") as env:
|
| 33 |
+
... result = env.reset()
|
| 34 |
+
... print([p.name for p in result.observation.inventory])
|
| 35 |
+
...
|
| 36 |
+
... action = StoremanagerAction(product_id=0, discount_pct=20)
|
| 37 |
+
... result = env.step(action)
|
| 38 |
+
... print(f"Reward: {result.reward:.2f}")
|
| 39 |
+
|
| 40 |
+
Example with Docker:
|
| 41 |
+
>>> client = StoremanagerEnv.from_docker_image("StoreManager-env:latest")
|
| 42 |
+
>>> try:
|
| 43 |
+
... result = client.reset()
|
| 44 |
+
... result = client.step(StoremanagerAction(product_id=0, discount_pct=10))
|
| 45 |
+
... finally:
|
| 46 |
+
... client.close()
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
def _step_payload(self, action: StoremanagerAction) -> Dict:
|
| 50 |
+
"""Convert StoremanagerAction to JSON payload for the step message."""
|
| 51 |
+
payload: Dict = {
|
| 52 |
+
"action_type": action.action_type,
|
| 53 |
+
"product_id": action.product_id,
|
| 54 |
+
"discount_pct": action.discount_pct,
|
| 55 |
+
}
|
| 56 |
+
if action.restock_quantity is not None:
|
| 57 |
+
payload["restock_quantity"] = action.restock_quantity
|
| 58 |
+
if action.target_zone is not None:
|
| 59 |
+
payload["target_zone"] = action.target_zone
|
| 60 |
+
return payload
|
| 61 |
+
|
| 62 |
+
def _parse_result(self, payload: Dict) -> StepResult[StoremanagerObservation]:
|
| 63 |
+
"""Parse server response into StepResult[StoremanagerObservation]."""
|
| 64 |
+
obs_data = payload.get("observation", {})
|
| 65 |
+
|
| 66 |
+
raw_inventory = obs_data.get("inventory", [])
|
| 67 |
+
inventory = [ProductState(**p) for p in raw_inventory if isinstance(p, dict)]
|
| 68 |
+
|
| 69 |
+
observation = StoremanagerObservation(
|
| 70 |
+
inventory=inventory,
|
| 71 |
+
current_step=obs_data.get("current_step", 0),
|
| 72 |
+
max_steps=obs_data.get("max_steps", 30),
|
| 73 |
+
steps_remaining=obs_data.get("steps_remaining", 0),
|
| 74 |
+
cumulative_profit=obs_data.get("cumulative_profit", 0.0),
|
| 75 |
+
last_step_profit=obs_data.get("last_step_profit", 0.0),
|
| 76 |
+
last_step_units_sold=obs_data.get("last_step_units_sold", 0),
|
| 77 |
+
last_expired_products=obs_data.get("last_expired_products", []),
|
| 78 |
+
num_customers_per_step=obs_data.get("num_customers_per_step", 10),
|
| 79 |
+
error=obs_data.get("error"),
|
| 80 |
+
zone_capacity=obs_data.get("zone_capacity", {1: 2, 2: 4, 3: 99}),
|
| 81 |
+
zone_occupancy=obs_data.get("zone_occupancy", {1: 0, 2: 0, 3: 0}),
|
| 82 |
+
zone_multipliers=obs_data.get("zone_multipliers", {1: 1.5, 2: 1.0, 3: 0.6}),
|
| 83 |
+
holding_cost_this_step=obs_data.get("holding_cost_this_step", 0.0),
|
| 84 |
+
placement_cost_this_step=obs_data.get("placement_cost_this_step", 0.0),
|
| 85 |
+
restock_cost_this_step=obs_data.get("restock_cost_this_step", 0.0),
|
| 86 |
+
unjustified_discount_penalty=obs_data.get("unjustified_discount_penalty", 0.0),
|
| 87 |
+
done=payload.get("done", False),
|
| 88 |
+
reward=payload.get("reward"),
|
| 89 |
+
metadata=obs_data.get("metadata", {}),
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
return StepResult(
|
| 93 |
+
observation=observation,
|
| 94 |
+
reward=payload.get("reward"),
|
| 95 |
+
done=payload.get("done", False),
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
def _parse_state(self, payload: Dict[str, Any]) -> StoremanagerState:
|
| 99 |
+
"""Parse state endpoint response into StoremanagerState."""
|
| 100 |
+
return StoremanagerState(
|
| 101 |
+
episode_id=payload.get("episode_id"),
|
| 102 |
+
step_count=payload.get("step_count", 0),
|
| 103 |
+
num_products=payload.get("num_products", 8),
|
| 104 |
+
max_steps=payload.get("max_steps", 30),
|
| 105 |
+
cumulative_profit=payload.get("cumulative_profit", 0.0),
|
| 106 |
+
last_reward=payload.get("last_reward", 0.0),
|
| 107 |
+
active_product_count=payload.get("active_product_count", 0),
|
| 108 |
+
inventory_snapshot=payload.get("inventory_snapshot", []),
|
| 109 |
+
)
|
inference.py
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Inference script for the Store Manager environment.
|
| 3 |
+
|
| 4 |
+
Runs an LLM agent through all three difficulty tasks (easy β medium β hard)
|
| 5 |
+
and reports a deterministic grade (0.0β1.0) per task.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python inference.py
|
| 9 |
+
python inference.py --url http://localhost:8000
|
| 10 |
+
|
| 11 |
+
Required environment variables:
|
| 12 |
+
HF_TOKEN API key for the LLM provider (Hugging Face token or OpenAI key)
|
| 13 |
+
API_BASE_URL Base URL of the LLM API (e.g. https://api-inference.huggingface.co/v1/)
|
| 14 |
+
MODEL_NAME Model identifier (e.g. meta-llama/Llama-3.1-8B-Instruct)
|
| 15 |
+
|
| 16 |
+
Optional environment variables:
|
| 17 |
+
STORE_ENV_URL Base URL of the running Store Manager server (default: http://localhost:8000)
|
| 18 |
+
|
| 19 |
+
Structured log format emitted to stdout:
|
| 20 |
+
[START] {"task": "...", "seed": N, "model": "...", "max_steps": N, "profit_target": N}
|
| 21 |
+
[STEP] {"task": "...", "step": N, "action": {...}, "reward": ..., "cumulative_profit": ...}
|
| 22 |
+
[END] {"task": "...", "cumulative_profit": ..., "score": ..., "profit_target": ...}
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import argparse
|
| 28 |
+
import json
|
| 29 |
+
import os
|
| 30 |
+
import re
|
| 31 |
+
import sys
|
| 32 |
+
import time
|
| 33 |
+
|
| 34 |
+
try:
|
| 35 |
+
import websocket # websocket-client library
|
| 36 |
+
except ImportError:
|
| 37 |
+
print(
|
| 38 |
+
"ERROR: websocket-client not installed. Run: pip install websocket-client",
|
| 39 |
+
file=sys.stderr,
|
| 40 |
+
)
|
| 41 |
+
sys.exit(1)
|
| 42 |
+
|
| 43 |
+
try:
|
| 44 |
+
from openai import OpenAI
|
| 45 |
+
except ImportError:
|
| 46 |
+
print(
|
| 47 |
+
"ERROR: openai not installed. Run: pip install openai",
|
| 48 |
+
file=sys.stderr,
|
| 49 |
+
)
|
| 50 |
+
sys.exit(1)
|
| 51 |
+
|
| 52 |
+
# Add project root to path so tasks.py is importable
|
| 53 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 54 |
+
from tasks import TASKS, grade
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ββ Prompt helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 58 |
+
|
| 59 |
+
_SYSTEM_PROMPT = """You are an expert retail store manager optimizing profit.
|
| 60 |
+
|
| 61 |
+
Each step you choose EXACTLY ONE action. There are three action types:
|
| 62 |
+
|
| 63 |
+
ββ 1. DISCOUNT ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 64 |
+
Apply a price discount to a product for this step only (ephemeral).
|
| 65 |
+
Discounts boost customer pick probability and clear near-expiry stock.
|
| 66 |
+
{"action_type": "discount", "product_id": <int>, "discount_pct": <0|10|20|50>}
|
| 67 |
+
|
| 68 |
+
Discount tiers and effect:
|
| 69 |
+
0% -> no change | 10% -> +10% relative boost | 20% -> +20% boost | 50% -> +50% boost
|
| 70 |
+
|
| 71 |
+
PRICE FLOOR RULES (selling below cost is penalized unless near expiry):
|
| 72 |
+
In(steps) >= 4 -> floor = cost_price (no below-cost selling allowed)
|
| 73 |
+
In(steps) == 3 -> floor = 70% of cost (max 30% loss/unit)
|
| 74 |
+
In(steps) == 2 -> floor = 40% of cost (max 60% loss/unit)
|
| 75 |
+
In(steps) == 1 -> floor = 10% of cost (max 90% loss/unit β clear it!)
|
| 76 |
+
|
| 77 |
+
Selling below cost when In(steps) >= 4 incurs an EXTRA penalty equal to the
|
| 78 |
+
per-unit loss Γ units sold. Avoid below-cost discounts unless near expiry.
|
| 79 |
+
|
| 80 |
+
When to discount: near-expiry items (avoid expiry penalty = remaining_qty x cost),
|
| 81 |
+
high-margin products (Margin > $0) to drive volume, or oversupplied items.
|
| 82 |
+
Near expiry (In <= 3): aggressive discounts are worthwhile to avoid the full expiry penalty.
|
| 83 |
+
|
| 84 |
+
ββ 2. RESTOCK βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 85 |
+
Place a reorder for a product. Stock arrives after restock_lead_time steps.
|
| 86 |
+
Costs restock_cost_per_unit per unit (upfront, this step). One order per product
|
| 87 |
+
at a time -- wait for delivery before reordering the same product.
|
| 88 |
+
{"action_type": "restock", "product_id": <int>, "restock_quantity": <int>}
|
| 89 |
+
|
| 90 |
+
When to restock: low stock on a high-demand product, enough steps left before expiry
|
| 91 |
+
to sell through the restocked units, and available budget.
|
| 92 |
+
|
| 93 |
+
ββ 3. PLACE βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 94 |
+
Move a product to a different shelf zone. Costs a flat placement fee ($3.00).
|
| 95 |
+
{"action_type": "place", "product_id": <int>, "target_zone": <1|2|3>}
|
| 96 |
+
|
| 97 |
+
Zone multipliers on pick probability:
|
| 98 |
+
Zone 1 (premium/entrance) -> x1.5 [capacity: 2 products max]
|
| 99 |
+
Zone 2 (standard) -> x1.0 [capacity: 4 products max]
|
| 100 |
+
Zone 3 (back/low-traffic) -> x0.6 [unlimited]
|
| 101 |
+
|
| 102 |
+
When to place: move high-value or near-expiry products to zone 1 to increase
|
| 103 |
+
their pick probability. Move slow-movers to zone 3.
|
| 104 |
+
|
| 105 |
+
ββ COSTS CHARGED EVERY STEP βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 106 |
+
- Holding cost: each unit in stock costs holding_cost_rate x cost_price per step.
|
| 107 |
+
Minimize idle inventory by balancing restocks with sales velocity.
|
| 108 |
+
- Expiry penalty: remaining_qty x cost_price for each expired product.
|
| 109 |
+
|
| 110 |
+
Goal: maximize cumulative profit = revenue - COGS - expiry_penalty - holding_cost
|
| 111 |
+
- placement_cost - restock_cost.
|
| 112 |
+
|
| 113 |
+
Respond ONLY with a single JSON object (one of the three formats above).
|
| 114 |
+
No explanation, no extra text -- just the JSON.
|
| 115 |
+
"""
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _format_inventory(obs: dict) -> str:
|
| 119 |
+
"""Format inventory state as a readable table for the LLM prompt."""
|
| 120 |
+
# JSON serializes int dict keys as strings; coerce back to int for lookup
|
| 121 |
+
zm_raw = obs.get("zone_multipliers", {})
|
| 122 |
+
zc_raw = obs.get("zone_capacity", {})
|
| 123 |
+
zo_raw = obs.get("zone_occupancy", {})
|
| 124 |
+
zm = {int(k): v for k, v in zm_raw.items()} if zm_raw else {1: 1.5, 2: 1.0, 3: 0.6}
|
| 125 |
+
zc = {int(k): v for k, v in zc_raw.items()} if zc_raw else {1: 2, 2: 4, 3: 99}
|
| 126 |
+
zo = {int(k): v for k, v in zo_raw.items()} if zo_raw else {1: 0, 2: 0, 3: 0}
|
| 127 |
+
|
| 128 |
+
lines = [
|
| 129 |
+
f"Step {obs['current_step'] + 1}/{obs['max_steps']} "
|
| 130 |
+
f"| Steps remaining: {obs['steps_remaining']} "
|
| 131 |
+
f"| Cumulative profit: ${obs['cumulative_profit']:.2f}",
|
| 132 |
+
f"Task difficulty: {obs.get('task_name', 'unknown')}",
|
| 133 |
+
f"Holding cost this step: ${obs.get('holding_cost_this_step', 0.0):.4f}",
|
| 134 |
+
"",
|
| 135 |
+
"Zone info: "
|
| 136 |
+
+ " ".join(
|
| 137 |
+
f"Zone {z}: x{zm.get(z, 1.0)} ({zo.get(z, 0)}/{zc.get(z, '?')} slots)"
|
| 138 |
+
for z in [1, 2, 3]
|
| 139 |
+
),
|
| 140 |
+
"",
|
| 141 |
+
f"{'ID':<4} {'Name':<10} {'Cost':>6} {'Price':>7} {'Margin':>7} {'Qty':>5} "
|
| 142 |
+
f"{'In(steps)':>9} {'Zone':>5} {'PickProb':>9} {'PopMult':>8} {'HoldRate':>9} "
|
| 143 |
+
f"{'RestockCost':>11} {'Lead':>5} {'PendingQty':>10} {'Active':>7}",
|
| 144 |
+
"-" * 122,
|
| 145 |
+
]
|
| 146 |
+
for p in obs["inventory"]:
|
| 147 |
+
pending_str = (
|
| 148 |
+
f"{p['pending_restock_quantity']}@s{p['pending_restock_arrives_at_step']}"
|
| 149 |
+
if p.get("pending_restock_quantity", 0) > 0
|
| 150 |
+
else "-"
|
| 151 |
+
)
|
| 152 |
+
margin = p.get("margin_per_unit", round(p["selling_price"] - p["cost_price"], 4))
|
| 153 |
+
steps_left = p.get("steps_until_expiry", p.get("expiry_step", 0) - obs.get("current_step", 0))
|
| 154 |
+
lines.append(
|
| 155 |
+
f"{p['product_id']:<4} {p['name']:<10} "
|
| 156 |
+
f"${p['cost_price']:>5.2f} ${p['selling_price']:>6.2f} "
|
| 157 |
+
f"${margin:>+6.2f} "
|
| 158 |
+
f"{p['quantity']:>5} "
|
| 159 |
+
f"{steps_left:>9} "
|
| 160 |
+
f"{p.get('zone', 2):>5} "
|
| 161 |
+
f"{p['effective_pick_prob']:>9.4f} "
|
| 162 |
+
f"{p.get('popularity_multiplier', 1.0):>8.3f} "
|
| 163 |
+
f"{p.get('holding_cost_rate', 0.0):>9.4f} "
|
| 164 |
+
f"${p.get('restock_cost_per_unit', 0.0):>10.2f} "
|
| 165 |
+
f"{p.get('restock_lead_time', 2):>5} "
|
| 166 |
+
f"{pending_str:>10} "
|
| 167 |
+
f"{'YES' if p['is_active'] else 'no':>7}"
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
if obs.get("last_expired_products"):
|
| 171 |
+
lines.append(f"\nJust expired: {', '.join(obs['last_expired_products'])}")
|
| 172 |
+
|
| 173 |
+
return "\n".join(lines)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _parse_action(text: str, active_ids: list[int]) -> dict:
|
| 177 |
+
"""Extract a valid action from LLM output; fall back to safe default."""
|
| 178 |
+
def _validate(data: dict) -> dict | None:
|
| 179 |
+
action_type = data.get("action_type", "discount")
|
| 180 |
+
pid = int(data.get("product_id", active_ids[0]))
|
| 181 |
+
if pid not in active_ids:
|
| 182 |
+
return None
|
| 183 |
+
|
| 184 |
+
if action_type == "discount":
|
| 185 |
+
disc = int(data.get("discount_pct", 0))
|
| 186 |
+
if disc in (0, 10, 20, 50):
|
| 187 |
+
return {"action_type": "discount", "product_id": pid, "discount_pct": disc}
|
| 188 |
+
|
| 189 |
+
elif action_type == "restock":
|
| 190 |
+
qty = data.get("restock_quantity")
|
| 191 |
+
if qty is not None and int(qty) > 0:
|
| 192 |
+
return {"action_type": "restock", "product_id": pid, "restock_quantity": int(qty)}
|
| 193 |
+
|
| 194 |
+
elif action_type == "place":
|
| 195 |
+
zone = data.get("target_zone")
|
| 196 |
+
if zone is not None and int(zone) in (1, 2, 3):
|
| 197 |
+
return {"action_type": "place", "product_id": pid, "target_zone": int(zone)}
|
| 198 |
+
|
| 199 |
+
return None
|
| 200 |
+
|
| 201 |
+
# Try direct JSON parse
|
| 202 |
+
try:
|
| 203 |
+
result = _validate(json.loads(text.strip()))
|
| 204 |
+
if result:
|
| 205 |
+
return result
|
| 206 |
+
except (json.JSONDecodeError, ValueError, KeyError):
|
| 207 |
+
pass
|
| 208 |
+
|
| 209 |
+
# Try extracting JSON from surrounding text
|
| 210 |
+
match = re.search(r'\{[^{}]+\}', text)
|
| 211 |
+
if match:
|
| 212 |
+
try:
|
| 213 |
+
result = _validate(json.loads(match.group(0)))
|
| 214 |
+
if result:
|
| 215 |
+
return result
|
| 216 |
+
except (json.JSONDecodeError, ValueError, KeyError):
|
| 217 |
+
pass
|
| 218 |
+
|
| 219 |
+
# Safe fallback
|
| 220 |
+
return {"action_type": "discount", "product_id": active_ids[0], "discount_pct": 0}
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
# ββ WebSocket helpers βββββββββοΏ½οΏ½ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 224 |
+
|
| 225 |
+
def _ws_url(http_url: str) -> str:
|
| 226 |
+
"""Convert http(s) URL to ws(s)."""
|
| 227 |
+
return http_url.replace("https://", "wss://").replace("http://", "ws://") + "/ws"
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def _recv_json(ws: websocket.WebSocket) -> dict:
|
| 231 |
+
"""Receive one WebSocket message and parse as JSON."""
|
| 232 |
+
return json.loads(ws.recv())
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _unpack(msg: dict) -> dict:
|
| 236 |
+
"""
|
| 237 |
+
Unpack a WebSocket message into a flat observation dict.
|
| 238 |
+
|
| 239 |
+
The server sends: {"type": "observation", "data": {"observation": {...}, "done": bool, "reward": float}}
|
| 240 |
+
This extracts the inner observation and injects done/reward at the top level.
|
| 241 |
+
"""
|
| 242 |
+
data = msg.get("data", msg)
|
| 243 |
+
obs = data.get("observation", data)
|
| 244 |
+
obs["done"] = data.get("done", obs.get("done", False))
|
| 245 |
+
obs["reward"] = data.get("reward", obs.get("reward", 0.0))
|
| 246 |
+
return obs
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
# ββ Episode runner βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 250 |
+
|
| 251 |
+
def run_episode(
|
| 252 |
+
ws_url: str,
|
| 253 |
+
task_name: str,
|
| 254 |
+
client: OpenAI,
|
| 255 |
+
model: str,
|
| 256 |
+
) -> tuple[float, float]:
|
| 257 |
+
"""
|
| 258 |
+
Run a full episode for `task_name` using the LLM agent.
|
| 259 |
+
|
| 260 |
+
Returns:
|
| 261 |
+
(cumulative_profit, score)
|
| 262 |
+
"""
|
| 263 |
+
task = TASKS[task_name]
|
| 264 |
+
|
| 265 |
+
ws = websocket.create_connection(ws_url, timeout=60)
|
| 266 |
+
cumulative = 0.0
|
| 267 |
+
score = 0.0
|
| 268 |
+
try:
|
| 269 |
+
# ββ Reset ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 270 |
+
ws.send(json.dumps({"type": "reset", "data": {"task": task_name, "seed": task.seed}}))
|
| 271 |
+
obs = _unpack(_recv_json(ws))
|
| 272 |
+
|
| 273 |
+
print(
|
| 274 |
+
f"[START] {json.dumps({'task': task_name, 'seed': task.seed, 'model': model, 'max_steps': task.max_steps, 'profit_target': task.profit_target})}",
|
| 275 |
+
flush=True,
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
# ββ Episode loop βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 279 |
+
step_num = 0
|
| 280 |
+
while True:
|
| 281 |
+
active_ids = [
|
| 282 |
+
p["product_id"]
|
| 283 |
+
for p in obs.get("inventory", [])
|
| 284 |
+
if p.get("is_active") and p.get("quantity", 0) > 0
|
| 285 |
+
]
|
| 286 |
+
if not active_ids or obs.get("done", False):
|
| 287 |
+
break
|
| 288 |
+
|
| 289 |
+
# Build LLM prompt
|
| 290 |
+
user_msg = _format_inventory(obs)
|
| 291 |
+
|
| 292 |
+
# Call LLM
|
| 293 |
+
response = client.chat.completions.create(
|
| 294 |
+
model=model,
|
| 295 |
+
messages=[
|
| 296 |
+
{"role": "system", "content": _SYSTEM_PROMPT},
|
| 297 |
+
{"role": "user", "content": user_msg},
|
| 298 |
+
],
|
| 299 |
+
temperature=0.2,
|
| 300 |
+
max_tokens=64,
|
| 301 |
+
)
|
| 302 |
+
llm_text = response.choices[0].message.content or ""
|
| 303 |
+
action = _parse_action(llm_text, active_ids)
|
| 304 |
+
|
| 305 |
+
# Send action to environment
|
| 306 |
+
ws.send(json.dumps({"type": "step", "data": action}))
|
| 307 |
+
obs = _unpack(_recv_json(ws))
|
| 308 |
+
|
| 309 |
+
step_num += 1
|
| 310 |
+
print(
|
| 311 |
+
f"[STEP] {json.dumps({'task': task_name, 'step': step_num, 'action': action, 'reward': round(obs.get('reward', 0.0) or 0.0, 4), 'cumulative_profit': round(obs.get('cumulative_profit', 0.0), 4), 'holding_cost': round(obs.get('holding_cost_this_step', 0.0), 4), 'placement_cost': obs.get('placement_cost_this_step', 0.0), 'restock_cost': round(obs.get('restock_cost_this_step', 0.0), 4), 'unjustified_penalty': round(obs.get('unjustified_discount_penalty', 0.0), 4)})}",
|
| 312 |
+
flush=True,
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
if obs.get("done", False):
|
| 316 |
+
break
|
| 317 |
+
|
| 318 |
+
cumulative = obs.get("cumulative_profit", 0.0)
|
| 319 |
+
score = grade(task_name, cumulative)
|
| 320 |
+
|
| 321 |
+
return cumulative, score
|
| 322 |
+
|
| 323 |
+
finally:
|
| 324 |
+
ws.close()
|
| 325 |
+
print(
|
| 326 |
+
f"[END] {json.dumps({'task': task_name, 'cumulative_profit': round(cumulative, 4), 'score': round(score, 4), 'profit_target': task.profit_target})}",
|
| 327 |
+
flush=True,
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
# ββ Main βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 332 |
+
|
| 333 |
+
def main() -> None:
|
| 334 |
+
parser = argparse.ArgumentParser(description="Run LLM agent on Store Manager tasks")
|
| 335 |
+
parser.add_argument(
|
| 336 |
+
"--url",
|
| 337 |
+
default=os.getenv("STORE_ENV_URL", "http://localhost:8000"),
|
| 338 |
+
help="Base URL of the running Store Manager server (env: STORE_ENV_URL)",
|
| 339 |
+
)
|
| 340 |
+
args = parser.parse_args()
|
| 341 |
+
|
| 342 |
+
# ββ Mandatory env vars (per hackathon spec) ββββββββββββββββββββββββββββββββ
|
| 343 |
+
hf_token = os.getenv("HF_TOKEN") or os.getenv("OPENAI_API_KEY")
|
| 344 |
+
if not hf_token:
|
| 345 |
+
print(
|
| 346 |
+
"ERROR: HF_TOKEN environment variable not set. "
|
| 347 |
+
"Set it to your Hugging Face token or OpenAI API key.",
|
| 348 |
+
file=sys.stderr,
|
| 349 |
+
)
|
| 350 |
+
sys.exit(1)
|
| 351 |
+
|
| 352 |
+
api_base_url = os.getenv("API_BASE_URL")
|
| 353 |
+
model = os.getenv("MODEL_NAME") or os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
| 354 |
+
|
| 355 |
+
# Build OpenAI-compatible client
|
| 356 |
+
client_kwargs: dict = {"api_key": hf_token}
|
| 357 |
+
if api_base_url:
|
| 358 |
+
client_kwargs["base_url"] = api_base_url
|
| 359 |
+
|
| 360 |
+
client = OpenAI(**client_kwargs)
|
| 361 |
+
ws_url = _ws_url(args.url)
|
| 362 |
+
|
| 363 |
+
results = {}
|
| 364 |
+
for task_name in ["easy", "medium", "hard", "expert"]:
|
| 365 |
+
try:
|
| 366 |
+
profit, score = run_episode(ws_url, task_name, client, model)
|
| 367 |
+
results[task_name] = {"profit": round(profit, 4), "score": round(score, 4)}
|
| 368 |
+
except Exception as exc:
|
| 369 |
+
print(f"[ERROR] task={task_name} error={exc}", file=sys.stderr, flush=True)
|
| 370 |
+
results[task_name] = {"profit": 0.0, "score": 0.0}
|
| 371 |
+
# Brief pause between tasks to avoid overwhelming the server
|
| 372 |
+
time.sleep(1)
|
| 373 |
+
|
| 374 |
+
# Final summary
|
| 375 |
+
print("\n=== Summary ===")
|
| 376 |
+
for task_name, r in results.items():
|
| 377 |
+
print(f" {task_name:<8} profit={r['profit']:>10.2f} score={r['score']:.4f}")
|
| 378 |
+
overall = sum(r["score"] for r in results.values()) / len(results)
|
| 379 |
+
print(f" {'AVERAGE':<8} score={overall:.4f}")
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
if __name__ == "__main__":
|
| 383 |
+
main()
|
models.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
Data models for the Store Manager Environment.
|
| 9 |
+
|
| 10 |
+
The Store Manager environment simulates a retail store where an LLM agent
|
| 11 |
+
acts as the store manager, applying discounts to products to maximize profit
|
| 12 |
+
while managing inventory and handling product expiry.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from typing import Dict, List, Literal, Optional
|
| 18 |
+
|
| 19 |
+
from openenv.core.env_server.types import Action, Observation, State
|
| 20 |
+
from pydantic import BaseModel, Field
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class ProductState(BaseModel):
|
| 24 |
+
"""State of a single product in the store inventory."""
|
| 25 |
+
|
| 26 |
+
product_id: int = Field(..., description="Unique integer identifier for the product")
|
| 27 |
+
name: str = Field(..., description="Human-readable product name")
|
| 28 |
+
cost_price: float = Field(..., description="Cost to the store per unit")
|
| 29 |
+
selling_price: float = Field(..., description="Current selling price (after discount)")
|
| 30 |
+
base_selling_price: float = Field(..., description="Original selling price before any discount")
|
| 31 |
+
quantity: int = Field(..., description="Units currently in stock")
|
| 32 |
+
expiry_step: int = Field(
|
| 33 |
+
..., description="Episode step number at which this product expires"
|
| 34 |
+
)
|
| 35 |
+
base_probability: float = Field(
|
| 36 |
+
..., description="Base pick-probability weight before discount"
|
| 37 |
+
)
|
| 38 |
+
current_discount_pct: float = Field(
|
| 39 |
+
default=0.0, description="Active discount percentage this step (0/10/20/50)"
|
| 40 |
+
)
|
| 41 |
+
effective_pick_prob: float = Field(
|
| 42 |
+
default=0.0,
|
| 43 |
+
description="Normalised pick probability this step after applying discount (sums to 1 across all active products)",
|
| 44 |
+
)
|
| 45 |
+
is_active: bool = Field(
|
| 46 |
+
default=True, description="False if the product is sold out or expired"
|
| 47 |
+
)
|
| 48 |
+
sales_history: List[Dict] = Field(
|
| 49 |
+
default_factory=list,
|
| 50 |
+
description="List of {step, discount_pct, units_sold} records",
|
| 51 |
+
)
|
| 52 |
+
# Zone / Shelf Placement fields
|
| 53 |
+
zone: int = Field(
|
| 54 |
+
default=2, description="Current shelf zone (1=premium/high-traffic, 2=standard, 3=back/low-traffic)"
|
| 55 |
+
)
|
| 56 |
+
# Restocking / Procurement fields
|
| 57 |
+
holding_cost_rate: float = Field(
|
| 58 |
+
default=0.02, description="Holding cost per unit per step as a fraction of cost_price"
|
| 59 |
+
)
|
| 60 |
+
restock_cost_per_unit: float = Field(
|
| 61 |
+
default=0.0, description="Cost per unit when reordering (may exceed cost_price due to rush ordering)"
|
| 62 |
+
)
|
| 63 |
+
restock_lead_time: int = Field(
|
| 64 |
+
default=2, description="Number of steps until a restock order is delivered"
|
| 65 |
+
)
|
| 66 |
+
pending_restock_quantity: int = Field(
|
| 67 |
+
default=0, description="Units currently on order (not yet delivered)"
|
| 68 |
+
)
|
| 69 |
+
pending_restock_arrives_at_step: Optional[int] = Field(
|
| 70 |
+
default=None, description="Step number when the pending restock order will arrive"
|
| 71 |
+
)
|
| 72 |
+
# Compounding popularity effect
|
| 73 |
+
popularity_multiplier: float = Field(
|
| 74 |
+
default=1.0,
|
| 75 |
+
description=(
|
| 76 |
+
"Cumulative pick-probability multiplier earned through repeated discounting. "
|
| 77 |
+
"Starts at 1.0 and compounds up each time a discount action is applied: "
|
| 78 |
+
"popularity_multiplier *= (1 + discount_pct / 100). "
|
| 79 |
+
"Persists across steps within an episode; resets to 1.0 on reset()."
|
| 80 |
+
),
|
| 81 |
+
)
|
| 82 |
+
# Derived signal fields (recomputed each step β do not set manually)
|
| 83 |
+
steps_until_expiry: int = Field(
|
| 84 |
+
default=0,
|
| 85 |
+
description=(
|
| 86 |
+
"Steps remaining before this product expires (expiry_step - current_step). "
|
| 87 |
+
"Near-expiry threshold: <= 3 steps. Below-cost discounting is permitted "
|
| 88 |
+
"when steps_until_expiry <= 3; the price floor loosens progressively."
|
| 89 |
+
),
|
| 90 |
+
)
|
| 91 |
+
margin_per_unit: float = Field(
|
| 92 |
+
default=0.0,
|
| 93 |
+
description=(
|
| 94 |
+
"Current per-unit profit margin: selling_price - cost_price. "
|
| 95 |
+
"Positive = profitable. Zero = break-even. "
|
| 96 |
+
"Negative = selling at a loss (only possible when steps_until_expiry <= 3)."
|
| 97 |
+
),
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class StoremanagerAction(Action):
|
| 102 |
+
"""Agent action: discount a product, restock inventory, or move a product to a different zone."""
|
| 103 |
+
|
| 104 |
+
action_type: Literal["discount", "restock", "place"] = Field(
|
| 105 |
+
default="discount",
|
| 106 |
+
description="Type of action: 'discount' to apply a price discount, 'restock' to order more inventory, 'place' to move product to a different shelf zone",
|
| 107 |
+
)
|
| 108 |
+
product_id: int = Field(..., description="ID of the product to act on")
|
| 109 |
+
discount_pct: Literal[0, 10, 20, 50] = Field(
|
| 110 |
+
default=0, description="Discount percentage (used when action_type='discount'): 0, 10, 20, or 50"
|
| 111 |
+
)
|
| 112 |
+
restock_quantity: Optional[int] = Field(
|
| 113 |
+
default=None, description="Number of units to order (used when action_type='restock'); must be > 0"
|
| 114 |
+
)
|
| 115 |
+
target_zone: Optional[Literal[1, 2, 3]] = Field(
|
| 116 |
+
default=None, description="Destination zone (used when action_type='place'): 1=premium, 2=standard, 3=back"
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class StoremanagerObservation(Observation):
|
| 121 |
+
"""Full observation returned to the agent after each step."""
|
| 122 |
+
|
| 123 |
+
inventory: List[ProductState] = Field(
|
| 124 |
+
default_factory=list,
|
| 125 |
+
description="Current state of all products (including inactive ones)",
|
| 126 |
+
)
|
| 127 |
+
current_step: int = Field(default=0, description="Current step number (0-indexed)")
|
| 128 |
+
max_steps: int = Field(default=30, description="Total steps in this episode")
|
| 129 |
+
steps_remaining: int = Field(
|
| 130 |
+
default=30, description="Steps remaining in the episode"
|
| 131 |
+
)
|
| 132 |
+
cumulative_profit: float = Field(
|
| 133 |
+
default=0.0, description="Total profit accumulated so far in the episode"
|
| 134 |
+
)
|
| 135 |
+
last_step_profit: float = Field(
|
| 136 |
+
default=0.0, description="Net profit from the most recent step"
|
| 137 |
+
)
|
| 138 |
+
last_step_units_sold: int = Field(
|
| 139 |
+
default=0, description="Total units sold across all products in the last step"
|
| 140 |
+
)
|
| 141 |
+
last_expired_products: List[str] = Field(
|
| 142 |
+
default_factory=list,
|
| 143 |
+
description="Names of products that expired at the end of the last step",
|
| 144 |
+
)
|
| 145 |
+
num_customers_per_step: int = Field(
|
| 146 |
+
default=10, description="Fixed number of customers simulated per step"
|
| 147 |
+
)
|
| 148 |
+
task_name: str = Field(
|
| 149 |
+
default="custom",
|
| 150 |
+
description="Active difficulty task: easy / medium / hard / expert / custom",
|
| 151 |
+
)
|
| 152 |
+
error: Optional[str] = Field(
|
| 153 |
+
default=None, description="Error message if the last action was invalid"
|
| 154 |
+
)
|
| 155 |
+
# Zone information
|
| 156 |
+
zone_capacity: Dict[int, int] = Field(
|
| 157 |
+
default_factory=lambda: {1: 2, 2: 4, 3: 99},
|
| 158 |
+
description="Maximum number of products allowed per zone",
|
| 159 |
+
)
|
| 160 |
+
zone_occupancy: Dict[int, int] = Field(
|
| 161 |
+
default_factory=lambda: {1: 0, 2: 0, 3: 0},
|
| 162 |
+
description="Current number of active products in each zone",
|
| 163 |
+
)
|
| 164 |
+
zone_multipliers: Dict[int, float] = Field(
|
| 165 |
+
default_factory=lambda: {1: 1.5, 2: 1.0, 3: 0.6},
|
| 166 |
+
description="Pick-probability multiplier per zone",
|
| 167 |
+
)
|
| 168 |
+
# Step cost breakdown
|
| 169 |
+
holding_cost_this_step: float = Field(
|
| 170 |
+
default=0.0, description="Total holding cost charged this step across all products"
|
| 171 |
+
)
|
| 172 |
+
placement_cost_this_step: float = Field(
|
| 173 |
+
default=0.0, description="Labor cost charged this step if a zone placement was made"
|
| 174 |
+
)
|
| 175 |
+
restock_cost_this_step: float = Field(
|
| 176 |
+
default=0.0, description="Ordering cost charged this step if a restock order was placed"
|
| 177 |
+
)
|
| 178 |
+
unjustified_discount_penalty: float = Field(
|
| 179 |
+
default=0.0,
|
| 180 |
+
description=(
|
| 181 |
+
"Extra penalty this step for selling below cost_price when steps_until_expiry >= 4. "
|
| 182 |
+
"Equals the total per-unit loss on unjustified below-cost units sold. "
|
| 183 |
+
"Zero when below-cost selling is justified by near-expiry urgency."
|
| 184 |
+
),
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
class StoremanagerState(State):
|
| 189 |
+
"""Internal state snapshot for debugging and orchestration."""
|
| 190 |
+
|
| 191 |
+
num_products: int = Field(default=8, description="Number of products in this episode")
|
| 192 |
+
max_steps: int = Field(default=30, description="Episode length in steps")
|
| 193 |
+
cumulative_profit: float = Field(default=0.0, description="Total episode profit so far")
|
| 194 |
+
last_reward: float = Field(default=0.0, description="Reward from the last step")
|
| 195 |
+
active_product_count: int = Field(
|
| 196 |
+
default=0, description="Number of currently active (non-expired, in-stock) products"
|
| 197 |
+
)
|
| 198 |
+
inventory_snapshot: List[Dict] = Field(
|
| 199 |
+
default_factory=list,
|
| 200 |
+
description="Serialised inventory for state introspection",
|
| 201 |
+
)
|
openenv.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
spec_version: 1
|
| 2 |
+
name: StoreManager
|
| 3 |
+
type: space
|
| 4 |
+
runtime: fastapi
|
| 5 |
+
app: server.app:app
|
| 6 |
+
port: 8000
|
| 7 |
+
|
pyproject.toml
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-StoreManager"
|
| 13 |
+
version = "0.1.0"
|
| 14 |
+
description = "Storemanager 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.1",
|
| 21 |
+
# Environment-specific dependencies
|
| 22 |
+
"numpy>=1.24.0",
|
| 23 |
+
# Inference script dependencies
|
| 24 |
+
"websocket-client>=1.6.0",
|
| 25 |
+
"openai>=1.0.0",
|
| 26 |
+
# Examples:
|
| 27 |
+
# "torch>=2.0.0",
|
| 28 |
+
# "gymnasium>=0.29.0",
|
| 29 |
+
# "openspiel>=1.0.0",
|
| 30 |
+
# "smolagents>=1.22.0,<2",
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
[project.optional-dependencies]
|
| 34 |
+
dev = [
|
| 35 |
+
"pytest>=8.0.0",
|
| 36 |
+
"pytest-cov>=4.0.0",
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
[project.scripts]
|
| 40 |
+
# Server entry point - enables running via: uv run --project . server
|
| 41 |
+
# or: python -m StoreManager.server.app
|
| 42 |
+
server = "StoreManager.server.app:main"
|
| 43 |
+
|
| 44 |
+
[tool.setuptools]
|
| 45 |
+
include-package-data = true
|
| 46 |
+
packages = ["StoreManager", "StoreManager.server"]
|
| 47 |
+
package-dir = { "StoreManager" = ".", "StoreManager.server" = "server" }
|
server/StoreManager_environment.py
ADDED
|
@@ -0,0 +1,681 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
Store Manager Environment Implementation.
|
| 9 |
+
|
| 10 |
+
A retail store simulator where an LLM agent acts as the store manager.
|
| 11 |
+
The agent can apply discounts, restock inventory, or move products between
|
| 12 |
+
shelf zones each step to maximise cumulative profit over a fixed episode horizon.
|
| 13 |
+
|
| 14 |
+
Episode dynamics:
|
| 15 |
+
- N products, each with a cost price, selling price, stock quantity,
|
| 16 |
+
expiry step, a base pick-probability weight, a shelf zone, and restock params.
|
| 17 |
+
- Each step the agent chooses one action:
|
| 18 |
+
- "discount": apply a discount (0/10/20/50%) to one product
|
| 19 |
+
- "restock": place a reorder for a product (delivered after lead_time steps)
|
| 20 |
+
- "place": move a product to a different shelf zone (1/2/3)
|
| 21 |
+
- Discounts are ephemeral β they apply only for the current step.
|
| 22 |
+
- Zone multiplier scales pick probability: zone 1 = 1.5x, 2 = 1.0x, 3 = 0.6x.
|
| 23 |
+
- 10 customers sample products per step; probabilities incorporate zone + discount.
|
| 24 |
+
- Holding cost is charged every step based on quantity on hand.
|
| 25 |
+
- Restock cost is charged upfront; stock arrives after lead_time steps.
|
| 26 |
+
- Placement cost is a flat labor fee per move.
|
| 27 |
+
- Reward = step_revenue - step_cost - expiry_penalty - holding_cost
|
| 28 |
+
- placement_cost - restock_cost.
|
| 29 |
+
- Expiry penalty = remaining_quantity * cost_price for each expired product.
|
| 30 |
+
- Episode ends after max_steps steps, or when all products are inactive.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
import sys
|
| 36 |
+
import os
|
| 37 |
+
|
| 38 |
+
# Ensure the project root (parent of server/) is on sys.path so that
|
| 39 |
+
# models.py and tasks.py are importable whether this file is run directly
|
| 40 |
+
# or imported as part of the package.
|
| 41 |
+
_ROOT = os.path.join(os.path.dirname(__file__), "..")
|
| 42 |
+
if _ROOT not in sys.path:
|
| 43 |
+
sys.path.insert(0, _ROOT)
|
| 44 |
+
|
| 45 |
+
import math
|
| 46 |
+
from typing import Any, Dict, List, Optional
|
| 47 |
+
from uuid import uuid4
|
| 48 |
+
|
| 49 |
+
import numpy as np
|
| 50 |
+
from openenv.core.env_server.interfaces import Environment
|
| 51 |
+
|
| 52 |
+
from models import (
|
| 53 |
+
ProductState,
|
| 54 |
+
StoremanagerAction,
|
| 55 |
+
StoremanagerObservation,
|
| 56 |
+
StoremanagerState,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
try:
|
| 60 |
+
from tasks import TaskConfig
|
| 61 |
+
except ImportError:
|
| 62 |
+
TaskConfig = None # type: ignore[assignment,misc]
|
| 63 |
+
|
| 64 |
+
# ββ Curated product catalogue ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 65 |
+
_PRODUCT_NAMES: List[str] = [
|
| 66 |
+
"Milk", "Bread", "Cheese", "Yogurt", "Juice", "Butter",
|
| 67 |
+
"Eggs", "Cream", "Honey", "Jam", "Soda", "Water",
|
| 68 |
+
"Tea", "Coffee", "Flour", "Rice",
|
| 69 |
+
]
|
| 70 |
+
|
| 71 |
+
# ββ Zone constants βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 72 |
+
ZONE_MULTIPLIERS: Dict[int, float] = {1: 1.5, 2: 1.0, 3: 0.6}
|
| 73 |
+
ZONE_CAPACITY: Dict[int, int] = {1: 2, 2: 4, 3: 99}
|
| 74 |
+
PLACEMENT_COST: float = 3.0 # flat labor fee per zone move
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class StoremanagerEnvironment(Environment):
|
| 78 |
+
"""
|
| 79 |
+
Retail store manager RL environment.
|
| 80 |
+
|
| 81 |
+
The agent observes full inventory state and chooses one action per step:
|
| 82 |
+
apply a discount, place a restock order, or move a product to a better zone.
|
| 83 |
+
|
| 84 |
+
Args:
|
| 85 |
+
num_products: Number of distinct products in the store (default 8).
|
| 86 |
+
max_steps: Episode length in steps (default 30).
|
| 87 |
+
num_customers: Customers simulated per step (default 10).
|
| 88 |
+
seed: Optional base random seed for reproducible episodes.
|
| 89 |
+
"""
|
| 90 |
+
|
| 91 |
+
SUPPORTS_CONCURRENT_SESSIONS: bool = True
|
| 92 |
+
|
| 93 |
+
def __init__(
|
| 94 |
+
self,
|
| 95 |
+
task_config: Optional["TaskConfig"] = None, # type: ignore[name-defined]
|
| 96 |
+
num_products: int = 8,
|
| 97 |
+
max_steps: int = 30,
|
| 98 |
+
num_customers: int = 10,
|
| 99 |
+
seed: Optional[int] = None,
|
| 100 |
+
) -> None:
|
| 101 |
+
super().__init__()
|
| 102 |
+
if task_config is not None:
|
| 103 |
+
num_products = task_config.num_products
|
| 104 |
+
max_steps = task_config.max_steps
|
| 105 |
+
num_customers = task_config.num_customers
|
| 106 |
+
self._expiry_offset_min = task_config.expiry_offset_min
|
| 107 |
+
self._expiry_offset_max = task_config.expiry_offset_max
|
| 108 |
+
self._task_name = task_config.name
|
| 109 |
+
self._profit_target = task_config.profit_target
|
| 110 |
+
else:
|
| 111 |
+
self._expiry_offset_min = 8
|
| 112 |
+
self._expiry_offset_max = 25
|
| 113 |
+
self._task_name = "custom"
|
| 114 |
+
# Default: assume ~$8 average profit per step for custom configs
|
| 115 |
+
self._profit_target = 8.0 * max_steps
|
| 116 |
+
|
| 117 |
+
self._num_products = min(num_products, len(_PRODUCT_NAMES))
|
| 118 |
+
self._max_steps = max_steps
|
| 119 |
+
self._num_customers = num_customers
|
| 120 |
+
self._base_seed = seed
|
| 121 |
+
|
| 122 |
+
self._rng: np.random.Generator = np.random.default_rng(seed)
|
| 123 |
+
self._inventory: List[ProductState] = []
|
| 124 |
+
self._pending_orders: List[Dict[str, Any]] = []
|
| 125 |
+
self._current_step: int = 0
|
| 126 |
+
self._cumulative_profit: float = 0.0
|
| 127 |
+
|
| 128 |
+
self._state = StoremanagerState(
|
| 129 |
+
num_products=self._num_products,
|
| 130 |
+
max_steps=self._max_steps,
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
# ββ OpenEnv API βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 134 |
+
|
| 135 |
+
def reset(
|
| 136 |
+
self,
|
| 137 |
+
seed: Optional[int] = None,
|
| 138 |
+
episode_id: Optional[str] = None,
|
| 139 |
+
**kwargs: Any,
|
| 140 |
+
) -> StoremanagerObservation:
|
| 141 |
+
"""Reset the store and generate a fresh inventory.
|
| 142 |
+
|
| 143 |
+
Accepts an optional ``task`` keyword argument to dynamically switch the
|
| 144 |
+
task configuration without restarting the server. This allows a single
|
| 145 |
+
server instance to serve all difficulty levels sequentially.
|
| 146 |
+
"""
|
| 147 |
+
# ββ Dynamic task reconfiguration ββββββββββββββββββββββββββββββββββββ
|
| 148 |
+
task_name = kwargs.get("task")
|
| 149 |
+
if task_name:
|
| 150 |
+
try:
|
| 151 |
+
from tasks import TASKS # local import to avoid circular deps
|
| 152 |
+
task_config = TASKS.get(task_name)
|
| 153 |
+
if task_config is not None:
|
| 154 |
+
self._num_products = min(task_config.num_products, len(_PRODUCT_NAMES))
|
| 155 |
+
self._max_steps = task_config.max_steps
|
| 156 |
+
self._num_customers = task_config.num_customers
|
| 157 |
+
self._expiry_offset_min = task_config.expiry_offset_min
|
| 158 |
+
self._expiry_offset_max = task_config.expiry_offset_max
|
| 159 |
+
self._task_name = task_config.name
|
| 160 |
+
self._profit_target = task_config.profit_target
|
| 161 |
+
except ImportError:
|
| 162 |
+
pass # tasks module unavailable; keep current config
|
| 163 |
+
|
| 164 |
+
self._reset_rubric()
|
| 165 |
+
|
| 166 |
+
effective_seed = seed if seed is not None else self._base_seed
|
| 167 |
+
self._rng = np.random.default_rng(effective_seed)
|
| 168 |
+
|
| 169 |
+
self._current_step = 0
|
| 170 |
+
self._cumulative_profit = 0.0
|
| 171 |
+
self._pending_orders = []
|
| 172 |
+
self._inventory = self._generate_inventory()
|
| 173 |
+
self._recompute_pick_probs()
|
| 174 |
+
|
| 175 |
+
self._update_state(episode_id=episode_id or str(uuid4()), last_reward=0.0)
|
| 176 |
+
self._refresh_product_derived_fields()
|
| 177 |
+
|
| 178 |
+
return StoremanagerObservation(
|
| 179 |
+
inventory=list(self._inventory),
|
| 180 |
+
current_step=0,
|
| 181 |
+
max_steps=self._max_steps,
|
| 182 |
+
steps_remaining=self._max_steps,
|
| 183 |
+
cumulative_profit=0.0,
|
| 184 |
+
last_step_profit=0.0,
|
| 185 |
+
last_step_units_sold=0,
|
| 186 |
+
last_expired_products=[],
|
| 187 |
+
num_customers_per_step=self._num_customers,
|
| 188 |
+
task_name=self._task_name,
|
| 189 |
+
zone_capacity=dict(ZONE_CAPACITY),
|
| 190 |
+
zone_occupancy=self._zone_occupancy(),
|
| 191 |
+
zone_multipliers=dict(ZONE_MULTIPLIERS),
|
| 192 |
+
holding_cost_this_step=0.0,
|
| 193 |
+
placement_cost_this_step=0.0,
|
| 194 |
+
restock_cost_this_step=0.0,
|
| 195 |
+
unjustified_discount_penalty=0.0,
|
| 196 |
+
done=False,
|
| 197 |
+
reward=0.0,
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
def step(
|
| 201 |
+
self,
|
| 202 |
+
action: StoremanagerAction,
|
| 203 |
+
timeout_s: Optional[float] = None,
|
| 204 |
+
**kwargs: Any,
|
| 205 |
+
) -> StoremanagerObservation:
|
| 206 |
+
"""Execute one step: process deliveries, apply action, simulate customers, handle expiry."""
|
| 207 |
+
# ββ 1. Validate product_id ββββββββββββββββββββββββββββββββββββββββββ
|
| 208 |
+
valid_ids = {p.product_id for p in self._inventory}
|
| 209 |
+
if action.product_id not in valid_ids:
|
| 210 |
+
return self._error_observation(
|
| 211 |
+
f"Invalid product_id {action.product_id}. "
|
| 212 |
+
f"Valid IDs: {sorted(valid_ids)}"
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
# ββ 2. Process pending deliveries βββββββββββββββββββββββββββββββββββ
|
| 216 |
+
delivered = [o for o in self._pending_orders if o["arrive_at_step"] <= self._current_step]
|
| 217 |
+
for order in delivered:
|
| 218 |
+
product = next((p for p in self._inventory if p.product_id == order["product_id"]), None)
|
| 219 |
+
if product is not None:
|
| 220 |
+
product.quantity += order["quantity"]
|
| 221 |
+
product.is_active = True
|
| 222 |
+
product.pending_restock_quantity = 0
|
| 223 |
+
product.pending_restock_arrives_at_step = None
|
| 224 |
+
self._pending_orders = [o for o in self._pending_orders if o["arrive_at_step"] > self._current_step]
|
| 225 |
+
|
| 226 |
+
# ββ 3. Compute holding cost (charged before action) ββββββββββοΏ½οΏ½οΏ½ββββββ
|
| 227 |
+
holding_cost = sum(
|
| 228 |
+
p.quantity * p.cost_price * p.holding_cost_rate
|
| 229 |
+
for p in self._inventory
|
| 230 |
+
if p.is_active and p.quantity > 0
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
# ββ 4. Reset per-step discount tracker (selling_price persists across steps) ββ
|
| 234 |
+
# current_discount_pct tracks only this step's discount for the pick-prob formula.
|
| 235 |
+
# selling_price is NOT reset β discounts compound: each action cuts the current price.
|
| 236 |
+
for product in self._inventory:
|
| 237 |
+
product.current_discount_pct = 0.0
|
| 238 |
+
|
| 239 |
+
# ββ 5. Dispatch action ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 240 |
+
placement_cost = 0.0
|
| 241 |
+
restock_cost = 0.0
|
| 242 |
+
target = next(p for p in self._inventory if p.product_id == action.product_id)
|
| 243 |
+
|
| 244 |
+
if action.action_type == "discount":
|
| 245 |
+
if not target.is_active:
|
| 246 |
+
return self._error_observation(
|
| 247 |
+
f"Product '{target.name}' (id={target.product_id}) is no longer active "
|
| 248 |
+
f"(sold out or expired). Choose an active product to discount."
|
| 249 |
+
)
|
| 250 |
+
if target.quantity <= 0:
|
| 251 |
+
return self._error_observation(
|
| 252 |
+
f"Product '{target.name}' (id={target.product_id}) has no stock remaining."
|
| 253 |
+
)
|
| 254 |
+
target.current_discount_pct = float(action.discount_pct)
|
| 255 |
+
# Phase 1: dynamic expiry-aware price floor.
|
| 256 |
+
# Far from expiry: protect margin β floor at cost_price.
|
| 257 |
+
# Near expiry: allow progressive below-cost markdown to clear stock
|
| 258 |
+
# before the far-worse full expiry penalty hits.
|
| 259 |
+
steps_left = target.expiry_step - self._current_step
|
| 260 |
+
if steps_left >= 4:
|
| 261 |
+
price_floor = target.cost_price # full protection
|
| 262 |
+
elif steps_left == 3:
|
| 263 |
+
price_floor = round(target.cost_price * 0.70, 2) # 30% loss/unit max
|
| 264 |
+
elif steps_left == 2:
|
| 265 |
+
price_floor = round(target.cost_price * 0.40, 2) # 60% loss/unit max
|
| 266 |
+
else: # 1 step left
|
| 267 |
+
price_floor = round(target.cost_price * 0.10, 2) # 90% loss/unit max
|
| 268 |
+
target.selling_price = round(
|
| 269 |
+
max(price_floor, target.selling_price * (1.0 - action.discount_pct / 100.0)),
|
| 270 |
+
2,
|
| 271 |
+
)
|
| 272 |
+
# popularity_multiplier compounds up: repeated discounting makes the
|
| 273 |
+
# product progressively more visible/attractive to customers
|
| 274 |
+
if action.discount_pct > 0:
|
| 275 |
+
target.popularity_multiplier = round(
|
| 276 |
+
target.popularity_multiplier * (1.0 + action.discount_pct / 100.0), 6
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
elif action.action_type == "restock":
|
| 280 |
+
qty = action.restock_quantity
|
| 281 |
+
if qty is None or qty <= 0:
|
| 282 |
+
return self._error_observation(
|
| 283 |
+
f"restock_quantity must be a positive integer, got {qty!r}."
|
| 284 |
+
)
|
| 285 |
+
if target.expiry_step <= self._current_step:
|
| 286 |
+
return self._error_observation(
|
| 287 |
+
f"Product '{target.name}' (id={target.product_id}) has expired "
|
| 288 |
+
f"(expiry_step={target.expiry_step}, current_step={self._current_step}). "
|
| 289 |
+
f"Restocking an expired product wastes money β the stock would "
|
| 290 |
+
f"immediately re-expire and incur an expiry penalty."
|
| 291 |
+
)
|
| 292 |
+
if target.pending_restock_quantity > 0:
|
| 293 |
+
return self._error_observation(
|
| 294 |
+
f"Product '{target.name}' already has a pending restock order "
|
| 295 |
+
f"for {target.pending_restock_quantity} units arriving at step "
|
| 296 |
+
f"{target.pending_restock_arrives_at_step}. Wait for delivery first."
|
| 297 |
+
)
|
| 298 |
+
arrive_at = self._current_step + target.restock_lead_time
|
| 299 |
+
if arrive_at >= target.expiry_step:
|
| 300 |
+
return self._error_observation(
|
| 301 |
+
f"Restock order for '{target.name}' would arrive at step {arrive_at} "
|
| 302 |
+
f"but the product expires at step {target.expiry_step}. "
|
| 303 |
+
f"The delivered units would immediately expire and be penalized. "
|
| 304 |
+
f"Order cancelled to prevent wasted spend."
|
| 305 |
+
)
|
| 306 |
+
self._pending_orders.append({
|
| 307 |
+
"product_id": target.product_id,
|
| 308 |
+
"quantity": qty,
|
| 309 |
+
"arrive_at_step": arrive_at,
|
| 310 |
+
})
|
| 311 |
+
target.pending_restock_quantity = qty
|
| 312 |
+
target.pending_restock_arrives_at_step = arrive_at
|
| 313 |
+
restock_cost = qty * target.restock_cost_per_unit
|
| 314 |
+
|
| 315 |
+
elif action.action_type == "place":
|
| 316 |
+
zone = action.target_zone
|
| 317 |
+
if zone is None:
|
| 318 |
+
return self._error_observation("target_zone must be specified for 'place' actions.")
|
| 319 |
+
if zone == target.zone:
|
| 320 |
+
return self._error_observation(
|
| 321 |
+
f"Product '{target.name}' is already in zone {zone}."
|
| 322 |
+
)
|
| 323 |
+
# Count active products currently in the target zone (excluding this product)
|
| 324 |
+
occupants = sum(
|
| 325 |
+
1 for p in self._inventory
|
| 326 |
+
if p.is_active and p.zone == zone and p.product_id != target.product_id
|
| 327 |
+
)
|
| 328 |
+
if occupants >= ZONE_CAPACITY[zone]:
|
| 329 |
+
return self._error_observation(
|
| 330 |
+
f"Zone {zone} is at capacity ({ZONE_CAPACITY[zone]} products). "
|
| 331 |
+
f"Move another product out first."
|
| 332 |
+
)
|
| 333 |
+
target.zone = zone
|
| 334 |
+
placement_cost = PLACEMENT_COST
|
| 335 |
+
|
| 336 |
+
# ββ 6. Gather sellable products βββββββββββββββββββββββββββββββββββββ
|
| 337 |
+
active = [p for p in self._inventory if p.is_active and p.quantity > 0]
|
| 338 |
+
if not active:
|
| 339 |
+
done = True
|
| 340 |
+
self._update_state(last_reward=0.0)
|
| 341 |
+
self._refresh_product_derived_fields()
|
| 342 |
+
return StoremanagerObservation(
|
| 343 |
+
inventory=list(self._inventory),
|
| 344 |
+
current_step=self._current_step,
|
| 345 |
+
max_steps=self._max_steps,
|
| 346 |
+
steps_remaining=max(0, self._max_steps - self._current_step),
|
| 347 |
+
cumulative_profit=self._cumulative_profit,
|
| 348 |
+
last_step_profit=0.0,
|
| 349 |
+
last_step_units_sold=0,
|
| 350 |
+
last_expired_products=[],
|
| 351 |
+
num_customers_per_step=self._num_customers,
|
| 352 |
+
task_name=self._task_name,
|
| 353 |
+
zone_capacity=dict(ZONE_CAPACITY),
|
| 354 |
+
zone_occupancy=self._zone_occupancy(),
|
| 355 |
+
zone_multipliers=dict(ZONE_MULTIPLIERS),
|
| 356 |
+
holding_cost_this_step=round(holding_cost, 4),
|
| 357 |
+
placement_cost_this_step=placement_cost,
|
| 358 |
+
restock_cost_this_step=round(restock_cost, 4),
|
| 359 |
+
unjustified_discount_penalty=0.0,
|
| 360 |
+
done=done,
|
| 361 |
+
reward=self._normalize_reward(0.0),
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
# ββ 7. Compute pick probabilities (zone + discount + popularity) ββββ
|
| 365 |
+
effective_weights = np.array([
|
| 366 |
+
p.base_probability
|
| 367 |
+
* ZONE_MULTIPLIERS[p.zone]
|
| 368 |
+
* (1.0 + p.current_discount_pct / 100.0)
|
| 369 |
+
* p.popularity_multiplier
|
| 370 |
+
for p in active
|
| 371 |
+
])
|
| 372 |
+
pick_probs = effective_weights / effective_weights.sum()
|
| 373 |
+
|
| 374 |
+
for product, prob in zip(active, pick_probs):
|
| 375 |
+
product.effective_pick_prob = round(float(prob), 6)
|
| 376 |
+
|
| 377 |
+
# ββ 8. Simulate customers βββββββββββββββββββββββββββββββββββββββββββ
|
| 378 |
+
chosen_ids = self._rng.choice(
|
| 379 |
+
a=[p.product_id for p in active],
|
| 380 |
+
size=self._num_customers,
|
| 381 |
+
p=pick_probs,
|
| 382 |
+
replace=True,
|
| 383 |
+
)
|
| 384 |
+
sales_counts: dict[int, int] = {}
|
| 385 |
+
for pid in chosen_ids:
|
| 386 |
+
sales_counts[pid] = sales_counts.get(int(pid), 0) + 1
|
| 387 |
+
|
| 388 |
+
# ββ 9. Apply sales (clamp by available quantity) ββββββββββββββββββββ
|
| 389 |
+
step_revenue = 0.0
|
| 390 |
+
step_cost = 0.0
|
| 391 |
+
units_sold_total = 0
|
| 392 |
+
unjustified_discount_penalty = 0.0
|
| 393 |
+
|
| 394 |
+
for product in active:
|
| 395 |
+
wanted = sales_counts.get(product.product_id, 0)
|
| 396 |
+
sold = min(wanted, product.quantity)
|
| 397 |
+
if sold > 0:
|
| 398 |
+
step_revenue += sold * product.selling_price
|
| 399 |
+
step_cost += sold * product.cost_price
|
| 400 |
+
product.quantity -= sold
|
| 401 |
+
units_sold_total += sold
|
| 402 |
+
product.sales_history.append(
|
| 403 |
+
{
|
| 404 |
+
"step": self._current_step,
|
| 405 |
+
"discount_pct": product.current_discount_pct,
|
| 406 |
+
"units_sold": sold,
|
| 407 |
+
}
|
| 408 |
+
)
|
| 409 |
+
# Phase 3: if selling below cost with no urgency, double the per-unit
|
| 410 |
+
# loss as an extra penalty β gives a sharp "this is bad" signal.
|
| 411 |
+
if product.selling_price < product.cost_price:
|
| 412 |
+
steps_left = product.expiry_step - self._current_step
|
| 413 |
+
if steps_left >= 4:
|
| 414 |
+
unjustified_discount_penalty += sold * (product.cost_price - product.selling_price)
|
| 415 |
+
if product.quantity == 0:
|
| 416 |
+
product.is_active = False
|
| 417 |
+
product.effective_pick_prob = 0.0
|
| 418 |
+
|
| 419 |
+
# ββ 10. Advance step counter ββββββββββββββββββββββββββββββββββββββββ
|
| 420 |
+
self._current_step += 1
|
| 421 |
+
|
| 422 |
+
# ββ 11. Check expiry ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 423 |
+
expiry_penalty = 0.0
|
| 424 |
+
expired_names: List[str] = []
|
| 425 |
+
for product in self._inventory:
|
| 426 |
+
if product.is_active and product.expiry_step <= self._current_step:
|
| 427 |
+
expiry_penalty += product.quantity * product.cost_price
|
| 428 |
+
expired_names.append(product.name)
|
| 429 |
+
product.is_active = False
|
| 430 |
+
product.quantity = 0
|
| 431 |
+
product.effective_pick_prob = 0.0
|
| 432 |
+
|
| 433 |
+
# ββ 12. Reward and cumulative profit ββββββββββββββββββββββββββββββββ
|
| 434 |
+
step_profit = (
|
| 435 |
+
step_revenue - step_cost - expiry_penalty
|
| 436 |
+
- holding_cost - placement_cost - restock_cost
|
| 437 |
+
- unjustified_discount_penalty
|
| 438 |
+
)
|
| 439 |
+
self._cumulative_profit += step_profit
|
| 440 |
+
|
| 441 |
+
# ββ 13. Terminal condition βββββββββββββββββββββββββββββββββββββββββββ
|
| 442 |
+
done = self._current_step >= self._max_steps or all(
|
| 443 |
+
not p.is_active for p in self._inventory
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
# ββ 14. State update and return βββββββββββββββββββββββββββββββββββββ
|
| 447 |
+
normalized_reward = self._normalize_reward(step_profit)
|
| 448 |
+
self._update_state(last_reward=normalized_reward)
|
| 449 |
+
self._refresh_product_derived_fields()
|
| 450 |
+
|
| 451 |
+
return StoremanagerObservation(
|
| 452 |
+
inventory=list(self._inventory),
|
| 453 |
+
current_step=self._current_step,
|
| 454 |
+
max_steps=self._max_steps,
|
| 455 |
+
steps_remaining=max(0, self._max_steps - self._current_step),
|
| 456 |
+
cumulative_profit=self._cumulative_profit,
|
| 457 |
+
last_step_profit=step_profit,
|
| 458 |
+
last_step_units_sold=units_sold_total,
|
| 459 |
+
last_expired_products=expired_names,
|
| 460 |
+
num_customers_per_step=self._num_customers,
|
| 461 |
+
task_name=self._task_name,
|
| 462 |
+
zone_capacity=dict(ZONE_CAPACITY),
|
| 463 |
+
zone_occupancy=self._zone_occupancy(),
|
| 464 |
+
zone_multipliers=dict(ZONE_MULTIPLIERS),
|
| 465 |
+
holding_cost_this_step=round(holding_cost, 4),
|
| 466 |
+
placement_cost_this_step=placement_cost,
|
| 467 |
+
restock_cost_this_step=round(restock_cost, 4),
|
| 468 |
+
unjustified_discount_penalty=round(unjustified_discount_penalty, 4),
|
| 469 |
+
done=done,
|
| 470 |
+
reward=normalized_reward,
|
| 471 |
+
metadata={
|
| 472 |
+
"step_revenue": round(step_revenue, 4),
|
| 473 |
+
"step_cost": round(step_cost, 4),
|
| 474 |
+
"expiry_penalty": round(expiry_penalty, 4),
|
| 475 |
+
"holding_cost": round(holding_cost, 4),
|
| 476 |
+
"placement_cost": placement_cost,
|
| 477 |
+
"restock_cost": round(restock_cost, 4),
|
| 478 |
+
"unjustified_discount_penalty": round(unjustified_discount_penalty, 4),
|
| 479 |
+
"step_profit": round(step_profit, 4),
|
| 480 |
+
},
|
| 481 |
+
)
|
| 482 |
+
|
| 483 |
+
@property
|
| 484 |
+
def state(self) -> StoremanagerState:
|
| 485 |
+
return self._state
|
| 486 |
+
|
| 487 |
+
# ββ Private helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 488 |
+
|
| 489 |
+
def _normalize_reward(self, step_profit: float) -> float:
|
| 490 |
+
"""
|
| 491 |
+
Map raw step profit to a meaningful reward in (0.0, 1.0) using a
|
| 492 |
+
sigmoid centered at break-even (step_profit = 0).
|
| 493 |
+
|
| 494 |
+
Formula:
|
| 495 |
+
x = step_profit / per_step_target (normalise by average needed per step)
|
| 496 |
+
reward = sigmoid(k * x) = 1 / (1 + exp(-k * x))
|
| 497 |
+
|
| 498 |
+
Where k=2 gives a useful spread:
|
| 499 |
+
step_profit = 0 -> 0.50 (neutral, broke even)
|
| 500 |
+
step_profit = per_step_tgt -> 0.88 (on-track for target)
|
| 501 |
+
step_profit = 2*per_step_tgt -> 0.98 (excellent step)
|
| 502 |
+
step_profit = -per_step_tgt -> 0.12 (below break-even)
|
| 503 |
+
step_profit = -2*per_step_tgt -> 0.02 (large penalty step)
|
| 504 |
+
|
| 505 |
+
This gives the model a continuous, differentiable gradient signal
|
| 506 |
+
across the full [0, 1] range β much more informative than a hard clamp.
|
| 507 |
+
Raw dollar profit is preserved in last_step_profit and metadata.
|
| 508 |
+
"""
|
| 509 |
+
per_step_target = self._profit_target / self._max_steps
|
| 510 |
+
x = step_profit / per_step_target if per_step_target != 0 else 0.0
|
| 511 |
+
return round(1.0 / (1.0 + math.exp(-2.0 * x)), 6)
|
| 512 |
+
|
| 513 |
+
def _zone_occupancy(self) -> Dict[int, int]:
|
| 514 |
+
"""Count active products in each zone."""
|
| 515 |
+
occupancy: Dict[int, int] = {1: 0, 2: 0, 3: 0}
|
| 516 |
+
for p in self._inventory:
|
| 517 |
+
if p.is_active and p.zone in occupancy:
|
| 518 |
+
occupancy[p.zone] += 1
|
| 519 |
+
return occupancy
|
| 520 |
+
|
| 521 |
+
def _refresh_product_derived_fields(self) -> None:
|
| 522 |
+
"""Recompute ephemeral derived fields on every product before returning an observation."""
|
| 523 |
+
for p in self._inventory:
|
| 524 |
+
p.steps_until_expiry = max(0, p.expiry_step - self._current_step)
|
| 525 |
+
p.margin_per_unit = round(p.selling_price - p.cost_price, 4)
|
| 526 |
+
|
| 527 |
+
def _recompute_pick_probs(self) -> None:
|
| 528 |
+
"""Recompute normalised pick probabilities for all active products (zone + popularity)."""
|
| 529 |
+
active = [p for p in self._inventory if p.is_active and p.quantity > 0]
|
| 530 |
+
if not active:
|
| 531 |
+
return
|
| 532 |
+
effs = np.array([
|
| 533 |
+
p.base_probability
|
| 534 |
+
* ZONE_MULTIPLIERS[p.zone]
|
| 535 |
+
* (1.0 + p.current_discount_pct / 100.0)
|
| 536 |
+
* p.popularity_multiplier
|
| 537 |
+
for p in active
|
| 538 |
+
])
|
| 539 |
+
probs = effs / effs.sum()
|
| 540 |
+
for product, prob in zip(active, probs):
|
| 541 |
+
product.effective_pick_prob = round(float(prob), 6)
|
| 542 |
+
|
| 543 |
+
def _generate_inventory(self) -> List[ProductState]:
|
| 544 |
+
"""Generate a randomised product inventory for a new episode."""
|
| 545 |
+
chosen_names = self._rng.choice(
|
| 546 |
+
_PRODUCT_NAMES, size=self._num_products, replace=False
|
| 547 |
+
)
|
| 548 |
+
|
| 549 |
+
# Draw base probability weights via Dirichlet, then rescale to [0.05, 0.4]
|
| 550 |
+
raw_probs = self._rng.dirichlet(alpha=[1.0] * self._num_products)
|
| 551 |
+
max_raw = raw_probs.max()
|
| 552 |
+
base_probs = 0.05 + raw_probs * (0.40 - 0.05) / max_raw
|
| 553 |
+
|
| 554 |
+
# Assign initial zones: fill zone 1 first (up to capacity), then zone 2, rest to zone 3
|
| 555 |
+
zone_slots = {z: ZONE_CAPACITY[z] for z in [1, 2, 3]}
|
| 556 |
+
product_zones: List[int] = []
|
| 557 |
+
for _ in range(self._num_products):
|
| 558 |
+
for zone in [1, 2, 3]:
|
| 559 |
+
if zone_slots[zone] > 0:
|
| 560 |
+
product_zones.append(zone)
|
| 561 |
+
zone_slots[zone] -= 1
|
| 562 |
+
break
|
| 563 |
+
|
| 564 |
+
inventory: List[ProductState] = []
|
| 565 |
+
for i in range(self._num_products):
|
| 566 |
+
cost = round(float(self._rng.uniform(1.0, 8.0)), 2)
|
| 567 |
+
margin = float(self._rng.uniform(1.3, 2.5))
|
| 568 |
+
selling = round(cost * margin, 2)
|
| 569 |
+
qty = int(self._rng.integers(15, 41))
|
| 570 |
+
expiry_offset = int(self._rng.integers(self._expiry_offset_min, self._expiry_offset_max + 1))
|
| 571 |
+
holding_rate = round(float(self._rng.uniform(0.01, 0.04)), 4)
|
| 572 |
+
restock_cost_per_unit = round(cost * float(self._rng.uniform(1.0, 1.25)), 2)
|
| 573 |
+
lead_time = int(self._rng.integers(1, 4)) # 1β3 steps
|
| 574 |
+
|
| 575 |
+
inventory.append(
|
| 576 |
+
ProductState(
|
| 577 |
+
product_id=i,
|
| 578 |
+
name=str(chosen_names[i]),
|
| 579 |
+
cost_price=cost,
|
| 580 |
+
selling_price=selling,
|
| 581 |
+
base_selling_price=selling,
|
| 582 |
+
quantity=qty,
|
| 583 |
+
expiry_step=self._current_step + expiry_offset,
|
| 584 |
+
base_probability=float(base_probs[i]),
|
| 585 |
+
current_discount_pct=0.0,
|
| 586 |
+
is_active=True,
|
| 587 |
+
sales_history=[],
|
| 588 |
+
zone=product_zones[i],
|
| 589 |
+
holding_cost_rate=holding_rate,
|
| 590 |
+
restock_cost_per_unit=restock_cost_per_unit,
|
| 591 |
+
restock_lead_time=lead_time,
|
| 592 |
+
pending_restock_quantity=0,
|
| 593 |
+
pending_restock_arrives_at_step=None,
|
| 594 |
+
popularity_multiplier=1.0,
|
| 595 |
+
)
|
| 596 |
+
)
|
| 597 |
+
return inventory
|
| 598 |
+
|
| 599 |
+
def _update_state(
|
| 600 |
+
self,
|
| 601 |
+
*,
|
| 602 |
+
episode_id: Optional[str] = None,
|
| 603 |
+
last_reward: float = 0.0,
|
| 604 |
+
) -> None:
|
| 605 |
+
"""Sync the internal StoremanagerState with current environment data."""
|
| 606 |
+
if episode_id is not None:
|
| 607 |
+
self._state.episode_id = episode_id
|
| 608 |
+
self._state.step_count = self._current_step
|
| 609 |
+
self._state.num_products = self._num_products
|
| 610 |
+
self._state.max_steps = self._max_steps
|
| 611 |
+
self._state.cumulative_profit = self._cumulative_profit
|
| 612 |
+
self._state.last_reward = last_reward
|
| 613 |
+
self._state.active_product_count = sum(
|
| 614 |
+
1 for p in self._inventory if p.is_active
|
| 615 |
+
)
|
| 616 |
+
self._state.inventory_snapshot = [p.model_dump() for p in self._inventory]
|
| 617 |
+
|
| 618 |
+
def _error_observation(self, message: str) -> StoremanagerObservation:
|
| 619 |
+
"""Return an observation flagging an invalid action (step does NOT advance)."""
|
| 620 |
+
self._refresh_product_derived_fields()
|
| 621 |
+
return StoremanagerObservation(
|
| 622 |
+
inventory=list(self._inventory),
|
| 623 |
+
current_step=self._current_step,
|
| 624 |
+
max_steps=self._max_steps,
|
| 625 |
+
steps_remaining=max(0, self._max_steps - self._current_step),
|
| 626 |
+
cumulative_profit=self._cumulative_profit,
|
| 627 |
+
last_step_profit=0.0,
|
| 628 |
+
last_step_units_sold=0,
|
| 629 |
+
last_expired_products=[],
|
| 630 |
+
num_customers_per_step=self._num_customers,
|
| 631 |
+
task_name=self._task_name,
|
| 632 |
+
zone_capacity=dict(ZONE_CAPACITY),
|
| 633 |
+
zone_occupancy=self._zone_occupancy(),
|
| 634 |
+
zone_multipliers=dict(ZONE_MULTIPLIERS),
|
| 635 |
+
holding_cost_this_step=0.0,
|
| 636 |
+
placement_cost_this_step=0.0,
|
| 637 |
+
restock_cost_this_step=0.0,
|
| 638 |
+
unjustified_discount_penalty=0.0,
|
| 639 |
+
done=False,
|
| 640 |
+
reward=0.0,
|
| 641 |
+
error=message,
|
| 642 |
+
)
|
| 643 |
+
|
| 644 |
+
|
| 645 |
+
# ββ Standalone smoke test βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 646 |
+
|
| 647 |
+
if __name__ == "__main__":
|
| 648 |
+
env = StoremanagerEnvironment(num_products=4, max_steps=6, seed=42)
|
| 649 |
+
obs = env.reset()
|
| 650 |
+
print("=== RESET ===")
|
| 651 |
+
for p in obs.inventory:
|
| 652 |
+
print(f" [{p.product_id}] {p.name:10s} zone={p.zone} qty={p.quantity} "
|
| 653 |
+
f"lead={p.restock_lead_time} hold_rate={p.holding_cost_rate:.3f}")
|
| 654 |
+
print(f" Zone occupancy: {obs.zone_occupancy}")
|
| 655 |
+
|
| 656 |
+
actions = [
|
| 657 |
+
StoremanagerAction(action_type="discount", product_id=0, discount_pct=20),
|
| 658 |
+
StoremanagerAction(action_type="restock", product_id=1, restock_quantity=10),
|
| 659 |
+
StoremanagerAction(action_type="place", product_id=2, target_zone=1),
|
| 660 |
+
StoremanagerAction(action_type="place", product_id=3, target_zone=1), # should fail β zone 1 at capacity
|
| 661 |
+
StoremanagerAction(action_type="discount", product_id=0, discount_pct=10),
|
| 662 |
+
StoremanagerAction(action_type="discount", product_id=1, discount_pct=0),
|
| 663 |
+
]
|
| 664 |
+
|
| 665 |
+
for step_num, action in enumerate(actions):
|
| 666 |
+
obs = env.step(action)
|
| 667 |
+
print(f"\n=== STEP {step_num + 1} ({action.action_type.upper()}) ===")
|
| 668 |
+
if obs.error:
|
| 669 |
+
print(f" ERROR: {obs.error}")
|
| 670 |
+
else:
|
| 671 |
+
print(f" Reward: {obs.reward:.2f}")
|
| 672 |
+
print(f" Holding cost: {obs.holding_cost_this_step:.4f}")
|
| 673 |
+
print(f" Placement cost: {obs.placement_cost_this_step:.2f}")
|
| 674 |
+
print(f" Restock cost: {obs.restock_cost_this_step:.4f}")
|
| 675 |
+
print(f" Cumulative: {obs.cumulative_profit:.2f}")
|
| 676 |
+
print(f" Zone occupancy: {obs.zone_occupancy}")
|
| 677 |
+
if obs.last_expired_products:
|
| 678 |
+
print(f" Expired: {obs.last_expired_products}")
|
| 679 |
+
if obs.done:
|
| 680 |
+
print(" Episode done.")
|
| 681 |
+
break
|
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 |
+
"""Storemanager environment server components."""
|
| 8 |
+
|
| 9 |
+
from .StoreManager_environment import StoremanagerEnvironment
|
| 10 |
+
|
| 11 |
+
__all__ = ["StoremanagerEnvironment"]
|
server/app.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 Store Manager Environment.
|
| 9 |
+
|
| 10 |
+
This module creates an HTTP + WebSocket server that exposes the
|
| 11 |
+
StoremanagerEnvironment over the OpenEnv API.
|
| 12 |
+
|
| 13 |
+
Endpoints:
|
| 14 |
+
POST /reset β Reset the environment, return initial observation
|
| 15 |
+
POST /step β Execute an action, return next observation + reward
|
| 16 |
+
GET /state β Get current environment state
|
| 17 |
+
GET /schema β Action / Observation / State JSON schemas
|
| 18 |
+
WS /ws β Persistent WebSocket session (lower latency)
|
| 19 |
+
|
| 20 |
+
Configuration (via environment variables):
|
| 21 |
+
STORE_NUM_PRODUCTS Number of products per episode (default 8)
|
| 22 |
+
STORE_MAX_STEPS Episode length in steps (default 30)
|
| 23 |
+
STORE_NUM_CUSTOMERS Customers simulated per step (default 10)
|
| 24 |
+
STORE_MAX_CONCURRENT_ENVS Max parallel WebSocket sessions (default 4)
|
| 25 |
+
|
| 26 |
+
Usage:
|
| 27 |
+
# Development (with auto-reload):
|
| 28 |
+
uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
|
| 29 |
+
|
| 30 |
+
# Production:
|
| 31 |
+
uvicorn server.app:app --host 0.0.0.0 --port 8000 --workers 4
|
| 32 |
+
|
| 33 |
+
# Or run directly:
|
| 34 |
+
python -m server.app
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
import os
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
from openenv.core.env_server.http_server import create_app
|
| 41 |
+
except Exception as e: # pragma: no cover
|
| 42 |
+
raise ImportError(
|
| 43 |
+
"openenv is required. Install dependencies with:\n uv sync\n"
|
| 44 |
+
) from e
|
| 45 |
+
|
| 46 |
+
try:
|
| 47 |
+
from models import StoremanagerAction, StoremanagerObservation
|
| 48 |
+
from StoreManager_environment import StoremanagerEnvironment
|
| 49 |
+
from tasks import TASKS
|
| 50 |
+
except ModuleNotFoundError:
|
| 51 |
+
from models import StoremanagerAction, StoremanagerObservation
|
| 52 |
+
from server.StoreManager_environment import StoremanagerEnvironment
|
| 53 |
+
from tasks import TASKS
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def create_store_environment() -> StoremanagerEnvironment:
|
| 57 |
+
"""Factory: create a fresh StoremanagerEnvironment from env-var config.
|
| 58 |
+
|
| 59 |
+
If STORE_TASK is set to a valid task name (easy/medium/hard), the task
|
| 60 |
+
config takes precedence over individual STORE_* vars.
|
| 61 |
+
"""
|
| 62 |
+
task_name = os.getenv("STORE_TASK", "")
|
| 63 |
+
task_config = TASKS.get(task_name) if task_name else None
|
| 64 |
+
return StoremanagerEnvironment(
|
| 65 |
+
task_config=task_config,
|
| 66 |
+
num_products=int(os.getenv("STORE_NUM_PRODUCTS", "8")),
|
| 67 |
+
max_steps=int(os.getenv("STORE_MAX_STEPS", "30")),
|
| 68 |
+
num_customers=int(os.getenv("STORE_NUM_CUSTOMERS", "10")),
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
app = create_app(
|
| 73 |
+
create_store_environment,
|
| 74 |
+
StoremanagerAction,
|
| 75 |
+
StoremanagerObservation,
|
| 76 |
+
env_name="StoreManager",
|
| 77 |
+
max_concurrent_envs=int(os.getenv("STORE_MAX_CONCURRENT_ENVS", "4")),
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def main(host: str = "0.0.0.0", port: int = 8000) -> None:
|
| 82 |
+
"""
|
| 83 |
+
Entry point for direct execution via uv run or python -m.
|
| 84 |
+
|
| 85 |
+
uv run --project . server
|
| 86 |
+
uv run --project . server --port 8001
|
| 87 |
+
python -m StoreManager.server.app
|
| 88 |
+
|
| 89 |
+
Args:
|
| 90 |
+
host: Host address to bind to (default: "0.0.0.0")
|
| 91 |
+
port: Port number to listen on (default: 8000)
|
| 92 |
+
"""
|
| 93 |
+
import uvicorn
|
| 94 |
+
|
| 95 |
+
uvicorn.run(app, host=host, port=port)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
if __name__ == "__main__":
|
| 99 |
+
import argparse
|
| 100 |
+
|
| 101 |
+
parser = argparse.ArgumentParser()
|
| 102 |
+
parser.add_argument("--port", type=int, default=8000)
|
| 103 |
+
args = parser.parse_args()
|
| 104 |
+
main(port=args.port)
|
server/requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openenv-core[core]>=0.2.1
|
| 2 |
+
fastapi>=0.115.0
|
| 3 |
+
uvicorn>=0.24.0
|
| 4 |
+
numpy>=1.24.0
|
| 5 |
+
websocket-client>=1.6.0
|
| 6 |
+
openai>=1.0.0
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
|
tasks.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
Task configurations and graders for the Store Manager environment.
|
| 9 |
+
|
| 10 |
+
Four difficulty levels are defined, in strict increasing order of complexity:
|
| 11 |
+
|
| 12 |
+
- easy : 4 products, 20 steps β learn discount mechanics, no expiry pressure
|
| 13 |
+
- medium : 8 products, 28 steps β discount + zone placement (zone 1 is contested)
|
| 14 |
+
- hard : 10 products, 30 steps β discount + zone + expiry timing (most expire mid-episode)
|
| 15 |
+
- expert : 12 products, 25 steps β all three actions required (restock essential, aggressive expiry)
|
| 16 |
+
|
| 17 |
+
Each task has a fixed seed so grading is deterministic: the same agent always
|
| 18 |
+
produces the same episode given the same seed, and the score is computed as:
|
| 19 |
+
|
| 20 |
+
score = clamp(cumulative_profit / profit_target, 0.0, 1.0)
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
from dataclasses import dataclass
|
| 26 |
+
from typing import Dict
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class TaskConfig:
|
| 31 |
+
"""Configuration for one difficulty level of the Store Manager task."""
|
| 32 |
+
|
| 33 |
+
name: str
|
| 34 |
+
description: str
|
| 35 |
+
num_products: int
|
| 36 |
+
max_steps: int
|
| 37 |
+
num_customers: int
|
| 38 |
+
expiry_offset_min: int # min steps before a product expires
|
| 39 |
+
expiry_offset_max: int # max steps before a product expires
|
| 40 |
+
seed: int # fixed seed for deterministic episode generation
|
| 41 |
+
profit_target: float # cumulative profit at which grade == 1.0
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ββ Canonical task registry ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 45 |
+
|
| 46 |
+
TASKS: Dict[str, TaskConfig] = {
|
| 47 |
+
"easy": TaskConfig(
|
| 48 |
+
name="easy",
|
| 49 |
+
description=(
|
| 50 |
+
"4 products, 20 steps, 15 customers/step. "
|
| 51 |
+
"Products expire well after the episode ends β no expiry pressure. "
|
| 52 |
+
"Focus on learning the discount mechanic to boost high-margin products. "
|
| 53 |
+
"Zone placement is available but low-stakes; restocking is unnecessary."
|
| 54 |
+
),
|
| 55 |
+
num_products=4,
|
| 56 |
+
max_steps=20,
|
| 57 |
+
num_customers=15,
|
| 58 |
+
expiry_offset_min=18,
|
| 59 |
+
expiry_offset_max=30,
|
| 60 |
+
seed=42,
|
| 61 |
+
profit_target=120.0,
|
| 62 |
+
),
|
| 63 |
+
"medium": TaskConfig(
|
| 64 |
+
name="medium",
|
| 65 |
+
description=(
|
| 66 |
+
"8 products, 28 steps, 12 customers/step. "
|
| 67 |
+
"Several products expire mid-episode (12-22 steps). "
|
| 68 |
+
"Zone 1 has only 2 slots for 8 competing products β "
|
| 69 |
+
"combine targeted discounts with zone placement to maximize clearance "
|
| 70 |
+
"before expiry. Restocking is rarely necessary."
|
| 71 |
+
),
|
| 72 |
+
num_products=8,
|
| 73 |
+
max_steps=28,
|
| 74 |
+
num_customers=12,
|
| 75 |
+
expiry_offset_min=12,
|
| 76 |
+
expiry_offset_max=22,
|
| 77 |
+
seed=200,
|
| 78 |
+
profit_target=200.0,
|
| 79 |
+
),
|
| 80 |
+
"hard": TaskConfig(
|
| 81 |
+
name="hard",
|
| 82 |
+
description=(
|
| 83 |
+
"10 products, 30 steps, 10 customers/step. "
|
| 84 |
+
"Most products expire in 8-18 steps β expiry management is critical. "
|
| 85 |
+
"Zone strategy (rotate zone 1 for near-expiry items) combined with "
|
| 86 |
+
"discounts drives clearance. Restock fast-sellers that run out early "
|
| 87 |
+
"to maintain revenue through the full episode."
|
| 88 |
+
),
|
| 89 |
+
num_products=10,
|
| 90 |
+
max_steps=30,
|
| 91 |
+
num_customers=10,
|
| 92 |
+
expiry_offset_min=8,
|
| 93 |
+
expiry_offset_max=18,
|
| 94 |
+
seed=456,
|
| 95 |
+
profit_target=240.0,
|
| 96 |
+
),
|
| 97 |
+
"expert": TaskConfig(
|
| 98 |
+
name="expert",
|
| 99 |
+
description=(
|
| 100 |
+
"12 products, 25 steps, 8 customers/step. "
|
| 101 |
+
"Aggressive expiry (4-10 steps) with limited initial stock β "
|
| 102 |
+
"all three actions must be coordinated: discount near-expiry items to "
|
| 103 |
+
"clear them fast, restock high-margin products before they stock out, "
|
| 104 |
+
"and continuously rotate zone 1 to prioritise the most urgent products."
|
| 105 |
+
),
|
| 106 |
+
num_products=12,
|
| 107 |
+
max_steps=25,
|
| 108 |
+
num_customers=8,
|
| 109 |
+
expiry_offset_min=4,
|
| 110 |
+
expiry_offset_max=10,
|
| 111 |
+
seed=789,
|
| 112 |
+
profit_target=190.0,
|
| 113 |
+
),
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# ββ Grader βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 118 |
+
|
| 119 |
+
def grade(task_name: str, cumulative_profit: float) -> float:
|
| 120 |
+
"""
|
| 121 |
+
Return a deterministic score in [0.0, 1.0] for a completed episode.
|
| 122 |
+
|
| 123 |
+
Formula:
|
| 124 |
+
score = clamp(cumulative_profit / profit_target, 0.0, 1.0)
|
| 125 |
+
|
| 126 |
+
Args:
|
| 127 |
+
task_name: One of "easy", "medium", "hard", "expert" (or a custom string).
|
| 128 |
+
cumulative_profit: Total profit accumulated over the episode.
|
| 129 |
+
|
| 130 |
+
Returns:
|
| 131 |
+
Score between 0.0 (no profit) and 1.0 (hit or exceeded target).
|
| 132 |
+
"""
|
| 133 |
+
config = TASKS.get(task_name)
|
| 134 |
+
if config is None or config.profit_target <= 0:
|
| 135 |
+
return 1.0
|
| 136 |
+
return min(1.0, max(0.0, cumulative_profit / config.profit_target))
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
__all__ = ["TaskConfig", "TASKS", "grade"]
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|