pytpg 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytpg/__init__.py +7 -0
- pytpg/adapters/__init__.py +43 -0
- pytpg/adapters/errors.py +40 -0
- pytpg/adapters/gymnasium.py +360 -0
- pytpg/adapters/gymnasium_evaluation.py +231 -0
- pytpg/callbacks/__init__.py +16 -0
- pytpg/callbacks/base.py +104 -0
- pytpg/callbacks/logging.py +52 -0
- pytpg/config.py +197 -0
- pytpg/core/__init__.py +45 -0
- pytpg/core/_validation.py +9 -0
- pytpg/core/action.py +44 -0
- pytpg/core/graph.py +160 -0
- pytpg/core/identifiers.py +22 -0
- pytpg/core/instruction.py +99 -0
- pytpg/core/learner.py +27 -0
- pytpg/core/program.py +27 -0
- pytpg/core/team.py +60 -0
- pytpg/evaluation/__init__.py +20 -0
- pytpg/evaluation/evaluator.py +52 -0
- pytpg/evaluation/statistics.py +222 -0
- pytpg/evaluation/toy.py +102 -0
- pytpg/evolution/__init__.py +81 -0
- pytpg/evolution/_ids.py +51 -0
- pytpg/evolution/engine.py +227 -0
- pytpg/evolution/errors.py +25 -0
- pytpg/evolution/genome.py +186 -0
- pytpg/evolution/initialization.py +129 -0
- pytpg/evolution/mutation/__init__.py +35 -0
- pytpg/evolution/mutation/_utils.py +59 -0
- pytpg/evolution/mutation/base.py +90 -0
- pytpg/evolution/mutation/defaults.py +119 -0
- pytpg/evolution/mutation/instruction.py +137 -0
- pytpg/evolution/mutation/learner.py +176 -0
- pytpg/evolution/mutation/team.py +162 -0
- pytpg/evolution/population.py +123 -0
- pytpg/evolution/reproduction.py +124 -0
- pytpg/evolution/rng.py +38 -0
- pytpg/evolution/selection.py +78 -0
- pytpg/experiment.py +140 -0
- pytpg/memory/__init__.py +34 -0
- pytpg/memory/_validation.py +48 -0
- pytpg/memory/base.py +123 -0
- pytpg/memory/errors.py +16 -0
- pytpg/memory/history.py +53 -0
- pytpg/memory/null.py +33 -0
- pytpg/memory/register.py +104 -0
- pytpg/memory/stateful.py +142 -0
- pytpg/metadata.py +129 -0
- pytpg/multiagent/__init__.py +31 -0
- pytpg/multiagent/_validation.py +102 -0
- pytpg/multiagent/codec.py +82 -0
- pytpg/multiagent/controller.py +159 -0
- pytpg/multiagent/errors.py +30 -0
- pytpg/multiagent/independent.py +191 -0
- pytpg/multiagent/model.py +74 -0
- pytpg/multiagent/shared.py +197 -0
- pytpg/py.typed +1 -0
- pytpg/runtime/__init__.py +84 -0
- pytpg/runtime/_model.py +115 -0
- pytpg/runtime/config.py +40 -0
- pytpg/runtime/errors.py +70 -0
- pytpg/runtime/executor.py +176 -0
- pytpg/runtime/graph_runtime.py +244 -0
- pytpg/runtime/graph_validation.py +368 -0
- pytpg/runtime/inference.py +46 -0
- pytpg/runtime/inspection.py +116 -0
- pytpg/runtime/operators.py +135 -0
- pytpg/runtime/registers.py +60 -0
- pytpg/runtime/team_runtime.py +76 -0
- pytpg/seed.py +48 -0
- pytpg/serialization/__init__.py +57 -0
- pytpg/serialization/_validation.py +71 -0
- pytpg/serialization/checkpoint.py +392 -0
- pytpg/serialization/errors.py +25 -0
- pytpg/serialization/graph.py +317 -0
- pytpg/serialization/io.py +74 -0
- pytpg/serialization/rng.py +104 -0
- pytpg-0.5.0.dist-info/METADATA +211 -0
- pytpg-0.5.0.dist-info/RECORD +82 -0
- pytpg-0.5.0.dist-info/WHEEL +4 -0
- pytpg-0.5.0.dist-info/licenses/LICENSE +21 -0
pytpg/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Optional environment adapters kept outside the TPG core."""
|
|
2
|
+
|
|
3
|
+
from pytpg.adapters.errors import (
|
|
4
|
+
GymnasiumDependencyError,
|
|
5
|
+
GymnasiumEpisodeStateError,
|
|
6
|
+
GymnasiumEvaluationError,
|
|
7
|
+
GymnasiumIntegrationError,
|
|
8
|
+
InvalidGymnasiumActionError,
|
|
9
|
+
InvalidGymnasiumTransitionError,
|
|
10
|
+
UnsupportedGymnasiumSpaceError,
|
|
11
|
+
)
|
|
12
|
+
from pytpg.adapters.gymnasium import (
|
|
13
|
+
GymnasiumAdapter,
|
|
14
|
+
GymnasiumEnvironment,
|
|
15
|
+
GymnasiumEnvironmentFactory,
|
|
16
|
+
GymnasiumReset,
|
|
17
|
+
GymnasiumStep,
|
|
18
|
+
make_gymnasium_environment,
|
|
19
|
+
)
|
|
20
|
+
from pytpg.adapters.gymnasium_evaluation import (
|
|
21
|
+
GymnasiumEpisodeResult,
|
|
22
|
+
GymnasiumEvaluationConfig,
|
|
23
|
+
GymnasiumFitness,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"GymnasiumAdapter",
|
|
28
|
+
"GymnasiumDependencyError",
|
|
29
|
+
"GymnasiumEnvironment",
|
|
30
|
+
"GymnasiumEnvironmentFactory",
|
|
31
|
+
"GymnasiumEpisodeResult",
|
|
32
|
+
"GymnasiumEpisodeStateError",
|
|
33
|
+
"GymnasiumEvaluationConfig",
|
|
34
|
+
"GymnasiumEvaluationError",
|
|
35
|
+
"GymnasiumFitness",
|
|
36
|
+
"GymnasiumIntegrationError",
|
|
37
|
+
"GymnasiumReset",
|
|
38
|
+
"GymnasiumStep",
|
|
39
|
+
"InvalidGymnasiumActionError",
|
|
40
|
+
"InvalidGymnasiumTransitionError",
|
|
41
|
+
"UnsupportedGymnasiumSpaceError",
|
|
42
|
+
"make_gymnasium_environment",
|
|
43
|
+
]
|
pytpg/adapters/errors.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Failures raised at the optional Gymnasium integration boundary."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class GymnasiumIntegrationError(Exception):
|
|
5
|
+
"""Base class for Gymnasium adapter and evaluation failures."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GymnasiumDependencyError(GymnasiumIntegrationError):
|
|
9
|
+
"""The optional Gymnasium dependency is unavailable."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UnsupportedGymnasiumSpaceError(GymnasiumIntegrationError):
|
|
13
|
+
"""An environment space cannot be represented by the current adapter."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InvalidGymnasiumTransitionError(GymnasiumIntegrationError):
|
|
17
|
+
"""An environment returned a value outside the modern Gymnasium API."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GymnasiumEpisodeStateError(GymnasiumIntegrationError):
|
|
21
|
+
"""An operation conflicts with the adapter's episode lifecycle."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InvalidGymnasiumActionError(GymnasiumIntegrationError):
|
|
25
|
+
"""A TPG action ID cannot be mapped into the environment action space."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GymnasiumEvaluationError(GymnasiumIntegrationError):
|
|
29
|
+
"""A Gymnasium fitness-evaluation configuration or result is invalid."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"GymnasiumDependencyError",
|
|
34
|
+
"GymnasiumEpisodeStateError",
|
|
35
|
+
"GymnasiumEvaluationError",
|
|
36
|
+
"GymnasiumIntegrationError",
|
|
37
|
+
"InvalidGymnasiumActionError",
|
|
38
|
+
"InvalidGymnasiumTransitionError",
|
|
39
|
+
"UnsupportedGymnasiumSpaceError",
|
|
40
|
+
]
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Strict, optional boundary for modern Gymnasium environments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import math
|
|
7
|
+
import operator
|
|
8
|
+
from collections.abc import Callable, Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from numbers import Real
|
|
11
|
+
from types import MappingProxyType
|
|
12
|
+
from typing import Any, Protocol, cast
|
|
13
|
+
|
|
14
|
+
from pytpg.adapters.errors import (
|
|
15
|
+
GymnasiumDependencyError,
|
|
16
|
+
GymnasiumEpisodeStateError,
|
|
17
|
+
InvalidGymnasiumActionError,
|
|
18
|
+
InvalidGymnasiumTransitionError,
|
|
19
|
+
UnsupportedGymnasiumSpaceError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GymnasiumEnvironment(Protocol):
|
|
24
|
+
"""Structural surface used without importing the optional package."""
|
|
25
|
+
|
|
26
|
+
action_space: object
|
|
27
|
+
observation_space: object
|
|
28
|
+
|
|
29
|
+
def reset(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
seed: int | None = None,
|
|
33
|
+
options: Mapping[str, object] | None = None,
|
|
34
|
+
) -> object: ...
|
|
35
|
+
|
|
36
|
+
def step(self, action: object) -> object: ...
|
|
37
|
+
|
|
38
|
+
def close(self) -> None: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GymnasiumEnvironmentFactory(Protocol):
|
|
42
|
+
"""Create a fresh environment for one graph fitness evaluation."""
|
|
43
|
+
|
|
44
|
+
def __call__(self) -> GymnasiumEnvironment: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class GymnasiumReset:
|
|
49
|
+
"""Normalized result of one Gymnasium reset."""
|
|
50
|
+
|
|
51
|
+
observation: tuple[float, ...]
|
|
52
|
+
info: Mapping[str, object]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class GymnasiumStep:
|
|
57
|
+
"""Normalized result of one Gymnasium step."""
|
|
58
|
+
|
|
59
|
+
observation: tuple[float, ...]
|
|
60
|
+
reward: float
|
|
61
|
+
terminated: bool
|
|
62
|
+
truncated: bool
|
|
63
|
+
info: Mapping[str, object]
|
|
64
|
+
environment_action: int
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def done(self) -> bool:
|
|
68
|
+
"""Whether reset is required before another step."""
|
|
69
|
+
|
|
70
|
+
return self.terminated or self.truncated
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class GymnasiumAdapter:
|
|
74
|
+
"""Map flat numeric observations and zero-based TPG actions to Gymnasium."""
|
|
75
|
+
|
|
76
|
+
__slots__ = (
|
|
77
|
+
"_action_start",
|
|
78
|
+
"_closed",
|
|
79
|
+
"_needs_reset",
|
|
80
|
+
"_observation_shape",
|
|
81
|
+
"environment",
|
|
82
|
+
"input_size",
|
|
83
|
+
"n_actions",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def __init__(self, environment: GymnasiumEnvironment) -> None:
|
|
87
|
+
self.environment = environment
|
|
88
|
+
self.n_actions, self._action_start = self._validate_action_space(
|
|
89
|
+
environment.action_space
|
|
90
|
+
)
|
|
91
|
+
self._observation_shape, self.input_size = self._validate_observation_space(
|
|
92
|
+
environment.observation_space
|
|
93
|
+
)
|
|
94
|
+
self._closed = False
|
|
95
|
+
self._needs_reset = True
|
|
96
|
+
|
|
97
|
+
def reset(
|
|
98
|
+
self,
|
|
99
|
+
*,
|
|
100
|
+
seed: int | None = None,
|
|
101
|
+
options: Mapping[str, object] | None = None,
|
|
102
|
+
) -> GymnasiumReset:
|
|
103
|
+
"""Reset explicitly and normalize the `(observation, info)` result."""
|
|
104
|
+
|
|
105
|
+
self._require_open()
|
|
106
|
+
if seed is not None and (
|
|
107
|
+
isinstance(seed, bool) or not isinstance(seed, int) or seed < 0
|
|
108
|
+
):
|
|
109
|
+
raise InvalidGymnasiumTransitionError(
|
|
110
|
+
"reset seed must be a non-negative integer or None"
|
|
111
|
+
)
|
|
112
|
+
if options is not None and not isinstance(options, Mapping):
|
|
113
|
+
raise InvalidGymnasiumTransitionError(
|
|
114
|
+
"reset options must be a mapping or None"
|
|
115
|
+
)
|
|
116
|
+
normalized_options: dict[str, object] | None = None
|
|
117
|
+
if options is not None:
|
|
118
|
+
untyped_options = cast(Mapping[object, object], options)
|
|
119
|
+
if not all(isinstance(key, str) for key in untyped_options):
|
|
120
|
+
raise InvalidGymnasiumTransitionError(
|
|
121
|
+
"reset option keys must all be strings"
|
|
122
|
+
)
|
|
123
|
+
normalized_options = cast(dict[str, object], dict(untyped_options))
|
|
124
|
+
self._needs_reset = True
|
|
125
|
+
result = self.environment.reset(seed=seed, options=normalized_options)
|
|
126
|
+
values = self._require_result_tuple(result, 2, "reset")
|
|
127
|
+
observation = self._normalize_observation(values[0])
|
|
128
|
+
info = self._normalize_info(values[1], "reset info")
|
|
129
|
+
self._needs_reset = False
|
|
130
|
+
return GymnasiumReset(observation, info)
|
|
131
|
+
|
|
132
|
+
def step(self, action_id: int) -> GymnasiumStep:
|
|
133
|
+
"""Map one action ID and normalize the five-value Gymnasium result."""
|
|
134
|
+
|
|
135
|
+
self._require_open()
|
|
136
|
+
if self._needs_reset:
|
|
137
|
+
raise GymnasiumEpisodeStateError(
|
|
138
|
+
"reset is required before stepping the environment"
|
|
139
|
+
)
|
|
140
|
+
environment_action = self.environment_action(action_id)
|
|
141
|
+
self._needs_reset = True
|
|
142
|
+
result = self.environment.step(environment_action)
|
|
143
|
+
values = self._require_result_tuple(result, 5, "step")
|
|
144
|
+
observation = self._normalize_observation(values[0])
|
|
145
|
+
reward = self._normalize_reward(values[1])
|
|
146
|
+
terminated = self._normalize_flag(values[2], "terminated")
|
|
147
|
+
truncated = self._normalize_flag(values[3], "truncated")
|
|
148
|
+
info = self._normalize_info(values[4], "step info")
|
|
149
|
+
self._needs_reset = terminated or truncated
|
|
150
|
+
return GymnasiumStep(
|
|
151
|
+
observation,
|
|
152
|
+
reward,
|
|
153
|
+
terminated,
|
|
154
|
+
truncated,
|
|
155
|
+
info,
|
|
156
|
+
environment_action,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def environment_action(self, action_id: int) -> int:
|
|
160
|
+
"""Translate a zero-based TPG action ID into `Discrete.start` space."""
|
|
161
|
+
|
|
162
|
+
if isinstance(action_id, bool) or not isinstance(action_id, int):
|
|
163
|
+
raise InvalidGymnasiumActionError(
|
|
164
|
+
f"TPG action ID must be an integer, got {action_id!r}"
|
|
165
|
+
)
|
|
166
|
+
if not 0 <= action_id < self.n_actions:
|
|
167
|
+
raise InvalidGymnasiumActionError(
|
|
168
|
+
f"TPG action ID {action_id} is outside [0, {self.n_actions})"
|
|
169
|
+
)
|
|
170
|
+
environment_action = self._action_start + action_id
|
|
171
|
+
contains = getattr(self.environment.action_space, "contains", None)
|
|
172
|
+
if callable(contains):
|
|
173
|
+
contains_action = cast(Callable[[object], object], contains)
|
|
174
|
+
if not bool(contains_action(environment_action)):
|
|
175
|
+
raise InvalidGymnasiumActionError(
|
|
176
|
+
f"mapped action {environment_action} is rejected by action_space"
|
|
177
|
+
)
|
|
178
|
+
return environment_action
|
|
179
|
+
|
|
180
|
+
def close(self) -> None:
|
|
181
|
+
"""Close the owned environment exactly once."""
|
|
182
|
+
|
|
183
|
+
if not self._closed:
|
|
184
|
+
self.environment.close()
|
|
185
|
+
self._closed = True
|
|
186
|
+
self._needs_reset = True
|
|
187
|
+
|
|
188
|
+
def __enter__(self) -> GymnasiumAdapter:
|
|
189
|
+
self._require_open()
|
|
190
|
+
return self
|
|
191
|
+
|
|
192
|
+
def __exit__(self, *args: object) -> None:
|
|
193
|
+
self.close()
|
|
194
|
+
|
|
195
|
+
def _require_open(self) -> None:
|
|
196
|
+
if self._closed:
|
|
197
|
+
raise GymnasiumEpisodeStateError("the environment adapter is closed")
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
def _validate_action_space(space: object) -> tuple[int, int]:
|
|
201
|
+
if getattr(space, "shape", None) != ():
|
|
202
|
+
raise UnsupportedGymnasiumSpaceError(
|
|
203
|
+
"action_space must be a scalar Discrete-like space"
|
|
204
|
+
)
|
|
205
|
+
try:
|
|
206
|
+
n_actions = _index(cast(Any, space).n, "action_space.n")
|
|
207
|
+
except AttributeError as error:
|
|
208
|
+
raise UnsupportedGymnasiumSpaceError(
|
|
209
|
+
"action_space must be a scalar Discrete-like space"
|
|
210
|
+
) from error
|
|
211
|
+
if n_actions < 1:
|
|
212
|
+
raise UnsupportedGymnasiumSpaceError(
|
|
213
|
+
"action_space.n must be a positive integer"
|
|
214
|
+
)
|
|
215
|
+
start = _index(getattr(space, "start", 0), "action_space.start")
|
|
216
|
+
return n_actions, start
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def _validate_observation_space(space: object) -> tuple[tuple[int, ...], int]:
|
|
220
|
+
shape = getattr(space, "shape", None)
|
|
221
|
+
if not isinstance(shape, tuple):
|
|
222
|
+
raise UnsupportedGymnasiumSpaceError(
|
|
223
|
+
"observation_space must have one fixed numeric shape; "
|
|
224
|
+
"composite spaces are not supported"
|
|
225
|
+
)
|
|
226
|
+
untyped_shape = cast(tuple[object, ...], shape)
|
|
227
|
+
dimensions = tuple(
|
|
228
|
+
_index(value, f"observation_space.shape[{index}]")
|
|
229
|
+
for index, value in enumerate(untyped_shape)
|
|
230
|
+
)
|
|
231
|
+
if any(value < 0 for value in dimensions):
|
|
232
|
+
raise UnsupportedGymnasiumSpaceError(
|
|
233
|
+
"observation_space dimensions must be non-negative"
|
|
234
|
+
)
|
|
235
|
+
input_size = math.prod(dimensions) if dimensions else 1
|
|
236
|
+
if input_size < 1:
|
|
237
|
+
raise UnsupportedGymnasiumSpaceError(
|
|
238
|
+
"observation_space must contain at least one scalar"
|
|
239
|
+
)
|
|
240
|
+
dtype = getattr(space, "dtype", None)
|
|
241
|
+
if getattr(dtype, "kind", None) not in {"i", "u", "f"}:
|
|
242
|
+
raise UnsupportedGymnasiumSpaceError(
|
|
243
|
+
"observation_space dtype must be integer or floating point"
|
|
244
|
+
)
|
|
245
|
+
return dimensions, input_size
|
|
246
|
+
|
|
247
|
+
def _normalize_observation(self, value: object) -> tuple[float, ...]:
|
|
248
|
+
numpy: Any = importlib.import_module("numpy")
|
|
249
|
+
try:
|
|
250
|
+
array: Any = numpy.asarray(value)
|
|
251
|
+
except (TypeError, ValueError) as error:
|
|
252
|
+
raise InvalidGymnasiumTransitionError(
|
|
253
|
+
"observation cannot be converted to a numeric array"
|
|
254
|
+
) from error
|
|
255
|
+
if tuple(array.shape) != self._observation_shape:
|
|
256
|
+
raise InvalidGymnasiumTransitionError(
|
|
257
|
+
f"observation shape {tuple(array.shape)!r} does not match "
|
|
258
|
+
f"observation_space shape {self._observation_shape!r}"
|
|
259
|
+
)
|
|
260
|
+
if array.dtype.kind not in {"i", "u", "f"}:
|
|
261
|
+
raise InvalidGymnasiumTransitionError(
|
|
262
|
+
"observation values must be integer or floating point"
|
|
263
|
+
)
|
|
264
|
+
flattened: list[Any] = array.reshape(-1, order="C").tolist()
|
|
265
|
+
normalized = tuple(float(item) for item in flattened)
|
|
266
|
+
if len(normalized) != self.input_size or not all(
|
|
267
|
+
math.isfinite(item) for item in normalized
|
|
268
|
+
):
|
|
269
|
+
raise InvalidGymnasiumTransitionError(
|
|
270
|
+
"observation must contain only finite numeric values"
|
|
271
|
+
)
|
|
272
|
+
return normalized
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def _normalize_reward(value: object) -> float:
|
|
276
|
+
if isinstance(value, bool) or not isinstance(value, Real):
|
|
277
|
+
raise InvalidGymnasiumTransitionError(
|
|
278
|
+
f"reward must be a finite real number, got {value!r}"
|
|
279
|
+
)
|
|
280
|
+
normalized = float(value)
|
|
281
|
+
if not math.isfinite(normalized):
|
|
282
|
+
raise InvalidGymnasiumTransitionError(
|
|
283
|
+
f"reward must be finite, got {value!r}"
|
|
284
|
+
)
|
|
285
|
+
return normalized
|
|
286
|
+
|
|
287
|
+
@staticmethod
|
|
288
|
+
def _normalize_flag(value: object, name: str) -> bool:
|
|
289
|
+
if not isinstance(value, bool):
|
|
290
|
+
raise InvalidGymnasiumTransitionError(
|
|
291
|
+
f"{name} must be a bool, got {value!r}"
|
|
292
|
+
)
|
|
293
|
+
return value
|
|
294
|
+
|
|
295
|
+
@staticmethod
|
|
296
|
+
def _normalize_info(value: object, name: str) -> Mapping[str, object]:
|
|
297
|
+
if not isinstance(value, Mapping):
|
|
298
|
+
raise InvalidGymnasiumTransitionError(f"{name} must be a mapping")
|
|
299
|
+
untyped = cast(Mapping[object, object], value)
|
|
300
|
+
if not all(isinstance(key, str) for key in untyped):
|
|
301
|
+
raise InvalidGymnasiumTransitionError(f"{name} keys must all be strings")
|
|
302
|
+
return MappingProxyType(cast(dict[str, object], dict(untyped)))
|
|
303
|
+
|
|
304
|
+
@staticmethod
|
|
305
|
+
def _require_result_tuple(
|
|
306
|
+
value: object,
|
|
307
|
+
length: int,
|
|
308
|
+
operation: str,
|
|
309
|
+
) -> tuple[object, ...]:
|
|
310
|
+
if not isinstance(value, tuple):
|
|
311
|
+
raise InvalidGymnasiumTransitionError(
|
|
312
|
+
f"{operation} must return a {length}-item tuple"
|
|
313
|
+
)
|
|
314
|
+
values = cast(tuple[object, ...], value)
|
|
315
|
+
if len(values) != length:
|
|
316
|
+
raise InvalidGymnasiumTransitionError(
|
|
317
|
+
f"{operation} must return a {length}-item tuple"
|
|
318
|
+
)
|
|
319
|
+
return values
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def make_gymnasium_environment(
|
|
323
|
+
environment_id: str,
|
|
324
|
+
**keyword_arguments: object,
|
|
325
|
+
) -> GymnasiumEnvironment:
|
|
326
|
+
"""Create an environment while keeping Gymnasium an optional dependency."""
|
|
327
|
+
|
|
328
|
+
if not isinstance(environment_id, str) or not environment_id.strip():
|
|
329
|
+
raise GymnasiumDependencyError("environment_id must be a non-empty string")
|
|
330
|
+
try:
|
|
331
|
+
gymnasium: Any = importlib.import_module("gymnasium")
|
|
332
|
+
except ModuleNotFoundError as error:
|
|
333
|
+
if error.name != "gymnasium":
|
|
334
|
+
raise
|
|
335
|
+
raise GymnasiumDependencyError(
|
|
336
|
+
'Gymnasium is not installed; install pyTPG with the "gymnasium" extra'
|
|
337
|
+
) from error
|
|
338
|
+
return cast(
|
|
339
|
+
GymnasiumEnvironment,
|
|
340
|
+
gymnasium.make(environment_id, **keyword_arguments),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _index(value: object, name: str) -> int:
|
|
345
|
+
if isinstance(value, bool):
|
|
346
|
+
raise UnsupportedGymnasiumSpaceError(f"{name} must be an integer")
|
|
347
|
+
try:
|
|
348
|
+
return operator.index(cast(Any, value))
|
|
349
|
+
except TypeError as error:
|
|
350
|
+
raise UnsupportedGymnasiumSpaceError(f"{name} must be an integer") from error
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
__all__ = [
|
|
354
|
+
"GymnasiumAdapter",
|
|
355
|
+
"GymnasiumEnvironment",
|
|
356
|
+
"GymnasiumEnvironmentFactory",
|
|
357
|
+
"GymnasiumReset",
|
|
358
|
+
"GymnasiumStep",
|
|
359
|
+
"make_gymnasium_environment",
|
|
360
|
+
]
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Reproducible episodic Gymnasium fitness evaluation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from pytpg.adapters.errors import GymnasiumEvaluationError
|
|
9
|
+
from pytpg.adapters.gymnasium import (
|
|
10
|
+
GymnasiumAdapter,
|
|
11
|
+
GymnasiumEnvironmentFactory,
|
|
12
|
+
)
|
|
13
|
+
from pytpg.core import TPGGraph
|
|
14
|
+
from pytpg.memory import (
|
|
15
|
+
Memory,
|
|
16
|
+
MemoryFactory,
|
|
17
|
+
NullMemory,
|
|
18
|
+
StatefulGraphRuntime,
|
|
19
|
+
)
|
|
20
|
+
from pytpg.runtime import GraphRuntime
|
|
21
|
+
from pytpg.seed import SeedManager
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class GymnasiumEvaluationConfig:
|
|
26
|
+
"""Episode seeds and hard rollout bound shared by every graph."""
|
|
27
|
+
|
|
28
|
+
episode_seeds: tuple[int, ...]
|
|
29
|
+
max_episode_steps: int = 1_000
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
if not isinstance(self.episode_seeds, tuple) or not self.episode_seeds:
|
|
33
|
+
raise GymnasiumEvaluationError("episode_seeds must be a non-empty tuple")
|
|
34
|
+
if any(
|
|
35
|
+
isinstance(seed, bool) or not isinstance(seed, int) or seed < 0
|
|
36
|
+
for seed in self.episode_seeds
|
|
37
|
+
):
|
|
38
|
+
raise GymnasiumEvaluationError(
|
|
39
|
+
"episode seeds must be non-negative integers"
|
|
40
|
+
)
|
|
41
|
+
if (
|
|
42
|
+
isinstance(self.max_episode_steps, bool)
|
|
43
|
+
or not isinstance(self.max_episode_steps, int)
|
|
44
|
+
or self.max_episode_steps < 1
|
|
45
|
+
):
|
|
46
|
+
raise GymnasiumEvaluationError(
|
|
47
|
+
"max_episode_steps must be a positive integer"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_seed(
|
|
52
|
+
cls,
|
|
53
|
+
master_seed: int,
|
|
54
|
+
episodes: int,
|
|
55
|
+
*,
|
|
56
|
+
max_episode_steps: int = 1_000,
|
|
57
|
+
) -> GymnasiumEvaluationConfig:
|
|
58
|
+
"""Derive call-order-independent common episode seeds."""
|
|
59
|
+
|
|
60
|
+
if isinstance(episodes, bool) or not isinstance(episodes, int) or episodes < 1:
|
|
61
|
+
raise GymnasiumEvaluationError("episodes must be a positive integer")
|
|
62
|
+
if (
|
|
63
|
+
isinstance(master_seed, bool)
|
|
64
|
+
or not isinstance(master_seed, int)
|
|
65
|
+
or master_seed < 0
|
|
66
|
+
):
|
|
67
|
+
raise GymnasiumEvaluationError("master_seed must be a non-negative integer")
|
|
68
|
+
seed_manager = SeedManager(master_seed)
|
|
69
|
+
return cls(
|
|
70
|
+
tuple(
|
|
71
|
+
seed_manager.derive_seed("gymnasium_episode", index)
|
|
72
|
+
for index in range(episodes)
|
|
73
|
+
),
|
|
74
|
+
max_episode_steps,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class GymnasiumEpisodeResult:
|
|
80
|
+
"""Inspectable return and termination reason for one rollout."""
|
|
81
|
+
|
|
82
|
+
seed: int
|
|
83
|
+
total_reward: float
|
|
84
|
+
steps: int
|
|
85
|
+
terminated: bool
|
|
86
|
+
truncated: bool
|
|
87
|
+
step_limit_reached: bool
|
|
88
|
+
|
|
89
|
+
def __post_init__(self) -> None:
|
|
90
|
+
if (
|
|
91
|
+
isinstance(self.seed, bool)
|
|
92
|
+
or not isinstance(self.seed, int)
|
|
93
|
+
or self.seed < 0
|
|
94
|
+
):
|
|
95
|
+
raise GymnasiumEvaluationError("episode seed must be non-negative")
|
|
96
|
+
if (
|
|
97
|
+
isinstance(self.total_reward, bool)
|
|
98
|
+
or not isinstance(self.total_reward, (int, float))
|
|
99
|
+
or not math.isfinite(float(self.total_reward))
|
|
100
|
+
):
|
|
101
|
+
raise GymnasiumEvaluationError("episode total_reward must be finite")
|
|
102
|
+
object.__setattr__(self, "total_reward", float(self.total_reward))
|
|
103
|
+
if (
|
|
104
|
+
isinstance(self.steps, bool)
|
|
105
|
+
or not isinstance(self.steps, int)
|
|
106
|
+
or self.steps < 1
|
|
107
|
+
):
|
|
108
|
+
raise GymnasiumEvaluationError("episode steps must be positive")
|
|
109
|
+
if not all(
|
|
110
|
+
isinstance(value, bool)
|
|
111
|
+
for value in (self.terminated, self.truncated, self.step_limit_reached)
|
|
112
|
+
):
|
|
113
|
+
raise GymnasiumEvaluationError("episode termination flags must be bools")
|
|
114
|
+
if self.step_limit_reached == (self.terminated or self.truncated):
|
|
115
|
+
raise GymnasiumEvaluationError(
|
|
116
|
+
"step_limit_reached must be the complement of environment completion"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class GymnasiumFitness:
|
|
121
|
+
"""Mean episodic return with common random seeds across all graphs."""
|
|
122
|
+
|
|
123
|
+
__slots__ = ("config", "environment_factory", "memory_factory")
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
environment_factory: GymnasiumEnvironmentFactory,
|
|
128
|
+
config: GymnasiumEvaluationConfig,
|
|
129
|
+
*,
|
|
130
|
+
memory_factory: MemoryFactory | None = None,
|
|
131
|
+
) -> None:
|
|
132
|
+
if not callable(environment_factory):
|
|
133
|
+
raise GymnasiumEvaluationError("environment_factory must be callable")
|
|
134
|
+
if not isinstance(config, GymnasiumEvaluationConfig):
|
|
135
|
+
raise GymnasiumEvaluationError("config must be a GymnasiumEvaluationConfig")
|
|
136
|
+
if memory_factory is not None and not callable(memory_factory):
|
|
137
|
+
raise GymnasiumEvaluationError("memory_factory must be callable or None")
|
|
138
|
+
self.environment_factory = environment_factory
|
|
139
|
+
self.config = config
|
|
140
|
+
self.memory_factory = memory_factory
|
|
141
|
+
|
|
142
|
+
def __call__(self, graph: TPGGraph) -> float:
|
|
143
|
+
"""Return mean total reward, suitable for `SequentialEvaluator`."""
|
|
144
|
+
|
|
145
|
+
results = self.evaluate(graph)
|
|
146
|
+
return math.fsum(result.total_reward for result in results) / len(results)
|
|
147
|
+
|
|
148
|
+
def evaluate(self, graph: TPGGraph) -> tuple[GymnasiumEpisodeResult, ...]:
|
|
149
|
+
"""Evaluate all configured episodes in one fresh, always-closed env."""
|
|
150
|
+
|
|
151
|
+
if not isinstance(graph, TPGGraph):
|
|
152
|
+
raise GymnasiumEvaluationError("graph must be a TPGGraph")
|
|
153
|
+
adapter = GymnasiumAdapter(self.environment_factory())
|
|
154
|
+
with adapter:
|
|
155
|
+
self._validate_graph_actions(graph, adapter.n_actions)
|
|
156
|
+
memory: Memory = (
|
|
157
|
+
NullMemory() if self.memory_factory is None else self.memory_factory()
|
|
158
|
+
)
|
|
159
|
+
controller: StatefulGraphRuntime | None = None
|
|
160
|
+
results: list[GymnasiumEpisodeResult] = []
|
|
161
|
+
for seed in self.config.episode_seeds:
|
|
162
|
+
reset = adapter.reset(seed=seed)
|
|
163
|
+
if controller is None:
|
|
164
|
+
initial_memory = memory.reset()
|
|
165
|
+
runtime = GraphRuntime.infer_for_graph(
|
|
166
|
+
graph,
|
|
167
|
+
reset.observation + initial_memory.values,
|
|
168
|
+
validate_before_execution=False,
|
|
169
|
+
)
|
|
170
|
+
runtime.validate(graph).require_valid()
|
|
171
|
+
controller = StatefulGraphRuntime(
|
|
172
|
+
runtime,
|
|
173
|
+
memory,
|
|
174
|
+
adapter.input_size,
|
|
175
|
+
)
|
|
176
|
+
controller.reset_episode()
|
|
177
|
+
total_reward = 0.0
|
|
178
|
+
observation = reset.observation
|
|
179
|
+
terminated = False
|
|
180
|
+
truncated = False
|
|
181
|
+
steps = 0
|
|
182
|
+
try:
|
|
183
|
+
for _ in range(self.config.max_episode_steps):
|
|
184
|
+
action_id = controller.act(graph, observation)
|
|
185
|
+
transition = adapter.step(action_id)
|
|
186
|
+
steps += 1
|
|
187
|
+
total_reward += transition.reward
|
|
188
|
+
if not math.isfinite(total_reward):
|
|
189
|
+
raise GymnasiumEvaluationError(
|
|
190
|
+
"accumulated episode reward is not finite"
|
|
191
|
+
)
|
|
192
|
+
observation = transition.observation
|
|
193
|
+
terminated = transition.terminated
|
|
194
|
+
truncated = transition.truncated
|
|
195
|
+
if transition.done:
|
|
196
|
+
break
|
|
197
|
+
finally:
|
|
198
|
+
if controller.episode_active:
|
|
199
|
+
controller.end_episode()
|
|
200
|
+
step_limit_reached = not (terminated or truncated)
|
|
201
|
+
results.append(
|
|
202
|
+
GymnasiumEpisodeResult(
|
|
203
|
+
seed,
|
|
204
|
+
total_reward,
|
|
205
|
+
steps,
|
|
206
|
+
terminated,
|
|
207
|
+
truncated,
|
|
208
|
+
step_limit_reached,
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
return tuple(results)
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def _validate_graph_actions(graph: TPGGraph, n_actions: int) -> None:
|
|
215
|
+
for team in graph.teams:
|
|
216
|
+
for learner in team.learners:
|
|
217
|
+
if learner.action.kind != "atomic":
|
|
218
|
+
continue
|
|
219
|
+
action_id = int(learner.action.action_id)
|
|
220
|
+
if not 0 <= action_id < n_actions:
|
|
221
|
+
raise GymnasiumEvaluationError(
|
|
222
|
+
f"graph atomic action ID {action_id} is outside "
|
|
223
|
+
f"the environment range [0, {n_actions})"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
__all__ = [
|
|
228
|
+
"GymnasiumEpisodeResult",
|
|
229
|
+
"GymnasiumEvaluationConfig",
|
|
230
|
+
"GymnasiumFitness",
|
|
231
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Structured, optional evolution lifecycle callbacks."""
|
|
2
|
+
|
|
3
|
+
from pytpg.callbacks.base import (
|
|
4
|
+
EvolutionCallback,
|
|
5
|
+
EvolutionEvent,
|
|
6
|
+
EvolutionEventKind,
|
|
7
|
+
)
|
|
8
|
+
from pytpg.callbacks.logging import EventRecorder, LoggingCallback
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"EventRecorder",
|
|
12
|
+
"EvolutionCallback",
|
|
13
|
+
"EvolutionEvent",
|
|
14
|
+
"EvolutionEventKind",
|
|
15
|
+
"LoggingCallback",
|
|
16
|
+
]
|