|
3 | 3 |
|
4 | 4 | import copy |
5 | 5 | import json |
| 6 | +import math |
6 | 7 | from typing import Any |
7 | 8 |
|
8 | 9 | import numpy as np |
@@ -37,6 +38,14 @@ def __init__( |
37 | 38 | "action_mask": spaces.MultiBinary(self.native.action_space_size), |
38 | 39 | }) |
39 | 40 | self.agents = [] |
| 41 | + self.rewards = {} |
| 42 | + self._cumulative_rewards = {} |
| 43 | + self.terminations = {} |
| 44 | + self.truncations = {} |
| 45 | + self.infos = {} |
| 46 | + self.agent_selection = None |
| 47 | + self._skip_agent_selection = None |
| 48 | + self._initialized = False |
40 | 49 | self._position_set = None |
41 | 50 | self._position_id = None |
42 | 51 | self._rng, _ = seeding.np_random(None) |
@@ -74,6 +83,7 @@ def reset(self, seed=None, options=None): |
74 | 83 | self.native, self._rng = candidate, rng |
75 | 84 | self._position_set, self._position_id = dataset, position_id |
76 | 85 | self.agents = self.possible_agents[:] |
| 86 | + self._initialized = True |
77 | 87 | self.rewards = {agent: 0.0 for agent in self.agents} |
78 | 88 | self._cumulative_rewards = self.rewards.copy() |
79 | 89 | terminated, truncated = self.native.flags() |
@@ -111,6 +121,96 @@ def render(self): |
111 | 121 | def close(self): |
112 | 122 | pass |
113 | 123 |
|
| 124 | + def get_state(self) -> dict[str, Any]: |
| 125 | + """Private, JSON-compatible checkpoint including pending AEC rewards.""" |
| 126 | + return { |
| 127 | + "version": 1, |
| 128 | + "native": json.loads(self.native.get_state()), |
| 129 | + "rng": copy.deepcopy(self._rng.bit_generator.state), |
| 130 | + "curriculum": curriculum.checkpoint(self._position_set, self._position_id), |
| 131 | + "initialized": self._initialized, |
| 132 | + "agents": self.agents[:], |
| 133 | + "agent_selection": self.agent_selection, |
| 134 | + "skip_agent_selection": self._skip_agent_selection, |
| 135 | + "rewards": self.rewards.copy(), |
| 136 | + "cumulative_rewards": self._cumulative_rewards.copy(), |
| 137 | + "terminations": self.terminations.copy(), |
| 138 | + "truncations": self.truncations.copy(), |
| 139 | + } |
| 140 | + |
| 141 | + def set_state(self, state: dict[str, Any]) -> None: |
| 142 | + """Validate a checkpoint completely before replacing any live state.""" |
| 143 | + required = { |
| 144 | + "version", "native", "rng", "curriculum", "initialized", "agents", |
| 145 | + "agent_selection", "skip_agent_selection", "rewards", |
| 146 | + "cumulative_rewards", "terminations", "truncations", |
| 147 | + } |
| 148 | + if ( |
| 149 | + not isinstance(state, dict) or set(state) != required |
| 150 | + or type(state["version"]) is not int or state["version"] != 1 |
| 151 | + or type(state["initialized"]) is not bool |
| 152 | + ): |
| 153 | + raise ValueError("incompatible AEC checkpoint") |
| 154 | + agents = state["agents"] |
| 155 | + if ( |
| 156 | + not isinstance(agents, list) |
| 157 | + or any(not isinstance(agent, str) or agent not in self._seats for agent in agents) |
| 158 | + or agents != [agent for agent in self.possible_agents if agent in agents] |
| 159 | + ): |
| 160 | + raise ValueError("invalid AEC checkpoint agents") |
| 161 | + for field in ("rewards", "cumulative_rewards", "terminations", "truncations"): |
| 162 | + values = state[field] |
| 163 | + if not isinstance(values, dict) or set(values) != set(agents): |
| 164 | + raise ValueError("invalid AEC checkpoint bookkeeping") |
| 165 | + if field in ("terminations", "truncations"): |
| 166 | + if any(type(value) is not bool for value in values.values()): |
| 167 | + raise ValueError("invalid AEC checkpoint flags") |
| 168 | + elif any( |
| 169 | + type(value) not in (int, float) or not math.isfinite(value) |
| 170 | + for value in values.values() |
| 171 | + ): |
| 172 | + raise ValueError("invalid AEC checkpoint rewards") |
| 173 | + selection, skip = state["agent_selection"], state["skip_agent_selection"] |
| 174 | + if any(value is not None and value not in self.possible_agents for value in (selection, skip)): |
| 175 | + raise ValueError("invalid AEC checkpoint turn") |
| 176 | + candidate = self.native.clone() |
| 177 | + candidate.set_state(json.dumps(state["native"])) |
| 178 | + terminated, truncated = candidate.flags() |
| 179 | + actors = candidate.current_players() |
| 180 | + if not state["initialized"]: |
| 181 | + if agents or selection is not None or skip is not None: |
| 182 | + raise ValueError("uninitialized AEC checkpoint has live bookkeeping") |
| 183 | + elif not terminated and not truncated: |
| 184 | + if ( |
| 185 | + agents != self.possible_agents or len(actors) != 1 |
| 186 | + or selection != self.possible_agents[actors[0]] or skip is not None |
| 187 | + ): |
| 188 | + raise ValueError("AEC checkpoint turn does not match engine") |
| 189 | + elif (agents and selection != agents[0]) or (not agents and skip is not None): |
| 190 | + raise ValueError("invalid AEC checkpoint dead-agent order") |
| 191 | + if any(value != terminated for value in state["terminations"].values()) or any( |
| 192 | + value != truncated for value in state["truncations"].values() |
| 193 | + ): |
| 194 | + raise ValueError("AEC checkpoint flags do not match engine") |
| 195 | + generator = np.random.default_rng() |
| 196 | + generator.bit_generator.state = copy.deepcopy(state["rng"]) |
| 197 | + dataset, position_id = curriculum.restore(state["curriculum"], candidate) |
| 198 | + |
| 199 | + # Derive information sets from the validated engine, never serialized infos. |
| 200 | + restored = copy.copy(self) |
| 201 | + restored.native = candidate |
| 202 | + restored._rng = generator |
| 203 | + restored._position_set, restored._position_id = dataset, position_id |
| 204 | + restored._initialized = state["initialized"] |
| 205 | + restored.agents = agents[:] |
| 206 | + restored.agent_selection, restored._skip_agent_selection = selection, skip |
| 207 | + restored.rewards = state["rewards"].copy() |
| 208 | + restored._cumulative_rewards = state["cumulative_rewards"].copy() |
| 209 | + restored.terminations = state["terminations"].copy() |
| 210 | + restored.truncations = state["truncations"].copy() |
| 211 | + restored._refresh_infos() |
| 212 | + self.__dict__.update(restored.__dict__) |
| 213 | + |
114 | 214 | def clone(self): |
115 | 215 | """Independent native and AEC bookkeeping state for trusted local search.""" |
116 | 216 | result = copy.copy(self) |
|
0 commit comments