joshua400 commited on
Commit
9cfc074
·
1 Parent(s): 29c0898

🚀 STRUCTURAL REBUILD: Fully aligned FairRecovery++ with OpenEnv reference patterns and stateful grading

Browse files
fairrecovery_env/constants.py CHANGED
@@ -1,14 +1,9 @@
1
  """
2
- FairRecovery++ — Constants and Configuration (Fair-GRPO-RLVR).
3
 
4
- All configurable values are centralised here.
5
- No hardcoded magic numbers anywhere else in the codebase.
6
-
7
- Multi-agent adaptive environment for post-disaster recovery implementing the Fair-GRPO-RLVR methodology.
8
  """
9
 
10
- from __future__ import annotations
11
-
12
  from enum import Enum, unique
13
  from typing import Final
14
 
@@ -16,150 +11,78 @@ from typing import Final
16
  # ──────────────────────────────────────────────────────────────────────────────
17
  # Environment Metadata
18
  # ──────────────────────────────────────────────────────────────────────────────
19
- ENV_NAME: Final[str] = "fairrecovery"
20
  ENV_VERSION: Final[str] = "2.0.0"
21
  ENV_DESCRIPTION: Final[str] = (
22
- "An adaptive multi-agent OpenEnv environment where an AI planner coordinates "
23
- "post-disaster recovery while interacting with dynamic agents (citizens, NGOs, "
24
- "adversaries). Learns to optimise fairness and efficiency while adapting to "
25
- "evolving behavioral patterns. Designed for RLVR training via Fair-GRPO-RLVR methodology."
26
  )
27
 
28
  # ──────────────────────────────────────────────────────────────────────────────
29
  # Episode Configuration
30
  # ──────────────────────────────────────────────────────────────────────────────
31
- MAX_DAYS: Final[int] = 5 # episode length (days)
32
- MAX_ACTIONS_PER_DAY: Final[int] = 3 # max allocations per execute step
33
- MAX_STEPS_SAFETY_CAP: Final[int] = 50 # hard cap on total steps
34
- DEFAULT_SEED: Final[int] = 42
35
-
36
- # ──────────────────────────────────────────────────────────────────────────────
37
- # Curriculum / Trajectory Shaping (post-hoc structural fix)
38
- # ──────────────────────────────────────────────────────────────────────────────
39
- # MIN_STEPS — agent MUST take at least this many steps before `submit` is honored.
40
- # Submitting earlier is a no-op + small penalty (does NOT end episode).
41
- # CURRICULUM_MAX_STEPS — denominator for curriculum-progress weighting.
42
- # FINAL_BONUS_WEIGHT_UTILITY / _FAIRNESS — applied once on episode end (long-horizon).
43
- # EARLY_SUBMIT_PENALTY — small immediate penalty for trying to exit early.
44
- MIN_STEPS: Final[int] = 4
45
- CURRICULUM_MAX_STEPS: Final[int] = 12
46
- EARLY_SUBMIT_PENALTY: Final[float] = 0.15
47
- FINAL_BONUS_WEIGHT_UTILITY: Final[float] = 0.5
48
- FINAL_BONUS_WEIGHT_FAIRNESS: Final[float] = 0.5
49
 
50
  # ──────────────────────────────────────────────────────────────────────────────
51
- # Resource Definitions
52
  # ──────────────────────────────────────────────────────────────────────────────
53
- RESOURCE_COSTS: Final[dict] = {
54
- "power": 10,
55
- "water": 15,
56
- "medical": 20,
57
- }
58
-
59
- RESOURCE_EFFECTS: Final[dict] = {
60
- "power": {"service": 0.20, "damage": -0.10},
61
- "water": {"service": 0.30, "damage": -0.15},
62
- "medical": {"service": 0.40, "damage": -0.20},
63
- }
64
 
65
- # ──────────────────────────────────────────────────────────────────────────────
66
- # Reward Weights (RLVR — fully deterministic, no learned model)
67
- # ──────────────────────────────────────────────────────────────────────────────
68
- REWARD_WEIGHTS: Final[dict] = {
69
- "exec": 0.40, # service improvement (utility)
70
- "fair": 0.40, # fairness (disparity reduction)
71
- "safe": 0.20, # constraint satisfaction (safety)
72
- }
73
-
74
- # ──────────────────────────────────────────────────────────────────────────────
75
- # Penalties
76
- # ──────────────────────────────────────────────────────────────────────────────
77
- PENALTY_INVALID_ACTION: Final[float] = -0.5
78
- PENALTY_INVALID_ZONE: Final[float] = -0.3
79
- PENALTY_INVALID_RESOURCE: Final[float] = -0.3
80
- PENALTY_BUDGET_EXCEEDED: Final[float] = -0.2
81
- PENALTY_IGNORE_VULNERABLE: Final[float] = -0.3
82
- PENALTY_WRONG_STAGE: Final[float] = -0.1
83
- PENALTY_REPEATED_ACTION: Final[float] = -0.05
84
- PENALTY_PATTERN_IGNORED: Final[float] = -0.15
85
- PENALTY_ADVERSARIAL_FAILURE: Final[float] = -0.2
86
 
87
  # ──────────────────────────────────────────────────────────────────────────────
88
- # Thresholds
89
  # ──────────────────────────────────────────────────────────────────────────────
90
- VULNERABILITY_THRESHOLD: Final[float] = 0.6
91
- SPAN_OVERLAP_THRESHOLD: Final[float] = 0.3
92
- COMPLAINT_RATE_HIGH: Final[float] = 0.7
93
- RISK_THRESHOLD: Final[float] = 0.6
94
 
95
- # Grader score bounds strict open interval (never exactly 0 or 1)
96
- GRADER_SCORE_MIN: Final[float] = 0.01
97
- GRADER_SCORE_MAX: Final[float] = 0.99
98
 
99
  # ──────────────────────────────────────────────────────────────────────────────
100
- # Multi-Agent Configuration
101
  # ──────────────────────────────────────────────────────────────────────────────
102
- CITIZEN_SATISFACTION_DECAY: Final[float] = 0.05
103
- NGO_RESOURCE_MULTIPLIER: Final[float] = 1.2
104
- ADVERSARY_DISRUPTION_CHANCE: Final[float] = 0.3
105
- MAX_INTERACTION_LOG_SIZE: Final[int] = 200
106
 
107
  # ──────────────────────────────────────────────────────────────────────────────
108
  # Enums
109
  # ──────────────────────────────────────────────────────────────────────────────
110
- @unique
111
- class Difficulty(str, Enum):
112
- """Task difficulty levels."""
113
- EASY = "easy"
114
- MEDIUM = "medium"
115
- HARD = "hard"
116
-
117
-
118
  @unique
119
  class ResourceType(str, Enum):
120
- """Available disaster-recovery resources."""
121
- POWER = "power"
122
- WATER = "water"
123
  MEDICAL = "medical"
124
-
 
 
125
 
126
  @unique
127
  class ActionType(str, Enum):
128
- """
129
- Agent action types — multi-step protocol:
130
- analyze → prioritize → allocate → execute → adapt → submit
131
- """
132
- ANALYZE = "analyze"
133
  ALLOCATE = "allocate"
134
- EXECUTE = "execute"
135
- ADAPT = "adapt"
136
- SUBMIT = "submit"
137
- NOOP = "noop"
138
-
139
 
140
  @unique
141
- class AgentType(str, Enum):
142
- """Types of agents in the multi-agent system."""
143
- PLANNER = "planner"
144
- CITIZEN = "citizen"
145
- NGO = "ngo"
146
- ADVERSARY = "adversary"
147
-
148
 
149
  @unique
150
- class EventType(str, Enum):
151
- """Types of events that agents can generate."""
152
- COMPLAINT = "complaint"
153
- RESOURCE_OFFER = "resource_offer"
154
- DISRUPTION = "disruption"
155
- COOPERATION = "cooperation"
156
- PROTEST = "protest"
157
- AID_DELIVERY = "aid_delivery"
158
-
159
-
160
- # Stage ordering for protocol enforcement
161
- STAGE_SEQUENCE: Final[list] = [
162
- ActionType.ANALYZE,
163
- ActionType.ALLOCATE,
164
- ActionType.EXECUTE,
165
- ]
 
1
  """
2
+ FairRecovery++ — Constants and Configuration.
3
 
4
+ All configurable values are centralized here. No hardcoded magic numbers elsewhere.
 
 
 
5
  """
6
 
 
 
7
  from enum import Enum, unique
8
  from typing import Final
9
 
 
11
  # ──────────────────────────────────────────────────────────────────────────────
12
  # Environment Metadata
13
  # ──────────────────────────────────────────────────────────────────────────────
14
+ ENV_NAME: Final[str] = "fair_recovery_gym"
15
  ENV_VERSION: Final[str] = "2.0.0"
16
  ENV_DESCRIPTION: Final[str] = (
17
+ "An OpenEnv environment for fair disaster recovery. Agents must allocate "
18
+ "scarce resources across zones while balancing efficiency and social equity."
 
 
19
  )
20
 
21
  # ──────────────────────────────────────────────────────────────────────────────
22
  # Episode Configuration
23
  # ──────────────────────────────────────────────────────────────────────────────
24
+ MAX_STEPS_PER_EPISODE: Final[int] = 30 # 10 days * 3 phases/day
25
+ MAX_DAYS: Final[int] = 10
26
+ BUDGET_INITIAL: Final[float] = 1.0 # Normalized total budget
27
+ NUM_ZONES: Final[int] = 5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
  # ──────────────────────────────────────────────────────────────────────────────
30
+ # Reward Weighting (The Honest Truth Formula)
31
  # ──────────────────────────────────────────────────────────────────────────────
32
+ WEIGHT_UTILITY: Final[float] = 0.4
33
+ WEIGHT_FAIRNESS: Final[float] = 0.4
34
+ WEIGHT_SAFETY: Final[float] = 0.2
 
 
 
 
 
 
 
 
35
 
36
+ # Grader score bounds (strictly between 0 and 1)
37
+ 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
 
46
 
47
+ REWARD_STABILITY: Final[float] = 0.01 # Per step for avoiding deterioration
 
 
48
 
49
  # ──────────────────────────────────────────────────────────────────────────────
50
+ # Resource Costs (Normalized)
51
  # ──────────────────────────────────────────────────────────────────────────────
52
+ COST_MEDICAL: Final[float] = 0.05
53
+ COST_WATER: Final[float] = 0.03
54
+ COST_POWER: Final[float] = 0.07
 
55
 
56
  # ──────────────────────────────────────────────────────────────────────────────
57
  # Enums
58
  # ──────────────────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
59
  @unique
60
  class ResourceType(str, Enum):
61
+ """Available recovery resources."""
 
 
62
  MEDICAL = "medical"
63
+ WATER = "water"
64
+ POWER = "power"
65
+ NONE = "none"
66
 
67
  @unique
68
  class ActionType(str, Enum):
69
+ """Available agent actions."""
70
+ ANALYZE = "analyze"
 
 
 
71
  ALLOCATE = "allocate"
72
+ EXECUTE = "execute"
73
+ SUBMIT = "submit"
74
+ NOOP = "noop"
 
 
75
 
76
  @unique
77
+ class Difficulty(str, Enum):
78
+ """Task difficulty levels."""
79
+ EASY = "easy"
80
+ MEDIUM = "medium"
81
+ HARD = "hard"
 
 
82
 
83
  @unique
84
+ class TaskID(str, Enum):
85
+ """Identifiers for the scenarios."""
86
+ FLOOD_EASY = "flood_easy"
87
+ EARTHQUAKE_MEDIUM = "earthquake_medium"
88
+ MULTI_DISASTER_HARD = "multi_disaster_hard"
 
 
 
 
 
 
 
 
 
 
 
fairrecovery_env/models.py CHANGED
@@ -1,287 +1,65 @@
1
  """
2
- FairRecovery++ — Pydantic Models.
3
 
4
- Typed Action, Observation, and State models conforming to the OpenEnv spec.
5
- Uses Literal types for enum fields so the OpenEnv Gradio web interface
6
- renders them as dropdown selectors instead of free-text inputs.
7
-
8
- Mirrors the exact pattern from the reference hallucination-detector-gym.
9
  """
10
 
11
  from __future__ import annotations
