Spaces:
Sleeping
Sleeping
| # 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__) | |
| class CheckResult: | |
| """Result of a single programmatic check.""" | |
| name: str | |
| passed: bool | |
| weight: float | |
| details: str = "" | |
| class ProgrammaticGradeResult: | |
| """Aggregate result from all 12 programmatic checks.""" | |
| checks: List[CheckResult] = field(default_factory=list) | |
| 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 | |
| def passed_count(self) -> int: | |
| """Number of passed checks.""" | |
| return sum(1 for c in self.checks if c.passed) | |
| def total_count(self) -> int: | |
| """Total number of checks.""" | |
| return len(self.checks) | |
| 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}") |