Spaces:
Sleeping
Sleeping
| """ | |
| Curriculum Scheduler — Unlocks harder scenarios as the agent improves. | |
| Uses rolling 50-episode windows for solve rate calculation to prevent | |
| premature unlocking from lucky streaks. Per the hackathon guide: | |
| "Curriculum is critical for RL convergence." | |
| """ | |
| from __future__ import annotations | |
| import random | |
| from collections import deque | |
| from typing import Dict, List | |
| class CurriculumScheduler: | |
| """Manages progressive difficulty unlocking for training. | |
| Starts training on Level 1 only. Unlocks Level 2 once the agent | |
| achieves > 80% solve rate over the last 50 Level 1 episodes, | |
| and Level 3 once Level 2 hits > 80% over the last 50 episodes. | |
| Uses rolling windows (not all-time stats) to prevent premature | |
| unlocking from a lucky streak. | |
| Usage: | |
| scheduler = CurriculumScheduler() | |
| levels = scheduler.get_active_levels() # [1] | |
| scheduler.record_episode(level=1, solved=True) | |
| # ... after 50+ episodes with >80% solve rate... | |
| levels = scheduler.get_active_levels() # [1, 2] | |
| """ | |
| def __init__( | |
| self, | |
| unlock_threshold: float = 0.8, | |
| window_size: int = 50, | |
| ) -> None: | |
| """Initialize the curriculum scheduler. | |
| Args: | |
| unlock_threshold: Solve rate threshold to unlock next level (0-1). | |
| window_size: Number of recent episodes to consider for unlock decisions. | |
| """ | |
| self.unlock_threshold = unlock_threshold | |
| self.window_size = window_size | |
| # Rolling windows per level — stores True/False for solved/failed | |
| self._windows: Dict[int, deque] = { | |
| 1: deque(maxlen=window_size), | |
| 2: deque(maxlen=window_size), | |
| 3: deque(maxlen=window_size), | |
| } | |
| self._unlocked: Dict[int, bool] = { | |
| 1: True, | |
| 2: False, | |
| 3: False, | |
| } | |
| self._total_episodes: Dict[int, int] = {1: 0, 2: 0, 3: 0} | |
| def record_episode(self, level: int, solved: bool) -> None: | |
| """Record the outcome of an episode for curriculum tracking. | |
| Args: | |
| level: The difficulty level of the episode. | |
| solved: Whether the scenario was solved. | |
| """ | |
| if level not in self._windows: | |
| return | |
| self._windows[level].append(solved) | |
| self._total_episodes[level] += 1 | |
| self._check_unlocks() | |
| def get_active_levels(self) -> List[int]: | |
| """Get currently unlocked difficulty levels. | |
| Returns: | |
| List of unlocked level numbers (e.g., [1] or [1, 2]). | |
| """ | |
| return sorted(lvl for lvl, unlocked in self._unlocked.items() if unlocked) | |
| def get_window_solve_rate(self, level: int) -> float: | |
| """Get the solve rate over the rolling window for a level. | |
| Args: | |
| level: The level to query. | |
| Returns: | |
| Solve rate (0.0-1.0) over the recent window, or 0.0 if no data. | |
| """ | |
| window = self._windows.get(level, deque()) | |
| if not window: | |
| return 0.0 | |
| return sum(window) / len(window) | |
| def _check_unlocks(self) -> None: | |
| """Check and perform level unlocks based on rolling window stats.""" | |
| # Level 2: unlocks when Level 1 window solve rate >= threshold | |
| if not self._unlocked[2]: | |
| window = self._windows[1] | |
| if len(window) >= self.window_size: | |
| rate = sum(window) / len(window) | |
| if rate >= self.unlock_threshold: | |
| self._unlocked[2] = True | |
| print(f"[Curriculum] ⬆ Level 2 UNLOCKED! " | |
| f"(L1 window solve rate: {rate:.1%} over {len(window)} episodes)") | |
| # Level 3: unlocks when Level 2 window solve rate >= threshold | |
| if self._unlocked[2] and not self._unlocked[3]: | |
| window = self._windows[2] | |
| if len(window) >= self.window_size: | |
| rate = sum(window) / len(window) | |
| if rate >= self.unlock_threshold: | |
| self._unlocked[3] = True | |
| print(f"[Curriculum] ⬆ Level 3 UNLOCKED! " | |
| f"(L2 window solve rate: {rate:.1%} over {len(window)} episodes)") | |
| def sample_level(self) -> int: | |
| """Sample a level from currently active levels. | |
| Weighted toward the hardest unlocked level (exactly 50% newest, | |
| remaining 50% split across other unlocked levels) to keep the | |
| agent challenged at the frontier. | |
| Returns: | |
| A level number to train on. | |
| """ | |
| active = self.get_active_levels() | |
| if len(active) == 1: | |
| return active[0] | |
| # 50% chance for the hardest unlocked level | |
| newest = max(active) | |
| if random.random() < 0.5: | |
| return newest | |
| others = [lvl for lvl in active if lvl != newest] | |
| return random.choice(others) if others else newest | |
| def update_stats(self, level: int, solve_rate: float, episodes: int = 0) -> None: | |
| """Bulk-update stats from replay buffer (for API/UI compatibility). | |
| This is a convenience method that doesn't use rolling windows — | |
| prefer record_episode() for training accuracy. | |
| Args: | |
| level: The level to update. | |
| solve_rate: Current solve rate for this level. | |
| episodes: Total episodes at this level. | |
| """ | |
| if level not in self._total_episodes: | |
| return | |
| if episodes > 0: | |
| self._total_episodes[level] = episodes | |
| def get_status(self) -> Dict: | |
| """Get the current curriculum status. | |
| Returns: | |
| Dict with level stats, unlock status, and window solve rates. | |
| """ | |
| return { | |
| level: { | |
| "unlocked": self._unlocked[level], | |
| "window_solve_rate": round(self.get_window_solve_rate(level), 3), | |
| "window_size": len(self._windows[level]), | |
| "total_episodes": self._total_episodes[level], | |
| } | |
| for level in [1, 2, 3] | |
| } | |
| def force_unlock(self, level: int) -> None: | |
| """Force-unlock a level (for debugging/testing). | |
| Args: | |
| level: Level to unlock. | |
| """ | |
| if level in self._unlocked: | |
| self._unlocked[level] = True | |