12
-
13
- from typing import Any, Dict, List, Literal, Optional
14
-
15
- from pydantic import BaseModel, ConfigDict, Field, model_validator
16
-
17
- try:
18
- from openenv.core.env_server.types import (
19
- Action as BaseAction,
20
- Observation as BaseObservation,
21
- State as BaseState,
22
- )
23
- except ImportError:
24
- # Fallback for environments without openenv installed
25
- BaseAction = BaseModel # type: ignore[misc, assignment]
26
- BaseObservation = BaseModel # type: ignore[misc, assignment]
27
- BaseState = BaseModel # type: ignore[misc, assignment]
28
-
29
- from .constants import ActionType, Difficulty, ResourceType
30
-
31
-
32
- # ──────────────────────────────────────────────────────────────────────────────
33
- # Literal type aliases — renders as dropdowns in OpenEnv Gradio UI
34
- # ──────────────────────────────────────────────────────────────────────────────
35
- ActionTypeLiteral = Literal["analyze", "allocate", "execute", "adapt", "submit", "noop"]
36
- ResourceTypeLiteral = Literal["power", "water", "medical"]
37
- DifficultyLiteral = Literal["easy", "medium", "hard"]
38
-
39
-
40
- def _flatten_enum_from_anyof(schema: Dict[str, Any]) -> None:
41
- """Post-process JSON schema for OpenEnv Gradio UI compatibility.
42
-
43
- Pydantic v2 wraps Optional[...] in anyOf and uses $ref for enums,
44
- hiding the 'enum' key the Gradio UI needs to render dropdowns.
45
- This promotes them to top-level so widgets render correctly.
46
- """
47
- defs = schema.get("$defs", {})
48
-
49
- for _name, prop in schema.get("properties", {}).items():
50
- # Resolve $ref → inline enum
51
- if "$ref" in prop and "enum" not in prop:
52
- ref_name = prop["$ref"].rsplit("/", 1)[-1]
53
- ref_def = defs.get(ref_name, {})
54
- if "enum" in ref_def:
55
- prop["enum"] = ref_def["enum"]
56
-
57
- # Lift enum + type from anyOf (for Optional[Literal[...]])
58
- if "anyOf" in prop:
59
- for variant in prop["anyOf"]:
60
- if variant.get("type") == "null":
61
- continue
62
- if "enum" not in prop and "enum" in variant:
63
- prop["enum"] = variant["enum"]
64
- if "type" not in prop and "type" in variant:
65
- prop["type"] = variant["type"]
66
- if "maxLength" not in prop and "maxLength" in variant:
67
- prop["maxLength"] = variant["maxLength"]
68
- if "enum" not in prop and "$ref" in variant:
69
- ref_name = variant["$ref"].rsplit("/", 1)[-1]
70
- ref_def = defs.get(ref_name, {})
71
- if "enum" in ref_def:
72
- prop["enum"] = ref_def["enum"]
73
-
74
- # Reorder properties for logical action-building flow
75
- desired_order = [
76
- "action_type",
77
- "difficulty",
78
- "critical_zones",
79
- "reasoning",
80
- "allocations",
81
- "adaptation_strategy",
82
- "metadata",
83
- ]
84
- props = schema.get("properties", {})
85
- ordered: Dict[str, Any] = {}
86
- for key in desired_order:
87
- if key in props:
88
- ordered[key] = props[key]
89
- for key, val in props.items():
90
- if key not in ordered:
91
- ordered[key] = val
92
- schema["properties"] = ordered
93
-
94
-
95
- # ──────────────────────────────────────────────────────────────────────────────
96
- # Sub-models (use plain BaseModel to avoid MRO issues with OpenEnv)
97
- # ──────────────────────────────────────────────────────────────────────────────
98
- class AllocationItem(BaseModel):
99
- """Single resource allocation to a zone."""
100
-
101
- model_config = ConfigDict(populate_by_name=True)
102
-
103
- zone: int = Field(
104
- ...,
105
- ge=0,
106
- title="Zone Index",
107
- description="Zone index (0-based). Must be a valid zone in the current scenario.",
108
- )
109
- resource: ResourceTypeLiteral = Field(
110
- ...,
111
- title="Resource Type",
112
- description="Resource to deploy: 'power' (cost 10), 'water' (cost 15), 'medical' (cost 20).",
113
- )
114
-
115
-
116
- class ZoneObservation(BaseModel):
117
- """Per-zone state visible to the agent."""
118
-
119
- model_config = ConfigDict(populate_by_name=True)
120
-
121
- zone_id: int = Field(..., description="Zone identifier (0-based).")
122
- damage: float = Field(..., ge=0.0, le=1.0, description="Damage level: 1.0=destroyed, 0.0=intact.")
123
- service: float = Field(..., ge=0.0, le=1.0, description="Service availability: 1.0=full, 0.0=none.")
124
- vulnerable_ratio: float = Field(..., ge=0.0, le=1.0, description="Fraction of population that is vulnerable.")
125
- citizen_satisfaction: float = Field(default=0.5, ge=0.0, le=1.0, description="Citizen satisfaction in this zone.")
126
- risk_level: float = Field(default=0.0, ge=0.0, le=1.0, description="Predicted risk level for this zone.")
127
-
128
-
129
- class AgentEvent(BaseModel):
130
- """A single event generated by an agent in the multi-agent system."""
131
-
132
- agent_type: str = Field(..., description="Type of agent: citizen, ngo, adversary.")
133
- event_type: str = Field(..., description="Type of event: complaint, resource_offer, disruption, etc.")
134
- zone_id: int = Field(..., description="Zone this event affects.")
135
- intensity: float = Field(default=0.5, ge=0.0, le=1.0, description="Event intensity/severity.")
136
- message: str = Field(default="", description="Human-readable event description.")
137
- timestamp: int = Field(default=0, description="Step/day when the event occurred.")
138
-
139
-
140
- # ──────────────────────────────────────────────────────────────────────────────
141
- # Action
142
- # ──────────────────────────────────────────────────────────────────────────────
143
- class FairRecoveryAction(BaseAction):
144
- """
145
- Action the agent submits each step.
146
-
147
- Protocol (repeat MAX_DAYS times, then submit):
148
- 1. analyze — identify critical zones + reasoning
149
- 2. allocate — queue resource allocations
150
- 3. execute — commit allocations, receive reward
151
- 4. adapt — respond to agent events and predictions (optional)
152
- 5. submit — terminate episode, receive final score
153
- """
154
-
155
- model_config = ConfigDict(json_schema_extra=_flatten_enum_from_anyof)
156
-
157
- action_type: ActionTypeLiteral = Field(
158
- default="noop",
159
- title="Action Type",
160
- description=(
161
- "Protocol stage: analyze → allocate → execute → adapt → submit. "
162
- "Skipping stages incurs penalties."
163
- ),
164
- )
165
- difficulty: Optional[DifficultyLiteral] = Field(
166
- default=None,
167
- title="Difficulty (reset only)",
168
- description="Scenario difficulty. Only used when action_type='reset'.",
169
- )
170
- critical_zones: Optional[List[int]] = Field(
171
- default=None,
172
- title="Critical Zones",
173
- description="Zone indices identified as critical. Used during 'analyze' step.",
174
- )
175
- reasoning: Optional[str] = Field(
176
- default=None,
177
- title="Reasoning",
178
- max_length=2000,
179
- description="Chain-of-thought reasoning. Not scored but helpful for training.",
180
- )
181
- allocations: Optional[List[AllocationItem]] = Field(
182
- default=None,
183
- title="Allocations",
184
- description="List of {zone, resource} pairs to deploy. Used during 'allocate' step.",
185
- )
186
- adaptation_strategy: Optional[str] = Field(
187
- default=None,
188
- title="Adaptation Strategy",
189
- max_length=1000,
190
- description="Strategy for responding to agent events/predictions during 'adapt' step.",
191
- )
192
- metadata: Optional[Dict[str, Any]] = Field(
193
- default=None,
194
- title="Metadata",
195
- description="Optional metadata for debugging.",
196
- )
197
-
198
- @model_validator(mode="after")
199
- def _coerce_action_type(self) -> "FairRecoveryAction":
200
- """Coerce string action_type to ActionType enum."""
201
- if isinstance(self.action_type, str):
202
- try:
203
- object.__setattr__(self, "action_type", ActionType(self.action_type))
204
- except ValueError:
205
- pass
206
- return self
207
-
208
-
209
- # ──────────────────────────────────────────────────────────────────────────────
210
- # Observation
211
- # ──────────────────────────────────────────────────────────────────────────────
212
- class FairRecoveryObservation(BaseObservation):
213
- """
214
- Observation returned to the agent after each step / reset.
215
-
216
- Contains full zone state, budget, day counter, step feedback,
217
- component reward breakdown, multi-agent events, and predictions.
218
- """
219
-
220
- # Core state
221
- zones: List[ZoneObservation] = Field(
222
- default_factory=list,
223
- description="Current state of all recovery zones.",
224
- )
225
- day: int = Field(default=0, description="Current day number (0-indexed).")
226
- budget_left: float = Field(default=0.0, description="Remaining resource budget.")
227
- step_stage: str = Field(default="analyze", description="Expected action type for next step.")
228
-
229
- # Scoring
230
- fairness_score: float = Field(default=0.0, description="Current fairness score.")
231
- step_feedback: Optional[str] = Field(default=None, description="Textual feedback from last action.")
232
- steps_remaining: int = Field(default=0, description="Steps remaining in the episode.")
233
- cumulative_reward: float = Field(default=0.0, description="Running total reward.")
234
-
235
- # Per-component reward breakdown (RLVR transparency)
236
- r_exec: float = Field(default=0.0, description="Execution reward component.")
237
- r_fair: float = Field(default=0.0, description="Fairness reward component.")
238
- r_safe: float = Field(default=0.0, description="Safety reward component.")
239
- r_adapt: float = Field(default=0.0, description="Adaptation reward component.")
240
- r_stable: float = Field(default=0.0, description="Stability reward component.")
241
-
242
- # Episode control (CRITICAL — required by OpenEnv)
243
- done: bool = Field(default=False, description="Whether the episode has ended.")
244
- reward: float = Field(default=0.0, description="Reward for this step.")
245
- info: Dict[str, Any] = Field(
246
- default_factory=dict,
247
- description="Transparent reward breakdown for training/analysis.",
248
- )
249
-
250
- # History
251
- action_history: List[str] = Field(default_factory=list, description="Summary of actions taken.")
252
- grader_score: Optional[float] = Field(
253
- default=None,
254
- description="Normalised task score in (0.01, 0.99). Set when done=True.",
255
- )
256
-
257
- # Multi-agent events
258
- agent_events: List[AgentEvent] = Field(
259
- default_factory=list,
260
- description="Events from citizens, NGOs, and adversaries this step.",
261
- )
262
- predictions: Optional[Dict[str, Any]] = Field(
263
- default=None,
264
- description="Predicted next events and risk levels from the behavior analyzer.",
265
- )
266
-
267
-
268
- # ──────────────────────────────────────────────────────────────────────────────
269
- # State (internal — exposed via GET /state)
270
- # ──────────────────────────────────────────────────────────────────────────────
271
- class FairRecoveryState(BaseState):
272
- """Internal environment state exposed via state() endpoint."""
273
-
274
- episode_id: Optional[str] = Field(default=None, description="Unique episode identifier.")
275
- difficulty: Optional[str] = Field(default=None, description="Current scenario difficulty.")
276
- day: int = Field(default=0, description="Current day.")
277
- budget_left: float = Field(default=0.0, description="Remaining budget.")
278
- step_stage: str = Field(default="analyze", description="Next expected action.")
279
- step_count: int = Field(default=0, description="Total steps taken.")
280
- cumulative_reward: float = Field(default=0.0, description="Total reward this episode.")
281
- fairness_score: float = Field(default=0.0, description="Current fairness score.")
282
- is_done: bool = Field(default=False, description="Whether episode has ended.")
283
- violations_total: int = Field(default=0, description="Total safety violations.")
284
- zones: List[ZoneObservation] = Field(default_factory=list, description="Zone snapshots.")
285
- active_agents: int = Field(default=0, description="Number of active agents.")
286
- adversarial_events: int = Field(default=0, description="Adversarial events this episode.")
287
- adaptation_score: float = Field(default=0.0, description="How well agent adapted.")
 
1
  """
2
+ FairRecovery++ — Domain Models.
3
 
4
+ Strict Pydantic models for Actions, Observations, and internal State.
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
+ from typing import List, Optional, Any, Dict
9
+ from pydantic import BaseModel, Field
10
+ from .constants import ResourceType, ActionType, Difficulty, TaskID
11
+
12
+
13
+ class ZoneState(BaseModel):
14
+ """Current state of a specific geographic zone."""
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
+ service_level: float = Field(0.0, ge=0.0, le=1.0)
19
+ history: List[float] = []
20
+
21
+
22
+ class ResourceAllocation(BaseModel):
23
+ """Specific resource assignment to a zone."""
24
+ resource: ResourceType
25
+ zone: int
26
+ amount: float = 1.0
27
+
28
+
29
+ class FairRecoveryAction(BaseModel):
30
+ """Action taken by the agent."""
31
+ action_type: ActionType
32
+ critical_zones: Optional[List[int]] = None
33
+ allocations: Optional[List[ResourceAllocation]] = None
34
+ reasoning: Optional[str] = None
35
+
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]
44
+ fairness_score: float
45
+ step_stage: str
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] = {}
52
+
53
+
54
+ class FairRecoveryState(BaseModel):
55
+ """Internal state of the environment."""
56
+ episode_id: str
57
+ step_count: int = 0
58
+ day: int = 1
59
+ budget_remaining: float = 1.0
60
+ zones: List[ZoneState]
61
+ violations_total: int = 0
62
+ is_done: bool = False
63
+ task_id: TaskID = TaskID.FLOOD_EASY
64
+ difficulty: Difficulty = Difficulty.EASY
65
+ cumulative_reward: float = 0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fairrecovery_env/rewards.py CHANGED
@@ -1,147 +1,110 @@
1
  """
