Krooz commited on
Commit
7c70ddd
·
verified ·
1 Parent(s): c9caf8b

Upload folder using huggingface_hub

Browse files
examples/pyre_ppo_training.ipynb ADDED
The diff for this file is too large to render. See raw diff
 
examples/train_torch_ppo.py CHANGED
@@ -173,11 +173,18 @@ def build_action_mask(observation: PyreObservation, exclude_look: bool = True) -
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)
@@ -568,6 +575,22 @@ def run_episode(
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)
@@ -875,6 +898,29 @@ def build_curriculum(schedule_str: str, n_episodes: int) -> List[str]:
875
  return schedule[:n_episodes]
876
 
877
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
878
  class PatienceCurriculum:
879
  """Dynamic difficulty scheduler that gates advancement on sustained success rate.
880
 
@@ -888,7 +934,13 @@ class PatienceCurriculum:
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__(
@@ -897,14 +949,28 @@ class PatienceCurriculum:
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]
@@ -913,7 +979,7 @@ class PatienceCurriculum:
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:
@@ -929,11 +995,18 @@ class PatienceCurriculum:
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
 
@@ -1019,13 +1092,17 @@ def train(args: argparse.Namespace) -> None:
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:
@@ -1204,7 +1281,11 @@ def parse_args() -> argparse.Namespace:
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)
 
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
+ NOTE: Look action indices are 4–7 in ACTION_KEYS. The guard below must be
178
+ applied in the ACTION_TO_INDEX fast-path as well as the regex fallback,
179
+ because look hint strings exactly match ACTION_TO_INDEX keys and would
180
+ otherwise bypass the exclude_look flag entirely.
181
  """
182
  mask = np.zeros(ACTION_DIM, dtype=np.float32)
183
  for hint in observation.available_actions_hint:
184
  idx = ACTION_TO_INDEX.get(hint)
185
  if idx is not None:
186
+ if exclude_look and 4 <= idx <= 7: # indices 4-7 are look(north/south/west/east)
187
+ continue
188
  mask[idx] = 1.0
189
  continue
190
  m = _MOVE_RE.fullmatch(hint)
 
575
  reward -= 0.2 # break the loop
576
  recent_positions.append(cur_pos)
577
 
578
+ # ----------------------------------------------------------------
579
+ # Reward shaping 4 — exit proximity pull
580
+ # Absolute (not just delta) distance-based bonus so the agent has
581
+ # a continuous gradient toward exits even before it learns
582
+ # consistent BFS progress. Complements the server-side
583
+ # ProgressReward which only fires on a single step of BFS gain.
584
+ # Max +0.25 when adjacent; tapers to 0 beyond 6 cells (Manhattan).
585
+ # Only fires on move to avoid rewarding standing still near exits.
586
+ # ----------------------------------------------------------------
587
+ if ms_next is not None and chosen_action.startswith("move") and not next_obs.agent_evacuated:
588
+ ax, ay = ms_next.agent_x, ms_next.agent_y
589
+ exits = ms_next.exit_positions # List[List[int]] of [x, y]
590
+ if exits:
591
+ min_manhattan = min(abs(ax - ex[0]) + abs(ay - ex[1]) for ex in exits)
592
+ reward += max(0.0, 0.25 - 0.04 * min_manhattan)
593
+
594
  done = bool(next_obs.done)
595
 
596
  buffer.obs.append(state_vec)
 
898
  return schedule[:n_episodes]
899
 
900
 
901
+ def parse_mix_dist(spec: Optional[str]) -> Optional[Dict[str, float]]:
902
+ """Parse a 'hard:0.6,medium:0.3,easy:0.1' style spec into a dict.
903
+
904
+ Returns None when ``spec`` is falsy. Probabilities are renormalised to
905
+ sum to 1 if they don't already (within 1% tolerance).
906
+ """
907
+ if not spec:
908
+ return None
909
+ out: Dict[str, float] = {}
910
+ for chunk in spec.split(","):
911
+ chunk = chunk.strip()
912
+ if not chunk:
913
+ continue
914
+ if ":" not in chunk:
915
+ raise ValueError(f"Invalid mix-dist entry '{chunk}', expected 'name:prob'")
916
+ name, val = chunk.split(":", 1)
917
+ out[name.strip().lower()] = float(val)
918
+ total = sum(out.values())
919
+ if total <= 0:
920
+ raise ValueError(f"mix-dist probabilities must be positive, got {out}")
921
+ return {k: v / total for k, v in out.items()}
922
+
923
+
924
  class PatienceCurriculum:
925
  """Dynamic difficulty scheduler that gates advancement on sustained success rate.
926
 
 
934
  stages: ordered list of difficulty strings, e.g. ['easy','medium','hard']
935
  threshold: minimum success rate (0–1) required before advancing
936
  patience_window: number of consecutive episodes that must meet threshold
937
+ mix_ratio: fraction of hard-phase episodes to run on medium instead (0–1).
938
+ Ignored when ``mix_dist`` is provided.
939
+ mix_dist: optional dict mapping difficulty -> probability used
940
+ during the *final* (hard) stage, e.g.
941
+ ``{"hard": 0.6, "medium": 0.3, "easy": 0.1}``. When set,
942
+ each hard-phase episode samples its difficulty from this
943
+ distribution. Probabilities must sum to 1.
944
  """
945
 
946
  def __init__(
 
949
  threshold: float,
950
  patience_window: int,
951
  mix_ratio: float = 0.0,
952
+ mix_dist: Optional[Dict[str, float]] = None,
953
  ) -> None:
954
  self.stages = stages
955
  self.threshold = threshold
956
  self.patience_window = patience_window
957
  self.mix_ratio = mix_ratio
958
+ self.mix_dist = mix_dist
959
  self.stage_idx = 0
960
  self._streak = 0
961
 
962
+ if self.mix_dist is not None:
963
+ total = sum(self.mix_dist.values())
964
+ if not (0.99 <= total <= 1.01):
965
+ raise ValueError(
966
+ f"mix_dist probabilities must sum to 1, got {total:.3f}"
967
+ )
968
+ for k in self.mix_dist:
969
+ if k not in self.stages:
970
+ raise ValueError(
971
+ f"mix_dist key '{k}' not in stages {self.stages}"
972
+ )
973
+
974
  @property
