Spaces:
Sleeping
Sleeping
thewildofficial
feat(game): Option A auto-critical, discretionary proposals, remove CRITICAL_FAILURE
e4666d9 | """NationGame orchestration for the phased core game loop.""" | |
| from __future__ import annotations | |
| import math | |
| import random | |
| from dataclasses import dataclass, field | |
| from enum import StrEnum | |
| from typing import Any, Iterable, Mapping | |
| from schemas.actions import ActionType, VoteChoice | |
| from schemas.phases import Phase, valid_action_types_for_phase | |
| from .config import DEFAULT_CONFIG, GameConfig | |
| from .events import Event, EventEngine | |
| from .population import PopulationTracker | |
| from .productivity import ProductivityTracker | |
| from .reward import RewardBreakdown, compute_reward | |
| from .sector import Sector | |
| from .treasury import Treasury | |
| PHASE_COUNT = 9 | |
| PROPOSAL_STATUS_PENDING = "pending" | |
| PROPOSAL_STATUS_APPROVED = "approved" | |
| PROPOSAL_STATUS_REJECTED = "rejected" | |
| PROPOSAL_STATUS_REJECTED_INVALID = "rejected_invalid" | |
| TERMINATION_BANKRUPTCY = "BANKRUPTCY" | |
| TERMINATION_SHUTDOWN = "SHUTDOWN" | |
| TERMINATION_MAX_ROUNDS = "MAX_ROUNDS" | |
| TERMINATION_PROSPERITY = "PROSPERITY_THRESHOLD" | |
| class Proposal: | |
| """Public budget proposal submitted during phase 3. | |
| ``amount`` is **discretionary** funding (above the auto-funded critical | |
| floor). Total spend if approved = ``critical(department) + amount``. | |
| """ | |
| proposal_id: str | |
| agent_id: str | |
| department: str | |
| amount: float | |
| justification: str = "" | |
| status: str = PROPOSAL_STATUS_PENDING | |
| votes: dict[str, str] = field(default_factory=dict) | |
| rejection_reason: str | None = None | |
| def to_dict(self) -> dict[str, Any]: | |
| return { | |
| "proposal_id": self.proposal_id, | |
| "agent_id": self.agent_id, | |
| "department": self.department, | |
| "amount": self.amount, | |
| "justification": self.justification, | |
| "status": self.status, | |
| "votes": dict(self.votes), | |
| "rejection_reason": self.rejection_reason, | |
| } | |
| class StepResult: | |
| """Result returned by :meth:`NationGame.step`.""" | |
| observation: dict[str, Any] | |
| reward: RewardBreakdown | |
| done: bool | |
| info: dict[str, Any] = field(default_factory=dict) | |
| def round_num(self) -> int: | |
| return self.observation["round"] | |
| def termination_reason(self) -> str | None: | |
| return self.info.get("termination_reason") | |
| def treasury(self) -> float: | |
| return self.observation["treasury"] | |
| def productivity(self) -> float: | |
| return self.observation["productivity"] | |
| def population(self) -> int: | |
| return self.observation["population"] | |
| def year(self) -> int: | |
| return self.observation["year"] | |
| def quarter(self) -> int: | |
| return self.observation["quarter"] | |
| def allocations(self) -> dict[str, float]: | |
| return { | |
| name: sector["allocation"] | |
| for name, sector in self.observation["sectors"].items() | |
| } | |
| def revenue_factors(self) -> dict[str, float]: | |
| return { | |
| name: sector["revenue_factor"] | |
| for name, sector in self.observation["sectors"].items() | |
| } | |
| def revenues(self) -> dict[str, float]: | |
| return { | |
| name: sector["revenue"] | |
| for name, sector in self.observation["sectors"].items() | |
| } | |
| def consumptions(self) -> dict[str, float]: | |
| return { | |
| name: sector["consumption"] | |
| for name, sector in self.observation["sectors"].items() | |
| } | |
| def demands(self) -> dict[str, float]: | |
| return { | |
| name: sector["demand"] | |
| for name, sector in self.observation["sectors"].items() | |
| } | |
| def total_revenue(self) -> float: | |
| return self.observation["last_total_revenue"] | |
| def total_allocation(self) -> float: | |
| return self.observation["last_total_allocation"] | |
| def surplus_returned(self) -> float: | |
| return self.observation["last_total_surplus"] | |
| def events(self) -> list[dict[str, Any]]: | |
| return self.observation["current_events"] | |
| def crisis_occurred(self) -> bool: | |
| return bool(self.info.get("crisis_occurred", False)) | |
| def to_dict(self) -> dict[str, Any]: | |
| """Serialise the result for legacy callers and environment wrappers.""" | |
| return { | |
| **self.observation, | |
| "round_num": self.round_num, | |
| "allocations": self.allocations, | |
| "revenue_factors": self.revenue_factors, | |
| "revenues": self.revenues, | |
| "consumptions": self.consumptions, | |
| "demands": self.demands, | |
| "total_revenue": self.total_revenue, | |
| "total_allocation": self.total_allocation, | |
| "surplus_returned": self.surplus_returned, | |
| "reward": self.reward.to_dict(), | |
| "done": self.done, | |
| "termination_reason": self.termination_reason, | |
| } | |
| class NationGame: | |
| """Playable core game loop across the nine specified phases.""" | |
| def __init__(self, config: GameConfig | None = None, seed: int | None = None) -> None: | |
| self.config = config or DEFAULT_CONFIG | |
| self._initial_seed = seed | |
| self.rng = random.Random(seed) | |
| self.event_engine = EventEngine(rng=self.rng) | |
| self.reset(seed=seed) | |
| def reset(self, seed: int | None = None) -> dict[str, Any]: | |
| """Start a new episode and reveal round 1 events.""" | |
| if seed is not None: | |
| self._initial_seed = seed | |
| self.rng = random.Random(seed) | |
| self.event_engine = EventEngine(rng=self.rng) | |
| self.round = 1 | |
| self.phase = Phase.EVENT_REVELATION | |
| self.done = False | |
| self.termination_reason: str | None = None | |
| self.total_reward = 0.0 | |
| self.last_reward = compute_reward(prosperity=0.0) | |
| self.last_info: dict[str, Any] = {} | |
| self.shutdown_counter = 0 | |
| self.prosperity_streak = 0 | |
| self.last_total_revenue = 0.0 | |
| self.last_total_surplus = 0.0 | |
| self.last_total_allocation = 0.0 | |
| self.last_total_discretionary = 0.0 | |
| self.current_round_crisis = False | |
| self.current_events: list[Event] = [] | |
| self.event_ledger: list[dict[str, Any]] = [] | |
| self.debate_messages: list[dict[str, str]] = [] | |
| self.proposals: list[Proposal] = [] | |
| self.votes: list[dict[str, str]] = [] | |
| self._proposal_counter = 0 | |
| self._submitted_departments: set[str] = set() | |
| self.treasury = Treasury( | |
| balance=self.config.INITIAL_TREASURY, | |
| baseline_tax=self.config.BASELINE_TAX, | |
| ) | |
| self.population = PopulationTracker( | |
| value=self.config.POP_0, | |
| birth_rate_base=self.config.BIRTH_RATE_BASE, | |
| death_rate_base=self.config.DEATH_RATE_BASE, | |
| crisis_death_penalty=self.config.CRISIS_DEATH_PENALTY, | |
| ) | |
| self.productivity = ProductivityTracker( | |
| value=self.config.INITIAL_PRODUCTIVITY, | |
| min_val=self.config.PRODUCTIVITY_MIN, | |
| max_val=self.config.PRODUCTIVITY_MAX, | |
| step=self.config.PRODUCTIVITY_STEP, | |
| ) | |
| self.sectors = { | |
| name: Sector( | |
| name=name, | |
| baseline=baseline, | |
| full_name=self.config.SECTOR_META.get(name, {}).get("full_name", name), | |
| description=self.config.SECTOR_META.get(name, {}).get("description", ""), | |
| _critical_ratio=self.config.CRITICAL_RATIO, | |
| _surplus_ratio=self.config.SURPLUS_RATIO, | |
| _wastage_ratio=self.config.WASTAGE_RATIO, | |
| _rf_max=self.config.RF_MAX, | |
| ) | |
| for name, baseline in self.config.SECTOR_BASELINES.items() | |
| } | |
| self._start_round() | |
| return self.state() | |
| def state(self) -> dict[str, Any]: | |
| """Return a structured public state snapshot.""" | |
| year, quarter = self._year_quarter(self.round) | |
| return { | |
| "round": self.round, | |
| "phase": self.phase, | |
| "phase_name": self.phase.name, | |
| "year": year, | |
| "quarter": quarter, | |
| "max_rounds": int(self.config.MAX_ROUNDS), | |
| "total_critical": round(self._total_critical_funding(), 6), | |
| "treasury": round(self.treasury.balance, 6), | |
| "population": self.population.value, | |
| "productivity": round(self.productivity.value, 6), | |
| "sectors": {name: sector.to_dict() for name, sector in self.sectors.items()}, | |
| "event_ledger": list(self.event_ledger), | |
| "current_events": [event.to_dict() for event in self.current_events], | |
| "debate_messages": list(self.debate_messages), | |
| "proposals": [proposal.to_dict() for proposal in self.proposals], | |
| "votes": list(self.votes), | |
| "termination": { | |
| "episode_ended": self.done, | |
| "reason": self.termination_reason, | |
| }, | |
| "last_reward": self.last_reward.to_dict(), | |
| "total_reward": round(self.total_reward, 6), | |
| "last_total_revenue": round(self.last_total_revenue, 6), | |
| "last_total_surplus": round(self.last_total_surplus, 6), | |
| "last_total_allocation": round(self.last_total_allocation, 6), | |
| } | |
| def step(self, action: Any = None) -> StepResult: | |
| """ | |
| Process one phase step. | |
| A mapping of ``{department: amount}`` remains supported as a legacy | |
| full-round shortcut and is converted into direct approved allocations. | |
| """ | |
| if self.done: | |
| return self._result({"ignored": True, "reason": "episode_done"}) | |
| if self._is_allocation_mapping(action): | |
| return self._run_direct_allocation_round(action) | |
| info: dict[str, Any] = {"accepted_actions": [], "ignored_actions": [], "rejected_actions": []} | |
| for item in self._normalize_actions(action): | |
| self._handle_phase_action(item, info) | |
| self._run_current_system_phase(info) | |
| completed_round = self.round | |
| self._advance_phase() | |
| if not self.done and self.phase == Phase.EVENT_REVELATION and self.round != completed_round: | |
| self._start_round() | |
| self.last_info = info | |
| return self._result(info) | |
| def _start_round(self) -> None: | |
| self.current_round_crisis = False | |
| self.current_events = [] | |
| self.debate_messages = [] | |
| self.proposals = [] | |
| self.votes = [] | |
| self._submitted_departments = set() | |
| for sector in self.sectors.values(): | |
| sector.reset_round() | |
| sector.update_thresholds(self.population.value, self.config.POP_0) | |
| self.current_events = self.event_engine.generate_events( | |
| sector_names=list(self.config.SECTOR_ORDER) | |
| ) | |
| self.current_round_crisis = self.event_engine.apply_events( | |
| self.current_events, | |
| self.sectors, | |
| self.treasury, | |
| ) | |
| for sector in self.sectors.values(): | |
| sector.update_thresholds(self.population.value, self.config.POP_0) | |
| for event in self.current_events: | |
| record = event.to_dict() | |
| record["round"] = self.round | |
| self.event_ledger.append(record) | |
| def _handle_phase_action(self, action: Mapping[str, Any], info: dict[str, Any]) -> None: | |
| action_type = self._action_type_value(action.get("type")) | |
| if action_type not in valid_action_types_for_phase(self.phase): | |
| info["ignored_actions"].append({"action": dict(action), "reason": "wrong_phase"}) | |
| return | |
| if action_type == ActionType.DEBATE.value: | |
| self._handle_debate(action, info) | |
| elif action_type == ActionType.PROPOSE_BUDGET.value: | |
| self._handle_proposal(action, info) | |
| elif action_type == ActionType.VOTE.value: | |
| self._handle_vote(action, info) | |
| def _handle_debate(self, action: Mapping[str, Any], info: dict[str, Any]) -> None: | |
| message = str(action.get("message", "")) | |
| agent_id = str(action.get("agent_id") or action.get("agent") or "anonymous") | |
| entry = {"agent_id": agent_id, "message": message} | |
| self.debate_messages.append(entry) | |
| info["accepted_actions"].append({"type": ActionType.DEBATE.value, **entry}) | |
| def _handle_proposal(self, action: Mapping[str, Any], info: dict[str, Any]) -> None: | |
| department = str(action.get("department", "")) | |
| agent_id = str(action.get("agent_id") or action.get("agent") or department) | |
| amount = action.get("amount") | |
| if department in self._submitted_departments: | |
| info["ignored_actions"].append({"action": dict(action), "reason": "duplicate_proposal"}) | |
| return | |
| rejection_reason = self._proposal_rejection_reason(agent_id, department, amount) | |
| if rejection_reason is not None: | |
| proposal = self._new_proposal( | |
| agent_id=agent_id, | |
| department=department, | |
| amount=self._safe_float(amount), | |
| justification=str(action.get("justification", "")), | |
| status=PROPOSAL_STATUS_REJECTED_INVALID, | |
| rejection_reason=rejection_reason, | |
| ) | |
| info["rejected_actions"].append( | |
| {"action": dict(action), "reason": rejection_reason, "proposal_id": proposal.proposal_id} | |
| ) | |
| return | |
| proposal = self._new_proposal( | |
| agent_id=agent_id, | |
| department=department, | |
| amount=float(amount), | |
| justification=str(action.get("justification", "")), | |
| ) | |
| self._submitted_departments.add(department) | |
| info["accepted_actions"].append({"type": ActionType.PROPOSE_BUDGET.value, "proposal_id": proposal.proposal_id}) | |
| def _handle_vote(self, action: Mapping[str, Any], info: dict[str, Any]) -> None: | |
| proposal_id = str(action.get("proposal_id", "")) | |
| proposal = self._proposal_by_id(proposal_id) | |
| agent_id = str(action.get("agent_id") or action.get("agent") or "") | |
| vote = self._vote_value(action.get("vote")) | |
| if proposal is None or proposal.status != PROPOSAL_STATUS_PENDING: | |
| info["ignored_actions"].append({"action": dict(action), "reason": "proposal_not_votable"}) | |
| return | |
| if not agent_id: | |
| info["ignored_actions"].append({"action": dict(action), "reason": "missing_agent_id"}) | |
| return | |
| if agent_id == proposal.agent_id or agent_id == proposal.department: | |
| info["rejected_actions"].append({"action": dict(action), "reason": "self_vote"}) | |
| return | |
| if vote is None: | |
| info["rejected_actions"].append({"action": dict(action), "reason": "invalid_vote"}) | |
| return | |
| if agent_id in proposal.votes: | |
| info["ignored_actions"].append({"action": dict(action), "reason": "duplicate_vote"}) | |
| return | |
| proposal.votes[agent_id] = vote | |
| self.votes.append({"proposal_id": proposal_id, "agent_id": agent_id, "vote": vote}) | |
| info["accepted_actions"].append({"type": ActionType.VOTE.value, "proposal_id": proposal_id, "agent_id": agent_id, "vote": vote}) | |
| def _run_current_system_phase(self, info: dict[str, Any]) -> None: | |
| if self.phase == Phase.BUDGET_EXECUTION: | |
| self._tally_votes() | |
| budget_status = self._execute_approved_budgets() | |
| if budget_status == "bankruptcy": | |
| self._finish_round(budget_bankruptcy=True, terminate_immediately=True) | |
| info["termination_reason"] = self.termination_reason | |
| elif self.phase == Phase.CONSUMPTION_AND_EVENT_IMPACT and not self.done: | |
| self._compute_consumption() | |
| elif self.phase == Phase.REVENUE_CALCULATION and not self.done: | |
| self._compute_revenue() | |
| self.treasury.credit(self.last_total_revenue) | |
| elif self.phase == Phase.SURPLUS_ROLLOVER and not self.done: | |
| self.treasury.credit(self.last_total_surplus) | |
| self.treasury.apply_baseline_tax() | |
| elif self.phase == Phase.TERMINATION_CHECK and not self.done: | |
| self._finish_round() | |
| info["termination_reason"] = self.termination_reason | |
| def _run_direct_allocation_round(self, allocations: Mapping[str, Any]) -> StepResult: | |
| if self.phase != Phase.EVENT_REVELATION: | |
| self.phase = Phase.EVENT_REVELATION | |
| self._start_round() | |
| for proposal in self.proposals: | |
| proposal.status = PROPOSAL_STATUS_REJECTED | |
| self.proposals = [] | |
| total_c = self._total_critical_funding() | |
| if self.treasury.balance < total_c: | |
| self.last_total_discretionary = 0.0 | |
| self._finish_round( | |
| budget_bankruptcy=True, terminate_immediately=False, completed_round=self.round | |
| ) | |
| info = { | |
| "accepted_actions": [{"type": "DIRECT_ALLOCATION"}], | |
| "ignored_actions": [], | |
| "rejected_actions": [], | |
| "termination_reason": self.termination_reason, | |
| } | |
| self.last_info = info | |
| return self._result(info, completed_round=self.round) | |
| pop = self.population.value | |
| for sector in self.sectors.values(): | |
| sector.allocate(0.0) | |
| for sector in self.sectors.values(): | |
| sector.allocate(float(sector.critical(pop))) | |
| self.treasury.debit(total_c) | |
| remaining = self.treasury.balance | |
| disc_sum = 0.0 | |
| for department in self.config.SECTOR_ORDER: | |
| sector = self.sectors[department] | |
| c = float(sector.critical(pop)) | |
| requested_total = max(0.0, float(allocations.get(department, 0.0))) | |
| want_disc = max(0.0, requested_total - c) | |
| disc = min(want_disc, max(0.0, remaining)) | |
| sector.allocate(c + disc) | |
| self.treasury.debit(disc) | |
| remaining = self.treasury.balance | |
| disc_sum += disc | |
| self._new_proposal( | |
| agent_id=department, | |
| department=department, | |
| amount=disc, | |
| status=PROPOSAL_STATUS_APPROVED, | |
| ) | |
| self.last_total_discretionary = disc_sum | |
| self.last_total_allocation = total_c + disc_sum | |
| self._compute_consumption() | |
| self._compute_revenue() | |
| self.treasury.credit(self.last_total_revenue) | |
| self.treasury.credit(self.last_total_surplus) | |
| self.treasury.apply_baseline_tax() | |
| self._finish_round(completed_round=self.round) | |
| info = { | |
| "accepted_actions": [{"type": "DIRECT_ALLOCATION"}], | |
| "ignored_actions": [], | |
| "rejected_actions": [], | |
| "termination_reason": self.termination_reason, | |
| } | |
| self.last_info = info | |
| completed_round_num = self.round | |
| end_observation = self.state() | |
| if not self.done: | |
| self.round += 1 | |
| self.phase = Phase.EVENT_REVELATION | |
| self._start_round() | |
| return self._result(info, completed_round=completed_round_num, observation=end_observation) | |
| def _total_critical_funding(self) -> float: | |
| """Total critical minimum spend for all sectors this round (population-based).""" | |
| pop = self.population.value | |
| return sum(s.critical(pop) for s in self.sectors.values()) | |
| def _execute_approved_budgets(self) -> str: | |
| """Set allocations from auto-funded critical + approved discretionary. Returns status.""" | |
| self.last_total_discretionary = 0.0 | |
| total_c = self._total_critical_funding() | |
| if self.treasury.balance < total_c: | |
| self.last_total_allocation = 0.0 | |
| return "bankruptcy" | |
| pop = self.population.value | |
| for sector in self.sectors.values(): | |
| sector.allocate(0.0) | |
| for sector in self.sectors.values(): | |
| sector.allocate(float(sector.critical(pop))) | |
| self.treasury.debit(total_c) | |
| disc_debited = 0.0 | |
| for proposal in self.proposals: | |
| if proposal.status != PROPOSAL_STATUS_APPROVED: | |
| continue | |
| disc = float(proposal.amount) | |
| department = proposal.department | |
| self.sectors[department].allocate(self.sectors[department].allocation + disc) | |
| self.treasury.debit(disc) | |
| disc_debited += disc | |
| self.last_total_discretionary = disc_debited | |
| self.last_total_allocation = total_c + disc_debited | |
| return "ok" | |
| def _tally_votes(self) -> None: | |
| total_c = self._total_critical_funding() | |
| remaining = max(0.0, self.treasury.balance - total_c) | |
| for proposal in self.proposals: | |
| if proposal.status != PROPOSAL_STATUS_PENDING: | |
| continue | |
| yes_votes = sum(1 for vote in proposal.votes.values() if vote == VoteChoice.YES.value) | |
| no_votes = sum(1 for vote in proposal.votes.values() if vote == VoteChoice.NO.value) | |
| if proposal.amount > remaining: | |
| proposal.status = PROPOSAL_STATUS_REJECTED | |
| proposal.rejection_reason = "exceeds_remaining_treasury" | |
| elif yes_votes > no_votes: | |
| proposal.status = PROPOSAL_STATUS_APPROVED | |
| remaining -= proposal.amount | |
| else: | |
| proposal.status = PROPOSAL_STATUS_REJECTED | |
| def _compute_consumption(self) -> None: | |
| self.last_total_surplus = 0.0 | |
| for sector in self.sectors.values(): | |
| self.last_total_surplus += sector.compute_consumption() | |
| def _compute_revenue(self) -> None: | |
| self.last_total_revenue = 0.0 | |
| for sector in self.sectors.values(): | |
| revenue = sector.compute_revenue(self.population.value, self.productivity.value) | |
| if revenue is not None: | |
| self.last_total_revenue += revenue | |
| def _finish_round( | |
| self, | |
| *, | |
| budget_bankruptcy: bool = False, | |
| terminate_immediately: bool = False, | |
| completed_round: int | None = None, | |
| ) -> None: | |
| completed_round = completed_round or self.round | |
| hard_failure = budget_bankruptcy | |
| if budget_bankruptcy: | |
| self.last_total_revenue = 0.0 | |
| self.last_total_surplus = 0.0 | |
| self.done = True | |
| self.termination_reason = TERMINATION_BANKRUPTCY | |
| else: | |
| revenue_factors = [sector.revenue_factor_value for sector in self.sectors.values()] | |
| self.productivity.update(revenue_factors) | |
| self.population.update(self.productivity.value, self.current_round_crisis) | |
| self._update_shutdown_counter() | |
| self._check_standard_termination() | |
| prosperity = self.last_total_revenue / max(self.population.value, 1) | |
| zone_penalty_overrides = ( | |
| {"over_allocated_count": 0, "under_allocated_count": 0} | |
| if hard_failure | |
| else {} | |
| ) | |
| self.last_reward = compute_reward( | |
| sectors=self.sectors, | |
| total_revenue=self.last_total_revenue, | |
| population=self.population.value, | |
| productivity=self.productivity.value, | |
| round_num=completed_round, | |
| **zone_penalty_overrides, | |
| productivity_bonus_scale=self.config.PRODUCTIVITY_BONUS_SCALE, | |
| survival_bonus_per_round=self.config.SURVIVAL_BONUS_PER_ROUND, | |
| over_alloc_penalty_val=self.config.OVER_ALLOC_PENALTY, | |
| under_alloc_penalty_val=self.config.UNDER_ALLOC_PENALTY, | |
| ) | |
| if self.termination_reason == TERMINATION_BANKRUPTCY: | |
| self.last_reward.critical_penalty = self.config.BANKRUPTCY_PENALTY | |
| self.total_reward += self.last_reward.total | |
| if terminate_immediately: | |
| self.phase = Phase.BUDGET_EXECUTION | |
| if not self.done and self.config.PROSPERITY_THRESHOLD is not None: | |
| if prosperity >= self.config.PROSPERITY_THRESHOLD: | |
| self.prosperity_streak += 1 | |
| else: | |
| self.prosperity_streak = 0 | |
| if self.prosperity_streak >= self.config.PROSPERITY_STREAK: | |
| self.done = True | |
| self.termination_reason = TERMINATION_PROSPERITY | |
| def _check_standard_termination(self) -> None: | |
| if self.shutdown_counter >= self.config.SHUTDOWN_THRESHOLD: | |
| self.done = True | |
| self.termination_reason = TERMINATION_SHUTDOWN | |
| elif self.treasury.is_bankrupt(): | |
| self.done = True | |
| self.termination_reason = TERMINATION_BANKRUPTCY | |
| elif self.round >= self.config.MAX_ROUNDS: | |
| self.done = True | |
| self.termination_reason = TERMINATION_MAX_ROUNDS | |
| def _update_shutdown_counter(self) -> None: | |
| if self.last_total_discretionary == 0: | |
| self.shutdown_counter += 1 | |
| else: | |
| self.shutdown_counter = 0 | |
| def _advance_phase(self) -> None: | |
| if self.done: | |
| return | |
| if self.phase == Phase.DEBATE: | |
| # Debate stays until force_advance_phase is called | |
| return | |
| if self.phase == Phase.PROPOSAL: | |
| # Only advance if all departments have submitted a proposal | |
| if len(self._submitted_departments) < len(self.sectors): | |
| return | |
| if self.phase == Phase.VOTING: | |
| # Only advance if all pending proposals have been voted on by everyone else | |
| pending = [p for p in self.proposals if p.status == PROPOSAL_STATUS_PENDING] | |
| if pending: | |
| for p in pending: | |
| # Everyone except the proposer must vote | |
| required_votes = len(self.sectors) - 1 | |
| if len(p.votes) < required_votes: | |
| return | |
| # If we reach here, all pending proposals are fully voted. | |
| self._tally_votes() | |
| else: | |
| # No proposals to vote on? Advance. | |
| pass | |
| if self.phase == Phase.TERMINATION_CHECK: | |
| self.round += 1 | |
| self.phase = Phase.EVENT_REVELATION | |
| return | |
| self.phase = Phase(int(self.phase) + 1) | |
| def force_advance_phase(self) -> None: | |
| """Force the phase to advance (used to end Debate).""" | |
| if self.done: | |
| return | |
| if self.phase == Phase.TERMINATION_CHECK: | |
| self.round += 1 | |
| self.phase = Phase.EVENT_REVELATION | |
| else: | |
| self.phase = Phase(int(self.phase) + 1) | |
| def reopen_proposal_phase(self, rejected_departments: list[str]) -> None: | |
| """Rewind to Phase 3 for rejected departments only. | |
| Called by the environment wrapper when the retry loop is active. | |
| Keeps approved proposals intact, clears rejected ones, and allows | |
| the rejected departments to re-propose. | |
| """ | |
| # Remove rejected proposals so they can be re-submitted | |
| self.proposals = [ | |
| p for p in self.proposals | |
| if p.department not in rejected_departments | |
| or p.status == "approved" | |
| ] | |
| # Allow rejected departments to submit again | |
| for dept in rejected_departments: | |
| self._submitted_departments.discard(dept) | |
| # Clear votes (new proposals need new votes) | |
| self.votes = [] | |
| # Rewind phase | |
| self.phase = Phase.PROPOSAL | |
| def apply_fallback_allocations(self, departments: list[str]) -> None: | |
| """Auto-assign a discretionary request for departments that exhausted retries.""" | |
| for dept in departments: | |
| if dept in self.sectors and dept not in self._submitted_departments: | |
| sector = self.sectors[dept] | |
| c = float(sector.critical) | |
| dem = float(sector.demand) | |
| b = float(sector.baseline) | |
| pool = max(0.0, self.treasury.balance - self._total_critical_funding()) | |
| want = max(0.0, min(dem, b) - c) | |
| disc = min(want, pool) | |
| self._new_proposal( | |
| agent_id=dept, | |
| department=dept, | |
| amount=disc, | |
| justification="Fallback: baseline demand after retry exhaustion.", | |
| status="approved", | |
| ) | |
| self._submitted_departments.add(dept) | |
| def _new_proposal( | |
| self, | |
| *, | |
| agent_id: str, | |
| department: str, | |
| amount: float, | |
| justification: str = "", | |
| status: str = PROPOSAL_STATUS_PENDING, | |
| rejection_reason: str | None = None, | |
| ) -> Proposal: | |
| self._proposal_counter += 1 | |
| proposal = Proposal( | |
| proposal_id=f"r{self.round}-p{self._proposal_counter}", | |
| agent_id=agent_id, | |
| department=department, | |
| amount=amount, | |
| justification=justification, | |
| status=status, | |
| rejection_reason=rejection_reason, | |
| ) | |
| self.proposals.append(proposal) | |
| return proposal | |
| def _proposal_rejection_reason(self, agent_id: str, department: str, amount: Any) -> str | None: | |
| amount_value = self._safe_float(amount) | |
| if department not in self.sectors: | |
| return "unknown_department" | |
| if agent_id != department: | |
| return "wrong_department" | |
| if amount_value < 0 or math.isnan(amount_value) or math.isinf(amount_value): | |
| return "invalid_amount" | |
| total_c = self._total_critical_funding() | |
| if amount_value > max(0.0, self.treasury.balance - total_c): | |
| return "exceeds_treasury" | |
| return None | |
| def _proposal_by_id(self, proposal_id: str) -> Proposal | None: | |
| return next((proposal for proposal in self.proposals if proposal.proposal_id == proposal_id), None) | |
| def _result( | |
| self, | |
| info: dict[str, Any], | |
| completed_round: int | None = None, | |
| *, | |
| observation: dict[str, Any] | None = None, | |
| ) -> StepResult: | |
| observation = dict(observation) if observation is not None else self.state() | |
| if completed_round is not None: | |
| year, quarter = self._year_quarter(completed_round) | |
| observation["round"] = completed_round | |
| observation["year"] = year | |
| observation["quarter"] = quarter | |
| return StepResult( | |
| observation=observation, | |
| reward=self.last_reward, | |
| done=self.done, | |
| info={ | |
| **info, | |
| "termination_reason": self.termination_reason, | |
| "crisis_occurred": self.current_round_crisis, | |
| }, | |
| ) | |
| def _normalize_actions(self, action: Any) -> list[Mapping[str, Any]]: | |
| if action is None: | |
| return [] | |
| if hasattr(action, "to_dict"): | |
| return [action.to_dict()] | |
| if isinstance(action, Mapping): | |
| return [action] | |
| if isinstance(action, Iterable) and not isinstance(action, (str, bytes)): | |
| normalized = [] | |
| for item in action: | |
| if hasattr(item, "to_dict"): | |
| normalized.append(item.to_dict()) | |
| elif isinstance(item, Mapping): | |
| normalized.append(item) | |
| return normalized | |
| return [] | |
| def _is_allocation_mapping(self, action: Any) -> bool: | |
| return ( | |
| isinstance(action, Mapping) | |
| and "type" not in action | |
| and bool(action) | |
| and set(action).issubset(set(self.config.SECTOR_ORDER)) | |
| ) | |
| def _action_type_value(self, action_type: Any) -> str: | |
| if isinstance(action_type, StrEnum): | |
| return action_type.value | |
| return str(action_type) | |
| def _vote_value(self, vote: Any) -> str | None: | |
| value = vote.value if isinstance(vote, StrEnum) else str(vote) | |
| return value if value in {choice.value for choice in VoteChoice} else None | |
| def _safe_float(self, value: Any) -> float: | |
| try: | |
| return float(value) | |
| except (TypeError, ValueError): | |
| return math.nan | |
| def _year_quarter(self, round_num: int) -> tuple[int, int]: | |
| year = (round_num - 1) // 4 + 1 | |
| quarter = (round_num - 1) % 4 + 1 | |
| return year, quarter | |