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.
@@ -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
+ }