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.
Files changed (82) hide show
  1. pytpg/__init__.py +7 -0
  2. pytpg/adapters/__init__.py +43 -0
  3. pytpg/adapters/errors.py +40 -0
  4. pytpg/adapters/gymnasium.py +360 -0
  5. pytpg/adapters/gymnasium_evaluation.py +231 -0
  6. pytpg/callbacks/__init__.py +16 -0
  7. pytpg/callbacks/base.py +104 -0
  8. pytpg/callbacks/logging.py +52 -0
  9. pytpg/config.py +197 -0
  10. pytpg/core/__init__.py +45 -0
  11. pytpg/core/_validation.py +9 -0
  12. pytpg/core/action.py +44 -0
  13. pytpg/core/graph.py +160 -0
  14. pytpg/core/identifiers.py +22 -0
  15. pytpg/core/instruction.py +99 -0
  16. pytpg/core/learner.py +27 -0
  17. pytpg/core/program.py +27 -0
  18. pytpg/core/team.py +60 -0
  19. pytpg/evaluation/__init__.py +20 -0
  20. pytpg/evaluation/evaluator.py +52 -0
  21. pytpg/evaluation/statistics.py +222 -0
  22. pytpg/evaluation/toy.py +102 -0
  23. pytpg/evolution/__init__.py +81 -0
  24. pytpg/evolution/_ids.py +51 -0
  25. pytpg/evolution/engine.py +227 -0
  26. pytpg/evolution/errors.py +25 -0
  27. pytpg/evolution/genome.py +186 -0
  28. pytpg/evolution/initialization.py +129 -0
  29. pytpg/evolution/mutation/__init__.py +35 -0
  30. pytpg/evolution/mutation/_utils.py +59 -0
  31. pytpg/evolution/mutation/base.py +90 -0
  32. pytpg/evolution/mutation/defaults.py +119 -0
  33. pytpg/evolution/mutation/instruction.py +137 -0
  34. pytpg/evolution/mutation/learner.py +176 -0
  35. pytpg/evolution/mutation/team.py +162 -0
  36. pytpg/evolution/population.py +123 -0
  37. pytpg/evolution/reproduction.py +124 -0
  38. pytpg/evolution/rng.py +38 -0
  39. pytpg/evolution/selection.py +78 -0
  40. pytpg/experiment.py +140 -0
  41. pytpg/memory/__init__.py +34 -0
  42. pytpg/memory/_validation.py +48 -0
  43. pytpg/memory/base.py +123 -0
  44. pytpg/memory/errors.py +16 -0
  45. pytpg/memory/history.py +53 -0
  46. pytpg/memory/null.py +33 -0
  47. pytpg/memory/register.py +104 -0
  48. pytpg/memory/stateful.py +142 -0
  49. pytpg/metadata.py +129 -0
  50. pytpg/multiagent/__init__.py +31 -0
  51. pytpg/multiagent/_validation.py +102 -0
  52. pytpg/multiagent/codec.py +82 -0
  53. pytpg/multiagent/controller.py +159 -0
  54. pytpg/multiagent/errors.py +30 -0
  55. pytpg/multiagent/independent.py +191 -0
  56. pytpg/multiagent/model.py +74 -0
  57. pytpg/multiagent/shared.py +197 -0
  58. pytpg/py.typed +1 -0
  59. pytpg/runtime/__init__.py +84 -0
  60. pytpg/runtime/_model.py +115 -0
  61. pytpg/runtime/config.py +40 -0
  62. pytpg/runtime/errors.py +70 -0
  63. pytpg/runtime/executor.py +176 -0
  64. pytpg/runtime/graph_runtime.py +244 -0
  65. pytpg/runtime/graph_validation.py +368 -0
  66. pytpg/runtime/inference.py +46 -0
  67. pytpg/runtime/inspection.py +116 -0
  68. pytpg/runtime/operators.py +135 -0
  69. pytpg/runtime/registers.py +60 -0
  70. pytpg/runtime/team_runtime.py +76 -0
  71. pytpg/seed.py +48 -0
  72. pytpg/serialization/__init__.py +57 -0
  73. pytpg/serialization/_validation.py +71 -0
  74. pytpg/serialization/checkpoint.py +392 -0
  75. pytpg/serialization/errors.py +25 -0
  76. pytpg/serialization/graph.py +317 -0
  77. pytpg/serialization/io.py +74 -0
  78. pytpg/serialization/rng.py +104 -0
  79. pytpg-0.5.0.dist-info/METADATA +211 -0
  80. pytpg-0.5.0.dist-info/RECORD +82 -0
  81. pytpg-0.5.0.dist-info/WHEEL +4 -0
  82. pytpg-0.5.0.dist-info/licenses/LICENSE +21 -0
pytpg/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Public package namespace for the pyTPG research framework."""
2
+
3
+ from importlib.metadata import version as _version
4
+
5
+ __version__ = _version("pytpg")
6
+
7
+ __all__ = ["__version__"]
@@ -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
+ ]
@@ -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
+ ]