Spaces:
Sleeping
Sleeping
Commit ·
b7a5bab
1
Parent(s): 21a8f00
Store
Browse files- .DS_Store +0 -0
- Dockerfile +82 -0
- README.md +741 -0
- __init__.py +31 -0
- _compat.py +62 -0
- client.py +53 -0
- data/market_offers.csv +101 -0
- data/marketing_actions.csv +16 -0
- data/pending_orders.csv +1 -0
- data/seller_profiles.csv +5 -0
- data/stock.csv +31 -0
- data/store_state.csv +2 -0
- gradio_app.py +629 -0
- models.py +154 -0
- openenv.yaml +7 -0
- pyproject.toml +51 -0
- scripts/generate_stock.py +134 -0
- server/__init__.py +11 -0
- server/app.py +51 -0
- server/requirements.txt +6 -0
- server/store_environment.py +655 -0
- store/.DS_Store +0 -0
- tests/test_gradio_app.py +24 -0
- tests/test_seed_csvs.py +378 -0
- tests/test_store_environment.py +174 -0
- uv.lock +0 -0
.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
|
|
|