Spaces:
Sleeping
Sleeping
File size: 19,080 Bytes
aab74bd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 | ---
title: Store Manager Environment
emoji: π
colorFrom: blue
colorTo: yellow
sdk: docker
pinned: false
app_port: 8000
base_path: /web
tags:
- openenv
---
# Store Manager Environment
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.
---
## What Makes This Environment Interesting
This environment models three interconnected real-world retail mechanics that require the agent to reason about **compounding consequences**:
- **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.
- **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.
- **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.
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.
---
## Task Overview
| Component | Description |
|---|---|
| **Products** | N products, each with cost price, selling price, quantity, expiry step, shelf zone, and a base pick probability |
| **Customers** | Fixed number of customers per step pick products according to a normalized probability distribution |
| **Agent Action** | Exactly one of three action types per step: `discount`, `restock`, or `place` |
| **Reward** | Normalized sigmoid reward in `[0.0, 1.0]` based on step profit vs. per-step target |
| **Expiry penalty** | `remaining_quantity Γ cost_price` charged when a product reaches its expiry step |
| **Episode end** | After `max_steps` steps, or when all products are inactive |
### Pick Probability Formula
Customer pick probability combines zone placement and discount multiplier:
```
effective_weight[i] = base_probability[i] Γ zone_multiplier[zone[i]] Γ (1 + discount_pct[i] / 100)
pick_prob[i] = effective_weight[i] / sum(effective_weight for all active products)
```
---
## Actions
The agent submits exactly one action per step.
### 1. Discount
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.
```json
{ "action_type": "discount", "product_id": 2, "discount_pct": 20 }
```
| Field | Type | Values |
|---|---|---|
| `action_type` | string | `"discount"` |
| `product_id` | int | any valid product ID |
| `discount_pct` | int | `0`, `10`, `20`, or `50` |
**Compounding example** (base price $10.00):
| Step | Discount | Selling Price | Total off base |
|------|----------|--------------|----------------|
| 1 | 20% | $8.00 | β20% |
| 2 | 20% | $6.40 | β36% |
| 3 | 20% | $5.12 | β49% |
| 4 | 50% | $2.56 | β74% |
- `base_selling_price` is never modified β it serves as a reference for how far prices have been cut.
- `current_discount_pct` reflects only the discount tier applied **this step** (used in pick-probability formula).
- Prices reset to `base_selling_price` at the start of each new episode (`reset()`).
- A `0%` discount leaves the price unchanged but still advances the step.
**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.
---
### 2. Restock
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.
```json
{ "action_type": "restock", "product_id": 1, "restock_quantity": 15 }
```
| Field | Type | Values |
|---|---|---|
| `action_type` | string | `"restock"` |
| `product_id` | int | any valid product ID |
| `restock_quantity` | int | any positive integer |
**How it works:**
- On restock: `pending_restock_quantity = restock_quantity`, `pending_restock_arrives_at_step = current_step + restock_lead_time`
- When delivery step is reached: `quantity += restock_quantity`, pending fields cleared
- Only **one pending order per product** at a time β wait for delivery before reordering
**Costs:**
- `restock_cost = restock_quantity Γ restock_cost_per_unit` charged upfront this step
- `holding_cost = quantity Γ cost_price Γ holding_cost_rate` charged every step on all stock regardless of action
**Per-product restock parameters (randomised at episode start):**
| Field | Range | Description |
|---|---|---|
| `restock_lead_time` | 1β3 steps | Steps until delivery |
| `restock_cost_per_unit` | `cost_price Γ 1.00β1.25` | Ordering cost per unit |
| `holding_cost_rate` | 0.01β0.04 | Fraction of `cost_price` charged per unit per step |
**Error cases:** Invalid if `restock_quantity β€ 0` or product already has a pending order.
**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.
---
### 3. Place (Zone Placement)
Move a product to a different shelf zone. Takes effect immediately. Charges a flat **labor fee of $3.00** per move.
```json
{ "action_type": "place", "product_id": 3, "target_zone": 1 }
```
| Field | Type | Values |
|---|---|---|
| `action_type` | string | `"place"` |
| `product_id` | int | any valid product ID |
| `target_zone` | int | `1`, `2`, or `3` |
**Zone multipliers and capacity:**
| Zone | Location | Pick Probability Multiplier | Max Products |
|------|----------|-----------------------------|--------------|
| 1 | Premium / entrance / eye-level | **Γ1.5** | 2 |
| 2 | Standard shelves | **Γ1.0** | 4 |
| 3 | Back / low-traffic | **Γ0.6** | unlimited |
**Initial zone assignment at episode start:** products fill zone 1 first (up to capacity 2), then zone 2, remainder in zone 3.
**Error cases:** Invalid if `target_zone` is not in {1, 2, 3}, product is already in `target_zone`, or target zone is at capacity.
**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.
---
## Reward Function
Each step returns a **normalized reward in `[0.0, 1.0]`** using a sigmoid centred at break-even:
```
step_profit = step_revenue β cost_of_goods_sold β expiry_penalty
β holding_cost β placement_cost β restock_cost
x = step_profit / per_step_target
reward = sigmoid(2x) = 1 / (1 + exp(β2x))
```
| Step profit vs. target | reward |
|---|---|
| Catastrophic loss (ββ) | β 0.0 |
| Break-even (0) | = 0.50 |
| On target | β 0.88 |
| 2Γ target | β 0.98 |
The raw per-step cost breakdown is available in `metadata`:
```json
{
"step_revenue": 45.20,
"step_cost": 18.40,
"expiry_penalty": 0.0,
"holding_cost": 12.35,
"placement_cost": 3.0,
"restock_cost": 0.0,
"step_profit": 11.45
}
```
---
## Difficulty Tasks
Four pre-configured tasks with increasing complexity. Each task has a fixed seed for deterministic, reproducible grading.
| Task | Products | Steps | Customers/step | Expiry range | Profit target | Dominant mechanic |
|------|----------|-------|----------------|--------------|--------------|-------------------|
| **easy** | 4 | 20 | 15 | 18β30 steps | $120 | Discount |
| **medium** | 8 | 28 | 12 | 12β22 steps | $200 | Discount + zone placement |
| **hard** | 10 | 30 | 10 | 8β18 steps | $240 | Discount + zone + expiry timing |
| **expert** | 12 | 25 | 8 | 4β10 steps | $190 | All three actions coordinated |
### Task Descriptions
**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.
**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.
**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.
**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.
### Grading
```
score = clamp(cumulative_profit / profit_target, 0.0, 1.0)
```
---
## Baseline Scores
Expected performance using `gpt-4o-mini` (deterministic, fixed seeds):
| Task | Profit Target | Expected Profit | Expected Score |
|------|--------------|-----------------|----------------|
| easy | $120 | ~$140β180 | ~0.90β1.0 |
| medium | $200 | ~$160β220 | ~0.80β1.0 |
| hard | $240 | ~$180β260 | ~0.75β1.0 |
| expert | $190 | ~$100β170 | ~0.50β0.90 |
Scores are fully reproducible. Re-run `inference.py` with the same model and seeds to verify.
---
## Quick Start
```python
from StoreManager import StoremanagerAction, StoremanagerEnv
with StoremanagerEnv(base_url="http://localhost:8000") as env:
result = env.reset()
print([p.name for p in result.observation.inventory])
# Discount: compounds on current price each time applied
result = env.step(StoremanagerAction(action_type="discount", product_id=0, discount_pct=20))
print(f"Reward: {result.reward:.2f}")
# Restock: stock arrives after restock_lead_time steps
result = env.step(StoremanagerAction(action_type="restock", product_id=1, restock_quantity=10))
print(f"Restock cost: {result.observation.restock_cost_this_step:.2f}")
# Place: move product to premium zone (costs $3.00 labor)
result = env.step(StoremanagerAction(action_type="place", product_id=2, target_zone=1))
print(f"Placement cost: {result.observation.placement_cost_this_step:.2f}")
print(f"Zone occupancy: {result.observation.zone_occupancy}")
```
---
## Running Locally
```bash
# Install dependencies
uv sync
# Run dev server (auto-reload, default config)
uvicorn server.app:app --reload
# Run with a specific difficulty task baked in at startup
STORE_TASK=hard uvicorn server.app:app --reload
# Test environment logic directly (no server needed)
python server/StoreManager_environment.py
# Run the WebSocket test suite (server must be running)
python test_ws.py
```
---
## Running the Inference Script
The `inference.py` script runs an LLM agent through all four tasks sequentially and reports a score for each.
### Required Environment Variables
| Variable | Description |
|---|---|
| `HF_TOKEN` | API key β your Hugging Face token or OpenAI API key |
| `API_BASE_URL` | LLM API base URL (e.g. `https://api-inference.huggingface.co/v1/`) |
| `MODEL_NAME` | Model identifier (e.g. `meta-llama/Llama-3.1-8B-Instruct` or `gpt-4o-mini`) |
```bash
# Server must be running first
uvicorn server.app:app &
# Run with Hugging Face Inference API
HF_TOKEN=hf_... \
API_BASE_URL=https://api-inference.huggingface.co/v1/ \
MODEL_NAME=meta-llama/Llama-3.1-8B-Instruct \
python inference.py
# Run with OpenAI (API_BASE_URL defaults to OpenAI when unset)
HF_TOKEN=sk-... MODEL_NAME=gpt-4o-mini python inference.py
# Override the environment server URL
HF_TOKEN=sk-... MODEL_NAME=gpt-4o-mini STORE_ENV_URL=http://localhost:8000 python inference.py
```
### Structured Log Format
```
[START] {"task": "easy", "seed": 42, "model": "gpt-4o-mini", "max_steps": 20, "profit_target": 120.0}
[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}
...
[END] {"task": "easy", "cumulative_profit": 145.3, "score": 1.0, "profit_target": 120.0}
```
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.
---
## Action & Observation Reference
### Action: `StoremanagerAction`
| Field | Type | Default | Description |
|---|---|---|---|
| `action_type` | `"discount"` \| `"restock"` \| `"place"` | `"discount"` | Type of action |
| `product_id` | int | required | ID of the product to act on |
| `discount_pct` | `0` \| `10` \| `20` \| `50` | `0` | Discount tier (for `discount` actions) |
| `restock_quantity` | int | `None` | Units to order (for `restock` actions) |
| `target_zone` | `1` \| `2` \| `3` | `None` | Destination zone (for `place` actions) |
### Observation: `StoremanagerObservation`
| Field | Type | Description |
|---|---|---|
| `inventory` | `List[ProductState]` | Full state of all products (including inactive) |
| `current_step` | int | Current step number (0 at reset, increments after each valid step) |
| `max_steps` | int | Total steps in this episode |
| `steps_remaining` | int | Steps left in the episode |
| `cumulative_profit` | float | Total profit accumulated so far |
| `last_step_profit` | float | Net profit from the most recent step |
| `last_expired_products` | `List[str]` | Names of products that expired this step |
| `zone_capacity` | `Dict[int, int]` | Max products per zone: `{1: 2, 2: 4, 3: 99}` |
| `zone_occupancy` | `Dict[int, int]` | Active products currently in each zone |
| `zone_multipliers` | `Dict[int, float]` | Pick-prob multiplier per zone: `{1: 1.5, 2: 1.0, 3: 0.6}` |
| `holding_cost_this_step` | float | Total holding cost charged this step |
| `placement_cost_this_step` | float | `3.0` if a `place` action was taken, else `0.0` |
| `restock_cost_this_step` | float | Ordering cost if a `restock` was placed, else `0.0` |
| `task_name` | str | Active task: `easy` / `medium` / `hard` / `expert` / `custom` |
| `error` | str \| None | Error message if the action was invalid (step does **not** advance) |
| `metadata` | dict | Per-step breakdown: `step_revenue`, `step_cost`, `expiry_penalty`, `holding_cost`, `placement_cost`, `restock_cost`, `step_profit` |
### ProductState Fields (inside `inventory`)
| Field | Description |
|---|---|
| `product_id` | Unique integer ID |
| `name` | Product name (e.g. `"Milk"`) |
| `cost_price` | Per-unit cost to the store |
| `selling_price` | Current selling price (decreases each time a discount is applied) |
| `base_selling_price` | Original price β never modified, reference for total discount depth |
| `quantity` | Units currently in stock |
| `expiry_step` | Episode step at which this product expires |
| `zone` | Current shelf zone: `1` (premium), `2` (standard), `3` (back) |
| `effective_pick_prob` | Normalized pick probability this step (sums to 1.0 across all active products) |
| `current_discount_pct` | Discount tier applied **this step only** (0 if no discount action taken) |
| `holding_cost_rate` | Holding cost per unit per step as a fraction of `cost_price` |
| `restock_cost_per_unit` | Cost to order one additional unit |
| `restock_lead_time` | Steps until a placed order is delivered |
| `pending_restock_quantity` | Units currently on order (not yet delivered) |
| `pending_restock_arrives_at_step` | Step number when the pending order will arrive |
| `is_active` | `False` if the product is sold out or expired |
---
## Error Handling
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:
| Scenario | Error |
|---|---|
| Discount on inactive / sold-out product | `"Product '...' is no longer active"` |
| Restock with `quantity β€ 0` | `"restock_quantity must be a positive integer"` |
| Restock when pending order exists | `"already has a pending restock order"` |
| Place to same zone product is already in | `"is already in zone N"` |
| Place to zone at capacity | `"Zone N is at capacity"` |
| Invalid `product_id` | `"Invalid product_id"` |
Always check `obs.get("error")` before processing the observation.
---
## Configuration
Control the server via environment variables:
| Variable | Default | Description |
|---|---|---|
| `STORE_TASK` | _(none)_ | Pre-load a task config at startup: `easy`, `medium`, `hard`, or `expert` |
| `STORE_NUM_PRODUCTS` | 8 | Number of products (overridden by `STORE_TASK`) |
| `STORE_MAX_STEPS` | 30 | Episode length in steps (overridden by `STORE_TASK`) |
| `STORE_NUM_CUSTOMERS` | 10 | Customers simulated per step (overridden by `STORE_TASK`) |
| `STORE_MAX_CONCURRENT_ENVS` | 4 | Max parallel WebSocket sessions |
**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.
---
## Deploying to Hugging Face Spaces
```bash
openenv push
openenv push --repo-id my-org/store-manager --private
```
After deployment, your space includes:
- **Web Interface** at `/web`
- **API Documentation** at `/docs`
- **WebSocket** at `/ws` β persistent session for low-latency agent interactions
---
## Project Structure
```
StoreManager/
βββ __init__.py # Module exports
βββ models.py # Pydantic schemas: Action, Observation, State
βββ client.py # StoremanagerEnv WebSocket client
βββ tasks.py # Task configs (easy/medium/hard/expert) + grader
βββ inference.py # LLM agent runner with structured logs
βββ test_ws.py # WebSocket integration test suite (7 test groups)
βββ openenv.yaml # OpenEnv manifest
βββ pyproject.toml # Dependencies
βββ server/
βββ StoreManager_environment.py # Core RL environment logic
βββ app.py # FastAPI server (HTTP + WebSocket)
βββ Dockerfile # Container image
```
|