nation_optimizer / server /models.py
thewildofficial
feat(game): Option A auto-critical, discretionary proposals, remove CRITICAL_FAILURE
e4666d9
Raw
History Blame Contribute Delete
6.14 kB
"""OpenEnv Pydantic models for the Nation Simulator parliamentary environment."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field, model_validator
from openenv.core.env_server import Action, Observation, State
# --- Supporting Models ---
class EventModel(BaseModel):
"""An event revealed during Phase 1."""
name: str
severity: int
category: str
narrative: str
affected_departments: list[str] = Field(default_factory=list)
round: int | None = None
cost: float | None = None
class ProposalModel(BaseModel):
"""A budget proposal submitted during Phase 3."""
proposal_id: str
agent_id: str
department: str
amount: float
justification: str = ""
status: str = "pending"
votes: dict[str, str] = Field(default_factory=dict)
rejection_reason: str | None = None
class VoteModel(BaseModel):
"""A single vote cast during Phase 4."""
proposal_id: str
agent_id: str
vote: str # YES / NO / ABSTAIN
class OwnDepartmentModel(BaseModel):
"""Private observation for a specific department's minister."""
name: str
allocated_budget: float | None = None
consumption: float | None = None
surplus: float | None = None
efficiency_rating: float | None = None
treasury_surplus_returned_this_round: float | None = None
baseline: float | None = None
# --- OpenEnv Action ---
class ParliamentaryAction(Action):
"""
A single agent action in the parliamentary cycle.
The `type` field determines which optional fields are required:
- DEBATE: requires `message`
- FINISH_DEBATE: requires `reason`
- PROPOSE_BUDGET: requires `department`, `amount`, `justification`
- VOTE: requires `proposal_id`, `vote`
``amount`` in PROPOSE_BUDGET is discretionary funding above the auto-funded
critical floor (see Option A in game rules).
"""
agent_id: str
type: str # DEBATE | FINISH_DEBATE | PROPOSE_BUDGET | VOTE
# DEBATE fields
message: str | None = None
# FINISH_DEBATE fields
reason: str | None = None
# PROPOSE_BUDGET fields
department: str | None = None
amount: float | None = None
justification: str | None = None
# VOTE fields
proposal_id: str | None = None
vote: str | None = None # YES / NO / ABSTAIN
def to_engine_dict(self) -> dict[str, Any]:
"""Convert to the dict format expected by core/game.py step()."""
d: dict[str, Any] = {"type": self.type, "agent_id": self.agent_id}
if self.type == "DEBATE":
d["message"] = self.message or ""
elif self.type == "PROPOSE_BUDGET":
d["department"] = self.department or ""
d["amount"] = self.amount if self.amount is not None else 0.0
d["justification"] = self.justification or ""
elif self.type == "VOTE":
d["proposal_id"] = self.proposal_id or ""
d["vote"] = str(self.vote) if self.vote else "ABSTAIN"
return d
# --- OpenEnv Observation ---
class ParliamentaryObservation(Observation):
"""
Full spec-compliant observation per 08_OBSERVATION_SPACE.md.
Includes all public information plus private `own_department` data
for the specific agent receiving the observation.
"""
round: int = 0
phase: int = 1
phase_name: str = "EVENT_REVELATION"
year: int = 1
quarter: int = 1
treasury: float = 0.0
population: int = 0
productivity: float = 1.0
# Public information
event_ledger: list[dict[str, Any]] = Field(default_factory=list)
current_events: list[EventModel] = Field(default_factory=list)
proposals: list[ProposalModel] = Field(default_factory=list)
votes: list[VoteModel] = Field(default_factory=list)
debate_messages: list[dict[str, str]] = Field(default_factory=list)
# Private to the observing agent
own_department: OwnDepartmentModel | None = None
# Phase-gated action mask
valid_actions: list[str] = Field(default_factory=list)
target_proposal_id: str | None = None
# Termination info
termination: dict[str, Any] = Field(default_factory=dict)
# Which agent should act next (set by environment)
current_agent: str | None = None
# Retry info
retry_count: int = 0
rejected_departments: list[str] = Field(default_factory=list)
# --- Legacy OpenEnv Action ---
class NationAction(Action):
"""OpenEnv action envelope for direct allocation and smoke clients."""
bids: list[float] | None = Field(
default=None,
min_length=6,
max_length=6,
description="Legacy continuous bids from each of the 6 sectors.",
)
actions: dict[str, Any] | list[dict[str, Any]] | None = Field(
default=None,
description="One phase action or a list of phase actions.",
)
direct_allocations: dict[str, float] | None = Field(
default=None,
description="Full-round direct allocation shortcut keyed by department.",
)
@model_validator(mode="after")
def require_single_action_source(self) -> "NationAction":
sources = [
self.bids is not None,
self.actions is not None,
self.direct_allocations is not None,
]
if sum(sources) != 1:
raise ValueError("Provide exactly one of bids, actions, or direct_allocations.")
return self
def to_core_action(self) -> Any:
if self.direct_allocations is not None:
return dict(self.direct_allocations)
return self.actions
class NationObservation(Observation):
"""Serializable whole-game observation for the thin OpenEnv wrapper."""
state: dict[str, Any] = Field(description="Public NationGame state snapshot.")
info: dict[str, Any] = Field(default_factory=dict)
# --- OpenEnv State ---
class NationState(State):
"""Internal state for the OpenEnv environment."""
raw_game_state: dict[str, Any] = Field(default_factory=dict)
core_state: dict[str, Any] = Field(default_factory=dict)