975
  def current(self) -> str:
976
  return self.stages[self.stage_idx]
 
979
  """Call once per episode *after* appending to success_window.
980
 
981
  Returns the difficulty to use for the *next* episode.
982
+ Also handles the final-stage cumulative-replay mix.
983
  """
984
  if self.stage_idx < len(self.stages) - 1:
985
  if success_rate_30 >= self.threshold:
 
995
  f"for {self.patience_window} eps)"
996
  )
997
 
998
+ is_final_stage = self.stage_idx == len(self.stages) - 1
999
+
1000
+ if is_final_stage and self.mix_dist is not None:
1001
+ keys = list(self.mix_dist.keys())
1002
+ probs = np.array([self.mix_dist[k] for k in keys], dtype=np.float64)
1003
+ probs = probs / probs.sum()
1004
+ return str(np.random.choice(keys, p=probs))
1005
+
1006
+ if is_final_stage and self.mix_ratio > 0.0 and len(self.stages) >= 2:
1007
  prev = self.stages[self.stage_idx - 1]
1008
  if np.random.rand() < self.mix_ratio:
1009
+ return prev
1010
  return self.current
1011
 
1012
 
 
1092
  # Build curriculum — patience-gated (dynamic) or static
1093
  stages = [s.strip().lower() for s in args.difficulty_schedule.split(",") if s.strip()]
1094
  if args.patience_threshold > 0:
1095
+ mix_dist = parse_mix_dist(getattr(args, "hard_mix_dist", None))
1096
  patience_curriculum = PatienceCurriculum(
1097
  stages=stages,
1098
  threshold=args.patience_threshold,
1099
  patience_window=args.patience_window,
1100
  mix_ratio=args.hard_mix_ratio,
1101
+ mix_dist=mix_dist,
1102
  )
1103
  static_curriculum: Optional[List[str]] = None
1104
+ if mix_dist is not None:
1105
+ print(f"[curriculum] hard-phase mix distribution: {mix_dist}")
1106
  print(f"[curriculum] patience-gated: threshold={args.patience_threshold} "
1107
  f"window={args.patience_window} mix={args.hard_mix_ratio}")
1108
  else:
 
1281
  help="Episodes that must sustain >= patience-threshold before advancing.")
1282
  p.add_argument("--hard-mix-ratio", type=float, default=0.25,
1283
  help="Fraction of hard-phase episodes to replay on medium (0=pure hard). "
1284
+ "Prevents catastrophic forgetting of the medium policy. "
1285
+ "Ignored when --hard-mix-dist is set.")
1286
+ p.add_argument("--hard-mix-dist", type=str, default=None,
1287
+ help="Cumulative replay distribution for the final stage, e.g. "
1288
+ "'hard:0.6,medium:0.3,easy:0.1'. Overrides --hard-mix-ratio.")
1289
  p.add_argument("--eval-difficulty", type=str, default="medium", choices=DIFFICULTIES)
1290
  p.add_argument("--eval-episodes", type=int, default=10)
1291
  p.add_argument("--eval-every", type=int, default=50)
examples/train_torch_ppo_http.py CHANGED
@@ -14,32 +14,31 @@ Usage
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
@@ -67,10 +66,13 @@ from examples.train_torch_ppo import (
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
  )
@@ -189,6 +191,7 @@ def run_episode(
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)
@@ -215,6 +218,9 @@ def run_episode(
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)
@@ -243,6 +249,17 @@ def run_episode(
243
  reward -= 0.2
244
  recent_positions.append(cur_pos)
245
 
 
 
 
 
 
 
 
 
 
 
 
246
  done = bool(next_obs.done)
247
 
248
  buffer.obs.append(state_vec)
@@ -278,9 +295,12 @@ def run_episode(
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
@@ -291,57 +311,114 @@ def train(args: argparse.Namespace) -> None:
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,
@@ -351,79 +428,98 @@ def train(args: argparse.Namespace) -> None:
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
 
@@ -434,19 +530,38 @@ def train(args: argparse.Namespace) -> None:
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)
@@ -456,10 +571,14 @@ def parse_args() -> argparse.Namespace:
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)
@@ -470,10 +589,12 @@ def parse_args() -> argparse.Namespace:
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)
 
14
  2. Run this script:
15
  .venv/Scripts/python.exe examples/train_torch_ppo_http.py
16
 