2
- FairRecovery++ - Reward Engine (Fair-GRPO-RLVR).
3
 
4
- Computes dense, verifiable, formula-based rewards - no learned reward model.
5
- Implements the Fair-GRPO-RLVR multi-objective reinforcement learning framework.
6
-
7
- R_total = 0.4*Utility + 0.4*Fairness + 0.2*Safety
8
  """
9
 
10
  from __future__ import annotations
11
  import structlog
12
- from dataclasses import dataclass, field
13
- from typing import List
14
- from .constants import (GRADER_SCORE_MAX, GRADER_SCORE_MIN, MAX_DAYS)
15
- from .state import CityState, ZoneState
16
- from .tasks import ScenarioConfig
 
 
 
 
17
 
18
  logger = structlog.get_logger(__name__)
19
 
20
-
21
- def compute_exec_reward(zones: List[ZoneState]) -> float:
22
- """Utility: Mean service level [0, 1]."""
23
- if not zones:
24
- return 0.0
25
- services = [z.service for z in zones]
26
- return float(sum(services) / len(services))
27
-
28
-
29
- def compute_fairness_reward(zones: List[ZoneState]) -> float:
30
- """
31
- Equity: 1 - Mean Absolute Deviation.
32
- Higher value means more equitable distribution of services.
33
- """
34
- if not zones:
35
- return 0.0
36
- services = [z.service for z in zones]
37
- mean_svc = sum(services) / len(services)
38
- if not services: return 0.0
39
-
40
- disparity = sum(abs(s - mean_svc) for s in services) / len(services)
41
- # Fairness index in [0, 1]
42
- return float(max(0.0, 1.0 - disparity))
43
-
44
-
45
- def compute_safety_reward(violations: List[str]) -> float:
46
- """Safety: Normalized violation count [0, 1]."""
47
- return float(max(0.0, 1.0 - len(violations) / 10.0))
48
-
49
-
50
- def compute_analysis_reward(chosen_zones: List[int], zones: List[ZoneState]) -> float:
51
- """Partial reward for correctly identifying critical zones."""
52
- if not zones or not chosen_zones:
53
- return 0.0
54
- k = max(1, len(zones) // 2)
55
- ranked = sorted(range(len(zones)),
56
- key=lambda i: zones[i].damage * zones[i].vulnerable_ratio, reverse=True)
57
- top_k = set(ranked[:k])
58
- return float(len(top_k & set(chosen_zones)) / k)
59
-
60
-
61
- @dataclass
62
- class RewardComponents:
63
- """Named reward components for a single step."""
64
- R_exec: float = 0.0
65
- R_fair: float = 0.0
66
- R_safe: float = 0.0
67
- R_analysis: float = 0.0
68
- R_total: float = 0.0
69
- violations: List[str] = field(default_factory=list)
70
- feedback: str = ""
71
-
72
- def to_dict(self) -> dict:
73
- return {k: round(v, 4) if isinstance(v, float) else v
74
- for k, v in {"R_exec": self.R_exec, "R_fair": self.R_fair,
75
- "R_safe": self.R_safe, "R_total": self.R_total,
76
- "violations": self.violations}.items()}
77
-
78
-
79
  class RewardEngine:
80
- """Stateful reward calculator for a single episode."""
81
 
82
- def __init__(self, task: ScenarioConfig) -> None:
83
  self._task = task
84
  self._cumulative_reward: float = 0.0
85
  self._step_count: int = 0
86
- self._action_history: List[str] = []
87
 
88
  @property
89
  def cumulative_reward(self) -> float:
90
  return self._cumulative_reward
91
 
92
- def compute_analysis_step(self, chosen_zones: List[int], city: CityState) -> RewardComponents:
93
- self._step_count += 1
94
- R_analysis = compute_analysis_reward(chosen_zones, city.zones)
95
- # Analysis provides a small progress signal
96
- R_total = 0.05 * R_analysis
97
- self._cumulative_reward += R_total
98
- return RewardComponents(
99
- R_analysis=R_analysis, R_total=R_total,
100
- feedback=f"Analysis: {R_total:+.3f} ({int(R_analysis * max(1, len(city.zones)//2))}"
101
- f"/{max(1, len(city.zones)//2)} critical zones correct)")
102
-
103
- def compute_execute_step(self, city: CityState, violations: List[str]) -> RewardComponents:
104
- """Main dense reward after execute step using Fair-GRPO-RLVR formula."""
105
  self._step_count += 1
106
 
107
- utility = compute_exec_reward(city.zones)
108
- fairness = compute_fairness_reward(city.zones)
109
- safety = compute_safety_reward(violations)
110
-
111
- # Truth Formula: 0.4*Utility + 0.4*Fairness + 0.2*Safety
112
- R_total = 0.4 * utility + 0.4 * fairness + 0.2 * safety
113
- R_total = float(max(0.0, min(1.0, R_total)))
 
 
 
 
 
 
 
 
 
 
 
 
 
114
 
115
- # Note: In interactive mode, we track cumulative, but train.ipynb uses final state.
116
- self._cumulative_reward += R_total
117
 
118
- feedback = (f"Utility={utility:.3f} | Fairness={fairness:.3f} | Safety={safety:.3f} → R_step={R_total:.3f}")
119
- if violations:
120
- feedback += f" | Violations: {violations}"
 
 
 
121
 
122
- return RewardComponents(R_exec=utility, R_fair=fairness, R_safe=safety,
123
- R_total=R_total, violations=violations, feedback=feedback)
124
 
125
- def compute_submit_reward(self, city: CityState) -> RewardComponents:
126
- """Final submission reward (matches Truth Formula)."""
127
- self._step_count += 1
128
- utility = compute_exec_reward(city.zones)
129
- fairness = compute_fairness_reward(city.zones)
130
- safety = compute_safety_reward([]) # Assume no new violations on submit
131
 
132
- terminal = 0.4 * utility + 0.4 * fairness + 0.2 * safety
133
- terminal = float(max(0.0, min(1.0, terminal)))
134
- self._cumulative_reward += terminal
135
 
136
- return RewardComponents(
137
- R_fair=fairness, R_exec=utility, R_total=terminal,
138
- feedback=f"Terminal Score={terminal:.3f} (Utility={utility:.3f}, Fairness={fairness:.3f})")
139
-
140
- def get_final_grader_score(self, city: CityState) -> float:
141
- """Normalised score in (GRADER_SCORE_MIN, GRADER_SCORE_MAX)."""
142
- utility = compute_exec_reward(city.zones)
143
- fairness = compute_fairness_reward(city.zones)
144
- safety = compute_safety_reward([])
145
 
146
- normalised = 0.4 * utility + 0.4 * fairness + 0.2 * safety
147
- return round(float(max(GRADER_SCORE_MIN, min(GRADER_SCORE_MAX, normalised))), 4)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
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
 
24
+ def __init__(self, task: TaskDefinition) -> None:
25
  self._task = task
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 based on environmental state."""
36
+ reward = 0.0
37
+ feedback_parts: list[str] = []
 
 
 
 
 
 
 
 
 
38
  self._step_count += 1
39
 
40
+ # 1. Action Type History (Penalty for repetition)
41
+ action_repr = action.action_type.value
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. Try a different phase.")
46
+ self._action_history.append(action_repr)
47
+
48
+ # 2. Safety Violations (Adversarial Penalties)
49
+ # Check if agent ignored critical zones identified in 'analyze'
50
+ if action.action_type == "allocate":
51
+ # (Simplified check: did they allocate anything?)
52
+ if not action.allocations:
53
+ reward += PENALTY_SAFETY_VIOLATION
54
+ feedback_parts.append("Budget is unspent while zones suffer.")
55
+
56
+ # 3. Stability Reward (Small positive signal for keeping things from getting worse)
57
+ avg_damage = sum(z.damage for z in state.zones) / len(state.zones)
58
+ if avg_damage < 0.5:
59
+ reward += REWARD_STABILITY
60
 
61
+ self._cumulative_reward += reward
62
+ feedback = " ".join(feedback_parts) if feedback_parts else "Action processed."
63
 
64
+ logger.info(
65
+ "reward_computed",
66
+ action_type=action.action_type.value,
67
+ reward=round(reward, 4),
68
+ cumulative=round(self._cumulative_reward, 4),
69
+ )
70
 
71
+ return reward, feedback
 
72
 
73
+ def get_final_score(self, state: FairRecoveryState) -> float:
74
+ """Compute the 'Honest Truth' terminal score in [0, 1]."""
 
 
 
 
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
+ # 2. Fairness (MAD-based Equity)
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 * fairness +
94
+ WEIGHT_SAFETY * safety
95
+ )
96
+
97
+ clamped = max(GRADER_SCORE_MIN, min(GRADER_SCORE_MAX, final_score))
98
+ return round(clamped, 4)
99
+
100
+
101
+ class TaskGrader:
102
+ """Grader for evaluating trained models."""
103
+
104
+ def __init__(self, task_id: str) -> None:
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)
fairrecovery_env/tasks.py CHANGED
@@ -1,78 +1,70 @@
1
  """
2
- FairRecovery++ — Scenario Definitions.
3
 
4
- Three scenarios of increasing difficulty with fairness traps.
5
  """
6
 
7
  from __future__ import annotations
8
- from dataclasses import dataclass
9
- from typing import Dict, List
10
- from .constants import Difficulty
 
11
 
12
 
13
- @dataclass
14
- class ScenarioConfig:
15
- """Immutable task configuration for one scenario."""
16
- task_id: str
17
  difficulty: Difficulty
 
18
  description: str
19
- initial_budget: float
20
- zones: List[Dict]
21
- hint: str = ""
22
 
23
 
24
- TASKS: Dict[str, ScenarioConfig] = {
25
- "easy": ScenarioConfig(
26
- task_id="easy_3zone", difficulty=Difficulty.EASY,
27
- description="3-zone post-flood scenario. One zone has moderate damage and high vulnerability.",
28
- initial_budget=80.0,
29
- hint="Focus on the zone with highest vulnerability × damage.",
30
- zones=[
31
- {"zone_id": 0, "damage": 0.30, "service": 0.70, "vulnerable_ratio": 0.15},
32
- {"zone_id": 1, "damage": 0.80, "service": 0.20, "vulnerable_ratio": 0.85},
33
- {"zone_id": 2, "damage": 0.20, "service": 0.80, "vulnerable_ratio": 0.10},
34
- ],
35
- ),
36
- "medium": ScenarioConfig(
37
- task_id="medium_5zone", difficulty=Difficulty.MEDIUM,
38
- description="5-zone earthquake scenario. Multiple zones damaged. Budget is constrained.",
39
- initial_budget=60.0,
40
- hint="Budget is limited. Prioritise zones with high damage AND vulnerability.",
41
- zones=[
42
- {"zone_id": 0, "damage": 0.40, "service": 0.60, "vulnerable_ratio": 0.30},
43
- {"zone_id": 1, "damage": 0.70, "service": 0.30, "vulnerable_ratio": 0.75},
44
- {"zone_id": 2, "damage": 0.50, "service": 0.50, "vulnerable_ratio": 0.50},
45
- {"zone_id": 3, "damage": 0.65, "service": 0.35, "vulnerable_ratio": 0.65},
46
- {"zone_id": 4, "damage": 0.15, "service": 0.85, "vulnerable_ratio": 0.20},
47
- ],
48
- ),
49
- "hard": ScenarioConfig(
50
- task_id="hard_5zone_fairness_trap", difficulty=Difficulty.HARD,
51
- description=(
52
- "5-zone hurricane — FAIRNESS TRAP + ADVERSARIAL AGENTS. "
53
- "Zone 0 is wealthy (low vulnerability). Zone 4 is an informal settlement "
54
- "(highest vulnerability × damage). Multi-agent dynamics add disruptions."
55
- ),
56
- initial_budget=45.0,
57
- hint=(
58
- "FAIRNESS TRAP: Zone 0 looks easy but has low vulnerability. "
59
- "Zone 4 has highest damage AND vulnerability — prioritise it. "
60
- "Watch for adversarial disruptions and adapt your strategy."
61
- ),
62
- zones=[
63
- {"zone_id": 0, "damage": 0.35, "service": 0.65, "vulnerable_ratio": 0.08},
64
- {"zone_id": 1, "damage": 0.50, "service": 0.50, "vulnerable_ratio": 0.40},
65
- {"zone_id": 2, "damage": 0.60, "service": 0.40, "vulnerable_ratio": 0.55},
66
- {"zone_id": 3, "damage": 0.72, "service": 0.28, "vulnerable_ratio": 0.72},
67
- {"zone_id": 4, "damage": 0.92, "service": 0.08, "vulnerable_ratio": 0.96},
68
- ],
69
- ),
70
- }
71
-
72
-
73
- def get_task(difficulty: str) -> ScenarioConfig:
74
- """Retrieve scenario config by difficulty string."""
75
- task = TASKS.get(difficulty)
76
- if task is None:
77
- raise ValueError(f"Unknown difficulty '{difficulty}'. Choose from: {list(TASKS.keys())}")
78
- return task
 
