Spaces:
Sleeping
Sleeping
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 +44 -121
- fairrecovery_env/models.py +60 -282
- fairrecovery_env/rewards.py +82 -119
- fairrecovery_env/tasks.py +59 -67
- inference.py +36 -132
- server/app.py +40 -175
- server/fairrecovery_environment.py +120 -564
fairrecovery_env/constants.py
CHANGED
|
@@ -1,14 +1,9 @@
|
|
| 1 |
"""
|
| 2 |
-
FairRecovery++ — Constants and Configuration
|
| 3 |
|
| 4 |
-
All configurable values are
|
| 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] = "
|
| 20 |
ENV_VERSION: Final[str] = "2.0.0"
|
| 21 |
ENV_DESCRIPTION: Final[str] = (
|
| 22 |
-
"An
|
| 23 |
-
"
|
| 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 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 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 |
-
#
|
| 52 |
# ──────────────────────────────────────────────────────────────────────────────
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 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 |
-
|
| 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 |
-
#
|
| 89 |
# ──────────────────────────────────────────────────────────────────────────────
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
RISK_THRESHOLD: Final[float] = 0.6
|
| 94 |
|
| 95 |
-
|
| 96 |
-
GRADER_SCORE_MIN: Final[float] = 0.01
|
| 97 |
-
GRADER_SCORE_MAX: Final[float] = 0.99
|
| 98 |
|
| 99 |
# ──────────────────────────────────────────────────────────────────────────────
|
| 100 |
-
#
|
| 101 |
# ──────────────────────────────────────────────────────────────────────────────
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 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
|
| 121 |
-
POWER = "power"
|
| 122 |
-
WATER = "water"
|
| 123 |
MEDICAL = "medical"
|
| 124 |
-
|
|
|
|
|
|
|
| 125 |
|
| 126 |
@unique
|
| 127 |
class ActionType(str, Enum):
|
| 128 |
-
"""
|
| 129 |
-
|
| 130 |
-
analyze → prioritize → allocate → execute → adapt → submit
|
| 131 |
-
"""
|
| 132 |
-
ANALYZE = "analyze"
|
| 133 |
ALLOCATE = "allocate"
|
| 134 |
-
EXECUTE
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
NOOP = "noop"
|
| 138 |
-
|
| 139 |
|
| 140 |
@unique
|
| 141 |
-
class
|
| 142 |
-
"""
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
ADVERSARY = "adversary"
|
| 147 |
-
|
| 148 |
|
| 149 |
@unique
|
| 150 |
-
class
|
| 151 |
-
"""
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 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++ —
|
| 3 |
|
| 4 |
-
|
| 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
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
)
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 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++
|
| 3 |
|
| 4 |
-
Computes dense
|
| 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
|
| 13 |
-
from
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 81 |
|
| 82 |
-
def __init__(self, task:
|
| 83 |
self._task = task
|
| 84 |
self._cumulative_reward: float = 0.0
|
| 85 |
self._step_count: int = 0
|
| 86 |
-
self._action_history:
|
| 87 |
|
| 88 |
@property
|
| 89 |
def cumulative_reward(self) -> float:
|
| 90 |
return self._cumulative_reward
|
| 91 |
|
| 92 |
-
def
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 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 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
-
|
| 116 |
-
|
| 117 |
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
-
return
|
| 123 |
-
R_total=R_total, violations=violations, feedback=feedback)
|
| 124 |
|
| 125 |
-
def
|
| 126 |
-
"""
|
| 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 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 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 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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++ —
|
| 3 |
|
| 4 |
-
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
-
from
|
| 9 |
-
from
|
| 10 |
-
from .constants import Difficulty
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
task_id: str
|
| 17 |
difficulty: Difficulty
|
|
|
|
| 18 |
description: str
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
hint: str = ""
|
| 22 |
|
| 23 |
|
| 24 |
-
|
| 25 |
-
"
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
)
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 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 |
-
|
| 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
|
| 26 |
-
from fairrecovery_env.
|
| 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
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 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 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 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
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 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 |
-
|
| 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.
|
| 32 |
hf_token = os.environ.get("HF_TOKEN")
|
| 33 |
if not hf_token:
|
| 34 |
return fairness_aware_policy(obs)
|
| 35 |
|
| 36 |
try:
|
| 37 |
-
|
| 38 |
-
|
| 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'\
|
| 50 |
if match:
|
| 51 |
data = json.loads(match.group())
|
| 52 |
return FairRecoveryAction(**data)
|
| 53 |
except Exception as e:
|
| 54 |
-
print(f"LLM API failed
|
| 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
|
| 95 |
env = FairRecoveryEnvironment()
|
| 96 |
-
obs = env.reset(
|
| 97 |
|
| 98 |
logs = []
|
| 99 |
-
logs.append("### 🚨
|
| 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 |
-
|
| 127 |
-
|
| 128 |
-
for
|
| 129 |
-
logs.append(f" -
|
| 130 |
-
|
| 131 |
done = obs.done
|
| 132 |
step_count += 1
|
| 133 |
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 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 |
-
|
| 164 |
-
|
| 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("
|
| 183 |
-
gr.
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
| 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
|
| 3 |
|
| 4 |
-
|
| 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,
|
| 26 |
-
|
| 27 |
import structlog
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 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 |
-
|
| 41 |
-
|
| 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 |
-
|
| 48 |
-
FairRecoveryState, ZoneObservation,
|
| 49 |
)
|
| 50 |
-
from fairrecovery_env.rewards import RewardEngine
|
| 51 |
-
from fairrecovery_env.
|
| 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.
|
| 95 |
-
self._task: Optional[
|
| 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 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
| 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 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 141 |
-
self._rubrics.reset()
|
| 142 |
-
initial_fairness = compute_fairness_reward(self._city.zones)
|
| 143 |
-
self._rubrics.fairness.set_initial_fairness(initial_fairness)
|
| 144 |
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 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
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 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 |
-
|
| 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
|
| 440 |
-
"""
|
| 441 |
-
if not
|
| 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 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 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 |
-
|
| 514 |
-
reward=
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 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 |
-
|
| 610 |
-
|
| 611 |
-
|
| 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
|
|
|