diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..c355b0dfb7ea83fdc2375a7a2e26c6eb05a1e702 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +.venv +venv +__pycache__ +*.pyc +.pytest_cache +.mypy_cache +.git +.gitignore +*.md +!README.md +node_modules +app/ui/frontend/node_modules +app/ui/frontend/dist +checkpoints/active +checkpoints/.hf_bundles +outputs +.env +*.log +submission_bundle +notebooks +.pytest_cache diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..d374e4cfef2901f7c4da1b81293864fca2e0aae3 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +POLYGUARD_DATA_DIR=./data +POLYGUARD_LOG_LEVEL=INFO +POLYGUARD_SEED=42 +POLYGUARD_ENV_HOST=127.0.0.1 +POLYGUARD_ENV_PORT=8100 +POLYGUARD_API_HOST=127.0.0.1 +POLYGUARD_API_PORT=8200 +POLYGUARD_UI_PORT=5173 +POLYGUARD_ENABLE_OLLAMA=false +POLYGUARD_OLLAMA_MODEL=qwen2.5:3b-instruct +# Optional explicit order (comma-separated): transformers,ollama +# POLYGUARD_PROVIDER_PREFERENCE=transformers,ollama +POLYGUARD_PROVIDER_TIMEOUT_SECONDS=25 +# Trained checkpoint (GRPO adapter + merged + SFT) from HF: run +# python scripts/install_hf_active_bundle.py +# Then enable loading from checkpoints/active/active_model_manifest.json. +POLYGUARD_ENABLE_ACTIVE_MODEL=true +POLYGUARD_HF_MODEL=Qwen/Qwen2.5-0.5B-Instruct +POLYGUARD_FRONTIER_MODEL=Qwen/Qwen2.5-7B-Instruct +POLYGUARD_ALLOW_WEB_FETCH=false +POLYGUARD_REWARD_MIN=0.001 +POLYGUARD_REWARD_MAX=0.999 diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..22bbdfd704055658b8a263c6a4f10e6a800b9f5b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,11 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +app/ui/frontend/dist/blackhole.webm filter=lfs diff=lfs merge=lfs -text +app/ui/frontend/public/blackhole.webm filter=lfs diff=lfs merge=lfs -text +docs/results/model_improvement_evidence_qwen_0_5b_1_5b/charts/reward_function/reward_component_bars.png filter=lfs diff=lfs merge=lfs -text +docs/results/qwen_completed_runs/charts/generated/reward_component_bars.png filter=lfs diff=lfs merge=lfs -text +docs/results/submission_evidence/qwen_0_5b_1_5b/reward_component_bars.png filter=lfs diff=lfs merge=lfs -text +docs/results/submission_evidence/qwen_0_5b_1_5b_3b/reward_component_bars.png filter=lfs diff=lfs merge=lfs -text +docs/results/submission_evidence_qwen_0_5b_1_5b/charts/generated/reward_component_bars.png filter=lfs diff=lfs merge=lfs -text +docs/results/submission_evidence_qwen_0_5b_1_5b_3b/charts/generated/reward_component_bars.png filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..f6d7266ef3d3acf505d07a97283a959c4e61a6b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +.DS_Store +__pycache__/ +*.pyc +*.pyo +*.pyd +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.venv/ +.env +node_modules/ +dist/ +build/ +*.log +# Weight bundles and run outputs are local-only; tracked READMEs explain layout. +checkpoints/* +!checkpoints/README.md +outputs/* +!outputs/README.md +artifacts/ +submission_bundle/model_artifacts/ +submission_bundle/*.zip +data/cache/* +data/processed/* +data/synthetic/* +data/retrieval_index/* +!data/**/.gitkeep +app/ui/frontend/.vite/ +/demo.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..98d1116f0afd6bb4c961509d865c140dcae6e78d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +# Hugging Face Space: single-port edge (nginx) + OpenEnv (8100) + API (8200) + static UI. +# Build from repository root: docker build -f Dockerfile.space -t polyguard-space . +# Cheap tier: use Space "CPU basic"; first boot downloads ~1.1GB model bundle. + +FROM node:20-bookworm-slim AS frontend +WORKDIR /build +COPY app/ui/frontend/package.json app/ui/frontend/package-lock.json ./ +RUN npm ci +COPY app/ui/frontend/ ./ +ENV VITE_API_BASE=/api +RUN npm run build + +FROM python:3.11-slim-bookworm +WORKDIR /app +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends nginx \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements-space.txt /app/requirements-space.txt +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu \ + && pip install --no-cache-dir -r /app/requirements-space.txt + +COPY . /app +COPY --from=frontend /build/dist /app/static + +RUN chmod +x /app/docker/space/entrypoint.sh \ + && mkdir -p /app/data /app/checkpoints/active + +ENV PORT=7860 +ENV POLYGUARD_ALLOW_HF_SPACE_CORS=true +ENV POLYGUARD_ENABLE_OLLAMA=false +ENV POLYGUARD_ENABLE_ACTIVE_MODEL=true +ENV POLYGUARD_HF_MODEL=Qwen/Qwen2.5-0.5B-Instruct +ENV POLYGUARD_PROVIDER_PREFERENCE=transformers +ENV POLYGUARD_ALLOW_WEB_FETCH=false +ENV POLYGUARD_DATA_DIR=/app/data +ENV PYTHONUNBUFFERED=1 + +EXPOSE 7860 +CMD ["/app/docker/space/entrypoint.sh"] diff --git a/Dockerfile.space b/Dockerfile.space new file mode 100644 index 0000000000000000000000000000000000000000..98d1116f0afd6bb4c961509d865c140dcae6e78d --- /dev/null +++ b/Dockerfile.space @@ -0,0 +1,41 @@ +# Hugging Face Space: single-port edge (nginx) + OpenEnv (8100) + API (8200) + static UI. +# Build from repository root: docker build -f Dockerfile.space -t polyguard-space . +# Cheap tier: use Space "CPU basic"; first boot downloads ~1.1GB model bundle. + +FROM node:20-bookworm-slim AS frontend +WORKDIR /build +COPY app/ui/frontend/package.json app/ui/frontend/package-lock.json ./ +RUN npm ci +COPY app/ui/frontend/ ./ +ENV VITE_API_BASE=/api +RUN npm run build + +FROM python:3.11-slim-bookworm +WORKDIR /app +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends nginx \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements-space.txt /app/requirements-space.txt +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu \ + && pip install --no-cache-dir -r /app/requirements-space.txt + +COPY . /app +COPY --from=frontend /build/dist /app/static + +RUN chmod +x /app/docker/space/entrypoint.sh \ + && mkdir -p /app/data /app/checkpoints/active + +ENV PORT=7860 +ENV POLYGUARD_ALLOW_HF_SPACE_CORS=true +ENV POLYGUARD_ENABLE_OLLAMA=false +ENV POLYGUARD_ENABLE_ACTIVE_MODEL=true +ENV POLYGUARD_HF_MODEL=Qwen/Qwen2.5-0.5B-Instruct +ENV POLYGUARD_PROVIDER_PREFERENCE=transformers +ENV POLYGUARD_ALLOW_WEB_FETCH=false +ENV POLYGUARD_DATA_DIR=/app/data +ENV PYTHONUNBUFFERED=1 + +EXPOSE 7860 +CMD ["/app/docker/space/entrypoint.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..14fac913ccf80234b1848540089a3bbcb6e5283d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..ddeb590354c7661233aba2b9eb17418d0adc7bcf --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +.PHONY: install test lint env api ui smoke run-all + +VENV_DIR := .venv +PYTHON := $(VENV_DIR)/bin/python +PIP := $(VENV_DIR)/bin/pip + +$(PYTHON): + python3 -m venv $(VENV_DIR) + +install: $(PYTHON) + bash scripts/bootstrap_venv.sh + +test: $(PYTHON) + PYTHONPATH=. $(PYTHON) -m pytest + +env: $(PYTHON) + PYTHONPATH=. $(PYTHON) -m app.env.fastapi_app + +api: $(PYTHON) + PYTHONPATH=. $(PYTHON) -m app.api + +ui: + cd app/ui/frontend && npm install && npm run dev + +smoke: + bash scripts/smoke_test_all.sh + +run-all: $(PYTHON) + bash scripts/run_all_local.sh --full diff --git a/PolyGuard_SFT_GRPO_One_Run_Runner.ipynb b/PolyGuard_SFT_GRPO_One_Run_Runner.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..e9505cdccfc559aebd6609bf04603c40f12f16b0 --- /dev/null +++ b/PolyGuard_SFT_GRPO_One_Run_Runner.ipynb @@ -0,0 +1,481 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# PolyGuard SFT + GRPO One-Run Runner\n", + "\n", + "`POLYGUARD_ONE_RUN_RUNNER`\n", + "\n", + "Run this notebook from top to bottom to execute the PolyGuard pipeline from data build through SFT baseline training, GRPO environment-reward training, artifact pull, inference validation, report/chart generation, and Hugging Face Space deployment.\n", + "\n", + "Default behavior uses Hugging Face Spaces for GPU training, not local Ollama or local GPU training. Keep `HF_TOKEN` in an environment variable or notebook secret; do not paste it into a cell output or commit it.\n", + "\n", + "Reward values are expected to remain numeric, rounded to 3 decimals, and clamped to `[0.001, 0.999]` throughout the API, reports, and charts." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 0) Configuration Notes\n", + "\n", + "The notebook is intentionally root-level in `polyguard-rl/`. If opened from Colab without the rest of the repo, the first cell clones the GitHub repo and changes into `polyguard-rl/`.\n", + "\n", + "Useful overrides:\n", + "\n", + "- `HF_TOKEN`: write token for Spaces, model artifact repos, and private artifact pulls.\n", + "- `HF_USERNAME`: target Hub namespace. If omitted, the authenticated username is used.\n", + "- `POLYGUARD_MODEL_SWEEP`: comma-separated models, default Qwen 0.5B, 1.5B, and 3B instruct.\n", + "- `POLYGUARD_SFT_EPOCHS`, `POLYGUARD_GRPO_EPOCHS`: training epochs.\n", + "- `POLYGUARD_SFT_MAX_STEPS=0`, `POLYGUARD_GRPO_MAX_STEPS=0`, `POLYGUARD_GRPO_MAX_PROMPTS=0`: full-corpus/full-epoch mode.\n", + "- `POLYGUARD_WAIT_FOR_REMOTE_TRAINING=1`: keep polling until artifacts are pulled or timeout hits.\n", + "- `POLYGUARD_RUN_LOCAL_SMOKE=1`: also run a tiny local SFT/GRPO smoke loop." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from __future__ import annotations\n", + "\n", + "import json\n", + "import os\n", + "from pathlib import Path\n", + "import subprocess\n", + "import sys\n", + "import time\n", + "\n", + "PROJECT_SUBDIR = \"polyguard-rl\"\n", + "DEFAULT_REPO_URL = \"https://github.com/Vishwa-docs/Meta_Pytorch_OpenEnv_Scaler_VK.git\"\n", + "REPO_URL = os.getenv(\"POLYGUARD_GITHUB_REPO_URL\", DEFAULT_REPO_URL)\n", + "\n", + "cwd = Path.cwd().resolve()\n", + "if (cwd / \"pyproject.toml\").exists() and (cwd / \"scripts\").exists():\n", + " ROOT = cwd\n", + "elif (cwd / PROJECT_SUBDIR / \"pyproject.toml\").exists():\n", + " ROOT = cwd / PROJECT_SUBDIR\n", + "else:\n", + " clone_root = Path(os.getenv(\"POLYGUARD_REPO_DIR\", \"/content/Meta_Pytorch_OpenEnv_Scaler_VK\")).resolve()\n", + " if not clone_root.exists():\n", + " subprocess.run([\"git\", \"clone\", REPO_URL, str(clone_root)], check=True)\n", + " ROOT = clone_root / PROJECT_SUBDIR\n", + "\n", + "os.chdir(ROOT)\n", + "print(f\"PolyGuard root: {ROOT}\")\n", + "\n", + "def run(cmd: list[str] | str, *, check: bool = True, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:\n", + " printable = cmd if isinstance(cmd, str) else \" \".join(cmd)\n", + " print(f\"\\n$ {printable}\")\n", + " merged_env = os.environ.copy()\n", + " if env:\n", + " merged_env.update(env)\n", + " completed = subprocess.run(cmd, check=False, text=True, env=merged_env)\n", + " if check and completed.returncode != 0:\n", + " raise RuntimeError(f\"command_failed:{printable}\")\n", + " return completed\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Install local runtime dependencies. This keeps the notebook kernel light while project commands run through uv.\n", + "run([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", \"-U\", \"uv\", \"huggingface_hub\", \"gradio_client\"])\n", + "run([\"uv\", \"sync\"])\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def read_colab_secret(name: str) -> str:\n", + " try:\n", + " from google.colab import userdata # type: ignore\n", + " except Exception:\n", + " return \"\"\n", + " try:\n", + " return str(userdata.get(name) or \"\")\n", + " except Exception:\n", + " return \"\"\n", + "\n", + "HF_TOKEN = os.getenv(\"HF_TOKEN\", \"\") or read_colab_secret(\"HF_TOKEN\")\n", + "if HF_TOKEN:\n", + " os.environ[\"HF_TOKEN\"] = HF_TOKEN\n", + "\n", + "if os.getenv(\"POLYGUARD_REQUIRE_HF_TOKEN\", \"1\") == \"1\" and not HF_TOKEN:\n", + " raise RuntimeError(\"Set HF_TOKEN as an environment variable or Colab secret before running the remote training cells.\")\n", + "\n", + "HF_USERNAME = os.getenv(\"HF_USERNAME\", \"\")\n", + "if HF_TOKEN and not HF_USERNAME:\n", + " from huggingface_hub import HfApi\n", + "\n", + " whoami = HfApi(token=HF_TOKEN).whoami(token=HF_TOKEN)\n", + " HF_USERNAME = str(whoami.get(\"name\") or whoami.get(\"fullname\") or \"\")\n", + "\n", + "if not HF_USERNAME:\n", + " HF_USERNAME = \"TheJackBright\"\n", + "\n", + "MODEL_SWEEP = os.getenv(\n", + " \"POLYGUARD_MODEL_SWEEP\",\n", + " \"Qwen/Qwen2.5-0.5B-Instruct,Qwen/Qwen2.5-1.5B-Instruct,Qwen/Qwen2.5-3B-Instruct\",\n", + ")\n", + "TRAINING_SPACE_REPO_ID = os.getenv(\"POLYGUARD_TRAINING_SPACE_REPO_ID\", f\"{HF_USERNAME}/polyguard-openenv-training-full\")\n", + "ARTIFACT_REPO_ID = os.getenv(\"POLYGUARD_ARTIFACT_REPO_ID\", f\"{HF_USERNAME}/polyguard-openenv-training-full-artifacts\")\n", + "PRODUCT_SPACE_REPO_ID = os.getenv(\"POLYGUARD_PRODUCT_SPACE_REPO_ID\", f\"{HF_USERNAME}/polyguard-openenv\")\n", + "\n", + "SFT_EPOCHS = os.getenv(\"POLYGUARD_SFT_EPOCHS\", \"2\")\n", + "GRPO_EPOCHS = os.getenv(\"POLYGUARD_GRPO_EPOCHS\", \"1\")\n", + "SFT_MAX_STEPS = os.getenv(\"POLYGUARD_SFT_MAX_STEPS\", \"0\")\n", + "GRPO_MAX_STEPS = os.getenv(\"POLYGUARD_GRPO_MAX_STEPS\", \"0\")\n", + "GRPO_MAX_PROMPTS = os.getenv(\"POLYGUARD_GRPO_MAX_PROMPTS\", \"0\")\n", + "GRPO_NUM_GENERATIONS = os.getenv(\"POLYGUARD_GRPO_NUM_GENERATIONS\", \"2\")\n", + "DATA_PROFILE = os.getenv(\"POLYGUARD_DATA_PROFILE\", \"massive\")\n", + "\n", + "RUN_REMOTE_TRAINING = os.getenv(\"POLYGUARD_RUN_REMOTE_TRAINING\", \"1\") == \"1\"\n", + "WAIT_FOR_REMOTE_TRAINING = os.getenv(\"POLYGUARD_WAIT_FOR_REMOTE_TRAINING\", \"1\") == \"1\"\n", + "RUN_LOCAL_SMOKE = os.getenv(\"POLYGUARD_RUN_LOCAL_SMOKE\", \"0\") == \"1\"\n", + "DEPLOY_PRODUCT_SPACE = os.getenv(\"POLYGUARD_DEPLOY_PRODUCT_SPACE\", \"1\") == \"1\"\n", + "PRODUCT_SPACE_PRIVATE = os.getenv(\"POLYGUARD_PRODUCT_SPACE_PRIVATE\", \"0\") == \"1\"\n", + "REMOTE_TIMEOUT_HOURS = float(os.getenv(\"POLYGUARD_REMOTE_TIMEOUT_HOURS\", \"12\"))\n", + "REMOTE_POLL_SECONDS = int(os.getenv(\"POLYGUARD_REMOTE_POLL_SECONDS\", \"300\"))\n", + "\n", + "print(json.dumps({\n", + " \"hf_username\": HF_USERNAME,\n", + " \"model_sweep\": MODEL_SWEEP,\n", + " \"training_space_repo_id\": TRAINING_SPACE_REPO_ID,\n", + " \"artifact_repo_id\": ARTIFACT_REPO_ID,\n", + " \"product_space_repo_id\": PRODUCT_SPACE_REPO_ID,\n", + " \"data_profile\": DATA_PROFILE,\n", + " \"run_remote_training\": RUN_REMOTE_TRAINING,\n", + " \"wait_for_remote_training\": WAIT_FOR_REMOTE_TRAINING,\n", + " \"run_local_smoke\": RUN_LOCAL_SMOKE,\n", + " \"deploy_product_space\": DEPLOY_PRODUCT_SPACE,\n", + "}, indent=2))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1) Build Data And Training Corpora\n", + "\n", + "This builds processed data, scenario artifacts, SFT records, and GRPO prompt episodes. The training Space repeats the full build inside its container so remote training is reproducible." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "run([\"uv\", \"run\", \"python\", \"scripts/bootstrap_data.py\"])\n", + "run([\n", + " \"uv\", \"run\", \"python\", \"scripts/build_training_corpus.py\",\n", + " \"--profile\", DATA_PROFILE,\n", + " \"--with-local\",\n", + " \"--with-synthetic\",\n", + " \"--with-hf\",\n", + "])\n", + "summary_path = Path(\"data/processed/training_corpus_summary.json\")\n", + "print(summary_path.read_text(encoding=\"utf-8\") if summary_path.exists() else \"training_corpus_summary_missing\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2) Local Contract Checks\n", + "\n", + "These checks verify the package, OpenEnv contract, reward bounds, and report-generation surfaces before spending GPU time." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "run([\"uv\", \"run\", \"pytest\"])\n", + "run([\"uv\", \"run\", \"openenv\", \"validate\", \".\"])\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3) Optional Local Smoke SFT And GRPO\n", + "\n", + "The final training path is the HF Space below. Set `POLYGUARD_RUN_LOCAL_SMOKE=1` only if you want a tiny local compliance run before the remote job." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if RUN_LOCAL_SMOKE:\n", + " local_model = os.getenv(\"POLYGUARD_LOCAL_SMOKE_MODEL\", \"Qwen/Qwen2.5-0.5B-Instruct\")\n", + " run([\n", + " \"uv\", \"run\", \"python\", \"scripts/train_sft_trl.py\",\n", + " \"--model-id\", local_model,\n", + " \"--dataset-path\", \"data/processed/training_corpus_sft.json\",\n", + " \"--output-dir\", \"checkpoints/sft_adapter\",\n", + " \"--report-path\", \"outputs/reports/sft_trl_run.json\",\n", + " \"--epochs\", \"1\",\n", + " \"--max-steps\", \"20\",\n", + " \"--batch-size\", \"1\",\n", + " \"--use-unsloth\",\n", + " ])\n", + " run([\n", + " \"uv\", \"run\", \"python\", \"scripts/train_grpo_trl.py\",\n", + " \"--model-id\", local_model,\n", + " \"--prompts-path\", \"data/processed/training_corpus_grpo_prompts.jsonl\",\n", + " \"--output-dir\", \"checkpoints/grpo_adapter\",\n", + " \"--report-path\", \"outputs/reports/grpo_trl_run.json\",\n", + " \"--max-steps\", \"20\",\n", + " \"--max-prompts\", \"64\",\n", + " \"--num-generations\", \"2\",\n", + " \"--batch-size\", \"1\",\n", + " \"--use-unsloth\",\n", + " ])\n", + "else:\n", + " print(\"Local smoke skipped. Remote HF Space training remains the main path.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4) Start SFT Baseline And GRPO Training On Hugging Face Spaces\n", + "\n", + "This deploys the private training Space and artifact repo, starts the Docker runner, builds the full corpus inside the Space, trains SFT as the baseline, trains GRPO with environment-backed rewards, runs post-save inference and ablations, then uploads reports, plots, adapters, and manifests." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if RUN_REMOTE_TRAINING:\n", + " deploy_cmd = [\n", + " \"uv\", \"run\", \"python\", \"scripts/deploy_training_space.py\",\n", + " \"--repo-id\", TRAINING_SPACE_REPO_ID,\n", + " \"--artifact-repo-id\", ARTIFACT_REPO_ID,\n", + " \"--hardware\", os.getenv(\"POLYGUARD_HF_HARDWARE\", \"a10g-large\"),\n", + " \"--model-sweep\", MODEL_SWEEP,\n", + " \"--training-mode\", os.getenv(\"POLYGUARD_TRAINING_MODE\", \"full\"),\n", + " \"--sft-epochs\", SFT_EPOCHS,\n", + " \"--grpo-epochs\", GRPO_EPOCHS,\n", + " \"--sft-max-steps\", SFT_MAX_STEPS,\n", + " \"--grpo-max-steps\", GRPO_MAX_STEPS,\n", + " \"--grpo-max-prompts\", GRPO_MAX_PROMPTS,\n", + " \"--grpo-num-generations\", GRPO_NUM_GENERATIONS,\n", + " ]\n", + " if os.getenv(\"POLYGUARD_TRAINING_SPACE_PUBLIC\", \"0\") == \"1\":\n", + " deploy_cmd.append(\"--public\")\n", + " run(deploy_cmd)\n", + " print(f\"Training Space: https://huggingface.co/spaces/{TRAINING_SPACE_REPO_ID}\")\n", + " print(f\"Artifact repo: https://huggingface.co/{ARTIFACT_REPO_ID}\")\n", + "else:\n", + " print(\"Remote training deployment skipped by POLYGUARD_RUN_REMOTE_TRAINING=0\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5) Monitor Space And Pull Artifacts\n", + "\n", + "If `POLYGUARD_WAIT_FOR_REMOTE_TRAINING=1`, this cell keeps polling until `scripts/pull_training_artifacts.py` succeeds or the timeout is reached. It never prints the token." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "monitor_output = \"outputs/reports/training_space_runtime_status.json\"\n", + "\n", + "def monitor_once() -> int:\n", + " return run([\n", + " \"uv\", \"run\", \"python\", \"scripts/monitor_training_space_status.py\",\n", + " \"--space-id\", TRAINING_SPACE_REPO_ID,\n", + " \"--artifact-repo-id\", ARTIFACT_REPO_ID,\n", + " \"--output\", monitor_output,\n", + " ], check=False).returncode\n", + "\n", + "def pull_once() -> bool:\n", + " return run([\n", + " \"uv\", \"run\", \"python\", \"scripts/pull_training_artifacts.py\",\n", + " \"--artifact-repo-id\", ARTIFACT_REPO_ID,\n", + " ], check=False).returncode == 0\n", + "\n", + "pulled = False\n", + "if RUN_REMOTE_TRAINING and WAIT_FOR_REMOTE_TRAINING:\n", + " deadline = time.time() + REMOTE_TIMEOUT_HOURS * 3600\n", + " attempt = 0\n", + " while time.time() < deadline:\n", + " attempt += 1\n", + " print(f\"Remote poll {attempt}\")\n", + " monitor_once()\n", + " pulled = pull_once()\n", + " if pulled:\n", + " print(\"Remote training artifacts pulled successfully.\")\n", + " break\n", + " print(f\"Artifacts not ready yet. Sleeping {REMOTE_POLL_SECONDS} seconds.\")\n", + " time.sleep(REMOTE_POLL_SECONDS)\n", + " if not pulled:\n", + " raise TimeoutError(\"Remote training did not produce pullable artifacts before timeout.\")\n", + "else:\n", + " monitor_once()\n", + " pulled = pull_once()\n", + " print(f\"Single pull attempt success: {pulled}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6) Generate Reports, Charts, And Evidence Bundles\n", + "\n", + "This creates SFT-vs-GRPO charts, Qwen model comparison charts, reward component bars, anti-hacking/overfit checks, basic-LLM-vs-PolyGuard evidence, action traces, and curated submission evidence folders." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "run([\"uv\", \"run\", \"python\", \"scripts/generate_hf_training_report.py\", \"--mode\", os.getenv(\"POLYGUARD_TRAINING_MODE\", \"full\")], check=False)\n", + "run([\"uv\", \"run\", \"python\", \"scripts/evaluate_policy_ablations.py\", \"--episodes\", os.getenv(\"POLYGUARD_ABLATION_EPISODES\", \"8\")], check=False)\n", + "run([\n", + " \"uv\", \"run\", \"python\", \"scripts/generate_submission_evidence.py\",\n", + " \"--models\", os.getenv(\"POLYGUARD_EVIDENCE_MODELS\", \"qwen-qwen2-5-0-5b-instruct,qwen-qwen2-5-1-5b-instruct\"),\n", + " \"--artifact-repo-id\", ARTIFACT_REPO_ID,\n", + " \"--training-space-url\", f\"https://{TRAINING_SPACE_REPO_ID.replace('/', '-').lower()}.hf.space\",\n", + " \"--episodes\", os.getenv(\"POLYGUARD_EVIDENCE_EPISODES\", \"8\"),\n", + "], check=False)\n", + "run([\"uv\", \"run\", \"python\", \"scripts/build_improvement_evidence_bundle.py\"], check=False)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7) Activate A Model For Product Inference And Validate Post-Save Inference\n", + "\n", + "The app reads `checkpoints/active/active_model_manifest.json`. The default active run is Qwen 0.5B because it is the smallest practical implementation target; switch `POLYGUARD_ACTIVE_RUN_ID` to the 1.5B or 3B run after those artifacts are pulled." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ACTIVE_RUN_ID = os.getenv(\"POLYGUARD_ACTIVE_RUN_ID\", \"qwen-qwen2-5-0-5b-instruct\")\n", + "run([\n", + " \"uv\", \"run\", \"python\", \"scripts/activate_sweep_model.py\",\n", + " \"--source\", \"sweep\",\n", + " \"--run-id\", ACTIVE_RUN_ID,\n", + " \"--preferred-artifact\", os.getenv(\"POLYGUARD_PREFERRED_ARTIFACT\", \"grpo_adapter\"),\n", + "], check=False)\n", + "run([\"uv\", \"run\", \"python\", \"scripts/test_inference_postsave.py\", \"--samples\", os.getenv(\"POLYGUARD_INFERENCE_SAMPLES\", \"3\")], check=False)\n", + "run([\"uv\", \"run\", \"python\", \"scripts/benchmark_inference.py\"], check=False)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8) Deploy The Product OpenEnv Space\n", + "\n", + "This deploys the FastAPI/OpenEnv product Space. It is separate from the private GPU training Space." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if DEPLOY_PRODUCT_SPACE:\n", + " product_cmd = [\"uv\", \"run\", \"python\", \"scripts/deploy_space_api.py\", \"--repo-id\", PRODUCT_SPACE_REPO_ID]\n", + " if PRODUCT_SPACE_PRIVATE:\n", + " product_cmd.append(\"--private\")\n", + " run(product_cmd)\n", + " runtime_url = f\"https://{PRODUCT_SPACE_REPO_ID.replace('/', '-').lower()}.hf.space\"\n", + " run([\"uv\", \"run\", \"openenv\", \"validate\", \"--url\", runtime_url], check=False)\n", + " print(f\"Product Space: https://huggingface.co/spaces/{PRODUCT_SPACE_REPO_ID}\")\n", + " print(f\"Runtime URL: {runtime_url}\")\n", + "else:\n", + " print(\"Product Space deploy skipped by POLYGUARD_DEPLOY_PRODUCT_SPACE=0\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 9) Final Acceptance Gate And Output Summary" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "run([\"uv\", \"run\", \"python\", \"scripts/acceptance_gate.py\"], check=False)\n", + "\n", + "summary = {\n", + " \"training_space\": f\"https://huggingface.co/spaces/{TRAINING_SPACE_REPO_ID}\",\n", + " \"artifact_repo\": f\"https://huggingface.co/{ARTIFACT_REPO_ID}\",\n", + " \"product_space\": f\"https://huggingface.co/spaces/{PRODUCT_SPACE_REPO_ID}\",\n", + " \"reports\": [\n", + " \"outputs/reports/hf_sweep_summary.json\",\n", + " \"outputs/reports/anti_hacking_overfit_report.json\",\n", + " \"outputs/reports/postsave_inference.json\",\n", + " \"docs/results/submission_evidence_qwen_0_5b_1_5b/README.md\",\n", + " \"docs/results/model_improvement_evidence_qwen_0_5b_1_5b/README.md\",\n", + " ],\n", + " \"plots_dir\": \"outputs/plots\",\n", + " \"active_model_manifest\": \"checkpoints/active/active_model_manifest.json\",\n", + "}\n", + "print(json.dumps(summary, indent=2))\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/README.md b/README.md index d44594ed07c2801ab9e92220bf25f90c923cc524..40afe389f19f24b8469e0d01ceb34e93bcabe752 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ --- -title: Polyguard Openenv Workbench -emoji: 📉 +title: PolyGuard OpenEnv +emoji: 🛡️ colorFrom: blue -colorTo: red +colorTo: purple sdk: docker +app_port: 7860 pinned: false +license: mit --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +Full-stack **PolyGuard** workbench: OpenEnv (WebSocket), FastAPI, and React UI behind nginx on `PORT`. Uses **CPU basic**; first cold start downloads the public [usable model bundle](https://huggingface.co/TheJackBright/polyguard-openenv-training-full-artifacts/tree/main/usable_model_bundles/local-qwen-0-5b-active-smoke) (~1.1 GB). See `docker/space/README.md` for details. diff --git a/README_HF_SPACE.md b/README_HF_SPACE.md new file mode 100644 index 0000000000000000000000000000000000000000..40afe389f19f24b8469e0d01ceb34e93bcabe752 --- /dev/null +++ b/README_HF_SPACE.md @@ -0,0 +1,12 @@ +--- +title: PolyGuard OpenEnv +emoji: 🛡️ +colorFrom: blue +colorTo: purple +sdk: docker +app_port: 7860 +pinned: false +license: mit +--- + +Full-stack **PolyGuard** workbench: OpenEnv (WebSocket), FastAPI, and React UI behind nginx on `PORT`. Uses **CPU basic**; first cold start downloads the public [usable model bundle](https://huggingface.co/TheJackBright/polyguard-openenv-training-full-artifacts/tree/main/usable_model_bundles/local-qwen-0-5b-active-smoke) (~1.1 GB). See `docker/space/README.md` for details. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..73bf6cb259b0de12d8ba554c093ef4d8d4515050 --- /dev/null +++ b/__init__.py @@ -0,0 +1,5 @@ +"""Root OpenEnv package shim for POLYGUARD-OPENENV.""" + +from app.env.env_core import PolyGuardEnv + +__all__ = ["PolyGuardEnv"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1c540cde6dd0c63e664d242825423d7a1cd7e7c6 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""POLYGUARD-RL application package.""" diff --git a/app/agents/__init__.py b/app/agents/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..570463fcc55c930bd9e34d62258a9ac5389e5fd8 --- /dev/null +++ b/app/agents/__init__.py @@ -0,0 +1,5 @@ +"""Agent package.""" + +from app.agents.orchestrator import Orchestrator + +__all__ = ["Orchestrator"] diff --git a/app/agents/candidate_agent.py b/app/agents/candidate_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..8107d5f36555eaf18d6aa63e2dac07f283a6823a --- /dev/null +++ b/app/agents/candidate_agent.py @@ -0,0 +1,14 @@ +"""Candidate generation agent.""" + +from __future__ import annotations + +from app.common.types import PolyGuardState +from app.models.policy.candidate_builder import build_candidates + + +class CandidateAgent: + name = "CandidateAgent" + + def run(self, state: PolyGuardState) -> dict: + candidates = build_candidates(state) + return {"candidates": [c.model_dump(mode="json") for c in candidates]} diff --git a/app/agents/critic_agent.py b/app/agents/critic_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..03a0b9dba477030fbc0d77f31a000c55bdbb0a45 --- /dev/null +++ b/app/agents/critic_agent.py @@ -0,0 +1,43 @@ +"""Safety critic agent.""" + +from __future__ import annotations + +from app.common.enums import ActionType, DecisionMode, DoseBucket +from app.common.types import PolyGuardAction, PolyGuardState +from app.env.verifier import verify_action_legality + + +class CriticAgent: + name = "CriticAgent" + + def run(self, state: PolyGuardState, proposed: PolyGuardAction) -> dict: + report = verify_action_legality(state, proposed) + if report.legal: + report_payload = report.model_dump(mode="json") + return { + "approved": True, + "report": report_payload, + "final_action": proposed, + "legal": True, + "violations": report_payload.get("violations", []), + } + fallback = PolyGuardAction( + mode=DecisionMode.REVIEW, + action_type=ActionType.REQUEST_SPECIALIST_REVIEW, + target_drug=None, + replacement_drug=None, + dose_bucket=DoseBucket.NA, + taper_days=None, + monitoring_plan="critic_veto", + candidate_id="cand_veto_fallback", + confidence=0.62, + rationale_brief=f"Critic veto: {', '.join(report.violations)}", + ) + report_payload = report.model_dump(mode="json") + return { + "approved": False, + "report": report_payload, + "final_action": fallback, + "legal": False, + "violations": report_payload.get("violations", []), + } diff --git a/app/agents/critic_safety_agent.py b/app/agents/critic_safety_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..fae04cd6bc818a993d54575c2363a90f9619302c --- /dev/null +++ b/app/agents/critic_safety_agent.py @@ -0,0 +1,11 @@ +"""Canonical CriticSafety agent module. + +This file preserves required naming while reusing the current critic +implementation. +""" + +from __future__ import annotations + +from app.agents.critic_agent import CriticAgent as CriticSafetyAgent + +__all__ = ["CriticSafetyAgent"] diff --git a/app/agents/dosing_agent.py b/app/agents/dosing_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..529da8ef0b328e856e43cd5cef6ef2da051a4d24 --- /dev/null +++ b/app/agents/dosing_agent.py @@ -0,0 +1,52 @@ +"""Dosing analysis agent.""" + +from __future__ import annotations + +from app.common.types import PolyGuardState +from app.knowledge.drug_catalog import DRUG_CLASSES +from app.models.dosing.dose_policy_features import build_dose_features +from app.models.dosing.infer import infer_dosing_quality +from app.models.dosing.pkpd_state import PKPDState +from app.models.dosing.surrogate_pkpd import step_pkpd + + +class DosingAgent: + name = "DosingAgent" + + def run(self, state: PolyGuardState) -> dict: + sensitive_classes = {"anticoagulant", "sedative", "glucose_lowering"} + dose_sensitive = [ + m.drug + for m in state.patient.medications + if DRUG_CLASSES.get(m.drug) in sensitive_classes + ][:3] + analyses: list[dict] = [] + for drug in dose_sensitive: + feats = build_dose_features(state.patient, drug) + base_state = PKPDState( + effect_level=min(1.0, 0.35 + feats["adherence"] * 0.45), + toxicity_level=min(1.0, 0.08 + feats["organ_stress"] * 0.4), + underdose_risk=max(0.0, 1.0 - (0.35 + feats["adherence"] * 0.45)), + organ_stress=feats["organ_stress"], + interaction_load=feats["interaction_load"], + ) + lower = infer_dosing_quality(step_pkpd(base_state, dose_delta=-0.2, organ_factor=feats["organ_stress"], interaction_factor=feats["interaction_load"])) + hold = infer_dosing_quality(step_pkpd(base_state, dose_delta=0.0, organ_factor=feats["organ_stress"], interaction_factor=feats["interaction_load"])) + higher = infer_dosing_quality(step_pkpd(base_state, dose_delta=0.2, organ_factor=feats["organ_stress"], interaction_factor=feats["interaction_load"])) + analyses.append( + { + "drug": drug, + "features": feats, + "options": { + "reduce": lower, + "hold": hold, + "increase": higher, + }, + } + ) + return { + "dose_sensitive_drugs": dose_sensitive, + "dosing_active": bool(dose_sensitive), + "recommend_mode": "DOSE_OPT" if dose_sensitive else "REGIMEN_OPT", + "analyses": analyses, + } diff --git a/app/agents/evidence_agent.py b/app/agents/evidence_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..f59a9e76f2bdb1f83ea57bf605644b59e8c59f54 --- /dev/null +++ b/app/agents/evidence_agent.py @@ -0,0 +1,14 @@ +"""Evidence retrieval agent.""" + +from __future__ import annotations + +from app.common.types import PolyGuardState +from app.knowledge.evidence_retriever import retrieve_evidence + + +class EvidenceAgent: + name = "EvidenceAgent" + + def run(self, state: PolyGuardState) -> dict: + query = " ".join(state.patient.comorbidities + [m.drug for m in state.patient.medications[:2]]) + return {"evidence": retrieve_evidence(query=query, top_k=3)} diff --git a/app/agents/explainer_agent.py b/app/agents/explainer_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..48234f11146774f51698c4446b1c3486d1373219 --- /dev/null +++ b/app/agents/explainer_agent.py @@ -0,0 +1,22 @@ +"""Explanation agent.""" + +from __future__ import annotations + +from app.common.types import PolyGuardAction, PolyGuardState + + +class ExplainerAgent: + name = "ExplainerAgent" + + def run(self, state: PolyGuardState, action: PolyGuardAction, critic_report: dict) -> dict: + return { + "explanation": ( + f"Action {action.action_type.value} selected for mode {action.mode.value}. " + f"Burden score={state.burden_score:.3f}, meds={len(state.patient.medications)}. " + f"Critic legal={critic_report.get('legal', False)}." + ), + "grounded_facts": { + "burden_score": state.burden_score, + "polypharmacy_count": len(state.patient.medications), + }, + } diff --git a/app/agents/graph_agent.py b/app/agents/graph_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..15641709b6b2e0773dd69cfbf2c16203b0558c1d --- /dev/null +++ b/app/agents/graph_agent.py @@ -0,0 +1,28 @@ +"""Graph safety agent.""" + +from __future__ import annotations + +from app.common.types import PolyGuardState +from app.knowledge.ddi_knowledge import top_risky_pairs +from app.models.graph.infer import infer_graph_risk + + +class GraphSafetyAgent: + name = "GraphSafetyAgent" + + def run(self, state: PolyGuardState) -> dict: + drugs = [m.drug for m in state.patient.medications] + risk = infer_graph_risk(drugs) + top_pairs = top_risky_pairs(drugs) + triples = [] + if len(drugs) >= 3: + triples = [ + [drugs[i], drugs[i + 1], drugs[i + 2]] + for i in range(min(2, len(drugs) - 2)) + ] + return { + **risk, + "top_dangerous_pairs": top_pairs[:5], + "top_dangerous_triples": triples, + "mechanism_tags": list(risk.get("side_effect_probs", {}).keys())[:5], + } diff --git a/app/agents/graph_safety_agent.py b/app/agents/graph_safety_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..b51f0390f799c854efe81f19d4ffb40ca3397c6a --- /dev/null +++ b/app/agents/graph_safety_agent.py @@ -0,0 +1,11 @@ +"""Canonical GraphSafety agent module. + +This file is kept for required path compatibility and re-exports the +implementation from ``graph_agent.py``. +""" + +from __future__ import annotations + +from app.agents.graph_agent import GraphSafetyAgent + +__all__ = ["GraphSafetyAgent"] diff --git a/app/agents/medrec_agent.py b/app/agents/medrec_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..22368c094ba51d8a7c20b9959cf4d6984477140c --- /dev/null +++ b/app/agents/medrec_agent.py @@ -0,0 +1,22 @@ +"""Medication reconciliation agent.""" + +from __future__ import annotations + +from app.common.types import PolyGuardState +from app.knowledge.drug_catalog import canonicalize_drug_name + + +class MedRecAgent: + name = "MedRecAgent" + + def run(self, state: PolyGuardState) -> dict: + normalized = [] + duplicates = set() + seen = set() + for med in state.patient.medications: + med.drug = canonicalize_drug_name(med.drug) + normalized.append(med.drug) + if med.drug in seen: + duplicates.add(med.drug) + seen.add(med.drug) + return {"normalized_meds": normalized, "duplicates": sorted(duplicates)} diff --git a/app/agents/orchestrator.py b/app/agents/orchestrator.py new file mode 100644 index 0000000000000000000000000000000000000000..74b94360a33dac2c83a8fd4af878962c16fa2d5c --- /dev/null +++ b/app/agents/orchestrator.py @@ -0,0 +1,151 @@ +"""Multi-agent orchestration graph.""" + +from __future__ import annotations + +import os +from typing import Any + +from app.agents.candidate_agent import CandidateAgent +from app.agents.critic_agent import CriticAgent +from app.agents.dosing_agent import DosingAgent +from app.agents.evidence_agent import EvidenceAgent +from app.agents.explainer_agent import ExplainerAgent +from app.agents.graph_agent import GraphSafetyAgent +from app.agents.medrec_agent import MedRecAgent +from app.agents.planner_agent import PlannerAgent +from app.agents.supervisor_agent import SupervisorAgent +from app.common.enums import CoordinationMode +from app.common.types import CandidateAction, PolyGuardAction +from app.env.env_core import PolyGuardEnv +from app.models.baselines.contextual_bandit_policy import ContextualBanditPolicy + + +class Orchestrator: + def __init__(self, env: PolyGuardEnv, coordination_mode: CoordinationMode = CoordinationMode.SEQUENTIAL) -> None: + self.env = env + self.coordination_mode = coordination_mode + self.medrec = MedRecAgent() + self.evidence = EvidenceAgent() + self.graph = GraphSafetyAgent() + self.dosing = DosingAgent() + self.candidate = CandidateAgent() + self.supervisor = SupervisorAgent() + self.planner = PlannerAgent() + self.critic = CriticAgent() + self.explainer = ExplainerAgent() + bandit_algo = os.getenv("POLYGUARD_BANDIT_ALGO", "linucb").strip().lower() + if bandit_algo not in {"linucb", "thompson"}: + bandit_algo = "linucb" + self.bandit = ContextualBanditPolicy( + algorithm=bandit_algo, # type: ignore[arg-type] + alpha=float(os.getenv("POLYGUARD_BANDIT_ALPHA", "0.55")), + epsilon=float(os.getenv("POLYGUARD_BANDIT_EPSILON", "0.1")), + seed=int(os.getenv("POLYGUARD_BANDIT_SEED", "42")), + ) + self.policy_stack = os.getenv("POLYGUARD_POLICY_STACK", "llm+bandit").strip().lower() + self.bandit_top_k = int(os.getenv("POLYGUARD_BANDIT_TOP_K", "3")) + + def set_mode(self, coordination_mode: CoordinationMode) -> None: + self.coordination_mode = coordination_mode + + def run_step(self, coordination_mode: str | None = None) -> dict[str, Any]: + if coordination_mode is not None: + self.coordination_mode = CoordinationMode(coordination_mode) + state = self.env.state + medrec_out = self.medrec.run(state) + evidence_out = self.evidence.run(state) + graph_out = self.graph.run(state) + dosing_out = self.dosing.run(state) + candidate_out = self.candidate.run(state) + candidates = [CandidateAction.model_validate(item) for item in candidate_out["candidates"]] + + supervisor_out = self.supervisor.run(state, dosing_active=dosing_out["dosing_active"]) + planner_candidates = [c for c in candidates if c.mode.value == supervisor_out["mode"]] or candidates + if self.coordination_mode == CoordinationMode.SUPERVISOR_ROUTED and supervisor_out["mode"] == "REVIEW": + planner_candidates = [c for c in candidates if c.mode.value == "REVIEW"] or planner_candidates + + candidate_by_id = {item.candidate_id: item for item in planner_candidates} + bandit_proposals = self.bandit.propose(planner_candidates, top_k=self.bandit_top_k) + bandit_candidates = [candidate_by_id[item.candidate_id] for item in bandit_proposals if item.candidate_id in candidate_by_id] + if not bandit_candidates: + bandit_candidates = planner_candidates + + if self.policy_stack == "bandit-only": + selected = bandit_candidates[0] + proposed = PolyGuardAction( + mode=selected.mode, + action_type=selected.action_type, + target_drug=selected.target_drug, + replacement_drug=selected.replacement_drug, + dose_bucket=selected.dose_bucket, + taper_days=selected.taper_days, + monitoring_plan=selected.monitoring_plan, + candidate_id=selected.candidate_id, + confidence=max(0.45, 1.0 - selected.uncertainty_score), + rationale_brief="Bandit-only policy selected top contextual candidate.", + ) + elif self.policy_stack == "llm-only": + proposed = self.planner.run(candidates=planner_candidates, mode=supervisor_out["mode"]) + else: + proposed = self.planner.run( + candidates=bandit_candidates, + mode=supervisor_out["mode"], + provider_prompt={ + "coordination_mode": self.coordination_mode.value, + "policy_stack": self.policy_stack, + "candidate_count": len(bandit_candidates), + "sub_environment": state.sub_environment.value, + }, + ) + + critic_out = self.critic.run(state, proposed) + final_action: PolyGuardAction = critic_out["final_action"] + replan_triggered = False + debate_rounds = 0 + + if self.coordination_mode in {CoordinationMode.REPLAN_ON_VETO, CoordinationMode.LIGHT_DEBATE} and not critic_out["approved"]: + replan_triggered = True + review_candidates = [c for c in candidates if c.mode.value == "REVIEW"] or candidates + proposed = self.planner.run(candidates=review_candidates, mode="REVIEW") + critic_out = self.critic.run(state, proposed) + final_action = critic_out["final_action"] + debate_rounds = 1 + + if self.coordination_mode == CoordinationMode.LIGHT_DEBATE and critic_out["approved"] and proposed.action_type != final_action.action_type: + debate_rounds = 2 + + obs, reward, done, info = self.env.step(final_action) + selected_for_update = candidate_by_id.get(final_action.candidate_id) + if selected_for_update is not None: + self.bandit.update(selected_for_update, reward=reward) + + explanation_out = self.explainer.run(state, final_action, critic_out["report"]) + return { + "medrec": medrec_out, + "evidence": evidence_out, + "graph": graph_out, + "dosing": dosing_out, + "supervisor": supervisor_out, + "proposed_action": proposed.model_dump(mode="json"), + "critic": critic_out["report"], + "final_action": final_action.model_dump(mode="json"), + "observation": obs.model_dump(mode="json"), + "reward": reward, + "done": done, + "info": info, + "explanation": explanation_out, + "coordination_mode": self.coordination_mode.value, + "policy_stack": self.policy_stack, + "bandit_topk": [item.candidate_id for item in bandit_candidates], + "bandit_scores": [ + { + "candidate_id": item.candidate_id, + "score": item.score, + "exploration_bonus": item.exploration_bonus, + "algorithm": item.algorithm, + } + for item in bandit_proposals + ], + "replan_triggered": replan_triggered, + "debate_rounds": debate_rounds, + } diff --git a/app/agents/planner_agent.py b/app/agents/planner_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..60278cd0856271b3e1eff602b53433513cab6444 --- /dev/null +++ b/app/agents/planner_agent.py @@ -0,0 +1,44 @@ +"""Planner agent.""" + +from __future__ import annotations + +from typing import Any + +from app.common.types import CandidateAction, PolyGuardAction +from app.models.policy.provider_runtime import PolicyProviderRouter, default_provider_preference +from app.models.policy.safety_ranker import rank_candidates + + +class PlannerAgent: + name = "PlannerAgent" + + def __init__(self) -> None: + self.provider_router = PolicyProviderRouter() + + def run( + self, + candidates: list[CandidateAction], + mode: str, + provider_prompt: dict[str, Any] | None = None, + provider_preference: tuple[str, ...] | None = None, + ) -> PolyGuardAction: + filtered = [c for c in candidates if c.mode.value == mode] or candidates + selection = self.provider_router.select_candidate( + candidates=filtered, + prompt=provider_prompt or {"mode": mode}, + provider_preference=provider_preference or default_provider_preference(), + ) + by_id = {item.candidate_id: item for item in filtered} + top = by_id.get(selection.candidate_id, rank_candidates(filtered)[0]) + return PolyGuardAction( + mode=top.mode, + action_type=top.action_type, + target_drug=top.target_drug, + replacement_drug=top.replacement_drug, + dose_bucket=top.dose_bucket, + taper_days=top.taper_days, + monitoring_plan=top.monitoring_plan, + candidate_id=top.candidate_id, + confidence=max(0.45, 1.0 - top.uncertainty_score), + rationale_brief=selection.rationale, + ) diff --git a/app/agents/supervisor_agent.py b/app/agents/supervisor_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..4576233ec17165e35c0e792b6584a2a473cbbcb2 --- /dev/null +++ b/app/agents/supervisor_agent.py @@ -0,0 +1,23 @@ +"""Supervisor agent.""" + +from __future__ import annotations + +from app.common.enums import DecisionMode +from app.common.types import PolyGuardState +from app.models.policy.uncertainty import estimate_uncertainty + + +class SupervisorAgent: + name = "SupervisorAgent" + + def run(self, state: PolyGuardState, dosing_active: bool) -> dict: + uncertainty = estimate_uncertainty(state) + if uncertainty > 0.72: + mode = DecisionMode.REVIEW + elif state.sub_environment.value == "PRECISION_DOSING": + mode = DecisionMode.DOSE_OPT + elif dosing_active: + mode = DecisionMode.DOSE_OPT + else: + mode = DecisionMode.REGIMEN_OPT + return {"mode": mode.value, "uncertainty": uncertainty, "sub_environment": state.sub_environment.value} diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3e4266a9c5e3a47595424cca849c88011bf8ca66 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,46 @@ +"""API application entrypoint.""" + +from __future__ import annotations + +import os + +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.common.config import load_project_env +from app.api.routes import router + +load_project_env() + +_cors_local = [ + "http://127.0.0.1:5173", + "http://localhost:5173", +] +_extra = os.getenv("POLYGUARD_CORS_ORIGINS", "").strip() +if _extra and _extra != "*": + _cors_local = _cors_local + [o.strip() for o in _extra.split(",") if o.strip()] +_hf_space_regex = None +if os.getenv("POLYGUARD_ALLOW_HF_SPACE_CORS", "").lower() in {"1", "true", "yes", "on"}: + _hf_space_regex = r"https://.*\.hf\.space" + +app = FastAPI(title="POLYGUARD-RL API", version="0.1.0") +app.add_middleware( + CORSMiddleware, + allow_origins=_cors_local, + allow_origin_regex=_hf_space_regex, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +app.include_router(router) + + +def main() -> None: + host = os.getenv("POLYGUARD_API_HOST", "127.0.0.1") + port = int(os.getenv("POLYGUARD_API_PORT", "8200")) + uvicorn.run("app.api:app", host=host, port=port, reload=False) + + +if __name__ == "__main__": + main() diff --git a/app/api/__main__.py b/app/api/__main__.py new file mode 100644 index 0000000000000000000000000000000000000000..2d8592f0f9ce2109a5273b7d924ce7ca63f8743b --- /dev/null +++ b/app/api/__main__.py @@ -0,0 +1,7 @@ +"""Run API with `python -m app.api`.""" + +from app.api import main + + +if __name__ == "__main__": + main() diff --git a/app/api/dependencies.py b/app/api/dependencies.py new file mode 100644 index 0000000000000000000000000000000000000000..5862e00bb7e494261dfd7c2895a616494ddb0f2a --- /dev/null +++ b/app/api/dependencies.py @@ -0,0 +1,11 @@ +"""API dependencies.""" + +from __future__ import annotations + +from app.api.service import APIService + +_SERVICE = APIService() + + +def get_service() -> APIService: + return _SERVICE diff --git a/app/api/main.py b/app/api/main.py new file mode 100644 index 0000000000000000000000000000000000000000..ed776e2a3a201a4b1a09f8f89f6c17378959dea9 --- /dev/null +++ b/app/api/main.py @@ -0,0 +1,10 @@ +"""Canonical API module path. + +Keeps compatibility with required file path while reusing ``app.api`` app. +""" + +from __future__ import annotations + +from app.api import app, main + +__all__ = ["app", "main"] diff --git a/app/api/routes.py b/app/api/routes.py new file mode 100644 index 0000000000000000000000000000000000000000..8460a5663d2fa04d105e1a4caa476cd2756af355 --- /dev/null +++ b/app/api/routes.py @@ -0,0 +1,139 @@ +"""API routes.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException + +from app.api.dependencies import get_service +from app.api.schemas import ( + BatchInferRequest, + EvidenceQueryRequest, + OrchestrateRequest, + ResetRequest, + StepCandidateRequest, + StepRequest, +) +from app.api.service import APIService + +router = APIRouter() + + +@router.get("/health") +def health() -> dict[str, str]: + return {"status": "ok"} + + +@router.post("/env/reset") +def env_reset(payload: ResetRequest, service: APIService = Depends(get_service)) -> dict: + try: + return service.reset(**payload.model_dump(mode="json")) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +@router.post("/env/step") +def env_step(payload: StepRequest, service: APIService = Depends(get_service)) -> dict: + return service.step(payload.model_dump(mode="json")) + + +@router.post("/env/step_candidate") +def env_step_candidate(payload: StepCandidateRequest, service: APIService = Depends(get_service)) -> dict: + result = service.step_candidate( + candidate_id=payload.candidate_id, + confidence=payload.confidence, + rationale_brief=payload.rationale_brief, + ) + if result is None: + raise HTTPException(status_code=404, detail=f"Candidate {payload.candidate_id!r} is not legal in this state.") + return result + + +@router.get("/env/catalog") +def env_catalog(service: APIService = Depends(get_service)) -> dict: + return service.catalog() + + +@router.get("/env/state") +def env_state(service: APIService = Depends(get_service)) -> dict: + return service.env.get_state() + + +@router.get("/env/trace") +def env_trace(service: APIService = Depends(get_service)) -> list[dict]: + return service.env.get_trace() + + +@router.get("/env/legal_actions") +def env_legal_actions(service: APIService = Depends(get_service)) -> list[dict]: + return service.env.get_legal_actions() + + +@router.get("/env/reward_breakdown") +def env_reward_breakdown(service: APIService = Depends(get_service)) -> dict: + return service.env.get_reward_breakdown() + + +@router.get("/env/uncertainty") +def env_uncertainty(service: APIService = Depends(get_service)) -> dict: + return service.env.get_uncertainty_report().model_dump(mode="json") + + +@router.post("/agents/orchestrate") +def agents_orchestrate( + payload: OrchestrateRequest = OrchestrateRequest(), + service: APIService = Depends(get_service), +) -> dict: + return service.orchestrate(coordination_mode=payload.coordination_mode) + + +@router.post("/policy/infer") +def policy_infer(service: APIService = Depends(get_service)) -> dict: + return service.infer_policy() + + +@router.get("/policy/model_status") +def policy_model_status(service: APIService = Depends(get_service)) -> dict: + return service.model_status() + + +@router.post("/policy/batch_infer") +def policy_batch_infer( + payload: BatchInferRequest = BatchInferRequest(), + service: APIService = Depends(get_service), +) -> list[dict]: + return service.batch_infer(batch_size=payload.batch_size) + + +@router.post("/eval/run_baselines") +def eval_baselines(service: APIService = Depends(get_service)) -> dict: + return service.run_baselines() + + +@router.post("/eval/run_policy") +def eval_run_policy(service: APIService = Depends(get_service)) -> dict: + return service.run_policy_eval() + + +@router.post("/eval/run_dosing") +def eval_run_dosing(service: APIService = Depends(get_service)) -> dict: + return service.run_dosing_eval() + + +@router.get("/metrics/training") +def metrics_training(service: APIService = Depends(get_service)) -> dict: + return service.get_metrics() + + +@router.get("/cases/sample") +def cases_sample(service: APIService = Depends(get_service)) -> dict: + return service.sample_case() + + +@router.get("/cases/search") +def cases_search(q: str, service: APIService = Depends(get_service)) -> list[dict]: + return service.search_cases(q) + + +@router.post("/evidence/query") +def evidence_query(payload: EvidenceQueryRequest, service: APIService = Depends(get_service)) -> list[dict]: + return service.evidence_query(query=payload.query, top_k=payload.top_k) diff --git a/app/api/schemas.py b/app/api/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..b1950a6962acac49d94bc8ef99e9b894c7621c4c --- /dev/null +++ b/app/api/schemas.py @@ -0,0 +1,57 @@ +"""API schemas.""" + +from __future__ import annotations + +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from app.common.enums import ActionType, DecisionMode, Difficulty, DoseBucket, SubEnvironment + + +class StrictSchema(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class ResetRequest(StrictSchema): + task_id: Optional[str] = None + seed: Optional[int] = None + difficulty: Optional[Difficulty] = None + sub_environment: Optional[SubEnvironment] = None + scenario_id: Optional[str] = None + patient_id: Optional[str] = None + + +class StepRequest(StrictSchema): + mode: DecisionMode + action_type: ActionType + target_drug: Optional[str] = None + replacement_drug: Optional[str] = None + dose_bucket: DoseBucket + taper_days: Optional[int] = None + monitoring_plan: Optional[str] = None + evidence_query: Optional[str] = None + new_drug_name: Optional[str] = None + candidate_components: list[str] = Field(default_factory=list) + candidate_id: str + confidence: float + rationale_brief: str + + +class StepCandidateRequest(StrictSchema): + candidate_id: str + confidence: float + rationale_brief: str + + +class OrchestrateRequest(StrictSchema): + coordination_mode: Optional[str] = None + + +class BatchInferRequest(StrictSchema): + batch_size: int = 4 + + +class EvidenceQueryRequest(StrictSchema): + query: str + top_k: int = 5 diff --git a/app/api/service.py b/app/api/service.py new file mode 100644 index 0000000000000000000000000000000000000000..881ab8a325618f2f4668dda5978749578ca685f5 --- /dev/null +++ b/app/api/service.py @@ -0,0 +1,219 @@ +"""API service layer.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from app.agents.orchestrator import Orchestrator +from app.env.catalog import apply_task_preset, env_catalog +from app.env.env_core import PolyGuardEnv +from app.evaluation.benchmark_report import build_benchmark_report +from app.evaluation.dosing_eval import dosing_eval +from app.knowledge.evidence_retriever import retrieve_evidence +from app.models.retrieval.retriever import retrieve +from app.models.policy.provider_runtime import PolicyProviderRouter, default_provider_preference +from app.models.baselines import ( + choose_beam_search, + choose_contextual_bandit, + choose_contextual_bandit_topk, + choose_greedy, + choose_no_change, + choose_rules_only, +) +from app.training import train_dosing_grpo, train_planner_grpo, train_supervisor_grpo + + +class APIService: + def __init__(self) -> None: + self.env = PolyGuardEnv() + self.orchestrator = Orchestrator(self.env) + self.policy_router = PolicyProviderRouter() + self.training_metrics: dict[str, Any] = {} + self.root = Path(__file__).resolve().parents[2] + + def reset(self, **kwargs: Any) -> dict[str, Any]: + kwargs = apply_task_preset(dict(kwargs)) + obs = self.env.reset(**kwargs) + return obs.model_dump(mode="json") + + def step(self, action: dict[str, Any]) -> dict[str, Any]: + obs, reward, done, info = self.env.step(action) + reason = str(info.get("termination_reason", "")) if isinstance(info, dict) else "" + truncated = reason in {"wall_clock_timeout", "step_timeout", "step_budget_exhausted"} + return { + "observation": obs.model_dump(mode="json"), + "reward": reward, + "done": done, + "terminated": done, + "truncated": truncated, + "info": info, + } + + def catalog(self) -> dict[str, Any]: + return env_catalog() + + def step_candidate(self, candidate_id: str, confidence: float, rationale_brief: str) -> dict[str, Any] | None: + for action in self.env.get_legal_actions(): + if action.get("candidate_id") != candidate_id: + continue + payload = dict(action) + payload["confidence"] = confidence + payload["rationale_brief"] = rationale_brief + return self.step(payload) + return None + + def orchestrate(self, coordination_mode: str | None = None) -> dict[str, Any]: + return self.orchestrator.run_step(coordination_mode=coordination_mode) + + def infer_policy(self) -> dict[str, Any]: + legal = self.env.get_legal_actions() + if not legal: + return {} + candidate_payloads = [ + item for item in self.env.get_candidate_actions() if bool(item.get("legality_precheck", False)) + ] + if not candidate_payloads: + return legal[0] + candidates = [self._candidate_obj(item) for item in candidate_payloads] + state = self.env.state + selection = self.policy_router.select_candidate( + candidates=candidates, + prompt={ + "patient_id": state.patient.patient_id, + "difficulty": state.difficulty.value, + "sub_environment": state.sub_environment.value, + "step_count": state.step_count, + }, + provider_preference=default_provider_preference(), + ) + selected = next((item for item in legal if item.get("candidate_id") == selection.candidate_id), legal[0]) + payload = dict(selected) + payload["policy_selection"] = { + "provider": selection.provider, + "candidate_id": selection.candidate_id, + "rationale": selection.rationale, + "latency_ms": round(selection.latency_ms, 3), + "raw_output": selection.raw_output, + } + return payload + + def model_status(self) -> dict[str, Any]: + return self.policy_router.model_status() + + def batch_infer(self, batch_size: int = 4) -> list[dict[str, Any]]: + legal = self.env.get_legal_actions() + return legal[:batch_size] + + def run_baselines(self) -> dict[str, Any]: + candidates = [c for c in self.env.get_candidate_actions() if c.get("legality_precheck")] + if not candidates: + self.env.reset() + candidates = [c for c in self.env.get_candidate_actions() if c.get("legality_precheck")] + baseline_results = { + "no_change": choose_no_change().model_dump(mode="json"), + "rules_only": choose_rules_only([self._candidate_obj(c) for c in candidates]).model_dump(mode="json"), + "greedy": choose_greedy([self._candidate_obj(c) for c in candidates]).model_dump(mode="json"), + "contextual_bandit": choose_contextual_bandit([self._candidate_obj(c) for c in candidates]).model_dump(mode="json"), + "contextual_bandit_topk": [ + { + "candidate_id": item.candidate_id, + "score": item.score, + "exploration_bonus": item.exploration_bonus, + "algorithm": item.algorithm, + } + for item in choose_contextual_bandit_topk([self._candidate_obj(c) for c in candidates], top_k=3) + ], + "beam_search": choose_beam_search([self._candidate_obj(c) for c in candidates]).model_dump(mode="json"), + } + return baseline_results + + def run_policy_eval(self) -> dict[str, Any]: + out = build_benchmark_report(Path("outputs/reports/benchmark_report.txt")) + return out + + def run_dosing_eval(self) -> dict[str, Any]: + return dosing_eval() + + def run_training(self) -> dict[str, Any]: + out_dir = Path("checkpoints") + out_dir.mkdir(parents=True, exist_ok=True) + self.training_metrics = { + "supervisor": train_supervisor_grpo(episodes=4, checkpoint_dir=out_dir), + "planner": train_planner_grpo(episodes=6, checkpoint_dir=out_dir), + "dosing": train_dosing_grpo(episodes=4, checkpoint_dir=out_dir), + } + return self.training_metrics + + def get_metrics(self) -> dict[str, Any]: + if self.training_metrics: + if "planner" in self.training_metrics: + merged = dict(self.training_metrics["planner"]) + merged["model_metrics"] = self.training_metrics + return merged + return self.training_metrics + reports_dir = Path("outputs/reports") + metrics: dict[str, Any] = {} + for name in ["supervisor_grpo", "planner_grpo", "dosing_grpo"]: + path = reports_dir / f"{name}.json" + if path.exists(): + import json + + metrics[name] = json.loads(path.read_text(encoding="utf-8")) + self.training_metrics = metrics + if "planner_grpo" in metrics: + merged = dict(metrics["planner_grpo"]) + merged["model_metrics"] = metrics + return merged + return metrics + + def sample_case(self) -> dict[str, Any]: + obs = self.env.reset() + return obs.model_dump(mode="json") + + def search_cases(self, query: str) -> list[dict[str, Any]]: + index_file = self.root / "data" / "retrieval_index" / "index.json" + hits = retrieve(index_file=index_file, query=query, top_k=5) + if hits: + return [ + { + "patient_id": Path(item.get("path", f"case_{idx}")).stem, + "query": query, + "source_path": item.get("path", ""), + "snippet": str(item.get("text", ""))[:280], + } + for idx, item in enumerate(hits) + ] + + fallback: list[dict[str, Any]] = [] + corpus = self.root / "data" / "processed" / "retrieval_corpus.jsonl" + if corpus.exists(): + query_tokens = {token for token in query.lower().split() if token} + with corpus.open("r", encoding="utf-8") as handle: + for idx, line in enumerate(handle): + if len(fallback) >= 5: + break + text = line.strip() + if not text: + continue + hay = text.lower() + if query_tokens and not any(token in hay for token in query_tokens): + continue + fallback.append( + { + "patient_id": f"retrieval_corpus_{idx}", + "query": query, + "source_path": str(corpus), + "snippet": text[:280], + } + ) + return fallback + + def evidence_query(self, query: str, top_k: int = 5) -> list[dict[str, str]]: + return retrieve_evidence(query=query, top_k=top_k) + + @staticmethod + def _candidate_obj(payload: dict) -> Any: + from app.common.types import CandidateAction + + return CandidateAction.model_validate(payload) diff --git a/app/common/config.py b/app/common/config.py new file mode 100644 index 0000000000000000000000000000000000000000..f8f1c5f1bf1d0bf2b3c5e336d8c3336df759fdb6 --- /dev/null +++ b/app/common/config.py @@ -0,0 +1,57 @@ +"""Configuration loading.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +import yaml + + +def _read_yaml(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + with path.open("r", encoding="utf-8") as handle: + return yaml.safe_load(handle) or {} + + +def load_config(config_name: str = "base.yaml") -> dict[str, Any]: + root = Path(__file__).resolve().parents[2] + config_path = root / "configs" / config_name + return _read_yaml(config_path) + + +def load_project_env(path: Path | None = None) -> None: + """Load simple KEY=VALUE pairs from .env without overriding shell env.""" + + root = Path(__file__).resolve().parents[2] + env_path = path or root / ".env" + if not env_path.exists(): + return + for raw_line in env_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + if not key or key in os.environ: + continue + os.environ[key] = value.strip().strip('"').strip("'") + + +def env_bool(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def env_int(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None: + return default + try: + return int(raw) + except ValueError: + return default diff --git a/app/common/constants.py b/app/common/constants.py new file mode 100644 index 0000000000000000000000000000000000000000..add5b325ba59597bdfcf2ffda32f0ecad7099aa8 --- /dev/null +++ b/app/common/constants.py @@ -0,0 +1,40 @@ +"""Shared constants for POLYGUARD-RL.""" + +from __future__ import annotations + +REWARD_MIN: float = 0.001 +REWARD_MAX: float = 0.999 +REWARD_PRECISION: int = 3 + +DEFAULT_SEED: int = 42 +DEFAULT_MAX_STEPS: int = 10 +MAX_REPEATED_ACTIONS: int = 3 +MAX_KEEP_REGIMEN_RATIO: float = 0.6 +MAX_REVIEW_RATIO: float = 0.5 +DEFAULT_STEP_TIMEOUT_SECONDS: float = 2.5 +DEFAULT_EPISODE_TIMEOUT_SECONDS: float = 45.0 + +DEFAULT_REWARD_WEIGHTS: dict[str, float] = { + "format_compliance_score": 0.08, + "candidate_alignment_score": 0.08, + "legality_score": 0.12, + "safety_delta_score": 0.15, + "burden_improvement_score": 0.08, + "disease_stability_score": 0.10, + "dosing_quality_score": 0.08, + "abstention_quality_score": 0.06, + "efficiency_score": 0.06, + "process_fidelity_score": 0.06, + "explanation_grounding_score": 0.03, + "anti_cheat_score": 0.06, + "uncertainty_calibration_score": 0.04, +} + +REQUIRED_REWARD_KEYS: tuple[str, ...] = tuple(DEFAULT_REWARD_WEIGHTS.keys()) + +PRIMARY_REWARD_KEYS: tuple[str, ...] = ( + "safety_legality", + "clinical_improvement", + "dosing_quality", + "process_integrity", +) diff --git a/app/common/enums.py b/app/common/enums.py new file mode 100644 index 0000000000000000000000000000000000000000..feebaf3a0a37d317c79f8013980dbe739190560a --- /dev/null +++ b/app/common/enums.py @@ -0,0 +1,61 @@ +"""Enumerations used throughout POLYGUARD-RL.""" + +from __future__ import annotations + +from enum import Enum + + +class Difficulty(str, Enum): + EASY = "easy" + MEDIUM = "medium" + HARD = "hard" + + +class SubEnvironment(str, Enum): + DDI = "DDI" + BANDIT_MINING = "BANDIT_MINING" + REGIMEN_RISK = "REGIMEN_RISK" + PRECISION_DOSING = "PRECISION_DOSING" + LONGITUDINAL_DEPRESCRIBING = "LONGITUDINAL_DEPRESCRIBING" + WEB_SEARCH_MISSING_DATA = "WEB_SEARCH_MISSING_DATA" + ALTERNATIVE_SUGGESTION = "ALTERNATIVE_SUGGESTION" + NEW_DRUG_DECOMPOSITION = "NEW_DRUG_DECOMPOSITION" + + +class DecisionMode(str, Enum): + REGIMEN_OPT = "REGIMEN_OPT" + DOSE_OPT = "DOSE_OPT" + REVIEW = "REVIEW" + ABSTAIN_REVIEW = "ABSTAIN_REVIEW" + + +class ActionType(str, Enum): + KEEP_REGIMEN = "KEEP_REGIMEN" + STOP_DRUG = "STOP_DRUG" + SUBSTITUTE_WITHIN_CLASS = "SUBSTITUTE_WITHIN_CLASS" + RECOMMEND_ALTERNATIVE = "RECOMMEND_ALTERNATIVE" + REDUCE_DOSE_BUCKET = "REDUCE_DOSE_BUCKET" + INCREASE_DOSE_BUCKET = "INCREASE_DOSE_BUCKET" + TAPER_INITIATE = "TAPER_INITIATE" + TAPER_CONTINUE = "TAPER_CONTINUE" + DOSE_HOLD = "DOSE_HOLD" + ORDER_MONITORING_AND_WAIT = "ORDER_MONITORING_AND_WAIT" + FETCH_EXTERNAL_EVIDENCE = "FETCH_EXTERNAL_EVIDENCE" + DECOMPOSE_NEW_DRUG = "DECOMPOSE_NEW_DRUG" + REQUEST_SPECIALIST_REVIEW = "REQUEST_SPECIALIST_REVIEW" + REQUEST_PHARMACIST_REVIEW = "REQUEST_PHARMACIST_REVIEW" + + +class DoseBucket(str, Enum): + LOW = "LOW" + MEDIUM = "MEDIUM" + HIGH = "HIGH" + HOLD = "HOLD" + NA = "NA" + + +class CoordinationMode(str, Enum): + SEQUENTIAL = "sequential_pipeline" + SUPERVISOR_ROUTED = "supervisor_routed" + REPLAN_ON_VETO = "replan_on_veto" + LIGHT_DEBATE = "lightweight_debate" diff --git a/app/common/exceptions.py b/app/common/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..cedc57106b7f07f65de13a2037c468c6da8fbfba --- /dev/null +++ b/app/common/exceptions.py @@ -0,0 +1,19 @@ +"""Custom exceptions.""" + +from __future__ import annotations + + +class PolyGuardError(Exception): + """Base exception for project errors.""" + + +class InvalidActionError(PolyGuardError): + """Raised when an action is malformed or disallowed.""" + + +class SafetyVetoError(PolyGuardError): + """Raised when safety governance rejects an action.""" + + +class ParserError(PolyGuardError): + """Raised when structured policy output cannot be parsed.""" diff --git a/app/common/json_utils.py b/app/common/json_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..60bb83c92f75bfd59a357213dd60495333055c57 --- /dev/null +++ b/app/common/json_utils.py @@ -0,0 +1,14 @@ +"""Strict JSON helpers.""" + +from __future__ import annotations + +import json +from typing import Any + + +def safe_json_dumps(payload: Any) -> str: + return json.dumps(payload, ensure_ascii=True, sort_keys=True, default=str) + + +def safe_json_loads(payload: str) -> Any: + return json.loads(payload) diff --git a/app/common/logging_utils.py b/app/common/logging_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..d6b4dfffb38f1e3314e0a760b5520deeff43a96e --- /dev/null +++ b/app/common/logging_utils.py @@ -0,0 +1,17 @@ +"""Logging utilities.""" + +from __future__ import annotations + +import logging +from typing import Optional + + +def configure_logging(level: str = "INFO") -> None: + logging.basicConfig( + level=getattr(logging, level.upper(), logging.INFO), + format="%(asctime)s | %(levelname)s | %(name)s | %(message)s", + ) + + +def get_logger(name: Optional[str] = None) -> logging.Logger: + return logging.getLogger(name or "polyguard") diff --git a/app/common/normalization.py b/app/common/normalization.py new file mode 100644 index 0000000000000000000000000000000000000000..8e13f72389ce695a98fbde2ac16669d2d3e37c7c --- /dev/null +++ b/app/common/normalization.py @@ -0,0 +1,24 @@ +"""Normalization and reward range utilities.""" + +from __future__ import annotations + +from app.common.constants import REWARD_MAX, REWARD_MIN, REWARD_PRECISION + + +def clamp_reward(value: float) -> float: + """Clamp and quantize reward to [0.001, 0.999] with 3 decimals.""" + value = min(REWARD_MAX, max(REWARD_MIN, float(value))) + return round(value, REWARD_PRECISION) + + +def normalize_unit_interval(value: float, lower: float, upper: float) -> float: + if upper <= lower: + return 0.5 + ratio = (value - lower) / (upper - lower) + return float(min(1.0, max(0.0, ratio))) + + +def to_reward(value: float, lower: float, upper: float) -> float: + raw = normalize_unit_interval(value, lower, upper) + scaled = REWARD_MIN + raw * (REWARD_MAX - REWARD_MIN) + return clamp_reward(scaled) diff --git a/app/common/seeding.py b/app/common/seeding.py new file mode 100644 index 0000000000000000000000000000000000000000..a005b24f8d30b6b0ecec4c11738ba5244b11d4dd --- /dev/null +++ b/app/common/seeding.py @@ -0,0 +1,17 @@ +"""Deterministic seeding helpers.""" + +from __future__ import annotations + +import os +import random + +import numpy as np + +from app.common.constants import DEFAULT_SEED + + +def set_global_seed(seed: int = DEFAULT_SEED) -> int: + random.seed(seed) + np.random.seed(seed) + os.environ["PYTHONHASHSEED"] = str(seed) + return seed diff --git a/app/common/types.py b/app/common/types.py new file mode 100644 index 0000000000000000000000000000000000000000..0518221d1e53a74652331597f2019cf88d9e02b4 --- /dev/null +++ b/app/common/types.py @@ -0,0 +1,175 @@ +"""Core typed models.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.common.enums import ActionType, DecisionMode, Difficulty, DoseBucket, SubEnvironment +from app.common.normalization import clamp_reward + + +class StrictBase(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class Medication(StrictBase): + drug: str + dose_bucket: DoseBucket = DoseBucket.MEDIUM + indication: Optional[str] = None + class_name: Optional[str] = None + requires_taper: bool = False + + +class LabSummary(StrictBase): + egfr: Optional[float] = None + ast: Optional[float] = None + alt: Optional[float] = None + inr: Optional[float] = None + glucose: Optional[float] = None + + +class PatientProfile(StrictBase): + patient_id: str + age: int + sex: str + comorbidities: list[str] = Field(default_factory=list) + medications: list[Medication] = Field(default_factory=list) + labs: LabSummary = Field(default_factory=LabSummary) + vitals: dict[str, float] = Field(default_factory=dict) + specialist_conflicts: list[str] = Field(default_factory=list) + prior_ade_history: list[str] = Field(default_factory=list) + frailty_score: float = 0.3 + adherence_estimate: float = 0.8 + latent_confounders: dict[str, float] = Field(default_factory=dict) + monitoring_gaps: list[str] = Field(default_factory=list) + + +class CandidateAction(StrictBase): + candidate_id: str + mode: DecisionMode + action_type: ActionType + target_drug: Optional[str] = None + replacement_drug: Optional[str] = None + dose_bucket: DoseBucket = DoseBucket.NA + taper_days: Optional[int] = None + monitoring_plan: Optional[str] = None + evidence_query: Optional[str] = None + new_drug_name: Optional[str] = None + candidate_components: list[str] = Field(default_factory=list) + estimated_safety_delta: float = 0.0 + burden_delta: float = 0.0 + disease_stability_estimate: float = 0.0 + uncertainty_score: float = 0.5 + rationale_tags: list[str] = Field(default_factory=list) + required_monitoring: list[str] = Field(default_factory=list) + legality_precheck: bool = True + + +class PolyGuardAction(StrictBase): + mode: DecisionMode + action_type: ActionType + target_drug: Optional[str] = None + replacement_drug: Optional[str] = None + dose_bucket: DoseBucket = DoseBucket.NA + taper_days: Optional[int] = None + monitoring_plan: Optional[str] = None + evidence_query: Optional[str] = None + new_drug_name: Optional[str] = None + candidate_components: list[str] = Field(default_factory=list) + candidate_id: str + confidence: float + rationale_brief: str + + @field_validator("confidence") + @classmethod + def _valid_confidence(cls, value: float) -> float: + return clamp_reward(value) + + +class RewardBreakdown(StrictBase): + format_compliance_score: float + candidate_alignment_score: float + legality_score: float + safety_delta_score: float + burden_improvement_score: float + disease_stability_score: float + dosing_quality_score: float + abstention_quality_score: float + efficiency_score: float + process_fidelity_score: float + explanation_grounding_score: float + anti_cheat_score: float + uncertainty_calibration_score: float + primary_safety_legality: float = 0.5 + primary_clinical_improvement: float = 0.5 + primary_dosing_quality: float = 0.5 + primary_process_integrity: float = 0.5 + total_reward: float + + +class SafetyReport(StrictBase): + legal: bool + violations: list[str] = Field(default_factory=list) + severity: str = "none" + recommended_fallback: Optional[ActionType] = None + uncertainty_notes: list[str] = Field(default_factory=list) + + +class UncertaintyReport(StrictBase): + overall_uncertainty: float = 0.5 + missing_data_flags: list[str] = Field(default_factory=list) + abstention_recommended: bool = False + + +class PolyGuardState(StrictBase): + episode_id: str + seed: int + scenario_id: Optional[str] = None + difficulty: Difficulty + sub_environment: SubEnvironment = SubEnvironment.REGIMEN_RISK + step_count: int + max_steps: int + patient: PatientProfile + active_mode: DecisionMode = DecisionMode.REGIMEN_OPT + cumulative_reward: float = 0.0 + unresolved_conflicts: list[str] = Field(default_factory=list) + risk_summary: dict[str, float] = Field(default_factory=dict) + burden_score: float = 0.5 + precision_dosing_flags: list[str] = Field(default_factory=list) + action_history: list[dict[str, Any]] = Field(default_factory=list) + done: bool = False + created_at: datetime = Field(default_factory=datetime.utcnow) + + +class PolyGuardObservation(StrictBase): + patient_summary: dict[str, Any] + medication_table: list[dict[str, Any]] + comorbidity_summary: list[str] + organ_function_summary: dict[str, Any] + labs_vitals_summary: dict[str, Any] + graph_safety_summary: dict[str, Any] + burden_score_summary: dict[str, Any] + precision_dosing_flags: list[str] + unresolved_conflicts: list[str] + candidate_action_set: list[CandidateAction] + step_budget_remaining: int + action_history: list[dict[str, Any]] + warning_summary: list[str] + abstention_indicators: dict[str, Any] + sub_environment: SubEnvironment + deterministic_contract: dict[str, Any] = Field(default_factory=dict) + + +class StepTrace(StrictBase): + step: int + observation_snapshot: PolyGuardObservation + selected_action: Optional[PolyGuardAction] = None + critic_output: dict[str, Any] = Field(default_factory=dict) + reward_components: dict[str, float] = Field(default_factory=dict) + transition_delta: dict[str, Any] = Field(default_factory=dict) + uncertainty_report: UncertaintyReport = Field(default_factory=UncertaintyReport) + failure_reasons: list[str] = Field(default_factory=list) + timeout: bool = False diff --git a/app/dataops/__init__.py b/app/dataops/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6a0653bf54348aa1ef5d98867cf459a814865642 --- /dev/null +++ b/app/dataops/__init__.py @@ -0,0 +1,5 @@ +"""Data operations package.""" + +from app.dataops.source_manager import SourceManager + +__all__ = ["SourceManager"] diff --git a/app/dataops/ddi_api.py b/app/dataops/ddi_api.py new file mode 100644 index 0000000000000000000000000000000000000000..2ea86ff185aebc9759d3dcabeb7ff4bdbff7ca1c --- /dev/null +++ b/app/dataops/ddi_api.py @@ -0,0 +1,65 @@ +"""DDI API ingestion helpers with offline-first caching.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import requests + + +DEFAULT_DDI_API_URL = "https://api.fda.gov/drug/label.json" + + +def fetch_ddi_api_records( + drugs: list[str], + timeout: int = 20, + api_url: str = DEFAULT_DDI_API_URL, +) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + for drug in drugs: + try: + response = requests.get( + api_url, + params={"search": f"openfda.generic_name:{drug}", "limit": 1}, + timeout=timeout, + ) + response.raise_for_status() + payload = response.json() + records.append( + { + "drug": drug, + "source": api_url, + "status": "ok", + "payload": payload, + } + ) + except Exception as exc: # noqa: BLE001 + records.append( + { + "drug": drug, + "source": api_url, + "status": "error", + "error": str(exc), + } + ) + return records + + +def load_cached_ddi(path: Path) -> list[dict[str, Any]]: + if not path.exists(): + return [] + try: + payload = json.loads(path.read_text(encoding="utf-8")) + if isinstance(payload, list): + return payload + return [] + except Exception: + return [] + + +def cache_ddi_records(path: Path, records: list[dict[str, Any]]) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(records, ensure_ascii=True, indent=2), encoding="utf-8") + return path diff --git a/app/dataops/normalizer.py b/app/dataops/normalizer.py new file mode 100644 index 0000000000000000000000000000000000000000..fa373080ecb25231f5b02f48053f8d808a69de63 --- /dev/null +++ b/app/dataops/normalizer.py @@ -0,0 +1,13 @@ +"""Entity normalizer.""" + +from __future__ import annotations + +from app.knowledge.drug_catalog import canonicalize_drug_name + + +def normalize_drug_entities(items: list[str]) -> list[str]: + return sorted({canonicalize_drug_name(item) for item in items}) + + +def normalize_component_entities(items: list[str]) -> list[str]: + return sorted({canonicalize_drug_name(item).replace("-", "_") for item in items if item}) diff --git a/app/dataops/package_loader.py b/app/dataops/package_loader.py new file mode 100644 index 0000000000000000000000000000000000000000..717948dec01432b72a16116403ea927eb37ab3ae --- /dev/null +++ b/app/dataops/package_loader.py @@ -0,0 +1,19 @@ +"""Package/local artifact loading.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import yaml + + +def load_artifact(path: Path) -> Any: + if path.suffix.lower() in {".json"}: + return json.loads(path.read_text(encoding="utf-8")) + if path.suffix.lower() in {".yaml", ".yml"}: + return yaml.safe_load(path.read_text(encoding="utf-8")) + if path.suffix.lower() in {".txt", ".md"}: + return path.read_text(encoding="utf-8") + return path.read_bytes() diff --git a/app/dataops/parser.py b/app/dataops/parser.py new file mode 100644 index 0000000000000000000000000000000000000000..1ef1336de491155090b7608b8844c7fc252ad663 --- /dev/null +++ b/app/dataops/parser.py @@ -0,0 +1,26 @@ +"""Raw text parser for knowledge ingestion.""" + +from __future__ import annotations + +import re + + +def extract_drug_mentions(text: str) -> list[str]: + tokens = re.findall(r"[a-zA-Z_-]{4,}", text.lower()) + return sorted(set(tokens)) + + +def extract_components(text: str) -> list[str]: + # Supports "active ingredient(s): ..." and similar label patterns. + lines = [line.strip().lower() for line in text.splitlines() if line.strip()] + components: list[str] = [] + for line in lines: + if "ingredient" in line or "component" in line or "contains" in line: + parts = re.split(r":|\\.|;", line, maxsplit=1) + if len(parts) > 1: + rhs = parts[1] + for item in re.split(r",|/| and ", rhs): + token = re.sub(r"[^a-z0-9_ -]", "", item).strip().replace(" ", "_") + if 3 <= len(token) <= 40: + components.append(token) + return sorted(set(components)) diff --git a/app/dataops/provenance.py b/app/dataops/provenance.py new file mode 100644 index 0000000000000000000000000000000000000000..712e84d602e85e9ad196ab060173df2bfcffa4c9 --- /dev/null +++ b/app/dataops/provenance.py @@ -0,0 +1,31 @@ +"""Provenance tracking.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(slots=True) +class ProvenanceRecord: + source: str + source_type: str + fetched_at: str + transform: str + + def to_dict(self) -> dict[str, str]: + return { + "source": self.source, + "source_type": self.source_type, + "fetched_at": self.fetched_at, + "transform": self.transform, + } + + +def make_provenance(source: str, source_type: str, transform: str) -> ProvenanceRecord: + return ProvenanceRecord( + source=source, + source_type=source_type, + fetched_at=datetime.utcnow().isoformat(), + transform=transform, + ) diff --git a/app/dataops/scraper.py b/app/dataops/scraper.py new file mode 100644 index 0000000000000000000000000000000000000000..37b26d1ef4fd43adaa363d2d10b7c58e220e338e --- /dev/null +++ b/app/dataops/scraper.py @@ -0,0 +1,9 @@ +"""Controlled scraper facade.""" + +from __future__ import annotations + +from app.dataops.web_agent import fetch_url + + +def scrape_allowed_page(url: str, allow_domains: list[str]) -> str: + return fetch_url(url, allowed_domains=allow_domains) diff --git a/app/dataops/source_manager.py b/app/dataops/source_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..ff69d515df7403d7369832b873d24b1bc2bbea9c --- /dev/null +++ b/app/dataops/source_manager.py @@ -0,0 +1,111 @@ +"""Source management for offline-first ingestion.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +from app.dataops.web_agent import fetch_url +from app.dataops.parser import extract_components, extract_drug_mentions +from app.dataops.normalizer import normalize_component_entities, normalize_drug_entities +from app.dataops.provenance import make_provenance + + +class SourceManager: + def __init__(self, root: Path) -> None: + self.root = root + self.raw = root / "data" / "raw" + self.cache = root / "data" / "cache" + self.cache.mkdir(parents=True, exist_ok=True) + + def local_sources(self) -> list[Path]: + return [p for p in self.raw.rglob("*") if p.is_file()] + + @staticmethod + def checksum_text(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + def cache_text(self, namespace: str, key: str, text: str) -> Path: + ns_dir = self.cache / namespace + ns_dir.mkdir(parents=True, exist_ok=True) + checksum = self.checksum_text(text) + target = ns_dir / f"{key}_{checksum[:12]}.txt" + target.write_text(text, encoding="utf-8") + meta = { + "key": key, + "checksum": checksum, + "path": str(target), + } + (ns_dir / f"{key}.meta.json").write_text(json.dumps(meta, ensure_ascii=True, indent=2), encoding="utf-8") + return target + + def read_cached(self, namespace: str, key: str) -> str | None: + meta_path = self.cache / namespace / f"{key}.meta.json" + if not meta_path.exists(): + return None + meta = json.loads(meta_path.read_text(encoding="utf-8")) + target = Path(meta["path"]) + if target.exists(): + return target.read_text(encoding="utf-8") + return None + + def fetch_with_cache( + self, + url: str, + allow_domains: list[str], + namespace: str = "web", + offline_first: bool = True, + ) -> dict[str, Any]: + key = url.replace("https://", "").replace("http://", "").replace("/", "_") + if offline_first: + cached = self.read_cached(namespace=namespace, key=key) + if cached is not None: + provenance = make_provenance(source=url, source_type="cache", transform="read_cached") + return {"text": cached, "provenance": provenance.__dict__, "from_cache": True} + text = fetch_url(url, allowed_domains=allow_domains) + self.cache_text(namespace=namespace, key=key, text=text) + provenance = make_provenance(source=url, source_type="web", transform="fetch_with_cache") + return {"text": text, "provenance": provenance.__dict__, "from_cache": False} + + +class DataAcquisitionAgent: + def __init__(self, root: Path, allow_domains: list[str]) -> None: + self.manager = SourceManager(root=root) + self.allow_domains = allow_domains + + def acquire_local_knowledge(self) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + for source in self.manager.local_sources(): + text = source.read_text(encoding="utf-8", errors="ignore") + mentions = normalize_drug_entities(extract_drug_mentions(text)) + components = normalize_component_entities(extract_components(text)) + provenance = make_provenance(source=str(source), source_type="local_file", transform="parse_local").to_dict() + records.append( + { + "source": str(source), + "mentions": mentions, + "components": components, + "provenance": provenance, + } + ) + return records + + def acquire_web_knowledge(self, url: str, offline_first: bool = True) -> dict[str, Any]: + blob = self.manager.fetch_with_cache( + url=url, + allow_domains=self.allow_domains, + namespace="drug_labels", + offline_first=offline_first, + ) + text = blob["text"] + mentions = normalize_drug_entities(extract_drug_mentions(text)) + components = normalize_component_entities(extract_components(text)) + return { + "url": url, + "mentions": mentions, + "components": components, + "provenance": blob["provenance"], + "from_cache": blob["from_cache"], + } diff --git a/app/dataops/synthetic_mix.py b/app/dataops/synthetic_mix.py new file mode 100644 index 0000000000000000000000000000000000000000..970d9a50ebe41cd5259bd98a8956e6908513754a --- /dev/null +++ b/app/dataops/synthetic_mix.py @@ -0,0 +1,9 @@ +"""Synthetic and mock data blending.""" + +from __future__ import annotations + +from typing import Any + + +def merge_sources(local_items: list[dict[str, Any]], generated_items: list[dict[str, Any]]) -> list[dict[str, Any]]: + return local_items + generated_items diff --git a/app/dataops/web_agent.py b/app/dataops/web_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..54c24e980062eb490a21ef96e6c14a15462df106 --- /dev/null +++ b/app/dataops/web_agent.py @@ -0,0 +1,20 @@ +"""Allow-listed web retrieval.""" + +from __future__ import annotations + +from urllib.parse import urlparse + +import requests + + +def fetch_url(url: str, allowed_domains: list[str]) -> str: + host = urlparse(url).netloc.lower() + if not any(host.endswith(domain) for domain in allowed_domains): + raise ValueError(f"Domain not allow-listed: {host}") + try: + response = requests.get(url, timeout=20) + response.raise_for_status() + return response.text + except Exception as exc: # noqa: BLE001 + # Explicit failure message makes offline-first behavior easier to reason about upstream. + raise RuntimeError(f"web_fetch_failed:{host}:{exc}") from exc diff --git a/app/dataops/web_fallback.py b/app/dataops/web_fallback.py new file mode 100644 index 0000000000000000000000000000000000000000..f54cd48c59103490c4f75429a4d68184f87df99b --- /dev/null +++ b/app/dataops/web_fallback.py @@ -0,0 +1,59 @@ +"""Optional web fallback ingestion via Scrapling and Playwright.""" + +from __future__ import annotations + +from urllib.parse import urlparse + +import requests + + +def _allowed(url: str, allow_domains: list[str]) -> bool: + host = urlparse(url).netloc.lower() + return any(host.endswith(domain) for domain in allow_domains) + + +def _scrape_with_scrapling(url: str) -> str: + # Scrapling API compatibility may vary by version, so this path is best-effort. + from scrapling import Fetcher # type: ignore + + fetcher = Fetcher() + page = fetcher.get(url) + return getattr(page, "text", "") or "" + + +def _scrape_with_playwright(url: str) -> str: + from playwright.sync_api import sync_playwright # type: ignore + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page() + page.goto(url, timeout=30_000) + content = page.content() + browser.close() + return content + + +def scrape_with_fallback(url: str, allow_domains: list[str]) -> dict[str, str]: + if not _allowed(url, allow_domains): + return {"status": "blocked", "url": url, "backend": "allowlist"} + + try: + text = _scrape_with_scrapling(url) + if text: + return {"status": "ok", "url": url, "backend": "scrapling", "text": text} + except Exception: + pass + + try: + text = _scrape_with_playwright(url) + if text: + return {"status": "ok", "url": url, "backend": "playwright", "text": text} + except Exception: + pass + + try: + response = requests.get(url, timeout=20) + response.raise_for_status() + return {"status": "ok", "url": url, "backend": "requests", "text": response.text} + except Exception as exc: # noqa: BLE001 + return {"status": "error", "url": url, "backend": "none", "error": str(exc)} diff --git a/app/env/__init__.py b/app/env/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..dc58760b3df0ab13b48b23354a00cf25442ae5e0 --- /dev/null +++ b/app/env/__init__.py @@ -0,0 +1,27 @@ +"""Environment package.""" + +__all__ = ["PolyGuardEnv", "EnvironmentA", "EnvironmentB", "EnvironmentC", "EnvironmentD"] + + +def __getattr__(name: str): + if name == "PolyGuardEnv": + from app.env.env_core import PolyGuardEnv + + return PolyGuardEnv + if name == "EnvironmentA": + from app.env.environment_a import EnvironmentA + + return EnvironmentA + if name == "EnvironmentB": + from app.env.environment_b import EnvironmentB + + return EnvironmentB + if name == "EnvironmentC": + from app.env.environment_c import EnvironmentC + + return EnvironmentC + if name == "EnvironmentD": + from app.env.environment_d import EnvironmentD + + return EnvironmentD + raise AttributeError(name) diff --git a/app/env/actions.py b/app/env/actions.py new file mode 100644 index 0000000000000000000000000000000000000000..df2e5d8b9cc0382bdc077d56e8ba899fe2142528 --- /dev/null +++ b/app/env/actions.py @@ -0,0 +1,7 @@ +"""Environment action helpers.""" + +from __future__ import annotations + +from app.common.types import PolyGuardAction + +__all__ = ["PolyGuardAction"] diff --git a/app/env/anti_cheat.py b/app/env/anti_cheat.py new file mode 100644 index 0000000000000000000000000000000000000000..3e4b9a396a4c26efa81dff5bb12eecc69c87142f --- /dev/null +++ b/app/env/anti_cheat.py @@ -0,0 +1,82 @@ +"""Anti reward-hacking guards.""" + +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass +from typing import Iterable + +from app.common.constants import MAX_KEEP_REGIMEN_RATIO, MAX_REPEATED_ACTIONS, MAX_REVIEW_RATIO +from app.common.enums import ActionType +from app.common.types import PolyGuardAction, PolyGuardState + + +@dataclass(slots=True) +class AntiCheatResult: + exploit_detected: bool + reasons: list[str] + + +def detect_repeated_action_loop(actions: Iterable[PolyGuardAction], threshold: int = 3) -> bool: + ids = [a.candidate_id for a in actions] + if len(ids) < threshold: + return False + return len(set(ids[-threshold:])) == 1 + + +def evaluate_anti_cheat( + state: PolyGuardState, + action: PolyGuardAction, + legal_candidate_ids: set[str] | None = None, +) -> AntiCheatResult: + reasons: list[str] = [] + history = [ + PolyGuardAction.model_validate(item["action"]) if isinstance(item.get("action"), dict) else None + for item in state.action_history + ] + history = [x for x in history if x is not None] + if detect_repeated_action_loop(history + [action], threshold=MAX_REPEATED_ACTIONS): + reasons.append("repeated_action_loop") + + action_types = [a.action_type for a in history] + type_count = Counter(action_types) + keep_count = type_count.get(ActionType.KEEP_REGIMEN, 0) + (1 if action.action_type == ActionType.KEEP_REGIMEN else 0) + total_count = len(history) + 1 + if total_count >= 3 and (keep_count / total_count) > MAX_KEEP_REGIMEN_RATIO: + reasons.append("keep_regimen_abuse") + + review_actions = { + ActionType.REQUEST_SPECIALIST_REVIEW, + ActionType.REQUEST_PHARMACIST_REVIEW, + } + review_count = sum(1 for t in action_types if t in review_actions) + (1 if action.action_type in review_actions else 0) + if total_count >= 3 and (review_count / total_count) > MAX_REVIEW_RATIO: + reasons.append("review_abuse") + + if not action.candidate_id.startswith("cand_"): + reasons.append("candidate_id_mismatch") + if legal_candidate_ids is not None and action.candidate_id not in legal_candidate_ids: + reasons.append("candidate_not_in_legal_set") + + # Hidden holdout rule: known high-risk pair should not be repeatedly ignored. + risky_pair_key = {"warfarin_like", "nsaid_like"} + current_drugs = {m.drug for m in state.patient.medications} + prior_holdout_keep = any(a.action_type == ActionType.KEEP_REGIMEN for a in history) + if risky_pair_key.issubset(current_drugs) and action.action_type == ActionType.KEEP_REGIMEN and prior_holdout_keep: + reasons.append("holdout_ddi_not_addressed") + + if "<" in action.rationale_brief or "{" in action.rationale_brief: + reasons.append("parser_exploit_pattern") + + if state.action_history: + last = state.action_history[-1] + last_action = last.get("action", {}) + if ( + isinstance(last_action, dict) + and last_action.get("candidate_id") == action.candidate_id + and last_action.get("action_type") == action.action_type.value + and last.get("applied") is False + ): + reasons.append("no_op_retry_loop") + + return AntiCheatResult(exploit_detected=bool(reasons), reasons=reasons) diff --git a/app/env/catalog.py b/app/env/catalog.py new file mode 100644 index 0000000000000000000000000000000000000000..c1e06818f6c62a31b61754949e51c745f0b3b3f0 --- /dev/null +++ b/app/env/catalog.py @@ -0,0 +1,61 @@ +"""Adapter catalog for old task labels over the canonical PolyGuard env.""" + +from __future__ import annotations + +from typing import Any + +from app.common.constants import REWARD_MAX, REWARD_MIN, REWARD_PRECISION +from app.common.enums import Difficulty, SubEnvironment + +TASK_PRESETS: tuple[dict[str, str], ...] = ( + { + "id": "easy_screening", + "label": "Easy Screening", + "difficulty": Difficulty.EASY.value, + "sub_environment": SubEnvironment.DDI.value, + }, + { + "id": "budgeted_screening", + "label": "Budgeted Screening", + "difficulty": Difficulty.MEDIUM.value, + "sub_environment": SubEnvironment.REGIMEN_RISK.value, + }, + { + "id": "complex_tradeoff", + "label": "Complex Tradeoff", + "difficulty": Difficulty.HARD.value, + "sub_environment": SubEnvironment.REGIMEN_RISK.value, + }, + { + "id": "bandit_mining", + "label": "Bandit Mining", + "difficulty": Difficulty.HARD.value, + "sub_environment": SubEnvironment.BANDIT_MINING.value, + }, +) + +TASK_PRESET_BY_ID = {item["id"]: item for item in TASK_PRESETS} + + +def env_catalog() -> dict[str, Any]: + return { + "reward_range": [REWARD_MIN, REWARD_MAX], + "reward_precision": REWARD_PRECISION, + "task_presets": [dict(item) for item in TASK_PRESETS], + "sub_environments": [item.value for item in SubEnvironment], + } + + +def apply_task_preset(payload: dict[str, Any]) -> dict[str, Any]: + """Expand an old task label into canonical difficulty/sub-environment fields.""" + task_id = payload.pop("task_id", None) + if not task_id: + return payload + preset = TASK_PRESET_BY_ID.get(str(task_id)) + if preset is None: + raise ValueError(f"Unknown task_id {task_id!r}") + if payload.get("difficulty") is None: + payload["difficulty"] = preset["difficulty"] + if payload.get("sub_environment") is None: + payload["sub_environment"] = preset["sub_environment"] + return payload diff --git a/app/env/client.py b/app/env/client.py new file mode 100644 index 0000000000000000000000000000000000000000..16ce028aa77f0718c7eacae3ccf767b1cea4440e --- /dev/null +++ b/app/env/client.py @@ -0,0 +1,62 @@ +"""Simple HTTP client for the local env service.""" + +from __future__ import annotations + +from typing import Any + +import requests + + +class PolyGuardEnvClient: + def __init__(self, base_url: str = "http://127.0.0.1:8100") -> None: + self.base_url = base_url.rstrip("/") + + def reset(self, **kwargs: Any) -> dict[str, Any]: + response = requests.post(f"{self.base_url}/env/reset", json=kwargs, timeout=30) + response.raise_for_status() + return response.json() + + def step(self, action: dict[str, Any]) -> dict[str, Any]: + response = requests.post(f"{self.base_url}/env/step", json=action, timeout=30) + response.raise_for_status() + return response.json() + + def state(self) -> dict[str, Any]: + response = requests.get(f"{self.base_url}/env/state", timeout=30) + response.raise_for_status() + return response.json() + + def trace(self) -> list[dict[str, Any]]: + response = requests.get(f"{self.base_url}/env/trace", timeout=30) + response.raise_for_status() + return response.json() + + def legal_actions(self) -> list[dict[str, Any]]: + response = requests.get(f"{self.base_url}/env/legal_actions", timeout=30) + response.raise_for_status() + return response.json() + + def reward_breakdown(self) -> dict[str, Any]: + response = requests.get(f"{self.base_url}/env/reward_breakdown", timeout=30) + response.raise_for_status() + return response.json() + + def uncertainty(self) -> dict[str, Any]: + response = requests.get(f"{self.base_url}/env/uncertainty", timeout=30) + response.raise_for_status() + return response.json() + + def metadata(self) -> dict[str, Any]: + response = requests.get(f"{self.base_url}/metadata", timeout=30) + response.raise_for_status() + return response.json() + + def schema(self) -> dict[str, Any]: + response = requests.get(f"{self.base_url}/schema", timeout=30) + response.raise_for_status() + return response.json() + + def mcp(self, payload: dict[str, Any] | None = None) -> dict[str, Any]: + response = requests.post(f"{self.base_url}/mcp", json=payload or {}, timeout=30) + response.raise_for_status() + return response.json() diff --git a/app/env/curriculum.py b/app/env/curriculum.py new file mode 100644 index 0000000000000000000000000000000000000000..b358abba1a64ae8c7a39d6a85b9593397bf68fd4 --- /dev/null +++ b/app/env/curriculum.py @@ -0,0 +1,34 @@ +"""Curriculum helpers.""" + +from __future__ import annotations + +from app.common.enums import Difficulty, SubEnvironment + + +def pick_difficulty(episode_index: int) -> Difficulty: + if episode_index < 20: + return Difficulty.EASY + if episode_index < 40: + return Difficulty.MEDIUM + return Difficulty.HARD + + +def pick_sub_environment(episode_index: int, difficulty: Difficulty) -> SubEnvironment: + # Curriculum starts in DDI/risk-heavy cases then introduces bandits/dosing. + if difficulty == Difficulty.EASY: + return SubEnvironment.DDI if episode_index % 2 == 0 else SubEnvironment.REGIMEN_RISK + if difficulty == Difficulty.MEDIUM: + if episode_index % 4 == 0: + return SubEnvironment.BANDIT_MINING + if episode_index % 4 == 1: + return SubEnvironment.ALTERNATIVE_SUGGESTION + return SubEnvironment.REGIMEN_RISK + if episode_index % 5 == 0: + return SubEnvironment.PRECISION_DOSING + if episode_index % 5 == 1: + return SubEnvironment.LONGITUDINAL_DEPRESCRIBING + if episode_index % 5 == 2: + return SubEnvironment.WEB_SEARCH_MISSING_DATA + if episode_index % 5 == 3: + return SubEnvironment.NEW_DRUG_DECOMPOSITION + return SubEnvironment.REGIMEN_RISK diff --git a/app/env/env_core.py b/app/env/env_core.py new file mode 100644 index 0000000000000000000000000000000000000000..4055ff1f6bb3dc27e037e9c178063d94fe3b16b8 --- /dev/null +++ b/app/env/env_core.py @@ -0,0 +1,363 @@ +"""Core PolyGuard environment implementation.""" + +from __future__ import annotations + +import time +import uuid +import os +from pathlib import Path +from typing import Optional + +from app.common.constants import ( + DEFAULT_EPISODE_TIMEOUT_SECONDS, + DEFAULT_MAX_STEPS, + DEFAULT_SEED, + DEFAULT_STEP_TIMEOUT_SECONDS, +) +from app.common.enums import Difficulty, SubEnvironment +from app.common.seeding import set_global_seed +from app.common.types import ( + CandidateAction, + PolyGuardAction, + PolyGuardObservation, + PolyGuardState, + RewardBreakdown, + StepTrace, + UncertaintyReport, +) +from app.env.anti_cheat import evaluate_anti_cheat +from app.env.curriculum import pick_difficulty, pick_sub_environment +from app.env.reward_router import compute_reward_breakdown +from app.env.scenario_loader import load_or_generate_scenario +from app.env.termination import check_termination_with_timeout +from app.env.transition import apply_transition +from app.env.verifier import verify_action_legality +from app.knowledge.ddi_knowledge import top_risky_pairs +from app.models.policy.candidate_builder import build_candidates +from app.models.policy.uncertainty import estimate_uncertainty + + +class PolyGuardEnv: + def __init__(self, root: Optional[Path] = None) -> None: + self.root = root or Path(__file__).resolve().parents[2] + self._episode_index = 0 + self._state: Optional[PolyGuardState] = None + self._trace: list[StepTrace] = [] + self._last_reward: Optional[RewardBreakdown] = None + self._episode_started_at: float = 0.0 + self._episode_timeout_seconds: float = float( + os.getenv("POLYGUARD_EPISODE_TIMEOUT_SECONDS", str(DEFAULT_EPISODE_TIMEOUT_SECONDS)) + ) + self._step_timeout_seconds: float = float( + os.getenv("POLYGUARD_STEP_TIMEOUT_SECONDS", str(DEFAULT_STEP_TIMEOUT_SECONDS)) + ) + + @property + def state(self) -> PolyGuardState: + if self._state is None: + raise RuntimeError("Environment has not been reset.") + return self._state + + def reset( + self, + seed: Optional[int] = None, + difficulty: Optional[str] = None, + sub_environment: Optional[str] = None, + scenario_id: Optional[str] = None, + patient_id: Optional[str] = None, + ) -> PolyGuardObservation: + run_seed = set_global_seed(seed if seed is not None else DEFAULT_SEED) + diff = Difficulty(difficulty) if difficulty else pick_difficulty(self._episode_index) + if sub_environment: + chosen_sub_environment = SubEnvironment(sub_environment) + else: + chosen_sub_environment = pick_sub_environment(self._episode_index, diff) + patient = load_or_generate_scenario( + root=self.root, + difficulty=diff, + scenario_id=scenario_id, + patient_id=patient_id, + seed=run_seed, + ) + scenario_key = scenario_id or patient.patient_id + max_steps = { + SubEnvironment.DDI: 3, + SubEnvironment.REGIMEN_RISK: 6, + SubEnvironment.BANDIT_MINING: 6, + SubEnvironment.PRECISION_DOSING: 8, + SubEnvironment.LONGITUDINAL_DEPRESCRIBING: 10, + SubEnvironment.WEB_SEARCH_MISSING_DATA: 5, + SubEnvironment.ALTERNATIVE_SUGGESTION: 6, + SubEnvironment.NEW_DRUG_DECOMPOSITION: 7, + }.get(chosen_sub_environment, { + Difficulty.EASY: 3, + Difficulty.MEDIUM: 6, + Difficulty.HARD: 10, + }.get(diff, DEFAULT_MAX_STEPS)) + risky_pairs = top_risky_pairs([m.drug for m in patient.medications]) + self._state = PolyGuardState( + episode_id=f"ep_{uuid.uuid4().hex[:8]}", + seed=run_seed, + scenario_id=scenario_key, + difficulty=diff, + sub_environment=chosen_sub_environment, + step_count=0, + max_steps=max_steps, + patient=patient, + risk_summary={ + "polypharmacy_count": float(len(patient.medications)), + "burden_score": len(patient.medications) / 12.0, + "severe_pair_count": float(len(risky_pairs)), + }, + burden_score=min(1.0, len(patient.medications) / 12.0), + precision_dosing_flags=["dose_sensitive_case"] if chosen_sub_environment == SubEnvironment.PRECISION_DOSING else [], + unresolved_conflicts=list(patient.specialist_conflicts), + ) + self._trace = [] + self._last_reward = None + self._episode_started_at = time.monotonic() + self._episode_index += 1 + obs = self._build_observation() + self._trace.append( + StepTrace( + step=0, + observation_snapshot=obs, + reward_components={}, + ) + ) + return obs + + def _build_observation(self) -> PolyGuardObservation: + state = self.state + candidates = build_candidates(state) + uncertainty = estimate_uncertainty(state) + risky_pairs = top_risky_pairs([m.drug for m in state.patient.medications]) + warning_summary: list[str] = [] + if state.burden_score >= 0.7: + warning_summary.append("high_polypharmacy_burden") + if state.patient.monitoring_gaps: + warning_summary.extend([f"monitoring_gap:{gap}" for gap in state.patient.monitoring_gaps[:2]]) + if state.sub_environment == SubEnvironment.WEB_SEARCH_MISSING_DATA: + warning_summary.append("missing_data_web_evidence_recommended") + if state.sub_environment == SubEnvironment.NEW_DRUG_DECOMPOSITION: + warning_summary.append("new_drug_component_analysis_recommended") + return PolyGuardObservation( + patient_summary={ + "patient_id": state.patient.patient_id, + "age": state.patient.age, + "sex": state.patient.sex, + "adherence_estimate": state.patient.adherence_estimate, + "sub_environment": state.sub_environment.value, + }, + medication_table=[m.model_dump(mode="json") for m in state.patient.medications], + comorbidity_summary=state.patient.comorbidities, + organ_function_summary={ + "egfr": state.patient.labs.egfr, + "ast": state.patient.labs.ast, + "alt": state.patient.labs.alt, + }, + labs_vitals_summary={**state.patient.labs.model_dump(mode="json"), **state.patient.vitals}, + graph_safety_summary={ + "top_risk_pairs": risky_pairs, + "polypharmacy_count": len(state.patient.medications), + "estimated_risk": state.risk_summary.get("burden_score", 0.5), + }, + burden_score_summary={"burden_score": state.burden_score}, + precision_dosing_flags=state.precision_dosing_flags, + unresolved_conflicts=state.unresolved_conflicts, + candidate_action_set=candidates, + step_budget_remaining=max(0, state.max_steps - state.step_count), + action_history=state.action_history, + warning_summary=warning_summary, + abstention_indicators={"uncertainty": uncertainty, "recommended": uncertainty > 0.65}, + sub_environment=state.sub_environment, + deterministic_contract={ + "seed": state.seed, + "scenario_id": state.scenario_id, + "difficulty": state.difficulty.value, + "sub_environment": state.sub_environment.value, + }, + ) + + @staticmethod + def _action_from_payload(action: PolyGuardAction | dict) -> PolyGuardAction: + if isinstance(action, PolyGuardAction): + return action + if not isinstance(action, dict): + raise ValueError("Action must be a PolyGuardAction or dictionary payload.") + try: + return PolyGuardAction.model_validate(action) + except Exception: # noqa: BLE001 + candidate = CandidateAction.model_validate(action) + return PolyGuardAction( + mode=candidate.mode, + action_type=candidate.action_type, + target_drug=candidate.target_drug, + replacement_drug=candidate.replacement_drug, + dose_bucket=candidate.dose_bucket, + taper_days=candidate.taper_days, + monitoring_plan=candidate.monitoring_plan, + evidence_query=candidate.evidence_query, + new_drug_name=candidate.new_drug_name, + candidate_components=candidate.candidate_components, + candidate_id=candidate.candidate_id, + confidence=max(0.45, 1.0 - candidate.uncertainty_score), + rationale_brief=f"Candidate-selected action ({','.join(candidate.rationale_tags[:2]) or 'rule'})", + ) + + def step(self, action: PolyGuardAction | dict) -> tuple[PolyGuardObservation, float, bool, dict]: + step_started_at = time.monotonic() + state = self.state + if state.done: + observation = self._build_observation() + reward = self._last_reward.total_reward if self._last_reward else 0.001 + info = { + "termination_reason": "already_done", + "reward_breakdown": self._last_reward.model_dump(mode="json") if self._last_reward else {}, + "transition_delta": {"applied": False, "reason": ["episode_already_complete"], "rolled_back": True}, + } + return observation, reward, True, info + + parsed = self._action_from_payload(action) + pre_burden = state.burden_score + pre_risky_pairs = len(top_risky_pairs([m.drug for m in state.patient.medications])) + safety_report = verify_action_legality(state, parsed) + legal_candidate_ids = {c.candidate_id for c in build_candidates(state)} + anti_cheat = evaluate_anti_cheat(state, parsed, legal_candidate_ids=legal_candidate_ids) + + if safety_report.legal and not anti_cheat.exploit_detected: + transition_delta = apply_transition(state, parsed) + else: + transition_delta = { + "applied": False, + "reason": safety_report.violations or anti_cheat.reasons or ["blocked"], + "rolled_back": True, + } + state.action_history.append({"step": state.step_count, "action": parsed.model_dump(mode="json"), "applied": False}) + state.step_count += 1 + + uncertainty_report = self.get_uncertainty_report() + reward = compute_reward_breakdown( + state=state, + action=parsed, + safety_report=safety_report, + anti_cheat_detected=anti_cheat.exploit_detected, + uncertainty=uncertainty_report.overall_uncertainty, + pre_burden=pre_burden, + pre_risky_pairs=pre_risky_pairs, + ) + self._last_reward = reward + state.cumulative_reward += reward.total_reward + + elapsed = time.monotonic() - self._episode_started_at + done, reason = check_termination_with_timeout( + state=state, + action=parsed, + exploit_detected=anti_cheat.exploit_detected, + elapsed_seconds=elapsed, + wall_clock_limit_seconds=self._episode_timeout_seconds, + ) + step_elapsed = time.monotonic() - step_started_at + step_timeout = step_elapsed >= self._step_timeout_seconds + if step_timeout and not done: + done = True + reason = "step_timeout" + + state.done = done + invalid_action_count = sum(1 for item in state.action_history if item.get("applied") is False) + transition_failures = transition_delta.get("reason", []) + if isinstance(transition_failures, str): + transition_failures = [transition_failures] + failure_reasons = list(dict.fromkeys([*safety_report.violations, *anti_cheat.reasons, *transition_failures])) + observation = self._build_observation() + self._trace.append( + StepTrace( + step=state.step_count, + observation_snapshot=observation, + selected_action=parsed, + critic_output={"safety_report": safety_report.model_dump(mode="json"), "anti_cheat": anti_cheat.reasons}, + reward_components=reward.model_dump(mode="json"), + transition_delta=transition_delta, + uncertainty_report=uncertainty_report, + failure_reasons=failure_reasons, + timeout=bool(step_timeout or reason == "wall_clock_timeout"), + ) + ) + info = { + "termination_reason": reason, + "safety_report": safety_report.model_dump(mode="json"), + "anti_cheat_reasons": anti_cheat.reasons, + "reward_breakdown": reward.model_dump(mode="json"), + "primary_reward_channels": { + "safety_legality": reward.primary_safety_legality, + "clinical_improvement": reward.primary_clinical_improvement, + "dosing_quality": reward.primary_dosing_quality, + "process_integrity": reward.primary_process_integrity, + }, + "failure_reasons": failure_reasons, + "transition_delta": transition_delta, + "step_timeout": step_timeout, + "episode_elapsed_seconds": round(elapsed, 3), + "step_elapsed_seconds": round(step_elapsed, 3), + "invalid_action_count": invalid_action_count, + "checks": { + "anti_cheat": bool(anti_cheat.reasons), + "timeout": bool(step_timeout or reason == "wall_clock_timeout"), + "parser_exploit": "parser_exploit_pattern" in anti_cheat.reasons, + "legality_gate": bool(safety_report.legal), + }, + } + return observation, reward.total_reward, done, info + + def get_state(self) -> dict: + return self.state.model_dump(mode="json") + + def get_reward_breakdown(self) -> dict: + return self._last_reward.model_dump(mode="json") if self._last_reward else {} + + def get_trace(self) -> list[dict]: + return [item.model_dump(mode="json") for item in self._trace] + + def get_legal_actions(self) -> list[dict]: + obs = self._build_observation() + return [ + self._action_from_payload(candidate.model_dump(mode="json")).model_dump(mode="json") + for candidate in obs.candidate_action_set + if candidate.legality_precheck + ] + + def get_candidate_actions(self) -> list[dict]: + obs = self._build_observation() + return [candidate.model_dump(mode="json") for candidate in obs.candidate_action_set] + + def get_metadata(self) -> dict[str, object]: + return { + "name": "polyguard-openenv", + "description": ( + "Polypharmacy safety and optimization environment with constrained " + "actions, reward decomposition, and OpenEnv-compatible APIs." + ), + "version": "0.2.0", + "openenv_mode": "simulation", + "reward_range": [0.001, 0.999], + "reward_precision": 3, + "action_schema": "PolyGuardAction (strict)", + "observation_schema": "PolyGuardObservation", + "state_schema": "PolyGuardState", + "step_timeout_seconds": self._step_timeout_seconds, + "episode_timeout_seconds": self._episode_timeout_seconds, + } + + def get_uncertainty_report(self) -> UncertaintyReport: + state = self.state + uncertainty = estimate_uncertainty(state) + missing_flags: list[str] = [] + if state.patient.labs.egfr is None: + missing_flags.append("missing_egfr") + if state.patient.labs.ast is None or state.patient.labs.alt is None: + missing_flags.append("missing_liver_enzymes") + return UncertaintyReport( + overall_uncertainty=uncertainty, + missing_data_flags=missing_flags, + abstention_recommended=uncertainty > 0.65, + ) diff --git a/app/env/environment_a.py b/app/env/environment_a.py new file mode 100644 index 0000000000000000000000000000000000000000..10bdb0f6b3edf6f6863868b9ef416d8ea6a1cfa9 --- /dev/null +++ b/app/env/environment_a.py @@ -0,0 +1,12 @@ +"""Environment A: Pairwise DDI and side-effect identification.""" + +from __future__ import annotations + +from app.env.env_core import PolyGuardEnv + + +class EnvironmentA(PolyGuardEnv): + def reset(self, **kwargs): + kwargs.setdefault("difficulty", "easy") + kwargs.setdefault("sub_environment", "DDI") + return super().reset(**kwargs) diff --git a/app/env/environment_b.py b/app/env/environment_b.py new file mode 100644 index 0000000000000000000000000000000000000000..20944bdfb6ea3f16a60897ad9c0a31852ca839ab --- /dev/null +++ b/app/env/environment_b.py @@ -0,0 +1,12 @@ +"""Environment B: Regimen risk reduction and medication optimization.""" + +from __future__ import annotations + +from app.env.env_core import PolyGuardEnv + + +class EnvironmentB(PolyGuardEnv): + def reset(self, **kwargs): + kwargs.setdefault("difficulty", "medium") + kwargs.setdefault("sub_environment", "REGIMEN_RISK") + return super().reset(**kwargs) diff --git a/app/env/environment_c.py b/app/env/environment_c.py new file mode 100644 index 0000000000000000000000000000000000000000..15428bf4e88016c5d013db9f5d5e1feae0604f55 --- /dev/null +++ b/app/env/environment_c.py @@ -0,0 +1,12 @@ +"""Environment C: Precision dosing.""" + +from __future__ import annotations + +from app.env.env_core import PolyGuardEnv + + +class EnvironmentC(PolyGuardEnv): + def reset(self, **kwargs): + kwargs.setdefault("difficulty", "hard") + kwargs.setdefault("sub_environment", "PRECISION_DOSING") + return super().reset(**kwargs) diff --git a/app/env/environment_d.py b/app/env/environment_d.py new file mode 100644 index 0000000000000000000000000000000000000000..85a000dd6fb09977e2353873f31eabe530cf2d3b --- /dev/null +++ b/app/env/environment_d.py @@ -0,0 +1,12 @@ +"""Environment D: Longitudinal deprescribing under conflicting plans.""" + +from __future__ import annotations + +from app.env.env_core import PolyGuardEnv + + +class EnvironmentD(PolyGuardEnv): + def reset(self, **kwargs): + kwargs.setdefault("difficulty", "hard") + kwargs.setdefault("sub_environment", "LONGITUDINAL_DEPRESCRIBING") + return super().reset(**kwargs) diff --git a/app/env/fastapi_app.py b/app/env/fastapi_app.py new file mode 100644 index 0000000000000000000000000000000000000000..5b48c1fb179983c1ebd12a709e87c2f09d390707 --- /dev/null +++ b/app/env/fastapi_app.py @@ -0,0 +1,261 @@ +"""FastAPI wrapper for PolyGuardEnv (OpenEnv-style).""" + +from __future__ import annotations + +import json +import os +from typing import Any, Optional + +import uvicorn +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from pydantic import BaseModel, ConfigDict + +from app.common.config import load_project_env +from app.common.enums import Difficulty, SubEnvironment +from app.common.types import PolyGuardAction, PolyGuardObservation, PolyGuardState +from app.env.env_core import PolyGuardEnv + +load_project_env() + +app = FastAPI(title="POLYGUARD-RL Env Service", version="0.1.0") +_ENV = PolyGuardEnv() + + +class ResetRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + seed: Optional[int] = None + difficulty: Optional[Difficulty] = None + sub_environment: Optional[SubEnvironment] = None + scenario_id: Optional[str] = None + patient_id: Optional[str] = None + + +def _step_payload(observation: dict[str, Any], reward: float, done: bool, info: dict[str, Any]) -> dict[str, Any]: + reason = str(info.get("termination_reason", "")) if isinstance(info, dict) else "" + truncated = reason in {"wall_clock_timeout", "step_timeout", "step_budget_exhausted"} + return { + "observation": observation, + "reward": reward, + "done": done, + "terminated": done, + "truncated": truncated, + "info": info, + } + + +@app.get("/health") +def health() -> dict[str, str]: + return {"status": "healthy"} + + +@app.post("/env/reset") +def env_reset(request: ResetRequest) -> dict[str, Any]: + obs = _ENV.reset( + seed=request.seed, + difficulty=request.difficulty, + sub_environment=request.sub_environment, + scenario_id=request.scenario_id, + patient_id=request.patient_id, + ) + return {"observation": obs.model_dump(mode="json")} + + +@app.post("/env/step") +def env_step(action: dict[str, Any]) -> dict[str, Any]: + obs, reward, done, info = _ENV.step(action) + return _step_payload(observation=obs.model_dump(mode="json"), reward=reward, done=done, info=info) + + +@app.get("/env/state") +def env_state() -> dict[str, Any]: + return _ENV.get_state() + + +@app.get("/env/trace") +def env_trace() -> list[dict[str, Any]]: + return _ENV.get_trace() + + +@app.get("/env/legal_actions") +def env_legal_actions() -> list[dict[str, Any]]: + return _ENV.get_legal_actions() + + +@app.get("/env/reward_breakdown") +def env_reward_breakdown() -> dict[str, Any]: + return _ENV.get_reward_breakdown() + + +@app.get("/env/uncertainty") +def env_uncertainty() -> dict[str, Any]: + return _ENV.get_uncertainty_report().model_dump(mode="json") + + +@app.get("/env/metadata") +def env_metadata() -> dict[str, Any]: + return _ENV.get_metadata() + + +@app.get("/schema") +def schema() -> dict[str, Any]: + return { + "action": PolyGuardAction.model_json_schema(), + "observation": PolyGuardObservation.model_json_schema(), + "state": PolyGuardState.model_json_schema(), + } + + +@app.post("/mcp") +def mcp(payload: dict[str, Any]) -> dict[str, Any]: + request_id = payload.get("id") + method = str(payload.get("method", "") or "") + params = payload.get("params", {}) if isinstance(payload.get("params", {}), dict) else {} + + try: + if method == "tools/list": + result = { + "tools": [ + { + "name": "env.reset", + "description": "Reset environment and return initial observation payload.", + "inputSchema": { + "type": "object", + "properties": { + "seed": {"type": "integer"}, + "difficulty": {"type": "string"}, + "sub_environment": {"type": "string"}, + "scenario_id": {"type": "string"}, + "patient_id": {"type": "string"}, + }, + }, + }, + { + "name": "env.step", + "description": "Execute a policy action.", + "inputSchema": PolyGuardAction.model_json_schema(), + }, + { + "name": "env.state", + "description": "Get current environment state.", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "env.metadata", + "description": "Get environment metadata.", + "inputSchema": {"type": "object", "properties": {}}, + }, + ] + } + elif method == "tools/call": + tool_name = str(params.get("name", "") or "") + arguments = params.get("arguments", {}) if isinstance(params.get("arguments"), dict) else {} + if tool_name == "env.reset": + request = ResetRequest.model_validate(arguments) + result = env_reset(request) + elif tool_name == "env.step": + result = env_step(arguments) + elif tool_name == "env.state": + result = env_state() + elif tool_name == "env.metadata": + result = env_metadata() + else: + raise ValueError(f"Unknown tool name: {tool_name}") + elif not method: + result = {"capabilities": {"tools": True, "ws": True}} + else: + raise ValueError(f"Unsupported method: {method}") + return {"jsonrpc": "2.0", "id": request_id, "result": result} + except Exception as exc: # noqa: BLE001 + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32000, "message": str(exc)}, + } + + +# OpenEnv baseline compatibility aliases. +@app.post("/reset") +def reset_alias(request: ResetRequest) -> dict[str, Any]: + payload = env_reset(request) + return _step_payload( + observation=payload["observation"], + reward=0.5, + done=False, + info={"reset": True}, + ) + + +@app.post("/step") +def step_alias(action: dict[str, Any]) -> dict[str, Any]: + return env_step(action) + + +@app.get("/state") +def state_alias() -> dict[str, Any]: + return env_state() + + +@app.get("/metadata") +def metadata_alias() -> dict[str, Any]: + return env_metadata() + + +@app.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket) -> None: + await websocket.accept() + try: + while True: + raw = await websocket.receive_text() + message = json.loads(raw) + msg_type = message.get("type") + data = message.get("data", {}) or {} + try: + if msg_type == "reset": + request = ResetRequest.model_validate(data) + obs = _ENV.reset( + seed=request.seed, + difficulty=request.difficulty, + sub_environment=request.sub_environment, + scenario_id=request.scenario_id, + patient_id=request.patient_id, + ) + payload = _step_payload( + observation=obs.model_dump(mode="json"), + reward=0.5, + done=False, + info={"reset": True}, + ) + elif msg_type == "step": + obs, reward, done, info = _ENV.step(data) + payload = _step_payload( + observation=obs.model_dump(mode="json"), + reward=reward, + done=done, + info=info, + ) + elif msg_type == "state": + payload = _ENV.get_state() + elif msg_type == "metadata": + payload = _ENV.get_metadata() + else: + raise ValueError(f"Unsupported message type: {msg_type}") + await websocket.send_json({"type": "result", "data": payload}) + except Exception as exc: # noqa: BLE001 + await websocket.send_json( + { + "type": "error", + "data": {"code": "EXECUTION_ERROR", "message": str(exc)}, + } + ) + except WebSocketDisconnect: + return + + +def main() -> None: + host = os.getenv("POLYGUARD_ENV_HOST", "127.0.0.1") + port = int(os.getenv("POLYGUARD_ENV_PORT", "8100")) + uvicorn.run("app.env.fastapi_app:app", host=host, port=port, reload=False) + + +if __name__ == "__main__": + main() diff --git a/app/env/observations.py b/app/env/observations.py new file mode 100644 index 0000000000000000000000000000000000000000..40f5aa196091bb235b7bbff3df3c7a350c5b982d --- /dev/null +++ b/app/env/observations.py @@ -0,0 +1,7 @@ +"""Observation type exports.""" + +from __future__ import annotations + +from app.common.types import PolyGuardObservation + +__all__ = ["PolyGuardObservation"] diff --git a/app/env/replay.py b/app/env/replay.py new file mode 100644 index 0000000000000000000000000000000000000000..8535e4e38b61625e0d920fdfdaee24d189b357cc --- /dev/null +++ b/app/env/replay.py @@ -0,0 +1,9 @@ +"""Replay helpers.""" + +from __future__ import annotations + +from app.common.types import StepTrace + + +def serialize_trace(trace: list[StepTrace]) -> list[dict]: + return [step.model_dump(mode="json") for step in trace] diff --git a/app/env/reward_router.py b/app/env/reward_router.py new file mode 100644 index 0000000000000000000000000000000000000000..bc6b335da7bb6fde8c9b8cd8b12b570b2ea695a3 --- /dev/null +++ b/app/env/reward_router.py @@ -0,0 +1,148 @@ +"""Reward router with 13 reward components.""" + +from __future__ import annotations + +from typing import Any + +from app.common.constants import PRIMARY_REWARD_KEYS, REQUIRED_REWARD_KEYS +from app.common.enums import ActionType +from app.common.normalization import clamp_reward +from app.common.types import PolyGuardAction, PolyGuardState, RewardBreakdown, SafetyReport +from app.env.reward_scaling import aggregate_rewards, scale_reward_components + + +def _safe_improvement_score(state: PolyGuardState) -> float: + med_count = max(1, len(state.patient.medications)) + return clamp_reward(1.0 - med_count / 14.0) + + +def _delta_to_reward(pre_value: float, post_value: float) -> float: + # Improvement is positive when risk-like values decrease. + delta = pre_value - post_value + return clamp_reward(0.5 + (delta * 0.6)) + + +def _avg(values: list[float]) -> float: + if not values: + return 0.5 + return clamp_reward(sum(values) / len(values)) + + +def compute_primary_reward_channels(components: dict[str, float]) -> dict[str, float]: + """Map legacy reward columns into 4 primary reward channels. + + This keeps backward-compatible legacy keys while giving higher-level reward + channels for GRPO/SFT analysis and dashboards. + """ + mapped = { + "safety_legality": _avg( + [ + components.get("legality_score", 0.5), + components.get("candidate_alignment_score", 0.5), + components.get("anti_cheat_score", 0.5), + components.get("uncertainty_calibration_score", 0.5), + ] + ), + "clinical_improvement": _avg( + [ + components.get("safety_delta_score", 0.5), + components.get("burden_improvement_score", 0.5), + components.get("disease_stability_score", 0.5), + ] + ), + "dosing_quality": _avg( + [ + components.get("dosing_quality_score", 0.5), + components.get("abstention_quality_score", 0.5), + ] + ), + "process_integrity": _avg( + [ + components.get("format_compliance_score", 0.5), + components.get("efficiency_score", 0.5), + components.get("process_fidelity_score", 0.5), + components.get("explanation_grounding_score", 0.5), + ] + ), + } + for key in PRIMARY_REWARD_KEYS: + mapped.setdefault(key, 0.5) + return {key: clamp_reward(value) for key, value in mapped.items()} + + +def compute_reward_breakdown( + state: PolyGuardState, + action: PolyGuardAction, + safety_report: SafetyReport, + anti_cheat_detected: bool, + uncertainty: float, + pre_burden: float | None = None, + pre_risky_pairs: int | None = None, +) -> RewardBreakdown: + legal = safety_report.legal + review_actions = { + ActionType.REQUEST_SPECIALIST_REVIEW, + ActionType.REQUEST_PHARMACIST_REVIEW, + } + post_burden = state.burden_score + post_risky_pairs = float(state.risk_summary.get("severe_pair_count", 0.0)) + pre_burden_val = pre_burden if pre_burden is not None else post_burden + pre_pair_val = float(pre_risky_pairs if pre_risky_pairs is not None else post_risky_pairs) + burden_reward = _delta_to_reward(pre_burden_val, post_burden) + pair_reward = _delta_to_reward(pre_pair_val, post_risky_pairs) + safe_delta = clamp_reward((pair_reward * 0.65) + (burden_reward * 0.35)) + overconfidence_penalty = abs(action.confidence - (1.0 - uncertainty)) + + components: dict[str, float] = { + "format_compliance_score": 0.999, + "candidate_alignment_score": 0.999 if action.candidate_id.startswith("cand_") else 0.001, + "legality_score": 0.999 if legal else 0.001, + "safety_delta_score": safe_delta if legal else 0.001, + "burden_improvement_score": burden_reward if legal else 0.001, + "disease_stability_score": 0.9 if action.action_type not in {ActionType.STOP_DRUG, ActionType.INCREASE_DOSE_BUCKET} else 0.58, + "dosing_quality_score": 0.75 if action.mode.value == "DOSE_OPT" else 0.5, + "abstention_quality_score": 0.82 if action.action_type in review_actions and uncertainty > 0.6 else 0.56, + "efficiency_score": clamp_reward(1.0 - (state.step_count / max(1, state.max_steps + 1))), + "process_fidelity_score": 0.92 if legal else 0.08, + "explanation_grounding_score": 0.8 if action.rationale_brief else 0.2, + "anti_cheat_score": 0.001 if anti_cheat_detected else 0.999, + "uncertainty_calibration_score": clamp_reward(1.0 - overconfidence_penalty), + } + + if state.sub_environment.value == "WEB_SEARCH_MISSING_DATA": + if action.action_type == ActionType.FETCH_EXTERNAL_EVIDENCE: + components["process_fidelity_score"] = clamp_reward(max(components["process_fidelity_score"], 0.9)) + components["explanation_grounding_score"] = clamp_reward(max(components["explanation_grounding_score"], 0.85)) + else: + components["process_fidelity_score"] = clamp_reward(components["process_fidelity_score"] * 0.75) + + if state.sub_environment.value == "ALTERNATIVE_SUGGESTION": + if action.action_type in {ActionType.RECOMMEND_ALTERNATIVE, ActionType.SUBSTITUTE_WITHIN_CLASS}: + components["safety_delta_score"] = clamp_reward(max(components["safety_delta_score"], 0.88)) + components["burden_improvement_score"] = clamp_reward(max(components["burden_improvement_score"], 0.76)) + else: + components["safety_delta_score"] = clamp_reward(components["safety_delta_score"] * 0.82) + + if state.sub_environment.value == "NEW_DRUG_DECOMPOSITION": + if action.action_type == ActionType.DECOMPOSE_NEW_DRUG and action.candidate_components: + components["explanation_grounding_score"] = clamp_reward(max(components["explanation_grounding_score"], 0.9)) + components["process_fidelity_score"] = clamp_reward(max(components["process_fidelity_score"], 0.88)) + components["uncertainty_calibration_score"] = clamp_reward(max(components["uncertainty_calibration_score"], 0.82)) + else: + components["explanation_grounding_score"] = clamp_reward(components["explanation_grounding_score"] * 0.7) + + components = scale_reward_components(components) + + # Guarantee all keys exist. + for key in REQUIRED_REWARD_KEYS: + components.setdefault(key, 0.5) + primary_channels = compute_primary_reward_channels(components) + total = aggregate_rewards(components) + return RewardBreakdown( + **components, + primary_safety_legality=primary_channels["safety_legality"], + primary_clinical_improvement=primary_channels["clinical_improvement"], + primary_dosing_quality=primary_channels["dosing_quality"], + primary_process_integrity=primary_channels["process_integrity"], + total_reward=total, + ) diff --git a/app/env/reward_scaling.py b/app/env/reward_scaling.py new file mode 100644 index 0000000000000000000000000000000000000000..5905e2e5b02b65d166b46689beb8b8b5fa052ca3 --- /dev/null +++ b/app/env/reward_scaling.py @@ -0,0 +1,27 @@ +"""Reward scaling in strict [0.001, 0.999].""" + +from __future__ import annotations + +from typing import Mapping + +from app.common.constants import DEFAULT_REWARD_WEIGHTS +from app.common.normalization import clamp_reward + + +def scale_reward_components(components: Mapping[str, float]) -> dict[str, float]: + return {k: clamp_reward(v) for k, v in components.items()} + + +def aggregate_rewards(components: Mapping[str, float], weights: Mapping[str, float] | None = None) -> float: + if not components: + return clamp_reward(0.5) + use_weights = dict(weights or DEFAULT_REWARD_WEIGHTS) + weighted_sum = 0.0 + denom = 0.0 + for key, value in components.items(): + w = use_weights.get(key, 0.0) + weighted_sum += w * clamp_reward(value) + denom += w + if denom <= 0.0: + return clamp_reward(sum(clamp_reward(v) for v in components.values()) / len(components)) + return clamp_reward(weighted_sum / denom) diff --git a/app/env/scenario_loader.py b/app/env/scenario_loader.py new file mode 100644 index 0000000000000000000000000000000000000000..a99c73417e77588cc03ec37112e64d131a803fb6 --- /dev/null +++ b/app/env/scenario_loader.py @@ -0,0 +1,30 @@ +"""Scenario loading utilities.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional + +from app.common.enums import Difficulty +from app.common.types import PatientProfile +from app.simulator.scenario_generator import generate_patient_scenario + + +def _scenario_path(root: Path, difficulty: Difficulty, scenario_id: str) -> Path: + return root / "data" / "scenarios" / difficulty.value / f"{scenario_id}.json" + + +def load_or_generate_scenario( + root: Path, + difficulty: Difficulty, + scenario_id: Optional[str], + patient_id: Optional[str], + seed: int, +) -> PatientProfile: + if scenario_id: + path = _scenario_path(root, difficulty, scenario_id) + if path.exists(): + payload = json.loads(path.read_text(encoding="utf-8")) + return PatientProfile.model_validate(payload) + return generate_patient_scenario(difficulty=difficulty, patient_id=patient_id, seed=seed) diff --git a/app/env/state.py b/app/env/state.py new file mode 100644 index 0000000000000000000000000000000000000000..1d99d945a7ca23f0bb787df9fda632f88214c747 --- /dev/null +++ b/app/env/state.py @@ -0,0 +1,7 @@ +"""State type exports.""" + +from __future__ import annotations + +from app.common.types import PolyGuardState + +__all__ = ["PolyGuardState"] diff --git a/app/env/termination.py b/app/env/termination.py new file mode 100644 index 0000000000000000000000000000000000000000..b31e06a80a4f4f53a56c5295fdf4681d1fd74faf --- /dev/null +++ b/app/env/termination.py @@ -0,0 +1,44 @@ +"""Episode termination logic.""" + +from __future__ import annotations + +from app.common.types import PolyGuardAction, PolyGuardState + + +def check_termination(state: PolyGuardState, action: PolyGuardAction, exploit_detected: bool = False) -> tuple[bool, str]: + if exploit_detected: + return True, "exploit_detection" + + if state.step_count >= state.max_steps: + return True, "step_budget_exhausted" + + invalid_recent = [h for h in state.action_history[-3:] if h.get("applied") is False] + if len(invalid_recent) >= 3: + return True, "repeated_invalid_actions" + + if state.risk_summary.get("severe_pair_count", 0.0) >= 2.0 and state.step_count >= max(2, state.max_steps // 2): + return True, "safety_veto_threshold" + + if state.risk_summary.get("burden_score", 1.0) > 0.92 and state.step_count >= 2: + return True, "patient_destabilization" + + if state.burden_score < 0.25 and not state.unresolved_conflicts: + return True, "safe_resolution" + + return False, "ongoing" + + +def check_termination_with_timeout( + state: PolyGuardState, + action: PolyGuardAction, + exploit_detected: bool = False, + elapsed_seconds: float | None = None, + wall_clock_limit_seconds: float | None = None, +) -> tuple[bool, str]: + done, reason = check_termination(state=state, action=action, exploit_detected=exploit_detected) + if done: + return done, reason + if elapsed_seconds is not None and wall_clock_limit_seconds is not None: + if elapsed_seconds >= max(0.1, wall_clock_limit_seconds): + return True, "wall_clock_timeout" + return False, "ongoing" diff --git a/app/env/transition.py b/app/env/transition.py new file mode 100644 index 0000000000000000000000000000000000000000..a35dfbf552c7e8876bb512df24607464e99fba90 --- /dev/null +++ b/app/env/transition.py @@ -0,0 +1,143 @@ +"""Environment transition dynamics.""" + +from __future__ import annotations + +from pathlib import Path + +from app.common.enums import ActionType, DecisionMode, DoseBucket +from app.common.types import PolyGuardAction, PolyGuardState +from app.dataops.parser import extract_components, extract_drug_mentions +from app.dataops.source_manager import SourceManager +from app.dataops.web_fallback import scrape_with_fallback +from app.knowledge.ddi_knowledge import top_risky_pairs + + +DOSE_BURDEN_WEIGHT = { + DoseBucket.LOW: 0.7, + DoseBucket.MEDIUM: 1.0, + DoseBucket.HIGH: 1.25, + DoseBucket.HOLD: 0.45, + DoseBucket.NA: 1.0, +} + + +def _find_med_idx(state: PolyGuardState, drug: str | None) -> int | None: + if not drug: + return None + for idx, med in enumerate(state.patient.medications): + if med.drug == drug: + return idx + return None + + +def apply_transition(state: PolyGuardState, action: PolyGuardAction) -> dict[str, object]: + delta: dict[str, object] = {"applied": True, "changes": []} + meds = state.patient.medications + target_idx = _find_med_idx(state, action.target_drug) + state.active_mode = action.mode + + if action.action_type == ActionType.KEEP_REGIMEN: + delta["changes"].append("no_change") + + elif action.action_type == ActionType.STOP_DRUG and target_idx is not None: + removed = meds.pop(target_idx) + delta["changes"].append(f"stopped:{removed.drug}") + + elif action.action_type == ActionType.SUBSTITUTE_WITHIN_CLASS and target_idx is not None and action.replacement_drug: + old = meds[target_idx].drug + meds[target_idx].drug = action.replacement_drug + delta["changes"].append(f"substituted:{old}->{action.replacement_drug}") + + elif action.action_type == ActionType.RECOMMEND_ALTERNATIVE and target_idx is not None and action.replacement_drug: + old = meds[target_idx].drug + meds[target_idx].drug = action.replacement_drug + delta["changes"].append(f"alternative_recommended:{old}->{action.replacement_drug}") + + elif action.action_type in {ActionType.REDUCE_DOSE_BUCKET, ActionType.INCREASE_DOSE_BUCKET} and target_idx is not None: + bucket_order = [DoseBucket.LOW, DoseBucket.MEDIUM, DoseBucket.HIGH] + current = meds[target_idx].dose_bucket + if current in bucket_order: + cur_idx = bucket_order.index(current) + if action.action_type == ActionType.REDUCE_DOSE_BUCKET and cur_idx > 0: + meds[target_idx].dose_bucket = bucket_order[cur_idx - 1] + if action.action_type == ActionType.INCREASE_DOSE_BUCKET and cur_idx < len(bucket_order) - 1: + meds[target_idx].dose_bucket = bucket_order[cur_idx + 1] + delta["changes"].append(f"dose_change:{meds[target_idx].drug}:{current}->{meds[target_idx].dose_bucket}") + + elif action.action_type == ActionType.DOSE_HOLD and target_idx is not None: + meds[target_idx].dose_bucket = DoseBucket.HOLD + delta["changes"].append(f"held:{meds[target_idx].drug}") + + elif action.action_type == ActionType.ORDER_MONITORING_AND_WAIT: + if target_idx is not None: + meds[target_idx].dose_bucket = DoseBucket.HOLD + delta["changes"].append(f"held_for_monitoring:{meds[target_idx].drug}") + state.unresolved_conflicts = [c for c in state.unresolved_conflicts if not c.startswith("review_requested")] + delta["changes"].append("monitoring_ordered") + + elif action.action_type == ActionType.TAPER_INITIATE and target_idx is not None: + meds[target_idx].requires_taper = True + delta["changes"].append(f"taper_start:{meds[target_idx].drug}:{action.taper_days or 7}d") + + elif action.action_type == ActionType.TAPER_CONTINUE and target_idx is not None: + meds[target_idx].dose_bucket = DoseBucket.LOW + delta["changes"].append(f"taper_continue:{meds[target_idx].drug}") + + elif action.action_type in {ActionType.REQUEST_SPECIALIST_REVIEW, ActionType.REQUEST_PHARMACIST_REVIEW}: + state.active_mode = DecisionMode.REVIEW + state.unresolved_conflicts.append(f"review_requested:{action.action_type.value}") + delta["changes"].append(f"review:{action.action_type.value}") + + elif action.action_type == ActionType.FETCH_EXTERNAL_EVIDENCE: + text = "" + allow_domains = ["who.int", "nih.gov", "fda.gov", "ema.europa.eu"] + query = (action.evidence_query or "").strip() + if query.startswith("http"): + manager = SourceManager(root=Path(__file__).resolve().parents[2]) + try: + fetched = manager.fetch_with_cache( + url=query, + allow_domains=allow_domains, + namespace="evidence_fetch", + offline_first=True, + ) + text = str(fetched.get("text", "")) + delta["changes"].append("evidence_cached_or_fetched") + except Exception: + fallback = scrape_with_fallback(query, allow_domains=allow_domains) + text = str(fallback.get("text", "")) + delta["changes"].append(f"evidence_fallback:{fallback.get('backend', 'none')}") + else: + text = query + delta["changes"].append("evidence_query_recorded") + mentions = extract_drug_mentions(text) + components = extract_components(text) + state.risk_summary["external_mentions_count"] = float(len(mentions)) + state.risk_summary["external_components_count"] = float(len(components)) + state.unresolved_conflicts = [item for item in state.unresolved_conflicts if "missing_data" not in item] + + elif action.action_type == ActionType.DECOMPOSE_NEW_DRUG: + seed_text = ( + " ".join(action.candidate_components) + if action.candidate_components + else f"active ingredients: {(action.new_drug_name or '').replace('_', ' ')}" + ) + extracted = extract_components(seed_text) + fallback_components = [token for token in (action.candidate_components or []) if token] + components = extracted or fallback_components + state.risk_summary["new_drug_component_count"] = float(len(components)) + state.risk_summary["new_drug_unknown_risk"] = 0.0 if components else 1.0 + state.unresolved_conflicts = [item for item in state.unresolved_conflicts if "new_drug_unknown" not in item] + delta["changes"].append(f"new_drug_components:{','.join(components) if components else 'none'}") + + state.action_history.append({"step": state.step_count, "action": action.model_dump(mode="json")}) + state.step_count += 1 + + # Dose-aware burden update so dose optimization has a real reward signal. + dose_weighted_burden = sum(DOSE_BURDEN_WEIGHT.get(med.dose_bucket, 1.0) for med in meds) + state.burden_score = max(0.0, min(1.0, dose_weighted_burden / 12.0)) + state.risk_summary["polypharmacy_count"] = float(len(meds)) + state.risk_summary["burden_score"] = float(state.burden_score) + state.risk_summary["severe_pair_count"] = float(len(top_risky_pairs([m.drug for m in meds]))) + delta["state"] = {"step_count": state.step_count, "med_count": len(meds)} + return delta diff --git a/app/env/verifier.py b/app/env/verifier.py new file mode 100644 index 0000000000000000000000000000000000000000..4b16d87612b18d584a31c8787b5e9a8edd4185b3 --- /dev/null +++ b/app/env/verifier.py @@ -0,0 +1,124 @@ +"""Safety and legality verifier.""" + +from __future__ import annotations + +from urllib.parse import urlparse + +from app.common.enums import ActionType, DoseBucket +from app.common.types import PolyGuardAction, PolyGuardState, SafetyReport +from app.knowledge.ddi_knowledge import is_contraindicated_pair +from app.knowledge.duplicate_therapy_rules import has_duplicate_therapy +from app.knowledge.drug_catalog import DRUG_CLASSES +from app.knowledge.renal_rules import is_renal_unsafe +from app.knowledge.hepatic_rules import is_hepatic_unsafe +from app.knowledge.substitution_rules import get_substitutions +from app.knowledge.taper_rules import requires_taper + + +def verify_action_legality(state: PolyGuardState, action: PolyGuardAction) -> SafetyReport: + violations: list[str] = [] + patient = state.patient + med_names = [m.drug for m in patient.medications] + + if action.target_drug and action.target_drug not in med_names and action.action_type != ActionType.SUBSTITUTE_WITHIN_CLASS: + violations.append("target_drug_not_in_regimen") + + if action.action_type == ActionType.SUBSTITUTE_WITHIN_CLASS and not action.replacement_drug: + violations.append("replacement_drug_required") + if action.action_type == ActionType.SUBSTITUTE_WITHIN_CLASS and action.target_drug and action.replacement_drug: + allowed = set(get_substitutions(action.target_drug)) + if action.replacement_drug not in allowed: + violations.append("unsafe_substitution") + + if action.action_type == ActionType.RECOMMEND_ALTERNATIVE: + if not action.target_drug or not action.replacement_drug: + violations.append("alternative_requires_target_and_replacement") + elif action.target_drug and action.replacement_drug: + allowed = set(get_substitutions(action.target_drug)) + if action.replacement_drug not in allowed: + violations.append("unsafe_alternative_recommendation") + + if action.action_type == ActionType.FETCH_EXTERNAL_EVIDENCE: + if not action.evidence_query: + violations.append("missing_evidence_query") + else: + query = action.evidence_query.strip() + if query.startswith("http"): + host = urlparse(query).netloc.lower() + allowlist = {"nih.gov", "fda.gov", "who.int", "ema.europa.eu"} + if host and not any(host.endswith(domain) for domain in allowlist): + violations.append("evidence_domain_not_allowlisted") + + if action.action_type == ActionType.DECOMPOSE_NEW_DRUG: + if not action.new_drug_name: + violations.append("missing_new_drug_name") + if not action.candidate_components: + violations.append("missing_candidate_components") + + if action.action_type == ActionType.STOP_DRUG and action.target_drug and requires_taper(action.target_drug): + if action.taper_days is None: + violations.append("abrupt_stop_requires_taper") + if action.action_type in {ActionType.TAPER_INITIATE, ActionType.TAPER_CONTINUE} and action.target_drug: + if not requires_taper(action.target_drug): + violations.append("invalid_taper_target") + + if action.action_type == ActionType.INCREASE_DOSE_BUCKET and action.dose_bucket == DoseBucket.HIGH: + if action.target_drug and is_renal_unsafe(action.target_drug, patient.labs.egfr): + violations.append("renal_unsafe_dose") + if action.target_drug and is_hepatic_unsafe(action.target_drug, patient.labs.ast, patient.labs.alt): + violations.append("hepatic_unsafe_dose") + if action.action_type == ActionType.REDUCE_DOSE_BUCKET and action.target_drug: + idx = med_names.index(action.target_drug) if action.target_drug in med_names else -1 + if idx >= 0 and patient.medications[idx].dose_bucket in {DoseBucket.LOW, DoseBucket.HOLD}: + violations.append("dose_already_minimized") + if action.action_type == ActionType.INCREASE_DOSE_BUCKET and action.target_drug: + idx = med_names.index(action.target_drug) if action.target_drug in med_names else -1 + if idx >= 0 and patient.medications[idx].dose_bucket == DoseBucket.HIGH: + violations.append("dose_overshoot_risk") + if patient.frailty_score > 0.7 and DRUG_CLASSES.get(action.target_drug) == "sedative": + violations.append("invalid_class_escalation") + + # Duplicate therapy check after substitutions/increases. + if action.action_type in { + ActionType.SUBSTITUTE_WITHIN_CLASS, + ActionType.RECOMMEND_ALTERNATIVE, + ActionType.INCREASE_DOSE_BUCKET, + } and has_duplicate_therapy(patient.medications, action.target_drug, action.replacement_drug): + violations.append("duplicate_therapy") + + if action.replacement_drug and action.target_drug and is_contraindicated_pair(action.target_drug, action.replacement_drug): + violations.append("unsafe_substitution_contraindication") + if action.replacement_drug: + contraindication_hits = 0 + for med in patient.medications: + if med.drug != action.target_drug and is_contraindicated_pair(med.drug, action.replacement_drug): + violations.append("creates_severe_contraindicated_pair") + contraindication_hits += 1 + if contraindication_hits >= 2: + violations.append("dangerous_triple_risk_creation") + + if action.action_type in {ActionType.ORDER_MONITORING_AND_WAIT, ActionType.DOSE_HOLD} and not action.monitoring_plan: + violations.append("invalid_monitoring_gap") + + if ( + action.action_type == ActionType.STOP_DRUG + and action.target_drug == "warfarin_like" + and "afib" in patient.comorbidities + and not action.replacement_drug + ): + violations.append("destabilizing_deprescribing") + + legal = len(violations) == 0 + severity = "none" if legal else ("high" if len(violations) > 1 else "medium") + fallback = ( + ActionType.REQUEST_SPECIALIST_REVIEW + if not legal + else ActionType.KEEP_REGIMEN + ) + return SafetyReport( + legal=legal, + violations=violations, + severity=severity, + recommended_fallback=fallback, + uncertainty_notes=["manual_review_recommended"] if not legal else [], + ) diff --git a/app/evaluation/__init__.py b/app/evaluation/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ab56ead40df18f62da0c148b863c59f3ab6accaf --- /dev/null +++ b/app/evaluation/__init__.py @@ -0,0 +1,5 @@ +"""Evaluation package.""" + +from app.evaluation.offline_policy_eval import offline_policy_eval + +__all__ = ["offline_policy_eval"] diff --git a/app/evaluation/abstention_eval.py b/app/evaluation/abstention_eval.py new file mode 100644 index 0000000000000000000000000000000000000000..6b14f62a9a9e52789dbea1a71d23cd7aec3b1a35 --- /dev/null +++ b/app/evaluation/abstention_eval.py @@ -0,0 +1,20 @@ +"""Abstention behavior evaluation.""" + +from __future__ import annotations + +from app.evaluation.simulator_rollouts import run_rollouts + + +def abstention_eval() -> dict[str, float]: + rows = run_rollouts(episodes=8, difficulty="hard") + if not rows: + return {"appropriate_abstention_rate": 0.0} + qualified = [ + row + for row in rows + if float((row.get("reward_breakdown", {}) or {}).get("abstention_quality_score", 0.0)) >= 0.6 + ] + if not qualified: + return {"appropriate_abstention_rate": 0.0} + appropriate = sum(1.0 for row in qualified if bool(row.get("abstain", False))) + return {"appropriate_abstention_rate": round(appropriate / len(qualified), 6)} diff --git a/app/evaluation/benchmark_report.py b/app/evaluation/benchmark_report.py new file mode 100644 index 0000000000000000000000000000000000000000..8d7211ae94c567ea6c367073a8661561592dadf1 --- /dev/null +++ b/app/evaluation/benchmark_report.py @@ -0,0 +1,33 @@ +"""Benchmark report generation.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from app.evaluation.abstention_eval import abstention_eval +from app.evaluation.calibration_eval import calibration_eval +from app.evaluation.dosing_eval import dosing_eval +from app.evaluation.offline_policy_eval import offline_policy_eval +from app.evaluation.process_eval import process_eval +from app.evaluation.robustness_eval import robustness_eval +from app.evaluation.safety_eval import safety_eval +from app.evaluation.subgroup_eval import subgroup_eval +from app.evaluation.explainability_eval import explainability_eval + + +def build_benchmark_report(out_path: Path) -> dict: + report = { + "offline_policy_eval": offline_policy_eval(), + "safety_eval": safety_eval(), + "dosing_eval": dosing_eval(), + "robustness_eval": robustness_eval(), + "calibration_eval": calibration_eval(), + "abstention_eval": abstention_eval(), + "process_eval": process_eval(), + "subgroup_eval": subgroup_eval(), + "explainability_eval": explainability_eval(), + } + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(report, ensure_ascii=True, indent=2), encoding="utf-8") + return report diff --git a/app/evaluation/calibration_eval.py b/app/evaluation/calibration_eval.py new file mode 100644 index 0000000000000000000000000000000000000000..c5793fd2ff6d83bc94663a1b699b47fdb421eb30 --- /dev/null +++ b/app/evaluation/calibration_eval.py @@ -0,0 +1,17 @@ +"""Uncertainty calibration evaluation.""" + +from __future__ import annotations + +from app.evaluation.simulator_rollouts import run_rollouts + + +def calibration_eval() -> dict[str, float]: + rows = run_rollouts(episodes=8, difficulty="medium") + if not rows: + return {"ece_proxy": 1.0} + calibration_scores = [ + float((row.get("reward_breakdown", {}) or {}).get("uncertainty_calibration_score", 0.0)) + for row in rows + ] + mean_calibration = sum(calibration_scores) / max(1, len(calibration_scores)) + return {"ece_proxy": round(max(0.0, 1.0 - mean_calibration), 6)} diff --git a/app/evaluation/dosing_eval.py b/app/evaluation/dosing_eval.py new file mode 100644 index 0000000000000000000000000000000000000000..c8ea3cdca0639a7a446d8077b384c516624261de --- /dev/null +++ b/app/evaluation/dosing_eval.py @@ -0,0 +1,22 @@ +"""Dosing-specific evaluation.""" + +from __future__ import annotations + +from app.evaluation.simulator_rollouts import run_rollouts + + +def dosing_eval() -> dict[str, float]: + rows = run_rollouts(episodes=8, difficulty="hard", sub_environment="PRECISION_DOSING") + if not rows: + return {"target_attainment": 0.0, "toxicity_avoidance": 0.0} + + dosing_quality = [ + float((row.get("reward_breakdown", {}) or {}).get("dosing_quality_score", 0.0)) + for row in rows + ] + target_attainment = sum(dosing_quality) / max(1, len(dosing_quality)) + toxicity_avoidance = sum(1.0 for row in rows if bool(row.get("legal", False))) / len(rows) + return { + "target_attainment": round(target_attainment, 6), + "toxicity_avoidance": round(toxicity_avoidance, 6), + } diff --git a/app/evaluation/explainability_eval.py b/app/evaluation/explainability_eval.py new file mode 100644 index 0000000000000000000000000000000000000000..ee26367e6b68fd0807dc6c79c0376cff9fa2b6f8 --- /dev/null +++ b/app/evaluation/explainability_eval.py @@ -0,0 +1,16 @@ +"""Explanation grounding evaluation.""" + +from __future__ import annotations + +from app.evaluation.simulator_rollouts import run_rollouts + + +def explainability_eval() -> dict[str, float]: + rows = run_rollouts(episodes=8, difficulty="medium") + if not rows: + return {"grounding_rate": 0.0} + grounding_scores = [ + float((row.get("reward_breakdown", {}) or {}).get("explanation_grounding_score", 0.0)) + for row in rows + ] + return {"grounding_rate": round(sum(grounding_scores) / max(1, len(grounding_scores)), 6)} diff --git a/app/evaluation/offline_policy_eval.py b/app/evaluation/offline_policy_eval.py new file mode 100644 index 0000000000000000000000000000000000000000..1436aba707db3e694ae87a18cc5dc20df05035ca --- /dev/null +++ b/app/evaluation/offline_policy_eval.py @@ -0,0 +1,19 @@ +"""Offline policy evaluation.""" + +from __future__ import annotations + +from app.evaluation.simulator_rollouts import run_rollouts + + +def offline_policy_eval(episodes: int = 10) -> dict[str, float]: + rows = run_rollouts(episodes=episodes) + if not rows: + return {} + avg_reward = sum(float(r.get("reward", 0.0)) for r in rows) / len(rows) + legal_rate = sum(1.0 for r in rows if bool(r.get("legal", False))) / len(rows) + success_rate = sum(1.0 for r in rows if str(r.get("termination_reason", "")) == "safe_resolution") / len(rows) + return { + "avg_reward": round(avg_reward, 6), + "legal_rate": round(legal_rate, 6), + "success_rate": round(success_rate, 6), + } diff --git a/app/evaluation/plotting.py b/app/evaluation/plotting.py new file mode 100644 index 0000000000000000000000000000000000000000..41bc4d7a77d78ffaddb0d75077902bd843fd45d4 --- /dev/null +++ b/app/evaluation/plotting.py @@ -0,0 +1,88 @@ +"""Evaluation and training plot generation.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt + + +def _load_json(path: Path) -> dict: + if not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def _policy_stack_label(label: str) -> str: + labels = { + "bandit-only": "Bandits only", + "bandit_only": "Bandits only", + "llm-only": "Baseline LLM only", + "llm_only": "Baseline LLM only", + "llm+bandit": "LLM + Bandits", + "llm_bandit": "LLM + Bandits", + } + return labels.get(label, label) + + +def generate_training_plots(report_dir: Path, plot_dir: Path) -> list[str]: + plot_dir.mkdir(parents=True, exist_ok=True) + planner = _load_json(report_dir / "planner_grpo.json") + supervisor = _load_json(report_dir / "supervisor_grpo.json") + dosing = _load_json(report_dir / "dosing_grpo.json") + + series_names = ["avg_reward", "legality_rate", "success_rate", "avg_process_fidelity"] + labels = ["supervisor", "planner", "dosing"] + payloads = [supervisor, planner, dosing] + output_paths: list[str] = [] + + for metric in series_names: + values = [float(item.get(metric, 0.0)) for item in payloads] + fig, ax = plt.subplots(figsize=(6.2, 3.6)) + ax.bar(labels, values, color=["#2f855a", "#2b6cb0", "#d69e2e"]) + ax.set_ylim(0.0, 1.0) + ax.set_title(metric) + ax.grid(alpha=0.2, axis="y") + path = plot_dir / f"{metric}.png" + fig.tight_layout() + fig.savefig(path) + plt.close(fig) + output_paths.append(str(path)) + + baselines = _load_json(report_dir / "baselines.json") + ablations = baselines.get("policy_stack_ablations", {}) if isinstance(baselines, dict) else {} + if isinstance(ablations, dict) and ablations: + keys = list(ablations.keys()) + labels = [_policy_stack_label(label) for label in keys] + values = [float((ablations.get(label) or {}).get("avg_reward", 0.0)) for label in keys] + fig, ax = plt.subplots(figsize=(7.0, 3.8)) + ax.bar(labels, values, color=["#805ad5", "#2c5282", "#2f855a"][: len(labels)]) + ax.set_ylim(0.0, 1.0) + ax.set_title("Without Bandits vs With Bandits average reward") + ax.grid(alpha=0.2, axis="y") + path = plot_dir / "policy_stack_avg_reward.png" + fig.tight_layout() + fig.savefig(path) + plt.close(fig) + output_paths.append(str(path)) + + # Primary reward channel comparison from planner summary when present. + planner_channels = ((planner or {}).get("primary_reward_channels", {}) if isinstance(planner, dict) else {}) or {} + if planner_channels: + labels = list(planner_channels.keys()) + values = [float(planner_channels[key]) for key in labels] + fig, ax = plt.subplots(figsize=(7.0, 3.8)) + ax.bar(labels, values, color=["#276749", "#2b6cb0", "#dd6b20", "#4a5568"][: len(labels)]) + ax.set_ylim(0.0, 1.0) + ax.set_title("planner_primary_reward_channels") + ax.grid(alpha=0.2, axis="y") + path = plot_dir / "planner_primary_reward_channels.png" + fig.tight_layout() + fig.savefig(path) + plt.close(fig) + output_paths.append(str(path)) + return output_paths diff --git a/app/evaluation/process_eval.py b/app/evaluation/process_eval.py new file mode 100644 index 0000000000000000000000000000000000000000..6b741151254739c49bb45d9663ba5c77d6669de2 --- /dev/null +++ b/app/evaluation/process_eval.py @@ -0,0 +1,20 @@ +"""Process-fidelity evaluation.""" + +from __future__ import annotations + +from app.evaluation.simulator_rollouts import run_rollouts + + +def process_eval() -> dict[str, float]: + rows = run_rollouts(episodes=8, difficulty="medium") + if not rows: + return {"process_fidelity": 0.0} + fidelity_scores = [ + float((row.get("reward_breakdown", {}) or {}).get("process_fidelity_score", 0.0)) + for row in rows + ] + invalid_actions = [float(row.get("invalid_action_count", 0)) for row in rows] + return { + "process_fidelity": round(sum(fidelity_scores) / max(1, len(fidelity_scores)), 6), + "avg_invalid_actions": round(sum(invalid_actions) / max(1, len(invalid_actions)), 6), + } diff --git a/app/evaluation/robustness_eval.py b/app/evaluation/robustness_eval.py new file mode 100644 index 0000000000000000000000000000000000000000..7aab55ba8309001ae42e89910156661efe2a0c04 --- /dev/null +++ b/app/evaluation/robustness_eval.py @@ -0,0 +1,40 @@ +"""Robustness evaluation suite computed from perturbed rollouts.""" + +from __future__ import annotations + +from app.evaluation.simulator_rollouts import run_rollouts + + +def _safety_rate(rows: list[dict]) -> float: + if not rows: + return 0.0 + return round(sum(1.0 for row in rows if bool(row.get("legal", False))) / len(rows), 6) + + +def robustness_eval() -> dict[str, float]: + return { + "missing_labs_safety_rate": _safety_rate( + run_rollouts(episodes=6, difficulty="hard", perturbation="missing_labs") + ), + "noisy_dose_info_safety_rate": _safety_rate( + run_rollouts(episodes=6, difficulty="medium", perturbation="noisy_dose_info") + ), + "conflicting_meds_safety_rate": _safety_rate( + run_rollouts(episodes=6, difficulty="hard", perturbation="conflicting_meds") + ), + "alias_noise_safety_rate": _safety_rate( + run_rollouts(episodes=6, difficulty="medium", perturbation="alias_noise") + ), + "hidden_duplicate_detection_rate": _safety_rate( + run_rollouts(episodes=6, difficulty="hard", perturbation="hidden_duplicate") + ), + "wrong_candidate_id_resilience": _safety_rate( + run_rollouts(episodes=6, difficulty="medium", policy_stack="bandit-only") + ), + "stale_evidence_safety_rate": _safety_rate( + run_rollouts(episodes=6, difficulty="hard", perturbation="stale_evidence") + ), + "delayed_ade_manifestation_safety_rate": _safety_rate( + run_rollouts(episodes=6, difficulty="hard", perturbation="delayed_ade") + ), + } diff --git a/app/evaluation/run_all.py b/app/evaluation/run_all.py new file mode 100644 index 0000000000000000000000000000000000000000..2b09558bfe247fb6e80e1649f699d081aa650ff0 --- /dev/null +++ b/app/evaluation/run_all.py @@ -0,0 +1,18 @@ +"""Canonical evaluation runner for all evaluation bundles.""" + +from __future__ import annotations + +from pathlib import Path + +from app.evaluation.benchmark_report import build_benchmark_report +from app.evaluation.plotting import generate_training_plots + + +def run_all(root: Path) -> dict[str, object]: + reports_dir = root / "outputs" / "reports" + report = build_benchmark_report(reports_dir / "benchmark_report.txt") + plots = generate_training_plots(report_dir=reports_dir, plot_dir=root / "outputs" / "plots") + return {"report": report, "plots": plots} + + +__all__ = ["run_all"] diff --git a/app/evaluation/safety_eval.py b/app/evaluation/safety_eval.py new file mode 100644 index 0000000000000000000000000000000000000000..f5820055d77bd15ea5dcece2379b7c1f05468024 --- /dev/null +++ b/app/evaluation/safety_eval.py @@ -0,0 +1,16 @@ +"""Safety evaluation.""" + +from __future__ import annotations + +from app.evaluation.simulator_rollouts import run_rollouts + + +def safety_eval(episodes: int = 10) -> dict[str, float]: + rows = run_rollouts(episodes=episodes) + total = max(1, len(rows)) + severe_violations = sum(1.0 for r in rows if bool(r.get("severe_violation", False))) + illegal_steps = sum(1.0 for r in rows if not bool(r.get("legal", False))) + return { + "severe_violation_rate": round(severe_violations / total, 6), + "illegal_step_rate": round(illegal_steps / total, 6), + } diff --git a/app/evaluation/simulator_rollouts.py b/app/evaluation/simulator_rollouts.py new file mode 100644 index 0000000000000000000000000000000000000000..a3fbf24852331a887ab18567bf7a31be6b949d65 --- /dev/null +++ b/app/evaluation/simulator_rollouts.py @@ -0,0 +1,100 @@ +"""Simulator rollout helpers with scenario perturbations for evaluation.""" + +from __future__ import annotations + +import os +from typing import Any + +from app.agents.orchestrator import Orchestrator +from app.common.enums import DoseBucket +from app.env.env_core import PolyGuardEnv + + +def _apply_perturbation(env: PolyGuardEnv, perturbation: str | None) -> None: + if not perturbation: + return + + state = env.state + meds = state.patient.medications + + if perturbation == "missing_labs": + state.patient.labs.egfr = None + state.patient.labs.ast = None + state.patient.labs.alt = None + elif perturbation == "noisy_dose_info": + for idx, med in enumerate(meds): + if idx % 2 == 0: + med.dose_bucket = DoseBucket.HIGH if med.dose_bucket != DoseBucket.HIGH else DoseBucket.LOW + elif perturbation == "conflicting_meds" and meds: + meds.append(meds[0].model_copy()) + elif perturbation == "alias_noise" and meds: + meds[0].drug = f"{meds[0].drug}_alias" + elif perturbation == "hidden_duplicate" and meds: + meds.append(meds[0].model_copy(update={"drug": meds[0].drug})) + elif perturbation == "stale_evidence": + state.unresolved_conflicts.append("evidence_stale") + elif perturbation == "delayed_ade": + state.patient.latent_confounders["delayed_ade"] = 0.8 + + +def run_rollouts( + episodes: int = 5, + difficulty: str = "medium", + sub_environment: str | None = None, + perturbation: str | None = None, + seed_offset: int = 900, + policy_stack: str = "llm+bandit", +) -> list[dict[str, Any]]: + previous_policy = os.getenv("POLYGUARD_POLICY_STACK") + os.environ["POLYGUARD_POLICY_STACK"] = policy_stack + + env = PolyGuardEnv() + orchestrator = Orchestrator(env) + rows: list[dict[str, Any]] = [] + + for i in range(episodes): + env.reset(seed=seed_offset + i, difficulty=difficulty, sub_environment=sub_environment) + _apply_perturbation(env, perturbation=perturbation) + + done = False + while not done: + out = orchestrator.run_step() + done = bool(out.get("done")) + info = out.get("info", {}) if isinstance(out.get("info", {}), dict) else {} + critic = out.get("critic", {}) if isinstance(out.get("critic", {}), dict) else {} + reward_breakdown = info.get("reward_breakdown", {}) if isinstance(info.get("reward_breakdown", {}), dict) else {} + primary_channels = ( + info.get("primary_reward_channels", {}) + if isinstance(info.get("primary_reward_channels", {}), dict) + else {} + ) + final_action = out.get("final_action", {}) if isinstance(out.get("final_action", {}), dict) else {} + + rows.append( + { + "episode": i, + "step": int(env.state.step_count), + "reward": float(out.get("reward", 0.0)), + "done": done, + "legal": bool(critic.get("legal", False)), + "severe_violation": len(critic.get("violations", [])) > 1, + "abstain": str(final_action.get("action_type", "")).startswith("REQUEST_"), + "termination_reason": info.get("termination_reason"), + "step_timeout": bool(info.get("step_timeout")), + "failure_reasons": info.get("failure_reasons", []), + "invalid_action_count": int(info.get("invalid_action_count", 0)), + "reward_breakdown": reward_breakdown, + "primary_reward_channels": primary_channels, + "policy_stack": policy_stack, + "difficulty": difficulty, + "sub_environment": sub_environment, + "perturbation": perturbation, + } + ) + + if previous_policy is None: + os.environ.pop("POLYGUARD_POLICY_STACK", None) + else: + os.environ["POLYGUARD_POLICY_STACK"] = previous_policy + + return rows diff --git a/app/evaluation/subgroup_eval.py b/app/evaluation/subgroup_eval.py new file mode 100644 index 0000000000000000000000000000000000000000..fc9386068836c082f40bb1bd399380a333ce7567 --- /dev/null +++ b/app/evaluation/subgroup_eval.py @@ -0,0 +1,24 @@ +"""Subgroup evaluation.""" + +from __future__ import annotations + +from app.evaluation.simulator_rollouts import run_rollouts + + +def subgroup_eval() -> dict[str, dict[str, float]]: + def _summary(rows: list[dict]) -> dict[str, float]: + if not rows: + return {"avg_reward": 0.0, "legal_rate": 0.0} + return { + "avg_reward": round(sum(float(r.get("reward", 0.0)) for r in rows) / len(rows), 6), + "legal_rate": round(sum(1.0 for r in rows if bool(r.get("legal", False))) / len(rows), 6), + } + + renal_rows = run_rollouts(episodes=6, difficulty="hard", sub_environment="PRECISION_DOSING", perturbation="missing_labs") + hepatic_rows = run_rollouts(episodes=6, difficulty="hard", sub_environment="REGIMEN_RISK", perturbation="stale_evidence") + frail_rows = run_rollouts(episodes=6, difficulty="hard", sub_environment="LONGITUDINAL_DEPRESCRIBING") + return { + "renal_compromise": _summary(renal_rows), + "hepatic_compromise": _summary(hepatic_rows), + "frail": _summary(frail_rows), + } diff --git a/app/hf_space/Dockerfile b/app/hf_space/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..1a6f2f678d7a57cf1317b45b84155b7261c69763 --- /dev/null +++ b/app/hf_space/Dockerfile @@ -0,0 +1,21 @@ +FROM pytorch/pytorch:2.5.1-cuda12.4-cudnn9-runtime + +WORKDIR /app + +ENV PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + TOKENIZERS_PARALLELISM=false \ + POLYGUARD_OFFLINE_MODE=false \ + POLYGUARD_MODEL_ID=Qwen/Qwen2.5-0.5B-Instruct \ + POLYGUARD_AUTORUN=1 + +COPY . . + +RUN python -m pip install --upgrade pip setuptools wheel \ + && python -m pip install --no-cache-dir -r requirements.txt \ + && python -m pip install --no-cache-dir --no-build-isolation -e . + +EXPOSE 7860 + +CMD ["python", "-m", "app.hf_space.training_runner"] + diff --git a/app/hf_space/__init__.py b/app/hf_space/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ac59d595c804f1bc1101fa53bf1d2b25c824cfcf --- /dev/null +++ b/app/hf_space/__init__.py @@ -0,0 +1,2 @@ +"""Hugging Face Space helpers for remote PolyGuard training.""" + diff --git a/app/hf_space/evidence_runner.py b/app/hf_space/evidence_runner.py new file mode 100644 index 0000000000000000000000000000000000000000..cf35d06f88ce4e3b5cdfc271681f613f67d78fcf --- /dev/null +++ b/app/hf_space/evidence_runner.py @@ -0,0 +1,207 @@ +"""Gradio runner for PolyGuard submission evidence generation on Hugging Face Spaces.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import threading +import time +from typing import Any + +import gradio as gr +from huggingface_hub import HfApi + + +ROOT = Path(__file__).resolve().parents[2] +LOG_DIR = ROOT / "outputs" / "logs" +REPORT_DIR = ROOT / "outputs" / "reports" +STATUS_PATH = REPORT_DIR / "hf_evidence_status.json" +LOG_PATH = LOG_DIR / "hf_evidence.log" +LOCK = threading.Lock() + +DEFAULT_MODELS = "qwen-qwen2-5-0-5b-instruct,qwen-qwen2-5-1-5b-instruct" +DEFAULT_ARTIFACT_REPO = "TheJackBright/polyguard-openenv-training-full-artifacts" +DEFAULT_TRAINING_SPACE_URL = "https://thejackbright-polyguard-openenv-training-full.hf.space" + +STATUS: dict[str, Any] = { + "status": "idle", + "started_at": None, + "finished_at": None, + "commands": [], + "artifact_repo_id": os.getenv("POLYGUARD_ARTIFACT_REPO_ID", DEFAULT_ARTIFACT_REPO), + "models": os.getenv("POLYGUARD_EVIDENCE_MODELS", DEFAULT_MODELS), + "training_space_url": os.getenv("POLYGUARD_TRAINING_SPACE_URL", DEFAULT_TRAINING_SPACE_URL), + "mode": "evaluation_only_no_retraining", +} + + +def _write_status() -> None: + REPORT_DIR.mkdir(parents=True, exist_ok=True) + STATUS_PATH.write_text(json.dumps(STATUS, ensure_ascii=True, indent=2), encoding="utf-8") + + +def _append_log(message: str) -> None: + LOG_DIR.mkdir(parents=True, exist_ok=True) + with LOG_PATH.open("a", encoding="utf-8") as handle: + handle.write(message.rstrip() + "\n") + + +def _run_command(args: list[str], env: dict[str, str]) -> None: + started = time.time() + _append_log(f"$ {' '.join(args)}") + proc = subprocess.Popen( + args, + cwd=ROOT, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + assert proc.stdout is not None + for line in proc.stdout: + _append_log(line) + proc.wait() + record = { + "args": args, + "returncode": proc.returncode, + "elapsed_seconds": round(time.time() - started, 3), + } + with LOCK: + STATUS["commands"].append(record) + _write_status() + if proc.returncode != 0: + raise RuntimeError(f"command_failed:{args}:{proc.returncode}") + + +def _upload_evidence() -> None: + token = os.getenv("HF_TOKEN") + repo_id = os.getenv("POLYGUARD_ARTIFACT_REPO_ID", DEFAULT_ARTIFACT_REPO) + if not token: + _append_log("HF_TOKEN missing; evidence upload skipped") + return + + api = HfApi(token=token) + api.create_repo(repo_id=repo_id, repo_type="model", private=True, exist_ok=True) + upload_targets = [ + (ROOT / "outputs" / "reports" / "submission_evidence" / "qwen_0_5b_1_5b", "submission_evidence/qwen_0_5b_1_5b/reports"), + (ROOT / "outputs" / "plots" / "submission_evidence" / "qwen_0_5b_1_5b", "submission_evidence/qwen_0_5b_1_5b/charts"), + (ROOT / "docs" / "results" / "submission_evidence_qwen_0_5b_1_5b", "submission_evidence/qwen_0_5b_1_5b/docs"), + ] + for local_path, remote_path in upload_targets: + if local_path.exists(): + api.upload_folder( + repo_id=repo_id, + repo_type="model", + folder_path=str(local_path), + path_in_repo=remote_path, + commit_message=f"Upload PolyGuard submission evidence: {remote_path}", + ignore_patterns=[".DS_Store", "**/.DS_Store"], + ) + bundle = ROOT / "submission_bundle" / "qwen_0_5b_1_5b_evidence.zip" + if bundle.exists(): + api.upload_file( + repo_id=repo_id, + repo_type="model", + path_or_fileobj=str(bundle), + path_in_repo="submission_evidence/qwen_0_5b_1_5b/qwen_0_5b_1_5b_evidence.zip", + commit_message="Upload PolyGuard Qwen 0.5B/1.5B evidence bundle", + ) + + +def _run_evidence_job() -> dict[str, Any]: + env = os.environ.copy() + env.setdefault("TOKENIZERS_PARALLELISM", "false") + models = os.getenv("POLYGUARD_EVIDENCE_MODELS", DEFAULT_MODELS) + episodes = os.getenv("POLYGUARD_EVIDENCE_EPISODES", "8") + artifact_repo = os.getenv("POLYGUARD_ARTIFACT_REPO_ID", DEFAULT_ARTIFACT_REPO) + training_space_url = os.getenv("POLYGUARD_TRAINING_SPACE_URL", DEFAULT_TRAINING_SPACE_URL) + + with LOCK: + STATUS.update( + { + "status": "running", + "started_at": time.time(), + "finished_at": None, + "commands": [], + "artifact_repo_id": artifact_repo, + "models": models, + "training_space_url": training_space_url, + } + ) + _write_status() + LOG_PATH.unlink(missing_ok=True) + + try: + _run_command( + [ + "python", + "scripts/generate_submission_evidence.py", + "--models", + models, + "--artifact-repo-id", + artifact_repo, + "--training-space-url", + training_space_url, + "--episodes", + episodes, + ], + env, + ) + _upload_evidence() + manifest_path = ROOT / "outputs" / "reports" / "submission_evidence" / "qwen_0_5b_1_5b" / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) if manifest_path.exists() else {} + with LOCK: + STATUS.update( + { + "status": "ok", + "finished_at": time.time(), + "manifest_status": manifest.get("status"), + "bundle_zip": manifest.get("bundle_zip"), + "pending_artifacts": manifest.get("pending_artifacts", []), + } + ) + _write_status() + except Exception as exc: # noqa: BLE001 + _append_log(str(exc)) + with LOCK: + STATUS.update({"status": "failed", "finished_at": time.time(), "error": str(exc)}) + _write_status() + return STATUS + + +def run_evidence() -> tuple[dict[str, Any], str]: + with LOCK: + if STATUS.get("status") == "running": + return STATUS, LOG_PATH.read_text(encoding="utf-8")[-20000:] if LOG_PATH.exists() else "" + thread = threading.Thread(target=_run_evidence_job, daemon=True) + thread.start() + return STATUS, "evidence generation started" + + +def read_status() -> tuple[dict[str, Any], str]: + log = LOG_PATH.read_text(encoding="utf-8") if LOG_PATH.exists() else "" + return STATUS, log[-20000:] + + +def build_app() -> gr.Blocks: + with gr.Blocks(title="PolyGuard Evidence Runner") as demo: + gr.Markdown("# PolyGuard Evidence Runner") + gr.Markdown("Evaluation-only bundle generation for Qwen 0.5B and 1.5B. This Space does not retrain models.") + run_button = gr.Button("Run evidence job", variant="primary") + refresh_button = gr.Button("Refresh") + status_box = gr.JSON(label="Status", value=STATUS) + log_box = gr.Textbox(label="Logs", lines=26) + run_button.click(fn=run_evidence, outputs=[status_box, log_box]) + refresh_button.click(fn=read_status, outputs=[status_box, log_box]) + return demo + + +if os.getenv("POLYGUARD_EVIDENCE_AUTORUN", "1").lower() in {"1", "true", "yes", "on"}: + threading.Thread(target=_run_evidence_job, daemon=True).start() + +app = build_app() + +if __name__ == "__main__": + app.launch(server_name="0.0.0.0", server_port=7860) diff --git a/app/hf_space/training_runner.py b/app/hf_space/training_runner.py new file mode 100644 index 0000000000000000000000000000000000000000..74840435b413d52821f1ad9ade19cc823c630cd1 --- /dev/null +++ b/app/hf_space/training_runner.py @@ -0,0 +1,685 @@ +"""Gradio runner for the private Hugging Face training Space.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import shutil +import subprocess +import threading +import time +from typing import Any + +import gradio as gr +from huggingface_hub import HfApi +from huggingface_hub import snapshot_download + + +ROOT = Path(__file__).resolve().parents[2] +LOG_DIR = ROOT / "outputs" / "logs" +REPORT_DIR = ROOT / "outputs" / "reports" +STATUS_PATH = REPORT_DIR / "hf_training_status.json" +LOG_PATH = LOG_DIR / "hf_training.log" +LOCK = threading.Lock() + +STATUS: dict[str, Any] = { + "status": "idle", + "started_at": None, + "finished_at": None, + "commands": [], + "artifact_repo_id": os.getenv("POLYGUARD_ARTIFACT_REPO_ID", "TheJackBright/polyguard-openenv-training-full-artifacts"), + "training_mode": os.getenv("POLYGUARD_TRAINING_MODE", "full"), + "model_sweep": os.getenv( + "POLYGUARD_MODEL_SWEEP", + "Qwen/Qwen2.5-0.5B-Instruct,Qwen/Qwen2.5-1.5B-Instruct,Qwen/Qwen2.5-3B-Instruct", + ), +} + + +def _env_bool(name: str, default: bool = False) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.lower() in {"1", "true", "yes", "on"} + + +def _write_status() -> None: + REPORT_DIR.mkdir(parents=True, exist_ok=True) + STATUS_PATH.write_text(json.dumps(STATUS, ensure_ascii=True, indent=2), encoding="utf-8") + + +def _append_log(message: str) -> None: + LOG_DIR.mkdir(parents=True, exist_ok=True) + with LOG_PATH.open("a", encoding="utf-8") as handle: + handle.write(message.rstrip() + "\n") + + +def _upload_relpath(rel: str, *, commit_suffix: str = "") -> None: + if not _env_bool("POLYGUARD_INCREMENTAL_UPLOAD", True): + return + token = os.getenv("HF_TOKEN") + repo_id = os.getenv("POLYGUARD_ARTIFACT_REPO_ID", "TheJackBright/polyguard-openenv-training-full-artifacts") + if not token: + return + + path = ROOT / rel + if not path.exists(): + return + + try: + api = HfApi(token=token) + api.create_repo(repo_id=repo_id, repo_type="model", private=True, exist_ok=True) + if path.is_file(): + api.upload_file( + repo_id=repo_id, + repo_type="model", + path_or_fileobj=str(path), + path_in_repo=rel, + commit_message=f"Upload PolyGuard artifact: {commit_suffix or rel}", + ) + else: + api.upload_folder( + repo_id=repo_id, + repo_type="model", + folder_path=str(path), + path_in_repo=rel, + commit_message=f"Upload PolyGuard artifact folder: {commit_suffix or rel}", + ignore_patterns=[".DS_Store", "**/.DS_Store"], + ) + except Exception as exc: # noqa: BLE001 + _append_log(f"incremental_upload_skipped:{rel}:{exc}") + + +def _upload_status_and_log(context: str) -> None: + _upload_relpath("outputs/reports/hf_training_status.json", commit_suffix=f"status {context}") + _upload_relpath("outputs/logs/hf_training.log", commit_suffix=f"log {context}") + + +def _upload_run_snapshot(run_id: str, stage: str) -> None: + if not _env_bool("POLYGUARD_UPLOAD_AFTER_EACH_STAGE", True): + return + _upload_status_and_log(f"{run_id} {stage}") + _upload_relpath(f"outputs/reports/sweeps/{run_id}", commit_suffix=f"{run_id} reports after {stage}") + _upload_relpath(f"checkpoints/sweeps/{run_id}", commit_suffix=f"{run_id} checkpoints after {stage}") + + +def _run_command(args: list[str], env: dict[str, str]) -> None: + started = time.time() + last_incremental_upload = started + _append_log(f"$ {' '.join(args)}") + proc = subprocess.Popen( + args, + cwd=ROOT, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + assert proc.stdout is not None + saw_output = False + for line in proc.stdout: + saw_output = True + _append_log(line) + now = time.time() + if now - last_incremental_upload >= _env_int("POLYGUARD_LOG_UPLOAD_INTERVAL_SECONDS", 180): + _upload_status_and_log("running") + last_incremental_upload = now + proc.wait() + elapsed = round(time.time() - started, 3) + record = { + "args": args, + "returncode": proc.returncode, + "elapsed_seconds": elapsed, + } + with LOCK: + STATUS["commands"].append(record) + _write_status() + _upload_status_and_log("command_complete") + if proc.returncode != 0: + if not saw_output: + _append_log("") + _upload_status_and_log("command_failed") + raise RuntimeError(f"command_failed:{args}:{proc.returncode}") + + +def _env_int(name: str, default: int) -> int: + try: + return int(os.getenv(name, str(default))) + except ValueError: + return default + + +def _env_float(name: str, default: float) -> float: + try: + return float(os.getenv(name, str(default))) + except ValueError: + return default + + +def _csv_env(name: str, default: str) -> list[str]: + value = os.getenv(name, default) + return [item.strip() for item in value.split(",") if item.strip()] + + +def _indexed_int_env(name: str, index: int, default: int) -> int: + values = _csv_env(name, "") + if index >= len(values): + return default + try: + return int(values[index]) + except ValueError: + return default + + +def _indexed_float_env(name: str, index: int, default: float) -> float: + values = _csv_env(name, "") + if index >= len(values): + return default + try: + return float(values[index]) + except ValueError: + return default + + +def _safe_name(value: str) -> str: + return "".join(ch if ch.isalnum() else "-" for ch in value).strip("-").lower() + + +def _copy_file_if_exists(source: Path, target: Path) -> None: + if source.exists(): + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + + +def _copy_dir_if_exists(source: Path, target: Path) -> None: + if source.exists(): + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(source, target, dirs_exist_ok=True) + + +def _record_reused_artifact(name: str, path: Path) -> None: + with LOCK: + STATUS["commands"].append( + { + "args": ["reuse_artifact", name, str(path)], + "returncode": 0, + "elapsed_seconds": 0.0, + } + ) + _write_status() + + +def _restore_remote_artifacts() -> None: + if os.getenv("POLYGUARD_REUSE_REMOTE_GRPO", "false").lower() not in {"1", "true", "yes", "on"}: + return + token = os.getenv("HF_TOKEN") + repo_id = os.getenv("POLYGUARD_ARTIFACT_REPO_ID", "TheJackBright/polyguard-openenv-training-full-artifacts") + if not token: + return + try: + snapshot = Path( + snapshot_download( + repo_id=repo_id, + repo_type="model", + token=token, + allow_patterns=[ + "checkpoints/grpo_adapter/*", + "outputs/reports/grpo_trl_run.json", + ], + ) + ) + except Exception as exc: # noqa: BLE001 + _append_log(f"remote_artifact_restore_skipped:{exc}") + return + + for rel in ["checkpoints/grpo_adapter", "outputs/reports/grpo_trl_run.json"]: + source = snapshot / rel + target = ROOT / rel + if source.is_dir(): + shutil.copytree(source, target, dirs_exist_ok=True) + elif source.is_file(): + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + + +def _grpo_artifact_ready() -> bool: + report = REPORT_DIR / "grpo_trl_run.json" + adapter = ROOT / "checkpoints" / "grpo_adapter" + if not report.exists() or not adapter.exists(): + return False + if not (adapter / "adapter_config.json").exists() or not (adapter / "adapter_model.safetensors").exists(): + return False + try: + payload = json.loads(report.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return False + return payload.get("status") == "ok" and bool(payload.get("artifact_path")) + + +def _mirror_results() -> None: + docs_results = ROOT / "docs" / "results" + docs_results.mkdir(parents=True, exist_ok=True) + for source_dir in [REPORT_DIR, ROOT / "outputs" / "plots"]: + if not source_dir.exists(): + continue + for path in source_dir.rglob("*"): + if path.is_file() and path.suffix.lower() in {".json", ".txt", ".png"}: + target = docs_results / path.relative_to(source_dir) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, target) + + +def _upload_artifacts() -> None: + token = os.getenv("HF_TOKEN") + repo_id = os.getenv("POLYGUARD_ARTIFACT_REPO_ID", "TheJackBright/polyguard-openenv-training-full-artifacts") + if not token: + _append_log("HF_TOKEN missing; artifact upload skipped") + return + + api = HfApi(token=token) + api.create_repo(repo_id=repo_id, repo_type="model", private=True, exist_ok=True) + for rel in [ + "outputs/reports", + "outputs/plots", + "docs/results", + "checkpoints/sft_adapter", + "checkpoints/grpo_adapter", + "checkpoints/merged", + "checkpoints/sweeps", + ]: + path = ROOT / rel + if path.exists(): + api.upload_folder( + repo_id=repo_id, + repo_type="model", + folder_path=str(path), + path_in_repo=rel, + commit_message=f"Upload PolyGuard training artifacts: {rel}", + ) + + +def _improved() -> bool: + path = REPORT_DIR / "improvement_report.json" + if not path.exists(): + return False + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return False + return payload.get("improved") is True + + +def _promote_run_artifacts(run_id: str) -> None: + checkpoint_dir = ROOT / "checkpoints" / "sweeps" / run_id + report_dir = REPORT_DIR / "sweeps" / run_id + _copy_dir_if_exists(checkpoint_dir / "sft_adapter", ROOT / "checkpoints" / "sft_adapter") + _copy_dir_if_exists(checkpoint_dir / "grpo_adapter", ROOT / "checkpoints" / "grpo_adapter") + _copy_dir_if_exists(checkpoint_dir / "merged", ROOT / "checkpoints" / "merged") + _copy_file_if_exists(report_dir / "sft_trl_run.json", REPORT_DIR / "sft_trl_run.json") + _copy_file_if_exists(report_dir / "grpo_trl_run.json", REPORT_DIR / "grpo_trl_run.json") + _copy_file_if_exists(report_dir / "postsave_inference_grpo.json", REPORT_DIR / "postsave_inference.json") + _copy_file_if_exists(report_dir / "grpo_ablation_report.json", REPORT_DIR / "grpo_ablation_report.json") + + +def _promote_sft_run_artifacts(run_id: str) -> None: + checkpoint_dir = ROOT / "checkpoints" / "sweeps" / run_id + report_dir = REPORT_DIR / "sweeps" / run_id + _copy_dir_if_exists(checkpoint_dir / "sft_adapter", ROOT / "checkpoints" / "sft_adapter") + _copy_dir_if_exists(checkpoint_dir / "merged", ROOT / "checkpoints" / "merged") + _copy_file_if_exists(report_dir / "sft_trl_run.json", REPORT_DIR / "sft_trl_run.json") + _copy_file_if_exists(report_dir / "postsave_inference_sft.json", REPORT_DIR / "postsave_inference.json") + + +def _run_model_experiment( + model_id: str, + env: dict[str, str], + *, + model_index: int, + run_grpo: bool, +) -> str: + run_id = _safe_name(model_id) + checkpoint_dir = ROOT / "checkpoints" / "sweeps" / run_id + report_dir = REPORT_DIR / "sweeps" / run_id + checkpoint_dir.mkdir(parents=True, exist_ok=True) + report_dir.mkdir(parents=True, exist_ok=True) + + sft_epochs = _indexed_int_env("POLYGUARD_SFT_EPOCH_SWEEP", model_index, _env_int("POLYGUARD_SFT_EPOCHS", 2)) + sft_max_steps = _indexed_int_env( + "POLYGUARD_SFT_MAX_STEP_SWEEP", + model_index, + _env_int("POLYGUARD_SFT_MAX_STEPS", 0), + ) + sft_batch_size = _indexed_int_env( + "POLYGUARD_SFT_BATCH_SIZE_SWEEP", + model_index, + _env_int("POLYGUARD_SFT_BATCH_SIZE", 2), + ) + sft_learning_rate = _indexed_float_env( + "POLYGUARD_SFT_LEARNING_RATE_SWEEP", + model_index, + _env_float("POLYGUARD_SFT_LEARNING_RATE", 2e-5), + ) + grpo_epochs = _env_float("POLYGUARD_GRPO_EPOCHS", 1.0) + grpo_max_steps = _env_int("POLYGUARD_GRPO_MAX_STEPS", 0) + grpo_max_prompts = _env_int("POLYGUARD_GRPO_MAX_PROMPTS", 0) + + _append_log(f"model_experiment_start:{model_id}") + (report_dir / "run_metadata.json").write_text( + json.dumps( + { + "training_mode": "full" if run_grpo else "sft-baseline", + "model_id": model_id, + "model_index": model_index, + "sft_epochs": sft_epochs, + "sft_max_steps": sft_max_steps, + "sft_batch_size": sft_batch_size, + "sft_learning_rate": sft_learning_rate, + }, + ensure_ascii=True, + indent=2, + ), + encoding="utf-8", + ) + _run_command( + [ + "python", + "scripts/train_sft_trl.py", + "--model-id", + model_id, + "--dataset-path", + "data/processed/training_corpus_sft.json", + "--output-dir", + f"checkpoints/sweeps/{run_id}", + "--report-path", + f"outputs/reports/sweeps/{run_id}/sft_trl_run.json", + "--epochs", + str(sft_epochs), + "--max-steps", + str(sft_max_steps), + "--batch-size", + str(sft_batch_size), + "--max-seq-len", + str(_env_int("POLYGUARD_SFT_MAX_SEQ_LEN", 512)), + "--learning-rate", + str(sft_learning_rate), + "--use-unsloth", + ], + env, + ) + _copy_file_if_exists(checkpoint_dir / "sft_history.json", report_dir / "sft_history.json") + _upload_run_snapshot(run_id, "sft_training") + + if run_grpo: + _run_command( + [ + "python", + "scripts/train_grpo_trl.py", + "--model-id", + model_id, + "--prompts-path", + "data/processed/training_corpus_grpo_prompts.jsonl", + "--output-dir", + f"checkpoints/sweeps/{run_id}", + "--report-path", + f"outputs/reports/sweeps/{run_id}/grpo_trl_run.json", + "--max-prompts", + str(grpo_max_prompts), + "--max-steps", + str(grpo_max_steps), + "--epochs", + str(grpo_epochs), + "--batch-size", + str(_env_int("POLYGUARD_GRPO_BATCH_SIZE", 2)), + "--grad-accum", + str(_env_int("POLYGUARD_GRPO_GRAD_ACCUM", 1)), + "--num-generations", + str(_env_int("POLYGUARD_GRPO_NUM_GENERATIONS", 2)), + "--max-prompt-length", + str(_env_int("POLYGUARD_GRPO_MAX_PROMPT_LENGTH", 384)), + "--max-completion-length", + str(_env_int("POLYGUARD_GRPO_MAX_COMPLETION_LENGTH", 64)), + "--learning-rate", + str(_env_float("POLYGUARD_GRPO_LEARNING_RATE", 1e-6)), + "--use-unsloth", + ], + env, + ) + _copy_file_if_exists(checkpoint_dir / "grpo_history.json", report_dir / "grpo_history.json") + _copy_file_if_exists(checkpoint_dir / "grpo_reward_components.jsonl", report_dir / "grpo_reward_components.jsonl") + _upload_run_snapshot(run_id, "grpo_training") + + _run_command( + [ + "python", + "scripts/merge_adapters_safe.py", + "--adapter-dir", + f"checkpoints/sweeps/{run_id}/sft_adapter", + "--output-dir", + f"checkpoints/sweeps/{run_id}/merged", + ], + env, + ) + _upload_run_snapshot(run_id, "sft_merge") + _run_command( + [ + "python", + "scripts/test_inference_postsave.py", + "--samples", + str(_env_int("POLYGUARD_INFERENCE_SAMPLES", 5)), + "--base-model", + model_id, + "--merged-model", + f"checkpoints/sweeps/{run_id}/merged", + "--adapter-dir", + f"checkpoints/sweeps/{run_id}/sft_adapter", + "--output", + f"outputs/reports/sweeps/{run_id}/postsave_inference_sft.json", + ], + env, + ) + _upload_run_snapshot(run_id, "sft_postsave_inference") + if run_grpo: + _run_command( + [ + "python", + "scripts/test_inference_postsave.py", + "--samples", + str(_env_int("POLYGUARD_INFERENCE_SAMPLES", 5)), + "--base-model", + model_id, + "--merged-model", + f"checkpoints/sweeps/{run_id}/missing_merged_grpo", + "--adapter-dir", + f"checkpoints/sweeps/{run_id}/grpo_adapter", + "--output", + f"outputs/reports/sweeps/{run_id}/postsave_inference_grpo.json", + ], + env, + ) + _upload_run_snapshot(run_id, "grpo_postsave_inference") + _run_command( + [ + "python", + "scripts/evaluate_policy_ablations.py", + "--episodes", + str(_env_int("POLYGUARD_ABLATION_EPISODES", 8)), + "--checkpoint-dir", + f"checkpoints/sweeps/{run_id}", + "--output", + f"outputs/reports/sweeps/{run_id}/grpo_ablation_report.json", + ], + env, + ) + _promote_run_artifacts(run_id) + _upload_run_snapshot(run_id, "policy_ablation") + for rel in [ + "checkpoints/sft_adapter", + "checkpoints/grpo_adapter", + "checkpoints/merged", + "outputs/reports/sft_trl_run.json", + "outputs/reports/grpo_trl_run.json", + "outputs/reports/postsave_inference.json", + "outputs/reports/grpo_ablation_report.json", + ]: + _upload_relpath(rel, commit_suffix=f"promoted {run_id}") + else: + _promote_sft_run_artifacts(run_id) + _upload_run_snapshot(run_id, "sft_promoted") + for rel in [ + "checkpoints/sft_adapter", + "checkpoints/merged", + "outputs/reports/sft_trl_run.json", + "outputs/reports/postsave_inference.json", + ]: + _upload_relpath(rel, commit_suffix=f"promoted {run_id}") + _append_log(f"model_experiment_done:{model_id}") + _upload_run_snapshot(run_id, "complete") + return run_id + + +def _train() -> dict[str, Any]: + training_mode = os.getenv("POLYGUARD_TRAINING_MODE", "full").strip().lower() + run_grpo = training_mode not in {"sft", "sft-only", "sft-baseline", "sft_baseline"} + model_sweep = _csv_env( + "POLYGUARD_MODEL_SWEEP", + "Qwen/Qwen2.5-0.5B-Instruct,Qwen/Qwen2.5-1.5B-Instruct,Qwen/Qwen2.5-3B-Instruct", + ) + env = os.environ.copy() + env.setdefault("POLYGUARD_OFFLINE_MODE", "false") + env.pop("HF_HUB_ENABLE_HF_TRANSFER", None) + env.setdefault("TOKENIZERS_PARALLELISM", "false") + + setup_commands = [ + ["python", "scripts/bootstrap_data.py"], + ["python", "scripts/build_training_corpus.py", "--profile", "massive", "--with-local", "--with-synthetic", "--with-hf"], + ] + + with LOCK: + STATUS.update( + { + "status": "running", + "started_at": time.time(), + "finished_at": None, + "commands": [], + "model_sweep": model_sweep, + "training_mode": "full" if run_grpo else "sft-baseline", + } + ) + _write_status() + LOG_PATH.unlink(missing_ok=True) + _restore_remote_artifacts() + + try: + for command in setup_commands: + _run_command(command, env) + completed_run_ids: list[str] = [] + for model_index, model_id in enumerate(model_sweep): + run_id = _safe_name(model_id) + try: + completed_run_ids.append( + _run_model_experiment( + model_id=model_id, + env=env, + model_index=model_index, + run_grpo=run_grpo, + ) + ) + except Exception as exc: # noqa: BLE001 + error_dir = REPORT_DIR / "sweeps" / run_id + error_dir.mkdir(parents=True, exist_ok=True) + (error_dir / "error.json").write_text( + json.dumps( + {"status": "failed", "model_id": model_id, "error": str(exc)}, + ensure_ascii=True, + indent=2, + ), + encoding="utf-8", + ) + _append_log(f"model_experiment_failed:{model_id}:{exc}") + _upload_run_snapshot(run_id, "failed") + if not completed_run_ids: + raise RuntimeError("all_model_experiments_failed") + if run_grpo and _grpo_artifact_ready(): + _append_log("top_level_grpo_adapter_ready") + _record_reused_artifact("grpo_adapter", ROOT / "checkpoints" / "grpo_adapter") + eval_commands = [ + ["python", "scripts/evaluate_baselines.py"], + ["python", "scripts/evaluate_all.py"], + [ + "python", + "scripts/evaluate_compare_runs.py", + "--baseline", + "outputs/reports/baselines.json", + "--candidate", + "outputs/reports/benchmark_report.json", + "--output", + "outputs/reports/improvement_report.json", + ], + ["python", "scripts/benchmark_inference.py"], + ] + if run_grpo: + eval_commands.append(["python", "scripts/run_robustness_suite.py"]) + eval_commands.append(["python", "scripts/generate_hf_training_report.py", "--mode", "full" if run_grpo else "sft-baseline"]) + for command in eval_commands: + _run_command(command, env) + anti_hacking = {} + anti_path = REPORT_DIR / "anti_hacking_overfit_report.json" + if anti_path.exists(): + anti_hacking = json.loads(anti_path.read_text(encoding="utf-8")) + with LOCK: + STATUS.update( + { + "status": "ok", + "finished_at": time.time(), + "improved": _improved(), + "anti_hacking_passed": anti_hacking.get("passed"), + "completed_run_ids": completed_run_ids, + } + ) + _write_status() + _mirror_results() + _upload_artifacts() + except Exception as exc: # noqa: BLE001 + _append_log(str(exc)) + with LOCK: + STATUS.update({"status": "failed", "finished_at": time.time(), "error": str(exc)}) + _write_status() + _mirror_results() + _upload_artifacts() + return STATUS + + +def run_training() -> tuple[dict[str, Any], str]: + with LOCK: + if STATUS.get("status") == "running": + return STATUS, LOG_PATH.read_text(encoding="utf-8") if LOG_PATH.exists() else "" + thread = threading.Thread(target=_train, daemon=True) + thread.start() + return STATUS, "training started" + + +def read_status() -> tuple[dict[str, Any], str]: + log = LOG_PATH.read_text(encoding="utf-8") if LOG_PATH.exists() else "" + return STATUS, log[-20000:] + + +def build_app() -> gr.Blocks: + with gr.Blocks(title="PolyGuard HF Training") as demo: + gr.Markdown("# PolyGuard HF Training") + run_button = gr.Button("Run training", variant="primary") + refresh_button = gr.Button("Refresh") + status_box = gr.JSON(label="Status", value=STATUS) + log_box = gr.Textbox(label="Logs", lines=26) + run_button.click(fn=run_training, outputs=[status_box, log_box]) + refresh_button.click(fn=read_status, outputs=[status_box, log_box]) + return demo + + +if os.getenv("POLYGUARD_AUTORUN", "1").lower() in {"1", "true", "yes", "on"}: + threading.Thread(target=_train, daemon=True).start() + +app = build_app() + +if __name__ == "__main__": + app.launch(server_name="0.0.0.0", server_port=7860) diff --git a/app/knowledge/__init__.py b/app/knowledge/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..452808e61217ece1027ac601fe72d55eef22a04d --- /dev/null +++ b/app/knowledge/__init__.py @@ -0,0 +1,5 @@ +"""Knowledge subsystem.""" + +from app.knowledge.evidence_retriever import retrieve_evidence + +__all__ = ["retrieve_evidence"] diff --git a/app/knowledge/burden_scores.py b/app/knowledge/burden_scores.py new file mode 100644 index 0000000000000000000000000000000000000000..c6efbf0ab49b3a07e23deed30356392cc6d90381 --- /dev/null +++ b/app/knowledge/burden_scores.py @@ -0,0 +1,7 @@ +"""Medication burden helpers.""" + +from __future__ import annotations + + +def compute_burden_score(med_count: int, high_risk_count: int = 0) -> float: + return max(0.0, min(1.0, med_count / 12.0 + high_risk_count * 0.04)) diff --git a/app/knowledge/ddi_knowledge.py b/app/knowledge/ddi_knowledge.py new file mode 100644 index 0000000000000000000000000000000000000000..24f015eca02f91f05adc8d97ef960db44fdf7331 --- /dev/null +++ b/app/knowledge/ddi_knowledge.py @@ -0,0 +1,23 @@ +"""DDI safety rules.""" + +from __future__ import annotations + +_CONTRAINDICATED: set[tuple[str, str]] = { + ("warfarin_like", "nsaid_like"), + ("benzodiazepine_like", "opioid_like"), +} + + +def is_contraindicated_pair(drug_a: str, drug_b: str) -> bool: + key = tuple(sorted((drug_a, drug_b))) + canon = {tuple(sorted(item)) for item in _CONTRAINDICATED} + return key in canon + + +def top_risky_pairs(drugs: list[str]) -> list[tuple[str, str]]: + hits: list[tuple[str, str]] = [] + for i, a in enumerate(drugs): + for b in drugs[i + 1 :]: + if is_contraindicated_pair(a, b): + hits.append((a, b)) + return hits diff --git a/app/knowledge/drug_catalog.py b/app/knowledge/drug_catalog.py new file mode 100644 index 0000000000000000000000000000000000000000..9a9fc0000346f308df35350471229e03f2400bbc --- /dev/null +++ b/app/knowledge/drug_catalog.py @@ -0,0 +1,20 @@ +"""Simple drug catalog.""" + +from __future__ import annotations + +DRUG_CLASSES: dict[str, str] = { + "warfarin_like": "anticoagulant", + "benzodiazepine_like": "sedative", + "metformin_like": "glucose_lowering", + "statin_like": "lipid_lowering", + "ace_inhibitor_like": "antihypertensive", + "nsaid_like": "analgesic", + "opioid_like": "analgesic", + "ssri_like": "antidepressant", + "ppi_like": "gastro", + "beta_blocker_like": "antihypertensive", +} + + +def canonicalize_drug_name(name: str) -> str: + return name.strip().lower().replace(" ", "_") diff --git a/app/knowledge/duplicate_therapy_rules.py b/app/knowledge/duplicate_therapy_rules.py new file mode 100644 index 0000000000000000000000000000000000000000..203dfafa25b47c245fd4ee40f0f24980f7a372ca --- /dev/null +++ b/app/knowledge/duplicate_therapy_rules.py @@ -0,0 +1,20 @@ +"""Duplicate therapy checks.""" + +from __future__ import annotations + +from app.common.types import Medication +from app.knowledge.drug_catalog import DRUG_CLASSES + + +def has_duplicate_therapy( + meds: list[Medication], + target_drug: str | None, + replacement_drug: str | None, +) -> bool: + classes = [DRUG_CLASSES.get(m.drug) for m in meds if DRUG_CLASSES.get(m.drug)] + if replacement_drug and DRUG_CLASSES.get(replacement_drug): + classes.append(DRUG_CLASSES[replacement_drug]) + if target_drug and replacement_drug: + # replacement within class is expected; duplicate only when 3+ active in same class. + return any(classes.count(c) >= 3 for c in set(classes)) + return any(classes.count(c) >= 3 for c in set(classes)) diff --git a/app/knowledge/evidence_retriever.py b/app/knowledge/evidence_retriever.py new file mode 100644 index 0000000000000000000000000000000000000000..401a8f5c654bb7221566ee184a7696081adc0aa2 --- /dev/null +++ b/app/knowledge/evidence_retriever.py @@ -0,0 +1,9 @@ +"""Evidence retrieval service.""" + +from __future__ import annotations + +from app.knowledge.literature_index import search_literature + + +def retrieve_evidence(query: str, top_k: int = 3) -> list[dict[str, str]]: + return search_literature(query=query, top_k=top_k) diff --git a/app/knowledge/guideline_fragments.py b/app/knowledge/guideline_fragments.py new file mode 100644 index 0000000000000000000000000000000000000000..865ce692df51d7c3587d48e634a97770c20c30fa --- /dev/null +++ b/app/knowledge/guideline_fragments.py @@ -0,0 +1,16 @@ +"""Local guideline snippets.""" + +from __future__ import annotations + +GUIDELINE_SNIPPETS: list[dict[str, str]] = [ + { + "id": "gl_001", + "topic": "benzodiazepine_deprescribing", + "text": "Avoid abrupt discontinuation of chronic sedatives; prefer taper and monitoring for withdrawal symptoms.", + }, + { + "id": "gl_002", + "topic": "anticoagulant_bleeding_risk", + "text": "Avoid combining anticoagulant-like therapies with chronic NSAID-like exposure in high bleeding-risk profiles.", + }, +] diff --git a/app/knowledge/hepatic_rules.py b/app/knowledge/hepatic_rules.py new file mode 100644 index 0000000000000000000000000000000000000000..fafdabbafeab275d430a9dcd2e5a585909fb42eb --- /dev/null +++ b/app/knowledge/hepatic_rules.py @@ -0,0 +1,11 @@ +"""Hepatic adjustment rules.""" + +from __future__ import annotations + +_HEPATIC_SENSITIVE = {"benzodiazepine_like", "opioid_like"} + + +def is_hepatic_unsafe(drug: str, ast: float | None, alt: float | None) -> bool: + if ast is None or alt is None: + return False + return drug in _HEPATIC_SENSITIVE and (ast > 100 or alt > 100) diff --git a/app/knowledge/literature_index.py b/app/knowledge/literature_index.py new file mode 100644 index 0000000000000000000000000000000000000000..396dfc3b3b706c971f4d71f114bad17502b8ed89 --- /dev/null +++ b/app/knowledge/literature_index.py @@ -0,0 +1,16 @@ +"""In-memory literature index.""" + +from __future__ import annotations + +from app.knowledge.guideline_fragments import GUIDELINE_SNIPPETS + + +def search_literature(query: str, top_k: int = 5) -> list[dict[str, str]]: + q = query.lower() + scored: list[tuple[int, dict[str, str]]] = [] + for snippet in GUIDELINE_SNIPPETS: + hay = f"{snippet['topic']} {snippet['text']}".lower() + score = sum(1 for token in q.split() if token in hay) + scored.append((score, snippet)) + scored.sort(key=lambda x: x[0], reverse=True) + return [item for score, item in scored[:top_k] if score > 0] or GUIDELINE_SNIPPETS[: min(top_k, len(GUIDELINE_SNIPPETS))] diff --git a/app/knowledge/renal_rules.py b/app/knowledge/renal_rules.py new file mode 100644 index 0000000000000000000000000000000000000000..d44d7ca29181dff84a53f4851965f8576da3d078 --- /dev/null +++ b/app/knowledge/renal_rules.py @@ -0,0 +1,11 @@ +"""Renal adjustment rules.""" + +from __future__ import annotations + +_RENAL_SENSITIVE = {"metformin_like", "nsaid_like"} + + +def is_renal_unsafe(drug: str, egfr: float | None) -> bool: + if egfr is None: + return False + return drug in _RENAL_SENSITIVE and egfr < 30.0 diff --git a/app/knowledge/side_effect_ontology.py b/app/knowledge/side_effect_ontology.py new file mode 100644 index 0000000000000000000000000000000000000000..ccd8aaab27b494555eb4ad234e4f5f9bdcaeb23c --- /dev/null +++ b/app/knowledge/side_effect_ontology.py @@ -0,0 +1,10 @@ +"""Side effect tags.""" + +from __future__ import annotations + +SIDE_EFFECT_TAGS: dict[str, list[str]] = { + "benzodiazepine_like": ["sedation", "falls"], + "opioid_like": ["respiratory_depression", "sedation"], + "warfarin_like": ["bleeding"], + "nsaid_like": ["bleeding", "renal_injury"], +} diff --git a/app/knowledge/substitution_rules.py b/app/knowledge/substitution_rules.py new file mode 100644 index 0000000000000000000000000000000000000000..cc5a406e7fd5d2f08ff277598817835210e96e08 --- /dev/null +++ b/app/knowledge/substitution_rules.py @@ -0,0 +1,13 @@ +"""Substitution maps.""" + +from __future__ import annotations + +SUBSTITUTIONS: dict[str, list[str]] = { + "nsaid_like": ["acetaminophen_like", "topical_nsaid_like"], + "benzodiazepine_like": ["non_benzo_sleep_support"], + "opioid_like": ["non_opioid_analgesic"], +} + + +def get_substitutions(drug: str) -> list[str]: + return SUBSTITUTIONS.get(drug, []) diff --git a/app/knowledge/taper_rules.py b/app/knowledge/taper_rules.py new file mode 100644 index 0000000000000000000000000000000000000000..bb12f97a93f9642f659c80ddf4129cc1cb17c1c9 --- /dev/null +++ b/app/knowledge/taper_rules.py @@ -0,0 +1,9 @@ +"""Taper rules.""" + +from __future__ import annotations + +_REQUIRES_TAPER = {"benzodiazepine_like", "opioid_like"} + + +def requires_taper(drug: str) -> bool: + return drug in _REQUIRES_TAPER diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8db41434292440ccbd710c36e7e2821f9508cc47 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1 @@ +"""Model package exports.""" diff --git a/app/models/baselines/__init__.py b/app/models/baselines/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..22a1a19e5459b6b31da40774a1686f119382bce4 --- /dev/null +++ b/app/models/baselines/__init__.py @@ -0,0 +1,18 @@ +"""Baseline policies.""" + +from app.models.baselines.no_change import choose_no_change +from app.models.baselines.rules_only import choose_rules_only +from app.models.baselines.greedy_regimen import choose_greedy +from app.models.baselines.imitation import choose_imitation +from app.models.baselines.contextual_bandit import choose_contextual_bandit, choose_contextual_bandit_topk +from app.models.baselines.beam_search_planner import choose_beam_search + +__all__ = [ + "choose_no_change", + "choose_rules_only", + "choose_greedy", + "choose_imitation", + "choose_contextual_bandit", + "choose_contextual_bandit_topk", + "choose_beam_search", +] diff --git a/app/models/baselines/beam_search_planner.py b/app/models/baselines/beam_search_planner.py new file mode 100644 index 0000000000000000000000000000000000000000..52ede0606fbb9412fabb0fcd0f620dd57af1cc78 --- /dev/null +++ b/app/models/baselines/beam_search_planner.py @@ -0,0 +1,25 @@ +"""Constrained beam search baseline.""" + +from __future__ import annotations + +from app.common.types import CandidateAction, PolyGuardAction + + +def choose_beam_search(candidates: list[CandidateAction], beam_width: int = 3) -> PolyGuardAction: + legal = [c for c in candidates if c.legality_precheck] + if not legal: + legal = candidates + topk = sorted(legal, key=lambda c: (c.estimated_safety_delta + c.burden_delta), reverse=True)[:beam_width] + chosen = topk[0] + return PolyGuardAction( + mode=chosen.mode, + action_type=chosen.action_type, + target_drug=chosen.target_drug, + replacement_drug=chosen.replacement_drug, + dose_bucket=chosen.dose_bucket, + taper_days=chosen.taper_days, + monitoring_plan=chosen.monitoring_plan, + candidate_id=chosen.candidate_id, + confidence=0.74, + rationale_brief=f"Beam-search({beam_width}) top candidate.", + ) diff --git a/app/models/baselines/contextual_bandit.py b/app/models/baselines/contextual_bandit.py new file mode 100644 index 0000000000000000000000000000000000000000..7ae7bfbd45fe2d7c83a56ee14ea476a8f72eaa2b --- /dev/null +++ b/app/models/baselines/contextual_bandit.py @@ -0,0 +1,49 @@ +"""Contextual bandit baseline and top-k proposer.""" + +from __future__ import annotations + +import random + +from app.common.types import CandidateAction, PolyGuardAction +from app.models.baselines.contextual_bandit_policy import BanditProposal, ContextualBanditPolicy +from app.models.baselines.rules_only import choose_rules_only + + +def choose_contextual_bandit(candidates: list[CandidateAction], epsilon: float = 0.2) -> PolyGuardAction: + proposals = choose_contextual_bandit_topk(candidates=candidates, top_k=1, epsilon=epsilon) + if not proposals: + return choose_rules_only(candidates) + candidate_map = {item.candidate_id: item for item in candidates} + top = candidate_map.get(proposals[0].candidate_id) + if top is None: + return choose_rules_only(candidates) + return PolyGuardAction( + mode=top.mode, + action_type=top.action_type, + target_drug=top.target_drug, + replacement_drug=top.replacement_drug, + dose_bucket=top.dose_bucket, + taper_days=top.taper_days, + monitoring_plan=top.monitoring_plan, + candidate_id=top.candidate_id, + confidence=0.68, + rationale_brief="Contextual bandit selected candidate.", + ) + + +def choose_contextual_bandit_topk( + candidates: list[CandidateAction], + top_k: int = 3, + epsilon: float = 0.2, + algorithm: str = "linucb", +) -> list[BanditProposal]: + if not candidates: + return [] + if algorithm not in {"linucb", "thompson"}: + algorithm = "linucb" + policy = ContextualBanditPolicy( + algorithm=algorithm, # type: ignore[arg-type] + epsilon=max(0.0, min(1.0, epsilon)), + seed=random.randint(1, 10_000), + ) + return policy.propose(candidates=candidates, top_k=top_k) diff --git a/app/models/baselines/contextual_bandit_policy.py b/app/models/baselines/contextual_bandit_policy.py new file mode 100644 index 0000000000000000000000000000000000000000..ca248dc32af830136a320b31b9bba26d0d36f569 --- /dev/null +++ b/app/models/baselines/contextual_bandit_policy.py @@ -0,0 +1,172 @@ +"""Contextual bandit co-policy module. + +Supports LinUCB and Thompson sampling with a shared feature space over +candidate actions. This policy is designed to propose top-k candidates +for the LLM/planner to finalize. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import math +import random +from typing import Literal + +from app.common.types import CandidateAction + +Algorithm = Literal["linucb", "thompson"] + + +@dataclass(slots=True) +class BanditProposal: + candidate_id: str + score: float + exploration_bonus: float + algorithm: Algorithm + + +class ContextualBanditPolicy: + def __init__( + self, + algorithm: Algorithm = "linucb", + alpha: float = 0.55, + epsilon: float = 0.1, + seed: int = 42, + ) -> None: + self.algorithm: Algorithm = algorithm + self.alpha = alpha + self.epsilon = max(0.0, min(1.0, epsilon)) + self.rng = random.Random(seed) + self._dimension = 8 + self._A: dict[str, list[list[float]]] = {} + self._b: dict[str, list[float]] = {} + + def _arm_key(self, candidate: CandidateAction) -> str: + return f"{candidate.mode.value}:{candidate.action_type.value}" + + def _features(self, candidate: CandidateAction) -> list[float]: + return [ + 1.0, + 1.0 if candidate.legality_precheck else 0.0, + float(candidate.estimated_safety_delta), + float(candidate.burden_delta), + float(candidate.disease_stability_estimate), + float(1.0 - candidate.uncertainty_score), + 1.0 if candidate.mode.value == "DOSE_OPT" else 0.0, + 1.0 if candidate.mode.value == "REVIEW" else 0.0, + ] + + def _ensure_arm(self, arm: str) -> None: + if arm in self._A: + return + ident = [[0.0] * self._dimension for _ in range(self._dimension)] + for i in range(self._dimension): + ident[i][i] = 1.0 + self._A[arm] = ident + self._b[arm] = [0.0] * self._dimension + + @staticmethod + def _dot(a: list[float], b: list[float]) -> float: + return sum(x * y for x, y in zip(a, b)) + + @staticmethod + def _mat_vec_mul(m: list[list[float]], v: list[float]) -> list[float]: + return [sum(mr[j] * v[j] for j in range(len(v))) for mr in m] + + @staticmethod + def _invert(matrix: list[list[float]]) -> list[list[float]]: + # Small-matrix Gauss-Jordan inversion for deterministic no-deps runtime. + n = len(matrix) + a = [[float(matrix[i][j]) for j in range(n)] for i in range(n)] + inv = [[0.0] * n for _ in range(n)] + for i in range(n): + inv[i][i] = 1.0 + + for i in range(n): + pivot = a[i][i] + if abs(pivot) < 1e-12: + for k in range(i + 1, n): + if abs(a[k][i]) > 1e-12: + a[i], a[k] = a[k], a[i] + inv[i], inv[k] = inv[k], inv[i] + pivot = a[i][i] + break + if abs(pivot) < 1e-12: + continue + scale = 1.0 / pivot + for j in range(n): + a[i][j] *= scale + inv[i][j] *= scale + for k in range(n): + if k == i: + continue + factor = a[k][i] + if abs(factor) < 1e-18: + continue + for j in range(n): + a[k][j] -= factor * a[i][j] + inv[k][j] -= factor * inv[i][j] + return inv + + def _score_linucb(self, arm: str, x: list[float]) -> tuple[float, float]: + self._ensure_arm(arm) + a_inv = self._invert(self._A[arm]) + theta = self._mat_vec_mul(a_inv, self._b[arm]) + exploitation = self._dot(theta, x) + ax = self._mat_vec_mul(a_inv, x) + exploration = self.alpha * math.sqrt(max(0.0, self._dot(x, ax))) + return exploitation + exploration, exploration + + def _score_thompson(self, arm: str, x: list[float]) -> tuple[float, float]: + self._ensure_arm(arm) + a_inv = self._invert(self._A[arm]) + theta = self._mat_vec_mul(a_inv, self._b[arm]) + noise = self.rng.gauss(0.0, self.alpha) + sampled = self._dot(theta, x) + noise + return sampled, abs(noise) + + def propose(self, candidates: list[CandidateAction], top_k: int = 3) -> list[BanditProposal]: + legal = [c for c in candidates if c.legality_precheck] + pool = legal or candidates + if not pool: + return [] + + scored: list[BanditProposal] = [] + for cand in pool: + arm = self._arm_key(cand) + x = self._features(cand) + if self.algorithm == "thompson": + score, bonus = self._score_thompson(arm, x) + else: + score, bonus = self._score_linucb(arm, x) + scored.append( + BanditProposal( + candidate_id=cand.candidate_id, + score=float(score), + exploration_bonus=float(bonus), + algorithm=self.algorithm, + ) + ) + + scored.sort(key=lambda item: item.score, reverse=True) + + # Keep explicit exploration path to avoid policy collapse. + if len(scored) > 1 and self.rng.random() < self.epsilon: + idx = self.rng.randint(1, len(scored) - 1) + scored[0], scored[idx] = scored[idx], scored[0] + + return scored[: max(1, top_k)] + + def update(self, candidate: CandidateAction, reward: float) -> None: + arm = self._arm_key(candidate) + self._ensure_arm(arm) + x = self._features(candidate) + + # A <- A + x x^T + for i in range(self._dimension): + for j in range(self._dimension): + self._A[arm][i][j] += x[i] * x[j] + + # b <- b + r x + for i in range(self._dimension): + self._b[arm][i] += reward * x[i] diff --git a/app/models/baselines/greedy_regimen.py b/app/models/baselines/greedy_regimen.py new file mode 100644 index 0000000000000000000000000000000000000000..283eab8123c82f2d4706495db7687b7fb48adf8d --- /dev/null +++ b/app/models/baselines/greedy_regimen.py @@ -0,0 +1,25 @@ +"""Greedy risk-reduction baseline.""" + +from __future__ import annotations + +from app.common.types import CandidateAction, PolyGuardAction +from app.models.baselines.rules_only import choose_rules_only + + +def choose_greedy(candidates: list[CandidateAction]) -> PolyGuardAction: + ranked = sorted(candidates, key=lambda c: (c.estimated_safety_delta, c.burden_delta), reverse=True) + if not ranked: + return choose_rules_only(candidates) + top = ranked[0] + return PolyGuardAction( + mode=top.mode, + action_type=top.action_type, + target_drug=top.target_drug, + replacement_drug=top.replacement_drug, + dose_bucket=top.dose_bucket, + taper_days=top.taper_days, + monitoring_plan=top.monitoring_plan, + candidate_id=top.candidate_id, + confidence=0.72, + rationale_brief="Greedy safety/burden improvement baseline.", + ) diff --git a/app/models/baselines/imitation.py b/app/models/baselines/imitation.py new file mode 100644 index 0000000000000000000000000000000000000000..d031616d813df1ad73eaf074f2897c1bdf503ba8 --- /dev/null +++ b/app/models/baselines/imitation.py @@ -0,0 +1,25 @@ +"""Imitation baseline from logged actions.""" + +from __future__ import annotations + +from app.common.types import CandidateAction, PolyGuardAction +from app.models.baselines.rules_only import choose_rules_only + + +def choose_imitation(candidates: list[CandidateAction], preferred_candidate_id: str | None = None) -> PolyGuardAction: + if preferred_candidate_id: + for c in candidates: + if c.candidate_id == preferred_candidate_id: + return PolyGuardAction( + mode=c.mode, + action_type=c.action_type, + target_drug=c.target_drug, + replacement_drug=c.replacement_drug, + dose_bucket=c.dose_bucket, + taper_days=c.taper_days, + monitoring_plan=c.monitoring_plan, + candidate_id=c.candidate_id, + confidence=0.7, + rationale_brief="Imitation-selected candidate from demonstration.", + ) + return choose_rules_only(candidates) diff --git a/app/models/baselines/no_change.py b/app/models/baselines/no_change.py new file mode 100644 index 0000000000000000000000000000000000000000..f71d00a87e3725a85e6c5d39af9215591cba6a4e --- /dev/null +++ b/app/models/baselines/no_change.py @@ -0,0 +1,21 @@ +"""No-change baseline.""" + +from __future__ import annotations + +from app.common.enums import ActionType, DecisionMode, DoseBucket +from app.common.types import PolyGuardAction + + +def choose_no_change() -> PolyGuardAction: + return PolyGuardAction( + mode=DecisionMode.REGIMEN_OPT, + action_type=ActionType.KEEP_REGIMEN, + target_drug=None, + replacement_drug=None, + dose_bucket=DoseBucket.NA, + taper_days=None, + monitoring_plan=None, + candidate_id="cand_01", + confidence=0.8, + rationale_brief="Baseline no-change policy.", + ) diff --git a/app/models/baselines/rules_only.py b/app/models/baselines/rules_only.py new file mode 100644 index 0000000000000000000000000000000000000000..23f877b2a184a17e8044c253e2450aa37e6fc6e1 --- /dev/null +++ b/app/models/baselines/rules_only.py @@ -0,0 +1,23 @@ +"""Rule-only baseline.""" + +from __future__ import annotations + +from app.common.enums import ActionType, DecisionMode, DoseBucket +from app.common.types import CandidateAction, PolyGuardAction + + +def choose_rules_only(candidates: list[CandidateAction]) -> PolyGuardAction: + ranked = sorted(candidates, key=lambda c: (c.legality_precheck, c.estimated_safety_delta), reverse=True) + top = ranked[0] + return PolyGuardAction( + mode=top.mode, + action_type=top.action_type, + target_drug=top.target_drug, + replacement_drug=top.replacement_drug, + dose_bucket=top.dose_bucket, + taper_days=top.taper_days, + monitoring_plan=top.monitoring_plan, + candidate_id=top.candidate_id, + confidence=0.75, + rationale_brief="Rules-only selected top legal candidate.", + ) diff --git a/app/models/dosing/__init__.py b/app/models/dosing/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b992478d2f97d398c3463fa52485487caf431e49 --- /dev/null +++ b/app/models/dosing/__init__.py @@ -0,0 +1,5 @@ +"""Dosing models package.""" + +from app.models.dosing.infer import infer_dosing_quality + +__all__ = ["infer_dosing_quality"] diff --git a/app/models/dosing/dose_policy_features.py b/app/models/dosing/dose_policy_features.py new file mode 100644 index 0000000000000000000000000000000000000000..62a78e72f6a6da75210f213904164f55e903b40f --- /dev/null +++ b/app/models/dosing/dose_policy_features.py @@ -0,0 +1,28 @@ +"""Dose policy features.""" + +from __future__ import annotations + +from app.common.types import PatientProfile + + +def build_dose_features(patient: PatientProfile, drug: str) -> dict[str, float]: + med_count = float(len(patient.medications)) + interaction_load = min(1.0, med_count / 12.0) + organ_stress = min( + 1.0, + max(0.0, (35.0 - float(patient.labs.egfr or 60.0)) / 35.0) + + max(0.0, (float(patient.labs.ast or 30.0) - 80.0) / 80.0) + + max(0.0, (float(patient.labs.alt or 30.0) - 80.0) / 80.0), + ) + return { + "egfr": float(patient.labs.egfr or 60.0), + "ast": float(patient.labs.ast or 30.0), + "alt": float(patient.labs.alt or 30.0), + "adherence": float(patient.adherence_estimate), + "frailty": float(patient.frailty_score), + "interaction_load": interaction_load, + "organ_stress": organ_stress, + "inr": float(patient.labs.inr or 1.2), + "glucose": float(patient.labs.glucose or 110.0), + "is_target_drug_present": float(any(m.drug == drug for m in patient.medications)), + } diff --git a/app/models/dosing/infer.py b/app/models/dosing/infer.py new file mode 100644 index 0000000000000000000000000000000000000000..4bdec8f0f354ea72fa18a3fd6956960914cda4f4 --- /dev/null +++ b/app/models/dosing/infer.py @@ -0,0 +1,17 @@ +"""Dosing inference.""" + +from __future__ import annotations + +from app.models.dosing.pkpd_state import PKPDState + + +def infer_dosing_quality(state: PKPDState) -> dict[str, float]: + target_attainment = max(0.0, min(1.0, 1.0 - abs(state.effect_level - 0.62))) + toxicity_proxy = min(1.0, state.toxicity_level + state.organ_stress * 0.2 + state.interaction_load * 0.12) + underdose_proxy = min(1.0, state.underdose_risk + max(0.0, 0.3 - state.effect_level)) + return { + "target_attainment": target_attainment, + "toxicity_proxy": toxicity_proxy, + "underdose_proxy": underdose_proxy, + "measurement_need": max(toxicity_proxy, underdose_proxy), + } diff --git a/app/models/dosing/pkpd_state.py b/app/models/dosing/pkpd_state.py new file mode 100644 index 0000000000000000000000000000000000000000..99af884a392d72d1c873549234e5e355535c48d0 --- /dev/null +++ b/app/models/dosing/pkpd_state.py @@ -0,0 +1,14 @@ +"""PK/PD-inspired dosing state.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(slots=True) +class PKPDState: + effect_level: float + toxicity_level: float + underdose_risk: float + organ_stress: float = 0.0 + interaction_load: float = 0.0 diff --git a/app/models/dosing/surrogate_pkpd.py b/app/models/dosing/surrogate_pkpd.py new file mode 100644 index 0000000000000000000000000000000000000000..796eb939629aba126d4dfa12be2cff652bf88ba6 --- /dev/null +++ b/app/models/dosing/surrogate_pkpd.py @@ -0,0 +1,26 @@ +"""Surrogate PK/PD transition.""" + +from __future__ import annotations + +from app.models.dosing.pkpd_state import PKPDState + + +def step_pkpd( + state: PKPDState, + dose_delta: float, + organ_factor: float = 0.0, + interaction_factor: float = 0.0, +) -> PKPDState: + # Effect benefits modestly from dose increases, but toxicity amplifies with organ stress + interactions. + effective_delta = dose_delta * (1.0 - min(0.6, organ_factor * 0.4)) + new_effect = max(0.0, min(1.0, state.effect_level + 0.28 * effective_delta - 0.05 * interaction_factor)) + toxicity_gain = max(0.0, dose_delta) * (0.35 + organ_factor * 0.25 + interaction_factor * 0.2) + new_toxicity = max(0.0, min(1.0, (state.toxicity_level * 0.85) + toxicity_gain)) + new_underdose = max(0.0, min(1.0, 1.0 - new_effect + max(0.0, -dose_delta) * 0.15)) + return PKPDState( + effect_level=new_effect, + toxicity_level=new_toxicity, + underdose_risk=new_underdose, + organ_stress=max(0.0, min(1.0, organ_factor)), + interaction_load=max(0.0, min(1.0, interaction_factor)), + ) diff --git a/app/models/dosing/train.py b/app/models/dosing/train.py new file mode 100644 index 0000000000000000000000000000000000000000..594fe0080458c2d8c68ee06bd2609eff7f3fbb5f --- /dev/null +++ b/app/models/dosing/train.py @@ -0,0 +1,7 @@ +"""Canonical dosing model training entrypoint.""" + +from __future__ import annotations + +from app.models.dosing.train_supervised import train_dosing_surrogate + +__all__ = ["train_dosing_surrogate"] diff --git a/app/models/dosing/train_supervised.py b/app/models/dosing/train_supervised.py new file mode 100644 index 0000000000000000000000000000000000000000..4048e1607f9ef6d7803d0f4ff0ddc044df39ef77 --- /dev/null +++ b/app/models/dosing/train_supervised.py @@ -0,0 +1,55 @@ +"""Dosing supervised training placeholder.""" + +from __future__ import annotations + +import pickle +from pathlib import Path + +import numpy as np +from sklearn.ensemble import RandomForestRegressor +from sklearn.multioutput import MultiOutputRegressor + +from app.common.enums import Difficulty +from app.models.dosing.dose_policy_features import build_dose_features +from app.simulator.patient_generator import generate_patient_profile + +def train_dosing_surrogate(dataset_size: int) -> dict[str, float | str]: + feature_rows: list[list[float]] = [] + target_rows: list[list[float]] = [] + for i in range(dataset_size): + difficulty = Difficulty.HARD if i % 2 == 0 else Difficulty.MEDIUM + patient = generate_patient_profile(seed=5000 + i, difficulty=difficulty) + drug = patient.medications[0].drug if patient.medications else "warfarin_like" + feats = build_dose_features(patient, drug) + organ = feats.get("organ_stress", 0.0) + interaction = feats.get("interaction_load", 0.0) + adherence = feats.get("adherence", 0.7) + target_attainment = max(0.0, min(1.0, 0.72 + adherence * 0.15 - interaction * 0.2)) + toxicity = max(0.0, min(1.0, 0.15 + organ * 0.5 + interaction * 0.25)) + underdose = max(0.0, min(1.0, 0.25 + (1.0 - adherence) * 0.35 + max(0.0, 0.4 - interaction) * 0.1)) + measurement_need = max(toxicity, underdose) + feature_rows.append(list(feats.values())) + target_rows.append([target_attainment, toxicity, underdose, measurement_need]) + + x = np.array(feature_rows, dtype=float) + y = np.array(target_rows, dtype=float) + model = MultiOutputRegressor(RandomForestRegressor(n_estimators=80, random_state=42)) + model.fit(x, y) + preds = model.predict(x) + mae = float(np.mean(np.abs(preds - y))) + + artifact = { + "model": model, + "feature_keys": list(build_dose_features(generate_patient_profile(seed=1, difficulty=Difficulty.EASY), "warfarin_like").keys()), + "target_keys": ["target_attainment", "toxicity_proxy", "underdose_proxy", "measurement_need"], + } + path = Path("outputs/models/dose_model.pkl") + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("wb") as f: + pickle.dump(artifact, f) + return { + "dataset_size": float(dataset_size), + "status": "trained", + "train_mae": round(mae, 4), + "model_path": str(path), + } diff --git a/app/models/graph/__init__.py b/app/models/graph/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..020c4129d8d84850d55f6b39e8b2d2d4d092da2f --- /dev/null +++ b/app/models/graph/__init__.py @@ -0,0 +1,5 @@ +"""Graph modeling package.""" + +from app.models.graph.infer import infer_graph_risk + +__all__ = ["infer_graph_risk"] diff --git a/app/models/graph/dataset.py b/app/models/graph/dataset.py new file mode 100644 index 0000000000000000000000000000000000000000..f9d5eb1d0b58522e8508be7d84ab7d93e04348ca --- /dev/null +++ b/app/models/graph/dataset.py @@ -0,0 +1,31 @@ +"""Graph dataset builder.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from app.knowledge.ddi_knowledge import top_risky_pairs +from app.knowledge.side_effect_ontology import SIDE_EFFECT_TAGS + + +@dataclass(slots=True) +class GraphSample: + drugs: list[str] + side_effects: list[str] + severe_alert: int + + +def build_graph_samples(regimens: list[list[str]]) -> list[GraphSample]: + samples: list[GraphSample] = [] + for regimen in regimens: + tags: list[str] = [] + for drug in regimen: + tags.extend(SIDE_EFFECT_TAGS.get(drug, [])) + samples.append( + GraphSample( + drugs=regimen, + side_effects=sorted(set(tags)), + severe_alert=1 if top_risky_pairs(regimen) else 0, + ) + ) + return samples diff --git a/app/models/graph/hetero_encoder.py b/app/models/graph/hetero_encoder.py new file mode 100644 index 0000000000000000000000000000000000000000..867fd4a9125e76f4f8ff7e520310f0cab29ceb96 --- /dev/null +++ b/app/models/graph/hetero_encoder.py @@ -0,0 +1,34 @@ +"""Placeholder hetero graph encoder.""" + +from __future__ import annotations + +import numpy as np + +from app.knowledge.ddi_knowledge import top_risky_pairs +from app.knowledge.drug_catalog import DRUG_CLASSES +from app.knowledge.side_effect_ontology import SIDE_EFFECT_TAGS + + +def encode_regimen(drugs: list[str], dim: int = 24) -> np.ndarray: + vec = np.zeros(dim, dtype=float) + ordered = sorted(drugs) + for idx, drug in enumerate(ordered[:12]): + vec[idx] = (hash(drug) % 1000) / 1000.0 + + class_counts: dict[str, int] = {} + for drug in ordered: + cls = DRUG_CLASSES.get(drug, "unknown") + class_counts[cls] = class_counts.get(cls, 0) + 1 + class_values = sorted(class_counts.values(), reverse=True) + for i, value in enumerate(class_values[:5], start=12): + vec[i] = min(1.0, value / 4.0) + + side_effect_count = sum(len(SIDE_EFFECT_TAGS.get(drug, [])) for drug in ordered) + vec[17] = min(1.0, side_effect_count / 20.0) + vec[18] = min(1.0, len(ordered) / 12.0) + vec[19] = min(1.0, len(top_risky_pairs(ordered)) / 4.0) + vec[20] = float(any("sedative" == DRUG_CLASSES.get(drug) for drug in ordered)) + vec[21] = float(any("anticoagulant" == DRUG_CLASSES.get(drug) for drug in ordered)) + vec[22] = float(any("glucose_lowering" == DRUG_CLASSES.get(drug) for drug in ordered)) + vec[23] = min(1.0, sum(ord(ch) for ch in "".join(ordered)) % 1000 / 1000.0) + return vec diff --git a/app/models/graph/infer.py b/app/models/graph/infer.py new file mode 100644 index 0000000000000000000000000000000000000000..bf3d4d054e74b73402b54c4b65c5b7b0aa4b1bc9 --- /dev/null +++ b/app/models/graph/infer.py @@ -0,0 +1,57 @@ +"""Graph model inference.""" + +from __future__ import annotations + +import pickle +from pathlib import Path + +from app.models.graph.regimen_embedder import regimen_embedding +from app.models.graph.hetero_encoder import encode_regimen +from app.models.graph.pairwise_ddi_head import score_pair +from app.models.graph.severe_alert_head import severe_alert_probability +from app.models.graph.side_effect_head import predict_side_effects + + +def _model_path() -> Path: + return Path("outputs/models/graph_model.pkl") + + +def infer_graph_risk(drugs: list[str], model_path: Path | None = None) -> dict: + path = model_path or _model_path() + base = { + "regimen_embedding": regimen_embedding(drugs), + "severe_alert_probability": severe_alert_probability(drugs), + "side_effect_probs": predict_side_effects(drugs), + "pairwise_ddi_severity": { + f"{a}__{b}": score_pair(a, b) + for i, a in enumerate(drugs) + for b in drugs[i + 1 :] + }, + } + if not path.exists(): + return base + try: + with path.open("rb") as f: + artifact = pickle.load(f) + except Exception: + return base + encoded = encode_regimen(drugs).reshape(1, -1) + severe_model = artifact.get("severe_model") + side_model = artifact.get("side_model") + mlb = artifact.get("mlb") + if severe_model is not None and hasattr(severe_model, "predict_proba"): + try: + base["severe_alert_probability"] = float(severe_model.predict_proba(encoded)[0][1]) + except Exception: + pass + if side_model is not None and mlb is not None: + try: + side_probs = side_model.predict_proba(encoded)[0] + base["side_effect_probs"] = { + str(label): float(prob) + for label, prob in zip(mlb.classes_, side_probs) + if float(prob) > 0.05 + } + except Exception: + pass + return base diff --git a/app/models/graph/pairwise_ddi_head.py b/app/models/graph/pairwise_ddi_head.py new file mode 100644 index 0000000000000000000000000000000000000000..4101aaa96d5e70642450f46c852a5ea9e000f733 --- /dev/null +++ b/app/models/graph/pairwise_ddi_head.py @@ -0,0 +1,9 @@ +"""Pairwise DDI head.""" + +from __future__ import annotations + +from app.knowledge.ddi_knowledge import is_contraindicated_pair + + +def score_pair(drug_a: str, drug_b: str) -> float: + return 0.95 if is_contraindicated_pair(drug_a, drug_b) else 0.15 diff --git a/app/models/graph/regimen_embedder.py b/app/models/graph/regimen_embedder.py new file mode 100644 index 0000000000000000000000000000000000000000..1405724e7ed8f1f42cf58a499785ca5ba904a1f0 --- /dev/null +++ b/app/models/graph/regimen_embedder.py @@ -0,0 +1,11 @@ +"""Regimen embedding helper.""" + +from __future__ import annotations + +import numpy as np + +from app.models.graph.hetero_encoder import encode_regimen + + +def regimen_embedding(drugs: list[str]) -> list[float]: + return encode_regimen(drugs).tolist() diff --git a/app/models/graph/severe_alert_head.py b/app/models/graph/severe_alert_head.py new file mode 100644 index 0000000000000000000000000000000000000000..673c55ad10d8085fba52ec47316be6fc16ba3a18 --- /dev/null +++ b/app/models/graph/severe_alert_head.py @@ -0,0 +1,9 @@ +"""Severe alert head.""" + +from __future__ import annotations + +from app.knowledge.ddi_knowledge import top_risky_pairs + + +def severe_alert_probability(drugs: list[str]) -> float: + return min(0.99, 0.1 + 0.3 * len(top_risky_pairs(drugs))) diff --git a/app/models/graph/side_effect_head.py b/app/models/graph/side_effect_head.py new file mode 100644 index 0000000000000000000000000000000000000000..9527ac51a96db722248455227f4a4e838e69c7f6 --- /dev/null +++ b/app/models/graph/side_effect_head.py @@ -0,0 +1,14 @@ +"""Side-effect class predictions.""" + +from __future__ import annotations + +from app.knowledge.side_effect_ontology import SIDE_EFFECT_TAGS + + +def predict_side_effects(drugs: list[str]) -> dict[str, float]: + counts: dict[str, float] = {} + for drug in drugs: + for tag in SIDE_EFFECT_TAGS.get(drug, []): + counts[tag] = counts.get(tag, 0.0) + 1.0 + total = sum(counts.values()) or 1.0 + return {k: v / total for k, v in counts.items()} diff --git a/app/models/graph/train.py b/app/models/graph/train.py new file mode 100644 index 0000000000000000000000000000000000000000..dbd64b7a05e5c68626d2528108de39d1e13d6906 --- /dev/null +++ b/app/models/graph/train.py @@ -0,0 +1,43 @@ +"""Graph model training entry.""" + +from __future__ import annotations + +import pickle +from pathlib import Path + +import numpy as np +from sklearn.linear_model import LogisticRegression +from sklearn.multiclass import OneVsRestClassifier +from sklearn.preprocessing import MultiLabelBinarizer + +from app.models.graph.dataset import build_graph_samples +from app.models.graph.hetero_encoder import encode_regimen + + +def train_graph_model(regimens: list[list[str]], model_path: Path | None = None) -> dict: + samples = build_graph_samples(regimens) + if not samples: + return {"num_samples": 0, "status": "no_data"} + x = np.stack([encode_regimen(s.drugs) for s in samples], axis=0) + y_severe = np.array([s.severe_alert for s in samples], dtype=int) + y_tags = [s.side_effects for s in samples] + + severe_model = LogisticRegression(max_iter=500, class_weight="balanced") + severe_model.fit(x, y_severe) + + mlb = MultiLabelBinarizer() + y_tag_matrix = mlb.fit_transform(y_tags) + side_model = OneVsRestClassifier(LogisticRegression(max_iter=500)) + side_model.fit(x, y_tag_matrix) + + artifact = { + "severe_model": severe_model, + "side_model": side_model, + "mlb": mlb, + "feature_dim": x.shape[1], + } + target = model_path or Path("outputs/models/graph_model.pkl") + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("wb") as f: + pickle.dump(artifact, f) + return {"num_samples": len(samples), "status": "trained", "model_path": str(target)} diff --git a/app/models/policy/__init__.py b/app/models/policy/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a7804ca9dc88b859311b1932f2182533d001c9c8 --- /dev/null +++ b/app/models/policy/__init__.py @@ -0,0 +1,5 @@ +"""Policy modules.""" + +from app.models.policy.candidate_builder import build_candidates + +__all__ = ["build_candidates"] diff --git a/app/models/policy/abstention.py b/app/models/policy/abstention.py new file mode 100644 index 0000000000000000000000000000000000000000..063a368ca2b81624176f7aeecbe681d4a06e903e --- /dev/null +++ b/app/models/policy/abstention.py @@ -0,0 +1,21 @@ +"""Abstention policy helpers.""" + +from __future__ import annotations + +from app.common.enums import ActionType, DecisionMode, DoseBucket +from app.common.types import PolyGuardAction + + +def abstain_action(reason: str = "uncertainty_high") -> PolyGuardAction: + return PolyGuardAction( + mode=DecisionMode.REVIEW, + action_type=ActionType.REQUEST_SPECIALIST_REVIEW, + target_drug=None, + replacement_drug=None, + dose_bucket=DoseBucket.NA, + taper_days=None, + monitoring_plan=reason, + candidate_id="cand_abstain", + confidence=0.5, + rationale_brief=f"Abstaining due to {reason}", + ) diff --git a/app/models/policy/active_model.py b/app/models/policy/active_model.py new file mode 100644 index 0000000000000000000000000000000000000000..1f65216643903b8eaaaf46c986204090d49b1430 --- /dev/null +++ b/app/models/policy/active_model.py @@ -0,0 +1,141 @@ +"""Active trained-model discovery for product inference. + +The HF training Space writes model artifacts into per-sweep folders. The app +uses this module to find the locally activated artifact without hard-coding a +specific checkpoint path into the API or agent stack. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[3] +ACTIVE_DIR = ROOT / "checkpoints" / "active" +MANIFEST_PATH = ACTIVE_DIR / "active_model_manifest.json" +DEFAULT_RUN_ID = "qwen-qwen2-5-0-5b-instruct" + + +def _truthy(value: str | None) -> bool | None: + if value is None: + return None + lowered = value.strip().lower() + if lowered in {"1", "true", "yes", "on"}: + return True + if lowered in {"0", "false", "no", "off"}: + return False + return None + + +def _read_json(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + return payload if isinstance(payload, dict) else {} + + +def _resolve_path(value: str | Path | None, default: Path) -> Path: + if value is None or str(value).strip() == "": + return default + path = Path(str(value)).expanduser() + if path.is_absolute(): + return path + return ROOT / path + + +def _adapter_base_model(adapter_dir: Path) -> str: + payload = _read_json(adapter_dir / "adapter_config.json") + value = payload.get("base_model_name_or_path") + return str(value) if isinstance(value, str) else "" + + +def active_model_status() -> dict[str, Any]: + """Return the activated model artifact contract used by the app.""" + + manifest = _read_json(MANIFEST_PATH) + env_enabled = _truthy(os.getenv("POLYGUARD_ENABLE_ACTIVE_MODEL")) + manifest_enabled = bool(manifest.get("enabled", False)) + enabled = env_enabled if env_enabled is not None else manifest_enabled + + preferred_artifact = ( + os.getenv("POLYGUARD_ACTIVE_PREFERRED_ARTIFACT") + or str(manifest.get("preferred_artifact") or "grpo_adapter") + ) + if preferred_artifact not in {"grpo_adapter", "merged", "sft_adapter"}: + preferred_artifact = "grpo_adapter" + + grpo_adapter = _resolve_path( + os.getenv("POLYGUARD_ACTIVE_GRPO_ADAPTER") or manifest.get("grpo_adapter"), + ACTIVE_DIR / "grpo_adapter", + ) + sft_adapter = _resolve_path( + os.getenv("POLYGUARD_ACTIVE_SFT_ADAPTER") or manifest.get("sft_adapter"), + ACTIVE_DIR / "sft_adapter", + ) + merged_model = _resolve_path( + os.getenv("POLYGUARD_ACTIVE_MERGED_MODEL") or manifest.get("merged_model"), + ACTIVE_DIR / "merged", + ) + base_model = ( + os.getenv("POLYGUARD_ACTIVE_BASE_MODEL") + or str(manifest.get("base_model") or "") + or _adapter_base_model(grpo_adapter) + or _adapter_base_model(sft_adapter) + or os.getenv("POLYGUARD_HF_MODEL", "Qwen/Qwen2.5-0.5B-Instruct") + ) + + availability = { + "grpo_adapter": (grpo_adapter / "adapter_config.json").exists() + and (grpo_adapter / "adapter_model.safetensors").exists(), + "merged": (merged_model / "config.json").exists(), + "sft_adapter": (sft_adapter / "adapter_config.json").exists() + and (sft_adapter / "adapter_model.safetensors").exists(), + } + load_order = [preferred_artifact] + [ + item for item in ["grpo_adapter", "merged", "sft_adapter"] if item != preferred_artifact + ] + active = any(availability.values()) + + return { + "enabled": enabled, + "active": active, + "manifest_path": str(MANIFEST_PATH), + "manifest_exists": MANIFEST_PATH.exists(), + "run_id": str(manifest.get("run_id") or DEFAULT_RUN_ID), + "source": str(manifest.get("source") or ""), + "label": str(manifest.get("label") or ""), + "model_id": str(manifest.get("model_id") or base_model), + "base_model": base_model, + "preferred_artifact": preferred_artifact, + "load_order": load_order, + "availability": availability, + "paths": { + "grpo_adapter": str(grpo_adapter), + "merged": str(merged_model), + "sft_adapter": str(sft_adapter), + }, + "reports": manifest.get("reports", {}) if isinstance(manifest.get("reports"), dict) else {}, + "notes": str(manifest.get("notes") or ""), + } + + +def available_artifact_path(status: dict[str, Any] | None = None) -> tuple[str, Path] | None: + """Return the first available artifact according to the active load order.""" + + status = status or active_model_status() + if not status.get("enabled") or not status.get("active"): + return None + paths = status.get("paths", {}) + availability = status.get("availability", {}) + if not isinstance(paths, dict) or not isinstance(availability, dict): + return None + for artifact in status.get("load_order", []): + if availability.get(artifact) and paths.get(artifact): + return str(artifact), Path(str(paths[artifact])) + return None diff --git a/app/models/policy/candidate_builder.py b/app/models/policy/candidate_builder.py new file mode 100644 index 0000000000000000000000000000000000000000..b032db2aa515ef42340c50fdd125d24a6ec1a7b1 --- /dev/null +++ b/app/models/policy/candidate_builder.py @@ -0,0 +1,233 @@ +"""Constrained candidate action generation.""" + +from __future__ import annotations + +from app.common.enums import ActionType, DecisionMode, DoseBucket +from app.common.types import CandidateAction, PolyGuardAction, PolyGuardState +from app.env.verifier import verify_action_legality +from app.knowledge.ddi_knowledge import top_risky_pairs +from app.knowledge.hepatic_rules import is_hepatic_unsafe +from app.knowledge.renal_rules import is_renal_unsafe +from app.knowledge.substitution_rules import get_substitutions + + +def _base_candidate( + idx: int, + action_type: ActionType, + target_drug: str | None = None, + replacement_drug: str | None = None, + mode: DecisionMode = DecisionMode.REGIMEN_OPT, +) -> CandidateAction: + return CandidateAction( + candidate_id=f"cand_{idx:02d}", + mode=mode, + action_type=action_type, + target_drug=target_drug, + replacement_drug=replacement_drug, + dose_bucket=DoseBucket.NA, + taper_days=14 if action_type == ActionType.TAPER_INITIATE else None, + monitoring_plan="repeat_labs_7d" if action_type == ActionType.ORDER_MONITORING_AND_WAIT else None, + estimated_safety_delta=0.02, + burden_delta=0.0, + disease_stability_estimate=0.85, + uncertainty_score=0.45, + rationale_tags=["rule_based_seed"], + required_monitoring=[], + legality_precheck=True, + ) + + +def _to_action(candidate: CandidateAction) -> PolyGuardAction: + return PolyGuardAction( + mode=candidate.mode, + action_type=candidate.action_type, + target_drug=candidate.target_drug, + replacement_drug=candidate.replacement_drug, + dose_bucket=candidate.dose_bucket, + taper_days=candidate.taper_days, + monitoring_plan=candidate.monitoring_plan, + evidence_query=candidate.evidence_query, + new_drug_name=candidate.new_drug_name, + candidate_components=candidate.candidate_components, + candidate_id=candidate.candidate_id, + confidence=max(0.45, 1.0 - candidate.uncertainty_score), + rationale_brief="candidate_precheck", + ) + + +def build_candidates(state: PolyGuardState) -> list[CandidateAction]: + meds = state.patient.medications + candidates: list[CandidateAction] = [] + risky_pairs = top_risky_pairs([m.drug for m in meds]) + target_risky_drug = risky_pairs[0][0] if risky_pairs else (meds[0].drug if meds else None) + + keep = _base_candidate(1, ActionType.KEEP_REGIMEN) + keep = keep.model_copy(update={"estimated_safety_delta": -0.02, "uncertainty_score": 0.5}) + candidates.append(keep) + + if meds: + first = target_risky_drug or meds[0].drug + stop = _base_candidate(2, ActionType.STOP_DRUG, target_drug=first) + stop = stop.model_copy( + update={ + "estimated_safety_delta": 0.26, + "burden_delta": 0.12, + "disease_stability_estimate": 0.68 if first == "warfarin_like" else 0.81, + "uncertainty_score": 0.42, + "rationale_tags": ["ddi_reduction", "deprescribing"], + } + ) + candidates.append(stop) + + dose_candidate = _base_candidate(3, ActionType.REDUCE_DOSE_BUCKET, target_drug=first) + candidates.append( + dose_candidate.model_copy( + update={ + "mode": DecisionMode.DOSE_OPT, + "dose_bucket": DoseBucket.LOW, + "estimated_safety_delta": 0.16, + "burden_delta": 0.03, + "uncertainty_score": 0.33, + "rationale_tags": ["dose_deintensification"], + } + ) + ) + + subs = get_substitutions(first) + if subs: + preferred = subs[0] + candidates.append( + _base_candidate( + 4, + ActionType.SUBSTITUTE_WITHIN_CLASS, + target_drug=first, + replacement_drug=preferred, + ).model_copy( + update={ + "estimated_safety_delta": 0.22, + "burden_delta": 0.05, + "uncertainty_score": 0.36, + "rationale_tags": ["therapeutic_substitution"], + } + ) + ) + + for med in meds: + if is_renal_unsafe(med.drug, state.patient.labs.egfr) or is_hepatic_unsafe(med.drug, state.patient.labs.ast, state.patient.labs.alt): + hold = _base_candidate(5, ActionType.DOSE_HOLD, target_drug=med.drug, mode=DecisionMode.DOSE_OPT).model_copy( + update={ + "monitoring_plan": "repeat_labs_72h", + "estimated_safety_delta": 0.2, + "disease_stability_estimate": 0.74, + "uncertainty_score": 0.28, + "required_monitoring": ["renal_or_hepatic_panel"], + "rationale_tags": ["organ_function_guardrail"], + } + ) + candidates.append(hold) + break + + monitoring = _base_candidate(8, ActionType.ORDER_MONITORING_AND_WAIT, mode=DecisionMode.DOSE_OPT).model_copy( + update={ + "monitoring_plan": "vitals_labs_7d", + "estimated_safety_delta": 0.1, + "disease_stability_estimate": 0.88, + "uncertainty_score": 0.26, + "rationale_tags": ["monitor_before_change"], + "required_monitoring": ["cbc", "cmp"], + } + ) + candidates.append(monitoring) + + pharm = _base_candidate(9, ActionType.REQUEST_PHARMACIST_REVIEW, mode=DecisionMode.REVIEW).model_copy( + update={"estimated_safety_delta": 0.04, "uncertainty_score": 0.18, "rationale_tags": ["abstain_for_review"]} + ) + spec = _base_candidate(10, ActionType.REQUEST_SPECIALIST_REVIEW, mode=DecisionMode.REVIEW).model_copy( + update={"estimated_safety_delta": 0.04, "uncertainty_score": 0.2, "rationale_tags": ["abstain_for_review"]} + ) + candidates.extend([pharm, spec]) + + if state.sub_environment.value == "BANDIT_MINING" and meds: + bandit = _base_candidate(6, ActionType.KEEP_REGIMEN).model_copy( + update={ + "candidate_id": "cand_06", + "mode": DecisionMode.REGIMEN_OPT, + "estimated_safety_delta": 0.08, + "burden_delta": 0.01, + "uncertainty_score": 0.31, + "rationale_tags": ["contextual_bandit_exploration"], + } + ) + candidates.append(bandit) + + if state.sub_environment.value == "WEB_SEARCH_MISSING_DATA": + candidates.append( + _base_candidate(7, ActionType.FETCH_EXTERNAL_EVIDENCE, mode=DecisionMode.REVIEW).model_copy( + update={ + "candidate_id": "cand_07", + "evidence_query": "https://www.nih.gov", + "estimated_safety_delta": 0.11, + "disease_stability_estimate": 0.84, + "uncertainty_score": 0.22, + "rationale_tags": ["missing_data_recovery", "external_evidence_fetch"], + } + ) + ) + + if state.sub_environment.value == "ALTERNATIVE_SUGGESTION" and meds: + alt_target = meds[0].drug + alt_replacements = get_substitutions(alt_target) + if alt_replacements: + candidates.append( + _base_candidate( + 11, + ActionType.RECOMMEND_ALTERNATIVE, + target_drug=alt_target, + replacement_drug=alt_replacements[0], + mode=DecisionMode.REGIMEN_OPT, + ).model_copy( + update={ + "candidate_id": "cand_11", + "estimated_safety_delta": 0.24, + "burden_delta": 0.04, + "uncertainty_score": 0.29, + "rationale_tags": ["alternative_suggestion", "safer_addition_or_swap"], + } + ) + ) + + if state.sub_environment.value == "NEW_DRUG_DECOMPOSITION": + candidates.append( + _base_candidate(12, ActionType.DECOMPOSE_NEW_DRUG, mode=DecisionMode.REVIEW).model_copy( + update={ + "candidate_id": "cand_12", + "new_drug_name": "novel_combination_x", + "candidate_components": ["novel_component_a", "novel_component_b"], + "estimated_safety_delta": 0.14, + "disease_stability_estimate": 0.8, + "uncertainty_score": 0.24, + "rationale_tags": ["new_drug_component_analysis"], + } + ) + ) + + priority_by_subenv = { + "WEB_SEARCH_MISSING_DATA": ActionType.FETCH_EXTERNAL_EVIDENCE, + "ALTERNATIVE_SUGGESTION": ActionType.RECOMMEND_ALTERNATIVE, + "NEW_DRUG_DECOMPOSITION": ActionType.DECOMPOSE_NEW_DRUG, + } + priority_action = priority_by_subenv.get(state.sub_environment.value) + if priority_action is not None: + prioritized = [item for item in candidates if item.action_type == priority_action] + non_prioritized = [item for item in candidates if item.action_type != priority_action] + candidates = prioritized + non_prioritized + + # Strict 3..10. + limited = candidates[:10] + if len(limited) < 3: + limited.extend([_base_candidate(i + 10, ActionType.KEEP_REGIMEN) for i in range(3 - len(limited))]) + validated: list[CandidateAction] = [] + for candidate in limited: + legal = verify_action_legality(state, _to_action(candidate)).legal + validated.append(candidate.model_copy(update={"legality_precheck": legal})) + return validated diff --git a/app/models/policy/output_schema.py b/app/models/policy/output_schema.py new file mode 100644 index 0000000000000000000000000000000000000000..8eadb966375b51601704d0175ba90f4f58e18119 --- /dev/null +++ b/app/models/policy/output_schema.py @@ -0,0 +1,24 @@ +"""Structured policy output schema.""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +from app.common.enums import ActionType, DecisionMode, DoseBucket + + +class DecisionSchema(BaseModel): + model_config = ConfigDict(extra="forbid") + mode: DecisionMode + action_type: ActionType + target_drug: str | None + replacement_drug: str | None + dose_bucket: DoseBucket + taper_days: int | None + monitoring_plan: str | None + evidence_query: str | None = None + new_drug_name: str | None = None + candidate_components: list[str] = Field(default_factory=list) + candidate_id: str + confidence: float + rationale_brief: str | None = None diff --git a/app/models/policy/parser.py b/app/models/policy/parser.py new file mode 100644 index 0000000000000000000000000000000000000000..c5039b3431fc7cdb29b01de33e307df610d8dd9d --- /dev/null +++ b/app/models/policy/parser.py @@ -0,0 +1,121 @@ +"""Parser for structured policy decisions. + +Provides strict XML parsing, soft parsing fallback, and repair-backed parsing. +""" + +from __future__ import annotations + +import json +import xml.etree.ElementTree as ET + +from app.common.exceptions import ParserError +from app.models.policy.output_schema import DecisionSchema +from app.models.policy.repair import repair_partial_decision + +_REQUIRED_XML_FIELDS = { + "mode", + "action_type", + "target_drug", + "replacement_drug", + "dose_bucket", + "taper_days", + "candidate_id", + "confidence", +} + +_ALLOWED_XML_FIELDS = _REQUIRED_XML_FIELDS | {"monitoring_plan", "rationale_brief"} + + +def _normalize_scalar(value: object) -> object: + if value is None: + return None + if isinstance(value, str) and value.strip().lower() in {"", "none", "null", "na"}: + return None + return value + + +def _coerce_payload_types(payload: dict[str, object]) -> dict[str, object]: + coerced = dict(payload) + for key in ["target_drug", "replacement_drug", "monitoring_plan", "taper_days", "rationale_brief"]: + coerced[key] = _normalize_scalar(coerced.get(key)) + if coerced.get("taper_days") is not None: + coerced["taper_days"] = int(coerced["taper_days"]) + if "confidence" in coerced: + coerced["confidence"] = float(coerced["confidence"]) + return coerced + + +def parse_decision_strict_xml(raw: str) -> DecisionSchema: + """Strictly parse the required XML decision schema.""" + try: + root = ET.fromstring(raw.strip()) + except Exception as exc: # noqa: BLE001 + raise ParserError(f"Invalid XML: {exc}") from exc + if root.tag != "decision": + raise ParserError("XML decision root must be .") + + payload: dict[str, object] = {} + for child in root: + if child.tag not in _ALLOWED_XML_FIELDS: + raise ParserError(f"Unknown XML field: {child.tag}") + payload[child.tag] = child.text + + missing = sorted(_REQUIRED_XML_FIELDS - payload.keys()) + if missing: + raise ParserError(f"Missing required XML fields: {missing}") + + return DecisionSchema.model_validate(_coerce_payload_types(payload)) + + +def parse_decision_soft(raw: str) -> DecisionSchema: + """Best-effort parsing across XML or JSON decision payloads.""" + stripped = raw.strip() + if not stripped: + raise ParserError("Empty decision payload.") + + if "") + if start >= 0 and end > start: + end += len("") + return parse_decision_strict_xml(stripped[start:end]) + raise + + try: + payload = json.loads(stripped) + except Exception as exc: # noqa: BLE001 + raise ParserError(f"Unable to parse JSON decision: {exc}") from exc + return DecisionSchema.model_validate(_coerce_payload_types(payload)) + + +def parse_decision_with_repair(raw: str) -> DecisionSchema: + """Soft-parse and deterministically repair malformed decision payloads.""" + try: + parsed = parse_decision_soft(raw) + repaired = repair_partial_decision(parsed.model_dump(mode="json")) + return DecisionSchema.model_validate(repaired) + except Exception: + stripped = raw.strip() + payload: dict[str, object] = {} + if stripped.startswith("{"): + try: + payload = json.loads(stripped) + except Exception: # noqa: BLE001 + payload = {} + repaired = repair_partial_decision(payload) + try: + return DecisionSchema.model_validate(repaired) + except Exception as exc: # noqa: BLE001 + raise ParserError(f"Unable to parse decision after repair: {exc}") from exc + + +def parse_decision(raw: str) -> DecisionSchema: + """Primary parse entrypoint (strict XML -> soft JSON/XML recovery).""" + if raw.strip().startswith(" str: + return json.dumps(action.model_dump(mode="json"), ensure_ascii=True) + + +def action_to_xml(action: PolyGuardAction) -> str: + payload = action.model_dump(mode="json") + lines = [""] + for key in [ + "mode", + "action_type", + "target_drug", + "replacement_drug", + "dose_bucket", + "taper_days", + "monitoring_plan", + "candidate_id", + "confidence", + "rationale_brief", + ]: + lines.append(f" <{key}>{payload.get(key)}") + lines.append("") + return "\n".join(lines) diff --git a/app/models/policy/prompt_templates.py b/app/models/policy/prompt_templates.py new file mode 100644 index 0000000000000000000000000000000000000000..09cadfb5b8995eba8e6c8b024296d75b170d723e --- /dev/null +++ b/app/models/policy/prompt_templates.py @@ -0,0 +1,13 @@ +"""Prompt templates for policy training/inference.""" + +from __future__ import annotations + +PLANNER_TEMPLATE = """ +You are PlannerAgent. Select one action from the candidate set. +Return strict JSON only with schema keys: +mode, action_type, target_drug, replacement_drug, dose_bucket, taper_days, monitoring_plan, candidate_id, confidence +""" + +SUPERVISOR_TEMPLATE = """ +You are SupervisorAgent. Choose macro mode: REGIMEN_OPT, DOSE_OPT, or REVIEW. +""" diff --git a/app/models/policy/provider_runtime.py b/app/models/policy/provider_runtime.py new file mode 100644 index 0000000000000000000000000000000000000000..1963b21a04a6a217b11ffd2f9b4683316ba07cda --- /dev/null +++ b/app/models/policy/provider_runtime.py @@ -0,0 +1,429 @@ +"""LLM provider runtime with Transformers-first fallback order. + +The runtime is intentionally conservative: if an LLM backend is unavailable or +errors, selection falls back to deterministic local ranking. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import os +import re +import shutil +import subprocess +import time +from pathlib import Path +from typing import Any + +from app.common.types import CandidateAction +from app.models.policy.active_model import active_model_status, available_artifact_path +from app.models.policy.safety_ranker import rank_candidates + + +def _transformers_low_cpu_mem() -> bool: + """Use lazy/meta init only on CUDA; on CPU it often breaks ``.to(device)`` (meta tensors).""" + try: + import torch + + return torch.cuda.is_available() + except Exception: + return False + + +def _peft_base_model_id(artifact_path: str, status: dict[str, Any], fallback: str) -> str: + cfg = Path(artifact_path) / "adapter_config.json" + if cfg.is_file(): + try: + payload = json.loads(cfg.read_text(encoding="utf-8")) + raw = payload.get("base_model_name_or_path") + if isinstance(raw, str) and raw.strip(): + return raw.strip() + except Exception: + pass + return str(status.get("base_model") or fallback) + + +def _env_truthy(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def default_provider_preference() -> tuple[str, ...]: + raw = os.getenv("POLYGUARD_PROVIDER_PREFERENCE", "").strip() + if raw: + order = tuple(p.strip().lower() for p in raw.split(",") if p.strip()) + cleaned = tuple(p for p in order if p in {"ollama", "transformers"}) + if cleaned: + return cleaned + if _env_truthy("POLYGUARD_ENABLE_OLLAMA"): + return ("ollama", "transformers") + return ("transformers",) + + +def _extract_candidate_id(text: str, legal_ids: set[str]) -> str | None: + lowered = text.lower() + for candidate_id in sorted(legal_ids): + if candidate_id.lower() in lowered: + return candidate_id + return None + + +def _compact_prompt(candidates: list[CandidateAction], prompt: dict[str, Any]) -> str: + compact_candidates = [ + { + "candidate_id": c.candidate_id, + "mode": c.mode.value, + "action_type": c.action_type.value, + "target_drug": c.target_drug, + "replacement_drug": c.replacement_drug, + "dose_bucket": c.dose_bucket.value, + "safety_delta": c.estimated_safety_delta, + "uncertainty": c.uncertainty_score, + "legal": c.legality_precheck, + "tags": c.rationale_tags[:4], + } + for c in candidates + ] + payload = { + "instruction": "Select the safest legal medication action candidate_id.", + "context": prompt, + "candidate_ids": [c.candidate_id for c in candidates], + "candidates": compact_candidates, + "answer": "", + "format": "Return candidate_id=; rationale=.", + } + return json.dumps(payload, ensure_ascii=True) + + +@dataclass(slots=True) +class ProviderSelection: + provider: str + candidate_id: str + rationale: str + latency_ms: float + raw_output: str = "" + + +class OllamaProvider: + name = "ollama" + + def __init__(self, model_name: str) -> None: + self.model_name = model_name + self._last_error = "" + + def is_available(self) -> bool: + if os.getenv("POLYGUARD_ENABLE_OLLAMA", "false").lower() not in {"1", "true", "yes", "on"}: + return False + return shutil.which("ollama") is not None + + def ensure_model(self) -> bool: + if not self.is_available(): + return False + if os.getenv("POLYGUARD_OLLAMA_AUTO_PULL", "true").lower() not in {"1", "true", "yes", "on"}: + return True + try: + subprocess.run( + ["ollama", "pull", self.model_name], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=90, + ) + return True + except Exception: + return False + + def select(self, candidates: list[CandidateAction], prompt: dict[str, Any]) -> ProviderSelection | None: + if not self.is_available() or not candidates: + return None + self.ensure_model() + deadline_seconds = float(os.getenv("POLYGUARD_PROVIDER_TIMEOUT_SECONDS", "25.0")) + legal_ids = {c.candidate_id for c in candidates} + compact_candidates = [ + { + "candidate_id": c.candidate_id, + "mode": c.mode.value, + "action_type": c.action_type.value, + "estimated_safety_delta": c.estimated_safety_delta, + "uncertainty_score": c.uncertainty_score, + "legality_precheck": c.legality_precheck, + } + for c in candidates + ] + request = { + "instruction": ( + "Choose exactly one safest legal medication action. " + "Return a single JSON object only: {\"candidate_id\":\"cand_XX\",\"rationale\":\"brief reason\"}. " + "Do not return arrays or multiple candidates." + ), + "context": prompt, + "candidates": compact_candidates, + } + start = time.monotonic() + try: + prompt_text = json.dumps(request, ensure_ascii=True) + proc = subprocess.run( + ["ollama", "run", self.model_name], + check=False, + input=prompt_text, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=deadline_seconds, + env={**os.environ, "TERM": "dumb", "NO_COLOR": "1"}, + ) + elapsed_ms = (time.monotonic() - start) * 1000.0 + if proc.returncode != 0: + self._last_error = (proc.stderr or "ollama run failed").strip()[:500] + return None + raw = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", proc.stdout or "").strip() + if not raw: + self._last_error = (proc.stderr or "ollama returned empty output").strip()[:500] + return None + try: + data = json.loads(raw) + except json.JSONDecodeError: + data = {} + parsed_candidate = data.get("candidate_id") if isinstance(data, dict) else None + if isinstance(parsed_candidate, list): + parsed_candidate = next((str(item) for item in parsed_candidate if str(item) in legal_ids), "") + candidate_id = str(parsed_candidate or "").strip() or (_extract_candidate_id(raw, legal_ids) or "") + if not candidate_id or candidate_id not in legal_ids: + self._last_error = f"ollama returned no legal candidate_id: {raw[:240]}" + return None + parsed_rationale = data.get("rationale") if isinstance(data, dict) else None + if isinstance(parsed_rationale, list): + parsed_rationale = " ".join(str(item) for item in parsed_rationale[:2]) + rationale = str(parsed_rationale or "Ollama provider selection.").strip() or "Ollama provider selection." + self._last_error = "" + return ProviderSelection( + provider=self.name, + candidate_id=candidate_id, + rationale=rationale, + latency_ms=elapsed_ms, + raw_output=raw, + ) + except Exception as exc: + self._last_error = str(exc)[:500] + return None + + def status(self) -> dict[str, Any]: + return { + "enabled": _env_truthy("POLYGUARD_ENABLE_OLLAMA"), + "available": self.is_available(), + "model": self.model_name, + "provider": self.name, + "last_error": self._last_error, + } + + +class TransformersProvider: + name = "transformers" + + def __init__(self, model_name: str) -> None: + self.model_name = model_name + self._model: Any | None = None + self._tokenizer: Any | None = None + self._model_source = "" + self._load_error = "" + + def is_available(self) -> bool: + try: + import transformers # noqa: F401 + + return True + except Exception: + return False + + def status(self) -> dict[str, Any]: + status = active_model_status() + status["provider"] = self.name + status["loaded_source"] = self._model_source + status["load_error"] = self._load_error + status["runtime_model_name"] = self.model_name + return status + + def _load_artifact(self, artifact_name: str, artifact_path: Any, status: dict[str, Any]) -> bool: + try: + import torch + from transformers import AutoModelForCausalLM, AutoTokenizer + + artifact_path = os.fspath(artifact_path) + dtype = torch.float16 if torch.cuda.is_available() else torch.float32 + low_mem = _transformers_low_cpu_mem() + if artifact_name == "merged": + tokenizer = AutoTokenizer.from_pretrained(artifact_path, trust_remote_code=True) + model = AutoModelForCausalLM.from_pretrained( + artifact_path, + dtype=dtype, + low_cpu_mem_usage=low_mem, + trust_remote_code=True, + ) + source = "active_merged" + else: + from peft import PeftModel + + base_model = _peft_base_model_id(artifact_path, status, self.model_name) + tokenizer = AutoTokenizer.from_pretrained(base_model, trust_remote_code=True) + base = AutoModelForCausalLM.from_pretrained( + base_model, + dtype=dtype, + low_cpu_mem_usage=low_mem, + trust_remote_code=True, + ) + model = PeftModel.from_pretrained(base, artifact_path) + source = f"active_{artifact_name}" + + if tokenizer.pad_token is None: + tokenizer.pad_token = tokenizer.eos_token + device = "cuda" if torch.cuda.is_available() else "cpu" + model = model.to(device) + model.eval() + self._model = model + self._tokenizer = tokenizer + self._model_source = source + self._load_error = "" + return True + except Exception as exc: # noqa: BLE001 + self._load_error = str(exc) + self._model = None + self._tokenizer = None + self._model_source = "" + return False + + def _load_active_model(self) -> bool: + if self._model is not None and self._tokenizer is not None: + return True + + status = active_model_status() + if available_artifact_path(status) is None: + return False + + paths = status.get("paths", {}) + availability = status.get("availability", {}) + errors: list[str] = [] + if not isinstance(paths, dict) or not isinstance(availability, dict): + return False + for artifact_name in status.get("load_order", []): + if not availability.get(artifact_name) or not paths.get(artifact_name): + continue + if self._load_artifact(str(artifact_name), paths[artifact_name], status): + return True + errors.append(f"{artifact_name}:{self._load_error}") + if errors: + self._load_error = " | ".join(errors) + return False + + def _select_with_active_model( + self, + candidates: list[CandidateAction], + prompt: dict[str, Any], + ) -> ProviderSelection | None: + if not self._load_active_model() or self._model is None or self._tokenizer is None: + return None + + import torch + + legal_ids = {c.candidate_id for c in candidates} + prompt_text = _compact_prompt(candidates, prompt) + max_new_tokens = int(os.getenv("POLYGUARD_PROVIDER_MAX_NEW_TOKENS", "64")) + started = time.monotonic() + try: + device = next(self._model.parameters()).device + encoded = self._tokenizer(prompt_text, return_tensors="pt", truncation=True, max_length=768) + encoded = {key: value.to(device) for key, value in encoded.items()} + with torch.no_grad(): + generated = self._model.generate( + **encoded, + max_new_tokens=max_new_tokens, + do_sample=False, + temperature=0.0, + eos_token_id=self._tokenizer.eos_token_id, + pad_token_id=self._tokenizer.pad_token_id, + ) + decoded = self._tokenizer.decode(generated[0], skip_special_tokens=True) + completion = decoded[len(prompt_text) :].strip() if decoded.startswith(prompt_text) else decoded + candidate_id = _extract_candidate_id(completion, legal_ids) + if candidate_id is None: + return None + rationale = completion.strip() or f"Active model selected {candidate_id}." + return ProviderSelection( + provider=self._model_source or self.name, + candidate_id=candidate_id, + rationale=rationale[:500], + latency_ms=(time.monotonic() - started) * 1000.0, + raw_output=completion, + ) + except Exception as exc: # noqa: BLE001 + self._load_error = str(exc) + return None + + def select(self, candidates: list[CandidateAction], prompt: dict[str, Any]) -> ProviderSelection | None: + if not self.is_available() or not candidates: + return None + + active_selection = self._select_with_active_model(candidates, prompt) + if active_selection is not None: + return active_selection + + # Keep this lightweight and deterministic when no active artifact is + # configured or model loading fails. + start = time.monotonic() + top = rank_candidates(candidates)[0] + status = active_model_status() + load_note = f" active_model_error={self._load_error}" if self._load_error else "" + return ProviderSelection( + provider="transformers_ranker_fallback", + candidate_id=top.candidate_id, + rationale=( + f"Transformers fallback selected {top.candidate_id} via local ranker; " + f"active_model_enabled={status.get('enabled')}; active_model_available={status.get('active')}." + f"{load_note}" + ), + latency_ms=(time.monotonic() - start) * 1000.0, + ) + + +class PolicyProviderRouter: + def __init__(self, ollama_model: str = "qwen2.5:1.5b-instruct", hf_model: str = "Qwen/Qwen2.5-0.5B-Instruct") -> None: + self.ollama = OllamaProvider(os.getenv("POLYGUARD_OLLAMA_MODEL", ollama_model)) + self.transformers = TransformersProvider( + os.getenv("POLYGUARD_HF_MODEL") or os.getenv("POLYGUARD_FRONTIER_MODEL") or hf_model + ) + + def select_candidate( + self, + candidates: list[CandidateAction], + prompt: dict[str, Any], + provider_preference: tuple[str, ...] | None = None, + ) -> ProviderSelection: + provider_preference = tuple(provider_preference or default_provider_preference()) + + for provider in provider_preference: + if provider == "ollama": + picked = self.ollama.select(candidates, prompt) + if picked is not None: + return picked + elif provider == "transformers": + picked = self.transformers.select(candidates, prompt) + if picked is not None: + return picked + + # Deterministic hard fallback. + fallback = rank_candidates(candidates)[0] + return ProviderSelection( + provider="heuristic_fallback", + candidate_id=fallback.candidate_id, + rationale="Fallback ranker selected top legal/safety candidate.", + latency_ms=0.0, + ) + + def model_status(self) -> dict[str, Any]: + status = self.transformers.status() + status["ollama"] = self.ollama.status() + status["provider_preference"] = list(default_provider_preference()) + return status diff --git a/app/models/policy/repair.py b/app/models/policy/repair.py new file mode 100644 index 0000000000000000000000000000000000000000..8ba4dc80ca45dc7bed56a2adba1297a3ac2420f8 --- /dev/null +++ b/app/models/policy/repair.py @@ -0,0 +1,55 @@ +"""Policy output repair utilities.""" + +from __future__ import annotations + +from app.common.enums import ActionType, DecisionMode, DoseBucket +from app.common.normalization import clamp_reward + +KNOWN_KEYS = { + "mode", + "action_type", + "target_drug", + "replacement_drug", + "dose_bucket", + "taper_days", + "monitoring_plan", + "candidate_id", + "confidence", + "rationale_brief", +} + + +def repair_partial_decision(payload: dict) -> dict: + repaired = {k: v for k, v in dict(payload).items() if k in KNOWN_KEYS} + repaired.setdefault("mode", DecisionMode.ABSTAIN_REVIEW.value) + if repaired.get("mode") == DecisionMode.ABSTAIN_REVIEW.value: + repaired["mode"] = DecisionMode.REVIEW.value + repaired.setdefault("action_type", ActionType.REQUEST_SPECIALIST_REVIEW.value) + repaired.setdefault("target_drug", None) + repaired.setdefault("replacement_drug", None) + repaired.setdefault("dose_bucket", DoseBucket.NA.value) + repaired.setdefault("taper_days", None) + repaired.setdefault("monitoring_plan", None) + repaired.setdefault("candidate_id", "cand_repair") + repaired.setdefault("rationale_brief", "repair_fallback") + + candidate_id = str(repaired.get("candidate_id", "cand_repair")).strip() + if not candidate_id.startswith("cand_"): + candidate_id = f"cand_{candidate_id or 'repair'}" + repaired["candidate_id"] = candidate_id + + try: + confidence = float(repaired.get("confidence", 0.5)) + except Exception: # noqa: BLE001 + confidence = 0.5 + repaired["confidence"] = clamp_reward(confidence) + + if repaired.get("taper_days") in {"", "null", "None"}: + repaired["taper_days"] = None + if repaired.get("target_drug") in {"", "null", "None"}: + repaired["target_drug"] = None + if repaired.get("replacement_drug") in {"", "null", "None"}: + repaired["replacement_drug"] = None + if repaired.get("monitoring_plan") in {"", "null", "None"}: + repaired["monitoring_plan"] = None + return repaired diff --git a/app/models/policy/safety_ranker.py b/app/models/policy/safety_ranker.py new file mode 100644 index 0000000000000000000000000000000000000000..6092174cb5fd2cd89623a23407445e30daada094 --- /dev/null +++ b/app/models/policy/safety_ranker.py @@ -0,0 +1,13 @@ +"""Rank candidates by heuristic safety/value.""" + +from __future__ import annotations + +from app.common.types import CandidateAction + + +def rank_candidates(candidates: list[CandidateAction]) -> list[CandidateAction]: + return sorted( + candidates, + key=lambda c: (c.legality_precheck, c.estimated_safety_delta, -c.uncertainty_score), + reverse=True, + ) diff --git a/app/models/policy/uncertainty.py b/app/models/policy/uncertainty.py new file mode 100644 index 0000000000000000000000000000000000000000..0965e67f8cc899bdb56a82f88d7fd18aab2416b2 --- /dev/null +++ b/app/models/policy/uncertainty.py @@ -0,0 +1,19 @@ +"""Uncertainty estimates.""" + +from __future__ import annotations + +from app.common.types import PolyGuardState + + +def estimate_uncertainty(state: PolyGuardState) -> float: + missing = 0 + total = 3 + if state.patient.labs.egfr is None: + missing += 1 + if state.patient.labs.ast is None: + missing += 1 + if state.patient.labs.alt is None: + missing += 1 + base = missing / total + conflict_penalty = min(0.3, 0.1 * len(state.unresolved_conflicts)) + return max(0.0, min(1.0, base + conflict_penalty)) diff --git a/app/models/retrieval/__init__.py b/app/models/retrieval/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..fc46f3e18028cf5d8b0108415becff80130cb2d1 --- /dev/null +++ b/app/models/retrieval/__init__.py @@ -0,0 +1,5 @@ +"""Retrieval package.""" + +from app.models.retrieval.retriever import retrieve + +__all__ = ["retrieve"] diff --git a/app/models/retrieval/chunker.py b/app/models/retrieval/chunker.py new file mode 100644 index 0000000000000000000000000000000000000000..1585a14a5599c98bd0b8a3735bee9c31dad11758 --- /dev/null +++ b/app/models/retrieval/chunker.py @@ -0,0 +1,7 @@ +"""Text chunker.""" + +from __future__ import annotations + + +def chunk_text(text: str, chunk_size: int = 256) -> list[str]: + return [text[i : i + chunk_size] for i in range(0, len(text), chunk_size)] or [text] diff --git a/app/models/retrieval/embedder.py b/app/models/retrieval/embedder.py new file mode 100644 index 0000000000000000000000000000000000000000..b75ec2ef7a8b4ae7b1ec4007ddd87cf6209ffd2b --- /dev/null +++ b/app/models/retrieval/embedder.py @@ -0,0 +1,8 @@ +"""Simple embedding surrogate.""" + +from __future__ import annotations + + +def embed_text(text: str) -> list[float]: + tokens = text.lower().split() + return [float((hash(token) % 1000) / 1000.0) for token in tokens[:32]] diff --git a/app/models/retrieval/index.py b/app/models/retrieval/index.py new file mode 100644 index 0000000000000000000000000000000000000000..36fd963bb91e1024eac199483b8749032d579143 --- /dev/null +++ b/app/models/retrieval/index.py @@ -0,0 +1,20 @@ +"""Local retrieval index builder.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from app.models.retrieval.chunker import chunk_text + + +def build_local_index(source_dir: Path, out_file: Path) -> int: + docs: list[dict[str, str]] = [] + for path in source_dir.rglob("*"): + if path.is_file() and path.suffix.lower() in {".txt", ".md", ".json"}: + text = path.read_text(encoding="utf-8", errors="ignore") + for idx, chunk in enumerate(chunk_text(text)): + docs.append({"id": f"{path.stem}_{idx}", "path": str(path), "text": chunk}) + out_file.parent.mkdir(parents=True, exist_ok=True) + out_file.write_text(json.dumps(docs, ensure_ascii=True, indent=2), encoding="utf-8") + return len(docs) diff --git a/app/models/retrieval/reranker.py b/app/models/retrieval/reranker.py new file mode 100644 index 0000000000000000000000000000000000000000..67b187546efa3153a31a1e39e362baff4bfbeaae --- /dev/null +++ b/app/models/retrieval/reranker.py @@ -0,0 +1,8 @@ +"""Result reranker.""" + +from __future__ import annotations + + +def rerank(results: list[dict], query: str) -> list[dict]: + qlen = max(1, len(query.split())) + return sorted(results, key=lambda item: abs(len(item.get("text", "")) - qlen * 24)) diff --git a/app/models/retrieval/retriever.py b/app/models/retrieval/retriever.py new file mode 100644 index 0000000000000000000000000000000000000000..9b408aea3f81a7ffb1ecf13b164b221c288b4548 --- /dev/null +++ b/app/models/retrieval/retriever.py @@ -0,0 +1,20 @@ +"""Simple lexical retriever.""" + +from __future__ import annotations + +import json +from pathlib import Path + + +def retrieve(index_file: Path, query: str, top_k: int = 5) -> list[dict]: + if not index_file.exists(): + return [] + docs = json.loads(index_file.read_text(encoding="utf-8")) + q = query.lower().split() + scored = [] + for doc in docs: + text = doc["text"].lower() + score = sum(1 for token in q if token in text) + scored.append((score, doc)) + scored.sort(key=lambda x: x[0], reverse=True) + return [doc for score, doc in scored[:top_k] if score > 0] diff --git a/app/models/tabular/__init__.py b/app/models/tabular/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..36ddce1510cf1406acc5ad0ca9cbc9ca1150f435 --- /dev/null +++ b/app/models/tabular/__init__.py @@ -0,0 +1,5 @@ +"""Tabular models package.""" + +from app.models.tabular.infer import infer_tabular_risk + +__all__ = ["infer_tabular_risk"] diff --git a/app/models/tabular/calibration.py b/app/models/tabular/calibration.py new file mode 100644 index 0000000000000000000000000000000000000000..8c0754ade57ebfc5af11695eba4e02c62112422f --- /dev/null +++ b/app/models/tabular/calibration.py @@ -0,0 +1,9 @@ +"""Calibration helpers.""" + +from __future__ import annotations + + +def calibrate_prob(prob: float, temperature: float = 1.0) -> float: + if temperature <= 0: + return prob + return max(0.0, min(1.0, prob ** (1.0 / temperature))) diff --git a/app/models/tabular/features.py b/app/models/tabular/features.py new file mode 100644 index 0000000000000000000000000000000000000000..ca8f6fa0763407d854ab18d25402ad4b7b0061b0 --- /dev/null +++ b/app/models/tabular/features.py @@ -0,0 +1,22 @@ +"""Tabular feature extraction.""" + +from __future__ import annotations + +from app.common.types import PatientProfile + + +def build_tabular_features(patient: PatientProfile) -> dict[str, float]: + return { + "age": float(patient.age), + "med_count": float(len(patient.medications)), + "frailty": float(patient.frailty_score), + "adherence": float(patient.adherence_estimate), + "egfr": float(patient.labs.egfr or 60.0), + "ast": float(patient.labs.ast or 30.0), + "alt": float(patient.labs.alt or 30.0), + "inr": float(patient.labs.inr or 1.2), + "glucose": float(patient.labs.glucose or 110.0), + "specialist_conflict_count": float(len(patient.specialist_conflicts)), + "ade_history_count": float(len(patient.prior_ade_history)), + "monitoring_gap_count": float(len(patient.monitoring_gaps)), + } diff --git a/app/models/tabular/infer.py b/app/models/tabular/infer.py new file mode 100644 index 0000000000000000000000000000000000000000..c177389863ac5785ffd275d964ec9af51686a3b9 --- /dev/null +++ b/app/models/tabular/infer.py @@ -0,0 +1,25 @@ +"""Tabular inference.""" + +from __future__ import annotations + +import pickle +from pathlib import Path + +from app.common.types import PatientProfile +from app.models.tabular.features import build_tabular_features +from app.models.tabular.risk_heads import predict_risk_heads + + +def infer_tabular_risk(patient: PatientProfile) -> dict[str, float]: + features = build_tabular_features(patient) + model_path = Path("outputs/models/tabular_risk.pkl") + if not model_path.exists(): + return predict_risk_heads(features) + with model_path.open("rb") as f: + artifact = pickle.load(f) + model = artifact.get("model") + feature_keys = artifact.get("feature_keys", list(features.keys())) + target_keys = artifact.get("target_keys", []) + x = [[float(features.get(k, 0.0)) for k in feature_keys]] + preds = model.predict(x)[0] + return {str(k): float(v) for k, v in zip(target_keys, preds)} diff --git a/app/models/tabular/risk_heads.py b/app/models/tabular/risk_heads.py new file mode 100644 index 0000000000000000000000000000000000000000..970c9a77a2f4e1afa362d7a571166b6d84d16dc7 --- /dev/null +++ b/app/models/tabular/risk_heads.py @@ -0,0 +1,27 @@ +"""Tabular risk heads.""" + +from __future__ import annotations + + +def predict_risk_heads(features: dict[str, float]) -> dict[str, float]: + med_count = features.get("med_count", 0.0) + frailty = features.get("frailty", 0.5) + adherence = features.get("adherence", 0.7) + monitoring = features.get("monitoring_gap_count", 0.0) + ade_history = features.get("ade_history_count", 0.0) + egfr = features.get("egfr", 60.0) + ast = features.get("ast", 30.0) + alt = features.get("alt", 30.0) + ade = min(1.0, 0.18 + med_count / 19.0 + frailty * 0.27 + monitoring * 0.04) + hosp = min(1.0, 0.1 + ade * 0.58 + (1.0 - adherence) * 0.2 + ade_history * 0.05) + falls = min(1.0, 0.1 + frailty * 0.48 + med_count / 33.0 + ade_history * 0.06) + organ_risk = max(0.0, (35.0 - egfr) / 35.0) + max(0.0, (ast - 80.0) / 80.0) + max(0.0, (alt - 80.0) / 80.0) + destabilization = min(1.0, 0.16 + (1.0 - adherence) * 0.52 + organ_risk * 0.22) + burden = min(1.0, med_count / 12.0) + return { + "ade_proxy": ade, + "hospitalization_proxy": hosp, + "falls_proxy": falls, + "destabilization_proxy": destabilization, + "burden_proxy": burden, + } diff --git a/app/models/tabular/train.py b/app/models/tabular/train.py new file mode 100644 index 0000000000000000000000000000000000000000..a6f9188649f527a1b5b046ed00f0236ff93bd5b7 --- /dev/null +++ b/app/models/tabular/train.py @@ -0,0 +1,60 @@ +"""Tabular model training placeholder.""" + +from __future__ import annotations + +import pickle +from pathlib import Path + +import numpy as np +from sklearn.ensemble import RandomForestRegressor +from sklearn.multioutput import MultiOutputRegressor + +from app.common.enums import Difficulty +from app.models.tabular.features import build_tabular_features +from app.models.tabular.risk_heads import predict_risk_heads +from app.simulator.patient_generator import generate_patient_profile + + +TARGET_KEYS = [ + "ade_proxy", + "hospitalization_proxy", + "falls_proxy", + "destabilization_proxy", + "burden_proxy", +] + + +def train_tabular_model(dataset_size: int) -> dict[str, float | str]: + x_rows: list[list[float]] = [] + y_rows: list[list[float]] = [] + for i in range(dataset_size): + if i < dataset_size // 3: + difficulty = Difficulty.EASY + elif i < (dataset_size * 2) // 3: + difficulty = Difficulty.MEDIUM + else: + difficulty = Difficulty.HARD + patient = generate_patient_profile(seed=3000 + i, difficulty=difficulty) + features = build_tabular_features(patient) + targets = predict_risk_heads(features) + x_rows.append(list(features.values())) + y_rows.append([targets[k] for k in TARGET_KEYS]) + + x = np.array(x_rows, dtype=float) + y = np.array(y_rows, dtype=float) + model = MultiOutputRegressor(RandomForestRegressor(n_estimators=80, random_state=42)) + model.fit(x, y) + predictions = model.predict(x) + mae = float(np.mean(np.abs(predictions - y))) + + artifact = {"model": model, "feature_keys": list(build_tabular_features(generate_patient_profile(seed=1, difficulty=Difficulty.EASY)).keys()), "target_keys": TARGET_KEYS} + path = Path("outputs/models/tabular_risk.pkl") + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("wb") as f: + pickle.dump(artifact, f) + return { + "dataset_size": float(dataset_size), + "status": "trained", + "train_mae": round(mae, 4), + "model_path": str(path), + } diff --git a/app/simulator/__init__.py b/app/simulator/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b8b2caa545d21d2a19848c59174fe100b6aff35d --- /dev/null +++ b/app/simulator/__init__.py @@ -0,0 +1,5 @@ +"""Simulator package.""" + +from app.simulator.scenario_generator import build_scenario_library, generate_patient_scenario + +__all__ = ["build_scenario_library", "generate_patient_scenario"] diff --git a/app/simulator/ade_event_model.py b/app/simulator/ade_event_model.py new file mode 100644 index 0000000000000000000000000000000000000000..c72e2b4099eac055dfb2986a719bbf2b805521e5 --- /dev/null +++ b/app/simulator/ade_event_model.py @@ -0,0 +1,7 @@ +"""ADE event risk proxy.""" + +from __future__ import annotations + + +def ade_risk_proxy(ddi_risk: float, frailty_score: float) -> float: + return max(0.0, min(1.0, 0.6 * ddi_risk + 0.4 * frailty_score)) diff --git a/app/simulator/adherence_dynamics.py b/app/simulator/adherence_dynamics.py new file mode 100644 index 0000000000000000000000000000000000000000..d6fcddbe755b443bfdce0220724ecddd298e5bd0 --- /dev/null +++ b/app/simulator/adherence_dynamics.py @@ -0,0 +1,7 @@ +"""Adherence dynamics.""" + +from __future__ import annotations + + +def update_adherence(current: float, burden_score: float) -> float: + return max(0.05, min(0.99, current - 0.1 * burden_score + 0.05)) diff --git a/app/simulator/burden_model.py b/app/simulator/burden_model.py new file mode 100644 index 0000000000000000000000000000000000000000..4214a228d60eac99b88af04d9eb7ef41778933b4 --- /dev/null +++ b/app/simulator/burden_model.py @@ -0,0 +1,7 @@ +"""Medication burden model.""" + +from __future__ import annotations + + +def burden_score(med_count: int, sedative_count: int = 0) -> float: + return max(0.0, min(1.0, med_count / 12.0 + sedative_count * 0.05)) diff --git a/app/simulator/ddi_event_model.py b/app/simulator/ddi_event_model.py new file mode 100644 index 0000000000000000000000000000000000000000..72ed900e43ab56e4c8fc815d1c7917fe5fd41e98 --- /dev/null +++ b/app/simulator/ddi_event_model.py @@ -0,0 +1,7 @@ +"""DDI event risk model.""" + +from __future__ import annotations + + +def ddi_risk_score(num_high_risk_pairs: int) -> float: + return max(0.0, min(1.0, 0.2 + 0.15 * num_high_risk_pairs)) diff --git a/app/simulator/disease_dynamics.py b/app/simulator/disease_dynamics.py new file mode 100644 index 0000000000000000000000000000000000000000..36d6d7033d36d755dbf5fb4aa0696824f5cb9c1a --- /dev/null +++ b/app/simulator/disease_dynamics.py @@ -0,0 +1,7 @@ +"""Disease stability proxy dynamics.""" + +from __future__ import annotations + + +def disease_stability_proxy(burden_score: float, adherence: float) -> float: + return max(0.0, min(1.0, 0.7 * adherence + 0.3 * (1.0 - burden_score))) diff --git a/app/simulator/dose_response.py b/app/simulator/dose_response.py new file mode 100644 index 0000000000000000000000000000000000000000..9edb9d9218d0e9b5a29a4b45f261eb81bcdc3feb --- /dev/null +++ b/app/simulator/dose_response.py @@ -0,0 +1,8 @@ +"""Dose response proxy model.""" + +from __future__ import annotations + + +def dose_response_score(dose_level: float, target: float = 0.5) -> float: + distance = abs(dose_level - target) + return max(0.0, min(1.0, 1.0 - distance * 2)) diff --git a/app/simulator/lab_dynamics.py b/app/simulator/lab_dynamics.py new file mode 100644 index 0000000000000000000000000000000000000000..d9e7265824385b93db0789267590926407c61f3e --- /dev/null +++ b/app/simulator/lab_dynamics.py @@ -0,0 +1,9 @@ +"""Lab dynamics.""" + +from __future__ import annotations + + +def renal_drift(egfr: float | None, burden_score: float) -> float | None: + if egfr is None: + return None + return max(5.0, min(120.0, egfr - 2.0 * burden_score)) diff --git a/app/simulator/latent_confounders.py b/app/simulator/latent_confounders.py new file mode 100644 index 0000000000000000000000000000000000000000..bf5703ae478d94a78c3f31e50be449cc639b77d1 --- /dev/null +++ b/app/simulator/latent_confounders.py @@ -0,0 +1,10 @@ +"""Latent confounder utilities.""" + +from __future__ import annotations + +import random + + +def sample_confounder(seed: int) -> float: + random.seed(seed) + return round(random.uniform(0.0, 1.0), 3) diff --git a/app/simulator/medication_effects.py b/app/simulator/medication_effects.py new file mode 100644 index 0000000000000000000000000000000000000000..1e29ca204fb5b0c185c9bfe21541820ce75d1f7d --- /dev/null +++ b/app/simulator/medication_effects.py @@ -0,0 +1,7 @@ +"""Medication effect proxies.""" + +from __future__ import annotations + + +def burden_from_med_count(med_count: int) -> float: + return max(0.0, min(1.0, med_count / 12.0)) diff --git a/app/simulator/patient_generator.py b/app/simulator/patient_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..da5ae8d93637f2a413e7f37baf656540dd1aa6a9 --- /dev/null +++ b/app/simulator/patient_generator.py @@ -0,0 +1,76 @@ +"""Synthetic patient generation.""" + +from __future__ import annotations + +import random + +from app.common.enums import Difficulty, DoseBucket +from app.common.types import LabSummary, Medication, PatientProfile + +_DRUG_POOL = [ + ("warfarin_like", "anticoagulant"), + ("benzodiazepine_like", "sedative"), + ("metformin_like", "glucose_lowering"), + ("statin_like", "lipid_lowering"), + ("ace_inhibitor_like", "antihypertensive"), + ("nsaid_like", "analgesic"), + ("opioid_like", "analgesic"), + ("ssri_like", "antidepressant"), + ("ppi_like", "gastro"), + ("beta_blocker_like", "antihypertensive"), +] + + +def generate_patient_profile(seed: int, difficulty: Difficulty, patient_id: str | None = None) -> PatientProfile: + random.seed(seed) + med_count = {Difficulty.EASY: 5, Difficulty.MEDIUM: 8, Difficulty.HARD: 10}[difficulty] + selected = random.sample(_DRUG_POOL, k=med_count) + medications = [ + Medication( + drug=drug, + class_name=cls, + dose_bucket=random.choice([DoseBucket.LOW, DoseBucket.MEDIUM, DoseBucket.HIGH]), + indication=f"indication_{idx}", + requires_taper=drug in {"benzodiazepine_like", "opioid_like"}, + ) + for idx, (drug, cls) in enumerate(selected) + ] + return PatientProfile( + patient_id=patient_id or f"patient_{seed}", + age=random.randint(55, 90), + sex=random.choice(["F", "M"]), + comorbidities=random.sample( + ["htn", "dm2", "afib", "ckd", "copd", "depression", "fall_risk"], k=3 + ), + medications=medications, + labs=LabSummary( + egfr=round(random.uniform(20, 95), 1), + ast=round(random.uniform(10, 120), 1), + alt=round(random.uniform(10, 120), 1), + inr=round(random.uniform(1.0, 4.0), 2), + glucose=round(random.uniform(70, 280), 1), + ), + vitals={ + "sbp": random.randint(100, 180), + "dbp": random.randint(60, 105), + "hr": random.randint(50, 120), + "egfr_trend": round(random.uniform(-8.0, 3.0), 2), + "inr_trend": round(random.uniform(-0.5, 0.7), 2), + "glucose_trend": round(random.uniform(-35.0, 45.0), 2), + }, + specialist_conflicts=[ + "duplicate_analgesic_strategy", + "cardio_vs_pain_med_conflict", + ] + if difficulty != Difficulty.EASY + else [], + prior_ade_history=["fall_event", "sedation_event"] if difficulty == Difficulty.HARD else [], + frailty_score=round(random.uniform(0.1, 0.9), 2), + adherence_estimate=round(random.uniform(0.4, 0.95), 2), + latent_confounders={ + "metabolism_variability": round(random.uniform(0.1, 0.9), 3), + "social_support_risk": round(random.uniform(0.0, 1.0), 3), + "polyprovider_fragmentation": round(random.uniform(0.1, 0.95), 3), + }, + monitoring_gaps=["no_recent_inr", "missing_liver_panel"] if difficulty == Difficulty.HARD else ["missing_followup_bp"], + ) diff --git a/app/simulator/scenario_generator.py b/app/simulator/scenario_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..b6d4b5b3663af35aadb0b53a02cee45014df5a79 --- /dev/null +++ b/app/simulator/scenario_generator.py @@ -0,0 +1,30 @@ +"""Scenario generation entrypoints.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional + +from app.common.enums import Difficulty +from app.common.types import PatientProfile +from app.simulator.patient_generator import generate_patient_profile + + +def generate_patient_scenario(difficulty: Difficulty, patient_id: Optional[str], seed: int) -> PatientProfile: + return generate_patient_profile(seed=seed, difficulty=difficulty, patient_id=patient_id) + + +def build_scenario_library(root: Path, easy: int, medium: int, hard: int, seed: int = 42) -> None: + counts = { + Difficulty.EASY: easy, + Difficulty.MEDIUM: medium, + Difficulty.HARD: hard, + } + for diff, count in counts.items(): + out_dir = root / "data" / "scenarios" / diff.value + out_dir.mkdir(parents=True, exist_ok=True) + for i in range(count): + profile = generate_patient_profile(seed=seed + i, difficulty=diff, patient_id=f"{diff.value}_{i:04d}") + target = out_dir / f"{profile.patient_id}.json" + target.write_text(json.dumps(profile.model_dump(mode="json"), ensure_ascii=True, indent=2), encoding="utf-8") diff --git a/app/simulator/uncertainty_model.py b/app/simulator/uncertainty_model.py new file mode 100644 index 0000000000000000000000000000000000000000..b55c3dc406ee3963db51ec3d746cc0fdeac267d9 --- /dev/null +++ b/app/simulator/uncertainty_model.py @@ -0,0 +1,9 @@ +"""Uncertainty proxy model.""" + +from __future__ import annotations + + +def uncertainty_from_missing(missing_fields: int, total_fields: int = 5) -> float: + if total_fields <= 0: + return 0.5 + return max(0.0, min(1.0, missing_fields / total_fields)) diff --git a/app/simulator/utilization_risk.py b/app/simulator/utilization_risk.py new file mode 100644 index 0000000000000000000000000000000000000000..6c68dbe532b6be1a31380ed275660c58c24d7924 --- /dev/null +++ b/app/simulator/utilization_risk.py @@ -0,0 +1,7 @@ +"""Hospitalization/utilization risk proxy.""" + +from __future__ import annotations + + +def hospitalization_proxy(ade_risk: float, disease_instability: float) -> float: + return max(0.0, min(1.0, 0.5 * ade_risk + 0.5 * (1.0 - disease_instability))) diff --git a/app/training/__init__.py b/app/training/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c2dc1107b6af3dc184e1f432ee5ca838fb3bacb0 --- /dev/null +++ b/app/training/__init__.py @@ -0,0 +1,19 @@ +"""Training package.""" + +from app.training.planner_grpo import train_planner_grpo +from app.training.supervisor_grpo import train_supervisor_grpo +from app.training.dosing_grpo import train_dosing_grpo +from app.training.grpo_trl import GRPOTrlConfig, run_grpo_trl +from app.training.sft_train import run_sft_train +from app.training.sft_trl import SFTRunConfig, run_sft_trl + +__all__ = [ + "run_sft_train", + "train_planner_grpo", + "train_supervisor_grpo", + "train_dosing_grpo", + "GRPOTrlConfig", + "run_grpo_trl", + "SFTRunConfig", + "run_sft_trl", +] diff --git a/app/training/callbacks.py b/app/training/callbacks.py new file mode 100644 index 0000000000000000000000000000000000000000..85493be166ef28618e1bca874f9d09fcd7784edd --- /dev/null +++ b/app/training/callbacks.py @@ -0,0 +1,13 @@ +"""Training callbacks.""" + +from __future__ import annotations + +from typing import Callable + + +def every_n_steps(n: int, fn: Callable[[int], None]) -> Callable[[int], None]: + def _callback(step: int) -> None: + if step % n == 0: + fn(step) + + return _callback diff --git a/app/training/checkpointing.py b/app/training/checkpointing.py new file mode 100644 index 0000000000000000000000000000000000000000..ed739424aff3f26d38f5c4e6d7642aa1e4faa7b7 --- /dev/null +++ b/app/training/checkpointing.py @@ -0,0 +1,17 @@ +"""Checkpoint utilities.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +def save_checkpoint(path: Path, payload: dict[str, Any]) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=True, indent=2), encoding="utf-8") + return path + + +def load_checkpoint(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) diff --git a/app/training/dosing_grpo.py b/app/training/dosing_grpo.py new file mode 100644 index 0000000000000000000000000000000000000000..6fa592f8a9d46a6b736aed3bf172bb822f4a5646 --- /dev/null +++ b/app/training/dosing_grpo.py @@ -0,0 +1,75 @@ +"""Dosing GRPO-like trainer.""" + +from __future__ import annotations + +from pathlib import Path + +from app.common.enums import ActionType +from app.env.env_core import PolyGuardEnv +from app.training.checkpointing import save_checkpoint +from app.training.metrics import TrainingMetrics +from app.training.replay_buffer import ReplayBuffer, failure_mining_summary + + +def train_dosing_grpo(episodes: int = 10, checkpoint_dir: Path | None = None) -> dict: + env = PolyGuardEnv() + metrics = TrainingMetrics() + replay = ReplayBuffer() + + for i in range(episodes): + env.reset(seed=300 + i, difficulty="hard") + done = False + while not done: + candidates = env.get_legal_actions() + dose_candidates = [ + c + for c in candidates + if c["action_type"] in {ActionType.REDUCE_DOSE_BUCKET.value, ActionType.INCREASE_DOSE_BUCKET.value, ActionType.ORDER_MONITORING_AND_WAIT.value} + ] + action = dose_candidates[0] if dose_candidates else candidates[0] + pre_burden = env.state.burden_score + _, reward, done, info = env.step(action) + legal = info["safety_report"]["legal"] + severe = len(info["safety_report"]["violations"]) > 1 + abstain = action["action_type"].startswith("REQUEST_") + reward_components = info.get("reward_breakdown", {}) + primary_channels = info.get("primary_reward_channels", {}) + failure_reasons = info.get("failure_reasons", []) + metrics.add( + reward, + legal=legal, + severe_violation=severe, + abstain=abstain, + episode_len=env.state.step_count, + reward_components=reward_components, + success=done and info.get("termination_reason") == "safe_resolution", + burden_delta=pre_burden - env.state.burden_score, + safety_delta=float(reward_components.get("safety_delta_score", 0.0)), + dosing_quality=float(reward_components.get("dosing_quality_score", 0.0)), + process_fidelity=float(reward_components.get("process_fidelity_score", 0.0)), + exploit_detected=bool(info.get("anti_cheat_reasons")), + timeout=bool(info.get("step_timeout") or info.get("termination_reason") == "wall_clock_timeout"), + failure_visible=bool(failure_reasons), + invalid_actions=int(info.get("invalid_action_count", 0)), + primary_channels=primary_channels if isinstance(primary_channels, dict) else None, + ) + replay.add( + { + "episode": i, + "step": env.state.step_count, + "reward": reward, + "legal": legal, + "termination_reason": info.get("termination_reason"), + "failure_reasons": failure_reasons, + "final_action": action, + "primary_reward_channels": primary_channels, + } + ) + + summary = metrics.summary() + summary["failure_mining"] = failure_mining_summary(replay.records) + if checkpoint_dir: + save_checkpoint(checkpoint_dir / "dosing_grpo.json", summary) + replay.dump_jsonl(checkpoint_dir / "dosing_replay.jsonl") + replay.dump_failures_json(checkpoint_dir / "dosing_failures.json") + return summary diff --git a/app/training/generation.py b/app/training/generation.py new file mode 100644 index 0000000000000000000000000000000000000000..4497ddfd68a9a5fa4e302b47c00644cc19a52df9 --- /dev/null +++ b/app/training/generation.py @@ -0,0 +1,11 @@ +"""Generation helper placeholders.""" + +from __future__ import annotations + +import json + +from app.common.types import PolyGuardAction + + +def generate_structured_action(action: PolyGuardAction) -> str: + return json.dumps(action.model_dump(mode="json"), ensure_ascii=True) diff --git a/app/training/grpo_dosing.py b/app/training/grpo_dosing.py new file mode 100644 index 0000000000000000000000000000000000000000..51ad81cf7d37ffa0a86ebfcbfcda8f15efddbb8a --- /dev/null +++ b/app/training/grpo_dosing.py @@ -0,0 +1,7 @@ +"""Canonical GRPO dosing training entrypoint.""" + +from __future__ import annotations + +from app.training.dosing_grpo import train_dosing_grpo + +__all__ = ["train_dosing_grpo"] diff --git a/app/training/grpo_experiment.py b/app/training/grpo_experiment.py new file mode 100644 index 0000000000000000000000000000000000000000..cb121bd53d1c7dff99b0ae76882cdfe73da16d61 --- /dev/null +++ b/app/training/grpo_experiment.py @@ -0,0 +1,97 @@ +"""GRPO-style experiments with policy-stack ablations.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +from app.agents.orchestrator import Orchestrator +from app.env.env_core import PolyGuardEnv +from app.training.metrics import TrainingMetrics +from app.training.replay_buffer import ReplayBuffer, failure_mining_summary + + +def run_policy_stack_rollout( + policy_stack: str, + episodes: int, + checkpoint_dir: Path | None = None, + seed_offset: int = 1_000, +) -> dict[str, Any]: + previous = os.getenv("POLYGUARD_POLICY_STACK") + os.environ["POLYGUARD_POLICY_STACK"] = policy_stack + + env = PolyGuardEnv() + orchestrator = Orchestrator(env=env) + metrics = TrainingMetrics() + replay = ReplayBuffer() + + # Start small (easy/medium) before introducing harder environments. + schedule = ["easy", "medium", "medium", "hard"] + + for i in range(episodes): + difficulty = schedule[min(len(schedule) - 1, (i * len(schedule)) // max(1, episodes))] + env.reset(seed=seed_offset + i, difficulty=difficulty) + done = False + while not done: + out = orchestrator.run_step() + done = bool(out.get("done", False)) + info = out.get("info", {}) + reward_components = info.get("reward_breakdown", {}) if isinstance(info, dict) else {} + primary_channels = info.get("primary_reward_channels", {}) if isinstance(info, dict) else {} + failure_reasons = info.get("failure_reasons", []) if isinstance(info, dict) else [] + metrics.add( + float(out.get("reward", 0.5)), + legal=bool(out.get("critic", {}).get("legal", False)), + severe_violation=len(out.get("critic", {}).get("violations", [])) > 1, + abstain=str(out.get("final_action", {}).get("action_type", "")).startswith("REQUEST_"), + episode_len=env.state.step_count, + reward_components=reward_components if isinstance(reward_components, dict) else None, + success=done and info.get("termination_reason") == "safe_resolution", + burden_delta=0.0, + safety_delta=float((reward_components or {}).get("safety_delta_score", 0.0)), + dosing_quality=float((reward_components or {}).get("dosing_quality_score", 0.0)), + process_fidelity=float((reward_components or {}).get("process_fidelity_score", 0.0)), + exploit_detected=bool(info.get("anti_cheat_reasons")), + timeout=bool(info.get("step_timeout") or info.get("termination_reason") == "wall_clock_timeout"), + failure_visible=bool(failure_reasons), + invalid_actions=int(info.get("invalid_action_count", 0)), + primary_channels=primary_channels if isinstance(primary_channels, dict) else None, + ) + replay.add( + { + "policy_stack": policy_stack, + "episode": i, + "step": env.state.step_count, + "reward": out.get("reward", 0.5), + "final_action": out.get("final_action", {}), + "termination_reason": info.get("termination_reason"), + "failure_reasons": failure_reasons, + "primary_reward_channels": primary_channels, + } + ) + + summary = metrics.summary() + summary["policy_stack"] = policy_stack + summary["failure_mining"] = failure_mining_summary(replay.records) + + if checkpoint_dir is not None: + checkpoint_dir.mkdir(parents=True, exist_ok=True) + replay.dump_jsonl(checkpoint_dir / f"{policy_stack.replace('+', '_')}_replay.jsonl") + replay.dump_failures_json(checkpoint_dir / f"{policy_stack.replace('+', '_')}_failures.json") + + if previous is None: + os.environ.pop("POLYGUARD_POLICY_STACK", None) + else: + os.environ["POLYGUARD_POLICY_STACK"] = previous + + return summary + + +def probe_trl_grpo_support() -> dict[str, Any]: + try: + from trl import GRPOTrainer # noqa: F401 + + return {"available": True, "backend": "trl", "note": "GRPOTrainer import successful."} + except Exception as exc: # noqa: BLE001 + return {"available": False, "backend": "trl", "note": f"GRPOTrainer unavailable: {exc}"} diff --git a/app/training/grpo_planner.py b/app/training/grpo_planner.py new file mode 100644 index 0000000000000000000000000000000000000000..1dfebe1702f981dc7de307f8c8123ba69799b7d0 --- /dev/null +++ b/app/training/grpo_planner.py @@ -0,0 +1,7 @@ +"""Canonical GRPO planner training entrypoint.""" + +from __future__ import annotations + +from app.training.planner_grpo import train_planner_grpo + +__all__ = ["train_planner_grpo"] diff --git a/app/training/grpo_supervisor.py b/app/training/grpo_supervisor.py new file mode 100644 index 0000000000000000000000000000000000000000..f5025c530f2f0abede2e3a1608efa1d8bd0df0d0 --- /dev/null +++ b/app/training/grpo_supervisor.py @@ -0,0 +1,7 @@ +"""Canonical GRPO supervisor training entrypoint.""" + +from __future__ import annotations + +from app.training.supervisor_grpo import train_supervisor_grpo + +__all__ = ["train_supervisor_grpo"] diff --git a/app/training/grpo_trl.py b/app/training/grpo_trl.py new file mode 100644 index 0000000000000000000000000000000000000000..ae1505fd006798f7e1f238064484644ae8c1f99b --- /dev/null +++ b/app/training/grpo_trl.py @@ -0,0 +1,441 @@ +"""TRL GRPO training with environment-backed reward verification.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import os +from pathlib import Path +import re +from typing import Any + +from app.common.normalization import clamp_reward +from app.common.constants import PRIMARY_REWARD_KEYS, REQUIRED_REWARD_KEYS +from app.common.enums import SubEnvironment +from app.env.env_core import PolyGuardEnv +from app.training.checkpointing import save_checkpoint +from app.training.lora_utils import build_lora_config +from app.training.model_registry import register_model_run +from app.training.unsloth_loader import load_unsloth_model + + +@dataclass(slots=True) +class GRPOTrlConfig: + model_id: str + prompts_path: Path + output_dir: Path + max_prompts: int = 256 + max_steps: int = 30 + epochs: float = 1.0 + per_device_batch_size: int = 1 + gradient_accumulation_steps: int = 1 + num_generations: int = 2 + learning_rate: float = 1e-6 + max_prompt_length: int = 512 + max_completion_length: int = 96 + temperature: float = 0.7 + seed: int = 42 + use_unsloth: bool = True + force_fallback: bool = False + allow_fallback: bool = False + + +def _load_jsonl(path: Path) -> list[dict[str, Any]]: + if not path.exists(): + return [] + rows: list[dict[str, Any]] = [] + with path.open("r", encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if not line: + continue + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(payload, dict): + rows.append(payload) + return rows + + +def _prompt_to_text(prompt: dict[str, Any], task: str) -> str: + patient_id = str(prompt.get("patient_id", prompt.get("patient_summary", {}).get("patient_id", "unknown"))) + medications = prompt.get("medications", prompt.get("medication_table", [])) + candidates = prompt.get("candidates", prompt.get("candidate_set", [])) + med_names = [str(item.get("drug", "unknown")) for item in medications if isinstance(item, dict)] + candidate_summaries = [] + for item in candidates: + if not isinstance(item, dict): + continue + candidate_summaries.append( + { + "candidate_id": item.get("candidate_id"), + "action_type": item.get("action_type"), + "target_drug": item.get("target_drug"), + "replacement_drug": item.get("replacement_drug"), + "legality_precheck": item.get("legality_precheck"), + "estimated_safety_delta": item.get("estimated_safety_delta"), + "uncertainty_score": item.get("uncertainty_score"), + } + ) + packed = { + "task": task, + "patient_id": patient_id, + "medications": med_names, + "candidates": candidate_summaries, + "instruction": "Return exactly one candidate_id and a concise rationale.", + "format": "candidate_id=; rationale=", + } + return json.dumps(packed, ensure_ascii=True) + + +def _to_sub_environment(value: str) -> str: + try: + return SubEnvironment(value).value + except Exception: # noqa: BLE001 + return SubEnvironment.REGIMEN_RISK.value + + +def _build_dataset_records(rows: list[dict[str, Any]], max_prompts: int) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + selected_rows = rows if max_prompts <= 0 else rows[:max_prompts] + for idx, row in enumerate(selected_rows): + prompt = row.get("prompt", {}) if isinstance(row.get("prompt"), dict) else {} + task = str(row.get("task", "planner_action_selection")) + patient_summary = prompt.get("patient_summary", {}) if isinstance(prompt.get("patient_summary"), dict) else {} + patient_id = str(prompt.get("patient_id", patient_summary.get("patient_id", f"case_{idx:05d}"))) + sub_environment = str( + prompt.get("sub_environment", patient_summary.get("sub_environment", SubEnvironment.REGIMEN_RISK.value)) + ) + candidate_rows = prompt.get("candidates", prompt.get("candidate_set", [])) + candidate_ids = [ + str(item.get("candidate_id")) + for item in candidate_rows + if isinstance(item, dict) and item.get("candidate_id") + ] + if not candidate_ids: + candidate_ids = ["cand_01"] + + records.append( + { + "prompt": _prompt_to_text(prompt, task=task), + "task": task, + "patient_id": patient_id, + "scenario_seed": int(row.get("scenario_seed", 10_000 + idx)), + "difficulty": str(row.get("difficulty", "medium")), + "sub_environment": _to_sub_environment(sub_environment), + "candidate_ids": candidate_ids, + } + ) + return records + + +class EnvironmentRewardVerifier: + """Computes GRPO rewards via env transitions and logs reward components.""" + + def __init__(self, log_path: Path) -> None: + self.__name__ = "environment_reward_verifier" + self.log_path = log_path + self.log_path.parent.mkdir(parents=True, exist_ok=True) + self.log_path.write_text("", encoding="utf-8") + self.count = 0 + self.total_reward = 0.0 + self.component_totals: dict[str, float] = {key: 0.0 for key in REQUIRED_REWARD_KEYS} + self.primary_totals: dict[str, float] = {key: 0.0 for key in PRIMARY_REWARD_KEYS} + + @staticmethod + def _extract_candidate_id(completion: Any, allowed: list[str]) -> str | None: + text = "" + if isinstance(completion, str): + text = completion + elif isinstance(completion, list) and completion: + item = completion[-1] + if isinstance(item, dict): + text = str(item.get("content", "")) + else: + text = str(item) + else: + text = str(completion) + + matches = re.findall(r"cand_\d+", text.lower()) + if matches: + allowed_set = {item.lower() for item in allowed} + for match in matches: + if not allowed_set or match in allowed_set: + return match + return allowed[0].lower() if allowed else None + + def _append_log(self, row: dict[str, Any]) -> None: + with self.log_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(row, ensure_ascii=True) + "\n") + + def __call__(self, prompts: list[Any], completions: list[Any], **kwargs: Any) -> list[float]: + seeds = kwargs.get("scenario_seed", []) + difficulties = kwargs.get("difficulty", []) + sub_envs = kwargs.get("sub_environment", []) + candidate_id_rows = kwargs.get("candidate_ids", []) + patient_ids = kwargs.get("patient_id", []) + tasks = kwargs.get("task", []) + + rewards: list[float] = [] + for idx, _ in enumerate(prompts): + env = PolyGuardEnv() + seed = int(seeds[idx]) if idx < len(seeds) else 42 + idx + difficulty = str(difficulties[idx]) if idx < len(difficulties) else "medium" + sub_env = str(sub_envs[idx]) if idx < len(sub_envs) else SubEnvironment.REGIMEN_RISK.value + allowed_candidate_ids = candidate_id_rows[idx] if idx < len(candidate_id_rows) else [] + if not isinstance(allowed_candidate_ids, list): + allowed_candidate_ids = [] + + try: + env.reset(seed=seed, difficulty=difficulty, sub_environment=sub_env) + except Exception: + env.reset(seed=seed, difficulty="medium", sub_environment=SubEnvironment.REGIMEN_RISK.value) + + generated_candidate = self._extract_candidate_id( + completions[idx] if idx < len(completions) else "", allowed=allowed_candidate_ids + ) + + legal_actions = env.get_legal_actions() + all_candidates = env.get_candidate_actions() + legal_by_id = {str(item.get("candidate_id", "")).lower(): item for item in legal_actions} + all_by_id = {str(item.get("candidate_id", "")).lower(): item for item in all_candidates} + action = legal_by_id.get(str(generated_candidate or "").lower()) + if action is None: + action = all_by_id.get(str(generated_candidate or "").lower()) + if action is None and legal_actions: + action = legal_actions[0] + + if action is None: + reward = 0.001 + breakdown: dict[str, float] = {} + primary: dict[str, float] = {} + legal = False + termination = "no_action_available" + else: + _, env_reward, _, info = env.step(action) + breakdown = info.get("reward_breakdown", {}) if isinstance(info, dict) else {} + primary = info.get("primary_reward_channels", {}) if isinstance(info, dict) else {} + legal = bool((info.get("safety_report") or {}).get("legal")) if isinstance(info, dict) else False + termination = str(info.get("termination_reason", "")) if isinstance(info, dict) else "" + verifier_bonus = 0.95 if legal else 0.05 + reward = clamp_reward((float(env_reward) * 0.8) + (verifier_bonus * 0.2)) + + rewards.append(reward) + self.total_reward += reward + self.count += 1 + + for key in REQUIRED_REWARD_KEYS: + self.component_totals[key] += float(breakdown.get(key, 0.0)) + for key in PRIMARY_REWARD_KEYS: + self.primary_totals[key] += float(primary.get(key, 0.0)) + + self._append_log( + { + "idx": idx, + "task": str(tasks[idx]) if idx < len(tasks) else "planner_action_selection", + "patient_id": str(patient_ids[idx]) if idx < len(patient_ids) else "unknown", + "generated_candidate_id": generated_candidate, + "selected_candidate_id": action.get("candidate_id") if isinstance(action, dict) else None, + "legal": legal, + "reward": reward, + "reward_breakdown": breakdown, + "primary_reward_channels": primary, + "termination_reason": termination, + } + ) + + return rewards + + def summary(self) -> dict[str, Any]: + if self.count == 0: + return { + "count": 0, + "avg_reward": 0.0, + "avg_reward_components": {key: 0.0 for key in REQUIRED_REWARD_KEYS}, + "avg_primary_reward_channels": {key: 0.0 for key in PRIMARY_REWARD_KEYS}, + } + return { + "count": self.count, + "avg_reward": clamp_reward(self.total_reward / self.count), + "avg_reward_components": { + key: clamp_reward(self.component_totals[key] / self.count) for key in REQUIRED_REWARD_KEYS + }, + "avg_primary_reward_channels": { + key: clamp_reward(self.primary_totals[key] / self.count) for key in PRIMARY_REWARD_KEYS + }, + } + + +def _fallback_completion(candidate_ids: list[str], idx: int) -> str: + if not candidate_ids: + return "candidate_id=cand_01; rationale=fallback_choice" + choice = candidate_ids[idx % len(candidate_ids)] + return f"candidate_id={choice}; rationale=env_fallback_policy" + + +def _run_fallback(records: list[dict[str, Any]], verifier: EnvironmentRewardVerifier, max_steps: int) -> dict[str, Any]: + sampled = records[: max(1, min(len(records), max_steps * 2))] + for idx, row in enumerate(sampled): + completion = _fallback_completion(row.get("candidate_ids", []), idx=idx) + verifier( + prompts=[row.get("prompt", "")], + completions=[completion], + scenario_seed=[row.get("scenario_seed", 0)], + difficulty=[row.get("difficulty", "medium")], + sub_environment=[row.get("sub_environment", SubEnvironment.REGIMEN_RISK.value)], + candidate_ids=[row.get("candidate_ids", ["cand_01"])], + patient_id=[row.get("patient_id", f"case_{idx:05d}")], + task=[row.get("task", "planner_action_selection")], + ) + + return { + "status": "fallback", + "backend": "env_reward_fallback", + "steps_executed": len(sampled), + } + + +def run_grpo_trl(config: GRPOTrlConfig) -> dict[str, Any]: + config.output_dir.mkdir(parents=True, exist_ok=True) + records = _build_dataset_records(_load_jsonl(config.prompts_path), max_prompts=config.max_prompts) + if not records: + payload = { + "status": "no_data", + "backend": "trl_grpo", + "records": 0, + "model_id": config.model_id, + "prompts_path": str(config.prompts_path), + } + save_checkpoint(config.output_dir / "grpo_trl_checkpoint.json", payload) + return payload + + log_path = config.output_dir / "grpo_reward_components.jsonl" + verifier = EnvironmentRewardVerifier(log_path=log_path) + unsloth_probe = load_unsloth_model(config.model_id) if config.use_unsloth else {"available": False} + + runtime_error = "" + train_metrics: dict[str, Any] = {} + backend = "trl_transformers" + artifact_path = "" + history_path = "" + + if config.force_fallback and not config.allow_fallback: + raise RuntimeError("force_fallback requires allow_fallback=True") + + try: + if config.force_fallback: + raise RuntimeError("forced_fallback") + + from datasets import Dataset + from peft import LoraConfig + import torch + from transformers import AutoTokenizer + from trl import GRPOConfig, GRPOTrainer + + dataset = Dataset.from_list(records) + offline_mode = os.getenv("POLYGUARD_OFFLINE_MODE", "false").lower() in {"1", "true", "yes", "on"} + if offline_mode: + os.environ.setdefault("HF_HUB_OFFLINE", "1") + else: + os.environ.pop("HF_HUB_OFFLINE", None) + tokenizer = AutoTokenizer.from_pretrained(config.model_id, local_files_only=offline_mode) + if tokenizer.pad_token is None: + tokenizer.pad_token = tokenizer.eos_token + + report_to: list[str] = [] + if os.getenv("WANDB_API_KEY"): + try: + import wandb # noqa: F401 + + report_to = ["wandb"] + except Exception: + report_to = [] + + args = GRPOConfig( + output_dir=str(config.output_dir / "grpo_artifacts"), + do_train=True, + max_steps=config.max_steps if config.max_steps > 0 else -1, + num_train_epochs=config.epochs, + per_device_train_batch_size=config.per_device_batch_size, + gradient_accumulation_steps=config.gradient_accumulation_steps, + learning_rate=config.learning_rate, + logging_steps=1, + save_steps=max(1, config.max_steps) if config.max_steps > 0 else 500, + save_total_limit=2, + num_generations=config.num_generations, + max_prompt_length=config.max_prompt_length, + max_completion_length=config.max_completion_length, + remove_unused_columns=False, + report_to=report_to, + temperature=config.temperature, + seed=config.seed, + fp16=torch.cuda.is_available(), + use_cpu=not torch.cuda.is_available(), + model_init_kwargs={ + "local_files_only": offline_mode, + "torch_dtype": torch.float16 if torch.cuda.is_available() else torch.float32, + "low_cpu_mem_usage": True, + }, + ) + lora_cfg = LoraConfig(**build_lora_config(rank=16, alpha=32, dropout=0.05)) + + trainer = GRPOTrainer( + model=config.model_id, + reward_funcs=verifier, + args=args, + train_dataset=dataset, + processing_class=tokenizer, + peft_config=lora_cfg, + ) + output = trainer.train() + train_metrics = dict(getattr(output, "metrics", {}) or {}) + history = list(getattr(trainer.state, "log_history", []) or []) + history_file = config.output_dir / "grpo_history.json" + history_file.write_text(json.dumps(history, ensure_ascii=True, indent=2), encoding="utf-8") + history_path = str(history_file) + artifact = config.output_dir / "grpo_adapter" + trainer.save_model(str(artifact)) + tokenizer.save_pretrained(str(artifact)) + artifact_path = str(artifact) + except Exception as exc: # noqa: BLE001 + runtime_error = str(exc) + if not config.allow_fallback: + raise RuntimeError( + "TRL GRPOTrainer runtime failed. Training is configured to require Hugging Face TRL. " + f"Fix the TRL runtime issue or rerun with allow_fallback=True. Details: {runtime_error}" + ) from exc + fallback = _run_fallback(records=records, verifier=verifier, max_steps=config.max_steps) + backend = str(fallback.get("backend", "env_reward_fallback")) + train_metrics = {"steps_executed": float(fallback.get("steps_executed", 0))} + + summary = verifier.summary() + payload = { + "status": "ok" if not runtime_error else "fallback", + "backend": backend, + "model_id": config.model_id, + "records": len(records), + "prompts_path": str(config.prompts_path), + "reward_summary": summary, + "reward_log": str(log_path), + "train_metrics": train_metrics, + "history_path": history_path, + "artifact_path": artifact_path, + "unsloth_available": bool(unsloth_probe.get("available", False)), + } + if runtime_error: + payload["trl_runtime_error"] = runtime_error + + save_checkpoint(config.output_dir / "grpo_trl_checkpoint.json", payload) + register_model_run( + config.output_dir / "model_registry.json", + { + "stage": "grpo_trl", + "model_id": config.model_id, + "backend": backend, + "artifact_path": artifact_path, + "records": len(records), + "reward_summary": summary, + }, + ) + return payload diff --git a/app/training/lora_utils.py b/app/training/lora_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..89813e12883ad3a1903b12d1de76e0bb0c7d9fa4 --- /dev/null +++ b/app/training/lora_utils.py @@ -0,0 +1,28 @@ +"""LoRA / QLoRA utilities.""" + +from __future__ import annotations + +from typing import Any + + +def build_lora_config(rank: int = 16, alpha: int = 32, dropout: float = 0.05) -> dict[str, Any]: + return { + "r": rank, + "lora_alpha": alpha, + "lora_dropout": dropout, + "bias": "none", + "task_type": "CAUSAL_LM", + } + + +def build_qlora_config(rank: int = 16, alpha: int = 32, dropout: float = 0.05) -> dict[str, Any]: + base = build_lora_config(rank=rank, alpha=alpha, dropout=dropout) + base.update( + { + "load_in_4bit": True, + "bnb_4bit_quant_type": "nf4", + "bnb_4bit_compute_dtype": "bfloat16", + "bnb_4bit_use_double_quant": True, + } + ) + return base diff --git a/app/training/metrics.py b/app/training/metrics.py new file mode 100644 index 0000000000000000000000000000000000000000..5397829fd9256878d43e98c593fb891cf0abf07f --- /dev/null +++ b/app/training/metrics.py @@ -0,0 +1,109 @@ +"""Training metrics aggregation.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from app.common.constants import PRIMARY_REWARD_KEYS, REQUIRED_REWARD_KEYS + + +@dataclass +class TrainingMetrics: + rewards: list[float] = field(default_factory=list) + legality_rate: list[float] = field(default_factory=list) + severe_violation_rate: list[float] = field(default_factory=list) + abstention_rate: list[float] = field(default_factory=list) + episode_lengths: list[int] = field(default_factory=list) + success_rate: list[float] = field(default_factory=list) + burden_delta: list[float] = field(default_factory=list) + safety_delta: list[float] = field(default_factory=list) + dosing_quality: list[float] = field(default_factory=list) + process_fidelity: list[float] = field(default_factory=list) + exploit_detection_count: int = 0 + timeout_count: int = 0 + failure_visible_count: int = 0 + invalid_action_count: list[float] = field(default_factory=list) + primary_reward_totals: dict[str, float] = field(default_factory=lambda: {k: 0.0 for k in PRIMARY_REWARD_KEYS}) + primary_reward_counts: int = 0 + reward_component_totals: dict[str, float] = field(default_factory=lambda: {k: 0.0 for k in REQUIRED_REWARD_KEYS}) + reward_component_counts: int = 0 + + def add( + self, + reward: float, + legal: bool, + severe_violation: bool, + abstain: bool, + episode_len: int, + reward_components: dict[str, float] | None = None, + success: bool = False, + burden_delta: float = 0.0, + safety_delta: float = 0.0, + dosing_quality: float = 0.0, + process_fidelity: float = 0.0, + exploit_detected: bool = False, + timeout: bool = False, + failure_visible: bool = False, + invalid_actions: int = 0, + primary_channels: dict[str, float] | None = None, + ) -> None: + self.rewards.append(reward) + self.legality_rate.append(1.0 if legal else 0.0) + self.severe_violation_rate.append(1.0 if severe_violation else 0.0) + self.abstention_rate.append(1.0 if abstain else 0.0) + self.episode_lengths.append(episode_len) + self.success_rate.append(1.0 if success else 0.0) + self.burden_delta.append(burden_delta) + self.safety_delta.append(safety_delta) + self.dosing_quality.append(dosing_quality) + self.process_fidelity.append(process_fidelity) + if exploit_detected: + self.exploit_detection_count += 1 + if timeout: + self.timeout_count += 1 + if failure_visible: + self.failure_visible_count += 1 + self.invalid_action_count.append(float(invalid_actions)) + if reward_components: + for key in REQUIRED_REWARD_KEYS: + self.reward_component_totals[key] += float(reward_components.get(key, 0.0)) + self.reward_component_counts += 1 + if primary_channels: + for key in PRIMARY_REWARD_KEYS: + self.primary_reward_totals[key] += float(primary_channels.get(key, 0.0)) + self.primary_reward_counts += 1 + + def summary(self) -> dict[str, float]: + def avg(values: list[float]) -> float: + return sum(values) / len(values) if values else 0.0 + + return { + "avg_reward": avg(self.rewards), + "legality_rate": avg(self.legality_rate), + "severe_violation_rate": avg(self.severe_violation_rate), + "abstention_rate": avg(self.abstention_rate), + "avg_episode_length": avg([float(x) for x in self.episode_lengths]), + "success_rate": avg(self.success_rate), + "avg_burden_delta": avg(self.burden_delta), + "avg_safety_delta": avg(self.safety_delta), + "avg_dosing_quality": avg(self.dosing_quality), + "avg_process_fidelity": avg(self.process_fidelity), + "exploit_detection_count": float(self.exploit_detection_count), + "timeout_rate": (float(self.timeout_count) / len(self.rewards)) if self.rewards else 0.0, + "failure_visible_rate": (float(self.failure_visible_count) / len(self.rewards)) if self.rewards else 0.0, + "avg_invalid_actions": avg(self.invalid_action_count), + "reward_columns": { + key: ( + self.reward_component_totals[key] / self.reward_component_counts + if self.reward_component_counts + else 0.0 + ) + for key in REQUIRED_REWARD_KEYS + }, + "primary_reward_channels": { + key: ( + self.primary_reward_totals[key] / self.primary_reward_counts if self.primary_reward_counts else 0.0 + ) + for key in PRIMARY_REWARD_KEYS + }, + } diff --git a/app/training/model_registry.py b/app/training/model_registry.py new file mode 100644 index 0000000000000000000000000000000000000000..55911ece58226c9dd4263929a7d91f880d1a0aa3 --- /dev/null +++ b/app/training/model_registry.py @@ -0,0 +1,31 @@ +"""Model run registry helpers.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +def register_model_run(registry_path: Path, payload: dict[str, Any]) -> dict[str, Any]: + registry_path.parent.mkdir(parents=True, exist_ok=True) + if registry_path.exists(): + existing = json.loads(registry_path.read_text(encoding="utf-8")) + if not isinstance(existing, list): + existing = [] + else: + existing = [] + existing.append(payload) + registry_path.write_text(json.dumps(existing, ensure_ascii=True, indent=2), encoding="utf-8") + return {"runs": len(existing), "registry_path": str(registry_path)} + + +def latest_run(registry_path: Path) -> dict[str, Any]: + if not registry_path.exists(): + return {} + payload = json.loads(registry_path.read_text(encoding="utf-8")) + if not payload: + return {} + if isinstance(payload, list): + return payload[-1] + return {} diff --git a/app/training/openenv_wrapper.py b/app/training/openenv_wrapper.py new file mode 100644 index 0000000000000000000000000000000000000000..0de3677a3e09a8cd89c2c3dbc8b84661bc0a00d8 --- /dev/null +++ b/app/training/openenv_wrapper.py @@ -0,0 +1,223 @@ +"""OpenEnv-compatible wrapper around local env service. + +The wrapper intentionally exposes meaningful clinician-facing tool methods for +LLM policy training instead of a single opaque ``step(action)`` interface. +""" + +from __future__ import annotations + +from typing import Any, Literal + +from app.env.client import PolyGuardEnvClient + +try: + from openenv import GenericEnvClient +except Exception: # noqa: BLE001 + GenericEnvClient = None # type: ignore[assignment] + + +class LocalOpenEnvWrapper: + def __init__(self, base_url: str = "http://127.0.0.1:8100") -> None: + self.http_client = PolyGuardEnvClient(base_url=base_url) + self.base_url = base_url + self._sync_client: Any = None + if GenericEnvClient is not None: + try: + self._sync_client = GenericEnvClient(base_url=base_url).sync() + self._sync_client.connect() + except Exception: # noqa: BLE001 + self._sync_client = None + + def reset(self, **kwargs: Any) -> dict[str, Any]: + if self._sync_client is not None: + result = self._sync_client.reset(**kwargs) + return { + "observation": result.observation, + "reward": result.reward, + "done": result.done, + } + return self.http_client.reset(**kwargs) + + def step(self, action: dict[str, Any]) -> dict[str, Any]: + if self._sync_client is not None: + result = self._sync_client.step(action) + return { + "observation": result.observation, + "reward": result.reward, + "done": result.done, + } + return self.http_client.step(action) + + def state(self) -> dict[str, Any]: + if self._sync_client is not None: + return self._sync_client.state() + return self.http_client.state() + + def trace(self) -> list[dict[str, Any]]: + return self.http_client.trace() + + def legal_actions(self) -> list[dict[str, Any]]: + return self.http_client.legal_actions() + + def reward_breakdown(self) -> dict[str, Any]: + return self.http_client.reward_breakdown() + + def uncertainty(self) -> dict[str, Any]: + return self.http_client.uncertainty() + + def inspect_regimen(self) -> dict[str, Any]: + """Return a compact clinical snapshot of the active case.""" + state = self.state() + patient = state.get("patient", {}) + risk_summary = state.get("risk_summary", {}) + meds = patient.get("medications", []) + return { + "patient_id": patient.get("patient_id"), + "age": patient.get("age"), + "comorbidities": patient.get("comorbidities", []), + "medication_count": len(meds), + "medications": meds, + "risk_summary": risk_summary, + "burden_score": state.get("burden_score"), + "step_count": state.get("step_count"), + "max_steps": state.get("max_steps"), + } + + def evaluate_candidate(self, candidate_id: str) -> dict[str, Any]: + """Lookup a legal candidate action by candidate id.""" + candidates = self.legal_actions() + for candidate in candidates: + if candidate.get("candidate_id") == candidate_id: + return candidate + return {"candidate_id": candidate_id, "found": False} + + def _execute_action( + self, + mode: str, + action_type: str, + target_drug: str | None = None, + replacement_drug: str | None = None, + dose_bucket: str = "NA", + taper_days: int | None = None, + monitoring_plan: str | None = None, + candidate_id: str = "cand_manual", + confidence: float = 0.65, + rationale_brief: str = "tool_action", + ) -> dict[str, Any]: + payload = { + "mode": mode, + "action_type": action_type, + "target_drug": target_drug, + "replacement_drug": replacement_drug, + "dose_bucket": dose_bucket, + "taper_days": taper_days, + "monitoring_plan": monitoring_plan, + "candidate_id": candidate_id, + "confidence": confidence, + "rationale_brief": rationale_brief, + } + return self.step(payload) + + def stop_drug(self, target_drug: str, taper_days: int | None = None, candidate_id: str = "cand_stop_tool") -> dict[str, Any]: + """Issue STOP_DRUG action for a single medication.""" + return self._execute_action( + mode="REGIMEN_OPT", + action_type="STOP_DRUG", + target_drug=target_drug, + taper_days=taper_days, + candidate_id=candidate_id, + rationale_brief=f"stop_drug:{target_drug}", + ) + + def substitute_drug( + self, + target_drug: str, + replacement_drug: str, + candidate_id: str = "cand_substitute_tool", + ) -> dict[str, Any]: + """Issue SUBSTITUTE_WITHIN_CLASS action.""" + return self._execute_action( + mode="REGIMEN_OPT", + action_type="SUBSTITUTE_WITHIN_CLASS", + target_drug=target_drug, + replacement_drug=replacement_drug, + candidate_id=candidate_id, + rationale_brief=f"substitute:{target_drug}->{replacement_drug}", + ) + + def start_taper(self, target_drug: str, taper_days: int = 14, candidate_id: str = "cand_taper_start_tool") -> dict[str, Any]: + """Issue TAPER_INITIATE action.""" + return self._execute_action( + mode="REGIMEN_OPT", + action_type="TAPER_INITIATE", + target_drug=target_drug, + taper_days=taper_days, + candidate_id=candidate_id, + rationale_brief=f"taper_start:{target_drug}", + ) + + def continue_taper(self, target_drug: str, taper_days: int = 7, candidate_id: str = "cand_taper_continue_tool") -> dict[str, Any]: + """Issue TAPER_CONTINUE action.""" + return self._execute_action( + mode="REGIMEN_OPT", + action_type="TAPER_CONTINUE", + target_drug=target_drug, + taper_days=taper_days, + candidate_id=candidate_id, + rationale_brief=f"taper_continue:{target_drug}", + ) + + def adjust_dose( + self, + target_drug: str, + direction: Literal["increase", "reduce", "hold"], + candidate_id: str = "cand_adjust_dose_tool", + ) -> dict[str, Any]: + """Adjust dose bucket with an explicit direction.""" + if direction == "increase": + action_type = "INCREASE_DOSE_BUCKET" + dose_bucket = "HIGH" + elif direction == "reduce": + action_type = "REDUCE_DOSE_BUCKET" + dose_bucket = "LOW" + else: + action_type = "DOSE_HOLD" + dose_bucket = "HOLD" + return self._execute_action( + mode="DOSE_OPT", + action_type=action_type, + target_drug=target_drug, + dose_bucket=dose_bucket, + candidate_id=candidate_id, + rationale_brief=f"adjust_dose:{direction}:{target_drug}", + ) + + def request_review( + self, + review_type: Literal["pharmacist", "specialist"] = "specialist", + candidate_id: str = "cand_review_tool", + ) -> dict[str, Any]: + """Request human review when uncertainty or legality concerns are high.""" + action_type = "REQUEST_PHARMACIST_REVIEW" if review_type == "pharmacist" else "REQUEST_SPECIALIST_REVIEW" + return self._execute_action( + mode="ABSTAIN_REVIEW", + action_type=action_type, + candidate_id=candidate_id, + rationale_brief=f"request_review:{review_type}", + ) + + def finish_case(self, candidate_id: str = "cand_finish_tool") -> dict[str, Any]: + """Close the episode with a conservative keep action.""" + return self._execute_action( + mode="REGIMEN_OPT", + action_type="KEEP_REGIMEN", + candidate_id=candidate_id, + rationale_brief="finish_case", + ) + + def close(self) -> None: + if self._sync_client is not None: + try: + self._sync_client.close() + except Exception: # noqa: BLE001 + pass diff --git a/app/training/planner_grpo.py b/app/training/planner_grpo.py new file mode 100644 index 0000000000000000000000000000000000000000..03dee5e0937eb08cd26184b2875f76e42a2f24ab --- /dev/null +++ b/app/training/planner_grpo.py @@ -0,0 +1,73 @@ +"""Planner GRPO-like trainer.""" + +from __future__ import annotations + +from pathlib import Path + +from app.agents.orchestrator import Orchestrator +from app.env.env_core import PolyGuardEnv +from app.training.checkpointing import save_checkpoint +from app.training.metrics import TrainingMetrics +from app.training.replay_buffer import ReplayBuffer, failure_mining_summary + + +def train_planner_grpo(episodes: int = 20, checkpoint_dir: Path | None = None) -> dict: + env = PolyGuardEnv() + orchestrator = Orchestrator(env=env) + metrics = TrainingMetrics() + replay = ReplayBuffer() + + for i in range(episodes): + env.reset(seed=101 + i, difficulty="medium" if i < episodes // 2 else "hard") + done = False + while not done: + pre_burden = env.state.burden_score + result = orchestrator.run_step() + reward = result["reward"] + done = result["done"] + legal = result["critic"]["legal"] + severe = len(result["critic"]["violations"]) > 1 + abstain = result["final_action"]["action_type"].startswith("REQUEST_") + reward_components = result["info"].get("reward_breakdown", {}) + primary_channels = result["info"].get("primary_reward_channels", {}) + failure_reasons = result["info"].get("failure_reasons", []) + metrics.add( + reward, + legal=legal, + severe_violation=severe, + abstain=abstain, + episode_len=env.state.step_count, + reward_components=reward_components, + success=done and result["info"].get("termination_reason") == "safe_resolution", + burden_delta=pre_burden - env.state.burden_score, + safety_delta=float(reward_components.get("safety_delta_score", 0.0)), + dosing_quality=float(reward_components.get("dosing_quality_score", 0.0)), + process_fidelity=float(reward_components.get("process_fidelity_score", 0.0)), + exploit_detected=bool(result["info"].get("anti_cheat_reasons")), + timeout=bool(result["info"].get("step_timeout") or result["info"].get("termination_reason") == "wall_clock_timeout"), + failure_visible=bool(failure_reasons), + invalid_actions=int(result["info"].get("invalid_action_count", 0)), + primary_channels=primary_channels if isinstance(primary_channels, dict) else None, + ) + replay.add( + { + "episode": i, + "step": env.state.step_count, + "reward": reward, + "legal": legal, + "termination_reason": result["info"].get("termination_reason"), + "failure_reasons": failure_reasons, + "policy_stack": result.get("policy_stack"), + "bandit_topk": result.get("bandit_topk", []), + "final_action": result.get("final_action", {}), + "primary_reward_channels": primary_channels, + } + ) + + summary = metrics.summary() + summary["failure_mining"] = failure_mining_summary(replay.records) + if checkpoint_dir: + save_checkpoint(checkpoint_dir / "planner_grpo.json", summary) + replay.dump_jsonl(checkpoint_dir / "planner_replay.jsonl") + replay.dump_failures_json(checkpoint_dir / "planner_failures.json") + return summary diff --git a/app/training/process_feedback.py b/app/training/process_feedback.py new file mode 100644 index 0000000000000000000000000000000000000000..6f62002df33ba925fd763cce845be994211d5456 --- /dev/null +++ b/app/training/process_feedback.py @@ -0,0 +1,12 @@ +"""Process-aware feedback checks.""" + +from __future__ import annotations + + +def build_process_feedback(parsed: bool, legal: bool, risk_reduced: bool, abstain_justified: bool) -> dict[str, bool]: + return { + "parsed_correctly": parsed, + "chosen_candidate_legal": legal, + "risk_reduced": risk_reduced, + "abstention_justified": abstain_justified, + } diff --git a/app/training/replay_buffer.py b/app/training/replay_buffer.py new file mode 100644 index 0000000000000000000000000000000000000000..e32e3144c21baa7d848873ac1b53f650622dc5c7 --- /dev/null +++ b/app/training/replay_buffer.py @@ -0,0 +1,50 @@ +"""Replay buffer and failure-case mining utilities.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import json +from pathlib import Path +from typing import Any + + +@dataclass +class ReplayBuffer: + records: list[dict[str, Any]] = field(default_factory=list) + + def add(self, payload: dict[str, Any]) -> None: + self.records.append(payload) + + def failures(self) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for row in self.records: + reasons = row.get("failure_reasons") or [] + if reasons: + out.append(row) + return out + + def dump_jsonl(self, path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as f: + for row in self.records: + f.write(json.dumps(row, ensure_ascii=True) + "\n") + return path + + def dump_failures_json(self, path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + failures = self.failures() + path.write_text(json.dumps(failures, ensure_ascii=True, indent=2), encoding="utf-8") + return path + + +def failure_mining_summary(rows: list[dict[str, Any]]) -> dict[str, Any]: + reason_counts: dict[str, int] = {} + for row in rows: + for reason in row.get("failure_reasons") or []: + reason_counts[reason] = reason_counts.get(reason, 0) + 1 + ranked = sorted(reason_counts.items(), key=lambda item: item[1], reverse=True) + return { + "total_rows": len(rows), + "failure_rows": sum(1 for row in rows if row.get("failure_reasons")), + "top_failure_reasons": [{"reason": k, "count": v} for k, v in ranked[:20]], + } diff --git a/app/training/reward_functions.py b/app/training/reward_functions.py new file mode 100644 index 0000000000000000000000000000000000000000..7198fc378bbf60e27250637ef31b3cbce47e7a5a --- /dev/null +++ b/app/training/reward_functions.py @@ -0,0 +1,70 @@ +"""Standalone reward functions with strict [0.001, 0.999] output.""" + +from __future__ import annotations + +from app.common.normalization import clamp_reward + + +def format_compliance_score(valid: bool) -> float: + """Schema validity: valid->0.999, invalid->0.001.""" + return clamp_reward(0.999 if valid else 0.001) + + +def candidate_alignment_score(aligned: bool) -> float: + """Whether selected action references legal candidate set.""" + return clamp_reward(0.999 if aligned else 0.001) + + +def legality_score(legal: bool) -> float: + """Hard constraint satisfaction score.""" + return clamp_reward(0.999 if legal else 0.001) + + +def safety_delta_score(delta: float) -> float: + """Risk-delta mapping where positive delta means lower safety risk.""" + return clamp_reward(0.5 + delta * 0.4) + + +def burden_improvement_score(delta: float) -> float: + """Burden reduction score; positive delta indicates lower burden.""" + return clamp_reward(0.5 + delta * 0.4) + + +def disease_stability_score(stability: float) -> float: + """Stability proxy in [0,1], default caller-side imputation when missing.""" + return clamp_reward(stability) + + +def dosing_quality_score(quality: float) -> float: + """Dose quality proxy in [0,1], neutral caller default for non-dose scenarios.""" + return clamp_reward(quality) + + +def abstention_quality_score(good_abstain: bool) -> float: + """Judges abstention quality; not merely abstaining.""" + return clamp_reward(0.8 if good_abstain else 0.3) + + +def efficiency_score(step_fraction: float) -> float: + """Shorter successful trajectories receive higher score.""" + return clamp_reward(1.0 - step_fraction) + + +def process_fidelity_score(fidelity: float) -> float: + """Process-supervision score for valid clinical decision sequence.""" + return clamp_reward(fidelity) + + +def explanation_grounding_score(grounded: float) -> float: + """Grounded explanation support score.""" + return clamp_reward(grounded) + + +def anti_cheat_score(exploit: bool) -> float: + """Exploit-like behavior gets floor score.""" + return clamp_reward(0.001 if exploit else 0.999) + + +def uncertainty_calibration_score(calibration: float) -> float: + """Confidence calibration score.""" + return clamp_reward(calibration) diff --git a/app/training/rl_dataset.py b/app/training/rl_dataset.py new file mode 100644 index 0000000000000000000000000000000000000000..1ba92e14593cfff4bdfa47eb3ae72fb93702e52a --- /dev/null +++ b/app/training/rl_dataset.py @@ -0,0 +1,12 @@ +"""RL episode logging dataset.""" + +from __future__ import annotations + + +def make_rl_record(observation: dict, action: dict, reward: float, done: bool) -> dict: + return { + "observation": observation, + "action": action, + "reward": reward, + "done": done, + } diff --git a/app/training/sft_dataset.py b/app/training/sft_dataset.py new file mode 100644 index 0000000000000000000000000000000000000000..8ecc9f6fe2e36bee9537d205bb2050f87178748b --- /dev/null +++ b/app/training/sft_dataset.py @@ -0,0 +1,16 @@ +"""SFT dataset helpers.""" + +from __future__ import annotations + +from app.common.types import CandidateAction, PolyGuardState + + +def build_sft_example(state: PolyGuardState, candidates: list[CandidateAction], target_candidate_id: str) -> dict: + return { + "prompt": { + "patient_id": state.patient.patient_id, + "medications": [m.model_dump(mode="json") for m in state.patient.medications], + "candidates": [c.model_dump(mode="json") for c in candidates], + }, + "target_candidate_id": target_candidate_id, + } diff --git a/app/training/sft_train.py b/app/training/sft_train.py new file mode 100644 index 0000000000000000000000000000000000000000..49c34d1d036a758c042447198a1d8c5d1f551752 --- /dev/null +++ b/app/training/sft_train.py @@ -0,0 +1,26 @@ +"""Canonical SFT training module.""" + +from __future__ import annotations + +import subprocess +import sys +import os +from pathlib import Path + + +def run_sft_train(checkpoint_dir: Path | None = None) -> dict[str, str]: + """Run SFT training via the script entrypoint. + + ``checkpoint_dir`` is accepted for interface stability; the current + implementation writes to the repository checkpoint folder. + """ + _ = checkpoint_dir + root = Path(__file__).resolve().parents[2] + script = root / "scripts" / "train_sft.py" + env = dict(os.environ) + env["PYTHONPATH"] = f"{root}:{env.get('PYTHONPATH', '')}".rstrip(":") + subprocess.run([sys.executable, str(script)], check=True, cwd=str(root), env=env) + return {"status": "ok"} + + +__all__ = ["run_sft_train"] diff --git a/app/training/sft_trl.py b/app/training/sft_trl.py new file mode 100644 index 0000000000000000000000000000000000000000..30700f1aef7fa9c05df0391376943f7a3cde0880 --- /dev/null +++ b/app/training/sft_trl.py @@ -0,0 +1,261 @@ +"""TRL + Unsloth SFT training utilities.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import os +from pathlib import Path +from typing import Any + +import numpy as np +from sklearn.ensemble import RandomForestClassifier + +from app.training.checkpointing import save_checkpoint +from app.training.lora_utils import build_lora_config +from app.training.lora_utils import build_qlora_config +from app.training.model_registry import register_model_run +from app.training.unsloth_loader import load_unsloth_model + + +@dataclass(slots=True) +class SFTRunConfig: + model_id: str + output_dir: Path + dataset_path: Path + max_seq_len: int = 1024 + epochs: int = 1 + learning_rate: float = 2e-5 + batch_size: int = 2 + max_steps: int = 30 + use_unsloth: bool = True + allow_fallback: bool = False + + +def effective_sft_max_steps(max_steps: int) -> int: + """TRL uses -1 to mean full-epoch training.""" + return max_steps if max_steps > 0 else -1 + + +def effective_sft_save_steps(max_steps: int) -> int: + return max(1, max_steps) if max_steps > 0 else 500 + + +def _to_text_record(example: dict[str, Any]) -> str: + prompt = example.get("prompt", {}) + meds = prompt.get("medications", []) + candidates = prompt.get("candidates", prompt.get("candidate_set", [])) + target = example.get("target_candidate_id", "cand_01") + return json.dumps( + { + "instruction": "Select the safest legal medication action candidate_id.", + "medications": meds, + "candidates": candidates, + "answer": target, + }, + ensure_ascii=True, + ) + + +def _load_examples(path: Path) -> list[dict[str, Any]]: + if not path.exists(): + return [] + payload = json.loads(path.read_text(encoding="utf-8")) + if isinstance(payload, list): + return [item for item in payload if isinstance(item, dict)] + return [] + + +def _fallback_train(config: SFTRunConfig, examples: list[dict[str, Any]]) -> dict[str, Any]: + if not examples: + out = { + "status": "no_data", + "backend": "fallback_sklearn", + "examples_used": 0, + "model_id": config.model_id, + } + save_checkpoint(config.output_dir / "sft_checkpoint.json", out) + return out + + def _features(example: dict[str, Any]) -> list[float]: + prompt = example.get("prompt", {}) + meds = prompt.get("medications", []) + candidates = prompt.get("candidates", prompt.get("candidate_set", [])) + uncertainty = float(prompt.get("uncertainty", 0.5)) + severe_pairs = float(prompt.get("severe_pair_count", 0.0)) + return [float(len(meds)), float(len(candidates)), uncertainty, severe_pairs] + + x = np.array([_features(example) for example in examples], dtype=float) + y = np.array([hash(str(example.get("target_candidate_id", "cand_00"))) % 97 for example in examples], dtype=int) + model = RandomForestClassifier(n_estimators=120, random_state=42) + model.fit(x, y) + acc = float((model.predict(x) == y).mean()) + + artifact = config.output_dir / "sft_policy_fallback.json" + artifact.write_text(json.dumps({"train_accuracy": round(acc, 4)}, ensure_ascii=True, indent=2), encoding="utf-8") + out = { + "status": "ok", + "backend": "fallback_sklearn", + "examples_used": len(examples), + "train_accuracy": round(acc, 4), + "artifact_path": str(artifact), + "model_id": config.model_id, + } + save_checkpoint(config.output_dir / "sft_checkpoint.json", out) + return out + + +def run_sft_trl(config: SFTRunConfig) -> dict[str, Any]: + config.output_dir.mkdir(parents=True, exist_ok=True) + examples = _load_examples(config.dataset_path) + if not examples: + result = { + "status": "no_data", + "backend": "trl_unsloth", + "examples_used": 0, + "model_id": config.model_id, + } + save_checkpoint(config.output_dir / "sft_checkpoint.json", result) + return result + + unsloth_probe = load_unsloth_model(config.model_id) if config.use_unsloth else {"available": False} + + try: + from datasets import Dataset + from peft import LoraConfig + import torch + from transformers import AutoModelForCausalLM, AutoTokenizer + from trl import SFTConfig, SFTTrainer + except Exception as exc: # noqa: BLE001 + if not config.allow_fallback: + raise RuntimeError( + "TRL SFTTrainer import failed. Training is configured to require Hugging Face TRL. " + f"Install TRL dependencies or rerun with allow_fallback=True. Details: {exc}" + ) from exc + result = _fallback_train(config=config, examples=examples) + result["trl_error"] = str(exc) + return result + + dataset = Dataset.from_dict({"text": [_to_text_record(item) for item in examples]}) + try: + model = None + tokenizer = None + backend = "trl_transformers" + + if config.use_unsloth: + try: + from unsloth import FastLanguageModel # type: ignore + + model, tokenizer = FastLanguageModel.from_pretrained( + model_name=config.model_id, + max_seq_length=config.max_seq_len, + dtype=None, + load_in_4bit=True, + ) + qlora = build_qlora_config(rank=16, alpha=32, dropout=0.05) + model = FastLanguageModel.get_peft_model( + model, + r=int(qlora["r"]), + target_modules=["q_proj", "v_proj"], + lora_alpha=int(qlora["lora_alpha"]), + lora_dropout=float(qlora["lora_dropout"]), + bias="none", + use_gradient_checkpointing="unsloth", + ) + backend = "trl_unsloth" + except Exception: + model = None + tokenizer = None + + if model is None or tokenizer is None: + tokenizer = AutoTokenizer.from_pretrained(config.model_id) + if tokenizer.pad_token is None: + tokenizer.pad_token = tokenizer.eos_token + model = AutoModelForCausalLM.from_pretrained( + config.model_id, + torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32, + low_cpu_mem_usage=True, + ) + + report_to = [] + if os.getenv("WANDB_API_KEY"): + try: + import wandb # noqa: F401 + + report_to = ["wandb"] + except Exception: + report_to = [] + + lora_cfg = LoraConfig(**build_lora_config(rank=16, alpha=32, dropout=0.05)) + args = SFTConfig( + output_dir=str(config.output_dir / "sft_artifacts"), + per_device_train_batch_size=config.batch_size, + gradient_accumulation_steps=1, + learning_rate=config.learning_rate, + num_train_epochs=float(config.epochs), + max_steps=effective_sft_max_steps(config.max_steps), + logging_steps=1, + save_steps=effective_sft_save_steps(config.max_steps), + report_to=report_to, + remove_unused_columns=False, + dataset_text_field="text", + max_length=config.max_seq_len, + fp16=torch.cuda.is_available(), + use_cpu=not torch.cuda.is_available(), + ) + + trainer = SFTTrainer( + model=model, + args=args, + train_dataset=dataset, + processing_class=tokenizer, + peft_config=None if backend == "trl_unsloth" else lora_cfg, + ) + train_output = trainer.train() + history_path = config.output_dir / "sft_history.json" + history_path.write_text(json.dumps(trainer.state.log_history, ensure_ascii=True, indent=2), encoding="utf-8") + trainer.save_model(str(config.output_dir / "sft_adapter")) + tokenizer.save_pretrained(str(config.output_dir / "sft_adapter")) + + sample_rows = [_to_text_record(item) for item in examples[:5]] + generations = [] + for row in sample_rows: + generations.append({"prompt": row[:240], "generation": "", "backend": backend}) + (config.output_dir / "sft_generations.json").write_text( + json.dumps(generations, ensure_ascii=True, indent=2), encoding="utf-8" + ) + + train_metrics = dict(getattr(train_output, "metrics", {}) or {}) + result = { + "status": "ok", + "backend": backend, + "examples_used": len(examples), + "model_id": config.model_id, + "unsloth_available": bool(unsloth_probe.get("available", False)), + "train_runtime": float(train_metrics.get("train_runtime", 0.0)), + "train_loss": float(train_metrics.get("train_loss", 0.0)), + "train_metrics": train_metrics, + "history_path": str(history_path), + "artifact_path": str(config.output_dir / "sft_adapter"), + } + save_checkpoint(config.output_dir / "sft_checkpoint.json", result) + register_model_run( + config.output_dir / "model_registry.json", + { + "stage": "sft", + "model_id": config.model_id, + "backend": backend, + "artifact_path": str(config.output_dir / "sft_adapter"), + "examples_used": len(examples), + }, + ) + return result + except Exception as exc: # noqa: BLE001 + if not config.allow_fallback: + raise RuntimeError( + "TRL SFTTrainer runtime failed. Training is configured to require Hugging Face TRL. " + f"Fix the TRL runtime issue or rerun with allow_fallback=True. Details: {exc}" + ) from exc + result = _fallback_train(config=config, examples=examples) + result["trl_runtime_error"] = str(exc) + return result diff --git a/app/training/supervisor_grpo.py b/app/training/supervisor_grpo.py new file mode 100644 index 0000000000000000000000000000000000000000..c529e00720774c0e501f919fac3aa28b6e8bf5d6 --- /dev/null +++ b/app/training/supervisor_grpo.py @@ -0,0 +1,67 @@ +"""Supervisor GRPO-like trainer.""" + +from __future__ import annotations + +from pathlib import Path + +from app.env.env_core import PolyGuardEnv +from app.training.checkpointing import save_checkpoint +from app.training.metrics import TrainingMetrics +from app.training.replay_buffer import ReplayBuffer, failure_mining_summary + + +def train_supervisor_grpo(episodes: int = 10, checkpoint_dir: Path | None = None) -> dict: + env = PolyGuardEnv() + metrics = TrainingMetrics() + replay = ReplayBuffer() + for i in range(episodes): + env.reset(seed=42 + i, difficulty="easy" if i < episodes // 2 else "medium") + done = False + while not done: + candidates = env.get_legal_actions() + action = candidates[0] + pre_burden = env.state.burden_score + obs, reward, done, info = env.step(action) + legal = info["safety_report"]["legal"] + severe = len(info["safety_report"]["violations"]) > 1 + abstain = action["action_type"].startswith("REQUEST_") + reward_components = info.get("reward_breakdown", {}) + primary_channels = info.get("primary_reward_channels", {}) + failure_reasons = info.get("failure_reasons", []) + metrics.add( + reward, + legal=legal, + severe_violation=severe, + abstain=abstain, + episode_len=env.state.step_count, + reward_components=reward_components, + success=done and info.get("termination_reason") == "safe_resolution", + burden_delta=pre_burden - env.state.burden_score, + safety_delta=float(reward_components.get("safety_delta_score", 0.0)), + dosing_quality=float(reward_components.get("dosing_quality_score", 0.0)), + process_fidelity=float(reward_components.get("process_fidelity_score", 0.0)), + exploit_detected=bool(info.get("anti_cheat_reasons")), + timeout=bool(info.get("step_timeout") or info.get("termination_reason") == "wall_clock_timeout"), + failure_visible=bool(failure_reasons), + invalid_actions=int(info.get("invalid_action_count", 0)), + primary_channels=primary_channels if isinstance(primary_channels, dict) else None, + ) + replay.add( + { + "episode": i, + "step": env.state.step_count, + "reward": reward, + "legal": legal, + "termination_reason": info.get("termination_reason"), + "failure_reasons": failure_reasons, + "final_action": action, + "primary_reward_channels": primary_channels, + } + ) + summary = metrics.summary() + summary["failure_mining"] = failure_mining_summary(replay.records) + if checkpoint_dir: + save_checkpoint(checkpoint_dir / "supervisor_grpo.json", summary) + replay.dump_jsonl(checkpoint_dir / "supervisor_replay.jsonl") + replay.dump_failures_json(checkpoint_dir / "supervisor_failures.json") + return summary diff --git a/app/training/unsloth_loader.py b/app/training/unsloth_loader.py new file mode 100644 index 0000000000000000000000000000000000000000..ee164cb81018622fe64167d6e0214f8576827844 --- /dev/null +++ b/app/training/unsloth_loader.py @@ -0,0 +1,24 @@ +"""Unsloth loader helpers.""" + +from __future__ import annotations + +from typing import Any + + +def load_unsloth_model(model_name: str) -> dict[str, Any]: + try: + import unsloth # type: ignore # noqa: F401 + + return {"backend": "unsloth", "model_name": model_name, "available": True, "quantization": "qlora_ready"} + except Exception: # noqa: BLE001 + return {"backend": "transformers_fallback", "model_name": model_name, "available": False, "quantization": "none"} + + +def load_ollama_manifest(model_name: str) -> dict[str, Any]: + # Minimal manifest payload for baseline tracking when running local Ollama models. + return { + "provider": "ollama", + "model": model_name, + "adapter_mode": "none", + "notes": "small-model baseline", + } diff --git a/app/ui/backend.py b/app/ui/backend.py new file mode 100644 index 0000000000000000000000000000000000000000..7bed07af3102611a08a216d80c0d0cfe444aae86 --- /dev/null +++ b/app/ui/backend.py @@ -0,0 +1,16 @@ +"""UI helper backend entrypoint (optional).""" + +from __future__ import annotations + +import os +import subprocess + + +def run_frontend_dev() -> int: + cwd = os.path.join(os.path.dirname(__file__), "frontend") + proc = subprocess.run(["npm", "run", "dev"], cwd=cwd, check=False) + return proc.returncode + + +if __name__ == "__main__": + raise SystemExit(run_frontend_dev()) diff --git a/app/ui/frontend/dist/assets/index-DV0STDGE.css b/app/ui/frontend/dist/assets/index-DV0STDGE.css new file mode 100644 index 0000000000000000000000000000000000000000..33bb75f3ca79eafffa3bb8d4ca4ba33df686d936 --- /dev/null +++ b/app/ui/frontend/dist/assets/index-DV0STDGE.css @@ -0,0 +1 @@ +@import"https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&family=Space+Grotesk:wght@500;600;700&display=swap";:root{--bg: #03030b;--surface: rgba(13, 16, 35, .62);--surface-2: rgba(19, 24, 51, .58);--surface-3: rgba(35, 26, 72, .68);--ink: #f6f7ff;--muted: #a6a9c8;--line: rgba(197, 187, 255, .22);--line-soft: rgba(189, 178, 255, .14);--accent: #9b7cff;--accent-2: #28e8ff;--accent-3: #ff4fd8;--warning: #d29922;--critical: #f85149;--glass: rgba(8, 11, 25, .58);--shadow: 0 24px 80px rgba(0, 0, 0, .42), inset 0 1px 0 rgba(255, 255, 255, .08);--glow: 0 0 34px rgba(155, 124, 255, .22), 0 0 64px rgba(40, 232, 255, .08);color-scheme:dark}*{box-sizing:border-box}html,body,#root{margin:0;min-height:100%;background:var(--bg);color:var(--ink);font-family:IBM Plex Sans,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}body{min-width:320px;overflow-x:hidden;background:radial-gradient(circle at 50% -10%,rgba(106,68,255,.28),transparent 34rem),radial-gradient(circle at 85% 12%,rgba(255,79,216,.12),transparent 30rem),#02020a}button,select,input{min-height:40px;border:1px solid var(--line);border-radius:14px;background:#080b1bc7;color:var(--ink);font:inherit}button{width:auto;padding:9px 14px;background:linear-gradient(180deg,rgba(255,255,255,.22),transparent),linear-gradient(135deg,var(--accent),var(--accent-2));border-color:transparent;color:#030414;font-weight:700;cursor:pointer;box-shadow:0 10px 30px #5b5cff52,inset 0 0 18px #ffffff2e;transition:background .14s ease,border-color .14s ease,box-shadow .14s ease,transform .12s ease}button:hover:not(:disabled){background:linear-gradient(180deg,rgba(255,255,255,.28),transparent),linear-gradient(135deg,#b49bff,#5ef5ff);box-shadow:0 14px 44px #28e8ff42,inset 0 0 22px #ffffff38;transform:translateY(-1px)}button.secondary,.mode-toggle button{background:#9b7cff1f;border-color:#9b7cff4d;color:var(--accent);box-shadow:inset 0 0 16px #bf97ff1f}button.secondary:hover:not(:disabled),.mode-toggle button:hover:not(:disabled){background:#9b7cff33}button:disabled{cursor:not-allowed;opacity:.48;transform:none}select,input{width:100%;padding:8px 11px;-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px)}select{color-scheme:dark}select:focus,input:focus,button:focus{outline:2px solid rgba(40,232,255,.38);outline-offset:2px}pre{margin:0;max-height:260px;overflow:auto;font-family:JetBrains Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,monospace;font-size:.76rem;line-height:1.55;white-space:pre-wrap;word-break:break-word}table{width:100%;border-collapse:collapse}th,td{padding:8px 10px;border-bottom:1px solid var(--line-soft);text-align:left;font-size:.84rem}.workbench-shell{position:relative;min-height:100vh;isolation:isolate;overflow:hidden;padding:20px;background:linear-gradient(180deg,#090b2338,#03030be0 44rem),var(--bg)}.workbench-container{position:relative;z-index:2;width:min(1440px,100%);margin:0 auto}.metaverse-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:0;overflow:hidden;pointer-events:none}.blackhole-video{position:absolute;top:-32vh;left:50%;width:min(1300px,148vw);min-width:760px;height:74vh;opacity:.78;mix-blend-mode:screen;object-fit:cover;transform:translate(-50%) rotate(180deg);filter:saturate(1.18) contrast(1.08)}.stars-canvas{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;opacity:.86}.stars-canvas canvas{display:block}.nebula-orb{position:absolute;border-radius:999px;filter:blur(18px);mix-blend-mode:screen}.orb-one{right:-8rem;top:14rem;width:28rem;height:28rem;background:radial-gradient(circle,rgba(255,79,216,.24),transparent 68%)}.orb-two{left:-10rem;bottom:0;width:34rem;height:34rem;background:radial-gradient(circle,rgba(40,232,255,.18),transparent 70%)}.nebula-grid{position:absolute;top:0;right:0;bottom:0;left:0;background-image:linear-gradient(rgba(255,255,255,.035) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.035) 1px,transparent 1px);background-size:72px 72px;-webkit-mask-image:linear-gradient(to bottom,transparent,black 18%,transparent 86%);mask-image:linear-gradient(to bottom,transparent,black 18%,transparent 86%);opacity:.36;transform:perspective(900px) rotateX(60deg) translateY(12rem);transform-origin:center bottom}.cosmic-vignette{position:absolute;top:0;right:0;bottom:0;left:0;z-index:2;background:radial-gradient(circle at 50% 0%,transparent 0,rgba(3,3,11,.1) 26rem,rgba(3,3,11,.86) 62rem),linear-gradient(180deg,#03030b0a,#03030be6 76%)}.metaverse-hero{position:relative;display:grid;grid-template-columns:minmax(0,1.3fr) minmax(300px,.72fr);align-items:end;gap:22px;margin:18px 0 14px;overflow:hidden;padding:28px}.metaverse-hero:before{content:"";position:absolute;top:-1px;right:-1px;bottom:-1px;left:-1px;z-index:-1;background:radial-gradient(circle at 16% 10%,rgba(155,124,255,.26),transparent 28rem),radial-gradient(circle at 80% 0%,rgba(40,232,255,.18),transparent 24rem)}.hero-copy{min-width:0}.welcome-box{display:inline-flex;align-items:center;width:max-content;max-width:100%;gap:9px;isolation:isolate;overflow:hidden;margin-bottom:18px;border:1px solid rgba(185,157,255,.45);border-radius:999px;padding:8px 12px;background:#712fff1a;box-shadow:inset 0 -7px 11px #a48fff1f,0 0 28px #9b7cff24;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px)}.spark-glyph,.welcome-text{color:var(--accent);font-size:.78rem;font-weight:900;letter-spacing:.12em;text-transform:uppercase}.welcome-text{background:linear-gradient(0deg,#ffffff6b,#ffffff6b),linear-gradient(90deg,#e59cff,#ba9cff 48%,#8ff6ff);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}.metaverse-hero h2{max-width:900px;margin:0;color:var(--ink);font-family:Space Grotesk,IBM Plex Sans,system-ui,sans-serif;font-size:clamp(2.4rem,6vw,5.7rem);line-height:.92;letter-spacing:-.07em}.metaverse-hero h2 span{display:inline;background:linear-gradient(90deg,#b49bff,#5ef5ff 52%,#ff7ce7);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}.metaverse-hero p{max-width:760px;margin:18px 0 0;color:#c5c8df;font-size:1rem;line-height:1.7}.hero-stat-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.hero-stat-grid div{min-width:0;border:1px solid var(--line-soft);border-radius:18px;background:#090d1f8f;padding:14px;box-shadow:inset 0 1px #ffffff14;-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px)}.hero-stat-grid span{display:block;color:var(--muted);font-size:.7rem;font-weight:900;letter-spacing:.08em;text-transform:uppercase}.hero-stat-grid strong{display:block;margin-top:7px;overflow:hidden;color:var(--ink);font-family:Space Grotesk,IBM Plex Sans,sans-serif;font-size:1.05rem;text-overflow:ellipsis;white-space:nowrap}.panel-surface,.panel{border:1px solid var(--line);border-radius:24px;background:var(--surface);box-shadow:var(--shadow);backdrop-filter:blur(22px) saturate(1.25);-webkit-backdrop-filter:blur(22px) saturate(1.25)}.topbar{display:grid;grid-template-columns:minmax(220px,1fr) auto auto minmax(320px,.9fr);align-items:center;gap:14px;padding:16px}.title-wrap{min-width:0}.title-wrap h1,.page h1{margin:0;color:var(--ink);font-family:Space Grotesk,IBM Plex Sans,sans-serif;font-size:1.5rem;line-height:1.1;font-weight:800;letter-spacing:-.04em}.title-wrap p,.muted{margin:4px 0 0;color:var(--muted);font-size:.88rem}.mode-toggle{display:grid;grid-template-columns:repeat(2,minmax(126px,1fr));gap:6px;padding:4px;border:1px solid var(--line);border-radius:18px;background:#050814b3;box-shadow:inset 0 0 24px #9b7cff14}.mode-toggle button{min-height:34px;padding:6px 10px;border-radius:14px;box-shadow:none}.mode-toggle button.active{background:linear-gradient(135deg,var(--accent),var(--accent-2));color:#030414;box-shadow:0 10px 28px #28e8ff2e}.topbar-status,.topbar-actions,.button-row{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:8px}.topbar-actions{display:grid;grid-template-columns:minmax(170px,1fr) auto}.qtip-trigger{min-height:32px;padding:6px 11px}.status-chip,.panel-heading span,.med-card-header span{display:inline-flex;align-items:center;min-height:28px;border:1px solid var(--line);border-radius:999px;padding:4px 10px;background:#0c1023b8;color:var(--muted);font-size:.72rem;font-weight:800;letter-spacing:.04em;text-transform:uppercase;white-space:nowrap}.status-chip.live{border-color:#28e8ff70;background:#28e8ff1f;color:#78f6ff;box-shadow:0 0 18px #28e8ff24}.status-chip.idle{border-color:#9aa6b247}.advanced-strip{display:grid;grid-template-columns:minmax(160px,.4fr) minmax(260px,1fr);gap:12px;margin-top:12px;padding:14px}.model-truth{margin-top:12px;padding:14px}.model-truth.verified{border-color:#28e8ff80}.model-truth.unverified{border-color:#ffd35c70}.model-truth p{margin:0 0 12px;color:var(--muted);font-size:.88rem;line-height:1.5}.model-truth-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}.model-truth-grid div{min-width:0;border:1px solid var(--line-soft);border-radius:18px;background:var(--surface-2);padding:10px}.model-truth-grid span{color:var(--muted);font-size:.7rem;font-weight:800;letter-spacing:.05em;text-transform:uppercase}.model-truth-grid strong{display:block;margin-top:5px;color:var(--ink);font-size:.86rem;line-height:1.35;overflow-wrap:anywhere}.field{display:flex;min-width:0;flex-direction:column;gap:6px}.field span,.kpi-grid span,.action-detail-grid span,.compact-defs dt{color:var(--muted);font-size:.72rem;font-weight:800;letter-spacing:.05em;text-transform:uppercase}.workbench-layout{display:grid;grid-template-columns:minmax(320px,1.05fr) minmax(320px,.95fr);gap:16px;margin-top:16px;align-items:start}.panel-wide{grid-column:1 / -1}.panel-scroll{min-height:348px;padding:16px}.panel-heading{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}.inline-heading{margin-bottom:10px}.panel-heading h2,.panel h3,.history-grid h2{margin:0;color:#d8d6ff;font-family:Space Grotesk,IBM Plex Sans,sans-serif;font-size:.82rem;font-weight:800;letter-spacing:.08em;text-transform:uppercase}.panel-surface:not(.topbar,.advanced-strip,.metaverse-hero){padding:16px}.kpi-grid,.action-detail-grid{display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:10px}.kpi-grid div,.action-detail-grid div{min-width:0;min-height:72px;border:1px solid var(--line-soft);border-radius:18px;background:var(--surface-2);padding:12px;box-shadow:inset 0 1px #ffffff0f}.kpi-grid strong,.action-detail-grid strong,.compact-defs dd{display:block;margin-top:6px;color:var(--ink);font-family:Space Grotesk,IBM Plex Sans,sans-serif;font-size:.96rem;line-height:1.25;overflow-wrap:anywhere}.overview-lower{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px}.overview-lower h3{margin:0 0 8px;color:var(--muted);font-size:.78rem;letter-spacing:.05em;text-transform:uppercase}.compact-defs{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:0}.compact-defs div{min-width:0;border:1px solid var(--line-soft);border-radius:16px;background:#080c1d9e;padding:10px}.compact-defs dd{margin-left:0;font-size:.86rem}.candidate-list,.history-list,.reward-bars,.event-log{display:flex;flex-direction:column;gap:8px;max-height:292px;overflow:auto;padding-right:2px}.candidate-row{display:grid;grid-template-columns:minmax(150px,1fr) minmax(90px,.65fr) 64px;width:100%;min-height:58px;align-items:center;gap:8px;border-color:var(--line-soft);background:var(--surface-2);color:var(--ink);text-align:left;box-shadow:none}.candidate-row:hover:not(:disabled){border-color:#28e8ff52;background:var(--surface-3);box-shadow:inset 0 0 24px #28e8ff14}.candidate-row.selected{border-color:#28e8ffb8;background:linear-gradient(90deg,#28e8ff29,#9b7cff14),#0b1023b8;box-shadow:inset 3px 0 0 var(--accent-2),0 0 26px #28e8ff1a}.candidate-row.illegal{border-color:#ffd35c38;background:#221b317a;color:#f6f7ff94}.candidate-row.illegal strong{color:#f7d878}.candidate-row span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.candidate-row strong{display:block;color:#90f8ff;font-size:.82rem}.action-console{min-height:348px}.action-detail-grid{grid-template-columns:repeat(2,minmax(0,1fr));margin-bottom:12px}.action-console .field{margin-bottom:10px}.console-notice{margin:0 0 12px;border:1px solid rgba(255,211,92,.34);border-radius:16px;background:#ffd35c1a;color:#f7d878;padding:10px 12px;font-size:.84rem;line-height:1.45}.console-notice strong{color:#fff4b8}.button-row{justify-content:flex-start}.reward-row{display:grid;grid-template-columns:minmax(150px,.9fr) minmax(110px,1fr) 56px;align-items:center;gap:8px;font-size:.8rem}.reward-row span{min-width:0;overflow:hidden;color:var(--muted);text-overflow:ellipsis;white-space:nowrap}.reward-row strong{color:var(--ink);font-family:JetBrains Mono,ui-monospace,monospace;font-size:.76rem;text-align:right}.reward-track{height:7px;overflow:hidden;border-radius:999px;background:#040712db}.reward-fill{height:100%;border-radius:inherit;background:linear-gradient(90deg,var(--accent-3),var(--accent),var(--accent-2));box-shadow:0 0 16px #28e8ff5c;transition:width .22s ease}.med-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:10px}.med-card{min-width:0;border:1px solid var(--line-soft);border-radius:18px;background:var(--surface-2);padding:12px;box-shadow:inset 0 1px #ffffff0f}.med-card.high-risk{border-color:#ff4fd86b;box-shadow:0 0 22px #ff4fd814,inset 0 1px #ffffff0f}.med-card-header{display:flex;align-items:center;justify-content:space-between;gap:8px}.med-card-header strong{min-width:0;overflow:hidden;color:var(--ink);text-overflow:ellipsis;white-space:nowrap}.med-card-header span{border-color:#ff4fd86b;background:#ff4fd81f;color:#ff9dea;font-size:.64rem}.med-card p,.med-meta{margin:6px 0 0;color:var(--muted);font-size:.84rem}.med-meta{display:flex;flex-wrap:wrap;gap:8px}.med-meta span{color:#8ff6ff}.history-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.history-item,.event-log div{border:1px solid var(--line-soft);border-radius:16px;background:var(--surface-2);padding:10px 12px;color:var(--ink);font-size:.84rem;overflow-wrap:anywhere}.history-item strong{display:block;margin-bottom:4px}.history-item span{color:var(--muted)}.history-item.warning{border-color:#d2992252;color:#f0c36a}.detail-panel{min-height:220px}.event-panel{margin-bottom:22px}.event-log{max-height:210px;font-family:JetBrains Mono,ui-monospace,monospace}.error-banner{margin-bottom:10px;border:1px solid rgba(248,81,73,.36);border-radius:16px;background:#f851491f;color:#ff8b85;padding:10px 12px;font-weight:800}.qtip-overlay{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1000;pointer-events:none}.qtip-dim{position:absolute;top:0;right:0;bottom:0;left:0;background:#03030bb8;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);pointer-events:auto}.qtip-ring{position:fixed;z-index:1001;border:2px solid var(--accent-2);border-radius:20px;box-shadow:0 0 0 4px #28e8ff29,0 0 38px #28e8ff4d;pointer-events:none;transition:top .18s ease,left .18s ease,width .18s ease,height .18s ease}.qtip-card{position:fixed;top:var(--tip-top, 18px);left:var(--tip-left, 18px);z-index:1002;width:min(374px,calc(100vw - 28px));padding:18px;pointer-events:auto;animation:qtipIn .16s ease-out}.qtip-header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px}.qtip-header span,.qtip-header strong{color:var(--accent);font-size:.72rem;font-weight:900;letter-spacing:.08em;text-transform:uppercase}.qtip-card h2{margin:0 0 8px;color:var(--ink);font-size:1.05rem;letter-spacing:0}.qtip-card p{margin:0;color:var(--muted);font-size:.9rem;line-height:1.55}.qtip-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}@keyframes qtipIn{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}.page{padding:20px}.grid,.grid-mini{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr));gap:12px}.list{margin:0;padding-left:18px}.kpi{margin:0;font-size:1.6rem;font-weight:800}.hero-line{width:280px;max-width:100%;height:4px;margin:14px 0;border-radius:999px;background:linear-gradient(90deg,var(--accent),var(--accent-2))}.actions{display:flex;flex-wrap:wrap;gap:8px}@media (max-width: 1180px){.metaverse-hero{grid-template-columns:1fr}.topbar{grid-template-columns:1fr;align-items:stretch}.topbar-status,.topbar-actions{justify-content:flex-start}.workbench-layout,.overview-lower,.history-grid{grid-template-columns:1fr}.panel-wide{grid-column:auto}}@media (max-width: 760px){.workbench-shell{padding:10px}.blackhole-video{top:-20vh;min-width:620px;height:54vh}.metaverse-hero{margin-top:8px;padding:18px}.metaverse-hero h2{font-size:clamp(2rem,13vw,3.4rem);letter-spacing:-.055em}.hero-stat-grid{grid-template-columns:1fr}.topbar,.panel-surface:not(.topbar,.advanced-strip,.metaverse-hero),.advanced-strip{padding:12px}.mode-toggle,.topbar-actions,.advanced-strip,.model-truth-grid,.kpi-grid,.action-detail-grid,.compact-defs,.grid,.grid-mini{grid-template-columns:1fr}.topbar-actions button,.button-row button,.qtip-actions button{width:100%}.qtip-card{inset:auto 10px 14px 10px;width:auto}.qtip-actions{flex-direction:column}.qtip-ring{display:none}.candidate-row,.reward-row{grid-template-columns:1fr}.candidate-row span,.reward-row span{white-space:normal}.reward-row strong{text-align:left}.panel-scroll,.action-console,.detail-panel{min-height:auto}.candidate-list,.history-list,.reward-bars,.event-log{max-height:none}}::-webkit-scrollbar{width:7px;height:7px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{border-radius:999px;background:#9aa6b257} diff --git a/app/ui/frontend/dist/assets/index-DgY-oaWG.js b/app/ui/frontend/dist/assets/index-DgY-oaWG.js new file mode 100644 index 0000000000000000000000000000000000000000..62266eb191e881e8a9716681cf7713e886a65d28 --- /dev/null +++ b/app/ui/frontend/dist/assets/index-DgY-oaWG.js @@ -0,0 +1,40 @@ +(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const i of l)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function t(l){const i={};return l.integrity&&(i.integrity=l.integrity),l.referrerPolicy&&(i.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?i.credentials="include":l.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function r(l){if(l.ep)return;l.ep=!0;const i=t(l);fetch(l.href,i)}})();function ud(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Os={exports:{}},zl={},Is={exports:{}},M={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var xr=Symbol.for("react.element"),sd=Symbol.for("react.portal"),ad=Symbol.for("react.fragment"),cd=Symbol.for("react.strict_mode"),dd=Symbol.for("react.profiler"),fd=Symbol.for("react.provider"),pd=Symbol.for("react.context"),hd=Symbol.for("react.forward_ref"),md=Symbol.for("react.suspense"),vd=Symbol.for("react.memo"),gd=Symbol.for("react.lazy"),Su=Symbol.iterator;function yd(e){return e===null||typeof e!="object"?null:(e=Su&&e[Su]||e["@@iterator"],typeof e=="function"?e:null)}var Ds={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Fs=Object.assign,As={};function jt(e,n,t){this.props=e,this.context=n,this.refs=As,this.updater=t||Ds}jt.prototype.isReactComponent={};jt.prototype.setState=function(e,n){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,n,"setState")};jt.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function $s(){}$s.prototype=jt.prototype;function wo(e,n,t){this.props=e,this.context=n,this.refs=As,this.updater=t||Ds}var So=wo.prototype=new $s;So.constructor=wo;Fs(So,jt.prototype);So.isPureReactComponent=!0;var ku=Array.isArray,Us=Object.prototype.hasOwnProperty,ko={current:null},Bs={key:!0,ref:!0,__self:!0,__source:!0};function Qs(e,n,t){var r,l={},i=null,o=null;if(n!=null)for(r in n.ref!==void 0&&(o=n.ref),n.key!==void 0&&(i=""+n.key),n)Us.call(n,r)&&!Bs.hasOwnProperty(r)&&(l[r]=n[r]);var u=arguments.length-2;if(u===1)l.children=t;else if(1>>1,J=_[A];if(0>>1;Al(Mn,R))qel(he,Mn)?(_[A]=he,_[qe]=R,A=qe):(_[A]=Mn,_[De]=R,A=De);else if(qel(he,R))_[A]=he,_[qe]=R,A=qe;else break e}}return T}function l(_,T){var R=_.sortIndex-T.sortIndex;return R!==0?R:_.id-T.id}if(typeof performance=="object"&&typeof performance.now=="function"){var i=performance;e.unstable_now=function(){return i.now()}}else{var o=Date,u=o.now();e.unstable_now=function(){return o.now()-u}}var s=[],d=[],v=1,h=null,m=3,w=!1,k=!1,S=!1,I=typeof setTimeout=="function"?setTimeout:null,f=typeof clearTimeout=="function"?clearTimeout:null,c=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function p(_){for(var T=t(d);T!==null;){if(T.callback===null)r(d);else if(T.startTime<=_)r(d),T.sortIndex=T.expirationTime,n(s,T);else break;T=t(d)}}function g(_){if(S=!1,p(_),!k)if(t(s)!==null)k=!0,Lt(x);else{var T=t(d);T!==null&&Jn(g,T.startTime-_)}}function x(_,T){k=!1,S&&(S=!1,f(j),j=-1),w=!0;var R=m;try{for(p(T),h=t(s);h!==null&&(!(h.expirationTime>T)||_&&!ke());){var A=h.callback;if(typeof A=="function"){h.callback=null,m=h.priorityLevel;var J=A(h.expirationTime<=T);T=e.unstable_now(),typeof J=="function"?h.callback=J:h===t(s)&&r(s),p(T)}else r(s);h=t(s)}if(h!==null)var cn=!0;else{var De=t(d);De!==null&&Jn(g,De.startTime-T),cn=!1}return cn}finally{h=null,m=R,w=!1}}var C=!1,N=null,j=-1,Q=5,z=-1;function ke(){return!(e.unstable_now()-z_||125<_?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):Q=0<_?Math.floor(1e3/_):5},e.unstable_getCurrentPriorityLevel=function(){return m},e.unstable_getFirstCallbackNode=function(){return t(s)},e.unstable_next=function(_){switch(m){case 1:case 2:case 3:var T=3;break;default:T=m}var R=m;m=T;try{return _()}finally{m=R}},e.unstable_pauseExecution=function(){},e.unstable_requestPaint=function(){},e.unstable_runWithPriority=function(_,T){switch(_){case 1:case 2:case 3:case 4:case 5:break;default:_=3}var R=m;m=_;try{return T()}finally{m=R}},e.unstable_scheduleCallback=function(_,T,R){var A=e.unstable_now();switch(typeof R=="object"&&R!==null?(R=R.delay,R=typeof R=="number"&&0A?(_.sortIndex=R,n(d,_),t(s)===null&&_===t(d)&&(S?(f(j),j=-1):S=!0,Jn(g,R-A))):(_.sortIndex=J,n(s,_),k||w||(k=!0,Lt(x))),_},e.unstable_shouldYield=ke,e.unstable_wrapCallback=function(_){var T=m;return function(){var R=m;m=T;try{return _.apply(this,arguments)}finally{m=R}}}})(Gs);Ks.exports=Gs;var Rd=Ks.exports;/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var zd=L,Ce=Rd;function y(e){for(var n="https://reactjs.org/docs/error-decoder.html?invariant="+e,t=1;t"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Ni=Object.prototype.hasOwnProperty,Ld=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,_u={},Eu={};function Md(e){return Ni.call(Eu,e)?!0:Ni.call(_u,e)?!1:Ld.test(e)?Eu[e]=!0:(_u[e]=!0,!1)}function Od(e,n,t,r){if(t!==null&&t.type===0)return!1;switch(typeof n){case"function":case"symbol":return!0;case"boolean":return r?!1:t!==null?!t.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Id(e,n,t,r){if(n===null||typeof n>"u"||Od(e,n,t,r))return!0;if(r)return!1;if(t!==null)switch(t.type){case 3:return!n;case 4:return n===!1;case 5:return isNaN(n);case 6:return isNaN(n)||1>n}return!1}function pe(e,n,t,r,l,i,o){this.acceptsBooleans=n===2||n===3||n===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=t,this.propertyName=e,this.type=n,this.sanitizeURL=i,this.removeEmptyString=o}var ie={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){ie[e]=new pe(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var n=e[0];ie[n]=new pe(n,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){ie[e]=new pe(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){ie[e]=new pe(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){ie[e]=new pe(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){ie[e]=new pe(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){ie[e]=new pe(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){ie[e]=new pe(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){ie[e]=new pe(e,5,!1,e.toLowerCase(),null,!1,!1)});var _o=/[\-:]([a-z])/g;function Eo(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var n=e.replace(_o,Eo);ie[n]=new pe(n,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var n=e.replace(_o,Eo);ie[n]=new pe(n,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var n=e.replace(_o,Eo);ie[n]=new pe(n,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){ie[e]=new pe(e,1,!1,e.toLowerCase(),null,!1,!1)});ie.xlinkHref=new pe("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){ie[e]=new pe(e,1,!1,e.toLowerCase(),null,!0,!0)});function No(e,n,t,r){var l=ie.hasOwnProperty(n)?ie[n]:null;(l!==null?l.type!==0:r||!(2u||l[o]!==i[u]){var s=` +`+l[o].replace(" at new "," at ");return e.displayName&&s.includes("")&&(s=s.replace("",e.displayName)),s}while(1<=o&&0<=u);break}}}finally{ql=!1,Error.prepareStackTrace=t}return(e=e?e.displayName||e.name:"")?Kt(e):""}function Dd(e){switch(e.tag){case 5:return Kt(e.type);case 16:return Kt("Lazy");case 13:return Kt("Suspense");case 19:return Kt("SuspenseList");case 0:case 2:case 15:return e=bl(e.type,!1),e;case 11:return e=bl(e.type.render,!1),e;case 1:return e=bl(e.type,!0),e;default:return""}}function Ti(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case rt:return"Fragment";case tt:return"Portal";case Ci:return"Profiler";case Co:return"StrictMode";case ji:return"Suspense";case Pi:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case Zs:return(e.displayName||"Context")+".Consumer";case Xs:return(e._context.displayName||"Context")+".Provider";case jo:var n=e.render;return e=e.displayName,e||(e=n.displayName||n.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Po:return n=e.displayName||null,n!==null?n:Ti(e.type)||"Memo";case fn:n=e._payload,e=e._init;try{return Ti(e(n))}catch{}}return null}function Fd(e){var n=e.type;switch(e.tag){case 24:return"Cache";case 9:return(n.displayName||"Context")+".Consumer";case 10:return(n._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=n.render,e=e.displayName||e.name||"",n.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return n;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return Ti(n);case 8:return n===Co?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof n=="function")return n.displayName||n.name||null;if(typeof n=="string")return n}return null}function jn(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function qs(e){var n=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(n==="checkbox"||n==="radio")}function Ad(e){var n=qs(e)?"checked":"value",t=Object.getOwnPropertyDescriptor(e.constructor.prototype,n),r=""+e[n];if(!e.hasOwnProperty(n)&&typeof t<"u"&&typeof t.get=="function"&&typeof t.set=="function"){var l=t.get,i=t.set;return Object.defineProperty(e,n,{configurable:!0,get:function(){return l.call(this)},set:function(o){r=""+o,i.call(this,o)}}),Object.defineProperty(e,n,{enumerable:t.enumerable}),{getValue:function(){return r},setValue:function(o){r=""+o},stopTracking:function(){e._valueTracker=null,delete e[n]}}}}function Lr(e){e._valueTracker||(e._valueTracker=Ad(e))}function bs(e){if(!e)return!1;var n=e._valueTracker;if(!n)return!0;var t=n.getValue(),r="";return e&&(r=qs(e)?e.checked?"true":"false":e.value),e=r,e!==t?(n.setValue(e),!0):!1}function ol(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Ri(e,n){var t=n.checked;return G({},n,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:t??e._wrapperState.initialChecked})}function Cu(e,n){var t=n.defaultValue==null?"":n.defaultValue,r=n.checked!=null?n.checked:n.defaultChecked;t=jn(n.value!=null?n.value:t),e._wrapperState={initialChecked:r,initialValue:t,controlled:n.type==="checkbox"||n.type==="radio"?n.checked!=null:n.value!=null}}function ea(e,n){n=n.checked,n!=null&&No(e,"checked",n,!1)}function zi(e,n){ea(e,n);var t=jn(n.value),r=n.type;if(t!=null)r==="number"?(t===0&&e.value===""||e.value!=t)&&(e.value=""+t):e.value!==""+t&&(e.value=""+t);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}n.hasOwnProperty("value")?Li(e,n.type,t):n.hasOwnProperty("defaultValue")&&Li(e,n.type,jn(n.defaultValue)),n.checked==null&&n.defaultChecked!=null&&(e.defaultChecked=!!n.defaultChecked)}function ju(e,n,t){if(n.hasOwnProperty("value")||n.hasOwnProperty("defaultValue")){var r=n.type;if(!(r!=="submit"&&r!=="reset"||n.value!==void 0&&n.value!==null))return;n=""+e._wrapperState.initialValue,t||n===e.value||(e.value=n),e.defaultValue=n}t=e.name,t!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,t!==""&&(e.name=t)}function Li(e,n,t){(n!=="number"||ol(e.ownerDocument)!==e)&&(t==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+t&&(e.defaultValue=""+t))}var Gt=Array.isArray;function ht(e,n,t,r){if(e=e.options,n){n={};for(var l=0;l"+n.valueOf().toString()+"",n=Mr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;n.firstChild;)e.appendChild(n.firstChild)}});function or(e,n){if(n){var t=e.firstChild;if(t&&t===e.lastChild&&t.nodeType===3){t.nodeValue=n;return}}e.textContent=n}var Zt={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},$d=["Webkit","ms","Moz","O"];Object.keys(Zt).forEach(function(e){$d.forEach(function(n){n=n+e.charAt(0).toUpperCase()+e.substring(1),Zt[n]=Zt[e]})});function la(e,n,t){return n==null||typeof n=="boolean"||n===""?"":t||typeof n!="number"||n===0||Zt.hasOwnProperty(e)&&Zt[e]?(""+n).trim():n+"px"}function ia(e,n){e=e.style;for(var t in n)if(n.hasOwnProperty(t)){var r=t.indexOf("--")===0,l=la(t,n[t],r);t==="float"&&(t="cssFloat"),r?e.setProperty(t,l):e[t]=l}}var Ud=G({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Ii(e,n){if(n){if(Ud[e]&&(n.children!=null||n.dangerouslySetInnerHTML!=null))throw Error(y(137,e));if(n.dangerouslySetInnerHTML!=null){if(n.children!=null)throw Error(y(60));if(typeof n.dangerouslySetInnerHTML!="object"||!("__html"in n.dangerouslySetInnerHTML))throw Error(y(61))}if(n.style!=null&&typeof n.style!="object")throw Error(y(62))}}function Di(e,n){if(e.indexOf("-")===-1)return typeof n.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Fi=null;function To(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Ai=null,mt=null,vt=null;function Ru(e){if(e=Nr(e)){if(typeof Ai!="function")throw Error(y(280));var n=e.stateNode;n&&(n=Dl(n),Ai(e.stateNode,e.type,n))}}function oa(e){mt?vt?vt.push(e):vt=[e]:mt=e}function ua(){if(mt){var e=mt,n=vt;if(vt=mt=null,Ru(e),n)for(e=0;e>>=0,e===0?32:31-(Jd(e)/qd|0)|0}var Or=64,Ir=4194304;function Yt(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function cl(e,n){var t=e.pendingLanes;if(t===0)return 0;var r=0,l=e.suspendedLanes,i=e.pingedLanes,o=t&268435455;if(o!==0){var u=o&~l;u!==0?r=Yt(u):(i&=o,i!==0&&(r=Yt(i)))}else o=t&~l,o!==0?r=Yt(o):i!==0&&(r=Yt(i));if(r===0)return 0;if(n!==0&&n!==r&&!(n&l)&&(l=r&-r,i=n&-n,l>=i||l===16&&(i&4194240)!==0))return n;if(r&4&&(r|=t&16),n=e.entangledLanes,n!==0)for(e=e.entanglements,n&=r;0t;t++)n.push(e);return n}function _r(e,n,t){e.pendingLanes|=n,n!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,n=31-Qe(n),e[n]=t}function tf(e,n){var t=e.pendingLanes&~n;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=n,e.mutableReadLanes&=n,e.entangledLanes&=n,n=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=qt),$u=" ",Uu=!1;function ja(e,n){switch(e){case"keyup":return zf.indexOf(n.keyCode)!==-1;case"keydown":return n.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Pa(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var lt=!1;function Mf(e,n){switch(e){case"compositionend":return Pa(n);case"keypress":return n.which!==32?null:(Uu=!0,$u);case"textInput":return e=n.data,e===$u&&Uu?null:e;default:return null}}function Of(e,n){if(lt)return e==="compositionend"||!Fo&&ja(e,n)?(e=Na(),Jr=Oo=vn=null,lt=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(n.ctrlKey||n.altKey||n.metaKey)||n.ctrlKey&&n.altKey){if(n.char&&1=n)return{node:t,offset:n-e};e=r}e:{for(;t;){if(t.nextSibling){t=t.nextSibling;break e}t=t.parentNode}t=void 0}t=Vu(t)}}function La(e,n){return e&&n?e===n?!0:e&&e.nodeType===3?!1:n&&n.nodeType===3?La(e,n.parentNode):"contains"in e?e.contains(n):e.compareDocumentPosition?!!(e.compareDocumentPosition(n)&16):!1:!1}function Ma(){for(var e=window,n=ol();n instanceof e.HTMLIFrameElement;){try{var t=typeof n.contentWindow.location.href=="string"}catch{t=!1}if(t)e=n.contentWindow;else break;n=ol(e.document)}return n}function Ao(e){var n=e&&e.nodeName&&e.nodeName.toLowerCase();return n&&(n==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||n==="textarea"||e.contentEditable==="true")}function Wf(e){var n=Ma(),t=e.focusedElem,r=e.selectionRange;if(n!==t&&t&&t.ownerDocument&&La(t.ownerDocument.documentElement,t)){if(r!==null&&Ao(t)){if(n=r.start,e=r.end,e===void 0&&(e=n),"selectionStart"in t)t.selectionStart=n,t.selectionEnd=Math.min(e,t.value.length);else if(e=(n=t.ownerDocument||document)&&n.defaultView||window,e.getSelection){e=e.getSelection();var l=t.textContent.length,i=Math.min(r.start,l);r=r.end===void 0?i:Math.min(r.end,l),!e.extend&&i>r&&(l=r,r=i,i=l),l=Hu(t,i);var o=Hu(t,r);l&&o&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(n=n.createRange(),n.setStart(l.node,l.offset),e.removeAllRanges(),i>r?(e.addRange(n),e.extend(o.node,o.offset)):(n.setEnd(o.node,o.offset),e.addRange(n)))}}for(n=[],e=t;e=e.parentNode;)e.nodeType===1&&n.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof t.focus=="function"&&t.focus(),t=0;t=document.documentMode,it=null,Vi=null,er=null,Hi=!1;function Ku(e,n,t){var r=t.window===t?t.document:t.nodeType===9?t:t.ownerDocument;Hi||it==null||it!==ol(r)||(r=it,"selectionStart"in r&&Ao(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),er&&fr(er,r)||(er=r,r=pl(Vi,"onSelect"),0st||(e.current=Ji[st],Ji[st]=null,st--)}function F(e,n){st++,Ji[st]=e.current,e.current=n}var Pn={},ae=Rn(Pn),ye=Rn(!1),Vn=Pn;function kt(e,n){var t=e.type.contextTypes;if(!t)return Pn;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===n)return r.__reactInternalMemoizedMaskedChildContext;var l={},i;for(i in t)l[i]=n[i];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=n,e.__reactInternalMemoizedMaskedChildContext=l),l}function we(e){return e=e.childContextTypes,e!=null}function ml(){U(ye),U(ae)}function bu(e,n,t){if(ae.current!==Pn)throw Error(y(168));F(ae,n),F(ye,t)}function Qa(e,n,t){var r=e.stateNode;if(n=n.childContextTypes,typeof r.getChildContext!="function")return t;r=r.getChildContext();for(var l in r)if(!(l in n))throw Error(y(108,Fd(e)||"Unknown",l));return G({},t,r)}function vl(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Pn,Vn=ae.current,F(ae,e),F(ye,ye.current),!0}function es(e,n,t){var r=e.stateNode;if(!r)throw Error(y(169));t?(e=Qa(e,n,Vn),r.__reactInternalMemoizedMergedChildContext=e,U(ye),U(ae),F(ae,e)):U(ye),F(ye,t)}var en=null,Fl=!1,pi=!1;function Wa(e){en===null?en=[e]:en.push(e)}function np(e){Fl=!0,Wa(e)}function zn(){if(!pi&&en!==null){pi=!0;var e=0,n=D;try{var t=en;for(D=1;e>=o,l-=o,nn=1<<32-Qe(n)+l|t<j?(Q=N,N=null):Q=N.sibling;var z=m(f,N,p[j],g);if(z===null){N===null&&(N=Q);break}e&&N&&z.alternate===null&&n(f,N),c=i(z,c,j),C===null?x=z:C.sibling=z,C=z,N=Q}if(j===p.length)return t(f,N),B&&Dn(f,j),x;if(N===null){for(;jj?(Q=N,N=null):Q=N.sibling;var ke=m(f,N,z.value,g);if(ke===null){N===null&&(N=Q);break}e&&N&&ke.alternate===null&&n(f,N),c=i(ke,c,j),C===null?x=ke:C.sibling=ke,C=ke,N=Q}if(z.done)return t(f,N),B&&Dn(f,j),x;if(N===null){for(;!z.done;j++,z=p.next())z=h(f,z.value,g),z!==null&&(c=i(z,c,j),C===null?x=z:C.sibling=z,C=z);return B&&Dn(f,j),x}for(N=r(f,N);!z.done;j++,z=p.next())z=w(N,f,j,z.value,g),z!==null&&(e&&z.alternate!==null&&N.delete(z.key===null?j:z.key),c=i(z,c,j),C===null?x=z:C.sibling=z,C=z);return e&&N.forEach(function(Ln){return n(f,Ln)}),B&&Dn(f,j),x}function I(f,c,p,g){if(typeof p=="object"&&p!==null&&p.type===rt&&p.key===null&&(p=p.props.children),typeof p=="object"&&p!==null){switch(p.$$typeof){case zr:e:{for(var x=p.key,C=c;C!==null;){if(C.key===x){if(x=p.type,x===rt){if(C.tag===7){t(f,C.sibling),c=l(C,p.props.children),c.return=f,f=c;break e}}else if(C.elementType===x||typeof x=="object"&&x!==null&&x.$$typeof===fn&&rs(x)===C.type){t(f,C.sibling),c=l(C,p.props),c.ref=Wt(f,C,p),c.return=f,f=c;break e}t(f,C);break}else n(f,C);C=C.sibling}p.type===rt?(c=Qn(p.props.children,f.mode,g,p.key),c.return=f,f=c):(g=il(p.type,p.key,p.props,null,f.mode,g),g.ref=Wt(f,c,p),g.return=f,f=g)}return o(f);case tt:e:{for(C=p.key;c!==null;){if(c.key===C)if(c.tag===4&&c.stateNode.containerInfo===p.containerInfo&&c.stateNode.implementation===p.implementation){t(f,c.sibling),c=l(c,p.children||[]),c.return=f,f=c;break e}else{t(f,c);break}else n(f,c);c=c.sibling}c=ki(p,f.mode,g),c.return=f,f=c}return o(f);case fn:return C=p._init,I(f,c,C(p._payload),g)}if(Gt(p))return k(f,c,p,g);if(At(p))return S(f,c,p,g);Qr(f,p)}return typeof p=="string"&&p!==""||typeof p=="number"?(p=""+p,c!==null&&c.tag===6?(t(f,c.sibling),c=l(c,p),c.return=f,f=c):(t(f,c),c=Si(p,f.mode,g),c.return=f,f=c),o(f)):t(f,c)}return I}var _t=Ga(!0),Ya=Ga(!1),wl=Rn(null),Sl=null,dt=null,Qo=null;function Wo(){Qo=dt=Sl=null}function Vo(e){var n=wl.current;U(wl),e._currentValue=n}function eo(e,n,t){for(;e!==null;){var r=e.alternate;if((e.childLanes&n)!==n?(e.childLanes|=n,r!==null&&(r.childLanes|=n)):r!==null&&(r.childLanes&n)!==n&&(r.childLanes|=n),e===t)break;e=e.return}}function yt(e,n){Sl=e,Qo=dt=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&n&&(ge=!0),e.firstContext=null)}function Oe(e){var n=e._currentValue;if(Qo!==e)if(e={context:e,memoizedValue:n,next:null},dt===null){if(Sl===null)throw Error(y(308));dt=e,Sl.dependencies={lanes:0,firstContext:e}}else dt=dt.next=e;return n}var $n=null;function Ho(e){$n===null?$n=[e]:$n.push(e)}function Xa(e,n,t,r){var l=n.interleaved;return l===null?(t.next=t,Ho(n)):(t.next=l.next,l.next=t),n.interleaved=t,un(e,r)}function un(e,n){e.lanes|=n;var t=e.alternate;for(t!==null&&(t.lanes|=n),t=e,e=e.return;e!==null;)e.childLanes|=n,t=e.alternate,t!==null&&(t.childLanes|=n),t=e,e=e.return;return t.tag===3?t.stateNode:null}var pn=!1;function Ko(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Za(e,n){e=e.updateQueue,n.updateQueue===e&&(n.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function rn(e,n){return{eventTime:e,lane:n,tag:0,payload:null,callback:null,next:null}}function xn(e,n,t){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,O&2){var l=r.pending;return l===null?n.next=n:(n.next=l.next,l.next=n),r.pending=n,un(e,t)}return l=r.interleaved,l===null?(n.next=n,Ho(r)):(n.next=l.next,l.next=n),r.interleaved=n,un(e,t)}function br(e,n,t){if(n=n.updateQueue,n!==null&&(n=n.shared,(t&4194240)!==0)){var r=n.lanes;r&=e.pendingLanes,t|=r,n.lanes=t,zo(e,t)}}function ls(e,n){var t=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,t===r)){var l=null,i=null;if(t=t.firstBaseUpdate,t!==null){do{var o={eventTime:t.eventTime,lane:t.lane,tag:t.tag,payload:t.payload,callback:t.callback,next:null};i===null?l=i=o:i=i.next=o,t=t.next}while(t!==null);i===null?l=i=n:i=i.next=n}else l=i=n;t={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:i,shared:r.shared,effects:r.effects},e.updateQueue=t;return}e=t.lastBaseUpdate,e===null?t.firstBaseUpdate=n:e.next=n,t.lastBaseUpdate=n}function kl(e,n,t,r){var l=e.updateQueue;pn=!1;var i=l.firstBaseUpdate,o=l.lastBaseUpdate,u=l.shared.pending;if(u!==null){l.shared.pending=null;var s=u,d=s.next;s.next=null,o===null?i=d:o.next=d,o=s;var v=e.alternate;v!==null&&(v=v.updateQueue,u=v.lastBaseUpdate,u!==o&&(u===null?v.firstBaseUpdate=d:u.next=d,v.lastBaseUpdate=s))}if(i!==null){var h=l.baseState;o=0,v=d=s=null,u=i;do{var m=u.lane,w=u.eventTime;if((r&m)===m){v!==null&&(v=v.next={eventTime:w,lane:0,tag:u.tag,payload:u.payload,callback:u.callback,next:null});e:{var k=e,S=u;switch(m=n,w=t,S.tag){case 1:if(k=S.payload,typeof k=="function"){h=k.call(w,h,m);break e}h=k;break e;case 3:k.flags=k.flags&-65537|128;case 0:if(k=S.payload,m=typeof k=="function"?k.call(w,h,m):k,m==null)break e;h=G({},h,m);break e;case 2:pn=!0}}u.callback!==null&&u.lane!==0&&(e.flags|=64,m=l.effects,m===null?l.effects=[u]:m.push(u))}else w={eventTime:w,lane:m,tag:u.tag,payload:u.payload,callback:u.callback,next:null},v===null?(d=v=w,s=h):v=v.next=w,o|=m;if(u=u.next,u===null){if(u=l.shared.pending,u===null)break;m=u,u=m.next,m.next=null,l.lastBaseUpdate=m,l.shared.pending=null}}while(!0);if(v===null&&(s=h),l.baseState=s,l.firstBaseUpdate=d,l.lastBaseUpdate=v,n=l.shared.interleaved,n!==null){l=n;do o|=l.lane,l=l.next;while(l!==n)}else i===null&&(l.shared.lanes=0);Gn|=o,e.lanes=o,e.memoizedState=h}}function is(e,n,t){if(e=n.effects,n.effects=null,e!==null)for(n=0;nt?t:4,e(!0);var r=mi.transition;mi.transition={};try{e(!1),n()}finally{D=t,mi.transition=r}}function pc(){return Ie().memoizedState}function ip(e,n,t){var r=En(e);if(t={lane:r,action:t,hasEagerState:!1,eagerState:null,next:null},hc(e))mc(n,t);else if(t=Xa(e,n,t,r),t!==null){var l=de();We(t,e,r,l),vc(t,n,r)}}function op(e,n,t){var r=En(e),l={lane:r,action:t,hasEagerState:!1,eagerState:null,next:null};if(hc(e))mc(n,l);else{var i=e.alternate;if(e.lanes===0&&(i===null||i.lanes===0)&&(i=n.lastRenderedReducer,i!==null))try{var o=n.lastRenderedState,u=i(o,t);if(l.hasEagerState=!0,l.eagerState=u,Ve(u,o)){var s=n.interleaved;s===null?(l.next=l,Ho(n)):(l.next=s.next,s.next=l),n.interleaved=l;return}}catch{}finally{}t=Xa(e,n,l,r),t!==null&&(l=de(),We(t,e,r,l),vc(t,n,r))}}function hc(e){var n=e.alternate;return e===K||n!==null&&n===K}function mc(e,n){nr=_l=!0;var t=e.pending;t===null?n.next=n:(n.next=t.next,t.next=n),e.pending=n}function vc(e,n,t){if(t&4194240){var r=n.lanes;r&=e.pendingLanes,t|=r,n.lanes=t,zo(e,t)}}var El={readContext:Oe,useCallback:oe,useContext:oe,useEffect:oe,useImperativeHandle:oe,useInsertionEffect:oe,useLayoutEffect:oe,useMemo:oe,useReducer:oe,useRef:oe,useState:oe,useDebugValue:oe,useDeferredValue:oe,useTransition:oe,useMutableSource:oe,useSyncExternalStore:oe,useId:oe,unstable_isNewReconciler:!1},up={readContext:Oe,useCallback:function(e,n){return Ge().memoizedState=[e,n===void 0?null:n],e},useContext:Oe,useEffect:us,useImperativeHandle:function(e,n,t){return t=t!=null?t.concat([e]):null,nl(4194308,4,sc.bind(null,n,e),t)},useLayoutEffect:function(e,n){return nl(4194308,4,e,n)},useInsertionEffect:function(e,n){return nl(4,2,e,n)},useMemo:function(e,n){var t=Ge();return n=n===void 0?null:n,e=e(),t.memoizedState=[e,n],e},useReducer:function(e,n,t){var r=Ge();return n=t!==void 0?t(n):n,r.memoizedState=r.baseState=n,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:n},r.queue=e,e=e.dispatch=ip.bind(null,K,e),[r.memoizedState,e]},useRef:function(e){var n=Ge();return e={current:e},n.memoizedState=e},useState:os,useDebugValue:eu,useDeferredValue:function(e){return Ge().memoizedState=e},useTransition:function(){var e=os(!1),n=e[0];return e=lp.bind(null,e[1]),Ge().memoizedState=e,[n,e]},useMutableSource:function(){},useSyncExternalStore:function(e,n,t){var r=K,l=Ge();if(B){if(t===void 0)throw Error(y(407));t=t()}else{if(t=n(),te===null)throw Error(y(349));Kn&30||ec(r,n,t)}l.memoizedState=t;var i={value:t,getSnapshot:n};return l.queue=i,us(tc.bind(null,r,i,e),[e]),r.flags|=2048,Sr(9,nc.bind(null,r,i,t,n),void 0,null),t},useId:function(){var e=Ge(),n=te.identifierPrefix;if(B){var t=tn,r=nn;t=(r&~(1<<32-Qe(r)-1)).toString(32)+t,n=":"+n+"R"+t,t=yr++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=o.createElement(t,{is:r.is}):(e=o.createElement(t),t==="select"&&(o=e,r.multiple?o.multiple=!0:r.size&&(o.size=r.size))):e=o.createElementNS(e,t),e[Ye]=n,e[mr]=r,Cc(e,n,!1,!1),n.stateNode=e;e:{switch(o=Di(t,r),t){case"dialog":$("cancel",e),$("close",e),l=r;break;case"iframe":case"object":case"embed":$("load",e),l=r;break;case"video":case"audio":for(l=0;lCt&&(n.flags|=128,r=!0,Vt(i,!1),n.lanes=4194304)}else{if(!r)if(e=xl(o),e!==null){if(n.flags|=128,r=!0,t=e.updateQueue,t!==null&&(n.updateQueue=t,n.flags|=4),Vt(i,!0),i.tail===null&&i.tailMode==="hidden"&&!o.alternate&&!B)return ue(n),null}else 2*X()-i.renderingStartTime>Ct&&t!==1073741824&&(n.flags|=128,r=!0,Vt(i,!1),n.lanes=4194304);i.isBackwards?(o.sibling=n.child,n.child=o):(t=i.last,t!==null?t.sibling=o:n.child=o,i.last=o)}return i.tail!==null?(n=i.tail,i.rendering=n,i.tail=n.sibling,i.renderingStartTime=X(),n.sibling=null,t=H.current,F(H,r?t&1|2:t&1),n):(ue(n),null);case 22:case 23:return ou(),r=n.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(n.flags|=8192),r&&n.mode&1?_e&1073741824&&(ue(n),n.subtreeFlags&6&&(n.flags|=8192)):ue(n),null;case 24:return null;case 25:return null}throw Error(y(156,n.tag))}function mp(e,n){switch(Uo(n),n.tag){case 1:return we(n.type)&&ml(),e=n.flags,e&65536?(n.flags=e&-65537|128,n):null;case 3:return Et(),U(ye),U(ae),Xo(),e=n.flags,e&65536&&!(e&128)?(n.flags=e&-65537|128,n):null;case 5:return Yo(n),null;case 13:if(U(H),e=n.memoizedState,e!==null&&e.dehydrated!==null){if(n.alternate===null)throw Error(y(340));xt()}return e=n.flags,e&65536?(n.flags=e&-65537|128,n):null;case 19:return U(H),null;case 4:return Et(),null;case 10:return Vo(n.type._context),null;case 22:case 23:return ou(),null;case 24:return null;default:return null}}var Vr=!1,se=!1,vp=typeof WeakSet=="function"?WeakSet:Set,E=null;function ft(e,n){var t=e.ref;if(t!==null)if(typeof t=="function")try{t(null)}catch(r){Y(e,n,r)}else t.current=null}function ao(e,n,t){try{t()}catch(r){Y(e,n,r)}}var ys=!1;function gp(e,n){if(Ki=dl,e=Ma(),Ao(e)){if("selectionStart"in e)var t={start:e.selectionStart,end:e.selectionEnd};else e:{t=(t=e.ownerDocument)&&t.defaultView||window;var r=t.getSelection&&t.getSelection();if(r&&r.rangeCount!==0){t=r.anchorNode;var l=r.anchorOffset,i=r.focusNode;r=r.focusOffset;try{t.nodeType,i.nodeType}catch{t=null;break e}var o=0,u=-1,s=-1,d=0,v=0,h=e,m=null;n:for(;;){for(var w;h!==t||l!==0&&h.nodeType!==3||(u=o+l),h!==i||r!==0&&h.nodeType!==3||(s=o+r),h.nodeType===3&&(o+=h.nodeValue.length),(w=h.firstChild)!==null;)m=h,h=w;for(;;){if(h===e)break n;if(m===t&&++d===l&&(u=o),m===i&&++v===r&&(s=o),(w=h.nextSibling)!==null)break;h=m,m=h.parentNode}h=w}t=u===-1||s===-1?null:{start:u,end:s}}else t=null}t=t||{start:0,end:0}}else t=null;for(Gi={focusedElem:e,selectionRange:t},dl=!1,E=n;E!==null;)if(n=E,e=n.child,(n.subtreeFlags&1028)!==0&&e!==null)e.return=n,E=e;else for(;E!==null;){n=E;try{var k=n.alternate;if(n.flags&1024)switch(n.tag){case 0:case 11:case 15:break;case 1:if(k!==null){var S=k.memoizedProps,I=k.memoizedState,f=n.stateNode,c=f.getSnapshotBeforeUpdate(n.elementType===n.type?S:$e(n.type,S),I);f.__reactInternalSnapshotBeforeUpdate=c}break;case 3:var p=n.stateNode.containerInfo;p.nodeType===1?p.textContent="":p.nodeType===9&&p.documentElement&&p.removeChild(p.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(y(163))}}catch(g){Y(n,n.return,g)}if(e=n.sibling,e!==null){e.return=n.return,E=e;break}E=n.return}return k=ys,ys=!1,k}function tr(e,n,t){var r=n.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var i=l.destroy;l.destroy=void 0,i!==void 0&&ao(n,t,i)}l=l.next}while(l!==r)}}function Ul(e,n){if(n=n.updateQueue,n=n!==null?n.lastEffect:null,n!==null){var t=n=n.next;do{if((t.tag&e)===e){var r=t.create;t.destroy=r()}t=t.next}while(t!==n)}}function co(e){var n=e.ref;if(n!==null){var t=e.stateNode;switch(e.tag){case 5:e=t;break;default:e=t}typeof n=="function"?n(e):n.current=e}}function Tc(e){var n=e.alternate;n!==null&&(e.alternate=null,Tc(n)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(n=e.stateNode,n!==null&&(delete n[Ye],delete n[mr],delete n[Zi],delete n[bf],delete n[ep])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Rc(e){return e.tag===5||e.tag===3||e.tag===4}function ws(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Rc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function fo(e,n,t){var r=e.tag;if(r===5||r===6)e=e.stateNode,n?t.nodeType===8?t.parentNode.insertBefore(e,n):t.insertBefore(e,n):(t.nodeType===8?(n=t.parentNode,n.insertBefore(e,t)):(n=t,n.appendChild(e)),t=t._reactRootContainer,t!=null||n.onclick!==null||(n.onclick=hl));else if(r!==4&&(e=e.child,e!==null))for(fo(e,n,t),e=e.sibling;e!==null;)fo(e,n,t),e=e.sibling}function po(e,n,t){var r=e.tag;if(r===5||r===6)e=e.stateNode,n?t.insertBefore(e,n):t.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(po(e,n,t),e=e.sibling;e!==null;)po(e,n,t),e=e.sibling}var re=null,Ue=!1;function dn(e,n,t){for(t=t.child;t!==null;)zc(e,n,t),t=t.sibling}function zc(e,n,t){if(Xe&&typeof Xe.onCommitFiberUnmount=="function")try{Xe.onCommitFiberUnmount(Ll,t)}catch{}switch(t.tag){case 5:se||ft(t,n);case 6:var r=re,l=Ue;re=null,dn(e,n,t),re=r,Ue=l,re!==null&&(Ue?(e=re,t=t.stateNode,e.nodeType===8?e.parentNode.removeChild(t):e.removeChild(t)):re.removeChild(t.stateNode));break;case 18:re!==null&&(Ue?(e=re,t=t.stateNode,e.nodeType===8?fi(e.parentNode,t):e.nodeType===1&&fi(e,t),cr(e)):fi(re,t.stateNode));break;case 4:r=re,l=Ue,re=t.stateNode.containerInfo,Ue=!0,dn(e,n,t),re=r,Ue=l;break;case 0:case 11:case 14:case 15:if(!se&&(r=t.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var i=l,o=i.destroy;i=i.tag,o!==void 0&&(i&2||i&4)&&ao(t,n,o),l=l.next}while(l!==r)}dn(e,n,t);break;case 1:if(!se&&(ft(t,n),r=t.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=t.memoizedProps,r.state=t.memoizedState,r.componentWillUnmount()}catch(u){Y(t,n,u)}dn(e,n,t);break;case 21:dn(e,n,t);break;case 22:t.mode&1?(se=(r=se)||t.memoizedState!==null,dn(e,n,t),se=r):dn(e,n,t);break;default:dn(e,n,t)}}function Ss(e){var n=e.updateQueue;if(n!==null){e.updateQueue=null;var t=e.stateNode;t===null&&(t=e.stateNode=new vp),n.forEach(function(r){var l=Cp.bind(null,e,r);t.has(r)||(t.add(r),r.then(l,l))})}}function Ae(e,n){var t=n.deletions;if(t!==null)for(var r=0;rl&&(l=o),r&=~i}if(r=l,r=X()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*wp(r/1960))-r,10e?16:e,gn===null)var r=!1;else{if(e=gn,gn=null,jl=0,O&6)throw Error(y(331));var l=O;for(O|=4,E=e.current;E!==null;){var i=E,o=i.child;if(E.flags&16){var u=i.deletions;if(u!==null){for(var s=0;sX()-lu?Bn(e,0):ru|=t),Se(e,n)}function $c(e,n){n===0&&(e.mode&1?(n=Ir,Ir<<=1,!(Ir&130023424)&&(Ir=4194304)):n=1);var t=de();e=un(e,n),e!==null&&(_r(e,n,t),Se(e,t))}function Np(e){var n=e.memoizedState,t=0;n!==null&&(t=n.retryLane),$c(e,t)}function Cp(e,n){var t=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(t=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(y(314))}r!==null&&r.delete(n),$c(e,t)}var Uc;Uc=function(e,n,t){if(e!==null)if(e.memoizedProps!==n.pendingProps||ye.current)ge=!0;else{if(!(e.lanes&t)&&!(n.flags&128))return ge=!1,pp(e,n,t);ge=!!(e.flags&131072)}else ge=!1,B&&n.flags&1048576&&Va(n,yl,n.index);switch(n.lanes=0,n.tag){case 2:var r=n.type;tl(e,n),e=n.pendingProps;var l=kt(n,ae.current);yt(n,t),l=Jo(null,n,r,e,l,t);var i=qo();return n.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(n.tag=1,n.memoizedState=null,n.updateQueue=null,we(r)?(i=!0,vl(n)):i=!1,n.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,Ko(n),l.updater=$l,n.stateNode=l,l._reactInternals=n,to(n,r,e,t),n=io(null,n,r,!0,i,t)):(n.tag=0,B&&i&&$o(n),ce(null,n,l,t),n=n.child),n;case 16:r=n.elementType;e:{switch(tl(e,n),e=n.pendingProps,l=r._init,r=l(r._payload),n.type=r,l=n.tag=Pp(r),e=$e(r,e),l){case 0:n=lo(null,n,r,e,t);break e;case 1:n=ms(null,n,r,e,t);break e;case 11:n=ps(null,n,r,e,t);break e;case 14:n=hs(null,n,r,$e(r.type,e),t);break e}throw Error(y(306,r,""))}return n;case 0:return r=n.type,l=n.pendingProps,l=n.elementType===r?l:$e(r,l),lo(e,n,r,l,t);case 1:return r=n.type,l=n.pendingProps,l=n.elementType===r?l:$e(r,l),ms(e,n,r,l,t);case 3:e:{if(_c(n),e===null)throw Error(y(387));r=n.pendingProps,i=n.memoizedState,l=i.element,Za(e,n),kl(n,r,null,t);var o=n.memoizedState;if(r=o.element,i.isDehydrated)if(i={element:r,isDehydrated:!1,cache:o.cache,pendingSuspenseBoundaries:o.pendingSuspenseBoundaries,transitions:o.transitions},n.updateQueue.baseState=i,n.memoizedState=i,n.flags&256){l=Nt(Error(y(423)),n),n=vs(e,n,r,t,l);break e}else if(r!==l){l=Nt(Error(y(424)),n),n=vs(e,n,r,t,l);break e}else for(Ee=kn(n.stateNode.containerInfo.firstChild),Ne=n,B=!0,Be=null,t=Ya(n,null,r,t),n.child=t;t;)t.flags=t.flags&-3|4096,t=t.sibling;else{if(xt(),r===l){n=sn(e,n,t);break e}ce(e,n,r,t)}n=n.child}return n;case 5:return Ja(n),e===null&&bi(n),r=n.type,l=n.pendingProps,i=e!==null?e.memoizedProps:null,o=l.children,Yi(r,l)?o=null:i!==null&&Yi(r,i)&&(n.flags|=32),xc(e,n),ce(e,n,o,t),n.child;case 6:return e===null&&bi(n),null;case 13:return Ec(e,n,t);case 4:return Go(n,n.stateNode.containerInfo),r=n.pendingProps,e===null?n.child=_t(n,null,r,t):ce(e,n,r,t),n.child;case 11:return r=n.type,l=n.pendingProps,l=n.elementType===r?l:$e(r,l),ps(e,n,r,l,t);case 7:return ce(e,n,n.pendingProps,t),n.child;case 8:return ce(e,n,n.pendingProps.children,t),n.child;case 12:return ce(e,n,n.pendingProps.children,t),n.child;case 10:e:{if(r=n.type._context,l=n.pendingProps,i=n.memoizedProps,o=l.value,F(wl,r._currentValue),r._currentValue=o,i!==null)if(Ve(i.value,o)){if(i.children===l.children&&!ye.current){n=sn(e,n,t);break e}}else for(i=n.child,i!==null&&(i.return=n);i!==null;){var u=i.dependencies;if(u!==null){o=i.child;for(var s=u.firstContext;s!==null;){if(s.context===r){if(i.tag===1){s=rn(-1,t&-t),s.tag=2;var d=i.updateQueue;if(d!==null){d=d.shared;var v=d.pending;v===null?s.next=s:(s.next=v.next,v.next=s),d.pending=s}}i.lanes|=t,s=i.alternate,s!==null&&(s.lanes|=t),eo(i.return,t,n),u.lanes|=t;break}s=s.next}}else if(i.tag===10)o=i.type===n.type?null:i.child;else if(i.tag===18){if(o=i.return,o===null)throw Error(y(341));o.lanes|=t,u=o.alternate,u!==null&&(u.lanes|=t),eo(o,t,n),o=i.sibling}else o=i.child;if(o!==null)o.return=i;else for(o=i;o!==null;){if(o===n){o=null;break}if(i=o.sibling,i!==null){i.return=o.return,o=i;break}o=o.return}i=o}ce(e,n,l.children,t),n=n.child}return n;case 9:return l=n.type,r=n.pendingProps.children,yt(n,t),l=Oe(l),r=r(l),n.flags|=1,ce(e,n,r,t),n.child;case 14:return r=n.type,l=$e(r,n.pendingProps),l=$e(r.type,l),hs(e,n,r,l,t);case 15:return Sc(e,n,n.type,n.pendingProps,t);case 17:return r=n.type,l=n.pendingProps,l=n.elementType===r?l:$e(r,l),tl(e,n),n.tag=1,we(r)?(e=!0,vl(n)):e=!1,yt(n,t),gc(n,r,l),to(n,r,l,t),io(null,n,r,!0,e,t);case 19:return Nc(e,n,t);case 22:return kc(e,n,t)}throw Error(y(156,n.tag))};function Bc(e,n){return ha(e,n)}function jp(e,n,t,r){this.tag=e,this.key=t,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=n,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Le(e,n,t,r){return new jp(e,n,t,r)}function su(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Pp(e){if(typeof e=="function")return su(e)?1:0;if(e!=null){if(e=e.$$typeof,e===jo)return 11;if(e===Po)return 14}return 2}function Nn(e,n){var t=e.alternate;return t===null?(t=Le(e.tag,n,e.key,e.mode),t.elementType=e.elementType,t.type=e.type,t.stateNode=e.stateNode,t.alternate=e,e.alternate=t):(t.pendingProps=n,t.type=e.type,t.flags=0,t.subtreeFlags=0,t.deletions=null),t.flags=e.flags&14680064,t.childLanes=e.childLanes,t.lanes=e.lanes,t.child=e.child,t.memoizedProps=e.memoizedProps,t.memoizedState=e.memoizedState,t.updateQueue=e.updateQueue,n=e.dependencies,t.dependencies=n===null?null:{lanes:n.lanes,firstContext:n.firstContext},t.sibling=e.sibling,t.index=e.index,t.ref=e.ref,t}function il(e,n,t,r,l,i){var o=2;if(r=e,typeof e=="function")su(e)&&(o=1);else if(typeof e=="string")o=5;else e:switch(e){case rt:return Qn(t.children,l,i,n);case Co:o=8,l|=8;break;case Ci:return e=Le(12,t,n,l|2),e.elementType=Ci,e.lanes=i,e;case ji:return e=Le(13,t,n,l),e.elementType=ji,e.lanes=i,e;case Pi:return e=Le(19,t,n,l),e.elementType=Pi,e.lanes=i,e;case Js:return Ql(t,l,i,n);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case Xs:o=10;break e;case Zs:o=9;break e;case jo:o=11;break e;case Po:o=14;break e;case fn:o=16,r=null;break e}throw Error(y(130,e==null?e:typeof e,""))}return n=Le(o,t,n,l),n.elementType=e,n.type=r,n.lanes=i,n}function Qn(e,n,t,r){return e=Le(7,e,r,n),e.lanes=t,e}function Ql(e,n,t,r){return e=Le(22,e,r,n),e.elementType=Js,e.lanes=t,e.stateNode={isHidden:!1},e}function Si(e,n,t){return e=Le(6,e,null,n),e.lanes=t,e}function ki(e,n,t){return n=Le(4,e.children!==null?e.children:[],e.key,n),n.lanes=t,n.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},n}function Tp(e,n,t,r,l){this.tag=n,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=ni(0),this.expirationTimes=ni(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=ni(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function au(e,n,t,r,l,i,o,u,s){return e=new Tp(e,n,t,u,s),n===1?(n=1,i===!0&&(n|=8)):n=0,i=Le(3,null,null,n),e.current=i,i.stateNode=e,i.memoizedState={element:r,isDehydrated:t,cache:null,transitions:null,pendingSuspenseBoundaries:null},Ko(i),e}function Rp(e,n,t){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(Hc)}catch(e){console.error(e)}}Hc(),Hs.exports=je;var Ip=Hs.exports,Ps=Ip;Ei.createRoot=Ps.createRoot,Ei.hydrateRoot=Ps.hydrateRoot;function Kc(e){return e.replace(/\/$/,"")}function Dp(){if(typeof window>"u")return"http://127.0.0.1:8100";const e=window.location.hostname;return new Set(["localhost","127.0.0.1","0.0.0.0"]).has(e)?"http://127.0.0.1:8100":window.location.origin}const Fp=Kc("/api"),Ap=Kc(Dp());async function Rt(e,n){const t=await fetch(`${Fp}${e}`,n);if(!t.ok){const r=await t.text();throw new Error(`API ${e} failed (${t.status}): ${r.slice(0,240)}`)}return await t.json()}let xe=null;const Rl=[];function xi(){return`${Ap.replace(/\/$/,"").replace(/^http/,"ws")}/ws`}async function Gc(){if((xe==null?void 0:xe.readyState)===WebSocket.OPEN)return xe;if((xe==null?void 0:xe.readyState)===WebSocket.CONNECTING)return await new Promise(n=>setTimeout(n,80)),Gc();const e=new WebSocket(xi());return xe=e,e.onmessage=n=>{const t=Rl.shift();if(t)try{const r=JSON.parse(n.data);if(r.type==="error"){const l=r.data,i=l&&typeof l=="object"&&"message"in l?String(l.message):"Env service returned an error";t.reject(new Error(i));return}t.resolve(r.data)}catch(r){t.reject(r)}},e.onerror=()=>{const n=Rl.shift();n&&n.reject(new Error(`Unable to connect to env service at ${xi()}`))},e.onclose=()=>{xe=null},await new Promise((n,t)=>{const r=window.setTimeout(()=>t(new Error(`Env service timeout at ${xi()}`)),2500);e.onopen=()=>{window.clearTimeout(r),n()}}),e}async function Ts(e,n){const t=await Gc();return new Promise((r,l)=>{Rl.push({resolve:i=>r(i),reject:l}),t.send(JSON.stringify({type:e,data:n}))})}function $p(){try{xe==null||xe.close()}catch{}finally{xe=null,Rl.splice(0)}}async function Up(){return Rt("/env/catalog")}async function Bp(e={}){return Rt("/env/reset",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)})}async function Qp(){return Rt("/agents/orchestrate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({})})}async function Wp(e){return Rt("/env/step_candidate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)})}async function Vp(){return Rt("/env/reward_breakdown")}async function Hp(){return Rt("/policy/model_status")}function Kp(e){return Array.from({length:e},()=>({x:Math.random()*2-1,y:Math.random()*2-1,z:Math.random(),size:Math.random()*1.4+.25,speed:Math.random()*55e-5+18e-5}))}function Gp(){const e=L.useRef(null);return L.useEffect(()=>{const n=e.current,t=n==null?void 0:n.getContext("2d");if(!n||!t)return;let r=0,l=0,i=0,o=0,u=0;const s=Kp(680),d=()=>{const h=Math.min(window.devicePixelRatio||1,2);l=window.innerWidth,i=window.innerHeight,o=l/2,u=i/2,n.width=Math.floor(l*h),n.height=Math.floor(i*h),n.style.width=`${l}px`,n.style.height=`${i}px`,t.setTransform(h,0,0,h,0,0)},v=()=>{t.clearRect(0,0,l,i),t.globalCompositeOperation="lighter",s.forEach(h=>{h.z-=h.speed,h.z<=.02&&(h.x=Math.random()*2-1,h.y=Math.random()*2-1,h.z=1);const m=1/h.z,w=o+h.x*m*o,k=u+h.y*m*u,S=Math.max(0,Math.min(1,1.15-h.z)),I=h.size*m*.85;t.beginPath(),t.fillStyle=`rgba(210, 246, 255, ${S})`,t.arc(w,k,I,0,Math.PI*2),t.fill()}),r=window.requestAnimationFrame(v)};return d(),v(),window.addEventListener("resize",d),()=>{window.removeEventListener("resize",d),window.cancelAnimationFrame(r)}},[]),a.jsx("canvas",{ref:e})}function Yp(){return a.jsxs("div",{className:"metaverse-backdrop","aria-hidden":"true",children:[a.jsx("video",{className:"blackhole-video",autoPlay:!0,muted:!0,loop:!0,playsInline:!0,preload:"auto",children:a.jsx("source",{src:"/blackhole.webm",type:"video/webm"})}),a.jsx("div",{className:"stars-canvas",children:a.jsx(Gp,{})}),a.jsx("div",{className:"nebula-orb orb-one"}),a.jsx("div",{className:"nebula-orb orb-two"}),a.jsx("div",{className:"nebula-grid"}),a.jsx("div",{className:"cosmic-vignette"})]})}const Rs={reward_range:[.001,.999],reward_precision:3,task_presets:[{id:"easy_screening",label:"Easy Screening",difficulty:"easy",sub_environment:"DDI"},{id:"budgeted_screening",label:"Budgeted Screening",difficulty:"medium",sub_environment:"REGIMEN_RISK"},{id:"complex_tradeoff",label:"Complex Tradeoff",difficulty:"hard",sub_environment:"REGIMEN_RISK"},{id:"bandit_mining",label:"Bandit Mining",difficulty:"hard",sub_environment:"BANDIT_MINING"}],sub_environments:["DDI","BANDIT_MINING","REGIMEN_RISK","PRECISION_DOSING","LONGITUDINAL_DEPRESCRIBING","WEB_SEARCH_MISSING_DATA","ALTERNATIVE_SUGGESTION","NEW_DRUG_DECOMPOSITION"]},Xp=["total_reward","primary_safety_legality","primary_clinical_improvement","primary_dosing_quality","primary_process_integrity","legality_score","safety_delta_score","burden_improvement_score","disease_stability_score","dosing_quality_score","process_fidelity_score","explanation_grounding_score","anti_cheat_score","uncertainty_calibration_score"],zs="polyguard.qtips.v2.seen",Ls=[{target:"topbar",title:"Start here",body:"PolyGuard is an interactive OpenEnv workbench. Use this top bar to choose the runtime, pick a clinical scenario, and reset into a real environment episode."},{target:"mode",title:"Choose the runtime",body:"Agent Workbench uses the local REST API, candidate selector, reward breakdown, and Qwen-backed policy path. Env Explorer talks directly to the OpenEnv WebSocket service."},{target:"task",title:"Pick a scenario",body:"Choose Easy Screening, Budgeted Screening, Complex Tradeoff, or Bandit Mining. Reset Episode then loads a real patient/regimen state from the backend."},{target:"model",title:"Check the model truth",body:"This panel reports the live model-status endpoint. It only calls Qwen active when the API says Qwen/Qwen2.5-0.5B-Instruct artifacts are enabled and available."},{target:"overview",title:"Read the episode state",body:"After reset, this shows the active task, patient, remaining step budget, latest reward, and risk delta. These values come from the current environment response."},{target:"candidates",title:"Review legal actions",body:"Candidate Actions are the currently legal moves emitted by the environment. Select one to inspect its safety, uncertainty, target drug, and mode."},{target:"console",title:"Submit or ask the agent",body:"Submit Candidate executes the selected legal action. Run Agent lets the policy stack choose a step, so check the model panel first if you require Qwen-backed output."},{target:"rewards",title:"Inspect reward channels",body:"Reward Channels show real scorer output after each step. Empty values mean no step has produced that channel yet, not placeholder scoring."},{target:"medications",title:"Track regimen changes",body:"Medication cards update from the environment observation. High-risk tags and dose/class details help explain why actions are legal or useful."},{target:"history",title:"Audit actions and warnings",body:"Action History and Warnings give a running trace of what happened in the episode. Use this to verify that the workflow is not canned."},{target:"event-log",title:"Follow the run",body:"The Event Log records resets, steps, rewards, and API errors. If Qwen or an env service is unavailable, this is where the UI tells you plainly."}];function Cn(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function pu(e){return typeof e=="number"&&Number.isFinite(e)?e:null}function Wn(e){const n=pu(e);return n===null?"-":n.toFixed(3)}function Je(e){return e.replace(/^primary_/,"").replace(/_/g," ").replace(/\b\w/g,n=>n.toUpperCase())}function Z(e){return e==null||e===""?"-":typeof e=="number"?Number.isFinite(e)?e.toFixed(e>10?0:3):"-":typeof e=="boolean"?e?"Yes":"No":Array.isArray(e)?e.length?e.map(Z).join(", "):"-":Cn(e)?JSON.stringify(e):String(e)}function yo(e,n){var t;return((t=n.find(r=>r.id===e))==null?void 0:t.label)??Je(e)}function Zp(e,n,t,r){const l=r.find(i=>i.id===e);return l?{agent:{task_id:l.id},env:{difficulty:l.difficulty,sub_environment:l.sub_environment}}:{agent:{difficulty:n,sub_environment:t},env:{difficulty:n,sub_environment:t}}}function Gr(e,n){return n!=="env"?e[0]??null:e.find(t=>t.legality_precheck!==!1&&t.action_type!=="KEEP_REGIMEN"&&!t.action_type.startsWith("REQUEST_"))??e.find(t=>t.legality_precheck!==!1&&t.action_type!=="KEEP_REGIMEN")??e[0]??null}function Yc(e){var u;if(!e)return{label:"Model status unavailable",detail:"The API did not return /policy/model_status. Results can still run, but Qwen cannot be verified here.",isQwen:!1,isLive:!1};if((u=e.ollama)!=null&&u.enabled&&e.ollama.available)return{label:"Ollama Qwen active",detail:`${e.ollama.model||"Ollama model"} is enabled locally; provider order=${(e.provider_preference??[]).join(" > ")||"ollama > transformers"}.`,isQwen:/qwen/i.test(e.ollama.model||""),isLive:!0};const n=e.model_id||e.base_model||e.runtime_model_name||"",t=/Qwen\/Qwen2\.5-0\.5B-Instruct/i.test(n),r=Object.values(e.availability??{}).some(Boolean),l=!!(e.enabled&&e.active&&r&&t),i=e.loaded_source||e.preferred_artifact||"artifact",o=e.load_error?` Load error: ${e.load_error}`:"";return{label:l?"Qwen 0.5B active":"Qwen not verified",detail:l?`${n} is enabled with ${i}; run ${e.run_id||"active manifest"}.${o}`:`${n||"No model"}; enabled=${String(e.enabled)} active=${String(e.active)} available=${String(r)}.${o}`,isQwen:t,isLive:l}}function Ms(e){const n=Cn(e.observation)?e.observation:null,t=Cn(e.info)?e.info:{};return{observation:n,reward:pu(e.reward),done:!!e.done,info:t}}function Jp(e,n,t){return{mode:e.mode||"REVIEW",action_type:e.action_type,target_drug:e.target_drug??null,replacement_drug:e.replacement_drug??null,dose_bucket:e.dose_bucket??"NA",taper_days:e.taper_days??null,monitoring_plan:e.monitoring_plan??null,evidence_query:e.evidence_query??null,new_drug_name:e.new_drug_name??null,candidate_components:e.candidate_components??[],candidate_id:e.candidate_id,confidence:n,rationale_brief:t}}function In(e,n){e(t=>[`${new Date().toLocaleTimeString()} ${n}`,...t].slice(0,24))}function qp({open:e,step:n,steps:t,onNext:r,onPrev:l,onClose:i}){const[o,u]=L.useState(null),s=t[n],d=L.useCallback(()=>{if(!e||!s)return;const h=document.querySelector(`[data-guide="${s.target}"]`);if(!h){u(null);return}h.scrollIntoView({block:"nearest",inline:"nearest",behavior:"smooth"}),u(h.getBoundingClientRect())},[s,e]);if(L.useEffect(()=>(d(),window.addEventListener("resize",d),window.addEventListener("scroll",d,!0),()=>{window.removeEventListener("resize",d),window.removeEventListener("scroll",d,!0)}),[d]),!e||!s)return null;const v=o?{"--tip-top":`${Math.max(14,Math.min(window.innerHeight-260,o.bottom+12))}px`,"--tip-left":`${Math.max(14,Math.min(window.innerWidth-390,o.left))}px`}:void 0;return a.jsxs("div",{className:"qtip-overlay",role:"dialog","aria-modal":"true","aria-label":"Q Tips walkthrough",children:[a.jsx("div",{className:"qtip-dim",onClick:i}),o&&a.jsx("div",{className:"qtip-ring",style:{top:o.top-6,left:o.left-6,width:o.width+12,height:o.height+12}}),a.jsxs("section",{className:"qtip-card panel-surface",style:v,children:[a.jsxs("div",{className:"qtip-header",children:[a.jsx("span",{children:"Q Tips"}),a.jsxs("strong",{children:[n+1," / ",t.length]})]}),a.jsx("h2",{children:s.title}),a.jsx("p",{children:s.body}),a.jsxs("div",{className:"qtip-actions",children:[a.jsx("button",{className:"secondary",onClick:l,disabled:n===0,children:"Back"}),a.jsx("button",{className:"secondary",onClick:i,children:"Skip"}),a.jsx("button",{onClick:n===t.length-1?i:r,children:n===t.length-1?"Done":"Next"})]})]})]})}function bp({mode:e,setMode:n,taskId:t,onTaskChange:r,catalog:l,statusText:i,modelStatus:o,loading:u,onReset:s,onOpenTips:d}){const v=Yc(o);return a.jsxs("header",{className:"topbar panel-surface","data-guide":"topbar",children:[a.jsxs("div",{className:"title-wrap",children:[a.jsx("h1",{children:"PolyGuard"}),a.jsx("p",{children:"OpenEnv medication safety workbench"})]}),a.jsxs("div",{className:"mode-toggle","aria-label":"Runtime mode","data-guide":"mode",children:[a.jsx("button",{className:e==="agent"?"active":"",onClick:()=>n("agent"),children:"Agent Workbench"}),a.jsx("button",{className:e==="env"?"active":"",onClick:()=>n("env"),children:"Env Explorer"})]}),a.jsxs("div",{className:"topbar-status",children:[a.jsx("span",{className:`status-chip ${i==="Live"?"live":"idle"}`,children:i}),a.jsx("span",{className:`status-chip ${v.isLive?"live":"idle"}`,children:e==="agent"?v.label:"ws env"}),a.jsx("button",{className:"qtip-trigger secondary",onClick:d,children:"Q Tips"})]}),a.jsxs("div",{className:"topbar-actions","data-guide":"task",children:[a.jsxs("select",{"aria-label":"Task",value:t,onChange:h=>r(h.target.value),children:[l.task_presets.map(h=>a.jsx("option",{value:h.id,children:h.label},h.id)),a.jsx("option",{value:"advanced",children:"Advanced"})]}),a.jsx("button",{onClick:s,disabled:u,children:"Reset Episode"})]})]})}function eh({mode:e,observation:n,reward:t,done:r,taskId:l,catalog:i}){const o=(n==null?void 0:n.deterministic_contract)??{},u=(n==null?void 0:n.patient_summary)??{},s=(n==null?void 0:n.burden_score_summary)??{},d=[["Mode",e==="agent"?"Agent Workbench":"Env Explorer"],["Task",yo(l,i.task_presets)],["Difficulty",o.difficulty??"-"],["Environment",o.sub_environment??(n==null?void 0:n.sub_environment)??"-"],["Step Budget",(n==null?void 0:n.step_budget_remaining)??"-"],["Last Reward",Wn(t)],["Patient",u.patient_id??u.id??"-"],["Status",r?"Complete":n?"Live":"Ready"]];return a.jsxs("section",{className:"panel-surface panel-wide","data-guide":"overview",children:[a.jsxs("div",{className:"panel-heading",children:[a.jsx("h2",{children:"Episode Overview"}),a.jsx("span",{children:n?"Live":"Ready"})]}),a.jsx("div",{className:"kpi-grid",children:d.map(([v,h])=>a.jsxs("div",{children:[a.jsx("span",{children:v}),a.jsx("strong",{children:Z(h)})]},String(v)))}),a.jsxs("div",{className:"overview-lower",children:[a.jsxs("div",{children:[a.jsx("h3",{children:"Patient Summary"}),a.jsxs("dl",{className:"compact-defs",children:[Object.entries(u).slice(0,8).map(([v,h])=>a.jsxs("div",{children:[a.jsx("dt",{children:Je(v)}),a.jsx("dd",{children:Z(h)})]},v)),Object.keys(u).length===0&&a.jsx("p",{className:"muted",children:"No patient loaded."})]})]}),a.jsxs("div",{children:[a.jsx("h3",{children:"Risk Delta"}),a.jsxs("dl",{className:"compact-defs",children:[Object.entries(s).slice(0,8).map(([v,h])=>a.jsxs("div",{children:[a.jsx("dt",{children:Je(v)}),a.jsx("dd",{children:Z(h)})]},v)),Object.keys(s).length===0&&a.jsx("p",{className:"muted",children:"No risk data."})]})]})]})]})}function nh({candidates:e,selected:n,onSelect:t}){return a.jsxs("section",{className:"panel-surface panel-scroll","data-guide":"candidates",children:[a.jsxs("div",{className:"panel-heading",children:[a.jsx("h2",{children:"Candidate Actions"}),a.jsx("span",{children:e.length})]}),a.jsxs("div",{className:"candidate-list",children:[e.map(r=>{const l=r.candidate_id===(n==null?void 0:n.candidate_id),i=r.legality_precheck!==!1;return a.jsxs("button",{className:`candidate-row ${l?"selected":""} ${i?"":"illegal"}`,onClick:()=>{i&&t(r.candidate_id)},disabled:!i,children:[a.jsxs("span",{children:[a.jsx("strong",{children:r.candidate_id}),Je(r.action_type)]}),a.jsx("span",{children:Z(r.target_drug??r.replacement_drug??r.mode)}),a.jsx("span",{children:i?Wn(r.estimated_safety_delta):"Blocked"})]},r.candidate_id)}),e.length===0&&a.jsx("p",{className:"muted",children:"Reset an episode to load legal candidates."})]})]})}function th({mode:e,selected:n,confidence:t,rationale:r,loading:l,canSubmit:i,canRunAgent:o,done:u,terminationReason:s,onConfidence:d,onRationale:v,onSubmit:h,onAgent:m,onReset:w}){const k=[["Type",n==null?void 0:n.action_type],["Mode",n==null?void 0:n.mode],["Target",n==null?void 0:n.target_drug],["Replacement",n==null?void 0:n.replacement_drug],["Dose",n==null?void 0:n.dose_bucket],["Uncertainty",n==null?void 0:n.uncertainty_score]];return a.jsxs("section",{className:"panel-surface action-console","data-guide":"console",children:[a.jsxs("div",{className:"panel-heading",children:[a.jsx("h2",{children:"Action Console"}),a.jsx("span",{children:(n==null?void 0:n.candidate_id)??"-"})]}),a.jsx("div",{className:"action-detail-grid",children:k.map(([S,I])=>a.jsxs("div",{children:[a.jsx("span",{children:S}),a.jsx("strong",{children:Z(I)})]},String(S)))}),a.jsxs("label",{className:"field",children:[a.jsx("span",{children:"Confidence"}),a.jsx("input",{type:"number",min:"0.001",max:"0.999",step:"0.001",value:t.toFixed(3),onChange:S=>d(Number(S.target.value))})]}),a.jsxs("label",{className:"field",children:[a.jsx("span",{children:"Rationale"}),a.jsx("input",{value:r,onChange:S=>v(S.target.value)})]}),u&&a.jsxs("div",{className:"console-notice",children:[e==="env"?"Env Explorer":"Agent Workbench"," returned ",a.jsx("strong",{children:"done"}),s?` (${Je(s)})`:"",". Reset the episode before submitting another step."]}),a.jsxs("div",{className:"button-row",children:[a.jsx("button",{onClick:u?w:h,disabled:l||!i&&!u,children:u?"Reset Episode":e==="env"?"Submit Env Step":"Submit Candidate"}),a.jsx("button",{className:"secondary",onClick:m,disabled:e!=="agent"||l||u||!o,children:"Run Agent"})]})]})}function rh({meds:e}){return a.jsxs("section",{className:"panel-surface panel-wide","data-guide":"medications",children:[a.jsxs("div",{className:"panel-heading",children:[a.jsx("h2",{children:"Current Medications"}),a.jsx("span",{children:e.length})]}),a.jsxs("div",{className:"med-grid",children:[e.map((n,t)=>{const r=[n.beers_flag,n.flag,n.warning].filter(Boolean),l=!!(n.high_risk??n.is_high_risk_elderly??r.length);return a.jsxs("article",{className:`med-card ${l?"high-risk":""}`,children:[a.jsxs("div",{className:"med-card-header",children:[a.jsx("strong",{children:Z(n.drug??n.drug_id??n.name)}),l&&a.jsx("span",{children:"High Risk"})]}),a.jsx("p",{children:Z(n.indication??n.class_name??n.atc_class)}),a.jsxs("div",{className:"med-meta",children:[a.jsx("span",{children:Z(n.dose_bucket??n.dose_mg??n.dose)}),a.jsx("span",{children:Z(n.requires_taper?"taper":n.monitoring??n.route)})]})]},`${Z(n.drug)}-${t}`)}),e.length===0&&a.jsx("p",{className:"muted",children:"No medications loaded."})]})]})}function lh({rewardBreakdown:e,reward:n}){const t=e??{total_reward:n};return a.jsxs("section",{className:"panel-surface panel-scroll","data-guide":"rewards",children:[a.jsxs("div",{className:"panel-heading",children:[a.jsx("h2",{children:"Reward Channels"}),a.jsx("span",{children:Wn(t.total_reward??n)})]}),a.jsx("div",{className:"reward-bars",children:Xp.map(r=>{const l=pu(t[r]),i=Math.max(.5,Math.min(l??0,.999)*100);return a.jsxs("div",{className:"reward-row",children:[a.jsx("span",{children:Je(r)}),a.jsx("div",{className:"reward-track",children:a.jsx("div",{className:"reward-fill",style:{width:`${i}%`}})}),a.jsx("strong",{children:Wn(l)})]},r)})})]})}function ih({status:e}){const n=Yc(e),t=(e==null?void 0:e.availability)??{},r=Object.entries(t);return a.jsxs("section",{className:`model-truth panel-surface ${n.isLive?"verified":"unverified"}`,"data-guide":"model",children:[a.jsxs("div",{className:"panel-heading",children:[a.jsx("h2",{children:"Model Truth"}),a.jsx("span",{children:n.label})]}),a.jsx("p",{children:n.detail}),a.jsxs("div",{className:"model-truth-grid",children:[a.jsxs("div",{children:[a.jsx("span",{children:"Model"}),a.jsx("strong",{children:Z((e==null?void 0:e.model_id)??(e==null?void 0:e.base_model)??"unavailable")})]}),a.jsxs("div",{children:[a.jsx("span",{children:"Run"}),a.jsx("strong",{children:Z(e==null?void 0:e.run_id)})]}),a.jsxs("div",{children:[a.jsx("span",{children:"Artifact"}),a.jsx("strong",{children:Z((e==null?void 0:e.loaded_source)||(e==null?void 0:e.preferred_artifact))})]}),a.jsxs("div",{children:[a.jsx("span",{children:"Availability"}),a.jsx("strong",{children:r.length?r.map(([l,i])=>`${Je(l)}:${i?"yes":"no"}`).join(" | "):"-"})]})]})]})}function oh({observation:e}){const n=(e==null?void 0:e.action_history)??[],t=(e==null?void 0:e.warning_summary)??[];return a.jsx("section",{className:"panel-surface panel-wide","data-guide":"history",children:a.jsxs("div",{className:"history-grid",children:[a.jsxs("div",{children:[a.jsxs("div",{className:"panel-heading inline-heading",children:[a.jsx("h2",{children:"Action History"}),a.jsx("span",{children:n.length})]}),a.jsxs("div",{className:"history-list",children:[n.map((r,l)=>{const i=Cn(r.action)?r.action:r;return a.jsxs("div",{className:"history-item",children:[a.jsxs("strong",{children:["Step ",Z(r.step??l)," - ",Je(Z(i.action_type??"action"))]}),a.jsx("span",{children:Z(i.candidate_id??i.target_drug??r.reward)})]},`${l}-${Z(r.step??l)}`)}),n.length===0&&a.jsx("p",{className:"muted",children:"No actions yet."})]})]}),a.jsxs("div",{children:[a.jsxs("div",{className:"panel-heading inline-heading",children:[a.jsx("h2",{children:"Warnings"}),a.jsx("span",{children:t.length})]}),a.jsxs("div",{className:"history-list",children:[t.map((r,l)=>a.jsx("div",{className:"history-item warning",children:r},`${r}-${l}`)),t.length===0&&a.jsx("p",{className:"muted",children:"No active warnings."})]})]})]})})}function _i({title:e,data:n}){const t=Array.isArray(n)?n.length>0:Cn(n)&&Object.keys(n).length>0;return a.jsxs("section",{className:"panel-surface detail-panel",children:[a.jsx("div",{className:"panel-heading",children:a.jsx("h2",{children:e})}),t?a.jsx("pre",{children:JSON.stringify(n,null,2)}):a.jsx("p",{className:"muted",children:"No data."})]})}function uh({events:e,error:n}){return a.jsxs("section",{className:"panel-surface panel-wide event-panel","data-guide":"event-log",children:[a.jsxs("div",{className:"panel-heading",children:[a.jsx("h2",{children:"Event Log"}),a.jsx("span",{children:e.length})]}),n&&a.jsx("div",{className:"error-banner",children:n}),a.jsxs("div",{className:"event-log",children:[e.map((t,r)=>a.jsx("div",{children:t},`${t}-${r}`)),e.length===0&&a.jsx("p",{className:"muted",children:"Events will appear here."})]})]})}function sh(){const[e,n]=L.useState("agent"),[t,r]=L.useState(Rs),[l,i]=L.useState("budgeted_screening"),[o,u]=L.useState("medium"),[s,d]=L.useState("REGIMEN_RISK"),[v,h]=L.useState(null),[m,w]=L.useState(null),[k,S]=L.useState(null),[I,f]=L.useState(null),[c,p]=L.useState(!1),[g,x]=L.useState(!1),[C,N]=L.useState(null),[j,Q]=L.useState(.75),[z,ke]=L.useState("Selected from the interactive workbench."),[Ln,Te]=L.useState(null),[jr,zt]=L.useState(null),[Lt,Jn]=L.useState(null),[_,T]=L.useState(null),[R,A]=L.useState(null),[J,cn]=L.useState(null),[De,Mn]=L.useState(null),[qe,he]=L.useState([]),[hu,qn]=L.useState(!1),[Xc,On]=L.useState(null),[Zc,mu]=L.useState(()=>{try{return window.localStorage.getItem(zs)!=="true"}catch{return!0}}),[Jc,Gl]=L.useState(0),Mt=L.useCallback(async()=>{try{const P=await Hp();return T(P),P}catch{return T(null),null}},[]);L.useEffect(()=>(Up().then(r).catch(()=>r(Rs)),Mt().then(P=>{P||In(he,"Model status endpoint unavailable; Qwen cannot be verified yet.")}),()=>$p()),[Mt]);const Fe=e==="agent"?v:m,Yl=e==="agent"?k:I,Ot=e==="agent"?c:g,It=(Fe==null?void 0:Fe.candidate_action_set)??[],He=L.useMemo(()=>It.find(P=>P.candidate_id===C)??Gr(It,e),[It,e,C]),qc=Ot?"Complete":Fe?"Live":"Ready",Xl=e==="agent"?jr:Lt,vu=Z(Xl==null?void 0:Xl.termination_reason),bc=vu!=="-"?vu:null,ed=[["Runtime",e==="agent"?"Agent Workbench":"Env Explorer"],["Scenario",yo(l,t.task_presets)],["Candidates",String(It.length)],["Reward",Wn(Yl)]],nd=()=>{mu(!1);try{window.localStorage.setItem(zs,"true")}catch{}},td=P=>{i(P);const W=t.task_presets.find(V=>V.id===P);W&&(u(W.difficulty),d(W.sub_environment))},rd=P=>{P!==e&&(n(P),he([]),On(null),N(null),P==="agent"?(h(null),S(null),p(!1),zt(null),Te(null),A(null),cn(null),Mn(null)):(w(null),f(null),x(!1),Jn(null),Te(null)))},gu=L.useCallback(async(P,W)=>{var Ft,Tr;const V=Ms(P);h(V.observation),S(V.reward),p(V.done),zt(V.info),A(P.final_action??null),cn(P.explanation??null),Mn(P.evidence);const me=Cn(P.final_action)?P.final_action:null,bn=typeof(me==null?void 0:me.candidate_id)=="string"?me.candidate_id:null,et=((Ft=V.observation)==null?void 0:Ft.candidate_action_set)??[];N(bn&&et.some(od=>od.candidate_id===bn)?bn:((Tr=Gr(et,"agent"))==null?void 0:Tr.candidate_id)??null);const Pr=V.info.reward_breakdown??await Vp().catch(()=>null);Te(Pr??null);const Dt=Z(V.info.termination_reason);In(he,`${W} reward ${Wn(V.reward)}${V.done&&Dt!=="-"?` - complete: ${Dt}`:""}`)},[]),yu=L.useCallback((P,W,V)=>{var Dt,Ft;const me=Ms(P),bn=((Dt=me.observation)==null?void 0:Dt.candidate_action_set)??[];w(me.observation),f(me.reward),x(me.done),Jn(me.info),N(V&&bn.some(Tr=>Tr.candidate_id===V)?V:((Ft=Gr(bn,"env"))==null?void 0:Ft.candidate_id)??null);const et=me.info.reward_breakdown;Cn(et)&&Object.keys(et).length>0?Te(et):Te(null);const Pr=Z(me.info.termination_reason);In(he,`${W} reward ${Wn(me.reward)}${me.done&&Pr!=="-"?` - complete: ${Pr}`:""}`)},[]),wu=async()=>{var P;qn(!0),On(null),he([]);try{const W=Zp(l,o,s,t.task_presets);if(e==="agent"){await Mt();const V=await Bp(W.agent);h(V),S(null),p(!1),zt(null),Te(null),A(null),cn(null),Mn(null),N(((P=Gr(V.candidate_action_set,"agent"))==null?void 0:P.candidate_id)??null)}else{const V=await Ts("reset",W.env);yu(V,"Env reset")}In(he,`Reset ${yo(l,t.task_presets)} in ${e}`)}catch(W){const V=W instanceof Error?W.message:"Reset failed";On(V),In(he,V)}finally{qn(!1)}},ld=async()=>{if(He){qn(!0),On(null);try{if(e==="agent"){const P=await Wp({candidate_id:He.candidate_id,confidence:j,rationale_brief:z});await gu(P,Je(He.action_type)),await Mt()}else{const P=Jp(He,j,z),W=await Ts("step",P);yu(W,Je(He.action_type),He.candidate_id)}}catch(P){const W=P instanceof Error?P.message:"Step failed";On(W),In(he,W)}finally{qn(!1)}}},id=async()=>{qn(!0),On(null);try{const P=await Qp();await gu(P,"Agent"),await Mt()}catch(P){const W=P instanceof Error?P.message:"Agent run failed";On(W),In(he,W)}finally{qn(!1)}};return a.jsxs("div",{className:"workbench-shell",children:[a.jsx(Yp,{}),a.jsxs("div",{className:"workbench-container",children:[a.jsxs("section",{className:"metaverse-hero panel-surface",children:[a.jsxs("div",{className:"hero-copy",children:[a.jsxs("div",{className:"welcome-box",children:[a.jsx("span",{className:"spark-glyph",children:"*"}),a.jsx("span",{className:"welcome-text",children:"PolyGuard neural safety cockpit"})]}),a.jsxs("h2",{children:["Clinical medication safety, guided by",a.jsx("span",{children:" constrained RL decisions."})]}),a.jsx("p",{children:"PolyGuard coordinates live OpenEnv episodes, candidate actions, reward channels, and evidence-grounded policy traces for safer polypharmacy review."})]}),a.jsx("div",{className:"hero-stat-grid","aria-label":"Current workbench state",children:ed.map(([P,W])=>a.jsxs("div",{children:[a.jsx("span",{children:P}),a.jsx("strong",{children:W})]},P))})]}),a.jsx(bp,{mode:e,setMode:rd,taskId:l,onTaskChange:td,catalog:t,statusText:qc,modelStatus:_,loading:hu,onReset:wu,onOpenTips:()=>{Gl(0),mu(!0)}}),a.jsx(ih,{status:_}),l==="advanced"&&a.jsxs("section",{className:"advanced-strip panel-surface",children:[a.jsxs("label",{className:"field",children:[a.jsx("span",{children:"Difficulty"}),a.jsxs("select",{value:o,onChange:P=>u(P.target.value),children:[a.jsx("option",{value:"easy",children:"easy"}),a.jsx("option",{value:"medium",children:"medium"}),a.jsx("option",{value:"hard",children:"hard"})]})]}),a.jsxs("label",{className:"field",children:[a.jsx("span",{children:"Environment"}),a.jsx("select",{value:s,onChange:P=>d(P.target.value),children:t.sub_environments.map(P=>a.jsx("option",{value:P,children:P},P))})]})]}),a.jsxs("main",{className:"workbench-layout",children:[a.jsx(eh,{mode:e,observation:Fe,reward:Yl,done:Ot,taskId:l,catalog:t}),a.jsx(nh,{candidates:It,selected:He,onSelect:N}),a.jsx(th,{mode:e,selected:He,confidence:j,rationale:z,loading:hu,canSubmit:!!(He&&He.legality_precheck!==!1&&Fe&&!Ot),canRunAgent:!!(e==="agent"&&Fe&&!Ot),done:Ot,terminationReason:bc,onConfidence:Q,onRationale:ke,onSubmit:ld,onAgent:id,onReset:wu}),a.jsx(lh,{rewardBreakdown:Ln,reward:Yl}),a.jsx(rh,{meds:(Fe==null?void 0:Fe.medication_table)??[]}),a.jsx(oh,{observation:Fe}),a.jsx(_i,{title:"Decision",data:e==="agent"?R:null}),a.jsx(_i,{title:"Explanation",data:e==="agent"?J:null}),a.jsx(_i,{title:"Evidence",data:e==="agent"&&(Cn(De)||Array.isArray(De))?De:null}),a.jsx(uh,{events:qe,error:Xc})]}),a.jsx(qp,{open:Zc,step:Jc,steps:Ls,onNext:()=>Gl(P=>Math.min(P+1,Ls.length-1)),onPrev:()=>Gl(P=>Math.max(P-1,0)),onClose:nd})]})]})}Ei.createRoot(document.getElementById("root")).render(a.jsx(_d.StrictMode,{children:a.jsx(sh,{})})); diff --git a/app/ui/frontend/dist/blackhole.webm b/app/ui/frontend/dist/blackhole.webm new file mode 100644 index 0000000000000000000000000000000000000000..dd40f2d9c469ab252993a1619e5ae533b0f7e7ae --- /dev/null +++ b/app/ui/frontend/dist/blackhole.webm @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3d7becf1e5b51c78dd83991f839510d81ab2d0a244de2d51b98ac523a9e485e +size 757186 diff --git a/app/ui/frontend/dist/index.html b/app/ui/frontend/dist/index.html new file mode 100644 index 0000000000000000000000000000000000000000..24577a4b70f53bd89da7ca9f65d8b488834d0d4d --- /dev/null +++ b/app/ui/frontend/dist/index.html @@ -0,0 +1,13 @@ + + + + + + POLYGUARD-RL Workbench + + + + +
+ + diff --git a/app/ui/frontend/index.html b/app/ui/frontend/index.html new file mode 100644 index 0000000000000000000000000000000000000000..ce4bae52c7885b9185d66b5989c2c2d248efff70 --- /dev/null +++ b/app/ui/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + POLYGUARD-RL Workbench + + +
+ + + diff --git a/app/ui/frontend/package-lock.json b/app/ui/frontend/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..201add20ac0f8f974f25906d3ee73187e75332a0 --- /dev/null +++ b/app/ui/frontend/package-lock.json @@ -0,0 +1,1729 @@ +{ + "name": "polyguard-rl-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "polyguard-rl-frontend", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "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/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "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.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz", + "integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==", + "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.30001790", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", + "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==", + "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/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "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.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "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.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "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.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "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.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "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.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "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/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "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/app/ui/frontend/package.json b/app/ui/frontend/package.json new file mode 100644 index 0000000000000000000000000000000000000000..76e2eb745bf7a51e6c166383f9fa7f5c090c129b --- /dev/null +++ b/app/ui/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "polyguard-rl-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0 --port 5173", + "build": "vite build", + "preview": "vite preview --host 0.0.0.0 --port 5173" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } +} diff --git a/app/ui/frontend/public/blackhole.webm b/app/ui/frontend/public/blackhole.webm new file mode 100644 index 0000000000000000000000000000000000000000..dd40f2d9c469ab252993a1619e5ae533b0f7e7ae --- /dev/null +++ b/app/ui/frontend/public/blackhole.webm @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3d7becf1e5b51c78dd83991f839510d81ab2d0a244de2d51b98ac523a9e485e +size 757186 diff --git a/app/ui/frontend/src/App.tsx b/app/ui/frontend/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..433a0c63723740417d35bb239320a4987176c58c --- /dev/null +++ b/app/ui/frontend/src/App.tsx @@ -0,0 +1,1179 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { CSSProperties, Dispatch, SetStateAction } from "react"; +import { + closeEnvSocket, + envWsSend, + fetchCatalog, + fetchModelStatus, + fetchRewardBreakdown, + orchestrateStep, + resetEnv, + stepCandidate, +} from "./lib/api"; +import type { + CandidateAction, + EnvCatalog, + EnvObservation, + EnvStepPacket, + ModelStatus, + PolyGuardActionPayload, + StepResponse, + TaskPreset, +} from "./lib/types"; +import MetaverseBackdrop from "./components/MetaverseBackdrop"; + +type WorkbenchMode = "agent" | "env"; +type GuideTarget = + | "topbar" + | "mode" + | "task" + | "model" + | "overview" + | "candidates" + | "console" + | "rewards" + | "medications" + | "history" + | "event-log"; + +type GuideStep = { + target: GuideTarget; + title: string; + body: string; +}; + +const FALLBACK_CATALOG: EnvCatalog = { + reward_range: [0.001, 0.999], + reward_precision: 3, + task_presets: [ + { id: "easy_screening", label: "Easy Screening", difficulty: "easy", sub_environment: "DDI" }, + { id: "budgeted_screening", label: "Budgeted Screening", difficulty: "medium", sub_environment: "REGIMEN_RISK" }, + { id: "complex_tradeoff", label: "Complex Tradeoff", difficulty: "hard", sub_environment: "REGIMEN_RISK" }, + { id: "bandit_mining", label: "Bandit Mining", difficulty: "hard", sub_environment: "BANDIT_MINING" }, + ], + sub_environments: [ + "DDI", + "BANDIT_MINING", + "REGIMEN_RISK", + "PRECISION_DOSING", + "LONGITUDINAL_DEPRESCRIBING", + "WEB_SEARCH_MISSING_DATA", + "ALTERNATIVE_SUGGESTION", + "NEW_DRUG_DECOMPOSITION", + ], +}; + +const REWARD_KEYS = [ + "total_reward", + "primary_safety_legality", + "primary_clinical_improvement", + "primary_dosing_quality", + "primary_process_integrity", + "legality_score", + "safety_delta_score", + "burden_improvement_score", + "disease_stability_score", + "dosing_quality_score", + "process_fidelity_score", + "explanation_grounding_score", + "anti_cheat_score", + "uncertainty_calibration_score", +]; + +const QTIPS_SEEN_KEY = "polyguard.qtips.v2.seen"; + +const GUIDE_STEPS: GuideStep[] = [ + { + target: "topbar", + title: "Start here", + body: "PolyGuard is an interactive OpenEnv workbench. Use this top bar to choose the runtime, pick a clinical scenario, and reset into a real environment episode.", + }, + { + target: "mode", + title: "Choose the runtime", + body: "Agent Workbench uses the local REST API, candidate selector, reward breakdown, and Qwen-backed policy path. Env Explorer talks directly to the OpenEnv WebSocket service.", + }, + { + target: "task", + title: "Pick a scenario", + body: "Choose Easy Screening, Budgeted Screening, Complex Tradeoff, or Bandit Mining. Reset Episode then loads a real patient/regimen state from the backend.", + }, + { + target: "model", + title: "Check the model truth", + body: "This panel reports the live model-status endpoint. It only calls Qwen active when the API says Qwen/Qwen2.5-0.5B-Instruct artifacts are enabled and available.", + }, + { + target: "overview", + title: "Read the episode state", + body: "After reset, this shows the active task, patient, remaining step budget, latest reward, and risk delta. These values come from the current environment response.", + }, + { + target: "candidates", + title: "Review legal actions", + body: "Candidate Actions are the currently legal moves emitted by the environment. Select one to inspect its safety, uncertainty, target drug, and mode.", + }, + { + target: "console", + title: "Submit or ask the agent", + body: "Submit Candidate executes the selected legal action. Run Agent lets the policy stack choose a step, so check the model panel first if you require Qwen-backed output.", + }, + { + target: "rewards", + title: "Inspect reward channels", + body: "Reward Channels show real scorer output after each step. Empty values mean no step has produced that channel yet, not placeholder scoring.", + }, + { + target: "medications", + title: "Track regimen changes", + body: "Medication cards update from the environment observation. High-risk tags and dose/class details help explain why actions are legal or useful.", + }, + { + target: "history", + title: "Audit actions and warnings", + body: "Action History and Warnings give a running trace of what happened in the episode. Use this to verify that the workflow is not canned.", + }, + { + target: "event-log", + title: "Follow the run", + body: "The Event Log records resets, steps, rewards, and API errors. If Qwen or an env service is unavailable, this is where the UI tells you plainly.", + }, +]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function toNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function formatReward(value: unknown): string { + const num = toNumber(value); + return num === null ? "-" : num.toFixed(3); +} + +function humanize(value: string): string { + return value + .replace(/^primary_/, "") + .replace(/_/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()); +} + +function shortValue(value: unknown): string { + if (value === null || value === undefined || value === "") return "-"; + if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(value > 10 ? 0 : 3) : "-"; + if (typeof value === "boolean") return value ? "Yes" : "No"; + if (Array.isArray(value)) return value.length ? value.map(shortValue).join(", ") : "-"; + if (isRecord(value)) return JSON.stringify(value); + return String(value); +} + +function taskLabel(taskId: string, presets: TaskPreset[]): string { + return presets.find((item) => item.id === taskId)?.label ?? humanize(taskId); +} + +function taskResetOptions(taskId: string, difficulty: string, subEnvironment: string, presets: TaskPreset[]) { + const preset = presets.find((item) => item.id === taskId); + if (preset) { + return { + agent: { task_id: preset.id }, + env: { difficulty: preset.difficulty, sub_environment: preset.sub_environment }, + }; + } + return { + agent: { difficulty, sub_environment: subEnvironment }, + env: { difficulty, sub_environment: subEnvironment }, + }; +} + +function defaultCandidateForMode(candidates: CandidateAction[], mode: WorkbenchMode): CandidateAction | null { + if (mode !== "env") return candidates[0] ?? null; + + return ( + candidates.find( + (candidate) => + candidate.legality_precheck !== false && + candidate.action_type !== "KEEP_REGIMEN" && + !candidate.action_type.startsWith("REQUEST_"), + ) ?? + candidates.find((candidate) => candidate.legality_precheck !== false && candidate.action_type !== "KEEP_REGIMEN") ?? + candidates[0] ?? + null + ); +} + +function modelSignal(status: ModelStatus | null): { + label: string; + detail: string; + isQwen: boolean; + isLive: boolean; +} { + if (!status) { + return { + label: "Model status unavailable", + detail: "The API did not return /policy/model_status. Results can still run, but Qwen cannot be verified here.", + isQwen: false, + isLive: false, + }; + } + + if (status.ollama?.enabled && status.ollama.available) { + return { + label: "Ollama Qwen active", + detail: `${status.ollama.model || "Ollama model"} is enabled locally; provider order=${(status.provider_preference ?? []).join(" > ") || "ollama > transformers"}.`, + isQwen: /qwen/i.test(status.ollama.model || ""), + isLive: true, + }; + } + + const modelName = status.model_id || status.base_model || status.runtime_model_name || ""; + const isQwen = /Qwen\/Qwen2\.5-0\.5B-Instruct/i.test(modelName); + const available = Object.values(status.availability ?? {}).some(Boolean); + const isLive = Boolean(status.enabled && status.active && available && isQwen); + const artifact = status.loaded_source || status.preferred_artifact || "artifact"; + const loadError = status.load_error ? ` Load error: ${status.load_error}` : ""; + + return { + label: isLive ? "Qwen 0.5B active" : "Qwen not verified", + detail: isLive + ? `${modelName} is enabled with ${artifact}; run ${status.run_id || "active manifest"}.${loadError}` + : `${modelName || "No model"}; enabled=${String(status.enabled)} active=${String(status.active)} available=${String(available)}.${loadError}`, + isQwen, + isLive, + }; +} + +function normalizeStepPacket(packet: EnvStepPacket | StepResponse | Record): { + observation: EnvObservation | null; + reward: number | null; + done: boolean; + info: Record; +} { + const observation = isRecord(packet.observation) ? (packet.observation as EnvObservation) : null; + const info = isRecord(packet.info) ? packet.info : {}; + return { + observation, + reward: toNumber(packet.reward), + done: Boolean(packet.done), + info, + }; +} + +function buildActionPayload( + candidate: CandidateAction, + confidence: number, + rationale: string, +): PolyGuardActionPayload { + return { + mode: candidate.mode || "REVIEW", + action_type: candidate.action_type, + target_drug: candidate.target_drug ?? null, + replacement_drug: candidate.replacement_drug ?? null, + dose_bucket: candidate.dose_bucket ?? "NA", + taper_days: candidate.taper_days ?? null, + monitoring_plan: candidate.monitoring_plan ?? null, + evidence_query: candidate.evidence_query ?? null, + new_drug_name: candidate.new_drug_name ?? null, + candidate_components: candidate.candidate_components ?? [], + candidate_id: candidate.candidate_id, + confidence, + rationale_brief: rationale, + }; +} + +function appendEvent(setter: Dispatch>, message: string) { + setter((prev) => [`${new Date().toLocaleTimeString()} ${message}`, ...prev].slice(0, 24)); +} + +function QTips({ + open, + step, + steps, + onNext, + onPrev, + onClose, +}: { + open: boolean; + step: number; + steps: GuideStep[]; + onNext: () => void; + onPrev: () => void; + onClose: () => void; +}) { + const [rect, setRect] = useState(null); + const current = steps[step]; + + const updateRect = useCallback(() => { + if (!open || !current) return; + const target = document.querySelector(`[data-guide="${current.target}"]`); + if (!target) { + setRect(null); + return; + } + target.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); + setRect(target.getBoundingClientRect()); + }, [current, open]); + + useEffect(() => { + updateRect(); + window.addEventListener("resize", updateRect); + window.addEventListener("scroll", updateRect, true); + return () => { + window.removeEventListener("resize", updateRect); + window.removeEventListener("scroll", updateRect, true); + }; + }, [updateRect]); + + if (!open || !current) return null; + + const tooltipStyle = rect + ? ({ + "--tip-top": `${Math.max(14, Math.min(window.innerHeight - 260, rect.bottom + 12))}px`, + "--tip-left": `${Math.max(14, Math.min(window.innerWidth - 390, rect.left))}px`, + } as CSSProperties) + : undefined; + + return ( +
+
+ {rect && ( +
+ )} +
+
+ Q Tips + + {step + 1} / {steps.length} + +
+

{current.title}

+

{current.body}

+
+ + + +
+
+
+ ); +} + +function TopBar({ + mode, + setMode, + taskId, + onTaskChange, + catalog, + statusText, + modelStatus, + loading, + onReset, + onOpenTips, +}: { + mode: WorkbenchMode; + setMode: (mode: WorkbenchMode) => void; + taskId: string; + onTaskChange: (taskId: string) => void; + catalog: EnvCatalog; + statusText: string; + modelStatus: ModelStatus | null; + loading: boolean; + onReset: () => void; + onOpenTips: () => void; +}) { + const signal = modelSignal(modelStatus); + + return ( +
+
+

PolyGuard

+

OpenEnv medication safety workbench

+
+ +
+ + +
+ +
+ {statusText} + + {mode === "agent" ? signal.label : "ws env"} + + +
+ +
+ + +
+
+ ); +} + +function EpisodeOverview({ + mode, + observation, + reward, + done, + taskId, + catalog, +}: { + mode: WorkbenchMode; + observation: EnvObservation | null; + reward: number | null; + done: boolean; + taskId: string; + catalog: EnvCatalog; +}) { + const contract = observation?.deterministic_contract ?? {}; + const summary = observation?.patient_summary ?? {}; + const burden = observation?.burden_score_summary ?? {}; + + const kpis: Array<[string, unknown]> = [ + ["Mode", mode === "agent" ? "Agent Workbench" : "Env Explorer"], + ["Task", taskLabel(taskId, catalog.task_presets)], + ["Difficulty", contract.difficulty ?? "-"], + ["Environment", contract.sub_environment ?? observation?.sub_environment ?? "-"], + ["Step Budget", observation?.step_budget_remaining ?? "-"], + ["Last Reward", formatReward(reward)], + ["Patient", summary.patient_id ?? summary.id ?? "-"], + ["Status", done ? "Complete" : observation ? "Live" : "Ready"], + ]; + + return ( +
+
+

Episode Overview

+ {observation ? "Live" : "Ready"} +
+
+ {kpis.map(([label, value]) => ( +
+ {label} + {shortValue(value)} +
+ ))} +
+
+
+

Patient Summary

+
+ {Object.entries(summary).slice(0, 8).map(([key, value]) => ( +
+
{humanize(key)}
+
{shortValue(value)}
+
+ ))} + {Object.keys(summary).length === 0 &&

No patient loaded.

} +
+
+
+

Risk Delta

+
+ {Object.entries(burden).slice(0, 8).map(([key, value]) => ( +
+
{humanize(key)}
+
{shortValue(value)}
+
+ ))} + {Object.keys(burden).length === 0 &&

No risk data.

} +
+
+
+
+ ); +} + +function CandidatePanel({ + candidates, + selected, + onSelect, +}: { + candidates: CandidateAction[]; + selected: CandidateAction | null; + onSelect: (candidateId: string) => void; +}) { + return ( +
+
+

Candidate Actions

+ {candidates.length} +
+
+ {candidates.map((candidate) => { + const active = candidate.candidate_id === selected?.candidate_id; + const legal = candidate.legality_precheck !== false; + return ( + + ); + })} + {candidates.length === 0 &&

Reset an episode to load legal candidates.

} +
+
+ ); +} + +function ActionConsole({ + mode, + selected, + confidence, + rationale, + loading, + canSubmit, + canRunAgent, + done, + terminationReason, + onConfidence, + onRationale, + onSubmit, + onAgent, + onReset, +}: { + mode: WorkbenchMode; + selected: CandidateAction | null; + confidence: number; + rationale: string; + loading: boolean; + canSubmit: boolean; + canRunAgent: boolean; + done: boolean; + terminationReason: string | null; + onConfidence: (value: number) => void; + onRationale: (value: string) => void; + onSubmit: () => void; + onAgent: () => void; + onReset: () => void; +}) { + const details = [ + ["Type", selected?.action_type], + ["Mode", selected?.mode], + ["Target", selected?.target_drug], + ["Replacement", selected?.replacement_drug], + ["Dose", selected?.dose_bucket], + ["Uncertainty", selected?.uncertainty_score], + ]; + + return ( +
+
+

Action Console

+ {selected?.candidate_id ?? "-"} +
+
+ {details.map(([label, value]) => ( +
+ {label} + {shortValue(value)} +
+ ))} +
+ + + {done && ( +
+ {mode === "env" ? "Env Explorer" : "Agent Workbench"} returned done + {terminationReason ? ` (${humanize(terminationReason)})` : ""}. Reset the episode before submitting another + step. +
+ )} +
+ + +
+
+ ); +} + +function MedicationCards({ meds }: { meds: Array> }) { + return ( +
+
+

Current Medications

+ {meds.length} +
+
+ {meds.map((med, index) => { + const flags = [med.beers_flag, med.flag, med.warning].filter(Boolean); + const highRisk = Boolean(med.high_risk ?? med.is_high_risk_elderly ?? flags.length); + return ( +
+
+ {shortValue(med.drug ?? med.drug_id ?? med.name)} + {highRisk && High Risk} +
+

{shortValue(med.indication ?? med.class_name ?? med.atc_class)}

+
+ {shortValue(med.dose_bucket ?? med.dose_mg ?? med.dose)} + {shortValue(med.requires_taper ? "taper" : med.monitoring ?? med.route)} +
+
+ ); + })} + {meds.length === 0 &&

No medications loaded.

} +
+
+ ); +} + +function RewardBars({ rewardBreakdown, reward }: { rewardBreakdown: Record | null; reward: number | null }) { + const source = rewardBreakdown ?? { total_reward: reward }; + return ( +
+
+

Reward Channels

+ {formatReward(source.total_reward ?? reward)} +
+
+ {REWARD_KEYS.map((key) => { + const value = toNumber(source[key]); + const width = Math.max(0.5, Math.min(value ?? 0, 0.999) * 100); + return ( +
+ {humanize(key)} +
+
+
+ {formatReward(value)} +
+ ); + })} +
+
+ ); +} + +function ModelTruthPanel({ status }: { status: ModelStatus | null }) { + const signal = modelSignal(status); + const availability = status?.availability ?? {}; + const availabilityRows = Object.entries(availability); + return ( +
+
+

Model Truth

+ {signal.label} +
+

{signal.detail}

+
+
+ Model + {shortValue(status?.model_id ?? status?.base_model ?? "unavailable")} +
+
+ Run + {shortValue(status?.run_id)} +
+
+ Artifact + {shortValue(status?.loaded_source || status?.preferred_artifact)} +
+
+ Availability + + {availabilityRows.length + ? availabilityRows.map(([key, value]) => `${humanize(key)}:${value ? "yes" : "no"}`).join(" | ") + : "-"} + +
+
+
+ ); +} + +function HistoryPanel({ observation }: { observation: EnvObservation | null }) { + const history = observation?.action_history ?? []; + const warnings = observation?.warning_summary ?? []; + return ( +
+
+
+
+

Action History

+ {history.length} +
+
+ {history.map((item, index) => { + const action = isRecord(item.action) ? item.action : item; + return ( +
+ + Step {shortValue(item.step ?? index)} - {humanize(shortValue(action.action_type ?? "action"))} + + {shortValue(action.candidate_id ?? action.target_drug ?? item.reward)} +
+ ); + })} + {history.length === 0 &&

No actions yet.

} +
+
+
+
+

Warnings

+ {warnings.length} +
+
+ {warnings.map((warning, index) => ( +
+ {warning} +
+ ))} + {warnings.length === 0 &&

No active warnings.

} +
+
+
+
+ ); +} + +function DetailPanel({ + title, + data, +}: { + title: string; + data: Record | unknown[] | null | undefined; +}) { + const hasData = Array.isArray(data) ? data.length > 0 : isRecord(data) && Object.keys(data).length > 0; + return ( +
+
+

{title}

+
+ {hasData ?
{JSON.stringify(data, null, 2)}
:

No data.

} +
+ ); +} + +function EventLog({ events, error }: { events: string[]; error: string | null }) { + return ( +
+
+

Event Log

+ {events.length} +
+ {error &&
{error}
} +
+ {events.map((line, index) => ( +
{line}
+ ))} + {events.length === 0 &&

Events will appear here.

} +
+
+ ); +} + +export default function App() { + const [mode, setMode] = useState("agent"); + const [catalog, setCatalog] = useState(FALLBACK_CATALOG); + const [taskId, setTaskId] = useState("budgeted_screening"); + const [difficulty, setDifficulty] = useState("medium"); + const [subEnvironment, setSubEnvironment] = useState("REGIMEN_RISK"); + const [agentObservation, setAgentObservation] = useState(null); + const [envObservation, setEnvObservation] = useState(null); + const [agentReward, setAgentReward] = useState(null); + const [envReward, setEnvReward] = useState(null); + const [agentDone, setAgentDone] = useState(false); + const [envDone, setEnvDone] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const [confidence, setConfidence] = useState(0.75); + const [rationale, setRationale] = useState("Selected from the interactive workbench."); + const [rewardBreakdown, setRewardBreakdown] = useState | null>(null); + const [agentInfo, setAgentInfo] = useState | null>(null); + const [envInfo, setEnvInfo] = useState | null>(null); + const [modelStatus, setModelStatus] = useState(null); + const [decision, setDecision] = useState | null>(null); + const [explanation, setExplanation] = useState | null>(null); + const [evidence, setEvidence] = useState(null); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [tipsOpen, setTipsOpen] = useState(() => { + try { + return window.localStorage.getItem(QTIPS_SEEN_KEY) !== "true"; + } catch { + return true; + } + }); + const [tipStep, setTipStep] = useState(0); + + const refreshModelStatus = useCallback(async () => { + try { + const status = await fetchModelStatus(); + setModelStatus(status); + return status; + } catch { + setModelStatus(null); + return null; + } + }, []); + + useEffect(() => { + fetchCatalog().then(setCatalog).catch(() => setCatalog(FALLBACK_CATALOG)); + refreshModelStatus().then((status) => { + if (!status) appendEvent(setEvents, "Model status endpoint unavailable; Qwen cannot be verified yet."); + }); + return () => closeEnvSocket(); + }, [refreshModelStatus]); + + const activeObservation = mode === "agent" ? agentObservation : envObservation; + const activeReward = mode === "agent" ? agentReward : envReward; + const activeDone = mode === "agent" ? agentDone : envDone; + const candidates = activeObservation?.candidate_action_set ?? []; + const selected = useMemo( + () => candidates.find((candidate) => candidate.candidate_id === selectedId) ?? defaultCandidateForMode(candidates, mode), + [candidates, mode, selectedId], + ); + const statusText = activeDone ? "Complete" : activeObservation ? "Live" : "Ready"; + const activeInfo = mode === "agent" ? agentInfo : envInfo; + const activeTerminationReason = shortValue(activeInfo?.termination_reason); + const terminationReason = activeTerminationReason !== "-" ? activeTerminationReason : null; + const heroStats: Array<[string, string]> = [ + ["Runtime", mode === "agent" ? "Agent Workbench" : "Env Explorer"], + ["Scenario", taskLabel(taskId, catalog.task_presets)], + ["Candidates", String(candidates.length)], + ["Reward", formatReward(activeReward)], + ]; + const closeTips = () => { + setTipsOpen(false); + try { + window.localStorage.setItem(QTIPS_SEEN_KEY, "true"); + } catch { + // Ignore localStorage failures in private browser contexts. + } + }; + + const handleTaskChange = (nextTaskId: string) => { + setTaskId(nextTaskId); + const preset = catalog.task_presets.find((item) => item.id === nextTaskId); + if (preset) { + setDifficulty(preset.difficulty); + setSubEnvironment(preset.sub_environment); + } + }; + + const handleModeChange = (nextMode: WorkbenchMode) => { + if (nextMode === mode) return; + setMode(nextMode); + setEvents([]); + setError(null); + setSelectedId(null); + if (nextMode === "agent") { + setAgentObservation(null); + setAgentReward(null); + setAgentDone(false); + setAgentInfo(null); + setRewardBreakdown(null); + setDecision(null); + setExplanation(null); + setEvidence(null); + } else { + setEnvObservation(null); + setEnvReward(null); + setEnvDone(false); + setEnvInfo(null); + setRewardBreakdown(null); + } + }; + + const updateAgentResult = useCallback(async (packet: StepResponse | Record, source: string) => { + const normalized = normalizeStepPacket(packet); + setAgentObservation(normalized.observation); + setAgentReward(normalized.reward); + setAgentDone(normalized.done); + setAgentInfo(normalized.info); + setDecision((packet.final_action as Record | undefined) ?? null); + setExplanation((packet.explanation as Record | undefined) ?? null); + setEvidence(packet.evidence); + const finalAction = isRecord(packet.final_action) ? packet.final_action : null; + const finalCandidateId = typeof finalAction?.candidate_id === "string" ? finalAction.candidate_id : null; + const candidatesAfterStep = normalized.observation?.candidate_action_set ?? []; + setSelectedId( + finalCandidateId && candidatesAfterStep.some((candidate) => candidate.candidate_id === finalCandidateId) + ? finalCandidateId + : defaultCandidateForMode(candidatesAfterStep, "agent")?.candidate_id ?? null, + ); + const breakdown = + (normalized.info.reward_breakdown as Record | undefined) ?? + ((await fetchRewardBreakdown().catch(() => null)) as Record | null); + setRewardBreakdown(breakdown ?? null); + const reason = shortValue(normalized.info.termination_reason); + appendEvent( + setEvents, + `${source} reward ${formatReward(normalized.reward)}${normalized.done && reason !== "-" ? ` - complete: ${reason}` : ""}`, + ); + }, []); + + const updateEnvResult = useCallback((packet: EnvStepPacket, source: string, submittedCandidateId?: string) => { + const normalized = normalizeStepPacket(packet); + const candidatesAfterStep = normalized.observation?.candidate_action_set ?? []; + setEnvObservation(normalized.observation); + setEnvReward(normalized.reward); + setEnvDone(normalized.done); + setEnvInfo(normalized.info); + setSelectedId( + submittedCandidateId && candidatesAfterStep.some((candidate) => candidate.candidate_id === submittedCandidateId) + ? submittedCandidateId + : defaultCandidateForMode(candidatesAfterStep, "env")?.candidate_id ?? null, + ); + const rawBreakdown = normalized.info.reward_breakdown; + if (isRecord(rawBreakdown) && Object.keys(rawBreakdown).length > 0) { + setRewardBreakdown(rawBreakdown); + } else { + setRewardBreakdown(null); + } + const reason = shortValue(normalized.info.termination_reason); + appendEvent( + setEvents, + `${source} reward ${formatReward(normalized.reward)}${normalized.done && reason !== "-" ? ` - complete: ${reason}` : ""}`, + ); + }, []); + + const handleReset = async () => { + setLoading(true); + setError(null); + setEvents([]); + try { + const options = taskResetOptions(taskId, difficulty, subEnvironment, catalog.task_presets); + if (mode === "agent") { + await refreshModelStatus(); + const obs = await resetEnv(options.agent); + setAgentObservation(obs); + setAgentReward(null); + setAgentDone(false); + setAgentInfo(null); + setRewardBreakdown(null); + setDecision(null); + setExplanation(null); + setEvidence(null); + setSelectedId(defaultCandidateForMode(obs.candidate_action_set, "agent")?.candidate_id ?? null); + } else { + const packet = await envWsSend("reset", options.env); + updateEnvResult(packet, "Env reset"); + } + appendEvent(setEvents, `Reset ${taskLabel(taskId, catalog.task_presets)} in ${mode}`); + } catch (err) { + const message = err instanceof Error ? err.message : "Reset failed"; + setError(message); + appendEvent(setEvents, message); + } finally { + setLoading(false); + } + }; + + const submitSelected = async () => { + if (!selected) return; + setLoading(true); + setError(null); + try { + if (mode === "agent") { + const result = await stepCandidate({ + candidate_id: selected.candidate_id, + confidence, + rationale_brief: rationale, + }); + await updateAgentResult(result, humanize(selected.action_type)); + await refreshModelStatus(); + } else { + const payload = buildActionPayload(selected, confidence, rationale); + const packet = await envWsSend("step", payload); + updateEnvResult(packet, humanize(selected.action_type), selected.candidate_id); + } + } catch (err) { + const message = err instanceof Error ? err.message : "Step failed"; + setError(message); + appendEvent(setEvents, message); + } finally { + setLoading(false); + } + }; + + const runAgent = async () => { + setLoading(true); + setError(null); + try { + const result = await orchestrateStep(); + await updateAgentResult(result, "Agent"); + await refreshModelStatus(); + } catch (err) { + const message = err instanceof Error ? err.message : "Agent run failed"; + setError(message); + appendEvent(setEvents, message); + } finally { + setLoading(false); + } + }; + + return ( +
+ +
+
+
+
+ * + PolyGuard neural safety cockpit +
+

+ Clinical medication safety, guided by + constrained RL decisions. +

+

+ PolyGuard coordinates live OpenEnv episodes, candidate actions, reward channels, and evidence-grounded + policy traces for safer polypharmacy review. +

+
+
+ {heroStats.map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+
+ { + setTipStep(0); + setTipsOpen(true); + }} + /> + + + {taskId === "advanced" && ( +
+ + +
+ )} + +
+ + + + + + + + + + +
+ setTipStep((step) => Math.min(step + 1, GUIDE_STEPS.length - 1))} + onPrev={() => setTipStep((step) => Math.max(step - 1, 0))} + onClose={closeTips} + /> +
+
+ ); +} diff --git a/app/ui/frontend/src/components/CandidateActions.tsx b/app/ui/frontend/src/components/CandidateActions.tsx new file mode 100644 index 0000000000000000000000000000000000000000..937a048ca5da05d9e594dba2403789dcb896aab1 --- /dev/null +++ b/app/ui/frontend/src/components/CandidateActions.tsx @@ -0,0 +1,16 @@ +export default function CandidateActions({ items }: { items: Array> }) { + return ( +
+

Candidate Actions

+
    + {items.map((item, idx) => ( +
  • + {String(item.candidate_id)} {String(item.action_type)} | safety{" "} + {String(item.estimated_safety_delta ?? "-")} | burden {String(item.burden_delta ?? "-")} | uncertainty{" "} + {String(item.uncertainty_score ?? "-")} +
  • + ))} +
+
+ ); +} diff --git a/app/ui/frontend/src/components/ConstraintWarnings.tsx b/app/ui/frontend/src/components/ConstraintWarnings.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c5c808d52b80aff94571be2d5c8cb5a4e65a5a44 --- /dev/null +++ b/app/ui/frontend/src/components/ConstraintWarnings.tsx @@ -0,0 +1,12 @@ +export default function ConstraintWarnings({ warnings }: { warnings: string[] }) { + return ( +
+

Constraint Warnings

+
    + {warnings.map((w, idx) => ( +
  • {w}
  • + ))} +
+
+ ); +} diff --git a/app/ui/frontend/src/components/DecisionPanel.tsx b/app/ui/frontend/src/components/DecisionPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fcfbc4b9716a3d41afcf37f133a44b16a7571120 --- /dev/null +++ b/app/ui/frontend/src/components/DecisionPanel.tsx @@ -0,0 +1,8 @@ +export default function DecisionPanel({ decision }: { decision: Record | null }) { + return ( +
+

Decision

+
{JSON.stringify(decision, null, 2)}
+
+ ); +} diff --git a/app/ui/frontend/src/components/DosingPanel.tsx b/app/ui/frontend/src/components/DosingPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ca6abc60b4d80c81e3c0a948defc47d737456642 --- /dev/null +++ b/app/ui/frontend/src/components/DosingPanel.tsx @@ -0,0 +1,8 @@ +export default function DosingPanel({ data }: { data: Record }) { + return ( +
+

Precision Dosing

+
{JSON.stringify(data, null, 2)}
+
+ ); +} diff --git a/app/ui/frontend/src/components/EpisodeTrace.tsx b/app/ui/frontend/src/components/EpisodeTrace.tsx new file mode 100644 index 0000000000000000000000000000000000000000..64996a8208994654d51adcb45541c1996310e992 --- /dev/null +++ b/app/ui/frontend/src/components/EpisodeTrace.tsx @@ -0,0 +1,24 @@ +import { useMemo, useState } from "react"; + +export default function EpisodeTrace({ trace }: { trace: Array> }) { + const [idx, setIdx] = useState(0); + const safeIdx = Math.max(0, Math.min(idx, Math.max(0, trace.length - 1))); + const selected = useMemo(() => trace[safeIdx] ?? {}, [trace, safeIdx]); + + return ( +
+

Episode Trace

+ setIdx(Number(e.target.value))} + /> +

+ Step {safeIdx + 1} / {Math.max(1, trace.length)} +

+
{JSON.stringify(selected, null, 2)}
+
+ ); +} diff --git a/app/ui/frontend/src/components/EvidenceDrawer.tsx b/app/ui/frontend/src/components/EvidenceDrawer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b9ea50c12d8b3afa4d06e9807b3e6fbed6e26435 --- /dev/null +++ b/app/ui/frontend/src/components/EvidenceDrawer.tsx @@ -0,0 +1,8 @@ +export default function EvidenceDrawer({ evidence }: { evidence: unknown }) { + return ( +
+

Evidence

+
{JSON.stringify(evidence, null, 2)}
+
+ ); +} diff --git a/app/ui/frontend/src/components/ExplanationPanel.tsx b/app/ui/frontend/src/components/ExplanationPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a7a61aa14ff957a6f21d7133fb7ae2f2df2cfa6b --- /dev/null +++ b/app/ui/frontend/src/components/ExplanationPanel.tsx @@ -0,0 +1,8 @@ +export default function ExplanationPanel({ explanation }: { explanation: Record | null }) { + return ( +
+

Explanation

+
{JSON.stringify(explanation, null, 2)}
+
+ ); +} diff --git a/app/ui/frontend/src/components/MedicationTable.tsx b/app/ui/frontend/src/components/MedicationTable.tsx new file mode 100644 index 0000000000000000000000000000000000000000..28964da83b453fe255e3ed727315152bbb29fb1e --- /dev/null +++ b/app/ui/frontend/src/components/MedicationTable.tsx @@ -0,0 +1,25 @@ +export default function MedicationTable({ meds }: { meds: Array> }) { + return ( +
+

Medication Table

+ + + + + + + + + + {meds.map((m, idx) => ( + + + + + + ))} + +
DrugDoseIndication
{String(m.drug ?? "-")}{String(m.dose_bucket ?? "-")}{String(m.indication ?? "-")}
+
+ ); +} diff --git a/app/ui/frontend/src/components/MetaverseBackdrop.tsx b/app/ui/frontend/src/components/MetaverseBackdrop.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d0bea5dd0f6923908cdd28cbb8e9c7972095588c --- /dev/null +++ b/app/ui/frontend/src/components/MetaverseBackdrop.tsx @@ -0,0 +1,104 @@ +import { useEffect, useRef } from "react"; + +type Star = { + x: number; + y: number; + z: number; + size: number; + speed: number; +}; + +function createStars(count: number): Star[] { + return Array.from({ length: count }, () => ({ + x: Math.random() * 2 - 1, + y: Math.random() * 2 - 1, + z: Math.random(), + size: Math.random() * 1.4 + 0.25, + speed: Math.random() * 0.00055 + 0.00018, + })); +} + +function StarCanvas() { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + const context = canvas?.getContext("2d"); + if (!canvas || !context) return undefined; + + let animationFrame = 0; + let width = 0; + let height = 0; + let centerX = 0; + let centerY = 0; + const stars = createStars(680); + + const resize = () => { + const pixelRatio = Math.min(window.devicePixelRatio || 1, 2); + width = window.innerWidth; + height = window.innerHeight; + centerX = width / 2; + centerY = height / 2; + canvas.width = Math.floor(width * pixelRatio); + canvas.height = Math.floor(height * pixelRatio); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); + }; + + const draw = () => { + context.clearRect(0, 0, width, height); + context.globalCompositeOperation = "lighter"; + + stars.forEach((star) => { + star.z -= star.speed; + if (star.z <= 0.02) { + star.x = Math.random() * 2 - 1; + star.y = Math.random() * 2 - 1; + star.z = 1; + } + + const perspective = 1 / star.z; + const x = centerX + star.x * perspective * centerX; + const y = centerY + star.y * perspective * centerY; + const opacity = Math.max(0, Math.min(1, 1.15 - star.z)); + const radius = star.size * perspective * 0.85; + + context.beginPath(); + context.fillStyle = `rgba(210, 246, 255, ${opacity})`; + context.arc(x, y, radius, 0, Math.PI * 2); + context.fill(); + }); + + animationFrame = window.requestAnimationFrame(draw); + }; + + resize(); + draw(); + window.addEventListener("resize", resize); + + return () => { + window.removeEventListener("resize", resize); + window.cancelAnimationFrame(animationFrame); + }; + }, []); + + return ; +} + +export default function MetaverseBackdrop() { + return ( +