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.
- game_learning_runtime/__init__.py +45 -0
- game_learning_runtime/collector.py +82 -0
- game_learning_runtime/contracts.py +146 -0
- game_learning_runtime/environment.py +118 -0
- game_learning_runtime/errors.py +13 -0
- game_learning_runtime/examples/__init__.py +9 -0
- game_learning_runtime/examples/counter.py +94 -0
- game_learning_runtime/integrations/__init__.py +1 -0
- game_learning_runtime/integrations/torchrl.py +153 -0
- game_learning_runtime/protocol/__init__.py +15 -0
- game_learning_runtime/protocol/glr/v1/runtime.proto +93 -0
- game_learning_runtime/py.typed +1 -0
- game_learning_runtime/serialization.py +171 -0
- game_learning_runtime/specs.py +182 -0
- game_learning_runtime-0.1.0.dist-info/METADATA +165 -0
- game_learning_runtime-0.1.0.dist-info/RECORD +18 -0
- game_learning_runtime-0.1.0.dist-info/WHEEL +4 -0
- game_learning_runtime-0.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -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,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
|
+
[](https://github.com/loonghao/GameLearningRuntime/actions/workflows/ci.yml)
|
|
32
|
+
[](LICENSE)
|
|
33
|
+
[](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,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
|
+
|