genesis-forge-runtime 1.0.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.
- genesis_forge_runtime/__init__.py +100 -0
- genesis_forge_runtime/action_schema.py +155 -0
- genesis_forge_runtime/actions.py +246 -0
- genesis_forge_runtime/archive.py +122 -0
- genesis_forge_runtime/bundle.py +220 -0
- genesis_forge_runtime/constants.py +28 -0
- genesis_forge_runtime/errors.py +32 -0
- genesis_forge_runtime/manifest.py +200 -0
- genesis_forge_runtime/observation_schema.py +131 -0
- genesis_forge_runtime/observations.py +187 -0
- genesis_forge_runtime/processors.py +234 -0
- genesis_forge_runtime/serialization.py +26 -0
- genesis_forge_runtime-1.0.0.dist-info/METADATA +66 -0
- genesis_forge_runtime-1.0.0.dist-info/RECORD +16 -0
- genesis_forge_runtime-1.0.0.dist-info/WHEEL +4 -0
- genesis_forge_runtime-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Reading and writing a bundle directory.
|
|
2
|
+
|
|
3
|
+
A bundle is what export writes and the robot reads::
|
|
4
|
+
|
|
5
|
+
go2_walk/
|
|
6
|
+
manifest.json # the deployment contract, human readable
|
|
7
|
+
golden.npz # recorded input/output pairs for the on-robot smoke test
|
|
8
|
+
policy.onnx # optional: the exported policy
|
|
9
|
+
|
|
10
|
+
Nothing in this package imports torch or genesis -- that is the whole point of it,
|
|
11
|
+
and ``test_bundle.py`` asserts it in a clean subprocess.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import tempfile
|
|
17
|
+
import zipfile
|
|
18
|
+
from collections.abc import Iterator
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
from .actions import ActionProcessor
|
|
27
|
+
from .archive import ensure_extracted, is_archive
|
|
28
|
+
from .constants import GOLDEN_FILENAME, MANIFEST_FILENAME, POLICY_DIRNAME
|
|
29
|
+
from .errors import MalformedBundleError
|
|
30
|
+
from .manifest import Manifest
|
|
31
|
+
from .observations import ObservationAssembler
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Bundle:
|
|
36
|
+
"""A deployment bundle: the manifest, and wherever its files are.
|
|
37
|
+
|
|
38
|
+
``path`` is a directory when the bundle is unpacked, and an archive file when
|
|
39
|
+
it is not -- which is what :func:`~genesis_forge.deployment.export` hands back
|
|
40
|
+
after writing one. The manifest and the golden samples are held in memory
|
|
41
|
+
either way, so describing a bundle never unpacks it.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
manifest: Manifest
|
|
45
|
+
path: Path
|
|
46
|
+
golden: dict[str, np.ndarray] | None = None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def is_archive(self) -> bool:
|
|
50
|
+
"""True when the files are still packed into a single file."""
|
|
51
|
+
return not self.path.is_dir()
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def policy_files(self) -> tuple[str, ...]:
|
|
55
|
+
"""Every policy file in the bundle, in the order it was exported."""
|
|
56
|
+
return self.manifest.policy
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def policy_dir(self) -> Path | None:
|
|
60
|
+
"""Where the policy files sit, relative to the bundle's own root.
|
|
61
|
+
|
|
62
|
+
Join it onto the directory :meth:`unpacked` yields, which works whether
|
|
63
|
+
the bundle arrived as a directory or an archive.
|
|
64
|
+
"""
|
|
65
|
+
if not self.policy_files:
|
|
66
|
+
return None
|
|
67
|
+
return Path(POLICY_DIRNAME)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def policy_path(self) -> Path | None:
|
|
71
|
+
"""The first policy file, relative to the bundle's own root.
|
|
72
|
+
|
|
73
|
+
A convenience for the common single-file case; :attr:`policy_files` has
|
|
74
|
+
them all.
|
|
75
|
+
|
|
76
|
+
Example::
|
|
77
|
+
|
|
78
|
+
with bundle.unpacked() as directory:
|
|
79
|
+
session = onnxruntime.InferenceSession(
|
|
80
|
+
str(directory / bundle.policy_path)
|
|
81
|
+
)
|
|
82
|
+
"""
|
|
83
|
+
if not self.policy_files:
|
|
84
|
+
return None
|
|
85
|
+
return Path(POLICY_DIRNAME) / self.policy_files[0]
|
|
86
|
+
|
|
87
|
+
@contextmanager
|
|
88
|
+
def unpacked(self) -> Iterator[Path]:
|
|
89
|
+
"""Yield a directory holding this bundle's files.
|
|
90
|
+
|
|
91
|
+
Already-unpacked bundles yield their own directory and nothing is copied.
|
|
92
|
+
An archive is unpacked to a temporary directory that is removed on the way
|
|
93
|
+
out, which is what you want on a training machine -- checking what you just
|
|
94
|
+
exported should not leave anything behind.
|
|
95
|
+
"""
|
|
96
|
+
if not self.is_archive:
|
|
97
|
+
yield self.path
|
|
98
|
+
return
|
|
99
|
+
with tempfile.TemporaryDirectory(prefix="genesis-forge-bundle-") as scratch:
|
|
100
|
+
with zipfile.ZipFile(self.path) as archive:
|
|
101
|
+
archive.extractall(scratch)
|
|
102
|
+
yield Path(scratch)
|
|
103
|
+
|
|
104
|
+
def create_observation_assembler(self, **kwargs: Any) -> ObservationAssembler:
|
|
105
|
+
"""Build a new :class:`ObservationAssembler` for this bundle.
|
|
106
|
+
|
|
107
|
+
Each call returns a fresh assembler with its own zero-filled history, so
|
|
108
|
+
create one and keep it for the life of the control loop -- a second one
|
|
109
|
+
starts with no history at all.
|
|
110
|
+
"""
|
|
111
|
+
return ObservationAssembler(self.manifest.observations, **kwargs)
|
|
112
|
+
|
|
113
|
+
def create_action_processor(self, **kwargs: Any) -> ActionProcessor:
|
|
114
|
+
"""Build a new :class:`ActionProcessor` for this bundle.
|
|
115
|
+
|
|
116
|
+
Each call returns a fresh processor with its own remembered actions, so
|
|
117
|
+
create one and keep it for the life of the control loop.
|
|
118
|
+
"""
|
|
119
|
+
return ActionProcessor(self.manifest.actions, **kwargs)
|
|
120
|
+
|
|
121
|
+
def describe(self) -> str:
|
|
122
|
+
"""Human-readable summary of what to wire up. Print this first."""
|
|
123
|
+
layout = self.manifest.observations
|
|
124
|
+
lines = [
|
|
125
|
+
f"Bundle: {self.path}",
|
|
126
|
+
f" control rate: {self.manifest.control_hz:.1f} Hz (dt={self.manifest.dt})",
|
|
127
|
+
(
|
|
128
|
+
f" observation vector: {layout.total_size} values "
|
|
129
|
+
f"({layout.single_size} per tick x {layout.history_length} history)"
|
|
130
|
+
),
|
|
131
|
+
" values you supply each tick:",
|
|
132
|
+
]
|
|
133
|
+
lines.extend(f" - {entry.describe()}" for entry in layout.entries)
|
|
134
|
+
joints = len(self.manifest.joint_names)
|
|
135
|
+
if joints == self.manifest.num_actions:
|
|
136
|
+
lines.append(f" joint targets produced ({joints}):")
|
|
137
|
+
else:
|
|
138
|
+
lines.append(
|
|
139
|
+
f" joint targets produced ({joints}, from "
|
|
140
|
+
f"{self.manifest.num_actions} policy outputs):"
|
|
141
|
+
)
|
|
142
|
+
for spec in sorted(self.manifest.actions, key=lambda item: item.slice_start):
|
|
143
|
+
joints = ", ".join(spec.joint_names)
|
|
144
|
+
lines.append(f" - [{spec.deploy_type}] {joints}")
|
|
145
|
+
if self.policy_files:
|
|
146
|
+
files = ", ".join(f"{POLICY_DIRNAME}/{name}" for name in self.policy_files)
|
|
147
|
+
lines.append(f" policy: {files}")
|
|
148
|
+
return "\n".join(lines)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def load_manifest(path: str | Path) -> Manifest:
|
|
152
|
+
"""Read and validate just the manifest, without touching the rest of the bundle."""
|
|
153
|
+
manifest_path = Path(path)
|
|
154
|
+
if manifest_path.is_dir():
|
|
155
|
+
manifest_path = manifest_path / MANIFEST_FILENAME
|
|
156
|
+
if not manifest_path.is_file():
|
|
157
|
+
raise MalformedBundleError(f"No manifest found at '{manifest_path}'.")
|
|
158
|
+
return Manifest.from_json(manifest_path.read_text())
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def load_bundle(path: str | Path, *, load_golden: bool = True) -> Bundle:
|
|
162
|
+
"""Load a deployment bundle.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
path: A bundle directory, or an archive holding one. An archive is
|
|
166
|
+
unpacked beside itself into ``.<name>/`` and reused on later loads,
|
|
167
|
+
so the files stay in one predictable place for as long as anything
|
|
168
|
+
needs them. Replacing the archive unpacks it again.
|
|
169
|
+
load_golden: Read ``golden.npz`` when present. Set False to skip the
|
|
170
|
+
recorded smoke-test samples.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
The loaded :class:`Bundle`. Its :attr:`~Bundle.path` is the directory the
|
|
174
|
+
files were read from, which for an archive is where they were unpacked.
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
SchemaVersionError: The bundle was written by an incompatible version.
|
|
178
|
+
MalformedBundleError: The bundle is missing something required, or an
|
|
179
|
+
archive could not be unpacked.
|
|
180
|
+
"""
|
|
181
|
+
bundle_path = Path(path)
|
|
182
|
+
if is_archive(bundle_path):
|
|
183
|
+
bundle_path = ensure_extracted(bundle_path)
|
|
184
|
+
if not bundle_path.is_dir():
|
|
185
|
+
raise MalformedBundleError(
|
|
186
|
+
f"'{bundle_path}' is not a bundle directory or archive. Expected a "
|
|
187
|
+
f"folder containing {MANIFEST_FILENAME}, or a single-file bundle."
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
manifest = load_manifest(bundle_path)
|
|
191
|
+
|
|
192
|
+
golden: dict[str, np.ndarray] | None = None
|
|
193
|
+
golden_path = bundle_path / GOLDEN_FILENAME
|
|
194
|
+
if load_golden and golden_path.is_file():
|
|
195
|
+
with np.load(golden_path, allow_pickle=False) as archive:
|
|
196
|
+
golden = {key: archive[key] for key in archive.files}
|
|
197
|
+
|
|
198
|
+
for name in manifest.policy:
|
|
199
|
+
if not (bundle_path / POLICY_DIRNAME / name).is_file():
|
|
200
|
+
raise MalformedBundleError(
|
|
201
|
+
f"Manifest lists policy file '{name}', but it is missing from "
|
|
202
|
+
f"'{bundle_path}'."
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
return Bundle(manifest=manifest, path=bundle_path, golden=golden)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def save_bundle(
|
|
209
|
+
bundle_path: str | Path,
|
|
210
|
+
manifest: Manifest,
|
|
211
|
+
*,
|
|
212
|
+
golden: dict[str, np.ndarray] | None = None,
|
|
213
|
+
) -> Path:
|
|
214
|
+
"""Write a manifest (and optional golden samples) into a bundle directory."""
|
|
215
|
+
path = Path(bundle_path)
|
|
216
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
(path / MANIFEST_FILENAME).write_text(manifest.to_json() + "\n")
|
|
218
|
+
if golden:
|
|
219
|
+
np.savez(path / GOLDEN_FILENAME, **golden)
|
|
220
|
+
return path
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The deployment bundle's vocabulary.
|
|
2
|
+
|
|
3
|
+
These strings and numbers are the manifest's contract. They are deliberately
|
|
4
|
+
separate from the code that uses them: a bundle may be read by tooling that never
|
|
5
|
+
imports the rest of this package, and the values must not drift.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
#: Schema version this runtime writes and understands.
|
|
9
|
+
SCHEMA_VERSION = 1
|
|
10
|
+
|
|
11
|
+
#: Oldest bundle schema this runtime can still read.
|
|
12
|
+
MIN_SUPPORTED_SCHEMA_VERSION = 1
|
|
13
|
+
|
|
14
|
+
MANIFEST_FILENAME = "manifest.json"
|
|
15
|
+
GOLDEN_FILENAME = "golden.npz"
|
|
16
|
+
|
|
17
|
+
#: Suffix for a bundle written as one file: a zip holding what the directory
|
|
18
|
+
#: would. Bundles are recognised by content, so a renamed one still loads.
|
|
19
|
+
ARCHIVE_SUFFIX = ".gfb"
|
|
20
|
+
|
|
21
|
+
#: Written inside an extracted archive, holding a fingerprint of the archive it
|
|
22
|
+
#: came from. A later load re-extracts unless the fingerprint still matches, so
|
|
23
|
+
#: replacing the archive can never leave a robot running the previous bundle.
|
|
24
|
+
EXTRACT_MARKER = ".source"
|
|
25
|
+
|
|
26
|
+
#: Directory inside the bundle holding the exported policy, under the filenames
|
|
27
|
+
#: the exporter was given. The runtime never opens them.
|
|
28
|
+
POLICY_DIRNAME = "policy"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Every error this package raises.
|
|
2
|
+
|
|
3
|
+
Collected in one module so a robot-side caller can see the whole hierarchy at a
|
|
4
|
+
glance and catch at whatever granularity it wants::
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
bundle = load_bundle(path)
|
|
8
|
+
except SchemaVersionError:
|
|
9
|
+
... # re-export from a matching Genesis Forge
|
|
10
|
+
except BundleError:
|
|
11
|
+
... # anything else wrong with the bundle
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BundleError(Exception):
|
|
16
|
+
"""Base class for every error raised while reading a bundle."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SchemaVersionError(BundleError):
|
|
20
|
+
"""The bundle was written by an incompatible version of Genesis Forge."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MalformedBundleError(BundleError):
|
|
24
|
+
"""The bundle is missing a required section or holds an unusable value."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ObservationError(Exception):
|
|
28
|
+
"""A value handed to the assembler was missing, mis-sized, or unexpected."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ActionError(Exception):
|
|
32
|
+
"""A processor could not be resolved, or was handed unusable policy output."""
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""The deployment contract as a whole.
|
|
2
|
+
|
|
3
|
+
Ties the observation and action halves together with the control rate, the policy
|
|
4
|
+
description, and provenance, and validates that the pieces are mutually consistent
|
|
5
|
+
before any of it reaches a robot.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .action_schema import ActionManagerSpec, ActuatorSpec
|
|
15
|
+
from .constants import MIN_SUPPORTED_SCHEMA_VERSION, SCHEMA_VERSION
|
|
16
|
+
from .errors import MalformedBundleError, SchemaVersionError
|
|
17
|
+
from .observation_schema import ObservationLayout
|
|
18
|
+
from .serialization import require
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Provenance:
|
|
23
|
+
"""Where this bundle came from -- the first thing to check when debugging.
|
|
24
|
+
|
|
25
|
+
The top-level fields are measured by the exporter itself. Anything the
|
|
26
|
+
developer chose to record sits under :attr:`additional`, so a reader can tell
|
|
27
|
+
a fact the tooling observed from a claim a human typed.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
exported_at: str | None = None
|
|
31
|
+
genesis_forge_version: str | None = None
|
|
32
|
+
torch_version: str | None = None
|
|
33
|
+
additional: dict[str, Any] = field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
# Ensure the additional dict is JSON-serializable
|
|
37
|
+
json.dumps(self.additional)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_dict(cls, data: dict[str, Any]) -> Provenance:
|
|
41
|
+
additional = data.get("additional") or {}
|
|
42
|
+
if not isinstance(additional, dict):
|
|
43
|
+
raise MalformedBundleError(
|
|
44
|
+
f"'provenance.additional' must be an object, got "
|
|
45
|
+
f"{type(additional).__name__}."
|
|
46
|
+
)
|
|
47
|
+
return cls(
|
|
48
|
+
exported_at=data.get("exported_at"),
|
|
49
|
+
genesis_forge_version=data.get("genesis_forge_version"),
|
|
50
|
+
torch_version=data.get("torch_version"),
|
|
51
|
+
additional=dict(additional),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, Any]:
|
|
55
|
+
data = {
|
|
56
|
+
key: value
|
|
57
|
+
for key, value in {
|
|
58
|
+
"exported_at": self.exported_at,
|
|
59
|
+
"genesis_forge_version": self.genesis_forge_version,
|
|
60
|
+
"torch_version": self.torch_version,
|
|
61
|
+
}.items()
|
|
62
|
+
if value is not None
|
|
63
|
+
}
|
|
64
|
+
if self.additional:
|
|
65
|
+
data["additional"] = dict(self.additional)
|
|
66
|
+
return data
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class Manifest:
|
|
71
|
+
"""The full deployment contract, as read from ``manifest.json``."""
|
|
72
|
+
|
|
73
|
+
dt: float
|
|
74
|
+
observations: ObservationLayout
|
|
75
|
+
actions: tuple[ActionManagerSpec, ...]
|
|
76
|
+
actuators: tuple[ActuatorSpec, ...] = ()
|
|
77
|
+
#: The policy's files, in the order they were exported. Empty when the bundle
|
|
78
|
+
#: carries no policy. They live in the bundle's ``policy/`` directory.
|
|
79
|
+
policy: tuple[str, ...] = ()
|
|
80
|
+
provenance: Provenance = field(default_factory=Provenance)
|
|
81
|
+
schema_version: int = SCHEMA_VERSION
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def control_hz(self) -> float:
|
|
85
|
+
"""Rate the policy was trained at; run the robot loop at this rate."""
|
|
86
|
+
return 1.0 / self.dt
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def num_actions(self) -> int:
|
|
90
|
+
return max((spec.slice_end for spec in self.actions), default=0)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def joint_names(self) -> tuple[str, ...]:
|
|
94
|
+
"""Every controlled joint, in policy-output order."""
|
|
95
|
+
names: list[str] = []
|
|
96
|
+
for spec in sorted(self.actions, key=lambda item: item.slice_start):
|
|
97
|
+
names.extend(spec.joint_names)
|
|
98
|
+
return tuple(names)
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_dict(cls, data: dict[str, Any]) -> Manifest:
|
|
102
|
+
version = require(data, "schema_version", where="manifest")
|
|
103
|
+
_check_schema_version(version)
|
|
104
|
+
|
|
105
|
+
control = require(data, "control", where="manifest")
|
|
106
|
+
dt = float(require(control, "dt", where="control"))
|
|
107
|
+
if dt <= 0:
|
|
108
|
+
raise MalformedBundleError(f"'control.dt' must be positive, got {dt}.")
|
|
109
|
+
|
|
110
|
+
raw_actions = require(data, "actions", where="manifest")
|
|
111
|
+
actions = tuple(
|
|
112
|
+
ActionManagerSpec.from_dict(item, where="actions")
|
|
113
|
+
for item in require(raw_actions, "managers", where="actions")
|
|
114
|
+
)
|
|
115
|
+
_check_action_slices(actions)
|
|
116
|
+
|
|
117
|
+
return cls(
|
|
118
|
+
schema_version=int(version),
|
|
119
|
+
dt=dt,
|
|
120
|
+
observations=ObservationLayout.from_dict(
|
|
121
|
+
require(data, "observations", where="manifest")
|
|
122
|
+
),
|
|
123
|
+
actions=actions,
|
|
124
|
+
actuators=tuple(
|
|
125
|
+
ActuatorSpec.from_dict(item, where="actuators")
|
|
126
|
+
for item in data.get("actuators", [])
|
|
127
|
+
),
|
|
128
|
+
policy=tuple(data.get("policy", ())),
|
|
129
|
+
provenance=Provenance.from_dict(data.get("provenance", {})),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def to_dict(self) -> dict[str, Any]:
|
|
133
|
+
data: dict[str, Any] = {
|
|
134
|
+
"schema_version": self.schema_version,
|
|
135
|
+
"provenance": self.provenance.to_dict(),
|
|
136
|
+
"control": {"dt": self.dt, "control_hz": self.control_hz},
|
|
137
|
+
"observations": self.observations.to_dict(),
|
|
138
|
+
"actions": {
|
|
139
|
+
"total_size": self.num_actions,
|
|
140
|
+
"managers": [spec.to_dict() for spec in self.actions],
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
if self.actuators:
|
|
144
|
+
data["actuators"] = [spec.to_dict() for spec in self.actuators]
|
|
145
|
+
if self.policy:
|
|
146
|
+
data["policy"] = list(self.policy)
|
|
147
|
+
return data
|
|
148
|
+
|
|
149
|
+
def to_json(self, *, indent: int = 2) -> str:
|
|
150
|
+
return json.dumps(self.to_dict(), indent=indent, sort_keys=False)
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def from_json(cls, text: str) -> Manifest:
|
|
154
|
+
try:
|
|
155
|
+
data = json.loads(text)
|
|
156
|
+
except json.JSONDecodeError as error:
|
|
157
|
+
raise MalformedBundleError(
|
|
158
|
+
f"manifest.json is not valid JSON: {error}"
|
|
159
|
+
) from error
|
|
160
|
+
return cls.from_dict(data)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _check_schema_version(version: Any) -> None:
|
|
164
|
+
"""Fail loudly, and specifically, on a bundle this runtime cannot read."""
|
|
165
|
+
if not isinstance(version, int) or isinstance(version, bool):
|
|
166
|
+
raise MalformedBundleError(
|
|
167
|
+
f"'schema_version' must be an integer, got {version!r}."
|
|
168
|
+
)
|
|
169
|
+
if version > SCHEMA_VERSION:
|
|
170
|
+
raise SchemaVersionError(
|
|
171
|
+
f"This bundle uses schema version {version}, but this runtime only "
|
|
172
|
+
f"understands up to version {SCHEMA_VERSION}. Upgrade genesis-forge-runtime."
|
|
173
|
+
)
|
|
174
|
+
if version < MIN_SUPPORTED_SCHEMA_VERSION:
|
|
175
|
+
raise SchemaVersionError(
|
|
176
|
+
f"This bundle uses schema version {version}, which is older than the "
|
|
177
|
+
f"oldest version this runtime supports ({MIN_SUPPORTED_SCHEMA_VERSION}). "
|
|
178
|
+
f"Re-export it with a current version of Genesis Forge."
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _check_action_slices(actions: tuple[ActionManagerSpec, ...]) -> None:
|
|
183
|
+
"""Action manager slices must tile the policy output with no gaps or overlaps."""
|
|
184
|
+
if not actions:
|
|
185
|
+
return
|
|
186
|
+
ordered = sorted(actions, key=lambda spec: spec.slice_start)
|
|
187
|
+
cursor = 0
|
|
188
|
+
for spec in ordered:
|
|
189
|
+
if spec.slice_start != cursor:
|
|
190
|
+
raise MalformedBundleError(
|
|
191
|
+
f"Action manager '{spec.name}' starts at index {spec.slice_start} but the "
|
|
192
|
+
f"previous manager ended at {cursor}; slices must tile the policy output "
|
|
193
|
+
f"without gaps or overlaps."
|
|
194
|
+
)
|
|
195
|
+
if spec.slice_end <= spec.slice_start:
|
|
196
|
+
raise MalformedBundleError(
|
|
197
|
+
f"Action manager '{spec.name}' has an empty slice "
|
|
198
|
+
f"[{spec.slice_start}, {spec.slice_end}]."
|
|
199
|
+
)
|
|
200
|
+
cursor = spec.slice_end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""How the policy's observation vector is laid out.
|
|
2
|
+
|
|
3
|
+
The half of the manifest that :mod:`genesis_forge_runtime.observations` consumes:
|
|
4
|
+
what each slot holds, how wide it is, what it is clipped to, and what it is scaled by.
|
|
5
|
+
|
|
6
|
+
Every slot is an input you supply each tick. Most come from sensors; some echo the
|
|
7
|
+
policy's own previous output, which you read off the processor. The bundle does not
|
|
8
|
+
distinguish them -- from the assembler's side they are passed in the same way, and
|
|
9
|
+
the deployment guide covers which of your entries is which.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .errors import MalformedBundleError
|
|
18
|
+
from .serialization import require
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ObservationEntry:
|
|
23
|
+
"""One named slot in the policy's observation vector."""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
size: int
|
|
27
|
+
scale: float = 1.0
|
|
28
|
+
clip: tuple[float, float] | None = None
|
|
29
|
+
description: str | None = None
|
|
30
|
+
units: str | None = None
|
|
31
|
+
|
|
32
|
+
def describe(self) -> str:
|
|
33
|
+
"""One-line summary of what to supply for this entry."""
|
|
34
|
+
parts = [f"{self.name} ({self.size} value{'s' if self.size != 1 else ''})"]
|
|
35
|
+
if self.units:
|
|
36
|
+
parts.append(f"in {self.units}")
|
|
37
|
+
# No scale or clip here: this listing is what the caller supplies, and the
|
|
38
|
+
# caller supplies raw readings. The assembler clips and scales them afterwards.
|
|
39
|
+
summary = ", ".join(parts)
|
|
40
|
+
if self.description:
|
|
41
|
+
summary = f"{summary} -- {self.description}"
|
|
42
|
+
return summary
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_dict(cls, data: dict[str, Any], *, where: str) -> ObservationEntry:
|
|
46
|
+
name = require(data, "name", where=where)
|
|
47
|
+
clip = data.get("clip")
|
|
48
|
+
entry = cls(
|
|
49
|
+
name=name,
|
|
50
|
+
size=int(require(data, "size", where=f"{where}.{name}")),
|
|
51
|
+
scale=float(data.get("scale", 1.0)),
|
|
52
|
+
clip=None if clip is None else (float(clip[0]), float(clip[1])),
|
|
53
|
+
description=data.get("description"),
|
|
54
|
+
units=data.get("units"),
|
|
55
|
+
)
|
|
56
|
+
return entry
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> dict[str, Any]:
|
|
59
|
+
data: dict[str, Any] = {
|
|
60
|
+
"name": self.name,
|
|
61
|
+
"size": self.size,
|
|
62
|
+
"scale": self.scale,
|
|
63
|
+
}
|
|
64
|
+
if self.clip is not None:
|
|
65
|
+
data["clip"] = [float(self.clip[0]), float(self.clip[1])]
|
|
66
|
+
if self.description is not None:
|
|
67
|
+
data["description"] = self.description
|
|
68
|
+
if self.units is not None:
|
|
69
|
+
data["units"] = self.units
|
|
70
|
+
return data
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class ObservationLayout:
|
|
75
|
+
"""Ordering, scaling, and history configuration of the policy's input vector."""
|
|
76
|
+
|
|
77
|
+
entries: tuple[ObservationEntry, ...]
|
|
78
|
+
history_length: int = 1
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def single_size(self) -> int:
|
|
82
|
+
"""Width of one observation tick, before history stacking."""
|
|
83
|
+
return sum(entry.size for entry in self.entries)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def total_size(self) -> int:
|
|
87
|
+
"""Width of the full vector handed to the policy."""
|
|
88
|
+
return self.single_size * self.history_length
|
|
89
|
+
|
|
90
|
+
def entry(self, name: str) -> ObservationEntry:
|
|
91
|
+
for entry in self.entries:
|
|
92
|
+
if entry.name == name:
|
|
93
|
+
return entry
|
|
94
|
+
known = ", ".join(item.name for item in self.entries)
|
|
95
|
+
raise KeyError(f"No observation entry named '{name}'. Known entries: {known}.")
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_dict(cls, data: dict[str, Any]) -> ObservationLayout:
|
|
99
|
+
where = "observations"
|
|
100
|
+
raw_entries = require(data, "entries", where=where)
|
|
101
|
+
if not isinstance(raw_entries, list) or not raw_entries:
|
|
102
|
+
raise MalformedBundleError(
|
|
103
|
+
"'observations.entries' must be a non-empty list of observation entries."
|
|
104
|
+
)
|
|
105
|
+
entries = tuple(
|
|
106
|
+
ObservationEntry.from_dict(item, where=f"{where}.entries")
|
|
107
|
+
for item in raw_entries
|
|
108
|
+
)
|
|
109
|
+
names = [entry.name for entry in entries]
|
|
110
|
+
duplicates = {name for name in names if names.count(name) > 1}
|
|
111
|
+
if duplicates:
|
|
112
|
+
raise MalformedBundleError(
|
|
113
|
+
f"Duplicate observation entry names: {', '.join(sorted(duplicates))}."
|
|
114
|
+
)
|
|
115
|
+
history_length = int(data.get("history_length", 1))
|
|
116
|
+
if history_length < 1:
|
|
117
|
+
raise MalformedBundleError(
|
|
118
|
+
f"'observations.history_length' must be at least 1, got {history_length}."
|
|
119
|
+
)
|
|
120
|
+
return cls(
|
|
121
|
+
entries=entries,
|
|
122
|
+
history_length=history_length,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def to_dict(self) -> dict[str, Any]:
|
|
126
|
+
return {
|
|
127
|
+
"history_length": self.history_length,
|
|
128
|
+
"single_size": self.single_size,
|
|
129
|
+
"total_size": self.total_size,
|
|
130
|
+
"entries": [entry.to_dict() for entry in self.entries],
|
|
131
|
+
}
|