game-learning-runtime 0.1.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.
@@ -0,0 +1,45 @@
1
+ """Game Learning Runtime public API."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from game_learning_runtime.collector import Policy, SyncCollector
6
+ from game_learning_runtime.contracts import Event, TimeStep, Transition, Unroll
7
+ from game_learning_runtime.environment import ContractEnvironment, GameEnvironment
8
+ from game_learning_runtime.errors import ContractViolation, GLRError, OptionalDependencyError
9
+ from game_learning_runtime.protocol import protocol_path
10
+ from game_learning_runtime.serialization import (
11
+ JsonlTransitionWriter,
12
+ read_jsonl_transitions,
13
+ transition_from_record,
14
+ transition_to_record,
15
+ )
16
+ from game_learning_runtime.specs import CompositeSpec, EnvironmentSpec, SpaceKind, TensorSpec
17
+
18
+ try:
19
+ __version__ = version("game-learning-runtime")
20
+ except PackageNotFoundError: # pragma: no cover - source tree without installation
21
+ __version__ = "0.0.0+local"
22
+
23
+ __all__ = [
24
+ "CompositeSpec",
25
+ "ContractEnvironment",
26
+ "ContractViolation",
27
+ "EnvironmentSpec",
28
+ "Event",
29
+ "GLRError",
30
+ "GameEnvironment",
31
+ "JsonlTransitionWriter",
32
+ "OptionalDependencyError",
33
+ "Policy",
34
+ "SpaceKind",
35
+ "SyncCollector",
36
+ "TensorSpec",
37
+ "TimeStep",
38
+ "Transition",
39
+ "Unroll",
40
+ "__version__",
41
+ "protocol_path",
42
+ "read_jsonl_transitions",
43
+ "transition_from_record",
44
+ "transition_to_record",
45
+ ]
@@ -0,0 +1,82 @@
1
+ """Framework-neutral synchronous collection primitives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol
6
+
7
+ from game_learning_runtime.contracts import TensorTree, TimeStep, Transition, Unroll
8
+ from game_learning_runtime.environment import ContractEnvironment, GameEnvironment
9
+
10
+
11
+ class Policy(Protocol):
12
+ """Minimal policy port shared by custom PPO, IMPALA, BC, and evaluation."""
13
+
14
+ def __call__(self, timestep: TimeStep) -> TensorTree:
15
+ """Choose a structured action from a time step."""
16
+ ...
17
+
18
+
19
+ class SyncCollector:
20
+ """Collect fixed-length unrolls without coupling to a learner framework."""
21
+
22
+ def __init__(self, environment: GameEnvironment, *, actor_id: str = "actor-0") -> None:
23
+ if not actor_id:
24
+ raise ValueError("actor_id cannot be empty")
25
+ self._environment = (
26
+ environment
27
+ if isinstance(environment, ContractEnvironment)
28
+ else ContractEnvironment(environment)
29
+ )
30
+ self._actor_id = actor_id
31
+ self._current: TimeStep | None = None
32
+ self._sequence_id = 0
33
+
34
+ def collect(
35
+ self,
36
+ policy: Policy,
37
+ *,
38
+ steps: int,
39
+ policy_version: int = 0,
40
+ seed: int | None = None,
41
+ ) -> Unroll:
42
+ if steps <= 0:
43
+ raise ValueError("steps must be positive")
44
+ if policy_version < 0:
45
+ raise ValueError("policy_version cannot be negative")
46
+ if self._current is None or self._current.done:
47
+ self._current = self._environment.reset(seed=seed)
48
+
49
+ transitions: list[Transition] = []
50
+ for _ in range(steps):
51
+ current = self._current
52
+ action = policy(current)
53
+ following = self._environment.step(action)
54
+ transitions.append(
55
+ Transition(
56
+ episode_id=current.episode_id,
57
+ step_id=current.step_id,
58
+ observation=current.observation,
59
+ action=action,
60
+ action_mask=current.action_mask,
61
+ reward=following.reward,
62
+ next_observation=following.observation,
63
+ next_action_mask=following.action_mask,
64
+ terminated=following.terminated,
65
+ truncated=following.truncated,
66
+ events=following.events,
67
+ info=following.info,
68
+ timestamp_ns=following.timestamp_ns,
69
+ )
70
+ )
71
+ self._current = following
72
+ if following.done and len(transitions) < steps:
73
+ self._current = self._environment.reset()
74
+
75
+ unroll = Unroll(
76
+ transitions=tuple(transitions),
77
+ actor_id=self._actor_id,
78
+ sequence_id=self._sequence_id,
79
+ policy_version=policy_version,
80
+ )
81
+ self._sequence_id += 1
82
+ return unroll
@@ -0,0 +1,146 @@
1
+ """Runtime data contracts shared by adapters, collectors, and learners."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from time import time_ns
8
+ from types import MappingProxyType
9
+ from typing import Any, TypeAlias
10
+ from uuid import UUID, uuid4
11
+
12
+ import numpy as np
13
+ from numpy.typing import NDArray
14
+
15
+ TensorTree: TypeAlias = Mapping[str, "NDArray[Any] | TensorTree"]
16
+
17
+
18
+ def freeze_tree(value: Mapping[str, Any]) -> TensorTree:
19
+ """Copy a nested tensor mapping and make each array read-only."""
20
+
21
+ frozen: dict[str, NDArray[Any] | TensorTree] = {}
22
+ for key, item in value.items():
23
+ if isinstance(item, Mapping):
24
+ frozen[key] = freeze_tree(item)
25
+ else:
26
+ array = np.array(item, copy=True)
27
+ array.flags.writeable = False
28
+ frozen[key] = array
29
+ return MappingProxyType(frozen)
30
+
31
+
32
+ def freeze_array(value: Any) -> NDArray[Any]:
33
+ array = np.array(value, copy=True)
34
+ array.flags.writeable = False
35
+ return array
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class Event:
40
+ """A timestamped semantic event emitted by a game runtime."""
41
+
42
+ name: str
43
+ payload: Mapping[str, Any] = field(default_factory=dict)
44
+ timestamp_ns: int = field(default_factory=time_ns)
45
+
46
+ def __post_init__(self) -> None:
47
+ if not self.name:
48
+ raise ValueError("event name cannot be empty")
49
+ object.__setattr__(self, "payload", MappingProxyType(dict(self.payload)))
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class TimeStep:
54
+ """Observation and environment signals after reset or an action."""
55
+
56
+ observation: TensorTree
57
+ reward: NDArray[Any]
58
+ terminated: NDArray[np.bool_]
59
+ truncated: NDArray[np.bool_]
60
+ episode_id: UUID = field(default_factory=uuid4)
61
+ step_id: int = 0
62
+ action_mask: TensorTree | None = None
63
+ events: tuple[Event, ...] = ()
64
+ info: Mapping[str, Any] = field(default_factory=dict)
65
+ timestamp_ns: int = field(default_factory=time_ns)
66
+
67
+ def __post_init__(self) -> None:
68
+ if self.step_id < 0:
69
+ raise ValueError("step_id cannot be negative")
70
+ object.__setattr__(self, "observation", freeze_tree(self.observation))
71
+ object.__setattr__(self, "reward", freeze_array(self.reward))
72
+ object.__setattr__(self, "terminated", freeze_array(self.terminated))
73
+ object.__setattr__(self, "truncated", freeze_array(self.truncated))
74
+ if self.action_mask is not None:
75
+ object.__setattr__(self, "action_mask", freeze_tree(self.action_mask))
76
+ object.__setattr__(self, "events", tuple(self.events))
77
+ object.__setattr__(self, "info", MappingProxyType(dict(self.info)))
78
+
79
+ @property
80
+ def done(self) -> bool:
81
+ """Whether every participant represented by the done tensors has ended."""
82
+
83
+ return bool(np.all(np.logical_or(self.terminated, self.truncated)))
84
+
85
+
86
+ @dataclass(frozen=True, slots=True)
87
+ class Transition:
88
+ """One learner-neutral transition suitable for RL, BC, or offline data."""
89
+
90
+ episode_id: UUID
91
+ step_id: int
92
+ observation: TensorTree
93
+ action: TensorTree
94
+ reward: NDArray[Any]
95
+ next_observation: TensorTree
96
+ terminated: NDArray[np.bool_]
97
+ truncated: NDArray[np.bool_]
98
+ action_mask: TensorTree | None = None
99
+ next_action_mask: TensorTree | None = None
100
+ events: tuple[Event, ...] = ()
101
+ info: Mapping[str, Any] = field(default_factory=dict)
102
+ timestamp_ns: int = field(default_factory=time_ns)
103
+
104
+ def __post_init__(self) -> None:
105
+ object.__setattr__(self, "observation", freeze_tree(self.observation))
106
+ object.__setattr__(self, "action", freeze_tree(self.action))
107
+ object.__setattr__(self, "reward", freeze_array(self.reward))
108
+ object.__setattr__(self, "next_observation", freeze_tree(self.next_observation))
109
+ object.__setattr__(self, "terminated", freeze_array(self.terminated))
110
+ object.__setattr__(self, "truncated", freeze_array(self.truncated))
111
+ if self.action_mask is not None:
112
+ object.__setattr__(self, "action_mask", freeze_tree(self.action_mask))
113
+ if self.next_action_mask is not None:
114
+ object.__setattr__(self, "next_action_mask", freeze_tree(self.next_action_mask))
115
+ object.__setattr__(self, "events", tuple(self.events))
116
+ object.__setattr__(self, "info", MappingProxyType(dict(self.info)))
117
+
118
+ @property
119
+ def done(self) -> bool:
120
+ return bool(np.all(np.logical_or(self.terminated, self.truncated)))
121
+
122
+
123
+ @dataclass(frozen=True, slots=True)
124
+ class Unroll:
125
+ """A fixed-length actor unroll for PPO/IMPALA-style learners."""
126
+
127
+ transitions: tuple[Transition, ...]
128
+ actor_id: str
129
+ sequence_id: int
130
+ policy_version: int = 0
131
+
132
+ def __post_init__(self) -> None:
133
+ if not self.transitions:
134
+ raise ValueError("an unroll requires at least one transition")
135
+ if not self.actor_id:
136
+ raise ValueError("actor_id cannot be empty")
137
+ if self.sequence_id < 0 or self.policy_version < 0:
138
+ raise ValueError("sequence_id and policy_version cannot be negative")
139
+ object.__setattr__(self, "transitions", tuple(self.transitions))
140
+
141
+ @property
142
+ def total_reward(self) -> NDArray[Any]:
143
+ total = np.zeros_like(self.transitions[0].reward)
144
+ for transition in self.transitions:
145
+ total = total + transition.reward
146
+ return total
@@ -0,0 +1,118 @@
1
+ """Environment port and a fail-closed runtime contract wrapper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import Mapping
7
+ from types import TracebackType
8
+ from typing import Any
9
+ from uuid import UUID
10
+
11
+ from game_learning_runtime.contracts import TensorTree, TimeStep
12
+ from game_learning_runtime.errors import ContractViolation
13
+ from game_learning_runtime.specs import EnvironmentSpec
14
+
15
+
16
+ class GameEnvironment(ABC):
17
+ """Port implemented by in-process, RPC, shared-memory, or replay adapters."""
18
+
19
+ @property
20
+ @abstractmethod
21
+ def spec(self) -> EnvironmentSpec:
22
+ """Return the immutable environment contract."""
23
+
24
+ @abstractmethod
25
+ def reset(
26
+ self, *, seed: int | None = None, options: Mapping[str, Any] | None = None
27
+ ) -> TimeStep:
28
+ """Start a new episode and return step zero."""
29
+
30
+ @abstractmethod
31
+ def step(self, action: TensorTree) -> TimeStep:
32
+ """Apply one structured action and return the resulting time step."""
33
+
34
+ def close(self) -> None:
35
+ """Release runtime resources. Implementations may override this method."""
36
+ return None
37
+
38
+ def __enter__(self) -> GameEnvironment:
39
+ return self
40
+
41
+ def __exit__(
42
+ self,
43
+ exc_type: type[BaseException] | None,
44
+ exc_value: BaseException | None,
45
+ traceback: TracebackType | None,
46
+ ) -> None:
47
+ self.close()
48
+
49
+
50
+ class ContractEnvironment(GameEnvironment):
51
+ """Validates an adapter at every state transition and fails closed."""
52
+
53
+ def __init__(self, environment: GameEnvironment) -> None:
54
+ self._environment = environment
55
+ self._current: TimeStep | None = None
56
+ self._previous_episode_id: UUID | None = None
57
+ self._closed = False
58
+
59
+ @property
60
+ def spec(self) -> EnvironmentSpec:
61
+ return self._environment.spec
62
+
63
+ def reset(
64
+ self, *, seed: int | None = None, options: Mapping[str, Any] | None = None
65
+ ) -> TimeStep:
66
+ self._ensure_open()
67
+ timestep = self._environment.reset(seed=seed, options=options)
68
+ self._validate_timestep(timestep)
69
+ if timestep.step_id != 0:
70
+ raise ContractViolation(f"reset returned step_id={timestep.step_id}; expected 0")
71
+ if timestep.done:
72
+ raise ContractViolation("reset returned a terminal time step")
73
+ if self._previous_episode_id == timestep.episode_id:
74
+ raise ContractViolation("reset reused the previous episode_id")
75
+ self._current = timestep
76
+ self._previous_episode_id = timestep.episode_id
77
+ return timestep
78
+
79
+ def step(self, action: TensorTree) -> TimeStep:
80
+ self._ensure_open()
81
+ if self._current is None:
82
+ raise ContractViolation("step requires reset first")
83
+ if self._current.done:
84
+ raise ContractViolation("step cannot follow a terminal time step; reset first")
85
+ self.spec.action.validate(action, path="action")
86
+ timestep = self._environment.step(action)
87
+ self._validate_timestep(timestep)
88
+ if timestep.episode_id != self._current.episode_id:
89
+ raise ContractViolation("step changed episode_id without reset")
90
+ expected_step_id = self._current.step_id + 1
91
+ if timestep.step_id != expected_step_id:
92
+ raise ContractViolation(
93
+ f"step returned step_id={timestep.step_id}; expected {expected_step_id}"
94
+ )
95
+ self._current = timestep
96
+ return timestep
97
+
98
+ def close(self) -> None:
99
+ if not self._closed:
100
+ self._environment.close()
101
+ self._closed = True
102
+
103
+ def _validate_timestep(self, timestep: TimeStep) -> None:
104
+ self.spec.observation.validate(timestep.observation, path="observation")
105
+ self.spec.reward.validate(timestep.reward, path="reward")
106
+ self.spec.done.validate(timestep.terminated, path="terminated")
107
+ self.spec.done.validate(timestep.truncated, path="truncated")
108
+ if self.spec.action_mask is None:
109
+ if timestep.action_mask is not None:
110
+ raise ContractViolation("adapter returned an undeclared action_mask")
111
+ elif timestep.action_mask is None:
112
+ raise ContractViolation("adapter omitted the declared action_mask")
113
+ else:
114
+ self.spec.action_mask.validate(timestep.action_mask, path="action_mask")
115
+
116
+ def _ensure_open(self) -> None:
117
+ if self._closed:
118
+ raise ContractViolation("environment is closed")
@@ -0,0 +1,13 @@
1
+ """Exception hierarchy for Game Learning Runtime."""
2
+
3
+
4
+ class GLRError(Exception):
5
+ """Base class for all GLR errors."""
6
+
7
+
8
+ class ContractViolation(GLRError, ValueError):
9
+ """Raised when an environment violates its declared contract."""
10
+
11
+
12
+ class OptionalDependencyError(GLRError, ImportError):
13
+ """Raised when an optional integration is used without its dependencies."""
@@ -0,0 +1,9 @@
1
+ """Executable reference environments."""
2
+
3
+ from game_learning_runtime.examples.counter import (
4
+ CounterEnvironment,
5
+ always_increment,
6
+ make_environment,
7
+ )
8
+
9
+ __all__ = ["CounterEnvironment", "always_increment", "make_environment"]
@@ -0,0 +1,94 @@
1
+ """Small complete GLR environment used by the getting-started guide."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+ from uuid import uuid4
8
+
9
+ import numpy as np
10
+
11
+ from game_learning_runtime.contracts import TensorTree, TimeStep
12
+ from game_learning_runtime.environment import GameEnvironment
13
+ from game_learning_runtime.specs import CompositeSpec, EnvironmentSpec, SpaceKind, TensorSpec
14
+
15
+
16
+ class CounterEnvironment(GameEnvironment):
17
+ """Increment a counter until it reaches a target value."""
18
+
19
+ def __init__(self, *, target: int = 3, max_steps: int = 5) -> None:
20
+ self._target = target
21
+ self._max_steps = max_steps
22
+ self._position = 0
23
+ self._step_id = 0
24
+ self._episode_id = uuid4()
25
+ self._spec = EnvironmentSpec(
26
+ environment_id="example.counter-v1",
27
+ observation=CompositeSpec(
28
+ {"position": TensorSpec((1,), np.int64, minimum=0, maximum=target)}
29
+ ),
30
+ action=CompositeSpec(
31
+ {
32
+ "choice": TensorSpec(
33
+ (1,),
34
+ np.int64,
35
+ kind=SpaceKind.DISCRETE,
36
+ minimum=0,
37
+ maximum=1,
38
+ description="0 waits and 1 increments the counter",
39
+ )
40
+ }
41
+ ),
42
+ action_mask=CompositeSpec(
43
+ {"choice": TensorSpec((2,), np.bool_, kind=SpaceKind.BINARY)}
44
+ ),
45
+ capabilities=frozenset({"action-mask", "deterministic-reset"}),
46
+ )
47
+
48
+ @property
49
+ def spec(self) -> EnvironmentSpec:
50
+ return self._spec
51
+
52
+ def reset(
53
+ self, *, seed: int | None = None, options: Mapping[str, Any] | None = None
54
+ ) -> TimeStep:
55
+ del seed, options
56
+ self._position = 0
57
+ self._step_id = 0
58
+ self._episode_id = uuid4()
59
+ return self._timestep()
60
+
61
+ def step(self, action: TensorTree) -> TimeStep:
62
+ choice_value = action["choice"]
63
+ if isinstance(choice_value, Mapping):
64
+ raise TypeError("choice must be a tensor leaf")
65
+ choice = int(choice_value[0])
66
+ if choice == 1:
67
+ self._position = min(self._position + 1, self._target)
68
+ self._step_id += 1
69
+ return self._timestep()
70
+
71
+ def _timestep(self) -> TimeStep:
72
+ reached_target = self._position == self._target
73
+ truncated = not reached_target and self._step_id >= self._max_steps
74
+ return TimeStep(
75
+ observation={"position": np.array([self._position], dtype=np.int64)},
76
+ reward=np.array([1.0 if reached_target else -0.01], dtype=np.float32),
77
+ terminated=np.array([reached_target], dtype=np.bool_),
78
+ truncated=np.array([truncated], dtype=np.bool_),
79
+ action_mask={"choice": np.array([True, not reached_target], dtype=np.bool_)},
80
+ episode_id=self._episode_id,
81
+ step_id=self._step_id,
82
+ )
83
+
84
+
85
+ def always_increment(timestep: TimeStep) -> TensorTree:
86
+ del timestep
87
+ return {"choice": np.array([1], dtype=np.int64)}
88
+
89
+
90
+ def make_environment() -> CounterEnvironment:
91
+ return CounterEnvironment()
92
+
93
+
94
+ __all__ = ["CounterEnvironment", "always_increment", "make_environment"]
@@ -0,0 +1 @@
1
+ """Optional learning-framework integrations."""
@@ -0,0 +1,153 @@
1
+ """TorchRL ``EnvBase`` adapter for any GLR game environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+ from game_learning_runtime.contracts import TensorTree, TimeStep
11
+ from game_learning_runtime.environment import ContractEnvironment, GameEnvironment
12
+ from game_learning_runtime.errors import OptionalDependencyError
13
+ from game_learning_runtime.specs import CompositeSpec, TensorSpec
14
+
15
+ try:
16
+ import torch
17
+ from tensordict import TensorDict, TensorDictBase
18
+ from torchrl.data import Bounded, Categorical, Composite, Unbounded
19
+ from torchrl.envs import EnvBase
20
+ except ImportError as error: # pragma: no cover - exercised without the optional extra
21
+ raise OptionalDependencyError(
22
+ "TorchRL support requires `uv add game-learning-runtime[torchrl]`"
23
+ ) from error
24
+
25
+
26
+ def _torch_dtype(dtype: np.dtype[Any]) -> torch.dtype:
27
+ return torch.from_numpy(np.empty((), dtype=dtype)).dtype
28
+
29
+
30
+ def _leaf_spec(spec: TensorSpec, *, device: torch.device) -> Any:
31
+ if spec.is_dynamic:
32
+ raise ValueError("TorchRL EnvBase specs require static shapes")
33
+ shape = tuple(int(dimension) for dimension in spec.shape if dimension is not None)
34
+ dtype = _torch_dtype(np.dtype(spec.dtype))
35
+ if spec.dtype == np.dtype(np.bool_):
36
+ return Categorical(n=2, shape=shape, dtype=torch.bool, device=device)
37
+ if spec.minimum is not None and spec.maximum is not None:
38
+ return Bounded(
39
+ low=torch.as_tensor(spec.minimum, dtype=dtype, device=device),
40
+ high=torch.as_tensor(spec.maximum, dtype=dtype, device=device),
41
+ shape=shape,
42
+ dtype=dtype,
43
+ device=device,
44
+ )
45
+ return Unbounded(shape=shape, dtype=dtype, device=device)
46
+
47
+
48
+ def _composite_spec(spec: CompositeSpec, *, device: torch.device) -> Composite:
49
+ values = {
50
+ key: _composite_spec(value, device=device)
51
+ if isinstance(value, CompositeSpec)
52
+ else _leaf_spec(value, device=device)
53
+ for key, value in spec.fields.items()
54
+ }
55
+ return Composite(values, shape=(), device=device)
56
+
57
+
58
+ def _tree_to_tensordict(tree: TensorTree, *, device: torch.device) -> TensorDict:
59
+ values = {
60
+ key: _tree_to_tensordict(value, device=device)
61
+ if isinstance(value, Mapping)
62
+ else torch.as_tensor(np.array(value, copy=True), device=device)
63
+ for key, value in tree.items()
64
+ }
65
+ return TensorDict(values, batch_size=(), device=device)
66
+
67
+
68
+ def _action_to_tree(tensordict: TensorDictBase, spec: CompositeSpec) -> TensorTree:
69
+ result: dict[str, Any] = {}
70
+ for key, child_spec in spec.fields.items():
71
+ value = tensordict.get(key)
72
+ if isinstance(child_spec, CompositeSpec):
73
+ if not isinstance(value, TensorDictBase):
74
+ raise TypeError(f"TorchRL action field {key!r} must be a TensorDict")
75
+ result[key] = _action_to_tree(value, child_spec)
76
+ else:
77
+ result[key] = value.detach().cpu().numpy()
78
+ return result
79
+
80
+
81
+ class TorchRLEnvironment(EnvBase): # type: ignore[misc]
82
+ """Adapt the learner-neutral GLR port to the current TorchRL environment API."""
83
+
84
+ batch_locked = True
85
+
86
+ def __init__(
87
+ self,
88
+ environment: GameEnvironment,
89
+ *,
90
+ device: str | torch.device = "cpu",
91
+ run_type_checks: bool = True,
92
+ ) -> None:
93
+ self._glr = (
94
+ environment
95
+ if isinstance(environment, ContractEnvironment)
96
+ else ContractEnvironment(environment)
97
+ )
98
+ self._next_seed: int | None = None
99
+ super().__init__(device=device, batch_size=(), run_type_checks=run_type_checks)
100
+ torch_device = torch.device(device)
101
+ observation_values: dict[str, Any] = {
102
+ "observation": _composite_spec(self._glr.spec.observation, device=torch_device)
103
+ }
104
+ if self._glr.spec.action_mask is not None:
105
+ observation_values["action_mask"] = _composite_spec(
106
+ self._glr.spec.action_mask, device=torch_device
107
+ )
108
+ self.observation_spec = Composite(observation_values, shape=(), device=torch_device)
109
+ self.action_spec = _composite_spec(self._glr.spec.action, device=torch_device)
110
+ self.reward_spec = _leaf_spec(self._glr.spec.reward, device=torch_device)
111
+ done_leaf = _leaf_spec(self._glr.spec.done, device=torch_device)
112
+ self.done_spec = Composite(
113
+ done=done_leaf,
114
+ terminated=done_leaf.clone(),
115
+ truncated=done_leaf.clone(),
116
+ shape=(),
117
+ device=torch_device,
118
+ )
119
+
120
+ def _step(self, tensordict: TensorDictBase) -> TensorDictBase:
121
+ action = _action_to_tree(tensordict, self._glr.spec.action)
122
+ return self._from_timestep(self._glr.step(action), include_reward=True)
123
+
124
+ def _reset(self, tensordict: TensorDictBase | None = None, **kwargs: Any) -> TensorDictBase:
125
+ del tensordict, kwargs
126
+ timestep = self._glr.reset(seed=self._next_seed)
127
+ self._next_seed = None
128
+ return self._from_timestep(timestep, include_reward=False)
129
+
130
+ def _set_seed(self, seed: int | None) -> None:
131
+ self._next_seed = seed
132
+
133
+ def _close(self) -> None:
134
+ self._glr.close()
135
+
136
+ def _from_timestep(self, timestep: TimeStep, *, include_reward: bool) -> TensorDict:
137
+ device = self.device or torch.device("cpu")
138
+ terminated = torch.as_tensor(np.array(timestep.terminated, copy=True), device=device)
139
+ truncated = torch.as_tensor(np.array(timestep.truncated, copy=True), device=device)
140
+ values: dict[str, Any] = {
141
+ "observation": _tree_to_tensordict(timestep.observation, device=device),
142
+ "done": torch.logical_or(terminated, truncated),
143
+ "terminated": terminated,
144
+ "truncated": truncated,
145
+ }
146
+ if timestep.action_mask is not None:
147
+ values["action_mask"] = _tree_to_tensordict(timestep.action_mask, device=device)
148
+ if include_reward:
149
+ values["reward"] = torch.as_tensor(np.array(timestep.reward, copy=True), device=device)
150
+ return TensorDict(values, batch_size=(), device=device)
151
+
152
+
153
+ __all__ = ["TorchRLEnvironment"]
@@ -0,0 +1,15 @@
1
+ """Access to the packaged GLR protocol definition."""
2
+
3
+ from collections.abc import Iterator
4
+ from contextlib import contextmanager
5
+ from importlib.resources import as_file, files
6
+ from pathlib import Path
7
+
8
+
9
+ @contextmanager
10
+ def protocol_path() -> Iterator[Path]:
11
+ """Yield a filesystem path to the packaged ``runtime.proto`` schema."""
12
+
13
+ resource = files("game_learning_runtime.protocol").joinpath("glr/v1/runtime.proto")
14
+ with as_file(resource) as path:
15
+ yield path
@@ -0,0 +1,93 @@
1
+ syntax = "proto3";
2
+
3
+ package glr.v1;
4
+
5
+ option csharp_namespace = "GameLearningRuntime.Protocol.V1";
6
+ option java_multiple_files = true;
7
+ option java_package = "dev.glr.protocol.v1";
8
+
9
+ enum DType {
10
+ DTYPE_UNSPECIFIED = 0;
11
+ DTYPE_BOOL = 1;
12
+ DTYPE_UINT8 = 2;
13
+ DTYPE_INT32 = 3;
14
+ DTYPE_INT64 = 4;
15
+ DTYPE_FLOAT32 = 5;
16
+ DTYPE_FLOAT64 = 6;
17
+ }
18
+
19
+ enum SpaceKind {
20
+ SPACE_KIND_UNSPECIFIED = 0;
21
+ SPACE_KIND_CONTINUOUS = 1;
22
+ SPACE_KIND_DISCRETE = 2;
23
+ SPACE_KIND_MULTI_DISCRETE = 3;
24
+ SPACE_KIND_BINARY = 4;
25
+ }
26
+
27
+ message TensorSpec {
28
+ string path = 1;
29
+ repeated int64 shape = 2; // -1 denotes a dynamic dimension.
30
+ DType dtype = 3;
31
+ SpaceKind kind = 4;
32
+ optional double minimum = 5;
33
+ optional double maximum = 6;
34
+ string description = 7;
35
+ }
36
+
37
+ message Tensor {
38
+ repeated int64 shape = 1;
39
+ DType dtype = 2;
40
+ bytes data = 3;
41
+ }
42
+
43
+ message EnvironmentDescriptor {
44
+ string environment_id = 1;
45
+ string protocol_version = 2;
46
+ repeated TensorSpec observations = 3;
47
+ repeated TensorSpec actions = 4;
48
+ repeated TensorSpec action_masks = 5;
49
+ TensorSpec reward = 6;
50
+ TensorSpec done = 7;
51
+ repeated string capabilities = 8;
52
+ map<string, string> metadata = 9;
53
+ }
54
+
55
+ message DescribeRequest {}
56
+
57
+ message ResetRequest {
58
+ optional uint64 seed = 1;
59
+ map<string, string> options = 2;
60
+ }
61
+
62
+ message StepRequest {
63
+ string episode_id = 1;
64
+ uint64 expected_step_id = 2;
65
+ map<string, Tensor> action = 3;
66
+ }
67
+
68
+ message RuntimeEvent {
69
+ string name = 1;
70
+ uint64 timestamp_ns = 2;
71
+ bytes json_payload = 3;
72
+ }
73
+
74
+ message TimeStep {
75
+ string episode_id = 1;
76
+ uint64 step_id = 2;
77
+ uint64 timestamp_ns = 3;
78
+ map<string, Tensor> observation = 4;
79
+ Tensor reward = 5;
80
+ Tensor terminated = 6;
81
+ Tensor truncated = 7;
82
+ map<string, Tensor> action_mask = 8;
83
+ repeated RuntimeEvent events = 9;
84
+ bytes json_info = 10;
85
+ }
86
+
87
+ service GameRuntime {
88
+ rpc Describe(DescribeRequest) returns (EnvironmentDescriptor);
89
+ rpc Reset(ResetRequest) returns (TimeStep);
90
+ rpc Step(StepRequest) returns (TimeStep);
91
+ rpc Interact(stream StepRequest) returns (stream TimeStep);
92
+ }
93
+
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,171 @@
1
+ """Portable JSONL transition records for BC, replay, and offline learning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ from collections.abc import Iterator, Mapping
8
+ from pathlib import Path
9
+ from types import TracebackType
10
+ from typing import IO, Any
11
+ from uuid import UUID
12
+
13
+ import numpy as np
14
+ from numpy.typing import NDArray
15
+
16
+ from game_learning_runtime.contracts import Event, TensorTree, Transition
17
+
18
+ RECORD_SCHEMA = "glr.transition.v1"
19
+
20
+
21
+ def _encode_array(array: NDArray[Any]) -> dict[str, Any]:
22
+ contiguous = np.ascontiguousarray(array)
23
+ return {
24
+ "dtype": contiguous.dtype.str,
25
+ "shape": list(contiguous.shape),
26
+ "data": base64.b64encode(contiguous.tobytes()).decode("ascii"),
27
+ }
28
+
29
+
30
+ def _decode_array(value: Mapping[str, Any]) -> NDArray[Any]:
31
+ dtype = np.dtype(value["dtype"])
32
+ shape = tuple(int(dimension) for dimension in value["shape"])
33
+ raw = base64.b64decode(value["data"], validate=True)
34
+ expected_size = int(np.prod(shape, dtype=np.int64)) * dtype.itemsize
35
+ if len(raw) != expected_size:
36
+ raise ValueError(f"tensor payload has {len(raw)} bytes; expected {expected_size}")
37
+ return np.frombuffer(raw, dtype=dtype).reshape(shape).copy()
38
+
39
+
40
+ def _encode_tree(tree: TensorTree | None) -> dict[str, Any] | None:
41
+ if tree is None:
42
+ return None
43
+ encoded: dict[str, Any] = {}
44
+ for key, value in tree.items():
45
+ encoded[key] = (
46
+ {"tree": _encode_tree(value)}
47
+ if isinstance(value, Mapping)
48
+ else {"tensor": _encode_array(value)}
49
+ )
50
+ return encoded
51
+
52
+
53
+ def _decode_tree(value: Mapping[str, Any] | None) -> TensorTree | None:
54
+ if value is None:
55
+ return None
56
+ decoded: dict[str, Any] = {}
57
+ for key, item in value.items():
58
+ if "tree" in item:
59
+ decoded[key] = _decode_tree(item["tree"])
60
+ elif "tensor" in item:
61
+ decoded[key] = _decode_array(item["tensor"])
62
+ else:
63
+ raise ValueError(f"tree field {key!r} is missing its value kind")
64
+ return decoded
65
+
66
+
67
+ def transition_to_record(transition: Transition) -> dict[str, Any]:
68
+ """Convert a transition to a stable, language-neutral JSON value."""
69
+
70
+ record = {
71
+ "schema": RECORD_SCHEMA,
72
+ "episode_id": str(transition.episode_id),
73
+ "step_id": transition.step_id,
74
+ "timestamp_ns": transition.timestamp_ns,
75
+ "observation": _encode_tree(transition.observation),
76
+ "action": _encode_tree(transition.action),
77
+ "action_mask": _encode_tree(transition.action_mask),
78
+ "reward": _encode_array(transition.reward),
79
+ "next_observation": _encode_tree(transition.next_observation),
80
+ "next_action_mask": _encode_tree(transition.next_action_mask),
81
+ "terminated": _encode_array(transition.terminated),
82
+ "truncated": _encode_array(transition.truncated),
83
+ "events": [
84
+ {
85
+ "name": event.name,
86
+ "timestamp_ns": event.timestamp_ns,
87
+ "payload": dict(event.payload),
88
+ }
89
+ for event in transition.events
90
+ ],
91
+ "info": dict(transition.info),
92
+ }
93
+ json.dumps(record, allow_nan=False)
94
+ return record
95
+
96
+
97
+ def transition_from_record(record: Mapping[str, Any]) -> Transition:
98
+ """Parse and validate one ``glr.transition.v1`` JSON value."""
99
+
100
+ if record.get("schema") != RECORD_SCHEMA:
101
+ raise ValueError(f"unsupported record schema: {record.get('schema')!r}")
102
+ observation = _decode_tree(record["observation"])
103
+ action = _decode_tree(record["action"])
104
+ next_observation = _decode_tree(record["next_observation"])
105
+ if observation is None or action is None or next_observation is None:
106
+ raise ValueError("observation, action, and next_observation are required")
107
+ return Transition(
108
+ episode_id=UUID(record["episode_id"]),
109
+ step_id=int(record["step_id"]),
110
+ timestamp_ns=int(record["timestamp_ns"]),
111
+ observation=observation,
112
+ action=action,
113
+ action_mask=_decode_tree(record.get("action_mask")),
114
+ reward=_decode_array(record["reward"]),
115
+ next_observation=next_observation,
116
+ next_action_mask=_decode_tree(record.get("next_action_mask")),
117
+ terminated=_decode_array(record["terminated"]),
118
+ truncated=_decode_array(record["truncated"]),
119
+ events=tuple(
120
+ Event(
121
+ name=item["name"],
122
+ timestamp_ns=int(item["timestamp_ns"]),
123
+ payload=item.get("payload", {}),
124
+ )
125
+ for item in record.get("events", [])
126
+ ),
127
+ info=record.get("info", {}),
128
+ )
129
+
130
+
131
+ class JsonlTransitionWriter:
132
+ """Append-only writer for replayable transition datasets."""
133
+
134
+ def __init__(self, path: str | Path) -> None:
135
+ self._path = Path(path)
136
+ self._stream: IO[str] | None = None
137
+
138
+ def __enter__(self) -> JsonlTransitionWriter:
139
+ self._path.parent.mkdir(parents=True, exist_ok=True)
140
+ self._stream = self._path.open("a", encoding="utf-8", newline="\n")
141
+ return self
142
+
143
+ def write(self, transition: Transition) -> None:
144
+ if self._stream is None:
145
+ raise RuntimeError("writer must be used as a context manager")
146
+ json.dump(transition_to_record(transition), self._stream, separators=(",", ":"))
147
+ self._stream.write("\n")
148
+ self._stream.flush()
149
+
150
+ def __exit__(
151
+ self,
152
+ exc_type: type[BaseException] | None,
153
+ exc_value: BaseException | None,
154
+ traceback: TracebackType | None,
155
+ ) -> None:
156
+ if self._stream is not None:
157
+ self._stream.close()
158
+ self._stream = None
159
+
160
+
161
+ def read_jsonl_transitions(path: str | Path) -> Iterator[Transition]:
162
+ """Stream transitions from a GLR JSONL dataset."""
163
+
164
+ with Path(path).open(encoding="utf-8") as stream:
165
+ for line_number, line in enumerate(stream, start=1):
166
+ if not line.strip():
167
+ continue
168
+ try:
169
+ yield transition_from_record(json.loads(line))
170
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
171
+ raise ValueError(f"invalid transition record at line {line_number}") from error
@@ -0,0 +1,182 @@
1
+ """Framework-neutral tensor and environment specifications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+ from types import MappingProxyType
9
+ from typing import Any, TypeAlias
10
+
11
+ import numpy as np
12
+ from numpy.typing import NDArray
13
+
14
+ from game_learning_runtime.errors import ContractViolation
15
+
16
+
17
+ class SpaceKind(str, Enum):
18
+ """Semantic meaning of a tensor leaf in an observation or action tree."""
19
+
20
+ CONTINUOUS = "continuous"
21
+ DISCRETE = "discrete"
22
+ MULTI_DISCRETE = "multi_discrete"
23
+ BINARY = "binary"
24
+
25
+
26
+ Shape = tuple[int | None, ...]
27
+ Limit: TypeAlias = int | float | NDArray[Any]
28
+
29
+
30
+ def _immutable_limit(value: Limit | None) -> Limit | None:
31
+ if value is None or isinstance(value, (int, float)):
32
+ return value
33
+ array = np.array(value, copy=True)
34
+ array.flags.writeable = False
35
+ return array
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class TensorSpec:
40
+ """Describes and validates one tensor leaf.
41
+
42
+ A ``None`` dimension is dynamic. Composite trees provide hybrid,
43
+ parameterized, and hierarchical spaces without adding algorithm concepts to
44
+ the runtime contract.
45
+ """
46
+
47
+ shape: Shape
48
+ dtype: np.dtype[Any] | str | type[Any]
49
+ kind: SpaceKind = SpaceKind.CONTINUOUS
50
+ minimum: Limit | None = None
51
+ maximum: Limit | None = None
52
+ description: str = ""
53
+
54
+ def __post_init__(self) -> None:
55
+ normalized_shape = tuple(self.shape)
56
+ if any(dimension is not None and dimension < 0 for dimension in normalized_shape):
57
+ raise ValueError("tensor dimensions must be non-negative or None")
58
+ normalized_dtype = np.dtype(self.dtype)
59
+ if self.kind in {SpaceKind.DISCRETE, SpaceKind.MULTI_DISCRETE} and not np.issubdtype(
60
+ normalized_dtype, np.integer
61
+ ):
62
+ raise ValueError(f"{self.kind.value} tensors require an integer dtype")
63
+ if self.kind is SpaceKind.BINARY and normalized_dtype != np.dtype(np.bool_):
64
+ raise ValueError("binary tensors require the bool dtype")
65
+ if (
66
+ self.minimum is not None
67
+ and self.maximum is not None
68
+ and np.any(np.asarray(self.minimum) > np.asarray(self.maximum))
69
+ ):
70
+ raise ValueError("minimum cannot exceed maximum")
71
+ object.__setattr__(self, "shape", normalized_shape)
72
+ object.__setattr__(self, "dtype", normalized_dtype)
73
+ object.__setattr__(self, "minimum", _immutable_limit(self.minimum))
74
+ object.__setattr__(self, "maximum", _immutable_limit(self.maximum))
75
+
76
+ @property
77
+ def is_dynamic(self) -> bool:
78
+ return any(dimension is None for dimension in self.shape)
79
+
80
+ def validate(self, value: Any, *, path: str = "tensor") -> NDArray[Any]:
81
+ array = np.asarray(value)
82
+ if array.dtype != self.dtype:
83
+ raise ContractViolation(f"{path} has dtype {array.dtype}; expected {self.dtype}")
84
+ if array.ndim != len(self.shape):
85
+ raise ContractViolation(f"{path} has shape {array.shape}; expected {self.shape}")
86
+ for actual, expected in zip(array.shape, self.shape, strict=True):
87
+ if expected is not None and actual != expected:
88
+ raise ContractViolation(f"{path} has shape {array.shape}; expected {self.shape}")
89
+ if self.minimum is not None and np.any(array < self.minimum):
90
+ raise ContractViolation(f"{path} contains a value below {self.minimum}")
91
+ if self.maximum is not None and np.any(array > self.maximum):
92
+ raise ContractViolation(f"{path} contains a value above {self.maximum}")
93
+ return array
94
+
95
+
96
+ SpecNode: TypeAlias = "TensorSpec | CompositeSpec"
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class CompositeSpec:
101
+ """A recursively nested tensor tree specification."""
102
+
103
+ fields: Mapping[str, SpecNode]
104
+ description: str = ""
105
+
106
+ def __post_init__(self) -> None:
107
+ if not self.fields:
108
+ raise ValueError("a composite spec requires at least one field")
109
+ copied: dict[str, SpecNode] = {}
110
+ for name, spec in self.fields.items():
111
+ if not name or "." in name:
112
+ raise ValueError("field names must be non-empty and cannot contain dots")
113
+ if not isinstance(spec, (TensorSpec, CompositeSpec)):
114
+ raise TypeError(f"field {name!r} is not a TensorSpec or CompositeSpec")
115
+ copied[name] = spec
116
+ object.__setattr__(self, "fields", MappingProxyType(copied))
117
+
118
+ @property
119
+ def is_dynamic(self) -> bool:
120
+ return any(spec.is_dynamic for spec in self.fields.values())
121
+
122
+ def validate(self, value: Mapping[str, Any], *, path: str = "tree") -> None:
123
+ if not isinstance(value, Mapping):
124
+ raise ContractViolation(f"{path} must be a mapping")
125
+ actual_keys = set(value)
126
+ expected_keys = set(self.fields)
127
+ if actual_keys != expected_keys:
128
+ missing = sorted(expected_keys - actual_keys)
129
+ unexpected = sorted(actual_keys - expected_keys)
130
+ raise ContractViolation(
131
+ f"{path} keys differ from the contract; missing={missing}, unexpected={unexpected}"
132
+ )
133
+ for name, spec in self.fields.items():
134
+ child_path = f"{path}.{name}"
135
+ child = value[name]
136
+ if isinstance(spec, CompositeSpec):
137
+ spec.validate(child, path=child_path)
138
+ else:
139
+ spec.validate(child, path=child_path)
140
+
141
+ def flatten(self, *, prefix: str = "") -> dict[str, TensorSpec]:
142
+ result: dict[str, TensorSpec] = {}
143
+ for name, spec in self.fields.items():
144
+ path = f"{prefix}.{name}" if prefix else name
145
+ if isinstance(spec, CompositeSpec):
146
+ result.update(spec.flatten(prefix=path))
147
+ else:
148
+ result[path] = spec
149
+ return result
150
+
151
+
152
+ def _default_reward_spec() -> TensorSpec:
153
+ return TensorSpec(shape=(1,), dtype=np.float32)
154
+
155
+
156
+ def _default_done_spec() -> TensorSpec:
157
+ return TensorSpec(shape=(1,), dtype=np.bool_, kind=SpaceKind.BINARY)
158
+
159
+
160
+ @dataclass(frozen=True, slots=True)
161
+ class EnvironmentSpec:
162
+ """Complete machine-readable contract exposed by a game adapter."""
163
+
164
+ environment_id: str
165
+ observation: CompositeSpec
166
+ action: CompositeSpec
167
+ reward: TensorSpec = field(default_factory=_default_reward_spec)
168
+ done: TensorSpec = field(default_factory=_default_done_spec)
169
+ action_mask: CompositeSpec | None = None
170
+ protocol_version: str = "1.0"
171
+ capabilities: frozenset[str] = frozenset()
172
+ metadata: Mapping[str, str] = field(default_factory=dict)
173
+
174
+ def __post_init__(self) -> None:
175
+ if not self.environment_id or any(character.isspace() for character in self.environment_id):
176
+ raise ValueError("environment_id must be non-empty and cannot contain whitespace")
177
+ if self.reward.kind is not SpaceKind.CONTINUOUS:
178
+ raise ValueError("reward spec must be continuous")
179
+ if self.done.kind is not SpaceKind.BINARY:
180
+ raise ValueError("done spec must be binary")
181
+ object.__setattr__(self, "capabilities", frozenset(self.capabilities))
182
+ object.__setattr__(self, "metadata", MappingProxyType(dict(self.metadata)))
@@ -0,0 +1,165 @@
1
+ Metadata-Version: 2.5
2
+ Name: game-learning-runtime
3
+ Version: 0.1.0
4
+ Summary: A universal runtime for connecting games to learning systems and AI agents.
5
+ Project-URL: Documentation, https://github.com/loonghao/GameLearningRuntime#readme
6
+ Project-URL: Issues, https://github.com/loonghao/GameLearningRuntime/issues
7
+ Project-URL: Repository, https://github.com/loonghao/GameLearningRuntime
8
+ Author-email: loonghao <hal.long@outlook.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: environment,game-ai,imitation-learning,reinforcement-learning,torchrl
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: numpy<2.3,>=1.26
24
+ Provides-Extra: torchrl
25
+ Requires-Dist: torch>=2.8; extra == 'torchrl'
26
+ Requires-Dist: torchrl<0.14,>=0.13; extra == 'torchrl'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # Game Learning Runtime
30
+
31
+ [![CI](https://github.com/loonghao/GameLearningRuntime/actions/workflows/ci.yml/badge.svg)](https://github.com/loonghao/GameLearningRuntime/actions/workflows/ci.yml)
32
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
33
+ [![Python](https://img.shields.io/badge/Python-3.10--3.13-3776AB.svg)](pyproject.toml)
34
+
35
+ Game Learning Runtime (GLR) is a framework-neutral contract between game
36
+ runtimes and learning systems. A game adapter describes observations, actions,
37
+ action masks, rewards, events, and episode boundaries once; TorchRL, custom PPO
38
+ or IMPALA learners, behavior cloning, offline datasets, evaluators, and QA tools
39
+ can then consume the same interface.
40
+
41
+ > A universal runtime for connecting games to learning systems and AI agents.
42
+
43
+ GLR is intended for games and test environments you own or are authorized to
44
+ instrument. It does not include anti-cheat bypasses, stealth injection, or
45
+ game-specific reverse-engineering code.
46
+
47
+ ## Why this boundary
48
+
49
+ ```text
50
+ Game / simulator
51
+
52
+
53
+ Runtime adapter (C#, C++, Rust, Python, official API, ...)
54
+
55
+
56
+ GLR protocol + environment contract
57
+
58
+ ├── TorchRL
59
+ ├── custom PPO / IMPALA
60
+ ├── BC / DAgger / offline learning
61
+ ├── recorder / replay
62
+ └── evaluation / automated QA
63
+ ```
64
+
65
+ Game adapters never import PPO, IMPALA, BC, or TorchRL. Learning code never
66
+ needs to know whether the game is Unity, Unreal, Source, native, or a test
67
+ simulator. The standardized boundary is the data and lifecycle contract, not a
68
+ single implementation language or transport.
69
+
70
+ ## Implemented in v0.1
71
+
72
+ - Recursive tensor-tree specs for continuous, discrete, multi-discrete, binary,
73
+ hybrid, parameterized, and hierarchical data.
74
+ - A `GameEnvironment` port with reset, step, close, action masks, semantic
75
+ events, terminated/truncated signals, episode IDs, and monotonic step IDs.
76
+ - A fail-closed `ContractEnvironment` wrapper that validates every boundary.
77
+ - Fixed-length actor `Unroll` collection suitable for custom PPO and IMPALA.
78
+ - Versioned `glr.transition.v1` JSONL records for BC, replay, and offline data.
79
+ - A packaged `glr.v1` Protobuf service with unary and bidirectional streaming
80
+ interaction contracts.
81
+ - An optional TorchRL `EnvBase` adapter tested against TorchRL 0.13.
82
+
83
+ Game-specific runtime adapters, generated C#/C++/Rust protocol SDKs,
84
+ distributed actor transport, and learner implementations are roadmap items—not
85
+ features claimed by this initial release.
86
+
87
+ ## Install
88
+
89
+ Until PyPI trusted publishing is enabled, pin a GitHub release tag:
90
+
91
+ ```powershell
92
+ uv add "game-learning-runtime @ git+https://github.com/loonghao/GameLearningRuntime@v0.1.0"
93
+ ```
94
+
95
+ Add the TorchRL integration only where training requires it:
96
+
97
+ ```powershell
98
+ uv add "game-learning-runtime[torchrl] @ git+https://github.com/loonghao/GameLearningRuntime@v0.1.0"
99
+ ```
100
+
101
+ ## Minimal environment
102
+
103
+ ```python
104
+ import numpy as np
105
+
106
+ from game_learning_runtime import ContractEnvironment, SyncCollector
107
+ from game_learning_runtime.examples import CounterEnvironment, always_increment
108
+
109
+ environment = ContractEnvironment(CounterEnvironment(target=3))
110
+ collector = SyncCollector(environment, actor_id="local-actor")
111
+ unroll = collector.collect(always_increment, steps=16, policy_version=0)
112
+
113
+ print(len(unroll.transitions), unroll.total_reward)
114
+ ```
115
+
116
+ Run the complete example from a clone:
117
+
118
+ ```powershell
119
+ uv sync --frozen
120
+ uv run python -c "from game_learning_runtime import *; from game_learning_runtime.examples import *; print(SyncCollector(ContractEnvironment(make_environment())).collect(always_increment, steps=4).total_reward)"
121
+ ```
122
+
123
+ For TorchRL:
124
+
125
+ ```python
126
+ from game_learning_runtime.examples import CounterEnvironment
127
+ from game_learning_runtime.integrations.torchrl import TorchRLEnvironment
128
+
129
+ env = TorchRLEnvironment(CounterEnvironment())
130
+ rollout = env.rollout(max_steps=32)
131
+ ```
132
+
133
+ ## Reuse the CI workflow
134
+
135
+ Any uv-managed Python repository can call the public reusable workflow:
136
+
137
+ ```yaml
138
+ jobs:
139
+ quality:
140
+ uses: loonghao/GameLearningRuntime/.github/workflows/reusable-python-ci.yml@v0.1.0
141
+ with:
142
+ python-versions: '["3.10", "3.12"]'
143
+ sync-args: "--frozen --all-groups"
144
+ lint-command: "uv run ruff check . && uv run mypy"
145
+ test-command: "uv run pytest"
146
+ ```
147
+
148
+ Pin a release tag or commit SHA in production repositories. The workflow never
149
+ receives deployment secrets and only checks out/tests the calling repository.
150
+
151
+ ## Documentation
152
+
153
+ - [Getting started](docs/guides/getting-started.md)
154
+ - [Architecture](docs/architecture/overview.md)
155
+ - [Protocol and data flow](docs/architecture/data-flow.md)
156
+ - [Local development](docs/runbooks/local-development.md)
157
+ - [Release runbook](docs/runbooks/release.md)
158
+ - [Roadmap](docs/planning/roadmap.md)
159
+ - [Architecture decisions](docs/decisions/README.md)
160
+
161
+ ## Contributing and security
162
+
163
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the development contract and
164
+ [SECURITY.md](SECURITY.md) for private vulnerability reporting. GLR is licensed
165
+ under the [MIT License](LICENSE).
@@ -0,0 +1,18 @@
1
+ game_learning_runtime/__init__.py,sha256=yV_oNHz0vzMaRRgfq-040jTaqMmkQJmLe1ZDlCabZFQ,1380
2
+ game_learning_runtime/collector.py,sha256=BSK2gkYO-Zr0Y1OQp1s1aNh07faQt2CcaUV26JxShsI,2911
3
+ game_learning_runtime/contracts.py,sha256=UpOoU3TreFn7CFR-J3w1VQCpI_cz4pG4a_fAY3rrF00,5412
4
+ game_learning_runtime/environment.py,sha256=QOgt_txru1uG5ODPo7ybDjmnQKlrsNwRTQuGEVn061k,4491
5
+ game_learning_runtime/errors.py,sha256=jP1rHrllJba2Wy5Ya1bb1SXID9ho93guYTQLL_YV83Y,377
6
+ game_learning_runtime/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
7
+ game_learning_runtime/serialization.py,sha256=DfefIIna9CMtCPLQLb7qFsDYkpZ5QF6Gcn-bQ3dqffw,6225
8
+ game_learning_runtime/specs.py,sha256=3SDFf0a7YqqjGw1dho0nXD2nUnZ03KjnluEvsxpC2fU,7263
9
+ game_learning_runtime/examples/__init__.py,sha256=znOFyaBrWhhqxu1H7wuqbddf6rhWSuwXhbbutGC0pNI,239
10
+ game_learning_runtime/examples/counter.py,sha256=dx9vUf0XcBFiKKg9Qq-YwWhGOFX35hBjBg9SA4D0zg4,3297
11
+ game_learning_runtime/integrations/__init__.py,sha256=kM3rSYjiDEZPoNjxe77V8qNbpcGLdBUDKWK8bw04tW0,48
12
+ game_learning_runtime/integrations/torchrl.py,sha256=xfmSew2Qn1FoBs6sTooGFbJYSymOHXZXD-gBsweqk94,6211
13
+ game_learning_runtime/protocol/__init__.py,sha256=6Hxr7MS2dzPkEUaYLLMCEDdnghlwpfajhbjhu1nZL2Q,479
14
+ game_learning_runtime/protocol/glr/v1/runtime.proto,sha256=gDt2A2ho9-Wc207SW3O2VJfVTkebz72lQ94btu_YfYE,2027
15
+ game_learning_runtime-0.1.0.dist-info/METADATA,sha256=ooMXrkIjkK7HztV-KF1YpXXOV9qdtB9ggFiS0DCusEI,6350
16
+ game_learning_runtime-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
17
+ game_learning_runtime-0.1.0.dist-info/licenses/LICENSE,sha256=tI9ZnJarjIkjC-Mje0HidFJgMMAhrK9qV-JdFSshBZw,1066
18
+ game_learning_runtime-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 loonghao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+