1
  """
2
+ FairRecovery++ — Task Definitions.
3
 
4
+ Pre-configured disaster scenarios for training and evaluation.
5
  """
6
 
7
  from __future__ import annotations
8
+ from typing import List, Dict
9
+ from pydantic import BaseModel
10
+ from .constants import TaskID, Difficulty, NUM_ZONES
11
+ from .models import ZoneState
12
 
13
 
14
+ class TaskDefinition(BaseModel):
15
+ """Configuration for a specific disaster scenario."""
16
+ task_id: TaskID
 
17
  difficulty: Difficulty
18
+ title: str
19
  description: str
20
+ initial_zones: List[ZoneState]
21
+ budget_limit: float = 1.0
 
22
 
23
 
24
+ def get_task(task_id: TaskID) -> TaskDefinition:
25
+ """Retrieve a pre-configured task definition."""
26
+
27
+ if task_id == TaskID.FLOOD_EASY:
28
+ return TaskDefinition(
29
+ task_id=task_id,
30
+ difficulty=Difficulty.EASY,
31
+ title="Monsoon Flash Flood",
32
+ description="Moderate damage in urban zones. Clear priorities.",
33
+ initial_zones=[
34
+ ZoneState(zone_id=0, damage=0.2, vulnerable_ratio=0.1),
35
+ ZoneState(zone_id=1, damage=0.3, vulnerable_ratio=0.2),
36
+ ZoneState(zone_id=2, damage=0.2, vulnerable_ratio=0.15),
37
+ ZoneState(zone_id=3, damage=0.4, vulnerable_ratio=0.3),
38
+ ZoneState(zone_id=4, damage=0.5, vulnerable_ratio=0.5),
39
+ ]
40
+ )
41
+
42
+ elif task_id == TaskID.EARTHQUAKE_MEDIUM:
43
+ return TaskDefinition(
44
+ task_id=task_id,
45
+ difficulty=Difficulty.MEDIUM,
46
+ title="7.2 Magnitude Earthquake",
47
+ description="Heavy damage across central districts. Power grid failure.",
48
+ initial_zones=[
49
+ ZoneState(zone_id=0, damage=0.4, vulnerable_ratio=0.1),
50
+ ZoneState(zone_id=1, damage=0.6, vulnerable_ratio=0.4),
51
+ ZoneState(zone_id=2, damage=0.5, vulnerable_ratio=0.3),
52
+ ZoneState(zone_id=3, damage=0.7, vulnerable_ratio=0.6),
53
+ ZoneState(zone_id=4, damage=0.8, vulnerable_ratio=0.8),
54
+ ]
55
+ )
56
+
57
+ else: # MULTI_DISASTER_HARD
58
+ return TaskDefinition(
59
+ task_id=TaskID.MULTI_DISASTER_HARD,
60
+ difficulty=Difficulty.HARD,
61
+ title="The Fairness Trap: Urban Cyclone",
62
+ description="Zone 4 is critical but ignored by greedy planners.",
63
+ initial_zones=[
64
+ ZoneState(zone_id=0, damage=0.15, vulnerable_ratio=0.08),
65
+ ZoneState(zone_id=1, damage=0.35, vulnerable_ratio=0.40),
66
+ ZoneState(zone_id=2, damage=0.42, vulnerable_ratio=0.55),
67
+ ZoneState(zone_id=3, damage=0.72, vulnerable_ratio=0.72),
68
+ ZoneState(zone_id=4, damage=0.92, vulnerable_ratio=0.96), # The Fairness Trap
69
+ ]
70
+ )
 
 
 
 
 
 
 
 
inference.py CHANGED
@@ -1,151 +1,55 @@
1
  """
2
  FairRecovery++ — Baseline Inference Script.
3
 
4
- Run BEFORE training to capture pre-training behaviour.
5
- Run AFTER training to compare trained vs baseline.
6
-
7
- Policies:
8
- random — random zone + resource selection
9
- greedy — utility-maximising heuristic (ignores fairness — the WRONG policy)
10
- fair — fairness-aware heuristic (the CORRECT policy baseline)
11
-
12
- Usage:
13
- python inference.py --difficulty hard --episodes 5 --policy all
14
  """
15
 
16
  from __future__ import annotations
17
-
18
  import argparse
19
  import json
20
  import random
21
- from typing import Dict, List
22
-
23
  import numpy as np
 
24
 
25
- from client import FairRecoveryEnv
26
- from fairrecovery_env.models import AllocationItem, FairRecoveryAction, FairRecoveryObservation
27
- from fairrecovery_env.constants import RESOURCE_COSTS
28
-
29
- BASE_URL = "http://localhost:8000"
30
 
 
 
 
 
31
 
32
  def random_policy(obs: FairRecoveryObservation) -> FairRecoveryAction:
33
- """Completely random policy — ignores all observations."""
34
- stage = obs.step_stage
35
- if stage == "analyze":
36
- n = len(obs.zones)
37
- return FairRecoveryAction(action_type="analyze",
38
- critical_zones=random.sample(range(n), min(2, n)), reasoning="Random.")
39
- elif stage == "allocate":
40
- n = len(obs.zones)
41
- return FairRecoveryAction(action_type="allocate", allocations=[
42
- AllocationItem(zone=random.randint(0, n-1),
43
- resource=random.choice(list(RESOURCE_COSTS.keys())))])
44
- elif stage == "execute":
45
- return FairRecoveryAction(action_type="execute")
46
- return FairRecoveryAction(action_type="submit")
47
-
48
 
49
  def greedy_policy(obs: FairRecoveryObservation) -> FairRecoveryAction:
50
  """Utility-maximising greedy — ignores vulnerability (WRONG policy)."""
51
- stage, zones, budget = obs.step_stage, obs.zones, obs.budget_left
52
- if stage == "analyze":
53
- ranked = sorted(range(len(zones)), key=lambda i: zones[i].damage, reverse=True)
54
- return FairRecoveryAction(action_type="analyze", critical_zones=ranked[:2],
55
- reasoning=f"Greedy: top by damage={ranked[:2]}")
56
- elif stage == "allocate":
57
- ranked = sorted(range(len(zones)), key=lambda i: zones[i].damage, reverse=True)
58
- allocs = []
59
- for zid in ranked:
60
- for res, cost in sorted(RESOURCE_COSTS.items(), key=lambda x: x[1]):
61
- if budget >= cost:
62
- allocs.append(AllocationItem(zone=zid, resource=res))
63
- budget -= cost
64
- break
65
- if allocs: break
66
- return FairRecoveryAction(action_type="allocate",
67
- allocations=allocs or [AllocationItem(zone=0, resource="power")])
68
- elif stage == "execute":
69
- return FairRecoveryAction(action_type="execute")
70
- return FairRecoveryAction(action_type="submit")
71
-
72
 
73
  def fairness_aware_policy(obs: FairRecoveryObservation) -> FairRecoveryAction:
74
- """Fairness-aware heuristic — prioritises damage × vulnerability (CORRECT)."""
75
- stage, zones, budget = obs.step_stage, obs.zones, obs.budget_left
76
- if stage == "analyze":
77
- scores = [(i, zones[i].damage * zones[i].vulnerable_ratio) for i in range(len(zones))]
78
- scores.sort(key=lambda x: -x[1])
79
- return FairRecoveryAction(action_type="analyze",
80
- critical_zones=[s[0] for s in scores[:2]],
81
- reasoning=f"Fair: critical={[s[0] for s in scores[:2]]}")
82
- elif stage == "allocate":
83
- scores = [(i, zones[i].damage * zones[i].vulnerable_ratio) for i in range(len(zones))]
84
- scores.sort(key=lambda x: -x[1])
85
- allocs = []
86
- for zid, sc in scores:
87
- if sc <= 0: break
88
- for res in ["medical", "water", "power"]:
89
- if budget >= RESOURCE_COSTS[res]:
90
- allocs.append(AllocationItem(zone=zid, resource=res))
91
- budget -= RESOURCE_COSTS[res]
92
- break
93
- if allocs: break
94
- return FairRecoveryAction(action_type="allocate",
95
- allocations=allocs or [AllocationItem(zone=scores[0][0], resource="power")])
96
- elif stage == "execute":
97
- return FairRecoveryAction(action_type="execute")
98
- return FairRecoveryAction(action_type="submit")
99
-
100
-
101
- POLICIES = {"random": random_policy, "greedy": greedy_policy, "fair": fairness_aware_policy}
102
-
103
-
104
- def evaluate_policy(policy_name: str, difficulty: str = "hard",
105
- n_episodes: int = 5, base_url: str = BASE_URL) -> Dict:
106
- policy_fn = POLICIES[policy_name]
107
- rewards, fairness, scores = [], [], []
108
-
109
- print(f"\n{'='*60}")
110
- print(f"Policy: {policy_name.upper()} | Difficulty: {difficulty} | Episodes: {n_episodes}")
111
- print(f"{'='*60}")
112
-
113
- with FairRecoveryEnv(base_url=base_url) as env:
114
- for ep in range(n_episodes):
115
- result = env.run_episode(policy_fn=policy_fn, difficulty=difficulty, verbose=True)
116
- rewards.append(result["total_reward"])
117
- fairness.append(result["final_fairness"])
118
- scores.append(result["grader_score"] or 0.0)
119
- print(f" Episode {ep+1}: reward={result['total_reward']:.3f}, "
120
- f"fairness={result['final_fairness']:.3f}, grader={result['grader_score']:.3f}")
121
-
122
- summary = {"policy": policy_name, "difficulty": difficulty,
123
- "mean_reward": float(np.mean(rewards)), "std_reward": float(np.std(rewards)),
124
- "mean_fairness": float(np.mean(fairness)), "mean_score": float(np.mean(scores))}
125
- print(f"\nSummary: {json.dumps(summary, indent=2)}")
126
- return summary
127
-
128
-
129
- def main():
130
- parser = argparse.ArgumentParser(description="FairRecovery++ Baseline Inference")
131
- parser.add_argument("--difficulty", default="hard", choices=["easy", "medium", "hard"])
132
- parser.add_argument("--episodes", default=3, type=int)
133
- parser.add_argument("--policy", default="all", choices=["all", "random", "greedy", "fair"])
134
- parser.add_argument("--url", default=BASE_URL)
135
- args = parser.parse_args()
136
-
137
- policies = list(POLICIES.keys()) if args.policy == "all" else [args.policy]
138
- results = [evaluate_policy(p, args.difficulty, args.episodes, args.url) for p in policies]
139
-
140
- print(f"\n{'='*60}\nCOMPARISON TABLE\n{'='*60}")
141
- print(f"{'Policy':<12} {'Mean Reward':>12} {'Mean Fairness':>14} {'Mean Score':>12}")
142
- print("-"*60)
143
- for r in results:
144
- print(f"{r['policy']:<12} {r['mean_reward']:>12.4f} {r['mean_fairness']:>14.4f} {r['mean_score']:>12.4f}")
145
- print("="*60)
146
- print("\nKey: 'greedy' has higher utility but LOWER fairness than 'fair'.")
147
- print("The trained agent should match/exceed 'fair' policy fairness score.")
148
-
149
-
150
- if __name__ == "__main__":
151
- main()
 
