Code-Father commited on
Commit
b7a5bab
·
1 Parent(s): 21a8f00
.DS_Store ADDED
Binary file (6.15 kB). View file
 
Dockerfile ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=store
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
+ ENV ENABLE_WEB_INTERFACE=true
75
+
76
+ # Health check
77
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
78
+ CMD curl -f http://localhost:8000/health || exit 1
79
+
80
+ # Run the FastAPI server
81
+ # The module path is constructed to work with the /app/env structure
82
+ CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
README.md ADDED
@@ -0,0 +1,741 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Store Environment Server
3
+ emoji: 🛒
4
+ colorFrom: green
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 Environment
15
+
16
+ This repository now contains a CSV-backed neighborhood grocery simulation for
17
+ OpenEnv. The environment is intentionally rooted in a kirana-store business
18
+ loop: manage cash, stock, supplier orders, customer satisfaction, pricing, and
19
+ durable operations upgrades over repeated decision windows.
20
+
21
+ This README documents the current implementation, not the older echo-demo
22
+ scaffold and not the earlier narrative plans. The runtime source of truth is:
23
+
24
+ - `store/data/*.csv`
25
+ - `store/models.py`
26
+ - `store/server/store_environment.py`
27
+ - `store/client.py`
28
+ - `store/tests/*.py`
29
+
30
+ If a note in `plan.md`, `plan2.md`, or `rules.md` conflicts with the code or the
31
+ seed CSVs, this document follows the code and the current CSVs.
32
+
33
+ ## At A Glance
34
+
35
+ - Episode horizon: `30` simulated days by default
36
+ - Decision window: `3` committed days per step
37
+ - Reward lookahead: `6` simulated days per step
38
+ - Action shape: one bundled action containing `orders`, `prices`, and
39
+ `action_ids`
40
+ - Observation shape: full store snapshot plus inventory, pending orders, market
41
+ catalog, seller profiles, marketing catalog, and an action guide
42
+ - State storage: in-memory per environment instance, seeded from CSV files on
43
+ `reset()`
44
+ - Reward form: `0.5 + normalized_difference`
45
+
46
+ ## Seed Data
47
+
48
+ The environment seeds itself from the CSV files in `store/data/`.
49
+
50
+ ### Current seed snapshot
51
+
52
+ - `store_state.csv`: 1 row
53
+ - `stock.csv`: 30 rows
54
+ - `market_offers.csv`: 100 rows
55
+ - `seller_profiles.csv`: 4 rows
56
+ - `pending_orders.csv`: 0 rows
57
+ - `marketing_actions.csv`: 15 rows
58
+
59
+ The opening store snapshot is currently:
60
+
61
+ | Field | Value |
62
+ |---|---:|
63
+ | `day` | `1` |
64
+ | `current_daily_expense_inr` | `3000` |
65
+ | `bank_balance_inr` | `120000` |
66
+ | `customer_satisfaction` | `70` |
67
+ | `order_processing_capacity` | `500` |
68
+ | `cumulative_profit_inr` | `0` |
69
+
70
+ The current opening stock value is `237,192 INR`, which sits inside the intended
71
+ `220k-260k INR` band.
72
+
73
+ ## Files And Roles
74
+
75
+ | File | Role |
76
+ |---|---|
77
+ | `store/models.py` | Action, observation, and data models |
78
+ | `store/server/store_environment.py` | Core simulation engine |
79
+ | `store/client.py` | Client wrapper for remote HTTP/WebSocket usage |
80
+ | `store/server/app.py` | FastAPI app factory entrypoint |
81
+ | `store/data/*.csv` | Seed data and catalog source of truth |
82
+ | `store/tests/test_seed_csvs.py` | Seed integrity checks |
83
+ | `store/tests/test_store_environment.py` | Behavioral simulation tests |
84
+
85
+ ## Action Contract
86
+
87
+ The agent sends one bundled action per decision window.
88
+
89
+ ```python
90
+ from store import OrderRequest, PriceUpdate, StoreAction
91
+
92
+ action = StoreAction(
93
+ orders=[
94
+ OrderRequest(product="Rice", seller="Seller A", quantity=25),
95
+ OrderRequest(product="Bread", seller="Seller C", quantity=12),
96
+ ],
97
+ prices=[PriceUpdate(product="Milk", price=33)],
98
+ action_ids=[7],
99
+ )
100
+ ```
101
+
102
+ Passing is:
103
+
104
+ ```python
105
+ StoreAction()
106
+ ```
107
+
108
+ ### Action fields
109
+
110
+ | Field | Type | Meaning |
111
+ |---|---|---|
112
+ | `orders` | `list[OrderRequest]` | Supplier orders to place at the start of the step |
113
+ | `prices` | `list[PriceUpdate] \| None` | Optional selling-price updates for stocked products |
114
+ | `action_ids` | `list[int]` | Durable marketing or operations actions to activate |
115
+
116
+ ### Nested action models
117
+
118
+ | Model | Fields |
119
+ |---|---|
120
+ | `OrderRequest` | `product`, `seller`, `quantity` |
121
+ | `PriceUpdate` | `product`, `price` |
122
+
123
+ ### Validation rules for actions
124
+
125
+ - `quantity` must be a positive integer.
126
+ - `seller` must be one of `Seller A`, `Seller B`, `Seller C`, or `Seller D`.
127
+ - `orders[*].product` must exist in `market_offers.csv`.
128
+ - `prices[*].product` must already exist in current stock.
129
+ - `prices[*].price` must be positive.
130
+ - `action_ids` must exist in `marketing_actions.csv`.
131
+ - Duplicate price updates for the same product are rejected.
132
+ - Duplicate marketing action IDs in one step are rejected.
133
+ - Already-active marketing actions cannot be activated again.
134
+
135
+ ## Observation Contract
136
+
137
+ `StoreObservation` is the agent-facing state. The OpenEnv transport `State`
138
+ still only carries `episode_id` and `step_count`, but the business state lives in
139
+ the observation payload.
140
+
141
+ ### Top-level observation fields
142
+
143
+ | Field | Meaning |
144
+ |---|---|
145
+ | `store` | Current top-level business snapshot |
146
+ | `stock` | Current in-store inventory rows |
147
+ | `pending_orders` | In-flight supplier orders |
148
+ | `market` | Full supplier catalog with prices and ETAs |
149
+ | `seller_profiles` | Seller behavior notes |
150
+ | `marketing_actions` | Full action catalog plus active flags |
151
+ | `available_action` | Agent guide showing the action shape |
152
+ | `summary` | Human-readable step summary |
153
+ | `reward` | Reward returned for the step |
154
+ | `done` | Whether the episode has terminated |
155
+ | `metadata` | Reward breakdown and simulation diagnostics |
156
+
157
+ ### Store snapshot fields
158
+
159
+ `store` is a `StoreSnapshot` with:
160
+
161
+ - `day`
162
+ - `current_daily_expense_inr`
163
+ - `bank_balance_inr`
164
+ - `customer_satisfaction`
165
+ - `order_processing_capacity`
166
+ - `cumulative_profit_inr`
167
+ - `active_action_ids`
168
+ - `active_recurring_cost_inr`
169
+ - `episode_horizon_days`
170
+ - `commit_window_days`
171
+ - `reward_horizon_days`
172
+
173
+ ### Available action guide
174
+
175
+ Each observation includes an `available_action` block with:
176
+
177
+ - `name`: always `action`
178
+ - `description`: short instruction about the bundled step contract
179
+ - `pass_action`: empty `StoreAction()`
180
+ - `example_action`: a concrete non-empty example
181
+ - `notes`: quick reminders about pass behavior, stock-only pricing, and durable
182
+ marketing actions
183
+
184
+ ## Step Lifecycle
185
+
186
+ One call to `step()` performs a full decision-window update.
187
+
188
+ ### High-level order of operations
189
+
190
+ 1. Validate the bundle.
191
+ 2. Read the current live state.
192
+ 3. Compute starting capital.
193
+ 4. Clone the live runtime state into two branches:
194
+ - commit branch for the next `3` days of live state
195
+ - reward branch for the `6`-day reward forecast
196
+ 5. Apply the same action bundle to both branches once at the start of the
197
+ window.
198
+ 6. Update customer satisfaction once per decision window.
199
+ 7. Simulate daily operations on both branches.
200
+ 8. Commit only the `3`-day branch back to the live environment state.
201
+ 9. Compute reward from the `6`-day branch.
202
+ 10. Increment `step_count` and set `done` if the committed state crosses a
203
+ terminal condition.
204
+
205
+ ### What the action does at step start
206
+
207
+ - Supplier orders are placed immediately and enter the pending queue.
208
+ - The store pays the supplier cost immediately when the order is placed.
209
+ - Price updates apply immediately to stocked items.
210
+ - Marketing actions activate immediately and remain active for the rest of the
211
+ episode.
212
+ - Customer satisfaction is recomputed once for the decision window after the
213
+ bundle is applied.
214
+
215
+ ### What happens during simulated days
216
+
217
+ For each simulated day:
218
+
219
+ - Active recurring marketing cost is charged.
220
+ - Demand is converted into sales.
221
+ - Sales revenue is added to bank balance.
222
+ - Daily operating expense is charged.
223
+ - COGS is tracked in cumulative profit accounting.
224
+ - Pending orders are decremented by one day.
225
+ - Arriving orders are received into stock.
226
+ - The day counter advances by one.
227
+
228
+ ## Simulation Formulas
229
+
230
+ The formulas below reflect the current implementation in
231
+ `store/server/store_environment.py`.
232
+
233
+ ### Starting capital
234
+
235
+ ```text
236
+ starting_capital = starting_bank_balance + total_inventory_value
237
+ ```
238
+
239
+ `total_inventory_value` is the sum of `stock_value_inr` across current stock
240
+ rows.
241
+
242
+ ### Effective daily orders
243
+
244
+ ```text
245
+ effective_daily_orders = order_processing_capacity * (customer_satisfaction / 100)
246
+ ```
247
+
248
+ This means the current satisfaction score is treated as a percentage scale.
249
+
250
+ ### Product margin
251
+
252
+ ```text
253
+ margin_percent = ((selling_price_inr - buying_price_inr) / selling_price_inr) * 100
254
+ ```
255
+
256
+ The implementation uses the current selling price, not the buying price, as the
257
+ denominator.
258
+
259
+ ### Product selling score
260
+
261
+ ```text
262
+ product_selling_score = max(0, demand_score - margin_percent * 3)
263
+ ```
264
+
265
+ This is computed separately for each stocked item with positive on-hand
266
+ quantity.
267
+
268
+ ### Total selling score
269
+
270
+ ```text
271
+ total_product_selling_score = sum(product_selling_score for each stocked item)
272
+ ```
273
+
274
+ ### Daily sales allocation
275
+
276
+ ```text
277
+ desired_sales_for_item = effective_daily_orders * product_selling_score / total_product_selling_score
278
+ actual_sales = min(quantity_on_hand, desired_sales_for_item)
279
+ ```
280
+
281
+ Sales are proportional to selling score across stocked items. Because the model
282
+ is proportional and not integer-rounded, `quantity_on_hand` can become
283
+ fractional after simulated sales.
284
+
285
+ ### Customer satisfaction update
286
+
287
+ Before the window is simulated, the environment applies any marketing action
288
+ delays to customer satisfaction. Then it recomputes a score-based multiplier.
289
+
290
+ ```text
291
+ adjusted_satisfaction = current_satisfaction + sum(customer_satisfaction_delta for activated actions)
292
+
293
+ if after_score > 0:
294
+ next_satisfaction = (1 + ((after_score - before_score) / after_score)) * adjusted_satisfaction
295
+ else:
296
+ next_satisfaction = adjusted_satisfaction
297
+
298
+ next_satisfaction is then clamped to the range [1, 100]
299
+ ```
300
+
301
+ `before_score` is the total selling score before the bundle is applied.
302
+ `after_score` is the total selling score after price updates and before the
303
+ simulated days begin.
304
+
305
+ ### Order arrival
306
+
307
+ When a pending order reaches `days_remaining <= 0`:
308
+
309
+ - If the product already exists in stock, the order is merged into that row and
310
+ the buying price becomes the weighted average of the existing and incoming
311
+ units.
312
+ - If the product does not exist in stock, a new stock row is created.
313
+ - Newly created stock uses:
314
+
315
+ ```text
316
+ selling_price = max(buying_price + 1, ceil(buying_price * 1.18))
317
+ ```
318
+
319
+ The `1.18` markup is the implementation default for a newly introduced SKU.
320
+
321
+ ### Reward
322
+
323
+ Reward is computed from the `6`-day reward branch:
324
+
325
+ ```text
326
+ difference =
327
+ (ending_inventory_value + ending_bank_balance)
328
+ - (starting_inventory_value + starting_bank_balance)
329
+ - recurring_costs_paid_over_6_days
330
+
331
+ normalized_difference = difference / starting_capital
332
+ reward = 0.5 + normalized_difference
333
+ ```
334
+
335
+ If `starting_capital <= 0`, the implementation falls back to
336
+ `normalized_difference = 0`.
337
+
338
+ ### Reward metadata
339
+
340
+ The step response includes reward diagnostics in `metadata`:
341
+
342
+ - `reward_breakdown.starting_capital_inr`
343
+ - `reward_breakdown.ending_capital_inr`
344
+ - `reward_breakdown.difference_inr`
345
+ - `reward_breakdown.normalized_difference`
346
+ - `reward_breakdown.commit_window_days`
347
+ - `reward_breakdown.reward_horizon_days`
348
+ - `reward_breakdown.recurring_costs_paid_inr`
349
+ - `commit_metrics`
350
+ - `reward_metrics`
351
+ - `done_reason`
352
+
353
+ The code rounds the high-level reward breakdown to two decimals, except for
354
+ `normalized_difference`, which is kept at six decimal places in metadata.
355
+
356
+ ## Seller Behavior And ETA Sampling
357
+
358
+ Supplier ETA is deterministic within an episode because the environment seeds
359
+ its RNG from the reset count. Different resets get different seeds, but the same
360
+ episode run is repeatable once started.
361
+
362
+ ### Seller profiles
363
+
364
+ | Seller | Behavior | ETA sampling |
365
+ |---|---|---|
366
+ | Seller A | Overcommits | `quoted + {0, 1, 2}` with delay-heavy weights |
367
+ | Seller B | Strict | Uses quoted ETA exactly |
368
+ | Seller C | Undercommits | `quoted + {-2, -1, 0}` with early-heavy weights |
369
+ | Seller D | Variable | `quoted + {-1, 0, 1, 2}` with mixed weights |
370
+
371
+ ### Actual ETA rules
372
+
373
+ - The quoted ETA comes from the corresponding seller column in
374
+ `market_offers.csv`.
375
+ - Actual ETA is sampled once when the order is placed.
376
+ - The sampled ETA is floored at `1`.
377
+ - Each simulated day decrements `days_remaining` by `1`.
378
+ - When `days_remaining <= 0`, the order is received.
379
+
380
+ ### Exact weights in the current code
381
+
382
+ - Seller A: offsets `[0, 1, 2]` with weights `[2, 4, 5]`
383
+ - Seller B: offset `[0]`
384
+ - Seller C: offsets `[-2, -1, 0]` with weights `[5, 4, 2]`
385
+ - Seller D: offsets `[-1, 0, 1, 2]` with weights `[2, 4, 3, 2]`
386
+
387
+ These weights are implementation details, not a business guarantee.
388
+
389
+ ## Data Contract
390
+
391
+ ### `store_state.csv`
392
+
393
+ Current columns:
394
+
395
+ - `day`
396
+ - `current_daily_expense_inr`
397
+ - `bank_balance_inr`
398
+ - `customer_satisfaction`
399
+ - `order_processing_capacity`
400
+ - `cumulative_profit_inr`
401
+
402
+ ### `stock.csv`
403
+
404
+ Current columns:
405
+
406
+ - `product_name`
407
+ - `unit`
408
+ - `buying_price_inr`
409
+ - `selling_price_inr`
410
+ - `demand_score`
411
+ - `quantity_on_hand`
412
+ - `stock_value_inr`
413
+
414
+ The seed data includes 30 stocked products and the stored value must equal
415
+ `buying_price_inr * quantity_on_hand` on each row.
416
+
417
+ ### `market_offers.csv`
418
+
419
+ Current columns:
420
+
421
+ - `product_name`
422
+ - `category`
423
+ - `unit`
424
+ - `demand_score`
425
+ - `seller_a_price_inr`
426
+ - `seller_a_etd_days`
427
+ - `seller_b_price_inr`
428
+ - `seller_b_etd_days`
429
+ - `seller_c_price_inr`
430
+ - `seller_c_etd_days`
431
+ - `seller_d_price_inr`
432
+ - `seller_d_etd_days`
433
+
434
+ The current catalog contains 100 products.
435
+
436
+ ### `seller_profiles.csv`
437
+
438
+ Current columns:
439
+
440
+ - `seller_name`
441
+ - `timeline_behavior`
442
+ - `reliability_note`
443
+ - `default_etd_days`
444
+
445
+ ### `pending_orders.csv`
446
+
447
+ Current columns:
448
+
449
+ - `order_id`
450
+ - `product_name`
451
+ - `quantity`
452
+ - `seller_name`
453
+ - `buy_price_inr`
454
+ - `days_remaining`
455
+ - `status`
456
+ - `created_day`
457
+
458
+ The seed file starts empty.
459
+
460
+ ### `marketing_actions.csv`
461
+
462
+ Current columns:
463
+
464
+ - `action_id`
465
+ - `description`
466
+ - `customer_satisfaction_delta`
467
+ - `cost_inr`
468
+ - `recurring_cost_inr`
469
+ - `order_processing_capacity_delta`
470
+
471
+ The current catalog contains 15 durable action rows.
472
+
473
+ ## Episode End Conditions
474
+
475
+ The environment uses a default episode horizon of `30` days, but each step
476
+ advances the live state by `3` days, so a full default episode lasts 10
477
+ decision windows.
478
+
479
+ The episode ends when either:
480
+
481
+ - committed bank balance drops below `0`
482
+ - committed day exceeds `30`
483
+
484
+ The current implementation checks `day > 30` after the commit branch is
485
+ applied, so the tenth decision window takes the day counter to `31` and ends
486
+ the episode.
487
+
488
+ ## Practical Edge Cases
489
+
490
+ ### Fractional inventory
491
+
492
+ Sales are proportional, so on-hand inventory can become fractional. The models
493
+ use floats for inventory fields for that reason.
494
+
495
+ ### Empty sales mix
496
+
497
+ If all stocked items have zero selling score or zero quantity, the sales loop
498
+ returns zero revenue and zero COGS for that day.
499
+
500
+ ### Zero or negative starting capital
501
+
502
+ The reward math guards against division by zero by falling back to a zero
503
+ normalized difference.
504
+
505
+ ### Already-active marketing actions
506
+
507
+ Once a marketing action is active, a later step that tries to re-activate it
508
+ raises a validation error.
509
+
510
+ ### Product pricing
511
+
512
+ Price updates only apply to products already in stock. If a product is not in
513
+ stock yet, you must wait for a supplier order to arrive before changing its
514
+ price.
515
+
516
+ ### New stock rows
517
+
518
+ If a pending order arrives for a product not currently in stock, the environment
519
+ creates a new stock row on arrival. This is rare in the seeded scenario, but the
520
+ code supports it.
521
+
522
+ ## How To Run
523
+
524
+ The project is designed to run through `uv` so the declared dependencies are
525
+ available. In this workspace, the global shell does not include `openenv-core`
526
+ or `pytest`, so prefer the project environment.
527
+
528
+ ### Install dependencies
529
+
530
+ ```bash
531
+ cd store
532
+ uv sync
533
+ ```
534
+
535
+ ### Start the server
536
+
537
+ ```bash
538
+ cd store
539
+ uv run --project . server --port 8000
540
+ ```
541
+
542
+ ### Start the Gradio playground
543
+
544
+ ```bash
545
+ cd store
546
+ uv run --project . gradio
547
+ ```
548
+
549
+ By default the Gradio UI launches on `http://127.0.0.1:7860` and runs the
550
+ environment in-process, so you do not need the FastAPI server running to play
551
+ with the simulation manually.
552
+
553
+ ### Validate the OpenEnv package
554
+
555
+ ```bash
556
+ cd store
557
+ uv run --project . openenv validate .
558
+ ```
559
+
560
+ ### Run the tests
561
+
562
+ ```bash
563
+ cd store
564
+ uv run --project . python -m pytest
565
+ ```
566
+
567
+ ## How To Play Manually
568
+
569
+ You can interact with the simulation directly from Python without starting a
570
+ network server:
571
+
572
+ ```python
573
+ from store import OrderRequest, PriceUpdate, StoreAction
574
+ from store.server.store_environment import StoreEnvironment
575
+
576
+ env = StoreEnvironment()
577
+ obs = env.reset()
578
+
579
+ print(obs.store)
580
+ print(obs.summary)
581
+
582
+ step = env.step(
583
+ StoreAction(
584
+ orders=[OrderRequest(product="Rice", seller="Seller A", quantity=25)],
585
+ prices=[PriceUpdate(product="Milk", price=33)],
586
+ action_ids=[7],
587
+ )
588
+ )
589
+
590
+ print(step.reward)
591
+ print(step.done)
592
+ print(step.metadata["reward_breakdown"])
593
+ print(step.store.day)
594
+ ```
595
+
596
+ ### Passing a step
597
+
598
+ ```python
599
+ env.step(StoreAction())
600
+ ```
601
+
602
+ ### Using the Gradio UI
603
+
604
+ The Gradio frontend lives in `store/gradio_app.py` and is designed as a local
605
+ playground for the same bundled action contract:
606
+
607
+ - `Orders JSON`: JSON array of `{product, seller, quantity}` objects
608
+ - `Price Updates JSON`: JSON array of `{product, price}` objects
609
+ - `Action IDs`: either a JSON array like `[7, 12]`, a single integer, or a
610
+ comma-separated string like `7, 12`
611
+
612
+ UI controls:
613
+
614
+ - `Reset Episode`: creates a fresh `StoreEnvironment` instance and resets it
615
+ - `Submit Step`: validates the current inputs and executes one step
616
+ - `Pass Step`: executes a real pass action immediately
617
+ - `Load Example`: fills the inputs using the observation's bundled example action
618
+
619
+ The UI displays:
620
+
621
+ - current store snapshot
622
+ - stock table
623
+ - pending orders
624
+ - market catalog
625
+ - marketing action catalog
626
+ - reward breakdown
627
+ - raw observation metadata
628
+
629
+ Invalid JSON or action validation errors are shown in the UI while preserving
630
+ the current episode state so you can correct the inputs and try again.
631
+
632
+ ### Inspecting the current state
633
+
634
+ The most useful fields to inspect after each step are:
635
+
636
+ - `obs.store`
637
+ - `obs.stock`
638
+ - `obs.pending_orders`
639
+ - `obs.marketing_actions`
640
+ - `obs.metadata["reward_breakdown"]`
641
+ - `obs.metadata["done_reason"]`
642
+
643
+ ## OpenEnv Client Usage
644
+
645
+ If you want to connect to a running server, use `StoreEnv`:
646
+
647
+ ```python
648
+ from store import OrderRequest, PriceUpdate, StoreAction, StoreEnv
649
+
650
+ with StoreEnv(base_url="http://localhost:8000") as client:
651
+ reset_result = client.reset()
652
+ print(reset_result.observation.store)
653
+
654
+ step_result = client.step(
655
+ StoreAction(
656
+ orders=[OrderRequest(product="Rice", seller="Seller A", quantity=10)],
657
+ prices=[PriceUpdate(product="Milk", price=33)],
658
+ action_ids=[7],
659
+ )
660
+ )
661
+
662
+ print(step_result.reward)
663
+ print(step_result.observation.summary)
664
+ ```
665
+
666
+ The client serializes the action bundle to JSON and reconstructs typed
667
+ observations from the server response.
668
+
669
+ ## Tests
670
+
671
+ The current test suite includes:
672
+
673
+ - `store/tests/test_seed_csvs.py`
674
+ - `store/tests/test_store_environment.py`
675
+
676
+ These tests cover:
677
+
678
+ - CSV shape and seed integrity
679
+ - opening store values
680
+ - market catalog completeness
681
+ - marketing action rows
682
+ - reset behavior
683
+ - 3-day commit semantics
684
+ - 6-day reward forecasting
685
+ - ETA sampling
686
+ - customer satisfaction changes
687
+ - durable marketing actions
688
+ - episode termination
689
+ - client serialization and parsing
690
+
691
+ ### Useful test command
692
+
693
+ ```bash
694
+ cd store
695
+ uv run --project . python -m pytest store/tests/test_seed_csvs.py store/tests/test_store_environment.py
696
+ ```
697
+
698
+ ## Known Limitations
699
+
700
+ - The simulation is deterministic aside from seller ETA sampling, which is
701
+ seeded per reset.
702
+ - Demand is not a full market model; it is based on stock-level selling score
703
+ proportions.
704
+ - There is no spoilage, shrinkage, theft, or seasonality model.
705
+ - There is no supplier cancellation workflow.
706
+ - Sales can become fractional because the simulation allocates demand
707
+ proportionally.
708
+ - The environment exposes the full market and marketing catalogs every step, so
709
+ observations are intentionally verbose.
710
+ - Reward is unbounded and can be greater than `1.0` or less than `0.0`.
711
+ - `plan.md` and `plan2.md` are useful design notes, but the code and CSVs are
712
+ the authoritative implementation.
713
+
714
+ ## Assumptions
715
+
716
+ - The agent-facing state is the observation payload, not the OpenEnv transport
717
+ `State`.
718
+ - CSV seed files are runtime truth and are re-read on every reset.
719
+ - The bundled action is the only supported step input.
720
+ - Marketing actions are durable and stay active once activated.
721
+ - A 30-day horizon with 3-day windows is the intended default episode shape.
722
+ - Current seed values in `store_state.csv` are intentional and should not be
723
+ replaced with the older numbers from the narrative docs unless the code and
724
+ tests are updated together.
725
+
726
+ ## Packaging Notes
727
+
728
+ The OpenEnv manifest is `store/openenv.yaml`, and the server entry point is
729
+ `store.server.app:app` with the script entry point `server = "store.server.app:main"`.
730
+
731
+ The current package also declares:
732
+
733
+ - `openenv-core[core]>=0.2.2`
734
+ - `pydantic>=2.0.0`
735
+
736
+ ## Further Reading
737
+
738
+ - `store/models.py`
739
+ - `store/server/store_environment.py`
740
+ - `store/tests/test_store_environment.py`
741
+ - `store/tests/test_seed_csvs.py`
__init__.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Store OpenEnv package exports."""
2
+
3
+ from .client import StoreEnv
4
+ from .models import (
5
+ AvailableActionSpec,
6
+ MarketingAction,
7
+ MarketOffer,
8
+ OrderRequest,
9
+ PendingOrder,
10
+ PriceUpdate,
11
+ SellerProfile,
12
+ StockItem,
13
+ StoreAction,
14
+ StoreObservation,
15
+ StoreSnapshot,
16
+ )
17
+
18
+ __all__ = [
19
+ "AvailableActionSpec",
20
+ "MarketingAction",
21
+ "MarketOffer",
22
+ "OrderRequest",
23
+ "PendingOrder",
24
+ "PriceUpdate",
25
+ "SellerProfile",
26
+ "StockItem",
27
+ "StoreAction",
28
+ "StoreEnv",
29
+ "StoreObservation",
30
+ "StoreSnapshot",
31
+ ]
_compat.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compatibility helpers for local development without openenv-core."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Generic, TypeVar
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ ActionT = TypeVar("ActionT")
11
+ ObservationT = TypeVar("ObservationT")
12
+ StateT = TypeVar("StateT")
13
+
14
+ try: # pragma: no cover - exercised when openenv-core is installed
15
+ from openenv.core import EnvClient
16
+ from openenv.core.client_types import StepResult
17
+ from openenv.core.env_server.interfaces import Environment
18
+ from openenv.core.env_server.types import Action, Observation, State
19
+
20
+ OPENENV_AVAILABLE = True
21
+ except ImportError: # pragma: no cover - local fallback for unit tests
22
+ OPENENV_AVAILABLE = False
23
+
24
+ class Action(BaseModel):
25
+ """Fallback action model."""
26
+
27
+ class Observation(BaseModel):
28
+ """Fallback observation model."""
29
+
30
+ reward: float | None = None
31
+ done: bool = False
32
+ metadata: dict[str, Any] = Field(default_factory=dict)
33
+
34
+ class State(BaseModel):
35
+ """Fallback transport state."""
36
+
37
+ episode_id: str | None = None
38
+ step_count: int = 0
39
+
40
+ class Environment(Generic[ObservationT, ActionT]):
41
+ """Fallback environment interface."""
42
+
43
+ SUPPORTS_CONCURRENT_SESSIONS: bool = False
44
+
45
+ @dataclass
46
+ class StepResult(Generic[ObservationT]):
47
+ """Fallback step result container."""
48
+
49
+ observation: ObservationT
50
+ reward: float | None = None
51
+ done: bool = False
52
+
53
+ class EnvClient(Generic[ActionT, ObservationT, StateT]):
54
+ """Fallback client base class used only for local parsing tests."""
55
+
56
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
57
+ self._base_url = kwargs.get("base_url")
58
+
59
+ def close(self) -> None:
60
+ """Mirror the real client close method."""
61
+
62
+ return None
client.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Client for the Store OpenEnv simulation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ try:
8
+ from ._compat import EnvClient, State, StepResult
9
+ from .models import StoreAction, StoreObservation, dump_model
10
+ except ImportError: # pragma: no cover - supports direct module imports
11
+ from _compat import EnvClient, State, StepResult
12
+ from models import StoreAction, StoreObservation, dump_model
13
+
14
+
15
+ class StoreEnv(
16
+ EnvClient[StoreAction, StoreObservation, State]
17
+ ):
18
+ """Client wrapper for the store environment."""
19
+
20
+ def _step_payload(self, action: StoreAction) -> dict[str, Any]:
21
+ """Convert a store action into a JSON payload."""
22
+
23
+ return dump_model(action, exclude_none=True, exclude_defaults=True)
24
+
25
+ def _parse_result(self, payload: dict[str, Any]) -> StepResult[StoreObservation]:
26
+ """Parse a server response into a typed observation."""
27
+
28
+ obs_data = payload.get("observation", {})
29
+ clean_obs_data = {
30
+ key: value
31
+ for key, value in obs_data.items()
32
+ if key not in {"done", "reward", "metadata"}
33
+ }
34
+ observation = StoreObservation(
35
+ **clean_obs_data,
36
+ done=payload.get("done", obs_data.get("done", False)),
37
+ reward=payload.get("reward", obs_data.get("reward")),
38
+ metadata=obs_data.get("metadata", payload.get("metadata", {})),
39
+ )
40
+
41
+ return StepResult(
42
+ observation=observation,
43
+ reward=payload.get("reward"),
44
+ done=payload.get("done", False),
45
+ )
46
+
47
+ def _parse_state(self, payload: dict[str, Any]) -> State:
48
+ """Parse the OpenEnv transport state payload."""
49
+
50
+ return State(
51
+ episode_id=payload.get("episode_id"),
52
+ step_count=payload.get("step_count", 0),
53
+ )
data/market_offers.csv ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ product_name,category,unit,demand_score,seller_a_price_inr,seller_a_etd_days,seller_b_price_inr,seller_b_etd_days,seller_c_price_inr,seller_c_etd_days,seller_d_price_inr,seller_d_etd_days
2
+ Rice,Pantry Staples,pack,92,138,2,160,3,165,5,164,4
3
+ Wheat Flour,Pantry Staples,pack,88,87,2,100,3,94,5,101,4
4
+ Other Flours,Pantry Staples,pack,65,139,2,140,3,141,4,139,3
5
+ Dals,Pantry Staples,pack,80,154,2,175,3,182,5,172,4
6
+ Pulses,Pantry Staples,pack,76,180,2,200,3,202,5,201,4
7
+ Salt,Pantry Staples,pack,70,20,2,20,3,18,4,20,3
8
+ Sugar,Pantry Staples,pack,74,78,2,80,3,76,4,78,3
9
+ Jaggery,Pantry Staples,pack,58,81,2,90,3,91,4,90,3
10
+ Cooking Oil,Pantry Staples,bottle,94,156,2,180,3,186,5,184,4
11
+ Ghee,Pantry Staples,jar,68,521,2,520,3,526,5,522,4
12
+ Spices,Pantry Staples,pack,81,345,2,400,3,405,5,402,4
13
+ Masalas,Pantry Staples,pack,78,162,2,180,3,182,5,181,4
14
+ Dry Fruits,Pantry Staples,pack,52,601,2,600,3,607,4,602,3
15
+ Nuts,Pantry Staples,pack,54,451,2,450,3,455,4,452,3
16
+ Seeds,Pantry Staples,pack,49,147,2,150,3,156,4,147,3
17
+ Poha,Pantry Staples,pack,60,61,2,70,3,66,4,71,3
18
+ Suji / Rava,Pantry Staples,pack,63,54,2,60,3,56,4,60,3
19
+ Besan,Pantry Staples,pack,61,99,2,110,3,103,4,110,3
20
+ Maida,Pantry Staples,pack,57,48,2,55,3,52,4,56,3
21
+ Breakfast Cereals,Breakfast & Convenience,box,59,258,2,260,3,264,4,241,2
22
+ Oats,Breakfast & Convenience,pack,64,175,2,180,3,186,4,170,2
23
+ Pasta,Breakfast & Convenience,pack,62,92,2,95,3,98,4,90,2
24
+ Noodles,Breakfast & Convenience,pack,75,64,2,65,3,68,4,59,2
25
+ Vermicelli,Breakfast & Convenience,pack,53,67,2,70,3,72,4,66,2
26
+ Ready-to-Cook Mixes,Breakfast & Convenience,pack,56,120,2,125,3,118,4,122,2
27
+ Ready-to-Eat Foods,Breakfast & Convenience,pack,58,146,2,150,3,155,4,142,2
28
+ Biscuits,Breakfast & Convenience,pack,90,20,2,20,3,18,4,19,2
29
+ Cookies,Breakfast & Convenience,pack,66,74,2,75,3,79,4,68,2
30
+ Rusk,Breakfast & Convenience,pack,51,58,2,60,3,57,4,57,2
31
+ Snacks,Breakfast & Convenience,pack,72,68,2,70,3,73,4,66,2
32
+ Namkeen,Breakfast & Convenience,pack,85,49,2,50,3,52,4,46,2
33
+ Chips,Breakfast & Convenience,pack,82,35,2,35,3,36,4,32,2
34
+ Sweets,Breakfast & Convenience,box,67,219,2,220,3,223,4,204,2
35
+ Chocolates,Breakfast & Convenience,bar,77,38,2,40,3,41,4,38,2
36
+ Candies,Breakfast & Convenience,pack,69,29,2,30,3,31,4,28,2
37
+ Bakery Products,Breakfast & Convenience,pack,55,130,2,130,3,121,4,121,2
38
+ Bread,Breakfast & Convenience,loaf,86,37,1,35,2,31,4,35,2
39
+ Cakes,Breakfast & Convenience,piece,48,277,2,280,3,295,4,268,2
40
+ Jams & Spreads,Condiments & Spreads,jar,55,165,2,170,3,176,4,169,3
41
+ Peanut Butter,Condiments & Spreads,jar,50,234,2,240,3,250,4,241,3
42
+ Honey,Condiments & Spreads,jar,46,257,2,260,3,274,4,252,3
43
+ Pickles,Condiments & Spreads,jar,64,136,2,140,3,145,4,132,3
44
+ Sauces,Condiments & Spreads,bottle,61,108,2,110,3,115,4,100,3
45
+ Ketchup,Condiments & Spreads,bottle,73,103,2,105,3,110,4,96,3
46
+ Chutneys,Condiments & Spreads,jar,57,89,2,90,3,95,4,83,3
47
+ Vinegar,Condiments & Spreads,bottle,38,83,2,85,3,89,4,85,3
48
+ Tea,Beverages,pack,87,307,2,350,3,365,4,348,2
49
+ Coffee,Beverages,jar,62,505,2,500,3,524,4,475,2
50
+ Health Drinks,Beverages,jar,49,323,2,320,3,335,4,304,2
51
+ Soft Drinks,Beverages,bottle,79,41,2,40,3,41,4,37,2
52
+ Packaged Juices,Beverages,pack,71,57,2,55,3,56,4,51,2
53
+ Energy Drinks,Beverages,can,44,96,2,95,3,99,4,90,2
54
+ Bottled Water,Beverages,bottle,89,18,2,18,3,18,4,17,2
55
+ Dairy Products,Dairy & Protein,pack,52,231,2,220,3,209,4,216,3
56
+ Milk,Dairy & Protein,pouch,96,32,1,30,2,28,4,30,2
57
+ Curd,Dairy & Protein,cup,82,38,1,35,2,33,4,35,2
58
+ Butter,Dairy & Protein,pack,69,416,2,400,3,396,4,404,3
59
+ Paneer,Dairy & Protein,pack,73,312,1,300,2,284,4,305,2
60
+ Cheese,Dairy & Protein,pack,60,437,2,420,3,418,4,427,3
61
+ Eggs,Dairy & Protein,dozen,84,77,1,72,2,64,4,72,2
62
+ Frozen Foods,Frozen & Fresh,pack,45,202,2,190,3,185,4,189,4
63
+ Frozen Snacks,Frozen & Fresh,pack,52,180,2,170,3,172,4,168,4
64
+ Frozen Vegetables,Frozen & Fresh,pack,57,139,2,130,3,121,4,131,4
65
+ Fruits,Frozen & Fresh,kg,79,52,1,50,2,47,3,50,2
66
+ Vegetables,Frozen & Fresh,kg,84,42,1,40,2,38,3,40,2
67
+ Leafy Vegetables,Frozen & Fresh,kg,76,31,1,30,2,28,3,30,2
68
+ Exotic Fruits,Frozen & Fresh,kg,43,187,3,180,4,179,6,183,5
69
+ Exotic Vegetables,Frozen & Fresh,kg,41,170,3,160,4,156,6,159,5
70
+ Cut Fruits & Vegetables,Frozen & Fresh,pack,58,95,2,90,3,86,4,89,4
71
+ Sprouts,Frozen & Fresh,pack,62,48,1,45,2,42,3,45,2
72
+ Detergents,Home Care,pack,78,249,2,250,3,260,5,251,3
73
+ Fabric Care,Home Care,bottle,52,216,2,220,3,227,5,218,3
74
+ Dishwash Products,Home Care,bottle,74,95,2,95,3,90,5,91,3
75
+ Floor Cleaners,Home Care,bottle,57,139,2,140,3,145,5,139,3
76
+ Toilet Cleaners,Home Care,bottle,56,113,2,115,3,118,5,114,3
77
+ Bathroom Cleaners,Home Care,bottle,54,118,2,120,3,124,5,119,3
78
+ Disinfectants,Home Care,bottle,61,178,2,180,3,186,5,179,3
79
+ Utensil Cleaners,Home Care,pack,65,60,2,60,3,63,5,58,3
80
+ Garbage Bags,Home Care,roll,59,75,2,75,3,78,5,71,3
81
+ Tissues & Napkins,Home Care,pack,67,70,2,70,3,73,5,64,3
82
+ Air Fresheners,Home Care,bottle,45,150,2,150,3,157,5,144,3
83
+ Bath Soap,Personal Care,pack,76,100,2,95,3,89,5,89,3
84
+ Handwash,Personal Care,bottle,73,87,2,85,3,81,5,85,3
85
+ Shampoo,Personal Care,bottle,64,158,2,150,3,153,5,148,3
86
+ Hair Oil,Personal Care,bottle,58,136,2,130,3,132,5,127,3
87
+ Toothpaste,Personal Care,tube,83,52,2,50,3,48,5,48,3
88
+ Toothbrushes,Personal Care,pack,68,56,2,55,3,57,5,52,3
89
+ Oral Care Products,Personal Care,pack,54,126,2,120,3,123,5,118,3
90
+ Skincare Products,Personal Care,bottle,46,252,2,240,3,245,5,236,3
91
+ Personal Hygiene Products,Personal Care,pack,63,112,2,110,3,114,5,110,3
92
+ Sanitary Products,Personal Care,pack,71,188,2,180,3,183,5,176,3
93
+ Baby Care Products,Personal Care,pack,47,270,2,260,3,274,5,252,3
94
+ Diapers & Wipes,Personal Care,pack,59,436,2,420,3,443,5,407,3
95
+ Stationery,Home & Stationery,pack,44,88,3,90,4,94,7,88,5
96
+ Notebooks,Home & Stationery,piece,55,59,3,60,4,63,7,55,5
97
+ Pens & Pencils,Home & Stationery,pack,53,48,3,50,4,52,7,47,5
98
+ Kitchen Tools,Home & Stationery,piece,48,178,3,180,4,190,6,178,5
99
+ Storage Containers,Home & Stationery,set,51,257,3,260,4,274,6,257,5
100
+ Kitchen Appliances,Home & Stationery,piece,28,1757,3,1800,4,1876,6,1843,5
101
+ Household Utility Items,Home & Stationery,piece,50,219,3,220,4,223,6,219,5
data/marketing_actions.csv ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ action_id,description,customer_satisfaction_delta,cost_inr,recurring_cost_inr,order_processing_capacity_delta
2
+ 1,Shelf placement upgrade,1,800,0,20
3
+ 2,Barcode billing / POS setup,3,12000,150,50
4
+ 3,Loyalty points program,5,2000,250,0
5
+ 4,Neighborhood WhatsApp ordering,4,1500,100,15
6
+ 5,Milk and dairy display fridge,5,25000,120,10
7
+ 6,Storage rack expansion,0,7000,0,30
8
+ 7,Local delivery partner tie-up,5,5000,600,40
9
+ 8,Referral rewards,3,1800,150,0
10
+ 9,Slow-mover clearance shelf reset,-2,500,0,6
11
+ 10,Staff merchandising training,2,2500,0,25
12
+ 11,Open second branch (deposit + fit-out),12,750000,3000,350
13
+ 12,Simple ordering website + catalog,6,60000,400,60
14
+ 13,Hire one delivery rider + scooter,6,45000,900,50
15
+ 14,Lease nearby stockroom / pickup counter,2,120000,1500,75
16
+ 15,Always-on targeted social ads,3,4000,1200,0
data/pending_orders.csv ADDED
@@ -0,0 +1 @@
 
 
1
+ order_id,product_name,quantity,seller_name,buy_price_inr,days_remaining,status,created_day
data/seller_profiles.csv ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ seller_name,timeline_behavior,reliability_note,default_etd_days
2
+ Seller A,overcommits,Quotes aggressively and often slips past the quoted ETA,2
3
+ Seller B,strict,Reliable and usually matches the committed ETA,3
4
+ Seller C,undercommits,Quotes conservatively and often arrives by or before ETA,5
5
+ Seller D,variable,Usually competitive on impulse categories but can slip due to staffing variability,3
data/stock.csv ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ product_name,unit,buying_price_inr,selling_price_inr,demand_score,quantity_on_hand,stock_value_inr
2
+ Rice,pack,160,189,92,180,28800
3
+ Wheat Flour,pack,100,118,88,150,15000
4
+ Dals,pack,175,207,80,90,15750
5
+ Pulses,pack,200,234,76,75,15000
6
+ Cooking Oil,bottle,180,213,94,120,21600
7
+ Salt,pack,20,24,70,160,3200
8
+ Tea,pack,350,413,87,60,21000
9
+ Spices,pack,400,476,81,40,16000
10
+ Namkeen,pack,50,61,85,90,4500
11
+ Chips,pack,35,43,82,90,3150
12
+ Detergents,pack,250,295,78,40,10000
13
+ Toothpaste,tube,50,60,83,60,3000
14
+ Vegetables,kg,40,48,84,60,2400
15
+ Fruits,kg,50,59,79,45,2250
16
+ Noodles,pack,65,78,75,90,5850
17
+ Ketchup,bottle,105,124,73,40,4200
18
+ Soft Drinks,bottle,40,48,79,96,3840
19
+ Snacks,pack,70,84,72,80,5600
20
+ Handwash,bottle,85,102,73,40,3400
21
+ Paneer,pack,300,354,73,30,9000
22
+ Shampoo,bottle,150,183,64,24,3600
23
+ Sanitary Products,pack,180,209,71,24,4320
24
+ Milk,pouch,30,32,96,96,2880
25
+ Bread,loaf,35,37,86,36,1260
26
+ Eggs,dozen,72,75,84,24,1728
27
+ Bottled Water,bottle,18,20,89,48,864
28
+ Sugar,pack,80,84,74,150,12000
29
+ Biscuits,pack,20,22,90,40,800
30
+ Coffee,jar,500,640,62,18,9000
31
+ Kitchen Appliances,piece,1800,2790,28,4,7200
data/store_state.csv ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ day,current_daily_expense_inr,bank_balance_inr,customer_satisfaction,order_processing_capacity,cumulative_profit_inr
2
+ 1,3000,120000,70,500,0
gradio_app.py ADDED
@@ -0,0 +1,629 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Interactive Gradio frontend for the Store OpenEnv simulation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ try:
8
+ import gradio as gr
9
+ except ImportError as exc: # pragma: no cover
10
+ raise ImportError(
11
+ "gradio is required for the UI. Install project dependencies with `uv sync`."
12
+ ) from exc
13
+
14
+ try:
15
+ from .models import OrderRequest, PriceUpdate, StoreAction, StoreObservation, dump_model
16
+ from .server.store_environment import StoreEnvironment
17
+ except ImportError: # pragma: no cover
18
+ from models import OrderRequest, PriceUpdate, StoreAction, StoreObservation, dump_model
19
+ from server.store_environment import StoreEnvironment
20
+
21
+ SELLERS = ["Seller A", "Seller B", "Seller C", "Seller D"]
22
+
23
+ CSS = """
24
+ /* ── Global ── */
25
+ .gradio-container {
26
+ background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%) !important;
27
+ font-family: 'Inter', system-ui, -apple-system, sans-serif !important;
28
+ }
29
+
30
+ /* ── Header ── */
31
+ .app-header {
32
+ text-align: center;
33
+ padding: 28px 16px 12px;
34
+ }
35
+ .app-header h1 {
36
+ font-size: 2rem;
37
+ font-weight: 700;
38
+ background: linear-gradient(135deg, #60a5fa, #a78bfa, #f472b6);
39
+ -webkit-background-clip: text;
40
+ -webkit-text-fill-color: transparent;
41
+ margin: 0;
42
+ }
43
+ .app-header p {
44
+ color: #94a3b8;
45
+ font-size: 0.95rem;
46
+ margin-top: 4px;
47
+ }
48
+
49
+ /* ── Panels ── */
50
+ .panel-card {
51
+ background: rgba(30, 41, 59, 0.85) !important;
52
+ border: 1px solid rgba(148, 163, 184, 0.12) !important;
53
+ border-radius: 16px !important;
54
+ padding: 20px !important;
55
+ backdrop-filter: blur(12px);
56
+ }
57
+
58
+ /* ── Section headings ── */
59
+ .section-title {
60
+ color: #e2e8f0 !important;
61
+ font-size: 1.05rem !important;
62
+ font-weight: 600 !important;
63
+ border-bottom: 1px solid rgba(148, 163, 184, 0.15);
64
+ padding-bottom: 8px;
65
+ margin-bottom: 12px !important;
66
+ }
67
+
68
+ /* ── Metric cards ── */
69
+ .metric-row {
70
+ display: flex;
71
+ gap: 10px;
72
+ flex-wrap: wrap;
73
+ margin-bottom: 8px;
74
+ }
75
+ .metric-card {
76
+ flex: 1;
77
+ min-width: 120px;
78
+ background: rgba(51, 65, 85, 0.6);
79
+ border: 1px solid rgba(148, 163, 184, 0.1);
80
+ border-radius: 12px;
81
+ padding: 14px 16px;
82
+ text-align: center;
83
+ }
84
+ .metric-card .label {
85
+ font-size: 0.72rem;
86
+ text-transform: uppercase;
87
+ letter-spacing: 0.6px;
88
+ color: #94a3b8;
89
+ margin-bottom: 4px;
90
+ }
91
+ .metric-card .value {
92
+ font-size: 1.25rem;
93
+ font-weight: 700;
94
+ color: #f1f5f9;
95
+ }
96
+ .metric-card .value.positive { color: #4ade80; }
97
+ .metric-card .value.negative { color: #f87171; }
98
+
99
+ /* ── Status banner ── */
100
+ .status-banner {
101
+ padding: 12px 16px;
102
+ border-radius: 10px;
103
+ font-size: 0.92rem;
104
+ margin-bottom: 10px;
105
+ }
106
+ .status-banner.success {
107
+ background: rgba(34, 197, 94, 0.12);
108
+ border: 1px solid rgba(34, 197, 94, 0.25);
109
+ color: #86efac;
110
+ }
111
+ .status-banner.error {
112
+ background: rgba(239, 68, 68, 0.12);
113
+ border: 1px solid rgba(239, 68, 68, 0.25);
114
+ color: #fca5a5;
115
+ }
116
+ .status-banner.info {
117
+ background: rgba(59, 130, 246, 0.12);
118
+ border: 1px solid rgba(59, 130, 246, 0.25);
119
+ color: #93c5fd;
120
+ }
121
+
122
+ /* ── Buttons ── */
123
+ .btn-primary {
124
+ background: linear-gradient(135deg, #3b82f6, #6366f1) !important;
125
+ border: none !important;
126
+ color: #fff !important;
127
+ font-weight: 600 !important;
128
+ border-radius: 10px !important;
129
+ padding: 10px 20px !important;
130
+ transition: opacity 0.2s !important;
131
+ }
132
+ .btn-primary:hover { opacity: 0.88 !important; }
133
+
134
+ .btn-secondary {
135
+ background: rgba(51, 65, 85, 0.7) !important;
136
+ border: 1px solid rgba(148, 163, 184, 0.2) !important;
137
+ color: #cbd5e1 !important;
138
+ font-weight: 500 !important;
139
+ border-radius: 10px !important;
140
+ padding: 10px 20px !important;
141
+ }
142
+ .btn-secondary:hover { background: rgba(71, 85, 105, 0.7) !important; }
143
+
144
+ .btn-danger {
145
+ background: rgba(239, 68, 68, 0.15) !important;
146
+ border: 1px solid rgba(239, 68, 68, 0.3) !important;
147
+ color: #fca5a5 !important;
148
+ font-weight: 500 !important;
149
+ border-radius: 10px !important;
150
+ }
151
+
152
+ /* ── Order rows ── */
153
+ .order-row {
154
+ background: rgba(51, 65, 85, 0.4);
155
+ border: 1px solid rgba(148, 163, 184, 0.1);
156
+ border-radius: 10px;
157
+ padding: 10px 14px;
158
+ margin-bottom: 6px;
159
+ }
160
+
161
+ /* ── Fix Gradio element readability ── */
162
+ .gradio-container label { color: #cbd5e1 !important; }
163
+ .gradio-container .gr-input, .gradio-container input,
164
+ .gradio-container select, .gradio-container textarea {
165
+ background: rgba(30, 41, 59, 0.9) !important;
166
+ color: #f1f5f9 !important;
167
+ border: 1px solid rgba(148, 163, 184, 0.2) !important;
168
+ border-radius: 8px !important;
169
+ }
170
+ .gradio-container .gr-input:focus, .gradio-container input:focus {
171
+ border-color: #3b82f6 !important;
172
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15) !important;
173
+ }
174
+ .gradio-container .gr-check-radio { accent-color: #6366f1 !important; }
175
+ .gradio-container .tabs .tab-nav button {
176
+ color: #94a3b8 !important;
177
+ border: none !important;
178
+ font-weight: 500 !important;
179
+ }
180
+ .gradio-container .tabs .tab-nav button.selected {
181
+ color: #60a5fa !important;
182
+ border-bottom: 2px solid #60a5fa !important;
183
+ }
184
+ .gradio-container .json-holder { background: rgba(15, 23, 42, 0.6) !important; }
185
+ .gradio-container .markdown-text { color: #e2e8f0 !important; }
186
+ .gradio-container .markdown-text h1,
187
+ .gradio-container .markdown-text h2,
188
+ .gradio-container .markdown-text h3 { color: #f1f5f9 !important; }
189
+ .gradio-container .markdown-text code {
190
+ background: rgba(99, 102, 241, 0.15) !important;
191
+ color: #a5b4fc !important;
192
+ padding: 2px 6px;
193
+ border-radius: 4px;
194
+ }
195
+ """
196
+
197
+
198
+ def _load_products_and_stock() -> tuple[list[str], list[str]]:
199
+ """Return (market product names, stock product names) from a fresh env."""
200
+ env = StoreEnvironment()
201
+ obs = env.reset()
202
+ market_products = [m.product_name for m in obs.market]
203
+ stock_products = [s.product_name for s in obs.stock]
204
+ return market_products, stock_products
205
+
206
+
207
+ def _load_marketing_catalog() -> list[dict[str, Any]]:
208
+ env = StoreEnvironment()
209
+ obs = env.reset()
210
+ return [dump_model(m, exclude_none=True) for m in obs.marketing_actions]
211
+
212
+
213
+ def _format_marketing_label(m: dict[str, Any]) -> str:
214
+ parts = [f"#{m['action_id']} {m['description']}"]
215
+ parts.append(f"cost ₹{m['cost_inr']:,}")
216
+ if m.get("recurring_cost_inr"):
217
+ parts.append(f"recurring ₹{m['recurring_cost_inr']:,}/day")
218
+ parts.append(f"satisfaction {m['customer_satisfaction_delta']:+d}")
219
+ if m.get("order_processing_capacity_delta"):
220
+ parts.append(f"capacity {m['order_processing_capacity_delta']:+d}")
221
+ return " · ".join(parts)
222
+
223
+
224
+ def _render_status_html(message: str, kind: str = "info") -> str:
225
+ return f'<div class="status-banner {kind}">{message}</div>'
226
+
227
+
228
+ def _render_metrics_html(obs: StoreObservation) -> str:
229
+ store = obs.store
230
+
231
+ def _card(label: str, value: str, css_class: str = "") -> str:
232
+ vc = f' class="value {css_class}"' if css_class else ' class="value"'
233
+ return (
234
+ f'<div class="metric-card">'
235
+ f'<div class="label">{label}</div>'
236
+ f"<div{vc}>{value}</div>"
237
+ f"</div>"
238
+ )
239
+
240
+ profit = store.cumulative_profit_inr
241
+ profit_cls = "positive" if profit >= 0 else "negative"
242
+ reward_val = f"{obs.reward:.4f}" if obs.reward else "—"
243
+ recurring_cost = store.active_recurring_cost_inr
244
+ total_daily_burn = store.current_daily_expense_inr + recurring_cost
245
+ burn_cls = "negative" if total_daily_burn > 0 else ""
246
+ recurring_cls = "negative" if recurring_cost > 0 else ""
247
+
248
+ cards = [
249
+ _card("Day", str(store.day)),
250
+ _card("Bank Balance", f"₹{store.bank_balance_inr:,.0f}"),
251
+ _card("Base Burn / Day", f"₹{store.current_daily_expense_inr:,.0f}", burn_cls),
252
+ _card("Recurring / Day", f"₹{recurring_cost:,.0f}", recurring_cls),
253
+ _card("Total Burn / Day", f"₹{total_daily_burn:,.0f}", burn_cls),
254
+ _card("Satisfaction", f"{store.customer_satisfaction:.0f}%"),
255
+ _card("Profit", f"₹{profit:,.0f}", profit_cls),
256
+ _card("Reward", reward_val),
257
+ _card("Capacity", f"{store.order_processing_capacity:.0f}"),
258
+ ]
259
+ return f'<div class="metric-row">{"".join(cards)}</div>'
260
+
261
+
262
+ THEME = gr.themes.Base(
263
+ primary_hue=gr.themes.colors.blue,
264
+ secondary_hue=gr.themes.colors.indigo,
265
+ neutral_hue=gr.themes.colors.slate,
266
+ font=gr.themes.GoogleFont("Inter"),
267
+ ).set(
268
+ body_background_fill="transparent",
269
+ block_background_fill="rgba(30, 41, 59, 0.7)",
270
+ block_border_width="1px",
271
+ block_border_color="rgba(148, 163, 184, 0.12)",
272
+ block_label_text_color="#cbd5e1",
273
+ block_title_text_color="#e2e8f0",
274
+ input_background_fill="rgba(15, 23, 42, 0.6)",
275
+ input_border_color="rgba(148, 163, 184, 0.2)",
276
+ button_primary_background_fill="linear-gradient(135deg, #3b82f6, #6366f1)",
277
+ button_primary_text_color="#ffffff",
278
+ button_secondary_background_fill="rgba(51, 65, 85, 0.7)",
279
+ button_secondary_text_color="#cbd5e1",
280
+ button_secondary_border_color="rgba(148, 163, 184, 0.2)",
281
+ )
282
+
283
+
284
+ def make_demo() -> gr.Blocks:
285
+ """Build the interactive Gradio app."""
286
+
287
+ market_products, stock_products = _load_products_and_stock()
288
+ marketing_catalog = _load_marketing_catalog()
289
+ marketing_labels = [_format_marketing_label(m) for m in marketing_catalog]
290
+ marketing_id_by_label = {
291
+ _format_marketing_label(m): m["action_id"] for m in marketing_catalog
292
+ }
293
+
294
+ with gr.Blocks(title="Store OpenEnv Playground") as demo:
295
+ env_state = gr.State(value=None)
296
+ obs_state = gr.State(value=None)
297
+
298
+ # ── Header ──
299
+ gr.HTML(
300
+ '<div class="app-header">'
301
+ "<h1>Store OpenEnv Playground</h1>"
302
+ "<p>Simulate a neighborhood kirana store — place orders, set prices, activate marketing</p>"
303
+ "</div>"
304
+ )
305
+
306
+ # ── Top metrics ──
307
+ metrics_html = gr.HTML("")
308
+ status_html = gr.HTML(_render_status_html("Press Reset to start a fresh episode.", "info"))
309
+
310
+ with gr.Row(equal_height=False):
311
+ # ═══════════ LEFT: ACTION PANEL ═══════════
312
+ with gr.Column(scale=4, elem_classes=["panel-card"]):
313
+ gr.Markdown("### Place Orders", elem_classes=["section-title"])
314
+ gr.Markdown(
315
+ "*Select a product from the market catalog, choose a seller, and specify quantity.*",
316
+ )
317
+
318
+ with gr.Group():
319
+ with gr.Row():
320
+ order_product = gr.Dropdown(
321
+ choices=market_products,
322
+ label="Product",
323
+ value=market_products[0] if market_products else None,
324
+ scale=3,
325
+ )
326
+ order_seller = gr.Dropdown(
327
+ choices=SELLERS,
328
+ label="Seller",
329
+ value=SELLERS[0],
330
+ scale=2,
331
+ )
332
+ order_qty = gr.Number(label="Qty", value=10, minimum=1, precision=0, scale=1)
333
+ add_order_btn = gr.Button("+ Add", size="sm", elem_classes=["btn-primary"], scale=1)
334
+
335
+ orders_display = gr.Dataframe(
336
+ headers=["Product", "Seller", "Quantity"],
337
+ datatype=["str", "str", "number"],
338
+ value=[],
339
+ interactive=False,
340
+ label="Pending Orders",
341
+ row_count=(0, "dynamic"),
342
+ column_count=(3, "fixed"),
343
+ )
344
+ clear_orders_btn = gr.Button("Clear Orders", size="sm", elem_classes=["btn-danger"])
345
+
346
+ gr.Markdown("---")
347
+ gr.Markdown("### Update Prices", elem_classes=["section-title"])
348
+ gr.Markdown("*Adjust selling prices for products currently in stock.*")
349
+
350
+ with gr.Group():
351
+ with gr.Row():
352
+ price_product = gr.Dropdown(
353
+ choices=stock_products,
354
+ label="Product",
355
+ value=stock_products[0] if stock_products else None,
356
+ scale=3,
357
+ )
358
+ price_value = gr.Number(label="New Price ₹", value=100, minimum=1, precision=0, scale=2)
359
+ add_price_btn = gr.Button("+ Add", size="sm", elem_classes=["btn-primary"], scale=1)
360
+
361
+ prices_display = gr.Dataframe(
362
+ headers=["Product", "Price (₹)"],
363
+ datatype=["str", "number"],
364
+ value=[],
365
+ interactive=False,
366
+ label="Price Changes",
367
+ row_count=(0, "dynamic"),
368
+ column_count=(2, "fixed"),
369
+ )
370
+ clear_prices_btn = gr.Button("Clear Prices", size="sm", elem_classes=["btn-danger"])
371
+
372
+ gr.Markdown("---")
373
+ gr.Markdown("### Marketing Actions", elem_classes=["section-title"])
374
+ gr.Markdown("*Select marketing or operational upgrades to activate.*")
375
+
376
+ marketing_checkboxes = gr.CheckboxGroup(
377
+ choices=marketing_labels,
378
+ label="Available Actions",
379
+ value=[],
380
+ )
381
+
382
+ gr.Markdown("---")
383
+ with gr.Row():
384
+ reset_btn = gr.Button("Reset Episode", variant="secondary", elem_classes=["btn-secondary"])
385
+ pass_btn = gr.Button("Pass (Skip Turn)", variant="secondary", elem_classes=["btn-secondary"])
386
+ step_btn = gr.Button("Submit Step", variant="primary", elem_classes=["btn-primary"], scale=2)
387
+
388
+ # ═══════════ RIGHT: OBSERVATION PANEL ═══════════
389
+ with gr.Column(scale=5, elem_classes=["panel-card"]):
390
+ gr.Markdown("### Live State", elem_classes=["section-title"])
391
+
392
+ with gr.Tabs():
393
+ with gr.Tab("Store"):
394
+ store_json = gr.JSON(label="Store snapshot")
395
+ with gr.Tab("Stock"):
396
+ stock_json = gr.JSON(label="Current stock levels")
397
+ with gr.Tab("Pending Orders"):
398
+ pending_json = gr.JSON(label="In-transit supplier orders")
399
+ with gr.Tab("Market"):
400
+ market_json = gr.JSON(label="Market catalog with seller quotes")
401
+ with gr.Tab("Marketing"):
402
+ marketing_json = gr.JSON(label="Marketing actions catalog")
403
+ with gr.Tab("Reward"):
404
+ reward_json = gr.JSON(label="Reward breakdown")
405
+ with gr.Tab("Full Observation"):
406
+ metadata_json = gr.JSON(label="Complete observation data")
407
+
408
+ # ── Helpers ──
409
+
410
+ def _initial_env() -> tuple[StoreEnvironment, StoreObservation]:
411
+ env = StoreEnvironment()
412
+ obs = env.reset()
413
+ return env, obs
414
+
415
+ def _render_obs(obs: StoreObservation, message: str = "", is_error: bool = False) -> list[Any]:
416
+ kind = "error" if is_error else "success"
417
+ if not message:
418
+ message = obs.summary or "Ready."
419
+ kind = "info"
420
+ return [
421
+ _render_metrics_html(obs),
422
+ _render_status_html(message, kind),
423
+ dump_model(obs.store, exclude_none=True),
424
+ [dump_model(item, exclude_none=True) for item in obs.stock],
425
+ [dump_model(item, exclude_none=True) for item in obs.pending_orders],
426
+ [dump_model(item, exclude_none=True) for item in obs.market],
427
+ [dump_model(item, exclude_none=True) for item in obs.marketing_actions],
428
+ obs.metadata.get("reward_breakdown", {}),
429
+ dump_model(obs, exclude_none=True),
430
+ ]
431
+
432
+ render_outputs = [
433
+ metrics_html, status_html,
434
+ store_json, stock_json, pending_json, market_json,
435
+ marketing_json, reward_json, metadata_json,
436
+ ]
437
+
438
+ def _update_stock_dropdown(env: StoreEnvironment | None, obs: StoreObservation | None):
439
+ if obs is None:
440
+ return gr.update()
441
+ names = [s.product_name for s in obs.stock]
442
+ return gr.update(choices=names, value=names[0] if names else None)
443
+
444
+ # ── Order management ──
445
+
446
+ def _df_to_list(df: Any) -> list[list]:
447
+ if df is None:
448
+ return []
449
+ try:
450
+ import pandas as pd
451
+ if isinstance(df, pd.DataFrame):
452
+ return df.values.tolist() if not df.empty else []
453
+ except ImportError:
454
+ pass
455
+ if isinstance(df, list):
456
+ return df
457
+ return []
458
+
459
+ def _add_order(current_orders: Any, product: str, seller: str, qty: int | float):
460
+ if not product or not seller:
461
+ return current_orders
462
+ qty = int(qty) if qty else 10
463
+ row = [product, seller, qty]
464
+ updated = _df_to_list(current_orders)
465
+ updated.append(row)
466
+ return updated
467
+
468
+ def _clear_orders():
469
+ return []
470
+
471
+ add_order_btn.click(
472
+ fn=_add_order,
473
+ inputs=[orders_display, order_product, order_seller, order_qty],
474
+ outputs=[orders_display],
475
+ )
476
+ clear_orders_btn.click(fn=_clear_orders, outputs=[orders_display])
477
+
478
+ # ── Price management ──
479
+
480
+ def _add_price(current_prices: Any, product: str, price: int | float):
481
+ if not product:
482
+ return current_prices
483
+ price = int(price) if price else 100
484
+ updated = _df_to_list(current_prices)
485
+ for i, row in enumerate(updated):
486
+ if row[0] == product:
487
+ updated[i] = [product, price]
488
+ return updated
489
+ updated.append([product, price])
490
+ return updated
491
+
492
+ def _clear_prices():
493
+ return []
494
+
495
+ add_price_btn.click(
496
+ fn=_add_price,
497
+ inputs=[prices_display, price_product, price_value],
498
+ outputs=[prices_display],
499
+ )
500
+ clear_prices_btn.click(fn=_clear_prices, outputs=[prices_display])
501
+
502
+ # ── Reset ──
503
+
504
+ def _reset():
505
+ env, obs = _initial_env()
506
+ stock_names = [s.product_name for s in obs.stock]
507
+ active_ids = set(obs.store.active_action_ids)
508
+ available_labels = [
509
+ lbl for lbl, aid in marketing_id_by_label.items()
510
+ if aid not in active_ids
511
+ ]
512
+ return (
513
+ env, obs,
514
+ [], # orders display
515
+ [], # prices display
516
+ gr.update(choices=available_labels, value=[]), # marketing
517
+ gr.update(choices=stock_names, value=stock_names[0] if stock_names else None),
518
+ *_render_obs(obs, "Episode reset. Day 1 loaded.", False),
519
+ )
520
+
521
+ reset_outputs = [
522
+ env_state, obs_state,
523
+ orders_display, prices_display,
524
+ marketing_checkboxes, price_product,
525
+ *render_outputs,
526
+ ]
527
+
528
+ reset_btn.click(fn=_reset, inputs=[], outputs=reset_outputs)
529
+ demo.load(fn=_reset, inputs=[], outputs=reset_outputs)
530
+
531
+ # ── Step ──
532
+
533
+ def _step(
534
+ env: StoreEnvironment | None,
535
+ obs: StoreObservation | None,
536
+ orders_data: Any,
537
+ prices_data: Any,
538
+ selected_marketing: list[str],
539
+ ):
540
+ if env is None or obs is None:
541
+ env, obs = _initial_env()
542
+
543
+ orders_rows = _df_to_list(orders_data)
544
+ prices_rows = _df_to_list(prices_data)
545
+
546
+ try:
547
+ order_requests = [
548
+ OrderRequest(product=row[0], seller=row[1], quantity=int(row[2]))
549
+ for row in orders_rows
550
+ if len(row) >= 3 and row[0]
551
+ ]
552
+ price_updates = [
553
+ PriceUpdate(product=row[0], price=int(row[1]))
554
+ for row in prices_rows
555
+ if len(row) >= 2 and row[0]
556
+ ]
557
+ action_ids = [
558
+ marketing_id_by_label[lbl]
559
+ for lbl in (selected_marketing or [])
560
+ if lbl in marketing_id_by_label
561
+ ]
562
+
563
+ action = StoreAction(
564
+ orders=order_requests,
565
+ prices=price_updates if price_updates else None,
566
+ action_ids=action_ids,
567
+ )
568
+ next_obs = env.step(action)
569
+ message = "Step completed successfully."
570
+ except Exception as exc:
571
+ message = f"{type(exc).__name__}: {exc}"
572
+ stock_names = [s.product_name for s in obs.stock]
573
+ return (
574
+ env, obs,
575
+ orders_rows, prices_rows,
576
+ gr.update(), gr.update(),
577
+ *_render_obs(obs, message, is_error=True),
578
+ )
579
+
580
+ stock_names = [s.product_name for s in next_obs.stock]
581
+ active_ids = set(next_obs.store.active_action_ids)
582
+ available_labels = [
583
+ lbl for lbl, aid in marketing_id_by_label.items()
584
+ if aid not in active_ids
585
+ ]
586
+ return (
587
+ env, next_obs,
588
+ [], # clear orders
589
+ [], # clear prices
590
+ gr.update(choices=available_labels, value=[]),
591
+ gr.update(choices=stock_names, value=stock_names[0] if stock_names else None),
592
+ *_render_obs(next_obs, message),
593
+ )
594
+
595
+ step_outputs = [
596
+ env_state, obs_state,
597
+ orders_display, prices_display,
598
+ marketing_checkboxes, price_product,
599
+ *render_outputs,
600
+ ]
601
+
602
+ step_btn.click(
603
+ fn=_step,
604
+ inputs=[env_state, obs_state, orders_display, prices_display, marketing_checkboxes],
605
+ outputs=step_outputs,
606
+ )
607
+
608
+ # ── Pass ──
609
+
610
+ def _pass_step(env: StoreEnvironment | None, obs: StoreObservation | None):
611
+ return _step(env, obs, [], [], [])
612
+
613
+ pass_btn.click(
614
+ fn=_pass_step,
615
+ inputs=[env_state, obs_state],
616
+ outputs=step_outputs,
617
+ )
618
+
619
+ return demo
620
+
621
+
622
+ def main(host: str = "127.0.0.1", port: int = 7860, share: bool = False) -> None:
623
+ """Launch the Gradio app."""
624
+ demo = make_demo()
625
+ demo.launch(server_name=host, server_port=port, share=share, css=CSS, theme=THEME)
626
+
627
+
628
+ if __name__ == "__main__":
629
+ main()
models.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Data models for the Store OpenEnv simulation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ try:
10
+ from ._compat import Action, Observation
11
+ except ImportError: # pragma: no cover - supports direct module imports
12
+ from _compat import Action, Observation
13
+
14
+
15
+ class OrderRequest(BaseModel):
16
+ """One supplier order request."""
17
+
18
+ product: str = Field(..., description="Product name from the market catalog")
19
+ seller: str = Field(..., description="Seller name, for example Seller A")
20
+ quantity: int = Field(..., gt=0, description="Number of units to order")
21
+
22
+
23
+ class PriceUpdate(BaseModel):
24
+ """One price update for an in-stock product."""
25
+
26
+ product: str = Field(..., description="Product name currently stocked by the store")
27
+ price: int = Field(..., gt=0, description="New selling price in INR")
28
+
29
+
30
+ class StockItem(BaseModel):
31
+ """Current in-store inventory row."""
32
+
33
+ product_name: str
34
+ unit: str
35
+ buying_price_inr: float
36
+ selling_price_inr: float
37
+ demand_score: float
38
+ quantity_on_hand: float
39
+ stock_value_inr: float
40
+
41
+
42
+ class PendingOrder(BaseModel):
43
+ """Supplier order still in transit."""
44
+
45
+ order_id: str
46
+ product_name: str
47
+ quantity: int
48
+ seller_name: str
49
+ buy_price_inr: float
50
+ days_remaining: int
51
+ status: str
52
+ created_day: int
53
+
54
+
55
+ class MarketOffer(BaseModel):
56
+ """Market catalog row with seller quotes."""
57
+
58
+ product_name: str
59
+ category: str
60
+ unit: str
61
+ demand_score: float
62
+ seller_a_price_inr: int
63
+ seller_a_etd_days: int
64
+ seller_b_price_inr: int
65
+ seller_b_etd_days: int
66
+ seller_c_price_inr: int
67
+ seller_c_etd_days: int
68
+ seller_d_price_inr: int
69
+ seller_d_etd_days: int
70
+
71
+
72
+ class SellerProfile(BaseModel):
73
+ """Seller reliability profile."""
74
+
75
+ seller_name: str
76
+ timeline_behavior: str
77
+ reliability_note: str
78
+ default_etd_days: int
79
+
80
+
81
+ class MarketingAction(BaseModel):
82
+ """Durable operational action the agent can activate."""
83
+
84
+ action_id: int
85
+ description: str
86
+ customer_satisfaction_delta: int
87
+ cost_inr: int
88
+ recurring_cost_inr: int
89
+ order_processing_capacity_delta: int
90
+ is_active: bool = False
91
+
92
+
93
+ class StoreSnapshot(BaseModel):
94
+ """Top-level store state shown to the agent."""
95
+
96
+ day: int
97
+ current_daily_expense_inr: float
98
+ bank_balance_inr: float
99
+ customer_satisfaction: float
100
+ order_processing_capacity: float
101
+ cumulative_profit_inr: float
102
+ active_action_ids: list[int] = Field(default_factory=list)
103
+ active_recurring_cost_inr: float = 0.0
104
+ episode_horizon_days: int
105
+ commit_window_days: int
106
+ reward_horizon_days: int
107
+
108
+
109
+ class StoreAction(Action):
110
+ """One bundled decision window for the environment."""
111
+
112
+ orders: list[OrderRequest] = Field(
113
+ default_factory=list,
114
+ description="Supplier orders to place at the start of the decision window",
115
+ )
116
+ prices: list[PriceUpdate] | None = Field(
117
+ default=None,
118
+ description="Optional selling-price updates for products already in stock",
119
+ )
120
+ action_ids: list[int] = Field(
121
+ default_factory=list,
122
+ description="Marketing or operations action IDs to activate",
123
+ )
124
+
125
+
126
+ class AvailableActionSpec(BaseModel):
127
+ """Agent-facing action guide included in every observation."""
128
+
129
+ name: str
130
+ description: str
131
+ pass_action: StoreAction
132
+ example_action: StoreAction
133
+ notes: list[str] = Field(default_factory=list)
134
+
135
+
136
+ class StoreObservation(Observation):
137
+ """Agent-facing observation for the store simulation."""
138
+
139
+ store: StoreSnapshot
140
+ stock: list[StockItem]
141
+ pending_orders: list[PendingOrder]
142
+ market: list[MarketOffer]
143
+ seller_profiles: list[SellerProfile]
144
+ marketing_actions: list[MarketingAction]
145
+ available_action: AvailableActionSpec
146
+ summary: str = ""
147
+
148
+
149
+ def dump_model(model: BaseModel, **kwargs: Any) -> dict[str, Any]:
150
+ """Handle Pydantic v1/v2 model serialization."""
151
+
152
+ if hasattr(model, "model_dump"):
153
+ return model.model_dump(**kwargs)
154
+ return model.dict(**kwargs)
openenv.yaml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: store
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 8000
7
+
pyproject.toml ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-store"
13
+ version = "0.1.0"
14
+ description = "Store 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
+ "gradio>=6.0.0",
21
+ "openenv-core[core]>=0.2.2",
22
+ "pydantic>=2.0.0",
23
+ # Environment-specific dependencies
24
+ # Add all dependencies needed for your environment here
25
+ # Examples:
26
+ # "numpy>=1.19.0",
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 store.server.app
42
+ server = "store.server.app:main"
43
+ gradio = "store.gradio_app:main"
44
+
45
+ [tool.setuptools]
46
+ include-package-data = true
47
+ packages = ["store", "store.server"]
48
+ package-dir = { "store" = ".", "store.server" = "server" }
49
+
50
+ [tool.setuptools.package-data]
51
+ "store" = ["data/*.csv"]
scripts/generate_stock.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate a kirana-style opening inventory for stock.csv.
2
+
3
+ The opening stock is intentionally hybrid:
4
+
5
+ - mostly believable neighborhood-grocery inventory
6
+ - a few teachable benchmark hooks for the agent
7
+ - opening stock value kept in the 220k-260k INR range
8
+
9
+ Embedded scenarios:
10
+
11
+ - thin-margin anchor items the agent must reason about carefully
12
+ - a believable replenishment miss on bottled water and biscuits
13
+ - one obvious dead-stock item
14
+ - a modest slow-mover pricing mistake
15
+ """
16
+
17
+ import csv
18
+ import math
19
+ from pathlib import Path
20
+
21
+
22
+ DATA_DIR = Path(__file__).resolve().parents[1] / "data"
23
+ FIELDS = [
24
+ "product_name",
25
+ "unit",
26
+ "buying_price_inr",
27
+ "selling_price_inr",
28
+ "demand_score",
29
+ "quantity_on_hand",
30
+ "stock_value_inr",
31
+ ]
32
+
33
+ market: dict[str, dict[str, str]] = {}
34
+ with (DATA_DIR / "market_offers.csv").open(newline="", encoding="utf-8") as handle:
35
+ for row in csv.DictReader(handle):
36
+ market[row["product_name"]] = row
37
+
38
+
39
+ def ref_price(name: str) -> int:
40
+ """Use Seller B as the reference buy price for opening inventory."""
41
+ return int(market[name]["seller_b_price_inr"])
42
+
43
+
44
+ def build_row(name: str, quantity: int, markup: float) -> dict[str, str | int]:
45
+ buying_price = ref_price(name)
46
+ selling_price = math.ceil(buying_price * (1 + markup))
47
+ return {
48
+ "product_name": name,
49
+ "unit": market[name]["unit"],
50
+ "buying_price_inr": buying_price,
51
+ "selling_price_inr": selling_price,
52
+ "demand_score": int(market[name]["demand_score"]),
53
+ "quantity_on_hand": quantity,
54
+ "stock_value_inr": buying_price * quantity,
55
+ }
56
+
57
+
58
+ rows: list[dict[str, str | int]] = []
59
+
60
+ # Good baseline inventory: solid coverage of staples and daily-use products.
61
+ good_items = [
62
+ ("Rice", 180, 0.18),
63
+ ("Wheat Flour", 150, 0.18),
64
+ ("Dals", 90, 0.18),
65
+ ("Pulses", 75, 0.17),
66
+ ("Cooking Oil", 120, 0.18),
67
+ ("Salt", 160, 0.18),
68
+ ("Tea", 60, 0.18),
69
+ ("Spices", 40, 0.19),
70
+ ("Namkeen", 90, 0.22),
71
+ ("Chips", 90, 0.22),
72
+ ("Detergents", 40, 0.18),
73
+ ("Toothpaste", 60, 0.20),
74
+ ("Vegetables", 60, 0.18),
75
+ ("Fruits", 45, 0.18),
76
+ ("Noodles", 90, 0.20),
77
+ ("Ketchup", 40, 0.18),
78
+ ("Soft Drinks", 96, 0.19),
79
+ ("Snacks", 80, 0.20),
80
+ ("Handwash", 40, 0.20),
81
+ ("Paneer", 30, 0.18),
82
+ ("Shampoo", 24, 0.22),
83
+ ("Sanitary Products", 24, 0.16),
84
+ ]
85
+
86
+ # Customer-visible anchors: intentionally thin margins.
87
+ anchor_items = [
88
+ ("Milk", 96, 0.04),
89
+ ("Bread", 36, 0.03),
90
+ ("Eggs", 24, 0.04),
91
+ ("Bottled Water", 48, 0.06),
92
+ ("Sugar", 150, 0.05),
93
+ ]
94
+
95
+ # Benchmark hooks: one replenishment miss, one mild pricing miss, one dead-stock trap.
96
+ special_items = [
97
+ ("Biscuits", 40, 0.10),
98
+ ("Coffee", 18, 0.28),
99
+ ("Kitchen Appliances", 4, 0.55),
100
+ ]
101
+
102
+ for name, quantity, markup in good_items + anchor_items + special_items:
103
+ rows.append(build_row(name, quantity, markup))
104
+
105
+ total_value = sum(int(row["stock_value_inr"]) for row in rows)
106
+ print(f"Items: {len(rows)}")
107
+ print(f"Total stock value: {total_value:,} INR")
108
+ print(
109
+ f"Good items: {len(good_items)} | Anchor items: {len(anchor_items)}"
110
+ f" | Special hooks: {len(special_items)}"
111
+ )
112
+
113
+ with (DATA_DIR / "stock.csv").open("w", newline="", encoding="utf-8") as handle:
114
+ writer = csv.DictWriter(handle, fieldnames=FIELDS)
115
+ writer.writeheader()
116
+ writer.writerows(rows)
117
+
118
+ print(f"\nWrote {len(rows)} rows to stock.csv")
119
+
120
+ lookup = {row["product_name"]: row for row in rows}
121
+ print("\n-- Scenario summary --")
122
+ print(
123
+ "Thin-margin anchors:",
124
+ ", ".join(name for name, _, _ in anchor_items),
125
+ )
126
+ print(
127
+ "Replenishment miss:",
128
+ f"Biscuits qty={lookup['Biscuits']['quantity_on_hand']}, "
129
+ f"Bottled Water qty={lookup['Bottled Water']['quantity_on_hand']}",
130
+ )
131
+ print(
132
+ "Dead stock:",
133
+ f"Kitchen Appliances value={lookup['Kitchen Appliances']['stock_value_inr']:,} INR",
134
+ )
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
+ """Store environment server components."""
8
+
9
+ from .store_environment import StoreEnvironment
10
+
11
+ __all__ = ["StoreEnvironment"]
server/app.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ """FastAPI application for the Store OpenEnv simulation."""
8
+
9
+ try:
10
+ from openenv.core.env_server.http_server import create_app
11
+ except Exception as e: # pragma: no cover
12
+ raise ImportError(
13
+ "openenv is required for the web interface. Install dependencies with '\n uv sync\n'"
14
+ ) from e
15
+
16
+ try:
17
+ from ..models import StoreAction, StoreObservation
18
+ from .store_environment import StoreEnvironment
19
+ except ModuleNotFoundError:
20
+ from models import StoreAction, StoreObservation
21
+ from server.store_environment import StoreEnvironment
22
+
23
+
24
+ app = create_app(
25
+ StoreEnvironment,
26
+ StoreAction,
27
+ StoreObservation,
28
+ env_name="store",
29
+ max_concurrent_envs=4,
30
+ )
31
+
32
+
33
+ def main(host: str = "0.0.0.0", port: int = 8000):
34
+ """
35
+ Entry point for direct execution via ``uv run`` or ``python -m``.
36
+ """
37
+ import uvicorn
38
+
39
+ uvicorn.run(app, host=host, port=port)
40
+
41
+
42
+ if __name__ == "__main__":
43
+ import argparse
44
+
45
+ parser = argparse.ArgumentParser()
46
+ parser.add_argument("--port", type=int, default=8000)
47
+ args = parser.parse_args()
48
+ if args.port == 8000:
49
+ main()
50
+ else:
51
+ main(port=args.port)
server/requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ openenv[core]>=0.2.0
2
+ fastapi>=0.115.0
3
+ uvicorn>=0.24.0
4
+
5
+
6
+
server/store_environment.py ADDED
@@ -0,0 +1,655 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Store OpenEnv simulation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import math
7
+ import random
8
+ from copy import deepcopy
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from uuid import uuid4
12
+
13
+ try:
14
+ from .._compat import Environment, State
15
+ from ..models import (
16
+ AvailableActionSpec,
17
+ MarketingAction,
18
+ MarketOffer,
19
+ OrderRequest,
20
+ PendingOrder,
21
+ PriceUpdate,
22
+ SellerProfile,
23
+ StockItem,
24
+ StoreAction,
25
+ StoreObservation,
26
+ StoreSnapshot,
27
+ )
28
+ except ImportError: # pragma: no cover - supports direct module imports
29
+ from _compat import Environment, State
30
+ from models import (
31
+ AvailableActionSpec,
32
+ MarketingAction,
33
+ MarketOffer,
34
+ OrderRequest,
35
+ PendingOrder,
36
+ PriceUpdate,
37
+ SellerProfile,
38
+ StockItem,
39
+ StoreAction,
40
+ StoreObservation,
41
+ StoreSnapshot,
42
+ )
43
+
44
+ DATA_DIR = Path(__file__).resolve().parents[1] / "data"
45
+ SELLER_PRICE_COLUMN = {
46
+ "Seller A": "seller_a_price_inr",
47
+ "Seller B": "seller_b_price_inr",
48
+ "Seller C": "seller_c_price_inr",
49
+ "Seller D": "seller_d_price_inr",
50
+ }
51
+ SELLER_ETD_COLUMN = {
52
+ "Seller A": "seller_a_etd_days",
53
+ "Seller B": "seller_b_etd_days",
54
+ "Seller C": "seller_c_etd_days",
55
+ "Seller D": "seller_d_etd_days",
56
+ }
57
+ NEW_PRODUCT_MARKUP = 1.18
58
+
59
+
60
+ class StoreEnvironment(Environment):
61
+ """Simulation for a neighborhood grocery store."""
62
+
63
+ SUPPORTS_CONCURRENT_SESSIONS: bool = True
64
+ EPISODE_HORIZON_DAYS = 30
65
+ COMMIT_WINDOW_DAYS = 3
66
+ REWARD_HORIZON_DAYS = 6
67
+
68
+ def __init__(self) -> None:
69
+ self._state = State(episode_id=str(uuid4()), step_count=0)
70
+ self._reset_count = 0
71
+ self._rng = random.Random(0)
72
+ self._runtime = self._build_initial_runtime()
73
+
74
+ def reset(self) -> StoreObservation:
75
+ """Reset the environment back to the seed CSV state."""
76
+
77
+ self._state = State(episode_id=str(uuid4()), step_count=0)
78
+ self._reset_count += 1
79
+ self._rng = random.Random(self._reset_count)
80
+ self._runtime = self._build_initial_runtime()
81
+ return self._build_observation(
82
+ self._runtime,
83
+ reward=0.0,
84
+ done=False,
85
+ summary=(
86
+ "Day 1 store snapshot loaded. Submit one bundled action for the next "
87
+ "3-day decision window; reward looks ahead across 6 simulated days."
88
+ ),
89
+ metadata={
90
+ "reset_count": self._reset_count,
91
+ "simulation": {
92
+ "commit_window_days": self.COMMIT_WINDOW_DAYS,
93
+ "reward_horizon_days": self.REWARD_HORIZON_DAYS,
94
+ },
95
+ },
96
+ )
97
+
98
+ def step(self, action: StoreAction) -> StoreObservation: # type: ignore[override]
99
+ """Execute one bundled decision window."""
100
+
101
+ self._validate_action(action)
102
+
103
+ live_runtime = self._runtime
104
+ start_day = live_runtime["store"]["day"]
105
+ starting_capital = self._capital(live_runtime)
106
+ score_before = self._total_product_selling_score(live_runtime["stock"])
107
+
108
+ prepared_bundle = self._prepare_bundle(action, live_runtime)
109
+
110
+ commit_branch = deepcopy(live_runtime)
111
+ reward_branch = deepcopy(live_runtime)
112
+ self._apply_bundle(commit_branch, prepared_bundle)
113
+ self._apply_bundle(reward_branch, prepared_bundle)
114
+
115
+ adjusted_satisfaction = live_runtime["store"]["customer_satisfaction"] + prepared_bundle[
116
+ "customer_satisfaction_delta"
117
+ ]
118
+ score_after = self._total_product_selling_score(commit_branch["stock"])
119
+ next_satisfaction = self._updated_customer_satisfaction(
120
+ adjusted_satisfaction,
121
+ score_before,
122
+ score_after,
123
+ )
124
+ commit_branch["store"]["customer_satisfaction"] = next_satisfaction
125
+ reward_branch["store"]["customer_satisfaction"] = next_satisfaction
126
+
127
+ commit_metrics = self._simulate_days(commit_branch, self.COMMIT_WINDOW_DAYS)
128
+ reward_metrics = self._simulate_days(reward_branch, self.REWARD_HORIZON_DAYS)
129
+
130
+ ending_capital = self._capital(reward_branch)
131
+ difference = (
132
+ ending_capital
133
+ - starting_capital
134
+ - reward_metrics["recurring_costs_paid_inr"]
135
+ )
136
+ normalized_difference = 0.0 if starting_capital <= 0 else difference / starting_capital
137
+ reward = 0.5 + normalized_difference
138
+
139
+ self._runtime = commit_branch
140
+ self._state.step_count += 1
141
+
142
+ done_reason = ""
143
+ if self._runtime["store"]["bank_balance_inr"] < 0:
144
+ done_reason = "bank_balance_below_zero"
145
+ elif self._runtime["store"]["day"] > self.EPISODE_HORIZON_DAYS:
146
+ done_reason = "episode_horizon_reached"
147
+ done = bool(done_reason)
148
+
149
+ summary = (
150
+ f"Committed days {start_day}-{commit_branch['store']['day'] - 1}. "
151
+ f"Reward forecast extends through day {reward_branch['store']['day'] - 1}."
152
+ )
153
+ metadata = {
154
+ "reward_breakdown": {
155
+ "starting_capital_inr": self._round(starting_capital),
156
+ "ending_capital_inr": self._round(ending_capital),
157
+ "difference_inr": self._round(difference),
158
+ "normalized_difference": self._round(normalized_difference, digits=6),
159
+ "reward_horizon_days": self.REWARD_HORIZON_DAYS,
160
+ "commit_window_days": self.COMMIT_WINDOW_DAYS,
161
+ "recurring_costs_paid_inr": self._round(reward_metrics["recurring_costs_paid_inr"]),
162
+ },
163
+ "commit_metrics": self._rounded_metrics(commit_metrics),
164
+ "reward_metrics": self._rounded_metrics(reward_metrics),
165
+ "done_reason": done_reason,
166
+ }
167
+ return self._build_observation(
168
+ self._runtime,
169
+ reward=reward,
170
+ done=done,
171
+ summary=summary,
172
+ metadata=metadata,
173
+ )
174
+
175
+ @property
176
+ def state(self) -> State:
177
+ """Return the OpenEnv transport state."""
178
+
179
+ return self._state
180
+
181
+ def _build_initial_runtime(self) -> dict[str, Any]:
182
+ store_rows = self._read_csv("store_state.csv")
183
+ stock_rows = self._read_csv("stock.csv")
184
+ pending_order_rows = self._read_csv("pending_orders.csv")
185
+ seller_rows = self._read_csv("seller_profiles.csv")
186
+ market_rows = self._read_csv("market_offers.csv")
187
+ marketing_rows = self._read_csv("marketing_actions.csv")
188
+
189
+ if len(store_rows) != 1:
190
+ raise ValueError("store_state.csv must contain exactly one row")
191
+
192
+ store_row = store_rows[0]
193
+ pending_orders = [
194
+ {
195
+ "order_id": row["order_id"],
196
+ "product_name": row["product_name"],
197
+ "quantity": int(row["quantity"]),
198
+ "seller_name": row["seller_name"],
199
+ "buy_price_inr": float(row["buy_price_inr"]),
200
+ "days_remaining": int(row["days_remaining"]),
201
+ "status": row["status"],
202
+ "created_day": int(row["created_day"]),
203
+ }
204
+ for row in pending_order_rows
205
+ ]
206
+
207
+ return {
208
+ "store": {
209
+ "day": int(store_row["day"]),
210
+ "current_daily_expense_inr": float(store_row["current_daily_expense_inr"]),
211
+ "bank_balance_inr": float(store_row["bank_balance_inr"]),
212
+ "customer_satisfaction": float(store_row["customer_satisfaction"]),
213
+ "order_processing_capacity": float(store_row["order_processing_capacity"]),
214
+ "cumulative_profit_inr": float(store_row["cumulative_profit_inr"]),
215
+ },
216
+ "stock": [
217
+ {
218
+ "product_name": row["product_name"],
219
+ "unit": row["unit"],
220
+ "buying_price_inr": float(row["buying_price_inr"]),
221
+ "selling_price_inr": float(row["selling_price_inr"]),
222
+ "demand_score": float(row["demand_score"]),
223
+ "quantity_on_hand": float(row["quantity_on_hand"]),
224
+ "stock_value_inr": float(row["stock_value_inr"]),
225
+ }
226
+ for row in stock_rows
227
+ ],
228
+ "pending_orders": pending_orders,
229
+ "market_offers": [
230
+ {
231
+ "product_name": row["product_name"],
232
+ "category": row["category"],
233
+ "unit": row["unit"],
234
+ "demand_score": float(row["demand_score"]),
235
+ "seller_a_price_inr": int(row["seller_a_price_inr"]),
236
+ "seller_a_etd_days": int(row["seller_a_etd_days"]),
237
+ "seller_b_price_inr": int(row["seller_b_price_inr"]),
238
+ "seller_b_etd_days": int(row["seller_b_etd_days"]),
239
+ "seller_c_price_inr": int(row["seller_c_price_inr"]),
240
+ "seller_c_etd_days": int(row["seller_c_etd_days"]),
241
+ "seller_d_price_inr": int(row["seller_d_price_inr"]),
242
+ "seller_d_etd_days": int(row["seller_d_etd_days"]),
243
+ }
244
+ for row in market_rows
245
+ ],
246
+ "seller_profiles": [
247
+ {
248
+ "seller_name": row["seller_name"],
249
+ "timeline_behavior": row["timeline_behavior"],
250
+ "reliability_note": row["reliability_note"],
251
+ "default_etd_days": int(row["default_etd_days"]),
252
+ }
253
+ for row in seller_rows
254
+ ],
255
+ "marketing_actions_catalog": [
256
+ {
257
+ "action_id": int(row["action_id"]),
258
+ "description": row["description"],
259
+ "customer_satisfaction_delta": int(row["customer_satisfaction_delta"]),
260
+ "cost_inr": int(row["cost_inr"]),
261
+ "recurring_cost_inr": int(row["recurring_cost_inr"]),
262
+ "order_processing_capacity_delta": int(row["order_processing_capacity_delta"]),
263
+ }
264
+ for row in marketing_rows
265
+ ],
266
+ "active_action_ids": set(),
267
+ "next_order_id": len(pending_orders) + 1,
268
+ }
269
+
270
+ def _read_csv(self, name: str) -> list[dict[str, str]]:
271
+ with (DATA_DIR / name).open(newline="", encoding="utf-8") as handle:
272
+ return list(csv.DictReader(handle))
273
+
274
+ def _validate_action(self, action: StoreAction) -> None:
275
+ market_by_product = {
276
+ row["product_name"]: row for row in self._runtime["market_offers"]
277
+ }
278
+ stock_by_product = {
279
+ row["product_name"]: row for row in self._runtime["stock"]
280
+ }
281
+ marketing_ids = {
282
+ row["action_id"] for row in self._runtime["marketing_actions_catalog"]
283
+ }
284
+
285
+ seen_price_products: set[str] = set()
286
+ for update in action.prices or []:
287
+ if update.product not in stock_by_product:
288
+ raise ValueError(
289
+ f"Cannot set price for {update.product!r} because it is not currently stocked."
290
+ )
291
+ if update.product in seen_price_products:
292
+ raise ValueError(f"Duplicate price update for {update.product!r}.")
293
+ if update.price <= 0:
294
+ raise ValueError("Price updates must be positive.")
295
+ seen_price_products.add(update.product)
296
+
297
+ seen_action_ids: set[int] = set()
298
+ for action_id in action.action_ids:
299
+ if action_id not in marketing_ids:
300
+ raise ValueError(f"Unknown marketing action id {action_id}.")
301
+ if action_id in seen_action_ids:
302
+ raise ValueError(f"Duplicate marketing action id {action_id}.")
303
+ if action_id in self._runtime["active_action_ids"]:
304
+ raise ValueError(f"Marketing action {action_id} is already active.")
305
+ seen_action_ids.add(action_id)
306
+
307
+ for order in action.orders:
308
+ if order.product not in market_by_product:
309
+ raise ValueError(f"Unknown market product {order.product!r}.")
310
+ if order.seller not in SELLER_PRICE_COLUMN:
311
+ raise ValueError(f"Unknown seller {order.seller!r}.")
312
+ if order.quantity <= 0:
313
+ raise ValueError("Order quantity must be positive.")
314
+
315
+ def _prepare_bundle(self, action: StoreAction, runtime: dict[str, Any]) -> dict[str, Any]:
316
+ market_by_product = {
317
+ row["product_name"]: row for row in runtime["market_offers"]
318
+ }
319
+ marketing_by_id = {
320
+ row["action_id"]: row for row in runtime["marketing_actions_catalog"]
321
+ }
322
+ next_order_id = runtime["next_order_id"]
323
+
324
+ prepared_orders = []
325
+ for index, request in enumerate(action.orders):
326
+ market_row = market_by_product[request.product]
327
+ price_key = SELLER_PRICE_COLUMN[request.seller]
328
+ etd_key = SELLER_ETD_COLUMN[request.seller]
329
+ quoted_eta = int(market_row[etd_key])
330
+ actual_eta = self._sample_actual_eta(request.seller, quoted_eta)
331
+ prepared_orders.append(
332
+ {
333
+ "order_id": f"ORD-{next_order_id + index:04d}",
334
+ "product_name": request.product,
335
+ "quantity": request.quantity,
336
+ "seller_name": request.seller,
337
+ "buy_price_inr": float(market_row[price_key]),
338
+ "days_remaining": actual_eta,
339
+ "status": "pending",
340
+ "created_day": int(runtime["store"]["day"]),
341
+ }
342
+ )
343
+
344
+ prepared_actions = [
345
+ deepcopy(marketing_by_id[action_id]) for action_id in action.action_ids
346
+ ]
347
+ return {
348
+ "price_updates": {
349
+ update.product: float(update.price) for update in action.prices or []
350
+ },
351
+ "new_orders": prepared_orders,
352
+ "new_action_ids": list(action.action_ids),
353
+ "selected_actions": prepared_actions,
354
+ "customer_satisfaction_delta": sum(
355
+ row["customer_satisfaction_delta"] for row in prepared_actions
356
+ ),
357
+ }
358
+
359
+ def _apply_bundle(self, runtime: dict[str, Any], bundle: dict[str, Any]) -> None:
360
+ stock_by_product = {
361
+ row["product_name"]: row for row in runtime["stock"]
362
+ }
363
+
364
+ for product, price in bundle["price_updates"].items():
365
+ stock_by_product[product]["selling_price_inr"] = price
366
+ self._refresh_stock_value(stock_by_product[product])
367
+
368
+ for marketing_action in bundle["selected_actions"]:
369
+ runtime["store"]["bank_balance_inr"] -= marketing_action["cost_inr"]
370
+ runtime["store"]["cumulative_profit_inr"] -= marketing_action["cost_inr"]
371
+ runtime["store"]["order_processing_capacity"] += marketing_action[
372
+ "order_processing_capacity_delta"
373
+ ]
374
+ runtime["active_action_ids"].add(marketing_action["action_id"])
375
+
376
+ for order in bundle["new_orders"]:
377
+ runtime["store"]["bank_balance_inr"] -= order["buy_price_inr"] * order["quantity"]
378
+ runtime["pending_orders"].append(deepcopy(order))
379
+
380
+ runtime["next_order_id"] += len(bundle["new_orders"])
381
+
382
+ def _simulate_days(self, runtime: dict[str, Any], days: int) -> dict[str, float]:
383
+ recurring_costs_paid = 0.0
384
+ total_revenue = 0.0
385
+ total_cogs = 0.0
386
+ total_units_sold = 0.0
387
+
388
+ for _ in range(days):
389
+ recurring_cost = self._active_recurring_cost(runtime)
390
+ revenue, cogs, units_sold = self._run_daily_sales(runtime)
391
+ daily_expense = runtime["store"]["current_daily_expense_inr"] + recurring_cost
392
+
393
+ runtime["store"]["bank_balance_inr"] += revenue - daily_expense
394
+ runtime["store"]["cumulative_profit_inr"] += revenue - cogs - daily_expense
395
+
396
+ recurring_costs_paid += recurring_cost
397
+ total_revenue += revenue
398
+ total_cogs += cogs
399
+ total_units_sold += units_sold
400
+
401
+ self._advance_pending_orders(runtime)
402
+ runtime["store"]["day"] += 1
403
+
404
+ return {
405
+ "days_simulated": float(days),
406
+ "revenue_inr": total_revenue,
407
+ "cogs_inr": total_cogs,
408
+ "units_sold": total_units_sold,
409
+ "recurring_costs_paid_inr": recurring_costs_paid,
410
+ }
411
+
412
+ def _run_daily_sales(self, runtime: dict[str, Any]) -> tuple[float, float, float]:
413
+ effective_daily_orders = runtime["store"]["order_processing_capacity"] * (
414
+ runtime["store"]["customer_satisfaction"] / 100.0
415
+ )
416
+ if effective_daily_orders <= 0:
417
+ return 0.0, 0.0, 0.0
418
+
419
+ sale_candidates: list[tuple[dict[str, Any], float]] = []
420
+ total_score = 0.0
421
+ for row in runtime["stock"]:
422
+ if row["quantity_on_hand"] <= 0:
423
+ continue
424
+ score = self._product_selling_score(row)
425
+ if score <= 0:
426
+ continue
427
+ sale_candidates.append((row, score))
428
+ total_score += score
429
+
430
+ if total_score <= 0:
431
+ return 0.0, 0.0, 0.0
432
+
433
+ revenue = 0.0
434
+ cogs = 0.0
435
+ units_sold = 0.0
436
+ for row, score in sale_candidates:
437
+ desired_sales = effective_daily_orders * score / total_score
438
+ actual_sales = min(row["quantity_on_hand"], desired_sales)
439
+ if actual_sales <= 0:
440
+ continue
441
+ row["quantity_on_hand"] -= actual_sales
442
+ revenue += actual_sales * row["selling_price_inr"]
443
+ cogs += actual_sales * row["buying_price_inr"]
444
+ units_sold += actual_sales
445
+ self._refresh_stock_value(row)
446
+
447
+ return revenue, cogs, units_sold
448
+
449
+ def _advance_pending_orders(self, runtime: dict[str, Any]) -> None:
450
+ remaining_orders = []
451
+ for order in runtime["pending_orders"]:
452
+ order["days_remaining"] -= 1
453
+ if order["days_remaining"] <= 0:
454
+ self._receive_order(runtime, order)
455
+ else:
456
+ remaining_orders.append(order)
457
+ runtime["pending_orders"] = remaining_orders
458
+
459
+ def _receive_order(self, runtime: dict[str, Any], order: dict[str, Any]) -> None:
460
+ stock_by_product = {
461
+ row["product_name"]: row for row in runtime["stock"]
462
+ }
463
+ market_by_product = {
464
+ row["product_name"]: row for row in runtime["market_offers"]
465
+ }
466
+
467
+ if order["product_name"] in stock_by_product:
468
+ stock_row = stock_by_product[order["product_name"]]
469
+ existing_quantity = stock_row["quantity_on_hand"]
470
+ new_quantity = existing_quantity + order["quantity"]
471
+ if new_quantity <= 0:
472
+ return
473
+ stock_row["buying_price_inr"] = (
474
+ (existing_quantity * stock_row["buying_price_inr"])
475
+ + (order["quantity"] * order["buy_price_inr"])
476
+ ) / new_quantity
477
+ stock_row["quantity_on_hand"] = new_quantity
478
+ self._refresh_stock_value(stock_row)
479
+ return
480
+
481
+ market_row = market_by_product[order["product_name"]]
482
+ buying_price = order["buy_price_inr"]
483
+ runtime["stock"].append(
484
+ {
485
+ "product_name": order["product_name"],
486
+ "unit": market_row["unit"],
487
+ "buying_price_inr": buying_price,
488
+ "selling_price_inr": max(
489
+ buying_price + 1,
490
+ math.ceil(buying_price * NEW_PRODUCT_MARKUP),
491
+ ),
492
+ "demand_score": market_row["demand_score"],
493
+ "quantity_on_hand": float(order["quantity"]),
494
+ "stock_value_inr": buying_price * order["quantity"],
495
+ }
496
+ )
497
+
498
+ def _product_selling_score(self, stock_row: dict[str, Any]) -> float:
499
+ selling_price = stock_row["selling_price_inr"]
500
+ if selling_price <= 0:
501
+ return 0.0
502
+ margin_percent = (
503
+ (selling_price - stock_row["buying_price_inr"]) / selling_price
504
+ ) * 100.0
505
+ return max(0.0, stock_row["demand_score"] - (margin_percent * 3.0))
506
+
507
+ def _total_product_selling_score(self, stock_rows: list[dict[str, Any]]) -> float:
508
+ return sum(
509
+ self._product_selling_score(row)
510
+ for row in stock_rows
511
+ if row["quantity_on_hand"] > 0
512
+ )
513
+
514
+ def _updated_customer_satisfaction(
515
+ self,
516
+ base_satisfaction: float,
517
+ before_score: float,
518
+ after_score: float,
519
+ ) -> float:
520
+ if after_score <= 0:
521
+ updated = base_satisfaction
522
+ else:
523
+ updated = (1.0 + ((after_score - before_score) / after_score)) * base_satisfaction
524
+ return min(100.0, max(1.0, updated))
525
+
526
+ def _active_recurring_cost(self, runtime: dict[str, Any]) -> float:
527
+ marketing_by_id = {
528
+ row["action_id"]: row for row in runtime["marketing_actions_catalog"]
529
+ }
530
+ return float(
531
+ sum(
532
+ marketing_by_id[action_id]["recurring_cost_inr"]
533
+ for action_id in runtime["active_action_ids"]
534
+ )
535
+ )
536
+
537
+ def _sample_actual_eta(self, seller_name: str, quoted_eta: int) -> int:
538
+ if seller_name == "Seller A":
539
+ offset = self._rng.choices([0, 1, 2], weights=[2, 4, 5], k=1)[0]
540
+ elif seller_name == "Seller B":
541
+ offset = 0
542
+ elif seller_name == "Seller C":
543
+ offset = self._rng.choices([-2, -1, 0], weights=[5, 4, 2], k=1)[0]
544
+ else:
545
+ offset = self._rng.choices([-1, 0, 1, 2], weights=[2, 4, 3, 2], k=1)[0]
546
+ return max(1, quoted_eta + offset)
547
+
548
+ def _capital(self, runtime: dict[str, Any]) -> float:
549
+ return runtime["store"]["bank_balance_inr"] + self._inventory_value(runtime["stock"])
550
+
551
+ def _inventory_value(self, stock_rows: list[dict[str, Any]]) -> float:
552
+ return sum(row["stock_value_inr"] for row in stock_rows)
553
+
554
+ def _refresh_stock_value(self, stock_row: dict[str, Any]) -> None:
555
+ stock_row["stock_value_inr"] = stock_row["buying_price_inr"] * stock_row["quantity_on_hand"]
556
+
557
+ def _build_observation(
558
+ self,
559
+ runtime: dict[str, Any],
560
+ *,
561
+ reward: float,
562
+ done: bool,
563
+ summary: str,
564
+ metadata: dict[str, Any],
565
+ ) -> StoreObservation:
566
+ active_action_ids = sorted(runtime["active_action_ids"])
567
+ marketing_actions = [
568
+ MarketingAction(
569
+ **row,
570
+ is_active=row["action_id"] in runtime["active_action_ids"],
571
+ )
572
+ for row in runtime["marketing_actions_catalog"]
573
+ ]
574
+
575
+ return StoreObservation(
576
+ store=StoreSnapshot(
577
+ day=int(runtime["store"]["day"]),
578
+ current_daily_expense_inr=self._round(runtime["store"]["current_daily_expense_inr"]),
579
+ bank_balance_inr=self._round(runtime["store"]["bank_balance_inr"]),
580
+ customer_satisfaction=self._round(runtime["store"]["customer_satisfaction"]),
581
+ order_processing_capacity=self._round(runtime["store"]["order_processing_capacity"]),
582
+ cumulative_profit_inr=self._round(runtime["store"]["cumulative_profit_inr"]),
583
+ active_action_ids=active_action_ids,
584
+ active_recurring_cost_inr=self._round(self._active_recurring_cost(runtime)),
585
+ episode_horizon_days=self.EPISODE_HORIZON_DAYS,
586
+ commit_window_days=self.COMMIT_WINDOW_DAYS,
587
+ reward_horizon_days=self.REWARD_HORIZON_DAYS,
588
+ ),
589
+ stock=[
590
+ StockItem(
591
+ product_name=row["product_name"],
592
+ unit=row["unit"],
593
+ buying_price_inr=self._round(row["buying_price_inr"]),
594
+ selling_price_inr=self._round(row["selling_price_inr"]),
595
+ demand_score=self._round(row["demand_score"]),
596
+ quantity_on_hand=self._round(row["quantity_on_hand"]),
597
+ stock_value_inr=self._round(row["stock_value_inr"]),
598
+ )
599
+ for row in runtime["stock"]
600
+ ],
601
+ pending_orders=[
602
+ PendingOrder(
603
+ order_id=row["order_id"],
604
+ product_name=row["product_name"],
605
+ quantity=int(row["quantity"]),
606
+ seller_name=row["seller_name"],
607
+ buy_price_inr=self._round(row["buy_price_inr"]),
608
+ days_remaining=int(row["days_remaining"]),
609
+ status=row["status"],
610
+ created_day=int(row["created_day"]),
611
+ )
612
+ for row in runtime["pending_orders"]
613
+ ],
614
+ market=[MarketOffer(**row) for row in runtime["market_offers"]],
615
+ seller_profiles=[SellerProfile(**row) for row in runtime["seller_profiles"]],
616
+ marketing_actions=marketing_actions,
617
+ available_action=self._available_action_spec(),
618
+ summary=summary,
619
+ reward=reward,
620
+ done=done,
621
+ metadata=metadata,
622
+ )
623
+
624
+ def _available_action_spec(self) -> AvailableActionSpec:
625
+ return AvailableActionSpec(
626
+ name="action",
627
+ description=(
628
+ "Submit one bundled action for the next 3 committed days. The same "
629
+ "bundle is used to forecast reward across 6 simulated days."
630
+ ),
631
+ pass_action=StoreAction(),
632
+ example_action=StoreAction(
633
+ orders=[
634
+ OrderRequest(product="Rice", seller="Seller A", quantity=25),
635
+ OrderRequest(product="Bread", seller="Seller C", quantity=12),
636
+ ],
637
+ prices=[PriceUpdate(product="Milk", price=33)],
638
+ action_ids=[7],
639
+ ),
640
+ notes=[
641
+ "Use an empty action to pass.",
642
+ "Orders can target any product in the market catalog.",
643
+ "Price changes only apply to products currently in stock.",
644
+ "Marketing actions are durable and cannot be activated twice.",
645
+ ],
646
+ )
647
+
648
+ def _rounded_metrics(self, metrics: dict[str, float]) -> dict[str, float]:
649
+ return {
650
+ key: self._round(value) if key != "days_simulated" else float(value)
651
+ for key, value in metrics.items()
652
+ }
653
+
654
+ def _round(self, value: float, *, digits: int = 2) -> float:
655
+ return round(float(value), digits)
store/.DS_Store ADDED
Binary file (6.15 kB). View file
 
tests/test_gradio_app.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from store import StoreAction
2
+ from store.gradio_app import _render_metrics_html, make_demo
3
+ from store.server.store_environment import StoreEnvironment
4
+
5
+
6
+ def test_make_demo_builds_blocks() -> None:
7
+ demo = make_demo()
8
+ try:
9
+ assert demo is not None
10
+ finally:
11
+ demo.close()
12
+
13
+
14
+ def test_metrics_html_shows_recurring_and_total_daily_burn() -> None:
15
+ env = StoreEnvironment()
16
+ env.reset()
17
+
18
+ observation = env.step(StoreAction(action_ids=[7]))
19
+ html = _render_metrics_html(observation)
20
+
21
+ assert "Recurring / Day" in html
22
+ assert "Total Burn / Day" in html
23
+ assert "₹600" in html
24
+ assert "₹3,600" in html
tests/test_seed_csvs.py ADDED
@@ -0,0 +1,378 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import csv
2
+ import math
3
+ from pathlib import Path
4
+
5
+
6
+ DATA_DIR = Path(__file__).resolve().parents[1] / "data"
7
+ PLAN_PATH = Path(__file__).resolve().parents[2] / "plan2.md"
8
+ EXPECTED_PRODUCTS = [
9
+ "Rice",
10
+ "Wheat Flour",
11
+ "Other Flours",
12
+ "Dals",
13
+ "Pulses",
14
+ "Salt",
15
+ "Sugar",
16
+ "Jaggery",
17
+ "Cooking Oil",
18
+ "Ghee",
19
+ "Spices",
20
+ "Masalas",
21
+ "Dry Fruits",
22
+ "Nuts",
23
+ "Seeds",
24
+ "Poha",
25
+ "Suji / Rava",
26
+ "Besan",
27
+ "Maida",
28
+ "Breakfast Cereals",
29
+ "Oats",
30
+ "Pasta",
31
+ "Noodles",
32
+ "Vermicelli",
33
+ "Ready-to-Cook Mixes",
34
+ "Ready-to-Eat Foods",
35
+ "Biscuits",
36
+ "Cookies",
37
+ "Rusk",
38
+ "Snacks",
39
+ "Namkeen",
40
+ "Chips",
41
+ "Sweets",
42
+ "Chocolates",
43
+ "Candies",
44
+ "Bakery Products",
45
+ "Bread",
46
+ "Cakes",
47
+ "Jams & Spreads",
48
+ "Peanut Butter",
49
+ "Honey",
50
+ "Pickles",
51
+ "Sauces",
52
+ "Ketchup",
53
+ "Chutneys",
54
+ "Vinegar",
55
+ "Tea",
56
+ "Coffee",
57
+ "Health Drinks",
58
+ "Soft Drinks",
59
+ "Packaged Juices",
60
+ "Energy Drinks",
61
+ "Bottled Water",
62
+ "Dairy Products",
63
+ "Milk",
64
+ "Curd",
65
+ "Butter",
66
+ "Paneer",
67
+ "Cheese",
68
+ "Eggs",
69
+ "Frozen Foods",
70
+ "Frozen Snacks",
71
+ "Frozen Vegetables",
72
+ "Fruits",
73
+ "Vegetables",
74
+ "Leafy Vegetables",
75
+ "Exotic Fruits",
76
+ "Exotic Vegetables",
77
+ "Cut Fruits & Vegetables",
78
+ "Sprouts",
79
+ "Detergents",
80
+ "Fabric Care",
81
+ "Dishwash Products",
82
+ "Floor Cleaners",
83
+ "Toilet Cleaners",
84
+ "Bathroom Cleaners",
85
+ "Disinfectants",
86
+ "Utensil Cleaners",
87
+ "Garbage Bags",
88
+ "Tissues & Napkins",
89
+ "Air Fresheners",
90
+ "Bath Soap",
91
+ "Handwash",
92
+ "Shampoo",
93
+ "Hair Oil",
94
+ "Toothpaste",
95
+ "Toothbrushes",
96
+ "Oral Care Products",
97
+ "Skincare Products",
98
+ "Personal Hygiene Products",
99
+ "Sanitary Products",
100
+ "Baby Care Products",
101
+ "Diapers & Wipes",
102
+ "Stationery",
103
+ "Notebooks",
104
+ "Pens & Pencils",
105
+ "Kitchen Tools",
106
+ "Storage Containers",
107
+ "Kitchen Appliances",
108
+ "Household Utility Items",
109
+ ]
110
+
111
+
112
+ def read_csv(name: str) -> list[dict[str, str]]:
113
+ with (DATA_DIR / name).open(newline="", encoding="utf-8") as handle:
114
+ return list(csv.DictReader(handle))
115
+
116
+
117
+ def header(name: str) -> list[str]:
118
+ with (DATA_DIR / name).open(newline="", encoding="utf-8") as handle:
119
+ return next(csv.reader(handle))
120
+
121
+
122
+ def test_required_files_exist() -> None:
123
+ expected_files = {
124
+ "store_state.csv",
125
+ "stock.csv",
126
+ "pending_orders.csv",
127
+ "seller_profiles.csv",
128
+ "market_offers.csv",
129
+ "marketing_actions.csv",
130
+ }
131
+ assert expected_files == {path.name for path in DATA_DIR.glob("*.csv")}
132
+
133
+
134
+ def test_headers_match_contract() -> None:
135
+ assert header("store_state.csv") == [
136
+ "day",
137
+ "current_daily_expense_inr",
138
+ "bank_balance_inr",
139
+ "customer_satisfaction",
140
+ "order_processing_capacity",
141
+ "cumulative_profit_inr",
142
+ ]
143
+ assert header("stock.csv") == [
144
+ "product_name",
145
+ "unit",
146
+ "buying_price_inr",
147
+ "selling_price_inr",
148
+ "demand_score",
149
+ "quantity_on_hand",
150
+ "stock_value_inr",
151
+ ]
152
+ assert header("pending_orders.csv") == [
153
+ "order_id",
154
+ "product_name",
155
+ "quantity",
156
+ "seller_name",
157
+ "buy_price_inr",
158
+ "days_remaining",
159
+ "status",
160
+ "created_day",
161
+ ]
162
+ assert header("seller_profiles.csv") == [
163
+ "seller_name",
164
+ "timeline_behavior",
165
+ "reliability_note",
166
+ "default_etd_days",
167
+ ]
168
+ assert header("market_offers.csv") == [
169
+ "product_name",
170
+ "category",
171
+ "unit",
172
+ "demand_score",
173
+ "seller_a_price_inr",
174
+ "seller_a_etd_days",
175
+ "seller_b_price_inr",
176
+ "seller_b_etd_days",
177
+ "seller_c_price_inr",
178
+ "seller_c_etd_days",
179
+ "seller_d_price_inr",
180
+ "seller_d_etd_days",
181
+ ]
182
+ assert header("marketing_actions.csv") == [
183
+ "action_id",
184
+ "description",
185
+ "customer_satisfaction_delta",
186
+ "cost_inr",
187
+ "recurring_cost_inr",
188
+ "order_processing_capacity_delta",
189
+ ]
190
+
191
+
192
+ def test_stock_has_30_rows_and_consistent_values() -> None:
193
+ stock_rows = read_csv("stock.csv")
194
+ assert len(stock_rows) == 30
195
+
196
+ for row in stock_rows:
197
+ buying_price = int(row["buying_price_inr"])
198
+ quantity = int(row["quantity_on_hand"])
199
+ stock_value = int(row["stock_value_inr"])
200
+ assert stock_value == buying_price * quantity
201
+
202
+ total_value = sum(int(r["stock_value_inr"]) for r in stock_rows)
203
+ assert 220_000 <= total_value <= 260_000
204
+
205
+
206
+ def test_store_state_matches_realistic_opening_values() -> None:
207
+ rows = read_csv("store_state.csv")
208
+ assert rows == [
209
+ {
210
+ "day": "1",
211
+ "current_daily_expense_inr": "3000",
212
+ "bank_balance_inr": "120000",
213
+ "customer_satisfaction": "70",
214
+ "order_processing_capacity": "500",
215
+ "cumulative_profit_inr": "0",
216
+ }
217
+ ]
218
+
219
+
220
+ def test_perishable_stock_levels_fit_kirana_scale() -> None:
221
+ stock = {row["product_name"]: row for row in read_csv("stock.csv")}
222
+
223
+ assert int(stock["Milk"]["quantity_on_hand"]) <= 120
224
+ assert int(stock["Bread"]["quantity_on_hand"]) <= 50
225
+ assert int(stock["Eggs"]["quantity_on_hand"]) <= 40
226
+ assert int(stock["Vegetables"]["quantity_on_hand"]) <= 80
227
+ assert int(stock["Fruits"]["quantity_on_hand"]) <= 60
228
+ assert int(stock["Bottled Water"]["quantity_on_hand"]) <= 96
229
+ assert int(stock["Biscuits"]["quantity_on_hand"]) <= 80
230
+
231
+
232
+ def test_stocked_products_exist_in_market_offers() -> None:
233
+ market_products = {row["product_name"] for row in read_csv("market_offers.csv")}
234
+ for row in read_csv("stock.csv"):
235
+ assert row["product_name"] in market_products
236
+
237
+
238
+ def test_pending_orders_starts_empty() -> None:
239
+ assert read_csv("pending_orders.csv") == []
240
+
241
+
242
+ def test_market_offers_contain_all_expected_products() -> None:
243
+ market_rows = read_csv("market_offers.csv")
244
+ product_names = [row["product_name"] for row in market_rows]
245
+
246
+ assert product_names == EXPECTED_PRODUCTS
247
+ assert len(set(product_names)) == len(EXPECTED_PRODUCTS)
248
+
249
+ for row in market_rows:
250
+ assert row["category"] != ""
251
+ assert row["unit"] != ""
252
+ for column in (
253
+ "seller_a_price_inr",
254
+ "seller_a_etd_days",
255
+ "seller_b_price_inr",
256
+ "seller_b_etd_days",
257
+ "seller_c_price_inr",
258
+ "seller_c_etd_days",
259
+ "seller_d_price_inr",
260
+ "seller_d_etd_days",
261
+ ):
262
+ assert row[column] != ""
263
+
264
+
265
+ def test_market_offers_have_varied_seller_patterns() -> None:
266
+ market_rows = read_csv("market_offers.csv")
267
+
268
+ etd_patterns = {
269
+ (
270
+ row["seller_a_etd_days"],
271
+ row["seller_b_etd_days"],
272
+ row["seller_c_etd_days"],
273
+ row["seller_d_etd_days"],
274
+ )
275
+ for row in market_rows
276
+ }
277
+ assert len(etd_patterns) >= 8
278
+
279
+ c_beats_b = sum(
280
+ int(row["seller_c_price_inr"]) < int(row["seller_b_price_inr"])
281
+ for row in market_rows
282
+ )
283
+ d_beats_b = sum(
284
+ int(row["seller_d_price_inr"]) < int(row["seller_b_price_inr"])
285
+ for row in market_rows
286
+ )
287
+ a_not_cheapest = sum(
288
+ int(row["seller_a_price_inr"])
289
+ > min(
290
+ int(row["seller_a_price_inr"]),
291
+ int(row["seller_b_price_inr"]),
292
+ int(row["seller_c_price_inr"]),
293
+ int(row["seller_d_price_inr"]),
294
+ )
295
+ for row in market_rows
296
+ )
297
+
298
+ assert c_beats_b >= 25
299
+ assert d_beats_b >= 25
300
+ assert a_not_cheapest >= 10
301
+
302
+ bc_deltas = {int(row["seller_c_price_inr"]) - int(row["seller_b_price_inr"]) for row in market_rows}
303
+ bd_deltas = {int(row["seller_d_price_inr"]) - int(row["seller_b_price_inr"]) for row in market_rows}
304
+ assert len(bc_deltas) >= 12
305
+ assert len(bd_deltas) >= 12
306
+
307
+
308
+ def test_marketing_actions_have_15_unique_ids() -> None:
309
+ rows = read_csv("marketing_actions.csv")
310
+ action_ids = [row["action_id"] for row in rows]
311
+ assert len(rows) == 15
312
+ assert len(set(action_ids)) == 15
313
+
314
+
315
+ def test_marketing_actions_match_realistic_catalog() -> None:
316
+ rows = {row["action_id"]: row for row in read_csv("marketing_actions.csv")}
317
+
318
+ assert rows["7"] == {
319
+ "action_id": "7",
320
+ "description": "Local delivery partner tie-up",
321
+ "customer_satisfaction_delta": "5",
322
+ "cost_inr": "5000",
323
+ "recurring_cost_inr": "600",
324
+ "order_processing_capacity_delta": "40",
325
+ }
326
+ assert rows["11"] == {
327
+ "action_id": "11",
328
+ "description": "Open second branch (deposit + fit-out)",
329
+ "customer_satisfaction_delta": "12",
330
+ "cost_inr": "750000",
331
+ "recurring_cost_inr": "3000",
332
+ "order_processing_capacity_delta": "350",
333
+ }
334
+ assert rows["15"] == {
335
+ "action_id": "15",
336
+ "description": "Always-on targeted social ads",
337
+ "customer_satisfaction_delta": "3",
338
+ "cost_inr": "4000",
339
+ "recurring_cost_inr": "1200",
340
+ "order_processing_capacity_delta": "0",
341
+ }
342
+
343
+
344
+ def test_doc_scenarios_match_seed_data() -> None:
345
+ plan_text = PLAN_PATH.read_text(encoding="utf-8")
346
+ stock = {row["product_name"]: row for row in read_csv("stock.csv")}
347
+ market = {row["product_name"]: row for row in read_csv("market_offers.csv")}
348
+
349
+ for phrase in (
350
+ "Rice at `138` vs Seller B at `160`",
351
+ "Cooking Oil at `156` vs Seller B at `180`",
352
+ "Spices at `345` vs Seller B at `400`",
353
+ "Bread at `31` vs Seller B at `35`",
354
+ "Eggs at `64` vs Seller B at `72`",
355
+ "Curd at `33` vs Seller B at `35`",
356
+ "Vegetables at `38` vs Seller B at `40`",
357
+ "Always-on targeted social ads",
358
+ "Kitchen Appliances",
359
+ "Biscuits | 40",
360
+ "Bottled Water | 48",
361
+ ):
362
+ assert phrase in plan_text
363
+
364
+ assert int(stock["Milk"]["selling_price_inr"]) <= math.ceil(int(stock["Milk"]["buying_price_inr"]) * 1.05)
365
+ assert int(stock["Bread"]["selling_price_inr"]) <= math.ceil(int(stock["Bread"]["buying_price_inr"]) * 1.05)
366
+ assert int(stock["Eggs"]["selling_price_inr"]) <= math.ceil(int(stock["Eggs"]["buying_price_inr"]) * 1.05)
367
+ assert int(stock["Sugar"]["selling_price_inr"]) <= math.ceil(int(stock["Sugar"]["buying_price_inr"]) * 1.06)
368
+ assert int(stock["Biscuits"]["quantity_on_hand"]) == 40
369
+ assert int(stock["Bottled Water"]["quantity_on_hand"]) == 48
370
+ assert int(stock["Kitchen Appliances"]["quantity_on_hand"]) == 4
371
+
372
+ assert int(market["Rice"]["seller_a_price_inr"]) == 138
373
+ assert int(market["Cooking Oil"]["seller_a_price_inr"]) == 156
374
+ assert int(market["Spices"]["seller_a_price_inr"]) == 345
375
+ assert int(market["Bread"]["seller_c_price_inr"]) == 31
376
+ assert int(market["Eggs"]["seller_c_price_inr"]) == 64
377
+ assert int(market["Curd"]["seller_c_price_inr"]) == 33
378
+ assert int(market["Vegetables"]["seller_c_price_inr"]) == 38
tests/test_store_environment.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+
3
+ import pytest
4
+
5
+ from store import OrderRequest, PriceUpdate, StoreAction, StoreEnv
6
+ from store.models import dump_model
7
+ from store.server.store_environment import StoreEnvironment
8
+
9
+
10
+ def test_reset_returns_full_plan_like_observation() -> None:
11
+ env = StoreEnvironment()
12
+
13
+ observation = env.reset()
14
+
15
+ assert observation.store.day == 1
16
+ assert len(observation.stock) == 30
17
+ assert len(observation.market) == 100
18
+ assert len(observation.marketing_actions) == 15
19
+ assert len(observation.seller_profiles) == 4
20
+ assert observation.pending_orders == []
21
+ assert observation.available_action.pass_action == StoreAction()
22
+ assert observation.available_action.example_action.action_ids == [7]
23
+
24
+
25
+ def test_pass_step_advances_three_days_but_reward_uses_six_day_forecast() -> None:
26
+ env = StoreEnvironment()
27
+ env.reset()
28
+
29
+ observation = env.step(StoreAction())
30
+
31
+ assert observation.store.day == 4
32
+ assert observation.metadata["reward_breakdown"]["commit_window_days"] == 3
33
+ assert observation.metadata["reward_breakdown"]["reward_horizon_days"] == 6
34
+
35
+
36
+ def test_eta_sampling_is_deterministic_for_a_seed() -> None:
37
+ env_a = StoreEnvironment()
38
+ env_b = StoreEnvironment()
39
+ env_a.reset()
40
+ env_b.reset()
41
+
42
+ env_a._rng = random.Random(7)
43
+ env_b._rng = random.Random(7)
44
+
45
+ assert env_a._sample_actual_eta("Seller C", 5) == env_b._sample_actual_eta("Seller C", 5)
46
+
47
+
48
+ def test_commit_state_only_keeps_three_days_of_progress() -> None:
49
+ env = StoreEnvironment()
50
+ env.reset()
51
+ env._sample_actual_eta = lambda seller, quoted: 4 # type: ignore[method-assign]
52
+
53
+ observation = env.step(
54
+ StoreAction(
55
+ orders=[OrderRequest(product="Rice", seller="Seller B", quantity=10)],
56
+ )
57
+ )
58
+
59
+ assert observation.store.day == 4
60
+ assert len(observation.pending_orders) == 1
61
+ assert observation.pending_orders[0].days_remaining == 1
62
+
63
+
64
+ def test_price_update_changes_customer_satisfaction() -> None:
65
+ env = StoreEnvironment()
66
+ reset_observation = env.reset()
67
+ milk_row = next(row for row in reset_observation.stock if row.product_name == "Milk")
68
+
69
+ observation = env.step(
70
+ StoreAction(
71
+ prices=[
72
+ PriceUpdate(
73
+ product="Milk",
74
+ price=max(1, int(milk_row.selling_price_inr) - 5),
75
+ )
76
+ ]
77
+ )
78
+ )
79
+
80
+ assert observation.store.customer_satisfaction > reset_observation.store.customer_satisfaction
81
+
82
+
83
+ def test_marketing_actions_are_durable_and_cannot_be_reactivated() -> None:
84
+ env = StoreEnvironment()
85
+ env.reset()
86
+
87
+ observation = env.step(StoreAction(action_ids=[7]))
88
+
89
+ assert observation.store.active_action_ids == [7]
90
+ assert observation.store.active_recurring_cost_inr > 0
91
+ assert any(
92
+ action.action_id == 7 and action.is_active for action in observation.marketing_actions
93
+ )
94
+
95
+ with pytest.raises(ValueError):
96
+ env.step(StoreAction(action_ids=[7]))
97
+
98
+
99
+ def test_recurring_costs_are_deducted_each_simulated_day() -> None:
100
+ env = StoreEnvironment()
101
+ env.reset()
102
+ runtime = env._build_initial_runtime()
103
+ runtime["store"]["bank_balance_inr"] = 10_000.0
104
+ runtime["store"]["cumulative_profit_inr"] = 0.0
105
+ runtime["store"]["current_daily_expense_inr"] = 0.0
106
+ runtime["store"]["customer_satisfaction"] = 0.0
107
+ runtime["active_action_ids"] = {7}
108
+
109
+ metrics = env._simulate_days(runtime, 3)
110
+
111
+ assert metrics["recurring_costs_paid_inr"] == 1_800.0
112
+ assert runtime["store"]["bank_balance_inr"] == 8_200.0
113
+ assert runtime["store"]["cumulative_profit_inr"] == -1_800.0
114
+
115
+
116
+ def test_large_capital_loss_reduces_reward_and_ends_episode() -> None:
117
+ baseline = StoreEnvironment()
118
+ baseline.reset()
119
+ baseline_reward = baseline.step(StoreAction()).reward
120
+
121
+ env = StoreEnvironment()
122
+ env.reset()
123
+ observation = env.step(StoreAction(action_ids=[11]))
124
+
125
+ assert observation.reward < baseline_reward
126
+ assert observation.done is True
127
+ assert observation.metadata["done_reason"] == "bank_balance_below_zero"
128
+
129
+
130
+ def test_episode_ends_after_tenth_decision_window() -> None:
131
+ env = StoreEnvironment()
132
+ env.reset()
133
+
134
+ observation = None
135
+ for _ in range(9):
136
+ observation = env.step(StoreAction())
137
+ assert observation.done is False
138
+
139
+ observation = env.step(StoreAction())
140
+
141
+ assert observation is not None
142
+ assert observation.done is True
143
+ assert observation.store.day == 31
144
+ assert observation.metadata["done_reason"] == "episode_horizon_reached"
145
+
146
+
147
+ def test_client_payload_and_result_parsing_support_nested_models() -> None:
148
+ client = StoreEnv(base_url="http://localhost:8000")
149
+ action = StoreAction(
150
+ orders=[OrderRequest(product="Rice", seller="Seller A", quantity=5)],
151
+ prices=[PriceUpdate(product="Milk", price=33)],
152
+ action_ids=[7],
153
+ )
154
+
155
+ payload = client._step_payload(action)
156
+
157
+ assert payload == {
158
+ "orders": [{"product": "Rice", "seller": "Seller A", "quantity": 5}],
159
+ "prices": [{"product": "Milk", "price": 33}],
160
+ "action_ids": [7],
161
+ }
162
+
163
+ env = StoreEnvironment()
164
+ observation = env.reset()
165
+ result = client._parse_result(
166
+ {
167
+ "observation": dump_model(observation, exclude_none=True),
168
+ "reward": 0.0,
169
+ "done": False,
170
+ }
171
+ )
172
+
173
+ assert result.observation.store.day == 1
174
+ assert len(result.observation.market) == 100
uv.lock ADDED
The diff for this file is too large to render. See raw diff