Krooz commited on
Commit
4bb2117
·
verified ·
1 Parent(s): 16adc4b

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +0 -1
  2. README.md +0 -1
  3. evals_hf.ipynb +114 -20
  4. examples/train_rl_agent.py +984 -0
  5. examples/train_sb3_agent.py +285 -0
  6. examples/train_torch_ppo.py +1278 -0
  7. examples/train_torch_ppo_http.py +492 -0
  8. frontend/README.md +93 -0
  9. frontend/eslint.config.js +22 -0
  10. frontend/index.html +16 -0
  11. frontend/package-lock.json +2772 -0
  12. frontend/package.json +30 -0
  13. frontend/public/favicon.svg +1 -0
  14. frontend/public/icons.svg +24 -0
  15. frontend/src/App.css +703 -0
  16. frontend/src/App.tsx +460 -0
  17. frontend/src/assets/hero.png +0 -0
  18. frontend/src/assets/react.svg +1 -0
  19. frontend/src/assets/vite.svg +1 -0
  20. frontend/src/components/APIReport.tsx +33 -0
  21. frontend/src/components/ControlPanel.tsx +90 -0
  22. frontend/src/components/HUD.tsx +169 -0
  23. frontend/src/components/Map2D.tsx +600 -0
  24. frontend/src/components/StatusCard.tsx +26 -0
  25. frontend/src/index.css +1 -0
  26. frontend/src/main.tsx +10 -0
  27. frontend/src/types.ts +112 -0
  28. frontend/tsconfig.app.json +25 -0
  29. frontend/tsconfig.json +7 -0
  30. frontend/tsconfig.node.json +24 -0
  31. frontend/vite.config.ts +20 -0
  32. openenv_pyre_env.egg-info/PKG-INFO +13 -0
  33. openenv_pyre_env.egg-info/SOURCES.txt +2 -0
  34. openenv_pyre_env.egg-info/requires.txt +14 -0
  35. outputs/20260425_154907_Qwen-Qwen3-06B/error.txt +57 -0
  36. outputs/20260425_154907_Qwen-Qwen3-06B/output.txt +46 -0
  37. outputs/20260425_154915_Qwen-Qwen3-17B/README.md +67 -0
  38. outputs/20260425_154915_Qwen-Qwen3-17B/checkpoint-10/config.json +60 -0
  39. outputs/20260425_154915_Qwen-Qwen3-17B/checkpoint-10/generation_config.json +12 -0
  40. outputs/20260425_154915_Qwen-Qwen3-17B/checkpoint-10/model-00001-of-00002.safetensors +3 -0
  41. outputs/20260425_154915_Qwen-Qwen3-17B/checkpoint-10/model-00002-of-00002.safetensors +3 -0
  42. outputs/20260425_154915_Qwen-Qwen3-17B/error.txt +220 -0
  43. outputs/20260425_154915_Qwen-Qwen3-17B/output.txt +46 -0
  44. outputs/20260425_154915_Qwen-Qwen3-17B/runs/Apr25_16-01-06_cccxc590/events.out.tfevents.1777147555.cccxc590.2920434.0 +3 -0
  45. pyproject.toml +28 -0
  46. run_training_openenv.sh +4 -7
  47. run_training_unsloth.sh +55 -0
  48. server/app.py +144 -6
  49. server/floor_plan.py +1 -0
  50. server/pyre_env_environment.py +1 -0
Dockerfile CHANGED
@@ -77,5 +77,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
77
 
78
  # Run the FastAPI server
79
  # The module path is constructed to work with the /app/env structure
80
- ENV ENABLE_WEB_INTERFACE=true
81
  CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
 
77
 
78
  # Run the FastAPI server
79
  # The module path is constructed to work with the /app/env structure
 
80
  CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
README.md CHANGED
@@ -6,7 +6,6 @@ colorTo: yellow
6
  sdk: docker
7
  pinned: false
8
  app_port: 8000
9
- base_path: /web
10
  tags:
11
  - openenv
12
  ---
 
6
  sdk: docker
7
  pinned: false
8
  app_port: 8000
 
9
  tags:
10
  - openenv
11
  ---
evals_hf.ipynb CHANGED
@@ -26,10 +26,18 @@
26
  },
27
  {
28
  "cell_type": "code",
29
- "execution_count": null,
30
  "id": "c3d4e5f6",
31
  "metadata": {},
32
- "outputs": [],
 
 
 
 
 
 
 
 
33
  "source": [
34
  "import csv\n",
35
  "import json\n",
@@ -65,7 +73,7 @@
65
  },
66
  {
67
  "cell_type": "markdown",
68
- "id": "d4e5f6a7",
69
  "metadata": {},
70
  "source": [
71
  "## Cell 2 — Config ✏️ Edit this cell to change eval settings"
@@ -73,10 +81,23 @@
73
  },
74
  {
75
  "cell_type": "code",
76
- "execution_count": null,
77
  "id": "e5f6a7b8",
78
  "metadata": {},
79
- "outputs": [],
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  "source": [
81
  "# ── Model ─────────────────────────────────────────────────────────────────────\n",
82
  "MODEL_ID = \"Qwen/Qwen3-1.7B\" # HF model ID or local path / adapter dir\n",
@@ -123,10 +144,19 @@
123
  },
124
  {
125
  "cell_type": "code",
126
- "execution_count": null,
127
  "id": "a7b8c9d0",
128
  "metadata": {},
129
- "outputs": [],
 
 
 
 
 
 
 
 
 
130
  "source": [
131
  "ALL_DIFFICULTIES: List[Dict[str, Any]] = [\n",
132
  " {\"difficulty\": \"easy\", \"max_steps\": 200, \"description\": \"1 fire source · slow spread · calm wind · high humidity\"},\n",
@@ -195,10 +225,18 @@
195
  },
196
  {
197
  "cell_type": "code",
198
- "execution_count": null,
199
  "id": "c9d0e1f2",
200
  "metadata": {},
201
- "outputs": [],
 
 
 
 
 
 
 
 
202
  "source": [
203
  "class HFChatModel(SimpleChatModel):\n",
204
  " \"\"\"\n",
@@ -308,10 +346,18 @@
308
  },
309
  {
310
  "cell_type": "code",
311
- "execution_count": null,
312
  "id": "e1f2a3b4",
313
  "metadata": {},
314
- "outputs": [],
 
 
 
 
 
 
 
 
315
  "source": [
316
  "def _build_user_message(obs: Dict[str, Any], history: List[str]) -> str:\n",
317
  " \"\"\"Convert a raw observation dict + history into the LLM user message.\"\"\"\n",
@@ -466,10 +512,18 @@
466
  },
467
  {
468
  "cell_type": "code",
469
- "execution_count": null,
470
  "id": "a3b4c5d6",
471
  "metadata": {},
472
- "outputs": [],
 
 
 
 
 
 
 
 
473
  "source": [
474
  "def run_episode(\n",
475
  " llm: HFChatModel,\n",
@@ -637,10 +691,54 @@
637
  },
638
  {
639
  "cell_type": "code",
640
- "execution_count": null,
641
  "id": "c5d6e7f8",
642
  "metadata": {},
643
- "outputs": [],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
644
  "source": [
645
  "# Health check first — fail fast before waiting for the large model to load\n",
646
  "try:\n",
@@ -855,13 +953,9 @@
855
  ],
856
  "metadata": {
857
  "kernelspec": {
858
- "display_name": "Python 3",
859
  "language": "python",
860
  "name": "python3"
861
- },
862
- "language_info": {
863
- "name": "python",
864
- "version": "3.10.0"
865
  }
866
  },
867
  "nbformat": 4,
 
26
  },
27
  {
28
  "cell_type": "code",
29
+ "execution_count": 1,
30
  "id": "c3d4e5f6",
31
  "metadata": {},
32
+ "outputs": [
33
+ {
34
+ "name": "stdout",
35
+ "output_type": "stream",
36
+ "text": [
37
+ "Imports OK\n"
38
+ ]
39
+ }
40
+ ],
41
  "source": [
42
  "import csv\n",
43
  "import json\n",
 
73
  },
74
  {
75
  "cell_type": "markdown",
76
+ "id": "1f1d9271",
77
  "metadata": {},
78
  "source": [
79
  "## Cell 2 — Config ✏️ Edit this cell to change eval settings"
 
81
  },
82
  {
83
  "cell_type": "code",
84
+ "execution_count": 2,
85
  "id": "e5f6a7b8",
86
  "metadata": {},
87
+ "outputs": [
88
+ {
89
+ "name": "stdout",
90
+ "output_type": "stream",
91
+ "text": [
92
+ "Model : Qwen/Qwen3-1.7B\n",
93
+ "4-bit : False\n",
94
+ "Temperature: 0.3\n",
95
+ "Levels : all 3 (easy, medium, hard)\n",
96
+ "Seeds : [1, 2, 3]\n",
97
+ "Output dir : ./outputs/hf_evals\n"
98
+ ]
99
+ }
100
+ ],
101
  "source": [
102
  "# ── Model ─────────────────────────────────────────────────────────────────────\n",
103
  "MODEL_ID = \"Qwen/Qwen3-1.7B\" # HF model ID or local path / adapter dir\n",
 
144
  },
145
  {
146
  "cell_type": "code",
147
+ "execution_count": 3,
148
  "id": "a7b8c9d0",
149
  "metadata": {},
150
+ "outputs": [
151
+ {
152
+ "name": "stdout",
153
+ "output_type": "stream",
154
+ "text": [
155
+ "Difficulties to evaluate : ['easy', 'medium', 'hard']\n",
156
+ "Total episodes : 9\n"
157
+ ]
158
+ }
159
+ ],
160
  "source": [
161
  "ALL_DIFFICULTIES: List[Dict[str, Any]] = [\n",
162
  " {\"difficulty\": \"easy\", \"max_steps\": 200, \"description\": \"1 fire source · slow spread · calm wind · high humidity\"},\n",
 
225
  },
226
  {
227
  "cell_type": "code",
228
+ "execution_count": 4,
229
  "id": "c9d0e1f2",
230
  "metadata": {},
231
+ "outputs": [
232
+ {
233
+ "name": "stdout",
234
+ "output_type": "stream",
235
+ "text": [
236
+ "HFChatModel class defined.\n"
237
+ ]
238
+ }
239
+ ],
240
  "source": [
241
  "class HFChatModel(SimpleChatModel):\n",
242
  " \"\"\"\n",
 
346
  },
347
  {
348
  "cell_type": "code",
349
+ "execution_count": 5,
350
  "id": "e1f2a3b4",
351
  "metadata": {},
352
+ "outputs": [
353
+ {
354
+ "name": "stdout",
355
+ "output_type": "stream",
356
+ "text": [
357
+ "Prompt builder and action parser defined.\n"
358
+ ]
359
+ }
360
+ ],
361
  "source": [
362
  "def _build_user_message(obs: Dict[str, Any], history: List[str]) -> str:\n",
363
  " \"\"\"Convert a raw observation dict + history into the LLM user message.\"\"\"\n",
 
512
  },
513
  {
514
  "cell_type": "code",
515
+ "execution_count": 6,
516
  "id": "a3b4c5d6",
517
  "metadata": {},
518
+ "outputs": [
519
+ {
520
+ "name": "stdout",
521
+ "output_type": "stream",
522
+ "text": [
523
+ "Episode runner defined.\n"
524
+ ]
525
+ }
526
+ ],
527
  "source": [
528
  "def run_episode(\n",
529
  " llm: HFChatModel,\n",
 
691
  },
692
  {
693
  "cell_type": "code",
694
+ "execution_count": 7,
695
  "id": "c5d6e7f8",
696
  "metadata": {},
697
+ "outputs": [
698
+ {
699
+ "ename": "RuntimeError",
700
+ "evalue": "Server not reachable at http://localhost:8000: HTTPConnectionPool(host='localhost', port=8000): Max retries exceeded with url: /health (Caused by NewConnectionError(\"HTTPConnection(host='localhost', port=8000): Failed to establish a new connection: [Errno 111] Connection refused\"))",
701
+ "output_type": "error",
702
+ "traceback": [
703
+ "\u001b[31m---------------------------------------------------------------------------\u001b[39m",
704
+ "\u001b[31mConnectionRefusedError\u001b[39m Traceback (most recent call last)",
705
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/connection.py:204\u001b[39m, in \u001b[36mHTTPConnection._new_conn\u001b[39m\u001b[34m(self)\u001b[39m\n\u001b[32m 203\u001b[39m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[32m--> \u001b[39m\u001b[32m204\u001b[39m sock = \u001b[30;43mconnection\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mcreate_connection\u001b[39;49m\u001b[30;43m(\u001b[39;49m\n\u001b[32m 205\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43m_dns_host\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mport\u001b[39;49m\u001b[30;43m)\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 206\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mtimeout\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 207\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43msource_address\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43msource_address\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 208\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43msocket_options\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43msocket_options\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 209\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 210\u001b[39m \u001b[38;5;28;01mexcept\u001b[39;00m socket.gaierror \u001b[38;5;28;01mas\u001b[39;00m e:\n",
706
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/util/connection.py:85\u001b[39m, in \u001b[36mcreate_connection\u001b[39m\u001b[34m(address, timeout, source_address, socket_options)\u001b[39m\n\u001b[32m 84\u001b[39m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[32m---> \u001b[39m\u001b[32m85\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m err\n\u001b[32m 86\u001b[39m \u001b[38;5;28;01mfinally\u001b[39;00m:\n\u001b[32m 87\u001b[39m \u001b[38;5;66;03m# Break explicitly a reference cycle\u001b[39;00m\n",
707
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/util/connection.py:73\u001b[39m, in \u001b[36mcreate_connection\u001b[39m\u001b[34m(address, timeout, source_address, socket_options)\u001b[39m\n\u001b[32m 72\u001b[39m sock.bind(source_address)\n\u001b[32m---> \u001b[39m\u001b[32m73\u001b[39m \u001b[30;43msock\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mconnect\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43msa\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 74\u001b[39m \u001b[38;5;66;03m# Break explicitly a reference cycle\u001b[39;00m\n",
708
+ "\u001b[31mConnectionRefusedError\u001b[39m: [Errno 111] Connection refused",
709
+ "\nThe above exception was the direct cause of the following exception:\n",
710
+ "\u001b[31mNewConnectionError\u001b[39m Traceback (most recent call last)",
711
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/connectionpool.py:787\u001b[39m, in \u001b[36mHTTPConnectionPool.urlopen\u001b[39m\u001b[34m(self, method, url, body, headers, retries, redirect, assert_same_host, timeout, pool_timeout, release_conn, chunked, body_pos, preload_content, decode_content, **response_kw)\u001b[39m\n\u001b[32m 786\u001b[39m \u001b[38;5;66;03m# Make the request on the HTTPConnection object\u001b[39;00m\n\u001b[32m--> \u001b[39m\u001b[32m787\u001b[39m response = \u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43m_make_request\u001b[39;49m\u001b[30;43m(\u001b[39;49m\n\u001b[32m 788\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mconn\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 789\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mmethod\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 790\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43murl\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 791\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mtimeout\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mtimeout_obj\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 792\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mbody\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mbody\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 793\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mheaders\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mheaders\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 794\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mchunked\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mchunked\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 795\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mretries\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mretries\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 796\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mresponse_conn\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mresponse_conn\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 797\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mpreload_content\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mpreload_content\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 798\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mdecode_content\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mdecode_content\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 799\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43mresponse_kw\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 800\u001b[39m \u001b[30;43m\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 802\u001b[39m \u001b[38;5;66;03m# Everything went great!\u001b[39;00m\n",
712
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/connectionpool.py:493\u001b[39m, in \u001b[36mHTTPConnectionPool._make_request\u001b[39m\u001b[34m(self, conn, method, url, body, headers, retries, timeout, chunked, response_conn, preload_content, decode_content, enforce_content_length)\u001b[39m\n\u001b[32m 492\u001b[39m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[32m--> \u001b[39m\u001b[32m493\u001b[39m \u001b[30;43mconn\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mrequest\u001b[39;49m\u001b[30;43m(\u001b[39;49m\n\u001b[32m 494\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mmethod\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 495\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43murl\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 496\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mbody\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mbody\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 497\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mheaders\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mheaders\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 498\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mchunked\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mchunked\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 499\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mpreload_content\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mpreload_content\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 500\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mdecode_content\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mdecode_content\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 501\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43menforce_content_length\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43menforce_content_length\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 502\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 504\u001b[39m \u001b[38;5;66;03m# We are swallowing BrokenPipeError (errno.EPIPE) since the server is\u001b[39;00m\n\u001b[32m 505\u001b[39m \u001b[38;5;66;03m# legitimately able to close the connection after sending a valid response.\u001b[39;00m\n\u001b[32m 506\u001b[39m \u001b[38;5;66;03m# With this behaviour, the received response is still readable.\u001b[39;00m\n",
713
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/connection.py:500\u001b[39m, in \u001b[36mHTTPConnection.request\u001b[39m\u001b[34m(self, method, url, body, headers, chunked, preload_content, decode_content, enforce_content_length)\u001b[39m\n\u001b[32m 499\u001b[39m \u001b[38;5;28mself\u001b[39m.putheader(header, value)\n\u001b[32m--> \u001b[39m\u001b[32m500\u001b[39m \u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mendheaders\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 502\u001b[39m \u001b[38;5;66;03m# If we're given a body we start sending that in chunks.\u001b[39;00m\n",
714
+ "\u001b[36mFile \u001b[39m\u001b[32m~/.local/share/uv/python/cpython-3.12.12-linux-x86_64-gnu/lib/python3.12/http/client.py:1333\u001b[39m, in \u001b[36mHTTPConnection.endheaders\u001b[39m\u001b[34m(self, message_body, encode_chunked)\u001b[39m\n\u001b[32m 1332\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m CannotSendHeader()\n\u001b[32m-> \u001b[39m\u001b[32m1333\u001b[39m \u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43m_send_output\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43mmessage_body\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43mencode_chunked\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mencode_chunked\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n",
715
+ "\u001b[36mFile \u001b[39m\u001b[32m~/.local/share/uv/python/cpython-3.12.12-linux-x86_64-gnu/lib/python3.12/http/client.py:1093\u001b[39m, in \u001b[36mHTTPConnection._send_output\u001b[39m\u001b[34m(self, message_body, encode_chunked)\u001b[39m\n\u001b[32m 1092\u001b[39m \u001b[38;5;28;01mdel\u001b[39;00m \u001b[38;5;28mself\u001b[39m._buffer[:]\n\u001b[32m-> \u001b[39m\u001b[32m1093\u001b[39m \u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43msend\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43mmsg\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 1095\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m message_body \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[32m 1096\u001b[39m \n\u001b[32m 1097\u001b[39m \u001b[38;5;66;03m# create a consistent interface to message_body\u001b[39;00m\n",
716
+ "\u001b[36mFile \u001b[39m\u001b[32m~/.local/share/uv/python/cpython-3.12.12-linux-x86_64-gnu/lib/python3.12/http/client.py:1037\u001b[39m, in \u001b[36mHTTPConnection.send\u001b[39m\u001b[34m(self, data)\u001b[39m\n\u001b[32m 1036\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mself\u001b[39m.auto_open:\n\u001b[32m-> \u001b[39m\u001b[32m1037\u001b[39m \u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mconnect\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 1038\u001b[39m \u001b[38;5;28;01melse\u001b[39;00m:\n",
717
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/connection.py:331\u001b[39m, in \u001b[36mHTTPConnection.connect\u001b[39m\u001b[34m(self)\u001b[39m\n\u001b[32m 330\u001b[39m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34mconnect\u001b[39m(\u001b[38;5;28mself\u001b[39m) -> \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[32m--> \u001b[39m\u001b[32m331\u001b[39m \u001b[38;5;28mself\u001b[39m.sock = \u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43m_new_conn\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 332\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mself\u001b[39m._tunnel_host:\n\u001b[32m 333\u001b[39m \u001b[38;5;66;03m# If we're tunneling it means we're connected to our proxy.\u001b[39;00m\n",
718
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/connection.py:219\u001b[39m, in \u001b[36mHTTPConnection._new_conn\u001b[39m\u001b[34m(self)\u001b[39m\n\u001b[32m 218\u001b[39m \u001b[38;5;28;01mexcept\u001b[39;00m \u001b[38;5;167;01mOSError\u001b[39;00m \u001b[38;5;28;01mas\u001b[39;00m e:\n\u001b[32m--> \u001b[39m\u001b[32m219\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m NewConnectionError(\n\u001b[32m 220\u001b[39m \u001b[38;5;28mself\u001b[39m, \u001b[33mf\u001b[39m\u001b[33m\"\u001b[39m\u001b[33mFailed to establish a new connection: \u001b[39m\u001b[38;5;132;01m{\u001b[39;00me\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m\"\u001b[39m\n\u001b[32m 221\u001b[39m ) \u001b[38;5;28;01mfrom\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34;01me\u001b[39;00m\n\u001b[32m 223\u001b[39m sys.audit(\u001b[33m\"\u001b[39m\u001b[33mhttp.client.connect\u001b[39m\u001b[33m\"\u001b[39m, \u001b[38;5;28mself\u001b[39m, \u001b[38;5;28mself\u001b[39m.host, \u001b[38;5;28mself\u001b[39m.port)\n",
719
+ "\u001b[31mNewConnectionError\u001b[39m: HTTPConnection(host='localhost', port=8000): Failed to establish a new connection: [Errno 111] Connection refused",
720
+ "\nThe above exception was the direct cause of the following exception:\n",
721
+ "\u001b[31mMaxRetryError\u001b[39m Traceback (most recent call last)",
722
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/requests/adapters.py:645\u001b[39m, in \u001b[36mHTTPAdapter.send\u001b[39m\u001b[34m(self, request, stream, timeout, verify, cert, proxies)\u001b[39m\n\u001b[32m 644\u001b[39m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[32m--> \u001b[39m\u001b[32m645\u001b[39m resp = \u001b[30;43mconn\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43murlopen\u001b[39;49m\u001b[30;43m(\u001b[39;49m\n\u001b[32m 646\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mmethod\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mrequest\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mmethod\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 647\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43murl\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43murl\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 648\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mbody\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mrequest\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mbody\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 649\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mheaders\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mrequest\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mheaders\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 650\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mredirect\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43;01mFalse\u001b[39;49;00m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 651\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43massert_same_host\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43;01mFalse\u001b[39;49;00m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 652\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mpreload_content\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43;01mFalse\u001b[39;49;00m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 653\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mdecode_content\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43;01mFalse\u001b[39;49;00m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 654\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mretries\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mmax_retries\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 655\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mtimeout\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mtimeout\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 656\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mchunked\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mchunked\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 657\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 659\u001b[39m \u001b[38;5;28;01mexcept\u001b[39;00m (ProtocolError, \u001b[38;5;167;01mOSError\u001b[39;00m) \u001b[38;5;28;01mas\u001b[39;00m err:\n",
723
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/connectionpool.py:841\u001b[39m, in \u001b[36mHTTPConnectionPool.urlopen\u001b[39m\u001b[34m(self, method, url, body, headers, retries, redirect, assert_same_host, timeout, pool_timeout, release_conn, chunked, body_pos, preload_content, decode_content, **response_kw)\u001b[39m\n\u001b[32m 839\u001b[39m new_e = ProtocolError(\u001b[33m\"\u001b[39m\u001b[33mConnection aborted.\u001b[39m\u001b[33m\"\u001b[39m, new_e)\n\u001b[32m--> \u001b[39m\u001b[32m841\u001b[39m retries = \u001b[30;43mretries\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mincrement\u001b[39;49m\u001b[30;43m(\u001b[39;49m\n\u001b[32m 842\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mmethod\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43murl\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43merror\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mnew_e\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43m_pool\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mself\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43m_stacktrace\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43msys\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mexc_info\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43m)\u001b[39;49m\u001b[30;43m[\u001b[39;49m\u001b[30;43m2\u001b[39;49m\u001b[30;43m]\u001b[39;49m\n\u001b[32m 843\u001b[39m \u001b[30;43m\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 844\u001b[39m retries.sleep()\n",
724
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/util/retry.py:535\u001b[39m, in \u001b[36mRetry.increment\u001b[39m\u001b[34m(self, method, url, response, error, _pool, _stacktrace)\u001b[39m\n\u001b[32m 534\u001b[39m reason = error \u001b[38;5;129;01mor\u001b[39;00m ResponseError(cause)\n\u001b[32m--> \u001b[39m\u001b[32m535\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m MaxRetryError(_pool, url, reason) \u001b[38;5;28;01mfrom\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34;01mreason\u001b[39;00m \u001b[38;5;66;03m# type: ignore[arg-type]\u001b[39;00m\n\u001b[32m 537\u001b[39m log.debug(\u001b[33m\"\u001b[39m\u001b[33mIncremented Retry for (url=\u001b[39m\u001b[33m'\u001b[39m\u001b[38;5;132;01m%s\u001b[39;00m\u001b[33m'\u001b[39m\u001b[33m): \u001b[39m\u001b[38;5;132;01m%r\u001b[39;00m\u001b[33m\"\u001b[39m, url, new_retry)\n",
725
+ "\u001b[31mMaxRetryError\u001b[39m: HTTPConnectionPool(host='localhost', port=8000): Max retries exceeded with url: /health (Caused by NewConnectionError(\"HTTPConnection(host='localhost', port=8000): Failed to establish a new connection: [Errno 111] Connection refused\"))",
726
+ "\nDuring handling of the above exception, another exception occurred:\n",
727
+ "\u001b[31mConnectionError\u001b[39m Traceback (most recent call last)",
728
+ "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[7]\u001b[39m\u001b[32m, line 7\u001b[39m\n\u001b[32m 5\u001b[39m print(f\"Server health check PASSED ({ENV_URL})\")\n\u001b[32m 6\u001b[39m \u001b[38;5;28;01mexcept\u001b[39;00m Exception \u001b[38;5;28;01mas\u001b[39;00m exc:\n\u001b[32m----> \u001b[39m\u001b[32m7\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m RuntimeError(f\"Server not reachable at {ENV_URL}: {exc}\")\n\u001b[32m 8\u001b[39m \n",
729
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/requests/api.py:73\u001b[39m, in \u001b[36mget\u001b[39m\u001b[34m(url, params, **kwargs)\u001b[39m\n\u001b[32m 63\u001b[39m \u001b[38;5;250m\u001b[39m\u001b[33mr\u001b[39m\u001b[33;03m\"\"\"Sends a GET request.\u001b[39;00m\n\u001b[32m 64\u001b[39m \n\u001b[32m 65\u001b[39m \u001b[33;03m:param url: URL for the new :class:`Request` object.\u001b[39;00m\n\u001b[32m (...)\u001b[39m\u001b[32m 70\u001b[39m \u001b[33;03m:rtype: requests.Response\u001b[39;00m\n\u001b[32m 71\u001b[39m \u001b[33;03m\"\"\"\u001b[39;00m\n\u001b[32m---> \u001b[39m\u001b[32m73\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[30;43mrequest\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43m\"\u001b[39;49m\u001b[30;43mget\u001b[39;49m\u001b[30;43m\"\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43murl\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43mparams\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mparams\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43mkwargs\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n",
730
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/requests/api.py:59\u001b[39m, in \u001b[36mrequest\u001b[39m\u001b[34m(method, url, **kwargs)\u001b[39m\n\u001b[32m 58\u001b[39m \u001b[38;5;28;01mwith\u001b[39;00m sessions.Session() \u001b[38;5;28;01mas\u001b[39;00m session:\n\u001b[32m---> \u001b[39m\u001b[32m59\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[30;43msession\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mrequest\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43mmethod\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mmethod\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43murl\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43murl\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43mkwargs\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n",
731
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/requests/sessions.py:592\u001b[39m, in \u001b[36mSession.request\u001b[39m\u001b[34m(self, method, url, params, data, headers, cookies, files, auth, timeout, allow_redirects, proxies, hooks, stream, verify, cert, json)\u001b[39m\n\u001b[32m 591\u001b[39m send_kwargs.update(settings)\n\u001b[32m--> \u001b[39m\u001b[32m592\u001b[39m resp = \u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43msend\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43mprep\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43msend_kwargs\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 594\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m resp\n",
732
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/requests/sessions.py:706\u001b[39m, in \u001b[36mSession.send\u001b[39m\u001b[34m(self, request, **kwargs)\u001b[39m\n\u001b[32m 705\u001b[39m \u001b[38;5;66;03m# Send the request\u001b[39;00m\n\u001b[32m--> \u001b[39m\u001b[32m706\u001b[39m r = \u001b[30;43madapter\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43msend\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43mrequest\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43mkwargs\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 708\u001b[39m \u001b[38;5;66;03m# Total elapsed time of the request (approximately)\u001b[39;00m\n",
733
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/requests/adapters.py:678\u001b[39m, in \u001b[36mHTTPAdapter.send\u001b[39m\u001b[34m(self, request, stream, timeout, verify, cert, proxies)\u001b[39m\n\u001b[32m 676\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m SSLError(e, request=request)\n\u001b[32m--> \u001b[39m\u001b[32m678\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mConnectionError\u001b[39;00m(e, request=request)\n\u001b[32m 680\u001b[39m \u001b[38;5;28;01mexcept\u001b[39;00m ClosedPoolError \u001b[38;5;28;01mas\u001b[39;00m e:\n",
734
+ "\u001b[31mConnectionError\u001b[39m: HTTPConnectionPool(host='localhost', port=8000): Max retries exceeded with url: /health (Caused by NewConnectionError(\"HTTPConnection(host='localhost', port=8000): Failed to establish a new connection: [Errno 111] Connection refused\"))",
735
+ "\nDuring handling of the above exception, another exception occurred:\n",
736
+ "\u001b[31mRuntimeError\u001b[39m Traceback (most recent call last)",
737
+ "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[7]\u001b[39m\u001b[32m, line 7\u001b[39m\n\u001b[32m 3\u001b[39m health = requests.get(f\"{ENV_URL}/health\", timeout=\u001b[32m5\u001b[39m)\n\u001b[32m 4\u001b[39m health.raise_for_status()\n\u001b[32m 5\u001b[39m print(f\"Server health check PASSED ({ENV_URL})\")\n\u001b[32m 6\u001b[39m \u001b[38;5;28;01mexcept\u001b[39;00m Exception \u001b[38;5;28;01mas\u001b[39;00m exc:\n\u001b[32m----> \u001b[39m\u001b[32m7\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m RuntimeError(f\"Server not reachable at {ENV_URL}: {exc}\")\n\u001b[32m 8\u001b[39m \n\u001b[32m 9\u001b[39m \u001b[38;5;66;03m# Build and load the HF model — this is the expensive step\u001b[39;00m\n\u001b[32m 10\u001b[39m llm = HFChatModel(\n",
738
+ "\u001b[31mRuntimeError\u001b[39m: Server not reachable at http://localhost:8000: HTTPConnectionPool(host='localhost', port=8000): Max retries exceeded with url: /health (Caused by NewConnectionError(\"HTTPConnection(host='localhost', port=8000): Failed to establish a new connection: [Errno 111] Connection refused\"))"
739
+ ]
740
+ }
741
+ ],
742
  "source": [
743
  "# Health check first — fail fast before waiting for the large model to load\n",
744
  "try:\n",
 
953
  ],
954
  "metadata": {
955
  "kernelspec": {
956
+ "display_name": "Python 3 (ipykernel)",
957
  "language": "python",
958
  "name": "python3"
 
 
 
 
959
  }
960
  },
961
  "nbformat": 4,
examples/train_rl_agent.py ADDED
@@ -0,0 +1,984 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train a deep RL baseline directly against the local Pyre environment.
2
+
3
+ This script makes the environment contract explicit:
4
+ - Observation: encoded from `PyreObservation.map_state` into a fixed-length vector
5
+ - Action: fixed discrete action table with a runtime validity mask from `available_actions_hint`
6
+ - Reward: the environment's composite reward returned by `PyreEnvironment.step()`
7
+
8
+ It uses a self-contained NumPy actor-critic implementation so it can run in
9
+ this repository without external ML dependencies.
10
+
11
+ Examples:
12
+ python examples/train_rl_agent.py --episodes 150 --difficulty easy
13
+ python examples/train_rl_agent.py --episodes 300 --difficulty-schedule easy,medium
14
+ python examples/train_rl_agent.py --episodes 200 --difficulty easy,medium,hard --observation-mode full
15
+ python examples/train_rl_agent.py --describe-only
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import csv
22
+ import json
23
+ import math
24
+ import re
25
+ from collections import deque
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+ from typing import Dict, Iterable, List, Sequence
29
+
30
+ import numpy as np
31
+
32
+ from pyre_env.models import PyreAction, PyreObservation
33
+ from pyre_env.server.pyre_env_environment import PyreEnvironment
34
+
35
+
36
+ MAX_GRID_W = 24
37
+ MAX_GRID_H = 24
38
+ MAX_DOORS = 16
39
+ DIRECTIONS = ("north", "south", "west", "east")
40
+ WINDS = ("CALM", "NORTH", "SOUTH", "WEST", "EAST")
41
+ DIFFICULTIES = ("easy", "medium", "hard")
42
+
43
+ MOVE_KEYS = [f"move(direction='{d}')" for d in DIRECTIONS]
44
+ LOOK_KEYS = [f"look(direction='{d}')" for d in DIRECTIONS]
45
+ WAIT_KEY = "wait()"
46
+ OPEN_KEYS = [f"door(target_id='door_{i}', door_state='open')" for i in range(1, MAX_DOORS + 1)]
47
+ CLOSE_KEYS = [f"door(target_id='door_{i}', door_state='close')" for i in range(1, MAX_DOORS + 1)]
48
+ ACTION_KEYS = MOVE_KEYS + LOOK_KEYS + [WAIT_KEY] + OPEN_KEYS + CLOSE_KEYS
49
+ ACTION_DIM = len(ACTION_KEYS)
50
+ ACTION_TO_INDEX = {key: idx for idx, key in enumerate(ACTION_KEYS)}
51
+
52
+ _MOVE_RE = re.compile(r"move\(direction='(north|south|west|east)'\)")
53
+ _LOOK_RE = re.compile(r"look\(direction='(north|south|west|east)'\)")
54
+ _DOOR_RE = re.compile(r"door\(target_id='(door_(\d+))', door_state='(open|close)'\)")
55
+
56
+
57
+ def _one_hot(index: int, size: int) -> np.ndarray:
58
+ arr = np.zeros(size, dtype=np.float32)
59
+ if 0 <= index < size:
60
+ arr[index] = 1.0
61
+ return arr
62
+
63
+
64
+ def action_index_to_env_action(index: int) -> PyreAction:
65
+ if 0 <= index < 4:
66
+ return PyreAction(action="move", direction=DIRECTIONS[index])
67
+ if 4 <= index < 8:
68
+ return PyreAction(action="look", direction=DIRECTIONS[index - 4])
69
+ if index == 8:
70
+ return PyreAction(action="wait")
71
+ if 9 <= index < 9 + MAX_DOORS:
72
+ door_id = f"door_{index - 8}"
73
+ return PyreAction(action="door", target_id=door_id, door_state="open")
74
+ door_slot = index - (9 + MAX_DOORS)
75
+ door_id = f"door_{door_slot + 1}"
76
+ return PyreAction(action="door", target_id=door_id, door_state="close")
77
+
78
+
79
+ def build_action_mask(observation: PyreObservation) -> np.ndarray:
80
+ mask = np.zeros(ACTION_DIM, dtype=np.float32)
81
+ for hint in observation.available_actions_hint:
82
+ idx = ACTION_TO_INDEX.get(hint)
83
+ if idx is not None:
84
+ mask[idx] = 1.0
85
+ continue
86
+
87
+ match = _MOVE_RE.fullmatch(hint)
88
+ if match:
89
+ mask[ACTION_TO_INDEX[f"move(direction='{match.group(1)}')"]] = 1.0
90
+ continue
91
+
92
+ match = _LOOK_RE.fullmatch(hint)
93
+ if match:
94
+ mask[ACTION_TO_INDEX[f"look(direction='{match.group(1)}')"]] = 1.0
95
+ continue
96
+
97
+ match = _DOOR_RE.fullmatch(hint)
98
+ if match:
99
+ door_id = match.group(1)
100
+ door_num = int(match.group(2))
101
+ state = match.group(3)
102
+ if 1 <= door_num <= MAX_DOORS:
103
+ mask[ACTION_TO_INDEX[f"door(target_id='{door_id}', door_state='{state}')"]] = 1.0
104
+
105
+ if mask.sum() == 0:
106
+ mask[ACTION_TO_INDEX[WAIT_KEY]] = 1.0
107
+ return mask
108
+
109
+
110
+ class ObservationEncoder:
111
+ """Encode Pyre observations into a fixed-size float vector."""
112
+
113
+ def __init__(self, mode: str = "visible"):
114
+ if mode not in {"visible", "full"}:
115
+ raise ValueError(f"Unsupported observation mode: {mode}")
116
+ self.mode = mode
117
+ self.base_dim = MAX_GRID_W * MAX_GRID_H * 10 + 22
118
+
119
+ def encode(self, observation: PyreObservation) -> np.ndarray:
120
+ map_state = observation.map_state
121
+ if map_state is None:
122
+ raise ValueError("PyreObservation.map_state is required for RL training.")
123
+
124
+ cell_one_hot = np.zeros((MAX_GRID_H, MAX_GRID_W, 6), dtype=np.float32)
125
+ fire_channel = np.zeros((MAX_GRID_H, MAX_GRID_W), dtype=np.float32)
126
+ smoke_channel = np.zeros((MAX_GRID_H, MAX_GRID_W), dtype=np.float32)
127
+ visible_channel = np.zeros((MAX_GRID_H, MAX_GRID_W), dtype=np.float32)
128
+ agent_channel = np.zeros((MAX_GRID_H, MAX_GRID_W), dtype=np.float32)
129
+
130
+ visible = {(x, y) for x, y in map_state.visible_cells}
131
+ for y in range(map_state.grid_h):
132
+ for x in range(map_state.grid_w):
133
+ if self.mode == "visible" and (x, y) not in visible and (x, y) != (map_state.agent_x, map_state.agent_y):
134
+ continue
135
+ i = y * map_state.grid_w + x
136
+ cell_type = int(map_state.cell_grid[i])
137
+ if 0 <= cell_type <= 5:
138
+ cell_one_hot[y, x, cell_type] = 1.0
139
+ fire_channel[y, x] = float(map_state.fire_grid[i])
140
+ smoke_channel[y, x] = float(map_state.smoke_grid[i])
141
+ visible_channel[y, x] = 1.0 if (x, y) in visible else 0.0
142
+
143
+ if 0 <= map_state.agent_x < MAX_GRID_W and 0 <= map_state.agent_y < MAX_GRID_H:
144
+ agent_channel[map_state.agent_y, map_state.agent_x] = 1.0
145
+
146
+ grid_features = np.concatenate(
147
+ [
148
+ cell_one_hot.reshape(-1),
149
+ fire_channel.reshape(-1),
150
+ smoke_channel.reshape(-1),
151
+ visible_channel.reshape(-1),
152
+ agent_channel.reshape(-1),
153
+ ]
154
+ )
155
+
156
+ metadata = observation.metadata or {}
157
+ wind_dir = str(metadata.get("wind_dir", map_state.wind_dir or "CALM")).upper()
158
+ difficulty = str(metadata.get("difficulty", "medium")).lower()
159
+ wind_index = WINDS.index(wind_dir) if wind_dir in WINDS else 0
160
+ difficulty_index = DIFFICULTIES.index(difficulty) if difficulty in DIFFICULTIES else 1
161
+
162
+ global_features = np.concatenate(
163
+ [
164
+ np.array(
165
+ [
166
+ float(observation.agent_health) / 100.0,
167
+ float(map_state.agent_health) / 100.0,
168
+ float(map_state.step_count) / max(1, map_state.max_steps),
169
+ float(map_state.fire_spread_rate),
170
+ float(map_state.humidity),
171
+ float(map_state.agent_x) / max(1, map_state.grid_w - 1),
172
+ float(map_state.agent_y) / max(1, map_state.grid_h - 1),
173
+ float(metadata.get("nearest_exit_distance", MAX_GRID_W + MAX_GRID_H) or 0.0) / float(MAX_GRID_W + MAX_GRID_H),
174
+ float(metadata.get("reachable_exit_count", 0.0)) / 4.0,
175
+ float(metadata.get("visible_cell_count", 0.0)) / float(MAX_GRID_W * MAX_GRID_H),
176
+ float(metadata.get("fire_sources", 0.0)) / 5.0,
177
+ {"none": 0.0, "light": 0.33, "moderate": 0.66, "heavy": 1.0}.get(observation.smoke_level, 0.0),
178
+ 1.0 if map_state.agent_alive else 0.0,
179
+ 1.0 if map_state.agent_evacuated else 0.0,
180
+ ],
181
+ dtype=np.float32,
182
+ ),
183
+ _one_hot(wind_index, len(WINDS)),
184
+ _one_hot(difficulty_index, len(DIFFICULTIES)),
185
+ ]
186
+ )
187
+
188
+ return np.concatenate([grid_features, global_features]).astype(np.float32)
189
+
190
+ def describe(self, history_length: int) -> str:
191
+ grid_text = (
192
+ f"Observation mode `{self.mode}` encodes a {MAX_GRID_W}x{MAX_GRID_H} padded map with "
193
+ "10 channels per cell: 6-way cell type one-hot, fire intensity, smoke intensity, visible mask, and agent mask."
194
+ )
195
+ if self.mode == "visible":
196
+ visibility_text = "Only currently visible cells are populated; unseen cells stay zeroed."
197
+ else:
198
+ visibility_text = "The full ground-truth map is exposed for curriculum/debug use."
199
+ return (
200
+ f"{grid_text} {visibility_text} "
201
+ f"Global features add health, step progress, fire parameters, position, exit-distance metadata, smoke severity, wind, and difficulty. "
202
+ f"{history_length} encoded frames are stacked, so the network input dimension is {self.base_dim * history_length}."
203
+ )
204
+
205
+
206
+ def softmax_with_mask(logits: np.ndarray, mask: np.ndarray) -> np.ndarray:
207
+ masked_logits = np.where(mask > 0.0, logits, -1e9)
208
+ max_logits = np.max(masked_logits, axis=1, keepdims=True)
209
+ exps = np.exp(masked_logits - max_logits) * mask
210
+ denom = np.sum(exps, axis=1, keepdims=True)
211
+ denom = np.where(denom <= 0.0, 1.0, denom)
212
+ return exps / denom
213
+
214
+
215
+ class AdamOptimizer:
216
+ def __init__(self, params: Dict[str, np.ndarray], lr: float = 3e-4, beta1: float = 0.9, beta2: float = 0.999):
217
+ self.lr = lr
218
+ self.beta1 = beta1
219
+ self.beta2 = beta2
220
+ self.eps = 1e-8
221
+ self.t = 0
222
+ self.m = {k: np.zeros_like(v) for k, v in params.items()}
223
+ self.v = {k: np.zeros_like(v) for k, v in params.items()}
224
+
225
+ def step(self, params: Dict[str, np.ndarray], grads: Dict[str, np.ndarray], clip_norm: float = 1.0) -> None:
226
+ total_norm_sq = 0.0
227
+ for grad in grads.values():
228
+ total_norm_sq += float(np.sum(grad * grad))
229
+ total_norm = math.sqrt(total_norm_sq)
230
+ scale = 1.0
231
+ if total_norm > clip_norm:
232
+ scale = clip_norm / (total_norm + 1e-8)
233
+
234
+ self.t += 1
235
+ for name, param in params.items():
236
+ grad = grads[name] * scale
237
+ self.m[name] = self.beta1 * self.m[name] + (1.0 - self.beta1) * grad
238
+ self.v[name] = self.beta2 * self.v[name] + (1.0 - self.beta2) * (grad * grad)
239
+ m_hat = self.m[name] / (1.0 - self.beta1 ** self.t)
240
+ v_hat = self.v[name] / (1.0 - self.beta2 ** self.t)
241
+ params[name] -= self.lr * m_hat / (np.sqrt(v_hat) + self.eps)
242
+
243
+
244
+ class PolicyValueNetwork:
245
+ def __init__(self, input_dim: int, action_dim: int, rng: np.random.Generator, hidden_sizes: Sequence[int] = (256, 128)):
246
+ h1, h2 = hidden_sizes
247
+ self.params: Dict[str, np.ndarray] = {
248
+ "w1": self._init_weight(rng, input_dim, h1),
249
+ "b1": np.zeros(h1, dtype=np.float32),
250
+ "w2": self._init_weight(rng, h1, h2),
251
+ "b2": np.zeros(h2, dtype=np.float32),
252
+ "wp": self._init_weight(rng, h2, action_dim),
253
+ "bp": np.zeros(action_dim, dtype=np.float32),
254
+ "wv": self._init_weight(rng, h2, 1),
255
+ "bv": np.zeros(1, dtype=np.float32),
256
+ }
257
+ self.optimizer = AdamOptimizer(self.params)
258
+
259
+ @staticmethod
260
+ def _init_weight(rng: np.random.Generator, in_dim: int, out_dim: int) -> np.ndarray:
261
+ scale = math.sqrt(2.0 / max(1, in_dim + out_dim))
262
+ return (rng.standard_normal((in_dim, out_dim)) * scale).astype(np.float32)
263
+
264
+ def forward(self, x: np.ndarray) -> tuple[np.ndarray, np.ndarray, Dict[str, np.ndarray]]:
265
+ z1 = x @ self.params["w1"] + self.params["b1"]
266
+ h1 = np.tanh(z1)
267
+ z2 = h1 @ self.params["w2"] + self.params["b2"]
268
+ h2 = np.tanh(z2)
269
+ logits = h2 @ self.params["wp"] + self.params["bp"]
270
+ values = (h2 @ self.params["wv"] + self.params["bv"]).reshape(-1)
271
+ cache = {"x": x, "h1": h1, "h2": h2}
272
+ return logits, values, cache
273
+
274
+ def predict(self, x: np.ndarray, mask: np.ndarray) -> tuple[np.ndarray, float]:
275
+ logits, values, _ = self.forward(x[None, :])
276
+ probs = softmax_with_mask(logits, mask[None, :])[0]
277
+ return probs, float(values[0])
278
+
279
+ def update(
280
+ self,
281
+ states: np.ndarray,
282
+ masks: np.ndarray,
283
+ actions: np.ndarray,
284
+ returns: np.ndarray,
285
+ advantages: np.ndarray,
286
+ value_coef: float = 0.5,
287
+ ) -> Dict[str, float]:
288
+ logits, values, cache = self.forward(states)
289
+ probs = softmax_with_mask(logits, masks)
290
+
291
+ batch_size = max(1, states.shape[0])
292
+ grad_logits = probs.copy()
293
+ grad_logits[np.arange(batch_size), actions] -= 1.0
294
+ grad_logits *= advantages[:, None] / batch_size
295
+ grad_logits *= masks
296
+
297
+ grad_values = ((values - returns)[:, None] * value_coef) / batch_size
298
+
299
+ grads: Dict[str, np.ndarray] = {}
300
+ grads["wp"] = cache["h2"].T @ grad_logits
301
+ grads["bp"] = np.sum(grad_logits, axis=0)
302
+ grads["wv"] = cache["h2"].T @ grad_values
303
+ grads["bv"] = np.sum(grad_values, axis=0)
304
+
305
+ dh2 = grad_logits @ self.params["wp"].T + grad_values @ self.params["wv"].T
306
+ dz2 = dh2 * (1.0 - cache["h2"] ** 2)
307
+ grads["w2"] = cache["h1"].T @ dz2
308
+ grads["b2"] = np.sum(dz2, axis=0)
309
+
310
+ dh1 = dz2 @ self.params["w2"].T
311
+ dz1 = dh1 * (1.0 - cache["h1"] ** 2)
312
+ grads["w1"] = cache["x"].T @ dz1
313
+ grads["b1"] = np.sum(dz1, axis=0)
314
+
315
+ self.optimizer.step(self.params, grads, clip_norm=1.0)
316
+
317
+ chosen_probs = np.clip(probs[np.arange(batch_size), actions], 1e-8, 1.0)
318
+ policy_loss = float(-np.mean(advantages * np.log(chosen_probs)))
319
+ value_loss = float(0.5 * np.mean((values - returns) ** 2))
320
+ entropy = float(-np.mean(np.sum(np.where(probs > 0.0, probs * np.log(np.clip(probs, 1e-8, 1.0)), 0.0), axis=1)))
321
+ return {
322
+ "policy_loss": policy_loss,
323
+ "value_loss": value_loss,
324
+ "entropy": entropy,
325
+ "mean_value": float(np.mean(values)),
326
+ }
327
+
328
+ def save(self, path: Path, metadata: Dict[str, object]) -> None:
329
+ path.parent.mkdir(parents=True, exist_ok=True)
330
+ arrays = {name: value for name, value in self.params.items()}
331
+ arrays["metadata_json"] = np.array(json.dumps(metadata))
332
+ np.savez(path, **arrays)
333
+
334
+
335
+ @dataclass
336
+ class Trajectory:
337
+ states: List[np.ndarray]
338
+ masks: List[np.ndarray]
339
+ actions: List[int]
340
+ rewards: List[float]
341
+ values: List[float]
342
+ evacuated: bool
343
+ final_health: float
344
+ steps: int
345
+ total_reward: float
346
+
347
+
348
+ def compute_gae(
349
+ rewards: Sequence[float],
350
+ values: Sequence[float],
351
+ gamma: float,
352
+ gae_lambda: float,
353
+ ) -> tuple[np.ndarray, np.ndarray]:
354
+ rewards_arr = np.asarray(rewards, dtype=np.float32)
355
+ values_arr = np.asarray(values, dtype=np.float32)
356
+ advantages = np.zeros(len(rewards_arr), dtype=np.float32)
357
+ gae = 0.0
358
+ next_value = 0.0
359
+ for i in range(len(rewards_arr) - 1, -1, -1):
360
+ delta = rewards_arr[i] + gamma * next_value - values_arr[i]
361
+ gae = delta + gamma * gae_lambda * gae
362
+ advantages[i] = gae
363
+ next_value = values_arr[i]
364
+ returns = advantages + values_arr
365
+ return returns.astype(np.float32), advantages.astype(np.float32)
366
+
367
+
368
+ def select_action(
369
+ network: PolicyValueNetwork,
370
+ state_vec: np.ndarray,
371
+ mask: np.ndarray,
372
+ rng: np.random.Generator,
373
+ greedy: bool = False,
374
+ ) -> tuple[int, float]:
375
+ probs, value = network.predict(state_vec, mask)
376
+ valid_indices = np.flatnonzero(mask > 0.0)
377
+ if len(valid_indices) == 0:
378
+ return ACTION_TO_INDEX[WAIT_KEY], value
379
+ if greedy:
380
+ best_local = int(np.argmax(probs[valid_indices]))
381
+ return int(valid_indices[best_local]), value
382
+ return int(rng.choice(np.arange(len(probs)), p=probs)), value
383
+
384
+
385
+ def build_stacked_state(frames: deque[np.ndarray]) -> np.ndarray:
386
+ return np.concatenate(list(frames), dtype=np.float32)
387
+
388
+
389
+ def run_episode(
390
+ env: PyreEnvironment,
391
+ network: PolicyValueNetwork,
392
+ encoder: ObservationEncoder,
393
+ rng: np.random.Generator,
394
+ difficulty: str,
395
+ history_length: int,
396
+ greedy: bool = False,
397
+ ) -> Trajectory:
398
+ observation = env.reset(difficulty=difficulty)
399
+ zero_frame = np.zeros(encoder.base_dim, dtype=np.float32)
400
+ frames: deque[np.ndarray] = deque([zero_frame.copy() for _ in range(history_length)], maxlen=history_length)
401
+ frames.append(encoder.encode(observation))
402
+
403
+ states: List[np.ndarray] = []
404
+ masks: List[np.ndarray] = []
405
+ actions: List[int] = []
406
+ rewards: List[float] = []
407
+ values: List[float] = []
408
+
409
+ total_reward = 0.0
410
+ final_health = observation.agent_health
411
+ evacuated = False
412
+ steps = 0
413
+
414
+ while True:
415
+ state_vec = build_stacked_state(frames)
416
+ mask = build_action_mask(observation)
417
+ action_idx, value = select_action(network, state_vec, mask, rng, greedy=greedy)
418
+ action = action_index_to_env_action(action_idx)
419
+
420
+ next_obs = env.step(action)
421
+ reward = float(next_obs.reward or 0.0)
422
+
423
+ states.append(state_vec)
424
+ masks.append(mask)
425
+ actions.append(action_idx)
426
+ rewards.append(reward)
427
+ values.append(value)
428
+
429
+ total_reward += reward
430
+ steps += 1
431
+ final_health = next_obs.agent_health
432
+ evacuated = next_obs.agent_evacuated
433
+
434
+ frames.append(encoder.encode(next_obs))
435
+ observation = next_obs
436
+ if next_obs.done:
437
+ break
438
+
439
+ return Trajectory(
440
+ states=states,
441
+ masks=masks,
442
+ actions=actions,
443
+ rewards=rewards,
444
+ values=values,
445
+ evacuated=evacuated,
446
+ final_health=final_health,
447
+ steps=steps,
448
+ total_reward=total_reward,
449
+ )
450
+
451
+
452
+ def evaluate_policy(
453
+ env: PyreEnvironment,
454
+ network: PolicyValueNetwork,
455
+ encoder: ObservationEncoder,
456
+ rng: np.random.Generator,
457
+ difficulty: str,
458
+ history_length: int,
459
+ episodes: int,
460
+ ) -> Dict[str, float]:
461
+ rewards = []
462
+ evacuations = 0
463
+ lengths = []
464
+ for _ in range(episodes):
465
+ traj = run_episode(env, network, encoder, rng, difficulty, history_length, greedy=True)
466
+ rewards.append(traj.total_reward)
467
+ lengths.append(traj.steps)
468
+ evacuations += int(traj.evacuated)
469
+ return {
470
+ "eval_reward_mean": float(np.mean(rewards)) if rewards else 0.0,
471
+ "eval_reward_max": float(np.max(rewards)) if rewards else 0.0,
472
+ "eval_success_rate": float(evacuations / max(1, episodes)),
473
+ "eval_steps_mean": float(np.mean(lengths)) if lengths else 0.0,
474
+ }
475
+
476
+
477
+ def expand_difficulty_schedule(schedule_text: str, episodes: int) -> List[str]:
478
+ stages = [part.strip().lower() for part in schedule_text.split(",") if part.strip()]
479
+ if not stages:
480
+ stages = ["medium"]
481
+ for stage in stages:
482
+ if stage not in DIFFICULTIES:
483
+ raise ValueError(f"Invalid difficulty in schedule: {stage}")
484
+ segment = max(1, episodes // len(stages))
485
+ expanded: List[str] = []
486
+ for stage in stages:
487
+ expanded.extend([stage] * segment)
488
+ while len(expanded) < episodes:
489
+ expanded.append(stages[-1])
490
+ return expanded[:episodes]
491
+
492
+
493
+ def describe_environment_contract(encoder: ObservationEncoder, history_length: int) -> str:
494
+ action_text = (
495
+ f"Action space has {ACTION_DIM} fixed discrete actions: 4 moves, 4 looks, wait, "
496
+ f"{MAX_DOORS} door-open slots, and {MAX_DOORS} door-close slots. "
497
+ "A per-step mask from `available_actions_hint` prevents invalid actions."
498
+ )
499
+ reward_text = (
500
+ "Reward comes directly from the environment's composite rubric: time penalty, exit progress, "
501
+ "progress regression penalty, safe-progress bonus, danger penalty, health-drain penalty, "
502
+ "strategic door bonus, exploration bonus, plus terminal evacuation/death/timeout/near-miss/time bonuses."
503
+ )
504
+ return "\n".join(
505
+ [
506
+ "Pyre RL contract",
507
+ encoder.describe(history_length),
508
+ action_text,
509
+ reward_text,
510
+ ]
511
+ )
512
+
513
+
514
+ def _moving_average(values: Sequence[float], window: int) -> List[float]:
515
+ if not values:
516
+ return []
517
+ out: List[float] = []
518
+ run = 0.0
519
+ q: deque[float] = deque()
520
+ for value in values:
521
+ q.append(float(value))
522
+ run += float(value)
523
+ if len(q) > window:
524
+ run -= q.popleft()
525
+ out.append(run / len(q))
526
+ return out
527
+
528
+
529
+ def save_metrics_csv(path: Path, rows: List[Dict[str, float | int | str]]) -> None:
530
+ path.parent.mkdir(parents=True, exist_ok=True)
531
+ if not rows:
532
+ return
533
+ with path.open("w", newline="", encoding="utf-8") as f:
534
+ writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
535
+ writer.writeheader()
536
+ writer.writerows(rows)
537
+
538
+
539
+ def save_training_graph(path: Path, episode_rows: List[Dict[str, float | int | str]], eval_rows: List[Dict[str, float | int | str]]) -> None:
540
+ path.parent.mkdir(parents=True, exist_ok=True)
541
+ if not episode_rows:
542
+ return
543
+
544
+ width = 1260
545
+ height = 780
546
+ margin_left = 100 # extra room for rotated Y-axis label + tick values
547
+ margin_right = 110 # extra room for right axis label + tick values
548
+ margin_top = 70 # room for title
549
+ margin_bottom = 90 # room for X-axis label + tick values + legend
550
+ plot_w = width - margin_left - margin_right
551
+ plot_h = height - margin_top - margin_bottom
552
+
553
+ # X: plot_left=100, plot_right=1150 Y: plot_top=70, plot_bottom=690
554
+
555
+ episodes = [int(r["episode"]) for r in episode_rows]
556
+ rewards = [float(r["reward"]) for r in episode_rows]
557
+ reward_ma = _moving_average(rewards, 20)
558
+ success_ma = _moving_average([float(r["evacuated"]) for r in episode_rows], 20)
559
+
560
+ all_reward_values = rewards + reward_ma + [float(r["reward_mean"]) for r in eval_rows] + [float(r["reward_max"]) for r in eval_rows]
561
+ y_min = min(all_reward_values) if all_reward_values else -1.0
562
+ y_max = max(all_reward_values) if all_reward_values else 1.0
563
+ if abs(y_max - y_min) < 1e-6:
564
+ y_min -= 1.0
565
+ y_max += 1.0
566
+ y_pad = 0.1 * (y_max - y_min)
567
+ y_min -= y_pad
568
+ y_max += y_pad
569
+
570
+ max_episode = max(episodes) if episodes else 1
571
+
572
+ plot_left = margin_left
573
+ plot_right = margin_left + plot_w
574
+ plot_top = margin_top
575
+ plot_bottom = margin_top + plot_h
576
+
577
+ def x_pos(ep: float) -> float:
578
+ return plot_left + (float(ep) - 1.0) / max(1.0, max_episode - 1.0) * plot_w
579
+
580
+ def y_pos_reward(value: float) -> float:
581
+ return plot_top + (y_max - float(value)) / max(1e-6, (y_max - y_min)) * plot_h
582
+
583
+ def y_pos_success(value: float) -> float:
584
+ return plot_top + (1.0 - float(value)) * plot_h
585
+
586
+ def polyline(points: List[tuple[float, float]]) -> str:
587
+ return " ".join(f"{x:.1f},{y:.1f}" for x, y in points)
588
+
589
+ reward_points = [(x_pos(ep), y_pos_reward(val)) for ep, val in zip(episodes, rewards)]
590
+ reward_ma_points = [(x_pos(ep), y_pos_reward(val)) for ep, val in zip(episodes, reward_ma)]
591
+ success_points = [(x_pos(ep), y_pos_success(val)) for ep, val in zip(episodes, success_ma)]
592
+ eval_points = [(x_pos(float(r["episode"])), y_pos_success(float(r["success_rate"]))) for r in eval_rows]
593
+
594
+ n_x_ticks = 8
595
+ episode_ticks = sorted(set(
596
+ max(1, round(1 + i * (max_episode - 1) / n_x_ticks))
597
+ for i in range(n_x_ticks + 1)
598
+ ))
599
+ n_y_ticks = 6
600
+ reward_ticks = [y_min + (y_max - y_min) * i / n_y_ticks for i in range(n_y_ticks + 1)]
601
+ success_ticks = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0]
602
+
603
+ svg = []
604
+ svg.append(f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">')
605
+
606
+ # Background
607
+ svg.append('<rect width="100%" height="100%" fill="#f7f5ef"/>')
608
+
609
+ # Title + subtitle
610
+ svg.append(f'<text x="{plot_left}" y="28" font-family="Georgia, serif" font-size="22" font-weight="bold" fill="#1d2a38">Pyre RL Training</text>')
611
+ svg.append(f'<text x="{plot_left}" y="50" font-family="Georgia, serif" font-size="13" fill="#5b6770">Left axis: Reward | Right axis: Success Rate (0–1)</text>')
612
+
613
+ # Plot area background + border
614
+ svg.append(f'<rect x="{plot_left}" y="{plot_top}" width="{plot_w}" height="{plot_h}" fill="#fffdf8" stroke="#b8b0a2" stroke-width="1.5"/>')
615
+
616
+ # ── Vertical grid lines + X-axis ticks ──────────────────────────────────
617
+ for tick in episode_ticks:
618
+ x = x_pos(float(tick))
619
+ # dashed grid line
620
+ svg.append(f'<line x1="{x:.1f}" y1="{plot_top}" x2="{x:.1f}" y2="{plot_bottom}" '
621
+ f'stroke="#d8d2c8" stroke-width="1" stroke-dasharray="4,4"/>')
622
+ # solid tick mark on bottom axis
623
+ svg.append(f'<line x1="{x:.1f}" y1="{plot_bottom}" x2="{x:.1f}" y2="{plot_bottom + 6}" '
624
+ f'stroke="#6b6460" stroke-width="1.5"/>')
625
+ # tick label
626
+ svg.append(f'<text x="{x:.1f}" y="{plot_bottom + 20}" text-anchor="middle" '
627
+ f'font-family="Georgia, serif" font-size="12" fill="#4a4540">{tick}</text>')
628
+
629
+ # X-axis title
630
+ x_title_x = plot_left + plot_w / 2
631
+ x_title_y = plot_bottom + 50
632
+ svg.append(f'<text x="{x_title_x:.1f}" y="{x_title_y}" text-anchor="middle" '
633
+ f'font-family="Georgia, serif" font-size="14" font-weight="bold" fill="#1d2a38">Episode</text>')
634
+
635
+ # ── Horizontal grid lines + Left Y-axis ticks (Reward) ──────────────────
636
+ for tick in reward_ticks:
637
+ y = y_pos_reward(tick)
638
+ # dashed grid line
639
+ svg.append(f'<line x1="{plot_left}" y1="{y:.1f}" x2="{plot_right}" y2="{y:.1f}" '
640
+ f'stroke="#d8d2c8" stroke-width="1" stroke-dasharray="4,4"/>')
641
+ # solid tick mark on left axis
642
+ svg.append(f'<line x1="{plot_left - 6}" y1="{y:.1f}" x2="{plot_left}" y2="{y:.1f}" '
643
+ f'stroke="#6b6460" stroke-width="1.5"/>')
644
+ # tick label
645
+ svg.append(f'<text x="{plot_left - 10}" y="{y + 4:.1f}" text-anchor="end" '
646
+ f'font-family="Georgia, serif" font-size="12" fill="#8a4b08">{tick:.1f}</text>')
647
+
648
+ # Left Y-axis title (rotated) — centered on plot height
649
+ ly_cx = plot_left - 70
650
+ ly_cy = plot_top + plot_h / 2
651
+ svg.append(f'<text transform="rotate(-90, {ly_cx:.1f}, {ly_cy:.1f})" '
652
+ f'x="{ly_cx:.1f}" y="{ly_cy:.1f}" text-anchor="middle" '
653
+ f'font-family="Georgia, serif" font-size="14" font-weight="bold" fill="#8a4b08">Reward</text>')
654
+
655
+ # ── Right Y-axis ticks (Success Rate) ───────────────────────────────────
656
+ for tick in success_ticks:
657
+ y = y_pos_success(tick)
658
+ # solid tick mark on right axis
659
+ svg.append(f'<line x1="{plot_right}" y1="{y:.1f}" x2="{plot_right + 6}" y2="{y:.1f}" '
660
+ f'stroke="#6b6460" stroke-width="1.5"/>')
661
+ # tick label
662
+ svg.append(f'<text x="{plot_right + 12}" y="{y + 4:.1f}" '
663
+ f'font-family="Georgia, serif" font-size="12" fill="#0d5b6b">{tick:.2f}</text>')
664
+
665
+ # Right Y-axis title (rotated)
666
+ ry_cx = plot_right + 85
667
+ ry_cy = plot_top + plot_h / 2
668
+ svg.append(f'<text transform="rotate(90, {ry_cx:.1f}, {ry_cy:.1f})" '
669
+ f'x="{ry_cx:.1f}" y="{ry_cy:.1f}" text-anchor="middle" '
670
+ f'font-family="Georgia, serif" font-size="14" font-weight="bold" fill="#0d5b6b">Success Rate</text>')
671
+
672
+ # ── Axis border lines (solid, on top of grid) ────────────────────────────
673
+ # Bottom axis
674
+ svg.append(f'<line x1="{plot_left}" y1="{plot_bottom}" x2="{plot_right}" y2="{plot_bottom}" '
675
+ f'stroke="#6b6460" stroke-width="2"/>')
676
+ # Left axis
677
+ svg.append(f'<line x1="{plot_left}" y1="{plot_top}" x2="{plot_left}" y2="{plot_bottom}" '
678
+ f'stroke="#6b6460" stroke-width="2"/>')
679
+ # Right axis
680
+ svg.append(f'<line x1="{plot_right}" y1="{plot_top}" x2="{plot_right}" y2="{plot_bottom}" '
681
+ f'stroke="#6b6460" stroke-width="2"/>')
682
+
683
+ # ── Data series ─────────────────────────────────────────────────────────
684
+ # Raw episode reward (faint)
685
+ svg.append(f'<polyline fill="none" stroke="#c5bfb1" stroke-width="1.5" points="{polyline(reward_points)}"/>')
686
+ # Reward moving average
687
+ svg.append(f'<polyline fill="none" stroke="#c1661c" stroke-width="3" stroke-linejoin="round" points="{polyline(reward_ma_points)}"/>')
688
+ # Success moving average
689
+ svg.append(f'<polyline fill="none" stroke="#127a8a" stroke-width="3" stroke-linejoin="round" points="{polyline(success_points)}"/>')
690
+ # Eval checkpoints
691
+ for x, y in eval_points:
692
+ svg.append(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="5" fill="#0d5b6b" stroke="#ffffff" stroke-width="2"/>')
693
+
694
+ # ── Legend ───────────────────────────────────────────────────────────────
695
+ legend_y = plot_bottom + 72
696
+ items = [
697
+ ("#c1661c", 3, False, "Reward (moving avg)"),
698
+ ("#127a8a", 3, False, "Success rate (moving avg)"),
699
+ ("#c5bfb1", 1.5, False, "Episode reward"),
700
+ ("#0d5b6b", 0, True, "Eval success checkpoint"),
701
+ ]
702
+ lx = plot_left
703
+ for color, sw, is_dot, label in items:
704
+ if is_dot:
705
+ svg.append(f'<circle cx="{lx + 15}" cy="{legend_y - 4}" r="5" fill="{color}" stroke="#ffffff" stroke-width="2"/>')
706
+ else:
707
+ svg.append(f'<line x1="{lx}" y1="{legend_y - 4}" x2="{lx + 30}" y2="{legend_y - 4}" stroke="{color}" stroke-width="{sw}"/>')
708
+ svg.append(f'<text x="{lx + 36}" y="{legend_y}" font-family="Georgia, serif" font-size="12" fill="#1d2a38">{label}</text>')
709
+ lx += 230
710
+
711
+ svg.append("</svg>")
712
+ path.write_text("\n".join(svg), encoding="utf-8")
713
+
714
+
715
+ def train(args: argparse.Namespace) -> None:
716
+ rng = np.random.default_rng(args.seed)
717
+ encoder = ObservationEncoder(mode=args.observation_mode)
718
+ difficulty_schedule = expand_difficulty_schedule(args.difficulty_schedule, args.episodes)
719
+ input_dim = encoder.base_dim * args.history_length
720
+ network = PolicyValueNetwork(input_dim=input_dim, action_dim=ACTION_DIM, rng=rng)
721
+ env = PyreEnvironment(max_steps=args.max_steps)
722
+
723
+ print(describe_environment_contract(encoder, args.history_length))
724
+ print("")
725
+
726
+ batch_states: List[np.ndarray] = []
727
+ batch_masks: List[np.ndarray] = []
728
+ batch_actions: List[int] = []
729
+ batch_returns: List[np.ndarray] = []
730
+ batch_advantages: List[np.ndarray] = []
731
+
732
+ reward_window: deque[float] = deque(maxlen=20)
733
+ success_window: deque[float] = deque(maxlen=20)
734
+ episode_metrics: List[Dict[str, float | int | str]] = []
735
+ eval_metrics_rows: List[Dict[str, float | int | str]] = []
736
+
737
+ for episode_idx in range(args.episodes):
738
+ difficulty = difficulty_schedule[episode_idx] if args.difficulty_schedule else args.difficulty
739
+ traj = run_episode(
740
+ env=env,
741
+ network=network,
742
+ encoder=encoder,
743
+ rng=rng,
744
+ difficulty=difficulty,
745
+ history_length=args.history_length,
746
+ greedy=False,
747
+ )
748
+
749
+ returns, advantages = compute_gae(traj.rewards, traj.values, args.gamma, args.gae_lambda)
750
+ batch_states.extend(traj.states)
751
+ batch_masks.extend(traj.masks)
752
+ batch_actions.extend(traj.actions)
753
+ batch_returns.append(returns)
754
+ batch_advantages.append(advantages)
755
+
756
+ reward_window.append(traj.total_reward)
757
+ success_window.append(float(traj.evacuated))
758
+ episode_metrics.append(
759
+ {
760
+ "episode": episode_idx + 1,
761
+ "difficulty": difficulty,
762
+ "reward": round(traj.total_reward, 4),
763
+ "evacuated": int(traj.evacuated),
764
+ "steps": traj.steps,
765
+ "final_health": round(traj.final_health, 2),
766
+ "reward_mean_20": round(float(np.mean(reward_window)), 4),
767
+ "success_rate_20": round(float(np.mean(success_window)), 4),
768
+ }
769
+ )
770
+
771
+ print(
772
+ f"episode={episode_idx + 1:04d} difficulty={difficulty:<6} "
773
+ f"steps={traj.steps:03d} reward={traj.total_reward:+8.3f} "
774
+ f"evacuated={int(traj.evacuated)} health={traj.final_health:6.1f}"
775
+ )
776
+
777
+ should_update = (episode_idx + 1) % args.update_every == 0 or (episode_idx + 1) == args.episodes
778
+ if should_update and batch_states:
779
+ states_arr = np.asarray(batch_states, dtype=np.float32)
780
+ masks_arr = np.asarray(batch_masks, dtype=np.float32)
781
+ actions_arr = np.asarray(batch_actions, dtype=np.int64)
782
+ returns_arr = np.concatenate(batch_returns).astype(np.float32)
783
+ advantages_arr = np.concatenate(batch_advantages).astype(np.float32)
784
+ advantages_arr = (advantages_arr - advantages_arr.mean()) / (advantages_arr.std() + 1e-8)
785
+
786
+ network.optimizer.lr = args.learning_rate
787
+ metrics = {}
788
+ for _ in range(args.update_epochs):
789
+ order = rng.permutation(len(states_arr))
790
+ for start in range(0, len(states_arr), args.minibatch_size):
791
+ idx = order[start:start + args.minibatch_size]
792
+ metrics = network.update(
793
+ states=states_arr[idx],
794
+ masks=masks_arr[idx],
795
+ actions=actions_arr[idx],
796
+ returns=returns_arr[idx],
797
+ advantages=advantages_arr[idx],
798
+ value_coef=args.value_coef,
799
+ )
800
+
801
+ print(
802
+ f"update episodes={episode_idx + 1:04d} samples={len(states_arr):05d} "
803
+ f"reward_mean20={np.mean(reward_window):+8.3f} success20={np.mean(success_window):.2f} "
804
+ f"policy_loss={metrics['policy_loss']:+.4f} value_loss={metrics['value_loss']:.4f} "
805
+ f"entropy={metrics['entropy']:.4f}"
806
+ )
807
+
808
+ batch_states.clear()
809
+ batch_masks.clear()
810
+ batch_actions.clear()
811
+ batch_returns.clear()
812
+ batch_advantages.clear()
813
+
814
+ should_eval = args.eval_every > 0 and ((episode_idx + 1) % args.eval_every == 0 or (episode_idx + 1) == args.episodes)
815
+ if should_eval:
816
+ eval_metrics = evaluate_policy(
817
+ env=env,
818
+ network=network,
819
+ encoder=encoder,
820
+ rng=rng,
821
+ difficulty=args.eval_difficulty,
822
+ history_length=args.history_length,
823
+ episodes=args.eval_episodes,
824
+ )
825
+ print(
826
+ f"eval episodes={episode_idx + 1:04d} difficulty={args.eval_difficulty:<6} "
827
+ f"reward_mean={eval_metrics['eval_reward_mean']:+8.3f} "
828
+ f"reward_max={eval_metrics['eval_reward_max']:+8.3f} "
829
+ f"success={eval_metrics['eval_success_rate']:.2f} "
830
+ f"steps={eval_metrics['eval_steps_mean']:.1f}"
831
+ )
832
+ eval_metrics_rows.append(
833
+ {
834
+ "episode": episode_idx + 1,
835
+ "difficulty": args.eval_difficulty,
836
+ "reward_mean": round(eval_metrics["eval_reward_mean"], 4),
837
+ "reward_max": round(eval_metrics["eval_reward_max"], 4),
838
+ "success_rate": round(eval_metrics["eval_success_rate"], 4),
839
+ "steps_mean": round(eval_metrics["eval_steps_mean"], 4),
840
+ }
841
+ )
842
+
843
+ if args.output:
844
+ output_path = Path(args.output)
845
+ network.save(
846
+ output_path,
847
+ metadata={
848
+ "observation_mode": args.observation_mode,
849
+ "history_length": args.history_length,
850
+ "episodes": args.episodes,
851
+ "difficulty": args.difficulty,
852
+ "difficulty_schedule": args.difficulty_schedule,
853
+ "gamma": args.gamma,
854
+ "gae_lambda": args.gae_lambda,
855
+ "learning_rate": args.learning_rate,
856
+ "update_epochs": args.update_epochs,
857
+ "minibatch_size": args.minibatch_size,
858
+ "action_dim": ACTION_DIM,
859
+ "input_dim": input_dim,
860
+ },
861
+ )
862
+ print(f"saved model={output_path}")
863
+ if args.save_metrics:
864
+ metrics_path = output_path.with_suffix(".csv")
865
+ save_metrics_csv(metrics_path, episode_metrics)
866
+ print(f"saved metrics={metrics_path}")
867
+ if args.save_graph:
868
+ graph_path = output_path.with_suffix(".svg")
869
+ save_training_graph(graph_path, episode_metrics, eval_metrics_rows)
870
+ print(f"saved graph={graph_path}")
871
+ # Also save PNG
872
+ try:
873
+ import matplotlib
874
+ matplotlib.use("Agg")
875
+ import matplotlib.pyplot as plt
876
+ import matplotlib.ticker as mticker
877
+ import matplotlib.patches as mpatches
878
+
879
+ episodes_list = [int(r["episode"]) for r in episode_metrics]
880
+ rewards_list = [float(r["reward"]) for r in episode_metrics]
881
+ evacuated_list = [float(r["evacuated"]) for r in episode_metrics]
882
+ diff_list = [str(r["difficulty"]) for r in episode_metrics]
883
+
884
+ def _ma(vals, w=20):
885
+ out, run, q = [], 0.0, []
886
+ for v in vals:
887
+ q.append(v); run += v
888
+ if len(q) > w: run -= q.pop(0)
889
+ out.append(run / len(q))
890
+ return out
891
+
892
+ reward_ma = _ma(rewards_list)
893
+ success_ma = _ma(evacuated_list)
894
+ eval_eps = [int(r["episode"]) for r in eval_metrics_rows]
895
+ eval_succ = [float(r["success_rate"]) for r in eval_metrics_rows]
896
+
897
+ diff_colors = {"easy": "#d4edda", "medium": "#fff3cd", "hard": "#f8d7da"}
898
+ regions = []
899
+ if diff_list:
900
+ cur, start = diff_list[0], episodes_list[0]
901
+ for ep, d in zip(episodes_list[1:], diff_list[1:]):
902
+ if d != cur:
903
+ regions.append((start, ep, cur)); cur, start = d, ep
904
+ regions.append((start, episodes_list[-1], cur))
905
+
906
+ fig, ax1 = plt.subplots(figsize=(14, 6))
907
+ ax2 = ax1.twinx()
908
+ for x0, x1, diff in regions:
909
+ ax1.axvspan(x0, x1, color=diff_colors.get(diff, "#eeeeee"), alpha=0.35, zorder=0)
910
+ ax1.axhline(0, color="#aaaaaa", linewidth=0.8, linestyle="--", zorder=1)
911
+ ax1.plot(episodes_list, rewards_list, color="#d1c7bc", linewidth=0.8, alpha=0.6, label="Episode reward", zorder=2)
912
+ ax1.plot(episodes_list, reward_ma, color="#c1661c", linewidth=2.5, label="Reward (MA-20)", zorder=3)
913
+ ax2.plot(episodes_list, success_ma, color="#1a7a8a", linewidth=2.5, label="Success rate (MA-20)", zorder=3)
914
+ if eval_eps:
915
+ ax2.scatter(eval_eps, eval_succ, color="#0d5b6b", s=60, zorder=5, marker="D", edgecolors="white", linewidths=1.2, label="Eval success")
916
+ ax1.set_xlabel("Episode", fontsize=13, fontweight="bold", labelpad=8)
917
+ ax1.set_ylabel("Reward", fontsize=13, fontweight="bold", color="#c1661c", labelpad=8)
918
+ ax2.set_ylabel("Success Rate", fontsize=13, fontweight="bold", color="#1a7a8a", labelpad=8)
919
+ ax1.tick_params(axis="y", labelcolor="#c1661c")
920
+ ax2.tick_params(axis="y", labelcolor="#1a7a8a")
921
+ ax2.set_ylim(-0.05, 1.05)
922
+ ax2.yaxis.set_major_formatter(mticker.PercentFormatter(xmax=1.0, decimals=0))
923
+ ax1.grid(True, linestyle="--", linewidth=0.6, color="#dddddd", alpha=0.8)
924
+ ax1.set_xlim(episodes_list[0], episodes_list[-1])
925
+ diff_patches = [mpatches.Patch(color=diff_colors[d], alpha=0.6, label=d.capitalize())
926
+ for d in ["easy", "medium", "hard"] if d in diff_list]
927
+ h1, l1 = ax1.get_legend_handles_labels()
928
+ h2, l2 = ax2.get_legend_handles_labels()
929
+ ax1.legend(h1 + h2 + diff_patches, l1 + l2 + [p.get_label() for p in diff_patches],
930
+ loc="upper left", fontsize=9, framealpha=0.85)
931
+ final_sr = success_ma[-1] if success_ma else 0.0
932
+ fig.suptitle(f"Pyre NumPy A2C Training — {episodes_list[-1]} episodes | final success: {final_sr:.0%}",
933
+ fontsize=14, fontweight="bold", y=1.01)
934
+ fig.tight_layout()
935
+ png_path = output_path.with_suffix(".png")
936
+ fig.savefig(png_path, dpi=150, bbox_inches="tight")
937
+ plt.close(fig)
938
+ print(f"saved graph_png={png_path}")
939
+ except ImportError:
940
+ pass
941
+
942
+
943
+ def parse_args() -> argparse.Namespace:
944
+ parser = argparse.ArgumentParser(description="Train a NumPy actor-critic baseline for Pyre.")
945
+ parser.add_argument("--episodes", type=int, default=120, help="Training episodes.")
946
+ parser.add_argument("--difficulty", type=str, default="easy", choices=DIFFICULTIES)
947
+ parser.add_argument(
948
+ "--difficulty-schedule",
949
+ type=str,
950
+ default="easy,medium",
951
+ help="Comma-separated curriculum, expanded evenly across episodes.",
952
+ )
953
+ parser.add_argument("--eval-difficulty", type=str, default="medium", choices=DIFFICULTIES)
954
+ parser.add_argument("--eval-episodes", type=int, default=5)
955
+ parser.add_argument("--eval-every", type=int, default=20)
956
+ parser.add_argument("--update-every", type=int, default=5, help="Episodes per policy update.")
957
+ parser.add_argument("--update-epochs", type=int, default=3, help="Gradient passes over each on-policy batch.")
958
+ parser.add_argument("--minibatch-size", type=int, default=256, help="Samples per gradient step.")
959
+ parser.add_argument("--gamma", type=float, default=0.99)
960
+ parser.add_argument("--gae-lambda", type=float, default=0.95)
961
+ parser.add_argument("--learning-rate", type=float, default=3e-4)
962
+ parser.add_argument("--value-coef", type=float, default=0.5)
963
+ parser.add_argument("--history-length", type=int, default=4)
964
+ parser.add_argument("--max-steps", type=int, default=150)
965
+ parser.add_argument("--seed", type=int, default=7)
966
+ parser.add_argument("--observation-mode", type=str, default="visible", choices=("visible", "full"))
967
+ parser.add_argument("--output", type=str, default="artifacts/pyre_actor_critic.npz")
968
+ parser.add_argument("--save-metrics", action="store_true", help="Save per-episode metrics as CSV beside the model.")
969
+ parser.add_argument("--save-graph", action="store_true", help="Save an SVG training graph beside the model.")
970
+ parser.add_argument("--describe-only", action="store_true", help="Print observation/action/reward definitions and exit.")
971
+ return parser.parse_args()
972
+
973
+
974
+ def main() -> None:
975
+ args = parse_args()
976
+ encoder = ObservationEncoder(mode=args.observation_mode)
977
+ if args.describe_only:
978
+ print(describe_environment_contract(encoder, args.history_length))
979
+ return
980
+ train(args)
981
+
982
+
983
+ if __name__ == "__main__":
984
+ main()
examples/train_sb3_agent.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import os
3
+ import types
4
+
5
+ # Windows AppControl can block matplotlib's compiled C extensions.
6
+ # Stub the minimal surface that stable_baselines3.common.logger imports
7
+ # at module level so SB3 loads cleanly even without a working matplotlib.
8
+ def _stub_matplotlib():
9
+ if "matplotlib" in sys.modules:
10
+ return
11
+ _mpl = types.ModuleType("matplotlib")
12
+ _mpl.figure = types.ModuleType("matplotlib.figure")
13
+ _mpl.figure.Figure = object
14
+ _mpl.use = lambda *a, **kw: None
15
+ _mpl.__version__ = "0.0.0"
16
+ sys.modules["matplotlib"] = _mpl
17
+ sys.modules["matplotlib.figure"] = _mpl.figure
18
+ for sub in ("matplotlib.pyplot", "matplotlib.ticker", "matplotlib.patches",
19
+ "matplotlib.gridspec", "matplotlib.colors", "matplotlib.cm",
20
+ "matplotlib.backend_bases", "matplotlib.backends",
21
+ "matplotlib.backends.backend_agg"):
22
+ m = types.ModuleType(sub)
23
+ sys.modules[sub] = m
24
+
25
+ _stub_matplotlib()
26
+
27
+ import gymnasium as gym
28
+ import numpy as np
29
+ from gymnasium import spaces
30
+ from pyre_env.models import PyreAction, PyreObservation
31
+ from pyre_env.server.pyre_env_environment import PyreEnvironment
32
+ import torch as th
33
+ sys.path.append(os.getcwd())
34
+
35
+ class PyreGymEnv(gym.Env):
36
+ """Gymnasium wrapper for PyreEnvironment."""
37
+
38
+ def __init__(self, difficulty="easy", max_steps=150, observation_mode="visible"):
39
+ super().__init__()
40
+ self.env = PyreEnvironment(max_steps=max_steps)
41
+ self.difficulty = difficulty
42
+ self.observation_mode = observation_mode
43
+
44
+ # Action space:
45
+ # 0-3: Move (N, S, W, E)
46
+ # 4-7: Look (N, S, W, E)
47
+ # 8: Wait
48
+ # 9-24: Open Door 1-16
49
+ # 25-40: Close Door 1-16
50
+ self.action_space = spaces.Discrete(41)
51
+
52
+ # Observation space: Multi-input
53
+ # 1. Grid: 24x24x7 (Floor, Wall, Door_Open, Door_Closed, Exit, Obstacle, Fire, Smoke)
54
+ # 2. Global: [health, oxygen, step_progress, fire_spread, humidity, agent_x, agent_y, nearest_exit_dist, is_coughing]
55
+ # 3. Heat Sensor: 3x3
56
+ self.observation_space = spaces.Dict({
57
+ "grid": spaces.Box(low=0, high=1, shape=(7, 24, 24), dtype=np.float32),
58
+ "global": spaces.Box(low=0, high=1, shape=(9,), dtype=np.float32),
59
+ "heat": spaces.Box(low=0, high=1, shape=(1, 3, 3), dtype=np.float32)
60
+ })
61
+
62
+ def _get_obs(self, pyre_obs: PyreObservation):
63
+ map_state = pyre_obs.map_state
64
+ w, h = map_state.grid_w, map_state.grid_h
65
+
66
+ # Build 7-channel grid
67
+ # Channels: 0:Wall, 1:Door_Open, 2:Door_Closed, 3:Exit, 4:Obstacle, 5:Fire, 6:Smoke
68
+ # (Floor is implicit as all zeros in other channels)
69
+ grid = np.zeros((7, 24, 24), dtype=np.float32)
70
+
71
+ visible = {(x, y) for x, y in map_state.visible_cells}
72
+ for y in range(h):
73
+ for x in range(w):
74
+ if self.observation_mode == "visible" and (x, y) not in visible and (x, y) != (map_state.agent_x, map_state.agent_y):
75
+ continue
76
+
77
+ i = y * w + x
78
+ ct = map_state.cell_grid[i]
79
+ if ct == 1: grid[0, y, x] = 1.0 # Wall
80
+ elif ct == 2: grid[1, y, x] = 1.0 # Door Open
81
+ elif ct == 3: grid[2, y, x] = 1.0 # Door Closed
82
+ elif ct == 4: grid[3, y, x] = 1.0 # Exit
83
+ elif ct == 5: grid[4, y, x] = 1.0 # Obstacle
84
+
85
+ grid[5, y, x] = float(map_state.fire_grid[i])
86
+ grid[6, y, x] = float(map_state.smoke_grid[i])
87
+
88
+ # Global features
89
+ metadata = pyre_obs.metadata or {}
90
+ nearest_exit = float(metadata.get("nearest_exit_distance", 48) or 48.0) / 48.0
91
+ # smoke_level → is_coughing proxy (moderate/heavy smoke = coughing)
92
+ smoke = getattr(pyre_obs, "smoke_level", "none") or "none"
93
+ is_coughing = 1.0 if smoke in ("moderate", "heavy") else 0.0
94
+
95
+ global_feats = np.array([
96
+ float(pyre_obs.agent_health) / 100.0,
97
+ float(pyre_obs.agent_health) / 100.0, # oxygen_level proxy
98
+ float(map_state.step_count) / float(map_state.max_steps),
99
+ float(map_state.fire_spread_rate),
100
+ float(map_state.humidity),
101
+ float(map_state.agent_x) / 24.0,
102
+ float(map_state.agent_y) / 24.0,
103
+ nearest_exit,
104
+ is_coughing,
105
+ ], dtype=np.float32)
106
+
107
+ # Heat sensor — derive 3×3 fire neighbourhood around agent from the fire grid
108
+ ax, ay = map_state.agent_x, map_state.agent_y
109
+ gw, gh = map_state.grid_w, map_state.grid_h
110
+ heat_vals = []
111
+ for dy in (-1, 0, 1):
112
+ for dx in (-1, 0, 1):
113
+ nx, ny = ax + dx, ay + dy
114
+ if 0 <= nx < gw and 0 <= ny < gh:
115
+ heat_vals.append(float(map_state.fire_grid[ny * gw + nx]))
116
+ else:
117
+ heat_vals.append(0.0)
118
+ heat = np.array(heat_vals, dtype=np.float32).reshape(1, 3, 3)
119
+
120
+ return {
121
+ "grid": grid,
122
+ "global": global_feats,
123
+ "heat": heat
124
+ }
125
+
126
+ def reset(self, seed=None, options=None):
127
+ super().reset(seed=seed)
128
+ difficulty = options.get("difficulty", self.difficulty) if options else self.difficulty
129
+ pyre_obs = self.env.reset(seed=seed, difficulty=difficulty)
130
+ return self._get_obs(pyre_obs), {}
131
+
132
+ def step(self, action_idx):
133
+ # Map Discrete action to PyreAction
134
+ if action_idx < 4:
135
+ dirs = ["north", "south", "west", "east"]
136
+ action = PyreAction(action="move", direction=dirs[action_idx])
137
+ elif action_idx < 8:
138
+ dirs = ["north", "south", "west", "east"]
139
+ action = PyreAction(action="look", direction=dirs[action_idx - 4])
140
+ elif action_idx == 8:
141
+ action = PyreAction(action="wait")
142
+ elif action_idx < 9 + 16:
143
+ action = PyreAction(action="door", target_id=f"door_{action_idx - 8}", door_state="open")
144
+ else:
145
+ action = PyreAction(action="door", target_id=f"door_{action_idx - 24}", door_state="close")
146
+
147
+ pyre_obs = self.env.step(action)
148
+
149
+ obs = self._get_obs(pyre_obs)
150
+ reward = pyre_obs.reward
151
+ terminated = pyre_obs.done
152
+ truncated = False # Step limit handled by env.done
153
+
154
+ return obs, reward, terminated, truncated, {"pyre_obs": pyre_obs}
155
+
156
+ if __name__ == "__main__":
157
+ from stable_baselines3 import PPO
158
+ from stable_baselines3.common.callbacks import CheckpointCallback
159
+ import argparse
160
+
161
+ parser = argparse.ArgumentParser()
162
+ parser.add_argument("--episodes", type=int, default=1500, help="Total episodes to train across all levels")
163
+ parser.add_argument("--difficulty", type=str, default="curriculum", help="easy, medium, hard, random, or curriculum")
164
+ parser.add_argument("--output", type=str, default="artifacts/ppo_pyre_multilevel")
165
+ args = parser.parse_args()
166
+
167
+ from gymnasium.wrappers import RecordEpisodeStatistics
168
+
169
+ # Custom wrapper to handle difficulty changes
170
+ class MultiLevelWrapper(gym.Wrapper):
171
+ def __init__(self, env, mode="curriculum"):
172
+ super().__init__(env)
173
+ self.mode = mode
174
+ self.current_difficulty = "easy"
175
+ self.step_count = 0
176
+ self.total_steps = 0
177
+
178
+ def reset(self, **kwargs):
179
+ if self.mode == "random":
180
+ self.current_difficulty = np.random.choice(["easy", "medium", "hard"])
181
+ elif self.mode == "curriculum":
182
+ if self.total_steps < 0.33 * total_training_steps:
183
+ self.current_difficulty = "easy"
184
+ elif self.total_steps < 0.66 * total_training_steps:
185
+ self.current_difficulty = "medium"
186
+ else:
187
+ self.current_difficulty = "hard"
188
+ else:
189
+ self.current_difficulty = self.mode
190
+
191
+ # Extract options from kwargs if present, or create new
192
+ options = kwargs.get("options")
193
+ if options is None:
194
+ options = {}
195
+ options["difficulty"] = self.current_difficulty
196
+ kwargs["options"] = options
197
+
198
+ return self.env.reset(**kwargs)
199
+
200
+ def step(self, action):
201
+ obs, reward, term, trunc, info = self.env.step(action)
202
+ self.total_steps += 1
203
+ info["difficulty"] = self.current_difficulty
204
+ return obs, reward, term, trunc, info
205
+
206
+ total_training_steps = args.episodes * 60
207
+
208
+ env = PyreGymEnv(difficulty="easy") # Base difficulty
209
+ env = MultiLevelWrapper(env, mode=args.difficulty)
210
+ env = RecordEpisodeStatistics(env)
211
+
212
+ # Custom CNN policy for the grid
213
+ # Increased network capacity for multiple levels
214
+ policy_kwargs = dict(
215
+ activation_fn=th.nn.ReLU,
216
+ net_arch=dict(pi=[256, 128], qf=[256, 128])
217
+ )
218
+
219
+ model = PPO(
220
+ "MultiInputPolicy",
221
+ env,
222
+ verbose=1,
223
+ learning_rate=2e-4, # Slightly lower LR for stability across levels
224
+ n_steps=2048,
225
+ batch_size=128,
226
+ n_epochs=10,
227
+ gamma=0.99,
228
+ gae_lambda=0.95,
229
+ clip_range=0.2,
230
+ ent_coef=0.02, # Higher entropy to encourage exploration in procedural maps
231
+ )
232
+
233
+ print(f"Starting multi-level training (mode: {args.difficulty})...")
234
+
235
+ # Add a simple callback to log episode rewards to a CSV
236
+ from stable_baselines3.common.callbacks import BaseCallback
237
+ import csv
238
+ from pathlib import Path
239
+
240
+ class CSVLogCallback(BaseCallback):
241
+ def __init__(self, filename):
242
+ super().__init__()
243
+ self.filename = filename
244
+ self.results = []
245
+ def _on_step(self):
246
+ # Check every step for finished episodes
247
+ for info in self.locals.get("infos", []):
248
+ if "episode" in info:
249
+ self.results.append({
250
+ "step": self.num_timesteps,
251
+ "reward": info["episode"]["r"],
252
+ "length": info["episode"]["l"]
253
+ })
254
+ return True
255
+ def _on_rollout_end(self):
256
+ # Save every rollout
257
+ if self.results:
258
+ with open(self.filename, "w", newline="") as f:
259
+ writer = csv.DictWriter(f, fieldnames=["step", "reward", "length"])
260
+ writer.writeheader()
261
+ writer.writerows(self.results)
262
+ return True
263
+
264
+ csv_path = args.output + ".csv"
265
+ callback = CSVLogCallback(csv_path)
266
+
267
+ # CNN MultiInputPolicy needs far more steps than a flat MLP to warm up.
268
+ # episodes * 50 ≈ 15k steps (too few). Use episodes * 500 for meaningful learning.
269
+ model.learn(total_timesteps=args.episodes * 500, callback=callback)
270
+
271
+ model.save(args.output)
272
+ print(f"Model saved to {args.output}")
273
+ print(f"Metrics saved to {csv_path}")
274
+
275
+ # Generate a quick SVG graph if we have results
276
+ if callback.results:
277
+ try:
278
+ from examples.train_rl_agent import save_training_graph
279
+ # Mocking the row format expected by the baseline plotter
280
+ rows = [{"episode": i, "reward": r["reward"], "evacuated": 0} for i, r in enumerate(callback.results)]
281
+ save_training_graph(Path(args.output + ".svg"), rows, [])
282
+ print(f"Graph saved to {args.output}.svg")
283
+ except Exception as e:
284
+ print(f"Could not generate SVG automatically: {e}")
285
+ print("CSV is available at " + csv_path)
examples/train_torch_ppo.py ADDED
@@ -0,0 +1,1278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PyTorch PPO Agent for Pyre — Fire Evacuation RL Training Script.
3
+
4
+ === ENVIRONMENT SUMMARY ===
5
+ Pyre is a partial-observability crisis navigation environment:
6
+ - Grid: 16×16 (easy/medium) or 20×24 (hard, procedural)
7
+ - Agent: Spawns inside a burning building, must evacuate before dying
8
+ - Fire: Spreads via cellular automaton — wind, humidity, fuel vary per episode
9
+ - Partial observability: visibility radius (2–5 cells) shrinks in heavy smoke
10
+ - Doors: Can be opened/closed to slow fire spread (+0.5 strategic door bonus)
11
+ - Health: 100 HP, drains from smoke (0.5–5/step) and fire (10/step)
12
+
13
+ === ACTION SPACE (41 discrete) ===
14
+ 0–3 : move(north|south|west|east)
15
+ 4–7 : look(north|south|west|east) — scan without moving, still costs a step
16
+ 8 : wait()
17
+ 9–24 : door(door_1..16, open)
18
+ 25–40 : door(door_1..16, close)
19
+ Runtime action masking via `available_actions_hint` prevents invalid moves.
20
+
21
+ === OBSERVATION ENCODING ===
22
+ Per-step grid: 24×24 padded map × 10 channels
23
+ • 6 one-hot cell type (floor/wall/door_open/door_closed/exit/obstacle)
24
+ • fire intensity [0, 1]
25
+ • smoke density [0, 1]
26
+ • visibility mask (1=visible, 0=unseen)
27
+ • agent position mask
28
+ Global scalars (22): health, step_progress, fire_spread, humidity,
29
+ agent_x, agent_y, exit_distance, reachable_exits, visible_cells,
30
+ fire_sources, smoke_severity, alive, evacuated, wind (one-hot 5), difficulty (one-hot 3)
31
+ Frame stacking: 4 consecutive frames → input_dim = 5782 × 4 = 23128
32
+
33
+ === REWARD STRUCTURE ===
34
+ Per-step:
35
+ -0.01 time penalty (urgency)
36
+ +0.10 BFS progress toward nearest unblocked exit
37
+ -0.05 regression (moved farther from exit)
38
+ +0.05 safe-progress bonus (progress through smoke-free cell)
39
+ -0.50 danger penalty (moved into smoke≥moderate or fire-adjacent)
40
+ -0.02×dmg health drain penalty
41
+ +0.50 strategic door close (adjacent to fire, once per door per episode)
42
+ +0.02 exploration bonus (first visit to cell)
43
+ Terminal:
44
+ +5.00 evacuation success
45
+ +1.50×(hp/100) health survival bonus (max +1.5)
46
+ -10.0 death
47
+ -5.00 timeout
48
+ 0→+3.0 near-miss partial credit (based on closest exit approach)
49
+ +0.05×remaining_steps time bonus
50
+
51
+ === ALGORITHM: PPO (Proximal Policy Optimization) ===
52
+ WHY PPO over alternatives:
53
+ • DQN — Off-policy, harder credit assignment for sparse terminal rewards; no clean action masking
54
+ • A2C — Simpler but no clipping → unstable on hard stochastic episodes
55
+ • SAC — Designed for continuous spaces; discrete SAC works but adds complexity
56
+ • LSTM-PPO — Better for fully text-only obs; grid map_state already encodes spatial state
57
+ → PPO + frame-stack + action-mask hits the sweet spot for this env
58
+
59
+ Key PPO improvements over the existing NumPy A2C (train_rl_agent.py):
60
+ ✓ PPO clip (ε=0.2) prevents catastrophic updates
61
+ ✓ Entropy regularization sustains exploration in smoke-obscured corridors
62
+ ✓ Value function clipping stabilises critic under sparse terminal rewards
63
+ ✓ GPU acceleration 10–20× faster than NumPy baseline
64
+ ✓ LayerNorm in network improves gradient flow for large input dims
65
+ ✓ Linear LR decay stabilises late-stage convergence
66
+ ✓ Better curriculum 3-stage easy→medium→hard with patience gating
67
+
68
+ Usage:
69
+ python examples/train_torch_ppo.py --episodes 500 --device cuda
70
+ python examples/train_torch_ppo.py --episodes 300 --difficulty-schedule easy,medium,hard
71
+ python examples/train_torch_ppo.py --resume artifacts/pyre_ppo_checkpoint.pt
72
+ python examples/train_torch_ppo.py --describe-only
73
+ """
74
+
75
+ from __future__ import annotations
76
+
77
+ import argparse
78
+ import csv
79
+ import json
80
+ import os
81
+ import sys
82
+ import time
83
+ from collections import deque
84
+ from dataclasses import dataclass, field
85
+ from pathlib import Path
86
+ from typing import Dict, List, Optional, Sequence, Tuple
87
+
88
+ import numpy as np
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Optional torch import — fail fast with a helpful message
92
+ # ---------------------------------------------------------------------------
93
+ try:
94
+ import torch
95
+ import torch.nn as nn
96
+ import torch.nn.functional as F
97
+ from torch.optim import Adam
98
+ from torch.optim.lr_scheduler import LinearLR
99
+ except ImportError:
100
+ sys.exit(
101
+ "PyTorch not found. Install with:\n"
102
+ " pip install torch --index-url https://download.pytorch.org/whl/cu121\n"
103
+ "or for CPU only:\n"
104
+ " pip install torch"
105
+ )
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Project imports — support both package install and direct run from root
109
+ # ---------------------------------------------------------------------------
110
+ _ROOT = Path(__file__).resolve().parent.parent
111
+ if str(_ROOT) not in sys.path:
112
+ sys.path.insert(0, str(_ROOT))
113
+
114
+ try:
115
+ from pyre_env.models import PyreAction, PyreObservation
116
+ from pyre_env.server.pyre_env_environment import PyreEnvironment
117
+ except ModuleNotFoundError:
118
+ try:
119
+ from models import PyreAction, PyreObservation
120
+ from server.pyre_env_environment import PyreEnvironment
121
+ except ModuleNotFoundError:
122
+ sys.exit(
123
+ "Cannot import Pyre modules. Run this script from the openenv-pyre root:\n"
124
+ " python examples/train_torch_ppo.py"
125
+ )
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Reuse the established observation/action interface from train_rl_agent.py
129
+ # These are the canonical definitions for this environment.
130
+ # ---------------------------------------------------------------------------
131
+ MAX_GRID_W = 24
132
+ MAX_GRID_H = 24
133
+ MAX_DOORS = 16
134
+ DIRECTIONS = ("north", "south", "west", "east")
135
+ WINDS = ("CALM", "NORTH", "SOUTH", "WEST", "EAST")
136
+ DIFFICULTIES = ("easy", "medium", "hard")
137
+
138
+ MOVE_KEYS = [f"move(direction='{d}')" for d in DIRECTIONS]
139
+ LOOK_KEYS = [f"look(direction='{d}')" for d in DIRECTIONS]
140
+ WAIT_KEY = "wait()"
141
+ OPEN_KEYS = [f"door(target_id='door_{i}', door_state='open')" for i in range(1, MAX_DOORS + 1)]
142
+ CLOSE_KEYS = [f"door(target_id='door_{i}', door_state='close')" for i in range(1, MAX_DOORS + 1)]
143
+ ACTION_KEYS = MOVE_KEYS + LOOK_KEYS + [WAIT_KEY] + OPEN_KEYS + CLOSE_KEYS
144
+ ACTION_DIM = len(ACTION_KEYS) # 41
145
+ ACTION_TO_INDEX = {key: idx for idx, key in enumerate(ACTION_KEYS)}
146
+
147
+ import re
148
+ _MOVE_RE = re.compile(r"move\(direction='(north|south|west|east)'\)")
149
+ _LOOK_RE = re.compile(r"look\(direction='(north|south|west|east)'\)")
150
+ _DOOR_RE = re.compile(r"door\(target_id='(door_(\d+))', door_state='(open|close)'\)")
151
+
152
+
153
+ def action_index_to_env_action(index: int) -> PyreAction:
154
+ if 0 <= index < 4:
155
+ return PyreAction(action="move", direction=DIRECTIONS[index])
156
+ if 4 <= index < 8:
157
+ return PyreAction(action="look", direction=DIRECTIONS[index - 4])
158
+ if index == 8:
159
+ return PyreAction(action="wait")
160
+ if 9 <= index < 9 + MAX_DOORS:
161
+ door_id = f"door_{index - 8}"
162
+ return PyreAction(action="door", target_id=door_id, door_state="open")
163
+ door_slot = index - (9 + MAX_DOORS)
164
+ door_id = f"door_{door_slot + 1}"
165
+ return PyreAction(action="door", target_id=door_id, door_state="close")
166
+
167
+
168
+ def build_action_mask(observation: PyreObservation, exclude_look: bool = True) -> np.ndarray:
169
+ """Build a binary validity mask over the 41-action space.
170
+
171
+ exclude_look=True (default for RL):
172
+ Suppresses all 4 'look' actions. The RL agent already receives the full
173
+ grid via map_state — look gives zero new information but wastes a step
174
+ and earns no reward. Excluding it concentrates the policy on moves and
175
+ doors, which are the only actions that can improve the agent's position.
176
+ """
177
+ mask = np.zeros(ACTION_DIM, dtype=np.float32)
178
+ for hint in observation.available_actions_hint:
179
+ idx = ACTION_TO_INDEX.get(hint)
180
+ if idx is not None:
181
+ mask[idx] = 1.0
182
+ continue
183
+ m = _MOVE_RE.fullmatch(hint)
184
+ if m:
185
+ mask[ACTION_TO_INDEX[f"move(direction='{m.group(1)}')"]] = 1.0
186
+ continue
187
+ m = _LOOK_RE.fullmatch(hint)
188
+ if m:
189
+ if not exclude_look:
190
+ mask[ACTION_TO_INDEX[f"look(direction='{m.group(1)}')"]] = 1.0
191
+ continue
192
+ m = _DOOR_RE.fullmatch(hint)
193
+ if m:
194
+ door_id, door_num, state = m.group(1), int(m.group(2)), m.group(3)
195
+ if 1 <= door_num <= MAX_DOORS:
196
+ mask[ACTION_TO_INDEX[f"door(target_id='{door_id}', door_state='{state}')"]] = 1.0
197
+ if mask.sum() == 0:
198
+ mask[ACTION_TO_INDEX[WAIT_KEY]] = 1.0
199
+ return mask
200
+
201
+
202
+ class ObservationEncoder:
203
+ """Encode PyreObservation into a fixed-length float32 vector.
204
+
205
+ Mode 'visible': only populate cells within the agent's sight radius —
206
+ mimics true partial observability; preferred for training.
207
+ Mode 'full': expose complete ground-truth grid — useful for debugging
208
+ or oracle upper-bound experiments.
209
+
210
+ Output shape: (base_dim,) = (MAX_GRID_W × MAX_GRID_H × 10 + 25,) = (5785,)
211
+ With history stacking of k frames: (5785 × k,)
212
+
213
+ The 3 extra scalars over the v1 baseline are map-agnostic exit-compass
214
+ features (Fix 3): exit_dx_norm, exit_dy_norm, exit_manhattan_norm.
215
+ These allow the agent to locate the nearest exit on procedurally generated
216
+ maps without having to memorise layout-specific coordinates.
217
+ """
218
+
219
+ base_dim = MAX_GRID_W * MAX_GRID_H * 10 + 25
220
+
221
+ def __init__(self, mode: str = "visible"):
222
+ if mode not in {"visible", "full"}:
223
+ raise ValueError(f"mode must be 'visible' or 'full', got '{mode}'")
224
+ self.mode = mode
225
+
226
+ def encode(self, observation: PyreObservation) -> np.ndarray:
227
+ ms = observation.map_state
228
+ if ms is None:
229
+ raise ValueError("map_state is required for encoding.")
230
+
231
+ cell_one_hot = np.zeros((MAX_GRID_H, MAX_GRID_W, 6), dtype=np.float32)
232
+ fire_ch = np.zeros((MAX_GRID_H, MAX_GRID_W), dtype=np.float32)
233
+ smoke_ch = np.zeros((MAX_GRID_H, MAX_GRID_W), dtype=np.float32)
234
+ vis_ch = np.zeros((MAX_GRID_H, MAX_GRID_W), dtype=np.float32)
235
+ agent_ch = np.zeros((MAX_GRID_H, MAX_GRID_W), dtype=np.float32)
236
+
237
+ visible = {(x, y) for x, y in ms.visible_cells}
238
+ for y in range(ms.grid_h):
239
+ for x in range(ms.grid_w):
240
+ if self.mode == "visible" and (x, y) not in visible and (x, y) != (ms.agent_x, ms.agent_y):
241
+ continue
242
+ i = y * ms.grid_w + x
243
+ ct = int(ms.cell_grid[i])
244
+ if 0 <= ct <= 5:
245
+ cell_one_hot[y, x, ct] = 1.0
246
+ fire_ch[y, x] = float(ms.fire_grid[i])
247
+ smoke_ch[y, x] = float(ms.smoke_grid[i])
248
+ vis_ch[y, x] = 1.0 if (x, y) in visible else 0.0
249
+
250
+ if 0 <= ms.agent_x < MAX_GRID_W and 0 <= ms.agent_y < MAX_GRID_H:
251
+ agent_ch[ms.agent_y, ms.agent_x] = 1.0
252
+
253
+ grid_features = np.concatenate([
254
+ cell_one_hot.reshape(-1),
255
+ fire_ch.reshape(-1),
256
+ smoke_ch.reshape(-1),
257
+ vis_ch.reshape(-1),
258
+ agent_ch.reshape(-1),
259
+ ])
260
+
261
+ meta = observation.metadata or {}
262
+ wind = str(meta.get("wind_dir", ms.wind_dir or "CALM")).upper()
263
+ diff = str(meta.get("difficulty", "medium")).lower()
264
+ wi = WINDS.index(wind) if wind in WINDS else 0
265
+ di = DIFFICULTIES.index(diff) if diff in DIFFICULTIES else 1
266
+
267
+ wind_oh = np.zeros(len(WINDS), dtype=np.float32); wind_oh[wi] = 1.0
268
+ diff_oh = np.zeros(len(DIFFICULTIES), dtype=np.float32); diff_oh[di] = 1.0
269
+
270
+ # Fix 3 — map-agnostic exit compass features.
271
+ # Compute the direction vector and normalised Manhattan distance to the
272
+ # nearest exit cell (cell_type == 4) directly from the live grid.
273
+ # This gives the agent an exit "compass" that works on procedurally
274
+ # generated maps without memorising any layout.
275
+ EXIT_CELL_TYPE = 4
276
+ ax, ay = ms.agent_x, ms.agent_y
277
+ gw, gh = ms.grid_w, ms.grid_h
278
+ best_dist = float(gw + gh)
279
+ best_dx = 0.0
280
+ best_dy = 0.0
281
+ for cy in range(gh):
282
+ for cx in range(gw):
283
+ if int(ms.cell_grid[cy * gw + cx]) == EXIT_CELL_TYPE:
284
+ d = abs(cx - ax) + abs(cy - ay)
285
+ if d < best_dist:
286
+ best_dist = d
287
+ best_dx = float(cx - ax) / max(1, gw - 1)
288
+ best_dy = float(cy - ay) / max(1, gh - 1)
289
+ exit_manhattan_norm = best_dist / float(gw + gh)
290
+
291
+ global_features = np.array([
292
+ float(observation.agent_health) / 100.0,
293
+ float(ms.agent_health) / 100.0,
294
+ float(ms.step_count) / max(1, ms.max_steps),
295
+ float(ms.fire_spread_rate),
296
+ float(ms.humidity),
297
+ float(ms.agent_x) / max(1, ms.grid_w - 1),
298
+ float(ms.agent_y) / max(1, ms.grid_h - 1),
299
+ float(meta.get("nearest_exit_distance", MAX_GRID_W + MAX_GRID_H) or 0.0) / float(MAX_GRID_W + MAX_GRID_H),
300
+ float(meta.get("reachable_exit_count", 0.0)) / 4.0,
301
+ float(meta.get("visible_cell_count", 0.0)) / float(MAX_GRID_W * MAX_GRID_H),
302
+ float(meta.get("fire_sources", 0.0)) / 5.0,
303
+ {"none": 0.0, "light": 0.33, "moderate": 0.66, "heavy": 1.0}.get(observation.smoke_level, 0.0),
304
+ 1.0 if ms.agent_alive else 0.0,
305
+ 1.0 if ms.agent_evacuated else 0.0,
306
+ # Fix 3: exit-compass (3 new scalars — map-agnostic, layout-independent)
307
+ best_dx, # signed x-direction toward nearest exit
308
+ best_dy, # signed y-direction toward nearest exit
309
+ exit_manhattan_norm, # how far away the exit is (0 = here, 1 = max)
310
+ ], dtype=np.float32)
311
+
312
+ return np.concatenate([grid_features, global_features, wind_oh, diff_oh]).astype(np.float32)
313
+
314
+
315
+ # ---------------------------------------------------------------------------
316
+ # Neural Network
317
+ # ---------------------------------------------------------------------------
318
+
319
+ class ActorCritic(nn.Module):
320
+ """Shared-backbone Actor-Critic network for PPO.
321
+
322
+ Architecture:
323
+ Input → LayerNorm → FC(512) → LayerNorm → ReLU
324
+ → FC(256) → LayerNorm → ReLU
325
+ → FC(128) → ReLU
326
+ ┌──────────────┴──────────────┐
327
+ Policy head (→ logits) Value head (→ scalar)
328
+
329
+ LayerNorm before activations improves gradient flow for the large
330
+ (23128-dim) flat input without requiring feature normalization.
331
+ """
332
+
333
+ def __init__(self, input_dim: int, action_dim: int, hidden_sizes: Tuple[int, ...] = (512, 256, 128)):
334
+ super().__init__()
335
+ h1, h2, h3 = hidden_sizes
336
+
337
+ self.shared = nn.Sequential(
338
+ nn.LayerNorm(input_dim),
339
+ nn.Linear(input_dim, h1),
340
+ nn.LayerNorm(h1),
341
+ nn.ReLU(),
342
+ nn.Linear(h1, h2),
343
+ nn.LayerNorm(h2),
344
+ nn.ReLU(),
345
+ nn.Linear(h2, h3),
346
+ nn.ReLU(),
347
+ )
348
+
349
+ # Orthogonal init — standard for PPO (improves early convergence)
350
+ self._init_orthogonal()
351
+
352
+ self.policy_head = nn.Linear(h3, action_dim)
353
+ self.value_head = nn.Linear(h3, 1)
354
+
355
+ # Small init for output heads prevents saturated softmax early on
356
+ nn.init.orthogonal_(self.policy_head.weight, gain=0.01)
357
+ nn.init.zeros_(self.policy_head.bias)
358
+ nn.init.orthogonal_(self.value_head.weight, gain=1.0)
359
+ nn.init.zeros_(self.value_head.bias)
360
+
361
+ def _init_orthogonal(self) -> None:
362
+ for layer in self.shared:
363
+ if isinstance(layer, nn.Linear):
364
+ nn.init.orthogonal_(layer.weight, gain=np.sqrt(2))
365
+ nn.init.zeros_(layer.bias)
366
+
367
+ def forward(
368
+ self,
369
+ obs: torch.Tensor,
370
+ mask: torch.Tensor,
371
+ ) -> Tuple[torch.distributions.Categorical, torch.Tensor]:
372
+ """
373
+ Args:
374
+ obs: (B, input_dim) float32
375
+ mask: (B, action_dim) float32 — 1.0 = valid, 0.0 = invalid
376
+ Returns:
377
+ dist: Categorical distribution (action masking applied as -inf)
378
+ values: (B,) float32
379
+ """
380
+ features = self.shared(obs)
381
+ logits = self.policy_head(features)
382
+
383
+ # Mask invalid actions with -inf before softmax (numerically stable)
384
+ logits = torch.where(mask.bool(), logits, torch.full_like(logits, -1e9))
385
+
386
+ dist = torch.distributions.Categorical(logits=logits)
387
+ values = self.value_head(features).squeeze(-1)
388
+ return dist, values
389
+
390
+ def act(
391
+ self,
392
+ obs: torch.Tensor,
393
+ mask: torch.Tensor,
394
+ deterministic: bool = False,
395
+ ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
396
+ """Sample (or take greedy) action. Returns (action, log_prob, value)."""
397
+ dist, values = self(obs, mask)
398
+ action = dist.mode if deterministic else dist.sample()
399
+ log_prob = dist.log_prob(action)
400
+ return action, log_prob, values
401
+
402
+ def evaluate(
403
+ self,
404
+ obs: torch.Tensor,
405
+ mask: torch.Tensor,
406
+ action: torch.Tensor,
407
+ ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
408
+ """Evaluate stored actions during PPO update. Returns (log_prob, value, entropy)."""
409
+ dist, values = self(obs, mask)
410
+ log_prob = dist.log_prob(action)
411
+ entropy = dist.entropy()
412
+ return log_prob, values, entropy
413
+
414
+
415
+ # ---------------------------------------------------------------------------
416
+ # Rollout buffer
417
+ # ---------------------------------------------------------------------------
418
+
419
+ @dataclass
420
+ class RolloutBuffer:
421
+ """Stores transitions for a batch of episodes before PPO update."""
422
+ obs: List[np.ndarray] = field(default_factory=list)
423
+ masks: List[np.ndarray] = field(default_factory=list)
424
+ actions: List[int] = field(default_factory=list)
425
+ rewards: List[float] = field(default_factory=list)
426
+ log_probs: List[float] = field(default_factory=list)
427
+ values: List[float] = field(default_factory=list)
428
+ dones: List[bool] = field(default_factory=list)
429
+
430
+ def clear(self) -> None:
431
+ self.obs.clear()
432
+ self.masks.clear()
433
+ self.actions.clear()
434
+ self.rewards.clear()
435
+ self.log_probs.clear()
436
+ self.values.clear()
437
+ self.dones.clear()
438
+
439
+ def __len__(self) -> int:
440
+ return len(self.rewards)
441
+
442
+
443
+ # ---------------------------------------------------------------------------
444
+ # GAE computation
445
+ # ---------------------------------------------------------------------------
446
+
447
+ def compute_gae(
448
+ rewards: np.ndarray,
449
+ values: np.ndarray,
450
+ dones: np.ndarray,
451
+ gamma: float,
452
+ gae_lambda: float,
453
+ ) -> Tuple[np.ndarray, np.ndarray]:
454
+ """Generalized Advantage Estimation.
455
+
456
+ Returns (returns, advantages) — both shape (T,).
457
+ Episode boundaries (done=True) reset the GAE accumulator so advantages
458
+ don't bleed across episodes within a mixed batch.
459
+ """
460
+ T = len(rewards)
461
+ advantages = np.zeros(T, dtype=np.float32)
462
+ gae = 0.0
463
+ next_value = 0.0
464
+ for t in reversed(range(T)):
465
+ if dones[t]:
466
+ next_value = 0.0
467
+ gae = 0.0
468
+ delta = rewards[t] + gamma * next_value * (1.0 - dones[t]) - values[t]
469
+ gae = delta + gamma * gae_lambda * (1.0 - dones[t]) * gae
470
+ advantages[t] = gae
471
+ next_value = values[t]
472
+ returns = advantages + values
473
+ return returns, advantages
474
+
475
+
476
+ # ---------------------------------------------------------------------------
477
+ # Episode runner
478
+ # ---------------------------------------------------------------------------
479
+
480
+ @dataclass
481
+ class EpisodeResult:
482
+ total_reward: float
483
+ steps: int
484
+ evacuated: bool
485
+ final_health: float
486
+ difficulty: str
487
+
488
+
489
+ def run_episode(
490
+ env: PyreEnvironment,
491
+ network: ActorCritic,
492
+ encoder: ObservationEncoder,
493
+ device: torch.device,
494
+ difficulty: str,
495
+ history_length: int,
496
+ buffer: RolloutBuffer,
497
+ deterministic: bool = False,
498
+ ) -> EpisodeResult:
499
+ """Run one episode, appending transitions to *buffer*."""
500
+ observation = env.reset(difficulty=difficulty)
501
+ zero_frame = np.zeros(encoder.base_dim, dtype=np.float32)
502
+ frames: deque = deque([zero_frame.copy() for _ in range(history_length)], maxlen=history_length)
503
+ frames.append(encoder.encode(observation))
504
+
505
+ total_reward = 0.0
506
+ final_health = observation.agent_health
507
+ evacuated = False
508
+ steps = 0
509
+ # Anti-loop tracking: remember the last LOOP_WINDOW positions this episode.
510
+ # Revisiting any of them means the agent is circling, not exploring.
511
+ LOOP_WINDOW = 12
512
+ recent_positions: deque = deque(maxlen=LOOP_WINDOW)
513
+
514
+ network.eval()
515
+ with torch.no_grad():
516
+ while True:
517
+ state_vec = np.concatenate(list(frames), dtype=np.float32)
518
+ # exclude_look=True: RL agent sees full grid — look wastes steps
519
+ action_mask = build_action_mask(observation, exclude_look=True)
520
+
521
+ obs_t = torch.tensor(state_vec, dtype=torch.float32, device=device).unsqueeze(0)
522
+ mask_t = torch.tensor(action_mask, dtype=torch.float32, device=device).unsqueeze(0)
523
+
524
+ action_t, log_prob_t, value_t = network.act(obs_t, mask_t, deterministic=deterministic)
525
+
526
+ action_idx = int(action_t.item())
527
+ env_action = action_index_to_env_action(action_idx)
528
+ next_obs = env.step(env_action)
529
+
530
+ reward = float(next_obs.reward or 0.0)
531
+
532
+ # ----------------------------------------------------------------
533
+ # Reward shaping 1 — idle penalty
534
+ # The env's -0.01/step is too weak; make waiting explicitly costly.
535
+ # ----------------------------------------------------------------
536
+ chosen_action = env_action.action
537
+ if chosen_action == "wait":
538
+ reward -= 0.05
539
+
540
+ # ----------------------------------------------------------------
541
+ # Reward shaping 2 — fire-approach penalty (Fix 2)
542
+ # Penalise landing on (or moving next to) a cell with active fire.
543
+ # This is stronger than the env's DangerPenalty and fires *before*
544
+ # health drain accumulates, teaching the agent to predict spread.
545
+ # We look at the NEW observation's map to catch the current step.
546
+ # ----------------------------------------------------------------
547
+ ms_next = next_obs.map_state
548
+ if ms_next is not None and chosen_action.startswith("move"):
549
+ ax, ay = ms_next.agent_x, ms_next.agent_y
550
+ gw, gh = ms_next.grid_w, ms_next.grid_h
551
+ fire_grid = ms_next.fire_grid
552
+ for dx, dy in ((0, 1), (0, -1), (1, 0), (-1, 0)):
553
+ nx, ny = ax + dx, ay + dy
554
+ if 0 <= nx < gw and 0 <= ny < gh:
555
+ if float(fire_grid[ny * gw + nx]) > 0.15:
556
+ reward -= 0.15 # early fire-proximity warning
557
+ break
558
+
559
+ # ----------------------------------------------------------------
560
+ # Reward shaping 3 — anti-loop penalty
561
+ # If the agent steps onto a cell it occupied in the last LOOP_WINDOW
562
+ # steps, it is circling. Penalise to force forward exploration.
563
+ # Fires only on move actions — wait is already penalised above.
564
+ # ----------------------------------------------------------------
565
+ if ms_next is not None and chosen_action.startswith("move"):
566
+ cur_pos = (ms_next.agent_x, ms_next.agent_y)
567
+ if cur_pos in recent_positions:
568
+ reward -= 0.2 # break the loop
569
+ recent_positions.append(cur_pos)
570
+
571
+ done = bool(next_obs.done)
572
+
573
+ buffer.obs.append(state_vec)
574
+ buffer.masks.append(action_mask)
575
+ buffer.actions.append(action_idx)
576
+ buffer.rewards.append(reward)
577
+ buffer.log_probs.append(float(log_prob_t.item()))
578
+ buffer.values.append(float(value_t.item()))
579
+ buffer.dones.append(done)
580
+
581
+ total_reward += reward
582
+ steps += 1
583
+ final_health = next_obs.agent_health
584
+ evacuated = next_obs.agent_evacuated
585
+
586
+ frames.append(encoder.encode(next_obs))
587
+ observation = next_obs
588
+ if done:
589
+ break
590
+
591
+ return EpisodeResult(
592
+ total_reward=total_reward,
593
+ steps=steps,
594
+ evacuated=evacuated,
595
+ final_health=final_health,
596
+ difficulty=difficulty,
597
+ )
598
+
599
+
600
+ # ---------------------------------------------------------------------------
601
+ # PPO update
602
+ # ---------------------------------------------------------------------------
603
+
604
+ def ppo_update(
605
+ network: ActorCritic,
606
+ optimizer: Adam,
607
+ buffer: RolloutBuffer,
608
+ device: torch.device,
609
+ clip_eps: float,
610
+ value_clip_eps: float,
611
+ entropy_coef: float,
612
+ value_coef: float,
613
+ n_epochs: int,
614
+ minibatch_size: int,
615
+ gamma: float,
616
+ gae_lambda: float,
617
+ max_grad_norm: float,
618
+ ) -> Dict[str, float]:
619
+ """Full PPO update over the collected rollout buffer."""
620
+ rewards = np.array(buffer.rewards, dtype=np.float32)
621
+ values = np.array(buffer.values, dtype=np.float32)
622
+ dones = np.array(buffer.dones, dtype=np.float32)
623
+
624
+ returns, advantages = compute_gae(rewards, values, dones, gamma, gae_lambda)
625
+
626
+ # Normalize advantages across the whole batch (reduces variance)
627
+ advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
628
+
629
+ obs_arr = torch.tensor(np.stack(buffer.obs), dtype=torch.float32, device=device)
630
+ mask_arr = torch.tensor(np.stack(buffer.masks), dtype=torch.float32, device=device)
631
+ action_arr = torch.tensor(buffer.actions, dtype=torch.long, device=device)
632
+ old_logp_arr = torch.tensor(buffer.log_probs, dtype=torch.float32, device=device)
633
+ return_arr = torch.tensor(returns, dtype=torch.float32, device=device)
634
+ adv_arr = torch.tensor(advantages, dtype=torch.float32, device=device)
635
+ old_value_arr = torch.tensor(values, dtype=torch.float32, device=device)
636
+
637
+ T = len(buffer)
638
+ metrics = {"policy_loss": 0.0, "value_loss": 0.0, "entropy": 0.0, "approx_kl": 0.0, "clip_frac": 0.0}
639
+ n_updates = 0
640
+
641
+ network.train()
642
+ for _ in range(n_epochs):
643
+ perm = torch.randperm(T, device=device)
644
+ for start in range(0, T, minibatch_size):
645
+ idx = perm[start:start + minibatch_size]
646
+ if len(idx) < 2:
647
+ continue
648
+
649
+ log_prob, value, entropy = network.evaluate(obs_arr[idx], mask_arr[idx], action_arr[idx])
650
+
651
+ # PPO ratio and clipped surrogate loss
652
+ ratio = torch.exp(log_prob - old_logp_arr[idx])
653
+ adv_mb = adv_arr[idx]
654
+ surr1 = ratio * adv_mb
655
+ surr2 = torch.clamp(ratio, 1.0 - clip_eps, 1.0 + clip_eps) * adv_mb
656
+ policy_loss = -torch.min(surr1, surr2).mean()
657
+
658
+ # Value loss with optional clipping (stabilises critic)
659
+ ret_mb = return_arr[idx]
660
+ old_val_mb = old_value_arr[idx]
661
+ value_pred_clipped = old_val_mb + torch.clamp(value - old_val_mb, -value_clip_eps, value_clip_eps)
662
+ value_loss = torch.max(
663
+ F.mse_loss(value, ret_mb),
664
+ F.mse_loss(value_pred_clipped, ret_mb),
665
+ )
666
+
667
+ entropy_loss = -entropy.mean()
668
+
669
+ loss = policy_loss + value_coef * value_loss + entropy_coef * entropy_loss
670
+
671
+ optimizer.zero_grad()
672
+ loss.backward()
673
+ nn.utils.clip_grad_norm_(network.parameters(), max_grad_norm)
674
+ optimizer.step()
675
+
676
+ with torch.no_grad():
677
+ approx_kl = ((ratio - 1) - (log_prob - old_logp_arr[idx])).mean().item()
678
+ clip_frac = ((ratio - 1.0).abs() > clip_eps).float().mean().item()
679
+
680
+ metrics["policy_loss"] += policy_loss.item()
681
+ metrics["value_loss"] += value_loss.item()
682
+ metrics["entropy"] += entropy.mean().item()
683
+ metrics["approx_kl"] += approx_kl
684
+ metrics["clip_frac"] += clip_frac
685
+ n_updates += 1
686
+
687
+ if n_updates > 0:
688
+ for k in metrics:
689
+ metrics[k] /= n_updates
690
+ return metrics
691
+
692
+
693
+ # ---------------------------------------------------------------------------
694
+ # Evaluation
695
+ # ---------------------------------------------------------------------------
696
+
697
+ def evaluate_policy(
698
+ env: PyreEnvironment,
699
+ network: ActorCritic,
700
+ encoder: ObservationEncoder,
701
+ device: torch.device,
702
+ difficulty: str,
703
+ history_length: int,
704
+ n_episodes: int,
705
+ ) -> Dict[str, float]:
706
+ rewards, successes, steps = [], [], []
707
+ dummy_buffer = RolloutBuffer()
708
+ for _ in range(n_episodes):
709
+ result = run_episode(
710
+ env=env, network=network, encoder=encoder, device=device,
711
+ difficulty=difficulty, history_length=history_length,
712
+ buffer=dummy_buffer, deterministic=True,
713
+ )
714
+ dummy_buffer.clear()
715
+ rewards.append(result.total_reward)
716
+ successes.append(float(result.evacuated))
717
+ steps.append(result.steps)
718
+ return {
719
+ "reward_mean": float(np.mean(rewards)),
720
+ "reward_max": float(np.max(rewards)),
721
+ "success_rate": float(np.mean(successes)),
722
+ "steps_mean": float(np.mean(steps)),
723
+ }
724
+
725
+
726
+ # ---------------------------------------------------------------------------
727
+ # PNG graph (matplotlib)
728
+ # ---------------------------------------------------------------------------
729
+
730
+ def save_training_graph_png(
731
+ path: Path,
732
+ episode_rows: List[Dict],
733
+ eval_rows: List[Dict],
734
+ window: int = 20,
735
+ ) -> None:
736
+ """Save a publication-quality PNG training graph with dual Y-axes."""
737
+ try:
738
+ import matplotlib
739
+ matplotlib.use("Agg") # non-interactive backend — no display needed
740
+ import matplotlib.pyplot as plt
741
+ import matplotlib.ticker as mticker
742
+ except ImportError:
743
+ print("[warn] matplotlib not installed — skipping PNG graph. Run: uv pip install matplotlib")
744
+ return
745
+
746
+ if not episode_rows:
747
+ return
748
+
749
+ path.parent.mkdir(parents=True, exist_ok=True)
750
+
751
+ episodes = [int(r["episode"]) for r in episode_rows]
752
+ rewards = [float(r["reward"]) for r in episode_rows]
753
+ evacuated = [float(r["evacuated"]) for r in episode_rows]
754
+ difficulty = [str(r["difficulty"]) for r in episode_rows]
755
+
756
+ # Moving average helper
757
+ def ma(values: list, w: int) -> list:
758
+ out, run, q = [], 0.0, []
759
+ for v in values:
760
+ q.append(v); run += v
761
+ if len(q) > w: run -= q.pop(0)
762
+ out.append(run / len(q))
763
+ return out
764
+
765
+ reward_ma = ma(rewards, window)
766
+ success_ma = ma(evacuated, window)
767
+
768
+ eval_eps = [int(r["episode"]) for r in eval_rows]
769
+ eval_succ = [float(r["success_rate"]) for r in eval_rows]
770
+
771
+ # Difficulty shading regions
772
+ diff_colors = {"easy": "#d4edda", "medium": "#fff3cd", "hard": "#f8d7da"}
773
+ regions: List[tuple] = []
774
+ if difficulty:
775
+ cur, start = difficulty[0], episodes[0]
776
+ for ep, d in zip(episodes[1:], difficulty[1:]):
777
+ if d != cur:
778
+ regions.append((start, ep, cur))
779
+ cur, start = d, ep
780
+ regions.append((start, episodes[-1], cur))
781
+
782
+ fig, ax1 = plt.subplots(figsize=(14, 6))
783
+ ax2 = ax1.twinx()
784
+
785
+ # Shade difficulty regions
786
+ for x0, x1, diff in regions:
787
+ ax1.axvspan(x0, x1, color=diff_colors.get(diff, "#eeeeee"), alpha=0.35, zorder=0)
788
+
789
+ # Zero line
790
+ ax1.axhline(0, color="#aaaaaa", linewidth=0.8, linestyle="--", zorder=1)
791
+
792
+ # Raw reward (faint)
793
+ ax1.plot(episodes, rewards, color="#d1c7bc", linewidth=0.8,
794
+ alpha=0.6, label="Episode reward", zorder=2)
795
+
796
+ # Reward moving average
797
+ ax1.plot(episodes, reward_ma, color="#c1661c", linewidth=2.5,
798
+ label=f"Reward (MA-{window})", zorder=3)
799
+
800
+ # Success moving average (right axis)
801
+ ax2.plot(episodes, success_ma, color="#1a7a8a", linewidth=2.5,
802
+ linestyle="-", label=f"Success rate (MA-{window})", zorder=3)
803
+
804
+ # Eval checkpoints
805
+ if eval_eps:
806
+ ax2.scatter(eval_eps, eval_succ, color="#0d5b6b", s=60, zorder=5,
807
+ marker="D", label="Eval success", edgecolors="white", linewidths=1.2)
808
+
809
+ # Axes labels & formatting
810
+ ax1.set_xlabel("Episode", fontsize=13, fontweight="bold", labelpad=8)
811
+ ax1.set_ylabel("Reward", fontsize=13, fontweight="bold", color="#c1661c", labelpad=8)
812
+ ax2.set_ylabel("Success Rate", fontsize=13, fontweight="bold", color="#1a7a8a", labelpad=8)
813
+
814
+ ax1.tick_params(axis="y", labelcolor="#c1661c")
815
+ ax2.tick_params(axis="y", labelcolor="#1a7a8a")
816
+ ax2.set_ylim(-0.05, 1.05)
817
+ ax2.yaxis.set_major_formatter(mticker.PercentFormatter(xmax=1.0, decimals=0))
818
+
819
+ ax1.grid(True, which="major", linestyle="--", linewidth=0.6,
820
+ color="#dddddd", alpha=0.8, zorder=0)
821
+ ax1.set_xlim(episodes[0], episodes[-1])
822
+
823
+ ax1.tick_params(axis="x", labelsize=10)
824
+ ax1.tick_params(axis="y", labelsize=10)
825
+ ax2.tick_params(axis="y", labelsize=10)
826
+
827
+ # Title
828
+ total_eps = episodes[-1]
829
+ final_sr = success_ma[-1] if success_ma else 0.0
830
+ fig.suptitle(
831
+ f"Pyre PPO Training — {total_eps} episodes | final success rate: {final_sr:.0%}",
832
+ fontsize=14, fontweight="bold", y=1.01,
833
+ )
834
+
835
+ # Difficulty legend patches
836
+ import matplotlib.patches as mpatches
837
+ diff_patches = [
838
+ mpatches.Patch(color=diff_colors[d], alpha=0.6, label=d.capitalize())
839
+ for d in ["easy", "medium", "hard"] if any(r == d for r in difficulty)
840
+ ]
841
+
842
+ # Combine legends from both axes
843
+ h1, l1 = ax1.get_legend_handles_labels()
844
+ h2, l2 = ax2.get_legend_handles_labels()
845
+ ax1.legend(h1 + h2 + diff_patches, l1 + l2 + [p.get_label() for p in diff_patches],
846
+ loc="upper left", fontsize=9, framealpha=0.85)
847
+
848
+ fig.tight_layout()
849
+ fig.savefig(path, dpi=150, bbox_inches="tight")
850
+ plt.close(fig)
851
+
852
+
853
+ # ---------------------------------------------------------------------------
854
+ # Curriculum scheduling
855
+ # ---------------------------------------------------------------------------
856
+
857
+ def build_curriculum(schedule_str: str, n_episodes: int) -> List[str]:
858
+ """Expand comma-separated difficulty stages evenly over n_episodes.
859
+
860
+ Example: 'easy,medium,hard' with 300 episodes → 100 each.
861
+ Used only when patience_threshold=0 (static schedule).
862
+ """
863
+ stages = [s.strip().lower() for s in schedule_str.split(",") if s.strip()]
864
+ if not stages:
865
+ stages = ["medium"]
866
+ for s in stages:
867
+ if s not in DIFFICULTIES:
868
+ raise ValueError(f"Unknown difficulty '{s}'. Choose from {DIFFICULTIES}.")
869
+ seg = max(1, n_episodes // len(stages))
870
+ schedule = []
871
+ for s in stages:
872
+ schedule.extend([s] * seg)
873
+ while len(schedule) < n_episodes:
874
+ schedule.append(stages[-1])
875
+ return schedule[:n_episodes]
876
+
877
+
878
+ class PatienceCurriculum:
879
+ """Dynamic difficulty scheduler that gates advancement on sustained success rate.
880
+
881
+ Stays on current difficulty until success_rate_30 >= threshold for
882
+ patience_window consecutive episodes, then advances to the next stage.
883
+ During the hard phase an optional mix_ratio fraction of episodes are
884
+ replayed on the previous (medium) difficulty to prevent catastrophic
885
+ forgetting of the medium policy.
886
+
887
+ Args:
888
+ stages: ordered list of difficulty strings, e.g. ['easy','medium','hard']
889
+ threshold: minimum success rate (0–1) required before advancing
890
+ patience_window: number of consecutive episodes that must meet threshold
891
+ mix_ratio: fraction of hard-phase episodes to run on medium instead (0–1)
892
+ """
893
+
894
+ def __init__(
895
+ self,
896
+ stages: List[str],
897
+ threshold: float,
898
+ patience_window: int,
899
+ mix_ratio: float = 0.0,
900
+ ) -> None:
901
+ self.stages = stages
902
+ self.threshold = threshold
903
+ self.patience_window = patience_window
904
+ self.mix_ratio = mix_ratio
905
+ self.stage_idx = 0
906
+ self._streak = 0
907
+
908
+ @property
909
+ def current(self) -> str:
910
+ return self.stages[self.stage_idx]
911
+
912
+ def step(self, success_rate_30: float) -> str:
913
+ """Call once per episode *after* appending to success_window.
914
+
915
+ Returns the difficulty to use for the *next* episode.
916
+ Also handles the hard-phase medium-mix injection.
917
+ """
918
+ if self.stage_idx < len(self.stages) - 1:
919
+ if success_rate_30 >= self.threshold:
920
+ self._streak += 1
921
+ else:
922
+ self._streak = 0
923
+ if self._streak >= self.patience_window:
924
+ self.stage_idx += 1
925
+ self._streak = 0
926
+ print(
927
+ f" [curriculum] Advanced to '{self.current}' "
928
+ f"(success_rate_30={success_rate_30:.2f} >= {self.threshold} "
929
+ f"for {self.patience_window} eps)"
930
+ )
931
+
932
+ # Hard-phase mix: occasionally replay medium to prevent forgetting
933
+ if self.current == "hard" and self.mix_ratio > 0.0 and len(self.stages) >= 2:
934
+ prev = self.stages[self.stage_idx - 1]
935
+ if np.random.rand() < self.mix_ratio:
936
+ return prev # medium replay episode
937
+ return self.current
938
+
939
+
940
+ # ---------------------------------------------------------------------------
941
+ # Checkpoint
942
+ # ---------------------------------------------------------------------------
943
+
944
+ def save_checkpoint(
945
+ path: Path,
946
+ network: ActorCritic,
947
+ optimizer: Adam,
948
+ scheduler,
949
+ episode: int,
950
+ args: argparse.Namespace,
951
+ ) -> None:
952
+ path.parent.mkdir(parents=True, exist_ok=True)
953
+ torch.save({
954
+ "episode": episode,
955
+ "network_state": network.state_dict(),
956
+ "optimizer_state": optimizer.state_dict(),
957
+ "scheduler_state": scheduler.state_dict() if scheduler else None,
958
+ "args": vars(args),
959
+ }, path)
960
+
961
+
962
+ def load_checkpoint(
963
+ path: Path,
964
+ network: ActorCritic,
965
+ optimizer: Adam,
966
+ scheduler,
967
+ ) -> int:
968
+ ckpt = torch.load(path, map_location="cpu", weights_only=False)
969
+ network.load_state_dict(ckpt["network_state"])
970
+ optimizer.load_state_dict(ckpt["optimizer_state"])
971
+ if scheduler and ckpt.get("scheduler_state"):
972
+ scheduler.load_state_dict(ckpt["scheduler_state"])
973
+ start_episode = int(ckpt.get("episode", 0))
974
+ print(f"[resume] Loaded checkpoint from episode {start_episode}: {path}")
975
+ return start_episode
976
+
977
+
978
+ # ---------------------------------------------------------------------------
979
+ # CSV logging
980
+ # ---------------------------------------------------------------------------
981
+
982
+ def save_csv(path: Path, rows: List[Dict]) -> None:
983
+ path.parent.mkdir(parents=True, exist_ok=True)
984
+ if not rows:
985
+ return
986
+ with path.open("w", newline="", encoding="utf-8") as f:
987
+ writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
988
+ writer.writeheader()
989
+ writer.writerows(rows)
990
+
991
+
992
+ # ---------------------------------------------------------------------------
993
+ # Main training loop
994
+ # ---------------------------------------------------------------------------
995
+
996
+ def train(args: argparse.Namespace) -> None:
997
+ device = torch.device(args.device if torch.cuda.is_available() or args.device == "cpu" else "cpu")
998
+ if args.device == "cuda" and not torch.cuda.is_available():
999
+ print("[warn] CUDA not available - falling back to CPU.")
1000
+
1001
+ print(f"[config] device={device} episodes={args.episodes} batch={args.update_every} eps "
1002
+ f"hidden={args.hidden_sizes} frames={args.history_length}")
1003
+ print(f"[config] curriculum: {args.difficulty_schedule}")
1004
+ print(f"[config] PPO clip_eps={args.clip_eps} entropy={args.entropy_coef} lr={args.learning_rate}\n")
1005
+
1006
+ encoder = ObservationEncoder(mode=args.observation_mode)
1007
+ input_dim = encoder.base_dim * args.history_length
1008
+
1009
+ hidden_sizes = tuple(int(h) for h in args.hidden_sizes.split(","))
1010
+ network = ActorCritic(input_dim=input_dim, action_dim=ACTION_DIM, hidden_sizes=hidden_sizes).to(device)
1011
+ optimizer = Adam(network.parameters(), lr=args.learning_rate, eps=1e-5)
1012
+
1013
+ total_steps_for_scheduler = args.episodes // args.update_every
1014
+ scheduler = LinearLR(optimizer, start_factor=1.0, end_factor=args.lr_end_factor,
1015
+ total_iters=max(1, total_steps_for_scheduler)) if args.lr_decay else None
1016
+
1017
+ env = PyreEnvironment(max_steps=args.max_steps)
1018
+
1019
+ # Build curriculum — patience-gated (dynamic) or static
1020
+ stages = [s.strip().lower() for s in args.difficulty_schedule.split(",") if s.strip()]
1021
+ if args.patience_threshold > 0:
1022
+ patience_curriculum = PatienceCurriculum(
1023
+ stages=stages,
1024
+ threshold=args.patience_threshold,
1025
+ patience_window=args.patience_window,
1026
+ mix_ratio=args.hard_mix_ratio,
1027
+ )
1028
+ static_curriculum: Optional[List[str]] = None
1029
+ print(f"[curriculum] patience-gated: threshold={args.patience_threshold} "
1030
+ f"window={args.patience_window} mix={args.hard_mix_ratio}")
1031
+ else:
1032
+ patience_curriculum = None
1033
+ static_curriculum = build_curriculum(args.difficulty_schedule, args.episodes)
1034
+ print(f"[curriculum] static: {args.difficulty_schedule}")
1035
+
1036
+ start_episode = 0
1037
+ if args.resume:
1038
+ resume_path = Path(args.resume)
1039
+ if resume_path.exists():
1040
+ start_episode = load_checkpoint(resume_path, network, optimizer, scheduler)
1041
+
1042
+ # Tracking
1043
+ buffer = RolloutBuffer()
1044
+ episode_rows: List[Dict] = []
1045
+ eval_rows: List[Dict] = []
1046
+ reward_window: deque = deque(maxlen=30)
1047
+ success_window: deque = deque(maxlen=30)
1048
+
1049
+ n_params = sum(p.numel() for p in network.parameters())
1050
+ print(f"[network] Parameters: {n_params:,}")
1051
+ print(f"[network] Input dim: {input_dim:,} (encoder.base_dim={encoder.base_dim} x {args.history_length} frames)")
1052
+ print(f"[network] Action dim: {ACTION_DIM} (4 move + 4 look + 1 wait + {MAX_DOORS} open + {MAX_DOORS} close)")
1053
+ print()
1054
+
1055
+ t_start = time.time()
1056
+
1057
+ for ep_idx in range(start_episode, args.episodes):
1058
+ # Determine difficulty for this episode
1059
+ if patience_curriculum is not None:
1060
+ difficulty = patience_curriculum.current
1061
+ else:
1062
+ difficulty = static_curriculum[ep_idx] # type: ignore[index]
1063
+
1064
+ result = run_episode(
1065
+ env=env, network=network, encoder=encoder, device=device,
1066
+ difficulty=difficulty, history_length=args.history_length,
1067
+ buffer=buffer, deterministic=False,
1068
+ )
1069
+
1070
+ reward_window.append(result.total_reward)
1071
+ success_window.append(float(result.evacuated))
1072
+
1073
+ # Advance patience curriculum *after* updating success_window
1074
+ if patience_curriculum is not None:
1075
+ difficulty = patience_curriculum.step(float(np.mean(success_window)))
1076
+
1077
+ ep_num = ep_idx + 1
1078
+ episode_rows.append({
1079
+ "episode": ep_num,
1080
+ "difficulty": difficulty,
1081
+ "reward": round(result.total_reward, 4),
1082
+ "evacuated": int(result.evacuated),
1083
+ "steps": result.steps,
1084
+ "final_health": round(result.final_health, 2),
1085
+ "reward_mean_30": round(float(np.mean(reward_window)), 4),
1086
+ "success_rate_30": round(float(np.mean(success_window)), 4),
1087
+ })
1088
+
1089
+ elapsed = time.time() - t_start
1090
+ print(
1091
+ f"ep={ep_num:04d} [{difficulty:<6}] "
1092
+ f"steps={result.steps:03d} "
1093
+ f"reward={result.total_reward:+8.3f} "
1094
+ f"evac={int(result.evacuated)} "
1095
+ f"hp={result.final_health:5.1f} "
1096
+ f"suc30={float(np.mean(success_window)):.2f} "
1097
+ f"r30={float(np.mean(reward_window)):+7.2f} "
1098
+ f"t={elapsed:.0f}s"
1099
+ )
1100
+
1101
+ # PPO update every N episodes
1102
+ should_update = (ep_num % args.update_every == 0) or (ep_num == args.episodes)
1103
+ if should_update and len(buffer) > 0:
1104
+ ppo_metrics = ppo_update(
1105
+ network=network, optimizer=optimizer, buffer=buffer, device=device,
1106
+ clip_eps=args.clip_eps, value_clip_eps=args.clip_eps,
1107
+ entropy_coef=args.entropy_coef, value_coef=args.value_coef,
1108
+ n_epochs=args.update_epochs, minibatch_size=args.minibatch_size,
1109
+ gamma=args.gamma, gae_lambda=args.gae_lambda,
1110
+ max_grad_norm=args.max_grad_norm,
1111
+ )
1112
+ if scheduler:
1113
+ scheduler.step()
1114
+ buffer.clear()
1115
+
1116
+ cur_lr = optimizer.param_groups[0]["lr"]
1117
+ print(
1118
+ f" >> PPO update samples={len(buffer) if len(buffer) > 0 else 'flushed'} "
1119
+ f"pi_loss={ppo_metrics['policy_loss']:+.4f} "
1120
+ f"v_loss={ppo_metrics['value_loss']:.4f} "
1121
+ f"entropy={ppo_metrics['entropy']:.4f} "
1122
+ f"kl={ppo_metrics['approx_kl']:.4f} "
1123
+ f"clip%={ppo_metrics['clip_frac']:.2f} "
1124
+ f"lr={cur_lr:.2e}"
1125
+ )
1126
+
1127
+ # Periodic evaluation
1128
+ if args.eval_every > 0 and (ep_num % args.eval_every == 0 or ep_num == args.episodes):
1129
+ eval_m = evaluate_policy(
1130
+ env=env, network=network, encoder=encoder, device=device,
1131
+ difficulty=args.eval_difficulty, history_length=args.history_length,
1132
+ n_episodes=args.eval_episodes,
1133
+ )
1134
+ eval_rows.append({"episode": ep_num, "difficulty": args.eval_difficulty, **{k: round(v, 4) for k, v in eval_m.items()}})
1135
+ print(
1136
+ f" ** EVAL [{args.eval_difficulty}] "
1137
+ f"reward={eval_m['reward_mean']:+.3f} "
1138
+ f"success={eval_m['success_rate']:.2f} "
1139
+ f"steps={eval_m['steps_mean']:.1f}"
1140
+ )
1141
+
1142
+ # Periodic checkpoint
1143
+ if args.checkpoint and args.checkpoint_every > 0 and ep_num % args.checkpoint_every == 0:
1144
+ save_checkpoint(Path(args.checkpoint), network, optimizer, scheduler, ep_num, args)
1145
+ print(f" [ckpt] saved -> {args.checkpoint}")
1146
+
1147
+ # Final save
1148
+ if args.output:
1149
+ out = Path(args.output)
1150
+ save_checkpoint(out, network, optimizer, scheduler, args.episodes, args)
1151
+ print(f"\n[done] Model saved -> {out}")
1152
+
1153
+ if args.save_metrics:
1154
+ csv_path = out.with_suffix(".csv")
1155
+ save_csv(csv_path, episode_rows)
1156
+ print(f"[done] Metrics CSV -> {csv_path}")
1157
+
1158
+ if eval_rows:
1159
+ eval_csv = out.parent / (out.stem + "_eval.csv")
1160
+ save_csv(eval_csv, eval_rows)
1161
+ print(f"[done] Eval CSV -> {eval_csv}")
1162
+
1163
+ if args.save_graph:
1164
+ png_path = out.with_suffix(".png")
1165
+ save_training_graph_png(png_path, episode_rows, eval_rows)
1166
+ print(f"[done] Graph PNG -> {png_path}")
1167
+
1168
+ total_time = time.time() - t_start
1169
+ print(f"\n[summary] {args.episodes - start_episode} episodes in {total_time:.1f}s "
1170
+ f"({(args.episodes - start_episode) / max(1, total_time):.1f} eps/s)")
1171
+ print(f"[summary] Final success rate (last 30): {float(np.mean(success_window)):.2f}")
1172
+ print(f"[summary] Final reward mean (last 30): {float(np.mean(reward_window)):+.3f}")
1173
+
1174
+
1175
+ # ---------------------------------------------------------------------------
1176
+ # CLI
1177
+ # ---------------------------------------------------------------------------
1178
+
1179
+ def describe_env() -> None:
1180
+ print(__doc__)
1181
+
1182
+
1183
+ def parse_args() -> argparse.Namespace:
1184
+ p = argparse.ArgumentParser(
1185
+ description="PPO training for Pyre fire-evacuation environment",
1186
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
1187
+ )
1188
+
1189
+ # Training scale
1190
+ p.add_argument("--episodes", type=int, default=400, help="Total training episodes")
1191
+ p.add_argument("--max-steps", type=int, default=150, help="Max steps per episode")
1192
+ p.add_argument("--device", type=str, default="cuda", choices=("cuda", "cpu"), help="Torch device")
1193
+
1194
+ # Curriculum
1195
+ p.add_argument("--difficulty", type=str, default="easy", choices=DIFFICULTIES,
1196
+ help="Single difficulty (overridden by --difficulty-schedule if set)")
1197
+ p.add_argument("--difficulty-schedule", type=str, default="easy,medium,hard",
1198
+ help="Comma-separated curriculum stages. With --patience-threshold>0 these "
1199
+ "become gated stages; otherwise split evenly across episodes.")
1200
+ p.add_argument("--patience-threshold", type=float, default=0.65,
1201
+ help="Success-rate threshold (30-ep window) required before advancing to next "
1202
+ "difficulty. Set 0 to use static even-split schedule.")
1203
+ p.add_argument("--patience-window", type=int, default=15,
1204
+ help="Episodes that must sustain >= patience-threshold before advancing.")
1205
+ p.add_argument("--hard-mix-ratio", type=float, default=0.25,
1206
+ help="Fraction of hard-phase episodes to replay on medium (0=pure hard). "
1207
+ "Prevents catastrophic forgetting of the medium policy.")
1208
+ p.add_argument("--eval-difficulty", type=str, default="medium", choices=DIFFICULTIES)
1209
+ p.add_argument("--eval-episodes", type=int, default=10)
1210
+ p.add_argument("--eval-every", type=int, default=50)
1211
+
1212
+ # Observation
1213
+ p.add_argument("--observation-mode", type=str, default="visible", choices=("visible", "full"),
1214
+ help="'visible': partial obs (realistic); 'full': oracle grid (debug)")
1215
+ p.add_argument("--history-length", type=int, default=4,
1216
+ help="Frames stacked per observation (temporal context for partial obs)")
1217
+
1218
+ # Network
1219
+ p.add_argument("--hidden-sizes", type=str, default="512,256,128",
1220
+ help="Comma-separated MLP hidden layer sizes")
1221
+
1222
+ # PPO hyperparameters
1223
+ p.add_argument("--update-every", type=int, default=5,
1224
+ help="Episodes between PPO updates (smaller = faster feedback loop early in training)")
1225
+ p.add_argument("--update-epochs", type=int, default=4,
1226
+ help="Gradient passes over each collected batch (PPO allows >1)")
1227
+ p.add_argument("--minibatch-size", type=int, default=256)
1228
+ p.add_argument("--clip-eps", type=float, default=0.2, help="PPO surrogate clip ε")
1229
+ p.add_argument("--entropy-coef", type=float, default=0.03,
1230
+ help="Entropy bonus coefficient — higher = more exploration (0.03 default encourages early exit-seeking)")
1231
+ p.add_argument("--value-coef", type=float, default=0.5)
1232
+ p.add_argument("--gamma", type=float, default=0.99)
1233
+ p.add_argument("--gae-lambda", type=float, default=0.95)
1234
+ p.add_argument("--max-grad-norm", type=float, default=0.5)
1235
+
1236
+ # Optimizer / LR schedule
1237
+ p.add_argument("--learning-rate", type=float, default=3e-4)
1238
+ p.add_argument("--lr-decay", action="store_true", default=True,
1239
+ help="Linear LR decay to lr_end_factor × initial_lr over training")
1240
+ p.add_argument("--lr-end-factor", type=float, default=0.1,
1241
+ help="LR at end of training = initial_lr × this value")
1242
+
1243
+ # Persistence
1244
+ p.add_argument("--output", type=str, default="artifacts/pyre_ppo.pt",
1245
+ help="Path to save final model checkpoint")
1246
+ p.add_argument("--checkpoint", type=str, default="artifacts/pyre_ppo_checkpoint.pt",
1247
+ help="Path for periodic checkpoints (also used by --resume)")
1248
+ p.add_argument("--checkpoint-every", type=int, default=50)
1249
+ p.add_argument("--resume", type=str, default=None,
1250
+ help="Path to checkpoint to resume training from")
1251
+ p.add_argument("--save-metrics", action="store_true", default=True,
1252
+ help="Save per-episode metrics as CSV alongside the model")
1253
+ p.add_argument("--save-graph", action="store_true", default=True,
1254
+ help="Save a PNG training graph alongside the model (requires matplotlib)")
1255
+
1256
+ # Misc
1257
+ p.add_argument("--seed", type=int, default=42)
1258
+ p.add_argument("--describe-only", action="store_true",
1259
+ help="Print environment/algorithm description and exit")
1260
+
1261
+ return p.parse_args()
1262
+
1263
+
1264
+ def main() -> None:
1265
+ args = parse_args()
1266
+
1267
+ if args.describe_only:
1268
+ describe_env()
1269
+ return
1270
+
1271
+ torch.manual_seed(args.seed)
1272
+ np.random.seed(args.seed)
1273
+
1274
+ train(args)
1275
+
1276
+
1277
+ if __name__ == "__main__":
1278
+ main()
examples/train_torch_ppo_http.py ADDED
@@ -0,0 +1,492 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PPO trainer that talks to the Pyre env via HTTP (localhost:8000).
2
+
3
+ Identical training logic to train_torch_ppo.py, but the environment is
4
+ accessed through the REST API instead of a direct Python import. This
5
+ lets you run the server once and connect any number of training scripts,
6
+ remote notebooks, or evaluation tools to the same live instance.
7
+
8
+ Usage
9
+ -----
10
+ 1. Start the server (in a separate terminal):
11
+ cd openenv-pyre
12
+ .venv/Scripts/python.exe server/app.py
13
+
14
+ 2. Run this script:
15
+ .venv/Scripts/python.exe examples/train_torch_ppo_http.py
16
+
17
+ Optional flags (identical to train_torch_ppo.py):
18
+ --server Base URL of the Pyre server [default: http://localhost:8000]
19
+ --episodes Total training episodes [default: 400]
20
+ --difficulty-schedule Curriculum [default: easy,easy,easy,medium,medium]
21
+ --output Where to save the model .pt [default: artifacts/pyre_ppo_http.pt]
22
+ ... (all other flags are the same as train_torch_ppo.py)
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import csv
29
+ import os
30
+ import sys
31
+ import time
32
+ from collections import deque
33
+ from dataclasses import dataclass, field
34
+ from pathlib import Path
35
+ from typing import Any, Dict, List, Optional
36
+
37
+ import numpy as np
38
+ import requests
39
+ import torch
40
+ import torch.nn as nn
41
+ import torch.optim as optim
42
+ from torch.distributions import Categorical
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Resolve project root so we can import shared models regardless of CWD
46
+ # ---------------------------------------------------------------------------
47
+ _HERE = Path(__file__).resolve().parent
48
+ _ROOT = _HERE.parent
49
+ if str(_ROOT) not in sys.path:
50
+ sys.path.insert(0, str(_ROOT))
51
+
52
+ try:
53
+ from models import PyreAction, PyreMapState, PyreObservation
54
+ except ImportError:
55
+ from openenv_pyre.models import PyreAction, PyreMapState, PyreObservation
56
+
57
+ # Reuse all shared utilities from the direct-import trainer
58
+ from examples.train_torch_ppo import (
59
+ ACTION_KEYS,
60
+ ACTION_DIM,
61
+ ACTION_TO_INDEX,
62
+ DIFFICULTIES,
63
+ MAX_DOORS,
64
+ MAX_GRID_H,
65
+ MAX_GRID_W,
66
+ WAIT_KEY,
67
+ WINDS,
68
+ ActorCritic,
69
+ ObservationEncoder,
70
+ RolloutBuffer,
71
+ action_index_to_env_action,
72
+ build_action_mask,
73
+ compute_gae,
74
+ ppo_update,
75
+ save_training_graph_png,
76
+ )
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # HTTP environment wrapper
81
+ # ---------------------------------------------------------------------------
82
+
83
+ class HttpPyreEnv:
84
+ """Thin wrapper around the Pyre REST API.
85
+
86
+ Exposes the same ``reset()`` / ``step()`` interface as ``PyreEnvironment``
87
+ so the episode runner needs no changes.
88
+
89
+ POST /reset → {"difficulty": str, "seed"?: int}
90
+ POST /step → {"action": str, "direction"?: str,
91
+ "target_id"?: str, "door_state"?: str}
92
+ Both return → {"observation": {...}, "reward": float,
93
+ "done": bool, "metadata": {...}}
94
+ """
95
+
96
+ def __init__(self, base_url: str = "http://localhost:8000", timeout: int = 15):
97
+ self.base_url = base_url.rstrip("/")
98
+ self.timeout = timeout
99
+ self.session = requests.Session()
100
+ self.session.headers.update({"Content-Type": "application/json"})
101
+
102
+ # ------------------------------------------------------------------
103
+ def _parse(self, data: Dict[str, Any]) -> PyreObservation:
104
+ """Convert a raw JSON response dict into a PyreObservation."""
105
+ obs_raw = data.get("observation", data)
106
+
107
+ map_state: Optional[PyreMapState] = None
108
+ ms_raw = obs_raw.get("map_state")
109
+ if ms_raw:
110
+ map_state = PyreMapState(**ms_raw)
111
+
112
+ return PyreObservation(
113
+ narrative=obs_raw.get("narrative", ""),
114
+ agent_evacuated=obs_raw.get("agent_evacuated", False),
115
+ location_label=obs_raw.get("location_label", ""),
116
+ smoke_level=obs_raw.get("smoke_level", "none"),
117
+ fire_visible=obs_raw.get("fire_visible", False),
118
+ fire_direction=obs_raw.get("fire_direction"),
119
+ agent_health=float(obs_raw.get("agent_health", 100.0)),
120
+ health_status=obs_raw.get("health_status", "Good"),
121
+ wind_dir=obs_raw.get("wind_dir", "CALM"),
122
+ visible_objects=obs_raw.get("visible_objects", []),
123
+ blocked_exit_ids=obs_raw.get("blocked_exit_ids", []),
124
+ audible_signals=obs_raw.get("audible_signals", []),
125
+ elapsed_steps=obs_raw.get("elapsed_steps", 0),
126
+ last_action_feedback=obs_raw.get("last_action_feedback", ""),
127
+ available_actions_hint=obs_raw.get("available_actions_hint", []),
128
+ map_state=map_state,
129
+ reward=float(data.get("reward", 0.0)),
130
+ done=bool(data.get("done", False)),
131
+ metadata=data.get("metadata", {}),
132
+ )
133
+
134
+ # ------------------------------------------------------------------
135
+ def reset(self, difficulty: str = "easy", seed: Optional[int] = None) -> PyreObservation:
136
+ payload: Dict[str, Any] = {"difficulty": difficulty}
137
+ if seed is not None:
138
+ payload["seed"] = seed
139
+ resp = self.session.post(
140
+ f"{self.base_url}/reset", json=payload, timeout=self.timeout
141
+ )
142
+ resp.raise_for_status()
143
+ return self._parse(resp.json())
144
+
145
+ # ------------------------------------------------------------------
146
+ def step(self, action: PyreAction) -> PyreObservation:
147
+ payload: Dict[str, Any] = {"action": action.action}
148
+ if action.direction is not None:
149
+ payload["direction"] = action.direction
150
+ if action.target_id is not None:
151
+ payload["target_id"] = action.target_id
152
+ if action.door_state is not None:
153
+ payload["door_state"] = action.door_state
154
+ resp = self.session.post(
155
+ f"{self.base_url}/step", json=payload, timeout=self.timeout
156
+ )
157
+ resp.raise_for_status()
158
+ return self._parse(resp.json())
159
+
160
+ # ------------------------------------------------------------------
161
+ def health_check(self) -> bool:
162
+ """Return True if the server is reachable."""
163
+ try:
164
+ r = self.session.get(f"{self.base_url}/state", timeout=5)
165
+ return r.status_code < 500
166
+ except requests.exceptions.RequestException:
167
+ return False
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # Episode runner (identical reward shaping as train_torch_ppo.py)
172
+ # ---------------------------------------------------------------------------
173
+
174
+ @dataclass
175
+ class EpisodeResult:
176
+ total_reward: float
177
+ steps: int
178
+ evacuated: bool
179
+ final_health: float
180
+ difficulty: str
181
+
182
+
183
+ def run_episode(
184
+ env: HttpPyreEnv,
185
+ network: ActorCritic,
186
+ encoder: ObservationEncoder,
187
+ device: torch.device,
188
+ difficulty: str,
189
+ history_length: int,
190
+ buffer: RolloutBuffer,
191
+ deterministic: bool = False,
192
+ ) -> EpisodeResult:
193
+ observation = env.reset(difficulty=difficulty)
194
+ zero_frame = np.zeros(encoder.base_dim, dtype=np.float32)
195
+ frames: deque = deque([zero_frame.copy() for _ in range(history_length)], maxlen=history_length)
196
+ frames.append(encoder.encode(observation))
197
+
198
+ total_reward = 0.0
199
+ final_health = observation.agent_health
200
+ evacuated = False
201
+ steps = 0
202
+ LOOP_WINDOW = 12
203
+ recent_positions: deque = deque(maxlen=LOOP_WINDOW)
204
+
205
+ network.eval()
206
+ with torch.no_grad():
207
+ while True:
208
+ state_vec = np.concatenate(list(frames), dtype=np.float32)
209
+ action_mask = build_action_mask(observation, exclude_look=True)
210
+
211
+ obs_t = torch.tensor(state_vec, dtype=torch.float32, device=device).unsqueeze(0)
212
+ mask_t = torch.tensor(action_mask, dtype=torch.float32, device=device).unsqueeze(0)
213
+
214
+ action_t, log_prob_t, value_t = network.act(obs_t, mask_t, deterministic=deterministic)
215
+
216
+ action_idx = int(action_t.item())
217
+ env_action = action_index_to_env_action(action_idx)
218
+ next_obs = env.step(env_action)
219
+
220
+ reward = float(next_obs.reward or 0.0)
221
+ chosen_action = env_action.action
222
+
223
+ # Shaping 1 — idle penalty
224
+ if chosen_action == "wait":
225
+ reward -= 0.05
226
+
227
+ # Shaping 2 — fire-approach penalty
228
+ ms_next = next_obs.map_state
229
+ if ms_next is not None and chosen_action.startswith("move"):
230
+ ax, ay = ms_next.agent_x, ms_next.agent_y
231
+ gw, gh = ms_next.grid_w, ms_next.grid_h
232
+ for dx, dy in ((0, 1), (0, -1), (1, 0), (-1, 0)):
233
+ nx, ny = ax + dx, ay + dy
234
+ if 0 <= nx < gw and 0 <= ny < gh:
235
+ if float(ms_next.fire_grid[ny * gw + nx]) > 0.15:
236
+ reward -= 0.15
237
+ break
238
+
239
+ # Shaping 3 — anti-loop penalty
240
+ if ms_next is not None and chosen_action.startswith("move"):
241
+ cur_pos = (ms_next.agent_x, ms_next.agent_y)
242
+ if cur_pos in recent_positions:
243
+ reward -= 0.2
244
+ recent_positions.append(cur_pos)
245
+
246
+ done = bool(next_obs.done)
247
+
248
+ buffer.obs.append(state_vec)
249
+ buffer.masks.append(action_mask)
250
+ buffer.actions.append(action_idx)
251
+ buffer.rewards.append(reward)
252
+ buffer.log_probs.append(float(log_prob_t.item()))
253
+ buffer.values.append(float(value_t.item()))
254
+ buffer.dones.append(done)
255
+
256
+ total_reward += reward
257
+ steps += 1
258
+ final_health = next_obs.agent_health
259
+ evacuated = next_obs.agent_evacuated
260
+
261
+ frames.append(encoder.encode(next_obs))
262
+ observation = next_obs
263
+ if done:
264
+ break
265
+
266
+ return EpisodeResult(
267
+ total_reward=total_reward,
268
+ steps=steps,
269
+ evacuated=evacuated,
270
+ final_health=final_health,
271
+ difficulty=difficulty,
272
+ )
273
+
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # Training loop
277
+ # ---------------------------------------------------------------------------
278
+
279
+ def train(args: argparse.Namespace) -> None:
280
+ device = torch.device("cuda" if args.device == "cuda" and torch.cuda.is_available() else "cpu")
281
+ encoder = ObservationEncoder(mode=args.observation_mode)
282
+ input_dim = encoder.base_dim * args.history_length
283
+ hidden_sizes = [int(x) for x in args.hidden_sizes.split(",")]
284
+ action_dim = ACTION_DIM
285
+
286
+ # Connect to server
287
+ env = HttpPyreEnv(base_url=args.server)
288
+ print(f"[server] Connecting to {args.server} ...", end=" ", flush=True)
289
+ if not env.health_check():
290
+ print("FAILED\n[error] Server not reachable. Start it with: python server/app.py")
291
+ sys.exit(1)
292
+ print("OK")
293
+
294
+ # Network
295
+ network = ActorCritic(input_dim, action_dim, hidden_sizes).to(device)
296
+ optimizer = optim.Adam(network.parameters(), lr=args.lr)
297
+
298
+ total_params = sum(p.numel() for p in network.parameters())
299
+ print(f"\n[config] server={args.server}")
300
+ print(f"[config] device={device} episodes={args.episodes} batch={args.update_every} eps")
301
+ print(f"[config] curriculum: {args.difficulty_schedule}")
302
+ print(f"[config] PPO clip_eps={args.clip_eps} entropy={args.entropy_coef} lr={args.lr}")
303
+ print(f"\n[network] Parameters: {total_params:,}")
304
+ print(f"[network] Input dim: {input_dim:,} (encoder.base_dim={encoder.base_dim} x {args.history_length} frames)")
305
+ print(f"[network] Action dim: {action_dim} (4 move + 4 look + 1 wait + {MAX_DOORS} open + {MAX_DOORS} close)\n", flush=True)
306
+
307
+ schedule = args.difficulty_schedule.split(",")
308
+ buffer = RolloutBuffer()
309
+ metrics: list = []
310
+ eval_metrics: list = []
311
+ success_window: deque = deque(maxlen=30)
312
+ reward_window: deque = deque(maxlen=30)
313
+ t0 = time.time()
314
+ lr_scheduler = optim.lr_scheduler.LinearLR(
315
+ optimizer, start_factor=1.0, end_factor=0.1, total_iters=args.episodes
316
+ )
317
+
318
+ for ep in range(1, args.episodes + 1):
319
+ stage_idx = min(int((ep - 1) / args.episodes * len(schedule)), len(schedule) - 1)
320
+ difficulty = schedule[stage_idx]
321
+
322
+ result = run_episode(env, network, encoder, device, difficulty, args.history_length, buffer)
323
+ success_window.append(1 if result.evacuated else 0)
324
+ reward_window.append(result.total_reward)
325
+ suc30 = sum(success_window) / len(success_window)
326
+ r30 = sum(reward_window) / len(reward_window)
327
+ elapsed = int(time.time() - t0)
328
+
329
+ evac_sym = "1" if result.evacuated else "0"
330
+ print(
331
+ f"ep={ep:04d} [{difficulty:<6}] steps={result.steps:03d} "
332
+ f"reward={result.total_reward:+8.3f} evac={evac_sym} "
333
+ f"hp={result.final_health:5.1f} suc30={suc30:.2f} "
334
+ f"r30={r30:+7.2f} t={elapsed}s"
335
+ )
336
+
337
+ metrics.append({
338
+ "episode": ep, "difficulty": difficulty, "steps": result.steps,
339
+ "reward": round(result.total_reward, 4), "evacuated": int(result.evacuated),
340
+ "final_health": result.final_health, "suc30": round(suc30, 3), "r30": round(r30, 3),
341
+ })
342
+
343
+ # PPO update
344
+ if ep % args.update_every == 0 and len(buffer.obs) > 0:
345
+ network.train()
346
+ stats = ppo_update(
347
+ network=network, optimizer=optimizer, buffer=buffer, device=device,
348
+ clip_eps=args.clip_eps, value_clip_eps=args.clip_eps,
349
+ entropy_coef=args.entropy_coef, value_coef=args.value_coef,
350
+ n_epochs=args.update_epochs, minibatch_size=args.minibatch_size,
351
+ gamma=args.gamma, gae_lambda=args.gae_lambda,
352
+ max_grad_norm=args.max_grad_norm,
353
+ )
354
+ lr_scheduler.step()
355
+ cur_lr = optimizer.param_groups[0]["lr"]
356
+ print(
357
+ f" >> PPO update samples=flushed "
358
+ f"pi_loss={stats['policy_loss']:+.4f} v_loss={stats['value_loss']:.4f} "
359
+ f"entropy={stats['entropy']:.4f} kl={stats['approx_kl']:.4f} "
360
+ f"clip%={stats['clip_frac']:.2f} lr={cur_lr:.2e}"
361
+ )
362
+ buffer.clear()
363
+ network.eval()
364
+
365
+ # Evaluation
366
+ if ep % args.eval_every == 0:
367
+ eval_rewards, eval_success, eval_steps_list = [], [], []
368
+ eval_buf = RolloutBuffer()
369
+ for _ in range(args.eval_episodes):
370
+ er = run_episode(
371
+ env, network, encoder, device,
372
+ args.eval_difficulty, args.history_length,
373
+ eval_buf, deterministic=True,
374
+ )
375
+ eval_rewards.append(er.total_reward)
376
+ eval_success.append(1 if er.evacuated else 0)
377
+ eval_steps_list.append(er.steps)
378
+ avg_r = sum(eval_rewards) / len(eval_rewards)
379
+ avg_s = sum(eval_success) / len(eval_success)
380
+ avg_st = sum(eval_steps_list) / len(eval_steps_list)
381
+ print(f" ** EVAL [{args.eval_difficulty}] reward={avg_r:+.3f} success={avg_s:.2f} steps={avg_st:.1f}")
382
+ eval_metrics.append({
383
+ "episode": ep, "eval_difficulty": args.eval_difficulty,
384
+ "avg_reward": round(avg_r, 4), "success_rate": round(avg_s, 3),
385
+ "avg_steps": round(avg_st, 1),
386
+ })
387
+
388
+ # Checkpoint
389
+ if args.checkpoint and ep % args.checkpoint_every == 0:
390
+ torch.save(network.state_dict(), args.checkpoint)
391
+ print(f" [ckpt] saved -> {args.checkpoint}")
392
+
393
+ # --- Save artefacts ---
394
+ out = Path(args.output)
395
+ out.parent.mkdir(parents=True, exist_ok=True)
396
+ torch.save(network.state_dict(), out)
397
+ print(f"\n[done] Model saved -> {out}")
398
+
399
+ if args.save_metrics and metrics:
400
+ csv_path = out.with_suffix(".csv")
401
+ with open(csv_path, "w", newline="") as f:
402
+ writer = csv.DictWriter(f, fieldnames=metrics[0].keys())
403
+ writer.writeheader()
404
+ writer.writerows(metrics)
405
+ print(f"[done] Metrics CSV -> {csv_path}")
406
+
407
+ if eval_metrics:
408
+ eval_csv = out.with_stem(out.stem + "_eval").with_suffix(".csv")
409
+ with open(eval_csv, "w", newline="") as f:
410
+ writer = csv.DictWriter(f, fieldnames=eval_metrics[0].keys())
411
+ writer.writeheader()
412
+ writer.writerows(eval_metrics)
413
+ print(f"[done] Eval CSV -> {eval_csv}")
414
+
415
+ if args.save_graph:
416
+ try:
417
+ png_path = out.with_suffix(".png")
418
+ save_training_graph_png(metrics, eval_metrics, str(png_path))
419
+ print(f"[done] Graph PNG -> {png_path}")
420
+ except Exception as e:
421
+ print(f"[warn] Graph skipped: {e}")
422
+
423
+ suc_final = sum(success_window) / max(1, len(success_window))
424
+ r_final = sum(reward_window) / max(1, len(reward_window))
425
+ elapsed_total = time.time() - t0
426
+ print(f"\n[summary] {args.episodes} episodes in {elapsed_total:.1f}s ({args.episodes / elapsed_total:.1f} eps/s)")
427
+ print(f"[summary] Final success rate (last 30): {suc_final:.2f}")
428
+ print(f"[summary] Final reward mean (last 30): {r_final:+.3f}")
429
+
430
+
431
+ # ---------------------------------------------------------------------------
432
+ # CLI
433
+ # ---------------------------------------------------------------------------
434
+
435
+ def parse_args() -> argparse.Namespace:
436
+ p = argparse.ArgumentParser(
437
+ description="PPO trainer using the Pyre HTTP server (localhost:8000)"
438
+ )
439
+
440
+ # Server
441
+ p.add_argument("--server", type=str, default="http://localhost:8000",
442
+ help="Base URL of the running Pyre env server")
443
+
444
+ # Training
445
+ p.add_argument("--episodes", type=int, default=400)
446
+ p.add_argument("--device", type=str, default="cpu", choices=("cuda", "cpu"))
447
+
448
+ # Curriculum
449
+ p.add_argument("--difficulty-schedule", type=str, default="easy,easy,easy,medium,medium")
450
+ p.add_argument("--eval-difficulty", type=str, default="medium", choices=DIFFICULTIES)
451
+ p.add_argument("--eval-episodes", type=int, default=10)
452
+ p.add_argument("--eval-every", type=int, default=50)
453
+
454
+ # Observation
455
+ p.add_argument("--observation-mode", type=str, default="visible", choices=("visible", "full"))
456
+ p.add_argument("--history-length", type=int, default=4)
457
+
458
+ # Network
459
+ p.add_argument("--hidden-sizes", type=str, default="256,128,64")
460
+
461
+ # PPO
462
+ p.add_argument("--lr", type=float, default=3e-4)
463
+ p.add_argument("--gamma", type=float, default=0.99)
464
+ p.add_argument("--gae-lambda", type=float, default=0.95)
465
+ p.add_argument("--clip-eps", type=float, default=0.2)
466
+ p.add_argument("--value-coef", type=float, default=0.5)
467
+ p.add_argument("--entropy-coef", type=float, default=0.03)
468
+ p.add_argument("--update-every", type=int, default=5)
469
+ p.add_argument("--update-epochs", type=int, default=4)
470
+ p.add_argument("--minibatch-size", type=int, default=256)
471
+ p.add_argument("--max-grad-norm", type=float, default=0.5)
472
+
473
+ # Output
474
+ p.add_argument("--output", type=str, default="artifacts/pyre_ppo_http.pt")
475
+ p.add_argument("--checkpoint", type=str, default="artifacts/pyre_ppo_http_ckpt.pt")
476
+ p.add_argument("--checkpoint-every", type=int, default=50)
477
+ p.add_argument("--save-metrics", action="store_true", default=True)
478
+ p.add_argument("--save-graph", action="store_true", default=True)
479
+ p.add_argument("--seed", type=int, default=42)
480
+
481
+ return p.parse_args()
482
+
483
+
484
+ def main() -> None:
485
+ args = parse_args()
486
+ torch.manual_seed(args.seed)
487
+ np.random.seed(args.seed)
488
+ train(args)
489
+
490
+
491
+ if __name__ == "__main__":
492
+ main()
frontend/README.md ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Pyre — Frontend Visualization
2
+
3
+ A cinematic real-time visualization for the **Pyre Crisis Navigation Environment** — a reinforcement learning environment where an LLM agent navigates a burning building.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ # Open directly in a browser — no build step needed
9
+ open frontend/index.html
10
+ ```
11
+
12
+ The app runs entirely in-browser. **Demo mode** simulates the fire physics in JavaScript (no server required). **Live mode** connects to the deployed environment.
13
+
14
+ ---
15
+
16
+ ## Demo mode vs Live mode
17
+
18
+ | | Demo | Live |
19
+ |---|---|---|
20
+ | Server needed | ✗ | ✓ |
21
+ | Fire physics | JS port (exact match) | Python server |
22
+ | Full reward rubric | Simplified | Complete |
23
+ | Toggle | Default | Click "Live" in topbar |
24
+
25
+ **Live server:** `https://krooz-pyre-env.hf.space`
26
+
27
+ ---
28
+
29
+ ## Controls
30
+
31
+ | Key | Action |
32
+ |---|---|
33
+ | `Space` | Play / pause |
34
+ | `→` | Single step |
35
+ | `R` | New episode |
36
+ | `1`–`5` | Speed ½× / 1× / 2× / 4× / 8× |
37
+
38
+ Bottom bar: difficulty selector, seed input, speed control, reset.
39
+
40
+ ---
41
+
42
+ ## Recording episodes (Python)
43
+
44
+ ```bash
45
+ pip install requests # only stdlib used, no install needed
46
+
47
+ python bridge/recorder.py \
48
+ --url https://krooz-pyre-env.hf.space/web \
49
+ --episodes 10 \
50
+ --difficulty medium \
51
+ --out episodes/
52
+ ```
53
+
54
+ Episodes are saved as JSON files to `episodes/`. Each file contains full frame-by-frame grid data (cell, fire, smoke grids + agent position + visible cells).
55
+
56
+ ---
57
+
58
+ ## File structure
59
+
60
+ ```
61
+ frontend/
62
+ ├── index.html Main app — open this
63
+ └── js/
64
+ ├── sim.js JS port of pyre_env fire simulation + floor plans
65
+ ├── renderer.js Canvas2D rendering (fire particles, fog-of-war, agent trail)
66
+ └── app.js App controller, charts, HUD, live/demo modes
67
+
68
+ bridge/
69
+ └── recorder.py Record live episodes to JSON for replay
70
+ ```
71
+
72
+ ---
73
+
74
+ ## Architecture notes
75
+
76
+ **Rendering:** HTML5 Canvas 2D — sufficient at 60fps for 16×16 grids; additive blending (`globalCompositeOperation: lighter`) for fire glow; ember particle pool (200 max); fog-of-war via per-cell alpha overlay.
77
+
78
+ **Demo agent:** BFS toward nearest unblocked exit, 15% random exploration, avoids fire cells > 0.4 intensity.
79
+
80
+ **Live bridge:** Polls `/web/scene` every 800ms; applies grid state to the same rendering pipeline.
81
+
82
+ ---
83
+
84
+ ## Demo script (30-second stage walkthrough)
85
+
86
+ 1. **Open** `frontend/index.html` — fire simulation starts automatically at 1×
87
+ 2. **Point out** the dark floor plan canvas with glowing fire cells, fog-of-war, and cyan agent dot
88
+ 3. **Slow to ½×** to show per-step fire propagation and smoke spread
89
+ 4. **Speed to 4×** — show agent navigating toward exits (green glow), closing doors (blue bars) to slow fire
90
+ 5. **Highlight** the side panel: cumulative reward curve dipping on smoke exposure, fire cell count climbing, action histogram
91
+ 6. **Describe partial observability** — the dark unexplored cells vs. visible corridor
92
+ 7. **Reset (R)** with a different seed to show episode variety
93
+ 8. If server is available: click **Live** — "Connected" chip turns green, real Python environment takes over
frontend/eslint.config.js ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import tseslint from 'typescript-eslint'
6
+ import { defineConfig, globalIgnores } from 'eslint/config'
7
+
8
+ export default defineConfig([
9
+ globalIgnores(['dist']),
10
+ {
11
+ files: ['**/*.{ts,tsx}'],
12
+ extends: [
13
+ js.configs.recommended,
14
+ tseslint.configs.recommended,
15
+ reactHooks.configs.flat.recommended,
16
+ reactRefresh.configs.vite,
17
+ ],
18
+ languageOptions: {
19
+ globals: globals.browser,
20
+ },
21
+ },
22
+ ])
frontend/index.html ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Pyre — Crisis Navigation</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
11
+ </head>
12
+ <body>
13
+ <div id="root"></div>
14
+ <script type="module" src="/src/main.tsx"></script>
15
+ </body>
16
+ </html>
frontend/package-lock.json ADDED
@@ -0,0 +1,2772 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "version": "0.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "frontend",
9
+ "version": "0.0.0",
10
+ "dependencies": {
11
+ "react": "^19.2.5",
12
+ "react-dom": "^19.2.5"
13
+ },
14
+ "devDependencies": {
15
+ "@eslint/js": "^10.0.1",
16
+ "@types/node": "^24.12.2",
17
+ "@types/react": "^19.2.14",
18
+ "@types/react-dom": "^19.2.3",
19
+ "@vitejs/plugin-react": "^6.0.1",
20
+ "eslint": "^10.2.1",
21
+ "eslint-plugin-react-hooks": "^7.1.1",
22
+ "eslint-plugin-react-refresh": "^0.5.2",
23
+ "globals": "^17.5.0",
24
+ "typescript": "~6.0.2",
25
+ "typescript-eslint": "^8.58.2",
26
+ "vite": "^8.0.10"
27
+ }
28
+ },
29
+ "node_modules/@babel/code-frame": {
30
+ "version": "7.29.0",
31
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
32
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
33
+ "dev": true,
34
+ "license": "MIT",
35
+ "dependencies": {
36
+ "@babel/helper-validator-identifier": "^7.28.5",
37
+ "js-tokens": "^4.0.0",
38
+ "picocolors": "^1.1.1"
39
+ },
40
+ "engines": {
41
+ "node": ">=6.9.0"
42
+ }
43
+ },
44
+ "node_modules/@babel/compat-data": {
45
+ "version": "7.29.0",
46
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
47
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
48
+ "dev": true,
49
+ "license": "MIT",
50
+ "engines": {
51
+ "node": ">=6.9.0"
52
+ }
53
+ },
54
+ "node_modules/@babel/core": {
55
+ "version": "7.29.0",
56
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
57
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
58
+ "dev": true,
59
+ "license": "MIT",
60
+ "dependencies": {
61
+ "@babel/code-frame": "^7.29.0",
62
+ "@babel/generator": "^7.29.0",
63
+ "@babel/helper-compilation-targets": "^7.28.6",
64
+ "@babel/helper-module-transforms": "^7.28.6",
65
+ "@babel/helpers": "^7.28.6",
66
+ "@babel/parser": "^7.29.0",
67
+ "@babel/template": "^7.28.6",
68
+ "@babel/traverse": "^7.29.0",
69
+ "@babel/types": "^7.29.0",
70
+ "@jridgewell/remapping": "^2.3.5",
71
+ "convert-source-map": "^2.0.0",
72
+ "debug": "^4.1.0",
73
+ "gensync": "^1.0.0-beta.2",
74
+ "json5": "^2.2.3",
75
+ "semver": "^6.3.1"
76
+ },
77
+ "engines": {
78
+ "node": ">=6.9.0"
79
+ },
80
+ "funding": {
81
+ "type": "opencollective",
82
+ "url": "https://opencollective.com/babel"
83
+ }
84
+ },
85
+ "node_modules/@babel/generator": {
86
+ "version": "7.29.1",
87
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
88
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
89
+ "dev": true,
90
+ "license": "MIT",
91
+ "dependencies": {
92
+ "@babel/parser": "^7.29.0",
93
+ "@babel/types": "^7.29.0",
94
+ "@jridgewell/gen-mapping": "^0.3.12",
95
+ "@jridgewell/trace-mapping": "^0.3.28",
96
+ "jsesc": "^3.0.2"
97
+ },
98
+ "engines": {
99
+ "node": ">=6.9.0"
100
+ }
101
+ },
102
+ "node_modules/@babel/helper-compilation-targets": {
103
+ "version": "7.28.6",
104
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
105
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
106
+ "dev": true,
107
+ "license": "MIT",
108
+ "dependencies": {
109
+ "@babel/compat-data": "^7.28.6",
110
+ "@babel/helper-validator-option": "^7.27.1",
111
+ "browserslist": "^4.24.0",
112
+ "lru-cache": "^5.1.1",
113
+ "semver": "^6.3.1"
114
+ },
115
+ "engines": {
116
+ "node": ">=6.9.0"
117
+ }
118
+ },
119
+ "node_modules/@babel/helper-globals": {
120
+ "version": "7.28.0",
121
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
122
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
123
+ "dev": true,
124
+ "license": "MIT",
125
+ "engines": {
126
+ "node": ">=6.9.0"
127
+ }
128
+ },
129
+ "node_modules/@babel/helper-module-imports": {
130
+ "version": "7.28.6",
131
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
132
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
133
+ "dev": true,
134
+ "license": "MIT",
135
+ "dependencies": {
136
+ "@babel/traverse": "^7.28.6",
137
+ "@babel/types": "^7.28.6"
138
+ },
139
+ "engines": {
140
+ "node": ">=6.9.0"
141
+ }
142
+ },
143
+ "node_modules/@babel/helper-module-transforms": {
144
+ "version": "7.28.6",
145
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
146
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
147
+ "dev": true,
148
+ "license": "MIT",
149
+ "dependencies": {
150
+ "@babel/helper-module-imports": "^7.28.6",
151
+ "@babel/helper-validator-identifier": "^7.28.5",
152
+ "@babel/traverse": "^7.28.6"
153
+ },
154
+ "engines": {
155
+ "node": ">=6.9.0"
156
+ },
157
+ "peerDependencies": {
158
+ "@babel/core": "^7.0.0"
159
+ }
160
+ },
161
+ "node_modules/@babel/helper-string-parser": {
162
+ "version": "7.27.1",
163
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
164
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
165
+ "dev": true,
166
+ "license": "MIT",
167
+ "engines": {
168
+ "node": ">=6.9.0"
169
+ }
170
+ },
171
+ "node_modules/@babel/helper-validator-identifier": {
172
+ "version": "7.28.5",
173
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
174
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
175
+ "dev": true,
176
+ "license": "MIT",
177
+ "engines": {
178
+ "node": ">=6.9.0"
179
+ }
180
+ },
181
+ "node_modules/@babel/helper-validator-option": {
182
+ "version": "7.27.1",
183
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
184
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
185
+ "dev": true,
186
+ "license": "MIT",
187
+ "engines": {
188
+ "node": ">=6.9.0"
189
+ }
190
+ },
191
+ "node_modules/@babel/helpers": {
192
+ "version": "7.29.2",
193
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
194
+ "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
195
+ "dev": true,
196
+ "license": "MIT",
197
+ "dependencies": {
198
+ "@babel/template": "^7.28.6",
199
+ "@babel/types": "^7.29.0"
200
+ },
201
+ "engines": {
202
+ "node": ">=6.9.0"
203
+ }
204
+ },
205
+ "node_modules/@babel/parser": {
206
+ "version": "7.29.2",
207
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
208
+ "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
209
+ "dev": true,
210
+ "license": "MIT",
211
+ "dependencies": {
212
+ "@babel/types": "^7.29.0"
213
+ },
214
+ "bin": {
215
+ "parser": "bin/babel-parser.js"
216
+ },
217
+ "engines": {
218
+ "node": ">=6.0.0"
219
+ }
220
+ },
221
+ "node_modules/@babel/template": {
222
+ "version": "7.28.6",
223
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
224
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
225
+ "dev": true,
226
+ "license": "MIT",
227
+ "dependencies": {
228
+ "@babel/code-frame": "^7.28.6",
229
+ "@babel/parser": "^7.28.6",
230
+ "@babel/types": "^7.28.6"
231
+ },
232
+ "engines": {
233
+ "node": ">=6.9.0"
234
+ }
235
+ },
236
+ "node_modules/@babel/traverse": {
237
+ "version": "7.29.0",
238
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
239
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
240
+ "dev": true,
241
+ "license": "MIT",
242
+ "dependencies": {
243
+ "@babel/code-frame": "^7.29.0",
244
+ "@babel/generator": "^7.29.0",
245
+ "@babel/helper-globals": "^7.28.0",
246
+ "@babel/parser": "^7.29.0",
247
+ "@babel/template": "^7.28.6",
248
+ "@babel/types": "^7.29.0",
249
+ "debug": "^4.3.1"
250
+ },
251
+ "engines": {
252
+ "node": ">=6.9.0"
253
+ }
254
+ },
255
+ "node_modules/@babel/types": {
256
+ "version": "7.29.0",
257
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
258
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
259
+ "dev": true,
260
+ "license": "MIT",
261
+ "dependencies": {
262
+ "@babel/helper-string-parser": "^7.27.1",
263
+ "@babel/helper-validator-identifier": "^7.28.5"
264
+ },
265
+ "engines": {
266
+ "node": ">=6.9.0"
267
+ }
268
+ },
269
+ "node_modules/@emnapi/core": {
270
+ "version": "1.10.0",
271
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
272
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
273
+ "dev": true,
274
+ "license": "MIT",
275
+ "optional": true,
276
+ "dependencies": {
277
+ "@emnapi/wasi-threads": "1.2.1",
278
+ "tslib": "^2.4.0"
279
+ }
280
+ },
281
+ "node_modules/@emnapi/runtime": {
282
+ "version": "1.10.0",
283
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
284
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
285
+ "dev": true,
286
+ "license": "MIT",
287
+ "optional": true,
288
+ "dependencies": {
289
+ "tslib": "^2.4.0"
290
+ }
291
+ },
292
+ "node_modules/@emnapi/wasi-threads": {
293
+ "version": "1.2.1",
294
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
295
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
296
+ "dev": true,
297
+ "license": "MIT",
298
+ "optional": true,
299
+ "dependencies": {
300
+ "tslib": "^2.4.0"
301
+ }
302
+ },
303
+ "node_modules/@eslint-community/eslint-utils": {
304
+ "version": "4.9.1",
305
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
306
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
307
+ "dev": true,
308
+ "license": "MIT",
309
+ "dependencies": {
310
+ "eslint-visitor-keys": "^3.4.3"
311
+ },
312
+ "engines": {
313
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
314
+ },
315
+ "funding": {
316
+ "url": "https://opencollective.com/eslint"
317
+ },
318
+ "peerDependencies": {
319
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
320
+ }
321
+ },
322
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
323
+ "version": "3.4.3",
324
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
325
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
326
+ "dev": true,
327
+ "license": "Apache-2.0",
328
+ "engines": {
329
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
330
+ },
331
+ "funding": {
332
+ "url": "https://opencollective.com/eslint"
333
+ }
334
+ },
335
+ "node_modules/@eslint-community/regexpp": {
336
+ "version": "4.12.2",
337
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
338
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
339
+ "dev": true,
340
+ "license": "MIT",
341
+ "engines": {
342
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
343
+ }
344
+ },
345
+ "node_modules/@eslint/config-array": {
346
+ "version": "0.23.5",
347
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
348
+ "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
349
+ "dev": true,
350
+ "license": "Apache-2.0",
351
+ "dependencies": {
352
+ "@eslint/object-schema": "^3.0.5",
353
+ "debug": "^4.3.1",
354
+ "minimatch": "^10.2.4"
355
+ },
356
+ "engines": {
357
+ "node": "^20.19.0 || ^22.13.0 || >=24"
358
+ }
359
+ },
360
+ "node_modules/@eslint/config-helpers": {
361
+ "version": "0.5.5",
362
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz",
363
+ "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==",
364
+ "dev": true,
365
+ "license": "Apache-2.0",
366
+ "dependencies": {
367
+ "@eslint/core": "^1.2.1"
368
+ },
369
+ "engines": {
370
+ "node": "^20.19.0 || ^22.13.0 || >=24"
371
+ }
372
+ },
373
+ "node_modules/@eslint/core": {
374
+ "version": "1.2.1",
375
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
376
+ "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
377
+ "dev": true,
378
+ "license": "Apache-2.0",
379
+ "dependencies": {
380
+ "@types/json-schema": "^7.0.15"
381
+ },
382
+ "engines": {
383
+ "node": "^20.19.0 || ^22.13.0 || >=24"
384
+ }
385
+ },
386
+ "node_modules/@eslint/js": {
387
+ "version": "10.0.1",
388
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
389
+ "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
390
+ "dev": true,
391
+ "license": "MIT",
392
+ "engines": {
393
+ "node": "^20.19.0 || ^22.13.0 || >=24"
394
+ },
395
+ "funding": {
396
+ "url": "https://eslint.org/donate"
397
+ },
398
+ "peerDependencies": {
399
+ "eslint": "^10.0.0"
400
+ },
401
+ "peerDependenciesMeta": {
402
+ "eslint": {
403
+ "optional": true
404
+ }
405
+ }
406
+ },
407
+ "node_modules/@eslint/object-schema": {
408
+ "version": "3.0.5",
409
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
410
+ "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
411
+ "dev": true,
412
+ "license": "Apache-2.0",
413
+ "engines": {
414
+ "node": "^20.19.0 || ^22.13.0 || >=24"
415
+ }
416
+ },
417
+ "node_modules/@eslint/plugin-kit": {
418
+ "version": "0.7.1",
419
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz",
420
+ "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==",
421
+ "dev": true,
422
+ "license": "Apache-2.0",
423
+ "dependencies": {
424
+ "@eslint/core": "^1.2.1",
425
+ "levn": "^0.4.1"
426
+ },
427
+ "engines": {
428
+ "node": "^20.19.0 || ^22.13.0 || >=24"
429
+ }
430
+ },
431
+ "node_modules/@humanfs/core": {
432
+ "version": "0.19.2",
433
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
434
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
435
+ "dev": true,
436
+ "license": "Apache-2.0",
437
+ "dependencies": {
438
+ "@humanfs/types": "^0.15.0"
439
+ },
440
+ "engines": {
441
+ "node": ">=18.18.0"
442
+ }
443
+ },
444
+ "node_modules/@humanfs/node": {
445
+ "version": "0.16.8",
446
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
447
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
448
+ "dev": true,
449
+ "license": "Apache-2.0",
450
+ "dependencies": {
451
+ "@humanfs/core": "^0.19.2",
452
+ "@humanfs/types": "^0.15.0",
453
+ "@humanwhocodes/retry": "^0.4.0"
454
+ },
455
+ "engines": {
456
+ "node": ">=18.18.0"
457
+ }
458
+ },
459
+ "node_modules/@humanfs/types": {
460
+ "version": "0.15.0",
461
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
462
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
463
+ "dev": true,
464
+ "license": "Apache-2.0",
465
+ "engines": {
466
+ "node": ">=18.18.0"
467
+ }
468
+ },
469
+ "node_modules/@humanwhocodes/module-importer": {
470
+ "version": "1.0.1",
471
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
472
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
473
+ "dev": true,
474
+ "license": "Apache-2.0",
475
+ "engines": {
476
+ "node": ">=12.22"
477
+ },
478
+ "funding": {
479
+ "type": "github",
480
+ "url": "https://github.com/sponsors/nzakas"
481
+ }
482
+ },
483
+ "node_modules/@humanwhocodes/retry": {
484
+ "version": "0.4.3",
485
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
486
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
487
+ "dev": true,
488
+ "license": "Apache-2.0",
489
+ "engines": {
490
+ "node": ">=18.18"
491
+ },
492
+ "funding": {
493
+ "type": "github",
494
+ "url": "https://github.com/sponsors/nzakas"
495
+ }
496
+ },
497
+ "node_modules/@jridgewell/gen-mapping": {
498
+ "version": "0.3.13",
499
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
500
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
501
+ "dev": true,
502
+ "license": "MIT",
503
+ "dependencies": {
504
+ "@jridgewell/sourcemap-codec": "^1.5.0",
505
+ "@jridgewell/trace-mapping": "^0.3.24"
506
+ }
507
+ },
508
+ "node_modules/@jridgewell/remapping": {
509
+ "version": "2.3.5",
510
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
511
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
512
+ "dev": true,
513
+ "license": "MIT",
514
+ "dependencies": {
515
+ "@jridgewell/gen-mapping": "^0.3.5",
516
+ "@jridgewell/trace-mapping": "^0.3.24"
517
+ }
518
+ },
519
+ "node_modules/@jridgewell/resolve-uri": {
520
+ "version": "3.1.2",
521
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
522
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
523
+ "dev": true,
524
+ "license": "MIT",
525
+ "engines": {
526
+ "node": ">=6.0.0"
527
+ }
528
+ },
529
+ "node_modules/@jridgewell/sourcemap-codec": {
530
+ "version": "1.5.5",
531
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
532
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
533
+ "dev": true,
534
+ "license": "MIT"
535
+ },
536
+ "node_modules/@jridgewell/trace-mapping": {
537
+ "version": "0.3.31",
538
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
539
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
540
+ "dev": true,
541
+ "license": "MIT",
542
+ "dependencies": {
543
+ "@jridgewell/resolve-uri": "^3.1.0",
544
+ "@jridgewell/sourcemap-codec": "^1.4.14"
545
+ }
546
+ },
547
+ "node_modules/@napi-rs/wasm-runtime": {
548
+ "version": "1.1.4",
549
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
550
+ "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
551
+ "dev": true,
552
+ "license": "MIT",
553
+ "optional": true,
554
+ "dependencies": {
555
+ "@tybys/wasm-util": "^0.10.1"
556
+ },
557
+ "funding": {
558
+ "type": "github",
559
+ "url": "https://github.com/sponsors/Brooooooklyn"
560
+ },
561
+ "peerDependencies": {
562
+ "@emnapi/core": "^1.7.1",
563
+ "@emnapi/runtime": "^1.7.1"
564
+ }
565
+ },
566
+ "node_modules/@oxc-project/types": {
567
+ "version": "0.127.0",
568
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
569
+ "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
570
+ "dev": true,
571
+ "license": "MIT",
572
+ "funding": {
573
+ "url": "https://github.com/sponsors/Boshen"
574
+ }
575
+ },
576
+ "node_modules/@rolldown/binding-android-arm64": {
577
+ "version": "1.0.0-rc.17",
578
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
579
+ "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
580
+ "cpu": [
581
+ "arm64"
582
+ ],
583
+ "dev": true,
584
+ "license": "MIT",
585
+ "optional": true,
586
+ "os": [
587
+ "android"
588
+ ],
589
+ "engines": {
590
+ "node": "^20.19.0 || >=22.12.0"
591
+ }
592
+ },
593
+ "node_modules/@rolldown/binding-darwin-arm64": {
594
+ "version": "1.0.0-rc.17",
595
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
596
+ "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
597
+ "cpu": [
598
+ "arm64"
599
+ ],
600
+ "dev": true,
601
+ "license": "MIT",
602
+ "optional": true,
603
+ "os": [
604
+ "darwin"
605
+ ],
606
+ "engines": {
607
+ "node": "^20.19.0 || >=22.12.0"
608
+ }
609
+ },
610
+ "node_modules/@rolldown/binding-darwin-x64": {
611
+ "version": "1.0.0-rc.17",
612
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
613
+ "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
614
+ "cpu": [
615
+ "x64"
616
+ ],
617
+ "dev": true,
618
+ "license": "MIT",
619
+ "optional": true,
620
+ "os": [
621
+ "darwin"
622
+ ],
623
+ "engines": {
624
+ "node": "^20.19.0 || >=22.12.0"
625
+ }
626
+ },
627
+ "node_modules/@rolldown/binding-freebsd-x64": {
628
+ "version": "1.0.0-rc.17",
629
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
630
+ "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
631
+ "cpu": [
632
+ "x64"
633
+ ],
634
+ "dev": true,
635
+ "license": "MIT",
636
+ "optional": true,
637
+ "os": [
638
+ "freebsd"
639
+ ],
640
+ "engines": {
641
+ "node": "^20.19.0 || >=22.12.0"
642
+ }
643
+ },
644
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
645
+ "version": "1.0.0-rc.17",
646
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
647
+ "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
648
+ "cpu": [
649
+ "arm"
650
+ ],
651
+ "dev": true,
652
+ "license": "MIT",
653
+ "optional": true,
654
+ "os": [
655
+ "linux"
656
+ ],
657
+ "engines": {
658
+ "node": "^20.19.0 || >=22.12.0"
659
+ }
660
+ },
661
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
662
+ "version": "1.0.0-rc.17",
663
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
664
+ "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
665
+ "cpu": [
666
+ "arm64"
667
+ ],
668
+ "dev": true,
669
+ "libc": [
670
+ "glibc"
671
+ ],
672
+ "license": "MIT",
673
+ "optional": true,
674
+ "os": [
675
+ "linux"
676
+ ],
677
+ "engines": {
678
+ "node": "^20.19.0 || >=22.12.0"
679
+ }
680
+ },
681
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
682
+ "version": "1.0.0-rc.17",
683
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
684
+ "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
685
+ "cpu": [
686
+ "arm64"
687
+ ],
688
+ "dev": true,
689
+ "libc": [
690
+ "musl"
691
+ ],
692
+ "license": "MIT",
693
+ "optional": true,
694
+ "os": [
695
+ "linux"
696
+ ],
697
+ "engines": {
698
+ "node": "^20.19.0 || >=22.12.0"
699
+ }
700
+ },
701
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
702
+ "version": "1.0.0-rc.17",
703
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
704
+ "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
705
+ "cpu": [
706
+ "ppc64"
707
+ ],
708
+ "dev": true,
709
+ "libc": [
710
+ "glibc"
711
+ ],
712
+ "license": "MIT",
713
+ "optional": true,
714
+ "os": [
715
+ "linux"
716
+ ],
717
+ "engines": {
718
+ "node": "^20.19.0 || >=22.12.0"
719
+ }
720
+ },
721
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
722
+ "version": "1.0.0-rc.17",
723
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
724
+ "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
725
+ "cpu": [
726
+ "s390x"
727
+ ],
728
+ "dev": true,
729
+ "libc": [
730
+ "glibc"
731
+ ],
732
+ "license": "MIT",
733
+ "optional": true,
734
+ "os": [
735
+ "linux"
736
+ ],
737
+ "engines": {
738
+ "node": "^20.19.0 || >=22.12.0"
739
+ }
740
+ },
741
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
742
+ "version": "1.0.0-rc.17",
743
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
744
+ "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
745
+ "cpu": [
746
+ "x64"
747
+ ],
748
+ "dev": true,
749
+ "libc": [
750
+ "glibc"
751
+ ],
752
+ "license": "MIT",
753
+ "optional": true,
754
+ "os": [
755
+ "linux"
756
+ ],
757
+ "engines": {
758
+ "node": "^20.19.0 || >=22.12.0"
759
+ }
760
+ },
761
+ "node_modules/@rolldown/binding-linux-x64-musl": {
762
+ "version": "1.0.0-rc.17",
763
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
764
+ "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
765
+ "cpu": [
766
+ "x64"
767
+ ],
768
+ "dev": true,
769
+ "libc": [
770
+ "musl"
771
+ ],
772
+ "license": "MIT",
773
+ "optional": true,
774
+ "os": [
775
+ "linux"
776
+ ],
777
+ "engines": {
778
+ "node": "^20.19.0 || >=22.12.0"
779
+ }
780
+ },
781
+ "node_modules/@rolldown/binding-openharmony-arm64": {
782
+ "version": "1.0.0-rc.17",
783
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
784
+ "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
785
+ "cpu": [
786
+ "arm64"
787
+ ],
788
+ "dev": true,
789
+ "license": "MIT",
790
+ "optional": true,
791
+ "os": [
792
+ "openharmony"
793
+ ],
794
+ "engines": {
795
+ "node": "^20.19.0 || >=22.12.0"
796
+ }
797
+ },
798
+ "node_modules/@rolldown/binding-wasm32-wasi": {
799
+ "version": "1.0.0-rc.17",
800
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
801
+ "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
802
+ "cpu": [
803
+ "wasm32"
804
+ ],
805
+ "dev": true,
806
+ "license": "MIT",
807
+ "optional": true,
808
+ "dependencies": {
809
+ "@emnapi/core": "1.10.0",
810
+ "@emnapi/runtime": "1.10.0",
811
+ "@napi-rs/wasm-runtime": "^1.1.4"
812
+ },
813
+ "engines": {
814
+ "node": "^20.19.0 || >=22.12.0"
815
+ }
816
+ },
817
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
818
+ "version": "1.0.0-rc.17",
819
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
820
+ "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
821
+ "cpu": [
822
+ "arm64"
823
+ ],
824
+ "dev": true,
825
+ "license": "MIT",
826
+ "optional": true,
827
+ "os": [
828
+ "win32"
829
+ ],
830
+ "engines": {
831
+ "node": "^20.19.0 || >=22.12.0"
832
+ }
833
+ },
834
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
835
+ "version": "1.0.0-rc.17",
836
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
837
+ "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
838
+ "cpu": [
839
+ "x64"
840
+ ],
841
+ "dev": true,
842
+ "license": "MIT",
843
+ "optional": true,
844
+ "os": [
845
+ "win32"
846
+ ],
847
+ "engines": {
848
+ "node": "^20.19.0 || >=22.12.0"
849
+ }
850
+ },
851
+ "node_modules/@rolldown/pluginutils": {
852
+ "version": "1.0.0-rc.7",
853
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz",
854
+ "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==",
855
+ "dev": true,
856
+ "license": "MIT"
857
+ },
858
+ "node_modules/@tybys/wasm-util": {
859
+ "version": "0.10.1",
860
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
861
+ "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
862
+ "dev": true,
863
+ "license": "MIT",
864
+ "optional": true,
865
+ "dependencies": {
866
+ "tslib": "^2.4.0"
867
+ }
868
+ },
869
+ "node_modules/@types/esrecurse": {
870
+ "version": "4.3.1",
871
+ "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
872
+ "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
873
+ "dev": true,
874
+ "license": "MIT"
875
+ },
876
+ "node_modules/@types/estree": {
877
+ "version": "1.0.8",
878
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
879
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
880
+ "dev": true,
881
+ "license": "MIT"
882
+ },
883
+ "node_modules/@types/json-schema": {
884
+ "version": "7.0.15",
885
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
886
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
887
+ "dev": true,
888
+ "license": "MIT"
889
+ },
890
+ "node_modules/@types/node": {
891
+ "version": "24.12.2",
892
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
893
+ "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
894
+ "dev": true,
895
+ "license": "MIT",
896
+ "dependencies": {
897
+ "undici-types": "~7.16.0"
898
+ }
899
+ },
900
+ "node_modules/@types/react": {
901
+ "version": "19.2.14",
902
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
903
+ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
904
+ "dev": true,
905
+ "license": "MIT",
906
+ "dependencies": {
907
+ "csstype": "^3.2.2"
908
+ }
909
+ },
910
+ "node_modules/@types/react-dom": {
911
+ "version": "19.2.3",
912
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
913
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
914
+ "dev": true,
915
+ "license": "MIT",
916
+ "peerDependencies": {
917
+ "@types/react": "^19.2.0"
918
+ }
919
+ },
920
+ "node_modules/@typescript-eslint/eslint-plugin": {
921
+ "version": "8.59.0",
922
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz",
923
+ "integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==",
924
+ "dev": true,
925
+ "license": "MIT",
926
+ "dependencies": {
927
+ "@eslint-community/regexpp": "^4.12.2",
928
+ "@typescript-eslint/scope-manager": "8.59.0",
929
+ "@typescript-eslint/type-utils": "8.59.0",
930
+ "@typescript-eslint/utils": "8.59.0",
931
+ "@typescript-eslint/visitor-keys": "8.59.0",
932
+ "ignore": "^7.0.5",
933
+ "natural-compare": "^1.4.0",
934
+ "ts-api-utils": "^2.5.0"
935
+ },
936
+ "engines": {
937
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
938
+ },
939
+ "funding": {
940
+ "type": "opencollective",
941
+ "url": "https://opencollective.com/typescript-eslint"
942
+ },
943
+ "peerDependencies": {
944
+ "@typescript-eslint/parser": "^8.59.0",
945
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
946
+ "typescript": ">=4.8.4 <6.1.0"
947
+ }
948
+ },
949
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
950
+ "version": "7.0.5",
951
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
952
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
953
+ "dev": true,
954
+ "license": "MIT",
955
+ "engines": {
956
+ "node": ">= 4"
957
+ }
958
+ },
959
+ "node_modules/@typescript-eslint/parser": {
960
+ "version": "8.59.0",
961
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz",
962
+ "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==",
963
+ "dev": true,
964
+ "license": "MIT",
965
+ "dependencies": {
966
+ "@typescript-eslint/scope-manager": "8.59.0",
967
+ "@typescript-eslint/types": "8.59.0",
968
+ "@typescript-eslint/typescript-estree": "8.59.0",
969
+ "@typescript-eslint/visitor-keys": "8.59.0",
970
+ "debug": "^4.4.3"
971
+ },
972
+ "engines": {
973
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
974
+ },
975
+ "funding": {
976
+ "type": "opencollective",
977
+ "url": "https://opencollective.com/typescript-eslint"
978
+ },
979
+ "peerDependencies": {
980
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
981
+ "typescript": ">=4.8.4 <6.1.0"
982
+ }
983
+ },
984
+ "node_modules/@typescript-eslint/project-service": {
985
+ "version": "8.59.0",
986
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz",
987
+ "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==",
988
+ "dev": true,
989
+ "license": "MIT",
990
+ "dependencies": {
991
+ "@typescript-eslint/tsconfig-utils": "^8.59.0",
992
+ "@typescript-eslint/types": "^8.59.0",
993
+ "debug": "^4.4.3"
994
+ },
995
+ "engines": {
996
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
997
+ },
998
+ "funding": {
999
+ "type": "opencollective",
1000
+ "url": "https://opencollective.com/typescript-eslint"
1001
+ },
1002
+ "peerDependencies": {
1003
+ "typescript": ">=4.8.4 <6.1.0"
1004
+ }
1005
+ },
1006
+ "node_modules/@typescript-eslint/scope-manager": {
1007
+ "version": "8.59.0",
1008
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz",
1009
+ "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==",
1010
+ "dev": true,
1011
+ "license": "MIT",
1012
+ "dependencies": {
1013
+ "@typescript-eslint/types": "8.59.0",
1014
+ "@typescript-eslint/visitor-keys": "8.59.0"
1015
+ },
1016
+ "engines": {
1017
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1018
+ },
1019
+ "funding": {
1020
+ "type": "opencollective",
1021
+ "url": "https://opencollective.com/typescript-eslint"
1022
+ }
1023
+ },
1024
+ "node_modules/@typescript-eslint/tsconfig-utils": {
1025
+ "version": "8.59.0",
1026
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz",
1027
+ "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==",
1028
+ "dev": true,
1029
+ "license": "MIT",
1030
+ "engines": {
1031
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1032
+ },
1033
+ "funding": {
1034
+ "type": "opencollective",
1035
+ "url": "https://opencollective.com/typescript-eslint"
1036
+ },
1037
+ "peerDependencies": {
1038
+ "typescript": ">=4.8.4 <6.1.0"
1039
+ }
1040
+ },
1041
+ "node_modules/@typescript-eslint/type-utils": {
1042
+ "version": "8.59.0",
1043
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz",
1044
+ "integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==",
1045
+ "dev": true,
1046
+ "license": "MIT",
1047
+ "dependencies": {
1048
+ "@typescript-eslint/types": "8.59.0",
1049
+ "@typescript-eslint/typescript-estree": "8.59.0",
1050
+ "@typescript-eslint/utils": "8.59.0",
1051
+ "debug": "^4.4.3",
1052
+ "ts-api-utils": "^2.5.0"
1053
+ },
1054
+ "engines": {
1055
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1056
+ },
1057
+ "funding": {
1058
+ "type": "opencollective",
1059
+ "url": "https://opencollective.com/typescript-eslint"
1060
+ },
1061
+ "peerDependencies": {
1062
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
1063
+ "typescript": ">=4.8.4 <6.1.0"
1064
+ }
1065
+ },
1066
+ "node_modules/@typescript-eslint/types": {
1067
+ "version": "8.59.0",
1068
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz",
1069
+ "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==",
1070
+ "dev": true,
1071
+ "license": "MIT",
1072
+ "engines": {
1073
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1074
+ },
1075
+ "funding": {
1076
+ "type": "opencollective",
1077
+ "url": "https://opencollective.com/typescript-eslint"
1078
+ }
1079
+ },
1080
+ "node_modules/@typescript-eslint/typescript-estree": {
1081
+ "version": "8.59.0",
1082
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz",
1083
+ "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==",
1084
+ "dev": true,
1085
+ "license": "MIT",
1086
+ "dependencies": {
1087
+ "@typescript-eslint/project-service": "8.59.0",
1088
+ "@typescript-eslint/tsconfig-utils": "8.59.0",
1089
+ "@typescript-eslint/types": "8.59.0",
1090
+ "@typescript-eslint/visitor-keys": "8.59.0",
1091
+ "debug": "^4.4.3",
1092
+ "minimatch": "^10.2.2",
1093
+ "semver": "^7.7.3",
1094
+ "tinyglobby": "^0.2.15",
1095
+ "ts-api-utils": "^2.5.0"
1096
+ },
1097
+ "engines": {
1098
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1099
+ },
1100
+ "funding": {
1101
+ "type": "opencollective",
1102
+ "url": "https://opencollective.com/typescript-eslint"
1103
+ },
1104
+ "peerDependencies": {
1105
+ "typescript": ">=4.8.4 <6.1.0"
1106
+ }
1107
+ },
1108
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
1109
+ "version": "7.7.4",
1110
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
1111
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
1112
+ "dev": true,
1113
+ "license": "ISC",
1114
+ "bin": {
1115
+ "semver": "bin/semver.js"
1116
+ },
1117
+ "engines": {
1118
+ "node": ">=10"
1119
+ }
1120
+ },
1121
+ "node_modules/@typescript-eslint/utils": {
1122
+ "version": "8.59.0",
1123
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz",
1124
+ "integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==",
1125
+ "dev": true,
1126
+ "license": "MIT",
1127
+ "dependencies": {
1128
+ "@eslint-community/eslint-utils": "^4.9.1",
1129
+ "@typescript-eslint/scope-manager": "8.59.0",
1130
+ "@typescript-eslint/types": "8.59.0",
1131
+ "@typescript-eslint/typescript-estree": "8.59.0"
1132
+ },
1133
+ "engines": {
1134
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1135
+ },
1136
+ "funding": {
1137
+ "type": "opencollective",
1138
+ "url": "https://opencollective.com/typescript-eslint"
1139
+ },
1140
+ "peerDependencies": {
1141
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
1142
+ "typescript": ">=4.8.4 <6.1.0"
1143
+ }
1144
+ },
1145
+ "node_modules/@typescript-eslint/visitor-keys": {
1146
+ "version": "8.59.0",
1147
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz",
1148
+ "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==",
1149
+ "dev": true,
1150
+ "license": "MIT",
1151
+ "dependencies": {
1152
+ "@typescript-eslint/types": "8.59.0",
1153
+ "eslint-visitor-keys": "^5.0.0"
1154
+ },
1155
+ "engines": {
1156
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1157
+ },
1158
+ "funding": {
1159
+ "type": "opencollective",
1160
+ "url": "https://opencollective.com/typescript-eslint"
1161
+ }
1162
+ },
1163
+ "node_modules/@vitejs/plugin-react": {
1164
+ "version": "6.0.1",
1165
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
1166
+ "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==",
1167
+ "dev": true,
1168
+ "license": "MIT",
1169
+ "dependencies": {
1170
+ "@rolldown/pluginutils": "1.0.0-rc.7"
1171
+ },
1172
+ "engines": {
1173
+ "node": "^20.19.0 || >=22.12.0"
1174
+ },
1175
+ "peerDependencies": {
1176
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
1177
+ "babel-plugin-react-compiler": "^1.0.0",
1178
+ "vite": "^8.0.0"
1179
+ },
1180
+ "peerDependenciesMeta": {
1181
+ "@rolldown/plugin-babel": {
1182
+ "optional": true
1183
+ },
1184
+ "babel-plugin-react-compiler": {
1185
+ "optional": true
1186
+ }
1187
+ }
1188
+ },
1189
+ "node_modules/acorn": {
1190
+ "version": "8.16.0",
1191
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
1192
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
1193
+ "dev": true,
1194
+ "license": "MIT",
1195
+ "bin": {
1196
+ "acorn": "bin/acorn"
1197
+ },
1198
+ "engines": {
1199
+ "node": ">=0.4.0"
1200
+ }
1201
+ },
1202
+ "node_modules/acorn-jsx": {
1203
+ "version": "5.3.2",
1204
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
1205
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
1206
+ "dev": true,
1207
+ "license": "MIT",
1208
+ "peerDependencies": {
1209
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
1210
+ }
1211
+ },
1212
+ "node_modules/ajv": {
1213
+ "version": "6.15.0",
1214
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
1215
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
1216
+ "dev": true,
1217
+ "license": "MIT",
1218
+ "dependencies": {
1219
+ "fast-deep-equal": "^3.1.1",
1220
+ "fast-json-stable-stringify": "^2.0.0",
1221
+ "json-schema-traverse": "^0.4.1",
1222
+ "uri-js": "^4.2.2"
1223
+ },
1224
+ "funding": {
1225
+ "type": "github",
1226
+ "url": "https://github.com/sponsors/epoberezkin"
1227
+ }
1228
+ },
1229
+ "node_modules/balanced-match": {
1230
+ "version": "4.0.4",
1231
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
1232
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
1233
+ "dev": true,
1234
+ "license": "MIT",
1235
+ "engines": {
1236
+ "node": "18 || 20 || >=22"
1237
+ }
1238
+ },
1239
+ "node_modules/baseline-browser-mapping": {
1240
+ "version": "2.10.22",
1241
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.22.tgz",
1242
+ "integrity": "sha512-6qruVrb5rse6WylFkU0FhBKKGuecWseqdpQfhkawn6ztyk2QlfwSRjsDxMCLJrkfmfN21qvhl9ABgaMeRkuwww==",
1243
+ "dev": true,
1244
+ "license": "Apache-2.0",
1245
+ "bin": {
1246
+ "baseline-browser-mapping": "dist/cli.cjs"
1247
+ },
1248
+ "engines": {
1249
+ "node": ">=6.0.0"
1250
+ }
1251
+ },
1252
+ "node_modules/brace-expansion": {
1253
+ "version": "5.0.5",
1254
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
1255
+ "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
1256
+ "dev": true,
1257
+ "license": "MIT",
1258
+ "dependencies": {
1259
+ "balanced-match": "^4.0.2"
1260
+ },
1261
+ "engines": {
1262
+ "node": "18 || 20 || >=22"
1263
+ }
1264
+ },
1265
+ "node_modules/browserslist": {
1266
+ "version": "4.28.2",
1267
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
1268
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
1269
+ "dev": true,
1270
+ "funding": [
1271
+ {
1272
+ "type": "opencollective",
1273
+ "url": "https://opencollective.com/browserslist"
1274
+ },
1275
+ {
1276
+ "type": "tidelift",
1277
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
1278
+ },
1279
+ {
1280
+ "type": "github",
1281
+ "url": "https://github.com/sponsors/ai"
1282
+ }
1283
+ ],
1284
+ "license": "MIT",
1285
+ "dependencies": {
1286
+ "baseline-browser-mapping": "^2.10.12",
1287
+ "caniuse-lite": "^1.0.30001782",
1288
+ "electron-to-chromium": "^1.5.328",
1289
+ "node-releases": "^2.0.36",
1290
+ "update-browserslist-db": "^1.2.3"
1291
+ },
1292
+ "bin": {
1293
+ "browserslist": "cli.js"
1294
+ },
1295
+ "engines": {
1296
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1297
+ }
1298
+ },
1299
+ "node_modules/caniuse-lite": {
1300
+ "version": "1.0.30001790",
1301
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz",
1302
+ "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==",
1303
+ "dev": true,
1304
+ "funding": [
1305
+ {
1306
+ "type": "opencollective",
1307
+ "url": "https://opencollective.com/browserslist"
1308
+ },
1309
+ {
1310
+ "type": "tidelift",
1311
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1312
+ },
1313
+ {
1314
+ "type": "github",
1315
+ "url": "https://github.com/sponsors/ai"
1316
+ }
1317
+ ],
1318
+ "license": "CC-BY-4.0"
1319
+ },
1320
+ "node_modules/convert-source-map": {
1321
+ "version": "2.0.0",
1322
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
1323
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1324
+ "dev": true,
1325
+ "license": "MIT"
1326
+ },
1327
+ "node_modules/cross-spawn": {
1328
+ "version": "7.0.6",
1329
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
1330
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
1331
+ "dev": true,
1332
+ "license": "MIT",
1333
+ "dependencies": {
1334
+ "path-key": "^3.1.0",
1335
+ "shebang-command": "^2.0.0",
1336
+ "which": "^2.0.1"
1337
+ },
1338
+ "engines": {
1339
+ "node": ">= 8"
1340
+ }
1341
+ },
1342
+ "node_modules/csstype": {
1343
+ "version": "3.2.3",
1344
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1345
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1346
+ "dev": true,
1347
+ "license": "MIT"
1348
+ },
1349
+ "node_modules/debug": {
1350
+ "version": "4.4.3",
1351
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1352
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1353
+ "dev": true,
1354
+ "license": "MIT",
1355
+ "dependencies": {
1356
+ "ms": "^2.1.3"
1357
+ },
1358
+ "engines": {
1359
+ "node": ">=6.0"
1360
+ },
1361
+ "peerDependenciesMeta": {
1362
+ "supports-color": {
1363
+ "optional": true
1364
+ }
1365
+ }
1366
+ },
1367
+ "node_modules/deep-is": {
1368
+ "version": "0.1.4",
1369
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
1370
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
1371
+ "dev": true,
1372
+ "license": "MIT"
1373
+ },
1374
+ "node_modules/detect-libc": {
1375
+ "version": "2.1.2",
1376
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
1377
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
1378
+ "dev": true,
1379
+ "license": "Apache-2.0",
1380
+ "engines": {
1381
+ "node": ">=8"
1382
+ }
1383
+ },
1384
+ "node_modules/electron-to-chromium": {
1385
+ "version": "1.5.344",
1386
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz",
1387
+ "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==",
1388
+ "dev": true,
1389
+ "license": "ISC"
1390
+ },
1391
+ "node_modules/escalade": {
1392
+ "version": "3.2.0",
1393
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1394
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1395
+ "dev": true,
1396
+ "license": "MIT",
1397
+ "engines": {
1398
+ "node": ">=6"
1399
+ }
1400
+ },
1401
+ "node_modules/escape-string-regexp": {
1402
+ "version": "4.0.0",
1403
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
1404
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
1405
+ "dev": true,
1406
+ "license": "MIT",
1407
+ "engines": {
1408
+ "node": ">=10"
1409
+ },
1410
+ "funding": {
1411
+ "url": "https://github.com/sponsors/sindresorhus"
1412
+ }
1413
+ },
1414
+ "node_modules/eslint": {
1415
+ "version": "10.2.1",
1416
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz",
1417
+ "integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==",
1418
+ "dev": true,
1419
+ "license": "MIT",
1420
+ "dependencies": {
1421
+ "@eslint-community/eslint-utils": "^4.8.0",
1422
+ "@eslint-community/regexpp": "^4.12.2",
1423
+ "@eslint/config-array": "^0.23.5",
1424
+ "@eslint/config-helpers": "^0.5.5",
1425
+ "@eslint/core": "^1.2.1",
1426
+ "@eslint/plugin-kit": "^0.7.1",
1427
+ "@humanfs/node": "^0.16.6",
1428
+ "@humanwhocodes/module-importer": "^1.0.1",
1429
+ "@humanwhocodes/retry": "^0.4.2",
1430
+ "@types/estree": "^1.0.6",
1431
+ "ajv": "^6.14.0",
1432
+ "cross-spawn": "^7.0.6",
1433
+ "debug": "^4.3.2",
1434
+ "escape-string-regexp": "^4.0.0",
1435
+ "eslint-scope": "^9.1.2",
1436
+ "eslint-visitor-keys": "^5.0.1",
1437
+ "espree": "^11.2.0",
1438
+ "esquery": "^1.7.0",
1439
+ "esutils": "^2.0.2",
1440
+ "fast-deep-equal": "^3.1.3",
1441
+ "file-entry-cache": "^8.0.0",
1442
+ "find-up": "^5.0.0",
1443
+ "glob-parent": "^6.0.2",
1444
+ "ignore": "^5.2.0",
1445
+ "imurmurhash": "^0.1.4",
1446
+ "is-glob": "^4.0.0",
1447
+ "json-stable-stringify-without-jsonify": "^1.0.1",
1448
+ "minimatch": "^10.2.4",
1449
+ "natural-compare": "^1.4.0",
1450
+ "optionator": "^0.9.3"
1451
+ },
1452
+ "bin": {
1453
+ "eslint": "bin/eslint.js"
1454
+ },
1455
+ "engines": {
1456
+ "node": "^20.19.0 || ^22.13.0 || >=24"
1457
+ },
1458
+ "funding": {
1459
+ "url": "https://eslint.org/donate"
1460
+ },
1461
+ "peerDependencies": {
1462
+ "jiti": "*"
1463
+ },
1464
+ "peerDependenciesMeta": {
1465
+ "jiti": {
1466
+ "optional": true
1467
+ }
1468
+ }
1469
+ },
1470
+ "node_modules/eslint-plugin-react-hooks": {
1471
+ "version": "7.1.1",
1472
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
1473
+ "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
1474
+ "dev": true,
1475
+ "license": "MIT",
1476
+ "dependencies": {
1477
+ "@babel/core": "^7.24.4",
1478
+ "@babel/parser": "^7.24.4",
1479
+ "hermes-parser": "^0.25.1",
1480
+ "zod": "^3.25.0 || ^4.0.0",
1481
+ "zod-validation-error": "^3.5.0 || ^4.0.0"
1482
+ },
1483
+ "engines": {
1484
+ "node": ">=18"
1485
+ },
1486
+ "peerDependencies": {
1487
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
1488
+ }
1489
+ },
1490
+ "node_modules/eslint-plugin-react-refresh": {
1491
+ "version": "0.5.2",
1492
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz",
1493
+ "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==",
1494
+ "dev": true,
1495
+ "license": "MIT",
1496
+ "peerDependencies": {
1497
+ "eslint": "^9 || ^10"
1498
+ }
1499
+ },
1500
+ "node_modules/eslint-scope": {
1501
+ "version": "9.1.2",
1502
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
1503
+ "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
1504
+ "dev": true,
1505
+ "license": "BSD-2-Clause",
1506
+ "dependencies": {
1507
+ "@types/esrecurse": "^4.3.1",
1508
+ "@types/estree": "^1.0.8",
1509
+ "esrecurse": "^4.3.0",
1510
+ "estraverse": "^5.2.0"
1511
+ },
1512
+ "engines": {
1513
+ "node": "^20.19.0 || ^22.13.0 || >=24"
1514
+ },
1515
+ "funding": {
1516
+ "url": "https://opencollective.com/eslint"
1517
+ }
1518
+ },
1519
+ "node_modules/eslint-visitor-keys": {
1520
+ "version": "5.0.1",
1521
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
1522
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
1523
+ "dev": true,
1524
+ "license": "Apache-2.0",
1525
+ "engines": {
1526
+ "node": "^20.19.0 || ^22.13.0 || >=24"
1527
+ },
1528
+ "funding": {
1529
+ "url": "https://opencollective.com/eslint"
1530
+ }
1531
+ },
1532
+ "node_modules/espree": {
1533
+ "version": "11.2.0",
1534
+ "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
1535
+ "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
1536
+ "dev": true,
1537
+ "license": "BSD-2-Clause",
1538
+ "dependencies": {
1539
+ "acorn": "^8.16.0",
1540
+ "acorn-jsx": "^5.3.2",
1541
+ "eslint-visitor-keys": "^5.0.1"
1542
+ },
1543
+ "engines": {
1544
+ "node": "^20.19.0 || ^22.13.0 || >=24"
1545
+ },
1546
+ "funding": {
1547
+ "url": "https://opencollective.com/eslint"
1548
+ }
1549
+ },
1550
+ "node_modules/esquery": {
1551
+ "version": "1.7.0",
1552
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
1553
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
1554
+ "dev": true,
1555
+ "license": "BSD-3-Clause",
1556
+ "dependencies": {
1557
+ "estraverse": "^5.1.0"
1558
+ },
1559
+ "engines": {
1560
+ "node": ">=0.10"
1561
+ }
1562
+ },
1563
+ "node_modules/esrecurse": {
1564
+ "version": "4.3.0",
1565
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
1566
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
1567
+ "dev": true,
1568
+ "license": "BSD-2-Clause",
1569
+ "dependencies": {
1570
+ "estraverse": "^5.2.0"
1571
+ },
1572
+ "engines": {
1573
+ "node": ">=4.0"
1574
+ }
1575
+ },
1576
+ "node_modules/estraverse": {
1577
+ "version": "5.3.0",
1578
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
1579
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
1580
+ "dev": true,
1581
+ "license": "BSD-2-Clause",
1582
+ "engines": {
1583
+ "node": ">=4.0"
1584
+ }
1585
+ },
1586
+ "node_modules/esutils": {
1587
+ "version": "2.0.3",
1588
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
1589
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
1590
+ "dev": true,
1591
+ "license": "BSD-2-Clause",
1592
+ "engines": {
1593
+ "node": ">=0.10.0"
1594
+ }
1595
+ },
1596
+ "node_modules/fast-deep-equal": {
1597
+ "version": "3.1.3",
1598
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
1599
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
1600
+ "dev": true,
1601
+ "license": "MIT"
1602
+ },
1603
+ "node_modules/fast-json-stable-stringify": {
1604
+ "version": "2.1.0",
1605
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
1606
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
1607
+ "dev": true,
1608
+ "license": "MIT"
1609
+ },
1610
+ "node_modules/fast-levenshtein": {
1611
+ "version": "2.0.6",
1612
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
1613
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
1614
+ "dev": true,
1615
+ "license": "MIT"
1616
+ },
1617
+ "node_modules/fdir": {
1618
+ "version": "6.5.0",
1619
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
1620
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
1621
+ "dev": true,
1622
+ "license": "MIT",
1623
+ "engines": {
1624
+ "node": ">=12.0.0"
1625
+ },
1626
+ "peerDependencies": {
1627
+ "picomatch": "^3 || ^4"
1628
+ },
1629
+ "peerDependenciesMeta": {
1630
+ "picomatch": {
1631
+ "optional": true
1632
+ }
1633
+ }
1634
+ },
1635
+ "node_modules/file-entry-cache": {
1636
+ "version": "8.0.0",
1637
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
1638
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
1639
+ "dev": true,
1640
+ "license": "MIT",
1641
+ "dependencies": {
1642
+ "flat-cache": "^4.0.0"
1643
+ },
1644
+ "engines": {
1645
+ "node": ">=16.0.0"
1646
+ }
1647
+ },
1648
+ "node_modules/find-up": {
1649
+ "version": "5.0.0",
1650
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
1651
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
1652
+ "dev": true,
1653
+ "license": "MIT",
1654
+ "dependencies": {
1655
+ "locate-path": "^6.0.0",
1656
+ "path-exists": "^4.0.0"
1657
+ },
1658
+ "engines": {
1659
+ "node": ">=10"
1660
+ },
1661
+ "funding": {
1662
+ "url": "https://github.com/sponsors/sindresorhus"
1663
+ }
1664
+ },
1665
+ "node_modules/flat-cache": {
1666
+ "version": "4.0.1",
1667
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
1668
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
1669
+ "dev": true,
1670
+ "license": "MIT",
1671
+ "dependencies": {
1672
+ "flatted": "^3.2.9",
1673
+ "keyv": "^4.5.4"
1674
+ },
1675
+ "engines": {
1676
+ "node": ">=16"
1677
+ }
1678
+ },
1679
+ "node_modules/flatted": {
1680
+ "version": "3.4.2",
1681
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
1682
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
1683
+ "dev": true,
1684
+ "license": "ISC"
1685
+ },
1686
+ "node_modules/fsevents": {
1687
+ "version": "2.3.3",
1688
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1689
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1690
+ "dev": true,
1691
+ "hasInstallScript": true,
1692
+ "license": "MIT",
1693
+ "optional": true,
1694
+ "os": [
1695
+ "darwin"
1696
+ ],
1697
+ "engines": {
1698
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1699
+ }
1700
+ },
1701
+ "node_modules/gensync": {
1702
+ "version": "1.0.0-beta.2",
1703
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
1704
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
1705
+ "dev": true,
1706
+ "license": "MIT",
1707
+ "engines": {
1708
+ "node": ">=6.9.0"
1709
+ }
1710
+ },
1711
+ "node_modules/glob-parent": {
1712
+ "version": "6.0.2",
1713
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
1714
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
1715
+ "dev": true,
1716
+ "license": "ISC",
1717
+ "dependencies": {
1718
+ "is-glob": "^4.0.3"
1719
+ },
1720
+ "engines": {
1721
+ "node": ">=10.13.0"
1722
+ }
1723
+ },
1724
+ "node_modules/globals": {
1725
+ "version": "17.5.0",
1726
+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz",
1727
+ "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==",
1728
+ "dev": true,
1729
+ "license": "MIT",
1730
+ "engines": {
1731
+ "node": ">=18"
1732
+ },
1733
+ "funding": {
1734
+ "url": "https://github.com/sponsors/sindresorhus"
1735
+ }
1736
+ },
1737
+ "node_modules/hermes-estree": {
1738
+ "version": "0.25.1",
1739
+ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
1740
+ "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
1741
+ "dev": true,
1742
+ "license": "MIT"
1743
+ },
1744
+ "node_modules/hermes-parser": {
1745
+ "version": "0.25.1",
1746
+ "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
1747
+ "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
1748
+ "dev": true,
1749
+ "license": "MIT",
1750
+ "dependencies": {
1751
+ "hermes-estree": "0.25.1"
1752
+ }
1753
+ },
1754
+ "node_modules/ignore": {
1755
+ "version": "5.3.2",
1756
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
1757
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
1758
+ "dev": true,
1759
+ "license": "MIT",
1760
+ "engines": {
1761
+ "node": ">= 4"
1762
+ }
1763
+ },
1764
+ "node_modules/imurmurhash": {
1765
+ "version": "0.1.4",
1766
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
1767
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
1768
+ "dev": true,
1769
+ "license": "MIT",
1770
+ "engines": {
1771
+ "node": ">=0.8.19"
1772
+ }
1773
+ },
1774
+ "node_modules/is-extglob": {
1775
+ "version": "2.1.1",
1776
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
1777
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
1778
+ "dev": true,
1779
+ "license": "MIT",
1780
+ "engines": {
1781
+ "node": ">=0.10.0"
1782
+ }
1783
+ },
1784
+ "node_modules/is-glob": {
1785
+ "version": "4.0.3",
1786
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
1787
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
1788
+ "dev": true,
1789
+ "license": "MIT",
1790
+ "dependencies": {
1791
+ "is-extglob": "^2.1.1"
1792
+ },
1793
+ "engines": {
1794
+ "node": ">=0.10.0"
1795
+ }
1796
+ },
1797
+ "node_modules/isexe": {
1798
+ "version": "2.0.0",
1799
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
1800
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
1801
+ "dev": true,
1802
+ "license": "ISC"
1803
+ },
1804
+ "node_modules/js-tokens": {
1805
+ "version": "4.0.0",
1806
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1807
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
1808
+ "dev": true,
1809
+ "license": "MIT"
1810
+ },
1811
+ "node_modules/jsesc": {
1812
+ "version": "3.1.0",
1813
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
1814
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
1815
+ "dev": true,
1816
+ "license": "MIT",
1817
+ "bin": {
1818
+ "jsesc": "bin/jsesc"
1819
+ },
1820
+ "engines": {
1821
+ "node": ">=6"
1822
+ }
1823
+ },
1824
+ "node_modules/json-buffer": {
1825
+ "version": "3.0.1",
1826
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
1827
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
1828
+ "dev": true,
1829
+ "license": "MIT"
1830
+ },
1831
+ "node_modules/json-schema-traverse": {
1832
+ "version": "0.4.1",
1833
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
1834
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
1835
+ "dev": true,
1836
+ "license": "MIT"
1837
+ },
1838
+ "node_modules/json-stable-stringify-without-jsonify": {
1839
+ "version": "1.0.1",
1840
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
1841
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
1842
+ "dev": true,
1843
+ "license": "MIT"
1844
+ },
1845
+ "node_modules/json5": {
1846
+ "version": "2.2.3",
1847
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
1848
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
1849
+ "dev": true,
1850
+ "license": "MIT",
1851
+ "bin": {
1852
+ "json5": "lib/cli.js"
1853
+ },
1854
+ "engines": {
1855
+ "node": ">=6"
1856
+ }
1857
+ },
1858
+ "node_modules/keyv": {
1859
+ "version": "4.5.4",
1860
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
1861
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
1862
+ "dev": true,
1863
+ "license": "MIT",
1864
+ "dependencies": {
1865
+ "json-buffer": "3.0.1"
1866
+ }
1867
+ },
1868
+ "node_modules/levn": {
1869
+ "version": "0.4.1",
1870
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
1871
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
1872
+ "dev": true,
1873
+ "license": "MIT",
1874
+ "dependencies": {
1875
+ "prelude-ls": "^1.2.1",
1876
+ "type-check": "~0.4.0"
1877
+ },
1878
+ "engines": {
1879
+ "node": ">= 0.8.0"
1880
+ }
1881
+ },
1882
+ "node_modules/lightningcss": {
1883
+ "version": "1.32.0",
1884
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
1885
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
1886
+ "dev": true,
1887
+ "license": "MPL-2.0",
1888
+ "dependencies": {
1889
+ "detect-libc": "^2.0.3"
1890
+ },
1891
+ "engines": {
1892
+ "node": ">= 12.0.0"
1893
+ },
1894
+ "funding": {
1895
+ "type": "opencollective",
1896
+ "url": "https://opencollective.com/parcel"
1897
+ },
1898
+ "optionalDependencies": {
1899
+ "lightningcss-android-arm64": "1.32.0",
1900
+ "lightningcss-darwin-arm64": "1.32.0",
1901
+ "lightningcss-darwin-x64": "1.32.0",
1902
+ "lightningcss-freebsd-x64": "1.32.0",
1903
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
1904
+ "lightningcss-linux-arm64-gnu": "1.32.0",
1905
+ "lightningcss-linux-arm64-musl": "1.32.0",
1906
+ "lightningcss-linux-x64-gnu": "1.32.0",
1907
+ "lightningcss-linux-x64-musl": "1.32.0",
1908
+ "lightningcss-win32-arm64-msvc": "1.32.0",
1909
+ "lightningcss-win32-x64-msvc": "1.32.0"
1910
+ }
1911
+ },
1912
+ "node_modules/lightningcss-android-arm64": {
1913
+ "version": "1.32.0",
1914
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
1915
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
1916
+ "cpu": [
1917
+ "arm64"
1918
+ ],
1919
+ "dev": true,
1920
+ "license": "MPL-2.0",
1921
+ "optional": true,
1922
+ "os": [
1923
+ "android"
1924
+ ],
1925
+ "engines": {
1926
+ "node": ">= 12.0.0"
1927
+ },
1928
+ "funding": {
1929
+ "type": "opencollective",
1930
+ "url": "https://opencollective.com/parcel"
1931
+ }
1932
+ },
1933
+ "node_modules/lightningcss-darwin-arm64": {
1934
+ "version": "1.32.0",
1935
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
1936
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
1937
+ "cpu": [
1938
+ "arm64"
1939
+ ],
1940
+ "dev": true,
1941
+ "license": "MPL-2.0",
1942
+ "optional": true,
1943
+ "os": [
1944
+ "darwin"
1945
+ ],
1946
+ "engines": {
1947
+ "node": ">= 12.0.0"
1948
+ },
1949
+ "funding": {
1950
+ "type": "opencollective",
1951
+ "url": "https://opencollective.com/parcel"
1952
+ }
1953
+ },
1954
+ "node_modules/lightningcss-darwin-x64": {
1955
+ "version": "1.32.0",
1956
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
1957
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
1958
+ "cpu": [
1959
+ "x64"
1960
+ ],
1961
+ "dev": true,
1962
+ "license": "MPL-2.0",
1963
+ "optional": true,
1964
+ "os": [
1965
+ "darwin"
1966
+ ],
1967
+ "engines": {
1968
+ "node": ">= 12.0.0"
1969
+ },
1970
+ "funding": {
1971
+ "type": "opencollective",
1972
+ "url": "https://opencollective.com/parcel"
1973
+ }
1974
+ },
1975
+ "node_modules/lightningcss-freebsd-x64": {
1976
+ "version": "1.32.0",
1977
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
1978
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
1979
+ "cpu": [
1980
+ "x64"
1981
+ ],
1982
+ "dev": true,
1983
+ "license": "MPL-2.0",
1984
+ "optional": true,
1985
+ "os": [
1986
+ "freebsd"
1987
+ ],
1988
+ "engines": {
1989
+ "node": ">= 12.0.0"
1990
+ },
1991
+ "funding": {
1992
+ "type": "opencollective",
1993
+ "url": "https://opencollective.com/parcel"
1994
+ }
1995
+ },
1996
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
1997
+ "version": "1.32.0",
1998
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
1999
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
2000
+ "cpu": [
2001
+ "arm"
2002
+ ],
2003
+ "dev": true,
2004
+ "license": "MPL-2.0",
2005
+ "optional": true,
2006
+ "os": [
2007
+ "linux"
2008
+ ],
2009
+ "engines": {
2010
+ "node": ">= 12.0.0"
2011
+ },
2012
+ "funding": {
2013
+ "type": "opencollective",
2014
+ "url": "https://opencollective.com/parcel"
2015
+ }
2016
+ },
2017
+ "node_modules/lightningcss-linux-arm64-gnu": {
2018
+ "version": "1.32.0",
2019
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
2020
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
2021
+ "cpu": [
2022
+ "arm64"
2023
+ ],
2024
+ "dev": true,
2025
+ "libc": [
2026
+ "glibc"
2027
+ ],
2028
+ "license": "MPL-2.0",
2029
+ "optional": true,
2030
+ "os": [
2031
+ "linux"
2032
+ ],
2033
+ "engines": {
2034
+ "node": ">= 12.0.0"
2035
+ },
2036
+ "funding": {
2037
+ "type": "opencollective",
2038
+ "url": "https://opencollective.com/parcel"
2039
+ }
2040
+ },
2041
+ "node_modules/lightningcss-linux-arm64-musl": {
2042
+ "version": "1.32.0",
2043
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
2044
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
2045
+ "cpu": [
2046
+ "arm64"
2047
+ ],
2048
+ "dev": true,
2049
+ "libc": [
2050
+ "musl"
2051
+ ],
2052
+ "license": "MPL-2.0",
2053
+ "optional": true,
2054
+ "os": [
2055
+ "linux"
2056
+ ],
2057
+ "engines": {
2058
+ "node": ">= 12.0.0"
2059
+ },
2060
+ "funding": {
2061
+ "type": "opencollective",
2062
+ "url": "https://opencollective.com/parcel"
2063
+ }
2064
+ },
2065
+ "node_modules/lightningcss-linux-x64-gnu": {
2066
+ "version": "1.32.0",
2067
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
2068
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
2069
+ "cpu": [
2070
+ "x64"
2071
+ ],
2072
+ "dev": true,
2073
+ "libc": [
2074
+ "glibc"
2075
+ ],
2076
+ "license": "MPL-2.0",
2077
+ "optional": true,
2078
+ "os": [
2079
+ "linux"
2080
+ ],
2081
+ "engines": {
2082
+ "node": ">= 12.0.0"
2083
+ },
2084
+ "funding": {
2085
+ "type": "opencollective",
2086
+ "url": "https://opencollective.com/parcel"
2087
+ }
2088
+ },
2089
+ "node_modules/lightningcss-linux-x64-musl": {
2090
+ "version": "1.32.0",
2091
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
2092
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
2093
+ "cpu": [
2094
+ "x64"
2095
+ ],
2096
+ "dev": true,
2097
+ "libc": [
2098
+ "musl"
2099
+ ],
2100
+ "license": "MPL-2.0",
2101
+ "optional": true,
2102
+ "os": [
2103
+ "linux"
2104
+ ],
2105
+ "engines": {
2106
+ "node": ">= 12.0.0"
2107
+ },
2108
+ "funding": {
2109
+ "type": "opencollective",
2110
+ "url": "https://opencollective.com/parcel"
2111
+ }
2112
+ },
2113
+ "node_modules/lightningcss-win32-arm64-msvc": {
2114
+ "version": "1.32.0",
2115
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
2116
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
2117
+ "cpu": [
2118
+ "arm64"
2119
+ ],
2120
+ "dev": true,
2121
+ "license": "MPL-2.0",
2122
+ "optional": true,
2123
+ "os": [
2124
+ "win32"
2125
+ ],
2126
+ "engines": {
2127
+ "node": ">= 12.0.0"
2128
+ },
2129
+ "funding": {
2130
+ "type": "opencollective",
2131
+ "url": "https://opencollective.com/parcel"
2132
+ }
2133
+ },
2134
+ "node_modules/lightningcss-win32-x64-msvc": {
2135
+ "version": "1.32.0",
2136
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
2137
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
2138
+ "cpu": [
2139
+ "x64"
2140
+ ],
2141
+ "dev": true,
2142
+ "license": "MPL-2.0",
2143
+ "optional": true,
2144
+ "os": [
2145
+ "win32"
2146
+ ],
2147
+ "engines": {
2148
+ "node": ">= 12.0.0"
2149
+ },
2150
+ "funding": {
2151
+ "type": "opencollective",
2152
+ "url": "https://opencollective.com/parcel"
2153
+ }
2154
+ },
2155
+ "node_modules/locate-path": {
2156
+ "version": "6.0.0",
2157
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
2158
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
2159
+ "dev": true,
2160
+ "license": "MIT",
2161
+ "dependencies": {
2162
+ "p-locate": "^5.0.0"
2163
+ },
2164
+ "engines": {
2165
+ "node": ">=10"
2166
+ },
2167
+ "funding": {
2168
+ "url": "https://github.com/sponsors/sindresorhus"
2169
+ }
2170
+ },
2171
+ "node_modules/lru-cache": {
2172
+ "version": "5.1.1",
2173
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
2174
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
2175
+ "dev": true,
2176
+ "license": "ISC",
2177
+ "dependencies": {
2178
+ "yallist": "^3.0.2"
2179
+ }
2180
+ },
2181
+ "node_modules/minimatch": {
2182
+ "version": "10.2.5",
2183
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
2184
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
2185
+ "dev": true,
2186
+ "license": "BlueOak-1.0.0",
2187
+ "dependencies": {
2188
+ "brace-expansion": "^5.0.5"
2189
+ },
2190
+ "engines": {
2191
+ "node": "18 || 20 || >=22"
2192
+ },
2193
+ "funding": {
2194
+ "url": "https://github.com/sponsors/isaacs"
2195
+ }
2196
+ },
2197
+ "node_modules/ms": {
2198
+ "version": "2.1.3",
2199
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
2200
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
2201
+ "dev": true,
2202
+ "license": "MIT"
2203
+ },
2204
+ "node_modules/nanoid": {
2205
+ "version": "3.3.11",
2206
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
2207
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
2208
+ "dev": true,
2209
+ "funding": [
2210
+ {
2211
+ "type": "github",
2212
+ "url": "https://github.com/sponsors/ai"
2213
+ }
2214
+ ],
2215
+ "license": "MIT",
2216
+ "bin": {
2217
+ "nanoid": "bin/nanoid.cjs"
2218
+ },
2219
+ "engines": {
2220
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
2221
+ }
2222
+ },
2223
+ "node_modules/natural-compare": {
2224
+ "version": "1.4.0",
2225
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
2226
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
2227
+ "dev": true,
2228
+ "license": "MIT"
2229
+ },
2230
+ "node_modules/node-releases": {
2231
+ "version": "2.0.38",
2232
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
2233
+ "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
2234
+ "dev": true,
2235
+ "license": "MIT"
2236
+ },
2237
+ "node_modules/optionator": {
2238
+ "version": "0.9.4",
2239
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
2240
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
2241
+ "dev": true,
2242
+ "license": "MIT",
2243
+ "dependencies": {
2244
+ "deep-is": "^0.1.3",
2245
+ "fast-levenshtein": "^2.0.6",
2246
+ "levn": "^0.4.1",
2247
+ "prelude-ls": "^1.2.1",
2248
+ "type-check": "^0.4.0",
2249
+ "word-wrap": "^1.2.5"
2250
+ },
2251
+ "engines": {
2252
+ "node": ">= 0.8.0"
2253
+ }
2254
+ },
2255
+ "node_modules/p-limit": {
2256
+ "version": "3.1.0",
2257
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
2258
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
2259
+ "dev": true,
2260
+ "license": "MIT",
2261
+ "dependencies": {
2262
+ "yocto-queue": "^0.1.0"
2263
+ },
2264
+ "engines": {
2265
+ "node": ">=10"
2266
+ },
2267
+ "funding": {
2268
+ "url": "https://github.com/sponsors/sindresorhus"
2269
+ }
2270
+ },
2271
+ "node_modules/p-locate": {
2272
+ "version": "5.0.0",
2273
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
2274
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
2275
+ "dev": true,
2276
+ "license": "MIT",
2277
+ "dependencies": {
2278
+ "p-limit": "^3.0.2"
2279
+ },
2280
+ "engines": {
2281
+ "node": ">=10"
2282
+ },
2283
+ "funding": {
2284
+ "url": "https://github.com/sponsors/sindresorhus"
2285
+ }
2286
+ },
2287
+ "node_modules/path-exists": {
2288
+ "version": "4.0.0",
2289
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
2290
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
2291
+ "dev": true,
2292
+ "license": "MIT",
2293
+ "engines": {
2294
+ "node": ">=8"
2295
+ }
2296
+ },
2297
+ "node_modules/path-key": {
2298
+ "version": "3.1.1",
2299
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
2300
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
2301
+ "dev": true,
2302
+ "license": "MIT",
2303
+ "engines": {
2304
+ "node": ">=8"
2305
+ }
2306
+ },
2307
+ "node_modules/picocolors": {
2308
+ "version": "1.1.1",
2309
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
2310
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
2311
+ "dev": true,
2312
+ "license": "ISC"
2313
+ },
2314
+ "node_modules/picomatch": {
2315
+ "version": "4.0.4",
2316
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
2317
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
2318
+ "dev": true,
2319
+ "license": "MIT",
2320
+ "engines": {
2321
+ "node": ">=12"
2322
+ },
2323
+ "funding": {
2324
+ "url": "https://github.com/sponsors/jonschlinkert"
2325
+ }
2326
+ },
2327
+ "node_modules/postcss": {
2328
+ "version": "8.5.10",
2329
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
2330
+ "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
2331
+ "dev": true,
2332
+ "funding": [
2333
+ {
2334
+ "type": "opencollective",
2335
+ "url": "https://opencollective.com/postcss/"
2336
+ },
2337
+ {
2338
+ "type": "tidelift",
2339
+ "url": "https://tidelift.com/funding/github/npm/postcss"
2340
+ },
2341
+ {
2342
+ "type": "github",
2343
+ "url": "https://github.com/sponsors/ai"
2344
+ }
2345
+ ],
2346
+ "license": "MIT",
2347
+ "dependencies": {
2348
+ "nanoid": "^3.3.11",
2349
+ "picocolors": "^1.1.1",
2350
+ "source-map-js": "^1.2.1"
2351
+ },
2352
+ "engines": {
2353
+ "node": "^10 || ^12 || >=14"
2354
+ }
2355
+ },
2356
+ "node_modules/prelude-ls": {
2357
+ "version": "1.2.1",
2358
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
2359
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
2360
+ "dev": true,
2361
+ "license": "MIT",
2362
+ "engines": {
2363
+ "node": ">= 0.8.0"
2364
+ }
2365
+ },
2366
+ "node_modules/punycode": {
2367
+ "version": "2.3.1",
2368
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
2369
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
2370
+ "dev": true,
2371
+ "license": "MIT",
2372
+ "engines": {
2373
+ "node": ">=6"
2374
+ }
2375
+ },
2376
+ "node_modules/react": {
2377
+ "version": "19.2.5",
2378
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
2379
+ "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
2380
+ "license": "MIT",
2381
+ "engines": {
2382
+ "node": ">=0.10.0"
2383
+ }
2384
+ },
2385
+ "node_modules/react-dom": {
2386
+ "version": "19.2.5",
2387
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
2388
+ "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
2389
+ "license": "MIT",
2390
+ "dependencies": {
2391
+ "scheduler": "^0.27.0"
2392
+ },
2393
+ "peerDependencies": {
2394
+ "react": "^19.2.5"
2395
+ }
2396
+ },
2397
+ "node_modules/rolldown": {
2398
+ "version": "1.0.0-rc.17",
2399
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
2400
+ "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
2401
+ "dev": true,
2402
+ "license": "MIT",
2403
+ "dependencies": {
2404
+ "@oxc-project/types": "=0.127.0",
2405
+ "@rolldown/pluginutils": "1.0.0-rc.17"
2406
+ },
2407
+ "bin": {
2408
+ "rolldown": "bin/cli.mjs"
2409
+ },
2410
+ "engines": {
2411
+ "node": "^20.19.0 || >=22.12.0"
2412
+ },
2413
+ "optionalDependencies": {
2414
+ "@rolldown/binding-android-arm64": "1.0.0-rc.17",
2415
+ "@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
2416
+ "@rolldown/binding-darwin-x64": "1.0.0-rc.17",
2417
+ "@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
2418
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
2419
+ "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
2420
+ "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
2421
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
2422
+ "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
2423
+ "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
2424
+ "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
2425
+ "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
2426
+ "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
2427
+ "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
2428
+ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
2429
+ }
2430
+ },
2431
+ "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
2432
+ "version": "1.0.0-rc.17",
2433
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
2434
+ "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
2435
+ "dev": true,
2436
+ "license": "MIT"
2437
+ },
2438
+ "node_modules/scheduler": {
2439
+ "version": "0.27.0",
2440
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
2441
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
2442
+ "license": "MIT"
2443
+ },
2444
+ "node_modules/semver": {
2445
+ "version": "6.3.1",
2446
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
2447
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
2448
+ "dev": true,
2449
+ "license": "ISC",
2450
+ "bin": {
2451
+ "semver": "bin/semver.js"
2452
+ }
2453
+ },
2454
+ "node_modules/shebang-command": {
2455
+ "version": "2.0.0",
2456
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
2457
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
2458
+ "dev": true,
2459
+ "license": "MIT",
2460
+ "dependencies": {
2461
+ "shebang-regex": "^3.0.0"
2462
+ },
2463
+ "engines": {
2464
+ "node": ">=8"
2465
+ }
2466
+ },
2467
+ "node_modules/shebang-regex": {
2468
+ "version": "3.0.0",
2469
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
2470
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
2471
+ "dev": true,
2472
+ "license": "MIT",
2473
+ "engines": {
2474
+ "node": ">=8"
2475
+ }
2476
+ },
2477
+ "node_modules/source-map-js": {
2478
+ "version": "1.2.1",
2479
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
2480
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
2481
+ "dev": true,
2482
+ "license": "BSD-3-Clause",
2483
+ "engines": {
2484
+ "node": ">=0.10.0"
2485
+ }
2486
+ },
2487
+ "node_modules/tinyglobby": {
2488
+ "version": "0.2.16",
2489
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
2490
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
2491
+ "dev": true,
2492
+ "license": "MIT",
2493
+ "dependencies": {
2494
+ "fdir": "^6.5.0",
2495
+ "picomatch": "^4.0.4"
2496
+ },
2497
+ "engines": {
2498
+ "node": ">=12.0.0"
2499
+ },
2500
+ "funding": {
2501
+ "url": "https://github.com/sponsors/SuperchupuDev"
2502
+ }
2503
+ },
2504
+ "node_modules/ts-api-utils": {
2505
+ "version": "2.5.0",
2506
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
2507
+ "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
2508
+ "dev": true,
2509
+ "license": "MIT",
2510
+ "engines": {
2511
+ "node": ">=18.12"
2512
+ },
2513
+ "peerDependencies": {
2514
+ "typescript": ">=4.8.4"
2515
+ }
2516
+ },
2517
+ "node_modules/tslib": {
2518
+ "version": "2.8.1",
2519
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
2520
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
2521
+ "dev": true,
2522
+ "license": "0BSD",
2523
+ "optional": true
2524
+ },
2525
+ "node_modules/type-check": {
2526
+ "version": "0.4.0",
2527
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
2528
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
2529
+ "dev": true,
2530
+ "license": "MIT",
2531
+ "dependencies": {
2532
+ "prelude-ls": "^1.2.1"
2533
+ },
2534
+ "engines": {
2535
+ "node": ">= 0.8.0"
2536
+ }
2537
+ },
2538
+ "node_modules/typescript": {
2539
+ "version": "6.0.3",
2540
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
2541
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
2542
+ "dev": true,
2543
+ "license": "Apache-2.0",
2544
+ "bin": {
2545
+ "tsc": "bin/tsc",
2546
+ "tsserver": "bin/tsserver"
2547
+ },
2548
+ "engines": {
2549
+ "node": ">=14.17"
2550
+ }
2551
+ },
2552
+ "node_modules/typescript-eslint": {
2553
+ "version": "8.59.0",
2554
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.0.tgz",
2555
+ "integrity": "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==",
2556
+ "dev": true,
2557
+ "license": "MIT",
2558
+ "dependencies": {
2559
+ "@typescript-eslint/eslint-plugin": "8.59.0",
2560
+ "@typescript-eslint/parser": "8.59.0",
2561
+ "@typescript-eslint/typescript-estree": "8.59.0",
2562
+ "@typescript-eslint/utils": "8.59.0"
2563
+ },
2564
+ "engines": {
2565
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
2566
+ },
2567
+ "funding": {
2568
+ "type": "opencollective",
2569
+ "url": "https://opencollective.com/typescript-eslint"
2570
+ },
2571
+ "peerDependencies": {
2572
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
2573
+ "typescript": ">=4.8.4 <6.1.0"
2574
+ }
2575
+ },
2576
+ "node_modules/undici-types": {
2577
+ "version": "7.16.0",
2578
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
2579
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
2580
+ "dev": true,
2581
+ "license": "MIT"
2582
+ },
2583
+ "node_modules/update-browserslist-db": {
2584
+ "version": "1.2.3",
2585
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
2586
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
2587
+ "dev": true,
2588
+ "funding": [
2589
+ {
2590
+ "type": "opencollective",
2591
+ "url": "https://opencollective.com/browserslist"
2592
+ },
2593
+ {
2594
+ "type": "tidelift",
2595
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
2596
+ },
2597
+ {
2598
+ "type": "github",
2599
+ "url": "https://github.com/sponsors/ai"
2600
+ }
2601
+ ],
2602
+ "license": "MIT",
2603
+ "dependencies": {
2604
+ "escalade": "^3.2.0",
2605
+ "picocolors": "^1.1.1"
2606
+ },
2607
+ "bin": {
2608
+ "update-browserslist-db": "cli.js"
2609
+ },
2610
+ "peerDependencies": {
2611
+ "browserslist": ">= 4.21.0"
2612
+ }
2613
+ },
2614
+ "node_modules/uri-js": {
2615
+ "version": "4.4.1",
2616
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
2617
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
2618
+ "dev": true,
2619
+ "license": "BSD-2-Clause",
2620
+ "dependencies": {
2621
+ "punycode": "^2.1.0"
2622
+ }
2623
+ },
2624
+ "node_modules/vite": {
2625
+ "version": "8.0.10",
2626
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
2627
+ "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
2628
+ "dev": true,
2629
+ "license": "MIT",
2630
+ "dependencies": {
2631
+ "lightningcss": "^1.32.0",
2632
+ "picomatch": "^4.0.4",
2633
+ "postcss": "^8.5.10",
2634
+ "rolldown": "1.0.0-rc.17",
2635
+ "tinyglobby": "^0.2.16"
2636
+ },
2637
+ "bin": {
2638
+ "vite": "bin/vite.js"
2639
+ },
2640
+ "engines": {
2641
+ "node": "^20.19.0 || >=22.12.0"
2642
+ },
2643
+ "funding": {
2644
+ "url": "https://github.com/vitejs/vite?sponsor=1"
2645
+ },
2646
+ "optionalDependencies": {
2647
+ "fsevents": "~2.3.3"
2648
+ },
2649
+ "peerDependencies": {
2650
+ "@types/node": "^20.19.0 || >=22.12.0",
2651
+ "@vitejs/devtools": "^0.1.0",
2652
+ "esbuild": "^0.27.0 || ^0.28.0",
2653
+ "jiti": ">=1.21.0",
2654
+ "less": "^4.0.0",
2655
+ "sass": "^1.70.0",
2656
+ "sass-embedded": "^1.70.0",
2657
+ "stylus": ">=0.54.8",
2658
+ "sugarss": "^5.0.0",
2659
+ "terser": "^5.16.0",
2660
+ "tsx": "^4.8.1",
2661
+ "yaml": "^2.4.2"
2662
+ },
2663
+ "peerDependenciesMeta": {
2664
+ "@types/node": {
2665
+ "optional": true
2666
+ },
2667
+ "@vitejs/devtools": {
2668
+ "optional": true
2669
+ },
2670
+ "esbuild": {
2671
+ "optional": true
2672
+ },
2673
+ "jiti": {
2674
+ "optional": true
2675
+ },
2676
+ "less": {
2677
+ "optional": true
2678
+ },
2679
+ "sass": {
2680
+ "optional": true
2681
+ },
2682
+ "sass-embedded": {
2683
+ "optional": true
2684
+ },
2685
+ "stylus": {
2686
+ "optional": true
2687
+ },
2688
+ "sugarss": {
2689
+ "optional": true
2690
+ },
2691
+ "terser": {
2692
+ "optional": true
2693
+ },
2694
+ "tsx": {
2695
+ "optional": true
2696
+ },
2697
+ "yaml": {
2698
+ "optional": true
2699
+ }
2700
+ }
2701
+ },
2702
+ "node_modules/which": {
2703
+ "version": "2.0.2",
2704
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
2705
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
2706
+ "dev": true,
2707
+ "license": "ISC",
2708
+ "dependencies": {
2709
+ "isexe": "^2.0.0"
2710
+ },
2711
+ "bin": {
2712
+ "node-which": "bin/node-which"
2713
+ },
2714
+ "engines": {
2715
+ "node": ">= 8"
2716
+ }
2717
+ },
2718
+ "node_modules/word-wrap": {
2719
+ "version": "1.2.5",
2720
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
2721
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
2722
+ "dev": true,
2723
+ "license": "MIT",
2724
+ "engines": {
2725
+ "node": ">=0.10.0"
2726
+ }
2727
+ },
2728
+ "node_modules/yallist": {
2729
+ "version": "3.1.1",
2730
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
2731
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
2732
+ "dev": true,
2733
+ "license": "ISC"
2734
+ },
2735
+ "node_modules/yocto-queue": {
2736
+ "version": "0.1.0",
2737
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
2738
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
2739
+ "dev": true,
2740
+ "license": "MIT",
2741
+ "engines": {
2742
+ "node": ">=10"
2743
+ },
2744
+ "funding": {
2745
+ "url": "https://github.com/sponsors/sindresorhus"
2746
+ }
2747
+ },
2748
+ "node_modules/zod": {
2749
+ "version": "4.3.6",
2750
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
2751
+ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
2752
+ "dev": true,
2753
+ "license": "MIT",
2754
+ "funding": {
2755
+ "url": "https://github.com/sponsors/colinhacks"
2756
+ }
2757
+ },
2758
+ "node_modules/zod-validation-error": {
2759
+ "version": "4.0.2",
2760
+ "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
2761
+ "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
2762
+ "dev": true,
2763
+ "license": "MIT",
2764
+ "engines": {
2765
+ "node": ">=18.0.0"
2766
+ },
2767
+ "peerDependencies": {
2768
+ "zod": "^3.25.0 || ^4.0.0"
2769
+ }
2770
+ }
2771
+ }
2772
+ }
frontend/package.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "react": "^19.2.5",
14
+ "react-dom": "^19.2.5"
15
+ },
16
+ "devDependencies": {
17
+ "@eslint/js": "^10.0.1",
18
+ "@types/node": "^24.12.2",
19
+ "@types/react": "^19.2.14",
20
+ "@types/react-dom": "^19.2.3",
21
+ "@vitejs/plugin-react": "^6.0.1",
22
+ "eslint": "^10.2.1",
23
+ "eslint-plugin-react-hooks": "^7.1.1",
24
+ "eslint-plugin-react-refresh": "^0.5.2",
25
+ "globals": "^17.5.0",
26
+ "typescript": "~6.0.2",
27
+ "typescript-eslint": "^8.58.2",
28
+ "vite": "^8.0.10"
29
+ }
30
+ }
frontend/public/favicon.svg ADDED
frontend/public/icons.svg ADDED
frontend/src/App.css ADDED
@@ -0,0 +1,703 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Reset ── */
2
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
3
+
4
+ /* ── Design Tokens ── */
5
+ :root {
6
+ --bg: #f5f2ed;
7
+ --s1: #ffffff;
8
+ --s2: #faf9f6;
9
+ --s3: #f0ede8;
10
+ --bd: #e4dfd7;
11
+ --bd2: #cdc7be;
12
+ --t1: #1c1712;
13
+ --t2: #6b6460;
14
+ --t3: #a8a29e;
15
+ --fire: #c2410c;
16
+ --fire2: rgba(194,65,12,0.08);
17
+ --blue: #1d4ed8;
18
+ --blue2: rgba(29,78,216,0.08);
19
+ --green: #166534;
20
+ --green2:rgba(22,101,52,0.08);
21
+ --amber: #92400e;
22
+ --amber2:rgba(146,64,14,0.08);
23
+ --red: #b91c1c;
24
+ --red2: rgba(185,28,28,0.08);
25
+ --gold: #d97706;
26
+ --gold2: rgba(217,119,6,0.12);
27
+ --mono: 'DM Mono', 'JetBrains Mono', monospace;
28
+ --sans: 'DM Sans', 'Inter', system-ui, sans-serif;
29
+ --r: 8px;
30
+ }
31
+
32
+ body {
33
+ background: var(--bg);
34
+ color: var(--t1);
35
+ font-family: var(--sans);
36
+ font-size: 13px;
37
+ line-height: 1.5;
38
+ overflow: hidden;
39
+ }
40
+
41
+ /* ── Shell ── */
42
+ .shell {
43
+ display: grid;
44
+ grid-template-rows: 48px 1fr;
45
+ height: 100vh;
46
+ overflow: hidden;
47
+ }
48
+
49
+ /* ── Topbar ── */
50
+ .topbar {
51
+ background: var(--s1);
52
+ border-bottom: 1px solid var(--bd);
53
+ display: flex;
54
+ align-items: center;
55
+ padding: 0 20px;
56
+ gap: 16px;
57
+ z-index: 20;
58
+ box-shadow: 0 1px 3px rgba(0,0,0,0.04);
59
+ }
60
+
61
+ .brand {
62
+ display: flex;
63
+ align-items: center;
64
+ gap: 10px;
65
+ }
66
+
67
+ .brand-icon {
68
+ width: 28px;
69
+ height: 28px;
70
+ background: linear-gradient(135deg, #c2410c, #f97316);
71
+ border-radius: 6px;
72
+ display: flex;
73
+ align-items: center;
74
+ justify-content: center;
75
+ font-size: 14px;
76
+ }
77
+
78
+ .brand-name {
79
+ font-family: var(--mono);
80
+ font-size: 15px;
81
+ font-weight: 500;
82
+ color: var(--t1);
83
+ letter-spacing: 0.06em;
84
+ text-transform: uppercase;
85
+ }
86
+
87
+ .brand-sep {
88
+ color: var(--bd2);
89
+ margin: 0 2px;
90
+ }
91
+
92
+ .brand-sub {
93
+ font-size: 11px;
94
+ color: var(--t3);
95
+ letter-spacing: 0.08em;
96
+ font-family: var(--mono);
97
+ }
98
+
99
+ .topbar-right {
100
+ margin-left: auto;
101
+ display: flex;
102
+ align-items: center;
103
+ gap: 12px;
104
+ }
105
+
106
+ .live-chip {
107
+ display: flex;
108
+ align-items: center;
109
+ gap: 5px;
110
+ font-family: var(--mono);
111
+ font-size: 10px;
112
+ font-weight: 500;
113
+ letter-spacing: 0.08em;
114
+ text-transform: uppercase;
115
+ padding: 4px 10px;
116
+ border-radius: 20px;
117
+ border: 1px solid var(--bd);
118
+ color: var(--t3);
119
+ background: var(--s2);
120
+ }
121
+
122
+ .live-chip::before {
123
+ content: '';
124
+ width: 6px;
125
+ height: 6px;
126
+ border-radius: 50%;
127
+ background: var(--bd2);
128
+ }
129
+
130
+ .live-chip.online {
131
+ color: var(--green);
132
+ background: var(--green2);
133
+ border-color: rgba(22,101,52,0.2);
134
+ }
135
+
136
+ .live-chip.online::before { background: #22c55e; animation: blink 1.2s infinite; }
137
+
138
+ .live-chip.offline {
139
+ color: var(--t3);
140
+ background: var(--s2);
141
+ }
142
+
143
+ .live-chip.error {
144
+ color: var(--red);
145
+ background: var(--red2);
146
+ border-color: rgba(185,28,28,0.2);
147
+ }
148
+
149
+ .live-chip.error::before { background: var(--red); }
150
+
151
+ @keyframes blink { 0%,100% { opacity:1 } 50% { opacity:0.3 } }
152
+
153
+ .topbar-ep {
154
+ font-family: var(--mono);
155
+ font-size: 10px;
156
+ color: var(--t3);
157
+ display: flex;
158
+ align-items: center;
159
+ gap: 6px;
160
+ }
161
+
162
+ .topbar-ep .ep-id {
163
+ color: var(--blue);
164
+ font-weight: 500;
165
+ }
166
+
167
+ /* ── Content Layout ── */
168
+ .content {
169
+ display: grid;
170
+ grid-template-columns: 1fr 300px;
171
+ overflow: hidden;
172
+ height: 100%;
173
+ }
174
+
175
+ /* ── Canvas Zone (left) ── */
176
+ .canvas-zone {
177
+ display: flex;
178
+ flex-direction: column;
179
+ align-items: center;
180
+ justify-content: flex-start;
181
+ padding: 20px;
182
+ overflow-y: auto;
183
+ gap: 16px;
184
+ background: var(--bg);
185
+ }
186
+
187
+ .canvas-frame {
188
+ position: relative;
189
+ border-radius: 10px;
190
+ overflow: hidden;
191
+ box-shadow:
192
+ 0 0 0 1px var(--bd2),
193
+ 0 4px 24px rgba(0,0,0,0.10),
194
+ 0 1px 4px rgba(0,0,0,0.06);
195
+ background: #d4c9a8;
196
+ line-height: 0;
197
+ flex-shrink: 0;
198
+ }
199
+
200
+ #map-canvas {
201
+ display: block;
202
+ max-width: 100%;
203
+ max-height: calc(100vh - 48px - 120px);
204
+ image-rendering: pixelated;
205
+ }
206
+
207
+ /* ── HUD Overlays on Canvas ── */
208
+ .hud-overlay {
209
+ position: absolute;
210
+ top: 0; left: 0; right: 0;
211
+ display: flex;
212
+ justify-content: space-between;
213
+ pointer-events: none;
214
+ z-index: 10;
215
+ padding: 10px;
216
+ gap: 8px;
217
+ }
218
+
219
+ .hud-card {
220
+ background: rgba(12, 8, 5, 0.82);
221
+ backdrop-filter: blur(10px);
222
+ border: 1px solid rgba(255,255,255,0.10);
223
+ border-radius: 6px;
224
+ padding: 7px 10px;
225
+ color: #ede6dc;
226
+ display: flex;
227
+ flex-direction: column;
228
+ gap: 3px;
229
+ min-width: 130px;
230
+ }
231
+
232
+ .hud-card-center {
233
+ min-width: 160px;
234
+ border-color: rgba(251,191,36,0.18);
235
+ }
236
+
237
+ .hud-r {
238
+ display: flex;
239
+ align-items: center;
240
+ gap: 6px;
241
+ }
242
+
243
+ .hlbl {
244
+ font-family: var(--mono);
245
+ font-size: 9px;
246
+ color: rgba(168,162,158,0.7);
247
+ letter-spacing: 0.10em;
248
+ text-transform: uppercase;
249
+ flex-shrink: 0;
250
+ }
251
+
252
+ .hval {
253
+ font-family: var(--mono);
254
+ font-size: 13px;
255
+ font-weight: 500;
256
+ color: #f0e8e0;
257
+ margin-left: auto;
258
+ min-width: 26px;
259
+ text-align: right;
260
+ }
261
+
262
+ .hbar-bg {
263
+ flex: 1;
264
+ height: 5px;
265
+ background: rgba(255,255,255,0.10);
266
+ border-radius: 3px;
267
+ overflow: hidden;
268
+ }
269
+
270
+ .hbar-fill {
271
+ height: 100%;
272
+ border-radius: 3px;
273
+ transition: width 0.35s ease;
274
+ }
275
+
276
+ .hbar-fill.g { background: linear-gradient(90deg, #22c55e, #4ade80); }
277
+ .hbar-fill.m { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
278
+ .hbar-fill.c { background: linear-gradient(90deg, #ef4444, #f87171); }
279
+
280
+ .hstatus {
281
+ font-family: var(--mono);
282
+ font-size: 9px;
283
+ font-weight: 500;
284
+ letter-spacing: 0.06em;
285
+ text-transform: uppercase;
286
+ padding: 2px 6px;
287
+ border-radius: 3px;
288
+ }
289
+
290
+ .hstatus.good { color: #4ade80; background: rgba(74,222,128,0.12); }
291
+ .hstatus.moderate { color: #fbbf24; background: rgba(251,191,36,0.12); }
292
+ .hstatus.low { color: #fb923c; background: rgba(251,146,60,0.12); }
293
+ .hstatus.critical { color: #f87171; background: rgba(248,113,113,0.12); animation: pulse-red 1s infinite; }
294
+
295
+ @keyframes pulse-red { 0%,100%{opacity:1} 50%{opacity:0.5} }
296
+
297
+ .step-val {
298
+ font-family: var(--mono);
299
+ font-size: 12px;
300
+ font-weight: 500;
301
+ color: #f0e8e0;
302
+ }
303
+
304
+ .sbar-bg {
305
+ height: 3px;
306
+ background: rgba(255,255,255,0.10);
307
+ border-radius: 2px;
308
+ overflow: hidden;
309
+ width: 100%;
310
+ margin-top: 1px;
311
+ }
312
+
313
+ .sbar-fill {
314
+ height: 100%;
315
+ background: linear-gradient(90deg, var(--blue), #60a5fa);
316
+ border-radius: 2px;
317
+ transition: width 0.35s ease;
318
+ }
319
+
320
+ .step-meta {
321
+ font-family: var(--mono);
322
+ font-size: 8px;
323
+ color: rgba(168,162,158,0.55);
324
+ letter-spacing: 0.06em;
325
+ text-transform: uppercase;
326
+ margin-top: 1px;
327
+ }
328
+
329
+ /* ── Legend below canvas ── */
330
+ .legend {
331
+ display: flex;
332
+ gap: 14px;
333
+ flex-wrap: wrap;
334
+ align-items: center;
335
+ padding: 8px 12px;
336
+ background: var(--s1);
337
+ border: 1px solid var(--bd);
338
+ border-radius: 6px;
339
+ width: 100%;
340
+ max-width: 640px;
341
+ }
342
+
343
+ .legend-item {
344
+ display: flex;
345
+ align-items: center;
346
+ gap: 5px;
347
+ font-family: var(--mono);
348
+ font-size: 9px;
349
+ color: var(--t2);
350
+ letter-spacing: 0.05em;
351
+ text-transform: uppercase;
352
+ }
353
+
354
+ .leg-swatch {
355
+ width: 12px;
356
+ height: 12px;
357
+ border-radius: 2px;
358
+ flex-shrink: 0;
359
+ }
360
+
361
+ /* ── Dialog / Field Report ── */
362
+ .dialog {
363
+ background: var(--s1);
364
+ border: 1px solid var(--bd);
365
+ border-left: 3px solid var(--fire);
366
+ border-radius: var(--r);
367
+ padding: 10px 14px;
368
+ font-family: var(--mono);
369
+ font-size: 12px;
370
+ color: var(--t2);
371
+ width: 100%;
372
+ max-width: 640px;
373
+ line-height: 1.5;
374
+ }
375
+
376
+ .dialog-who {
377
+ font-size: 9px;
378
+ color: var(--t3);
379
+ text-transform: uppercase;
380
+ letter-spacing: 0.10em;
381
+ display: block;
382
+ margin-bottom: 3px;
383
+ }
384
+
385
+ /* ── Side Panel (right) ── */
386
+ .side {
387
+ background: var(--s1);
388
+ border-left: 1px solid var(--bd);
389
+ display: flex;
390
+ flex-direction: column;
391
+ overflow-y: auto;
392
+ overflow-x: hidden;
393
+ }
394
+
395
+ .side-sec {
396
+ border-bottom: 1px solid var(--bd);
397
+ padding: 14px 16px;
398
+ }
399
+
400
+ .sec-hd {
401
+ font-family: var(--mono);
402
+ font-size: 9px;
403
+ font-weight: 600;
404
+ color: var(--t3);
405
+ text-transform: uppercase;
406
+ letter-spacing: 0.12em;
407
+ margin-bottom: 10px;
408
+ display: flex;
409
+ align-items: center;
410
+ justify-content: space-between;
411
+ }
412
+
413
+ /* ── Stat Grid (2×N) ── */
414
+ .sg {
415
+ display: grid;
416
+ grid-template-columns: 1fr 1fr;
417
+ gap: 6px;
418
+ }
419
+
420
+ .sc {
421
+ background: var(--s2);
422
+ border: 1px solid var(--bd);
423
+ border-radius: 6px;
424
+ padding: 8px 10px;
425
+ }
426
+
427
+ .sc-l {
428
+ font-family: var(--mono);
429
+ font-size: 9px;
430
+ color: var(--t3);
431
+ text-transform: uppercase;
432
+ letter-spacing: 0.08em;
433
+ margin-bottom: 2px;
434
+ }
435
+
436
+ .sc-v {
437
+ font-family: var(--mono);
438
+ font-size: 14px;
439
+ font-weight: 500;
440
+ color: var(--t1);
441
+ }
442
+
443
+ .sc-v.fire { color: var(--fire); }
444
+ .sc-v.blue { color: var(--blue); }
445
+ .sc-v.green { color: var(--green); }
446
+ .sc-v.amber { color: var(--amber); }
447
+ .sc-v.gold { color: var(--gold); }
448
+
449
+ /* ── Status Rows ── */
450
+ .srow {
451
+ display: flex;
452
+ justify-content: space-between;
453
+ align-items: center;
454
+ padding: 5px 0;
455
+ font-size: 12px;
456
+ border-bottom: 1px solid rgba(0,0,0,0.03);
457
+ }
458
+
459
+ .srow:last-child { border-bottom: none; }
460
+
461
+ .sv { font-family: var(--mono); font-weight: 500; }
462
+ .sv.blue { color: var(--blue); }
463
+ .sv.green { color: var(--green); }
464
+ .sv.hot { color: var(--fire); }
465
+ .sv.danger{ color: var(--red); }
466
+ .sv.warn { color: var(--amber); }
467
+ .sv.gold { color: var(--gold); }
468
+
469
+ /* ── Health bar in side panel ── */
470
+ .bar-w { margin-top: 10px; }
471
+
472
+ .bar-lbl {
473
+ font-family: var(--mono);
474
+ font-size: 9px;
475
+ color: var(--t3);
476
+ display: flex;
477
+ justify-content: space-between;
478
+ margin-bottom: 5px;
479
+ text-transform: uppercase;
480
+ letter-spacing: 0.06em;
481
+ }
482
+
483
+ .bar-bg {
484
+ background: var(--s2);
485
+ border: 1px solid var(--bd);
486
+ border-radius: 4px;
487
+ height: 6px;
488
+ overflow: hidden;
489
+ }
490
+
491
+ .bar-fill {
492
+ height: 100%;
493
+ transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
494
+ border-radius: 4px;
495
+ }
496
+
497
+ /* ── Event Log ── */
498
+ .elog {
499
+ display: flex;
500
+ flex-direction: column;
501
+ gap: 2px;
502
+ max-height: 160px;
503
+ overflow-y: auto;
504
+ }
505
+
506
+ .erow {
507
+ display: flex;
508
+ align-items: flex-start;
509
+ gap: 6px;
510
+ padding: 4px 6px;
511
+ border-radius: 4px;
512
+ font-size: 11px;
513
+ background: transparent;
514
+ }
515
+
516
+ .erow.alarm {
517
+ background: var(--red2);
518
+ border-left: 2px solid var(--red);
519
+ }
520
+
521
+ .erow.warn-row {
522
+ background: var(--amber2);
523
+ border-left: 2px solid var(--amber);
524
+ }
525
+
526
+ .estep {
527
+ font-family: var(--mono);
528
+ font-size: 9px;
529
+ color: var(--t3);
530
+ min-width: 26px;
531
+ padding-top: 1px;
532
+ flex-shrink: 0;
533
+ }
534
+
535
+ .etext {
536
+ color: var(--t2);
537
+ line-height: 1.4;
538
+ flex: 1;
539
+ font-size: 11px;
540
+ }
541
+
542
+ .erwd {
543
+ font-family: var(--mono);
544
+ font-size: 9px;
545
+ font-weight: 500;
546
+ flex-shrink: 0;
547
+ padding-top: 1px;
548
+ }
549
+
550
+ .erwd.p { color: var(--green); }
551
+ .erwd.n { color: var(--red); }
552
+
553
+ /* ── Direction Pad ── */
554
+ .dpad {
555
+ display: grid;
556
+ grid-template-columns: repeat(3, 1fr);
557
+ gap: 5px;
558
+ max-width: 140px;
559
+ margin: 0 auto;
560
+ }
561
+
562
+ .dpad-center {
563
+ display: flex;
564
+ align-items: center;
565
+ justify-content: center;
566
+ }
567
+
568
+ /* ── Control Buttons ── */
569
+ .ctrl-grid {
570
+ display: grid;
571
+ grid-template-columns: repeat(3, 1fr);
572
+ gap: 5px;
573
+ }
574
+
575
+ .ctrl-row {
576
+ display: flex;
577
+ gap: 5px;
578
+ margin-top: 8px;
579
+ }
580
+
581
+ .ctrl-btn {
582
+ background: var(--s2);
583
+ color: var(--t2);
584
+ border: 1px solid var(--bd);
585
+ border-radius: 5px;
586
+ padding: 7px 6px;
587
+ font-family: var(--mono);
588
+ font-size: 10px;
589
+ font-weight: 500;
590
+ letter-spacing: 0.04em;
591
+ cursor: pointer;
592
+ transition: all 0.1s;
593
+ text-align: center;
594
+ flex: 1;
595
+ }
596
+
597
+ .ctrl-btn:hover:not(:disabled) {
598
+ background: var(--s3);
599
+ color: var(--t1);
600
+ border-color: var(--bd2);
601
+ }
602
+
603
+ .ctrl-btn:disabled {
604
+ opacity: 0.4;
605
+ cursor: not-allowed;
606
+ }
607
+
608
+ .ctrl-btn.accent {
609
+ background: var(--fire2);
610
+ color: var(--fire);
611
+ border-color: rgba(194,65,12,0.25);
612
+ }
613
+
614
+ .ctrl-btn.accent:hover {
615
+ background: rgba(194,65,12,0.15);
616
+ color: var(--fire);
617
+ }
618
+
619
+ .ctrl-btn.active {
620
+ background: var(--blue2);
621
+ color: var(--blue);
622
+ border-color: rgba(29,78,216,0.25);
623
+ }
624
+
625
+ .ctrl-btn.active:hover {
626
+ background: rgba(29,78,216,0.15);
627
+ }
628
+
629
+ .ctrl-btn.play {
630
+ background: linear-gradient(135deg, #c2410c, #f97316);
631
+ color: white;
632
+ border: none;
633
+ font-size: 11px;
634
+ box-shadow: 0 2px 8px rgba(194,65,12,0.30);
635
+ }
636
+
637
+ .ctrl-btn.play:hover {
638
+ box-shadow: 0 3px 12px rgba(194,65,12,0.45);
639
+ filter: brightness(1.05);
640
+ }
641
+
642
+ .ctrl-status {
643
+ margin-top: 10px;
644
+ font-family: var(--mono);
645
+ font-size: 10px;
646
+ color: var(--t3);
647
+ padding: 7px 10px;
648
+ background: var(--s2);
649
+ border-radius: 5px;
650
+ border: 1px solid var(--bd);
651
+ border-left: 3px solid var(--bd2);
652
+ }
653
+
654
+ .ctrl-status.error {
655
+ color: var(--red);
656
+ background: var(--red2);
657
+ border-left-color: var(--red);
658
+ }
659
+
660
+ /* ── Door List ── */
661
+ .door-grid {
662
+ display: grid;
663
+ grid-template-columns: 1fr 1fr;
664
+ gap: 4px;
665
+ }
666
+
667
+ .door-btn {
668
+ background: var(--s2);
669
+ border: 1px solid var(--bd);
670
+ border-radius: 5px;
671
+ padding: 5px 4px;
672
+ font-family: var(--mono);
673
+ font-size: 9px;
674
+ cursor: pointer;
675
+ color: var(--t2);
676
+ text-align: center;
677
+ transition: all 0.1s;
678
+ }
679
+
680
+ .door-btn:hover { background: var(--s3); color: var(--t1); }
681
+ .door-btn.open { color: var(--green); border-color: rgba(22,101,52,0.25); background: var(--green2); }
682
+ .door-btn.closed { color: var(--amber); border-color: rgba(146,64,14,0.25); background: var(--amber2); }
683
+ .door-btn.failed { color: var(--red); border-color: rgba(185,28,28,0.25); background: var(--red2); }
684
+
685
+ /* ── API Report ── */
686
+ .report-box {
687
+ background: var(--s2);
688
+ border: 1px solid var(--bd);
689
+ border-radius: 5px;
690
+ padding: 10px;
691
+ max-height: 160px;
692
+ overflow-y: auto;
693
+ font-family: var(--mono);
694
+ font-size: 9px;
695
+ color: var(--t2);
696
+ white-space: pre-wrap;
697
+ line-height: 1.5;
698
+ }
699
+
700
+ /* ── Scrollbar ── */
701
+ ::-webkit-scrollbar { width: 4px; height: 4px; }
702
+ ::-webkit-scrollbar-track { background: transparent; }
703
+ ::-webkit-scrollbar-thumb { background: var(--bd2); border-radius: 2px; }
frontend/src/App.tsx ADDED
@@ -0,0 +1,460 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
2
+ import './App.css';
3
+ import type { Observation, Door, ApiReport, SceneResponse } from './types';
4
+ import Map2D from './components/Map2D';
5
+ import HUD from './components/HUD';
6
+ import ControlPanel from './components/ControlPanel';
7
+ import APIReport from './components/APIReport';
8
+
9
+ const DOOR_CLOSED = 3;
10
+ const OBSTACLE = 5;
11
+
12
+ interface EventEntry {
13
+ step: number;
14
+ text: string;
15
+ reward: number;
16
+ isAlarm?: boolean;
17
+ }
18
+
19
+ function App() {
20
+ const [observation, setObservation] = useState<Observation | null>(null);
21
+ const [sceneData, setSceneData] = useState<SceneResponse | null>(null);
22
+ const [isAutoWait, setIsAutoWait] = useState(false);
23
+ const [isPolling, setIsPolling] = useState(true);
24
+ const [status, setStatus] = useState('Idle — waiting for connection');
25
+ const [isError, setIsError] = useState(false);
26
+ const [apiReport, setApiReport] = useState<ApiReport | null>(null);
27
+ const [agentMoveCount, setAgentMoveCount] = useState(0);
28
+ const [agentMoveFlash, setAgentMoveFlash] = useState(0);
29
+ const [eventLog, setEventLog] = useState<EventEntry[]>([]);
30
+
31
+ const prevAgentPos = useRef({ x: -1, y: -1 });
32
+ const autoWaitTimer = useRef<number | null>(null);
33
+ const pollTimer = useRef<number | null>(null);
34
+ const logEndRef = useRef<HTMLDivElement | null>(null);
35
+
36
+ /* scroll event log to bottom */
37
+ useEffect(() => {
38
+ logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
39
+ }, [eventLog]);
40
+
41
+ const setStatusMsg = (msg: string, error = false) => {
42
+ setStatus(msg);
43
+ setIsError(error);
44
+ };
45
+
46
+ const pushLog = (text: string, step: number, reward: number, isAlarm = false) => {
47
+ setEventLog(prev => [...prev.slice(-49), { step, text, reward, isAlarm }]);
48
+ };
49
+
50
+ const applyObservation = useCallback((obs: Observation) => {
51
+ const newX = obs.map_state.agent_x;
52
+ const newY = obs.map_state.agent_y;
53
+ if (prevAgentPos.current.x !== -1 &&
54
+ (newX !== prevAgentPos.current.x || newY !== prevAgentPos.current.y)) {
55
+ setAgentMoveFlash(18);
56
+ setAgentMoveCount(c => c + 1);
57
+ }
58
+ prevAgentPos.current = { x: newX, y: newY };
59
+ setObservation(obs);
60
+ }, []);
61
+
62
+ const updateReport = (kind: string, request: unknown, response: any) => {
63
+ const mapState = response?.observation?.map_state || response?.map_state || response?.graph;
64
+ const template = mapState?.template_name || response?.labels?.episode?.template || 'unknown';
65
+ const step = mapState?.step_count ?? response?.observation?.elapsed_steps ?? response?.labels?.episode?.step ?? '-';
66
+ const reward = Number(response?.reward ?? 0).toFixed(3);
67
+ const done = Boolean(response?.done);
68
+ setApiReport({
69
+ call_type: kind,
70
+ request,
71
+ response,
72
+ meta: `${kind.toUpperCase()} | template=${template} | step=${step} | reward=${reward} | done=${done}`,
73
+ });
74
+ };
75
+
76
+ const apiCall = async (path: string, payload: unknown) => {
77
+ const res = await fetch(path, {
78
+ method: 'POST',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify(payload || {}),
81
+ });
82
+ if (!res.ok) {
83
+ const text = await res.text();
84
+ throw new Error(`${res.status} ${res.statusText}: ${text}`);
85
+ }
86
+ return res.json();
87
+ };
88
+
89
+ const resetLive = async (difficulty = 'medium') => {
90
+ try {
91
+ setStatusMsg('Initiating Reset...');
92
+ const payload = { difficulty };
93
+ const data = await apiCall('/reset', payload);
94
+ const obs: Observation = data.observation;
95
+ if (data.observation?.map_state) {
96
+ obs.metadata = {
97
+ fire_sources: data.observation.fire_sources_count ?? 0,
98
+ fire_spread_rate:data.observation.fire_spread_rate ?? 0,
99
+ humidity: data.observation.humidity ?? 0,
100
+ difficulty,
101
+ };
102
+ }
103
+ applyObservation(obs);
104
+ updateReport('reset', payload, data);
105
+ setStatusMsg(`Ready. Reward: ${Number(data.reward || 0).toFixed(2)}`);
106
+ pushLog('Episode reset. Assess surroundings.', obs.map_state.step_count, data.reward ?? 0);
107
+ } catch (err: any) {
108
+ setStatusMsg(`Reset Failed: ${err.message}`, true);
109
+ }
110
+ };
111
+
112
+ const resetUntilDoors = async () => {
113
+ try {
114
+ setStatusMsg('Searching for layout with doors...');
115
+ for (let i = 1; i <= 8; i++) {
116
+ const payload = { difficulty: 'medium' };
117
+ const data = await apiCall('/reset', payload);
118
+ const doorCount = Object.keys(data?.observation?.map_state?.door_registry || {}).length;
119
+ if (doorCount > 0) {
120
+ const obs: Observation = data.observation;
121
+ obs.metadata = {
122
+ fire_sources: data.observation.fire_sources_count ?? 0,
123
+ fire_spread_rate:data.observation.fire_spread_rate ?? 0,
124
+ humidity: data.observation.humidity ?? 0,
125
+ difficulty: 'medium',
126
+ };
127
+ applyObservation(obs);
128
+ updateReport('reset', payload, data);
129
+ setStatusMsg(`System Ready — ${doorCount} door(s) detected.`);
130
+ pushLog(`Layout found (${doorCount} doors). Doors detected.`, obs.map_state.step_count, 0);
131
+ return;
132
+ }
133
+ }
134
+ setStatusMsg('Optimal layout not found after 8 attempts.', true);
135
+ } catch (err: any) {
136
+ setStatusMsg(`Search Failed: ${err.message}`, true);
137
+ }
138
+ };
139
+
140
+ const runAction = async (actionObj: unknown, label: string) => {
141
+ try {
142
+ setStatusMsg(`Action: ${label}`);
143
+ const payload = actionObj;
144
+ const data = await apiCall('/step', payload);
145
+ const obs: Observation = data.observation;
146
+ obs.metadata = observation?.metadata;
147
+ applyObservation(obs);
148
+ updateReport('step', payload, data);
149
+ const rwd = Number(data.reward || 0);
150
+ setStatusMsg(`Executed. Reward: ${rwd.toFixed(2)}`);
151
+ pushLog(
152
+ obs.last_action_feedback || label,
153
+ obs.map_state.step_count,
154
+ rwd,
155
+ rwd < -0.5,
156
+ );
157
+ if (data.done) setIsAutoWait(false);
158
+ } catch (err: any) {
159
+ setStatusMsg(`Error: ${err.message}`, true);
160
+ }
161
+ };
162
+
163
+ const fetchAndApplyScene = useCallback(async () => {
164
+ try {
165
+ const res = await fetch('/scene');
166
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
167
+ const scene: SceneResponse = await res.json();
168
+ setSceneData(scene);
169
+ updateReport('scene', {}, scene);
170
+
171
+ const { labels, graph } = scene;
172
+ const cell_grid: number[] = [];
173
+ const fire_grid: number[] = [];
174
+ const smoke_grid: number[] = [];
175
+
176
+ for (let y = 0; y < graph.height; y++) {
177
+ for (let x = 0; x < graph.width; x++) {
178
+ const [type, fire, smoke] = graph.grid[y][x];
179
+ cell_grid.push(type);
180
+ fire_grid.push(fire);
181
+ smoke_grid.push(smoke);
182
+ }
183
+ }
184
+
185
+ const visible_cells: [number, number][] = [];
186
+ for (let y = 0; y < graph.height; y++) {
187
+ for (let x = 0; x < graph.width; x++) {
188
+ if (graph.grid[y][x][4] === 1.0) visible_cells.push([x, y]);
189
+ }
190
+ }
191
+
192
+ const pseudoObs: Observation = {
193
+ map_state: {
194
+ cell_grid, fire_grid, smoke_grid,
195
+ agent_x: labels.agent.x,
196
+ agent_y: labels.agent.y,
197
+ visible_cells,
198
+ door_registry: labels.map.door_registry,
199
+ exit_positions: labels.map.exit_positions,
200
+ step_count: labels.episode.step,
201
+ max_steps: labels.episode.max_steps,
202
+ grid_w: graph.width,
203
+ grid_h: graph.height,
204
+ template_name: labels.episode.template,
205
+ },
206
+ agent_health: labels.agent.health,
207
+ location_label: labels.agent.location,
208
+ smoke_level: labels.agent.smoke_level,
209
+ wind_dir: labels.episode.wind_dir,
210
+ fire_visible: labels.agent.fire_visible,
211
+ fire_direction: labels.agent.fire_direction,
212
+ last_action_feedback: labels.agent.last_action_feedback,
213
+ narrative: '',
214
+ metadata: {
215
+ fire_sources: labels.episode.fire_sources,
216
+ fire_spread_rate:labels.episode.fire_spread_rate,
217
+ humidity: labels.episode.humidity,
218
+ difficulty: labels.episode.difficulty,
219
+ },
220
+ };
221
+ applyObservation(pseudoObs);
222
+ } catch (err: any) {
223
+ setStatusMsg(`Sync Error: ${err.message}`, true);
224
+ }
225
+ }, [applyObservation, observation?.metadata]);
226
+
227
+ useEffect(() => {
228
+ if (isAutoWait) {
229
+ autoWaitTimer.current = window.setInterval(() => runAction({ action: 'wait' }, 'AUTO WAIT'), 900);
230
+ } else {
231
+ if (autoWaitTimer.current) clearInterval(autoWaitTimer.current);
232
+ }
233
+ return () => { if (autoWaitTimer.current) clearInterval(autoWaitTimer.current); };
234
+ }, [isAutoWait]);
235
+
236
+ useEffect(() => {
237
+ if (isPolling) {
238
+ fetchAndApplyScene();
239
+ pollTimer.current = window.setInterval(fetchAndApplyScene, 500);
240
+ } else {
241
+ if (pollTimer.current) clearInterval(pollTimer.current);
242
+ }
243
+ return () => { if (pollTimer.current) clearInterval(pollTimer.current); };
244
+ }, [isPolling, fetchAndApplyScene]);
245
+
246
+ useEffect(() => {
247
+ if (agentMoveFlash > 0) {
248
+ const timer = setTimeout(() => setAgentMoveFlash(f => f - 1), 50);
249
+ return () => clearTimeout(timer);
250
+ }
251
+ }, [agentMoveFlash]);
252
+
253
+ const setup = async () => {
254
+ setIsPolling(false);
255
+ setAgentMoveCount(0);
256
+ setAgentMoveFlash(0);
257
+ setEventLog([]);
258
+ prevAgentPos.current = { x: -1, y: -1 };
259
+ await resetLive();
260
+ setIsPolling(true);
261
+ };
262
+
263
+ /* Derived state */
264
+ const doors: Door[] = Object.entries(observation?.map_state.door_registry || {})
265
+ .map(([id, [x, y]]) => {
266
+ const ct = observation?.map_state.cell_grid[y * (observation?.map_state.grid_w ?? 16) + x];
267
+ let state: 'open' | 'closed' | 'failed' = 'open';
268
+ if (ct === DOOR_CLOSED) state = 'closed';
269
+ if (ct === OBSTACLE) state = 'failed';
270
+ return { id, x, y, state };
271
+ })
272
+ .sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }));
273
+
274
+ const fireCells = observation?.map_state.fire_grid.filter(v => v > 0.05).length ?? 0;
275
+ const exploredPct = observation
276
+ ? Math.round((new Set(observation.map_state.visible_cells.map(([vx, vy]) => `${vx},${vy}`)).size
277
+ / observation.map_state.cell_grid.length) * 100)
278
+ : 0;
279
+
280
+ const hp = Math.round(observation?.agent_health ?? 0);
281
+ const hpColor = hp >= 60 ? 'var(--green)' : hp >= 30 ? 'var(--amber)' : 'var(--red)';
282
+ const isOnline = isPolling && !isError;
283
+ const epId = sceneData?.labels.episode.id?.slice(0, 8) ?? '—';
284
+
285
+ return (
286
+ <div className="shell">
287
+ {/* ── Topbar ── */}
288
+ <header className="topbar">
289
+ <div className="brand">
290
+ <div className="brand-icon">🔥</div>
291
+ <span className="brand-name">Pyre</span>
292
+ <span className="brand-sep">/</span>
293
+ <span className="brand-sub">Crisis Navigation</span>
294
+ </div>
295
+
296
+ <div className="topbar-right">
297
+ <div className="topbar-ep">
298
+ Episode <span className="ep-id">{epId}</span>
299
+ </div>
300
+ <span className={`live-chip ${isError ? 'error' : isOnline ? 'online' : 'offline'}`}>
301
+ {isError ? 'Error' : isOnline ? 'Live' : 'Idle'}
302
+ </span>
303
+ </div>
304
+ </header>
305
+
306
+ {/* ── Body ── */}
307
+ <div className="content">
308
+
309
+ {/* ── Left: Canvas Zone ── */}
310
+ <div className="canvas-zone">
311
+ <div className="canvas-frame">
312
+ <Map2D observation={observation} agentMoveFlash={agentMoveFlash} />
313
+ <HUD observation={observation} agentMoveCount={agentMoveCount} />
314
+ </div>
315
+
316
+ {/* Legend */}
317
+ <div className="legend">
318
+ {[
319
+ { color:'#5e5850', label:'Wall' },
320
+ { color:'#3a3530', label:'Obstacle' },
321
+ { color:'#e6f4ec', label:'Exit' },
322
+ { color:'#7c5c3c', label:'Door' },
323
+ { color:'#f97316', label:'Fire' },
324
+ { color:'rgba(72,82,96,0.7)', label:'Smoke' },
325
+ { color:'#3b82f6', label:'Agent' },
326
+ { color:'rgba(2,132,199,0.6)', label:'Trail'},
327
+ ].map(({ color, label }) => (
328
+ <div key={label} className="legend-item">
329
+ <div className="leg-swatch" style={{ background: color, border:'1px solid rgba(0,0,0,0.1)' }} />
330
+ {label}
331
+ </div>
332
+ ))}
333
+ </div>
334
+
335
+ {/* Field Report */}
336
+ <div className="dialog">
337
+ <span className="dialog-who">Field Report</span>
338
+ {observation?.last_action_feedback || 'Establishing link to field systems...'}
339
+ </div>
340
+ </div>
341
+
342
+ {/* ── Right: Side Panel ── */}
343
+ <aside className="side">
344
+
345
+ {/* Controls */}
346
+ <ControlPanel
347
+ onAction={runAction}
348
+ onReset={resetLive}
349
+ onResetDoors={resetUntilDoors}
350
+ onSetup={setup}
351
+ doors={doors}
352
+ isAutoWait={isAutoWait}
353
+ toggleAutoWait={() => setIsAutoWait(!isAutoWait)}
354
+ isPolling={isPolling}
355
+ togglePolling={() => setIsPolling(!isPolling)}
356
+ status={status}
357
+ isError={isError}
358
+ />
359
+
360
+ {/* Agent Biometrics */}
361
+ <div className="side-sec">
362
+ <div className="sec-hd">Agent Biometrics</div>
363
+ <div className="sg">
364
+ <div className="sc">
365
+ <div className="sc-l">Health</div>
366
+ <div className="sc-v" style={{ color: hpColor }}>{hp}%</div>
367
+ </div>
368
+ <div className="sc">
369
+ <div className="sc-l">Status</div>
370
+ <div className="sc-v" style={{ color: hpColor }}>
371
+ {sceneData?.labels.agent.health_status ?? 'NOMINAL'}
372
+ </div>
373
+ </div>
374
+ <div className="sc">
375
+ <div className="sc-l">Position</div>
376
+ <div className="sc-v blue">
377
+ ({observation?.map_state.agent_x ?? '—'},{observation?.map_state.agent_y ?? '—'})
378
+ </div>
379
+ </div>
380
+ <div className="sc">
381
+ <div className="sc-l">Sector</div>
382
+ <div className="sc-v">{observation?.location_label ?? 'Unknown'}</div>
383
+ </div>
384
+ </div>
385
+ <div className="bar-w">
386
+ <div className="bar-lbl">
387
+ <span>System Integrity</span>
388
+ <span>{hp}%</span>
389
+ </div>
390
+ <div className="bar-bg">
391
+ <div className="bar-fill" style={{ width: `${hp}%`, background: hpColor }} />
392
+ </div>
393
+ </div>
394
+ </div>
395
+
396
+ {/* Environment */}
397
+ <div className="side-sec">
398
+ <div className="sec-hd">Environment</div>
399
+ <div className="sg">
400
+ <div className="sc">
401
+ <div className="sc-l">Hazard Cells</div>
402
+ <div className="sc-v fire">{fireCells}</div>
403
+ </div>
404
+ <div className="sc">
405
+ <div className="sc-l">Explored</div>
406
+ <div className="sc-v blue">{exploredPct}%</div>
407
+ </div>
408
+ <div className="sc">
409
+ <div className="sc-l">Wind</div>
410
+ <div className="sc-v">{observation?.wind_dir ?? 'CALM'}</div>
411
+ </div>
412
+ <div className="sc">
413
+ <div className="sc-l">Humidity</div>
414
+ <div className="sc-v amber">
415
+ {Math.round((observation?.metadata?.humidity ?? 0) * 100)}%
416
+ </div>
417
+ </div>
418
+ </div>
419
+ </div>
420
+
421
+ {/* Event Log */}
422
+ <div className="side-sec" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
423
+ <div className="sec-hd">
424
+ Event Log
425
+ <span style={{ fontFamily: 'var(--mono)', fontSize: '9px', color: 'var(--t3)' }}>
426
+ {eventLog.length} events
427
+ </span>
428
+ </div>
429
+ <div className="elog">
430
+ {eventLog.length === 0 && (
431
+ <div style={{ color: 'var(--t3)', fontFamily: 'var(--mono)', fontSize: '10px', padding: '4px' }}>
432
+ No events yet…
433
+ </div>
434
+ )}
435
+ {eventLog.map((e, i) => (
436
+ <div key={i} className={`erow ${e.isAlarm ? 'alarm' : ''}`}>
437
+ <span className="estep">T{e.step}</span>
438
+ <span className="etext">{e.text}</span>
439
+ <span className={`erwd ${e.reward >= 0 ? 'p' : 'n'}`}>
440
+ {e.reward >= 0 ? '+' : ''}{e.reward.toFixed(2)}
441
+ </span>
442
+ </div>
443
+ ))}
444
+ <div ref={logEndRef} />
445
+ </div>
446
+ </div>
447
+
448
+ {/* API Report */}
449
+ <div className="side-sec">
450
+ <div className="sec-hd">Network Activity</div>
451
+ <APIReport report={apiReport} onCopyReset={() => {}} onCopyStep={() => {}} onCopyScene={() => {}} />
452
+ </div>
453
+
454
+ </aside>
455
+ </div>
456
+ </div>
457
+ );
458
+ }
459
+
460
+ export default App;
frontend/src/assets/hero.png ADDED
frontend/src/assets/react.svg ADDED
frontend/src/assets/vite.svg ADDED
frontend/src/components/APIReport.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import type { ApiReport } from '../types';
3
+
4
+ interface APIReportProps {
5
+ report: ApiReport | null;
6
+ onCopyReset: () => void;
7
+ onCopyStep: () => void;
8
+ onCopyScene: () => void;
9
+ }
10
+
11
+ const APIReport: React.FC<APIReportProps> = ({ report, onCopyReset, onCopyStep, onCopyScene }) => {
12
+ return (
13
+ <div>
14
+ <div className="report-meta" style={{ marginBottom: '8px' }}>
15
+ {report ? report.meta : 'Awaiting telemetry...'}
16
+ </div>
17
+ <div className="report-box">
18
+ {report ? JSON.stringify({
19
+ call_type: report.call_type,
20
+ request: report.request,
21
+ response: report.response
22
+ }, null, 2) : '{}'}
23
+ </div>
24
+ <div className="ctrl-grid" style={{ marginTop: '12px' }}>
25
+ <button className="ctrl-btn" onClick={onCopyScene}>Scene</button>
26
+ <button className="ctrl-btn" onClick={onCopyReset}>Reset</button>
27
+ <button className="ctrl-btn" onClick={onCopyStep}>Step</button>
28
+ </div>
29
+ </div>
30
+ );
31
+ };
32
+
33
+ export default APIReport;
frontend/src/components/ControlPanel.tsx ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import type { Door } from '../types';
3
+
4
+ interface ControlPanelProps {
5
+ onAction: (action: unknown, label: string) => void;
6
+ onReset: (difficulty: string) => void;
7
+ onResetDoors: () => void;
8
+ onSetup: () => void;
9
+ doors: Door[];
10
+ isAutoWait: boolean;
11
+ toggleAutoWait: () => void;
12
+ isPolling: boolean;
13
+ togglePolling: () => void;
14
+ status: string;
15
+ isError: boolean;
16
+ }
17
+
18
+ const ControlPanel: React.FC<ControlPanelProps> = ({
19
+ onAction, onSetup, doors,
20
+ isAutoWait, toggleAutoWait,
21
+ isPolling, togglePolling,
22
+ status, isError,
23
+ }) => {
24
+ const mv = (dir: string) => onAction({ action: 'move', direction: dir.toLowerCase() }, `MOVE ${dir.toUpperCase()}`);
25
+
26
+ return (
27
+ <div className="side-sec">
28
+ <div className="sec-hd">Tactical Controls</div>
29
+
30
+ {/* D-Pad */}
31
+ <div className="ctrl-grid" style={{ maxWidth: 160, margin: '0 auto', marginBottom: 8 }}>
32
+ {/* Row 1 */}
33
+ <div />
34
+ <button className="ctrl-btn" onClick={() => mv('north')}>▲</button>
35
+ <div />
36
+ {/* Row 2 */}
37
+ <button className="ctrl-btn" onClick={() => mv('west')}>◀</button>
38
+ <button className="ctrl-btn accent" onClick={() => onAction({ action: 'wait' }, 'WAIT')} title="Wait in place">●</button>
39
+ <button className="ctrl-btn" onClick={() => mv('east')}>▶</button>
40
+ {/* Row 3 */}
41
+ <div />
42
+ <button className="ctrl-btn" onClick={() => mv('south')}>▼</button>
43
+ <div />
44
+ </div>
45
+
46
+ {/* Secondary actions */}
47
+ <div className="ctrl-row">
48
+ <button className="ctrl-btn" onClick={() => onAction({ action: 'look' }, 'LOOK')}>SCAN</button>
49
+ <button className={`ctrl-btn ${isAutoWait ? 'active' : ''}`} onClick={toggleAutoWait}>
50
+ {isAutoWait ? 'STOP AUTO' : 'AUTO'}
51
+ </button>
52
+ <button className={`ctrl-btn ${isPolling ? 'active' : ''}`} onClick={togglePolling}>
53
+ {isPolling ? 'LIVE ●' : 'LIVE ○'}
54
+ </button>
55
+ </div>
56
+
57
+ {/* Reboot */}
58
+ <div className="ctrl-row" style={{ marginTop: 6 }}>
59
+ <button className="ctrl-btn play" onClick={onSetup} style={{ flex: 'none', width: '100%' }}>
60
+ ↺ REBOOT EPISODE
61
+ </button>
62
+ </div>
63
+
64
+ {/* Status line */}
65
+ <div className={`ctrl-status ${isError ? 'error' : ''}`}>
66
+ {status}
67
+ </div>
68
+
69
+ {/* Doors */}
70
+ {doors.length > 0 && (
71
+ <div style={{ marginTop: 12 }}>
72
+ <div className="sec-hd" style={{ marginBottom: 6 }}>Proximity Doors</div>
73
+ <div className="door-grid">
74
+ {doors.map(d => (
75
+ <button
76
+ key={d.id}
77
+ className={`door-btn ${d.state}`}
78
+ onClick={() => onAction({ action: 'door', target_id: d.id, door_state: d.state === 'closed' ? 'open' : 'close' }, `DOOR ${d.id}`)}
79
+ >
80
+ {d.id} [{d.state}]
81
+ </button>
82
+ ))}
83
+ </div>
84
+ </div>
85
+ )}
86
+ </div>
87
+ );
88
+ };
89
+
90
+ export default ControlPanel;
frontend/src/components/HUD.tsx ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import type { Observation } from '../types';
3
+
4
+ interface HUDProps {
5
+ observation: Observation | null;
6
+ agentMoveCount?: number;
7
+ }
8
+
9
+ /* Unicode compass arrows for each wind direction */
10
+ const WIND_ARROW: Record<string, string> = {
11
+ N: '↑', S: '↓', E: '→', W: '←',
12
+ NE: '↗', NW: '↖', SE: '↘', SW: '↙',
13
+ CALM: '·',
14
+ };
15
+
16
+ /* Rotation degrees so the arrow visually points in the right direction */
17
+ const WIND_DEG: Record<string, number> = {
18
+ N: 0, NE: 45, E: 90, SE: 135,
19
+ S: 180, SW: 225, W: 270, NW: 315, CALM: 0,
20
+ };
21
+
22
+ const HUD: React.FC<HUDProps> = ({ observation, agentMoveCount = 0 }) => {
23
+ const [firePulse, setFirePulse] = useState(0);
24
+ const pulseRef = useRef(0);
25
+
26
+ useEffect(() => {
27
+ let raf: number;
28
+ const tick = () => {
29
+ pulseRef.current += 0.05;
30
+ setFirePulse(Math.sin(pulseRef.current * 4) * 0.5 + 0.5);
31
+ raf = requestAnimationFrame(tick);
32
+ };
33
+ raf = requestAnimationFrame(tick);
34
+ return () => cancelAnimationFrame(raf);
35
+ }, []);
36
+
37
+ if (!observation) return null;
38
+
39
+ const { map_state, agent_health, smoke_level, wind_dir, fire_visible, metadata } = observation;
40
+ const hPct = Math.round(agent_health);
41
+ const sPct = Math.round((map_state.step_count / map_state.max_steps) * 100);
42
+ const totalFireCells = map_state.fire_grid.filter(v => v > 0.05).length;
43
+
44
+ let hBarClass = 'g';
45
+ let hStatusLabel = 'Nominal';
46
+ let hStatusClass = 'good';
47
+
48
+ if (hPct < 30) { hBarClass = 'c'; hStatusLabel = 'Critical'; hStatusClass = 'critical'; }
49
+ else if (hPct < 60) { hBarClass = 'm'; hStatusLabel = 'Moderate'; hStatusClass = 'moderate'; }
50
+
51
+ const windDir = wind_dir || 'CALM';
52
+ const windArrow = WIND_ARROW[windDir] ?? '?';
53
+ const windDeg = WIND_DEG[windDir] ?? 0;
54
+ const spreadRate = metadata?.fire_spread_rate ?? 0;
55
+ const humidity = metadata?.humidity ?? 0;
56
+
57
+ return (
58
+ <div className="hud-overlay">
59
+ {/* ── Left: Health ── */}
60
+ <div className="hud-card">
61
+ <div className="hud-r">
62
+ <span className="hlbl">HP</span>
63
+ <div className="hbar-bg">
64
+ <div className={`hbar-fill ${hBarClass}`} style={{ width: `${hPct}%` }} />
65
+ </div>
66
+ <span className="hval">{hPct}</span>
67
+ </div>
68
+ <div className="hud-r" style={{ gap: '8px', marginTop: '2px' }}>
69
+ <span className={`hstatus ${hStatusClass}`}>{hStatusLabel}</span>
70
+ <span style={{ fontFamily: 'var(--mono)', fontSize: '9px', color: 'rgba(168,162,158,.55)', marginLeft: 'auto' }}>
71
+ 💨 {smoke_level || 'clear'}
72
+ </span>
73
+ </div>
74
+ {agentMoveCount > 0 && (
75
+ <div style={{ fontFamily: 'var(--mono)', fontSize: '8px', color: 'rgba(168,162,158,.4)', marginTop: '2px' }}>
76
+ moves: {agentMoveCount}
77
+ </div>
78
+ )}
79
+ </div>
80
+
81
+ {/* ── Center: Wind & Hazard ── */}
82
+ <div className="hud-card hud-card-center">
83
+ {/* Compass rose */}
84
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
85
+ <div style={{ position: 'relative', width: 32, height: 32, flexShrink: 0 }}>
86
+ {/* compass ring */}
87
+ <svg width="32" height="32" viewBox="0 0 32 32" style={{ position: 'absolute', top: 0, left: 0 }}>
88
+ <circle cx="16" cy="16" r="14" fill="none" stroke="rgba(255,255,255,0.12)" strokeWidth="1.5" />
89
+ <text x="16" y="6" textAnchor="middle" fontSize="5" fill="rgba(255,255,255,0.35)" dominantBaseline="middle">N</text>
90
+ <text x="16" y="28" textAnchor="middle" fontSize="5" fill="rgba(255,255,255,0.35)" dominantBaseline="middle">S</text>
91
+ <text x="4" y="17" textAnchor="middle" fontSize="5" fill="rgba(255,255,255,0.35)" dominantBaseline="middle">W</text>
92
+ <text x="28" y="17" textAnchor="middle" fontSize="5" fill="rgba(255,255,255,0.35)" dominantBaseline="middle">E</text>
93
+ </svg>
94
+ {/* direction arrow */}
95
+ <div style={{
96
+ position: 'absolute', top: '50%', left: '50%',
97
+ transform: `translate(-50%,-50%) rotate(${windDeg}deg)`,
98
+ fontSize: windDir === 'CALM' ? '10px' : '14px',
99
+ color: windDir === 'CALM' ? 'rgba(168,162,158,0.6)' : '#fbbf24',
100
+ lineHeight: 1,
101
+ transition: 'transform 0.6s ease',
102
+ }}>
103
+ {windArrow}
104
+ </div>
105
+ </div>
106
+
107
+ <div>
108
+ <div className="hlbl">Wind</div>
109
+ <div style={{ fontFamily: 'var(--mono)', fontSize: '13px', fontWeight: 500, color: '#f0e8e0', lineHeight: 1.2 }}>
110
+ {windDir}
111
+ </div>
112
+ </div>
113
+ </div>
114
+
115
+ <div style={{ display: 'flex', gap: '10px', marginTop: '4px' }}>
116
+ <div>
117
+ <div className="hlbl">Spread</div>
118
+ <div style={{ fontFamily: 'var(--mono)', fontSize: '11px', color: spreadRate > 0.5 ? '#f87171' : '#fbbf24' }}>
119
+ {(spreadRate * 100).toFixed(0)}%
120
+ </div>
121
+ </div>
122
+ <div>
123
+ <div className="hlbl">Humidity</div>
124
+ <div style={{ fontFamily: 'var(--mono)', fontSize: '11px', color: humidity > 0.6 ? '#60a5fa' : '#a8a29e' }}>
125
+ {(humidity * 100).toFixed(0)}%
126
+ </div>
127
+ </div>
128
+ {totalFireCells > 0 && (
129
+ <div style={{ alignSelf: 'center' }}>
130
+ <span style={{
131
+ fontFamily: 'var(--mono)', fontSize: '9px', fontWeight: 700,
132
+ color: fire_visible ? '#fff' : '#fbbf24',
133
+ background: fire_visible
134
+ ? `rgba(239,${Math.floor(30 + firePulse * 40)},0,${0.75 + firePulse * 0.25})`
135
+ : `rgba(180,60,0,${0.55 + firePulse * 0.3})`,
136
+ border: fire_visible
137
+ ? `1px solid rgba(255,${Math.floor(60 + firePulse * 80)},0,0.8)`
138
+ : '1px solid rgba(251,191,36,0.5)',
139
+ padding: '2px 6px', borderRadius: '3px', letterSpacing: '0.06em',
140
+ boxShadow: fire_visible
141
+ ? `0 0 ${6 + firePulse * 8}px rgba(255,80,0,0.7)`
142
+ : `0 0 ${3 + firePulse * 4}px rgba(200,80,0,0.4)`,
143
+ display: 'flex', alignItems: 'center', gap: '3px',
144
+ transition: 'box-shadow 0.1s',
145
+ }}>
146
+ 🔥 {fire_visible ? 'IN RANGE' : 'ACTIVE'} · {totalFireCells}
147
+ </span>
148
+ </div>
149
+ )}
150
+ </div>
151
+ </div>
152
+
153
+ {/* ── Right: Steps ── */}
154
+ <div className="hud-card" style={{ textAlign: 'right', minWidth: 'auto' }}>
155
+ <div className="hud-r" style={{ justifyContent: 'flex-end' }}>
156
+ <span className="step-val">{map_state.step_count} / {map_state.max_steps}</span>
157
+ </div>
158
+ <div className="sbar-bg">
159
+ <div className="sbar-fill" style={{ width: `${sPct}%` }} />
160
+ </div>
161
+ <div className="step-meta">
162
+ {metadata?.difficulty ?? 'medium'} · {map_state.template_name}
163
+ </div>
164
+ </div>
165
+ </div>
166
+ );
167
+ };
168
+
169
+ export default HUD;
frontend/src/components/Map2D.tsx ADDED
@@ -0,0 +1,600 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useRef, useEffect } from 'react';
2
+ import type { Observation } from '../types';
3
+
4
+ interface Map2DProps {
5
+ observation: Observation | null;
6
+ agentMoveFlash: number;
7
+ }
8
+
9
+ /* ── Cell type constants ── */
10
+ const WALL = 1;
11
+ const DOOR_OPEN = 2;
12
+ const DOOR_CLOSED = 3;
13
+ const EXIT = 4;
14
+ const OBSTACLE = 5;
15
+
16
+ /* ── Wind direction vectors ── */
17
+ const WIND_DIRS: Record<string, [number, number]> = {
18
+ N: [0, -1], S: [0, 1], E: [1, 0], W: [-1, 0],
19
+ NW: [-0.7, -0.7], NE: [0.7, -0.7], SW: [-0.7, 0.7], SE: [0.7, 0.7],
20
+ CALM: [0, 0],
21
+ };
22
+
23
+ /* ── Agent appearance per health tier ── */
24
+ const AGENT_THEMES = {
25
+ healthy: { body: '#3b82f6', dark: '#1d4ed8', arm: '#2563eb', ring: '#fbbf24', ringGlow: 'rgba(251,191,36,0.5)' },
26
+ moderate: { body: '#f97316', dark: '#c2410c', arm: '#ea580c', ring: '#fb923c', ringGlow: 'rgba(251,146,60,0.5)' },
27
+ low: { body: '#dc2626', dark: '#991b1b', arm: '#b91c1c', ring: '#f87171', ringGlow: 'rgba(248,113,113,0.5)' },
28
+ critical: { body: '#7c3aed', dark: '#5b21b6', arm: '#6d28d9', ring: '#c4b5fd', ringGlow: 'rgba(196,181,253,0.5)' },
29
+ };
30
+
31
+ /* ── Ember particle ── */
32
+ class Ember {
33
+ x: number; y: number; vx: number; vy: number;
34
+ life: number; decay: number; size: number;
35
+ type: 'ember' | 'spark';
36
+
37
+ constructor(x: number, y: number, windX: number) {
38
+ const speed = 0.4 + Math.random() * 1.0;
39
+ const angle = -Math.PI / 2 + (Math.random() - 0.5) * 1.6;
40
+ this.x = x + (Math.random() - 0.5) * 3;
41
+ this.y = y + (Math.random() - 0.5) * 3;
42
+ this.vx = Math.cos(angle) * speed + windX * 0.7;
43
+ this.vy = Math.sin(angle) * speed - 0.22;
44
+ this.life = 1.0;
45
+ this.decay = 0.012 + Math.random() * 0.015;
46
+ this.size = 1.2 + Math.random() * 2.2;
47
+ this.type = Math.random() > 0.4 ? 'ember' : 'spark';
48
+ }
49
+
50
+ update() {
51
+ this.x += this.vx;
52
+ this.y += this.vy;
53
+ this.vy -= 0.012;
54
+ this.vx *= 0.97;
55
+ this.life -= this.decay;
56
+ }
57
+ }
58
+
59
+
60
+ /* ── Minecraft pixel-art character ── */
61
+ function drawMinecraftAgent(
62
+ ctx: CanvasRenderingContext2D,
63
+ cx: number, cy: number, cs: number,
64
+ theme: typeof AGENT_THEMES.healthy
65
+ ) {
66
+ const u = cs / 18;
67
+ const left = cx - 5 * u;
68
+ const top = cy - 8.5 * u;
69
+
70
+ const px = (rx: number, ry: number, rw: number, rh: number, color: string) => {
71
+ ctx.fillStyle = color;
72
+ ctx.fillRect(left + rx * u, top + ry * u, rw * u, rh * u);
73
+ };
74
+
75
+ /* Helmet */
76
+ px(2, 0, 6, 1, '#5c4a3d');
77
+ /* Head */
78
+ px(2, 1, 6, 5, '#f5d5a0');
79
+ /* Face features */
80
+ px(3, 3, 1, 1, '#3d2b1a'); /* left eye */
81
+ px(6, 3, 1, 1, '#3d2b1a'); /* right eye */
82
+ px(4, 5, 2, 1, '#c8937a'); /* mouth */
83
+ /* Hair accent */
84
+ px(2, 1, 6, 1, '#7a5c3e');
85
+
86
+ /* Body */
87
+ px(3, 6, 4, 4, theme.body);
88
+ px(3, 6, 4, 1, theme.dark);
89
+
90
+ /* Arms */
91
+ px(1, 6, 2, 4, theme.arm);
92
+ px(7, 6, 2, 4, theme.arm);
93
+
94
+ /* Legs */
95
+ px(3, 10, 2, 4, '#1e40af');
96
+ px(5, 10, 2, 4, '#1e3a8a');
97
+
98
+ /* Boots */
99
+ px(3, 14, 2, 2, '#3a2e26');
100
+ px(5, 14, 2, 2, '#2e2420');
101
+ }
102
+
103
+ /* ── Main canvas component ── */
104
+ const Map2D: React.FC<Map2DProps> = ({ observation, agentMoveFlash }) => {
105
+ const canvasRef = useRef<HTMLCanvasElement>(null);
106
+ const embersRef = useRef<Ember[]>([]);
107
+ const trailRef = useRef<{ x: number; y: number; t: number }[]>([]);
108
+ const timeRef = useRef(0);
109
+ const rafRef = useRef(0);
110
+
111
+ const CS = 40;
112
+
113
+ const animate = () => {
114
+ const canvas = canvasRef.current;
115
+ if (!canvas || !observation) { rafRef.current = requestAnimationFrame(animate); return; }
116
+ const ctx = canvas.getContext('2d');
117
+ if (!ctx) return;
118
+
119
+ timeRef.current += 0.016;
120
+ const t = timeRef.current;
121
+
122
+ const { map_state, agent_health, wind_dir } = observation;
123
+ const { grid_w: W, grid_h: H, cell_grid, fire_grid, smoke_grid, agent_x, agent_y } = map_state;
124
+ const cs = CS;
125
+ const wv = WIND_DIRS[wind_dir] ?? [0, 0];
126
+ const idx = (x: number, y: number) => y * W + x;
127
+ const visible = new Set(map_state.visible_cells.map(([vx, vy]) => `${vx},${vy}`));
128
+
129
+ /* ── Canvas bg ── */
130
+ ctx.fillStyle = '#c8b890';
131
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
132
+
133
+ /* ── Base layer ── */
134
+ for (let y = 0; y < H; y++) {
135
+ for (let x = 0; x < W; x++) {
136
+ const ct = cell_grid[idx(x, y)];
137
+ const px = x * cs, py = y * cs;
138
+
139
+ switch (ct) {
140
+ case WALL: {
141
+ /* Animated heat tint — walls near fire glow ember-red */
142
+ const nearFire = fire_grid[idx(x, y)] > 0.05
143
+ ? fire_grid[idx(x, y)]
144
+ : (
145
+ (x > 0 && fire_grid[idx(x-1, y)] > 0.05 ? fire_grid[idx(x-1, y)] : 0) +
146
+ (x < W-1 && fire_grid[idx(x+1, y)] > 0.05 ? fire_grid[idx(x+1, y)] : 0) +
147
+ (y > 0 && fire_grid[idx(x, y-1)] > 0.05 ? fire_grid[idx(x, y-1)] : 0) +
148
+ (y < H-1 && fire_grid[idx(x, y+1)] > 0.05 ? fire_grid[idx(x, y+1)] : 0)
149
+ ) * 0.28;
150
+
151
+ const heatShift = Math.min(1, nearFire * 2.2);
152
+ const wallFlicker = 0.88 + 0.12 * Math.sin(t * 7.3 + x * 2.1 + y * 3.7);
153
+
154
+ /* Base stone colour, heat-shifted toward deep orange-red */
155
+ const br = Math.round(94 + heatShift * 100 * wallFlicker);
156
+ const bg = Math.round(88 - heatShift * 52);
157
+ const bb = Math.round(80 - heatShift * 70);
158
+ ctx.fillStyle = `rgb(${br},${bg},${bb})`;
159
+ ctx.fillRect(px, py, cs, cs);
160
+
161
+ /* Brick rows — two horizontal bands */
162
+ const brickH = cs / 2;
163
+ for (let row = 0; row < 2; row++) {
164
+ const by = py + row * brickH;
165
+ /* mortar gap between rows */
166
+ ctx.fillStyle = `rgba(0,0,0,${0.28 + heatShift * 0.12})`;
167
+ ctx.fillRect(px, by + brickH - 1, cs, 1);
168
+ /* vertical mortar — staggered per row */
169
+ const mortarX = px + ((x + row) % 2 === 0 ? cs / 2 : cs / 4);
170
+ ctx.fillStyle = `rgba(0,0,0,${0.22 + heatShift * 0.10})`;
171
+ ctx.fillRect(mortarX, by, 1, brickH - 1);
172
+ }
173
+
174
+ /* Top-left highlight bevel */
175
+ ctx.fillStyle = `rgba(255,${200 - Math.round(heatShift * 80)},${160 - Math.round(heatShift * 140)},${0.32 + heatShift * 0.15})`;
176
+ ctx.fillRect(px, py, cs, 2);
177
+ ctx.fillRect(px, py + 2, 2, cs - 2);
178
+
179
+ /* Bottom-right shadow bevel */
180
+ ctx.fillStyle = `rgba(0,0,0,${0.50 + heatShift * 0.20})`;
181
+ ctx.fillRect(px, py + cs - 2, cs, 2);
182
+ ctx.fillRect(px + cs - 2, py, 2, cs - 2);
183
+
184
+ /* Heat glow overlay on wall face */
185
+ if (heatShift > 0.05) {
186
+ const glowA = heatShift * 0.35 * wallFlicker;
187
+ ctx.fillStyle = `rgba(255,${Math.round(80 - heatShift * 60)},0,${glowA})`;
188
+ ctx.fillRect(px + 2, py + 2, cs - 4, cs - 4);
189
+
190
+ /* Hot crack lines radiating from fire side */
191
+ ctx.strokeStyle = `rgba(255,${Math.round(160 - heatShift * 120)},0,${heatShift * 0.6 * wallFlicker})`;
192
+ ctx.lineWidth = 1;
193
+ ctx.beginPath();
194
+ ctx.moveTo(px + cs * 0.3, py + cs * 0.2);
195
+ ctx.lineTo(px + cs * 0.5, py + cs * 0.55);
196
+ ctx.lineTo(px + cs * 0.7, py + cs * 0.4);
197
+ ctx.stroke();
198
+ if (heatShift > 0.4) {
199
+ ctx.beginPath();
200
+ ctx.moveTo(px + cs * 0.2, py + cs * 0.7);
201
+ ctx.lineTo(px + cs * 0.45, py + cs * 0.85);
202
+ ctx.stroke();
203
+ }
204
+ }
205
+ break;
206
+ }
207
+ case OBSTACLE: {
208
+ /* Charred debris — dark with ember glow */
209
+ const obsNearFire = (
210
+ (x > 0 && fire_grid[idx(x-1, y)] > 0.05 ? fire_grid[idx(x-1, y)] : 0) +
211
+ (x < W-1 && fire_grid[idx(x+1, y)] > 0.05 ? fire_grid[idx(x+1, y)] : 0) +
212
+ (y > 0 && fire_grid[idx(x, y-1)] > 0.05 ? fire_grid[idx(x, y-1)] : 0) +
213
+ (y < H-1 && fire_grid[idx(x, y+1)] > 0.05 ? fire_grid[idx(x, y+1)] : 0)
214
+ ) * 0.4 + fire_grid[idx(x, y)] * 0.8;
215
+ const obsHeat = Math.min(1, obsNearFire);
216
+ const obsFlicker = 0.82 + 0.18 * Math.sin(t * 9.1 + x * 1.9 + y * 2.5);
217
+
218
+ ctx.fillStyle = '#2a2520';
219
+ ctx.fillRect(px, py, cs, cs);
220
+
221
+ /* Rubble texture patches */
222
+ ctx.fillStyle = 'rgba(60,50,40,0.7)';
223
+ ctx.fillRect(px + 3, py + 3, cs * 0.4, cs * 0.35);
224
+ ctx.fillRect(px + cs * 0.55, py + cs * 0.5, cs * 0.35, cs * 0.4);
225
+ ctx.fillStyle = 'rgba(80,65,50,0.5)';
226
+ ctx.fillRect(px + cs * 0.25, py + cs * 0.6, cs * 0.45, cs * 0.3);
227
+
228
+ /* Ember glow if near fire */
229
+ if (obsHeat > 0.05) {
230
+ const eg = Math.round(40 + obsHeat * 90 * obsFlicker);
231
+ ctx.fillStyle = `rgba(255,${eg},0,${obsHeat * 0.55 * obsFlicker})`;
232
+ ctx.fillRect(px + 2, py + 2, cs - 4, cs - 4);
233
+
234
+ /* Glowing edge cracks */
235
+ ctx.strokeStyle = `rgba(255,${Math.round(120 * obsHeat * obsFlicker)},0,${obsHeat * 0.8})`;
236
+ ctx.lineWidth = 1.5;
237
+ ctx.beginPath();
238
+ ctx.moveTo(px + cs * 0.1, py + cs * 0.5);
239
+ ctx.lineTo(px + cs * 0.4, py + cs * 0.3);
240
+ ctx.lineTo(px + cs * 0.6, py + cs * 0.7);
241
+ ctx.lineTo(px + cs * 0.9, py + cs * 0.4);
242
+ ctx.stroke();
243
+ }
244
+
245
+ /* Orange danger frame */
246
+ ctx.strokeStyle = `rgba(255,${Math.round(80 + obsHeat * 60)},0,${0.55 + obsHeat * 0.35})`;
247
+ ctx.lineWidth = 2;
248
+ ctx.strokeRect(px + 1, py + 1, cs - 2, cs - 2);
249
+
250
+ /* Corner bolts */
251
+ ctx.fillStyle = `rgba(255,${Math.round(100 + obsHeat * 80)},0,${0.7 + obsHeat * 0.3})`;
252
+ [[4,4],[cs-6,4],[4,cs-6],[cs-6,cs-6]].forEach(([bx, by]) => {
253
+ ctx.beginPath(); ctx.arc(px+bx, py+by, 2, 0, Math.PI*2); ctx.fill();
254
+ });
255
+ break;
256
+ }
257
+ default: {
258
+ /* Checkerboard floor with warm heat tint near fire */
259
+ const floorFire = Math.min(1,
260
+ fire_grid[idx(x, y)] * 1.5 +
261
+ (x > 0 ? fire_grid[idx(x-1, y)] : 0) * 0.3 +
262
+ (x < W-1 ? fire_grid[idx(x+1, y)] : 0) * 0.3 +
263
+ (y > 0 ? fire_grid[idx(x, y-1)] : 0) * 0.3 +
264
+ (y < H-1 ? fire_grid[idx(x, y+1)] : 0) * 0.3
265
+ );
266
+ const base = (x + y) % 2 === 0;
267
+ const fr = Math.round((base ? 232 : 208) + floorFire * 23);
268
+ const fg = Math.round((base ? 216 : 190) - floorFire * 40);
269
+ const fb = Math.round((base ? 184 : 152) - floorFire * 80);
270
+ ctx.fillStyle = `rgb(${fr},${fg},${fb})`;
271
+ ctx.fillRect(px, py, cs, cs);
272
+ /* tile bevel */
273
+ ctx.fillStyle = 'rgba(255,255,255,0.20)';
274
+ ctx.fillRect(px, py, cs, 2);
275
+ ctx.fillRect(px, py + 2, 2, cs - 2);
276
+ ctx.fillStyle = 'rgba(0,0,0,0.18)';
277
+ ctx.fillRect(px, py + cs - 2, cs, 2);
278
+ ctx.fillRect(px + cs - 2, py, 2, cs - 2);
279
+ }
280
+ }
281
+ }
282
+ }
283
+
284
+ /* ── Fire ambient: multiply scorches the floor tiles (under fog) ── */
285
+ ctx.save();
286
+ ctx.globalCompositeOperation = 'multiply';
287
+ for (let y = 0; y < H; y++) {
288
+ for (let x = 0; x < W; x++) {
289
+ const fire = fire_grid[idx(x, y)];
290
+ if (fire < 0.1) continue;
291
+ const px = x * cs + cs / 2, py = y * cs + cs / 2;
292
+ const radius = cs * (1.2 + fire * 1.8);
293
+ const a = Math.min(0.85, fire * 0.9);
294
+ const gr = ctx.createRadialGradient(px, py, 0, px, py, radius);
295
+ gr.addColorStop(0, `rgba(255,80,0,${a})`);
296
+ gr.addColorStop(0.4, `rgba(220,40,0,${a * 0.5})`);
297
+ gr.addColorStop(1, 'rgba(0,0,0,0)');
298
+ ctx.fillStyle = gr;
299
+ ctx.fillRect(px - radius, py - radius, radius * 2, radius * 2);
300
+ }
301
+ }
302
+ ctx.restore();
303
+
304
+ /* ── Smoke (dark on light bg, under fog) ── */
305
+ for (let y = 0; y < H; y++) {
306
+ for (let x = 0; x < W; x++) {
307
+ const smoke = smoke_grid[idx(x, y)];
308
+ if (smoke < 0.1) continue;
309
+ const px = x * cs + cs / 2, py = y * cs + cs / 2;
310
+ const offX = Math.sin(t * 0.5 + x) * 2;
311
+ const offY = Math.cos(t * 0.4 + y) * 2;
312
+ const alpha = Math.min(0.68, smoke * 0.8);
313
+ const gr = ctx.createRadialGradient(px + offX, py + offY, 0, px + offX, py + offY, cs * 0.82);
314
+ gr.addColorStop(0, `rgba(72,82,96,${alpha})`);
315
+ gr.addColorStop(1, 'rgba(72,82,96,0)');
316
+ ctx.fillStyle = gr;
317
+ ctx.beginPath(); ctx.arc(px + offX, py + offY, cs * 0.82, 0, Math.PI * 2); ctx.fill();
318
+ }
319
+ }
320
+
321
+ /* ── Exits & Doors ── */
322
+ for (let y = 0; y < H; y++) {
323
+ for (let x = 0; x < W; x++) {
324
+ const ct = cell_grid[idx(x, y)];
325
+ const px = x * cs, py = y * cs;
326
+ const pulse = 0.7 + 0.3 * Math.sin(t * 3);
327
+
328
+ if (ct === EXIT) {
329
+ ctx.fillStyle = '#e6f4ec';
330
+ ctx.fillRect(px + 2, py + 2, cs - 4, cs - 4);
331
+ ctx.strokeStyle = `rgba(22,163,74,${0.7 + 0.3 * pulse})`;
332
+ ctx.lineWidth = 2 * pulse;
333
+ ctx.strokeRect(px + 5, py + 5, cs - 10, cs - 10);
334
+ /* EXIT symbol */
335
+ ctx.fillStyle = `rgba(22,163,74,${0.85 + 0.15 * pulse})`;
336
+ ctx.font = `bold ${cs * 0.26}px var(--mono, monospace)`;
337
+ ctx.textAlign = 'center';
338
+ ctx.textBaseline = 'middle';
339
+ ctx.fillText('EXIT', px + cs / 2, py + cs / 2);
340
+ } else if (ct === DOOR_CLOSED) {
341
+ ctx.fillStyle = '#7c5c3c';
342
+ ctx.fillRect(px + 4, py + 2, cs - 8, cs - 4);
343
+ ctx.fillStyle = '#4a3020';
344
+ ctx.fillRect(px + 2, py, cs - 4, 2);
345
+ ctx.fillRect(px + 2, py + cs - 2, cs - 4, 2);
346
+ /* handle */
347
+ ctx.fillStyle = '#f0b030';
348
+ ctx.beginPath();
349
+ ctx.arc(px + cs - 10, py + cs / 2, 2.5, 0, Math.PI * 2);
350
+ ctx.fill();
351
+ } else if (ct === DOOR_OPEN) {
352
+ ctx.fillStyle = '#4a3020';
353
+ ctx.fillRect(px + 2, py, 4, cs);
354
+ ctx.fillRect(px + cs - 6, py, 4, cs);
355
+ }
356
+ }
357
+ }
358
+
359
+ /* ── Fog of War (dim only — fire still punches through above) ── */
360
+ for (let y = 0; y < H; y++) {
361
+ for (let x = 0; x < W; x++) {
362
+ const key = `${x},${y}`;
363
+ if (!visible.has(key)) {
364
+ ctx.fillStyle = 'rgba(140,134,126,0.55)';
365
+ ctx.fillRect(x * cs, y * cs, cs, cs);
366
+ }
367
+ }
368
+ }
369
+
370
+ /* ── Fire volumetric: drawn ABOVE fog — always visible ── */
371
+ for (let y = 0; y < H; y++) {
372
+ for (let x = 0; x < W; x++) {
373
+ const fire = fire_grid[idx(x, y)];
374
+ if (fire < 0.05) continue;
375
+ const px = x * cs + cs / 2, py = y * cs + cs / 2;
376
+ const flicker = 0.80 + 0.20 * Math.sin(t * 11.0 + x * 3.1 + y * 2.7);
377
+ const eff = Math.min(1, fire * flicker);
378
+ const isVisible = visible.has(`${x},${y}`);
379
+
380
+ const windDx = wv[0] * cs * 0.25 * eff;
381
+ const windDy = wv[1] * cs * 0.25 * eff - cs * 0.06;
382
+
383
+ /* Wide warning beacon glow for fire in fog — always shown */
384
+ if (!isVisible) {
385
+ const beaconPulse = 0.6 + 0.4 * Math.sin(t * 6.0 + x * 1.7 + y * 2.3);
386
+ const beaconR = cs * (1.4 + beaconPulse * 0.6);
387
+ const beaconGr = ctx.createRadialGradient(px, py, 0, px, py, beaconR);
388
+ beaconGr.addColorStop(0, `rgba(255,100,0,${eff * beaconPulse * 0.85})`);
389
+ beaconGr.addColorStop(0.3, `rgba(255,60,0,${eff * beaconPulse * 0.50})`);
390
+ beaconGr.addColorStop(1, 'rgba(220,30,0,0)');
391
+ ctx.fillStyle = beaconGr;
392
+ ctx.beginPath(); ctx.arc(px, py, beaconR, 0, Math.PI * 2); ctx.fill();
393
+ }
394
+
395
+ /* Outer dark-red base */
396
+ {
397
+ const r = cs * 0.70 * (0.7 + eff * 0.3);
398
+ const cx2 = px + windDx * 0.5, cy2 = py + windDy * 0.5;
399
+ const gr = ctx.createRadialGradient(cx2, cy2, 0, cx2, cy2, r);
400
+ gr.addColorStop(0, `rgba(200,20,0,${eff * 0.65})`);
401
+ gr.addColorStop(0.55,`rgba(170,10,0,${eff * 0.35})`);
402
+ gr.addColorStop(1, 'rgba(80,0,0,0)');
403
+ ctx.fillStyle = gr;
404
+ ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI * 2); ctx.fill();
405
+ }
406
+
407
+ /* Mid vivid-orange body */
408
+ {
409
+ const r = cs * 0.46 * (0.8 + eff * 0.2);
410
+ const cx2 = px + windDx * 0.35, cy2 = py + windDy * 0.35 - cs * 0.04 * eff;
411
+ const gr = ctx.createRadialGradient(cx2, cy2, 0, cx2, cy2, r);
412
+ gr.addColorStop(0, `rgba(255,110,0,${eff * 0.92})`);
413
+ gr.addColorStop(0.45,`rgba(255,60,0,${eff * 0.62})`);
414
+ gr.addColorStop(1, 'rgba(220,20,0,0)');
415
+ ctx.fillStyle = gr;
416
+ ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI * 2); ctx.fill();
417
+ }
418
+
419
+ /* Inner bright-yellow core */
420
+ {
421
+ const r = cs * 0.28 * eff;
422
+ const cx2 = px + windDx * 0.15, cy2 = py + windDy * 0.15 - cs * 0.10 * eff;
423
+ const gr = ctx.createRadialGradient(cx2, cy2, 0, cx2, cy2, r);
424
+ gr.addColorStop(0, `rgba(255,235,90,${eff * 0.97})`);
425
+ gr.addColorStop(0.35,`rgba(255,175,25,${eff * 0.78})`);
426
+ gr.addColorStop(1, 'rgba(255,80,0,0)');
427
+ ctx.fillStyle = gr;
428
+ ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI * 2); ctx.fill();
429
+ }
430
+
431
+ /* White-hot tip (only for intense fire) */
432
+ if (eff > 0.55) {
433
+ const r = cs * 0.14 * eff;
434
+ const cx2 = px + windDx * 0.1, cy2 = py + windDy * 0.1 - cs * 0.18 * eff;
435
+ const gr = ctx.createRadialGradient(cx2, cy2, 0, cx2, cy2, r);
436
+ gr.addColorStop(0, `rgba(255,255,230,${eff * 0.95})`);
437
+ gr.addColorStop(1, 'rgba(255,220,60,0)');
438
+ ctx.fillStyle = gr;
439
+ ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI * 2); ctx.fill();
440
+ }
441
+
442
+ /* Wind-carried plume tip */
443
+ if (fire > 0.35) {
444
+ const r = cs * 0.30 * eff;
445
+ const cx2 = px + windDx, cy2 = py + windDy - cs * 0.22 * eff;
446
+ const gr = ctx.createRadialGradient(cx2, cy2, 0, cx2, cy2, r);
447
+ gr.addColorStop(0, `rgba(255,165,10,${eff * 0.68})`);
448
+ gr.addColorStop(1, 'rgba(255,60,0,0)');
449
+ ctx.fillStyle = gr;
450
+ ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI * 2); ctx.fill();
451
+ }
452
+
453
+ /* Outer visible bloom ring (makes fire pop even in fog) */
454
+ {
455
+ const bloomR = cs * (0.85 + eff * 0.35);
456
+ const bloomGr = ctx.createRadialGradient(px, py, cs * 0.2, px, py, bloomR);
457
+ bloomGr.addColorStop(0, 'rgba(255,120,0,0)');
458
+ bloomGr.addColorStop(0.6, `rgba(255,80,0,${eff * 0.22})`);
459
+ bloomGr.addColorStop(1, 'rgba(200,30,0,0)');
460
+ ctx.fillStyle = bloomGr;
461
+ ctx.beginPath(); ctx.arc(px, py, bloomR, 0, Math.PI * 2); ctx.fill();
462
+ }
463
+
464
+ if (fire > 0.45 && Math.random() < 0.09 && embersRef.current.length < 120) {
465
+ embersRef.current.push(new Ember(px, py, wv[0]));
466
+ }
467
+ }
468
+ }
469
+
470
+ /* ── Vision lantern glow around agent ── */
471
+ const apx = agent_x * cs + cs / 2, apy = agent_y * cs + cs / 2;
472
+ const lanternR = cs * 3.5;
473
+ const lanternGr = ctx.createRadialGradient(apx, apy, 0, apx, apy, lanternR);
474
+ lanternGr.addColorStop(0, 'rgba(255,240,190,0.18)');
475
+ lanternGr.addColorStop(0.5, 'rgba(255,220,140,0.08)');
476
+ lanternGr.addColorStop(1, 'rgba(0,0,0,0)');
477
+ ctx.fillStyle = lanternGr;
478
+ ctx.fillRect(apx - lanternR, apy - lanternR, lanternR * 2, lanternR * 2);
479
+
480
+ /* ── Agent trail ── */
481
+ const now = timeRef.current;
482
+ if (
483
+ trailRef.current.length === 0 ||
484
+ Math.abs(trailRef.current[0].x - apx) > 1 ||
485
+ Math.abs(trailRef.current[0].y - apy) > 1
486
+ ) {
487
+ trailRef.current.unshift({ x: apx, y: apy, t: now });
488
+ }
489
+ if (trailRef.current.length > 20) trailRef.current.pop();
490
+
491
+ trailRef.current.forEach((p, i) => {
492
+ const alpha = (1 - i / trailRef.current.length) * 0.70;
493
+ ctx.fillStyle = `rgba(2,132,199,${alpha})`;
494
+ ctx.beginPath();
495
+ ctx.arc(p.x, p.y, cs * 0.12 * (1 - i / 22), 0, Math.PI * 2);
496
+ ctx.fill();
497
+ });
498
+
499
+ /* ── Agent rendering ── */
500
+ const theme =
501
+ agent_health >= 60 ? AGENT_THEMES.healthy :
502
+ agent_health >= 30 ? AGENT_THEMES.moderate :
503
+ agent_health > 0 ? AGENT_THEMES.low :
504
+ AGENT_THEMES.critical;
505
+
506
+ const pulse = 0.85 + 0.15 * Math.sin(t * 4);
507
+ const ringR = cs * 0.48;
508
+
509
+ /* pulsing gold aura */
510
+ const auraR = ringR * (1.5 + 0.2 * pulse);
511
+ const auraGr = ctx.createRadialGradient(apx, apy, ringR * 0.7, apx, apy, auraR);
512
+ auraGr.addColorStop(0, `rgba(251,191,36,${0.28 * pulse})`);
513
+ auraGr.addColorStop(1, 'rgba(251,191,36,0)');
514
+ ctx.fillStyle = auraGr;
515
+ ctx.beginPath(); ctx.arc(apx, apy, auraR, 0, Math.PI * 2); ctx.fill();
516
+
517
+ /* ground shadow */
518
+ ctx.save();
519
+ ctx.globalAlpha = 0.22;
520
+ const shadowGr = ctx.createRadialGradient(apx, apy + cs * 0.32, 0, apx, apy + cs * 0.32, cs * 0.38);
521
+ shadowGr.addColorStop(0, 'rgba(0,0,0,0.6)');
522
+ shadowGr.addColorStop(1, 'rgba(0,0,0,0)');
523
+ ctx.fillStyle = shadowGr;
524
+ ctx.beginPath(); ctx.ellipse(apx, apy + cs * 0.32, cs * 0.38, cs * 0.14, 0, 0, Math.PI * 2); ctx.fill();
525
+ ctx.restore();
526
+
527
+ /* Minecraft character */
528
+ drawMinecraftAgent(ctx, apx, apy, cs, theme);
529
+
530
+ /* health arc ring — gold ring + colored fill */
531
+ const hRatio = Math.max(0, Math.min(1, agent_health / 100));
532
+ /* ring track */
533
+ ctx.beginPath();
534
+ ctx.arc(apx, apy, ringR, 0, Math.PI * 2);
535
+ ctx.strokeStyle = 'rgba(0,0,0,0.12)';
536
+ ctx.lineWidth = 4;
537
+ ctx.lineCap = 'round';
538
+ ctx.stroke();
539
+ /* gold base ring */
540
+ ctx.beginPath();
541
+ ctx.arc(apx, apy, ringR, 0, Math.PI * 2);
542
+ ctx.strokeStyle = 'rgba(251,191,36,0.25)';
543
+ ctx.lineWidth = 3.5;
544
+ ctx.stroke();
545
+ /* health fill */
546
+ ctx.beginPath();
547
+ ctx.arc(apx, apy, ringR, -Math.PI / 2, -Math.PI / 2 + hRatio * Math.PI * 2);
548
+ ctx.strokeStyle = theme.ring;
549
+ ctx.lineWidth = 3.5;
550
+ ctx.lineCap = 'round';
551
+ ctx.stroke();
552
+ /* ring glow */
553
+ ctx.beginPath();
554
+ ctx.arc(apx, apy, ringR, -Math.PI / 2, -Math.PI / 2 + hRatio * Math.PI * 2);
555
+ ctx.strokeStyle = theme.ringGlow;
556
+ ctx.lineWidth = 6;
557
+ ctx.stroke();
558
+
559
+ /* move flash */
560
+ if (agentMoveFlash > 0) {
561
+ const fa = agentMoveFlash / 18;
562
+ ctx.strokeStyle = `rgba(255,255,255,${fa * 0.8})`;
563
+ ctx.lineWidth = 3;
564
+ ctx.beginPath();
565
+ ctx.arc(apx, apy, ringR * (1.8 + (1 - fa) * 0.6), 0, Math.PI * 2);
566
+ ctx.stroke();
567
+ }
568
+
569
+ /* ── Embers ── */
570
+ for (let i = embersRef.current.length - 1; i >= 0; i--) {
571
+ const e = embersRef.current[i];
572
+ e.update();
573
+ if (e.life <= 0) { embersRef.current.splice(i, 1); continue; }
574
+ ctx.fillStyle = `rgba(255,${Math.floor(80 + 175 * e.life)},0,${e.life})`;
575
+ ctx.beginPath(); ctx.arc(e.x, e.y, e.size * e.life, 0, Math.PI * 2); ctx.fill();
576
+ }
577
+
578
+ rafRef.current = requestAnimationFrame(animate);
579
+ };
580
+
581
+ useEffect(() => {
582
+ rafRef.current = requestAnimationFrame(animate);
583
+ return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
584
+ // eslint-disable-next-line react-hooks/exhaustive-deps
585
+ }, [observation, agentMoveFlash]);
586
+
587
+ const W = observation?.map_state.grid_w ?? 16;
588
+ const H = observation?.map_state.grid_h ?? 16;
589
+
590
+ return (
591
+ <canvas
592
+ ref={canvasRef}
593
+ id="map-canvas"
594
+ width={W * CS}
595
+ height={H * CS}
596
+ />
597
+ );
598
+ };
599
+
600
+ export default Map2D;
frontend/src/components/StatusCard.tsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ interface StatusRowProps {
4
+ label: string;
5
+ value: string | number;
6
+ className?: string;
7
+ }
8
+
9
+ export const StatusRow: React.FC<StatusRowProps> = ({ label, value, className = '' }) => (
10
+ <div className="srow">
11
+ <span>{label}</span>
12
+ <span className={`sv ${className}`}>{value}</span>
13
+ </div>
14
+ );
15
+
16
+ interface StatusCardProps {
17
+ title: string;
18
+ children: React.ReactNode;
19
+ }
20
+
21
+ export const StatusCard: React.FC<StatusCardProps> = ({ title, children }) => (
22
+ <div className="card">
23
+ <div className="card-title">{title}</div>
24
+ {children}
25
+ </div>
26
+ );
frontend/src/index.css ADDED
@@ -0,0 +1 @@
 
 
1
+ /* index.css cleared to use App.css exclusively for project styling */
frontend/src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './index.css'
4
+ import App from './App.tsx'
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
frontend/src/types.ts ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface SceneLabels {
2
+ agent: {
3
+ x: number;
4
+ y: number;
5
+ health: number;
6
+ health_status: string;
7
+ alive: boolean;
8
+ evacuated: boolean;
9
+ location: string;
10
+ smoke_level: string;
11
+ fire_visible: boolean;
12
+ fire_direction: string | null;
13
+ last_action_feedback: string;
14
+ };
15
+ episode: {
16
+ id: string;
17
+ step: number;
18
+ max_steps: number;
19
+ template: string;
20
+ difficulty: string;
21
+ wind_dir: string;
22
+ fire_spread_rate: number;
23
+ humidity: number;
24
+ fire_sources: number;
25
+ };
26
+ map: {
27
+ width: number;
28
+ height: number;
29
+ exit_positions: [number, number][];
30
+ door_registry: Record<string, [number, number]>;
31
+ };
32
+ surroundings: {
33
+ visible_objects: {
34
+ id: string;
35
+ type: string;
36
+ relative_pos: string;
37
+ state: string;
38
+ }[];
39
+ blocked_exit_ids: string[];
40
+ audible_signals: string[];
41
+ available_actions: string[];
42
+ };
43
+ }
44
+
45
+ export interface SceneGraph {
46
+ channels: string[];
47
+ channel_info: Record<string, string>;
48
+ width: number;
49
+ height: number;
50
+ grid: number[][][]; // grid[y][x] = [cell_type, fire, smoke, is_agent, is_visible]
51
+ }
52
+
53
+ export interface SceneResponse {
54
+ labels: SceneLabels;
55
+ graph: SceneGraph;
56
+ }
57
+
58
+ export interface Observation {
59
+ map_state: {
60
+ cell_grid: number[];
61
+ fire_grid: number[];
62
+ smoke_grid: number[];
63
+ agent_x: number;
64
+ agent_y: number;
65
+ visible_cells: [number, number][];
66
+ door_registry: Record<string, [number, number]>;
67
+ exit_positions: [number, number][];
68
+ step_count: number;
69
+ max_steps: number;
70
+ grid_w: number;
71
+ grid_h: number;
72
+ template_name: string;
73
+ };
74
+ agent_health: number;
75
+ location_label: string;
76
+ smoke_level: string;
77
+ wind_dir: string;
78
+ fire_visible: boolean;
79
+ fire_direction: string | null;
80
+ last_action_feedback: string;
81
+ narrative: string;
82
+ reward?: number;
83
+ done?: boolean;
84
+ metadata?: {
85
+ fire_sources: number;
86
+ fire_spread_rate: number;
87
+ humidity: number;
88
+ difficulty: string;
89
+ };
90
+ }
91
+
92
+ export interface Door {
93
+ id: string;
94
+ x: number;
95
+ y: number;
96
+ state: 'open' | 'closed' | 'failed';
97
+ }
98
+
99
+ export interface StaffMember {
100
+ id: string;
101
+ x: number;
102
+ y: number;
103
+ phase: number;
104
+ mood: 'calm' | 'panicked';
105
+ }
106
+
107
+ export interface ApiReport {
108
+ call_type: string;
109
+ request: any;
110
+ response: any;
111
+ meta: string;
112
+ }
frontend/tsconfig.app.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4
+ "target": "es2023",
5
+ "lib": ["ES2023", "DOM"],
6
+ "module": "esnext",
7
+ "types": ["vite/client"],
8
+ "skipLibCheck": true,
9
+
10
+ /* Bundler mode */
11
+ "moduleResolution": "bundler",
12
+ "allowImportingTsExtensions": true,
13
+ "verbatimModuleSyntax": true,
14
+ "moduleDetection": "force",
15
+ "noEmit": true,
16
+ "jsx": "react-jsx",
17
+
18
+ /* Linting */
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "erasableSyntaxOnly": true,
22
+ "noFallthroughCasesInSwitch": true
23
+ },
24
+ "include": ["src"]
25
+ }
frontend/tsconfig.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ]
7
+ }
frontend/tsconfig.node.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
+ "target": "es2023",
5
+ "lib": ["ES2023"],
6
+ "module": "esnext",
7
+ "types": ["node"],
8
+ "skipLibCheck": true,
9
+
10
+ /* Bundler mode */
11
+ "moduleResolution": "bundler",
12
+ "allowImportingTsExtensions": true,
13
+ "verbatimModuleSyntax": true,
14
+ "moduleDetection": "force",
15
+ "noEmit": true,
16
+
17
+ /* Linting */
18
+ "noUnusedLocals": true,
19
+ "noUnusedParameters": true,
20
+ "erasableSyntaxOnly": true,
21
+ "noFallthroughCasesInSwitch": true
22
+ },
23
+ "include": ["vite.config.ts"]
24
+ }
frontend/vite.config.ts ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ import path from 'path'
4
+
5
+ // https://vite.dev/config/
6
+ export default defineConfig({
7
+ plugins: [react()],
8
+ build: {
9
+ outDir: path.resolve(__dirname, '../server/static'),
10
+ emptyOutDir: true,
11
+ },
12
+ server: {
13
+ proxy: {
14
+ '/state': 'http://127.0.0.1:8000',
15
+ '/reset': 'http://127.0.0.1:8000',
16
+ '/step': 'http://127.0.0.1:8000',
17
+ '/scene': 'http://127.0.0.1:8000',
18
+ }
19
+ }
20
+ })
openenv_pyre_env.egg-info/PKG-INFO CHANGED
@@ -13,3 +13,16 @@ Requires-Dist: langchain-openai>=1.2.1
13
  Provides-Extra: dev
14
  Requires-Dist: pytest>=8.0.0; extra == "dev"
15
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  Provides-Extra: dev
14
  Requires-Dist: pytest>=8.0.0; extra == "dev"
15
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
16
+ Provides-Extra: train
17
+ Requires-Dist: datasets>=4.8.4; extra == "train"
18
+ Requires-Dist: peft>=0.15.2; extra == "train"
19
+ Requires-Dist: tensorboard>=2.20.0; extra == "train"
20
+ Requires-Dist: torch>=2.9.0; extra == "train"
21
+ Requires-Dist: transformers>=4.57.6; extra == "train"
22
+ Requires-Dist: trl>=1.2.0; extra == "train"
23
+ Requires-Dist: tornado>=6.5.5; extra == "train"
24
+ Requires-Dist: vllm<=0.18.0,>=0.11.0; extra == "train"
25
+ Requires-Dist: flashinfer-python==0.6.3; extra == "train"
26
+ Requires-Dist: flashinfer-cubin==0.6.3; extra == "train"
27
+ Requires-Dist: jupyter>=1.1.1; extra == "train"
28
+ Requires-Dist: flash-attn>=2.7.0; extra == "train"
openenv_pyre_env.egg-info/SOURCES.txt CHANGED
@@ -4,6 +4,7 @@ client.py
4
  evals.py
5
  models.py
6
  pyproject.toml
 
7
  ./__init__.py
8
  ./client.py
9
  ./evals.py
@@ -20,5 +21,6 @@ server/app.py
20
  server/fire_sim.py
21
  server/floor_plan.py
22
  server/narrative.py
 
23
  server/pyre_env_environment.py
24
  server/rubrics.py
 
4
  evals.py
5
  models.py
6
  pyproject.toml
7
+ train_grpo_openenv.py
8
  ./__init__.py
9
  ./client.py
10
  ./evals.py
 
21
  server/fire_sim.py
22
  server/floor_plan.py
23
  server/narrative.py
24
+ server/npc_model.py
25
  server/pyre_env_environment.py
26
  server/rubrics.py
openenv_pyre_env.egg-info/requires.txt CHANGED
@@ -9,3 +9,17 @@ langchain-openai>=1.2.1
9
  [dev]
10
  pytest>=8.0.0
11
  pytest-cov>=4.0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  [dev]
10
  pytest>=8.0.0
11
  pytest-cov>=4.0.0
12
+
13
+ [train]
14
+ datasets>=4.8.4
15
+ peft>=0.15.2
16
+ tensorboard>=2.20.0
17
+ torch>=2.9.0
18
+ transformers>=4.57.6
19
+ trl>=1.2.0
20
+ tornado>=6.5.5
21
+ vllm<=0.18.0,>=0.11.0
22
+ flashinfer-python==0.6.3
23
+ flashinfer-cubin==0.6.3
24
+ jupyter>=1.1.1
25
+ flash-attn>=2.7.0
outputs/20260425_154907_Qwen-Qwen3-06B/error.txt ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ warning: Failed to uninstall package at .venv/lib/python3.12/site-packages/numpy-2.4.4.dist-info due to missing `RECORD` file. Installation may result in an incomplete environment.
2
+ Uninstalled 2 packages in 6.94s
3
+ warning: Failed to hardlink files; falling back to full copy. This may lead to degraded performance.
4
+ If the cache and target directories are on different filesystems, hardlinking may not be supported.
5
+ If this is intentional, set `export UV_LINK_MODE=copy` or use `--link-mode=copy` to suppress this warning.
6
+ Installed 1 package in 1.15s
7
+ [2026-04-25 16:01:06] INFO train_grpo_openenv.py:567: Model: Qwen/Qwen3-0.6B
8
+ [2026-04-25 16:01:06] INFO train_grpo_openenv.py:573: Dataset: 1000 prompts
9
+ [2026-04-25 16:01:07] INFO train_grpo_openenv.py:608: Output: ./outputs/20260425_154907_Qwen-Qwen3-06B | vLLM mode: colocate
10
+ `torch_dtype` is deprecated! Use `dtype` instead!
11
+ Flash Attention 2 only supports torch.float16 and torch.bfloat16 dtypes, but the current dype in Qwen3ForCausalLM is torch.float32. You should run training or inference using Automatic Mixed-Precision via the `with torch.autocast(device_type='torch_device'):` decorator, or load the model with the `dtype` argument. Example: `model = AutoModel.from_pretrained("openai/whisper-tiny", attn_implementation="flash_attention_2", dtype=torch.float16)`
12
+ Flash Attention 2 only supports torch.float16 and torch.bfloat16 dtypes, but the current dype in Qwen3Model is torch.float32. You should run training or inference using Automatic Mixed-Precision via the `with torch.autocast(device_type='torch_device'):` decorator, or load the model with the `dtype` argument. Example: `model = AutoModel.from_pretrained("openai/whisper-tiny", attn_implementation="flash_attention_2", dtype=torch.float16)`
13
+ <frozen importlib._bootstrap_external>:1301: FutureWarning: The cuda.cudart module is deprecated and will be removed in a future release, please switch to use the cuda.bindings.runtime module instead.
14
+ <frozen importlib._bootstrap_external>:1301: FutureWarning: The cuda.nvrtc module is deprecated and will be removed in a future release, please switch to use the cuda.bindings.nvrtc module instead.
15
+
16
+
17
+
18
+
19
+ [rank0]: Traceback (most recent call last):
20
+ [rank0]: File "/dccstor/kirushikesh/personal-projects/openenv-pyre/train_grpo_openenv.py", line 611, in <module>
21
+ [rank0]: trainer = GRPOTrainer(
22
+ [rank0]: ^^^^^^^^^^^^
23
+ [rank0]: File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/trl/trainer/grpo_trainer.py", line 750, in __init__
24
+ [rank0]: self.vllm_generation = VLLMGeneration(
25
+ [rank0]: ^^^^^^^^^^^^^^^
26
+ [rank0]: File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/trl/generation/vllm_generation.py", line 282, in __init__
27
+ [rank0]: self._init_vllm()
28
+ [rank0]: File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/trl/generation/vllm_generation.py", line 342, in _init_vllm
29
+ [rank0]: self.llm = LLM(
30
+ [rank0]: ^^^^
31
+ [rank0]: File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/vllm/entrypoints/llm.py", line 346, in __init__
32
+ [rank0]: self.llm_engine = LLMEngine.from_engine_args(
33
+ [rank0]: ^^^^^^^^^^^^^^^^^^^^^^^^^^^
34
+ [rank0]: File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/vllm/v1/engine/llm_engine.py", line 174, in from_engine_args
35
+ [rank0]: return cls(
36
+ [rank0]: ^^^^
37
+ [rank0]: File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/vllm/v1/engine/llm_engine.py", line 108, in __init__
38
+ [rank0]: self.engine_core = EngineCoreClient.make_client(
39
+ [rank0]: ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
40
+ [rank0]: File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/vllm/v1/engine/core_client.py", line 97, in make_client
41
+ [rank0]: return InprocClient(vllm_config, executor_class, log_stats)
42
+ [rank0]: ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
43
+ [rank0]: File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/vllm/v1/engine/core_client.py", line 277, in __init__
44
+ [rank0]: self.engine_core = EngineCore(*args, **kwargs)
45
+ [rank0]: ^^^^^^^^^^^^^^^^^^^^^^^^^^^
46
+ [rank0]: File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/vllm/v1/engine/core.py", line 113, in __init__
47
+ [rank0]: num_gpu_blocks, num_cpu_blocks, kv_cache_config = self._initialize_kv_caches(
48
+ [rank0]: ^^^^^^^^^^^^^^^^^^^^^^^^^^^
49
+ [rank0]: File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/vllm/v1/engine/core.py", line 259, in _initialize_kv_caches
50
+ [rank0]: kv_cache_configs = get_kv_cache_configs(
51
+ [rank0]: ^^^^^^^^^^^^^^^^^^^^^
52
+ [rank0]: File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/vllm/v1/core/kv_cache_utils.py", line 1516, in get_kv_cache_configs
53
+ [rank0]: _check_enough_kv_cache_memory(
54
+ [rank0]: File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/vllm/v1/core/kv_cache_utils.py", line 634, in _check_enough_kv_cache_memory
55
+ [rank0]: raise ValueError(
56
+ [rank0]: ValueError: To serve at least one request with the models's max seq len (8192), (0.88 GiB KV cache is needed, which is larger than the available KV cache memory (0.12 GiB). Based on the available memory, the estimated maximum model length is 1104. Try increasing `gpu_memory_utilization` or decreasing `max_model_len` when initializing the engine. See https://docs.vllm.ai/en/latest/configuration/conserving_memory/ for more details.
57
+ [rank0]:[W425 16:05:53.916115210 ProcessGroupNCCL.cpp:1524] Warning: WARNING: destroy_process_group() was not called before program exit, which can leak resources. For more info, please see https://pytorch.org/docs/stable/distributed.html#shutdown (function operator())
outputs/20260425_154907_Qwen-Qwen3-06B/output.txt ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Helper functions defined.
2
+ Rollout functions defined.
3
+ Reward functions: evacuated, step_efficiency, format, efficiency
4
+
5
+ ------------------------------------------------------------
6
+ Sender: LSF System <lsfadmin@cccxc590>
7
+ Subject: Job 1281121: <20260425_154907_Qwen-Qwen3-06B> in cluster <CCCFP15Cluster> Exited
8
+
9
+ Job <20260425_154907_Qwen-Qwen3-06B> was submitted from host <ccc-login3> by user <kirushi> in cluster <CCCFP15Cluster> at Sat Apr 25 15:50:13 2026
10
+ Job was executed on host(s) <cccxc590>, in queue <normal>, as user <kirushi> in cluster <CCCFP15Cluster> at Sat Apr 25 15:49:50 2026
11
+ </u/kirushi> was used as the home directory.
12
+ </dccstor/kirushikesh/personal-projects/openenv-pyre> was used as the working directory.
13
+ Started at Sat Apr 25 15:49:50 2026
14
+ Terminated at Sat Apr 25 16:05:56 2026
15
+ Results reported at Sat Apr 25 16:05:56 2026
16
+
17
+ Your job looked like:
18
+
19
+ ------------------------------------------------------------
20
+ # LSBATCH: User input
21
+ /u/kirushi/.local/bin/uv run python train_grpo_openenv.py --model-id Qwen/Qwen3-0.6B --dataset-size 1000 --output-dir ./outputs/20260425_154907_Qwen-Qwen3-06B --report-to tensorboard --seed 42
22
+ ------------------------------------------------------------
23
+
24
+ Exited with exit code 1.
25
+
26
+ Resource usage summary:
27
+
28
+ CPU time : 374.61 sec.
29
+ Max Memory : 3 GB
30
+ Average Memory : 1.39 GB
31
+ Total Requested Memory : -
32
+ Delta Memory : -
33
+ Max Swap : -
34
+ Max Processes : 5
35
+ Max Threads : 494
36
+ Run time : 966 sec.
37
+ Turnaround time : 943 sec.
38
+
39
+ The output (if any) is above this job summary.
40
+
41
+
42
+
43
+ PS:
44
+
45
+ Read file <./outputs/20260425_154907_Qwen-Qwen3-06B/error.txt> for stderr output of this job.
46
+
outputs/20260425_154915_Qwen-Qwen3-17B/README.md ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ base_model: Qwen/Qwen3-1.7B
3
+ library_name: transformers
4
+ model_name: 20260425_154915_Qwen-Qwen3-17B
5
+ tags:
6
+ - generated_from_trainer
7
+ - trl
8
+ - grpo
9
+ licence: license
10
+ ---
11
+
12
+ # Model Card for 20260425_154915_Qwen-Qwen3-17B
13
+
14
+ This model is a fine-tuned version of [Qwen/Qwen3-1.7B](https://huggingface.co/Qwen/Qwen3-1.7B).
15
+ It has been trained using [TRL](https://github.com/huggingface/trl).
16
+
17
+ ## Quick start
18
+
19
+ ```python
20
+ from transformers import pipeline
21
+
22
+ question = "If you had a time machine, but could only go to the past or the future once and never return, which would you choose and why?"
23
+ generator = pipeline("text-generation", model="None", device="cuda")
24
+ output = generator([{"role": "user", "content": question}], max_new_tokens=128, return_full_text=False)[0]
25
+ print(output["generated_text"])
26
+ ```
27
+
28
+ ## Training procedure
29
+
30
+
31
+
32
+
33
+
34
+ This model was trained with GRPO, a method introduced in [DeepSeekMath: Pushing the Limits of Mathematical Reasoning in Open Language Models](https://huggingface.co/papers/2402.03300).
35
+
36
+ ### Framework versions
37
+
38
+ - TRL: 1.2.0
39
+ - Transformers: 4.57.6
40
+ - Pytorch: 2.9.1
41
+ - Datasets: 4.8.4
42
+ - Tokenizers: 0.22.2
43
+
44
+ ## Citations
45
+
46
+ Cite GRPO as:
47
+
48
+ ```bibtex
49
+ @article{shao2024deepseekmath,
50
+ title = {{DeepSeekMath: Pushing the Limits of Mathematical Reasoning in Open Language Models}},
51
+ author = {Zhihong Shao and Peiyi Wang and Qihao Zhu and Runxin Xu and Junxiao Song and Mingchuan Zhang and Y. K. Li and Y. Wu and Daya Guo},
52
+ year = 2024,
53
+ eprint = {arXiv:2402.03300},
54
+ }
55
+ ```
56
+
57
+ Cite TRL as:
58
+
59
+ ```bibtex
60
+ @software{vonwerra2020trl,
61
+ title = {{TRL: Transformers Reinforcement Learning}},
62
+ author = {von Werra, Leandro and Belkada, Younes and Tunstall, Lewis and Beeching, Edward and Thrush, Tristan and Lambert, Nathan and Huang, Shengyi and Rasul, Kashif and Gallouédec, Quentin},
63
+ license = {Apache-2.0},
64
+ url = {https://github.com/huggingface/trl},
65
+ year = {2020}
66
+ }
67
+ ```
outputs/20260425_154915_Qwen-Qwen3-17B/checkpoint-10/config.json ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "Qwen3ForCausalLM"
4
+ ],
5
+ "attention_bias": false,
6
+ "attention_dropout": 0.0,
7
+ "dtype": "float32",
8
+ "eos_token_id": 151645,
9
+ "head_dim": 128,
10
+ "hidden_act": "silu",
11
+ "hidden_size": 2048,
12
+ "initializer_range": 0.02,
13
+ "intermediate_size": 6144,
14
+ "layer_types": [
15
+ "full_attention",
16
+ "full_attention",
17
+ "full_attention",
18
+ "full_attention",
19
+ "full_attention",
20
+ "full_attention",
21
+ "full_attention",
22
+ "full_attention",
23
+ "full_attention",
24
+ "full_attention",
25
+ "full_attention",
26
+ "full_attention",
27
+ "full_attention",
28
+ "full_attention",
29
+ "full_attention",
30
+ "full_attention",
31
+ "full_attention",
32
+ "full_attention",
33
+ "full_attention",
34
+ "full_attention",
35
+ "full_attention",
36
+ "full_attention",
37
+ "full_attention",
38
+ "full_attention",
39
+ "full_attention",
40
+ "full_attention",
41
+ "full_attention",
42
+ "full_attention"
43
+ ],
44
+ "max_position_embeddings": 40960,
45
+ "max_window_layers": 28,
46
+ "model_type": "qwen3",
47
+ "num_attention_heads": 16,
48
+ "num_hidden_layers": 28,
49
+ "num_key_value_heads": 8,
50
+ "pad_token_id": 151643,
51
+ "rms_norm_eps": 1e-06,
52
+ "rope_scaling": null,
53
+ "rope_theta": 1000000,
54
+ "sliding_window": null,
55
+ "tie_word_embeddings": true,
56
+ "transformers_version": "4.57.6",
57
+ "use_cache": true,
58
+ "use_sliding_window": false,
59
+ "vocab_size": 151936
60
+ }
outputs/20260425_154915_Qwen-Qwen3-17B/checkpoint-10/generation_config.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "do_sample": true,
3
+ "eos_token_id": [
4
+ 151645,
5
+ 151643
6
+ ],
7
+ "pad_token_id": 151643,
8
+ "temperature": 0.6,
9
+ "top_k": 20,
10
+ "top_p": 0.95,
11
+ "transformers_version": "4.57.6"
12
+ }
outputs/20260425_154915_Qwen-Qwen3-17B/checkpoint-10/model-00001-of-00002.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:69a3cc129138e8190c7a058ddd4346f9c540ff3b4f110490e8a796ffc4457942
3
+ size 5242880
outputs/20260425_154915_Qwen-Qwen3-17B/checkpoint-10/model-00002-of-00002.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a66c56b9b128725068ad1913324cd9d69e411d41c81e627caa41d68c7a5db527
3
+ size 5242880
outputs/20260425_154915_Qwen-Qwen3-17B/error.txt ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0
  0%| | 0/500 [00:00<?, ?it/s][2026-04-25 16:05:56] INFO train_grpo_openenv.py:444: Episode 1 | difficulty=easy | seed=794772
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  0%| | 1/500 [04:42<39:13:06, 282.94s/it]
2
 
3
  0%| | 1/500 [04:43<39:13:06, 282.94s/it][2026-04-25 16:10:39] INFO train_grpo_openenv.py:444: Episode 1 | difficulty=easy | seed=792518
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  0%| | 2/500 [09:56<41:39:36, 301.16s/it]
5
 
6
  0%| | 2/500 [09:57<41:39:36, 301.16s/it][2026-04-25 16:15:53] INFO train_grpo_openenv.py:444: Episode 1 | difficulty=easy | seed=105592
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  1%| | 3/500 [15:20<42:57:56, 311.22s/it]
8
 
9
  1%| | 3/500 [15:20<42:57:56, 311.22s/it][2026-04-25 16:21:16] INFO train_grpo_openenv.py:444: Episode 1 | difficulty=easy | seed=418196
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  1%| | 4/500 [20:11<41:48:51, 303.49s/it]
11
 
12
  1%| | 4/500 [20:11<41:48:51, 303.49s/it][2026-04-25 16:26:07] INFO train_grpo_openenv.py:444: Episode 1 | difficulty=easy | seed=149416
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  1%| | 5/500 [25:32<42:35:25, 309.75s/it]
14
 
15
  1%| | 5/500 [25:32<42:35:25, 309.75s/it][2026-04-25 16:31:28] INFO train_grpo_openenv.py:444: Episode 1 | difficulty=easy | seed=838260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  1%| | 6/500 [30:28<41:51:02, 304.98s/it]
17
 
18
  1%| | 6/500 [30:28<41:51:02, 304.98s/it][2026-04-25 16:36:24] INFO train_grpo_openenv.py:444: Episode 1 | difficulty=easy | seed=72441
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  1%|▏ | 7/500 [35:36<41:54:16, 306.00s/it]
20
 
21
  1%|▏ | 7/500 [35:36<41:54:16, 306.00s/it][2026-04-25 16:41:32] INFO train_grpo_openenv.py:444: Episode 1 | difficulty=easy | seed=246941
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  2%|▏ | 8/500 [40:45<41:57:45, 307.04s/it]
23
 
24
  2%|▏ | 8/500 [40:45<41:57:45, 307.04s/it][2026-04-25 16:46:42] INFO train_grpo_openenv.py:444: Episode 1 | difficulty=easy | seed=510073
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  2%|▏ | 9/500 [45:33<41:03:58, 301.10s/it]
26
 
27
  2%|▏ | 9/500 [45:33<41:03:58, 301.10s/it][2026-04-25 16:51:29] INFO train_grpo_openenv.py:444: Episode 1 | difficulty=easy | seed=731560
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  2%|▏ | 10/500 [50:50<41:38:33, 305.95s/it]
29
 
30
  2%|▏ | 10/500 [50:50<41:38:33, 305.95s/it][rank0]: Traceback (most recent call last):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ warning: Failed to uninstall package at .venv/lib/python3.12/site-packages/numpy-2.4.4.dist-info due to missing `RECORD` file. Installation may result in an incomplete environment.
2
+ Uninstalled 2 packages in 1.65s
3
+ warning: Failed to hardlink files; falling back to full copy. This may lead to degraded performance.
4
+ If the cache and target directories are on different filesystems, hardlinking may not be supported.
5
+ If this is intentional, set `export UV_LINK_MODE=copy` or use `--link-mode=copy` to suppress this warning.
6
+ Installed 1 package in 1.24s
7
+ [2026-04-25 16:01:06] INFO train_grpo_openenv.py:567: Model: Qwen/Qwen3-1.7B
8
+ [2026-04-25 16:01:06] INFO train_grpo_openenv.py:573: Dataset: 1000 prompts
9
+ [2026-04-25 16:01:07] INFO train_grpo_openenv.py:608: Output: ./outputs/20260425_154915_Qwen-Qwen3-17B | vLLM mode: colocate
10
+ `torch_dtype` is deprecated! Use `dtype` instead!
11
+ Flash Attention 2 only supports torch.float16 and torch.bfloat16 dtypes, but the current dype in Qwen3ForCausalLM is torch.float32. You should run training or inference using Automatic Mixed-Precision via the `with torch.autocast(device_type='torch_device'):` decorator, or load the model with the `dtype` argument. Example: `model = AutoModel.from_pretrained("openai/whisper-tiny", attn_implementation="flash_attention_2", dtype=torch.float16)`
12
+ Flash Attention 2 only supports torch.float16 and torch.bfloat16 dtypes, but the current dype in Qwen3Model is torch.float32. You should run training or inference using Automatic Mixed-Precision via the `with torch.autocast(device_type='torch_device'):` decorator, or load the model with the `dtype` argument. Example: `model = AutoModel.from_pretrained("openai/whisper-tiny", attn_implementation="flash_attention_2", dtype=torch.float16)`
13
+
14
+ <frozen importlib._bootstrap_external>:1301: FutureWarning: The cuda.cudart module is deprecated and will be removed in a future release, please switch to use the cuda.bindings.runtime module instead.
15
+ <frozen importlib._bootstrap_external>:1301: FutureWarning: The cuda.nvrtc module is deprecated and will be removed in a future release, please switch to use the cuda.bindings.nvrtc module instead.
16
+
17
+
18
+
19
+
20
+
21
+
22
+ [2026-04-25 16:05:55] INFO train_grpo_openenv.py:630: GPU: NVIDIA A100-SXM4-80GB — 79.251 GB total, 25.477 GB reserved
23
+ The tokenizer has new PAD/BOS/EOS tokens that differ from the model config and generation config. The model config and generation config were aligned accordingly, being updated with the tokenizer's values. Updated tokens: {'bos_token_id': None, 'pad_token_id': 151643}.
24
+
25
  0%| | 0/500 [00:00<?, ?it/s][2026-04-25 16:05:56] INFO train_grpo_openenv.py:444: Episode 1 | difficulty=easy | seed=794772
26
+ [2026-04-25 16:06:33] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.030 | steps=15 | fmt=87%
27
+ [2026-04-25 16:06:33] INFO train_grpo_openenv.py:444: Episode 2 | difficulty=easy | seed=42450
28
+ [2026-04-25 16:07:07] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.040 | steps=14 | fmt=86%
29
+ [2026-04-25 16:07:07] INFO train_grpo_openenv.py:444: Episode 3 | difficulty=easy | seed=536110
30
+ [2026-04-25 16:07:41] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.021 | steps=13 | fmt=92%
31
+ [2026-04-25 16:07:41] INFO train_grpo_openenv.py:444: Episode 4 | difficulty=easy | seed=962838
32
+ [2026-04-25 16:08:15] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.009 | steps=13 | fmt=85%
33
+ [2026-04-25 16:08:15] INFO train_grpo_openenv.py:444: Episode 5 | difficulty=easy | seed=318046
34
+ [2026-04-25 16:08:48] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.010 | steps=11 | fmt=82%
35
+ [2026-04-25 16:08:48] INFO train_grpo_openenv.py:444: Episode 6 | difficulty=easy | seed=375441
36
+ [2026-04-25 16:09:24] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.014 | steps=14 | fmt=86%
37
+ [2026-04-25 16:09:24] INFO train_grpo_openenv.py:444: Episode 7 | difficulty=easy | seed=952225
38
+ [2026-04-25 16:09:58] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.023 | steps=14 | fmt=86%
39
+ [2026-04-25 16:09:58] INFO train_grpo_openenv.py:444: Episode 8 | difficulty=easy | seed=146039
40
+ [2026-04-25 16:10:06] INFO train_grpo_openenv.py:466: → EVACUATED | health=100.0 | mean_step=2.822 | steps=6 | fmt=100%
41
+ Casting fp32 inputs back to torch.bfloat16 for flash-attn compatibility.
42
+ Could not estimate the number of tokens of the input, floating-point operations will not be computed
43
+
44
  0%| | 1/500 [04:42<39:13:06, 282.94s/it]
45
 
46
  0%| | 1/500 [04:43<39:13:06, 282.94s/it][2026-04-25 16:10:39] INFO train_grpo_openenv.py:444: Episode 1 | difficulty=easy | seed=792518
47
+ [2026-04-25 16:11:15] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.002 | steps=15 | fmt=80%
48
+ [2026-04-25 16:11:15] INFO train_grpo_openenv.py:444: Episode 2 | difficulty=easy | seed=838234
49
+ [2026-04-25 16:11:49] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.019 | steps=15 | fmt=93%
50
+ [2026-04-25 16:11:49] INFO train_grpo_openenv.py:444: Episode 3 | difficulty=easy | seed=953938
51
+ [2026-04-25 16:12:26] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.001 | steps=16 | fmt=88%
52
+ [2026-04-25 16:12:26] INFO train_grpo_openenv.py:444: Episode 4 | difficulty=easy | seed=739426
53
+ [2026-04-25 16:13:00] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.017 | steps=16 | fmt=94%
54
+ [2026-04-25 16:13:00] INFO train_grpo_openenv.py:444: Episode 5 | difficulty=easy | seed=945989
55
+ [2026-04-25 16:13:34] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.019 | steps=14 | fmt=93%
56
+ [2026-04-25 16:13:34] INFO train_grpo_openenv.py:444: Episode 6 | difficulty=easy | seed=103560
57
+ [2026-04-25 16:14:07] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.018 | steps=16 | fmt=100%
58
+ [2026-04-25 16:14:07] INFO train_grpo_openenv.py:444: Episode 7 | difficulty=easy | seed=942500
59
+ [2026-04-25 16:14:42] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.015 | steps=18 | fmt=94%
60
+ [2026-04-25 16:14:42] INFO train_grpo_openenv.py:444: Episode 8 | difficulty=easy | seed=346236
61
+ [2026-04-25 16:15:15] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.004 | steps=16 | fmt=88%
62
+
63
  0%| | 2/500 [09:56<41:39:36, 301.16s/it]
64
 
65
  0%| | 2/500 [09:57<41:39:36, 301.16s/it][2026-04-25 16:15:53] INFO train_grpo_openenv.py:444: Episode 1 | difficulty=easy | seed=105592
66
+ [2026-04-25 16:16:29] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.025 | steps=16 | fmt=88%
67
+ [2026-04-25 16:16:29] INFO train_grpo_openenv.py:444: Episode 2 | difficulty=easy | seed=331556
68
+ [2026-04-25 16:17:03] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.017 | steps=13 | fmt=85%
69
+ [2026-04-25 16:17:03] INFO train_grpo_openenv.py:444: Episode 3 | difficulty=easy | seed=957361
70
+ [2026-04-25 16:17:40] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.059 | steps=12 | fmt=58%
71
+ [2026-04-25 16:17:40] INFO train_grpo_openenv.py:444: Episode 4 | difficulty=easy | seed=579363
72
+ [2026-04-25 16:18:16] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.017 | steps=15 | fmt=100%
73
+ [2026-04-25 16:18:16] INFO train_grpo_openenv.py:444: Episode 5 | difficulty=easy | seed=907343
74
+ [2026-04-25 16:18:55] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.011 | steps=15 | fmt=93%
75
+ [2026-04-25 16:18:55] INFO train_grpo_openenv.py:444: Episode 6 | difficulty=easy | seed=65304
76
+ [2026-04-25 16:19:30] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.001 | steps=15 | fmt=93%
77
+ [2026-04-25 16:19:30] INFO train_grpo_openenv.py:444: Episode 7 | difficulty=easy | seed=575352
78
+ [2026-04-25 16:20:06] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.035 | steps=15 | fmt=93%
79
+ [2026-04-25 16:20:06] INFO train_grpo_openenv.py:444: Episode 8 | difficulty=easy | seed=97802
80
+ [2026-04-25 16:20:43] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.013 | steps=14 | fmt=100%
81
+
82
  1%| | 3/500 [15:20<42:57:56, 311.22s/it]
83
 
84
  1%| | 3/500 [15:20<42:57:56, 311.22s/it][2026-04-25 16:21:16] INFO train_grpo_openenv.py:444: Episode 1 | difficulty=easy | seed=418196
85
+ [2026-04-25 16:21:25] INFO train_grpo_openenv.py:466: → EVACUATED | health=100.0 | mean_step=2.803 | steps=6 | fmt=100%
86
+ [2026-04-25 16:21:25] INFO train_grpo_openenv.py:444: Episode 2 | difficulty=easy | seed=823182
87
+ [2026-04-25 16:22:01] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.045 | steps=15 | fmt=100%
88
+ [2026-04-25 16:22:01] INFO train_grpo_openenv.py:444: Episode 3 | difficulty=easy | seed=1198
89
+ [2026-04-25 16:22:37] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.024 | steps=14 | fmt=93%
90
+ [2026-04-25 16:22:37] INFO train_grpo_openenv.py:444: Episode 4 | difficulty=easy | seed=868287
91
+ [2026-04-25 16:23:13] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.028 | steps=12 | fmt=92%
92
+ [2026-04-25 16:23:13] INFO train_grpo_openenv.py:444: Episode 5 | difficulty=easy | seed=255759
93
+ [2026-04-25 16:23:47] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.010 | steps=15 | fmt=87%
94
+ [2026-04-25 16:23:47] INFO train_grpo_openenv.py:444: Episode 6 | difficulty=easy | seed=737822
95
+ [2026-04-25 16:24:22] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.032 | steps=13 | fmt=85%
96
+ [2026-04-25 16:24:22] INFO train_grpo_openenv.py:444: Episode 7 | difficulty=easy | seed=200348
97
+ [2026-04-25 16:25:00] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.027 | steps=14 | fmt=86%
98
+ [2026-04-25 16:25:00] INFO train_grpo_openenv.py:444: Episode 8 | difficulty=easy | seed=232473
99
+ [2026-04-25 16:25:36] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.011 | steps=15 | fmt=100%
100
+
101
  1%| | 4/500 [20:11<41:48:51, 303.49s/it]
102
 
103
  1%| | 4/500 [20:11<41:48:51, 303.49s/it][2026-04-25 16:26:07] INFO train_grpo_openenv.py:444: Episode 1 | difficulty=easy | seed=149416
104
+ [2026-04-25 16:26:43] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.028 | steps=16 | fmt=88%
105
+ [2026-04-25 16:26:43] INFO train_grpo_openenv.py:444: Episode 2 | difficulty=easy | seed=469730
106
+ [2026-04-25 16:27:19] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.007 | steps=15 | fmt=100%
107
+ [2026-04-25 16:27:19] INFO train_grpo_openenv.py:444: Episode 3 | difficulty=easy | seed=335601
108
+ [2026-04-25 16:27:54] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.005 | steps=14 | fmt=86%
109
+ [2026-04-25 16:27:54] INFO train_grpo_openenv.py:444: Episode 4 | difficulty=easy | seed=978147
110
+ [2026-04-25 16:28:30] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.015 | steps=15 | fmt=87%
111
+ [2026-04-25 16:28:30] INFO train_grpo_openenv.py:444: Episode 5 | difficulty=easy | seed=316089
112
+ [2026-04-25 16:29:06] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.016 | steps=17 | fmt=94%
113
+ [2026-04-25 16:29:06] INFO train_grpo_openenv.py:444: Episode 6 | difficulty=easy | seed=740883
114
+ [2026-04-25 16:29:43] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.005 | steps=15 | fmt=87%
115
+ [2026-04-25 16:29:43] INFO train_grpo_openenv.py:444: Episode 7 | difficulty=easy | seed=348914
116
+ [2026-04-25 16:30:19] INFO train_grpo_openenv.py:466: → failed | health=50.0 | mean_step=-0.159 | steps=13 | fmt=85%
117
+ [2026-04-25 16:30:19] INFO train_grpo_openenv.py:444: Episode 8 | difficulty=easy | seed=566528
118
+ [2026-04-25 16:30:54] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.001 | steps=13 | fmt=77%
119
+
120
  1%| | 5/500 [25:32<42:35:25, 309.75s/it]
121
 
122
  1%| | 5/500 [25:32<42:35:25, 309.75s/it][2026-04-25 16:31:28] INFO train_grpo_openenv.py:444: Episode 1 | difficulty=easy | seed=838260
123
+ [2026-04-25 16:32:05] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.004 | steps=12 | fmt=83%
124
+ [2026-04-25 16:32:05] INFO train_grpo_openenv.py:444: Episode 2 | difficulty=easy | seed=616161
125
+ [2026-04-25 16:32:40] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.038 | steps=12 | fmt=75%
126
+ [2026-04-25 16:32:40] INFO train_grpo_openenv.py:444: Episode 3 | difficulty=easy | seed=96083
127
+ [2026-04-25 16:32:55] INFO train_grpo_openenv.py:466: → EVACUATED | health=100.0 | mean_step=1.823 | steps=9 | fmt=100%
128
+ [2026-04-25 16:32:55] INFO train_grpo_openenv.py:444: Episode 4 | difficulty=easy | seed=403598
129
+ [2026-04-25 16:33:30] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.044 | steps=14 | fmt=79%
130
+ [2026-04-25 16:33:30] INFO train_grpo_openenv.py:444: Episode 5 | difficulty=easy | seed=253867
131
+ [2026-04-25 16:34:08] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.018 | steps=13 | fmt=77%
132
+ [2026-04-25 16:34:08] INFO train_grpo_openenv.py:444: Episode 6 | difficulty=easy | seed=198591
133
+ [2026-04-25 16:34:43] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.009 | steps=13 | fmt=85%
134
+ [2026-04-25 16:34:43] INFO train_grpo_openenv.py:444: Episode 7 | difficulty=easy | seed=34574
135
+ [2026-04-25 16:35:19] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.027 | steps=15 | fmt=93%
136
+ [2026-04-25 16:35:19] INFO train_grpo_openenv.py:444: Episode 8 | difficulty=easy | seed=688557
137
+ [2026-04-25 16:35:53] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.034 | steps=14 | fmt=93%
138
+
139
  1%| | 6/500 [30:28<41:51:02, 304.98s/it]
140
 
141
  1%| | 6/500 [30:28<41:51:02, 304.98s/it][2026-04-25 16:36:24] INFO train_grpo_openenv.py:444: Episode 1 | difficulty=easy | seed=72441
142
+ [2026-04-25 16:36:57] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.019 | steps=14 | fmt=93%
143
+ [2026-04-25 16:36:57] INFO train_grpo_openenv.py:444: Episode 2 | difficulty=easy | seed=794405
144
+ [2026-04-25 16:37:32] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.000 | steps=15 | fmt=93%
145
+ [2026-04-25 16:37:32] INFO train_grpo_openenv.py:444: Episode 3 | difficulty=easy | seed=156814
146
+ [2026-04-25 16:38:07] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.004 | steps=14 | fmt=71%
147
+ [2026-04-25 16:38:07] INFO train_grpo_openenv.py:444: Episode 4 | difficulty=easy | seed=883383
148
+ [2026-04-25 16:38:44] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.005 | steps=13 | fmt=92%
149
+ [2026-04-25 16:38:44] INFO train_grpo_openenv.py:444: Episode 5 | difficulty=easy | seed=733293
150
+ [2026-04-25 16:39:16] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.058 | steps=12 | fmt=75%
151
+ [2026-04-25 16:39:16] INFO train_grpo_openenv.py:444: Episode 6 | difficulty=easy | seed=566860
152
+ [2026-04-25 16:39:51] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.031 | steps=13 | fmt=92%
153
+ [2026-04-25 16:39:51] INFO train_grpo_openenv.py:444: Episode 7 | difficulty=easy | seed=878565
154
+ [2026-04-25 16:40:26] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.045 | steps=13 | fmt=85%
155
+ [2026-04-25 16:40:26] INFO train_grpo_openenv.py:444: Episode 8 | difficulty=easy | seed=289023
156
+ [2026-04-25 16:41:01] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.032 | steps=13 | fmt=69%
157
+
158
  1%|▏ | 7/500 [35:36<41:54:16, 306.00s/it]
159
 
160
  1%|▏ | 7/500 [35:36<41:54:16, 306.00s/it][2026-04-25 16:41:32] INFO train_grpo_openenv.py:444: Episode 1 | difficulty=easy | seed=246941
161
+ [2026-04-25 16:42:07] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.031 | steps=13 | fmt=77%
162
+ [2026-04-25 16:42:07] INFO train_grpo_openenv.py:444: Episode 2 | difficulty=easy | seed=938516
163
+ [2026-04-25 16:42:43] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.034 | steps=16 | fmt=94%
164
+ [2026-04-25 16:42:43] INFO train_grpo_openenv.py:444: Episode 3 | difficulty=easy | seed=865351
165
+ [2026-04-25 16:43:16] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.056 | steps=13 | fmt=77%
166
+ [2026-04-25 16:43:16] INFO train_grpo_openenv.py:444: Episode 4 | difficulty=easy | seed=607854
167
+ [2026-04-25 16:43:49] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.019 | steps=15 | fmt=93%
168
+ [2026-04-25 16:43:49] INFO train_grpo_openenv.py:444: Episode 5 | difficulty=easy | seed=516586
169
+ [2026-04-25 16:44:25] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.031 | steps=15 | fmt=87%
170
+ [2026-04-25 16:44:25] INFO train_grpo_openenv.py:444: Episode 6 | difficulty=easy | seed=734239
171
+ [2026-04-25 16:45:00] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.016 | steps=14 | fmt=93%
172
+ [2026-04-25 16:45:00] INFO train_grpo_openenv.py:444: Episode 7 | difficulty=easy | seed=831861
173
+ [2026-04-25 16:45:34] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.017 | steps=15 | fmt=100%
174
+ [2026-04-25 16:45:34] INFO train_grpo_openenv.py:444: Episode 8 | difficulty=easy | seed=340079
175
+ [2026-04-25 16:46:08] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.007 | steps=13 | fmt=77%
176
+
177
  2%|▏ | 8/500 [40:45<41:57:45, 307.04s/it]
178
 
179
  2%|▏ | 8/500 [40:45<41:57:45, 307.04s/it][2026-04-25 16:46:42] INFO train_grpo_openenv.py:444: Episode 1 | difficulty=easy | seed=510073
180
+ [2026-04-25 16:47:17] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.033 | steps=13 | fmt=100%
181
+ [2026-04-25 16:47:17] INFO train_grpo_openenv.py:444: Episode 2 | difficulty=easy | seed=351556
182
+ [2026-04-25 16:47:54] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.004 | steps=13 | fmt=77%
183
+ [2026-04-25 16:47:54] INFO train_grpo_openenv.py:444: Episode 3 | difficulty=easy | seed=254841
184
+ [2026-04-25 16:48:29] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.017 | steps=18 | fmt=94%
185
+ [2026-04-25 16:48:29] INFO train_grpo_openenv.py:444: Episode 4 | difficulty=easy | seed=284203
186
+ [2026-04-25 16:48:36] INFO train_grpo_openenv.py:466: → EVACUATED | health=100.0 | mean_step=4.190 | steps=4 | fmt=100%
187
+ [2026-04-25 16:48:36] INFO train_grpo_openenv.py:444: Episode 5 | difficulty=easy | seed=231169
188
+ [2026-04-25 16:49:10] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.021 | steps=14 | fmt=93%
189
+ [2026-04-25 16:49:10] INFO train_grpo_openenv.py:444: Episode 6 | difficulty=easy | seed=178763
190
+ [2026-04-25 16:49:44] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.021 | steps=15 | fmt=100%
191
+ [2026-04-25 16:49:44] INFO train_grpo_openenv.py:444: Episode 7 | difficulty=easy | seed=855546
192
+ [2026-04-25 16:50:19] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.014 | steps=13 | fmt=92%
193
+ [2026-04-25 16:50:19] INFO train_grpo_openenv.py:444: Episode 8 | difficulty=easy | seed=821159
194
+ [2026-04-25 16:50:55] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.022 | steps=14 | fmt=86%
195
+
196
  2%|▏ | 9/500 [45:33<41:03:58, 301.10s/it]
197
 
198
  2%|▏ | 9/500 [45:33<41:03:58, 301.10s/it][2026-04-25 16:51:29] INFO train_grpo_openenv.py:444: Episode 1 | difficulty=easy | seed=731560
199
+ [2026-04-25 16:52:07] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.021 | steps=13 | fmt=85%
200
+ [2026-04-25 16:52:07] INFO train_grpo_openenv.py:444: Episode 2 | difficulty=easy | seed=856812
201
+ [2026-04-25 16:52:40] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.025 | steps=13 | fmt=69%
202
+ [2026-04-25 16:52:40] INFO train_grpo_openenv.py:444: Episode 3 | difficulty=easy | seed=954220
203
+ [2026-04-25 16:53:15] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.001 | steps=13 | fmt=77%
204
+ [2026-04-25 16:53:15] INFO train_grpo_openenv.py:444: Episode 4 | difficulty=easy | seed=631421
205
+ [2026-04-25 16:53:50] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.021 | steps=11 | fmt=55%
206
+ [2026-04-25 16:53:50] INFO train_grpo_openenv.py:444: Episode 5 | difficulty=easy | seed=27993
207
+ [2026-04-25 16:54:25] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.015 | steps=19 | fmt=100%
208
+ [2026-04-25 16:54:25] INFO train_grpo_openenv.py:444: Episode 6 | difficulty=easy | seed=197678
209
+ [2026-04-25 16:55:01] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=0.019 | steps=14 | fmt=93%
210
+ [2026-04-25 16:55:01] INFO train_grpo_openenv.py:444: Episode 7 | difficulty=easy | seed=603930
211
+ [2026-04-25 16:55:35] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.026 | steps=11 | fmt=73%
212
+ [2026-04-25 16:55:35] INFO train_grpo_openenv.py:444: Episode 8 | difficulty=easy | seed=95978
213
+ [2026-04-25 16:56:11] INFO train_grpo_openenv.py:466: → failed | health=100.0 | mean_step=-0.045 | steps=12 | fmt=83%
214
+
215
  2%|▏ | 10/500 [50:50<41:38:33, 305.95s/it]
216
 
217
  2%|▏ | 10/500 [50:50<41:38:33, 305.95s/it][rank0]: Traceback (most recent call last):
218
+ [rank0]: File "/dccstor/kirushikesh/personal-projects/openenv-pyre/train_grpo_openenv.py", line 633, in <module>
219
+ [rank0]: trainer_stats = trainer.train()
220
+ [rank0]: ^^^^^^^^^^^^^^^
221
+ [rank0]: File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/transformers/trainer.py", line 2325, in train
222
+ [rank0]: return inner_training_loop(
223
+ [rank0]: ^^^^^^^^^^^^^^^^^^^^
224
+ [rank0]: File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/transformers/trainer.py", line 2756, in _inner_training_loop
225
+ [rank0]: self._maybe_log_save_evaluate(
226
+ [rank0]: File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/transformers/trainer.py", line 3228, in _maybe_log_save_evaluate
227
+ [rank0]: self._save_checkpoint(model, trial)
228
+ [rank0]: File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/trl/trainer/grpo_trainer.py", line 2719, in _save_checkpoint
229
+ [rank0]: super()._save_checkpoint(model, trial)
230
+ [rank0]: File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/transformers/trainer.py", line 3325, in _save_checkpoint
231
+ [rank0]: self.save_model(output_dir, _internal_call=True)
232
+ [rank0]: File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/transformers/trainer.py", line 4227, in save_model
233
+ [rank0]: self._save(output_dir)
234
+ [rank0]: File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/transformers/trainer.py", line 4331, in _save
235
+ [rank0]: self.model.save_pretrained(
236
+ [rank0]: File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/transformers/modeling_utils.py", line 4173, in save_pretrained
237
+ [rank0]: safe_save_file(shard, os.path.join(save_directory, shard_file), metadata=metadata)
238
+ [rank0]: File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/safetensors/torch.py", line 307, in save_file
239
+ [rank0]: serialize_file(_flatten(tensors), filename, metadata=metadata)
240
+ [rank0]: safetensors_rust.SafetensorError: Error while serializing: I/O error: Disk quota exceeded (os error 122)
241
+ Exception ignored in: <_io.TextIOWrapper name='<stdout>' mode='w' encoding='utf-8'>
242
+ OSError: [Errno 122] Disk quota exceeded
243
+ [rank0]:[W425 16:56:59.402981600 ProcessGroupNCCL.cpp:1524] Warning: WARNING: destroy_process_group() was not called before program exit, which can leak resources. For more info, please see https://pytorch.org/docs/stable/distributed.html#shutdown (function operator())
244
+ Exception ignored in: <function tqdm.__del__ at 0x14992c829f80>
245
+ Traceback (most recent call last):
246
+ File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/tqdm/std.py", line 1148, in __del__
247
+ File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/tqdm/std.py", line 1302, in close
248
+ File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/tqdm/std.py", line 1495, in display
249
+ File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/tqdm/std.py", line 1151, in __str__
250
+ File "/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/tqdm/std.py", line 546, in format_meter
251
+ AttributeError: 'NoneType' object has no attribute 'format_interval'
outputs/20260425_154915_Qwen-Qwen3-17B/output.txt ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Helper functions defined.
2
+ Rollout functions defined.
3
+ Reward functions: evacuated, step_efficiency, format, efficiency
4
+
5
+ ------------------------------------------------------------
6
+ Sender: LSF System <lsfadmin@cccxc590>
7
+ Subject: Job 1281122: <20260425_154915_Qwen-Qwen3-17B> in cluster <CCCFP15Cluster> Exited
8
+
9
+ Job <20260425_154915_Qwen-Qwen3-17B> was submitted from host <ccc-login3> by user <kirushi> in cluster <CCCFP15Cluster> at Sat Apr 25 15:50:20 2026
10
+ Job was executed on host(s) <cccxc590>, in queue <normal>, as user <kirushi> in cluster <CCCFP15Cluster> at Sat Apr 25 15:49:58 2026
11
+ </u/kirushi> was used as the home directory.
12
+ </dccstor/kirushikesh/personal-projects/openenv-pyre> was used as the working directory.
13
+ Started at Sat Apr 25 15:49:58 2026
14
+ Terminated at Sat Apr 25 16:57:00 2026
15
+ Results reported at Sat Apr 25 16:57:00 2026
16
+
17
+ Your job looked like:
18
+
19
+ ------------------------------------------------------------
20
+ # LSBATCH: User input
21
+ /u/kirushi/.local/bin/uv run python train_grpo_openenv.py --model-id Qwen/Qwen3-1.7B --dataset-size 1000 --output-dir ./outputs/20260425_154915_Qwen-Qwen3-17B --report-to tensorboard --seed 42
22
+ ------------------------------------------------------------
23
+
24
+ Exited with exit code 120.
25
+
26
+ Resource usage summary:
27
+
28
+ CPU time : 3999.24 sec.
29
+ Max Memory : 8 GB
30
+ Average Memory : 2.66 GB
31
+ Total Requested Memory : -
32
+ Delta Memory : -
33
+ Max Swap : -
34
+ Max Processes : 5
35
+ Max Threads : 625
36
+ Run time : 4022 sec.
37
+ Turnaround time : 4000 sec.
38
+
39
+ The output (if any) is above this job summary.
40
+
41
+
42
+
43
+ PS:
44
+
45
+ Read file <./outputs/20260425_154915_Qwen-Qwen3-17B/error.txt> for stderr output of this job.
46
+
outputs/20260425_154915_Qwen-Qwen3-17B/runs/Apr25_16-01-06_cccxc590/events.out.tfevents.1777147555.cccxc590.2920434.0 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8c8afb63bb7a46085a82d14e6a8f0c988a3f2f3c45d8969a4294f653573f645b
3
+ size 31316
pyproject.toml CHANGED
@@ -28,6 +28,31 @@ dev = [
28
  "pytest>=8.0.0",
29
  "pytest-cov>=4.0.0",
30
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  [project.scripts]
33
  # Server entry point - enables running via: uv run --project . server
@@ -38,3 +63,6 @@ server = "pyre_env.server.app:main"
38
  include-package-data = true
39
  packages = ["pyre_env", "pyre_env.server"]
40
  package-dir = { "pyre_env" = ".", "pyre_env.server" = "server" }
 
 
 
 
28
  "pytest>=8.0.0",
29
  "pytest-cov>=4.0.0",
30
  ]
31
+ train = [
32
+ "datasets>=4.8.4",
33
+ "peft>=0.15.2",
34
+ "tensorboard>=2.20.0",
35
+ "torch>=2.9.0",
36
+ "transformers>=4.57.6",
37
+ "trl>=1.2.0",
38
+ "tornado>=6.5.5",
39
+ "vllm>=0.11.0,<=0.18.0",
40
+ "flashinfer-python==0.6.3",
41
+ "flashinfer-cubin==0.6.3",
42
+ "jupyter>=1.1.1",
43
+ "flash-attn>=2.7.0",
44
+ ]
45
+ # Unsloth-backed training (replaces raw TRL + vLLM stack for train_grpo_unsloth.py)
46
+ # Variant is fixed: A100 (Ampere) + CUDA 12.4, inferred from flashinfer==0.6.3 in train extra.
47
+ # Unsloth bundles its own vLLM & transformers builds.
48
+ train-unsloth = [
49
+ "unsloth[cu124-ampere-torch260]",
50
+ "trl==0.22.2",
51
+ "datasets>=4.8.4",
52
+ "tensorboard>=2.20.0",
53
+ "tornado>=6.5.5",
54
+ "jupyter>=1.1.1",
55
+ ]
56
 
57
  [project.scripts]
58
  # Server entry point - enables running via: uv run --project . server
 
63
  include-package-data = true
64
  packages = ["pyre_env", "pyre_env.server"]
65
  package-dir = { "pyre_env" = ".", "pyre_env.server" = "server" }
66
+
67
+ [tool.uv]
68
+ no-build-isolation-package = ["flash-attn"]
run_training_openenv.sh CHANGED
@@ -25,18 +25,15 @@ echo "Job name : ${RUN_ID}"
25
  echo "Port : ${MASTER_PORT}"
26
  echo "uv : ${UV_BIN}"
27
 
28
- export CUDA_LAUNCH_BLOCKING=1
29
-
30
  bsub -q normal -M 128 -n 1 \
31
- -gpu "num=2:gmodel=NVIDIAA100_SXM4_80GB" \
32
  -o "${OUTPUT_DIR}/output.txt" \
33
  -e "${OUTPUT_DIR}/error.txt" \
34
  -J "${RUN_ID}" \
35
  -env "MASTER_PORT=${MASTER_PORT},VLLM_USE_V1=0" \
36
- "${UV_BIN}" run python training/train_grpo_openenv.py \
37
  --model-id "${MODEL_NAME}" \
38
- --dataset-size 1000 \
39
  --output-dir "${OUTPUT_DIR}" \
40
  --report-to "tensorboard" \
41
- --seed 42 \
42
- --debug
 
25
  echo "Port : ${MASTER_PORT}"
26
  echo "uv : ${UV_BIN}"
27
 
 
 
28
  bsub -q normal -M 128 -n 1 \
29
+ -gpu "num=1:gmodel=NVIDIAA100_SXM4_80GB" \
30
  -o "${OUTPUT_DIR}/output.txt" \
31
  -e "${OUTPUT_DIR}/error.txt" \
32
  -J "${RUN_ID}" \
33
  -env "MASTER_PORT=${MASTER_PORT},VLLM_USE_V1=0" \
34
+ "${UV_BIN}" run python train_grpo_openenv.py \
35
  --model-id "${MODEL_NAME}" \
36
+ --dataset-size 200 \
37
  --output-dir "${OUTPUT_DIR}" \
38
  --report-to "tensorboard" \
39
+ --seed 42
 
run_training_unsloth.sh ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # run_training_unsloth.sh — LSF launcher for train_grpo_unsloth.py
3
+ #
4
+ # Usage:
5
+ # ./run_training_unsloth.sh <model-name>
6
+ # ./run_training_unsloth.sh unsloth/Qwen3-1.7B
7
+ # ./run_training_unsloth.sh unsloth/Qwen3-4B --lora-rank 32 --save-merged
8
+ #
9
+ # All arguments after the model name are forwarded verbatim to the Python
10
+ # script, so any train_grpo_unsloth.py flag can be passed here:
11
+ # ./run_training_unsloth.sh unsloth/Qwen3-4B --dataset-size 500 --save-merged
12
+ set -e
13
+
14
+ if [ -z "$1" ]; then
15
+ echo "Usage: $0 <model-name> [extra python args...]"
16
+ echo " e.g.: $0 unsloth/Qwen3-1.7B"
17
+ echo " e.g.: $0 unsloth/Qwen3-4B --lora-rank 32 --save-merged"
18
+ exit 1
19
+ fi
20
+
21
+ MODEL_NAME="$1"
22
+ shift # remaining args forwarded to the python script
23
+ EXTRA_ARGS="$*"
24
+
25
+ MODEL_SAFE=$(echo "$MODEL_NAME" | tr '/:' '--' | tr -cd '[:alnum:]_-')
26
+ TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
27
+ RUN_ID="${TIMESTAMP}_${MODEL_SAFE}"
28
+
29
+ OUTPUT_DIR="./outputs/${RUN_ID}"
30
+ mkdir -p "${OUTPUT_DIR}"
31
+
32
+ MASTER_PORT=$(( 29500 + RANDOM % 1000 ))
33
+ UV_BIN=$(which uv)
34
+
35
+ echo "Model : ${MODEL_NAME}"
36
+ echo "Output dir: ${OUTPUT_DIR}"
37
+ echo "Job name : ${RUN_ID}"
38
+ echo "Port : ${MASTER_PORT}"
39
+ echo "uv : ${UV_BIN}"
40
+ echo "Extra args: ${EXTRA_ARGS}"
41
+
42
+ bsub -q normal -M 128 -n 1 \
43
+ -gpu "num=1:gmodel=NVIDIAA100_SXM4_80GB" \
44
+ -o "${OUTPUT_DIR}/output.txt" \
45
+ -e "${OUTPUT_DIR}/error.txt" \
46
+ -J "${RUN_ID}" \
47
+ -env "MASTER_PORT=${MASTER_PORT}" \
48
+ "${UV_BIN}" run python train_grpo_unsloth.py \
49
+ --model-id "${MODEL_NAME}" \
50
+ --dataset-size 200 \
51
+ --lora-rank 32 \
52
+ --output-dir "${OUTPUT_DIR}" \
53
+ --report-to "tensorboard" \
54
+ --seed 42 \
55
+ ${EXTRA_ARGS}
server/app.py CHANGED
@@ -10,20 +10,22 @@ Configuration via environment variables:
10
 
11
  import os
12
  from pathlib import Path
13
- from typing import Any, Dict, Optional
14
- from pydantic import Field
15
  from fastapi import HTTPException
16
  from fastapi.responses import FileResponse
 
17
  from openenv.core.env_server.http_server import create_app
18
- from pydantic import BaseModel
19
  from starlette.routing import Route
20
 
21
  try:
22
  from ..models import PyreAction, PyreObservation
23
  from .pyre_env_environment import PyreEnvironment
 
24
  except (ImportError, ModuleNotFoundError):
25
  from models import PyreAction, PyreObservation
26
  from server.pyre_env_environment import PyreEnvironment
 
27
 
28
  MAX_STEPS = int(os.getenv("PYRE_MAX_STEPS", "150"))
29
  BASE_SEED = int(os.getenv("PYRE_SEED", "42"))
@@ -83,13 +85,22 @@ STATIC_DIR = Path(__file__).resolve().parent / "static"
83
 
84
  @app.get("/")
85
  def serve_frontend() -> FileResponse:
86
- """Serve the bundled RPG viewer from server/static/viewer_rpg.html."""
87
- html_path = STATIC_DIR / "viewer_rpg.html"
88
  if not html_path.exists():
89
- raise HTTPException(status_code=404, detail="Frontend file not found: server/static/viewer_rpg.html")
 
 
 
 
90
  return FileResponse(str(html_path))
91
 
92
 
 
 
 
 
 
93
  @app.post("/reset")
94
  def reset_episode(body: ResetRequest = ResetRequest()) -> Dict[str, Any]:
95
  env = get_stateful_env()
@@ -127,6 +138,133 @@ def get_state() -> Dict[str, Any]:
127
  return env.state.model_dump()
128
 
129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  def main(host: str = "0.0.0.0", port: int = 8000):
131
  import uvicorn
132
  port = int(os.getenv("PORT", port))
 
10
 
11
  import os
12
  from pathlib import Path
13
+ from typing import Any, Dict, List, Optional
14
+ from pydantic import Field, BaseModel
15
  from fastapi import HTTPException
16
  from fastapi.responses import FileResponse
17
+ from fastapi.staticfiles import StaticFiles
18
  from openenv.core.env_server.http_server import create_app
 
19
  from starlette.routing import Route
20
 
21
  try:
22
  from ..models import PyreAction, PyreObservation
23
  from .pyre_env_environment import PyreEnvironment
24
+ from .narrative import build_narrative_observation, compute_visible_cells
25
  except (ImportError, ModuleNotFoundError):
26
  from models import PyreAction, PyreObservation
27
  from server.pyre_env_environment import PyreEnvironment
28
+ from server.narrative import build_narrative_observation, compute_visible_cells
29
 
30
  MAX_STEPS = int(os.getenv("PYRE_MAX_STEPS", "150"))
31
  BASE_SEED = int(os.getenv("PYRE_SEED", "42"))
 
85
 
86
  @app.get("/")
87
  def serve_frontend() -> FileResponse:
88
+ """Serve the React frontend from server/static/index.html."""
89
+ html_path = STATIC_DIR / "index.html"
90
  if not html_path.exists():
91
+ # Fallback to the RPG viewer if index.html is missing
92
+ rpg_path = STATIC_DIR / "viewer_rpg.html"
93
+ if rpg_path.exists():
94
+ return FileResponse(str(rpg_path))
95
+ raise HTTPException(status_code=404, detail="Frontend file not found.")
96
  return FileResponse(str(html_path))
97
 
98
 
99
+ # Mount the static directory for assets (CSS, JS, etc.)
100
+ if (STATIC_DIR / "assets").exists():
101
+ app.mount("/assets", StaticFiles(directory=str(STATIC_DIR / "assets")), name="assets")
102
+
103
+
104
  @app.post("/reset")
105
  def reset_episode(body: ResetRequest = ResetRequest()) -> Dict[str, Any]:
106
  env = get_stateful_env()
 
138
  return env.state.model_dump()
139
 
140
 
141
+ @app.get("/scene")
142
+ def get_scene() -> Dict[str, Any]:
143
+ """Return a compact scene snapshot for external frontends.
144
+
145
+ Response shape
146
+ --------------
147
+ labels
148
+ agent — position, health, status flags, perception summary
149
+ episode — fire parameters, step counters, difficulty
150
+ map — grid dimensions, exit positions, door registry
151
+ surroundings — visible objects, blocked exits, audible signals,
152
+ available action hints
153
+ graph
154
+ channels — ordered list of channel names (index guide)
155
+ channel_info — human-readable description of each channel
156
+ width / height
157
+ grid — grid[y][x] = [cell_type, fire, smoke, is_agent, is_visible]
158
+ cell_type: 0=floor 1=wall 2=door_open 3=door_closed
159
+ 4=exit 5=obstacle
160
+ fire / smoke: 0.0 (none) → 1.0 (max)
161
+ is_agent / is_visible: 0 or 1
162
+ """
163
+ env = get_stateful_env()
164
+ st = env.state
165
+
166
+ # --- Build structured observation fields (no narrative) ---
167
+ obs_data = build_narrative_observation(
168
+ step_count=st.step_count,
169
+ agent_x=st.agent_x,
170
+ agent_y=st.agent_y,
171
+ agent_alive=st.agent_alive,
172
+ agent_evacuated=st.agent_evacuated,
173
+ agent_health=st.agent_health,
174
+ cell_grid=st.cell_grid,
175
+ fire_grid=st.fire_grid,
176
+ smoke_grid=st.smoke_grid,
177
+ exit_positions=st.exit_positions,
178
+ door_registry=st.door_registry,
179
+ zone_map=st.zone_map,
180
+ last_action_feedback=getattr(env, "_last_feedback", ""),
181
+ wind_dir=st.wind_dir,
182
+ w=st.grid_w,
183
+ h=st.grid_h,
184
+ )
185
+
186
+ # --- Visibility set for the graph layer ---
187
+ if st.agent_alive and not st.agent_evacuated:
188
+ visible_set = compute_visible_cells(
189
+ st.agent_x, st.agent_y,
190
+ st.cell_grid, st.smoke_grid,
191
+ st.grid_w, st.grid_h,
192
+ )
193
+ else:
194
+ visible_set = set()
195
+
196
+ # --- Labels ---
197
+ labels: Dict[str, Any] = {
198
+ "agent": {
199
+ "x": st.agent_x,
200
+ "y": st.agent_y,
201
+ "health": st.agent_health,
202
+ "health_status": obs_data.get("health_status", "Good"),
203
+ "alive": st.agent_alive,
204
+ "evacuated": st.agent_evacuated,
205
+ "location": obs_data.get("location_label", ""),
206
+ "smoke_level": obs_data.get("smoke_level", "none"),
207
+ "fire_visible": obs_data.get("fire_visible", False),
208
+ "fire_direction": obs_data.get("fire_direction", None),
209
+ "last_action_feedback": obs_data.get("last_action_feedback", ""),
210
+ },
211
+ "episode": {
212
+ "id": st.episode_id,
213
+ "step": st.step_count,
214
+ "max_steps": st.max_steps,
215
+ "template": st.template_name,
216
+ "difficulty": getattr(env, "_difficulty", "medium"),
217
+ "wind_dir": st.wind_dir,
218
+ "fire_spread_rate": st.fire_spread_rate,
219
+ "humidity": st.humidity,
220
+ "fire_sources": st.fire_sources_count,
221
+ },
222
+ "map": {
223
+ "width": st.grid_w,
224
+ "height": st.grid_h,
225
+ "exit_positions": st.exit_positions,
226
+ "door_registry": st.door_registry,
227
+ },
228
+ "surroundings": {
229
+ "visible_objects": obs_data.get("visible_objects", []),
230
+ "blocked_exit_ids": obs_data.get("blocked_exit_ids", []),
231
+ "audible_signals": obs_data.get("audible_signals", []),
232
+ "available_actions": obs_data.get("available_actions_hint", []),
233
+ },
234
+ }
235
+
236
+ # --- 2-D multi-channel grid ---
237
+ w, h = st.grid_w, st.grid_h
238
+ grid: List[List[List[float]]] = []
239
+ for y in range(h):
240
+ row: List[List[float]] = []
241
+ for x in range(w):
242
+ idx = y * w + x
243
+ cell_type = float(st.cell_grid[idx])
244
+ fire = round(st.fire_grid[idx], 4)
245
+ smoke = round(st.smoke_grid[idx], 4)
246
+ is_agent = 1.0 if (x == st.agent_x and y == st.agent_y) else 0.0
247
+ is_visible = 1.0 if (x, y) in visible_set else 0.0
248
+ row.append([cell_type, fire, smoke, is_agent, is_visible])
249
+ grid.append(row)
250
+
251
+ graph: Dict[str, Any] = {
252
+ "channels": ["cell_type", "fire", "smoke", "is_agent", "is_visible"],
253
+ "channel_info": {
254
+ "cell_type": "0=floor 1=wall 2=door_open 3=door_closed 4=exit 5=obstacle",
255
+ "fire": "0.0=none to 1.0=fully burning",
256
+ "smoke": "0.0=clear to 1.0=dense smoke",
257
+ "is_agent": "1 if agent occupies this cell, else 0",
258
+ "is_visible": "1 if within agent line-of-sight, else 0",
259
+ },
260
+ "width": w,
261
+ "height": h,
262
+ "grid": grid,
263
+ }
264
+
265
+ return {"labels": labels, "graph": graph}
266
+
267
+
268
  def main(host: str = "0.0.0.0", port: int = 8000):
269
  import uvicorn
270
  port = int(os.getenv("PORT", port))
server/floor_plan.py CHANGED
@@ -47,6 +47,7 @@ class FloorPlan:
47
  spawn_zones: List[Tuple[int, int]] # valid NPC spawn cells
48
  agent_spawn_options: List[Tuple[int, int]]
49
  zone_map: Dict[str, str] # "{x},{y}" → zone_label
 
50
  fire_min_exit_dist: int = 5 # fire ignition at least this far from any exit
51
  fuel_map: List[float] = field(default_factory=list) # fire fuel per cell
52
  ventilation_map: List[float] = field(default_factory=list) # smoke decay per cell
 
47
  spawn_zones: List[Tuple[int, int]] # valid NPC spawn cells
48
  agent_spawn_options: List[Tuple[int, int]]
49
  zone_map: Dict[str, str] # "{x},{y}" → zone_label
50
+ static_objects: Dict[str, str] = field(default_factory=dict) # "{x},{y}" → item_type
51
  fire_min_exit_dist: int = 5 # fire ignition at least this far from any exit
52
  fuel_map: List[float] = field(default_factory=list) # fire fuel per cell
53
  ventilation_map: List[float] = field(default_factory=list) # smoke decay per cell
server/pyre_env_environment.py CHANGED
@@ -572,6 +572,7 @@ class PyreEnvironment(Environment):
572
  is_new_cell=is_new_cell,
573
  min_exit_dist_reached=self._min_exit_dist_reached,
574
  rewarded_doors=self._rewarded_doors,
 
575
  )
576
 
577
  total = 0.0
 
572
  is_new_cell=is_new_cell,
573
  min_exit_dist_reached=self._min_exit_dist_reached,
574
  rewarded_doors=self._rewarded_doors,
575
+ reachable_exit_count=len(unblocked_exits(st.exit_positions, st.fire_grid, st.grid_w)),
576
  )
577
 
578
  total = 0.0