1
  """
2
  FairRecovery++ — Baseline Inference Script.
3
 
4
+ Updated to match the refactored project structure.
 
 
 
 
 
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
 
8
  import argparse
9
  import json
10
  import random
 
 
11
  import numpy as np
12
+ from typing import Dict, List
13
 
14
+ from fairrecovery_env.models import ResourceAllocation, FairRecoveryAction, FairRecoveryObservation
15
+ from fairrecovery_env.constants import ActionType, ResourceType, COST_MEDICAL, COST_WATER, COST_POWER
 
 
 
16
 
17
+ # Local dummy client for testing if server is not running
18
+ class LocalInference:
19
+ def __init__(self, base_url: str):
20
+ self.base_url = base_url
21
 
22
  def random_policy(obs: FairRecoveryObservation) -> FairRecoveryAction:
23
+ """Completely random policy."""
24
+ return FairRecoveryAction(
25
+ action_type=random.choice([ActionType.ANALYZE, ActionType.ALLOCATE, ActionType.EXECUTE]),
26
+ reasoning="Random strategy."
27
+ )
 
 
 
 
 
 
 
 
 
 
28
 
29
  def greedy_policy(obs: FairRecoveryObservation) -> FairRecoveryAction:
30
  """Utility-maximising greedy — ignores vulnerability (WRONG policy)."""
31
+ if obs.day > 10: return FairRecoveryAction(action_type=ActionType.SUBMIT)
32
+
33
+ # Simple heuristic for this example
34
+ return FairRecoveryAction(
35
+ action_type=ActionType.ALLOCATE,
36
+ allocations=[
37
+ ResourceAllocation(zone=0, resource=ResourceType.MEDICAL)
38
+ ],
39
+ reasoning="Greedy: targeting zone 0 first."
40
+ )
 
 
 
 
 
 
 
 
 
 
 
41
 
42
  def fairness_aware_policy(obs: FairRecoveryObservation) -> FairRecoveryAction:
43
+ """Fairness-aware heuristic (CORRECT policy)."""
44
+ if obs.day > 10: return FairRecoveryAction(action_type=ActionType.SUBMIT)
45
+
46
+ # Prioritize the most vulnerable zone
47
+ vulnerable_zone = sorted(range(len(obs.zones)), key=lambda i: obs.zones[i].vulnerable_ratio, reverse=True)[0]
48
+
49
+ return FairRecoveryAction(
50
+ action_type=ActionType.ALLOCATE,
51
+ allocations=[
52
+ ResourceAllocation(zone=vulnerable_zone, resource=ResourceType.MEDICAL)
53
+ ],
54
+ reasoning=f"Fair: prioritizing zone {vulnerable_zone} due to vulnerability."
55
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
server/app.py CHANGED
@@ -1,42 +1,33 @@
1
  """
2
  FairRecovery++ — FastAPI Application.
3
 
4
- Mirrors hallucination-detector-gym server/app.py pattern exactly.
5
  """
6
 
7
  from __future__ import annotations
8
  import os, sys
 
 
 
9
  from typing import Optional
10
  from fastapi import FastAPI, Request
 
11
 
12
  sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
13
 
14
- try:
15
- from openenv.core.env_server.http_server import create_app
16
- _OPENENV_AVAILABLE = True
17
- except ImportError:
18
- _OPENENV_AVAILABLE = False
19
-
20
  from fairrecovery_env.models import FairRecoveryAction, FairRecoveryObservation
21
- from fairrecovery_env.logging_config import configure_logging
22
  from server.fairrecovery_environment import FairRecoveryEnvironment
23
  from inference import greedy_policy, fairness_aware_policy
24
- import requests
25
- import json
26
- import re
27
-
28
- configure_logging(json_output=True, log_level="INFO")
29
 
30
  def llm_policy(obs: FairRecoveryObservation):
31
- """Real-time LLM inference using HF API. Falls back to heuristic if API fails."""
32
  hf_token = os.environ.get("HF_TOKEN")
33
  if not hf_token:
34
  return fairness_aware_policy(obs)
35
 
36
  try:
37
- # Build a prompt describing the state
38
- zones_str = '\\n'.join([f"Zone {z.zone_id}: damage={z.damage:.2f}, vulnerable={z.vulnerable_ratio:.2f}" for z in obs.zones])
39
- prompt = f"System: You are an AI allocating resources fairly. Respond with JSON action.\\nUser: Day {obs.day}. Budget {obs.budget_left}. Zones:\\n{zones_str}\\nWhat is your next action?"
40
 
41
  response = requests.post(
42
  "https://api-inference.huggingface.co/models/meta-llama/Llama-3.2-3B-Instruct",
@@ -46,28 +37,20 @@ def llm_policy(obs: FairRecoveryObservation):
46
  )
47
  if response.status_code == 200:
48
  text = response.json()[0]["generated_text"]
49
- match = re.search(r'\\{.*?\\}', text, re.DOTALL)
50
  if match:
51
  data = json.loads(match.group())
52
  return FairRecoveryAction(**data)
53
  except Exception as e:
54
- print(f"LLM API failed, falling back to fairness heuristic: {e}")
55
 
56
  return fairness_aware_policy(obs)
57
 
58
-
59
-
60
  def _build_app():
61
- from fastapi.responses import JSONResponse, RedirectResponse
62
  import gradio as gr
63
-
64
  app = FastAPI(title="FairRecovery++ RL Environment", version="2.0.0")
65
  _env = FairRecoveryEnvironment()
66
 
67
- @app.get("/web")
68
- async def web_redirect():
69
- return RedirectResponse(url="/")
70
-
71
  @app.post("/reset")
72
  async def reset(difficulty: str = "medium", episode_id: Optional[str] = None):
73
  return _env.reset(difficulty=difficulty, episode_id=episode_id).model_dump()
@@ -78,181 +61,63 @@ def _build_app():
78
  action = FairRecoveryAction(**payload)
79
  return _env.step(action).model_dump()
80
 
81
- @app.get("/health")
82
- async def health():
83
- return {"status": "ok"}
84
-
85
  # ── Simulation Logic for Gradio UI ───────────────────────────────────────
86
  def translate_zone_status(damage, vulnerability):
87
  people_affected = int(damage * 10000)
88
  vulnerable_count = int(people_affected * vulnerability)
89
-
90
  status_icon = "🔴" if damage > 0.6 else "🟡" if damage > 0.3 else "🟢"
91
-
92
- return f"{status_icon} **{people_affected:,}** people affected | ⚠️ **{vulnerable_count:,}** highly vulnerable (elderly/low-income)"
93
 
94
- def run_simulation_raw(policy_type: str):
95
  env = FairRecoveryEnvironment()
96
- obs = env.reset(difficulty="hard")
97
 
98
  logs = []
99
- logs.append("### 🚨 DISASTER SCENARIO: 7.2 Magnitude Earthquake")
100
- logs.append(f"**Day:** {obs.day} | **Emergency Budget:** ${obs.budget_left * 100000:,.0f}")
101
- for z in obs.zones:
102
- logs.append(f"- **Zone {z.zone_id}**: {translate_zone_status(z.damage, z.vulnerable_ratio)}")
103
- logs.append("\n---\n### ⚙️ SYSTEM BOOTING AI POLICY: " + policy_type.upper() + "\n")
104
 
105
  done = False
106
  step_count = 0
107
-
108
  policy_fn = greedy_policy if policy_type == "Baseline (Greedy)" else llm_policy
109
 
110
  while not done and step_count < 30:
111
  action = policy_fn(obs)
112
- logs.append(f"**▶ Day {step_count+1}: {action.action_type.capitalize()} Phase**")
113
-
114
- if action.action_type == "analyze":
115
- logs.append(f"> 🛰️ AI identified priority zones: {action.critical_zones}")
116
- elif action.action_type == "allocate":
117
- allocs = []
118
- for a in action.allocations:
119
- res_name = {"medical": "Medical Teams", "water": "Water Trucks", "power": "Power Grid Repair"}.get(a.resource, a.resource)
120
- allocs.append(f"Zone {a.zone} ({res_name})")
121
-
122
- logs.append(f"> 🚚 Dispatching resources: {', '.join(allocs) if allocs else 'None'}")
123
-
124
  obs = env.step(action)
125
 
126
- if action.action_type == "execute":
127
- logs.append("> 🏥 **Recovery Update:**")
128
- for z in obs.zones:
129
- logs.append(f" - Zone {z.zone_id} Status: {translate_zone_status(z.damage, z.vulnerable_ratio)}")
130
-
131
  done = obs.done
132
  step_count += 1
133
 
134
- # Calculate Honest Truth Metrics at the end
135
- services = [z.service for z in obs.zones]
136
- utility = sum(services) / len(services)
137
- mean_svc = utility
138
- disparity = sum(abs(s - mean_svc) for s in services) / len(services)
139
- fairness = max(0.0, 1.0 - disparity)
140
- # Safety: assume no persistent violations for the final summary if it finished
141
- safety = max(0.0, 1.0 - env.state.violations_total / 10.0)
142
-
143
- normalized_reward = 0.4 * utility + 0.4 * fairness + 0.2 * safety
144
- normalized_reward = max(0.0, min(1.0, normalized_reward))
145
-
146
- logs.append("\n---\n### 🏁 EPISODE COMPLETE")
147
- return "\n".join(logs), float(normalized_reward), float(fairness)
148
-
149
- def run_simulation(policy_type: str):
150
- logs, reward, fairness = run_simulation_raw(policy_type)
151
-
152
- fairness_eval = ""
153
- if fairness < 0.6:
154
- fairness_eval = "🔴 **CRITICAL NEGLECT** — Vulnerable populations were systematically bypassed to maximize raw efficiency. High human cost."
155
- elif fairness < 0.8:
156
- fairness_eval = "🟡 **MEDIUM PARITY** — Recovery reached vulnerable zones eventually, but disparity remained significant."
157
- else:
158
- fairness_eval = "🟢 **RESEARCH-LEVEL EQUITY** — Balanced recovery achieved. Socioeconomic demographics were protected equally."
159
-
160
- result_text = f"### 🏆 FINAL OUTCOME\n- **Overall Efficiency (Normalized Reward):** {reward:.3f}\n- **Equity Index (Fairness):** {fairness:.3f}\n\n**Impact Analysis:**\n{fairness_eval}"
161
- return logs, result_text
162
 
163
- def compare_policies():
164
- _, greedy_reward, greedy_fairness = run_simulation_raw("Baseline (Greedy)")
165
- _, fair_reward, fair_fairness = run_simulation_raw("Trained LLM (FairRecovery++)")
166
-
167
- return f"""### 📊 POLICY COMPARISON: THE TRUTH ABOUT BIAS
168
-
169
- | AI Model | Efficiency Score | Equity (Fairness) | Ethical Verdict |
170
- |---|---|---|---|
171
- | **Baseline (Greedy)** | {greedy_reward:.3f} | {greedy_fairness:.3f} | ❌ **Neglects vulnerable zones to save 'easier' wealthy zones.** |
172
- | **Trained LLM (Ours)** | {fair_reward:.3f} | {fair_fairness:.3f} | ✅ **Prioritizes high-vulnerability populations under pressure.** |
173
-
174
- > **Key Insight**: While the greedy model seems fast, its "Efficiency" is an illusion built on socioeconomic exclusion. Our **Fair-GRPO-RLVR** agent learns that true recovery must be equitable to be sustainable.
175
- """
176
-
177
- # ── Custom Simplified Gradio UI ──────────────────────────────────────────
178
- with gr.Blocks(title="FairRecovery++ Simulator", theme=gr.themes.Soft()) as gradio_app:
179
- gr.Markdown("# 🏗️ FairRecovery++: Adaptive Multi-Agent Disaster Recovery Environment")
180
-
181
  with gr.Tabs():
182
- with gr.Tab("Interactive Simulation"):
183
- gr.Markdown("> **An OpenEnv environment that teaches LLMs to make fair resource allocation decisions under adversarial pressure, multi-agent dynamics, and long-horizon planning constraints.**")
184
-
185
- with gr.Row():
186
- with gr.Column(scale=1):
187
- gr.Markdown("### 🚨 The Fairness Trap (Hard Scenario)")
188
- gr.Markdown("""
189
- After a disaster, AI systems optimizing for **efficiency alone** consistently neglect vulnerable populations.
190
- A greedy planner fixes wealthy **Zone 0** (low damage, easy win) while **Zone 4** (96% vulnerable, 92% damaged) gets nothing. This is the **Fairness Trap** -- and current LLMs fall right into it.
 
 
 
 
191
 
192
- **FairRecovery++** teaches the LLM to escape this trap by balancing efficiency vs fairness under tight budgets.
193
- """)
194
-
195
- policy_dropdown = gr.Dropdown(
196
- choices=["Baseline (Greedy)", "Trained LLM (FairRecovery++)"],
197
- value="Baseline (Greedy)",
198
- label="Choose AI Policy"
199
- )
200
- run_btn = gr.Button("▶ Run Episode", variant="primary")
201
-
202
- gr.Markdown("---")
203
- compare_btn = gr.Button("🔁 Run Both & Compare Impact", variant="secondary")
204
-
205
- with gr.Column(scale=2):
206
- gr.Markdown("### 📜 Mission Log")
207
- log_output = gr.Markdown("*Select a policy and click 'Run Episode' to view the ground-truth simulation.*")
208
- result_output = gr.Markdown("")
209
-
210
- run_btn.click(
211
- fn=run_simulation,
212
- inputs=[policy_dropdown],
213
- outputs=[log_output, result_output]
214
- )
215
-
216
- compare_btn.click(
217
- fn=compare_policies,
218
- inputs=[],
219
- outputs=[result_output]
220
- )
221
-
222
- with gr.Tab("Project README"):
223
- try:
224
- readme_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "README.md")
225
- with open(readme_path, "r", encoding="utf-8") as f:
226
- # Strip the YAML frontmatter for cleaner display in UI
227
- content = f.read()
228
- if content.startswith("---"):
229
- content = re.sub(r"^---.*?---", "", content, flags=re.DOTALL)
230
- gr.Markdown(content)
231
- except Exception as e:
232
- gr.Markdown(f"### Error loading README.md\n{e}")
233
-
234
- # Re-add root redirects but ensure trailing slash for /ui/
235
- # to prevent relative asset 404s (the cause of the blank screen).
236
  @app.get("/")
237
- async def root_redirect():
238
- from fastapi.responses import RedirectResponse
239
- return RedirectResponse(url="/ui/")
240
-
241
- @app.get("/web")
242
- async def old_web_redirect():
243
- from fastapi.responses import RedirectResponse
244
- return RedirectResponse(url="/ui/")
245
 
246
  return gr.mount_gradio_app(app, gradio_app, path="/ui")
247
 
248
-
249
  app = _build_app()
250
-
251
-
252
- def main(host: str = "0.0.0.0", port: int = 8000) -> None:
253
- import uvicorn
254
- uvicorn.run(app, host=host, port=port)
255
-
256
-
257
  if __name__ == "__main__":
258
- main()
 
 
1
  """