17
+ Optional flags (all match 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 stages [default: easy,medium,hard]
21
+ --patience-threshold Success-rate gate (0=static) [default: 0.65]
22
+ --learning-rate Adam learning rate [default: 3e-4]
23
+ --resume Path to checkpoint to resume [default: None]
24
+ --output Where to save the model .pt [default: artifacts/pyre_ppo_http.pt]
25
  """
26
 
27
  from __future__ import annotations
28
 
29
  import argparse
30
  import csv
 
31
  import sys
32
  import time
33
  from collections import deque
34
+ from dataclasses import dataclass
35
  from pathlib import Path
36
  from typing import Any, Dict, List, Optional
37
 
38
  import numpy as np
39
  import requests
40
  import torch
 
41
  import torch.optim as optim
 
42
 
43
  # ---------------------------------------------------------------------------
44
  # Resolve project root so we can import shared models regardless of CWD
 
66
  WINDS,
67
  ActorCritic,
68
  ObservationEncoder,
69
+ PatienceCurriculum,
70
  RolloutBuffer,
71
  action_index_to_env_action,
72
  build_action_mask,
73
+ build_curriculum,
74
  compute_gae,
75
+ parse_mix_dist,
76
  ppo_update,
77
  save_training_graph_png,
78
  )
 
191
  history_length: int,
192
  buffer: RolloutBuffer,
193
  deterministic: bool = False,
194
+ step_delay: float = 0.0,
195
  ) -> EpisodeResult:
196
  observation = env.reset(difficulty=difficulty)
197
  zero_frame = np.zeros(encoder.base_dim, dtype=np.float32)
 
218
 
219
  action_idx = int(action_t.item())
220
  env_action = action_index_to_env_action(action_idx)
221
+ if step_delay > 0.0:
222
+ time.sleep(step_delay)
223
+ print(f" step={steps+1:03d} action={ACTION_KEYS[action_idx]:<40} hp={observation.agent_health:5.1f}", flush=True)
224
  next_obs = env.step(env_action)
225
 
226
  reward = float(next_obs.reward or 0.0)
 
249
  reward -= 0.2
250
  recent_positions.append(cur_pos)
251
 
252
+ # Shaping 4 — exit proximity pull
253
+ # Absolute distance-based bonus (not just delta) so the network
254
+ # has a continuous gradient toward exits from anywhere on the map.
255
+ # Max +0.25 when adjacent, tapers to 0 beyond 6 cells (Manhattan).
256
+ if ms_next is not None and chosen_action.startswith("move") and not next_obs.agent_evacuated:
257
+ ax, ay = ms_next.agent_x, ms_next.agent_y
258
+ exits = ms_next.exit_positions
259
+ if exits:
260
+ min_manhattan = min(abs(ax - ex[0]) + abs(ay - ex[1]) for ex in exits)
261
+ reward += max(0.0, 0.25 - 0.04 * min_manhattan)
262
+
263
  done = bool(next_obs.done)
264
 
265
  buffer.obs.append(state_vec)
 
295
 
296
  def train(args: argparse.Namespace) -> None:
297
  device = torch.device("cuda" if args.device == "cuda" and torch.cuda.is_available() else "cpu")
298
+ if args.device == "cuda" and not torch.cuda.is_available():
299
+ print("[warn] CUDA not available — falling back to CPU.")
300
+
301
  encoder = ObservationEncoder(mode=args.observation_mode)
302
  input_dim = encoder.base_dim * args.history_length
303
+ hidden_sizes = tuple(int(x) for x in args.hidden_sizes.split(","))
304
  action_dim = ACTION_DIM
305
 
306
  # Connect to server
 
311
  sys.exit(1)
312
  print("OK")
313
 
314
+ # Network + optimizer
315
  network = ActorCritic(input_dim, action_dim, hidden_sizes).to(device)
316
+ optimizer = optim.Adam(network.parameters(), lr=args.learning_rate, eps=1e-5)
317
+
318
+ # LinearLR scheduler: step once per PPO update, not per episode
319
+ total_updates = args.episodes // args.update_every
320
+ lr_scheduler = optim.lr_scheduler.LinearLR(
321
+ optimizer,
322
+ start_factor=1.0,
323
+ end_factor=args.lr_end_factor,
324
+ total_iters=max(1, total_updates),
325
+ ) if args.lr_decay else None
326
 
327
  total_params = sum(p.numel() for p in network.parameters())
328
  print(f"\n[config] server={args.server}")
329
  print(f"[config] device={device} episodes={args.episodes} batch={args.update_every} eps")
330
  print(f"[config] curriculum: {args.difficulty_schedule}")
331
+ print(f"[config] PPO clip_eps={args.clip_eps} entropy={args.entropy_coef} lr={args.learning_rate}")
332
  print(f"\n[network] Parameters: {total_params:,}")
333
  print(f"[network] Input dim: {input_dim:,} (encoder.base_dim={encoder.base_dim} x {args.history_length} frames)")
334
  print(f"[network] Action dim: {action_dim} (4 move + 4 look + 1 wait + {MAX_DOORS} open + {MAX_DOORS} close)\n", flush=True)
335
 
336
+ # Build curriculum — patience-gated (dynamic) or static
337
+ stages = [s.strip().lower() for s in args.difficulty_schedule.split(",") if s.strip()]
338
+ if args.patience_threshold > 0:
339
+ mix_dist = parse_mix_dist(getattr(args, "hard_mix_dist", None))
340
+ patience_curriculum = PatienceCurriculum(
341
+ stages=stages,
342
+ threshold=args.patience_threshold,
343
+ patience_window=args.patience_window,
344
+ mix_ratio=args.hard_mix_ratio,
345
+ mix_dist=mix_dist,
346
+ )
347
+ static_schedule: Optional[List[str]] = None
348
+ print(f"[curriculum] patience-gated: threshold={args.patience_threshold} "
349
+ f"window={args.patience_window} mix={args.hard_mix_ratio}", flush=True)
350
+ if mix_dist is not None:
351
+ print(f"[curriculum] hard-phase mix distribution: {mix_dist}", flush=True)
352
+ else:
353
+ patience_curriculum = None
354
+ static_schedule = build_curriculum(args.difficulty_schedule, args.episodes)
355
+ print(f"[curriculum] static: {args.difficulty_schedule}", flush=True)
356
+
357
+ # Resume
358
+ start_ep = 0
359
+ if args.resume and Path(args.resume).exists():
360
+ ckpt = torch.load(args.resume, map_location="cpu", weights_only=False)
361
+ network.load_state_dict(ckpt.get("network_state", ckpt))
362
+ if "optimizer_state" in ckpt:
363
+ optimizer.load_state_dict(ckpt["optimizer_state"])
364
+ if lr_scheduler and ckpt.get("scheduler_state"):
365
+ lr_scheduler.load_state_dict(ckpt["scheduler_state"])
366
+ start_ep = int(ckpt.get("episode", 0))
367
+ print(f"[resume] Loaded checkpoint from episode {start_ep}: {args.resume}")
368
+
369
  buffer = RolloutBuffer()
370
+ episode_rows: List[Dict] = []
371
+ eval_rows: List[Dict] = []
372
  success_window: deque = deque(maxlen=30)
373
  reward_window: deque = deque(maxlen=30)
374
  t0 = time.time()
 
 
 
375
 
376
+ for ep_idx in range(start_ep, args.episodes):
377
+ ep = ep_idx + 1
378
+
379
+ # Determine difficulty for this episode
380
+ if patience_curriculum is not None:
381
+ difficulty = patience_curriculum.current
382
+ else:
383
+ difficulty = static_schedule[ep_idx] # type: ignore[index]
384
+
385
+ # Use step delay only after --viz-after-ep episodes have been trained
386
+ ep_step_delay = args.step_delay if ep > args.viz_after_ep else 0.0
387
+ result = run_episode(env, network, encoder, device, difficulty, args.history_length, buffer,
388
+ step_delay=ep_step_delay)
389
 
 
390
  success_window.append(1 if result.evacuated else 0)
391
  reward_window.append(result.total_reward)
392
  suc30 = sum(success_window) / len(success_window)
393
  r30 = sum(reward_window) / len(reward_window)
394
  elapsed = int(time.time() - t0)
395
 
396
+ # Advance patience curriculum after updating success_window
397
+ if patience_curriculum is not None:
398
+ difficulty = patience_curriculum.step(suc30)
399
+
400
  print(
401
  f"ep={ep:04d} [{difficulty:<6}] steps={result.steps:03d} "
402
+ f"reward={result.total_reward:+8.3f} evac={int(result.evacuated)} "
403
  f"hp={result.final_health:5.1f} suc30={suc30:.2f} "
404
+ f"r30={r30:+7.2f} t={elapsed}s",
405
+ flush=True,
406
  )
407
 
408
+ episode_rows.append({
409
+ "episode": ep,
410
+ "difficulty": difficulty,
411
+ "steps": result.steps,
412
+ "reward": round(result.total_reward, 4),
413
+ "evacuated": int(result.evacuated),
414
+ "final_health": round(result.final_health, 2),
415
+ "reward_mean_30": round(r30, 4),
416
+ "success_rate_30": round(suc30, 4),
417
  })
418
 
419
+ # PPO update every N episodes (or at the very last episode)
420
+ should_update = (ep % args.update_every == 0) or (ep == args.episodes)
421
+ if should_update and len(buffer) > 0:
422
  network.train()
423
  stats = ppo_update(
424
  network=network, optimizer=optimizer, buffer=buffer, device=device,
 
428
  gamma=args.gamma, gae_lambda=args.gae_lambda,
429
  max_grad_norm=args.max_grad_norm,
430
  )
431
+ if lr_scheduler:
432
+ lr_scheduler.step()
433
+ buffer.clear()
434
+ network.eval()
435
+
436
  cur_lr = optimizer.param_groups[0]["lr"]
437
  print(
438
  f" >> PPO update samples=flushed "
439
  f"pi_loss={stats['policy_loss']:+.4f} v_loss={stats['value_loss']:.4f} "
440
  f"entropy={stats['entropy']:.4f} kl={stats['approx_kl']:.4f} "
441
+ f"clip%={stats['clip_frac']:.2f} lr={cur_lr:.2e}",
442
+ flush=True,
443
  )
 
 
444
 
445
  # Evaluation
446
+ if args.eval_every > 0 and (ep % args.eval_every == 0 or ep == args.episodes):
447
  eval_rewards, eval_success, eval_steps_list = [], [], []
448
  eval_buf = RolloutBuffer()
449
  for _ in range(args.eval_episodes):
450
  er = run_episode(
451
  env, network, encoder, device,
452
  args.eval_difficulty, args.history_length,
453
+ eval_buf, deterministic=True, step_delay=0.0,
454
  )
455
+ eval_buf.clear() # clear after each eval episode — don't accumulate
456
  eval_rewards.append(er.total_reward)
457
  eval_success.append(1 if er.evacuated else 0)
458
  eval_steps_list.append(er.steps)
459
  avg_r = sum(eval_rewards) / len(eval_rewards)
460
  avg_s = sum(eval_success) / len(eval_success)
461
  avg_st = sum(eval_steps_list) / len(eval_steps_list)
462
+ print(f" ** EVAL [{args.eval_difficulty}] reward={avg_r:+.3f} success={avg_s:.2f} steps={avg_st:.1f}", flush=True)
463
+ eval_rows.append({
464
+ "episode": ep,
465
+ "difficulty": args.eval_difficulty,
466
+ "reward_mean": round(avg_r, 4),
467
+ "success_rate": round(avg_s, 3),
468
+ "steps_mean": round(avg_st, 1),
469
  })
470
 
471
+ # Periodic checkpoint (full state, same as train_torch_ppo.py)
472
+ if args.checkpoint and args.checkpoint_every > 0 and ep % args.checkpoint_every == 0:
473
+ ckpt_path = Path(args.checkpoint)
474
+ ckpt_path.parent.mkdir(parents=True, exist_ok=True)
475
+ torch.save({
476
+ "episode": ep,
477
+ "network_state": network.state_dict(),
478
+ "optimizer_state": optimizer.state_dict(),
479
+ "scheduler_state": lr_scheduler.state_dict() if lr_scheduler else None,
480
+ "args": vars(args),
481
+ }, ckpt_path)
482
+ print(f" [ckpt] saved -> {args.checkpoint}", flush=True)
483
 
484
  # --- Save artefacts ---
485
  out = Path(args.output)
486
  out.parent.mkdir(parents=True, exist_ok=True)
487
+ torch.save({
488
+ "episode": args.episodes,
489
+ "network_state": network.state_dict(),
490
+ "optimizer_state": optimizer.state_dict(),
491
+ "scheduler_state": lr_scheduler.state_dict() if lr_scheduler else None,
492
+ "args": vars(args),
493
+ }, out)
494
  print(f"\n[done] Model saved -> {out}")
495
 
496
+ if args.save_metrics and episode_rows:
497
  csv_path = out.with_suffix(".csv")
498
  with open(csv_path, "w", newline="") as f:
499
+ writer = csv.DictWriter(f, fieldnames=episode_rows[0].keys())
500
  writer.writeheader()
501
+ writer.writerows(episode_rows)
502
  print(f"[done] Metrics CSV -> {csv_path}")
503
 
504
+ if eval_rows:
505
+ eval_csv = out.parent / (out.stem + "_eval.csv")
506
  with open(eval_csv, "w", newline="") as f:
507
+ writer = csv.DictWriter(f, fieldnames=eval_rows[0].keys())
508
  writer.writeheader()
509
+ writer.writerows(eval_rows)
510
  print(f"[done] Eval CSV -> {eval_csv}")
511
 
512
  if args.save_graph:
513
+ png_path = out.with_suffix(".png")
514
+ # Correct arg order: save_training_graph_png(path, episode_rows, eval_rows)
515
+ save_training_graph_png(png_path, episode_rows, eval_rows)
516
+ print(f"[done] Graph PNG -> {png_path}")
 
 
517
 
518
  suc_final = sum(success_window) / max(1, len(success_window))
519
  r_final = sum(reward_window) / max(1, len(reward_window))
520
  elapsed_total = time.time() - t0
521
+ n_trained = args.episodes - start_ep
522
+ print(f"\n[summary] {n_trained} episodes in {elapsed_total:.1f}s ({n_trained / max(1, elapsed_total):.1f} eps/s)")
523
  print(f"[summary] Final success rate (last 30): {suc_final:.2f}")
524
  print(f"[summary] Final reward mean (last 30): {r_final:+.3f}")
525
 
 
530
 
531
  def parse_args() -> argparse.Namespace:
532
  p = argparse.ArgumentParser(
533
+ description="PPO trainer using the Pyre HTTP server (localhost:8000)",
534
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
535
  )
536
 
537
  # Server
538
  p.add_argument("--server", type=str, default="http://localhost:8000",
539
  help="Base URL of the running Pyre env server")
540
 
541
+ # Visualization / pacing
542
+ p.add_argument("--step-delay", type=float, default=0.0,
543
+ help="Seconds to sleep between steps (0=full speed, 0.5=smooth viz)")
544
+ p.add_argument("--viz-after-ep", type=int, default=0,
545
+ help="Episode after which --step-delay activates. "
546
+ "0=always delay, 100=fast first 100 eps then slow.")
547
+
548
+ # Training scale
549
  p.add_argument("--episodes", type=int, default=400)
550
+ p.add_argument("--max-steps", type=int, default=150, help="Max steps per episode (informational; enforced server-side)")
551
  p.add_argument("--device", type=str, default="cpu", choices=("cuda", "cpu"))
552
 
553
  # Curriculum
554
+ p.add_argument("--difficulty-schedule", type=str, default="easy,medium,hard",
555
+ help="Comma-separated curriculum stages")
556
+ p.add_argument("--patience-threshold", type=float, default=0.65,
557
+ help="Success-rate (30-ep window) required before advancing difficulty. Set 0 for static split.")
558
+ p.add_argument("--patience-window", type=int, default=15,
559
+ help="Consecutive episodes that must meet --patience-threshold before advancing.")
560
+ p.add_argument("--hard-mix-dist", type=str, default=None,
561
+ help="Cumulative replay distribution for the final stage, e.g. "
562
+ "'hard:0.6,medium:0.3,easy:0.1'. Overrides --hard-mix-ratio.")
563
+ p.add_argument("--hard-mix-ratio", type=float, default=0.25,
564
+ help="Fraction of hard-phase episodes replayed on medium (prevents forgetting).")
565
  p.add_argument("--eval-difficulty", type=str, default="medium", choices=DIFFICULTIES)
566
  p.add_argument("--eval-episodes", type=int, default=10)
567
  p.add_argument("--eval-every", type=int, default=50)
 
571
  p.add_argument("--history-length", type=int, default=4)
572
 
573
  # Network
574
+ p.add_argument("--hidden-sizes", type=str, default="512,256,128",
575
+ help="Comma-separated MLP hidden layer sizes (match train_torch_ppo.py defaults)")
576
+
577
+ # PPO hyperparameters
578
+ p.add_argument("--learning-rate", type=float, default=3e-4)
579
+ p.add_argument("--lr-decay", action="store_true", default=True,
580
+ help="Linear LR decay to lr-end-factor × initial LR over training updates")
581
+ p.add_argument("--lr-end-factor", type=float, default=0.1)
582
  p.add_argument("--gamma", type=float, default=0.99)
583
  p.add_argument("--gae-lambda", type=float, default=0.95)
584
  p.add_argument("--clip-eps", type=float, default=0.2)
 
589
  p.add_argument("--minibatch-size", type=int, default=256)
590
  p.add_argument("--max-grad-norm", type=float, default=0.5)
591
 
592
+ # Persistence
593
  p.add_argument("--output", type=str, default="artifacts/pyre_ppo_http.pt")
594
  p.add_argument("--checkpoint", type=str, default="artifacts/pyre_ppo_http_ckpt.pt")
595
  p.add_argument("--checkpoint-every", type=int, default=50)
596
+ p.add_argument("--resume", type=str, default=None,
597
+ help="Path to a checkpoint (.pt) to resume training from")
598
  p.add_argument("--save-metrics", action="store_true", default=True)
599
  p.add_argument("--save-graph", action="store_true", default=True)
600
  p.add_argument("--seed", type=int, default=42)
server/fire_sim.py CHANGED
@@ -34,9 +34,6 @@ FIRE_BURNING = 0.3
34
  FIRE_INTENSITY_GAIN = 0.15
35
  BURNOUT_TICKS = 5
36
 
37
- # Doors smolder longer before collapsing so they reliably spread fire into corridors
38
- DOOR_BURNOUT_MULTIPLIER = 3
39
-
40
  # Door fire reduction factor
41
  DOOR_CLOSED_FIRE_FACTOR = 0.15
42
 
@@ -221,14 +218,7 @@ class FireSim:
221
  new_fire[i] = min(1.0, fire_grid[i] + intensity_gain)
222
  if fire_grid[i] >= FIRE_BURNING:
223
  new_burn_timers[i] = burn_timers[i] + 1
224
- # Doors smolder longer than normal cells so they spread fire
225
- # into the corridor before collapsing to floor.
226
- effective_burnout = (
227
- self.burnout_ticks * DOOR_BURNOUT_MULTIPLIER
228
- if ct in (DOOR_OPEN, DOOR_CLOSED)
229
- else self.burnout_ticks
230
- )
231
- if new_burn_timers[i] >= effective_burnout and new_fire[i] >= 1.0:
232
  # Doors burn away to open floor (passage stays navigable).
233
  # Everything else (furniture, walls that caught fire, etc.) becomes rubble.
234
  cell_grid[i] = FLOOR if ct in (DOOR_OPEN, DOOR_CLOSED) else OBSTACLE
 
34
  FIRE_INTENSITY_GAIN = 0.15
35
  BURNOUT_TICKS = 5
36
 
 
 
 
37
  # Door fire reduction factor
38
  DOOR_CLOSED_FIRE_FACTOR = 0.15
39
 
 
218
  new_fire[i] = min(1.0, fire_grid[i] + intensity_gain)
219
  if fire_grid[i] >= FIRE_BURNING:
220
  new_burn_timers[i] = burn_timers[i] + 1
221
+ if new_burn_timers[i] >= self.burnout_ticks and new_fire[i] >= 1.0:
 
 
 
 
 
 
 
222
  # Doors burn away to open floor (passage stays navigable).
223
  # Everything else (furniture, walls that caught fire, etc.) becomes rubble.
224
  cell_grid[i] = FLOOR if ct in (DOOR_OPEN, DOOR_CLOSED) else OBSTACLE
server/floor_plan.py CHANGED
@@ -235,8 +235,8 @@ def _make_small_office() -> FloorPlan:
235
  # Row 11: W F F O O F F F F F O O F F F W
236
  # Row 12: W F F O O F F F F F O O F F F W
237
  # Row 13: W F F F F F F F F F F F F F F W
238
- # Row 14: W F F F F F F F F F F F F F F E ← exit at (15,14)
239
- # Row 15: W W W W W W W W W W W W W W W W
240
  # ---------------------------------------------------------------------------
241
 
242
  def _make_open_plan() -> FloorPlan:
@@ -256,12 +256,12 @@ def _make_open_plan() -> FloorPlan:
256
  [1, 0, 0, 5, 5, 0, 0, 0, 0, 0, 5, 5, 0, 0, 0, 1], # 11 pillars
257
  [1, 0, 0, 5, 5, 0, 0, 0, 0, 0, 5, 5, 0, 0, 0, 1], # 12
258
  [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], # 13
259
- [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4], # 14 exit at x=15
260
- [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], # 15
261
  ]
262
  grid = [c for row in rows for c in row]
263
 
264
- exit_positions = [(0, 1), (15, 14)]
265
  door_positions = [] # No internal doors in open plan
266
 
267
  floor_cells = [(x, y) for y in range(H) for x in range(W)
@@ -304,15 +304,14 @@ def _make_open_plan() -> FloorPlan:
304
  # Template 3: t_corridor
305
  #
306
  # T-shaped layout: vertical stem (x=7, y=0-14) + horizontal bar (y=7, x=0-15)
307
- # Side rooms off horizontal bar — accessed through bar-to-room doors at row 8,
308
- # with internal doors at row 10 connecting upper (y=8-9) and lower (y=11-12) halves.
309
  #
310
  # Row 0: W W W W W W W E W W W W W W W W ← exit at (7,0)
311
  # Row 1-6: vertical stem only (x=7)
312
  # Row 7: E F F F F F F F F F F F F F F E ← horizontal bar + exits
313
- # Row 8: W W D W W D W F W W D W W W D W ← bar-to-room doors (fire must pass these)
314
- # Row 9: W F F W F F W F W F F W F F F W ← room interior (4 cells deep: rows 9–12)
315
- # Row 10: W F F W F F W F W F F W F F F W
316
  # Row 11: W F F W F F W F W F F W F F F W
317
  # Row 12: W F F W F F W F W F F W F F F W
318
  # Row 13-14: stem only
@@ -330,11 +329,11 @@ def _make_t_corridor() -> FloorPlan:
330
  [1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1], # 5
331
  [1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1], # 6
332
  [4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4], # 7 horizontal + exits
333
- [1, 1, 2, 1, 1, 2, 1, 0, 1, 1, 2, 1, 1, 1, 2, 1], # 8 bar→room doors
334
- [1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 0, 1], # 9 room interior
335
- [1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 0, 1], # 10 room interior
336
- [1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 0, 1], # 11 room interior
337
- [1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 0, 1], # 12 room interior (deepest)
338
  [1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1], # 13 stem continues
339
  [1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1], # 14
340
  [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], # 15
@@ -342,11 +341,11 @@ def _make_t_corridor() -> FloorPlan:
342
  grid = [c for row in rows for c in row]
343
 
344
  exit_positions = [(7, 0), (0, 7), (15, 7)]
345
- door_positions = [(2, 8), (5, 8), (10, 8), (14, 8)] # bar → room entry doors
346
 
347
- # Spawn zones: horizontal bar + side rooms (row 8 is now doors/walls, start from row 9)
348
  bar_cells = [(x, 7) for x in range(1, 15) if grid[_idx(x, 7, W)] == 0]
349
- room_cells = [(x, y) for y in range(9, 13) for x in range(1, 15)
350
  if grid[_idx(x, y, W)] == 0]
351
  stem_cells = [(7, y) for y in range(1, 15) if grid[_idx(7, y, W)] == 0]
352
 
 
235
  # Row 11: W F F O O F F F F F O O F F F W
236
  # Row 12: W F F O O F F F F F O O F F F W
237
  # Row 13: W F F F F F F F F F F F F F F W
238
+ # Row 14: W F F F F F F F F F F F F F F W
239
+ # Row 15: W W W W W W W W W W W W W W W E ← exit at (15,15)
240
  # ---------------------------------------------------------------------------
241
 
242
  def _make_open_plan() -> FloorPlan:
 
256
  [1, 0, 0, 5, 5, 0, 0, 0, 0, 0, 5, 5, 0, 0, 0, 1], # 11 pillars
257
  [1, 0, 0, 5, 5, 0, 0, 0, 0, 0, 5, 5, 0, 0, 0, 1], # 12
258
  [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], # 13
259
+ [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], # 14
260
+ [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 4], # 15 exit at x=15
261
  ]
262
  grid = [c for row in rows for c in row]
263
 
264
+ exit_positions = [(0, 1), (15, 15)]
265
  door_positions = [] # No internal doors in open plan
266
 
267
  floor_cells = [(x, y) for y in range(H) for x in range(W)
 
304
  # Template 3: t_corridor
305
  #
306
  # T-shaped layout: vertical stem (x=7, y=0-14) + horizontal bar (y=7, x=0-15)
307
+ # Side rooms off horizontal bar (y=8-12, left and right of stem):
 
308
  #
309
  # Row 0: W W W W W W W E W W W W W W W W ← exit at (7,0)
310
  # Row 1-6: vertical stem only (x=7)
311
  # Row 7: E F F F F F F F F F F F F F F E ← horizontal bar + exits
312
+ # Row 8: W F F W F F W F W F F W F F F W ← rooms branch off bar
313
+ # Row 9: W F F W F F W F W F F W F F F W
314
+ # Row 10: W W D W W D W F W D W W W W D W ← doors to stem
315
  # Row 11: W F F W F F W F W F F W F F F W
316
  # Row 12: W F F W F F W F W F F W F F F W
317
  # Row 13-14: stem only
 
329
  [1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1], # 5
330
  [1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1], # 6
331
  [4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4], # 7 horizontal + exits
332
+ [1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 0, 1], # 8 side rooms
333
+ [1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 0, 1], # 9
334
+ [1, 1, 2, 1, 1, 2, 1, 0, 1, 2, 1, 1, 1, 1, 2, 1], # 10 doors to stem
335
+ [1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 0, 1], # 11
336
+ [1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 0, 1], # 12
337
  [1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1], # 13 stem continues
338
  [1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1], # 14
339
  [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], # 15
 
341
  grid = [c for row in rows for c in row]
342
 
343
  exit_positions = [(7, 0), (0, 7), (15, 7)]
344
+ door_positions = [(2, 10), (5, 10), (9, 10), (14, 10)]
345
 
346
+ # Spawn zones: horizontal bar + side rooms
347
  bar_cells = [(x, 7) for x in range(1, 15) if grid[_idx(x, 7, W)] == 0]
348
+ room_cells = [(x, y) for y in range(8, 13) for x in range(1, 15)
349
  if grid[_idx(x, y, W)] == 0]
350
  stem_cells = [(7, y) for y in range(1, 15) if grid[_idx(7, y, W)] == 0]
351
 
server/pyre_env_environment.py CHANGED
@@ -364,9 +364,8 @@ class PyreEnvironment(Environment):
364
  self._visited_cells.add((st.agent_x, st.agent_y))
365
 
366
  # Track closest approach to any exit for NearMissBonus
367
- _exits = unblocked_exits(st.exit_positions, st.fire_grid, st.grid_w)
368
- if not _exits:
369
- _exits = st.exit_positions
370
  _cur_dist = bfs_exit_dist(st.agent_x, st.agent_y, _exits, st.cell_grid, st.grid_w, st.grid_h)
371
  if _cur_dist < self._min_exit_dist_reached:
372
  self._min_exit_dist_reached = _cur_dist
@@ -407,6 +406,12 @@ class PyreEnvironment(Environment):
407
  )
408
  obs_data["done"] = done
409
  obs_data["reward"] = reward
 
 
 
 
 
 
410
  obs_data["metadata"] = {
411
  "agent_health": st.agent_health,
412
  "step": st.step_count,
@@ -415,8 +420,12 @@ class PyreEnvironment(Environment):
415
  "fire_sources": st.fire_sources_count,
416
  "humidity": st.humidity,
417
  "difficulty": getattr(self, "_difficulty", "medium"),
 
 
 
 
418
  }
419
- obs_data["map_state"] = self._build_map_state(st)
420
  return PyreObservation(**obs_data)
421
 
422
  @property
@@ -588,15 +597,22 @@ class PyreEnvironment(Environment):
588
  # Map state builder
589
  # ------------------------------------------------------------------
590
 
591
- def _build_map_state(self, st: PyreState) -> PyreMapState:
592
- """Assemble the full numerical grid snapshot for UI / visualization."""
 
 
 
 
 
 
593
  if st.agent_alive and not st.agent_evacuated:
594
- visible = compute_visible_cells(
595
- st.agent_x, st.agent_y,
596
- st.cell_grid, st.smoke_grid,
597
- st.grid_w, st.grid_h,
598
- )
599
- visible_cells = [[x, y] for x, y in sorted(visible)]
 
600
  else:
601
  visible_cells = []
602
 
 
364
  self._visited_cells.add((st.agent_x, st.agent_y))
365
 
366
  # Track closest approach to any exit for NearMissBonus
367
+ _exits_reachable = unblocked_exits(st.exit_positions, st.fire_grid, st.grid_w)
368
+ _exits = _exits_reachable if _exits_reachable else st.exit_positions
 
369
  _cur_dist = bfs_exit_dist(st.agent_x, st.agent_y, _exits, st.cell_grid, st.grid_w, st.grid_h)
370
  if _cur_dist < self._min_exit_dist_reached:
371
  self._min_exit_dist_reached = _cur_dist
 
406
  )
407
  obs_data["done"] = done
408
  obs_data["reward"] = reward
409
+
410
+ # Compute visible cells once so both metadata and map_state can use the count
411
+ _visible_set = compute_visible_cells(
412
+ st.agent_x, st.agent_y, st.cell_grid, st.smoke_grid, st.grid_w, st.grid_h,
413
+ ) if st.agent_alive and not st.agent_evacuated else set()
414
+
415
  obs_data["metadata"] = {
416
  "agent_health": st.agent_health,
417
  "step": st.step_count,
 
420
  "fire_sources": st.fire_sources_count,
421
  "humidity": st.humidity,
422
  "difficulty": getattr(self, "_difficulty", "medium"),
423
+ # Fields consumed by ObservationEncoder global_features — previously missing
424
+ "nearest_exit_distance": _cur_dist,
425
+ "reachable_exit_count": len(_exits_reachable),
426
+ "visible_cell_count": len(_visible_set),
427
  }
428
+ obs_data["map_state"] = self._build_map_state(st, visible_set=_visible_set)
429
  return PyreObservation(**obs_data)
430
 
431
  @property
 
597
  # Map state builder
598
  # ------------------------------------------------------------------
599
 
600
+ def _build_map_state(self, st: PyreState, visible_set: Optional[set] = None) -> PyreMapState:
601
+ """Assemble the full numerical grid snapshot for UI / visualization.
602
+
603
+ Args:
604
+ visible_set: Pre-computed set of (x, y) visible cells. When provided
605
+ (e.g. from step()) the second compute_visible_cells call
606
+ is skipped. Pass None to compute fresh (used by reset()).
607
+ """
608
  if st.agent_alive and not st.agent_evacuated:
609
+ if visible_set is None:
610
+ visible_set = compute_visible_cells(
611
+ st.agent_x, st.agent_y,
612
+ st.cell_grid, st.smoke_grid,
613
+ st.grid_w, st.grid_h,
614
+ )
615
+ visible_cells = [[x, y] for x, y in sorted(visible_set)]
616
  else:
617
  visible_cells = []
618
 
server/rubrics.py CHANGED
@@ -5,9 +5,9 @@ Each rubric class exposes a score() method.
5
  The environment composes them by calling each rubric each step.
6
 
7
  Per-step rubrics:
8
- TimeStepPenalty -0.05 constant time pressure
9
- ProgressReward +0.1 agent moved closer to nearest unblocked exit (BFS distance)
10
- ProgressRegressionPenalty -0.05 agent moved farther from nearest exit (symmetric gradient)
11
  SafeProgressBonus +0.05 stacks on ProgressReward when progress made through smoke-free cell
12
  DangerPenalty -0.5 agent moved into smoke≥moderate or fire-adjacent cell
13
  HealthDrainPenalty -0.02×dmg proportional to health lost this step
@@ -103,10 +103,10 @@ _bfs_exit_dist = bfs_exit_dist
103
  # ---------------------------------------------------------------------------
104
 
105
  class TimeStepPenalty:
106
- """Constant penalty per step to encourage urgency."""
107
 
108
  def score(self, **_) -> float:
109
- return -0.05
110
 
111
 
112
  class ProgressReward:
@@ -114,6 +114,8 @@ class ProgressReward:
114
 
115
  Uses BFS traversal distance (respects walls and obstacles) instead of
116
  Manhattan distance, so only genuine navigational progress is rewarded.
 
 
117
  """
118
 
119
  def score(
@@ -134,7 +136,7 @@ class ProgressReward:
134
  exits = exit_positions # all blocked — still try to reward progress
135
  prev_dist = _bfs_exit_dist(prev_agent_x, prev_agent_y, exits, cell_grid, w, h)
136
  new_dist = _bfs_exit_dist(agent_x, agent_y, exits, cell_grid, w, h)
137
- return 0.15 if new_dist < prev_dist else 0.0
138
 
139
 
140
  class DangerPenalty:
@@ -219,8 +221,9 @@ class StrategicDoorBonus:
219
  class ProgressRegressionPenalty:
220
  """Penalise moving farther from the nearest unblocked exit.
221
 
222
- Symmetric counterpart to ProgressReward: the agent gets +0.1 for progress
223
- and –0.05 for regression, creating a clean two-sided gradient.
 
224
  """
225
 
226
  def score(
@@ -241,7 +244,7 @@ class ProgressRegressionPenalty:
241
  exits = exit_positions
242
  prev_dist = bfs_exit_dist(prev_agent_x, prev_agent_y, exits, cell_grid, w, h)
243
  new_dist = bfs_exit_dist(agent_x, agent_y, exits, cell_grid, w, h)
244
- return -0.05 if new_dist > prev_dist else 0.0
245
 
246
 
247
  class SafeProgressBonus:
 
5
  The environment composes them by calling each rubric each step.
6
 
7
  Per-step rubrics:
8
+ TimeStepPenalty -0.01 constant time pressure
9
+ ProgressReward +0.25 agent moved closer to nearest unblocked exit (BFS distance)
10
+ ProgressRegressionPenalty -0.15 agent moved farther from nearest exit (symmetric gradient)
11
  SafeProgressBonus +0.05 stacks on ProgressReward when progress made through smoke-free cell
12
  DangerPenalty -0.5 agent moved into smoke≥moderate or fire-adjacent cell
13
  HealthDrainPenalty -0.02×dmg proportional to health lost this step
 
103
  # ---------------------------------------------------------------------------
104
 
105
  class TimeStepPenalty:
106
+ """Small constant penalty per step to encourage urgency."""
107
 
108
  def score(self, **_) -> float:
109
+ return -0.01
110
 
111
 
112
  class ProgressReward:
 
114
 
115
  Uses BFS traversal distance (respects walls and obstacles) instead of
116
  Manhattan distance, so only genuine navigational progress is rewarded.
117
+ Value raised to +0.25 to create a stronger pull toward exits relative to
118
+ the danger/loop penalties that push the agent away from threats.
119
  """
120
 
121
  def score(
 
136
  exits = exit_positions # all blocked — still try to reward progress
137
  prev_dist = _bfs_exit_dist(prev_agent_x, prev_agent_y, exits, cell_grid, w, h)
138
  new_dist = _bfs_exit_dist(agent_x, agent_y, exits, cell_grid, w, h)
139
+ return 0.25 if new_dist < prev_dist else 0.0
140
 
141
 
142
  class DangerPenalty:
 
221
  class ProgressRegressionPenalty:
222
  """Penalise moving farther from the nearest unblocked exit.
223
 
224
+ Symmetric counterpart to ProgressReward: the agent gets +0.25 for progress
225
+ and –0.15 for regression, creating a strong two-sided gradient that
226
+ discourages wandering away from the exit under fire pressure.
227
  """
228
 
229
  def score(
 
244
  exits = exit_positions
245
  prev_dist = bfs_exit_dist(prev_agent_x, prev_agent_y, exits, cell_grid, w, h)
246
  new_dist = bfs_exit_dist(agent_x, agent_y, exits, cell_grid, w, h)
247
+ return -0.15 if new_dist > prev_dist else 0.0
248
 
249
 
250
  class SafeProgressBonus: