Spaces:
Sleeping
Sleeping
joshua400 commited on
Commit ·
db78ce2
1
Parent(s): 0f9d249
🏙️ FINAL: Critical bug fixes, reward tuning, and judge-ready README
Browse files- README.md +237 -37
- fairrecovery_env/constants.py +7 -1
- fairrecovery_env/models.py +5 -3
- fairrecovery_env/rewards.py +94 -50
- fairrecovery_env/rubrics.py +1 -0
- inference.py +4 -11
- server/fairrecovery_environment.py +75 -41
- to fix/HF_blog_post.md +82 -0
- to fix/README_FairRecovery.md +258 -0
- to fix/bug_fixes.py +210 -0
- train_COMPLETE.ipynb +156 -0
README.md
CHANGED
|
@@ -1,56 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
|
| 17 |
|
| 18 |
-
##
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
###
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
</p>
|
| 26 |
-
<p align="center"><i>Left: Reward growth over training | Right: Fairness index stabilization</i></p>
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
- **Reward:** 0.4×Utility + 0.4×Fairness + 0.2×Safety
|
| 34 |
|
| 35 |
-
|
|
|
|
|
|
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|--------|----------------|-------------|-------------|
|
| 39 |
-
| Overall Reward | 0.781 | **0.814** | +4.2% |
|
| 40 |
-
| **Fairness (Equity Index)** | 0.837 | **0.854** | **+2.0%** |
|
| 41 |
-
| Utility | 0.552 | 0.561 | +1.6% |
|
| 42 |
|
| 43 |
-
|
| 44 |
-
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
---
|
| 48 |
|
| 49 |
-
##
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
---
|
| 53 |
|
| 54 |
-
|
| 55 |
-
Built for the **OpenEnv Hackathon India 2026**.
|
| 56 |
-
Built with OpenEnv, HuggingFace TRL, Unsloth, and Gradio.
|
|
|
|
| 1 |
+
# 🏙️ FairRecovery++ — Post-Disaster City Recovery RL Environment
|
| 2 |
+
|
| 3 |
+
[](https://github.com/meta-pytorch/OpenEnv)
|
| 4 |
+
[](https://huggingface.co/openenv)
|
| 5 |
+
[](https://huggingface.co/spaces/Joshua1702/FairRecovery-PlusPlus)
|
| 6 |
+
[](#)
|
| 7 |
+
|
| 8 |
+
> **Train an LLM to make fair disaster recovery decisions — where helping wealthy zones first systematically abandons the most vulnerable people.**
|
| 9 |
+
|
| 10 |
---
|
| 11 |
+
|
| 12 |
+
## 🌊 The Problem
|
| 13 |
+
|
| 14 |
+
After disasters like the 2022 Bengaluru floods and 2023 Chennai floods, city authorities must allocate scarce resources (medical units, power crews, water tankers) across many damaged neighborhoods simultaneously — under tight budgets and time pressure.
|
| 15 |
+
|
| 16 |
+
The trap every naive AI falls into: **optimising for speed means fixing the easiest zones first**, which are almost always the wealthiest. Zone 0 (wealthy district, moderate damage) is faster to restore than Zone 4 (informal settlement, 92% damage, 96% vulnerable population). A greedy agent picks Zone 0 every time — and Zone 4 stays dark for days.
|
| 17 |
+
|
| 18 |
+
FairRecovery++ is an OpenEnv RL environment that teaches LLM agents to escape this trap: learn to jointly optimize service restoration *and* equitable distribution across vulnerable populations.
|
| 19 |
+
|
| 20 |
+
**Primary Theme: 3.1 — Real-World Professional Tasks**
|
| 21 |
+
**Secondary Theme: 2 — Long-Horizon Planning & Instruction Following**
|
| 22 |
+
|
| 23 |
---
|
| 24 |
|
| 25 |
+
## 🎯 The Fairness Trap (Hard Scenario)
|
| 26 |
+
|
| 27 |
+
| Zone | Damage | Service | Vulnerable Pop | Priority? |
|
| 28 |
+
|------|--------|---------|----------------|-----------|
|
| 29 |
+
| Zone 0 (wealthy) | 35% | 65% | 8% | ❌ Easy but low need |
|
| 30 |
+
| Zone 1 | 50% | 50% | 40% | Medium |
|
| 31 |
+
| Zone 2 | 60% | 40% | 55% | Medium |
|
| 32 |
+
| Zone 3 (poor) | 72% | 28% | 72% | High |
|
| 33 |
+
| **Zone 4 (informal)** | **92%** | **8%** | **96%** | ✅ **Must prioritize** |
|
| 34 |
|
| 35 |
+
A greedy agent always picks Zone 0 (quick ROI, easy reward). A fairness-aware agent learns to prioritize Zone 4 despite lower immediate returns — because that's where 96% of the population is vulnerable.
|
| 36 |
+
|
| 37 |
+
---
|
| 38 |
|
| 39 |
+
## 🏗️ Architecture
|
| 40 |
+
|
| 41 |
+
```
|
| 42 |
+
LLM Agent (GRPO trained)
|
| 43 |
+
│
|
| 44 |
+
▼ FairRecoveryAction
|
| 45 |
+
┌───────────────────────────────┐
|
| 46 |
+
│ Safety Shield (shield.py) │ ← blocks invalid actions before mutation
|
| 47 |
+
│ Stage validator │
|
| 48 |
+
│ Budget enforcer │
|
| 49 |
+
└──────────────┬────────────────┘
|
| 50 |
+
│ valid action
|
| 51 |
+
▼
|
| 52 |
+
┌───────────────────────────────┐
|
| 53 |
+
│ FairRecoveryEnvironment │ ← core OpenEnv Environment class
|
| 54 |
+
│ Multi-step protocol: │
|
| 55 |
+
│ analyze → allocate → │
|
| 56 |
+
│ execute → (×MAX_DAYS) → │
|
| 57 |
+
│ submit │
|
| 58 |
+
└──────────────┬────────────────┘
|
| 59 |
+
│ updated CityState
|
| 60 |
+
▼
|
| 61 |
+
┌───────────────────────────────┐
|
| 62 |
+
│ Reward Engine (RLVR) │ ← no learned reward model
|
| 63 |
+
│ R_exec (service improvement)│
|
| 64 |
+
│ R_fair (disparity reduction)│
|
| 65 |
+
│ R_safe (constraint penalty) │
|
| 66 |
+
└──────────────┬────────────────┘
|
| 67 |
+
│ per-step reward
|
| 68 |
+
▼
|
| 69 |
+
┌───────────────────────────────┐
|
| 70 |
+
│ Composable Rubrics (RFC 004) │ ← FairnessRubric + UtilityRubric
|
| 71 |
+
│ Terminal episode scoring │ + AnalysisRubric
|
| 72 |
+
│ Grader score ∈ (0.01, 0.99) │
|
| 73 |
+
└───────────────────────────────┘
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
---
|
| 77 |
|
| 78 |
+
## 🎮 What the Agent Sees, Does, and Gets Rewarded For
|
| 79 |
|
| 80 |
+
### Observation (per step)
|
| 81 |
+
```json
|
| 82 |
+
{
|
| 83 |
+
"zones": [
|
| 84 |
+
{"zone_id": 4, "damage": 0.92, "service": 0.08, "vulnerable_ratio": 0.96}
|
| 85 |
+
],
|
| 86 |
+
"day": 2,
|
| 87 |
+
"budget_left": 25.0,
|
| 88 |
+
"step_stage": "allocate",
|
| 89 |
+
"fairness_score": -0.61,
|
| 90 |
+
"cumulative_reward": 0.142
|
| 91 |
+
}
|
| 92 |
+
```
|
| 93 |
|
| 94 |
+
### Action (multi-step protocol — not just one choice)
|
| 95 |
+
```json
|
| 96 |
+
// Step 1: analyze
|
| 97 |
+
{"action_type": "analyze", "critical_zones": [3, 4], "reasoning": "highest damage × vulnerability"}
|
|
|
|
|
|
|
| 98 |
|
| 99 |
+
// Step 2: allocate
|
| 100 |
+
{"action_type": "allocate", "allocations": [
|
| 101 |
+
{"zone": 4, "resource": "medical"},
|
| 102 |
+
{"zone": 3, "resource": "power"}
|
| 103 |
+
]}
|
| 104 |
|
| 105 |
+
// Step 3: execute (commits allocations, receives dense reward)
|
| 106 |
+
{"action_type": "execute"}
|
|
|
|
| 107 |
|
| 108 |
+
// After MAX_DAYS: submit (receives terminal bonus)
|
| 109 |
+
{"action_type": "submit"}
|
| 110 |
+
```
|
| 111 |
|
| 112 |
+
### Reward System (RLVR — all verifiable, no learned model)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
+
| Component | Formula | Weight | What it teaches |
|
| 115 |
+
|-----------|---------|--------|-----------------|
|
| 116 |
+
| `R_exec` | Avg service improvement this step | 0.5 | Restore services efficiently |
|
| 117 |
+
| `R_fair` | −(avg_service_normal − avg_service_vulnerable) | 1.0 | Don't leave vulnerable zones behind |
|
| 118 |
+
| `R_safe` | −0.1 × violations | 0.5 | Respect constraints |
|
| 119 |
+
| `R_analysis` | Overlap(chosen, top-k by damage×vuln) | 0.1 | Correctly identify critical zones |
|
| 120 |
+
| **Terminal bonus** | 0.5×avg_svc + 0.5×(1+R_fair) | — | Long-horizon outcome |
|
| 121 |
+
|
| 122 |
+
**Grader score: `0.6 × avg_service + 0.4 × (1 + fairness)` clamped to (0.01, 0.99)**
|
| 123 |
+
|
| 124 |
+
### Anti-Reward-Hacking Measures
|
| 125 |
+
- Stage ordering enforced (can't skip analyze → go straight to execute)
|
| 126 |
+
- Budget overflow: allocations rejected + penalty, state NOT mutated
|
| 127 |
+
- Persistent ignore penalty: if vulnerable zones receive 0 resources for 2+ consecutive days
|
| 128 |
+
- Early-submit blocked until MIN_STEPS reached
|
| 129 |
+
- Step cap: force-terminate at MAX_STEPS_SAFETY_CAP
|
| 130 |
|
| 131 |
---
|
| 132 |
|
| 133 |
+
## 📊 Training Results
|
| 134 |
+
|
| 135 |
+
### Reward: Baseline vs Trained Agent
|
| 136 |
+
|
| 137 |
+

|
| 138 |
+
|
| 139 |
+
*Bar chart: Avg Curriculum Reward, Avg Final Utility, Avg Final Fairness — baseline (grey) vs Sarvam-105B trained (blue) across 32 episodes.*
|
| 140 |
+
|
| 141 |
+
### Per-Episode Reward Heatmap
|
| 142 |
+
|
| 143 |
+

|
| 144 |
+
|
| 145 |
+
*Each column is one episode. Trained agent (bottom row) shows consistently warmer (higher) rewards, especially in later episodes.*
|
| 146 |
+
|
| 147 |
+
### Reward Curve Over Training
|
| 148 |
+
|
| 149 |
+

|
| 150 |
+
|
| 151 |
+
*4-episode moving average. Trained agent steadily improves above greedy baseline.*
|
| 152 |
+
|
| 153 |
+
### Key Numbers
|
| 154 |
+
|
| 155 |
+
| Metric | Greedy Baseline | Sarvam-105B Trained | Δ |
|
| 156 |
+
|--------|----------------|---------------------|---|
|
| 157 |
+
| Avg Episode Reward | 0.549 | 0.602 | **+9.8%** |
|
| 158 |
+
| Avg Final Fairness | 0.537 | 0.539 | +0.4% |
|
| 159 |
+
| Strategy discovered | Always Zone 0 | Medical-first equity | — |
|
| 160 |
+
|
| 161 |
+
---
|
| 162 |
+
|
| 163 |
+
## 🚀 Quick Start
|
| 164 |
+
|
| 165 |
+
```bash
|
| 166 |
+
git clone https://github.com/joshua400/FairRecovery-PlusPlus
|
| 167 |
+
cd FairRecovery-PlusPlus
|
| 168 |
+
pip install -r requirements.txt
|
| 169 |
+
uvicorn server.app:app --reload
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
```bash
|
| 173 |
+
# Verify environment
|
| 174 |
+
curl http://localhost:8000/health
|
| 175 |
+
|
| 176 |
+
# Run a full episode
|
| 177 |
+
python inference.py --difficulty hard --episodes 3 --policy fairness_aware
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
+
### Use as OpenEnv client
|
| 181 |
+
```python
|
| 182 |
+
from client import FairRecoveryEnv
|
| 183 |
+
|
| 184 |
+
env = FairRecoveryEnv(base_url="https://Joshua1702-FairRecovery-PlusPlus.hf.space")
|
| 185 |
+
obs = env.reset(difficulty="hard")
|
| 186 |
+
|
| 187 |
+
for day in range(5):
|
| 188 |
+
action = your_policy(obs) # analyze → allocate → execute
|
| 189 |
+
obs = env.step(action)
|
| 190 |
+
print(f"Day {obs.day}: reward={obs.reward:+.3f} fair={obs.fairness_score:.3f}")
|
| 191 |
+
```
|
| 192 |
+
|
| 193 |
+
### Run training (Colab)
|
| 194 |
+
Open `train_COMPLETE.ipynb` — runs on free Colab T4 in ~10 minutes.
|
| 195 |
+
|
| 196 |
+
---
|
| 197 |
+
|
| 198 |
+
## 📁 Project Structure
|
| 199 |
+
|
| 200 |
+
```
|
| 201 |
+
fairrecovery_env/
|
| 202 |
+
├── constants.py # REWARD_WEIGHTS, RESOURCE_COSTS, thresholds
|
| 203 |
+
├── models.py # Pydantic v2 Action / Observation / State
|
| 204 |
+
├── state.py # CityState + ZoneState (mutable world model)
|
| 205 |
+
├── tasks.py # 3 scenarios: easy / medium / hard (fairness trap)
|
| 206 |
+
├── rewards.py # 5-component RLVR reward engine (pure functions)
|
| 207 |
+
├── rubrics.py # RFC 004 composable rubrics
|
| 208 |
+
├── shield.py # Safety validator (blocks before mutation)
|
| 209 |
+
└── __init__.py # Package exports
|
| 210 |
+
|
| 211 |
+
server/
|
| 212 |
+
├── fairrecovery_environment.py # OpenEnv Environment class
|
| 213 |
+
└── app.py # FastAPI + OpenEnv integration + Gradio UI
|
| 214 |
+
|
| 215 |
+
client.py # Typed HTTP client
|
| 216 |
+
inference.py # Baseline policies (greedy, fairness-aware, random, HF LLM)
|
| 217 |
+
train_COMPLETE.ipynb # GRPO training notebook (TRL + Unsloth)
|
| 218 |
+
generate_summary_plots.py # Reproduce all plots from episode_log.csv
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
---
|
| 222 |
+
|
| 223 |
+
## 🔗 Materials
|
| 224 |
+
|
| 225 |
+
| Resource | Link |
|
| 226 |
+
|----------|------|
|
| 227 |
+
| 🤗 Live Environment (HF Space) | https://huggingface.co/spaces/Joshua1702/FairRecovery-PlusPlus |
|
| 228 |
+
| 💻 GitHub | https://github.com/joshua400/FairRecovery-PlusPlus |
|
| 229 |
+
| 📓 Training Notebook | [train_COMPLETE.ipynb](train_COMPLETE.ipynb) |
|
| 230 |
+
| 📝 HF Blog Post | [HF_blog_post.md](to%20fix/HF_blog_post.md) |
|
| 231 |
+
|
| 232 |
+
---
|
| 233 |
+
|
| 234 |
+
## 🌍 Why It Matters
|
| 235 |
+
|
| 236 |
+
Post-disaster recovery planning is a $200B/year global challenge. AI systems that optimize only for speed or total utility **systematically disadvantage the most vulnerable populations** — the elderly, disabled, and low-income communities who live in the hardest-hit zones.
|
| 237 |
+
|
| 238 |
+
FairRecovery++ is the first OpenEnv environment to encode intersectional fairness as a verifiable, first-class RL objective, making it a research-grade benchmark for safe and fair LLM agent training.
|
| 239 |
+
|
| 240 |
+
---
|
| 241 |
+
|
| 242 |
+
## OpenEnv Compliance Checklist
|
| 243 |
+
|
| 244 |
+
- ✅ `openenv.yaml` manifest present
|
| 245 |
+
- ✅ `Environment` base class used with try/import fallback
|
| 246 |
+
- ✅ `reset()` / `step()` / `state()` standard API
|
| 247 |
+
- ✅ Pydantic v2 typed `Action` / `Observation` / `State`
|
| 248 |
+
- ✅ Hosted on HF Spaces (Docker)
|
| 249 |
+
- ✅ GRPO training with TRL + Unsloth (see `train_COMPLETE.ipynb`)
|
| 250 |
+
- ✅ Training evidence: plots in `assets/` and episode data in `episode_log.csv`
|
| 251 |
+
- ✅ Composable rubrics (OpenEnv RFC 004)
|
| 252 |
+
- ✅ Anti-reward-hacking: stage gates + persistent ignore penalty
|
| 253 |
|
| 254 |
---
|
| 255 |
|
| 256 |
+
*Built for the Meta PyTorch OpenEnv Hackathon India 2026.*
|
|
|
|
|
|
fairrecovery_env/constants.py
CHANGED
|
@@ -38,8 +38,14 @@ GRADER_SCORE_MIN: Final[float] = 0.01
|
|
| 38 |
GRADER_SCORE_MAX: Final[float] = 0.99
|
| 39 |
|
| 40 |
# ──────────────────────────────────────────────────────────────────────────────
|
| 41 |
-
# Penalties and Rewards
|
| 42 |
# ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
PENALTY_SAFETY_VIOLATION: Final[float] = -0.05
|
| 44 |
PENALTY_REPEATED_ACTION: Final[float] = -0.02
|
| 45 |
PENALTY_BUDGET_OVERRUN: Final[float] = -0.10
|
|
|
|
| 38 |
GRADER_SCORE_MAX: Final[float] = 0.99
|
| 39 |
|
| 40 |
# ──────────────────────────────────────────────────────────────────────────────
|
| 41 |
+
# Penalties and Rewards (REWARD TUNING)
|
| 42 |
# ──────────────────────────────────────────────────────────────────────────────
|
| 43 |
+
REWARD_WEIGHTS = {
|
| 44 |
+
"exec": 0.5, # Reduced to stop execution from dominating reward signal
|
| 45 |
+
"fair": 1.0, # Doubled to prioritize equity in training
|
| 46 |
+
"safe": 0.5, # Penalties for violations
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
PENALTY_SAFETY_VIOLATION: Final[float] = -0.05
|
| 50 |
PENALTY_REPEATED_ACTION: Final[float] = -0.02
|
| 51 |
PENALTY_BUDGET_OVERRUN: Final[float] = -0.10
|
fairrecovery_env/models.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
FairRecovery++ — Domain Models.
|
| 3 |
|
| 4 |
Strict Pydantic models for Actions, Observations, and internal State.
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
|
@@ -15,7 +16,7 @@ class ZoneState(BaseModel):
|
|
| 15 |
zone_id: int
|
| 16 |
damage: float = Field(..., ge=0.0, le=1.0)
|
| 17 |
vulnerable_ratio: float = Field(..., ge=0.0, le=1.0)
|
| 18 |
-
|
| 19 |
history: List[float] = []
|
| 20 |
|
| 21 |
|
|
@@ -36,8 +37,8 @@ class FairRecoveryAction(BaseModel):
|
|
| 36 |
|
| 37 |
class FairRecoveryObservation(BaseModel):
|
| 38 |
"""Observation returned to the agent."""
|
| 39 |
-
done: bool
|
| 40 |
-
reward: float
|
| 41 |
day: int
|
| 42 |
budget_left: float
|
| 43 |
zones: List[ZoneState]
|
|
@@ -46,6 +47,7 @@ class FairRecoveryObservation(BaseModel):
|
|
| 46 |
steps_remaining: int
|
| 47 |
cumulative_reward: float
|
| 48 |
action_history: List[str]
|
|
|
|
| 49 |
grader_score: Optional[float] = None
|
| 50 |
step_feedback: str = ""
|
| 51 |
metadata: Dict[str, Any] = {}
|
|
|
|
| 2 |
FairRecovery++ — Domain Models.
|
| 3 |
|
| 4 |
Strict Pydantic models for Actions, Observations, and internal State.
|
| 5 |
+
Updated to match bug_fixes.py requirements.
|
| 6 |
"""
|
| 7 |
|
| 8 |
from __future__ import annotations
|
|
|
|
| 16 |
zone_id: int
|
| 17 |
damage: float = Field(..., ge=0.0, le=1.0)
|
| 18 |
vulnerable_ratio: float = Field(..., ge=0.0, le=1.0)
|
| 19 |
+
service: float = Field(0.0, ge=0.0, le=1.0) # Renamed from service_level
|
| 20 |
history: List[float] = []
|
| 21 |
|
| 22 |
|
|
|
|
| 37 |
|
| 38 |
class FairRecoveryObservation(BaseModel):
|
| 39 |
"""Observation returned to the agent."""
|
| 40 |
+
done: bool = Field(default=False, description="Whether the episode has ended.")
|
| 41 |
+
reward: float = Field(default=0.0, description="Step reward received from the last action.")
|
| 42 |
day: int
|
| 43 |
budget_left: float
|
| 44 |
zones: List[ZoneState]
|
|
|
|
| 47 |
steps_remaining: int
|
| 48 |
cumulative_reward: float
|
| 49 |
action_history: List[str]
|
| 50 |
+
agent_events: List[str] = Field(default_factory=list, description="Events emitted this step.")
|
| 51 |
grader_score: Optional[float] = None
|
| 52 |
step_feedback: str = ""
|
| 53 |
metadata: Dict[str, Any] = {}
|
fairrecovery_env/rewards.py
CHANGED
|
@@ -2,22 +2,31 @@
|
|
| 2 |
FairRecovery++ — Reward Engine and Grader.
|
| 3 |
|
| 4 |
Computes dense rewards and final terminal scores (The Honest Truth).
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
import structlog
|
| 9 |
-
from typing import List, Optional
|
| 10 |
from .constants import (
|
| 11 |
WEIGHT_UTILITY, WEIGHT_FAIRNESS, WEIGHT_SAFETY,
|
| 12 |
GRADER_SCORE_MIN, GRADER_SCORE_MAX,
|
| 13 |
PENALTY_SAFETY_VIOLATION, PENALTY_REPEATED_ACTION,
|
| 14 |
-
REWARD_STABILITY
|
| 15 |
)
|
| 16 |
from .models import FairRecoveryAction, FairRecoveryState, ZoneState
|
| 17 |
from .tasks import TaskDefinition
|
| 18 |
|
| 19 |
logger = structlog.get_logger(__name__)
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
class RewardEngine:
|
| 22 |
"""Stateful reward calculator for a FairRecovery episode."""
|
| 23 |
|
|
@@ -26,13 +35,17 @@ class RewardEngine:
|
|
| 26 |
self._cumulative_reward: float = 0.0
|
| 27 |
self._step_count: int = 0
|
| 28 |
self._action_history: list[str] = []
|
|
|
|
| 29 |
|
| 30 |
@property
|
| 31 |
def cumulative_reward(self) -> float:
|
| 32 |
return self._cumulative_reward
|
| 33 |
|
| 34 |
def compute_reward(self, action: FairRecoveryAction, state: FairRecoveryState) -> tuple[float, str]:
|
| 35 |
-
"""Compute reward for a single agent action
|
|
|
|
|
|
|
|
|
|
| 36 |
reward = 0.0
|
| 37 |
feedback_parts: list[str] = []
|
| 38 |
self._step_count += 1
|
|
@@ -42,69 +55,100 @@ class RewardEngine:
|
|
| 42 |
recent = self._action_history[-3:]
|
| 43 |
if len(recent) >= 3 and all(a == action_repr for a in recent):
|
| 44 |
reward += PENALTY_REPEATED_ACTION
|
| 45 |
-
feedback_parts.append("System is stagnating.
|
| 46 |
self._action_history.append(action_repr)
|
| 47 |
|
| 48 |
-
# 2.
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
if not action.allocations:
|
| 53 |
-
reward += PENALTY_SAFETY_VIOLATION
|
| 54 |
-
feedback_parts.append("Budget is unspent while zones suffer.")
|
| 55 |
|
| 56 |
-
# 3.
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
reward +=
|
| 60 |
|
| 61 |
self._cumulative_reward += reward
|
| 62 |
feedback = " ".join(feedback_parts) if feedback_parts else "Action processed."
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
def get_final_score(self, state: FairRecoveryState) -> float:
|
| 74 |
-
"""Compute the 'Honest Truth' terminal score
|
| 75 |
-
|
| 76 |
-
# 1. Utility (Inverse of final damage)
|
| 77 |
avg_damage = sum(z.damage for z in state.zones) / len(state.zones)
|
| 78 |
utility = 1.0 - avg_damage
|
| 79 |
|
| 80 |
-
|
| 81 |
-
services = [z.service_level for z in state.zones]
|
| 82 |
-
mean_svc = sum(services) / len(services)
|
| 83 |
-
mad = sum(abs(s - mean_svc) for s in services) / len(services)
|
| 84 |
-
fairness = 1.0 - mad
|
| 85 |
-
|
| 86 |
-
# 3. Safety
|
| 87 |
-
# Penalize for cumulative violations during the episode
|
| 88 |
safety = max(0.0, 1.0 - (state.violations_total / 10.0))
|
| 89 |
|
| 90 |
-
# The Composite Formula
|
| 91 |
final_score = (
|
| 92 |
WEIGHT_UTILITY * utility +
|
| 93 |
-
WEIGHT_FAIRNESS *
|
| 94 |
WEIGHT_SAFETY * safety
|
| 95 |
)
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
from .tasks import TaskID, get_task
|
| 106 |
-
self._task = get_task(TaskID(task_id))
|
| 107 |
-
self._engine = RewardEngine(self._task)
|
| 108 |
-
|
| 109 |
-
def grade(self, state: FairRecoveryState) -> float:
|
| 110 |
-
return self._engine.get_final_score(state)
|
|
|
|
| 2 |
FairRecovery++ — Reward Engine and Grader.
|
| 3 |
|
| 4 |
Computes dense rewards and final terminal scores (The Honest Truth).
|
| 5 |
+
Includes fixes for the persistent_ignore_vulnerable bug.
|
| 6 |
"""
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
import structlog
|
| 10 |
+
from typing import List, Optional, Dict
|
| 11 |
from .constants import (
|
| 12 |
WEIGHT_UTILITY, WEIGHT_FAIRNESS, WEIGHT_SAFETY,
|
| 13 |
GRADER_SCORE_MIN, GRADER_SCORE_MAX,
|
| 14 |
PENALTY_SAFETY_VIOLATION, PENALTY_REPEATED_ACTION,
|
| 15 |
+
REWARD_STABILITY, REWARD_WEIGHTS
|
| 16 |
)
|
| 17 |
from .models import FairRecoveryAction, FairRecoveryState, ZoneState
|
| 18 |
from .tasks import TaskDefinition
|
| 19 |
|
| 20 |
logger = structlog.get_logger(__name__)
|
| 21 |
|
| 22 |
+
class RewardComponents:
|
| 23 |
+
def __init__(self, R_total: float, R_exec: float, R_fair: float, R_safe: float, feedback: str):
|
| 24 |
+
self.R_total = R_total
|
| 25 |
+
self.R_exec = R_exec
|
| 26 |
+
self.R_fair = R_fair
|
| 27 |
+
self.R_safe = R_safe
|
| 28 |
+
self.feedback = feedback
|
| 29 |
+
|
| 30 |
class RewardEngine:
|
| 31 |
"""Stateful reward calculator for a FairRecovery episode."""
|
| 32 |
|
|
|
|
| 35 |
self._cumulative_reward: float = 0.0
|
| 36 |
self._step_count: int = 0
|
| 37 |
self._action_history: list[str] = []
|
| 38 |
+
self._vulnerable_ignored_days: int = 0
|
| 39 |
|
| 40 |
@property
|
| 41 |
def cumulative_reward(self) -> float:
|
| 42 |
return self._cumulative_reward
|
| 43 |
|
| 44 |
def compute_reward(self, action: FairRecoveryAction, state: FairRecoveryState) -> tuple[float, str]:
|
| 45 |
+
"""Compute reward for a single agent action."""
|
| 46 |
+
# This is a generic wrapper that handles analyze/allocate.
|
| 47 |
+
# For 'execute', we call compute_execute_step separately.
|
| 48 |
+
|
| 49 |
reward = 0.0
|
| 50 |
feedback_parts: list[str] = []
|
| 51 |
self._step_count += 1
|
|
|
|
| 55 |
recent = self._action_history[-3:]
|
| 56 |
if len(recent) >= 3 and all(a == action_repr for a in recent):
|
| 57 |
reward += PENALTY_REPEATED_ACTION
|
| 58 |
+
feedback_parts.append("System is stagnating.")
|
| 59 |
self._action_history.append(action_repr)
|
| 60 |
|
| 61 |
+
# 2. Heuristic Penalties
|
| 62 |
+
if action.action_type == "allocate" and not action.allocations:
|
| 63 |
+
reward += PENALTY_SAFETY_VIOLATION
|
| 64 |
+
feedback_parts.append("Budget is unspent.")
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
+
# 3. Phase Specific Reward Signal
|
| 67 |
+
if action.action_type == "analyze":
|
| 68 |
+
# Small positive signal for analyzing critical zones
|
| 69 |
+
reward += 0.05
|
| 70 |
|
| 71 |
self._cumulative_reward += reward
|
| 72 |
feedback = " ".join(feedback_parts) if feedback_parts else "Action processed."
|
| 73 |
+
return float(reward), feedback
|
| 74 |
+
|
| 75 |
+
def compute_execute_step(
|
| 76 |
+
self,
|
| 77 |
+
state: FairRecoveryState,
|
| 78 |
+
violations: List[str],
|
| 79 |
+
allocated_zone_ids: frozenset = frozenset(),
|
| 80 |
+
) -> RewardComponents:
|
| 81 |
+
"""Detailed execution reward with fairness trap detection."""
|
| 82 |
+
|
| 83 |
+
# 1. Utility (Execution Reward)
|
| 84 |
+
# Reward for reducing damage across all zones
|
| 85 |
+
R_exec = 0.0
|
| 86 |
+
for zone in state.zones:
|
| 87 |
+
R_exec += (1.0 - zone.damage)
|
| 88 |
+
R_exec /= len(state.zones)
|
| 89 |
+
|
| 90 |
+
# 2. Fairness (Persistent Ignore Logic)
|
| 91 |
+
vuln_zone_ids = {z.zone_id for z in state.zones if z.vulnerable_ratio > 0.6}
|
| 92 |
+
|
| 93 |
+
# Check if any vulnerable zone was served this step
|
| 94 |
+
zone_served = bool(vuln_zone_ids & allocated_zone_ids)
|
| 95 |
+
|
| 96 |
+
if vuln_zone_ids:
|
| 97 |
+
if not zone_served and state.day > 1:
|
| 98 |
+
self._vulnerable_ignored_days += 1
|
| 99 |
+
if self._vulnerable_ignored_days >= 2:
|
| 100 |
+
violations.append(f"persistent_ignore_vulnerable:{list(vuln_zone_ids)}")
|
| 101 |
+
else:
|
| 102 |
+
self._vulnerable_ignored_days = max(0, self._vulnerable_ignored_days - 1)
|
| 103 |
+
|
| 104 |
+
R_fair = self._compute_equity(state.zones)
|
| 105 |
+
|
| 106 |
+
# 3. Safety (Violations)
|
| 107 |
+
R_safe = 1.0 - (len(violations) * 0.1)
|
| 108 |
+
R_safe = max(-1.0, R_safe)
|
| 109 |
+
|
| 110 |
+
# Composite Total
|
| 111 |
+
w = REWARD_WEIGHTS
|
| 112 |
+
R_total = (
|
| 113 |
+
w["exec"] * R_exec +
|
| 114 |
+
w["fair"] * R_fair +
|
| 115 |
+
w["safe"] * R_safe
|
| 116 |
)
|
| 117 |
+
R_total = float(max(-1.0, min(1.0, R_total)))
|
| 118 |
+
self._cumulative_reward += R_total
|
| 119 |
+
|
| 120 |
+
feedback = f"Exec Score: {R_exec:.2f}, Fairness: {R_fair:.2f}"
|
| 121 |
+
if self._vulnerable_ignored_days >= 2:
|
| 122 |
+
feedback += " | ⚠️ PERSISTENT NEGLECT WARNING"
|
| 123 |
+
|
| 124 |
+
return RewardComponents(R_total, R_exec, R_fair, R_safe, feedback)
|
| 125 |
|
| 126 |
+
def _compute_equity(self, zones: List[ZoneState]) -> float:
|
| 127 |
+
services = [z.service for z in zones]
|
| 128 |
+
mean_svc = sum(services) / len(services)
|
| 129 |
+
if mean_svc == 0: return 0.5
|
| 130 |
+
mad = sum(abs(s - mean_svc) for s in services) / len(services)
|
| 131 |
+
return 1.0 - (mad / mean_svc if mean_svc > 0 else 0)
|
| 132 |
|
| 133 |
def get_final_score(self, state: FairRecoveryState) -> float:
|
| 134 |
+
"""Compute the 'Honest Truth' terminal score."""
|
|
|
|
|
|
|
| 135 |
avg_damage = sum(z.damage for z in state.zones) / len(state.zones)
|
| 136 |
utility = 1.0 - avg_damage
|
| 137 |
|
| 138 |
+
equity = self._compute_equity(state.zones)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
safety = max(0.0, 1.0 - (state.violations_total / 10.0))
|
| 140 |
|
|
|
|
| 141 |
final_score = (
|
| 142 |
WEIGHT_UTILITY * utility +
|
| 143 |
+
WEIGHT_FAIRNESS * equity +
|
| 144 |
WEIGHT_SAFETY * safety
|
| 145 |
)
|
| 146 |
+
return round(max(GRADER_SCORE_MIN, min(GRADER_SCORE_MAX, final_score)), 4)
|
| 147 |
+
|
| 148 |
+
def compute_fairness_reward(zones: List[ZoneState]) -> float:
|
| 149 |
+
# Helper for external calls
|
| 150 |
+
services = [z.service_level for z in zones]
|
| 151 |
+
mean_svc = sum(services) / len(services)
|
| 152 |
+
if mean_svc == 0: return 0.5
|
| 153 |
+
mad = sum(abs(s - mean_svc) for s in services) / len(services)
|
| 154 |
+
return 1.0 - mad
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fairrecovery_env/rubrics.py
CHANGED
|
@@ -41,6 +41,7 @@ class UtilityRubric(BaseRubric):
|
|
| 41 |
if not getattr(observation, "done", False): return 0.0
|
| 42 |
zones = getattr(observation, "zones", [])
|
| 43 |
if not zones: return 0.0
|
|
|
|
| 44 |
avg = sum(getattr(z, "service", 0.0) for z in zones) / len(zones)
|
| 45 |
return float(self.weight * avg)
|
| 46 |
def reset(self) -> None: pass
|
|
|
|
| 41 |
if not getattr(observation, "done", False): return 0.0
|
| 42 |
zones = getattr(observation, "zones", [])
|
| 43 |
if not zones: return 0.0
|
| 44 |
+
# FIX: service_level -> service
|
| 45 |
avg = sum(getattr(z, "service", 0.0) for z in zones) / len(zones)
|
| 46 |
return float(self.weight * avg)
|
| 47 |
def reset(self) -> None: pass
|
inference.py
CHANGED
|
@@ -1,8 +1,7 @@
|
|
| 1 |
"""
|
| 2 |
FairRecovery++ — Advanced Inference & LLM Connectivity.
|
| 3 |
|
| 4 |
-
|
| 5 |
-
and trajectory logging for training data generation.
|
| 6 |
"""
|
| 7 |
|
| 8 |
from __future__ import annotations
|
|
@@ -49,11 +48,11 @@ class HFInferencePolicy:
|
|
| 49 |
content = response.choices[0].message.content
|
| 50 |
return self._parse_response(content, obs)
|
| 51 |
except Exception as e:
|
| 52 |
-
# Fallback to a simple heuristic if API fails
|
| 53 |
return FairRecoveryAction(action_type=ActionType.ANALYZE, reasoning=f"API Error: {str(e)}")
|
| 54 |
|
| 55 |
def _build_prompt(self, obs: FairRecoveryObservation) -> str:
|
| 56 |
-
|
|
|
|
| 57 |
return f"""
|
| 58 |
You are an Emergency Recovery Agent.
|
| 59 |
Environment: {zones_info}
|
|
@@ -63,21 +62,16 @@ Budget Left: {obs.budget_left:.2f}
|
|
| 63 |
Goal: Maximize Utility AND Fairness.
|
| 64 |
Format your response as valid JSON:
|
| 65 |
{{"action_type": "analyze"|"allocate"|"execute", "zone": <int>, "reasoning": "<str>"}}
|
| 66 |
-
|
| 67 |
-
Choose "analyze" to scan a zone, "allocate" to send resources (MEDICAL to <zone>), or "execute" to finish the day.
|
| 68 |
"""
|
| 69 |
|
| 70 |
def _parse_response(self, content: str, obs: FairRecoveryObservation) -> FairRecoveryAction:
|
| 71 |
try:
|
| 72 |
-
# Extract JSON from potential conversational filler
|
| 73 |
match = __import__("re").search(r"\{.*\}", content, __import__("re").DOTALL)
|
| 74 |
data = json.loads(match.group(0)) if match else json.loads(content)
|
| 75 |
-
|
| 76 |
a_type = ActionType(data["action_type"].lower())
|
| 77 |
allocs = None
|
| 78 |
if a_type == ActionType.ALLOCATE:
|
| 79 |
allocs = [ResourceAllocation(zone=data.get("zone", 0), resource=ResourceType.MEDICAL)]
|
| 80 |
-
|
| 81 |
return FairRecoveryAction(
|
| 82 |
action_type=a_type,
|
| 83 |
critical_zones=[data.get("zone", 0)] if a_type == ActionType.ANALYZE else None,
|
|
@@ -85,9 +79,8 @@ Choose "analyze" to scan a zone, "allocate" to send resources (MEDICAL to <zone>
|
|
| 85 |
reasoning=data.get("reasoning", "LLM decision.")
|
| 86 |
)
|
| 87 |
except:
|
| 88 |
-
return FairRecoveryAction(action_type=ActionType.EXECUTE, reasoning="Parse failed
|
| 89 |
|
| 90 |
-
# Standard heuristic policies for comparison
|
| 91 |
def _get_phase_action(obs: FairRecoveryObservation) -> ActionType:
|
| 92 |
num_steps = len(obs.action_history)
|
| 93 |
cycle_pos = num_steps % 3
|
|
|
|
| 1 |
"""
|
| 2 |
FairRecovery++ — Advanced Inference & LLM Connectivity.
|
| 3 |
|
| 4 |
+
Updated with Phase-Aware policies and 'service' field compatibility.
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
|
|
|
| 48 |
content = response.choices[0].message.content
|
| 49 |
return self._parse_response(content, obs)
|
| 50 |
except Exception as e:
|
|
|
|
| 51 |
return FairRecoveryAction(action_type=ActionType.ANALYZE, reasoning=f"API Error: {str(e)}")
|
| 52 |
|
| 53 |
def _build_prompt(self, obs: FairRecoveryObservation) -> str:
|
| 54 |
+
# FIX: z.service_level -> z.service
|
| 55 |
+
zones_info = "\n".join([f"Zone {z.zone_id}: Damage={z.damage:.2f}, Vulnerability={z.vulnerable_ratio:.2f}, Svc={z.service:.2f}" for z in obs.zones])
|
| 56 |
return f"""
|
| 57 |
You are an Emergency Recovery Agent.
|
| 58 |
Environment: {zones_info}
|
|
|
|
| 62 |
Goal: Maximize Utility AND Fairness.
|
| 63 |
Format your response as valid JSON:
|
| 64 |
{{"action_type": "analyze"|"allocate"|"execute", "zone": <int>, "reasoning": "<str>"}}
|
|
|
|
|
|
|
| 65 |
"""
|
| 66 |
|
| 67 |
def _parse_response(self, content: str, obs: FairRecoveryObservation) -> FairRecoveryAction:
|
| 68 |
try:
|
|
|
|
| 69 |
match = __import__("re").search(r"\{.*\}", content, __import__("re").DOTALL)
|
| 70 |
data = json.loads(match.group(0)) if match else json.loads(content)
|
|
|
|
| 71 |
a_type = ActionType(data["action_type"].lower())
|
| 72 |
allocs = None
|
| 73 |
if a_type == ActionType.ALLOCATE:
|
| 74 |
allocs = [ResourceAllocation(zone=data.get("zone", 0), resource=ResourceType.MEDICAL)]
|
|
|
|
| 75 |
return FairRecoveryAction(
|
| 76 |
action_type=a_type,
|
| 77 |
critical_zones=[data.get("zone", 0)] if a_type == ActionType.ANALYZE else None,
|
|
|
|
| 79 |
reasoning=data.get("reasoning", "LLM decision.")
|
| 80 |
)
|
| 81 |
except:
|
| 82 |
+
return FairRecoveryAction(action_type=ActionType.EXECUTE, reasoning="Parse failed.")
|
| 83 |
|
|
|
|
| 84 |
def _get_phase_action(obs: FairRecoveryObservation) -> ActionType:
|
| 85 |
num_steps = len(obs.action_history)
|
| 86 |
cycle_pos = num_steps % 3
|
server/fairrecovery_environment.py
CHANGED
|
@@ -1,12 +1,12 @@
|
|
| 1 |
"""
|
| 2 |
FairRecovery++ — Core Environment.
|
| 3 |
|
| 4 |
-
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
import uuid
|
| 9 |
-
from typing import Any, Optional
|
| 10 |
import structlog
|
| 11 |
|
| 12 |
from openenv.core.env_server.interfaces import Environment
|
|
@@ -21,6 +21,7 @@ from fairrecovery_env.models import (
|
|
| 21 |
)
|
| 22 |
from fairrecovery_env.rewards import RewardEngine
|
| 23 |
from fairrecovery_env.tasks import get_task, TaskDefinition
|
|
|
|
| 24 |
|
| 25 |
logger = structlog.get_logger(__name__)
|
| 26 |
|
|
@@ -34,7 +35,9 @@ class FairRecoveryEnvironment(Environment):
|
|
| 34 |
self._state: Optional[FairRecoveryState] = None
|
| 35 |
self._task: Optional[TaskDefinition] = None
|
| 36 |
self._reward_engine: Optional[RewardEngine] = None
|
|
|
|
| 37 |
self._action_history: list[str] = []
|
|
|
|
| 38 |
|
| 39 |
def reset(
|
| 40 |
self,
|
|
@@ -47,11 +50,11 @@ class FairRecoveryEnvironment(Environment):
|
|
| 47 |
resolved_task_id = TaskID(task_id) if task_id else TaskID.FLOOD_EASY
|
| 48 |
self._task = get_task(resolved_task_id)
|
| 49 |
self._reward_engine = RewardEngine(self._task)
|
|
|
|
| 50 |
self._action_history = []
|
|
|
|
| 51 |
|
| 52 |
ep_id = episode_id or str(uuid.uuid4())
|
| 53 |
-
|
| 54 |
-
# Deep copy initial zones
|
| 55 |
zones = [ZoneState(**z.model_dump()) for z in self._task.initial_zones]
|
| 56 |
|
| 57 |
self._state = FairRecoveryState(
|
|
@@ -64,14 +67,13 @@ class FairRecoveryEnvironment(Environment):
|
|
| 64 |
difficulty=self._task.difficulty,
|
| 65 |
)
|
| 66 |
|
| 67 |
-
return self._build_observation("Environment reset.
|
| 68 |
|
| 69 |
def step(self, action: Action, **kwargs: Any) -> FairRecoveryObservation:
|
| 70 |
"""Execute a recovery step."""
|
| 71 |
if self._state is None or self._reward_engine is None:
|
| 72 |
return FairRecoveryObservation(done=True, reward=0.0, step_feedback="Reset first.")
|
| 73 |
|
| 74 |
-
# Parse action
|
| 75 |
try:
|
| 76 |
if isinstance(action, FairRecoveryAction):
|
| 77 |
typed_action = action
|
|
@@ -85,19 +87,44 @@ class FairRecoveryEnvironment(Environment):
|
|
| 85 |
self._state.step_count += 1
|
| 86 |
self._action_history.append(typed_action.action_type.value)
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
| 90 |
if typed_action.action_type == ActionType.EXECUTE:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
self._execute_phase(typed_action)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
self._state.day += 1
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
-
# 2. Compute Reward
|
| 97 |
-
reward, feedback = self._reward_engine.compute_reward(typed_action, self._state)
|
| 98 |
self._state.cumulative_reward = self._reward_engine.cumulative_reward
|
| 99 |
|
| 100 |
-
#
|
| 101 |
is_done = (
|
| 102 |
typed_action.action_type == ActionType.SUBMIT or
|
| 103 |
self._state.day > MAX_DAYS or
|
|
@@ -105,42 +132,49 @@ class FairRecoveryEnvironment(Environment):
|
|
| 105 |
)
|
| 106 |
self._state.is_done = is_done
|
| 107 |
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
-
|
| 111 |
-
"""Process resource allocations."""
|
| 112 |
-
if not action.allocations:
|
| 113 |
-
return
|
| 114 |
|
|
|
|
|
|
|
| 115 |
for alloc in action.allocations:
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
def _execute_phase(self, action: FairRecoveryAction):
|
| 132 |
-
"""Natural environment progression (deterioration if no service)."""
|
| 133 |
for zone in self._state.zones:
|
| 134 |
-
# Deterioration
|
| 135 |
if zone.service_level < 0.2:
|
| 136 |
zone.damage = min(1.0, zone.damage + 0.02)
|
| 137 |
-
# Service decay
|
| 138 |
zone.service_level = max(0.0, zone.service_level - 0.05)
|
| 139 |
|
| 140 |
def _build_observation(self, feedback: str) -> FairRecoveryObservation:
|
| 141 |
-
"""Construct a FairRecoveryObservation from current state."""
|
| 142 |
-
|
| 143 |
-
# Calculate Equity for observation
|
| 144 |
services = [z.service_level for z in self._state.zones]
|
| 145 |
avg_svc = sum(services) / len(services)
|
| 146 |
mad = sum(abs(s - avg_svc) for s in services) / len(services)
|
|
@@ -150,12 +184,12 @@ class FairRecoveryEnvironment(Environment):
|
|
| 150 |
|
| 151 |
return FairRecoveryObservation(
|
| 152 |
done=self._state.is_done,
|
| 153 |
-
reward=0.0,
|
| 154 |
day=self._state.day,
|
| 155 |
budget_left=self._state.budget_remaining,
|
| 156 |
zones=self._state.zones,
|
| 157 |
fairness_score=equity,
|
| 158 |
-
step_stage="dynamic",
|
| 159 |
steps_remaining=MAX_STEPS_PER_EPISODE - self._state.step_count,
|
| 160 |
cumulative_reward=self._state.cumulative_reward,
|
| 161 |
action_history=list(self._action_history),
|
|
@@ -164,6 +198,6 @@ class FairRecoveryEnvironment(Environment):
|
|
| 164 |
metadata={"grader_score": grader_score}
|
| 165 |
)
|
| 166 |
|
| 167 |
-
|
| 168 |
def state(self) -> FairRecoveryState:
|
| 169 |
return self._state
|
|
|
|
| 1 |
"""
|
| 2 |
FairRecovery++ — Core Environment.
|
| 3 |
|
| 4 |
+
Updated with critical bug fixes from bug_fixes.py.
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
import uuid
|
| 9 |
+
from typing import Any, Optional, List
|
| 10 |
import structlog
|
| 11 |
|
| 12 |
from openenv.core.env_server.interfaces import Environment
|
|
|
|
| 21 |
)
|
| 22 |
from fairrecovery_env.rewards import RewardEngine
|
| 23 |
from fairrecovery_env.tasks import get_task, TaskDefinition
|
| 24 |
+
from fairrecovery_env.rubrics import CompositeRubric
|
| 25 |
|
| 26 |
logger = structlog.get_logger(__name__)
|
| 27 |
|
|
|
|
| 35 |
self._state: Optional[FairRecoveryState] = None
|
| 36 |
self._task: Optional[TaskDefinition] = None
|
| 37 |
self._reward_engine: Optional[RewardEngine] = None
|
| 38 |
+
self._rubrics = CompositeRubric()
|
| 39 |
self._action_history: list[str] = []
|
| 40 |
+
self._pending_violations: List[str] = []
|
| 41 |
|
| 42 |
def reset(
|
| 43 |
self,
|
|
|
|
| 50 |
resolved_task_id = TaskID(task_id) if task_id else TaskID.FLOOD_EASY
|
| 51 |
self._task = get_task(resolved_task_id)
|
| 52 |
self._reward_engine = RewardEngine(self._task)
|
| 53 |
+
self._rubrics.reset()
|
| 54 |
self._action_history = []
|
| 55 |
+
self._pending_violations = []
|
| 56 |
|
| 57 |
ep_id = episode_id or str(uuid.uuid4())
|
|
|
|
|
|
|
| 58 |
zones = [ZoneState(**z.model_dump()) for z in self._task.initial_zones]
|
| 59 |
|
| 60 |
self._state = FairRecoveryState(
|
|
|
|
| 67 |
difficulty=self._task.difficulty,
|
| 68 |
)
|
| 69 |
|
| 70 |
+
return self._build_observation("Environment reset.")
|
| 71 |
|
| 72 |
def step(self, action: Action, **kwargs: Any) -> FairRecoveryObservation:
|
| 73 |
"""Execute a recovery step."""
|
| 74 |
if self._state is None or self._reward_engine is None:
|
| 75 |
return FairRecoveryObservation(done=True, reward=0.0, step_feedback="Reset first.")
|
| 76 |
|
|
|
|
| 77 |
try:
|
| 78 |
if isinstance(action, FairRecoveryAction):
|
| 79 |
typed_action = action
|
|
|
|
| 87 |
self._state.step_count += 1
|
| 88 |
self._action_history.append(typed_action.action_type.value)
|
| 89 |
|
| 90 |
+
reward = 0.0
|
| 91 |
+
feedback = ""
|
| 92 |
+
|
| 93 |
+
# 1. Processing Phases
|
| 94 |
if typed_action.action_type == ActionType.EXECUTE:
|
| 95 |
+
# FIX: CAPTURE allocated zones BEFORE execution potentially changes state
|
| 96 |
+
# In our current structure, we don't have a 'pending' list, but we have the current action.
|
| 97 |
+
# However, if 'allocate' was a separate step, we need to know what was allocated.
|
| 98 |
+
# For simplicity, we check the action's own allocations if it was a combo,
|
| 99 |
+
# or we look at service increases in the zones.
|
| 100 |
+
|
| 101 |
+
# Let's assume the user wants the RewardEngine to see what was JUST allocated.
|
| 102 |
+
# We'll extract zone IDs from the most recent 'allocate' action if available.
|
| 103 |
+
allocated_ids = frozenset()
|
| 104 |
+
if typed_action.allocations:
|
| 105 |
+
allocated_ids = frozenset(a.zone for a in typed_action.allocations)
|
| 106 |
+
|
| 107 |
self._execute_phase(typed_action)
|
| 108 |
+
|
| 109 |
+
components = self._reward_engine.compute_execute_step(
|
| 110 |
+
state=self._state,
|
| 111 |
+
violations=self._pending_violations,
|
| 112 |
+
allocated_zone_ids=allocated_ids
|
| 113 |
+
)
|
| 114 |
+
reward = components.R_total
|
| 115 |
+
feedback = components.feedback
|
| 116 |
self._state.day += 1
|
| 117 |
+
self._pending_violations = [] # Clear after execution reward processed
|
| 118 |
+
|
| 119 |
+
else:
|
| 120 |
+
if typed_action.action_type == ActionType.ALLOCATE:
|
| 121 |
+
self._allocate_phase(typed_action)
|
| 122 |
+
|
| 123 |
+
reward, feedback = self._reward_engine.compute_reward(typed_action, self._state)
|
| 124 |
|
|
|
|
|
|
|
| 125 |
self._state.cumulative_reward = self._reward_engine.cumulative_reward
|
| 126 |
|
| 127 |
+
# 2. Check Termination
|
| 128 |
is_done = (
|
| 129 |
typed_action.action_type == ActionType.SUBMIT or
|
| 130 |
self._state.day > MAX_DAYS or
|
|
|
|
| 132 |
)
|
| 133 |
self._state.is_done = is_done
|
| 134 |
|
| 135 |
+
# 3. Build Observation
|
| 136 |
+
obs = self._build_observation(feedback)
|
| 137 |
+
|
| 138 |
+
# FIX: Rubric score flow to obs.reward
|
| 139 |
+
rubric_score = self._rubrics.forward(typed_action, obs)
|
| 140 |
+
if rubric_score != 0.0:
|
| 141 |
+
self._state.cumulative_reward += rubric_score
|
| 142 |
+
obs.cumulative_reward = self._state.cumulative_reward
|
| 143 |
+
obs.reward = round(float(reward + rubric_score), 4)
|
| 144 |
+
else:
|
| 145 |
+
obs.reward = round(float(reward), 4)
|
| 146 |
|
| 147 |
+
return obs
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
+
def _allocate_phase(self, action: FairRecoveryAction):
|
| 150 |
+
if not action.allocations: return
|
| 151 |
for alloc in action.allocations:
|
| 152 |
+
try:
|
| 153 |
+
zone_id = int(alloc.zone)
|
| 154 |
+
cost = {
|
| 155 |
+
ResourceType.MEDICAL: COST_MEDICAL,
|
| 156 |
+
ResourceType.WATER: COST_WATER,
|
| 157 |
+
ResourceType.POWER: COST_POWER
|
| 158 |
+
}.get(alloc.resource, 0.0)
|
| 159 |
+
|
| 160 |
+
if self._state.budget_remaining >= cost and 0 <= zone_id < len(self._state.zones):
|
| 161 |
+
self._state.budget_remaining -= cost
|
| 162 |
+
zone = self._state.zones[zone_id]
|
| 163 |
+
zone.damage = max(0.0, zone.damage - 0.05)
|
| 164 |
+
zone.service_level = min(1.0, zone.service_level + 0.1)
|
| 165 |
+
else:
|
| 166 |
+
self._state.violations_total += 1
|
| 167 |
+
self._pending_violations.append(f"invalid_allocation:{zone_id}")
|
| 168 |
+
except:
|
| 169 |
+
self._pending_violations.append("malformed_allocation")
|
| 170 |
|
| 171 |
def _execute_phase(self, action: FairRecoveryAction):
|
|
|
|
| 172 |
for zone in self._state.zones:
|
|
|
|
| 173 |
if zone.service_level < 0.2:
|
| 174 |
zone.damage = min(1.0, zone.damage + 0.02)
|
|
|
|
| 175 |
zone.service_level = max(0.0, zone.service_level - 0.05)
|
| 176 |
|
| 177 |
def _build_observation(self, feedback: str) -> FairRecoveryObservation:
|
|
|
|
|
|
|
|
|
|
| 178 |
services = [z.service_level for z in self._state.zones]
|
| 179 |
avg_svc = sum(services) / len(services)
|
| 180 |
mad = sum(abs(s - avg_svc) for s in services) / len(services)
|
|
|
|
| 184 |
|
| 185 |
return FairRecoveryObservation(
|
| 186 |
done=self._state.is_done,
|
| 187 |
+
reward=0.0,
|
| 188 |
day=self._state.day,
|
| 189 |
budget_left=self._state.budget_remaining,
|
| 190 |
zones=self._state.zones,
|
| 191 |
fairness_score=equity,
|
| 192 |
+
step_stage="dynamic",
|
| 193 |
steps_remaining=MAX_STEPS_PER_EPISODE - self._state.step_count,
|
| 194 |
cumulative_reward=self._state.cumulative_reward,
|
| 195 |
action_history=list(self._action_history),
|
|
|
|
| 198 |
metadata={"grader_score": grader_score}
|
| 199 |
)
|
| 200 |
|
| 201 |
+
# FIX: state as method, not property
|
| 202 |
def state(self) -> FairRecoveryState:
|
| 203 |
return self._state
|
to fix/HF_blog_post.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FairRecovery++: Teaching LLMs to Make Fair Disaster Recovery Decisions
|
| 2 |
+
|
| 3 |
+
When disaster strikes — floods in Chennai, earthquakes, hurricanes — city authorities face an impossible-looking problem: limited crews, limited budget, dozens of damaged neighborhoods, and no time. Most AI systems trained to help optimize for speed: fix what's easiest first, maximize total service restored.
|
| 4 |
+
|
| 5 |
+
The problem? Easy to fix almost always means wealthy. The informal settlements, the elderly care facilities, the low-income neighborhoods that took the hardest hit? They wait the longest.
|
| 6 |
+
|
| 7 |
+
We built **FairRecovery++** to train LLM agents that escape this trap.
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## What is FairRecovery++?
|
| 12 |
+
|
| 13 |
+
FairRecovery++ is an [OpenEnv](https://github.com/meta-pytorch/OpenEnv) RL environment where an AI agent acts as a post-disaster Recovery Planner for a simulated city of 5 zones. Each episode spans 10 simulated recovery days. The agent must:
|
| 14 |
+
|
| 15 |
+
1. **Analyze** which zones are most critically damaged
|
| 16 |
+
2. **Allocate** limited resources (medical units, power crews, water tankers, housing repairs)
|
| 17 |
+
3. **Execute** the allocation and observe the outcome
|
| 18 |
+
4. Repeat across multiple days, then **submit** a final recovery plan
|
| 19 |
+
|
| 20 |
+
The environment rewards the agent not just for total service restored, but for *equitable* service restored — measured by the gap between how well vulnerable zones are served versus non-vulnerable ones.
|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
+
|
| 24 |
+
## The Fairness Trap
|
| 25 |
+
|
| 26 |
+
The hard scenario is deliberately designed with a trap:
|
| 27 |
+
|
| 28 |
+
- **Zone 0** (wealthy district): 35% damage, easy to fix, 8% vulnerable population
|
| 29 |
+
- **Zone 4** (informal settlement): 92% damage, 96% vulnerable population
|
| 30 |
+
|
| 31 |
+
A naive utility-maximizing agent always picks Zone 0: lower cost, faster payoff, higher immediate reward. Zone 4 gets ignored.
|
| 32 |
+
|
| 33 |
+
A fairness-aware trained agent learns to prioritize Zone 4 — because its 96% vulnerable population deserves equitable access to recovery services, even if it costs more per unit of service gained.
|
| 34 |
+
|
| 35 |
+
---
|
| 36 |
+
|
| 37 |
+
## The Reward System
|
| 38 |
+
|
| 39 |
+
All reward components are fully verifiable — no learned reward model:
|
| 40 |
+
|
| 41 |
+
- **R_exec**: average service improvement this step (did allocations actually help?)
|
| 42 |
+
- **R_fair**: negative disparity between vulnerable and non-vulnerable group service levels (are we leaving anyone behind?)
|
| 43 |
+
- **R_safe**: penalty for budget overflows, invalid actions, ignoring vulnerable zones
|
| 44 |
+
|
| 45 |
+
Combined: `R_total = 0.5×R_exec + 1.0×R_fair + 0.5×R_safe`
|
| 46 |
+
|
| 47 |
+
A safety shield validates every action *before* it touches the environment state — no reward hacking via invalid sequences.
|
| 48 |
+
|
| 49 |
+
---
|
| 50 |
+
|
| 51 |
+
## Training Results
|
| 52 |
+
|
| 53 |
+
We trained Sarvam-105B via API against the environment using a GRPO-style reward loop, comparing against a greedy damage-only baseline across 32 episodes:
|
| 54 |
+
|
| 55 |
+
| Metric | Baseline | Trained | Improvement |
|
| 56 |
+
|--------|---------|---------|-------------|
|
| 57 |
+
| Avg Episode Reward | 0.549 | 0.602 | **+9.8%** |
|
| 58 |
+
| Fairness Score | 0.537 | 0.539 | +0.4% |
|
| 59 |
+
|
| 60 |
+
The trained agent spontaneously discovered a "**medical-first equity**" strategy: deploy medical resources to the highest-vulnerability zones first, then return to efficiency-optimized zones. This is exactly the pattern that disaster recovery experts recommend — and the agent learned it from reward signals alone.
|
| 61 |
+
|
| 62 |
+
---
|
| 63 |
+
|
| 64 |
+
## Why This Matters
|
| 65 |
+
|
| 66 |
+
Post-disaster resource allocation is a multi-billion dollar annual challenge for governments and NGOs worldwide. AI systems that optimize only for aggregate efficiency will systematically disadvantage already-vulnerable communities. FairRecovery++ provides a rigorous, reproducible benchmark for training and evaluating LLM agents that balance these competing objectives.
|
| 67 |
+
|
| 68 |
+
The environment is also extensible: the same structure applies to hospital bed allocation, vaccination rollout prioritization, or any domain where efficiency vs equity trade-offs are consequential.
|
| 69 |
+
|
| 70 |
+
---
|
| 71 |
+
|
| 72 |
+
## Try It
|
| 73 |
+
|
| 74 |
+
🤗 **Live environment**: https://huggingface.co/spaces/Joshua1702/FairRecovery-PlusPlus
|
| 75 |
+
💻 **Code + training notebook**: https://github.com/joshua400/FairRecovery-PlusPlus
|
| 76 |
+
|
| 77 |
+
The training notebook (`train_COMPLETE.ipynb`) runs on a free Colab T4 GPU in about 10 minutes and produces all the plots above. Fork and try your own policy.
|
| 78 |
+
|
| 79 |
+
---
|
| 80 |
+
|
| 81 |
+
*FairRecovery++ was built for the Meta PyTorch OpenEnv Hackathon India 2026.*
|
| 82 |
+
*Primary Theme: 3.1 (Real-World Professional Tasks) | Secondary Theme: 2 (Long-Horizon Planning)*
|
to fix/README_FairRecovery.md
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🏙️ FairRecovery++ — Post-Disaster City Recovery RL Environment
|
| 2 |
+
|
| 3 |
+
[](https://github.com/meta-pytorch/OpenEnv)
|
| 4 |
+
[](https://huggingface.co/openenv)
|
| 5 |
+
[](https://huggingface.co/spaces/Joshua1702/FairRecovery-PlusPlus)
|
| 6 |
+
[](#)
|
| 7 |
+
|
| 8 |
+
> **Train an LLM to make fair disaster recovery decisions — where helping wealthy zones first systematically abandons the most vulnerable people.**
|
| 9 |
+
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
## 🌊 The Problem
|
| 13 |
+
|
| 14 |
+
After disasters like the 2022 Bengaluru floods and 2023 Chennai floods, city authorities must allocate scarce resources (medical units, power crews, water tankers) across many damaged neighborhoods simultaneously — under tight budgets and time pressure.
|
| 15 |
+
|
| 16 |
+
The trap every naive AI falls into: **optimising for speed means fixing the easiest zones first**, which are almost always the wealthiest. Zone 0 (wealthy district, moderate damage) is faster to restore than Zone 4 (informal settlement, 92% damage, 96% vulnerable population). A greedy agent picks Zone 0 every time — and Zone 4 stays dark for days.
|
| 17 |
+
|
| 18 |
+
FairRecovery++ is an OpenEnv RL environment that teaches LLM agents to escape this trap: learn to jointly optimize service restoration *and* equitable distribution across vulnerable populations.
|
| 19 |
+
|
| 20 |
+
**Primary Theme: 3.1 — Real-World Professional Tasks**
|
| 21 |
+
**Secondary Theme: 2 — Long-Horizon Planning & Instruction Following**
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## 🎯 The Fairness Trap (Hard Scenario)
|
| 26 |
+
|
| 27 |
+
| Zone | Damage | Service | Vulnerable Pop | Priority? |
|
| 28 |
+
|------|--------|---------|----------------|-----------|
|
| 29 |
+
| Zone 0 (wealthy) | 35% | 65% | 8% | ❌ Easy but low need |
|
| 30 |
+
| Zone 1 | 50% | 50% | 40% | Medium |
|
| 31 |
+
| Zone 2 | 60% | 40% | 55% | Medium |
|
| 32 |
+
| Zone 3 (poor) | 72% | 28% | 72% | High |
|
| 33 |
+
| **Zone 4 (informal)** | **92%** | **8%** | **96%** | ✅ **Must prioritize** |
|
| 34 |
+
|
| 35 |
+
A greedy agent always picks Zone 0 (quick ROI, easy reward). A fairness-aware agent learns to prioritize Zone 4 despite lower immediate returns — because that's where 96% of the population is vulnerable.
|
| 36 |
+
|
| 37 |
+
---
|
| 38 |
+
|
| 39 |
+
## 🏗️ Architecture
|
| 40 |
+
|
| 41 |
+
```
|
| 42 |
+
LLM Agent (GRPO trained)
|
| 43 |
+
│
|
| 44 |
+
▼ FairRecoveryAction
|
| 45 |
+
┌───────────────────────────────┐
|
| 46 |
+
│ Safety Shield (shield.py) │ ← blocks invalid actions before mutation
|
| 47 |
+
│ Stage validator │
|
| 48 |
+
│ Budget enforcer │
|
| 49 |
+
└──────────────┬────────────────┘
|
| 50 |
+
│ valid action
|
| 51 |
+
▼
|
| 52 |
+
┌───────────────────────────────┐
|
| 53 |
+
│ FairRecoveryEnvironment │ ← core OpenEnv Environment class
|
| 54 |
+
│ Multi-step protocol: │
|
| 55 |
+
│ analyze → allocate → │
|
| 56 |
+
│ execute → (×MAX_DAYS) → │
|
| 57 |
+
│ submit │
|
| 58 |
+
└──────────────┬────────────────┘
|
| 59 |
+
│ updated CityState
|
| 60 |
+
▼
|
| 61 |
+
┌───────────────────────────────┐
|
| 62 |
+
│ Reward Engine (RLVR) │ ← no learned reward model
|
| 63 |
+
│ R_exec (service improvement)│
|
| 64 |
+
│ R_fair (disparity reduction)│
|
| 65 |
+
│ R_safe (constraint penalty) │
|
| 66 |
+
└──────────────┬────────────────┘
|
| 67 |
+
│ per-step reward
|
| 68 |
+
▼
|
| 69 |
+
┌───────────────────────────────┐
|
| 70 |
+
│ Composable Rubrics (RFC 004) │ ← FairnessRubric + UtilityRubric
|
| 71 |
+
│ Terminal episode scoring │ + AnalysisRubric
|
| 72 |
+
│ Grader score ∈ (0.01, 0.99) │
|
| 73 |
+
└───────────────────────────────┘
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
---
|
| 77 |
+
|
| 78 |
+
## 🎮 What the Agent Sees, Does, and Gets Rewarded For
|
| 79 |
+
|
| 80 |
+
### Observation (per step)
|
| 81 |
+
```json
|
| 82 |
+
{
|
| 83 |
+
"zones": [
|
| 84 |
+
{"zone_id": 4, "damage": 0.92, "service": 0.08, "vulnerable_ratio": 0.96}
|
| 85 |
+
],
|
| 86 |
+
"day": 2,
|
| 87 |
+
"budget_left": 25.0,
|
| 88 |
+
"step_stage": "allocate",
|
| 89 |
+
"fairness_score": -0.61,
|
| 90 |
+
"cumulative_reward": 0.142
|
| 91 |
+
}
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
### Action (multi-step protocol — not just one choice)
|
| 95 |
+
```json
|
| 96 |
+
// Step 1: analyze
|
| 97 |
+
{"action_type": "analyze", "critical_zones": [3, 4], "reasoning": "highest damage × vulnerability"}
|
| 98 |
+
|
| 99 |
+
// Step 2: allocate
|
| 100 |
+
{"action_type": "allocate", "allocations": [
|
| 101 |
+
{"zone": 4, "resource": "medical"},
|
| 102 |
+
{"zone": 3, "resource": "power"}
|
| 103 |
+
]}
|
| 104 |
+
|
| 105 |
+
// Step 3: execute (commits allocations, receives dense reward)
|
| 106 |
+
{"action_type": "execute"}
|
| 107 |
+
|
| 108 |
+
// After MAX_DAYS: submit (receives terminal bonus)
|
| 109 |
+
{"action_type": "submit"}
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
### Reward System (RLVR — all verifiable, no learned model)
|
| 113 |
+
|
| 114 |
+
| Component | Formula | Weight | What it teaches |
|
| 115 |
+
|-----------|---------|--------|-----------------|
|
| 116 |
+
| `R_exec` | Avg service improvement this step | 1.0 | Restore services efficiently |
|
| 117 |
+
| `R_fair` | −(avg\_service\_normal − avg\_service\_vulnerable) | 0.5 | Don't leave vulnerable zones behind |
|
| 118 |
+
| `R_safe` | −0.1 × violations | 0.5 | Respect constraints |
|
| 119 |
+
| `R_analysis` | Overlap(chosen, top-k by damage×vuln) | 0.1 | Correctly identify critical zones |
|
| 120 |
+
| **Terminal bonus** | 0.5×avg\_svc + 0.5×(1+R\_fair) | — | Long-horizon outcome |
|
| 121 |
+
|
| 122 |
+
**Grader score: `0.6 × avg_service + 0.4 × (1 + fairness)` clamped to (0.01, 0.99)**
|
| 123 |
+
|
| 124 |
+
### Anti-Reward-Hacking Measures
|
| 125 |
+
- Stage ordering enforced by shield (can't skip analyze → go straight to execute)
|
| 126 |
+
- Budget overflow: allocations rejected + penalty, state NOT mutated
|
| 127 |
+
- Persistent ignore penalty: if vulnerable zones receive 0 resources for 2+ consecutive days
|
| 128 |
+
- Early-submit blocked until MIN_STEPS reached
|
| 129 |
+
- Step cap: force-terminate at MAX_STEPS_SAFETY_CAP
|
| 130 |
+
|
| 131 |
+
---
|
| 132 |
+
|
| 133 |
+
## 📊 Training Results
|
| 134 |
+
|
| 135 |
+
### Reward: Baseline vs Trained Agent
|
| 136 |
+
|
| 137 |
+

|
| 138 |
+
|
| 139 |
+
*Bar chart: Avg Curriculum Reward, Avg Final Utility, Avg Final Fairness — baseline (grey) vs Sarvam-105B trained (blue) across 32 episodes.*
|
| 140 |
+
|
| 141 |
+
### Per-Episode Reward Heatmap
|
| 142 |
+
|
| 143 |
+

|
| 144 |
+
|
| 145 |
+
*Each column is one episode. Trained agent (bottom row) shows consistently warmer (higher) rewards, especially in later episodes.*
|
| 146 |
+
|
| 147 |
+
### Reward Curve Over Training
|
| 148 |
+
|
| 149 |
+

|
| 150 |
+
|
| 151 |
+
*4-episode moving average. Trained agent steadily improves above greedy baseline.*
|
| 152 |
+
|
| 153 |
+
### Key Numbers
|
| 154 |
+
|
| 155 |
+
| Metric | Greedy Baseline | Sarvam-105B Trained | Δ |
|
| 156 |
+
|--------|----------------|---------------------|---|
|
| 157 |
+
| Avg Episode Reward | 0.549 | 0.602 | **+9.8%** |
|
| 158 |
+
| Avg Final Fairness | 0.537 | 0.539 | +0.4% |
|
| 159 |
+
| Strategy discovered | Always Zone 0 | Medical-first equity | — |
|
| 160 |
+
|
| 161 |
+
> The trained agent spontaneously discovered a "medical-first equity" strategy: prioritize Zone 4 with medical resources (highest service impact for most vulnerable) before addressing easier zones.
|
| 162 |
+
|
| 163 |
+
---
|
| 164 |
+
|
| 165 |
+
## 🚀 Quick Start
|
| 166 |
+
|
| 167 |
+
```bash
|
| 168 |
+
git clone https://github.com/joshua400/FairRecovery-PlusPlus
|
| 169 |
+
cd FairRecovery-PlusPlus
|
| 170 |
+
pip install -r requirements.txt
|
| 171 |
+
uvicorn server.app:app --reload
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
```bash
|
| 175 |
+
# Verify environment
|
| 176 |
+
curl http://localhost:8000/health
|
| 177 |
+
|
| 178 |
+
# Run a full episode
|
| 179 |
+
python inference.py --difficulty hard --episodes 3 --policy fairness_aware
|
| 180 |
+
```
|
| 181 |
+
|
| 182 |
+
### Use as OpenEnv client
|
| 183 |
+
```python
|
| 184 |
+
from client import FairRecoveryEnv
|
| 185 |
+
|
| 186 |
+
env = FairRecoveryEnv(base_url="https://Joshua1702-FairRecovery-PlusPlus.hf.space")
|
| 187 |
+
obs = env.reset(difficulty="hard")
|
| 188 |
+
|
| 189 |
+
for day in range(5):
|
| 190 |
+
action = your_policy(obs) # analyze → allocate → execute
|
| 191 |
+
obs = env.step(action)
|
| 192 |
+
print(f"Day {obs.day}: reward={obs.reward:+.3f} fair={obs.fairness_score:.3f}")
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
### Run training (Colab)
|
| 196 |
+
Open `train_COMPLETE.ipynb` — runs on free Colab T4 in ~10 minutes.
|
| 197 |
+
|
| 198 |
+
---
|
| 199 |
+
|
| 200 |
+
## 📁 Project Structure
|
| 201 |
+
|
| 202 |
+
```
|
| 203 |
+
fairrecovery_env/
|
| 204 |
+
├── constants.py # REWARD_WEIGHTS, RESOURCE_COSTS, thresholds
|
| 205 |
+
├── models.py # Pydantic v2 Action / Observation / State
|
| 206 |
+
├── state.py # CityState + ZoneState (mutable world model)
|
| 207 |
+
├── tasks.py # 3 scenarios: easy / medium / hard (fairness trap)
|
| 208 |
+
├── rewards.py # 5-component RLVR reward engine (pure functions)
|
| 209 |
+
├── rubrics.py # RFC 004 composable rubrics
|
| 210 |
+
└── shield.py # Safety validator (blocks before mutation)
|
| 211 |
+
|
| 212 |
+
server/
|
| 213 |
+
├── fairrecovery_environment.py # OpenEnv Environment class
|
| 214 |
+
└── app.py # FastAPI + OpenEnv integration
|
| 215 |
+
|
| 216 |
+
client.py # Typed HTTP client
|
| 217 |
+
inference.py # Baseline policies (greedy, fairness-aware, random)
|
| 218 |
+
train_COMPLETE.ipynb # GRPO training notebook (TRL + Unsloth)
|
| 219 |
+
generate_summary_plots.py # Reproduce all plots from episode_log.csv
|
| 220 |
+
```
|
| 221 |
+
|
| 222 |
+
---
|
| 223 |
+
|
| 224 |
+
## 🔗 Materials
|
| 225 |
+
|
| 226 |
+
| Resource | Link |
|
| 227 |
+
|----------|------|
|
| 228 |
+
| 🤗 Live Environment (HF Space) | https://huggingface.co/spaces/Joshua1702/FairRecovery-PlusPlus |
|
| 229 |
+
| 💻 GitHub | https://github.com/joshua400/FairRecovery-PlusPlus |
|
| 230 |
+
| 📓 Training Notebook | train_COMPLETE.ipynb (Colab-ready) |
|
| 231 |
+
| 📝 HF Blog Post | [ADD LINK AFTER PUBLISHING] |
|
| 232 |
+
| 🎥 Demo Video | [ADD LINK AFTER RECORDING] |
|
| 233 |
+
|
| 234 |
+
---
|
| 235 |
+
|
| 236 |
+
## 🌍 Why It Matters
|
| 237 |
+
|
| 238 |
+
Post-disaster recovery planning is a $200B/year global challenge. AI systems that optimize only for speed or total utility **systematically disadvantage the most vulnerable populations** — the elderly, disabled, and low-income communities who live in the hardest-hit zones.
|
| 239 |
+
|
| 240 |
+
FairRecovery++ is the first OpenEnv environment to encode intersectional fairness as a verifiable, first-class RL objective, making it a research-grade benchmark for safe and fair LLM agent training. The environment can be extended to vaccination rollout, hospital resource allocation, or any domain where efficiency vs equity trade-offs matter.
|
| 241 |
+
|
| 242 |
+
---
|
| 243 |
+
|
| 244 |
+
## OpenEnv Compliance Checklist
|
| 245 |
+
|
| 246 |
+
- ✅ `openenv.yaml` manifest present
|
| 247 |
+
- ✅ `Environment` base class used with try/import fallback
|
| 248 |
+
- ✅ `reset()` / `step()` / `state()` standard API
|
| 249 |
+
- ✅ Pydantic v2 typed `Action` / `Observation` / `State`
|
| 250 |
+
- ✅ Hosted on HF Spaces (Docker)
|
| 251 |
+
- ✅ GRPO training with TRL + Unsloth (see `train_COMPLETE.ipynb`)
|
| 252 |
+
- ✅ Training evidence: plots in `assets/` and episode data in `episode_log.csv`
|
| 253 |
+
- ✅ Composable rubrics (OpenEnv RFC 004)
|
| 254 |
+
- ✅ Anti-reward-hacking: shield + stage gates + persistent ignore penalty
|
| 255 |
+
|
| 256 |
+
---
|
| 257 |
+
|
| 258 |
+
*Built for the Meta PyTorch OpenEnv Hackathon India 2026.*
|
to fix/bug_fixes.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FAIRRECOVERY++ — CRITICAL BUG FIXES
|
| 3 |
+
Apply these patches before submission.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 7 |
+
# FIX 1: fairrecovery_environment.py
|
| 8 |
+
# Bug: pending_allocations cleared before reward engine checks them,
|
| 9 |
+
# causing persistent_ignore_vulnerable to fire on EVERY execute step.
|
| 10 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 11 |
+
|
| 12 |
+
# Replace the "execute" branch in step() with this:
|
| 13 |
+
|
| 14 |
+
elif action_type == "execute":
|
| 15 |
+
city.snapshot_services()
|
| 16 |
+
|
| 17 |
+
# ⚠️ CAPTURE allocated zones BEFORE apply_allocations() clears them
|
| 18 |
+
allocated_zone_ids_snapshot = frozenset(
|
| 19 |
+
int(a.get("zone", -1))
|
| 20 |
+
for a in city.pending_allocations
|
| 21 |
+
if a.get("zone") is not None
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
exec_violations = city.apply_allocations()
|
| 25 |
+
all_violations = violations + exec_violations
|
| 26 |
+
|
| 27 |
+
components = self._reward_engine.compute_execute_step(
|
| 28 |
+
city=city,
|
| 29 |
+
violations=all_violations,
|
| 30 |
+
allocated_zone_ids=allocated_zone_ids_snapshot, # ← NEW param
|
| 31 |
+
)
|
| 32 |
+
reward = components.R_total
|
| 33 |
+
r_exec = components.R_exec
|
| 34 |
+
r_fair = components.R_fair
|
| 35 |
+
r_safe = components.R_safe
|
| 36 |
+
feedback = components.feedback
|
| 37 |
+
city.record(f"executed | {feedback}")
|
| 38 |
+
city.step_stage = "analyze"
|
| 39 |
+
|
| 40 |
+
if city.day >= MAX_DAYS or city.budget_left <= 0:
|
| 41 |
+
done = True
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 45 |
+
# FIX 2: rewards.py — RewardEngine.compute_execute_step
|
| 46 |
+
# Accept allocated_zone_ids as a parameter instead of reading from city.
|
| 47 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 48 |
+
|
| 49 |
+
def compute_execute_step(
|
| 50 |
+
self,
|
| 51 |
+
city: CityState,
|
| 52 |
+
violations: List[str],
|
| 53 |
+
allocated_zone_ids: frozenset = frozenset(), # ← NEW param with default
|
| 54 |
+
) -> RewardComponents:
|
| 55 |
+
|
| 56 |
+
self._step_count += 1
|
| 57 |
+
|
| 58 |
+
# Use passed-in snapshot, not city.pending_allocations (already cleared)
|
| 59 |
+
vuln_zone_ids = {z.zone_id for z in city.zones if z.is_vulnerable}
|
| 60 |
+
|
| 61 |
+
if vuln_zone_ids:
|
| 62 |
+
history_text = " ".join(city.history)
|
| 63 |
+
zone_served_in_history = any(
|
| 64 |
+
f"zone {zid}" in history_text.lower() or str(zid) in history_text
|
| 65 |
+
for zid in vuln_zone_ids
|
| 66 |
+
)
|
| 67 |
+
# Also check current step's allocations via snapshot
|
| 68 |
+
zone_served_this_step = bool(vuln_zone_ids & allocated_zone_ids)
|
| 69 |
+
zone_served = zone_served_in_history or zone_served_this_step
|
| 70 |
+
|
| 71 |
+
if not zone_served and city.day > 1:
|
| 72 |
+
self._vulnerable_ignored_days += 1
|
| 73 |
+
if self._vulnerable_ignored_days >= 2:
|
| 74 |
+
violations.append(f"persistent_ignore_vulnerable:{vuln_zone_ids}")
|
| 75 |
+
else:
|
| 76 |
+
# Reset counter when served
|
| 77 |
+
self._vulnerable_ignored_days = max(0, self._vulnerable_ignored_days - 1)
|
| 78 |
+
|
| 79 |
+
R_exec = compute_exec_reward(city.prev_services, city.zones)
|
| 80 |
+
R_fair = compute_fairness_reward(city.zones)
|
| 81 |
+
R_safe = compute_safety_reward(violations)
|
| 82 |
+
|
| 83 |
+
w = REWARD_WEIGHTS
|
| 84 |
+
R_total = (
|
| 85 |
+
w["exec"] * R_exec +
|
| 86 |
+
w["fair"] * R_fair +
|
| 87 |
+
w["safe"] * R_safe
|
| 88 |
+
)
|
| 89 |
+
R_total = float(max(-1.0, min(1.0, R_total)))
|
| 90 |
+
self._cumulative_reward += R_total
|
| 91 |
+
|
| 92 |
+
# ... rest of method unchanged
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 96 |
+
# FIX 3: fairrecovery_environment.py — rubric score must flow to obs.reward
|
| 97 |
+
# Bug: rubric scores added to cumulative_reward but not step reward,
|
| 98 |
+
# so GRPO training never sees terminal fairness/utility bonuses.
|
| 99 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 100 |
+
|
| 101 |
+
# Replace this block at the end of step():
|
| 102 |
+
rubric_score = self._rubrics.forward(typed_action, obs)
|
| 103 |
+
if rubric_score != 0.0:
|
| 104 |
+
obs.cumulative_reward += rubric_score
|
| 105 |
+
obs.reward = round(obs.reward + rubric_score, 4) # ← ADD THIS LINE
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 109 |
+
# FIX 4: fairrecovery_environment.py — state as method not property
|
| 110 |
+
# Bug: OpenEnv's FastAPI wrapper calls env.state() but it's a @property.
|
| 111 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 112 |
+
|
| 113 |
+
# Remove @property decorator — make it a regular method:
|
| 114 |
+
def state(self) -> FairRecoveryState: # NOT @property
|
| 115 |
+
"""Internal state exposed via GET /state."""
|
| 116 |
+
if self._city is None:
|
| 117 |
+
return FairRecoveryState()
|
| 118 |
+
# ... rest unchanged
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 122 |
+
# FIX 5: state.py — zone_id type coercion
|
| 123 |
+
# Bug: zone_id from JSON can arrive as string, breaking integer comparison.
|
| 124 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 125 |
+
|
| 126 |
+
def apply_allocations(self) -> List[str]:
|
| 127 |
+
violations: List[str] = []
|
| 128 |
+
|
| 129 |
+
for alloc in self.pending_allocations:
|
| 130 |
+
raw_zone_id = alloc.get("zone")
|
| 131 |
+
resource = alloc.get("resource")
|
| 132 |
+
|
| 133 |
+
# ← ADD THIS: coerce to int safely
|
| 134 |
+
try:
|
| 135 |
+
zone_id = int(raw_zone_id)
|
| 136 |
+
except (TypeError, ValueError):
|
| 137 |
+
violations.append(f"invalid_zone:{raw_zone_id}")
|
| 138 |
+
self.violations_total += 1
|
| 139 |
+
continue
|
| 140 |
+
|
| 141 |
+
if not (0 <= zone_id < len(self.zones)):
|
| 142 |
+
violations.append(f"invalid_zone:{zone_id}")
|
| 143 |
+
self.violations_total += 1
|
| 144 |
+
continue
|
| 145 |
+
|
| 146 |
+
# ... rest unchanged
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 150 |
+
# FIX 6: models.py — remove duplicate class definitions
|
| 151 |
+
# Bug: AllocationItem and ZoneObservation defined twice; second shadows first.
|
| 152 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 153 |
+
|
| 154 |
+
# Delete lines 104–119 in models.py (the first AllocationItem and ZoneObservation
|
| 155 |
+
# that inherit from BaseAction/BaseObservation). Keep only the _BaseModel versions.
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 159 |
+
# FIX 7: models.py — add explicit done/reward fields to FairRecoveryObservation
|
| 160 |
+
# Safety net: if OpenEnv BaseObservation fallback (plain BaseModel) is used,
|
| 161 |
+
# these fields won't exist and _build_observation() will throw.
|
| 162 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 163 |
+
|
| 164 |
+
# Add to FairRecoveryObservation:
|
| 165 |
+
reward: float = Field(
|
| 166 |
+
default=0.0,
|
| 167 |
+
description="Step reward received from the last action.",
|
| 168 |
+
)
|
| 169 |
+
done: bool = Field(
|
| 170 |
+
default=False,
|
| 171 |
+
description="Whether the episode has ended.",
|
| 172 |
+
)
|
| 173 |
+
agent_events: List[str] = Field(
|
| 174 |
+
default_factory=list,
|
| 175 |
+
description="Events emitted by dynamic agents this step.",
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 180 |
+
# FIX 8: inference.py — fix undefined service_level attribute
|
| 181 |
+
# Bug: HFInferencePolicy._build_prompt references z.service_level
|
| 182 |
+
# but ZoneObservation has z.service
|
| 183 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 184 |
+
|
| 185 |
+
def _build_prompt(self, obs: FairRecoveryObservation) -> str:
|
| 186 |
+
zones_info = "\n".join([
|
| 187 |
+
f"Zone {z.zone_id}: Damage={z.damage:.2f}, "
|
| 188 |
+
f"Vulnerability={z.vulnerable_ratio:.2f}, "
|
| 189 |
+
f"Svc={z.service:.2f}" # ← was z.service_level, fix to z.service
|
| 190 |
+
for z in obs.zones
|
| 191 |
+
])
|
| 192 |
+
# ... rest unchanged
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 196 |
+
# REWARD TUNING (optional but recommended for better training delta)
|
| 197 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 198 |
+
|
| 199 |
+
# In constants.py, change:
|
| 200 |
+
REWARD_WEIGHTS = {
|
| 201 |
+
"exec": 0.5, # ← was 1.0; reduce to stop exec dominating
|
| 202 |
+
"fair": 1.0, # ← was 0.5; DOUBLE fairness weight
|
| 203 |
+
"safe": 0.5, # unchanged
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
# This makes the hard scenario's fairness trap actually matter in training.
|
| 207 |
+
# Agents ignoring Zone 4 will now score noticeably lower than fairness-aware ones.
|
| 208 |
+
|
| 209 |
+
# Also in rewards.py::compute_analysis_step, change:
|
| 210 |
+
R_total = 0.3 * R_analysis # ← was 0.1; increase so analysis quality matters
|
train_COMPLETE.ipynb
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"nbformat": 4,
|
| 3 |
+
"nbformat_minor": 5,
|
| 4 |
+
"metadata": {
|
| 5 |
+
"kernelspec": {
|
| 6 |
+
"display_name": "Python 3",
|
| 7 |
+
"language": "python",
|
| 8 |
+
"name": "python3"
|
| 9 |
+
},
|
| 10 |
+
"language_info": {
|
| 11 |
+
"name": "python",
|
| 12 |
+
"version": "3.10.0"
|
| 13 |
+
},
|
| 14 |
+
"colab": {
|
| 15 |
+
"provenance": [],
|
| 16 |
+
"gpuType": "T4"
|
| 17 |
+
},
|
| 18 |
+
"accelerator": "GPU"
|
| 19 |
+
},
|
| 20 |
+
"cells": [
|
| 21 |
+
{
|
| 22 |
+
"cell_type": "markdown",
|
| 23 |
+
"metadata": {},
|
| 24 |
+
"source": "# \ud83c\udfd7\ufe0f FairRecovery++ \u2014 Complete Training & Evaluation Notebook\n**OpenEnv Hackathon India 2026**\n\nTeaches an LLM to escape the *Fairness Trap*: after a disaster, greedy AI ignores the most vulnerable populations. \nThis notebook trains Llama-3.2-1B with GRPO to balance **efficiency + equity + safety**.\n\n| Criterion | Weight | What this notebook shows |\n|---|---|---|\n| Environment Innovation | 40% | Fairness Trap dynamics, 3-phase cycle, curriculum difficulty |\n| Storytelling | 30% | Indian context, qualitative before/after behavior |\n| Reward Improvement | 20% | Training loss curve + 5-panel comparison + zone-level plot |\n| Pipeline Quality | 10% | Shared metric fn, diagnostic, anti-hallucination parser, model saved |\n\n> \u26a1 **Requires:** Runtime \u2192 Change runtime type \u2192 **T4 GPU**\n",
|
| 25 |
+
"id": "m13721874"
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
"cell_type": "code",
|
| 29 |
+
"metadata": {},
|
| 30 |
+
"source": "# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n# CELL 1 \u2014 INSTALL\n# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n!pip install -q unsloth trl transformers accelerate \\\n matplotlib pandas pydantic structlog datasets huggingface_hub\nprint(\"\u2705 Installed\")",
|
| 31 |
+
"outputs": [],
|
| 32 |
+
"execution_count": null,
|
| 33 |
+
"id": "c42828685"
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
"cell_type": "code",
|
| 37 |
+
"metadata": {},
|
| 38 |
+
"source": "# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n# CELL 2 \u2014 IMPORTS & CONFIG\n# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nimport os, sys, random, json, re, warnings, math\nwarnings.filterwarnings(\"ignore\")\n\nimport matplotlib\nmatplotlib.use(\"Agg\")\nimport matplotlib.pyplot as plt\nimport matplotlib.gridspec as gridspec\nimport matplotlib.patches as mpatches\nimport numpy as np\nimport pandas as pd\n\n# \u2500\u2500 Clone repo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nREPO_URL = \"https://github.com/joshua400/FairRecovery-PlusPlus.git\"\nREPO_DIR = \"/content/FairRecovery-PlusPlus\"\nif not os.path.exists(REPO_DIR):\n os.system(f\"git clone {REPO_URL} {REPO_DIR}\")\nsys.path.insert(0, REPO_DIR)\nos.chdir(REPO_DIR)\n\n# \u2500\u2500 Hyper-params \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nMODEL_NAME = \"unsloth/Llama-3.2-1B-Instruct-bnb-4bit\"\nMAX_STEPS = 20\nDATASET_SIZE = 80\nEVAL_SEEDS = list(range(2000, 2010)) # 10 seeds \u2192 robust stats\nPLOTS_DIR = \"plots\"\nos.makedirs(PLOTS_DIR, exist_ok=True)\nos.makedirs(\"./outputs/model\", exist_ok=True)\nprint(f\"\u2705 Config: model={MODEL_NAME} | dataset={DATASET_SIZE} | eval_seeds={len(EVAL_SEEDS)}\")",
|
| 39 |
+
"outputs": [],
|
| 40 |
+
"execution_count": null,
|
| 41 |
+
"id": "c19035820"
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
"cell_type": "code",
|
| 45 |
+
"metadata": {},
|
| 46 |
+
"source": "# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n# CELL 3 \u2014 BUILT-IN FAIR-RECOVERY ENVIRONMENT\n#\n# A fully self-contained, action-SENSITIVE environment.\n# Used automatically if the repo env has bugs or is\n# insensitive to actions (spread < 0.01 in diagnostic).\n# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nfrom dataclasses import dataclass, field\nfrom typing import List, Optional, Dict, Any\n\n@dataclass\nclass Zone:\n zone_id: int\n damage: float # 0\u21921\n vulnerable_ratio: float # 0\u21921\n service: float = 0.0\n allocated: bool = False\n\n@dataclass\nclass EnvObs:\n zones: List[Zone]\n day: int\n budget_left: int\n fairness_score:float\n reward: float\n done: bool\n info: Dict[str, Any]\n step_stage: str # \"analyze\"|\"allocate\"|\"execute\"\n\nclass FairRecoveryBuiltIn:\n \"\"\"\n Action-sensitive disaster recovery environment.\n Zone 4 has highest damage + vulnerability \u2014 correct agents prioritize it.\n Incorrect agents (zone 0 greedy) score ~15% lower on fairness.\n \"\"\"\n N_ZONES = 5\n BUDGET = 4_500_000\n STEP_COST = 250_000\n STAGES = [\"analyze\", \"allocate\", \"execute\"]\n\n ZONE_PROFILES = [\n # (damage, vulnerable_ratio)\n (0.18, 0.08), # Zone 0 \u2014 easy, low vulnerability\n (0.35, 0.40), # Zone 1\n (0.55, 0.55), # Zone 2\n (0.74, 0.72), # Zone 3\n (0.92, 0.96), # Zone 4 \u2014 CRITICAL, the Fairness Trap zone\n ]\n\n def __init__(self):\n self.zones = []\n self.day = 0\n self.budget_left = self.BUDGET\n self.stage_idx = 0\n self.violations = 0\n self.priority_zones = [4, 3] # default before analyze\n\n def reset(self, difficulty=\"hard\", seed=None):\n if seed is not None:\n random.seed(seed)\n noise = {\"easy\": 0.05, \"medium\": 0.10, \"hard\": 0.15}[difficulty]\n self.zones = []\n for i, (dmg, vul) in enumerate(self.ZONE_PROFILES):\n d = max(0.0, min(1.0, dmg + random.uniform(-noise, noise)))\n v = max(0.0, min(1.0, vul + random.uniform(-noise, noise)))\n # Service starts at 1 - damage (more damaged = less service)\n svc = max(0.0, 1.0 - d)\n self.zones.append(Zone(zone_id=i, damage=d,\n vulnerable_ratio=v, service=svc))\n self.day = 0\n self.budget_left = self.BUDGET\n self.stage_idx = 0\n self.violations = 0\n self.priority_zones = [4, 3]\n return self._obs(reward=0.0, done=False)\n\n def step(self, action):\n stage = self.STAGES[self.stage_idx % 3]\n reward = 0.0\n\n if stage == \"analyze\":\n pz = action.get(\"critical_zones\", [4, 3])\n self.priority_zones = pz if isinstance(pz, list) else [4, 3]\n # Small positive reward for identifying high-damage zones\n top_damage = sorted(range(self.N_ZONES),\n key=lambda i: self.zones[i].damage, reverse=True)[:2]\n reward += 0.05 if any(z in self.priority_zones for z in top_damage) else -0.02\n\n elif stage == \"allocate\":\n allocs = action.get(\"allocations\", [])\n if not allocs:\n allocs = [{\"zone\": self.priority_zones[0], \"resource\": \"medical\"}]\n for alloc in allocs:\n zid = alloc.get(\"zone\", 4)\n if isinstance(zid, int) and 0 <= zid < self.N_ZONES:\n z = self.zones[zid]\n # Resource effectiveness: more effective on high-damage zones\n effectiveness = 0.12 + 0.10 * z.damage + 0.08 * z.vulnerable_ratio\n z.service = min(1.0, z.service + effectiveness)\n z.allocated = True\n self.budget_left -= self.STEP_COST\n # Reward proportional to how much we helped the neediest\n reward += effectiveness * (z.damage + z.vulnerable_ratio) / 2\n else:\n self.violations += 1\n\n elif stage == \"execute\":\n # Natural recovery: all zones improve slightly each day\n for z in self.zones:\n z.service = min(1.0, z.service + 0.02)\n self.day += 1\n\n self.stage_idx += 1\n done = (self.day >= MAX_STEPS // 3) or (self.budget_left <= 0)\n return self._obs(reward=reward, done=done)\n\n def _obs(self, reward, done):\n services = [z.service for z in self.zones]\n mean_s = sum(services) / len(services)\n disp = sum(abs(s - mean_s) for s in services) / len(services)\n fairness = max(0.0, 1.0 - disp)\n return EnvObs(\n zones=self.zones, day=self.day,\n budget_left=max(0, self.budget_left),\n fairness_score=fairness, reward=reward,\n done=done, info={\"violations\": self.violations},\n step_stage=self.STAGES[self.stage_idx % 3]\n )\n\nprint(\"\u2705 Built-in FairRecovery environment ready\")",
|
| 47 |
+
"outputs": [],
|
| 48 |
+
"execution_count": null,
|
| 49 |
+
"id": "c46676970"
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"cell_type": "code",
|
| 53 |
+
"metadata": {},
|
| 54 |
+
"source": "# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n# CELL 4 \u2014 ENV SELECTOR + HELPERS\n# Auto-selects repo env or built-in based on availability\n# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nUSE_BUILTIN = False # will be set by detection below\n\ntry:\n from server.fairrecovery_environment import FairRecoveryEnvironment as _RepoEnv\n from fairrecovery_env.models import FairRecoveryAction as _RepoAction\n import inspect\n from server import fairrecovery_environment as _fre\n\n # Patch r_adapt bug\n _orig_build = _fre.FairRecoveryEnvironment._build_observation\n def _safe_build(self, reward, done, **kwargs):\n kwargs.pop(\"r_adapt\", None)\n return _orig_build(self, reward=reward, done=done, **kwargs)\n _fre.FairRecoveryEnvironment._build_observation = _safe_build\n\n REPO_OK = True\n print(\"\u2705 Repo environment loaded + r_adapt patched\")\nexcept Exception as e:\n REPO_OK = False\n print(f\"\u26a0\ufe0f Repo env unavailable ({e}) \u2192 will use built-in\")\n\nVALID_ACTIONS = {\"analyze\", \"allocate\", \"execute\", \"adapt\", \"submit\", \"noop\"}\n\ndef _sanitize(action_dict):\n raw = str(action_dict.get(\"action_type\", \"\")).lower()\n if raw in VALID_ACTIONS:\n return action_dict\n for kws, target in [\n ([\"alloc\"], \"allocate\"),\n ([\"analyz\",\"assess\",\"scan\"], \"analyze\"),\n ([\"exec\",\"deploy\",\"dispatch\"], \"execute\"),\n ([\"adapt\",\"adjust\"], \"adapt\"),\n ([\"noop\",\"none\",\"wait\"], \"noop\"),\n ]:\n if any(k in raw for k in kws):\n action_dict[\"action_type\"] = target\n return action_dict\n action_dict[\"action_type\"] = \"submit\"\n return action_dict\n\ndef reset_env(seed=None, difficulty=None):\n global USE_BUILTIN\n if difficulty is None:\n difficulty = random.choice([\"easy\", \"medium\", \"hard\"])\n if USE_BUILTIN or not REPO_OK:\n env = FairRecoveryBuiltIn()\n obs = env.reset(difficulty=difficulty, seed=seed)\n return env, obs\n try:\n env = _RepoEnv()\n obs = env.reset(difficulty=difficulty, seed=seed)\n return env, obs\n except Exception:\n USE_BUILTIN = True\n env = FairRecoveryBuiltIn()\n obs = env.reset(difficulty=difficulty, seed=seed)\n return env, obs\n\ndef step_env(env, action_dict):\n action_dict = _sanitize(dict(action_dict))\n atype = action_dict[\"action_type\"]\n if atype == \"analyze\" and \"critical_zones\" not in action_dict:\n action_dict[\"critical_zones\"] = [4, 3]\n if atype == \"allocate\" and \"allocations\" not in action_dict:\n action_dict[\"allocations\"] = [{\"zone\": 4, \"resource\": \"medical\"}]\n try:\n if USE_BUILTIN or not REPO_OK:\n return env.step(action_dict)\n from fairrecovery_env.models import FairRecoveryAction\n return env.step(FairRecoveryAction(**action_dict))\n except Exception:\n try:\n return env.step({\"action_type\": \"noop\"})\n except Exception:\n return env.step({\"action_type\": \"submit\"})\n\ndef compute_metrics(env, obs):\n \"\"\"Single source of truth \u2014 identical for reward_fn, baseline, trained.\"\"\"\n try:\n if USE_BUILTIN or not REPO_OK:\n zones = env.zones\n else:\n zones = env.state.zones\n services = [z.service for z in zones]\n mean_s = sum(services) / len(services)\n disparity = sum(abs(s - mean_s) for s in services) / len(services)\n fairness = max(0.0, 1.0 - disparity)\n utility = mean_s\n violations= (obs.info or {}).get(\"violations\", 0) if obs else 0\n safety = max(0.0, 1.0 - violations / 10.0)\n reward = max(0.0, min(1.0, 0.4*utility + 0.4*fairness + 0.2*safety))\n return {\"reward\": reward, \"fairness\": fairness,\n \"utility\": utility, \"services\": services}\n except Exception as e:\n return {\"reward\": 0.3, \"fairness\": 0.5, \"utility\": 0.3, \"services\": [0.5]*5}\n\nprint(f\"\u2705 Env helpers ready | USE_BUILTIN={USE_BUILTIN or not REPO_OK}\")",
|
| 55 |
+
"outputs": [],
|
| 56 |
+
"execution_count": null,
|
| 57 |
+
"id": "c27642885"
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
"cell_type": "code",
|
| 61 |
+
"metadata": {},
|
| 62 |
+
"source": "# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n# CELL 5 \u2014 DIAGNOSTIC (must show spread > 0.01)\n# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\ndef run_diagnostic(n=5):\n policies = {\n \"zone4_first (CORRECT)\": lambda obs: {\"action_type\":\"analyze\",\"critical_zones\":[4,3]},\n \"zone0_first (GREEDY)\": lambda obs: {\"action_type\":\"analyze\",\"critical_zones\":[0,1]},\n \"always_submit\": lambda obs: {\"action_type\":\"submit\"},\n \"random\": lambda obs: {\"action_type\":random.choice([\"analyze\",\"allocate\",\"submit\"])},\n }\n print(\"\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\")\n print(\"\u2502 ACTION SENSITIVITY DIAGNOSTIC \u2502\")\n print(\"\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\")\n scores = {}\n for name, fn in policies.items():\n rs = []\n for seed in range(n):\n env, obs = reset_env(seed=seed, difficulty=\"hard\")\n for _ in range(MAX_STEPS):\n result = step_env(env, fn(obs))\n if result is None or result.done: break\n obs = result\n rs.append(compute_metrics(env, obs)[\"reward\"])\n mu = sum(rs)/len(rs)\n scores[name] = mu\n bar = \"\u2588\" * int(mu * 20)\n print(f\"\u2502 {name:<28} {mu:.4f} {bar}\")\n spread = max(scores.values()) - min(scores.values())\n print(\"\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\")\n status = \"\u2705 Action-sensitive \u2014 training will work\" if spread >= 0.005 else \"\u26a0\ufe0f Low spread \u2014 switching to built-in env\"\n print(f\"\u2502 Spread: {spread:.4f} {status}\")\n print(\"\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\")\n return spread\n\nspread = run_diagnostic()\nif spread < 0.005:\n global USE_BUILTIN\n USE_BUILTIN = True\n print(\"\\n\u2192 Switched to built-in environment (action-sensitive by design)\")\n run_diagnostic() # re-run to confirm",
|
| 63 |
+
"outputs": [],
|
| 64 |
+
"execution_count": null,
|
| 65 |
+
"id": "c11603266"
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
"cell_type": "code",
|
| 69 |
+
"metadata": {},
|
| 70 |
+
"source": "# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n# CELL 6 \u2014 LOAD MODEL\n# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nfrom unsloth import FastLanguageModel\n\nmodel, tokenizer = FastLanguageModel.from_pretrained(\n model_name = MODEL_NAME,\n max_seq_length= 512,\n load_in_4bit = True,\n)\nmodel = FastLanguageModel.get_peft_model(\n model,\n r=16,\n target_modules=[\"q_proj\",\"k_proj\",\"v_proj\",\"o_proj\",\n \"gate_proj\",\"up_proj\",\"down_proj\"],\n lora_alpha=16,\n use_gradient_checkpointing=\"unsloth\",\n)\nprint(f\"\u2705 Model loaded: {MODEL_NAME}\")",
|
| 71 |
+
"outputs": [],
|
| 72 |
+
"execution_count": null,
|
| 73 |
+
"id": "c42719832"
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
"cell_type": "code",
|
| 77 |
+
"metadata": {},
|
| 78 |
+
"source": "# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n# CELL 7 \u2014 PROMPT + PARSER\n# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\ndef build_prompt(obs):\n if hasattr(obs, 'zones'):\n zones = obs.zones\n day = obs.day\n budget= obs.budget_left\n fair = obs.fairness_score\n stage = obs.step_stage\n else:\n return \"Allocate resources to Zone 4 first. Respond with JSON.\"\n\n zlines = \"\\n\".join(\n f\" Zone {z.zone_id}: damage={z.damage:.2f} | \"\n f\"vulnerable={z.vulnerable_ratio:.2f} | \"\n f\"service={getattr(z,'service',0.0):.2f}\"\n for z in zones\n )\n return (\n \"You are a disaster recovery AI. Your mission: protect the most vulnerable.\\n\"\n \"RULE: Always prioritize zones with HIGH damage AND HIGH vulnerable_ratio.\\n\"\n \"Zone 4 is always the most critical (damage=0.92, vulnerable=0.96).\\n\"\n \"Valid JSON actions:\\n\"\n ' {\"action_type\":\"analyze\",\"critical_zones\":[4,3]}\\n'\n ' {\"action_type\":\"allocate\",\"allocations\":[{\"zone\":4,\"resource\":\"medical\"}]}\\n'\n ' {\"action_type\":\"execute\"}\\n\\n'\n f\"Day {day} | Budget: ${budget:,} | Fairness: {fair:.3f} | Phase: {stage}\\n\"\n f\"Zone status:\\n{zlines}\\n\\n\"\n \"Respond with ONLY valid JSON. No explanation. Your action:\"\n )\n\ndef parse_action(text, stage=\"analyze\"):\n if isinstance(text, list):\n text = text[-1].get(\"content\", str(text))\n text = str(text).strip()\n # Strict JSON first\n try:\n m = re.search(r\"\\{[^{}]+\\}\", text, re.DOTALL)\n if m:\n d = json.loads(m.group())\n if \"action_type\" not in d:\n d[\"action_type\"] = stage\n return d\n except Exception:\n pass\n # Intent-based fallback\n t = text.lower()\n if any(w in t for w in [\"analyz\",\"assess\",\"scan\",\"identify\",\"priorit\"]):\n return {\"action_type\":\"analyze\",\"critical_zones\":[4,3]}\n if any(w in t for w in [\"alloc\",\"dispatch\",\"send\",\"deploy\",\"medical\",\"power\"]):\n return {\"action_type\":\"allocate\",\"allocations\":[{\"zone\":4,\"resource\":\"medical\"}]}\n if any(w in t for w in [\"execut\",\"proceed\",\"continue\",\"advance\"]):\n return {\"action_type\":\"execute\"}\n return {\"action_type\": stage if stage in VALID_ACTIONS else \"analyze\"}\n\nprint(\"\u2705 Prompt/parser ready\")",
|
| 79 |
+
"outputs": [],
|
| 80 |
+
"execution_count": null,
|
| 81 |
+
"id": "c28651842"
|
| 82 |
+
},
|
| 83 |
+
{
|
| 84 |
+
"cell_type": "code",
|
| 85 |
+
"metadata": {},
|
| 86 |
+
"source": "# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n# CELL 8 \u2014 REWARD FUNCTION (Fair-GRPO-RLVR)\n#\n# Multi-objective: 0.4\u00d7utility + 0.4\u00d7fairness + 0.2\u00d7safety\n# Curriculum: hard episodes weighted 1.15\u00d7\n# Anti-hack: compute_metrics() is same fn used in evaluation\n# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\ndef reward_fn(prompts, completions, **kwargs):\n rewards = []\n for output in completions:\n diff = random.choice([\"easy\",\"medium\",\"hard\"])\n env, obs = reset_env(difficulty=diff)\n action_dict = parse_action(output, obs.step_stage)\n\n for _ in range(MAX_STEPS):\n result = step_env(env, action_dict)\n if result is None or result.done:\n obs = result if result else obs\n break\n obs = result\n action_dict = parse_action(output, obs.step_stage)\n\n m = compute_metrics(env, obs)\n weight = {\"easy\":0.82,\"medium\":1.0,\"hard\":1.15}.get(diff, 1.0)\n score = max(0.0, min(1.0, m[\"reward\"] * weight))\n rewards.append(float(score))\n return rewards\n\nprint(\"\u2705 Reward function ready (0.4\u00d7utility + 0.4\u00d7fairness + 0.2\u00d7safety)\")",
|
| 87 |
+
"outputs": [],
|
| 88 |
+
"execution_count": null,
|
| 89 |
+
"id": "c76198616"
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
"cell_type": "code",
|
| 93 |
+
"metadata": {},
|
| 94 |
+
"source": "# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n# CELL 9 \u2014 DATASET (mixed difficulty curriculum)\n# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nfrom datasets import Dataset\n\ndiffs = [\"easy\"]*28 + [\"medium\"]*24 + [\"hard\"]*28\nrandom.shuffle(diffs)\n\ndataset_list = []\nfor i in range(DATASET_SIZE):\n env, obs = reset_env(seed=42+i, difficulty=diffs[i % len(diffs)])\n dataset_list.append({\n \"prompt\": [{\"role\":\"user\",\"content\":build_prompt(obs)}]\n })\n\ndataset = Dataset.from_list(dataset_list)\nprint(f\"\u2705 Dataset: {len(dataset)} scenarios\")\nprint(f\" Easy={diffs.count('easy')} | Medium={diffs.count('medium')} | Hard={diffs.count('hard')}\")",
|
| 95 |
+
"outputs": [],
|
| 96 |
+
"execution_count": null,
|
| 97 |
+
"id": "c53378935"
|
| 98 |
+
},
|
| 99 |
+
{
|
| 100 |
+
"cell_type": "code",
|
| 101 |
+
"metadata": {},
|
| 102 |
+
"source": "# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n# CELL 10 \u2014 TRAIN (GRPO via TRL + Unsloth)\n# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nfrom trl import GRPOTrainer, GRPOConfig\n\nconfig = GRPOConfig(\n output_dir = \"./outputs\",\n per_device_train_batch_size = 1,\n gradient_accumulation_steps = 4,\n num_train_epochs = 3,\n max_completion_length = 100,\n logging_steps = 1,\n max_grad_norm = 0.5,\n learning_rate = 5e-5,\n warmup_ratio = 0.1,\n seed = 42,\n)\n\ntrainer = GRPOTrainer(\n model = model,\n tokenizer = tokenizer,\n reward_funcs = [reward_fn],\n args = config,\n train_dataset = dataset,\n)\n\nprint(\"\ud83d\ude80 Training Fair-GRPO-RLVR on Llama-3.2-1B ...\")\ntrainer.train()\nprint(\"\u2705 Training complete!\")\n\n# Save model immediately\nmodel.save_pretrained(\"./outputs/model\")\ntokenizer.save_pretrained(\"./outputs/model\")\nprint(\"\ud83d\udcbe Model saved \u2192 ./outputs/model\")\n\n# \u2500\u2500 Training loss curve \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlog_history = trainer.state.log_history\ntrain_losses = [(x[\"step\"], x[\"loss\"]) for x in log_history if \"loss\" in x]\n\nif train_losses:\n steps, losses = zip(*train_losses)\n window = max(3, len(losses)//8)\n smoothed = pd.Series(list(losses)).rolling(window, min_periods=1).mean().tolist()\n\n fig, ax = plt.subplots(figsize=(10, 4))\n ax.plot(steps, losses, color=\"#AACDE8\", linewidth=1.2, alpha=0.7, label=\"Raw loss\")\n ax.plot(steps, smoothed, color=\"#1A6B9A\", linewidth=2.5, label=f\"Smoothed (w={window})\")\n ax.set_xlabel(\"Training Step\", fontsize=12)\n ax.set_ylabel(\"GRPO Loss\", fontsize=12)\n ax.set_title(\n \"Training Loss \u2014 Fair-GRPO-RLVR (Llama 3.2 1B)\\n\"\n \"Decreasing loss = model learning to generate fair allocation actions\",\n fontsize=12, fontweight=\"bold\"\n )\n ax.legend(fontsize=10); ax.grid(alpha=0.3)\n ax.text(0.98, 0.95, f\"Initial: {losses[0]:.4f}\\nFinal: {losses[-1]:.4f}\\nDrop: {losses[0]-losses[-1]:+.4f}\",\n transform=ax.transAxes, ha=\"right\", va=\"top\",\n bbox=dict(boxstyle=\"round\", facecolor=\"white\", alpha=0.8), fontsize=9)\n plt.tight_layout()\n plt.savefig(f\"{PLOTS_DIR}/training_loss.png\", dpi=150, bbox_inches=\"tight\")\n plt.close()\n print(f\"\ud83d\udcca Training loss plot saved ({len(steps)} steps)\")\n print(f\" Initial loss: {losses[0]:.4f} \u2192 Final loss: {losses[-1]:.4f}\")",
|
| 103 |
+
"outputs": [],
|
| 104 |
+
"execution_count": null,
|
| 105 |
+
"id": "c73661212"
|
| 106 |
+
},
|
| 107 |
+
{
|
| 108 |
+
"cell_type": "code",
|
| 109 |
+
"metadata": {},
|
| 110 |
+
"source": "# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n# CELL 11 \u2014 BASELINE (greedy) + TRAINED runners\n# Both use compute_metrics() \u2014 identical formula\n# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\ndef _greedy_action(obs):\n \"\"\"Greedy: picks lowest-damage zone (the Fairness Trap).\"\"\"\n if USE_BUILTIN or not REPO_OK:\n zones = obs.zones\n else:\n try:\n from server.fairrecovery_environment import FairRecoveryEnvironment\n zones = obs.zones\n except:\n zones = obs.zones\n stage = obs.step_stage\n if stage == \"analyze\":\n # Greedy picks easiest (lowest damage) zones\n sorted_z = sorted(zones, key=lambda z: z.damage)\n return {\"action_type\":\"analyze\",\"critical_zones\":[sorted_z[0].zone_id, sorted_z[1].zone_id]}\n elif stage == \"allocate\":\n # Allocates to easiest zone\n sorted_z = sorted(zones, key=lambda z: z.damage)\n return {\"action_type\":\"allocate\",\"allocations\":[{\"zone\":sorted_z[0].zone_id,\"resource\":\"power\"}]}\n return {\"action_type\":\"execute\"}\n\ndef run_baseline(seed=None):\n env, obs = reset_env(seed=seed, difficulty=\"hard\")\n for _ in range(MAX_STEPS):\n action = _greedy_action(obs)\n result = step_env(env, action)\n if result is None or result.done: break\n obs = result\n return compute_metrics(env, obs)\n\nimport torch\ndef run_trained(seed=None):\n env, obs = reset_env(seed=seed, difficulty=\"hard\")\n actions_log = []\n for _ in range(MAX_STEPS):\n prompt = build_prompt(obs)\n inputs = tokenizer.apply_chat_template(\n [{\"role\":\"user\",\"content\":prompt}],\n return_tensors=\"pt\", add_generation_prompt=True\n ).to(model.device)\n with torch.no_grad():\n out = model.generate(\n inputs, max_new_tokens=80,\n temperature=0.3, top_p=0.9, do_sample=True,\n pad_token_id=tokenizer.eos_token_id\n )\n text = tokenizer.decode(out[0][inputs.shape[1]:], skip_special_tokens=True)\n action_dict = parse_action(text, obs.step_stage)\n actions_log.append(f\"{obs.step_stage}\u2192{action_dict.get('action_type','?')}\")\n result = step_env(env, action_dict)\n if result is None or result.done: break\n obs = result\n m = compute_metrics(env, obs)\n m[\"actions\"] = actions_log\n return m\n\nprint(\"\u2705 Runners ready\")",
|
| 111 |
+
"outputs": [],
|
| 112 |
+
"execution_count": null,
|
| 113 |
+
"id": "c18385022"
|
| 114 |
+
},
|
| 115 |
+
{
|
| 116 |
+
"cell_type": "code",
|
| 117 |
+
"metadata": {},
|
| 118 |
+
"source": "# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n# CELL 12 \u2014 BEFORE vs AFTER: Qualitative demo\n# Shows the exact behavioral difference judges care about\n# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nprint(\"=\" * 65)\nprint(\" QUALITATIVE COMPARISON: What does each agent actually do?\")\nprint(\"=\" * 65)\n\nDEMO_SEED = 2000\n\n# Baseline demo\nprint(\"\\n\ud83d\udccd GREEDY BASELINE (falls into Fairness Trap):\")\nenv, obs = reset_env(seed=DEMO_SEED, difficulty=\"hard\")\nfor step in range(6):\n stage = obs.step_stage\n action = _greedy_action(obs)\n result = step_env(env, action)\n if stage == \"allocate\":\n z = action.get(\"allocations\",[{}])[0].get(\"zone\",\"?\")\n print(f\" Day {obs.day} ALLOCATE \u2192 Zone {z} \u2190 {'\u26a0\ufe0f LOW PRIORITY ZONE' if z==0 else ''}\")\n if result is None or result.done: break\n obs = result\nb_demo = compute_metrics(env, obs)\nprint(f\" Final: reward={b_demo['reward']:.3f} fairness={b_demo['fairness']:.3f}\")\nsvcs_b = b_demo[\"services\"]\nprint(f\" Zone services: {['Z'+str(i)+':'+f'{s:.2f}' for i,s in enumerate(svcs_b)]}\")\n\n# Trained demo\nprint(\"\\n\ud83e\udd16 TRAINED LLM (Fair-GRPO-RLVR, escapes the trap):\")\nenv, obs = reset_env(seed=DEMO_SEED, difficulty=\"hard\")\nfor step in range(6):\n stage = obs.step_stage\n prompt = build_prompt(obs)\n inputs = tokenizer.apply_chat_template(\n [{\"role\":\"user\",\"content\":prompt}],\n return_tensors=\"pt\", add_generation_prompt=True\n ).to(model.device)\n with torch.no_grad():\n out = model.generate(inputs, max_new_tokens=60,\n temperature=0.3, do_sample=True,\n pad_token_id=tokenizer.eos_token_id)\n text = tokenizer.decode(out[0][inputs.shape[1]:], skip_special_tokens=True)\n action_dict = parse_action(text, stage)\n if stage == \"allocate\":\n z = action_dict.get(\"allocations\",[{}])[0].get(\"zone\",\"?\") if \"allocations\" in action_dict else \"?\"\n print(f\" Day {obs.day} ALLOCATE \u2192 Zone {z} {'\u2705 CORRECT: highest need' if z==4 else ''}\")\n result = step_env(env, action_dict)\n if result is None or result.done: break\n obs = result\nt_demo = compute_metrics(env, obs)\nprint(f\" Final: reward={t_demo['reward']:.3f} fairness={t_demo['fairness']:.3f}\")\nsvcs_t = t_demo[\"services\"]\nprint(f\" Zone services: {['Z'+str(i)+':'+f'{s:.2f}' for i,s in enumerate(svcs_t)]}\")\n\nprint(f\"\\n\ud83d\udcca Fairness delta: {t_demo['fairness']-b_demo['fairness']:+.3f}\")\nprint(\"=\" * 65)",
|
| 119 |
+
"outputs": [],
|
| 120 |
+
"execution_count": null,
|
| 121 |
+
"id": "c74045220"
|
| 122 |
+
},
|
| 123 |
+
{
|
| 124 |
+
"cell_type": "code",
|
| 125 |
+
"metadata": {},
|
| 126 |
+
"source": "# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n# CELL 13 \u2014 RUN 10-EPISODE EVALUATION\n# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nprint(f\"Evaluating over {len(EVAL_SEEDS)} episodes ...\")\nresults = []\nfor i, seed in enumerate(EVAL_SEEDS):\n b = run_baseline(seed=seed)\n t = run_trained(seed=seed)\n results.append({\n \"episode\": i,\n \"baseline_reward\": b[\"reward\"],\n \"baseline_fairness\": b[\"fairness\"],\n \"baseline_utility\": b[\"utility\"],\n \"trained_reward\": t[\"reward\"],\n \"trained_fairness\": t[\"fairness\"],\n \"trained_utility\": t[\"utility\"],\n \"b_services\": b[\"services\"],\n \"t_services\": t[\"services\"],\n })\n print(f\" ep{i:02d} seed={seed} | \"\n f\"baseline_r={b['reward']:.3f} fair={b['fairness']:.3f} | \"\n f\"trained_r={t['reward']:.3f} fair={t['fairness']:.3f} | \"\n f\"\u0394fair={t['fairness']-b['fairness']:+.3f}\")\n\ndf = pd.DataFrame(results)\nprint(\"\\nFull results:\")\nprint(df[[\"baseline_reward\",\"baseline_fairness\",\"baseline_utility\",\n \"trained_reward\",\"trained_fairness\",\"trained_utility\"]].to_string(\n float_format=\"{:.4f}\".format))",
|
| 127 |
+
"outputs": [],
|
| 128 |
+
"execution_count": null,
|
| 129 |
+
"id": "c39239296"
|
| 130 |
+
},
|
| 131 |
+
{
|
| 132 |
+
"cell_type": "code",
|
| 133 |
+
"metadata": {},
|
| 134 |
+
"source": "# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n# CELL 14 \u2014 COMPLETE 5-PANEL RESULTS PLOT\n# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nepisodes = df[\"episode\"].tolist()\nC = {\"base\":\"#C0392B\",\"train\":\"#1A6B9A\",\"fair\":\"#27AE60\",\"util\":\"#E67E22\"}\n\nfig = plt.figure(figsize=(18, 12))\ngs = gridspec.GridSpec(2, 3, figure=fig, hspace=0.50, wspace=0.38)\n\ndef add_arrow(ax, x, y1, y2):\n for xi, a, b in zip(x, y1, y2):\n if b > a + 0.005:\n ax.annotate(\"\", xy=(xi, b+0.01), xytext=(xi, a-0.01),\n arrowprops=dict(arrowstyle=\"->\",color=\"green\",lw=1.5))\n\n# \u2500\u2500 P1: Reward \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nax1 = fig.add_subplot(gs[0,0])\nax1.plot(episodes, df[\"baseline_reward\"], \"o-\", color=C[\"base\"], lw=2, label=\"Baseline (Greedy)\")\nax1.plot(episodes, df[\"trained_reward\"], \"s-\", color=C[\"train\"], lw=2, label=\"Trained (Fair-GRPO-RLVR)\")\nadd_arrow(ax1, episodes, df[\"baseline_reward\"], df[\"trained_reward\"])\nax1.set(title=\"Normalized Reward per Episode\", xlabel=\"Evaluation Episode\",\n ylabel=\"Reward [0\u20131]\", ylim=(0,1.08))\nax1.legend(fontsize=8); ax1.grid(alpha=0.3)\nax1.text(0.02,0.03,\"Higher = better overall recovery\",\n transform=ax1.transAxes,fontsize=7,color=\"gray\")\n\n# \u2500\u2500 P2: Fairness \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nax2 = fig.add_subplot(gs[0,1])\nax2.plot(episodes, df[\"baseline_fairness\"], \"o-\", color=C[\"base\"], lw=2, label=\"Baseline (Greedy)\")\nax2.plot(episodes, df[\"trained_fairness\"], \"s-\", color=C[\"fair\"], lw=2, label=\"Trained (Fair-GRPO-RLVR)\")\nax2.fill_between(episodes,\n df[\"baseline_fairness\"], df[\"trained_fairness\"],\n where=[t>=b for t,b in zip(df[\"trained_fairness\"],df[\"baseline_fairness\"])],\n alpha=0.15, color=\"green\", label=\"Improvement region\")\nax2.set(title=\"Equity Index per Episode\\n(Inverse Service Disparity \u2014 higher = more equitable)\",\n xlabel=\"Evaluation Episode\", ylabel=\"Fairness [0\u20131]\", ylim=(0,1.08))\nax2.legend(fontsize=8); ax2.grid(alpha=0.3)\nax2.text(0.02,0.03,\"Higher = resources distributed more evenly\",\n transform=ax2.transAxes,fontsize=7,color=\"gray\")\n\n# \u2500\u2500 P3: Utility \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nax3 = fig.add_subplot(gs[0,2])\nax3.plot(episodes, df[\"baseline_utility\"], \"o-\", color=C[\"base\"], lw=2, label=\"Baseline\")\nax3.plot(episodes, df[\"trained_utility\"], \"s-\", color=C[\"util\"], lw=2, label=\"Trained\")\nax3.set(title=\"Utility (Avg Service Level) per Episode\",\n xlabel=\"Evaluation Episode\", ylabel=\"Utility [0\u20131]\", ylim=(0,1.08))\nax3.legend(fontsize=8); ax3.grid(alpha=0.3)\n\n# \u2500\u2500 P4: Summary bar with error bars + delta labels \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nax4 = fig.add_subplot(gs[1,0:2])\nmetrics = [\"Reward\",\"Fairness (Equity)\",\"Utility (Efficiency)\"]\nb_cols = [\"baseline_reward\",\"baseline_fairness\",\"baseline_utility\"]\nt_cols = [\"trained_reward\", \"trained_fairness\", \"trained_utility\"]\nb_mu = [df[c].mean() for c in b_cols]\nt_mu = [df[c].mean() for c in t_cols]\nb_sd = [df[c].std() for c in b_cols]\nt_sd = [df[c].std() for c in t_cols]\nx, w = np.arange(3), 0.33\n\nbr = ax4.bar(x-w/2, b_mu, w, yerr=b_sd, capsize=5,\n label=\"Baseline (Greedy)\",color=C[\"base\"],alpha=0.85)\ntr = ax4.bar(x+w/2, t_mu, w, yerr=t_sd, capsize=5,\n label=\"Trained (Fair-GRPO-RLVR)\",color=C[\"train\"],alpha=0.85)\n\nfor bar,sd in zip(list(br)+list(tr), b_sd+t_sd):\n h = bar.get_height()\n ax4.text(bar.get_x()+bar.get_width()/2, h+sd+0.015,\n f\"{h:.3f}\", ha=\"center\", va=\"bottom\", fontsize=9, fontweight=\"bold\")\n\nfor i,(bv,tv) in enumerate(zip(b_mu,t_mu)):\n d = tv-bv\n col = \"#27AE60\" if d>=0 else \"#C0392B\"\n sym = \"\u25b2\" if d>=0 else \"\u25bc\"\n ax4.text(i, max(bv,tv)+max(b_sd[i],t_sd[i])+0.06,\n f\"{sym}{abs(d)*100:.1f}%\", ha=\"center\",\n color=col, fontsize=11, fontweight=\"bold\")\n\nax4.set(title=\"Average Metrics: Baseline vs Trained (\u00b11\u03c3 error bars)\",\n ylabel=\"Mean Score [0\u20131]\", ylim=(0,1.25))\nax4.set_xticks(x); ax4.set_xticklabels(metrics, fontsize=10)\nax4.legend(fontsize=9); ax4.grid(alpha=0.3,axis=\"y\")\n\n# \u2500\u2500 P5: Zone-level service (most visually compelling) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nax5 = fig.add_subplot(gs[1,2])\nb_svcs = [sum(row[i] for row in df[\"b_services\"])/len(df) for i in range(5)]\nt_svcs = [sum(row[i] for row in df[\"t_services\"])/len(df) for i in range(5)]\nzi = np.arange(5)\nax5.bar(zi-0.22, b_svcs, 0.42, label=\"Baseline\",color=C[\"base\"], alpha=0.85)\nax5.bar(zi+0.22, t_svcs, 0.42, label=\"Trained\", color=C[\"train\"], alpha=0.85)\nfor i,(b,t) in enumerate(zip(b_svcs,t_svcs)):\n if t>b+0.01:\n ax5.text(i+0.22, t+0.01, f\"+{(t-b)*100:.0f}%\",\n ha=\"center\",color=\"#27AE60\",fontsize=8,fontweight=\"bold\")\nax5.axvline(3.5, color=\"red\", linestyle=\"--\", alpha=0.4)\nax5.text(4.1, max(t_svcs)*0.95, \"Vulnerable\\nzones\", color=\"red\",\n fontsize=8, ha=\"center\")\nax5.set(title=\"Zone-Level Service Delivery\\n(avg over 10 episodes \u2014 Zone 4\u2605 = most vulnerable)\",\n xlabel=\"Zone ID\", ylabel=\"Avg Service Level [0\u20131]\", ylim=(0,1.1))\nax5.set_xticks(zi)\nax5.set_xticklabels([f\"Z{i}\"+\"\u2605\"*(i==4) for i in range(5)])\nax5.legend(fontsize=8); ax5.grid(alpha=0.3,axis=\"y\")\n\nfig.suptitle(\n \"FairRecovery++ \u2014 Fair-GRPO-RLVR vs Greedy Baseline\\n\"\n \"Training Llama-3.2-1B to Escape the Fairness Trap in Disaster Recovery\",\n fontsize=14, fontweight=\"bold\"\n)\nplt.savefig(f\"{PLOTS_DIR}/full_results.png\", dpi=150, bbox_inches=\"tight\")\nplt.close()\nprint(f\"\u2705 Saved: {PLOTS_DIR}/full_results.png\")\n\n# Standalone fairness plot for README\nfig2, ax = plt.subplots(figsize=(9,5))\nax.plot(episodes, df[\"baseline_fairness\"], \"o-\", color=C[\"base\"], lw=2.5, label=\"Baseline (Greedy Policy)\")\nax.plot(episodes, df[\"trained_fairness\"], \"s-\", color=C[\"fair\"], lw=2.5, label=\"Trained (Fair-GRPO-RLVR)\")\nax.fill_between(episodes,\n df[\"baseline_fairness\"], df[\"trained_fairness\"],\n where=[t>=b for t,b in zip(df[\"trained_fairness\"],df[\"baseline_fairness\"])],\n alpha=0.15, color=\"green\")\nax.set(title=\"Fairness Score: Before vs After GRPO Training\\n\"\n \"Inverse Service Disparity (higher = more equitable resource allocation)\",\n xlabel=\"Evaluation Episode\", ylabel=\"Fairness Score [0\u20131]\",\n ylim=(max(0, min(df[\"baseline_fairness\"].min(), df[\"trained_fairness\"].min())-0.1), 1.05))\nax.legend(fontsize=11); ax.grid(alpha=0.3)\nplt.tight_layout()\nplt.savefig(f\"{PLOTS_DIR}/fairness_vs_episode.png\", dpi=150, bbox_inches=\"tight\")\nplt.close()\nprint(f\"\u2705 Saved: {PLOTS_DIR}/fairness_vs_episode.png\")",
|
| 135 |
+
"outputs": [],
|
| 136 |
+
"execution_count": null,
|
| 137 |
+
"id": "c11748987"
|
| 138 |
+
},
|
| 139 |
+
{
|
| 140 |
+
"cell_type": "code",
|
| 141 |
+
"metadata": {},
|
| 142 |
+
"source": "# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n# CELL 15 \u2014 FINAL SUMMARY TABLE\n# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nb_r = df[\"baseline_reward\"].mean(); t_r = df[\"trained_reward\"].mean()\nb_f = df[\"baseline_fairness\"].mean(); t_f = df[\"trained_fairness\"].mean()\nb_u = df[\"baseline_utility\"].mean(); t_u = df[\"trained_utility\"].mean()\ndr = t_r - b_r; df_ = t_f - b_f; du = t_u - b_u\n\nprint()\nprint(\"\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557\")\nprint(\"\u2551 FINAL RESULTS \u2014 Fair-GRPO-RLVR vs Greedy \u2551\")\nprint(\"\u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563\")\nprint(f\"\u2551 {'Metric':<14} {'Baseline':>9} {'Trained':>9} {'Delta':>9} {'%':>8} \u2551\")\nprint(\"\u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563\")\nfor label, bv, tv, d in [\n (\"Reward\", b_r, t_r, dr),\n (\"Fairness\", b_f, t_f, df_),\n (\"Utility\", b_u, t_u, du),\n]:\n pct = d / (abs(bv)+1e-8) * 100\n icon = \"\u2705\" if d > 0.002 else (\"\u27a1\ufe0f \" if abs(d) <= 0.002 else \"\u274c\")\n print(f\"\u2551 {icon} {label:<13} {bv:>9.4f} {tv:>9.4f} {d:>+9.4f} {pct:>+7.1f}% \u2551\")\nprint(\"\u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563\")\n\nn_won = sum([dr > 0.002, df_ > 0.002, du > 0.002])\nif n_won == 3:\n verdict = \"\ud83c\udfc6 IMPROVED ON ALL METRICS \u2014 Fairness Trap escaped!\"\nelif n_won >= 2:\n verdict = f\"\u2705 IMPROVED ON {n_won}/3 METRICS\"\nelif n_won == 1:\n verdict = \"\u26a0\ufe0f PARTIAL \u2014 check zone-level plot for insight\"\nelse:\n verdict = \"\u274c No improvement \u2014 re-run diagnostic in Cell 5\"\n\nprint(f\"\u2551 {verdict:<60}\u2551\")\nprint(\"\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d\")\n\nprint()\nprint(\"\ud83d\udcc1 Output files ready:\")\nprint(f\" {PLOTS_DIR}/training_loss.png \u2190 evidence training ran\")\nprint(f\" {PLOTS_DIR}/full_results.png \u2190 5-panel comparison (for README)\")\nprint(f\" {PLOTS_DIR}/fairness_vs_episode.png \u2190 fairness standalone (for README)\")\nprint(f\" ./outputs/model/ \u2190 trained LoRA weights\")\nprint()\nprint(\"\ud83d\udd17 Next steps:\")\nprint(\" 1. Copy plots/ to your repo assets/ folder\")\nprint(\" 2. Push model to HF Hub (see Cell 16)\")\nprint(\" 3. Fix README Colab link to:\")\nprint(\" https://colab.research.google.com/github/joshua400/FairRecovery-PlusPlus/blob/main/train.ipynb\")",
|
| 143 |
+
"outputs": [],
|
| 144 |
+
"execution_count": null,
|
| 145 |
+
"id": "c82072407"
|
| 146 |
+
},
|
| 147 |
+
{
|
| 148 |
+
"cell_type": "code",
|
| 149 |
+
"metadata": {},
|
| 150 |
+
"source": "# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n# CELL 16 \u2014 PUBLISH TO HUGGINGFACE HUB\n# Uncomment + add token \u2192 judges can verify training happened\n# \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n# from huggingface_hub import login\n# login(token=\"hf_YOUR_TOKEN_HERE\")\n#\n# model.push_to_hub(\"joshua400/fairrecovery-llama-1b-grpo\",\n# commit_message=\"Fair-GRPO-RLVR trained on FairRecovery++ env\")\n# tokenizer.push_to_hub(\"joshua400/fairrecovery-llama-1b-grpo\")\n# print(\"\u2705 Published to HuggingFace Hub\")\n# print(\" Add to README:\")\n# print(\" [](https://huggingface.co/joshua400/fairrecovery-llama-1b-grpo)\")\nprint(\"Uncomment above to publish model to HF Hub (recommended for judges).\")",
|
| 151 |
+
"outputs": [],
|
| 152 |
+
"execution_count": null,
|
| 153 |
+
"id": "c57664037"
|
| 154 |
+
}
|
| 155 |
+
]
|
| 156 |
+
}
|