2
  FairRecovery++ — FastAPI Application.
3
 
4
+ Perfectly aligned with the refactored environment and reference structure.
5
  """
6
 
7
  from __future__ import annotations
8
  import os, sys
9
+ import re
10
+ import requests
11
+ import json
12
  from typing import Optional
13
  from fastapi import FastAPI, Request
14
+ from fastapi.responses import RedirectResponse
15
 
16
  sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
17
 
 
 
 
 
 
 
18
  from fairrecovery_env.models import FairRecoveryAction, FairRecoveryObservation
 
19
  from server.fairrecovery_environment import FairRecoveryEnvironment
20
  from inference import greedy_policy, fairness_aware_policy
 
 
 
 
 
21
 
22
  def llm_policy(obs: FairRecoveryObservation):
23
+ """Real-time LLM inference using HF API."""
24
  hf_token = os.environ.get("HF_TOKEN")
25
  if not hf_token:
26
  return fairness_aware_policy(obs)
27
 
28
  try:
29
+ zones_str = '\n'.join([f"Zone {z.zone_id}: damage={z.damage:.2f}, vulnerable={z.vulnerable_ratio:.2f}" for z in obs.zones])
30
+ prompt = f"System: You are an AI allocating resources fairly. Respond with JSON action.\nUser: Day {obs.day}. Budget {obs.budget_left}. Zones:\n{zones_str}\nWhat is your next action?"
 
31
 
32
  response = requests.post(
33
  "https://api-inference.huggingface.co/models/meta-llama/Llama-3.2-3B-Instruct",
 
37
  )
38
  if response.status_code == 200:
39
  text = response.json()[0]["generated_text"]
40
+ match = re.search(r'\{.*?\}', text, re.DOTALL)
41
  if match:
42
  data = json.loads(match.group())
43
  return FairRecoveryAction(**data)
44
  except Exception as e:
45
+ print(f"LLM API failed: {e}")
46
 
47
  return fairness_aware_policy(obs)
48
 
 
 
49
  def _build_app():
 
50
  import gradio as gr
 
51
  app = FastAPI(title="FairRecovery++ RL Environment", version="2.0.0")
52
  _env = FairRecoveryEnvironment()
53
 
 
 
 
 
54
  @app.post("/reset")
55
  async def reset(difficulty: str = "medium", episode_id: Optional[str] = None):
56
  return _env.reset(difficulty=difficulty, episode_id=episode_id).model_dump()
 
61
  action = FairRecoveryAction(**payload)
62
  return _env.step(action).model_dump()
63
 
 
 
 
 
64
  # ── Simulation Logic for Gradio UI ───────────────────────────────────────
65
  def translate_zone_status(damage, vulnerability):
66
  people_affected = int(damage * 10000)
67
  vulnerable_count = int(people_affected * vulnerability)
 
68
  status_icon = "🔴" if damage > 0.6 else "🟡" if damage > 0.3 else "🟢"
69
+ return f"{status_icon} **{people_affected:,}** people | ⚠️ **{vulnerable_count:,}** vulnerable"
 
70
 
71
+ def run_simulation(policy_type: str):
72
  env = FairRecoveryEnvironment()
73
+ obs = env.reset(task_id="multi_disaster_hard")
74
 
75
  logs = []
76
+ logs.append(f"### 🚨 SCENARIO: {policy_type.upper()}")
 
 
 
 
77
 
78
  done = False
79
  step_count = 0
 
80
  policy_fn = greedy_policy if policy_type == "Baseline (Greedy)" else llm_policy
81
 
82
  while not done and step_count < 30:
83
  action = policy_fn(obs)
 
 
 
 
 
 
 
 
 
 
 
 
84
  obs = env.step(action)
85
 
86
+ logs.append(f"**Day {obs.day}**: AI performed {action.action_type.value}")
87
+ if action.action_type == "allocate":
88
+ for a in (action.allocations or []):
89
+ logs.append(f" - Dispatched {a.resource.value} to Zone {a.zone}")
90
+
91
  done = obs.done
92
  step_count += 1
93
 
94
+ res_eval = "🟢 **EQUITY ACHIEVED**" if obs.fairness_score > 0.8 else "🔴 **NEGLECT DETECTED**"
95
+ result_text = f"### 🏆 FINAL OUTCOME\n- **Reward:** {obs.cumulative_reward:.3f}\n- **Equity:** {obs.fairness_score:.3f}\n\n{res_eval}"
96
+ return "\n".join(logs), result_text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
+ with gr.Blocks(title="FairRecovery++", theme=gr.themes.Soft()) as gradio_app:
99
+ gr.Markdown("# 🏗️ FairRecovery++")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  with gr.Tabs():
101
+ with gr.Tab("Simulation"):
102
+ policy = gr.Dropdown(choices=["Baseline (Greedy)", "Trained LLM"], value="Baseline (Greedy)")
103
+ btn = gr.Button("Run Simulation")
104
+ logs = gr.Markdown()
105
+ results = gr.Markdown()
106
+ btn.click(run_simulation, inputs=[policy], outputs=[logs, results])
107
+ with gr.Tab("README"):
108
+ readme_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "README.md")
109
+ with open(readme_path, "r", encoding="utf-8") as f:
110
+ content = f.read()
111
+ if content.startswith("---"):
112
+ content = re.sub(r"^---.*?---", "", content, flags=re.DOTALL)
113
+ gr.Markdown(content)
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  @app.get("/")
116
+ async def root(): return RedirectResponse(url="/ui/")
 
 
 
 
 
 
 
117
 
118
  return gr.mount_gradio_app(app, gradio_app, path="/ui")
119
 
 
120
  app = _build_app()
 
 
 
 
 
 
 
121
  if __name__ == "__main__":
122
+ import uvicorn
123
+ uvicorn.run(app, host="0.0.0.0", port=8000)
server/fairrecovery_environment.py CHANGED
@@ -1,175 +1,75 @@
1
  """
2
- FairRecovery++ — Core Environment Implementation.
3
 
