RaoAditya commited on
Commit
aab74bd
Β·
verified Β·
1 Parent(s): c5ac258

Upload folder using huggingface_hub

Browse files
Files changed (15) hide show
  1. CLAUDE.md +247 -0
  2. Dockerfile +81 -0
  3. README.md +443 -10
  4. __init__.py +23 -0
  5. client.py +109 -0
  6. inference.py +383 -0
  7. models.py +201 -0
  8. openenv.yaml +7 -0
  9. pyproject.toml +47 -0
  10. server/StoreManager_environment.py +681 -0
  11. server/__init__.py +11 -0
  12. server/app.py +104 -0
  13. server/requirements.txt +9 -0
  14. tasks.py +139 -0
  15. 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: StoreManager
3
- emoji: πŸ“ˆ
4
- colorFrom: yellow
5
- colorTo: pink
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: 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