AquaGuard-RL / src /server /grader /programmatic.py
Ashgen12's picture
Upload folder using huggingface_hub
7e69b8f verified
Raw
History Blame Contribute Delete
17.9 kB
# src/server/grader/programmatic.py
"""
Programmatic grader: 12 automated checks for AquaGuard-RL environment quality.
Checks verify:
1. API compliance (reset/step/state work correctly)
2. Reward properties (bounds, correlation)
3. Domain-specific thresholds (groundwater, food security, farmer income)
4. Episode management (done, step count)
5. Observation completeness
Each check has an associated weight; the final score is weighted_passed / total_weight.
"""
from __future__ import annotations
import math
import logging
from dataclasses import dataclass, field
from typing import List, Optional, Dict
logger = logging.getLogger(__name__)
@dataclass
class CheckResult:
"""Result of a single programmatic check."""
name: str
passed: bool
weight: float
details: str = ""
@dataclass
class ProgrammaticGradeResult:
"""Aggregate result from all 12 programmatic checks."""
checks: List[CheckResult] = field(default_factory=list)
@property
def score(self) -> float:
"""Weighted score: sum of passed weights / total weight. Range [0, 1]."""
total_weight = sum(c.weight for c in self.checks)
if total_weight == 0:
return 0.0
passed_weight = sum(c.weight for c in self.checks if c.passed)
return passed_weight / total_weight
@property
def passed_count(self) -> int:
"""Number of passed checks."""
return sum(1 for c in self.checks if c.passed)
@property
def total_count(self) -> int:
"""Total number of checks."""
return len(self.checks)
@property
def summary(self) -> str:
"""Human-readable summary of all check results."""
lines = [
f"Programmatic Grade: {self.score:.3f} "
f"({self.passed_count}/{self.total_count} checks passed)"
]
for c in self.checks:
icon = "✓" if c.passed else "✗"
lines.append(f" {icon} [{c.weight:.2f}] {c.name}: {c.details}")
return "\n".join(lines)
def to_dict(self) -> Dict:
"""Convert to dictionary for JSON serialization."""
return {
"score": self.score,
"passed_count": self.passed_count,
"total_count": self.total_count,
"checks": [
{"name": c.name, "passed": c.passed, "weight": c.weight, "details": c.details}
for c in self.checks
],
}
class ProgrammaticGrader:
"""
Runs 12 automated quality checks on AquaGuard-RL environment behavior.
Check overview:
| # | Name | Weight | What it tests |
|---|-------------------------|--------|-------------------------------------------|
| 1 | reset_returns_obs | 0.10 | reset() returns valid AquaGuardObservation |
| 2 | step_returns_obs | 0.10 | step() returns valid AquaGuardObservation |
| 3 | state_valid | 0.05 | state returns valid AquaGuardState |
| 4 | reward_bounds | 0.10 | rewards in [−10, 10], no NaN/Inf |
| 5 | reward_correlation | 0.10 | higher food security ↔ higher reward |
| 6 | groundwater_conserved | 0.15 | ≥70% steps keep all zones below critical |
| 7 | food_security | 0.10 | ≥70% steps meet food requirement |
| 8 | farmer_income | 0.10 | ≥70% steps below 40% poverty |
| 9 | done_condition | 0.05 | episode terminates correctly |
| 10| diversity_achievable | 0.05 | Shannon diversity > 0.5 achievable |
| 11| step_count_monotonic | 0.10 | step_count strictly increments |
| 12| observation_complete | 0.10 | all required fields populated |
"""
def evaluate_episode(
self,
episode_observations: List,
episode_actions: List,
episode_states: List,
) -> ProgrammaticGradeResult:
"""
Run all 12 checks on a complete or partial episode.
Args:
episode_observations: List of AquaGuardObservation objects (includes reset obs).
episode_actions: List of AquaGuardAction objects taken.
episode_states: List of AquaGuardState objects after each step.
Returns:
ProgrammaticGradeResult with all check results and composite score.
"""
result = ProgrammaticGradeResult()
obs = episode_observations
acts = episode_actions
states = episode_states
result.checks.append(self._check_reset_returns_observation(obs))
result.checks.append(self._check_step_returns_observation(obs, acts))
result.checks.append(self._check_state_valid(states))
result.checks.append(self._check_reward_bounds(obs))
result.checks.append(self._check_reward_correlation(obs))
result.checks.append(self._check_groundwater_conservation(obs))
result.checks.append(self._check_food_security(obs))
result.checks.append(self._check_farmer_income(obs))
result.checks.append(self._check_done_condition(obs, states))
result.checks.append(self._check_diversity_achievable(obs))
result.checks.append(self._check_step_count_monotonic(states))
result.checks.append(self._check_observation_completeness(obs))
logger.info(f"Programmatic grade: {result.score:.3f} ({result.passed_count}/{result.total_count})")
return result
# ─── Individual check implementations ────────────────────────────────────
def _check_reset_returns_observation(self, obs: List) -> CheckResult:
"""Check 1: reset() returns a valid AquaGuardObservation."""
if not obs:
return CheckResult("reset_returns_obs", False, 0.10, "No observations recorded")
first = obs[0]
try:
valid = (
hasattr(first, "step_number")
and first.step_number == 0
and hasattr(first, "season")
and first.season in ["kharif", "rabi", "zaid"]
and hasattr(first, "zones")
and len(first.zones) == 3
and hasattr(first, "crops")
and len(first.crops) == 6
and hasattr(first, "scenario_description")
and len(first.scenario_description) > 10
)
return CheckResult(
"reset_returns_obs", valid, 0.10,
f"Initial obs: season={first.season}, zones={len(first.zones)}, crops={len(first.crops)}"
if valid else f"Invalid initial observation: type={type(first).__name__}"
)
except Exception as e:
return CheckResult("reset_returns_obs", False, 0.10, f"Error: {e}")
def _check_step_returns_observation(self, obs: List, acts: List) -> CheckResult:
"""Check 2: step() returns a valid AquaGuardObservation with step_number=1."""
if len(obs) < 2 or not acts:
return CheckResult("step_returns_obs", False, 0.10, "Not enough steps recorded (need ≥2 obs)")
try:
step_obs = obs[1]
valid = (
hasattr(step_obs, "step_number")
and step_obs.step_number == 1
and hasattr(step_obs, "reward")
and step_obs.reward is not None
and isinstance(step_obs.reward, (int, float))
)
return CheckResult(
"step_returns_obs", valid, 0.10,
f"Step 1 obs: step_number={step_obs.step_number}, reward={step_obs.reward:.3f}"
if valid else "Step observation missing or invalid"
)
except Exception as e:
return CheckResult("step_returns_obs", False, 0.10, f"Error: {e}")
def _check_state_valid(self, states: List) -> CheckResult:
"""Check 3: state property returns valid AquaGuardState."""
if not states:
return CheckResult("state_valid", False, 0.05, "No states recorded")
try:
s = states[-1]
valid_tasks = {"baseline", "crisis", "policy_shift", "climate_shock", "multi_district"}
valid = (
hasattr(s, "episode_id")
and s.episode_id is not None
and hasattr(s, "step_count")
and s.step_count >= 0
and hasattr(s, "task_name")
and s.task_name in valid_tasks
)
return CheckResult(
"state_valid", valid, 0.05,
f"State: episode_id={s.episode_id[:8]}..., step={s.step_count}, task={s.task_name}"
if valid else f"Invalid state: {type(s).__name__}"
)
except Exception as e:
return CheckResult("state_valid", False, 0.05, f"Error: {e}")
def _check_reward_bounds(self, obs: List) -> CheckResult:
"""Check 4: All rewards are numeric, non-NaN/Inf, and in [−10, 10]."""
rewards = [o.reward for o in obs if hasattr(o, "reward") and o.reward is not None]
if not rewards:
return CheckResult("reward_bounds", False, 0.10, "No rewards recorded (all None)")
try:
invalid = [
r for r in rewards
if not isinstance(r, (int, float))
or math.isnan(r)
or math.isinf(r)
or r < -10.0
or r > 10.0
]
passed = len(invalid) == 0
return CheckResult(
"reward_bounds", passed, 0.10,
f"All {len(rewards)} rewards valid ∈ [−10, 10]: "
f"min={min(rewards):.3f}, max={max(rewards):.3f}"
if passed else f"{len(invalid)} invalid rewards: {invalid[:3]}"
)
except Exception as e:
return CheckResult("reward_bounds", False, 0.10, f"Error: {e}")
def _check_reward_correlation(self, obs: List) -> CheckResult:
"""
Check 5: Reward correlates positively with food security.
Simplified check: average reward in top-half food security steps
should be ≥ average reward in bottom-half steps.
"""
step_obs = [o for o in obs if hasattr(o, "reward") and o.reward is not None]
if len(step_obs) < 4:
return CheckResult("reward_correlation", True, 0.10,
f"Too few steps ({len(step_obs)}) — pass by default")
try:
sorted_by_food = sorted(step_obs, key=lambda o: o.food_security_ratio)
n = len(sorted_by_food)
low_food_reward = sum(o.reward for o in sorted_by_food[:n // 2]) / max(n // 2, 1)
high_food_reward = sum(o.reward for o in sorted_by_food[n // 2:]) / max(n - n // 2, 1)
correlated = high_food_reward >= low_food_reward - 0.5 # allow small tolerance
return CheckResult(
"reward_correlation", correlated, 0.10,
f"High-food avg reward={high_food_reward:.2f} vs low-food avg={low_food_reward:.2f}"
)
except Exception as e:
return CheckResult("reward_correlation", False, 0.10, f"Error: {e}")
def _check_groundwater_conservation(self, obs: List) -> CheckResult:
"""Check 6: ≥70% of steps maintain aquifer below critical threshold."""
if not obs:
return CheckResult("groundwater_conserved", False, 0.15, "No observations")
try:
danger_steps = sum(1 for o in obs if hasattr(o, "aquifer_danger_zone") and o.aquifer_danger_zone)
total = len(obs)
pass_rate = (total - danger_steps) / total
passed = pass_rate >= 0.70
return CheckResult(
"groundwater_conserved", passed, 0.15,
f"{total - danger_steps}/{total} steps in safe zone ({pass_rate:.0%})"
)
except Exception as e:
return CheckResult("groundwater_conserved", False, 0.15, f"Error: {e}")
def _check_food_security(self, obs: List) -> CheckResult:
"""Check 7: ≥70% of steps meet food security requirement (ratio ≥ 1.0)."""
step_obs = [o for o in obs if hasattr(o, "reward") and o.reward is not None]
if not step_obs:
return CheckResult("food_security", False, 0.10, "No step observations recorded")
try:
meeting = sum(1 for o in step_obs if o.food_security_ratio >= 1.0)
rate = meeting / len(step_obs)
passed = rate >= 0.70
return CheckResult(
"food_security", passed, 0.10,
f"{meeting}/{len(step_obs)} steps meet food requirement (ratio≥1.0) = {rate:.0%}"
)
except Exception as e:
return CheckResult("food_security", False, 0.10, f"Error: {e}")
def _check_farmer_income(self, obs: List) -> CheckResult:
"""Check 8: ≥70% of steps have <40% of farmers in poverty."""
step_obs = [o for o in obs if hasattr(o, "reward") and o.reward is not None]
if not step_obs:
return CheckResult("farmer_income", False, 0.10, "No step observations recorded")
try:
passing = sum(1 for o in step_obs if o.percent_farmers_below_poverty < 40.0)
rate = passing / len(step_obs)
passed = rate >= 0.70
return CheckResult(
"farmer_income", passed, 0.10,
f"{passing}/{len(step_obs)} steps below 40% poverty = {rate:.0%}"
)
except Exception as e:
return CheckResult("farmer_income", False, 0.10, f"Error: {e}")
def _check_done_condition(self, obs: List, states: List) -> CheckResult:
"""Check 9: Episode terminates correctly (done=True in last observation)."""
if not obs:
return CheckResult("done_condition", False, 0.05, "No observations")
try:
final_obs = obs[-1]
done_triggered = hasattr(final_obs, "done") and final_obs.done
return CheckResult(
"done_condition", done_triggered, 0.05,
f"Episode terminated (done=True) at step {final_obs.step_number}"
if done_triggered else f"Episode did not terminate (done={final_obs.done})"
)
except Exception as e:
return CheckResult("done_condition", False, 0.05, f"Error: {e}")
def _check_diversity_achievable(self, obs: List) -> CheckResult:
"""Check 10: At least one step achieves Shannon diversity index > 0.5."""
if not obs:
return CheckResult("diversity_achievable", False, 0.05, "No observations")
try:
max_diversity = max((o.shannon_diversity_index for o in obs if hasattr(o, "shannon_diversity_index")), default=0.0)
achieved = max_diversity > 0.5
return CheckResult(
"diversity_achievable", achieved, 0.05,
f"Max Shannon diversity achieved: {max_diversity:.3f} (threshold: 0.5)"
)
except Exception as e:
return CheckResult("diversity_achievable", False, 0.05, f"Error: {e}")
def _check_step_count_monotonic(self, states: List) -> CheckResult:
"""Check 11: step_count strictly increments after each step."""
if len(states) < 2:
return CheckResult("step_count_monotonic", True, 0.10,
f"Too few states ({len(states)}) — pass by default")
try:
monotonic = all(
states[i].step_count > states[i - 1].step_count
for i in range(1, len(states))
)
step_counts = [s.step_count for s in states]
return CheckResult(
"step_count_monotonic", monotonic, 0.10,
f"Step counts: {step_counts[:5]}{'...' if len(step_counts) > 5 else ''}"
if monotonic else f"Non-monotonic step counts: {step_counts}"
)
except Exception as e:
return CheckResult("step_count_monotonic", False, 0.10, f"Error: {e}")
def _check_observation_completeness(self, obs: List) -> CheckResult:
"""Check 12: All observations have required fields populated."""
if not obs:
return CheckResult("observation_complete", False, 0.10, "No observations")
try:
incomplete = []
for o in obs:
if not (
hasattr(o, "scenario_description") and len(o.scenario_description) > 10
and hasattr(o, "zones") and len(o.zones) == 3
and hasattr(o, "crops") and len(o.crops) == 6
and hasattr(o, "season") and o.season in ["kharif", "rabi", "zaid"]
and hasattr(o, "food_security_ratio")
and hasattr(o, "shannon_diversity_index")
):
incomplete.append(o.step_number if hasattr(o, "step_number") else "?")
passed = len(incomplete) == 0
return CheckResult(
"observation_complete", passed, 0.10,
f"All {len(obs)} observations fully populated"
if passed else f"Incomplete observations at steps: {incomplete}"
)
except Exception as e:
return CheckResult("observation_complete", False, 0.10, f"Error: {e}")