4
- Adaptive multi-agent OpenEnv environment for post-disaster recovery.
5
- Implements Environment base class with step(), reset(), state().
6
-
7
- Design:
8
- * Multi-step protocol: analyze -> allocate -> execute -> adapt -> submit
9
- * Multi-agent system: citizens, NGOs, adversaries interact each step
10
- * Behavior analysis: patterns extracted from interaction logs
11
- * Predictive engine: forecasts events for proactive planning
12
- * Dense rewards at every step with 5-component breakdown
13
- * Composable rubrics wired via RFC 004 pattern
14
- * Safety shield validates before state mutation
15
-
16
- Themes Hit:
17
- * Theme 3.1 — Real-World Professional Tasks (core)
18
- * Theme 2 — Long-Horizon Planning (strong)
19
- * Theme 1 — Multi-Agent Interaction (added)
20
  """
21
 
22
  from __future__ import annotations
23
-
24
  import uuid
25
- from typing import Any, Dict, List, Optional
26
-
27
  import structlog
28
 
29
- try:
30
- from openenv.core.env_server.interfaces import Environment
31
- from openenv.core.env_server.types import Action, Observation, State
32
- except ImportError:
33
- class Environment: # type: ignore
34
- pass
35
- Action = object # type: ignore
36
- Observation = object # type: ignore
37
- State = object # type: ignore
38
 
39
  from fairrecovery_env.constants import (
40
- ActionType, MAX_DAYS, MAX_STEPS_SAFETY_CAP,
41
- MIN_STEPS, CURRICULUM_MAX_STEPS, EARLY_SUBMIT_PENALTY,
42
- FINAL_BONUS_WEIGHT_UTILITY, FINAL_BONUS_WEIGHT_FAIRNESS,
43
- PENALTY_INVALID_ACTION, PENALTY_WRONG_STAGE,
44
- PENALTY_PATTERN_IGNORED, PENALTY_ADVERSARIAL_FAILURE,
45
  )
46
  from fairrecovery_env.models import (
47
- AgentEvent, FairRecoveryAction, FairRecoveryObservation,
48
- FairRecoveryState, ZoneObservation,
49
  )
50
- from fairrecovery_env.rewards import RewardEngine, compute_fairness_reward
51
- from fairrecovery_env.rubrics import CompositeRubric
52
- from fairrecovery_env.shield import validate, check_timeout
53
- from fairrecovery_env.state import CityState
54
- from fairrecovery_env.tasks import ScenarioConfig, get_task
55
- from fairrecovery_env.agents import MultiAgentManager
56
- from fairrecovery_env.behavior_analyzer import BehaviorAnalyzer
57
- from fairrecovery_env.predictor import Predictor, Prediction
58
 
59
  logger = structlog.get_logger(__name__)
60
 
61
-
62
- def _clip01(value: float) -> float:
63
- return float(max(0.0, min(1.0, value)))
64
-
65
-
66
- def _signed_to_unit(value: float) -> float:
67
- # Convert [-1, 1] style component into [0, 1] for transparent dashboards.
68
- return _clip01((value + 1.0) / 2.0)
69
-
70
-
71
  class FairRecoveryEnvironment(Environment):
72
- """
73
- Post-disaster city recovery RL environment with multi-agent dynamics.
74
-
75
- The agent must:
76
- 1. Analyse zone status (damage, service, vulnerability, agent events)
77
- 2. Queue resource allocations respecting budget constraints
78
- 3. Execute allocations and receive dense rewards
79
- 4. Adapt strategy based on predictions and agent behavior
80
- 5. Repeat for MAX_DAYS days, then submit for final score
81
-
82
- Rewards balance 5 components:
83
- R_exec — service improvement (utility)
84
- R_fair — service parity between vulnerable/non-vulnerable zones
85
- R_adapt — success against predicted events
86
- R_stable — system balance (citizen satisfaction variance)
87
- R_safe — constraint satisfaction
88
- """
89
 
90
  SUPPORTS_CONCURRENT_SESSIONS: bool = True
91
 
92
  def __init__(self) -> None:
93
  super().__init__()
94
- self._city: Optional[CityState] = None
95
- self._task: Optional[ScenarioConfig] = None
96
  self._reward_engine: Optional[RewardEngine] = None
97
- self._rubrics: CompositeRubric = CompositeRubric()
98
- self._episode_id: Optional[str] = None
99
- self._step_count: int = 0
100
  self._action_history: list[str] = []
101
- self._current_difficulty: str = "medium"
102
-
103
- # Multi-agent system
104
- self._agent_manager: Optional[MultiAgentManager] = None
105
- self._behavior_analyzer: Optional[BehaviorAnalyzer] = None
106
- self._predictor: Optional[Predictor] = None
107
- self._last_prediction: Optional[Prediction] = None
108
- self._last_events: List[AgentEvent] = []
109
- self._adaptation_scores: List[float] = []
110
 
111
- # ──────────────────────────────────��───────────────────────────────────────
112
- # reset()
113
- # ──────────────────────────────────────────────────────────────────────────
114
- def reset(self, seed: Optional[int] = None, difficulty: str = "medium",
115
- episode_id: Optional[str] = None, **kwargs: Any) -> FairRecoveryObservation:
116
- """Reset environment with multi-agent system initialization."""
117
- self._task = get_task(difficulty)
118
- self._current_difficulty = difficulty
119
- self._city = CityState(self._task.__dict__)
 
120
  self._reward_engine = RewardEngine(self._task)
121
- self._episode_id = episode_id or str(uuid.uuid4())
122
- self._step_count = 0
123
  self._action_history = []
124
- self._city.step_stage = "analyze"
125
- self._adaptation_scores = []
126
 
127
- # Initialize multi-agent system
128
- vuln_ratios = [z.vulnerable_ratio for z in self._city.zones]
129
- actual_seed = seed or 42
130
- self._agent_manager = MultiAgentManager(
131
- num_zones=len(self._city.zones),
132
- zone_vulnerabilities=vuln_ratios,
133
- seed=actual_seed,
 
 
 
 
 
 
134
  )
135
- self._behavior_analyzer = BehaviorAnalyzer(num_zones=len(self._city.zones))
136
- self._predictor = Predictor(self._behavior_analyzer)
137
- self._last_prediction = None
138
- self._last_events = []
139
 
140
- # Rubrics reset
141
- self._rubrics.reset()
142
- initial_fairness = compute_fairness_reward(self._city.zones)
143
- self._rubrics.fairness.set_initial_fairness(initial_fairness)
144
 
145
- logger.info("environment_reset", episode_id=self._episode_id,
146
- difficulty=difficulty, num_zones=len(self._city.zones),
147
- initial_budget=self._city.budget_left,
148
- initial_fairness=round(initial_fairness, 4))
149
-
150
- obs = self._build_observation(reward=0.0, done=False, r_exec=0.0, r_fair=0.0, r_safe=0.0)
151
- obs.step_feedback = (
152
- f"Episode started. Difficulty: {difficulty}. "
153
- f"Zones: {len(self._city.zones)}. Budget: {self._city.budget_left}. "
154
- f"Active agents: {self._agent_manager.get_active_agent_count()}. "
155
- f"{self._task.hint}"
156
- )
157
- return obs
158
-
159
- # ──────────────────────────────────────────────────────────────────────────
160
- # step()
161
- # ──────────────────────────────────────────────────────────────────────────
162
- def step(self, action: Action, timeout_s: Optional[float] = None,
163
- **kwargs: Any) -> FairRecoveryObservation:
164
- """Execute one agent action with multi-agent dynamics."""
165
- if self._city is None or self._reward_engine is None:
166
- return self._error_obs("Call reset() before step().")
167
-
168
- if check_timeout(self._step_count):
169
- return self._build_observation(
170
- reward=PENALTY_INVALID_ACTION, done=True,
171
- r_exec=0.0, r_fair=0.0, r_safe=-0.5,
172
- feedback="Episode terminated: safety step cap exceeded.")
173
 
174
  # Parse action
175
  try:
@@ -179,434 +79,90 @@ class FairRecoveryEnvironment(Environment):
179
  typed_action = FairRecoveryAction(**action)
180
  else:
181
  typed_action = FairRecoveryAction(**action.model_dump())
182
- except Exception as exc:
183
- logger.warning("malformed_action", error=str(exc))
184
- typed_action = FairRecoveryAction(action_type="noop",
185
- reasoning=f"Parse error: {exc}")
186
-
187
- self._step_count += 1
188
- city = self._city
189
- action_type = str(typed_action.action_type).replace("ActionType.", "").lower()
190
-
191
- # ── HARD GATE: kill early-submit exploitation (structural, not band-aid) ──
192
- # If the agent tries to submit before MIN_STEPS, we apply a small penalty,
193
- # do NOT end the episode, and return immediately with a clear feedback msg.
194
- # This removes the "submit on step 1 to exit fast" exploit entirely.
195
- if action_type == "submit" and self._step_count < MIN_STEPS:
196
- obs = self._build_observation(
197
- reward=-EARLY_SUBMIT_PENALTY, done=False,
198
- r_exec=0.0, r_fair=0.0, r_safe=-EARLY_SUBMIT_PENALTY,
199
- r_adapt=0.0, r_stable=0.0,
200
- feedback=(
201
- f"early_submit_blocked: step {self._step_count} < MIN_STEPS={MIN_STEPS}. "
202
- f"Penalty {EARLY_SUBMIT_PENALTY}. Continue with analyze/allocate/execute."
203
- ),
204
- )
205
- self._action_history.append(
206
- f"Step {self._step_count}: submit_blocked (penalty={-EARLY_SUBMIT_PENALTY:+.3f})"
207
- )
208
- obs.action_history = list(self._action_history[-8:])
209
- logger.info("early_submit_blocked", episode_id=self._episode_id,
210
- step=self._step_count, min_steps=MIN_STEPS)
211
- return obs
212
-
213
- # Retrieve allocations for shield
214
- alloc_dicts = None
215
- if typed_action.allocations:
216
- alloc_dicts = [a.model_dump() for a in typed_action.allocations]
217
-
218
- # Shield validation
219
- is_valid, violations = validate(
220
- action_type=action_type, current_stage=city.step_stage,
221
- step_count=self._step_count, city=city, allocations=alloc_dicts)
222
-
223
- # Run multi-agent system to generate events
224
- agent_events = self._run_agents()
225
-
226
- # Generate prediction for next step
227
- prediction = self._generate_prediction()
228
-
229
- # Dispatch by action type
230
- reward = r_exec = r_fair = r_safe = r_adapt = r_stable = 0.0
231
- done = False
232
- feedback = ""
233
-
234
- if action_type == "analyze":
235
- components = self._reward_engine.compute_analysis_step(
236
- chosen_zones=typed_action.critical_zones or [], city=city)
237
- reward = components.R_total
238
- r_exec = components.R_analysis
239
- feedback = components.feedback
240
- city.record(f"analyzed zones={typed_action.critical_zones}")
241
- city.step_stage = "allocate"
242
-
243
- elif action_type == "allocate":
244
- city.pending_allocations = alloc_dicts or []
245
- city.record(f"queued {len(city.pending_allocations)} allocations")
246
- city.step_stage = "execute"
247
- feedback = (f"Queued {len(city.pending_allocations)} allocation(s). "
248
- f"Budget: {city.budget_left}. Call execute next.")
249
-
250
- elif action_type == "execute":
251
- city.snapshot_services()
252
- exec_violations = city.apply_allocations()
253
- all_violations = violations + exec_violations
254
-
255
- # Evaluate adaptation to predictions
256
- adapt_score = self._evaluate_adaptation(typed_action)
257
-
258
- # Apply multi-agent effects
259
- self._apply_agent_effects()
260
-
261
- components = self._reward_engine.compute_execute_step(
262
- city=city, violations=all_violations)
263
- reward = components.R_total
264
- r_exec = components.R_exec
265
- r_fair = components.R_fair
266
- r_safe = components.R_safe
267
- feedback = components.feedback
268
- city.record(f"executed | {feedback}")
269
- city.step_stage = "analyze"
270
-
271
- if self._should_end_episode(action_type=action_type):
272
- done = True
273
-
274
- elif action_type == "adapt":
275
- # Adaptation step: agent explicitly responds to predictions/events
276
- adapt_score = self._evaluate_adaptation(typed_action)
277
- self._adaptation_scores.append(adapt_score)
278
- reward = 0.05 * adapt_score # Small reward for adaptation
279
- r_adapt = adapt_score
280
- feedback = (f"Adaptation score: {adapt_score:.3f}. "
281
- f"Strategy: {typed_action.adaptation_strategy or 'none'}")
282
- city.record(f"adapted | {feedback}")
283
- city.step_stage = "analyze"
284
-
285
- elif action_type == "submit":
286
- components = self._reward_engine.compute_submit_reward(city=city)
287
- reward = components.R_total
288
- r_fair = components.R_fair
289
- feedback = components.feedback
290
- done = True
291
-
292
- elif action_type == "noop":
293
- reward = -0.02
294
- feedback = "No action taken (noop). This wastes a step."
295
-
296
- else:
297
- reward = PENALTY_INVALID_ACTION
298
- feedback = f"Unknown action_type '{action_type}'."
299
- violations.append(f"unknown_action_type:{action_type}")
300
-
301
- # Stage violations
302
- if violations and not is_valid:
303
- reward += PENALTY_WRONG_STAGE
304
- feedback += f" | Violations: {violations}"
305
-
306
- # Global termination check (covers analyze/allocate/adapt/noop paths too).
307
- # NOTE: submit pre-MIN_STEPS already short-circuited earlier.
308
- if not done and self._should_end_episode(action_type=action_type):
309
- done = True
310
- feedback += " | episode_end_reached"
311
-
312
- # Build observation with multi-agent data
313
- obs = self._build_observation(
314
- reward=reward, done=done, r_exec=r_exec, r_fair=r_fair,
315
- r_safe=r_safe, feedback=feedback, agent_events=self._last_events,
316
- predictions=prediction.to_dict() if prediction else None)
317
-
318
- # Rubric scoring (RFC 004)
319
- rubric_score = self._rubrics.forward(typed_action, obs)
320
- if rubric_score != 0.0:
321
- obs.cumulative_reward += rubric_score
322
-
323
- # Action history
324
- action_summary = f"Step {self._step_count}: {action_type} (reward={reward:+.3f})"
325
- self._action_history.append(action_summary)
326
- obs.action_history = list(self._action_history[-8:])
327
-
328
- # Final grader score
329
- if done:
330
- obs.grader_score = self._reward_engine.get_final_grader_score(city)
331
-
332
- logger.info("step_executed", episode_id=self._episode_id,
333
- step=self._step_count, action_type=action_type,
334
- day=city.day, reward=round(reward, 4), done=done)
335
-
336
- return obs
337
-
338
- # ──────────────────────────────────────────────────────────────────────────
339
- # state property
340
- # ──────────────────────────────────────────────────────────────────────────
341
- @property
342
- def state(self) -> FairRecoveryState:
343
- if self._city is None:
344
- return FairRecoveryState()
345
- city = self._city
346
- zones_obs = [
347
- ZoneObservation(
348
- zone_id=z.zone_id, damage=round(z.damage, 3),
349
- service=round(z.service, 3),
350
- vulnerable_ratio=round(z.vulnerable_ratio, 3),
351
- citizen_satisfaction=round(z.citizen_satisfaction, 3),
352
- risk_level=0.0)
353
- for z in city.zones
354
- ]
355
- fairness = compute_fairness_reward(city.zones)
356
- adapt_avg = (sum(self._adaptation_scores) / len(self._adaptation_scores)
357
- if self._adaptation_scores else 0.0)
358
- return FairRecoveryState(
359
- episode_id=self._episode_id, difficulty=self._current_difficulty,
360
- day=city.day, budget_left=round(city.budget_left, 2),
361
- step_stage=city.step_stage, step_count=self._step_count,
362
- cumulative_reward=round(self._reward_engine.cumulative_reward if self._reward_engine else 0.0, 4),
363
- fairness_score=round(fairness, 4),
364
- is_done=(
365
- city.day >= MAX_DAYS
366
- or self._average_recovery() >= 0.95
367
- or city.budget_left <= 0
368
- ),
369
- violations_total=city.violations_total, zones=zones_obs,
370
- active_agents=self._agent_manager.get_active_agent_count() if self._agent_manager else 0,
371
- adversarial_events=self._agent_manager.get_adversarial_event_count() if self._agent_manager else 0,
372
- adaptation_score=round(adapt_avg, 4))
373
-
374
- def close(self) -> None:
375
- self._city = None
376
- self._task = None
377
- self._reward_engine = None
378
- self._agent_manager = None
379
- self._behavior_analyzer = None
380
- self._predictor = None
381
- self._action_history.clear()
382
- logger.info("environment_closed", episode_id=self._episode_id)
383
-
384
- # ──────────────────────────────────────────────────────────────────────────
385
- # Multi-Agent Helpers
386
- # ──────────────────────────────────────────────────────────────────────────
387
- def _run_agents(self) -> List[AgentEvent]:
388
- """Run all agents and collect events."""
389
- if not self._agent_manager or not self._city:
390
- return []
391
- city = self._city
392
- events = self._agent_manager.step(
393
- zone_services=city.current_services,
394
- zone_damages=city.current_damages,
395
- zone_vulnerabilities=city.current_vulnerabilities,
396
- planner_target_zones=city.planner_target_zones,
397
- day=city.day)
398
-
399
- # Ingest into behavior analyzer
400
- if self._behavior_analyzer:
401
- self._behavior_analyzer.ingest(events)
402
-
403
- # Convert to AgentEvent models for observation
404
- self._last_events = [
405
- AgentEvent(agent_type=e.agent_type, event_type=e.event_type,
406
- zone_id=e.zone_id, intensity=e.intensity,
407
- message=e.message, timestamp=e.timestamp)
408
- for e in events
409
- ]
410
- return self._last_events
411
-
412
- def _generate_prediction(self) -> Optional[Prediction]:
413
- """Generate prediction for next events."""
414
- if not self._predictor or not self._city:
415
- return None
416
- city = self._city
417
- prediction = self._predictor.predict_next(
418
- zone_services=city.current_services,
419
- zone_damages=city.current_damages,
420
- zone_vulnerabilities=city.current_vulnerabilities,
421
- day=city.day)
422
- self._last_prediction = prediction
423
- return prediction
424
 
425
- def _evaluate_adaptation(self, action: FairRecoveryAction) -> float:
426
- """Evaluate how well the agent adapted to predictions."""
427
- if not self._predictor or not self._last_prediction:
428
- return 0.5
429
- planner_zones = []
430
- if action.critical_zones:
431
- planner_zones = action.critical_zones
432
- elif action.allocations:
433
- planner_zones = [a.zone for a in action.allocations]
434
- return self._predictor.evaluate_adaptation(
435
- prediction=self._last_prediction,
436
- actual_events=self._agent_manager.interaction_log[-5:] if self._agent_manager else [],
437
- planner_actions=planner_zones)
438
 
439
- def _apply_agent_effects(self) -> None:
440
- """Apply multi-agent effects to the world state."""
441
- if not self._agent_manager or not self._city:
442
  return
443
- city = self._city
444
- # Apply disruptions from adversarial agents
445
- for event in self._last_events:
446
- if event.event_type == "disruption":
447
- idx = event.zone_id
448
- if 0 <= idx < len(city.zones):
449
- city.zones[idx].apply_disruption(event.intensity)
450
- elif event.event_type in ("cooperation", "aid_delivery"):
451
- idx = event.zone_id
452
- if 0 <= idx < len(city.zones):
453
- city.zones[idx].service = min(1.0, city.zones[idx].service + 0.05)
454
- # Update citizen satisfaction
455
- for citizen in self._agent_manager.citizens:
456
- idx = citizen.zone_id
457
- if 0 <= idx < len(city.zones):
458
- city.zones[idx].citizen_satisfaction = citizen.satisfaction
459
 
460
- # ──────────────────────────────────────────────────────────────────────────
461
- # Observation Builders
462
- # ──────────────────────────────────────────────────────────────────────────
463
- def _build_observation(self, reward: float, done: bool, r_exec: float,
464
- r_fair: float, r_safe: float, feedback: str = "",
465
- agent_events: Optional[List[AgentEvent]] = None,
466
- predictions: Optional[dict] = None) -> FairRecoveryObservation:
467
- city = self._city
468
- zones_obs = [
469
- ZoneObservation(
470
- zone_id=z.zone_id, damage=round(z.damage, 3),
471
- service=round(z.service, 3),
472
- vulnerable_ratio=round(z.vulnerable_ratio, 3),
473
- citizen_satisfaction=round(z.citizen_satisfaction, 3),
474
- risk_level=0.0)
475
- for z in city.zones
476
- ] if city else []
477
-
478
- fairness = compute_fairness_reward(city.zones) if city else 0.0
479
- steps_remaining = (MAX_DAYS - city.day) * 3 if city else 0
480
- cumulative = self._reward_engine.cumulative_reward if self._reward_engine else 0.0
481
-
482
- # Attach risk levels from behavior analyzer
483
- if self._behavior_analyzer and zones_obs:
484
- risk_levels = self._behavior_analyzer.get_risk_levels()
485
- for i, zo in enumerate(zones_obs):
486
- if i < len(risk_levels):
487
- zo.risk_level = round(risk_levels[i], 3)
488
-
489
- info = self._build_reward_info(
490
- reward=reward,
491
- r_exec=r_exec,
492
- r_fair=r_fair,
493
- r_safe=r_safe,
494
- done=done,
495
- fairness_score=fairness,
496
- )
497
-
498
- return FairRecoveryObservation(
499
- zones=zones_obs, day=city.day if city else 0,
500
- budget_left=round(city.budget_left, 2) if city else 0.0,
501
- step_stage=city.step_stage if city else "analyze",
502
- fairness_score=round(fairness, 4), step_feedback=feedback,
503
- steps_remaining=max(0, steps_remaining),
504
- cumulative_reward=round(cumulative, 4),
505
- r_exec=round(r_exec, 4), r_fair=round(r_fair, 4),
506
- r_safe=round(r_safe, 4),
507
- done=done, reward=round(reward, 4),
508
- info=info,
509
- agent_events=agent_events or [], predictions=predictions)
510
 
511
- def _error_obs(self, msg: str) -> FairRecoveryObservation:
512
  return FairRecoveryObservation(
513
- step_feedback=f"Error: {msg}", done=False,
514
- reward=PENALTY_INVALID_ACTION,
515
- info=self._build_reward_info(
516
- reward=PENALTY_INVALID_ACTION,
517
- r_exec=0.0,
518
- r_fair=0.0,
519
- r_safe=-1.0,
520
- ),
521
- )
522
-
523
- def _build_reward_info(
524
- self,
525
- reward: float,
526
- r_exec: float,
527
- r_fair: float,
528
- r_safe: float,
529
- done: bool = False,
530
- fairness_score: float = 0.0,
531
- ) -> Dict[str, Any]:
532
- """Curriculum-weighted reward in [0,1] + trajectory-level final bonus.
533
-
534
- Curriculum:
535
- progress = min(step_count, CURRICULUM_MAX_STEPS) / CURRICULUM_MAX_STEPS
536
- step_r = (0.6 + 0.4*p)*utility + (0.2 + 0.3*p)*fairness + 0.2*safety
537
-
538
- => Early steps reward utility; late steps reward fairness.
539
- => Prevents "fair but useless" policies and "instant-submit" exploits.
540
-
541
- Final bonus (only when done=True) teaches long-horizon planning:
542
- bonus = 0.5*final_utility + 0.5*final_fairness
543
- """
544
- utility = _signed_to_unit(r_exec)
545
- fairness = _signed_to_unit(r_fair)
546
- safety = _signed_to_unit(r_safe)
547
- raw = _signed_to_unit(reward)
548
-
549
- progress = min(self._step_count, CURRICULUM_MAX_STEPS) / float(CURRICULUM_MAX_STEPS)
550
- w_u = 0.6 + 0.4 * progress
551
- w_f = 0.2 + 0.3 * progress
552
- w_s = 0.2
553
-
554
- step_r = w_u * utility + w_f * fairness + w_s * safety
555
- # Normalize back to [0,1] (max possible weights ≈ 1.0+0.5+0.2 = 1.7)
556
- step_r = _clip01(step_r / (w_u + w_f + w_s))
557
-
558
- # Trajectory-level long-horizon bonus on episode end.
559
- bonus = 0.0
560
- if done:
561
- final_utility = self._average_recovery()
562
- final_fairness = _signed_to_unit(fairness_score)
563
- bonus = (
564
- FINAL_BONUS_WEIGHT_UTILITY * final_utility
565
- + FINAL_BONUS_WEIGHT_FAIRNESS * final_fairness
566
- )
567
- bonus = _clip01(bonus)
568
-
569
- # Blended reward: 0.7 * curriculum step reward + 0.3 * final bonus (if any).
570
- # If not done, blend = step_r alone.
571
- if done:
572
- blended = _clip01(0.6 * step_r + 0.4 * bonus)
573
- else:
574
- blended = step_r
575
-
576
- return {
577
- "reward": round(blended, 4),
578
- "reward_step": round(step_r, 4),
579
- "reward_raw": round(raw, 4),
580
- "final_bonus": round(bonus, 4),
581
- "progress": round(progress, 4),
582
- "utility": round(utility, 4),
583
- "fairness": round(fairness, 4),
584
- "safety": round(safety, 4),
585
- }
586
-
587
- def _should_end_episode(self, action_type: str) -> bool:
588
- """Episode-end logic with structural trajectory shaping.
589
-
590
- done = (step_count >= MIN_STEPS AND action == "submit")
591
- OR step_count >= CURRICULUM_MAX_STEPS
592
- OR day >= MAX_DAYS
593
- OR recovery >= 0.95
594
- OR budget_left <= 0
595
- """
596
- if self._city is None:
597
- return False
598
- recovery = self._average_recovery()
599
- valid_submit = action_type == "submit" and self._step_count >= MIN_STEPS
600
- hit_max_steps = self._step_count >= CURRICULUM_MAX_STEPS
601
- return (
602
- self._city.day >= MAX_DAYS
603
- or recovery >= 0.95
604
- or valid_submit
605
- or hit_max_steps
606
- or self._city.budget_left <= 0
607
  )
608
 
609
- def _average_recovery(self) -> float:
610
- if self._city is None or not self._city.zones:
611
- return 0.0
612
- return float(sum(z.service for z in self._city.zones) / len(self._city.zones))
 
1
  """
