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

```