diff --git a/.gitignore b/.gitignore
index c8f32108bd848ae948a1ea99189c62c952017198..9baeb38e3f93d0d7d831ff3f28373b5341acf696 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,34 @@
+# --- Python ---
venv/
+.venv/
+env/
.env
+.env.*
+*.py[cod]
__pycache__/
-*.pyc
-frontend/node_modules/
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+.coverage
+coverage.xml
+
+# --- Node / frontend ---
+node_modules/
+**/node_modules/
frontend/dist/
+**/dist/
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+# --- Build / temp ---
+*.log
+*.tmp
+*.swp
+.DS_Store
+
+# --- Project-specific nested paths ---
+openenv-polypharmacy/frontend/node_modules/
+openenv-polypharmacy/frontend/dist/
+openenv-polypharmacy/.pytest_cache/
diff --git a/openenv-polypharmacy/.dockerignore b/openenv-polypharmacy/.dockerignore
new file mode 100644
index 0000000000000000000000000000000000000000..5007867e3a3b2c5514c4ff5bb18588b36951901f
--- /dev/null
+++ b/openenv-polypharmacy/.dockerignore
@@ -0,0 +1,8 @@
+.git
+.gitignore
+**/__pycache__/
+**/.pytest_cache/
+**/.DS_Store
+.env
+frontend/node_modules
+frontend/dist
diff --git a/openenv-polypharmacy/Dockerfile b/openenv-polypharmacy/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..68b69d986a780501f6c9461410b2add26413473e
--- /dev/null
+++ b/openenv-polypharmacy/Dockerfile
@@ -0,0 +1,39 @@
+FROM node:20-alpine AS frontend-builder
+WORKDIR /app/frontend
+COPY frontend/package*.json ./
+RUN npm ci
+COPY frontend/ ./
+RUN npm run build
+
+FROM python:3.11-slim
+
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends build-essential curl && \
+ rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+COPY backend/requirements.txt /app/backend/requirements.txt
+RUN pip install --no-cache-dir -r /app/backend/requirements.txt
+
+COPY backend /app/backend
+COPY data /app/data
+COPY scripts /app/scripts
+COPY openenv.yaml /app/openenv.yaml
+COPY .env.example /app/.env.example
+COPY inference.py /app/inference.py
+
+COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist
+
+RUN python3 /app/scripts/preprocess_data.py
+
+ENV PORT=7860
+ENV PYTHONPATH="/app/backend/src:${PYTHONPATH}"
+ENV PYTHONUNBUFFERED=1
+
+EXPOSE 7860
+
+HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \
+ CMD curl -f http://localhost:7860/health || exit 1
+
+CMD ["sh", "-c", "uvicorn backend.main:app --host 0.0.0.0 --port ${PORT:-7860}"]
diff --git a/openenv-polypharmacy/PROMPT.md b/openenv-polypharmacy/PROMPT.md
new file mode 100644
index 0000000000000000000000000000000000000000..d7d5481b1ba6a83c8590ebcec024b911f5800982
--- /dev/null
+++ b/openenv-polypharmacy/PROMPT.md
@@ -0,0 +1,571 @@
+You are an expert Python backend, ML, and infrastructure engineer.
+Your task is to implement a complete, production-ready OpenEnv environment called **PolypharmacyEnv** for training and evaluating agentic RL policies that act as an "elderly polypharmacy safety agent" (clinical pharmacist assistant).
+
+The deliverable MUST satisfy all of the following:
+- Fully compliant with the OpenEnv spec (typed models, `step()` / `reset()` / `state()`, `openenv.yaml`, HTTP server, Dockerfile).
+- Simulates a realistic healthcare workflow around elderly polypharmacy and dangerous drug combinations.
+- Defines at least **3 tasks** (easy → medium → hard) with deterministic agent graders producing scores in (0.0, 1.0).
+- Provides shaped rewards over the trajectory (not just sparse terminal rewards).
+- Includes a baseline LLM-based inference script `inference.py` in the repo root, following the evaluation requirements:
+ - Uses the OpenAI Python client.
+ - Reads `OPENAI_API_KEY`, `API_BASE_URL`, `MODEL_NAME`, and `HF_TOKEN` from the environment.
+ - Emits structured stdout logs in the exact `[START]`, `[STEP]`, `[END]` format from the OpenEnv sample inference script.
+- Is containerized and deployable as a **Hugging Face Space** tagged with `openenv` that responds to OpenEnv-style `reset` / `step` / `state` HTTP calls.
+
+Implement everything described below.
+
+=================================================
+1. Repository and folder structure
+=================================================
+
+Create a Python package repository with this structure (names are important unless clearly labeled as examples):
+
+- `openenv-polypharmacy/`
+ - `openenv.yaml`
+ - `README.md`
+ - `requirements.txt`
+ - `Dockerfile`
+ - `inference.py` # baseline LLM agent per spec
+ - `pyproject.toml` or `setup.cfg` (optional but recommended)
+ - `src/`
+ - `polypharmacy_env/`
+ - `__init__.py`
+ - `config.py`
+ - `models.py` # Action, Observation, State, helper models
+ - `env_core.py` # PolypharmacyEnv implementation
+ - `tasks.py` # task setup utilities
+ - `graders.py` # deterministic graders for each task
+ - `rewards.py` # reward shaping logic
+ - `data_loader.py` # load/preprocess patient and lookup data
+ - `ddi_simulator.py` # local DDI / guideline simulator
+ - `api/`
+ - `__init__.py`
+ - `schemas.py` # HTTP request/response schemas
+ - `server.py` # FastAPI app exposing OpenEnv endpoints
+ - `baselines/`
+ - `__init__.py`
+ - `heuristic_agent.py` # simple rule-based baseline agent
+ - `random_agent.py` # trivial random baseline (optional)
+ - `tests/`
+ - `__init__.py`
+ - `test_env_core.py`
+ - `test_api.py`
+ - `data/`
+ - `raw/` # placeholder for real/synthetic source data
+ - `processed/`
+ - `lookups/`
+ - `ddi_rules.csv`
+ - `beers_criteria.csv`
+ - `drug_metadata.csv`
+ - `scripts/`
+ - `preprocess_data.py`
+ - `run_validation.sh` # optional; runs OpenEnv validator, tests, etc.
+
+Use Python 3.10+ with full type hints, and keep the code black/isort-compatible.
+
+=================================================
+2. Domain, data, and clinical abstraction
+=================================================
+
+2.1. Core scenario
+
+Model an elderly patient (age ≥ 65) with:
+- Demographics: age, sex.
+- Comorbidities: e.g., hypertension, diabetes, heart failure, CKD, dementia.
+- Basic labs: kidney function (eGFR category), liver function category.
+- A current medication list (polypharmacy, e.g., 3–15 drugs depending on task).
+
+Each **episode** is one medication-review session where the agent:
+- Observes patient info and current meds.
+- Optionally **queries** a DDI/guideline tool for specific drug pairs.
+- Proposes **interventions**:
+ - `stop`: discontinue a drug.
+ - `dose_reduce`: lower dose of a drug.
+ - `substitute`: swap to a safer alternative.
+ - `add_monitoring`: keep the drug but flag extra monitoring.
+- Calls `finish_review` when it decides the regimen is acceptable or budgets are exhausted.
+
+No external PHI, EHRs, or online APIs: all data is **synthetic** or de-identified and local to the container (CSV files).
+
+2.2. Data files and CSV schemas
+
+Implement local CSVs under `data/lookups/`:
+
+**`drug_metadata.csv`**
+- `drug_id` (string; unique key)
+- `generic_name` (string)
+- `atc_class` (string)
+- `is_high_risk_elderly` (0/1)
+- `default_dose_mg` (float)
+- `min_dose_mg` (float)
+- `max_dose_mg` (float)
+
+**`beers_criteria.csv`**
+- `drug_id` (string)
+- `criterion_type` (enum string: `avoid`, `caution`, `dose_adjust`, `avoid_in_condition`)
+- `condition` (nullable string; e.g., `CKD`, `dementia`)
+- `rationale` (brief text)
+
+**`ddi_rules.csv`**
+- `drug_id_1` (string; normalized so `drug_id_1 < drug_id_2` lexicographically)
+- `drug_id_2` (string)
+- `severity` (enum string: `mild`, `moderate`, `severe`)
+- `mechanism` (short text)
+- `recommendation` (enum string: `avoid_combination`, `monitor_closely`, `dose_adjust`, `no_action`)
+- `base_risk_score` (float in [0.0, 1.0])
+
+Implement a synthetic patient-episode dataset under `data/processed/`:
+
+**`patients_polypharmacy.csv`**
+- `episode_id` (string)
+- `age` (int)
+- `sex` (enum: `M`, `F`, `O`)
+- `conditions` (semicolon-separated; e.g., `HTN;DM;CKD`)
+- `eGFR_category` (enum: `normal`, `mild`, `moderate`, `severe`)
+- `liver_function_category` (enum: `normal`, `impaired`)
+- `medication_ids` (semicolon-separated list of `drug_id`)
+- `baseline_risk_score` (float in [0.0, 1.0])
+
+2.3. Preprocessing script
+
+In `scripts/preprocess_data.py`:
+- If real data is not provided, procedurally generate synthetic but plausible data using:
+ - Random combinations of conditions and drugs constrained by simple rules (e.g., CKD + renally-cleared drugs).
+ - Controlled distribution of high-risk DDIs and Beers violations.
+- Explicitly tag episodes as easy/medium/hard (e.g., via number of drugs, number/severity of DDIs, and number of Beers issues).
+- Save `patients_polypharmacy.csv` ready for the environment to consume.
+
+=================================================
+3. OpenEnv models and environment implementation
+=================================================
+
+3.1. Models
+
+In `models.py`, define dataclasses or Pydantic models that extend the appropriate OpenEnv base types (`Action`, `Observation`, `State`) and are JSON-compatible.
+
+Auxiliary models:
+
+**`MedicationEntry`**
+- `drug_id: str`
+- `generic_name: str`
+- `atc_class: str`
+- `dose_mg: float`
+- `frequency: str` # e.g., `qd`, `bid`
+- `route: str` # e.g., `po`
+- `is_high_risk_elderly: bool`
+- `beers_flags: list[str]` # e.g., `["avoid", "dose_adjust_CKD"]`
+
+**`InteractionQueryRecord`**
+- `drug_id_1: str`
+- `drug_id_2: str`
+- `severity: str | None`
+- `recommendation: str | None`
+- `risk_score: float | None`
+- `step_index: int`
+
+**`InterventionRecord`**
+- `target_drug_id: str`
+- `action_type: Literal["stop", "dose_reduce", "substitute", "add_monitoring"]`
+- `proposed_new_drug_id: str | None`
+- `rationale: str`
+- `step_index: int`
+
+Core wire models:
+
+**`PolypharmacyObservation`** (extends OpenEnv `Observation`)
+- `episode_id: str`
+- `task_id: Literal["easy_screening", "budgeted_screening", "complex_tradeoff"]`
+- `age: int`
+- `sex: str`
+- `conditions: list[str]`
+- `eGFR_category: str`
+- `liver_function_category: str`
+- `current_medications: list[MedicationEntry]`
+- `interaction_queries: list[InteractionQueryRecord]`
+- `interventions: list[InterventionRecord]`
+- `step_index: int`
+- `remaining_query_budget: int`
+- `remaining_intervention_budget: int`
+- `shaped_reward: float` # reward from last step
+- `done: bool`
+
+**`PolypharmacyAction`** (extends OpenEnv `Action`)
+- `action_type: Literal["query_ddi", "propose_intervention", "finish_review"]`
+- `drug_id_1: str | None` # for DDI queries or some interventions
+- `drug_id_2: str | None` # for DDI queries
+- `target_drug_id: str | None` # for interventions
+- `intervention_type: Literal["stop", "dose_reduce", "substitute", "add_monitoring", "none"] | None`
+- `proposed_new_drug_id: str | None`
+- `rationale: str | None`
+
+**`PolypharmacyState`** (extends OpenEnv `State`)
+- `episode_id: str`
+- `task_id: str`
+- `step_count: int`
+- `max_steps: int`
+- `num_query_actions: int`
+- `num_interventions: int`
+
+3.2. Environment core
+
+In `env_core.py`, implement `PolypharmacyEnv` extending the appropriate OpenEnv environment base class. It must implement:
+
+**`reset(task_id: str | None = None) -> PolypharmacyObservation`**
+- If `task_id` is `None`, default to medium (`budgeted_screening`).
+- Sample an episode from `patients_polypharmacy.csv` filtered by difficulty.
+- Initialize:
+ - `episode_id`
+ - `step_count = 0`
+ - task-specific budgets (query, interventions, max_steps)
+ - baseline regime and risk
+ - empty `interaction_queries` and `interventions`
+- Return the initial `PolypharmacyObservation` with:
+ - `step_index = 0`
+ - `shaped_reward = 0.0`
+ - `done = False`
+
+**`step(action: PolypharmacyAction) -> dict`**
+- Validate the action; if invalid:
+ - Apply a negative reward.
+ - Do not modify regimen, but log error in `info`.
+- If `action_type == "query_ddi"`:
+ - If query budget exhausted, apply penalty and do not query.
+ - Else:
+ - Use `ddi_simulator.lookup_ddi(drug_id_1, drug_id_2)` to get severity, recommendation, base_risk_score.
+ - Append an `InteractionQueryRecord`.
+ - Apply a small negative reward for query cost.
+- If `action_type == "propose_intervention"`:
+ - If intervention budget exhausted, apply penalty and ignore change.
+ - Else:
+ - Update `current_medications` according to `intervention_type`:
+ - `stop`: remove medication.
+ - `dose_reduce`: adjust dose downward within [min_dose_mg, default_dose_mg].
+ - `substitute`: replace with a safer alternative from same `atc_class`.
+ - `add_monitoring`: keep drug but tag in internal state.
+ - Append an `InterventionRecord`.
+ - Recompute current regimen risk using the risk model (see 3.3).
+ - Compute shaped reward = (previous_risk - new_risk) - small intervention cost.
+- If `action_type == "finish_review"`:
+ - Mark `done = True`.
+ - Call the task’s grader to get episode-level score in [0.0, 1.0].
+ - Add this as a terminal bonus to the current step reward.
+
+- In all cases:
+ - Increment `step_count`.
+ - Check `max_steps`; if exceeded, auto-terminate:
+ - `done = True`
+ - apply time-out penalty
+ - call grader with current trajectory for a final score if appropriate.
+ - Construct next `PolypharmacyObservation` with updated fields.
+ - Return a dict:
+ - `observation`: `PolypharmacyObservation`
+ - `reward`: float shaped reward for this step
+ - `done`: bool
+ - `info`: dict with fields like `current_risk`, `baseline_risk`, `grader_score_if_terminal`, and debug flags.
+
+**`state` property**
+- Returns `PolypharmacyState` reflecting the current internal state.
+
+3.3. DDI simulator and risk model
+
+In `ddi_simulator.py`:
+- Load `ddi_rules.csv` once via `data_loader`.
+- Implement `lookup_ddi(drug_id_1, drug_id_2) -> tuple[severity, recommendation, base_risk_score]`:
+ - Normalize the pair ordering.
+ - Look up row; if missing, return:
+ - severity = `"none"`
+ - recommendation = `"no_action"`
+ - base_risk_score = 0.0
+
+In `rewards.py` (or a dedicated module), implement:
+- `compute_regimen_risk(current_drug_ids, patient_context, ddi_rules, beers_rules, drug_metadata) -> float`
+ - Aggregate contributions from:
+ - Beers violations (weighted by `criterion_type` and relevant conditions).
+ - DDI base risk scores for all present drug pairs.
+ - High-risk elderly drugs.
+ - Normalize and clip to [0.0, 1.0].
+
+Use this function to compute:
+- `baseline_risk` at episode start.
+- Risk after each intervention step.
+
+Also implement:
+- `compute_shaped_reward(previous_risk, new_risk, action, context, partial_metrics) -> float`
+ - Positive component: `previous_risk - new_risk`.
+ - Negative components: per-query cost, per-intervention cost, invalid-action penalty, time-out penalty.
+
+=================================================
+4. Tasks and graders (3 difficulty levels)
+=================================================
+
+Define three task IDs and semantics in `tasks.py` and `graders.py`:
+
+Task IDs:
+- `easy_screening`
+- `budgeted_screening`
+- `complex_tradeoff`
+
+4.1. `easy_screening` (easy)
+
+- Small regimen: 3–5 drugs.
+- Exactly one **severe** DDI pair and possibly one simple Beers violation.
+- Budgets:
+ - query_budget ≈ 4
+ - intervention_budget ≈ 2
+ - max_steps ≈ 10
+
+Grader:
+- Input: full trajectory, baseline risk, final risk, list of interventions.
+- Compute:
+ - `risk_reduction = max(0.0, baseline_risk - final_risk) / max(baseline_risk, ε)` (normalized).
+ - `targeted_intervention_flag = 1.0` if at least one intervention affects one of the drugs in the known severe DDI pair, else 0.0.
+- Score:
+ - `score = 0.5 * risk_reduction + 0.5 * targeted_intervention_flag`
+ - Clip to [0.0, 1.0].
+
+4.2. `budgeted_screening` (medium)
+
+- Medium regimen: 6–10 drugs.
+- Multiple DDIs (mild/moderate/severe) and multiple Beers issues.
+- Budgets:
+ - query_budget ≈ 8
+ - intervention_budget ≈ 3
+ - max_steps ≈ 20
+
+Grader:
+- Compute:
+ - `risk_reduction_score` as normalized risk drop.
+ - `intervention_precision_score` = fraction of interventions that actually reduce risk or fix guideline violations.
+ - `query_efficiency_score` = (number of severe/moderate DDIs discovered) / (number of queries used), normalized.
+- Weighted score, for example:
+ - `score = 0.5 * risk_reduction_score + 0.3 * intervention_precision_score + 0.2 * query_efficiency_score`
+ - Clip to [0.0, 1.0].
+
+4.3. `complex_tradeoff` (hard)
+
+- Larger regimen: 10–15 drugs.
+- Some drugs are **clinically critical** (e.g., anticoagulants, insulin analogues) and encoded as such in `drug_metadata` or a small internal map.
+- Episodes contain:
+ - multiple DDIs and Beers issues, including ones involving critical drugs.
+ - safer substitutes for some risky drugs.
+
+Budgets:
+- query_budget ≈ 12
+- intervention_budget ≈ 5
+- max_steps ≈ 30
+
+Grader adds a **regimen disruption penalty** component:
+- Metrics:
+ - `risk_reduction_score` (as above).
+ - `critical_drug_penalty` = penalty if a critical drug is stopped without substitution to another suitable agent.
+ - `total_drug_changes` = number of drugs stopped or substituted.
+ - `regimen_disruption_penalty` derived from `total_drug_changes` and `critical_drug_penalty`.
+
+Example scoring:
+- `base = risk_reduction_score`
+- `penalty = α * regimen_disruption_penalty`
+- `score = clamp(base - penalty, 0.0, 1.0)`
+
+4.4. Reward shaping
+
+In `rewards.py`, define a consistent shaping scheme:
+- On each query:
+ - Small negative reward (e.g., −0.01) plus any small bonus if it discovers a severe DDI, if desired.
+- On each intervention:
+ - Reward ≈ (previous_risk - new_risk) − small intervention cost.
+- On invalid actions:
+ - Larger negative reward (e.g., −0.1) and no state change.
+- On `finish_review`:
+ - Add the task-level `score` ∈ [0.0, 1.0] from the corresponding grader to that step’s shaped reward.
+
+Ensure the sum of step rewards per episode remains in a reasonable numeric range (e.g., roughly -5 to +5) while still allowing meaningful differentiation by graders.
+
+=================================================
+5. HTTP API server and openenv.yaml
+=================================================
+
+5.1. HTTP server (FastAPI)
+
+In `api/server.py`:
+- Implement a FastAPI app that maintains a `PolypharmacyEnv` instance (or a multiplexing scheme if needed).
+- Endpoints:
+ - `POST /reset`:
+ - Request body: may include `task_id` (string).
+ - Response: serialized `PolypharmacyObservation`.
+ - `POST /step`:
+ - Request body: serialized `PolypharmacyAction`.
+ - Response: dict with:
+ - `observation`: `PolypharmacyObservation`
+ - `reward`: float
+ - `done`: bool
+ - `info`: dict
+ - `GET /state`:
+ - Response: `PolypharmacyState`.
+
+Provide a module-level `app = FastAPI(...)` object for use with uvicorn and Hugging Face Spaces. Ensure the JSON schema is consistent with OpenEnv clients (simple, flat JSON for observation/action/state).
+
+5.2. `openenv.yaml`
+
+At repo root, define `openenv.yaml` consistent with the latest OpenEnv spec. At minimum, include:
+- `name`: `polypharmacy_env`
+- `version`: e.g., `0.1.0`
+- `description`: human-readable description.
+- `author`: your details.
+- `tags`: e.g., `["healthcare", "polypharmacy", "openenv"]`
+- `tasks`:
+ - One entry per task:
+ - `id`: `"easy_screening"` / `"budgeted_screening"` / `"complex_tradeoff"`
+ - `description`: one-line description
+ - `difficulty`: `"easy"`, `"medium"`, `"hard"`
+
+Ensure `openenv validate` (or equivalent validator) passes once implemented.
+
+=================================================
+6. Baseline heuristic (non-LLM) agent
+=================================================
+
+In `baselines/heuristic_agent.py`, implement a simple, deterministic baseline agent that:
+
+For each episode:
+- Iterates through all unordered medication pairs within query budget:
+ - Calls `query_ddi` via the environment for each pair until the query budget is exhausted or all pairs are examined.
+ - Records severe and moderate interactions.
+- After querying:
+ - For each severe DDI pair:
+ - Try `substitute` one of the drugs using `drug_metadata`:
+ - Prefer substitute within same `atc_class` that:
+ - is not marked high-risk elderly.
+ - does not participate in known severe DDIs with the rest of the regimen.
+ - If no substitute exists, propose `stop` for the higher-risk drug.
+ - Respect intervention budget limits.
+- Finally, call `finish_review`.
+
+This baseline should be callable as a simple Python function that interacts with `PolypharmacyEnv` directly (without HTTP).
+
+=================================================
+7. Baseline LLM inference script (inference.py)
+=================================================
+
+At repo root, create `inference.py` that:
+
+7.1. Uses the OpenAI Python client
+
+- Import and configure the official OpenAI Python client.
+- Read environment variables:
+ - `OPENAI_API_KEY` (required).
+ - `API_BASE_URL` (base URL for LLM; default to OpenAI standard if not set).
+ - `MODEL_NAME` (e.g., `gpt-4.1` or similar).
+ - `HF_TOKEN` (if needed for HF auth; do not hardcode).
+- Read `POLYPHARMACY_ENV_URL` (or similar) for the environment’s HTTP base URL.
+
+7.2. Implements the required logging format
+
+- For each **run** across all tasks:
+ - Emit a `[START]` line with a JSON payload exactly matching the evaluation specification:
+ - Fields such as `run_id`, `task_id`, `model`, etc., in the same order and naming as the sample OpenEnv inference script.
+- For each **step** in an episode:
+ - Emit a `[STEP]` line with JSON fields including:
+ - `run_id`
+ - `task_id`
+ - `episode_id`
+ - `step_index`
+ - `observation_summary` (brief, machine-readable summary)
+ - `action_payload` (the action sent to the env)
+ - `reward`
+ - `done`
+- After finishing an episode for a task:
+ - Emit an `[END]` line summarizing:
+ - `run_id`
+ - `task_id`
+ - per-episode statistics (e.g., total reward, grader score from last step’s `info`).
+- The stdout format MUST follow the sample exactly:
+ - Same tags: `[START]`, `[STEP]`, `[END]`.
+ - Same JSON field names and ordering as the provided reference.
+ - No extra prints except these structured logs (and necessary error messages to stderr).
+
+7.3. LLM agent loop
+
+- For each task (`easy_screening`, `budgeted_screening`, `complex_tradeoff`):
+ - Run a fixed small number of episodes (e.g., 5–10 per task) for baseline scoring.
+ - For each episode:
+ - Call `/reset` with the task id.
+ - At each step:
+ - Summarize the observation into a concise prompt for the LLM:
+ - Include age, sex, conditions, high-risk flags, budgets, and a compressed view of meds and previous actions.
+ - Ask the model to output a **strict JSON** representing `PolypharmacyAction` fields.
+ - Parse and validate the JSON; if invalid, fall back to a safe default (e.g., `finish_review` or a no-op) and penalize in evaluation.
+ - Send this action to `/step` and log `[STEP]`.
+ - End when `done=True` or max_steps is reached.
+- At the end, print aggregate scores per task and overall.
+
+Make sure runtime < 20 minutes and that the script can run within 2 vCPUs and 8 GB RAM.
+
+=================================================
+8. Dockerfile and Hugging Face Space
+=================================================
+
+8.1. Dockerfile
+
+Create a `Dockerfile` that:
+- Starts from a slim Python image (e.g., `python:3.11-slim`).
+- Installs system dependencies as needed (e.g., `build-essential`, `curl`).
+- Copies the project into the container.
+- Installs Python dependencies from `requirements.txt`.
+- Sets appropriate environment variables for the app (e.g., `PORT=7860`).
+- Exposes port 7860.
+- Uses a `CMD` or `ENTRYPOINT` that runs the FastAPI server, for example:
+ - `uvicorn polypharmacy_env.api.server:app --host 0.0.0.0 --port 7860`
+
+8.2. Hugging Face Space
+
+Ensure the repository is ready to be used as a Hugging Face Space:
+- Space type: `docker`.
+- Tag: `openenv`.
+- On container start, the server must listen on the correct port and respond to:
+ - `POST /reset`
+ - `POST /step`
+ - `GET /state`
+- The environment must start cleanly with `docker build` + `docker run` locally.
+
+=================================================
+9. README and documentation
+=================================================
+
+In `README.md`, include:
+
+- **Environment description & motivation**:
+ - What PolypharmacyEnv simulates.
+ - Why elderly polypharmacy safety matters.
+- **Action and observation spaces**:
+ - Describe `PolypharmacyAction`, `PolypharmacyObservation`, and `PolypharmacyState` fields and semantics.
+- **Task descriptions**:
+ - `easy_screening`, `budgeted_screening`, `complex_tradeoff`, their difficulty and goals.
+- **Reward structure**:
+ - Summarize shaping and terminal rewards.
+- **Setup & usage**:
+ - How to install dependencies.
+ - How to run the API server locally (uvicorn command).
+ - How to run the heuristic baseline.
+ - How to run `inference.py` with environment variables.
+- **Baseline scores**:
+ - Document reproducible baseline scores for each task (heuristic agent, and LLM baseline if available).
+
+=================================================
+10. Validation and quality gates
+=================================================
+
+- Ensure:
+ - `openenv.yaml` and the HTTP server pass the OpenEnv validation script.
+ - `docker build` and `docker run` work without errors.
+ - `inference.py` completes under 20 minutes, within 2 vCPUs / 8 GB RAM.
+ - All graders:
+ - Are deterministic.
+ - Return scores strictly in [0.0, 1.0].
+ - No grader returns a constant score irrespective of behavior.
+
+Aim for clean, well-structured, well-documented code with clear separation of concerns between:
+- Data loading,
+- Environment state & dynamics,
+- Reward/grade logic,
+- HTTP serving,
+- Baseline agents and inference.
\ No newline at end of file
diff --git a/openenv-polypharmacy/README.md b/openenv-polypharmacy/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..dddbf4047eacd2e74adc50df3b8cf73f80f162f9
--- /dev/null
+++ b/openenv-polypharmacy/README.md
@@ -0,0 +1,245 @@
+# PolypharmacyEnv
+
+Monorepo for an OpenEnv-compatible medication safety environment with:
+
+- a FastAPI backend (`backend/`)
+- a React frontend (`frontend/`)
+- data assets (`data/`)
+- utility scripts (`scripts/`)
+
+---
+
+## Repository Structure
+
+```text
+openenv-polypharmacy/
+ backend/
+ main.py # ASGI entrypoint (uvicorn target)
+ requirements.txt # Backend dependencies
+ Dockerfile # Backend container
+ src/polypharmacy_env/ # Python package source
+ api/
+ app.py # FastAPI/OpenEnv app assembly
+ server.py # Compatibility import wrapper
+ routes/agent.py # /agent/suggest route
+ services/
+ groq_agent.py # Groq-based action suggestion logic
+ env_core.py # OpenEnv environment core
+ models.py # Action/observation/state models
+ data_loader.py # CSV loading
+ ddi_simulator.py # DDI and Beers lookups
+ rewards.py # Reward shaping
+ graders.py # Task graders
+ tasks.py # Task/episode selection
+ tests/ # Backend tests
+ frontend/
+ src/ # React UI code
+ package.json
+ Dockerfile # Frontend container
+ data/
+ lookups/ # drug_metadata.csv, ddi_rules.csv, beers_criteria.csv
+ processed/ # patients_polypharmacy.csv
+ scripts/
+ preprocess_data.py # Synthetic data generation
+ dev_backend.sh # Local backend run helper
+ dev_frontend.sh # Local frontend run helper
+ run_validation.sh # Tests + baseline validation
+ docker-compose.yml # Full stack orchestration
+ openenv.yaml # OpenEnv manifest
+ inference.py # Optional CLI inference baseline
+ .env.example # Environment template
+```
+
+---
+
+## What It Does
+
+The environment simulates elderly polypharmacy review. Agent actions:
+
+- `query_ddi`
+- `propose_intervention`
+- `finish_review`
+
+Supported tasks:
+
+- `easy_screening`
+- `budgeted_screening`
+- `complex_tradeoff`
+
+---
+
+## Prerequisites
+
+- Python 3.10+
+- Node.js 18+ (or 20+ recommended)
+- npm
+- Docker + Docker Compose (optional, for containerized run)
+
+---
+
+## Environment Setup
+
+Create `.env`:
+
+```bash
+cp .env.example .env
+```
+
+Set values:
+
+- `GROQ_API_KEY=...` (required)
+- `GROQ_BASE_URL=https://api.groq.com/openai/v1` (recommended)
+- `GROQ_MODEL_NAME=llama-3.3-70b-versatile` (recommended)
+
+---
+
+## Local Run (Recommended During Development)
+
+### 1) Install dependencies
+
+Backend:
+
+```bash
+pip install -r backend/requirements.txt
+```
+
+Frontend:
+
+```bash
+cd frontend
+npm install
+cd ..
+```
+
+### 2) Generate/update synthetic data (if needed)
+
+```bash
+python scripts/preprocess_data.py
+```
+
+### 3) Start services in two terminals
+
+Terminal A:
+
+```bash
+./scripts/dev_backend.sh
+```
+
+Terminal B:
+
+```bash
+./scripts/dev_frontend.sh
+```
+
+### 4) Open app
+
+- Frontend: [http://localhost:5173](http://localhost:5173)
+- Backend health: [http://localhost:7860/health](http://localhost:7860/health)
+
+---
+
+## Docker Run
+
+Run both services:
+
+```bash
+docker compose up --build
+```
+
+Stop:
+
+```bash
+docker compose down
+```
+
+Ports:
+
+- backend: `7860`
+- frontend: `5173`
+
+---
+
+## Hugging Face Spaces Deployment (Docker)
+
+This repo now includes a **root `Dockerfile`** that builds frontend + backend into one container, so Spaces can host both API and UI together.
+
+### 1) Create a new Space
+
+- Go to [Hugging Face Spaces](https://huggingface.co/new-space)
+- Choose **Docker** SDK
+- Create the Space
+
+### 2) Add Space secrets/variables
+
+In Space Settings -> Variables and Secrets:
+
+- Secret: `GROQ_API_KEY`
+- Variable: `GROQ_BASE_URL=https://api.groq.com/openai/v1`
+- Variable: `GROQ_MODEL_NAME=llama-3.3-70b-versatile`
+
+### 3) Push this repository to the Space
+
+Commit and push all files, including root `Dockerfile`.
+
+### 4) Verify after build
+
+- Space root URL loads the React UI
+- `/health` returns healthy status
+- OpenEnv endpoints are available (`/reset`, `/step`, `/state`, `/schema`)
+
+Notes:
+
+- Container reads `PORT` (defaults to `7860`) which is Space-friendly.
+- Frontend static assets are served by FastAPI from `frontend/dist`.
+
+---
+
+## API Endpoints
+
+OpenEnv/health:
+
+- `POST /reset`
+- `POST /step`
+- `GET /state`
+- `GET /health`
+- `GET /schema`
+- `WS /ws` (stateful session)
+
+AI helper:
+
+- `POST /agent/suggest`
+
+---
+
+## Testing
+
+Run backend tests:
+
+```bash
+python -m pytest backend/src/polypharmacy_env/tests -v
+```
+
+Or run validation script:
+
+```bash
+./scripts/run_validation.sh
+```
+
+---
+
+## Notes
+
+- OpenEnv HTTP reset/step is stateless; multi-step episode continuity should use websocket (`/ws`).
+- The frontend uses websocket for episode continuity and HTTP for AI suggestion.
+- AI behavior includes rule-based guardrails to avoid repetitive low-value loops.
+
+---
+
+## Troubleshooting
+
+- `ModuleNotFoundError: polypharmacy_env`
+ - Start backend using `./scripts/dev_backend.sh` from repo root.
+- `/agent/suggest` fails
+ - Check `.env` keys and restart backend.
+- UI state looks stale
+ - Hard refresh browser and click `Reset Episode`.
diff --git a/openenv-polypharmacy/backend/Dockerfile b/openenv-polypharmacy/backend/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..0cba5065ef8349abee5686485d0ada06fe57ce05
--- /dev/null
+++ b/openenv-polypharmacy/backend/Dockerfile
@@ -0,0 +1,28 @@
+FROM python:3.11-slim
+
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends build-essential curl && \
+ rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+COPY backend/requirements.txt /app/backend/requirements.txt
+RUN pip install --no-cache-dir -r /app/backend/requirements.txt
+
+COPY backend /app/backend
+COPY data /app/data
+COPY scripts /app/scripts
+COPY .env.example /app/.env.example
+
+RUN python3 /app/scripts/preprocess_data.py
+
+ENV PORT=7860
+ENV PYTHONPATH="/app/backend/src:${PYTHONPATH}"
+ENV PYTHONUNBUFFERED=1
+
+EXPOSE 7860
+
+HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
+ CMD curl -f http://localhost:7860/health || exit 1
+
+CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
diff --git a/openenv-polypharmacy/backend/__init__.py b/openenv-polypharmacy/backend/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..2f40af2be5beb19dd1c96a0a63b74124b144b304
--- /dev/null
+++ b/openenv-polypharmacy/backend/__init__.py
@@ -0,0 +1 @@
+"""Backend entrypoint package for monorepo structure."""
diff --git a/openenv-polypharmacy/backend/main.py b/openenv-polypharmacy/backend/main.py
new file mode 100644
index 0000000000000000000000000000000000000000..d7058d14ed4d68e0e2612e4168c9ec6d3ee0828c
--- /dev/null
+++ b/openenv-polypharmacy/backend/main.py
@@ -0,0 +1,15 @@
+"""ASGI entrypoint for backend service in monorepo layout."""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+BACKEND_DIR = Path(__file__).resolve().parent
+SRC = BACKEND_DIR / "src"
+if str(SRC) not in sys.path:
+ sys.path.insert(0, str(SRC))
+
+from polypharmacy_env.api.app import app # noqa: E402
+
+__all__ = ["app"]
diff --git a/openenv-polypharmacy/backend/requirements.txt b/openenv-polypharmacy/backend/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..c975be48d5a69da77e34da8973f265732a115236
--- /dev/null
+++ b/openenv-polypharmacy/backend/requirements.txt
@@ -0,0 +1,9 @@
+fastapi>=0.104.0
+uvicorn>=0.24.0
+pydantic>=2.0.0
+requests>=2.31.0
+httpx>=0.25.0
+openenv-core>=0.2.0
+openai>=1.0.0
+python-dotenv>=1.0.0
+pytest>=7.0.0
diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/__init__.py b/openenv-polypharmacy/backend/src/polypharmacy_env/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..6798eed03973a697da00247c5ac1aef68ee5e8d8
--- /dev/null
+++ b/openenv-polypharmacy/backend/src/polypharmacy_env/__init__.py
@@ -0,0 +1,11 @@
+"""PolypharmacyEnv – an OpenEnv environment for elderly polypharmacy safety."""
+
+from .client import PolypharmacyClient
+from .models import PolypharmacyAction, PolypharmacyObservation, PolypharmacyState
+
+__all__ = [
+ "PolypharmacyClient",
+ "PolypharmacyAction",
+ "PolypharmacyObservation",
+ "PolypharmacyState",
+]
diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/api/__init__.py b/openenv-polypharmacy/backend/src/polypharmacy_env/api/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..dff53e5af3f99340bcfd48c954f9ba7d6c2f12fa
--- /dev/null
+++ b/openenv-polypharmacy/backend/src/polypharmacy_env/api/__init__.py
@@ -0,0 +1 @@
+"""API package."""
diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/api/app.py b/openenv-polypharmacy/backend/src/polypharmacy_env/api/app.py
new file mode 100644
index 0000000000000000000000000000000000000000..a4377baaca9f6c0d8191c1a2e89d1e9c70b48605
--- /dev/null
+++ b/openenv-polypharmacy/backend/src/polypharmacy_env/api/app.py
@@ -0,0 +1,63 @@
+"""FastAPI app factory for PolypharmacyEnv."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from dotenv import load_dotenv
+from fastapi import HTTPException
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.staticfiles import StaticFiles
+from openenv.core.env_server.http_server import create_app
+from starlette.responses import FileResponse
+
+from ..env_core import PolypharmacyEnv
+from ..models import PolypharmacyAction, PolypharmacyObservation
+from .routes.agent import router as agent_router
+
+load_dotenv()
+
+
+class SPAStaticFiles(StaticFiles):
+ """Serve SPA index for unknown frontend routes."""
+
+ async def get_response(self, path: str, scope):
+ response = await super().get_response(path, scope)
+ if response.status_code != 404:
+ return response
+ index_path = Path(self.directory) / "index.html"
+ if index_path.exists():
+ return FileResponse(index_path)
+ raise HTTPException(status_code=404, detail="Not Found")
+
+
+def create_polypharmacy_app():
+ app = create_app(
+ PolypharmacyEnv,
+ PolypharmacyAction,
+ PolypharmacyObservation,
+ env_name="polypharmacy_env",
+ )
+
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=[
+ "http://localhost:5173",
+ "http://127.0.0.1:5173",
+ ],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+ app.include_router(agent_router)
+
+ # In Docker Space deployment, serve built frontend from same container.
+ project_root = Path(__file__).resolve().parents[4]
+ frontend_dist = project_root / "frontend" / "dist"
+ if frontend_dist.exists():
+ app.mount("/", SPAStaticFiles(directory=frontend_dist, html=True), name="frontend")
+
+ return app
+
+
+app = create_polypharmacy_app()
diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/api/routes/__init__.py b/openenv-polypharmacy/backend/src/polypharmacy_env/api/routes/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..fb0a2f808230d18b89b49b68b2bce37b7c869206
--- /dev/null
+++ b/openenv-polypharmacy/backend/src/polypharmacy_env/api/routes/__init__.py
@@ -0,0 +1 @@
+"""API route modules."""
diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/api/routes/agent.py b/openenv-polypharmacy/backend/src/polypharmacy_env/api/routes/agent.py
new file mode 100644
index 0000000000000000000000000000000000000000..f74e627ff9f5fcea9954cc51f51991bdb6218fbf
--- /dev/null
+++ b/openenv-polypharmacy/backend/src/polypharmacy_env/api/routes/agent.py
@@ -0,0 +1,35 @@
+"""Agent suggestion API routes."""
+
+from __future__ import annotations
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel, Field
+
+from ...models import PolypharmacyAction, PolypharmacyObservation
+from ...services.groq_agent import suggest_action_from_observation
+
+router = APIRouter(prefix="/agent", tags=["agent"])
+
+
+class AgentSuggestRequest(BaseModel):
+ observation: PolypharmacyObservation
+ model_name: str | None = None
+
+
+class AgentSuggestResponse(BaseModel):
+ action: PolypharmacyAction
+ source: str = Field(default="groq")
+
+
+@router.post("/suggest", response_model=AgentSuggestResponse)
+def suggest_agent_action(payload: AgentSuggestRequest) -> AgentSuggestResponse:
+ """Return a model-suggested action for the current observation."""
+ try:
+ action = suggest_action_from_observation(
+ payload.observation, model_name=payload.model_name
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ except Exception as exc:
+ raise HTTPException(status_code=500, detail=f"Model call failed: {exc}") from exc
+ return AgentSuggestResponse(action=action)
diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/api/server.py b/openenv-polypharmacy/backend/src/polypharmacy_env/api/server.py
new file mode 100644
index 0000000000000000000000000000000000000000..63717ca71f81ef20f44c410d4dd7a59576942b2e
--- /dev/null
+++ b/openenv-polypharmacy/backend/src/polypharmacy_env/api/server.py
@@ -0,0 +1,6 @@
+"""Backward-compatible app import path.
+
+Use `polypharmacy_env.api.app:app` for the main app module.
+"""
+
+from .app import app
diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/baselines/__init__.py b/openenv-polypharmacy/backend/src/polypharmacy_env/baselines/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..0f2de5b4de65026f090ebceec783d333169cd6ac
--- /dev/null
+++ b/openenv-polypharmacy/backend/src/polypharmacy_env/baselines/__init__.py
@@ -0,0 +1 @@
+"""Baseline agents."""
diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/baselines/heuristic_agent.py b/openenv-polypharmacy/backend/src/polypharmacy_env/baselines/heuristic_agent.py
new file mode 100644
index 0000000000000000000000000000000000000000..ec579d927aac4144b62f8b33f69a340cf5c327cb
--- /dev/null
+++ b/openenv-polypharmacy/backend/src/polypharmacy_env/baselines/heuristic_agent.py
@@ -0,0 +1,197 @@
+"""Deterministic heuristic baseline agent for PolypharmacyEnv.
+
+Strategy:
+1. Query all unordered medication pairs for DDIs (within budget),
+ prioritising high-risk elderly drugs first.
+2. For each severe DDI found, attempt substitution or stop.
+3. For each moderate DDI found, attempt substitution or stop.
+4. For remaining budget, address Beers-flagged "avoid" drugs.
+5. Call finish_review.
+"""
+
+from __future__ import annotations
+
+from itertools import combinations
+from typing import List, Tuple
+
+from ..env_core import PolypharmacyEnv
+from ..models import PolypharmacyAction, PolypharmacyObservation
+
+
+def run_heuristic_episode(
+ env: PolypharmacyEnv,
+ task_id: str = "budgeted_screening",
+ seed: int | None = None,
+) -> Tuple[float, float, int]:
+ """Run one episode with the heuristic agent.
+
+ Returns (total_reward, grader_score, steps).
+ """
+ obs = env.reset(seed=seed, task_id=task_id)
+ total_reward = 0.0
+ grader_score = 0.0
+ steps = 0
+
+ # Phase 1: Query DDIs between medication pairs, prioritising high-risk drugs
+ meds = obs.current_medications
+ # Sort: high-risk elderly drugs first, then by Beers flag count
+ meds_sorted = sorted(
+ meds,
+ key=lambda m: (not m.is_high_risk_elderly, -len(m.beers_flags), m.drug_id),
+ )
+ med_ids = [m.drug_id for m in meds_sorted]
+ pairs: List[Tuple[str, str]] = list(combinations(med_ids, 2))
+ severe_pairs: List[Tuple[str, str]] = []
+ moderate_pairs: List[Tuple[str, str]] = []
+
+ for a, b in pairs:
+ if obs.remaining_query_budget <= 0:
+ break
+ action = PolypharmacyAction(
+ action_type="query_ddi",
+ drug_id_1=a,
+ drug_id_2=b,
+ )
+ obs = env.step(action)
+ reward = obs.reward or 0.0
+ total_reward += reward
+ steps += 1
+
+ if obs.done:
+ grader_score = obs.metadata.get("grader_score", 0.0)
+ return total_reward, grader_score, steps
+
+ # Track severity from metadata
+ ddi_info = obs.metadata.get("ddi_result", {})
+ sev = ddi_info.get("severity", "none")
+ if sev == "severe":
+ severe_pairs.append((a, b))
+ elif sev == "moderate":
+ moderate_pairs.append((a, b))
+
+ # Phase 2: Intervene on severe DDI drugs first
+ intervened: set[str] = set()
+
+ def _try_intervene(
+ target: str,
+ rationale: str,
+ ) -> Tuple[bool, PolypharmacyObservation]:
+ """Try substitute then stop. Returns (done, obs)."""
+ nonlocal total_reward, steps
+ # Try substitute first
+ act = PolypharmacyAction(
+ action_type="propose_intervention",
+ target_drug_id=target,
+ intervention_type="substitute",
+ rationale=rationale,
+ )
+ obs_new = env.step(act)
+ total_reward += obs_new.reward or 0.0
+ steps += 1
+
+ if obs_new.done:
+ return True, obs_new
+
+ # If substitute failed, try stop
+ if obs_new.metadata.get("warning"):
+ if obs_new.remaining_intervention_budget <= 0:
+ return False, obs_new
+ act2 = PolypharmacyAction(
+ action_type="propose_intervention",
+ target_drug_id=target,
+ intervention_type="stop",
+ rationale=f"No substitute; {rationale}",
+ )
+ obs_new = env.step(act2)
+ total_reward += obs_new.reward or 0.0
+ steps += 1
+ if obs_new.done:
+ return True, obs_new
+
+ return False, obs_new
+
+ # Intervene on severe pairs
+ for a, b in severe_pairs:
+ if obs.remaining_intervention_budget <= 0:
+ break
+ target = b if a in intervened else a
+ if target in intervened:
+ target = b
+ if target in intervened:
+ continue
+ intervened.add(target)
+
+ done, obs = _try_intervene(target, f"Severe DDI between {a} and {b}")
+ if done:
+ grader_score = obs.metadata.get("grader_score", 0.0)
+ return total_reward, grader_score, steps
+
+ # Phase 2b: Intervene on moderate DDI drugs
+ for a, b in moderate_pairs:
+ if obs.remaining_intervention_budget <= 0:
+ break
+ target = b if a in intervened else a
+ if target in intervened:
+ target = b
+ if target in intervened:
+ continue
+ intervened.add(target)
+
+ done, obs = _try_intervene(target, f"Moderate DDI between {a} and {b}")
+ if done:
+ grader_score = obs.metadata.get("grader_score", 0.0)
+ return total_reward, grader_score, steps
+
+ # Phase 3: Address Beers-flagged "avoid" drugs
+ for med in meds_sorted:
+ if obs.remaining_intervention_budget <= 0:
+ break
+ if med.drug_id in intervened:
+ continue
+ if not med.beers_flags:
+ continue
+ if any("avoid" in f for f in med.beers_flags):
+ intervened.add(med.drug_id)
+ done, obs = _try_intervene(
+ med.drug_id, f"Beers criteria: {', '.join(med.beers_flags)}"
+ )
+ if done:
+ grader_score = obs.metadata.get("grader_score", 0.0)
+ return total_reward, grader_score, steps
+
+ # Phase 4: Finish
+ action = PolypharmacyAction(action_type="finish_review")
+ obs = env.step(action)
+ total_reward += obs.reward or 0.0
+ steps += 1
+ grader_score = obs.metadata.get("grader_score", 0.0)
+
+ return total_reward, grader_score, steps
+
+
+def run_heuristic_baseline(
+ n_episodes: int = 5,
+ task_ids: List[str] | None = None,
+) -> None:
+ """Run the heuristic agent across tasks and print results."""
+ if task_ids is None:
+ task_ids = ["easy_screening", "budgeted_screening", "complex_tradeoff"]
+
+ env = PolypharmacyEnv()
+
+ for tid in task_ids:
+ scores: list[float] = []
+ rewards: list[float] = []
+ for i in range(n_episodes):
+ total_r, score, steps = run_heuristic_episode(env, task_id=tid, seed=i)
+ scores.append(score)
+ rewards.append(total_r)
+ print(f" [{tid}] ep={i} steps={steps} reward={total_r:.4f} score={score:.4f}")
+
+ avg_s = sum(scores) / len(scores) if scores else 0.0
+ avg_r = sum(rewards) / len(rewards) if rewards else 0.0
+ print(f" [{tid}] avg_score={avg_s:.4f} avg_reward={avg_r:.4f}\n")
+
+
+if __name__ == "__main__":
+ run_heuristic_baseline()
diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/baselines/random_agent.py b/openenv-polypharmacy/backend/src/polypharmacy_env/baselines/random_agent.py
new file mode 100644
index 0000000000000000000000000000000000000000..44b647b4d558cddef7706bc93ed837bdd11e8285
--- /dev/null
+++ b/openenv-polypharmacy/backend/src/polypharmacy_env/baselines/random_agent.py
@@ -0,0 +1,53 @@
+"""Trivial random baseline agent for PolypharmacyEnv."""
+
+from __future__ import annotations
+
+import random
+from typing import List, Tuple
+
+from ..env_core import PolypharmacyEnv
+from ..models import PolypharmacyAction, PolypharmacyObservation
+
+
+def run_random_episode(
+ env: PolypharmacyEnv,
+ task_id: str = "budgeted_screening",
+ seed: int | None = None,
+) -> Tuple[float, float, int]:
+ rng = random.Random(seed)
+ obs = env.reset(task_id=task_id, seed=seed)
+ total_reward = 0.0
+ grader_score = 0.0
+ steps = 0
+
+ while not obs.done:
+ med_ids = [m.drug_id for m in obs.current_medications]
+ choice = rng.choice(["query_ddi", "propose_intervention", "finish_review"])
+
+ if choice == "query_ddi" and len(med_ids) >= 2 and obs.remaining_query_budget > 0:
+ pair = rng.sample(med_ids, 2)
+ action = PolypharmacyAction(
+ action_type="query_ddi",
+ drug_id_1=pair[0],
+ drug_id_2=pair[1],
+ )
+ elif choice == "propose_intervention" and med_ids and obs.remaining_intervention_budget > 0:
+ target = rng.choice(med_ids)
+ itype = rng.choice(["stop", "dose_reduce", "substitute", "add_monitoring"])
+ action = PolypharmacyAction(
+ action_type="propose_intervention",
+ target_drug_id=target,
+ intervention_type=itype,
+ rationale="random",
+ )
+ else:
+ action = PolypharmacyAction(action_type="finish_review")
+
+ obs = env.step(action)
+ total_reward += obs.reward or 0.0
+ steps += 1
+ if obs.done:
+ grader_score = obs.metadata.get("grader_score", 0.0)
+ break
+
+ return total_reward, grader_score, steps
diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/client.py b/openenv-polypharmacy/backend/src/polypharmacy_env/client.py
new file mode 100644
index 0000000000000000000000000000000000000000..9172acf8bbe3a436281124c59c8f89a759beb5d6
--- /dev/null
+++ b/openenv-polypharmacy/backend/src/polypharmacy_env/client.py
@@ -0,0 +1,49 @@
+"""OpenEnv client for PolypharmacyEnv.
+
+Provides a typed async/sync client for interacting with a PolypharmacyEnv
+server via WebSocket, following the OpenEnv EnvClient pattern.
+
+Example (async):
+ >>> async with PolypharmacyClient(base_url="ws://localhost:8000") as env:
+ ... result = await env.reset(task_id="easy_screening")
+ ... result = await env.step(PolypharmacyAction(action_type="finish_review"))
+
+Example (sync):
+ >>> with PolypharmacyClient(base_url="ws://localhost:8000").sync() as env:
+ ... result = env.reset(task_id="easy_screening")
+"""
+
+from __future__ import annotations
+
+from typing import Any, Dict
+
+from openenv.core.client_types import StepResult
+from openenv.core.env_client import EnvClient
+
+from .models import PolypharmacyAction, PolypharmacyObservation, PolypharmacyState
+
+
+class PolypharmacyClient(
+ EnvClient[PolypharmacyAction, PolypharmacyObservation, PolypharmacyState]
+):
+ """Typed OpenEnv client for the PolypharmacyEnv environment."""
+
+ def _step_payload(self, action: PolypharmacyAction) -> Dict[str, Any]:
+ """Convert a PolypharmacyAction to the JSON payload for the server."""
+ return action.model_dump(exclude_none=True)
+
+ def _parse_result(
+ self, payload: Dict[str, Any]
+ ) -> StepResult[PolypharmacyObservation]:
+ """Parse a server response into a StepResult with typed observation."""
+ obs_data = payload.get("observation", payload)
+ obs = PolypharmacyObservation.model_validate(obs_data)
+ return StepResult(
+ observation=obs,
+ reward=payload.get("reward"),
+ done=payload.get("done", False),
+ )
+
+ def _parse_state(self, payload: Dict[str, Any]) -> PolypharmacyState:
+ """Parse a server state response into a typed PolypharmacyState."""
+ return PolypharmacyState.model_validate(payload)
diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/config.py b/openenv-polypharmacy/backend/src/polypharmacy_env/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..e71035ae3e075c08b7f0a9dbbef0857abb8be231
--- /dev/null
+++ b/openenv-polypharmacy/backend/src/polypharmacy_env/config.py
@@ -0,0 +1,79 @@
+"""Environment configuration constants and task parameter definitions."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Dict
+
+# ── Paths ────────────────────────────────────────────────────────────────────
+PROJECT_ROOT = Path(__file__).resolve().parents[3] # openenv-polypharmacy/
+DATA_DIR = PROJECT_ROOT / "data"
+LOOKUPS_DIR = DATA_DIR / "lookups"
+PROCESSED_DIR = DATA_DIR / "processed"
+
+DDI_RULES_CSV = LOOKUPS_DIR / "ddi_rules.csv"
+BEERS_CRITERIA_CSV = LOOKUPS_DIR / "beers_criteria.csv"
+DRUG_METADATA_CSV = LOOKUPS_DIR / "drug_metadata.csv"
+PATIENTS_CSV = PROCESSED_DIR / "patients_polypharmacy.csv"
+
+# ── Reward hyper-parameters ──────────────────────────────────────────────────
+QUERY_COST: float = 0.01
+INTERVENTION_COST: float = 0.02
+INVALID_ACTION_PENALTY: float = 0.10
+TIMEOUT_PENALTY: float = 0.20
+SEVERE_DDI_DISCOVERY_BONUS: float = 0.03
+
+# ── Task parameters ─────────────────────────────────────────────────────────
+
+@dataclass(frozen=True)
+class TaskConfig:
+ task_id: str
+ difficulty: str
+ min_drugs: int
+ max_drugs: int
+ query_budget: int
+ intervention_budget: int
+ max_steps: int
+
+
+TASK_CONFIGS: Dict[str, TaskConfig] = {
+ "easy_screening": TaskConfig(
+ task_id="easy_screening",
+ difficulty="easy",
+ min_drugs=3,
+ max_drugs=5,
+ query_budget=4,
+ intervention_budget=2,
+ max_steps=10,
+ ),
+ "budgeted_screening": TaskConfig(
+ task_id="budgeted_screening",
+ difficulty="medium",
+ min_drugs=6,
+ max_drugs=10,
+ query_budget=8,
+ intervention_budget=3,
+ max_steps=20,
+ ),
+ "complex_tradeoff": TaskConfig(
+ task_id="complex_tradeoff",
+ difficulty="hard",
+ min_drugs=10,
+ max_drugs=15,
+ query_budget=12,
+ intervention_budget=5,
+ max_steps=30,
+ ),
+}
+
+DEFAULT_TASK = "budgeted_screening"
+
+# ── Critical drugs (must not be stopped without substitution) ────────────────
+CRITICAL_DRUG_IDS: set[str] = {
+ "DRUG_WARFARIN",
+ "DRUG_APIXABAN",
+ "DRUG_INSULIN_GLARGINE",
+ "DRUG_METOPROLOL",
+ "DRUG_DIGOXIN",
+}
diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/data_loader.py b/openenv-polypharmacy/backend/src/polypharmacy_env/data_loader.py
new file mode 100644
index 0000000000000000000000000000000000000000..f0d08463257be1bd148912584673d345ac0c8d09
--- /dev/null
+++ b/openenv-polypharmacy/backend/src/polypharmacy_env/data_loader.py
@@ -0,0 +1,142 @@
+"""Load and cache CSV lookup data for the PolypharmacyEnv."""
+
+from __future__ import annotations
+
+import csv
+from dataclasses import dataclass, field
+from functools import lru_cache
+from pathlib import Path
+from typing import Dict, List, Optional, Tuple
+
+from .config import (
+ BEERS_CRITERIA_CSV,
+ DDI_RULES_CSV,
+ DRUG_METADATA_CSV,
+ PATIENTS_CSV,
+)
+
+
+# ── Row-level data classes ───────────────────────────────────────────────────
+
+@dataclass(frozen=True)
+class DrugMeta:
+ drug_id: str
+ generic_name: str
+ atc_class: str
+ is_high_risk_elderly: bool
+ default_dose_mg: float
+ min_dose_mg: float
+ max_dose_mg: float
+
+
+@dataclass(frozen=True)
+class DDIRule:
+ drug_id_1: str
+ drug_id_2: str
+ severity: str
+ mechanism: str
+ recommendation: str
+ base_risk_score: float
+
+
+@dataclass(frozen=True)
+class BeersCriterion:
+ drug_id: str
+ criterion_type: str # avoid | caution | dose_adjust | avoid_in_condition
+ condition: Optional[str]
+ rationale: str
+
+
+@dataclass
+class PatientEpisode:
+ episode_id: str
+ age: int
+ sex: str
+ conditions: List[str]
+ eGFR_category: str
+ liver_function_category: str
+ medication_ids: List[str]
+ baseline_risk_score: float
+ difficulty: str
+
+
+# ── Loaders (cached) ────────────────────────────────────────────────────────
+
+def _read_csv(path: Path) -> List[Dict[str, str]]:
+ with open(path, newline="") as f:
+ return list(csv.DictReader(f))
+
+
+@lru_cache(maxsize=1)
+def load_drug_metadata(path: Path = DRUG_METADATA_CSV) -> Dict[str, DrugMeta]:
+ out: Dict[str, DrugMeta] = {}
+ for row in _read_csv(path):
+ dm = DrugMeta(
+ drug_id=row["drug_id"],
+ generic_name=row["generic_name"],
+ atc_class=row["atc_class"],
+ is_high_risk_elderly=row["is_high_risk_elderly"] == "1",
+ default_dose_mg=float(row["default_dose_mg"]),
+ min_dose_mg=float(row["min_dose_mg"]),
+ max_dose_mg=float(row["max_dose_mg"]),
+ )
+ out[dm.drug_id] = dm
+ return out
+
+
+def _normalise_pair(a: str, b: str) -> Tuple[str, str]:
+ return (a, b) if a < b else (b, a)
+
+
+@lru_cache(maxsize=1)
+def load_ddi_rules(path: Path = DDI_RULES_CSV) -> Dict[Tuple[str, str], DDIRule]:
+ out: Dict[Tuple[str, str], DDIRule] = {}
+ for row in _read_csv(path):
+ key = _normalise_pair(row["drug_id_1"], row["drug_id_2"])
+ out[key] = DDIRule(
+ drug_id_1=key[0],
+ drug_id_2=key[1],
+ severity=row["severity"],
+ mechanism=row["mechanism"],
+ recommendation=row["recommendation"],
+ base_risk_score=float(row["base_risk_score"]),
+ )
+ return out
+
+
+@lru_cache(maxsize=1)
+def load_beers_criteria(path: Path = BEERS_CRITERIA_CSV) -> List[BeersCriterion]:
+ out: List[BeersCriterion] = []
+ for row in _read_csv(path):
+ cond = row["condition"].strip() or None
+ out.append(BeersCriterion(
+ drug_id=row["drug_id"],
+ criterion_type=row["criterion_type"],
+ condition=cond,
+ rationale=row["rationale"],
+ ))
+ return out
+
+
+def load_patients(
+ path: Path = PATIENTS_CSV,
+ difficulty: Optional[str] = None,
+) -> List[PatientEpisode]:
+ rows = _read_csv(path)
+ eps: List[PatientEpisode] = []
+ for row in rows:
+ d = row.get("difficulty", "medium")
+ if difficulty and d != difficulty:
+ continue
+ eps.append(PatientEpisode(
+ episode_id=row["episode_id"],
+ age=int(row["age"]),
+ sex=row["sex"],
+ conditions=[c.strip() for c in row["conditions"].split(";") if c.strip()],
+ eGFR_category=row["eGFR_category"],
+ liver_function_category=row["liver_function_category"],
+ medication_ids=[m.strip() for m in row["medication_ids"].split(";") if m.strip()],
+ baseline_risk_score=float(row["baseline_risk_score"]),
+ difficulty=d,
+ ))
+ return eps
diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/ddi_simulator.py b/openenv-polypharmacy/backend/src/polypharmacy_env/ddi_simulator.py
new file mode 100644
index 0000000000000000000000000000000000000000..52d390e2d530d091718414c6f86b29ca09b942da
--- /dev/null
+++ b/openenv-polypharmacy/backend/src/polypharmacy_env/ddi_simulator.py
@@ -0,0 +1,115 @@
+"""Local DDI and guideline simulation using CSV lookup data."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Dict, List, Optional, Tuple
+
+from .data_loader import (
+ BeersCriterion,
+ DDIRule,
+ DrugMeta,
+ load_beers_criteria,
+ load_ddi_rules,
+ load_drug_metadata,
+)
+
+
+@dataclass(frozen=True)
+class DDIResult:
+ severity: str
+ recommendation: str
+ base_risk_score: float
+
+
+_NO_INTERACTION = DDIResult(severity="none", recommendation="no_action", base_risk_score=0.0)
+
+
+class DDISimulator:
+ """Provides drug–drug interaction and Beers-criteria lookups."""
+
+ def __init__(self) -> None:
+ self._ddi_rules: Dict[Tuple[str, str], DDIRule] = load_ddi_rules()
+ self._drug_meta: Dict[str, DrugMeta] = load_drug_metadata()
+ self._beers: List[BeersCriterion] = load_beers_criteria()
+
+ @staticmethod
+ def _normalise_pair(a: str, b: str) -> Tuple[str, str]:
+ return (a, b) if a < b else (b, a)
+
+ def lookup_ddi(self, drug_id_1: str, drug_id_2: str) -> DDIResult:
+ key = self._normalise_pair(drug_id_1, drug_id_2)
+ rule = self._ddi_rules.get(key)
+ if rule is None:
+ return _NO_INTERACTION
+ return DDIResult(
+ severity=rule.severity,
+ recommendation=rule.recommendation,
+ base_risk_score=rule.base_risk_score,
+ )
+
+ def get_beers_flags(
+ self,
+ drug_id: str,
+ patient_conditions: List[str],
+ ) -> List[str]:
+ """Return list of Beers flags applicable to *drug_id* given patient conditions."""
+ flags: List[str] = []
+ for bc in self._beers:
+ if bc.drug_id != drug_id:
+ continue
+ if bc.condition is None:
+ flags.append(bc.criterion_type)
+ elif bc.condition in patient_conditions:
+ flags.append(f"{bc.criterion_type}_{bc.condition}")
+ return flags
+
+ def get_drug_meta(self, drug_id: str) -> Optional[DrugMeta]:
+ return self._drug_meta.get(drug_id)
+
+ def find_substitute(
+ self,
+ drug_id: str,
+ current_drug_ids: List[str],
+ ) -> Optional[str]:
+ """Find a safer same-class substitute not already in the regimen."""
+ meta = self._drug_meta.get(drug_id)
+ if meta is None:
+ return None
+ candidates = [
+ dm
+ for dm in self._drug_meta.values()
+ if (
+ dm.atc_class == meta.atc_class
+ and dm.drug_id != drug_id
+ and dm.drug_id not in current_drug_ids
+ and not dm.is_high_risk_elderly
+ )
+ ]
+ if not candidates:
+ return None
+ # Pick the candidate with fewest severe DDIs with current regimen
+ def _severe_count(cand: DrugMeta) -> int:
+ count = 0
+ for did in current_drug_ids:
+ if did == drug_id:
+ continue
+ r = self.lookup_ddi(cand.drug_id, did)
+ if r.severity == "severe":
+ count += 1
+ return count
+
+ candidates.sort(key=lambda c: (_severe_count(c), c.drug_id))
+ return candidates[0].drug_id
+
+ @property
+ def drug_metadata(self) -> Dict[str, DrugMeta]:
+ return self._drug_meta
+
+ @property
+ def ddi_rules(self) -> Dict[Tuple[str, str], DDIRule]:
+ return self._ddi_rules
+
+ @property
+ def beers_criteria(self) -> List[BeersCriterion]:
+ return self._beers
diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/env_core.py b/openenv-polypharmacy/backend/src/polypharmacy_env/env_core.py
new file mode 100644
index 0000000000000000000000000000000000000000..74190ac53a1c32d8b7294987e7b9ead83238a3cf
--- /dev/null
+++ b/openenv-polypharmacy/backend/src/polypharmacy_env/env_core.py
@@ -0,0 +1,416 @@
+"""PolypharmacyEnv – core environment implementing OpenEnv step / reset / state."""
+
+from __future__ import annotations
+
+from copy import deepcopy
+from itertools import combinations
+from typing import Any, Dict, List, Optional, Tuple
+
+from openenv.core.env_server.interfaces import Environment
+
+from .config import CRITICAL_DRUG_IDS, TaskConfig
+from .data_loader import PatientEpisode
+from .ddi_simulator import DDISimulator
+from .graders import (
+ grade_budgeted_screening,
+ grade_complex_tradeoff,
+ grade_easy_screening,
+)
+from .models import (
+ InteractionQueryRecord,
+ InterventionRecord,
+ MedicationEntry,
+ PolypharmacyAction,
+ PolypharmacyObservation,
+ PolypharmacyState,
+)
+from .rewards import compute_regimen_risk, compute_shaped_reward
+from .tasks import get_task_config, sample_episode
+
+
+class PolypharmacyEnv(
+ Environment[PolypharmacyAction, PolypharmacyObservation, PolypharmacyState]
+):
+ """OpenEnv-compliant environment for elderly polypharmacy medication review.
+
+ Extends openenv.core.env_server.interfaces.Environment with typed
+ Action/Observation/State generics.
+ """
+
+ def __init__(self) -> None:
+ super().__init__()
+ self._sim = DDISimulator()
+ self._task_cfg: Optional[TaskConfig] = None
+ self._episode: Optional[PatientEpisode] = None
+ self._medications: List[MedicationEntry] = []
+ self._interaction_queries: List[InteractionQueryRecord] = []
+ self._interventions: List[InterventionRecord] = []
+ self._risk_deltas: List[float] = [] # per-intervention risk improvement
+ self._step_count: int = 0
+ self._done: bool = True
+ self._baseline_risk: float = 0.0
+ self._current_risk: float = 0.0
+ self._remaining_query_budget: int = 0
+ self._remaining_intervention_budget: int = 0
+ self._severe_moderate_discovered: int = 0
+ self._total_drug_changes: int = 0
+ self._critical_stopped_without_sub: int = 0
+ self._last_reward: float = 0.0
+
+ # ── reset ────────────────────────────────────────────────────────────────
+
+ def reset(
+ self,
+ seed: Optional[int] = None,
+ episode_id: Optional[str] = None,
+ **kwargs: Any,
+ ) -> PolypharmacyObservation:
+ task_id = kwargs.get("task_id", None)
+ self._task_cfg = get_task_config(task_id)
+ self._episode = sample_episode(task_id, seed=seed, episode_id=episode_id)
+
+ # Build medication list
+ self._medications = []
+ for did in self._episode.medication_ids:
+ meta = self._sim.get_drug_meta(did)
+ if meta is None:
+ continue
+ flags = self._sim.get_beers_flags(did, self._episode.conditions)
+ self._medications.append(MedicationEntry(
+ drug_id=did,
+ generic_name=meta.generic_name,
+ atc_class=meta.atc_class,
+ dose_mg=meta.default_dose_mg,
+ is_high_risk_elderly=meta.is_high_risk_elderly,
+ beers_flags=flags,
+ ))
+
+ self._interaction_queries = []
+ self._interventions = []
+ self._risk_deltas = []
+ self._step_count = 0
+ self._done = False
+ self._remaining_query_budget = self._task_cfg.query_budget
+ self._remaining_intervention_budget = self._task_cfg.intervention_budget
+ self._severe_moderate_discovered = 0
+ self._total_drug_changes = 0
+ self._critical_stopped_without_sub = 0
+ self._last_reward = 0.0
+
+ # Compute baseline risk
+ self._baseline_risk = self._compute_risk()
+ self._current_risk = self._baseline_risk
+
+ return self._make_observation()
+
+ # ── step ─────────────────────────────────────────────────────────────────
+
+ def step(
+ self,
+ action: PolypharmacyAction,
+ timeout_s: Optional[float] = None,
+ **kwargs: Any,
+ ) -> PolypharmacyObservation:
+ if self._done:
+ return self._make_observation()
+
+ assert self._task_cfg is not None
+ assert self._episode is not None
+
+ reward = 0.0
+ info: Dict[str, Any] = {}
+
+ # Validate basic action structure
+ valid, err = self._validate_action(action)
+ if not valid:
+ reward = compute_shaped_reward(
+ self._current_risk, self._current_risk,
+ action.action_type, is_invalid=True,
+ )
+ info["error"] = err
+ self._step_count += 1
+ return self._check_timeout_and_build_obs(reward, info)
+
+ if action.action_type == "query_ddi":
+ reward, info = self._handle_query(action)
+
+ elif action.action_type == "propose_intervention":
+ reward, info = self._handle_intervention(action)
+
+ elif action.action_type == "finish_review":
+ self._done = True
+ score = self._run_grader()
+ reward = score # terminal bonus
+ info["grader_score"] = score
+
+ self._step_count += 1
+ return self._check_timeout_and_build_obs(reward, info)
+
+ # ── state property ───────────────────────────────────────────────────────
+
+ @property
+ def state(self) -> PolypharmacyState:
+ return PolypharmacyState(
+ episode_id=self._episode.episode_id if self._episode else None,
+ step_count=self._step_count,
+ task_id=self._task_cfg.task_id if self._task_cfg else "",
+ max_steps=self._task_cfg.max_steps if self._task_cfg else 0,
+ num_query_actions=len(self._interaction_queries),
+ num_interventions=len(self._interventions),
+ )
+
+ # ── Internal helpers ─────────────────────────────────────────────────────
+
+ def _compute_risk(self) -> float:
+ drug_ids = [m.drug_id for m in self._medications]
+ return compute_regimen_risk(
+ drug_ids,
+ self._episode.conditions if self._episode else [],
+ self._sim.ddi_rules,
+ self._sim.beers_criteria,
+ self._sim.drug_metadata,
+ )
+
+ def _validate_action(self, action: PolypharmacyAction) -> Tuple[bool, str]:
+ if action.action_type == "query_ddi":
+ if not action.drug_id_1 or not action.drug_id_2:
+ return False, "query_ddi requires drug_id_1 and drug_id_2"
+ elif action.action_type == "propose_intervention":
+ if not action.target_drug_id:
+ return False, "propose_intervention requires target_drug_id"
+ if action.intervention_type in (None, "none"):
+ return False, "propose_intervention requires a valid intervention_type"
+ return True, ""
+
+ def _handle_query(self, action: PolypharmacyAction) -> Tuple[float, Dict[str, Any]]:
+ info: Dict[str, Any] = {}
+ assert action.drug_id_1 and action.drug_id_2
+
+ if self._remaining_query_budget <= 0:
+ reward = compute_shaped_reward(
+ self._current_risk, self._current_risk,
+ "query_ddi", is_invalid=True,
+ )
+ info["error"] = "Query budget exhausted"
+ return reward, info
+
+ result = self._sim.lookup_ddi(action.drug_id_1, action.drug_id_2)
+ self._remaining_query_budget -= 1
+
+ self._interaction_queries.append(InteractionQueryRecord(
+ drug_id_1=action.drug_id_1,
+ drug_id_2=action.drug_id_2,
+ severity=result.severity,
+ recommendation=result.recommendation,
+ risk_score=result.base_risk_score,
+ step_index=self._step_count,
+ ))
+
+ discovered_severe = result.severity in ("severe", "moderate")
+ if discovered_severe:
+ self._severe_moderate_discovered += 1
+
+ reward = compute_shaped_reward(
+ self._current_risk, self._current_risk,
+ "query_ddi",
+ discovered_severe=(result.severity == "severe"),
+ )
+ info["ddi_result"] = {
+ "severity": result.severity,
+ "recommendation": result.recommendation,
+ "risk_score": result.base_risk_score,
+ }
+ return reward, info
+
+ def _handle_intervention(self, action: PolypharmacyAction) -> Tuple[float, Dict[str, Any]]:
+ info: Dict[str, Any] = {}
+ assert action.target_drug_id
+ assert action.intervention_type and action.intervention_type != "none"
+
+ if self._remaining_intervention_budget <= 0:
+ reward = compute_shaped_reward(
+ self._current_risk, self._current_risk,
+ "propose_intervention", is_invalid=True,
+ )
+ info["error"] = "Intervention budget exhausted"
+ return reward, info
+
+ # Find target medication
+ target_idx: Optional[int] = None
+ for i, m in enumerate(self._medications):
+ if m.drug_id == action.target_drug_id:
+ target_idx = i
+ break
+
+ if target_idx is None:
+ reward = compute_shaped_reward(
+ self._current_risk, self._current_risk,
+ "propose_intervention", is_invalid=True,
+ )
+ info["error"] = f"Drug {action.target_drug_id} not in current medications"
+ return reward, info
+
+ previous_risk = self._current_risk
+ target_med = self._medications[target_idx]
+
+ if action.intervention_type == "stop":
+ self._medications.pop(target_idx)
+ self._total_drug_changes += 1
+ if action.target_drug_id in CRITICAL_DRUG_IDS:
+ self._critical_stopped_without_sub += 1
+
+ elif action.intervention_type == "dose_reduce":
+ meta = self._sim.get_drug_meta(action.target_drug_id)
+ if meta:
+ new_dose = max(meta.min_dose_mg, target_med.dose_mg * 0.5)
+ self._medications[target_idx] = target_med.model_copy(
+ update={"dose_mg": new_dose}
+ )
+
+ elif action.intervention_type == "substitute":
+ new_drug_id = action.proposed_new_drug_id
+ if not new_drug_id:
+ # Auto-find substitute
+ current_ids = [m.drug_id for m in self._medications]
+ new_drug_id = self._sim.find_substitute(action.target_drug_id, current_ids)
+ if new_drug_id:
+ new_meta = self._sim.get_drug_meta(new_drug_id)
+ if new_meta:
+ flags = self._sim.get_beers_flags(
+ new_drug_id,
+ self._episode.conditions if self._episode else [],
+ )
+ self._medications[target_idx] = MedicationEntry(
+ drug_id=new_drug_id,
+ generic_name=new_meta.generic_name,
+ atc_class=new_meta.atc_class,
+ dose_mg=new_meta.default_dose_mg,
+ is_high_risk_elderly=new_meta.is_high_risk_elderly,
+ beers_flags=flags,
+ )
+ self._total_drug_changes += 1
+ # If critical drug was substituted, don't penalise
+ if action.target_drug_id in CRITICAL_DRUG_IDS:
+ pass # substitution is acceptable
+ else:
+ info["warning"] = f"Substitute {new_drug_id} not found in metadata"
+ # Don't consume budget for a failed substitute
+ self._remaining_intervention_budget += 1
+ else:
+ info["warning"] = "No suitable substitute found"
+ # Don't consume budget for a failed substitute
+ self._remaining_intervention_budget += 1
+
+ elif action.intervention_type == "add_monitoring":
+ # Tag in metadata but don't change regimen
+ self._medications[target_idx] = target_med.model_copy(
+ update={"beers_flags": target_med.beers_flags + ["monitored"]}
+ )
+
+ self._remaining_intervention_budget -= 1
+ self._current_risk = self._compute_risk()
+ risk_delta = previous_risk - self._current_risk
+ self._risk_deltas.append(risk_delta)
+
+ self._interventions.append(InterventionRecord(
+ target_drug_id=action.target_drug_id,
+ action_type=action.intervention_type,
+ proposed_new_drug_id=action.proposed_new_drug_id,
+ rationale=action.rationale or "",
+ step_index=self._step_count,
+ ))
+
+ reward = compute_shaped_reward(previous_risk, self._current_risk, "propose_intervention")
+ info["risk_delta"] = risk_delta
+ return reward, info
+
+ def _run_grader(self) -> float:
+ assert self._task_cfg is not None
+ tid = self._task_cfg.task_id
+
+ if tid == "easy_screening":
+ severe_pairs = self._get_severe_pairs()
+ return grade_easy_screening(
+ self._baseline_risk,
+ self._current_risk,
+ self._interventions,
+ severe_pairs,
+ )
+ elif tid == "budgeted_screening":
+ return grade_budgeted_screening(
+ self._baseline_risk,
+ self._current_risk,
+ self._interventions,
+ self._risk_deltas,
+ len(self._interaction_queries),
+ self._severe_moderate_discovered,
+ )
+ elif tid == "complex_tradeoff":
+ return grade_complex_tradeoff(
+ self._baseline_risk,
+ self._current_risk,
+ self._interventions,
+ self._total_drug_changes,
+ self._critical_stopped_without_sub,
+ )
+ return 0.0
+
+ def _get_severe_pairs(self) -> List[Tuple[str, str]]:
+ """Return all severe DDI pairs present in the *initial* medication list."""
+ if not self._episode:
+ return []
+ pairs: List[Tuple[str, str]] = []
+ med_ids = self._episode.medication_ids
+ for a, b in combinations(sorted(set(med_ids)), 2):
+ key = (a, b) if a < b else (b, a)
+ rule = self._sim.ddi_rules.get(key)
+ if rule and rule.severity == "severe":
+ pairs.append(key)
+ return pairs
+
+ def _check_timeout_and_build_obs(
+ self, reward: float, info: Dict[str, Any]
+ ) -> PolypharmacyObservation:
+ assert self._task_cfg is not None
+
+ if not self._done and self._step_count >= self._task_cfg.max_steps:
+ self._done = True
+ timeout_penalty = compute_shaped_reward(
+ self._current_risk, self._current_risk,
+ "finish_review", is_timeout=True,
+ )
+ score = self._run_grader()
+ reward += timeout_penalty + score
+ info["timeout"] = True
+ info["grader_score"] = score
+
+ self._last_reward = reward
+ info["current_risk"] = self._current_risk
+ info["baseline_risk"] = self._baseline_risk
+
+ return self._make_observation(reward=reward, info=info)
+
+ def _make_observation(
+ self, reward: float = 0.0, info: Optional[Dict[str, Any]] = None,
+ ) -> PolypharmacyObservation:
+ ep = self._episode
+ cfg = self._task_cfg
+ return PolypharmacyObservation(
+ episode_id=ep.episode_id if ep else "",
+ task_id=cfg.task_id if cfg else "budgeted_screening",
+ age=ep.age if ep else 65,
+ sex=ep.sex if ep else "M",
+ conditions=ep.conditions if ep else [],
+ eGFR_category=ep.eGFR_category if ep else "normal",
+ liver_function_category=ep.liver_function_category if ep else "normal",
+ current_medications=deepcopy(self._medications),
+ interaction_queries=deepcopy(self._interaction_queries),
+ interventions=deepcopy(self._interventions),
+ step_index=self._step_count,
+ remaining_query_budget=self._remaining_query_budget,
+ remaining_intervention_budget=self._remaining_intervention_budget,
+ shaped_reward=reward,
+ done=self._done,
+ reward=reward,
+ metadata=info or {},
+ )
diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/graders.py b/openenv-polypharmacy/backend/src/polypharmacy_env/graders.py
new file mode 100644
index 0000000000000000000000000000000000000000..2b9e8c121757b745a150a98958378f78cc102923
--- /dev/null
+++ b/openenv-polypharmacy/backend/src/polypharmacy_env/graders.py
@@ -0,0 +1,98 @@
+"""Deterministic graders for the three PolypharmacyEnv task difficulties."""
+
+from __future__ import annotations
+
+from itertools import combinations
+from typing import Dict, List, Tuple
+
+from .data_loader import DDIRule
+from .config import CRITICAL_DRUG_IDS
+from .models import InterventionRecord
+
+
+_EPS = 1e-8
+
+
+def _clip(x: float) -> float:
+ return max(0.0, min(x, 1.0))
+
+
+# ── Easy: easy_screening ─────────────────────────────────────────────────────
+
+def grade_easy_screening(
+ baseline_risk: float,
+ final_risk: float,
+ interventions: List[InterventionRecord],
+ severe_ddi_drug_ids: List[Tuple[str, str]],
+) -> float:
+ """Score ∈ [0, 1] for the easy task.
+
+ 50 % risk reduction + 50 % targeted-intervention flag.
+ """
+ risk_reduction = max(0.0, baseline_risk - final_risk) / max(baseline_risk, _EPS)
+
+ targeted = 0.0
+ severe_drugs = set()
+ for a, b in severe_ddi_drug_ids:
+ severe_drugs.add(a)
+ severe_drugs.add(b)
+ for iv in interventions:
+ if iv.target_drug_id in severe_drugs:
+ targeted = 1.0
+ break
+
+ return _clip(0.5 * risk_reduction + 0.5 * targeted)
+
+
+# ── Medium: budgeted_screening ───────────────────────────────────────────────
+
+def grade_budgeted_screening(
+ baseline_risk: float,
+ final_risk: float,
+ interventions: List[InterventionRecord],
+ risk_deltas: List[float],
+ num_queries: int,
+ severe_moderate_discovered: int,
+) -> float:
+ """Score ∈ [0, 1] for the medium task.
+
+ 50 % risk reduction + 30 % intervention precision + 20 % query efficiency.
+ """
+ risk_reduction = max(0.0, baseline_risk - final_risk) / max(baseline_risk, _EPS)
+
+ # Intervention precision: fraction of interventions that reduced risk
+ if interventions:
+ good = sum(1 for d in risk_deltas if d > 0)
+ precision = good / len(interventions)
+ else:
+ precision = 0.0
+
+ # Query efficiency
+ if num_queries > 0:
+ query_eff = min(severe_moderate_discovered / num_queries, 1.0)
+ else:
+ query_eff = 0.0
+
+ return _clip(0.5 * risk_reduction + 0.3 * precision + 0.2 * query_eff)
+
+
+# ── Hard: complex_tradeoff ───────────────────────────────────────────────────
+
+def grade_complex_tradeoff(
+ baseline_risk: float,
+ final_risk: float,
+ interventions: List[InterventionRecord],
+ total_drug_changes: int,
+ critical_drugs_stopped_without_sub: int,
+) -> float:
+ """Score ∈ [0, 1] for the hard task.
+
+ Base = risk reduction; penalty for regimen disruption and critical-drug stops.
+ """
+ risk_reduction = max(0.0, baseline_risk - final_risk) / max(baseline_risk, _EPS)
+
+ # Regimen disruption: penalise excessive changes
+ disruption = 0.05 * total_drug_changes
+ critical_penalty = 0.20 * critical_drugs_stopped_without_sub
+
+ return _clip(risk_reduction - disruption - critical_penalty)
diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/models.py b/openenv-polypharmacy/backend/src/polypharmacy_env/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..69f51f1d819a3867c88d8be81093f9030d78337b
--- /dev/null
+++ b/openenv-polypharmacy/backend/src/polypharmacy_env/models.py
@@ -0,0 +1,111 @@
+"""Pydantic models for the PolypharmacyEnv environment.
+
+Extends OpenEnv base types (Action, Observation, State) and defines
+auxiliary records for medications, interactions, and interventions.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Dict, List, Literal, Optional
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from openenv.core.env_server.types import (
+ Action as OpenEnvAction,
+ Observation as OpenEnvObservation,
+ State as OpenEnvState,
+)
+
+
+# ── Auxiliary models ─────────────────────────────────────────────────────────
+
+class MedicationEntry(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ drug_id: str
+ generic_name: str
+ atc_class: str
+ dose_mg: float
+ frequency: str = "qd"
+ route: str = "po"
+ is_high_risk_elderly: bool = False
+ beers_flags: List[str] = Field(default_factory=list)
+
+
+class InteractionQueryRecord(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ drug_id_1: str
+ drug_id_2: str
+ severity: Optional[str] = None
+ recommendation: Optional[str] = None
+ risk_score: Optional[float] = None
+ step_index: int = 0
+
+
+class InterventionRecord(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ target_drug_id: str
+ action_type: Literal["stop", "dose_reduce", "substitute", "add_monitoring"]
+ proposed_new_drug_id: Optional[str] = None
+ rationale: str = ""
+ step_index: int = 0
+
+
+# ── OpenEnv wire models ─────────────────────────────────────────────────────
+
+class PolypharmacyAction(OpenEnvAction):
+ """Action sent by the agent each step.
+
+ Extends openenv.core.env_server.types.Action.
+ """
+
+ action_type: Literal["query_ddi", "propose_intervention", "finish_review"]
+ drug_id_1: Optional[str] = None
+ drug_id_2: Optional[str] = None
+ target_drug_id: Optional[str] = None
+ intervention_type: Optional[
+ Literal["stop", "dose_reduce", "substitute", "add_monitoring", "none"]
+ ] = None
+ proposed_new_drug_id: Optional[str] = None
+ rationale: Optional[str] = None
+
+
+class PolypharmacyObservation(OpenEnvObservation):
+ """Observation returned to the agent.
+
+ Extends openenv.core.env_server.types.Observation which provides:
+ - done: bool
+ - reward: float | None
+ - metadata: Dict[str, Any]
+ """
+
+ episode_id: str = ""
+ task_id: str = "budgeted_screening"
+ age: int = 65
+ sex: str = "M"
+ conditions: List[str] = Field(default_factory=list)
+ eGFR_category: str = "normal"
+ liver_function_category: str = "normal"
+ current_medications: List[MedicationEntry] = Field(default_factory=list)
+ interaction_queries: List[InteractionQueryRecord] = Field(default_factory=list)
+ interventions: List[InterventionRecord] = Field(default_factory=list)
+ step_index: int = 0
+ remaining_query_budget: int = 0
+ remaining_intervention_budget: int = 0
+ shaped_reward: float = 0.0
+
+
+class PolypharmacyState(OpenEnvState):
+ """Compact state snapshot for the /state endpoint.
+
+ Extends openenv.core.env_server.types.State which provides:
+ - episode_id: str | None
+ - step_count: int
+ """
+
+ task_id: str = ""
+ max_steps: int = 0
+ num_query_actions: int = 0
+ num_interventions: int = 0
diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/rewards.py b/openenv-polypharmacy/backend/src/polypharmacy_env/rewards.py
new file mode 100644
index 0000000000000000000000000000000000000000..6771966f34d7e90285eaaf8264e99debfd6e45b4
--- /dev/null
+++ b/openenv-polypharmacy/backend/src/polypharmacy_env/rewards.py
@@ -0,0 +1,92 @@
+"""Reward shaping and regimen-risk computation."""
+
+from __future__ import annotations
+
+from itertools import combinations
+from typing import Dict, List, Optional, Tuple
+
+from .config import (
+ INTERVENTION_COST,
+ INVALID_ACTION_PENALTY,
+ QUERY_COST,
+ SEVERE_DDI_DISCOVERY_BONUS,
+ TIMEOUT_PENALTY,
+)
+from .data_loader import BeersCriterion, DDIRule, DrugMeta
+
+
+def compute_regimen_risk(
+ current_drug_ids: List[str],
+ patient_conditions: List[str],
+ ddi_rules: Dict[Tuple[str, str], DDIRule],
+ beers_criteria: List[BeersCriterion],
+ drug_metadata: Dict[str, DrugMeta],
+) -> float:
+ """Compute an aggregate risk score for the current medication regimen.
+
+ Returns a float clipped to [0.0, 1.0].
+ """
+ if not current_drug_ids:
+ return 0.0
+
+ risk = 0.0
+ drug_set = set(current_drug_ids)
+
+ # 1. DDI pairwise risk
+ for a, b in combinations(sorted(drug_set), 2):
+ key = (a, b) if a < b else (b, a)
+ rule = ddi_rules.get(key)
+ if rule is not None:
+ risk += rule.base_risk_score
+
+ # 2. Beers violations
+ beers_weight = {"avoid": 0.25, "caution": 0.10, "dose_adjust": 0.08, "avoid_in_condition": 0.20}
+ for bc in beers_criteria:
+ if bc.drug_id not in drug_set:
+ continue
+ if bc.condition is None:
+ risk += beers_weight.get(bc.criterion_type, 0.05)
+ elif bc.condition in patient_conditions:
+ risk += beers_weight.get(bc.criterion_type, 0.05)
+
+ # 3. High-risk elderly drugs
+ for did in drug_set:
+ dm = drug_metadata.get(did)
+ if dm and dm.is_high_risk_elderly:
+ risk += 0.05
+
+ # Normalise by regimen size to keep score comparable across difficulties
+ risk /= max(len(drug_set), 1)
+ return min(max(risk, 0.0), 1.0)
+
+
+def compute_shaped_reward(
+ previous_risk: float,
+ new_risk: float,
+ action_type: str,
+ *,
+ is_invalid: bool = False,
+ is_timeout: bool = False,
+ discovered_severe: bool = False,
+) -> float:
+ """Compute the step-level shaped reward."""
+ reward = 0.0
+
+ if is_invalid:
+ return -INVALID_ACTION_PENALTY
+
+ if is_timeout:
+ return -TIMEOUT_PENALTY
+
+ if action_type == "query_ddi":
+ reward -= QUERY_COST
+ if discovered_severe:
+ reward += SEVERE_DDI_DISCOVERY_BONUS
+
+ elif action_type == "propose_intervention":
+ reward += (previous_risk - new_risk)
+ reward -= INTERVENTION_COST
+
+ # finish_review terminal bonus is added by the caller after grading
+
+ return reward
diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/services/__init__.py b/openenv-polypharmacy/backend/src/polypharmacy_env/services/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..daa00ba9855c5ea54e0e4d3fe0a29407c421340a
--- /dev/null
+++ b/openenv-polypharmacy/backend/src/polypharmacy_env/services/__init__.py
@@ -0,0 +1 @@
+"""Service layer for external integrations."""
diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/services/groq_agent.py b/openenv-polypharmacy/backend/src/polypharmacy_env/services/groq_agent.py
new file mode 100644
index 0000000000000000000000000000000000000000..9f15f22ff2ec9f4710949815b29f0431b2cac63b
--- /dev/null
+++ b/openenv-polypharmacy/backend/src/polypharmacy_env/services/groq_agent.py
@@ -0,0 +1,246 @@
+"""Groq-powered action suggester for PolypharmacyEnv."""
+
+from __future__ import annotations
+
+import json
+import os
+from typing import Any
+
+from openai import OpenAI
+
+from ..models import PolypharmacyAction, PolypharmacyObservation
+
+DEFAULT_MODEL = "llama-3.1-8b-instant"
+FALLBACK_MODELS = [
+ "llama-3.1-8b-instant",
+ "llama-3.3-70b-versatile",
+ "gemma2-9b-it",
+]
+CRITICAL_DRUG_IDS = {"DRUG_WARFARIN", "DRUG_INSULIN_GLARGINE", "DRUG_DIGOXIN"}
+
+SYSTEM_PROMPT = """You are a clinical medication safety assistant.
+Return exactly one JSON object describing the next action.
+Allowed output schema:
+{
+ "action_type": "query_ddi" | "propose_intervention" | "finish_review",
+ "drug_id_1": "optional",
+ "drug_id_2": "optional",
+ "target_drug_id": "optional",
+ "intervention_type": "stop|dose_reduce|substitute|add_monitoring|none",
+ "proposed_new_drug_id": "optional",
+ "rationale": "optional"
+}
+No markdown fences. No extra text.
+Do NOT use finish_review early. First, gather evidence with query_ddi and/or
+perform at least one meaningful intervention when needed.
+"""
+
+
+def _obs_to_prompt(obs: PolypharmacyObservation) -> str:
+ meds = ", ".join(m.drug_id for m in obs.current_medications)
+ conds = ", ".join(obs.conditions)
+ return (
+ f"Task: {obs.task_id}\n"
+ f"Age: {obs.age}, sex: {obs.sex}\n"
+ f"Conditions: {conds}\n"
+ f"Medications: {meds}\n"
+ f"Query budget: {obs.remaining_query_budget}\n"
+ f"Intervention budget: {obs.remaining_intervention_budget}\n"
+ f"Step index: {obs.step_index}\n"
+ "Choose the single safest, most useful next action."
+ )
+
+
+def _parse_action(text: str) -> PolypharmacyAction:
+ raw = text.strip()
+ if raw.startswith("```"):
+ raw = raw.split("\n", 1)[-1]
+ if raw.endswith("```"):
+ raw = raw.rsplit("```", 1)[0]
+ raw = raw.strip()
+ payload: dict[str, Any] = json.loads(raw)
+ return PolypharmacyAction.model_validate(payload)
+
+
+def _fallback_query_action(obs: PolypharmacyObservation) -> PolypharmacyAction:
+ meds = [m.drug_id for m in obs.current_medications]
+ if len(meds) >= 2 and obs.remaining_query_budget > 0:
+ return PolypharmacyAction(
+ action_type="query_ddi",
+ drug_id_1=meds[0],
+ drug_id_2=meds[1],
+ )
+ return PolypharmacyAction(action_type="finish_review")
+
+
+def _norm_pair(a: str, b: str) -> tuple[str, str]:
+ return (a, b) if a < b else (b, a)
+
+
+def _pick_unseen_query_pair(obs: PolypharmacyObservation) -> tuple[str, str] | None:
+ meds = [m.drug_id for m in obs.current_medications]
+ if len(meds) < 2 or obs.remaining_query_budget <= 0:
+ return None
+
+ seen = {
+ _norm_pair(q.drug_id_1, q.drug_id_2)
+ for q in obs.interaction_queries
+ }
+ # Prioritize pairs containing high-risk drugs.
+ high_risk = [m.drug_id for m in obs.current_medications if m.is_high_risk_elderly]
+ ordered = high_risk + [m for m in meds if m not in set(high_risk)]
+
+ for i in range(len(ordered)):
+ for j in range(i + 1, len(ordered)):
+ p = _norm_pair(ordered[i], ordered[j])
+ if p not in seen:
+ return p
+ return None
+
+
+def _pick_intervention_target(obs: PolypharmacyObservation) -> str | None:
+ if obs.remaining_intervention_budget <= 0:
+ return None
+ med_set = {m.drug_id for m in obs.current_medications}
+
+ # Use latest discovered severe/moderate query as intervention target.
+ for q in reversed(obs.interaction_queries):
+ if q.severity in ("severe", "moderate"):
+ m1 = next((m for m in obs.current_medications if m.drug_id == q.drug_id_1), None)
+ m2 = next((m for m in obs.current_medications if m.drug_id == q.drug_id_2), None)
+ candidates = [m for m in (m1, m2) if m is not None]
+ if not candidates:
+ continue
+ # Prefer non-critical risky drugs first.
+ candidates.sort(
+ key=lambda m: (
+ m.drug_id in CRITICAL_DRUG_IDS,
+ 0 if any("avoid" in f for f in m.beers_flags) else 1,
+ 0 if m.is_high_risk_elderly else 1,
+ )
+ )
+ return candidates[0].drug_id
+
+ # Fallback: if no severe/moderate discovered, still intervene on obviously
+ # risky medications (Beers/high-risk flags) when budgets permit.
+ risky = sorted(
+ obs.current_medications,
+ key=lambda m: (
+ 0 if any("avoid" in f for f in m.beers_flags) else 1,
+ 0 if m.is_high_risk_elderly else 1,
+ 1 if m.drug_id in CRITICAL_DRUG_IDS else 0,
+ ),
+ )
+ for med in risky:
+ if any("avoid" in f for f in med.beers_flags) or med.is_high_risk_elderly:
+ return med.drug_id
+ return None
+
+
+def _rule_based_action(obs: PolypharmacyObservation) -> PolypharmacyAction | None:
+ # If we already discovered significant risk, intervene before more querying.
+ target = _pick_intervention_target(obs)
+ if target and (
+ obs.step_index >= 1
+ and (
+ obs.remaining_query_budget <= 2
+ or len(obs.interaction_queries) >= 4
+ or any(q.severity in ("severe", "moderate") for q in obs.interaction_queries)
+ )
+ ):
+ intervention = "stop"
+ rationale = "Remove likely contributor to discovered interaction risk"
+ if target in CRITICAL_DRUG_IDS:
+ # Avoid blunt stop for critical meds.
+ intervention = "dose_reduce"
+ rationale = "Critical medication: prefer dose reduction over abrupt stop"
+ return PolypharmacyAction(
+ action_type="propose_intervention",
+ target_drug_id=target,
+ intervention_type=intervention,
+ rationale=rationale,
+ )
+
+ pair = _pick_unseen_query_pair(obs)
+ if pair:
+ return PolypharmacyAction(
+ action_type="query_ddi",
+ drug_id_1=pair[0],
+ drug_id_2=pair[1],
+ )
+
+ if obs.remaining_intervention_budget > 0:
+ # Final fallback before finish: at least one safety action.
+ target = _pick_intervention_target(obs)
+ if target:
+ return PolypharmacyAction(
+ action_type="propose_intervention",
+ target_drug_id=target,
+ intervention_type="dose_reduce"
+ if target in CRITICAL_DRUG_IDS
+ else "stop",
+ rationale="Fallback intervention when query options are exhausted",
+ )
+
+ if obs.step_index >= 3:
+ return PolypharmacyAction(action_type="finish_review")
+ return None
+
+
+def _postprocess_action(
+ obs: PolypharmacyObservation, action: PolypharmacyAction
+) -> PolypharmacyAction:
+ # First apply deterministic guardrails to avoid repetitive loops.
+ ruled = _rule_based_action(obs)
+ if ruled is not None:
+ return ruled
+
+ # Guardrail: prevent useless immediate finish actions.
+ if action.action_type == "finish_review":
+ if obs.step_index < 2 and obs.remaining_query_budget > 0:
+ return _fallback_query_action(obs)
+ if len(obs.interaction_queries) == 0 and obs.remaining_query_budget > 0:
+ return _fallback_query_action(obs)
+ return action
+
+
+def suggest_action_from_observation(
+ observation: PolypharmacyObservation,
+ model_name: str | None = None,
+) -> PolypharmacyAction:
+ """Use Groq chat completions to suggest a valid action."""
+ api_key = os.getenv("GROQ_API_KEY", "").strip()
+ if not api_key:
+ raise ValueError("GROQ_API_KEY is missing. Add it to your .env file.")
+
+ base_url = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1").strip()
+ model = (model_name or os.getenv("GROQ_MODEL_NAME", DEFAULT_MODEL)).strip()
+ client = OpenAI(api_key=api_key, base_url=base_url)
+
+ user_prompt = _obs_to_prompt(observation)
+ tried: list[tuple[str, str]] = []
+ candidates: list[str] = [model] + [m for m in FALLBACK_MODELS if m != model]
+
+ for candidate in candidates:
+ try:
+ resp = client.chat.completions.create(
+ model=candidate,
+ messages=[
+ {"role": "system", "content": SYSTEM_PROMPT},
+ {"role": "user", "content": user_prompt},
+ ],
+ temperature=0.2,
+ max_tokens=220,
+ )
+ generated = (resp.choices[0].message.content or "").strip()
+ parsed = _parse_action(generated)
+ return _postprocess_action(observation, parsed)
+ except Exception as exc:
+ tried.append((candidate, str(exc)))
+
+ tried_txt = " | ".join(f"{m}: {err}" for m, err in tried)
+ raise ValueError(
+ "No Groq model worked. Try one of: "
+ "llama-3.3-70b-versatile, llama-3.1-8b-instant, gemma2-9b-it. "
+ f"Errors: {tried_txt}"
+ )
diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/tasks.py b/openenv-polypharmacy/backend/src/polypharmacy_env/tasks.py
new file mode 100644
index 0000000000000000000000000000000000000000..ee077d9d60e13ae19b399f3e4d452a10fb2ea2c2
--- /dev/null
+++ b/openenv-polypharmacy/backend/src/polypharmacy_env/tasks.py
@@ -0,0 +1,47 @@
+"""Task setup utilities: select episodes and configure budgets per difficulty."""
+
+from __future__ import annotations
+
+import random
+from typing import Optional
+
+from .config import DEFAULT_TASK, TASK_CONFIGS, TaskConfig
+from .data_loader import PatientEpisode, load_patients
+
+
+# Map OpenEnv difficulty labels to the CSV difficulty tags
+_DIFFICULTY_MAP = {
+ "easy_screening": "easy",
+ "budgeted_screening": "medium",
+ "complex_tradeoff": "hard",
+}
+
+
+def get_task_config(task_id: Optional[str] = None) -> TaskConfig:
+ tid = task_id or DEFAULT_TASK
+ cfg = TASK_CONFIGS.get(tid)
+ if cfg is None:
+ raise ValueError(f"Unknown task_id {tid!r}. Choose from {list(TASK_CONFIGS)}")
+ return cfg
+
+
+def sample_episode(
+ task_id: Optional[str] = None,
+ seed: Optional[int] = None,
+ episode_id: Optional[str] = None,
+) -> PatientEpisode:
+ """Return a single patient episode appropriate for *task_id*."""
+ tid = task_id or DEFAULT_TASK
+ difficulty = _DIFFICULTY_MAP.get(tid, "medium")
+ episodes = load_patients(difficulty=difficulty)
+ if not episodes:
+ raise RuntimeError(f"No episodes found for difficulty={difficulty!r}")
+
+ if episode_id:
+ for ep in episodes:
+ if ep.episode_id == episode_id:
+ return ep
+ raise ValueError(f"Episode {episode_id!r} not found for difficulty={difficulty!r}")
+
+ rng = random.Random(seed)
+ return rng.choice(episodes)
diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/tests/__init__.py b/openenv-polypharmacy/backend/src/polypharmacy_env/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..d420712d8bbf0195c02e3317e4395b2c2ffeacf6
--- /dev/null
+++ b/openenv-polypharmacy/backend/src/polypharmacy_env/tests/__init__.py
@@ -0,0 +1 @@
+"""Tests."""
diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/tests/test_api.py b/openenv-polypharmacy/backend/src/polypharmacy_env/tests/test_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..6ba69ae65100a6611dc3883c4a286dfb3c821f2c
--- /dev/null
+++ b/openenv-polypharmacy/backend/src/polypharmacy_env/tests/test_api.py
@@ -0,0 +1,146 @@
+"""Tests for the FastAPI HTTP server (OpenEnv create_app endpoints).
+
+OpenEnv HTTP endpoints are *stateless*: each /reset and /step creates a
+fresh environment instance. Multi-step sessions only work via WebSocket.
+These tests validate single-call behaviour and schema contracts.
+"""
+
+from __future__ import annotations
+
+import json
+
+import pytest
+from fastapi.testclient import TestClient
+
+from polypharmacy_env.api.server import app
+
+
+@pytest.fixture
+def client() -> TestClient:
+ return TestClient(app)
+
+
+class TestHealth:
+ def test_health(self, client: TestClient) -> None:
+ resp = client.get("/health")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["status"] == "healthy"
+
+
+class TestReset:
+ def test_reset_default(self, client: TestClient) -> None:
+ resp = client.post("/reset", json={})
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "observation" in data
+ assert data["done"] is False
+
+ def test_reset_with_task(self, client: TestClient) -> None:
+ resp = client.post("/reset", json={"task_id": "easy_screening"})
+ assert resp.status_code == 200
+ obs = resp.json()["observation"]
+ assert obs["task_id"] == "easy_screening"
+
+ def test_reset_observation_has_medications(self, client: TestClient) -> None:
+ resp = client.post("/reset", json={"task_id": "easy_screening", "seed": 42})
+ assert resp.status_code == 200
+ obs = resp.json()["observation"]
+ assert len(obs["current_medications"]) >= 3
+
+
+class TestStep:
+ """Test /step endpoint – each call is independent (stateless)."""
+
+ def test_step_finish(self, client: TestClient) -> None:
+ resp = client.post(
+ "/step",
+ json={"action": {"action_type": "finish_review"}},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "observation" in data
+
+ def test_invalid_action_422(self, client: TestClient) -> None:
+ resp = client.post(
+ "/step",
+ json={"action": {"action_type": "invalid_type"}},
+ )
+ assert resp.status_code == 422
+
+
+class TestSchema:
+ def test_schema(self, client: TestClient) -> None:
+ resp = client.get("/schema")
+ assert resp.status_code == 200
+ data = resp.json()
+ # OpenEnv schema endpoint returns keys: action, observation, state
+ assert "action" in data
+ assert "observation" in data
+
+
+class TestWebSocketSession:
+ """Test multi-step sessions through the /ws WebSocket endpoint.
+
+ OpenEnv WS protocol:
+ Send: {"type": "reset", "data": {"task_id": "...", "seed": ...}}
+ Recv: {"type": "observation", "data": {"observation": {...}, "reward": ..., "done": ...}}
+ Send: {"type": "step", "data": {"action_type": "...", ...}}
+ Recv: {"type": "observation", "data": {"observation": {...}, ...}}
+ Send: {"type": "state"}
+ Recv: {"type": "state", "data": {...state fields...}}
+ """
+
+ def test_ws_reset_step_finish(self, client: TestClient) -> None:
+ with client.websocket_connect("/ws") as ws:
+ # Reset
+ ws.send_json({
+ "type": "reset",
+ "data": {"task_id": "easy_screening", "seed": 42},
+ })
+ reset_resp = ws.receive_json()
+ assert reset_resp["type"] == "observation"
+ reset_data = reset_resp["data"]
+ assert reset_data["done"] is False
+ obs = reset_data["observation"]
+ assert obs["task_id"] == "easy_screening"
+ meds = obs["current_medications"]
+ assert len(meds) >= 3
+
+ # Step – query DDI
+ if len(meds) >= 2:
+ ws.send_json({
+ "type": "step",
+ "data": {
+ "action_type": "query_ddi",
+ "drug_id_1": meds[0]["drug_id"],
+ "drug_id_2": meds[1]["drug_id"],
+ },
+ })
+ step_resp = ws.receive_json()
+ assert step_resp["type"] == "observation"
+ assert step_resp["data"]["done"] is False
+
+ # Finish
+ ws.send_json({
+ "type": "step",
+ "data": {"action_type": "finish_review"},
+ })
+ finish_resp = ws.receive_json()
+ assert finish_resp["type"] == "observation"
+ assert finish_resp["data"]["done"] is True
+
+ def test_ws_state(self, client: TestClient) -> None:
+ with client.websocket_connect("/ws") as ws:
+ ws.send_json({
+ "type": "reset",
+ "data": {"task_id": "easy_screening", "seed": 0},
+ })
+ ws.receive_json() # consume reset response
+
+ ws.send_json({"type": "state"})
+ state_resp = ws.receive_json()
+ assert state_resp["type"] == "state"
+ state_data = state_resp["data"]
+ assert state_data["step_count"] == 0
+ assert state_data["task_id"] == "easy_screening"
diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/tests/test_env_core.py b/openenv-polypharmacy/backend/src/polypharmacy_env/tests/test_env_core.py
new file mode 100644
index 0000000000000000000000000000000000000000..f3b9fc35fb49896c88ea4fc8e72f459fc3fdea84
--- /dev/null
+++ b/openenv-polypharmacy/backend/src/polypharmacy_env/tests/test_env_core.py
@@ -0,0 +1,192 @@
+"""Tests for the PolypharmacyEnv core."""
+
+from __future__ import annotations
+
+import pytest
+
+from polypharmacy_env.env_core import PolypharmacyEnv
+from polypharmacy_env.models import (
+ PolypharmacyAction,
+ PolypharmacyObservation,
+ PolypharmacyState,
+)
+
+
+class TestReset:
+ def test_reset_returns_observation(self) -> None:
+ env = PolypharmacyEnv()
+ obs = env.reset(task_id="easy_screening", seed=42)
+ assert isinstance(obs, PolypharmacyObservation)
+ assert obs.done is False
+ assert obs.step_index == 0
+ assert len(obs.current_medications) >= 3
+
+ def test_reset_medium(self) -> None:
+ env = PolypharmacyEnv()
+ obs = env.reset(task_id="budgeted_screening", seed=0)
+ assert obs.remaining_query_budget == 8
+ assert obs.remaining_intervention_budget == 3
+ assert len(obs.current_medications) >= 6
+
+ def test_reset_hard(self) -> None:
+ env = PolypharmacyEnv()
+ obs = env.reset(task_id="complex_tradeoff", seed=0)
+ assert obs.remaining_query_budget == 12
+ assert obs.remaining_intervention_budget == 5
+ assert len(obs.current_medications) >= 10
+
+ def test_default_task(self) -> None:
+ env = PolypharmacyEnv()
+ obs = env.reset(seed=0)
+ assert obs.task_id == "budgeted_screening"
+
+
+class TestStep:
+ def test_query_ddi(self) -> None:
+ env = PolypharmacyEnv()
+ obs = env.reset(task_id="easy_screening", seed=42)
+ meds = obs.current_medications
+ assert len(meds) >= 2
+
+ action = PolypharmacyAction(
+ action_type="query_ddi",
+ drug_id_1=meds[0].drug_id,
+ drug_id_2=meds[1].drug_id,
+ )
+ obs = env.step(action)
+ assert isinstance(obs, PolypharmacyObservation)
+ assert obs.done is False
+ assert obs.step_index == 1
+ assert len(obs.interaction_queries) == 1
+
+ def test_invalid_action_penalised(self) -> None:
+ env = PolypharmacyEnv()
+ env.reset(task_id="easy_screening", seed=42)
+
+ action = PolypharmacyAction(
+ action_type="propose_intervention",
+ target_drug_id=None,
+ intervention_type=None,
+ )
+ obs = env.step(action)
+ assert obs.reward is not None
+ assert obs.reward < 0 # penalty
+
+ def test_finish_review(self) -> None:
+ env = PolypharmacyEnv()
+ env.reset(task_id="easy_screening", seed=42)
+
+ action = PolypharmacyAction(action_type="finish_review")
+ obs = env.step(action)
+ assert obs.done is True
+ assert "grader_score" in obs.metadata
+ score = obs.metadata["grader_score"]
+ assert 0.0 <= score <= 1.0
+
+ def test_intervention_stop(self) -> None:
+ env = PolypharmacyEnv()
+ obs = env.reset(task_id="easy_screening", seed=42)
+ target = obs.current_medications[0].drug_id
+ n_meds = len(obs.current_medications)
+
+ action = PolypharmacyAction(
+ action_type="propose_intervention",
+ target_drug_id=target,
+ intervention_type="stop",
+ rationale="test stop",
+ )
+ obs = env.step(action)
+ assert len(obs.current_medications) == n_meds - 1
+
+ def test_budget_exhaustion(self) -> None:
+ env = PolypharmacyEnv()
+ obs = env.reset(task_id="easy_screening", seed=42)
+ meds = obs.current_medications
+
+ # Exhaust query budget (4 for easy)
+ for i in range(4):
+ a_idx = i % len(meds)
+ b_idx = (i + 1) % len(meds)
+ action = PolypharmacyAction(
+ action_type="query_ddi",
+ drug_id_1=meds[a_idx].drug_id,
+ drug_id_2=meds[b_idx].drug_id,
+ )
+ obs = env.step(action)
+ if obs.done:
+ break
+
+ if not obs.done:
+ assert obs.remaining_query_budget == 0
+ # Trying another query should be penalised
+ action = PolypharmacyAction(
+ action_type="query_ddi",
+ drug_id_1=meds[0].drug_id,
+ drug_id_2=meds[1].drug_id,
+ )
+ obs = env.step(action)
+ assert obs.reward is not None
+ assert obs.reward < 0
+
+ def test_max_steps_timeout(self) -> None:
+ env = PolypharmacyEnv()
+ obs = env.reset(task_id="easy_screening", seed=42) # max_steps=10
+ meds = obs.current_medications
+
+ # Keep querying until timeout
+ for i in range(15):
+ if obs.done:
+ break
+ a = meds[i % len(meds)].drug_id
+ b = meds[(i + 1) % len(meds)].drug_id
+ action = PolypharmacyAction(
+ action_type="query_ddi",
+ drug_id_1=a,
+ drug_id_2=b,
+ )
+ obs = env.step(action)
+
+ assert obs.done is True
+
+
+class TestState:
+ def test_state_after_reset(self) -> None:
+ env = PolypharmacyEnv()
+ env.reset(task_id="easy_screening", seed=42)
+ st = env.state
+ assert isinstance(st, PolypharmacyState)
+ assert st.step_count == 0
+ assert st.episode_id is not None
+
+
+class TestGraderDeterminism:
+ def test_same_trajectory_same_score(self) -> None:
+ scores = []
+ for _ in range(3):
+ env = PolypharmacyEnv()
+ env.reset(task_id="easy_screening", seed=99)
+ obs = env.step(PolypharmacyAction(action_type="finish_review"))
+ scores.append(obs.metadata.get("grader_score", 0.0))
+ assert all(s == scores[0] for s in scores)
+
+ def test_intervention_changes_score(self) -> None:
+ # No intervention
+ env = PolypharmacyEnv()
+ env.reset(task_id="budgeted_screening", seed=42)
+ obs = env.step(PolypharmacyAction(action_type="finish_review"))
+ score_noop = obs.metadata.get("grader_score", 0.0)
+
+ # With intervention
+ env2 = PolypharmacyEnv()
+ obs_init2 = env2.reset(task_id="budgeted_screening", seed=42)
+ if obs_init2.current_medications:
+ env2.step(PolypharmacyAction(
+ action_type="propose_intervention",
+ target_drug_id=obs_init2.current_medications[0].drug_id,
+ intervention_type="stop",
+ rationale="test",
+ ))
+ obs2 = env2.step(PolypharmacyAction(action_type="finish_review"))
+ score_act = obs2.metadata.get("grader_score", 0.0)
+
+ assert score_noop != score_act
diff --git a/openenv-polypharmacy/data/lookups/beers_criteria.csv b/openenv-polypharmacy/data/lookups/beers_criteria.csv
new file mode 100644
index 0000000000000000000000000000000000000000..019b1ecfc2c7935e9c03a761a3df704dc3993f32
--- /dev/null
+++ b/openenv-polypharmacy/data/lookups/beers_criteria.csv
@@ -0,0 +1,16 @@
+drug_id,criterion_type,condition,rationale
+DRUG_DIAZEPAM,avoid,,"Long-acting benzodiazepine: falls, fractures, cognitive impairment in elderly"
+DRUG_ALPRAZOLAM,avoid,,"Benzodiazepine: falls, fractures, cognitive impairment in elderly"
+DRUG_AMITRIPTYLINE,avoid,,"Strongly anticholinergic TCA: sedation, confusion, urinary retention in elderly"
+DRUG_GLIPIZIDE,caution,,Sulfonylurea: hypoglycemia risk higher in elderly
+DRUG_NAPROXEN,avoid,CKD,"NSAID contraindicated in CKD – renal deterioration, fluid retention"
+DRUG_IBUPROFEN,avoid,CKD,"NSAID contraindicated in CKD – renal deterioration, fluid retention"
+DRUG_NAPROXEN,caution,,NSAID: GI bleeding and renal risk in elderly
+DRUG_IBUPROFEN,caution,,NSAID: GI bleeding and renal risk in elderly
+DRUG_DIGOXIN,dose_adjust,,Avoid doses > 0.125 mg/day in elderly – toxicity risk
+DRUG_TRAMADOL,avoid,,"Opioid: CNS depression, falls, constipation in elderly"
+DRUG_METFORMIN,dose_adjust,CKD,Reduce dose or avoid if eGFR < 30 – lactic acidosis risk
+DRUG_INSULIN_GLARGINE,caution,,Tight glycemic control increases hypoglycemia risk in elderly
+DRUG_PREDNISONE,avoid_in_condition,DM,Corticosteroid worsens glycemic control in diabetes
+DRUG_DONEPEZIL,avoid_in_condition,dementia,"Limited benefit, GI side effects; reassess regularly"
+DRUG_CIPROFLOXACIN,caution,,"Fluoroquinolone: tendon rupture, QT prolongation risk in elderly"
diff --git a/openenv-polypharmacy/data/lookups/ddi_rules.csv b/openenv-polypharmacy/data/lookups/ddi_rules.csv
new file mode 100644
index 0000000000000000000000000000000000000000..dc8adc9362d8da83517374a94044c0ebd06b2e74
--- /dev/null
+++ b/openenv-polypharmacy/data/lookups/ddi_rules.csv
@@ -0,0 +1,25 @@
+drug_id_1,drug_id_2,severity,mechanism,recommendation,base_risk_score
+DRUG_NAPROXEN,DRUG_WARFARIN,severe,Increased bleeding risk – NSAID inhibits platelet + anticoagulant,avoid_combination,0.9
+DRUG_IBUPROFEN,DRUG_WARFARIN,severe,Increased bleeding risk – NSAID + anticoagulant synergy,avoid_combination,0.88
+DRUG_ASPIRIN,DRUG_WARFARIN,moderate,Additive antiplatelet + anticoagulant bleeding risk,monitor_closely,0.55
+DRUG_FLUOXETINE,DRUG_WARFARIN,moderate,SSRI increases serotonin and may potentiate bleeding,monitor_closely,0.45
+DRUG_CIPROFLOXACIN,DRUG_WARFARIN,moderate,CYP1A2 inhibition raises warfarin levels,dose_adjust,0.5
+DRUG_APIXABAN,DRUG_NAPROXEN,severe,DOAC + NSAID – high bleeding risk,avoid_combination,0.85
+DRUG_APIXABAN,DRUG_ASPIRIN,moderate,Additive bleeding risk with antiplatelet,monitor_closely,0.5
+DRUG_AMIODARONE,DRUG_DIGOXIN,severe,Amiodarone increases digoxin levels – toxicity risk,dose_adjust,0.8
+DRUG_DIGOXIN,DRUG_SPIRONOLACTONE,moderate,Spironolactone may raise digoxin levels,monitor_closely,0.4
+DRUG_CIPROFLOXACIN,DRUG_METFORMIN,moderate,Fluoroquinolone may cause dysglycemia with metformin,monitor_closely,0.35
+DRUG_DIAZEPAM,DRUG_TRAMADOL,severe,CNS depression – benzodiazepine + opioid,avoid_combination,0.92
+DRUG_ALPRAZOLAM,DRUG_TRAMADOL,severe,CNS depression – benzodiazepine + opioid,avoid_combination,0.91
+DRUG_LISINOPRIL,DRUG_SPIRONOLACTONE,moderate,Hyperkalemia risk – ACE-I + K-sparing diuretic,monitor_closely,0.48
+DRUG_LISINOPRIL,DRUG_NAPROXEN,moderate,"NSAID reduces ACE-I efficacy, renal risk",monitor_closely,0.42
+DRUG_AMLODIPINE,DRUG_SIMVASTATIN,moderate,CYP3A4 interaction increases statin exposure,dose_adjust,0.38
+DRUG_ATORVASTATIN,DRUG_CIPROFLOXACIN,mild,Minor CYP interaction raising statin levels,no_action,0.15
+DRUG_CLOPIDOGREL,DRUG_OMEPRAZOLE,moderate,PPI reduces clopidogrel activation via CYP2C19,dose_adjust,0.45
+DRUG_GLIPIZIDE,DRUG_INSULIN_GLARGINE,moderate,Additive hypoglycemia risk,monitor_closely,0.5
+DRUG_FLUOXETINE,DRUG_TRAMADOL,severe,Serotonin syndrome risk – SSRI + serotonergic opioid,avoid_combination,0.82
+DRUG_AMITRIPTYLINE,DRUG_TRAMADOL,severe,Serotonin syndrome + CNS depression,avoid_combination,0.85
+DRUG_DIGOXIN,DRUG_METOPROLOL,moderate,Additive bradycardia,monitor_closely,0.4
+DRUG_DIGOXIN,DRUG_FUROSEMIDE,moderate,Loop diuretic causes hypokalemia increasing digoxin toxicity risk,monitor_closely,0.45
+DRUG_NAPROXEN,DRUG_PREDNISONE,moderate,GI bleeding risk – corticosteroid + NSAID,monitor_closely,0.5
+DRUG_PREDNISONE,DRUG_WARFARIN,mild,Corticosteroid may alter INR,monitor_closely,0.25
diff --git a/openenv-polypharmacy/data/lookups/drug_metadata.csv b/openenv-polypharmacy/data/lookups/drug_metadata.csv
new file mode 100644
index 0000000000000000000000000000000000000000..f3ba924648540f5981feaecfc63f608ecac2ecba
--- /dev/null
+++ b/openenv-polypharmacy/data/lookups/drug_metadata.csv
@@ -0,0 +1,34 @@
+drug_id,generic_name,atc_class,is_high_risk_elderly,default_dose_mg,min_dose_mg,max_dose_mg
+DRUG_WARFARIN,warfarin,B01AA,1,5.0,1.0,10.0
+DRUG_APIXABAN,apixaban,B01AF,1,5.0,2.5,10.0
+DRUG_METFORMIN,metformin,A10BA,0,1000,500,2000
+DRUG_GLIPIZIDE,glipizide,A10BB,1,5.0,2.5,20.0
+DRUG_LISINOPRIL,lisinopril,C09AA,0,10.0,2.5,40.0
+DRUG_AMLODIPINE,amlodipine,C08CA,0,5.0,2.5,10.0
+DRUG_METOPROLOL,metoprolol,C07AB,0,50.0,25.0,200.0
+DRUG_DIGOXIN,digoxin,C01AA,1,0.25,0.0625,0.5
+DRUG_FUROSEMIDE,furosemide,C03CA,0,40.0,20.0,160.0
+DRUG_SPIRONOLACTONE,spironolactone,C03DA,0,25.0,12.5,50.0
+DRUG_ATORVASTATIN,atorvastatin,C10AA,0,20.0,10.0,80.0
+DRUG_SIMVASTATIN,simvastatin,C10AA,0,20.0,10.0,40.0
+DRUG_OMEPRAZOLE,omeprazole,A02BC,0,20.0,10.0,40.0
+DRUG_DIAZEPAM,diazepam,N05BA,1,5.0,2.0,10.0
+DRUG_ALPRAZOLAM,alprazolam,N05BA,1,0.5,0.25,2.0
+DRUG_AMITRIPTYLINE,amitriptyline,N06AA,1,25.0,10.0,75.0
+DRUG_INSULIN_GLARGINE,insulin glargine,A10AE,1,20.0,10.0,60.0
+DRUG_PREDNISONE,prednisone,H02AB,0,10.0,5.0,60.0
+DRUG_NAPROXEN,naproxen,M01AE,1,500,250,1000
+DRUG_IBUPROFEN,ibuprofen,M01AE,1,400,200,800
+DRUG_CLOPIDOGREL,clopidogrel,B01AC,0,75.0,75.0,75.0
+DRUG_ASPIRIN,aspirin,B01AC,0,81.0,81.0,325.0
+DRUG_HYDROCHLOROTHIAZIDE,HCTZ,C03AA,0,25.0,12.5,50.0
+DRUG_DONEPEZIL,donepezil,N06DA,0,5.0,5.0,10.0
+DRUG_GABAPENTIN,gabapentin,N03AX,0,300,100,1200
+DRUG_TRAMADOL,tramadol,N02AX,1,50.0,25.0,200.0
+DRUG_FLUOXETINE,fluoxetine,N06AB,0,20.0,10.0,60.0
+DRUG_SERTRALINE,sertraline,N06AB,0,50.0,25.0,200.0
+DRUG_CIPROFLOXACIN,ciprofloxacin,J01MA,0,500,250,750
+DRUG_TAMSULOSIN,tamsulosin,G04CA,0,0.4,0.4,0.8
+DRUG_CELECOXIB,celecoxib,M01AE,0,200,100,400
+DRUG_NORTRIPTYLINE,nortriptyline,N06AA,0,25.0,10.0,75.0
+DRUG_LOSARTAN,losartan,C09AA,0,50.0,25.0,100.0
diff --git a/openenv-polypharmacy/data/processed/patients_polypharmacy.csv b/openenv-polypharmacy/data/processed/patients_polypharmacy.csv
new file mode 100644
index 0000000000000000000000000000000000000000..3dfd4b83053958e03a785dd8bd057c7c5879c74b
--- /dev/null
+++ b/openenv-polypharmacy/data/processed/patients_polypharmacy.csv
@@ -0,0 +1,121 @@
+episode_id,age,sex,conditions,eGFR_category,liver_function_category,medication_ids,baseline_risk_score,difficulty
+EP_0001,72,F,HTN,moderate,normal,DRUG_AMLODIPINE;DRUG_LISINOPRIL;DRUG_DIGOXIN;DRUG_FUROSEMIDE;DRUG_AMIODARONE,0.25,easy
+EP_0002,67,M,OA;COPD;neuropathy,normal,normal,DRUG_IBUPROFEN;DRUG_TRAMADOL;DRUG_AMITRIPTYLINE,0.2833,easy
+EP_0003,73,F,HTN;HF,normal,normal,DRUG_FUROSEMIDE;DRUG_FLUOXETINE;DRUG_TRAMADOL,0.2733,easy
+EP_0004,74,M,CKD,mild,impaired,DRUG_AMITRIPTYLINE;DRUG_AMLODIPINE;DRUG_TRAMADOL,0.2833,easy
+EP_0005,76,F,OA;neuropathy;CKD,mild,normal,DRUG_IBUPROFEN;DRUG_GABAPENTIN;DRUG_TRAMADOL;DRUG_NAPROXEN;DRUG_AMITRIPTYLINE,0.17,easy
+EP_0006,74,M,HTN;OA,normal,impaired,DRUG_IBUPROFEN;DRUG_LISINOPRIL;DRUG_DIGOXIN;DRUG_FUROSEMIDE;DRUG_AMIODARONE,0.25,easy
+EP_0007,90,M,BPH;OA,moderate,normal,DRUG_WARFARIN;DRUG_NAPROXEN;DRUG_TAMSULOSIN;DRUG_GABAPENTIN,0.225,easy
+EP_0008,77,F,CKD;OA;depression,mild,normal,DRUG_AMITRIPTYLINE;DRUG_IBUPROFEN;DRUG_SERTRALINE;DRUG_TRAMADOL;DRUG_FUROSEMIDE,0.17,easy
+EP_0009,67,M,COPD;GERD;BPH,mild,normal,DRUG_WARFARIN;DRUG_IBUPROFEN;DRUG_OMEPRAZOLE;DRUG_TAMSULOSIN,0.22,easy
+EP_0010,75,M,dementia;HTN;depression,normal,impaired,DRUG_TRAMADOL;DRUG_SERTRALINE;DRUG_AMITRIPTYLINE,0.2833,easy
+EP_0011,83,F,AF,moderate,normal,DRUG_ALPRAZOLAM;DRUG_WARFARIN;DRUG_DIGOXIN;DRUG_TRAMADOL,0.2275,easy
+EP_0012,71,F,HTN;GERD;depression,normal,normal,DRUG_DIAZEPAM;DRUG_AMLODIPINE;DRUG_FLUOXETINE;DRUG_LISINOPRIL;DRUG_TRAMADOL,0.348,easy
+EP_0013,70,F,HF;HTN;AF,mild,normal,DRUG_ALPRAZOLAM;DRUG_FUROSEMIDE;DRUG_TRAMADOL,0.3033,easy
+EP_0014,82,F,dementia,normal,normal,DRUG_DONEPEZIL;DRUG_NAPROXEN;DRUG_APIXABAN;DRUG_SPIRONOLACTONE;DRUG_FUROSEMIDE,0.17,easy
+EP_0015,84,F,dementia;neuropathy,normal,normal,DRUG_DONEPEZIL;DRUG_GABAPENTIN;DRUG_AMITRIPTYLINE;DRUG_CELECOXIB;DRUG_TRAMADOL,0.17,easy
+EP_0016,83,M,HTN,normal,normal,DRUG_ALPRAZOLAM;DRUG_METOPROLOL;DRUG_TRAMADOL,0.3033,easy
+EP_0017,83,F,CKD,severe,normal,DRUG_DIAZEPAM;DRUG_AMLODIPINE;DRUG_TRAMADOL,0.3067,easy
+EP_0018,70,F,CKD;HF;HTN,mild,normal,DRUG_SPIRONOLACTONE;DRUG_AMLODIPINE;DRUG_ALPRAZOLAM;DRUG_METOPROLOL;DRUG_TRAMADOL,0.182,easy
+EP_0019,84,M,DM;depression,normal,normal,DRUG_INSULIN_GLARGINE;DRUG_FLUOXETINE;DRUG_AMITRIPTYLINE;DRUG_GLIPIZIDE;DRUG_TRAMADOL,0.434,easy
+EP_0020,90,F,neuropathy;BPH;AF,normal,normal,DRUG_WARFARIN;DRUG_DIGOXIN;DRUG_AMIODARONE;DRUG_TAMSULOSIN,0.2,easy
+EP_0021,87,M,HTN;BPH;HF,normal,normal,DRUG_SPIRONOLACTONE;DRUG_APIXABAN;DRUG_NAPROXEN;DRUG_AMLODIPINE,0.2125,easy
+EP_0022,90,M,AF;GERD;DM,normal,impaired,DRUG_OMEPRAZOLE;DRUG_DIAZEPAM;DRUG_METOPROLOL;DRUG_TRAMADOL,0.23,easy
+EP_0023,90,F,HF,normal,normal,DRUG_DIAZEPAM;DRUG_METOPROLOL;DRUG_TRAMADOL,0.3067,easy
+EP_0024,71,F,OA,mild,normal,DRUG_IBUPROFEN;DRUG_GABAPENTIN;DRUG_TRAMADOL;DRUG_NAPROXEN;DRUG_APIXABAN,0.17,easy
+EP_0025,71,M,COPD;AF;neuropathy,mild,normal,DRUG_WARFARIN;DRUG_DIGOXIN;DRUG_AMIODARONE;DRUG_GABAPENTIN,0.2,easy
+EP_0026,88,M,GERD;dementia,severe,normal,DRUG_DONEPEZIL;DRUG_OMEPRAZOLE;DRUG_APIXABAN;DRUG_NAPROXEN,0.2125,easy
+EP_0027,76,M,AF,normal,normal,DRUG_DIGOXIN;DRUG_METOPROLOL;DRUG_WARFARIN;DRUG_APIXABAN;DRUG_NAPROXEN,0.43,easy
+EP_0028,73,F,CKD,moderate,normal,DRUG_AMLODIPINE;DRUG_FUROSEMIDE;DRUG_METFORMIN;DRUG_AMITRIPTYLINE;DRUG_TRAMADOL,0.17,easy
+EP_0029,70,F,CKD;OA,mild,normal,DRUG_IBUPROFEN;DRUG_AMLODIPINE;DRUG_AMITRIPTYLINE;DRUG_GABAPENTIN;DRUG_TRAMADOL,0.17,easy
+EP_0030,87,F,dementia;HF;depression,normal,normal,DRUG_DIGOXIN;DRUG_FLUOXETINE;DRUG_DONEPEZIL;DRUG_FUROSEMIDE;DRUG_AMIODARONE,0.25,easy
+EP_0031,69,M,HF,severe,normal,DRUG_SPIRONOLACTONE;DRUG_LISINOPRIL;DRUG_DIGOXIN;DRUG_FUROSEMIDE;DRUG_AMIODARONE,0.426,easy
+EP_0032,89,F,neuropathy,mild,normal,DRUG_AMITRIPTYLINE;DRUG_GABAPENTIN;DRUG_PREDNISONE;DRUG_TRAMADOL,0.2125,easy
+EP_0033,68,F,dementia,mild,impaired,DRUG_DONEPEZIL;DRUG_OMEPRAZOLE;DRUG_SPIRONOLACTONE;DRUG_TRAMADOL;DRUG_ALPRAZOLAM,0.182,easy
+EP_0034,84,F,CKD;HF;HTN,moderate,normal,DRUG_WARFARIN;DRUG_DIGOXIN;DRUG_NAPROXEN;DRUG_HYDROCHLOROTHIAZIDE,0.225,easy
+EP_0035,74,M,HTN;DM,normal,impaired,DRUG_FLUOXETINE;DRUG_HYDROCHLOROTHIAZIDE;DRUG_GLIPIZIDE;DRUG_METOPROLOL;DRUG_TRAMADOL,0.164,easy
+EP_0036,80,F,DM;neuropathy;HTN,severe,normal,DRUG_DIGOXIN;DRUG_AMLODIPINE;DRUG_AMIODARONE;DRUG_AMITRIPTYLINE,0.2,easy
+EP_0037,78,M,HF,normal,normal,DRUG_LISINOPRIL;DRUG_AMITRIPTYLINE;DRUG_FUROSEMIDE;DRUG_TRAMADOL,0.2125,easy
+EP_0038,89,F,HTN;AF,moderate,normal,DRUG_AMITRIPTYLINE;DRUG_FUROSEMIDE;DRUG_TRAMADOL,0.2833,easy
+EP_0039,78,F,OA;depression,moderate,normal,DRUG_GABAPENTIN;DRUG_FLUOXETINE;DRUG_TRAMADOL;DRUG_SERTRALINE,0.205,easy
+EP_0040,72,F,neuropathy;COPD;BPH,normal,normal,DRUG_ALPRAZOLAM;DRUG_AMITRIPTYLINE;DRUG_TRAMADOL;DRUG_TAMSULOSIN,0.44,easy
+EP_0041,89,F,AF;BPH;DM;HF;HTN,mild,normal,DRUG_GLIPIZIDE;DRUG_DIGOXIN;DRUG_METOPROLOL;DRUG_WARFARIN;DRUG_METFORMIN;DRUG_AMLODIPINE;DRUG_INSULIN_GLARGINE;DRUG_HYDROCHLOROTHIAZIDE;DRUG_APIXABAN,0.1,medium
+EP_0042,66,F,HTN;AF;CKD,moderate,normal,DRUG_METOPROLOL;DRUG_AMLODIPINE;DRUG_LISINOPRIL;DRUG_DIGOXIN;DRUG_FUROSEMIDE;DRUG_WARFARIN;DRUG_APIXABAN;DRUG_HYDROCHLOROTHIAZIDE;DRUG_IBUPROFEN;DRUG_SERTRALINE,0.173,medium
+EP_0043,70,F,OA;HTN;dementia,moderate,normal,DRUG_TRAMADOL;DRUG_HYDROCHLOROTHIAZIDE;DRUG_GABAPENTIN;DRUG_IBUPROFEN;DRUG_DONEPEZIL;DRUG_FUROSEMIDE;DRUG_NAPROXEN;DRUG_LISINOPRIL;DRUG_METOPROLOL,0.0467,medium
+EP_0044,77,M,HF;HTN;GERD;COPD;neuropathy,normal,normal,DRUG_OMEPRAZOLE;DRUG_AMLODIPINE;DRUG_PREDNISONE;DRUG_LISINOPRIL;DRUG_SPIRONOLACTONE;DRUG_METOPROLOL;DRUG_GABAPENTIN;DRUG_HYDROCHLOROTHIAZIDE;DRUG_DIGOXIN,0.1422,medium
+EP_0045,78,M,CKD;depression;dementia;GERD;OA,severe,normal,DRUG_FLUOXETINE;DRUG_NAPROXEN;DRUG_GABAPENTIN;DRUG_IBUPROFEN;DRUG_OMEPRAZOLE;DRUG_TRAMADOL;DRUG_SERTRALINE;DRUG_DONEPEZIL;DRUG_FUROSEMIDE;DRUG_AMITRIPTYLINE,0.167,medium
+EP_0046,82,M,BPH;DM;CKD;dementia;HF,moderate,normal,DRUG_GLIPIZIDE;DRUG_INSULIN_GLARGINE;DRUG_METOPROLOL;DRUG_METFORMIN;DRUG_DIGOXIN;DRUG_AMLODIPINE;DRUG_TAMSULOSIN;DRUG_SPIRONOLACTONE;DRUG_DONEPEZIL;DRUG_LISINOPRIL,0.178,medium
+EP_0047,83,F,depression;HTN;BPH;neuropathy;AF,normal,impaired,DRUG_SERTRALINE;DRUG_GABAPENTIN;DRUG_HYDROCHLOROTHIAZIDE;DRUG_METOPROLOL;DRUG_LISINOPRIL;DRUG_APIXABAN;DRUG_TAMSULOSIN;DRUG_AMLODIPINE;DRUG_FUROSEMIDE,0.0,medium
+EP_0048,85,F,AF;DM;OA,severe,impaired,DRUG_WARFARIN;DRUG_GLIPIZIDE;DRUG_INSULIN_GLARGINE;DRUG_TRAMADOL;DRUG_NAPROXEN;DRUG_GABAPENTIN;DRUG_DIGOXIN;DRUG_METFORMIN;DRUG_IBUPROFEN;DRUG_METOPROLOL,0.268,medium
+EP_0049,65,F,BPH;COPD;neuropathy,normal,normal,DRUG_TAMSULOSIN;DRUG_GABAPENTIN;DRUG_AMITRIPTYLINE;DRUG_PREDNISONE;DRUG_WARFARIN;DRUG_FLUOXETINE;DRUG_AMLODIPINE;DRUG_TRAMADOL,0.2963,medium
+EP_0050,86,M,dementia;depression;OA;neuropathy,mild,impaired,DRUG_AMITRIPTYLINE;DRUG_GABAPENTIN;DRUG_SERTRALINE;DRUG_NAPROXEN;DRUG_DONEPEZIL;DRUG_IBUPROFEN;DRUG_TRAMADOL,0.1214,medium
+EP_0051,90,M,OA;HF;HTN;DM,normal,normal,DRUG_FUROSEMIDE;DRUG_DIGOXIN;DRUG_HYDROCHLOROTHIAZIDE;DRUG_IBUPROFEN;DRUG_GLIPIZIDE;DRUG_TRAMADOL;DRUG_METOPROLOL;DRUG_AMLODIPINE;DRUG_METFORMIN;DRUG_NAPROXEN,0.085,medium
+EP_0052,70,M,AF;depression;GERD,moderate,normal,DRUG_FLUOXETINE;DRUG_METOPROLOL;DRUG_SERTRALINE;DRUG_AMITRIPTYLINE;DRUG_OMEPRAZOLE;DRUG_APIXABAN;DRUG_WARFARIN;DRUG_DIGOXIN,0.1063,medium
+EP_0053,65,F,HF;DM;GERD;neuropathy;BPH,moderate,impaired,DRUG_INSULIN_GLARGINE;DRUG_GLIPIZIDE;DRUG_OMEPRAZOLE;DRUG_TAMSULOSIN;DRUG_FUROSEMIDE;DRUG_METFORMIN;DRUG_SPIRONOLACTONE;DRUG_AMITRIPTYLINE;DRUG_LISINOPRIL;DRUG_GABAPENTIN,0.098,medium
+EP_0054,82,F,OA;neuropathy;AF;DM,mild,normal,DRUG_GABAPENTIN;DRUG_TRAMADOL;DRUG_APIXABAN;DRUG_INSULIN_GLARGINE;DRUG_IBUPROFEN;DRUG_NAPROXEN,0.1417,medium
+EP_0055,74,M,GERD;HTN;CKD,moderate,normal,DRUG_LISINOPRIL;DRUG_AMLODIPINE;DRUG_HYDROCHLOROTHIAZIDE;DRUG_OMEPRAZOLE;DRUG_METOPROLOL;DRUG_FUROSEMIDE;DRUG_SPIRONOLACTONE;DRUG_INSULIN_GLARGINE,0.06,medium
+EP_0056,67,F,HTN;GERD;COPD;AF,moderate,normal,DRUG_PREDNISONE;DRUG_FUROSEMIDE;DRUG_DIGOXIN;DRUG_OMEPRAZOLE;DRUG_LISINOPRIL;DRUG_METOPROLOL;DRUG_APIXABAN;DRUG_WARFARIN;DRUG_AMLODIPINE,0.1222,medium
+EP_0057,74,F,DM;HTN;BPH,normal,normal,DRUG_INSULIN_GLARGINE;DRUG_LISINOPRIL;DRUG_AMLODIPINE;DRUG_GLIPIZIDE;DRUG_METFORMIN;DRUG_FUROSEMIDE;DRUG_TAMSULOSIN;DRUG_METOPROLOL;DRUG_HYDROCHLOROTHIAZIDE;DRUG_APIXABAN,0.05,medium
+EP_0058,90,F,AF;OA;BPH,moderate,normal,DRUG_DIGOXIN;DRUG_METOPROLOL;DRUG_GABAPENTIN;DRUG_WARFARIN;DRUG_IBUPROFEN;DRUG_NAPROXEN;DRUG_TAMSULOSIN;DRUG_TRAMADOL;DRUG_APIXABAN;DRUG_CLOPIDOGREL,0.303,medium
+EP_0059,85,F,BPH;HTN;depression;dementia;COPD,mild,normal,DRUG_PREDNISONE;DRUG_FUROSEMIDE;DRUG_AMLODIPINE;DRUG_SERTRALINE;DRUG_FLUOXETINE;DRUG_HYDROCHLOROTHIAZIDE;DRUG_TAMSULOSIN;DRUG_AMITRIPTYLINE,0.0,medium
+EP_0060,80,F,HF;CKD;neuropathy;HTN,moderate,normal,DRUG_AMLODIPINE;DRUG_HYDROCHLOROTHIAZIDE;DRUG_SPIRONOLACTONE;DRUG_GABAPENTIN;DRUG_LISINOPRIL;DRUG_DIGOXIN;DRUG_AMITRIPTYLINE;DRUG_METOPROLOL;DRUG_FUROSEMIDE;DRUG_ATORVASTATIN,0.173,medium
+EP_0061,90,M,GERD;BPH;HF;HTN;CKD,mild,normal,DRUG_DIGOXIN;DRUG_AMLODIPINE;DRUG_LISINOPRIL;DRUG_METOPROLOL;DRUG_TAMSULOSIN;DRUG_FUROSEMIDE,0.1417,medium
+EP_0062,65,M,neuropathy;COPD;GERD;BPH;AF,moderate,normal,DRUG_PREDNISONE;DRUG_TAMSULOSIN;DRUG_DIGOXIN;DRUG_METOPROLOL;DRUG_OMEPRAZOLE;DRUG_GABAPENTIN;DRUG_AMITRIPTYLINE;DRUG_WARFARIN;DRUG_APIXABAN,0.0722,medium
+EP_0063,76,M,depression;COPD;OA,mild,normal,DRUG_IBUPROFEN;DRUG_TRAMADOL;DRUG_NAPROXEN;DRUG_AMITRIPTYLINE;DRUG_FLUOXETINE;DRUG_SERTRALINE;DRUG_GABAPENTIN,0.2386,medium
+EP_0064,88,M,BPH;GERD;COPD,mild,normal,DRUG_OMEPRAZOLE;DRUG_PREDNISONE;DRUG_TAMSULOSIN;DRUG_METOPROLOL;DRUG_DIGOXIN;DRUG_SIMVASTATIN;DRUG_AMLODIPINE;DRUG_ATORVASTATIN;DRUG_ALPRAZOLAM,0.0867,medium
+EP_0065,75,M,HTN;HF;AF,mild,normal,DRUG_FUROSEMIDE;DRUG_WARFARIN;DRUG_METOPROLOL;DRUG_HYDROCHLOROTHIAZIDE;DRUG_APIXABAN;DRUG_SPIRONOLACTONE;DRUG_DIGOXIN,0.1786,medium
+EP_0066,66,M,HF;dementia;GERD;OA;DM,moderate,normal,DRUG_INSULIN_GLARGINE;DRUG_FUROSEMIDE;DRUG_DIGOXIN;DRUG_TRAMADOL;DRUG_NAPROXEN;DRUG_METFORMIN;DRUG_DONEPEZIL;DRUG_OMEPRAZOLE,0.0563,medium
+EP_0067,70,F,CKD;HTN;AF;HF,moderate,normal,DRUG_APIXABAN;DRUG_HYDROCHLOROTHIAZIDE;DRUG_LISINOPRIL;DRUG_METOPROLOL;DRUG_FUROSEMIDE;DRUG_WARFARIN;DRUG_AMLODIPINE,0.0,medium
+EP_0068,85,M,depression;dementia;neuropathy;HF,normal,impaired,DRUG_FLUOXETINE;DRUG_SERTRALINE;DRUG_GABAPENTIN;DRUG_AMITRIPTYLINE;DRUG_SPIRONOLACTONE;DRUG_DONEPEZIL;DRUG_LISINOPRIL;DRUG_METOPROLOL;DRUG_DIGOXIN;DRUG_FUROSEMIDE,0.173,medium
+EP_0069,74,F,OA;CKD;AF,mild,normal,DRUG_IBUPROFEN;DRUG_FUROSEMIDE;DRUG_GABAPENTIN;DRUG_NAPROXEN;DRUG_AMLODIPINE;DRUG_APIXABAN;DRUG_METOPROLOL;DRUG_TRAMADOL;DRUG_DIGOXIN,0.1889,medium
+EP_0070,75,F,dementia;GERD;COPD;OA,mild,normal,DRUG_DONEPEZIL;DRUG_PREDNISONE;DRUG_OMEPRAZOLE;DRUG_IBUPROFEN;DRUG_TRAMADOL;DRUG_GABAPENTIN,0.0,medium
+EP_0071,68,M,BPH;DM;COPD;neuropathy,normal,normal,DRUG_AMITRIPTYLINE;DRUG_INSULIN_GLARGINE;DRUG_METFORMIN;DRUG_TAMSULOSIN;DRUG_GABAPENTIN;DRUG_PREDNISONE;DRUG_GLIPIZIDE,0.0714,medium
+EP_0072,92,F,CKD;BPH;COPD;AF,normal,normal,DRUG_PREDNISONE;DRUG_FUROSEMIDE;DRUG_DIGOXIN;DRUG_AMLODIPINE;DRUG_TAMSULOSIN;DRUG_METOPROLOL;DRUG_APIXABAN,0.1214,medium
+EP_0073,88,F,OA;GERD;HTN;depression,mild,normal,DRUG_AMITRIPTYLINE;DRUG_OMEPRAZOLE;DRUG_NAPROXEN;DRUG_METOPROLOL;DRUG_IBUPROFEN;DRUG_FUROSEMIDE;DRUG_LISINOPRIL;DRUG_SERTRALINE,0.0525,medium
+EP_0074,80,F,neuropathy;OA;CKD;depression,mild,normal,DRUG_AMLODIPINE;DRUG_SERTRALINE;DRUG_IBUPROFEN;DRUG_FUROSEMIDE;DRUG_TRAMADOL;DRUG_AMITRIPTYLINE;DRUG_NAPROXEN,0.1214,medium
+EP_0075,68,F,dementia;AF;COPD;HTN;neuropathy,mild,normal,DRUG_GABAPENTIN;DRUG_AMLODIPINE;DRUG_DIGOXIN;DRUG_FUROSEMIDE;DRUG_DONEPEZIL;DRUG_METOPROLOL;DRUG_WARFARIN;DRUG_HYDROCHLOROTHIAZIDE;DRUG_LISINOPRIL,0.0944,medium
+EP_0076,71,M,HF;DM;dementia,severe,normal,DRUG_SPIRONOLACTONE;DRUG_DONEPEZIL;DRUG_INSULIN_GLARGINE;DRUG_LISINOPRIL;DRUG_DIGOXIN;DRUG_METFORMIN;DRUG_GLIPIZIDE;DRUG_FUROSEMIDE,0.2287,medium
+EP_0077,75,F,AF;BPH;dementia,mild,impaired,DRUG_METOPROLOL;DRUG_TAMSULOSIN;DRUG_APIXABAN;DRUG_DONEPEZIL;DRUG_DIGOXIN;DRUG_WARFARIN;DRUG_SPIRONOLACTONE,0.1143,medium
+EP_0078,81,F,OA;depression;DM;neuropathy;CKD,normal,normal,DRUG_GLIPIZIDE;DRUG_NAPROXEN;DRUG_FUROSEMIDE;DRUG_AMLODIPINE;DRUG_SERTRALINE;DRUG_INSULIN_GLARGINE;DRUG_IBUPROFEN,0.0714,medium
+EP_0079,74,F,DM;OA;GERD;CKD,mild,impaired,DRUG_GLIPIZIDE;DRUG_AMLODIPINE;DRUG_INSULIN_GLARGINE;DRUG_METFORMIN;DRUG_IBUPROFEN;DRUG_GABAPENTIN;DRUG_NAPROXEN,0.0714,medium
+EP_0080,72,M,GERD;HF;OA;CKD,normal,normal,DRUG_AMLODIPINE;DRUG_TRAMADOL;DRUG_LISINOPRIL;DRUG_OMEPRAZOLE;DRUG_FUROSEMIDE;DRUG_DIGOXIN;DRUG_METOPROLOL;DRUG_IBUPROFEN,0.1063,medium
+EP_0081,84,M,neuropathy;CKD;depression;OA,normal,impaired,DRUG_FLUOXETINE;DRUG_AMLODIPINE;DRUG_IBUPROFEN;DRUG_NAPROXEN;DRUG_SERTRALINE;DRUG_GABAPENTIN;DRUG_FUROSEMIDE;DRUG_AMITRIPTYLINE;DRUG_TRAMADOL;DRUG_METOPROLOL;DRUG_SIMVASTATIN;DRUG_SPIRONOLACTONE;DRUG_DIGOXIN;DRUG_WARFARIN,0.395,hard
+EP_0082,75,M,OA;COPD;neuropathy;CKD;GERD;HTN;depression,severe,impaired,DRUG_SERTRALINE;DRUG_FUROSEMIDE;DRUG_METOPROLOL;DRUG_FLUOXETINE;DRUG_LISINOPRIL;DRUG_NAPROXEN;DRUG_GABAPENTIN;DRUG_OMEPRAZOLE;DRUG_AMLODIPINE;DRUG_AMITRIPTYLINE;DRUG_TRAMADOL;DRUG_HYDROCHLOROTHIAZIDE;DRUG_IBUPROFEN;DRUG_PREDNISONE;DRUG_DIGOXIN,0.2293,hard
+EP_0083,82,F,DM;dementia;OA;HF;neuropathy;COPD,moderate,normal,DRUG_SPIRONOLACTONE;DRUG_LISINOPRIL;DRUG_DONEPEZIL;DRUG_TRAMADOL;DRUG_AMITRIPTYLINE;DRUG_FUROSEMIDE;DRUG_GLIPIZIDE;DRUG_PREDNISONE;DRUG_IBUPROFEN;DRUG_GABAPENTIN;DRUG_INSULIN_GLARGINE;DRUG_DIGOXIN;DRUG_WARFARIN,0.2931,hard
+EP_0084,80,F,CKD;neuropathy;COPD;BPH;dementia;HTN;OA,moderate,normal,DRUG_GABAPENTIN;DRUG_METOPROLOL;DRUG_HYDROCHLOROTHIAZIDE;DRUG_PREDNISONE;DRUG_TRAMADOL;DRUG_NAPROXEN;DRUG_LISINOPRIL;DRUG_AMLODIPINE;DRUG_AMITRIPTYLINE;DRUG_TAMSULOSIN;DRUG_DIGOXIN;DRUG_WARFARIN,0.2767,hard
+EP_0085,79,F,OA;DM;CKD;GERD;BPH;HF;neuropathy,severe,normal,DRUG_OMEPRAZOLE;DRUG_AMLODIPINE;DRUG_SPIRONOLACTONE;DRUG_METFORMIN;DRUG_TRAMADOL;DRUG_METOPROLOL;DRUG_IBUPROFEN;DRUG_FUROSEMIDE;DRUG_GABAPENTIN;DRUG_LISINOPRIL;DRUG_DIGOXIN;DRUG_WARFARIN;DRUG_INSULIN_GLARGINE,0.2008,hard
+EP_0086,82,M,AF;DM;GERD;COPD;OA,moderate,impaired,DRUG_PREDNISONE;DRUG_METFORMIN;DRUG_APIXABAN;DRUG_WARFARIN;DRUG_DIGOXIN;DRUG_OMEPRAZOLE;DRUG_INSULIN_GLARGINE;DRUG_NAPROXEN;DRUG_GABAPENTIN;DRUG_GLIPIZIDE;DRUG_METOPROLOL;DRUG_TRAMADOL;DRUG_IBUPROFEN;DRUG_LOSARTAN,0.3057,hard
+EP_0087,90,M,HTN;GERD;DM;AF;CKD,mild,impaired,DRUG_APIXABAN;DRUG_FUROSEMIDE;DRUG_GLIPIZIDE;DRUG_OMEPRAZOLE;DRUG_WARFARIN;DRUG_METOPROLOL;DRUG_INSULIN_GLARGINE;DRUG_LISINOPRIL;DRUG_AMLODIPINE;DRUG_HYDROCHLOROTHIAZIDE,0.05,hard
+EP_0088,86,F,HF;AF;COPD;HTN;OA;GERD,normal,impaired,DRUG_PREDNISONE;DRUG_AMLODIPINE;DRUG_METOPROLOL;DRUG_APIXABAN;DRUG_IBUPROFEN;DRUG_OMEPRAZOLE;DRUG_SPIRONOLACTONE;DRUG_NAPROXEN;DRUG_HYDROCHLOROTHIAZIDE;DRUG_WARFARIN;DRUG_LISINOPRIL;DRUG_FUROSEMIDE;DRUG_TRAMADOL;DRUG_DIGOXIN;DRUG_INSULIN_GLARGINE,0.3687,hard
+EP_0089,75,M,DM;CKD;HTN;HF;BPH;neuropathy;GERD,mild,normal,DRUG_HYDROCHLOROTHIAZIDE;DRUG_AMLODIPINE;DRUG_METFORMIN;DRUG_OMEPRAZOLE;DRUG_METOPROLOL;DRUG_LISINOPRIL;DRUG_AMITRIPTYLINE;DRUG_GLIPIZIDE;DRUG_GABAPENTIN;DRUG_TAMSULOSIN;DRUG_WARFARIN;DRUG_INSULIN_GLARGINE,0.0417,hard
+EP_0090,87,M,AF;depression;DM;COPD;OA,mild,normal,DRUG_NAPROXEN;DRUG_FLUOXETINE;DRUG_APIXABAN;DRUG_SERTRALINE;DRUG_WARFARIN;DRUG_DIGOXIN;DRUG_INSULIN_GLARGINE;DRUG_TRAMADOL;DRUG_PREDNISONE;DRUG_GLIPIZIDE;DRUG_GABAPENTIN;DRUG_IBUPROFEN;DRUG_METFORMIN;DRUG_METOPROLOL;DRUG_AMITRIPTYLINE,0.4267,hard
+EP_0091,83,M,OA;dementia;GERD;depression;DM;HF,severe,normal,DRUG_GLIPIZIDE;DRUG_DIGOXIN;DRUG_IBUPROFEN;DRUG_SPIRONOLACTONE;DRUG_METFORMIN;DRUG_FLUOXETINE;DRUG_FUROSEMIDE;DRUG_SERTRALINE;DRUG_OMEPRAZOLE;DRUG_AMITRIPTYLINE;DRUG_INSULIN_GLARGINE;DRUG_NAPROXEN;DRUG_METOPROLOL;DRUG_WARFARIN,0.2843,hard
+EP_0092,78,F,CKD;OA;AF;COPD;depression,severe,normal,DRUG_METOPROLOL;DRUG_TRAMADOL;DRUG_SERTRALINE;DRUG_NAPROXEN;DRUG_AMITRIPTYLINE;DRUG_APIXABAN;DRUG_AMLODIPINE;DRUG_IBUPROFEN;DRUG_DIGOXIN;DRUG_WARFARIN;DRUG_FUROSEMIDE;DRUG_GABAPENTIN;DRUG_FLUOXETINE;DRUG_PREDNISONE,0.4536,hard
+EP_0093,93,F,HTN;DM;BPH;OA;dementia,severe,impaired,DRUG_LISINOPRIL;DRUG_DONEPEZIL;DRUG_METFORMIN;DRUG_FUROSEMIDE;DRUG_INSULIN_GLARGINE;DRUG_GABAPENTIN;DRUG_IBUPROFEN;DRUG_METOPROLOL;DRUG_GLIPIZIDE;DRUG_HYDROCHLOROTHIAZIDE;DRUG_TAMSULOSIN;DRUG_DIGOXIN,0.1125,hard
+EP_0094,94,F,BPH;GERD;COPD;HF,moderate,impaired,DRUG_LISINOPRIL;DRUG_DIGOXIN;DRUG_TAMSULOSIN;DRUG_PREDNISONE;DRUG_FUROSEMIDE;DRUG_OMEPRAZOLE;DRUG_METOPROLOL;DRUG_SPIRONOLACTONE;DRUG_HYDROCHLOROTHIAZIDE;DRUG_WARFARIN,0.198,hard
+EP_0095,90,M,HF;neuropathy;COPD;BPH;dementia;DM;CKD,normal,normal,DRUG_GABAPENTIN;DRUG_METFORMIN;DRUG_INSULIN_GLARGINE;DRUG_DIGOXIN;DRUG_AMITRIPTYLINE;DRUG_FUROSEMIDE;DRUG_GLIPIZIDE;DRUG_AMLODIPINE;DRUG_LISINOPRIL;DRUG_DONEPEZIL;DRUG_PREDNISONE;DRUG_SPIRONOLACTONE;DRUG_TAMSULOSIN;DRUG_METOPROLOL;DRUG_SERTRALINE,0.1487,hard
+EP_0096,85,F,DM;COPD;HTN;CKD;depression;dementia,severe,impaired,DRUG_AMITRIPTYLINE;DRUG_INSULIN_GLARGINE;DRUG_PREDNISONE;DRUG_METOPROLOL;DRUG_HYDROCHLOROTHIAZIDE;DRUG_FUROSEMIDE;DRUG_AMLODIPINE;DRUG_FLUOXETINE;DRUG_LISINOPRIL;DRUG_DONEPEZIL;DRUG_DIGOXIN,0.0773,hard
+EP_0097,70,M,BPH;COPD;neuropathy;CKD;GERD;depression,severe,normal,DRUG_PREDNISONE;DRUG_AMITRIPTYLINE;DRUG_AMLODIPINE;DRUG_FLUOXETINE;DRUG_GABAPENTIN;DRUG_TAMSULOSIN;DRUG_FUROSEMIDE;DRUG_SERTRALINE;DRUG_OMEPRAZOLE;DRUG_IBUPROFEN;DRUG_LOSARTAN;DRUG_INSULIN_GLARGINE;DRUG_DIGOXIN;DRUG_WARFARIN,0.145,hard
+EP_0098,81,F,COPD;depression;GERD;BPH;OA,severe,impaired,DRUG_GABAPENTIN;DRUG_FLUOXETINE;DRUG_OMEPRAZOLE;DRUG_NAPROXEN;DRUG_SERTRALINE;DRUG_IBUPROFEN;DRUG_TRAMADOL;DRUG_TAMSULOSIN;DRUG_PREDNISONE;DRUG_AMITRIPTYLINE;DRUG_NORTRIPTYLINE;DRUG_INSULIN_GLARGINE;DRUG_DIGOXIN;DRUG_LOSARTAN;DRUG_HYDROCHLOROTHIAZIDE,0.1447,hard
+EP_0099,91,M,neuropathy;OA;AF;CKD,mild,normal,DRUG_TRAMADOL;DRUG_APIXABAN;DRUG_WARFARIN;DRUG_AMLODIPINE;DRUG_METOPROLOL;DRUG_IBUPROFEN;DRUG_AMITRIPTYLINE;DRUG_GABAPENTIN;DRUG_FUROSEMIDE;DRUG_DIGOXIN;DRUG_NAPROXEN;DRUG_SIMVASTATIN;DRUG_FLUOXETINE;DRUG_INSULIN_GLARGINE,0.4271,hard
+EP_0100,90,F,DM;OA;HTN;HF,mild,impaired,DRUG_INSULIN_GLARGINE;DRUG_SPIRONOLACTONE;DRUG_METFORMIN;DRUG_HYDROCHLOROTHIAZIDE;DRUG_METOPROLOL;DRUG_AMLODIPINE;DRUG_DIGOXIN;DRUG_GLIPIZIDE;DRUG_LISINOPRIL;DRUG_FUROSEMIDE;DRUG_NAPROXEN;DRUG_IBUPROFEN;DRUG_GABAPENTIN;DRUG_TRAMADOL;DRUG_AMITRIPTYLINE,0.2333,hard
+EP_0101,88,M,BPH;neuropathy;dementia;OA;CKD;DM;HF,severe,impaired,DRUG_AMLODIPINE;DRUG_TAMSULOSIN;DRUG_TRAMADOL;DRUG_GLIPIZIDE;DRUG_FUROSEMIDE;DRUG_AMITRIPTYLINE;DRUG_LISINOPRIL;DRUG_INSULIN_GLARGINE;DRUG_DIGOXIN;DRUG_METFORMIN;DRUG_IBUPROFEN;DRUG_SPIRONOLACTONE;DRUG_METOPROLOL;DRUG_GABAPENTIN;DRUG_NAPROXEN,0.2333,hard
+EP_0102,74,M,BPH;HF;dementia;CKD;DM;GERD,moderate,normal,DRUG_OMEPRAZOLE;DRUG_LISINOPRIL;DRUG_INSULIN_GLARGINE;DRUG_METOPROLOL;DRUG_FUROSEMIDE;DRUG_GLIPIZIDE;DRUG_DONEPEZIL;DRUG_SPIRONOLACTONE;DRUG_DIGOXIN;DRUG_AMLODIPINE;DRUG_METFORMIN;DRUG_TAMSULOSIN;DRUG_WARFARIN,0.1715,hard
+EP_0103,70,F,AF;GERD;depression;CKD,mild,impaired,DRUG_DIGOXIN;DRUG_AMLODIPINE;DRUG_FLUOXETINE;DRUG_FUROSEMIDE;DRUG_APIXABAN;DRUG_METOPROLOL;DRUG_WARFARIN;DRUG_SERTRALINE;DRUG_OMEPRAZOLE;DRUG_AMITRIPTYLINE;DRUG_LOSARTAN;DRUG_GLIPIZIDE;DRUG_DIAZEPAM;DRUG_TRAMADOL;DRUG_HYDROCHLOROTHIAZIDE,0.2593,hard
+EP_0104,84,F,OA;DM;dementia;AF;GERD;COPD;BPH,mild,normal,DRUG_METOPROLOL;DRUG_DONEPEZIL;DRUG_METFORMIN;DRUG_GABAPENTIN;DRUG_APIXABAN;DRUG_OMEPRAZOLE;DRUG_TAMSULOSIN;DRUG_NAPROXEN;DRUG_GLIPIZIDE;DRUG_TRAMADOL;DRUG_IBUPROFEN;DRUG_INSULIN_GLARGINE;DRUG_DIGOXIN;DRUG_WARFARIN,0.2521,hard
+EP_0105,88,F,neuropathy;BPH;HTN;COPD;DM,severe,impaired,DRUG_AMITRIPTYLINE;DRUG_AMLODIPINE;DRUG_METOPROLOL;DRUG_LISINOPRIL;DRUG_FUROSEMIDE;DRUG_PREDNISONE;DRUG_GABAPENTIN;DRUG_INSULIN_GLARGINE;DRUG_METFORMIN;DRUG_HYDROCHLOROTHIAZIDE;DRUG_TAMSULOSIN;DRUG_WARFARIN,0.0208,hard
+EP_0106,73,M,HTN;DM;CKD;OA;depression,normal,normal,DRUG_LISINOPRIL;DRUG_NAPROXEN;DRUG_AMITRIPTYLINE;DRUG_SERTRALINE;DRUG_HYDROCHLOROTHIAZIDE;DRUG_METFORMIN;DRUG_FLUOXETINE;DRUG_INSULIN_GLARGINE;DRUG_AMLODIPINE;DRUG_GLIPIZIDE;DRUG_TRAMADOL;DRUG_METOPROLOL;DRUG_IBUPROFEN;DRUG_WARFARIN,0.3443,hard
+EP_0107,82,M,HTN;GERD;BPH;depression;AF,mild,impaired,DRUG_WARFARIN;DRUG_LISINOPRIL;DRUG_FUROSEMIDE;DRUG_SERTRALINE;DRUG_FLUOXETINE;DRUG_OMEPRAZOLE;DRUG_HYDROCHLOROTHIAZIDE;DRUG_TAMSULOSIN;DRUG_METOPROLOL;DRUG_AMITRIPTYLINE;DRUG_AMLODIPINE;DRUG_DIGOXIN;DRUG_APIXABAN;DRUG_ATORVASTATIN;DRUG_CLOPIDOGREL,0.1167,hard
+EP_0108,72,F,DM;dementia;GERD;BPH;neuropathy;OA;depression,severe,normal,DRUG_FLUOXETINE;DRUG_GABAPENTIN;DRUG_TAMSULOSIN;DRUG_AMITRIPTYLINE;DRUG_NAPROXEN;DRUG_DONEPEZIL;DRUG_OMEPRAZOLE;DRUG_METFORMIN;DRUG_SERTRALINE;DRUG_IBUPROFEN;DRUG_INSULIN_GLARGINE;DRUG_GLIPIZIDE;DRUG_TRAMADOL;DRUG_NORTRIPTYLINE;DRUG_WARFARIN,0.2933,hard
+EP_0109,91,M,COPD;dementia;HF;OA;HTN;DM,mild,normal,DRUG_DONEPEZIL;DRUG_GLIPIZIDE;DRUG_LISINOPRIL;DRUG_DIGOXIN;DRUG_INSULIN_GLARGINE;DRUG_SPIRONOLACTONE;DRUG_AMLODIPINE;DRUG_FUROSEMIDE;DRUG_NAPROXEN;DRUG_METFORMIN;DRUG_WARFARIN,0.2864,hard
+EP_0110,84,M,HF;GERD;dementia;CKD;COPD,severe,impaired,DRUG_DONEPEZIL;DRUG_SPIRONOLACTONE;DRUG_AMLODIPINE;DRUG_FUROSEMIDE;DRUG_LISINOPRIL;DRUG_PREDNISONE;DRUG_OMEPRAZOLE;DRUG_METOPROLOL;DRUG_DIGOXIN;DRUG_AMITRIPTYLINE;DRUG_ASPIRIN;DRUG_NORTRIPTYLINE;DRUG_WARFARIN;DRUG_INSULIN_GLARGINE,0.1807,hard
+EP_0111,91,F,dementia;CKD;DM;AF;BPH,normal,impaired,DRUG_GLIPIZIDE;DRUG_DONEPEZIL;DRUG_TAMSULOSIN;DRUG_WARFARIN;DRUG_APIXABAN;DRUG_FUROSEMIDE;DRUG_METFORMIN;DRUG_AMLODIPINE;DRUG_METOPROLOL;DRUG_DIGOXIN;DRUG_INSULIN_GLARGINE;DRUG_CELECOXIB;DRUG_AMITRIPTYLINE;DRUG_ATORVASTATIN;DRUG_IBUPROFEN,0.1487,hard
+EP_0112,87,F,AF;HF;CKD;neuropathy;HTN;depression,moderate,impaired,DRUG_HYDROCHLOROTHIAZIDE;DRUG_GABAPENTIN;DRUG_WARFARIN;DRUG_FLUOXETINE;DRUG_AMLODIPINE;DRUG_SPIRONOLACTONE;DRUG_FUROSEMIDE;DRUG_METOPROLOL;DRUG_AMITRIPTYLINE;DRUG_APIXABAN;DRUG_SERTRALINE;DRUG_DIGOXIN;DRUG_INSULIN_GLARGINE,0.1308,hard
+EP_0113,92,M,OA;HF;COPD;dementia;neuropathy;CKD,moderate,normal,DRUG_AMLODIPINE;DRUG_DONEPEZIL;DRUG_LISINOPRIL;DRUG_GABAPENTIN;DRUG_METOPROLOL;DRUG_IBUPROFEN;DRUG_SPIRONOLACTONE;DRUG_AMITRIPTYLINE;DRUG_DIGOXIN;DRUG_NAPROXEN;DRUG_FUROSEMIDE;DRUG_WARFARIN;DRUG_INSULIN_GLARGINE,0.3023,hard
+EP_0114,72,M,depression;COPD;neuropathy;dementia;AF,moderate,normal,DRUG_DIGOXIN;DRUG_APIXABAN;DRUG_SERTRALINE;DRUG_METOPROLOL;DRUG_FLUOXETINE;DRUG_DONEPEZIL;DRUG_AMITRIPTYLINE;DRUG_PREDNISONE;DRUG_WARFARIN;DRUG_GABAPENTIN;DRUG_LISINOPRIL;DRUG_NORTRIPTYLINE;DRUG_AMLODIPINE,0.0846,hard
+EP_0115,75,M,HTN;OA;dementia;HF;depression;CKD;AF,mild,normal,DRUG_WARFARIN;DRUG_SERTRALINE;DRUG_METOPROLOL;DRUG_SPIRONOLACTONE;DRUG_FUROSEMIDE;DRUG_TRAMADOL;DRUG_AMLODIPINE;DRUG_NAPROXEN;DRUG_GABAPENTIN;DRUG_IBUPROFEN;DRUG_AMITRIPTYLINE;DRUG_DIGOXIN,0.3233,hard
+EP_0116,76,F,OA;depression;neuropathy;HF,severe,impaired,DRUG_FLUOXETINE;DRUG_NAPROXEN;DRUG_FUROSEMIDE;DRUG_METOPROLOL;DRUG_SPIRONOLACTONE;DRUG_AMITRIPTYLINE;DRUG_TRAMADOL;DRUG_LISINOPRIL;DRUG_GABAPENTIN;DRUG_DIGOXIN;DRUG_IBUPROFEN;DRUG_SERTRALINE;DRUG_WARFARIN;DRUG_INSULIN_GLARGINE,0.4321,hard
+EP_0117,85,M,depression;GERD;neuropathy;HTN,normal,normal,DRUG_GABAPENTIN;DRUG_SERTRALINE;DRUG_OMEPRAZOLE;DRUG_HYDROCHLOROTHIAZIDE;DRUG_FUROSEMIDE;DRUG_AMLODIPINE;DRUG_AMITRIPTYLINE;DRUG_METOPROLOL;DRUG_FLUOXETINE;DRUG_LISINOPRIL;DRUG_SPIRONOLACTONE;DRUG_IBUPROFEN;DRUG_NAPROXEN;DRUG_WARFARIN;DRUG_INSULIN_GLARGINE,0.2087,hard
+EP_0118,70,F,DM;depression;HTN;HF;AF;neuropathy;CKD,severe,impaired,DRUG_FUROSEMIDE;DRUG_HYDROCHLOROTHIAZIDE;DRUG_METFORMIN;DRUG_AMLODIPINE;DRUG_SPIRONOLACTONE;DRUG_GABAPENTIN;DRUG_FLUOXETINE;DRUG_GLIPIZIDE;DRUG_WARFARIN;DRUG_METOPROLOL;DRUG_INSULIN_GLARGINE;DRUG_DIGOXIN,0.1833,hard
+EP_0119,86,F,AF;HTN;HF;OA;dementia,normal,impaired,DRUG_APIXABAN;DRUG_HYDROCHLOROTHIAZIDE;DRUG_IBUPROFEN;DRUG_SPIRONOLACTONE;DRUG_GABAPENTIN;DRUG_NAPROXEN;DRUG_DONEPEZIL;DRUG_LISINOPRIL;DRUG_FUROSEMIDE;DRUG_DIGOXIN;DRUG_INSULIN_GLARGINE,0.2364,hard
+EP_0120,94,F,HF;COPD;dementia;HTN;CKD,mild,normal,DRUG_AMLODIPINE;DRUG_METOPROLOL;DRUG_SPIRONOLACTONE;DRUG_DONEPEZIL;DRUG_HYDROCHLOROTHIAZIDE;DRUG_DIGOXIN;DRUG_LISINOPRIL;DRUG_PREDNISONE;DRUG_FUROSEMIDE;DRUG_AMITRIPTYLINE;DRUG_INSULIN_GLARGINE;DRUG_WARFARIN,0.165,hard
diff --git a/openenv-polypharmacy/docker-compose.yml b/openenv-polypharmacy/docker-compose.yml
new file mode 100644
index 0000000000000000000000000000000000000000..2b2bde3a4b6870ab496787ea9e355ab209a085f9
--- /dev/null
+++ b/openenv-polypharmacy/docker-compose.yml
@@ -0,0 +1,35 @@
+version: "3.9"
+
+services:
+ backend:
+ build:
+ context: .
+ dockerfile: backend/Dockerfile
+ container_name: polypharmacy-backend
+ env_file:
+ - .env
+ ports:
+ - "7860:7860"
+ volumes:
+ - ./backend/src:/app/backend/src
+ - ./data:/app/data
+ - ./scripts:/app/scripts
+ - ./backend:/app/backend
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:7860/health"]
+ interval: 20s
+ timeout: 5s
+ retries: 5
+
+ frontend:
+ build:
+ context: .
+ dockerfile: frontend/Dockerfile
+ container_name: polypharmacy-frontend
+ depends_on:
+ - backend
+ ports:
+ - "5173:5173"
+ volumes:
+ - ./frontend:/app
+ - /app/node_modules
diff --git a/openenv-polypharmacy/frontend/Dockerfile b/openenv-polypharmacy/frontend/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..aa04742e98c70c50c11a21ee578eabd0f9269e15
--- /dev/null
+++ b/openenv-polypharmacy/frontend/Dockerfile
@@ -0,0 +1,12 @@
+FROM node:20-alpine
+
+WORKDIR /app
+
+COPY frontend/package*.json ./
+RUN npm ci
+
+COPY frontend/ ./
+
+EXPOSE 5173
+
+CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
diff --git a/openenv-polypharmacy/frontend/index.html b/openenv-polypharmacy/frontend/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..a879a0927092473c247198a104ca9a5a4ab0dac9
--- /dev/null
+++ b/openenv-polypharmacy/frontend/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Polypharmacy Control Center
+
+
+
+
+
+
diff --git a/openenv-polypharmacy/frontend/package-lock.json b/openenv-polypharmacy/frontend/package-lock.json
new file mode 100644
index 0000000000000000000000000000000000000000..891d0adb22e236c2a759f3266ee843269f1bec5e
--- /dev/null
+++ b/openenv-polypharmacy/frontend/package-lock.json
@@ -0,0 +1,1677 @@
+{
+ "name": "polypharmacy-frontend",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "polypharmacy-frontend",
+ "version": "0.1.0",
+ "dependencies": {
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "^4.3.1",
+ "vite": "^5.4.2"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
+ "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
+ "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
+ "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
+ "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
+ "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
+ "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
+ "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
+ "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
+ "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
+ "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
+ "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
+ "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
+ "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
+ "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
+ "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
+ "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
+ "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
+ "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
+ "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
+ "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
+ "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
+ "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
+ "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
+ "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
+ "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
+ "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
+ "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.16",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz",
+ "integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.12",
+ "caniuse-lite": "^1.0.30001782",
+ "electron-to-chromium": "^1.5.328",
+ "node-releases": "^2.0.36",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001786",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz",
+ "integrity": "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.331",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz",
+ "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.37",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
+ "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.8",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+ "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
+ "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.1",
+ "@rollup/rollup-android-arm64": "4.60.1",
+ "@rollup/rollup-darwin-arm64": "4.60.1",
+ "@rollup/rollup-darwin-x64": "4.60.1",
+ "@rollup/rollup-freebsd-arm64": "4.60.1",
+ "@rollup/rollup-freebsd-x64": "4.60.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.1",
+ "@rollup/rollup-linux-arm64-musl": "4.60.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.1",
+ "@rollup/rollup-linux-loong64-musl": "4.60.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.1",
+ "@rollup/rollup-linux-x64-gnu": "4.60.1",
+ "@rollup/rollup-linux-x64-musl": "4.60.1",
+ "@rollup/rollup-openbsd-x64": "4.60.1",
+ "@rollup/rollup-openharmony-arm64": "4.60.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.1",
+ "@rollup/rollup-win32-x64-gnu": "4.60.1",
+ "@rollup/rollup-win32-x64-msvc": "4.60.1",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/openenv-polypharmacy/frontend/package.json b/openenv-polypharmacy/frontend/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..858ae22b50ac1a6732557b4815e882047b198712
--- /dev/null
+++ b/openenv-polypharmacy/frontend/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "polypharmacy-frontend",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview --port 4173"
+ },
+ "dependencies": {
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "^4.3.1",
+ "vite": "^5.4.2"
+ }
+}
diff --git a/openenv-polypharmacy/frontend/src/App.jsx b/openenv-polypharmacy/frontend/src/App.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..dd59ecb3e10109445a7b2e9be47177e7cd2d3233
--- /dev/null
+++ b/openenv-polypharmacy/frontend/src/App.jsx
@@ -0,0 +1,371 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+
+const API_BASE = "http://localhost:7860";
+const WS_URL = "ws://localhost:7860/ws";
+const TASKS = ["easy_screening", "budgeted_screening", "complex_tradeoff"];
+
+async function apiPost(path, body) {
+ const res = await fetch(`${API_BASE}${path}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const msg = await res.text();
+ throw new Error(msg || `HTTP ${res.status}`);
+ }
+ return res.json();
+}
+
+export default function App() {
+ const [taskId, setTaskId] = useState("budgeted_screening");
+ const [obs, setObs] = useState(null);
+ const [log, setLog] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [action, setAction] = useState({
+ action_type: "query_ddi",
+ drug_id_1: "",
+ drug_id_2: "",
+ target_drug_id: "",
+ intervention_type: "stop",
+ proposed_new_drug_id: "",
+ rationale: "",
+ });
+
+ const medIds = useMemo(
+ () => (obs?.current_medications || []).map((m) => m.drug_id),
+ [obs]
+ );
+ const hasValidEpisode = Boolean(obs?.episode_id) && (obs?.current_medications?.length || 0) > 0;
+ const isDone = Boolean(obs?.done);
+ const finalScore =
+ typeof obs?.metadata?.grader_score === "number" ? obs.metadata.grader_score : null;
+ const noBudgetsLeft =
+ hasValidEpisode &&
+ (obs?.remaining_query_budget ?? 0) <= 0 &&
+ (obs?.remaining_intervention_budget ?? 0) <= 0;
+ const wsRef = useRef(null);
+ const pendingRef = useRef([]);
+
+ const wsEnsure = async () => {
+ if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) return wsRef.current;
+ if (wsRef.current && wsRef.current.readyState === WebSocket.CONNECTING) {
+ await new Promise((r) => setTimeout(r, 80));
+ return wsEnsure();
+ }
+
+ const ws = new WebSocket(WS_URL);
+ wsRef.current = ws;
+
+ ws.onmessage = (evt) => {
+ try {
+ const msg = JSON.parse(evt.data);
+ const pending = pendingRef.current.shift();
+ if (pending) pending.resolve(msg);
+ } catch (e) {
+ const pending = pendingRef.current.shift();
+ if (pending) pending.reject(e);
+ }
+ };
+ ws.onerror = (err) => {
+ const pending = pendingRef.current.shift();
+ if (pending) pending.reject(err);
+ };
+ ws.onclose = () => {
+ wsRef.current = null;
+ };
+
+ await new Promise((resolve, reject) => {
+ const t = setTimeout(() => reject(new Error("WebSocket connect timeout")), 2500);
+ ws.onopen = () => {
+ clearTimeout(t);
+ resolve();
+ };
+ });
+ return ws;
+ };
+
+ const wsSend = async (type, data) => {
+ const ws = await wsEnsure();
+ return await new Promise((resolve, reject) => {
+ pendingRef.current.push({ resolve, reject });
+ ws.send(JSON.stringify({ type, data }));
+ });
+ };
+
+ useEffect(() => {
+ return () => {
+ try {
+ wsRef.current?.close();
+ } catch {
+ // ignore
+ }
+ };
+ }, []);
+
+ const appendLog = (text) => {
+ setLog((prev) => [`${new Date().toLocaleTimeString()} ${text}`, ...prev].slice(0, 20));
+ };
+
+ const normalizeObsFromWs = (packetData) => {
+ const observation = packetData?.observation || {};
+ const mergedMetadata = {
+ ...(observation?.metadata || {}),
+ ...(packetData?.info || {}),
+ };
+ return {
+ ...observation,
+ done: Boolean(packetData?.done ?? observation?.done ?? false),
+ reward: packetData?.reward ?? observation?.reward ?? null,
+ metadata: mergedMetadata,
+ };
+ };
+
+ const handleReset = async () => {
+ setLoading(true);
+ try {
+ const msg = await wsSend("reset", { task_id: taskId });
+ const data = msg?.data || {};
+ const normalized = normalizeObsFromWs(data);
+ setObs(normalized);
+ const ids = (normalized?.current_medications || []).map((m) => m.drug_id);
+ setAction((prev) => ({
+ ...prev,
+ drug_id_1: ids[0] || "",
+ drug_id_2: ids[1] || "",
+ target_drug_id: ids[0] || "",
+ }));
+ appendLog(`Reset task=${taskId}`);
+ } catch (err) {
+ appendLog(`Reset failed: ${err.message}`);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const buildActionPayload = () => {
+ if (noBudgetsLeft) {
+ return { action_type: "finish_review" };
+ }
+ if (action.action_type === "query_ddi") {
+ return {
+ action_type: "query_ddi",
+ drug_id_1: action.drug_id_1,
+ drug_id_2: action.drug_id_2,
+ };
+ }
+ if (action.action_type === "propose_intervention") {
+ return {
+ action_type: "propose_intervention",
+ target_drug_id: action.target_drug_id,
+ intervention_type: action.intervention_type,
+ proposed_new_drug_id: action.proposed_new_drug_id || undefined,
+ rationale: action.rationale || undefined,
+ };
+ }
+ return { action_type: "finish_review" };
+ };
+
+ const isActionValid = () => {
+ if (!hasValidEpisode) return false;
+ if (isDone) return false;
+ if (noBudgetsLeft) return true;
+ if (action.action_type === "query_ddi") {
+ return Boolean(action.drug_id_1 && action.drug_id_2);
+ }
+ if (action.action_type === "propose_intervention") {
+ return Boolean(action.target_drug_id && action.intervention_type);
+ }
+ return true;
+ };
+
+ const handleStep = async (overrideAction = null) => {
+ if (!hasValidEpisode) {
+ appendLog("Run Reset Episode before stepping.");
+ return;
+ }
+ setLoading(true);
+ try {
+ const payload = overrideAction || buildActionPayload();
+ const msg = await wsSend("step", payload);
+ const data = msg?.data || {};
+ const normalized = normalizeObsFromWs(data);
+ setObs(normalized);
+ appendLog(`Step: ${payload.action_type} -> reward=${data.reward ?? 0}`);
+ } catch (err) {
+ appendLog(`Step failed: ${err.message}`);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const askAi = async () => {
+ if (!hasValidEpisode) {
+ appendLog("Run Reset Episode before asking AI.");
+ return;
+ }
+ setLoading(true);
+ try {
+ const data = await apiPost("/agent/suggest", { observation: obs });
+ appendLog(`AI suggestion: ${data.action.action_type}`);
+ await handleStep(data.action);
+ } catch (err) {
+ appendLog(`AI suggestion failed: ${err.message}`);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
Polypharmacy Control Center
+
+
+ {hasValidEpisode ? "Session Live" : "Waiting for reset"}
+
+
+
+
+
+
+
+
+
+
+ Episode
+ {hasValidEpisode ? (
+
+
Episode{obs.episode_id}
+
Task{obs.task_id}
+
Age / Sex{obs.age} / {obs.sex}
+
Step{obs.step_index}
+
Query budget{obs.remaining_query_budget}
+
Intervention budget{obs.remaining_intervention_budget}
+
+ ) : (
+ Start with Reset Episode. Until then, step actions are blocked.
+ )}
+ {noBudgetsLeft && (
+ Query and intervention budgets are exhausted. Finish review to get final score.
+ )}
+ {isDone && (
+
+ Episode complete
+ {finalScore !== null ? ` • final score: ${finalScore.toFixed(3)}` : ""}.
+ Click Reset Episode to start a new case.
+
+ )}
+
+
+
+
+
+ Current Medications
+
+ {(obs?.current_medications || []).map((m) => (
+
+
{m.drug_id}
+
{m.generic_name}
+
{m.dose_mg} mg • {m.atc_class}
+
+ ))}
+
+
+
+
+ Event Log
+
+ {log.map((line, idx) => (
+
{line}
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/openenv-polypharmacy/frontend/src/main.jsx b/openenv-polypharmacy/frontend/src/main.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..5733886c4cff3e43049f0daddcb06387ffd341d1
--- /dev/null
+++ b/openenv-polypharmacy/frontend/src/main.jsx
@@ -0,0 +1,10 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import App from "./App";
+import "./styles.css";
+
+ReactDOM.createRoot(document.getElementById("root")).render(
+
+
+
+);
diff --git a/openenv-polypharmacy/frontend/src/styles.css b/openenv-polypharmacy/frontend/src/styles.css
new file mode 100644
index 0000000000000000000000000000000000000000..4370b6a96b9e10ccdbcdfc945481998770023f90
--- /dev/null
+++ b/openenv-polypharmacy/frontend/src/styles.css
@@ -0,0 +1,304 @@
+:root {
+ --bg: #eef5ff;
+ --panel: rgba(255, 255, 255, 0.82);
+ --panel-solid: #ffffff;
+ --text: #0b2445;
+ --muted: #5b7596;
+ --primary: #1f8bff;
+ --primary-2: #69beff;
+ --accent: #0dd3ff;
+ --border: rgba(93, 156, 219, 0.22);
+ --shadow: 0 20px 50px rgba(25, 83, 143, 0.12);
+ --shadow-strong: 0 20px 42px rgba(31, 112, 182, 0.24);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ font-family: "Inter", "SF Pro Text", "Segoe UI", sans-serif;
+ background:
+ radial-gradient(circle at 8% 0%, #cce7ff 0%, rgba(204, 231, 255, 0) 42%),
+ radial-gradient(circle at 92% 100%, #d5efff 0%, rgba(213, 239, 255, 0) 42%),
+ var(--bg);
+ color: var(--text);
+}
+
+.shell {
+ min-height: 100vh;
+ position: relative;
+ padding: 28px 22px;
+ overflow: hidden;
+}
+
+.container {
+ width: min(1300px, 100%);
+ margin: 0 auto;
+ position: relative;
+ z-index: 1;
+}
+
+.bg-orb {
+ position: absolute;
+ border-radius: 50%;
+ filter: blur(18px);
+ opacity: 0.9;
+}
+.orb-a {
+ width: 420px;
+ height: 420px;
+ right: -120px;
+ top: -100px;
+ background: radial-gradient(circle, rgba(72, 168, 255, 0.5), rgba(72, 168, 255, 0.1));
+}
+.orb-b {
+ width: 360px;
+ height: 360px;
+ left: -100px;
+ bottom: -120px;
+ background: radial-gradient(circle, rgba(110, 200, 255, 0.4), rgba(141, 205, 255, 0.06));
+}
+
+.glass {
+ backdrop-filter: blur(12px);
+ border: 1px solid var(--border);
+ background: var(--panel);
+ box-shadow: var(--shadow);
+}
+
+.topbar {
+ border-radius: 20px;
+ padding: 18px;
+ display: grid;
+ grid-template-columns: 1.2fr auto 1fr;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+}
+
+.title-wrap h1 {
+ margin: 0;
+ font-size: clamp(1.1rem, 1.5vw, 1.45rem);
+ letter-spacing: 0.01em;
+}
+
+.title-wrap p {
+ margin: 4px 0 0;
+ color: var(--muted);
+ font-size: 0.92rem;
+}
+
+.status-chip {
+ justify-self: center;
+ border-radius: 999px;
+ padding: 7px 12px;
+ font-size: 0.76rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ border: 1px solid transparent;
+}
+
+.status-chip.live {
+ color: #0d6a3f;
+ background: rgba(130, 245, 195, 0.18);
+ border-color: rgba(70, 199, 142, 0.3);
+}
+
+.status-chip.idle {
+ color: #24527f;
+ background: rgba(114, 194, 255, 0.18);
+ border-color: rgba(62, 152, 223, 0.28);
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+button,
+select,
+input {
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 10px 13px;
+ font-size: 0.92rem;
+ background: #fff;
+ color: var(--text);
+ min-height: 42px;
+}
+
+button {
+ cursor: pointer;
+ background: linear-gradient(135deg, var(--primary) 0%, var(--primary-2) 78%, var(--accent) 100%);
+ color: #fff;
+ border: none;
+ font-weight: 700;
+ box-shadow: var(--shadow-strong);
+ transition: transform 120ms ease, filter 120ms ease;
+}
+
+button:hover {
+ transform: translateY(-1px);
+ filter: brightness(1.02);
+}
+
+button.secondary {
+ background: linear-gradient(135deg, #68c2ff, #9dd9ff);
+}
+
+button:disabled {
+ opacity: 0.58;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.layout {
+ margin-top: 18px;
+ display: grid;
+ gap: 16px;
+ grid-template-columns: 1.15fr 0.85fr;
+ align-items: start;
+}
+
+.panel {
+ border-radius: 18px;
+ padding: 18px;
+}
+
+.panel-wide {
+ grid-column: 1 / -1;
+}
+
+.panel h2 {
+ margin: 0 0 12px;
+ font-size: 1rem;
+ letter-spacing: 0.01em;
+}
+
+.kpi-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.kpi-grid div {
+ background: rgba(255, 255, 255, 0.9);
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ padding: 12px;
+}
+
+.kpi-grid span {
+ display: block;
+ font-size: 0.74rem;
+ color: var(--muted);
+ margin-bottom: 4px;
+}
+
+.kpi-grid strong {
+ font-size: 1.05rem;
+}
+
+.action-row,
+.stack {
+ display: grid;
+ gap: 10px;
+ margin-bottom: 12px;
+}
+
+.stack-two {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.med-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 10px;
+ max-height: 420px;
+ overflow: auto;
+ padding-right: 2px;
+}
+
+.med-card {
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ padding: 12px;
+ background: var(--panel-solid);
+ transition: transform 120ms ease, box-shadow 120ms ease;
+}
+
+.med-card:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 10px 25px rgba(44, 105, 165, 0.12);
+}
+
+.med-card p {
+ margin: 6px 0 4px;
+ color: var(--muted);
+ text-transform: capitalize;
+}
+
+.logs {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
+ font-size: 0.85rem;
+ max-height: 300px;
+ overflow: auto;
+ display: grid;
+ gap: 6px;
+ padding-right: 2px;
+}
+
+.logs div {
+ background: rgba(255, 255, 255, 0.78);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 8px 10px;
+}
+
+.muted {
+ color: var(--muted);
+ margin: 0;
+}
+
+.budget-note {
+ margin-top: 10px;
+ padding: 10px 12px;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.78);
+}
+
+@media (max-width: 1120px) {
+ .layout {
+ grid-template-columns: 1fr;
+ }
+
+ .topbar {
+ grid-template-columns: 1fr;
+ }
+
+ .status-chip {
+ justify-self: start;
+ }
+
+ .actions {
+ justify-content: flex-start;
+ }
+}
+
+@media (max-width: 760px) {
+ .shell {
+ padding: 18px 12px;
+ }
+
+ .kpi-grid,
+ .med-grid,
+ .stack-two {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/openenv-polypharmacy/frontend/vite.config.js b/openenv-polypharmacy/frontend/vite.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..2a3f20a36de78020e9832ce22b3b8234ae1e5a13
--- /dev/null
+++ b/openenv-polypharmacy/frontend/vite.config.js
@@ -0,0 +1,10 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5173,
+ host: "0.0.0.0",
+ },
+});
diff --git a/openenv-polypharmacy/inference.py b/openenv-polypharmacy/inference.py
new file mode 100644
index 0000000000000000000000000000000000000000..a6809184af7dbc299ba4b8799eff00636647fb81
--- /dev/null
+++ b/openenv-polypharmacy/inference.py
@@ -0,0 +1,210 @@
+#!/usr/bin/env python3
+"""Baseline LLM inference script for the PolypharmacyEnv.
+
+Uses Groq's OpenAI-compatible Chat Completions API to drive an LLM agent through the
+PolypharmacyEnv HTTP API. Emits structured stdout logs in the
+[START], [STEP], [END] format required by the OpenEnv evaluation spec.
+
+Environment variables:
+ GROQ_API_KEY – required
+ GROQ_BASE_URL – optional (default: https://api.groq.com/openai/v1)
+ GROQ_MODEL_NAME – model to use (default: llama-3.1-8b-instant)
+ POLYPHARMACY_ENV_URL – environment HTTP base URL (default: http://localhost:7860)
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+import uuid
+from typing import Any, Dict, List
+
+import requests
+from openai import OpenAI
+
+# ── Configuration ────────────────────────────────────────────────────────────
+
+MODEL = os.environ.get("GROQ_MODEL_NAME", "llama-3.1-8b-instant")
+API_KEY = os.environ.get("GROQ_API_KEY", "")
+API_BASE = os.environ.get("GROQ_BASE_URL", "https://api.groq.com/openai/v1")
+ENV_URL = os.environ.get("POLYPHARMACY_ENV_URL", "http://localhost:7860")
+
+TASKS = ["easy_screening", "budgeted_screening", "complex_tradeoff"]
+EPISODES_PER_TASK = 5
+
+client = OpenAI(api_key=API_KEY, base_url=API_BASE)
+
+# ── Logging helpers ──────────────────────────────────────────────────────────
+
+def _log(tag: str, payload: Dict[str, Any]) -> None:
+ print(f"[{tag}] {json.dumps(payload, default=str)}", flush=True)
+
+
+def _err(msg: str) -> None:
+ print(msg, file=sys.stderr, flush=True)
+
+
+# ── Environment HTTP helpers ─────────────────────────────────────────────────
+
+def env_reset(task_id: str) -> Dict[str, Any]:
+ resp = requests.post(f"{ENV_URL}/reset", json={"task_id": task_id}, timeout=30)
+ resp.raise_for_status()
+ return resp.json()
+
+
+def env_step(action: Dict[str, Any]) -> Dict[str, Any]:
+ resp = requests.post(f"{ENV_URL}/step", json={"action": action}, timeout=30)
+ resp.raise_for_status()
+ return resp.json()
+
+
+# ── Observation → prompt ─────────────────────────────────────────────────────
+
+SYSTEM_PROMPT = """\
+You are a clinical pharmacist AI assistant reviewing an elderly patient's medication regimen.
+You must reduce drug-interaction risk and address Beers-criteria violations while minimising
+unnecessary medication changes.
+
+Available actions (respond with STRICT JSON, no extra text):
+1. Query a drug pair for interactions:
+ {"action_type": "query_ddi", "drug_id_1": "...", "drug_id_2": "..."}
+
+2. Propose an intervention:
+ {"action_type": "propose_intervention", "target_drug_id": "...",
+ "intervention_type": "stop|dose_reduce|substitute|add_monitoring",
+ "proposed_new_drug_id": "...(optional)", "rationale": "..."}
+
+3. Finish the review:
+ {"action_type": "finish_review"}
+
+Respond with EXACTLY ONE JSON object per turn. No markdown, no explanation outside JSON.
+"""
+
+
+def _summarise_obs(obs: Dict[str, Any]) -> str:
+ meds = obs.get("current_medications", [])
+ med_summary = "; ".join(
+ f"{m['drug_id']}({m['generic_name']},{m['dose_mg']}mg)"
+ for m in meds
+ )
+ queries = obs.get("interaction_queries", [])
+ q_summary = "; ".join(
+ f"{q['drug_id_1']}+{q['drug_id_2']}={q.get('severity','?')}"
+ for q in queries
+ )
+ interventions = obs.get("interventions", [])
+ iv_summary = "; ".join(
+ f"{iv['action_type']}({iv['target_drug_id']})"
+ for iv in interventions
+ )
+ return (
+ f"Patient: age={obs.get('age')}, sex={obs.get('sex')}, "
+ f"conditions={obs.get('conditions')}, "
+ f"eGFR={obs.get('eGFR_category')}, liver={obs.get('liver_function_category')}\n"
+ f"Medications: {med_summary}\n"
+ f"Queries so far: {q_summary or 'none'}\n"
+ f"Interventions so far: {iv_summary or 'none'}\n"
+ f"Remaining query budget: {obs.get('remaining_query_budget')}\n"
+ f"Remaining intervention budget: {obs.get('remaining_intervention_budget')}\n"
+ f"Step: {obs.get('step_index')}"
+ )
+
+
+# ── LLM call ─────────────────────────────────────────────────────────────────
+
+def _ask_llm(obs_summary: str) -> Dict[str, Any]:
+ """Call the LLM and parse a PolypharmacyAction JSON."""
+ try:
+ chat_resp = client.chat.completions.create(
+ model=MODEL,
+ messages=[
+ {"role": "system", "content": SYSTEM_PROMPT},
+ {"role": "user", "content": obs_summary},
+ ],
+ max_tokens=256,
+ temperature=0.2,
+ )
+ text = (chat_resp.choices[0].message.content or "").strip()
+ # Strip markdown fences if present
+ text = text.strip()
+ if text.startswith("```"):
+ text = text.split("\n", 1)[-1]
+ if text.endswith("```"):
+ text = text.rsplit("```", 1)[0]
+ text = text.strip()
+ return json.loads(text)
+ except Exception as e:
+ _err(f"LLM parse error: {e}")
+ return {"action_type": "finish_review"}
+
+
+# ── Main loop ────────────────────────────────────────────────────────────────
+
+def main() -> None:
+ if not API_KEY:
+ _err("GROQ_API_KEY is required")
+ sys.exit(1)
+
+ run_id = str(uuid.uuid4())[:8]
+
+ for task_id in TASKS:
+ task_scores: List[float] = []
+ task_rewards: List[float] = []
+
+ _log("START", {
+ "run_id": run_id,
+ "task_id": task_id,
+ "model": MODEL,
+ "episodes": EPISODES_PER_TASK,
+ })
+
+ for ep_idx in range(EPISODES_PER_TASK):
+ reset_resp = env_reset(task_id)
+ obs = reset_resp["observation"]
+ done = reset_resp.get("done", False)
+ episode_id = obs.get("episode_id", f"ep_{ep_idx}")
+ total_reward = 0.0
+ step_idx = 0
+
+ while not done:
+ obs_summary = _summarise_obs(obs)
+ action_payload = _ask_llm(obs_summary)
+
+ step_resp = env_step(action_payload)
+ obs = step_resp["observation"]
+ reward = step_resp.get("reward", 0.0)
+ done = step_resp.get("done", False)
+ total_reward += reward
+
+ _log("STEP", {
+ "run_id": run_id,
+ "task_id": task_id,
+ "episode_id": episode_id,
+ "step_index": step_idx,
+ "observation_summary": obs_summary[:200],
+ "action_payload": action_payload,
+ "reward": reward,
+ "done": done,
+ })
+
+ step_idx += 1
+
+ grader_score = step_resp.get("info", {}).get("grader_score", 0.0)
+ task_scores.append(grader_score)
+ task_rewards.append(total_reward)
+
+ _log("END", {
+ "run_id": run_id,
+ "task_id": task_id,
+ "episodes": EPISODES_PER_TASK,
+ "avg_grader_score": sum(task_scores) / max(len(task_scores), 1),
+ "avg_total_reward": sum(task_rewards) / max(len(task_rewards), 1),
+ "per_episode_scores": task_scores,
+ })
+
+ _err("Inference complete.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/openenv-polypharmacy/openenv.yaml b/openenv-polypharmacy/openenv.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..695032aa05514c2e804d3199ef5a7ba417974f72
--- /dev/null
+++ b/openenv-polypharmacy/openenv.yaml
@@ -0,0 +1,30 @@
+spec_version: 1
+name: polypharmacy_env
+version: "0.1.0"
+description: >
+ An OpenEnv environment that simulates elderly polypharmacy medication review.
+ An RL agent acts as a clinical pharmacist assistant, identifying dangerous
+ drug-drug interactions, Beers-criteria violations, and proposing safe
+ interventions (stop, dose-reduce, substitute, monitor).
+author: "PolypharmacyEnv Team"
+tags:
+ - healthcare
+ - polypharmacy
+ - openenv
+type: space
+runtime: fastapi
+app: backend.main:app
+port: 7860
+
+tasks:
+ - id: easy_screening
+ description: "Small regimen (3-5 drugs) with one severe DDI. Identify and resolve it."
+ difficulty: easy
+
+ - id: budgeted_screening
+ description: "Medium regimen (6-10 drugs) with multiple DDIs and Beers issues under query/intervention budgets."
+ difficulty: medium
+
+ - id: complex_tradeoff
+ description: "Large regimen (10-15 drugs) including critical drugs. Balance risk reduction against regimen disruption."
+ difficulty: hard
diff --git a/openenv-polypharmacy/pyproject.toml b/openenv-polypharmacy/pyproject.toml
new file mode 100644
index 0000000000000000000000000000000000000000..9bd219ea59455a8765851931c923b726ea32d1d9
--- /dev/null
+++ b/openenv-polypharmacy/pyproject.toml
@@ -0,0 +1,41 @@
+[build-system]
+requires = ["setuptools>=68.0", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "polypharmacy-env"
+version = "0.1.0"
+description = "OpenEnv environment for elderly polypharmacy medication-review safety"
+requires-python = ">=3.10"
+dependencies = [
+ "fastapi>=0.104.0",
+ "uvicorn>=0.24.0",
+ "pydantic>=2.0.0",
+ "requests>=2.31.0",
+ "openai>=1.0.0",
+ "python-dotenv>=1.0.0",
+ "openenv-core>=0.2.0",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=7.0.0",
+ "httpx>=0.25.0",
+ "black",
+ "isort",
+]
+
+[tool.setuptools.packages.find]
+where = ["backend/src"]
+
+[tool.pytest.ini_options]
+testpaths = ["backend/src/polypharmacy_env/tests"]
+pythonpath = ["backend/src"]
+
+[tool.black]
+line-length = 99
+target-version = ["py310"]
+
+[tool.isort]
+profile = "black"
+line_length = 99
diff --git a/openenv-polypharmacy/requirements.txt b/openenv-polypharmacy/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..82f21ff0993d8328c16385ebf07082f3318ecf27
--- /dev/null
+++ b/openenv-polypharmacy/requirements.txt
@@ -0,0 +1 @@
+-r backend/requirements.txt
diff --git a/openenv-polypharmacy/scripts/dev_backend.sh b/openenv-polypharmacy/scripts/dev_backend.sh
new file mode 100755
index 0000000000000000000000000000000000000000..83c2586f4d2a8ded9eed3a1c9817ef7bcc710c30
--- /dev/null
+++ b/openenv-polypharmacy/scripts/dev_backend.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+uvicorn backend.main:app --reload --host 0.0.0.0 --port 7860
diff --git a/openenv-polypharmacy/scripts/dev_frontend.sh b/openenv-polypharmacy/scripts/dev_frontend.sh
new file mode 100755
index 0000000000000000000000000000000000000000..d840f21d1f981bb07e20b9b055641ee198888cc2
--- /dev/null
+++ b/openenv-polypharmacy/scripts/dev_frontend.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd frontend
+npm run dev
diff --git a/openenv-polypharmacy/scripts/preprocess_data.py b/openenv-polypharmacy/scripts/preprocess_data.py
new file mode 100644
index 0000000000000000000000000000000000000000..22a8cb64172f111daaf900e1d878857e638f8070
--- /dev/null
+++ b/openenv-polypharmacy/scripts/preprocess_data.py
@@ -0,0 +1,301 @@
+"""Synthetic data generator for the PolypharmacyEnv.
+
+Generates:
+ - data/lookups/drug_metadata.csv
+ - data/lookups/ddi_rules.csv
+ - data/lookups/beers_criteria.csv
+ - data/processed/patients_polypharmacy.csv
+"""
+
+from __future__ import annotations
+
+import csv
+import random
+import sys
+from itertools import combinations
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+LOOKUPS = ROOT / "data" / "lookups"
+PROCESSED = ROOT / "data" / "processed"
+
+# ── Drug catalogue ───────────────────────────────────────────────────────────
+
+DRUGS = [
+ # drug_id, generic_name, atc_class, high_risk, default, min, max
+ ("DRUG_WARFARIN", "warfarin", "B01AA", 1, 5.0, 1.0, 10.0),
+ ("DRUG_APIXABAN", "apixaban", "B01AF", 1, 5.0, 2.5, 10.0),
+ ("DRUG_METFORMIN", "metformin", "A10BA", 0, 1000, 500, 2000),
+ ("DRUG_GLIPIZIDE", "glipizide", "A10BB", 1, 5.0, 2.5, 20.0),
+ ("DRUG_LISINOPRIL", "lisinopril", "C09AA", 0, 10.0, 2.5, 40.0),
+ ("DRUG_AMLODIPINE", "amlodipine", "C08CA", 0, 5.0, 2.5, 10.0),
+ ("DRUG_METOPROLOL", "metoprolol", "C07AB", 0, 50.0, 25.0,200.0),
+ ("DRUG_DIGOXIN", "digoxin", "C01AA", 1, 0.25, 0.0625,0.5),
+ ("DRUG_FUROSEMIDE", "furosemide", "C03CA", 0, 40.0, 20.0,160.0),
+ ("DRUG_SPIRONOLACTONE", "spironolactone", "C03DA", 0, 25.0, 12.5, 50.0),
+ ("DRUG_ATORVASTATIN", "atorvastatin", "C10AA", 0, 20.0, 10.0, 80.0),
+ ("DRUG_SIMVASTATIN", "simvastatin", "C10AA", 0, 20.0, 10.0, 40.0),
+ ("DRUG_OMEPRAZOLE", "omeprazole", "A02BC", 0, 20.0, 10.0, 40.0),
+ ("DRUG_DIAZEPAM", "diazepam", "N05BA", 1, 5.0, 2.0, 10.0),
+ ("DRUG_ALPRAZOLAM", "alprazolam", "N05BA", 1, 0.5, 0.25, 2.0),
+ ("DRUG_AMITRIPTYLINE", "amitriptyline", "N06AA", 1, 25.0, 10.0, 75.0),
+ ("DRUG_INSULIN_GLARGINE","insulin glargine", "A10AE", 1, 20.0, 10.0, 60.0),
+ ("DRUG_PREDNISONE", "prednisone", "H02AB", 0, 10.0, 5.0, 60.0),
+ ("DRUG_NAPROXEN", "naproxen", "M01AE", 1, 500, 250, 1000),
+ ("DRUG_IBUPROFEN", "ibuprofen", "M01AE", 1, 400, 200, 800),
+ ("DRUG_CLOPIDOGREL", "clopidogrel", "B01AC", 0, 75.0, 75.0, 75.0),
+ ("DRUG_ASPIRIN", "aspirin", "B01AC", 0, 81.0, 81.0, 325.0),
+ ("DRUG_HYDROCHLOROTHIAZIDE","HCTZ", "C03AA", 0, 25.0, 12.5, 50.0),
+ ("DRUG_DONEPEZIL", "donepezil", "N06DA", 0, 5.0, 5.0, 10.0),
+ ("DRUG_GABAPENTIN", "gabapentin", "N03AX", 0, 300, 100, 1200),
+ ("DRUG_TRAMADOL", "tramadol", "N02AX", 1, 50.0, 25.0, 200.0),
+ ("DRUG_FLUOXETINE", "fluoxetine", "N06AB", 0, 20.0, 10.0, 60.0),
+ ("DRUG_SERTRALINE", "sertraline", "N06AB", 0, 50.0, 25.0, 200.0),
+ ("DRUG_CIPROFLOXACIN", "ciprofloxacin", "J01MA", 0, 500, 250, 750),
+ ("DRUG_TAMSULOSIN", "tamsulosin", "G04CA", 0, 0.4, 0.4, 0.8),
+ ("DRUG_CELECOXIB", "celecoxib", "M01AE", 0, 200, 100, 400),
+ ("DRUG_NORTRIPTYLINE", "nortriptyline", "N06AA", 0, 25.0, 10.0, 75.0),
+ ("DRUG_LOSARTAN", "losartan", "C09AA", 0, 50.0, 25.0, 100.0),
+]
+
+# ── DDI rules ────────────────────────────────────────────────────────────────
+
+DDI_PAIRS: list[tuple[str, str, str, str, str, float]] = [
+ # id1, id2, severity, mechanism, recommendation, base_risk_score
+ ("DRUG_WARFARIN", "DRUG_NAPROXEN", "severe", "Increased bleeding risk – NSAID inhibits platelet + anticoagulant", "avoid_combination", 0.90),
+ ("DRUG_WARFARIN", "DRUG_IBUPROFEN", "severe", "Increased bleeding risk – NSAID + anticoagulant synergy", "avoid_combination", 0.88),
+ ("DRUG_WARFARIN", "DRUG_ASPIRIN", "moderate", "Additive antiplatelet + anticoagulant bleeding risk", "monitor_closely", 0.55),
+ ("DRUG_WARFARIN", "DRUG_FLUOXETINE", "moderate", "SSRI increases serotonin and may potentiate bleeding", "monitor_closely", 0.45),
+ ("DRUG_WARFARIN", "DRUG_CIPROFLOXACIN","moderate","CYP1A2 inhibition raises warfarin levels", "dose_adjust", 0.50),
+ ("DRUG_APIXABAN", "DRUG_NAPROXEN", "severe", "DOAC + NSAID – high bleeding risk", "avoid_combination", 0.85),
+ ("DRUG_APIXABAN", "DRUG_ASPIRIN", "moderate", "Additive bleeding risk with antiplatelet", "monitor_closely", 0.50),
+ ("DRUG_DIGOXIN", "DRUG_AMIODARONE", "severe", "Amiodarone increases digoxin levels – toxicity risk", "dose_adjust", 0.80),
+ ("DRUG_DIGOXIN", "DRUG_SPIRONOLACTONE","moderate","Spironolactone may raise digoxin levels", "monitor_closely", 0.40),
+ ("DRUG_METFORMIN", "DRUG_CIPROFLOXACIN","moderate","Fluoroquinolone may cause dysglycemia with metformin", "monitor_closely", 0.35),
+ ("DRUG_DIAZEPAM", "DRUG_TRAMADOL", "severe", "CNS depression – benzodiazepine + opioid", "avoid_combination", 0.92),
+ ("DRUG_ALPRAZOLAM", "DRUG_TRAMADOL", "severe", "CNS depression – benzodiazepine + opioid", "avoid_combination", 0.91),
+ ("DRUG_LISINOPRIL", "DRUG_SPIRONOLACTONE","moderate","Hyperkalemia risk – ACE-I + K-sparing diuretic", "monitor_closely", 0.48),
+ ("DRUG_LISINOPRIL", "DRUG_NAPROXEN", "moderate", "NSAID reduces ACE-I efficacy, renal risk", "monitor_closely", 0.42),
+ ("DRUG_SIMVASTATIN","DRUG_AMLODIPINE", "moderate", "CYP3A4 interaction increases statin exposure", "dose_adjust", 0.38),
+ ("DRUG_ATORVASTATIN","DRUG_CIPROFLOXACIN","mild", "Minor CYP interaction raising statin levels", "no_action", 0.15),
+ ("DRUG_CLOPIDOGREL","DRUG_OMEPRAZOLE", "moderate", "PPI reduces clopidogrel activation via CYP2C19", "dose_adjust", 0.45),
+ ("DRUG_INSULIN_GLARGINE","DRUG_GLIPIZIDE","moderate","Additive hypoglycemia risk", "monitor_closely", 0.50),
+ ("DRUG_FLUOXETINE", "DRUG_TRAMADOL", "severe", "Serotonin syndrome risk – SSRI + serotonergic opioid", "avoid_combination", 0.82),
+ ("DRUG_AMITRIPTYLINE","DRUG_TRAMADOL", "severe", "Serotonin syndrome + CNS depression", "avoid_combination", 0.85),
+ ("DRUG_METOPROLOL", "DRUG_DIGOXIN", "moderate", "Additive bradycardia", "monitor_closely", 0.40),
+ ("DRUG_FUROSEMIDE", "DRUG_DIGOXIN", "moderate", "Loop diuretic causes hypokalemia increasing digoxin toxicity risk", "monitor_closely", 0.45),
+ ("DRUG_PREDNISONE", "DRUG_NAPROXEN", "moderate", "GI bleeding risk – corticosteroid + NSAID", "monitor_closely", 0.50),
+ ("DRUG_PREDNISONE", "DRUG_WARFARIN", "mild", "Corticosteroid may alter INR", "monitor_closely", 0.25),
+]
+
+# ── Beers criteria ───────────────────────────────────────────────────────────
+
+BEERS_ENTRIES: list[tuple[str, str, str | None, str]] = [
+ # drug_id, criterion_type, condition, rationale
+ ("DRUG_DIAZEPAM", "avoid", None, "Long-acting benzodiazepine: falls, fractures, cognitive impairment in elderly"),
+ ("DRUG_ALPRAZOLAM", "avoid", None, "Benzodiazepine: falls, fractures, cognitive impairment in elderly"),
+ ("DRUG_AMITRIPTYLINE", "avoid", None, "Strongly anticholinergic TCA: sedation, confusion, urinary retention in elderly"),
+ ("DRUG_GLIPIZIDE", "caution", None, "Sulfonylurea: hypoglycemia risk higher in elderly"),
+ ("DRUG_NAPROXEN", "avoid", "CKD", "NSAID contraindicated in CKD – renal deterioration, fluid retention"),
+ ("DRUG_IBUPROFEN", "avoid", "CKD", "NSAID contraindicated in CKD – renal deterioration, fluid retention"),
+ ("DRUG_NAPROXEN", "caution", None, "NSAID: GI bleeding and renal risk in elderly"),
+ ("DRUG_IBUPROFEN", "caution", None, "NSAID: GI bleeding and renal risk in elderly"),
+ ("DRUG_DIGOXIN", "dose_adjust", None, "Avoid doses > 0.125 mg/day in elderly – toxicity risk"),
+ ("DRUG_TRAMADOL", "avoid", None, "Opioid: CNS depression, falls, constipation in elderly"),
+ ("DRUG_METFORMIN", "dose_adjust", "CKD", "Reduce dose or avoid if eGFR < 30 – lactic acidosis risk"),
+ ("DRUG_INSULIN_GLARGINE","caution", None, "Tight glycemic control increases hypoglycemia risk in elderly"),
+ ("DRUG_PREDNISONE", "avoid_in_condition", "DM", "Corticosteroid worsens glycemic control in diabetes"),
+ ("DRUG_DONEPEZIL", "avoid_in_condition", "dementia", "Limited benefit, GI side effects; reassess regularly"),
+ ("DRUG_CIPROFLOXACIN", "caution", None, "Fluoroquinolone: tendon rupture, QT prolongation risk in elderly"),
+]
+
+# ── Conditions pool & constraints ────────────────────────────────────────────
+
+ALL_CONDITIONS = ["HTN", "DM", "HF", "CKD", "AF", "COPD", "OA", "depression", "dementia", "GERD", "BPH", "neuropathy"]
+EGFR_CATS = ["normal", "mild", "moderate", "severe"]
+LIVER_CATS = ["normal", "impaired"]
+
+# Drugs that make clinical sense per condition
+CONDITION_DRUG_MAP: dict[str, list[str]] = {
+ "HTN": ["DRUG_LISINOPRIL", "DRUG_AMLODIPINE", "DRUG_METOPROLOL", "DRUG_HYDROCHLOROTHIAZIDE", "DRUG_FUROSEMIDE"],
+ "DM": ["DRUG_METFORMIN", "DRUG_GLIPIZIDE", "DRUG_INSULIN_GLARGINE"],
+ "HF": ["DRUG_FUROSEMIDE", "DRUG_SPIRONOLACTONE", "DRUG_METOPROLOL", "DRUG_LISINOPRIL", "DRUG_DIGOXIN"],
+ "CKD": ["DRUG_FUROSEMIDE", "DRUG_AMLODIPINE"],
+ "AF": ["DRUG_WARFARIN", "DRUG_APIXABAN", "DRUG_METOPROLOL", "DRUG_DIGOXIN"],
+ "COPD": ["DRUG_PREDNISONE"],
+ "OA": ["DRUG_NAPROXEN", "DRUG_IBUPROFEN", "DRUG_TRAMADOL", "DRUG_GABAPENTIN"],
+ "depression": ["DRUG_FLUOXETINE", "DRUG_SERTRALINE", "DRUG_AMITRIPTYLINE"],
+ "dementia": ["DRUG_DONEPEZIL"],
+ "GERD": ["DRUG_OMEPRAZOLE"],
+ "BPH": ["DRUG_TAMSULOSIN"],
+ "neuropathy": ["DRUG_GABAPENTIN", "DRUG_AMITRIPTYLINE"],
+}
+
+
+def _normalise_pair(a: str, b: str) -> tuple[str, str]:
+ return (a, b) if a < b else (b, a)
+
+
+def _gen_drug_metadata(out: Path) -> None:
+ out.parent.mkdir(parents=True, exist_ok=True)
+ with open(out, "w", newline="") as f:
+ w = csv.writer(f)
+ w.writerow(["drug_id", "generic_name", "atc_class", "is_high_risk_elderly",
+ "default_dose_mg", "min_dose_mg", "max_dose_mg"])
+ for row in DRUGS:
+ w.writerow(row)
+
+
+def _gen_ddi_rules(out: Path) -> None:
+ out.parent.mkdir(parents=True, exist_ok=True)
+ with open(out, "w", newline="") as f:
+ w = csv.writer(f)
+ w.writerow(["drug_id_1", "drug_id_2", "severity", "mechanism",
+ "recommendation", "base_risk_score"])
+ for pair in DDI_PAIRS:
+ a, b = _normalise_pair(pair[0], pair[1])
+ w.writerow([a, b, pair[2], pair[3], pair[4], pair[5]])
+
+
+def _gen_beers(out: Path) -> None:
+ out.parent.mkdir(parents=True, exist_ok=True)
+ with open(out, "w", newline="") as f:
+ w = csv.writer(f)
+ w.writerow(["drug_id", "criterion_type", "condition", "rationale"])
+ for row in BEERS_ENTRIES:
+ w.writerow([row[0], row[1], row[2] or "", row[3]])
+
+
+def _gen_patients(out: Path, n_easy: int = 40, n_med: int = 40, n_hard: int = 40) -> None:
+ """Generate synthetic patient episodes tagged by difficulty."""
+ out.parent.mkdir(parents=True, exist_ok=True)
+ rng = random.Random(42)
+ drug_ids = [d[0] for d in DRUGS]
+
+ # Build severity lookup for quick reference
+ severe_pairs: set[tuple[str, str]] = set()
+ for pair in DDI_PAIRS:
+ if pair[2] == "severe":
+ severe_pairs.add(_normalise_pair(pair[0], pair[1]))
+
+ rows: list[list[str]] = []
+ ep_counter = 0
+
+ def _pick_conditions(n: int) -> list[str]:
+ return rng.sample(ALL_CONDITIONS, min(n, len(ALL_CONDITIONS)))
+
+ def _drugs_for_conditions(conds: list[str], target_n: int) -> list[str]:
+ pool: list[str] = []
+ for c in conds:
+ pool.extend(CONDITION_DRUG_MAP.get(c, []))
+ pool = list(dict.fromkeys(pool)) # deduplicate preserving order
+ rng.shuffle(pool)
+ selected = pool[:target_n]
+ # Pad with random drugs if needed
+ remaining = [d for d in drug_ids if d not in selected]
+ while len(selected) < target_n and remaining:
+ pick = rng.choice(remaining)
+ remaining.remove(pick)
+ selected.append(pick)
+ return selected
+
+ def _count_severe(meds: list[str]) -> int:
+ count = 0
+ for a, b in combinations(meds, 2):
+ if _normalise_pair(a, b) in severe_pairs:
+ count += 1
+ return count
+
+ def _baseline_risk(meds: list[str]) -> float:
+ risk = 0.0
+ for pair in DDI_PAIRS:
+ a, b = _normalise_pair(pair[0], pair[1])
+ if a in meds and b in meds:
+ risk += pair[5]
+ return min(risk / max(len(meds), 1), 1.0)
+
+ # Easy episodes: 3-5 drugs, exactly 1 severe DDI
+ for _ in range(n_easy):
+ ep_counter += 1
+ n_drugs = rng.randint(3, 5)
+ conds = _pick_conditions(rng.randint(1, 3))
+ # Ensure at least one severe DDI pair is present
+ for attempt in range(50):
+ meds = _drugs_for_conditions(conds, n_drugs)
+ if _count_severe(meds) >= 1:
+ break
+ else:
+ # Force a known severe pair
+ sp = rng.choice(list(severe_pairs))
+ meds = list(set(meds[:n_drugs - 2]) | {sp[0], sp[1]})[:n_drugs]
+
+ age = rng.randint(65, 90)
+ sex = rng.choice(["M", "F"])
+ egfr = rng.choices(EGFR_CATS, weights=[4, 3, 2, 1])[0]
+ liver = rng.choices(LIVER_CATS, weights=[8, 2])[0]
+ br = round(_baseline_risk(meds), 4)
+ rows.append([
+ f"EP_{ep_counter:04d}", str(age), sex, ";".join(conds),
+ egfr, liver, ";".join(meds), str(br), "easy",
+ ])
+
+ # Medium episodes: 6-10 drugs, multiple DDIs
+ for _ in range(n_med):
+ ep_counter += 1
+ n_drugs = rng.randint(6, 10)
+ conds = _pick_conditions(rng.randint(3, 5))
+ meds = _drugs_for_conditions(conds, n_drugs)
+ age = rng.randint(65, 92)
+ sex = rng.choice(["M", "F"])
+ egfr = rng.choices(EGFR_CATS, weights=[3, 3, 3, 1])[0]
+ liver = rng.choices(LIVER_CATS, weights=[7, 3])[0]
+ br = round(_baseline_risk(meds), 4)
+ rows.append([
+ f"EP_{ep_counter:04d}", str(age), sex, ";".join(conds),
+ egfr, liver, ";".join(meds), str(br), "medium",
+ ])
+
+ # Hard episodes: 10-15 drugs, many issues, include critical drugs
+ for _ in range(n_hard):
+ ep_counter += 1
+ n_drugs = rng.randint(10, 15)
+ conds = _pick_conditions(rng.randint(4, 7))
+ meds = _drugs_for_conditions(conds, n_drugs)
+ # Ensure some critical drugs are present
+ critical = ["DRUG_WARFARIN", "DRUG_INSULIN_GLARGINE", "DRUG_DIGOXIN"]
+ for cd in rng.sample(critical, min(2, len(critical))):
+ if cd not in meds and len(meds) < 15:
+ meds.append(cd)
+ age = rng.randint(70, 95)
+ sex = rng.choice(["M", "F"])
+ egfr = rng.choices(EGFR_CATS, weights=[2, 2, 3, 3])[0]
+ liver = rng.choices(LIVER_CATS, weights=[6, 4])[0]
+ br = round(_baseline_risk(meds), 4)
+ rows.append([
+ f"EP_{ep_counter:04d}", str(age), sex, ";".join(conds),
+ egfr, liver, ";".join(meds), str(br), "hard",
+ ])
+
+ with open(out, "w", newline="") as f:
+ w = csv.writer(f)
+ w.writerow(["episode_id", "age", "sex", "conditions", "eGFR_category",
+ "liver_function_category", "medication_ids",
+ "baseline_risk_score", "difficulty"])
+ for r in rows:
+ w.writerow(r)
+
+
+def main() -> None:
+ print("Generating drug_metadata.csv …")
+ _gen_drug_metadata(LOOKUPS / "drug_metadata.csv")
+ print("Generating ddi_rules.csv …")
+ _gen_ddi_rules(LOOKUPS / "ddi_rules.csv")
+ print("Generating beers_criteria.csv …")
+ _gen_beers(LOOKUPS / "beers_criteria.csv")
+ print("Generating patients_polypharmacy.csv …")
+ _gen_patients(PROCESSED / "patients_polypharmacy.csv")
+ print("Done.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/openenv-polypharmacy/scripts/run_validation.sh b/openenv-polypharmacy/scripts/run_validation.sh
new file mode 100755
index 0000000000000000000000000000000000000000..903c8176803d36fa91643db5aa55b521f7d725e5
--- /dev/null
+++ b/openenv-polypharmacy/scripts/run_validation.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+# Run validation: tests, server smoke test, and heuristic baseline
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+echo "=== Running unit tests ==="
+PYTHONPATH=backend/src python3 -m pytest backend/src/polypharmacy_env/tests/ -v
+
+echo ""
+echo "=== Running heuristic baseline ==="
+PYTHONPATH=backend/src python3 -m polypharmacy_env.baselines.heuristic_agent
+
+echo ""
+echo "=== Validation complete ==="