2
+ FairRecovery++ — Core Environment.
3
 
4
+ Perfectly aligned with OpenEnv patterns and reference project structure.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
13
+ from openenv.core.env_server.types import Action, Observation, State
 
 
 
 
 
 
 
14
 
15
  from fairrecovery_env.constants import (
16
+ MAX_STEPS_PER_EPISODE, MAX_DAYS, TaskID, ActionType, ResourceType,
17
+ COST_MEDICAL, COST_WATER, COST_POWER
 
 
 
18
  )
19
  from fairrecovery_env.models import (
20
+ FairRecoveryAction, FairRecoveryObservation, FairRecoveryState, ZoneState
 
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
 
 
 
 
 
 
 
 
 
 
 
27
  class FairRecoveryEnvironment(Environment):
28
+ """OpenEnv environment for fair disaster recovery."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  SUPPORTS_CONCURRENT_SESSIONS: bool = True
31
 
32
  def __init__(self) -> None:
33
  super().__init__()
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,
41
+ seed: Optional[int] = None,
42
+ episode_id: Optional[str] = None,
43
+ task_id: Optional[str] = None,
44
+ **kwargs: Any,
45
+ ) -> FairRecoveryObservation:
46
+ """Reset the 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(
58
+ episode_id=ep_id,
59
+ step_count=0,
60
+ day=1,
61
+ budget_remaining=self._task.budget_limit,
62
+ zones=zones,
63
+ task_id=resolved_task_id,
64
+ difficulty=self._task.difficulty,
65
  )
 
 
 
 
66
 
67
+ return self._build_observation("Environment reset. Begin disaster recovery.")
 
 
 
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:
 
79
  typed_action = FairRecoveryAction(**action)
80
  else:
81
  typed_action = FairRecoveryAction(**action.model_dump())
82
+ except Exception as e:
83
+ typed_action = FairRecoveryAction(action_type=ActionType.NOOP, reasoning=str(e))
84
+
85
+ self._state.step_count += 1
86
+
87
+ # 1. Update Day Counter
88
+ # Sequence: Analyze -> Allocate -> Execute -> Day++
89
+ if typed_action.action_type == ActionType.EXECUTE:
90
+ self._execute_phase(typed_action)
91
+ self._state.day += 1
92
+ elif typed_action.action_type == ActionType.ALLOCATE:
93
+ self._allocate_phase(typed_action)
94
+
95
+ # 2. Compute Reward
96
+ reward, feedback = self._reward_engine.compute_reward(typed_action, self._state)
97
+ self._state.cumulative_reward = self._reward_engine.cumulative_reward
98
+
99
+ # 3. Check Termination
100
+ is_done = (
101
+ typed_action.action_type == ActionType.SUBMIT or
102
+ self._state.day > MAX_DAYS or
103
+ self._state.step_count >= MAX_STEPS_PER_EPISODE
104
+ )
105
+ self._state.is_done = is_done
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
 
107
+ return self._build_observation(feedback)
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
+ def _allocate_phase(self, action: FairRecoveryAction):
110
+ """Process resource allocations."""
111
+ if not action.allocations:
112
  return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
+ for alloc in action.allocations:
115
+ cost = {
116
+ ResourceType.MEDICAL: COST_MEDICAL,
117
+ ResourceType.WATER: COST_WATER,
118
+ ResourceType.POWER: COST_POWER
119
+ }.get(alloc.resource, 0.0)
120
+
121
+ if self._state.budget_remaining >= cost:
122
+ self._state.budget_remaining -= cost
123
+ zone = self._state.zones[alloc.zone]
124
+ # Resources reduce damage and increase service level
125
+ zone.damage = max(0.0, zone.damage - 0.05)
126
+ zone.service_level = min(1.0, zone.service_level + 0.1)
127
+ else:
128
+ self._state.violations_total += 1
129
+
130
+ def _execute_phase(self, action: FairRecoveryAction):
131
+ """Natural environment progression (deterioration if no service)."""
132
+ for zone in self._state.zones:
133
+ # Deterioration
134
+ if zone.service_level < 0.2:
135
+ zone.damage = min(1.0, zone.damage + 0.02)
136
+ # Service decay
137
+ zone.service_level = max(0.0, zone.service_level - 0.05)
138
+
139
+ def _build_observation(self, feedback: str) -> FairRecoveryObservation:
140
+ """Construct a FairRecoveryObservation from current state."""
141
+
142
+ # Calculate Equity for observation
143
+ services = [z.service_level for z in self._state.zones]
144
+ avg_svc = sum(services) / len(services)
145
+ mad = sum(abs(s - avg_svc) for s in services) / len(services)
146
+ equity = 1.0 - mad
147
+
148
+ grader_score = self._reward_engine.get_final_score(self._state) if self._state.is_done else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
 
 
150
  return FairRecoveryObservation(
151
+ done=self._state.is_done,
152
+ reward=0.0, # Per-step reward is handled by cumulative
153
+ day=self._state.day,
154
+ budget_left=self._state.budget_remaining,
155
+ zones=self._state.zones,
156
+ fairness_score=equity,
157
+ step_stage="dynamic", # Could be more granular
158
+ steps_remaining=MAX_STEPS_PER_EPISODE - self._state.step_count,
159
+ cumulative_reward=self._state.cumulative_reward,
160
+ action_history=list(self._action_history),
161
+ grader_score=grader_score,
162
+ step_feedback=feedback,
163
+ metadata={"grader_score": grader_score}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  )
165
 
166
+ @property
167
+ def state(self) -> FairRecoveryState:
168
+ return self._state