xlab-api 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.
xlab_api/spec/mdp.py ADDED
@@ -0,0 +1,384 @@
1
+ """The MDP of a task, declared once for every engine.
2
+
3
+ A term is stated in one of two ways. The choice follows the implementation's
4
+ actual portability; event terms in particular may use either form:
5
+
6
+ **Portable families carry a function.** Observations, rewards, terminations and commands are
7
+ written as ``ObsTermSpec(mdp.base_ang_vel, ...)`` -- a direct reference to a function in
8
+ :mod:`xlab.tasks.<task>.mdp` that runs unmodified under either engine's native manager. This is not a
9
+ convenience. It is the reason migration is cheap: an Isaac Lab task's term definitions transfer
10
+ almost verbatim, with the import redirected and the class renamed, because the function itself did
11
+ not have to change.
12
+
13
+ **Native terms carry a semantic name.** Actions and events that change native
14
+ physics state are stated as ``EventTermSpec(kind="randomize_friction")`` and
15
+ resolved through each engine's term registry. Isaac Lab randomises friction
16
+ per shape across a bucket pool, while MJLab changes MuJoCo geom fields; choosing
17
+ one native function as the shared implementation would silently change the
18
+ other engine's behavior. Named intent keeps those translations explicit.
19
+
20
+ An event that only uses the shared environment surface should instead carry its
21
+ task-owned function, just like an observation or reward. Adding such an event
22
+ does not require an engine registration. A new ``kind=`` is reserved for an
23
+ operation that genuinely needs a new native adapter or capability.
24
+
25
+ The split is visible in :class:`TermSpec`: exactly one of ``func`` and ``kind`` is set, and which
26
+ one is set determines whether the compiler needs a per-engine mapping at all.
27
+
28
+ Engine-specific values reach a term through ``engine_params``, keyed by engine name. That is data,
29
+ not a branch -- ``spec/`` may not contain ``if engine == ...`` anywhere, since a conditional here
30
+ would mean the declaration is no longer engine-agnostic and every future engine would have to edit
31
+ task files to be supported.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import math
37
+ from collections.abc import Callable, Iterable, Mapping
38
+ from dataclasses import dataclass, field, replace
39
+ from typing import Any, Literal
40
+
41
+ from .capability import Requirement
42
+ from .entity import EntityRef
43
+
44
+ __all__ = [
45
+ "ActionTermSpec",
46
+ "CommandTermSpec",
47
+ "CurriculumTermSpec",
48
+ "DoneTermSpec",
49
+ "EventTermSpec",
50
+ "MdpSpec",
51
+ "NoiseSpec",
52
+ "ObsGroupSpec",
53
+ "ObsTermSpec",
54
+ "RewardTermSpec",
55
+ "TermSpec",
56
+ ]
57
+
58
+
59
+ def walk_parameter_values(values: Iterable[Any]) -> Iterable[Any]:
60
+ """Yield parameter values recursively through mappings and sequences."""
61
+ for value in values:
62
+ yield value
63
+ if isinstance(value, Mapping):
64
+ yield from walk_parameter_values(value.values())
65
+ elif isinstance(value, (tuple, list, set, frozenset)):
66
+ yield from walk_parameter_values(value)
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class NoiseSpec:
71
+ """Observation noise, in the two shapes both engines' noise models already share.
72
+
73
+ Args:
74
+ kind: ``"uniform"`` over ``[lo, hi]``, or ``"gaussian"`` with mean ``lo`` and standard
75
+ deviation ``hi``. The reuse of the field names for the gaussian case is deliberate --
76
+ they are the two numbers each distribution takes, and naming them per-distribution
77
+ would need a class per distribution for no gain.
78
+ lo: Lower bound, or the mean.
79
+ hi: Upper bound, or the standard deviation.
80
+ operation: Whether the sample is added to the observation or scales it.
81
+ """
82
+
83
+ kind: Literal["uniform", "gaussian"]
84
+ lo: float
85
+ hi: float
86
+ operation: Literal["add", "scale", "abs"] = "add"
87
+
88
+ def __post_init__(self) -> None:
89
+ if self.kind == "uniform" and self.lo > self.hi:
90
+ raise ValueError(f"Uniform noise has lo={self.lo} above hi={self.hi}.")
91
+ if self.kind == "gaussian" and self.hi < 0.0:
92
+ raise ValueError(
93
+ f"Gaussian noise has a negative standard deviation: {self.hi}."
94
+ )
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class TermSpec:
99
+ """Base of every MDP term.
100
+
101
+ Args:
102
+ func: The task-owned portable implementation, run directly by either engine's native
103
+ manager.
104
+ kind: The semantic name for a native term, looked up in the running engine's registry.
105
+ Exactly one of ``func`` and ``kind`` is set.
106
+ params: Arguments passed to the term, identical on every engine.
107
+ target: The entity subset the term acts on, if any.
108
+ level: What the compiler does when this engine cannot provide the term.
109
+ engine_params: Per-engine parameter overrides, merged over ``params`` at compile time by
110
+ :meth:`resolved_params`. Data rather than a branch, so that adding an engine never
111
+ edits a task file.
112
+ """
113
+
114
+ func: Callable[..., Any] | None = None
115
+ kind: str | None = None
116
+ params: Mapping[str, Any] = field(default_factory=dict)
117
+ target: EntityRef | None = None
118
+ level: Requirement = Requirement.OPTIONAL
119
+ engine_params: Mapping[str, Mapping[str, Any]] = field(default_factory=dict)
120
+
121
+ def __post_init__(self) -> None:
122
+ if (self.func is None) == (self.kind is None):
123
+ stated = "both" if self.func is not None else "neither"
124
+ raise ValueError(
125
+ f"{type(self).__name__} must set exactly one of func / kind; {stated} was given. "
126
+ "Portable terms carry the task function; native terms carry a semantic name."
127
+ )
128
+ object.__setattr__(self, "params", dict(self.params))
129
+ object.__setattr__(
130
+ self, "engine_params", {k: dict(v) for k, v in self.engine_params.items()}
131
+ )
132
+
133
+ @property
134
+ def is_portable(self) -> bool:
135
+ """Whether this term runs the same implementation on every engine."""
136
+ return self.func is not None
137
+
138
+ def resolved_params(self, engine: str) -> dict[str, Any]:
139
+ """``params`` with this engine's overrides applied. The only place the two are combined.
140
+
141
+ Nested mappings merge rather than replace, so a shared ``velocity_ranges`` dict can keep
142
+ the nine common sub-terrains and put only the tenth name in ``engine_params``. A shallow
143
+ update would drop the shared keys on that engine and the command would raise at init.
144
+ """
145
+ merged = dict(self.params)
146
+ for key, value in self.engine_params.get(engine, {}).items():
147
+ existing = merged.get(key)
148
+ if isinstance(existing, Mapping) and isinstance(value, Mapping):
149
+ nested = dict(existing)
150
+ nested.update(value)
151
+ merged[key] = nested
152
+ else:
153
+ merged[key] = value
154
+ return merged
155
+
156
+ def engines_named(self) -> frozenset[str]:
157
+ """Engines this term mentions by name, so the compiler can reject typos in engine keys."""
158
+ return frozenset(self.engine_params)
159
+
160
+ def replace(self, **changes: Any) -> TermSpec:
161
+ """Return the same term with only the named configuration fields changed."""
162
+ return replace(self, **changes)
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class ObsTermSpec(TermSpec):
167
+ """One observation. REQUIRED by default: dropping it changes the policy's input width."""
168
+
169
+ noise: NoiseSpec | None = None
170
+ scale: float | None = None
171
+ clip: tuple[float, float] | None = None
172
+ history_length: int = 0
173
+ level: Requirement = Requirement.REQUIRED
174
+
175
+
176
+ @dataclass(frozen=True)
177
+ class RewardTermSpec(TermSpec):
178
+ """One reward. OPTIONAL by default, and therefore the term family most worth watching.
179
+
180
+ A skipped reward leaves a run that trains, converges and looks healthy while optimising a
181
+ different objective than the one written down. That is why every skip is recorded and printed
182
+ rather than warned about, and why a task that depends on a particular reward should raise its
183
+ level rather than trusting the report to be read.
184
+ """
185
+
186
+ weight: float = 0.0
187
+
188
+
189
+ @dataclass(frozen=True)
190
+ class DoneTermSpec(TermSpec):
191
+ """One termination. REQUIRED by default: dropping it changes the episode structure.
192
+
193
+ Args:
194
+ time_out: Whether this termination is a time limit rather than a failure. Both engines use
195
+ the distinction for bootstrapping the value function at the episode boundary, and
196
+ getting it wrong biases the value estimate rather than raising anything.
197
+ """
198
+
199
+ time_out: bool = False
200
+ level: Requirement = Requirement.REQUIRED
201
+
202
+
203
+ @dataclass(frozen=True)
204
+ class EventTermSpec(TermSpec):
205
+ """One event or domain randomisation.
206
+
207
+ Use ``func=`` when the event runs on the shared environment interface. Use
208
+ ``kind=`` only when each engine must lower the operation onto different
209
+ native physics or sensor state.
210
+
211
+ Args:
212
+ mode: When it fires. ``"startup"`` once at construction, ``"reset"`` per episode,
213
+ ``"interval"`` on a timer.
214
+ interval_range_s: Seconds between firings, for ``"interval"`` mode.
215
+ """
216
+
217
+ mode: Literal["startup", "reset", "interval"] = "reset"
218
+ interval_range_s: tuple[float, float] | None = None
219
+
220
+ def __post_init__(self) -> None:
221
+ super().__post_init__()
222
+ if self.mode not in {"startup", "reset", "interval"}:
223
+ raise ValueError(
224
+ f"Unknown event mode {self.mode!r}; expected startup, reset, or interval."
225
+ )
226
+ if (self.mode == "interval") != (self.interval_range_s is not None):
227
+ raise ValueError(
228
+ f"Event in {self.mode!r} mode with interval_range_s={self.interval_range_s}: an "
229
+ "interval range is required in interval mode and meaningless otherwise."
230
+ )
231
+ if self.interval_range_s is not None:
232
+ lo, hi = self.interval_range_s
233
+ if not math.isfinite(lo) or not math.isfinite(hi) or lo <= 0.0 or hi < lo:
234
+ raise ValueError(
235
+ f"Event interval_range_s must be finite with 0 < min <= max, got {self.interval_range_s}."
236
+ )
237
+
238
+
239
+ @dataclass(frozen=True)
240
+ class ActionTermSpec(TermSpec):
241
+ """One action term. REQUIRED by default: without it the policy cannot act.
242
+
243
+ Always per-engine. The two engines drive joints through different objects -- Isaac Lab's
244
+ articulation actuators against mjlab's MuJoCo actuators -- so this family is stated with
245
+ ``kind`` and resolved per engine even though the resulting behaviour is meant to match.
246
+ """
247
+
248
+ level: Requirement = Requirement.REQUIRED
249
+
250
+
251
+ @dataclass(frozen=True)
252
+ class CommandTermSpec(TermSpec):
253
+ """One command generator. REQUIRED by default: observations read its output.
254
+
255
+ A task-owned ``func`` is a class or factory called as ``func(env=env,
256
+ params=params)``. It exposes ``command``, ``metrics``,
257
+ ``_update_metrics()``, ``_resample_command(env_ids)`` and
258
+ ``_update_command()``; each engine's portable command wrapper supplies the
259
+ native manager base and timer. A new task command therefore does not need
260
+ an engine registration.
261
+ """
262
+
263
+ level: Requirement = Requirement.REQUIRED
264
+
265
+
266
+ @dataclass(frozen=True)
267
+ class CurriculumTermSpec(TermSpec):
268
+ """One curriculum term."""
269
+
270
+
271
+ @dataclass(frozen=True)
272
+ class ObsGroupSpec:
273
+ """One observation group, such as the policy's input or the critic's.
274
+
275
+ Args:
276
+ terms: The group's terms. **Insertion order is the concatenation order**, and therefore
277
+ part of the task's contract with any checkpoint trained from it -- reordering this
278
+ mapping silently permutes the policy's input vector.
279
+ enable_corruption: Whether the terms' noise is applied. Off for critic groups by
280
+ convention on both engines.
281
+ concatenate_terms: Whether the group is flattened into one tensor.
282
+ history_length: Group-level override. ``None`` (the default) is unset — each
283
+ term keeps its own history. An integer, including ``0``, overrides every
284
+ term. Both engines' managers test ``is not None``, so ``0`` cannot mean
285
+ "unspecified": that value is a real instruction to drop history.
286
+ """
287
+
288
+ terms: Mapping[str, ObsTermSpec]
289
+ enable_corruption: bool = True
290
+ concatenate_terms: bool = True
291
+ history_length: int | None = None
292
+
293
+ def __post_init__(self) -> None:
294
+ if not self.terms:
295
+ raise ValueError(
296
+ "An observation group with no terms produces an empty input tensor."
297
+ )
298
+ if self.history_length is not None and self.history_length < 0:
299
+ raise ValueError(
300
+ f"Observation group history_length={self.history_length} is negative."
301
+ )
302
+ object.__setattr__(self, "terms", dict(self.terms))
303
+
304
+
305
+ @dataclass(frozen=True)
306
+ class MdpSpec:
307
+ """Every term of a task's MDP, grouped by family.
308
+
309
+ Rewards are grouped rather than flat because both this project's ``MultiRewardManager`` and the
310
+ agent configs that consume it treat reward groups as separate signals. A task with one group
311
+ writes ``rewards={"rewards": {...}}``.
312
+ """
313
+
314
+ observations: Mapping[str, ObsGroupSpec] = field(default_factory=dict)
315
+ actions: Mapping[str, ActionTermSpec] = field(default_factory=dict)
316
+ rewards: Mapping[str, Mapping[str, RewardTermSpec]] = field(default_factory=dict)
317
+ terminations: Mapping[str, DoneTermSpec] = field(default_factory=dict)
318
+ events: Mapping[str, EventTermSpec] = field(default_factory=dict)
319
+ commands: Mapping[str, CommandTermSpec] = field(default_factory=dict)
320
+ curriculum: Mapping[str, CurriculumTermSpec] = field(default_factory=dict)
321
+
322
+ def __post_init__(self) -> None:
323
+ for name in (
324
+ "observations",
325
+ "actions",
326
+ "terminations",
327
+ "events",
328
+ "commands",
329
+ "curriculum",
330
+ ):
331
+ object.__setattr__(self, name, dict(getattr(self, name)))
332
+ object.__setattr__(
333
+ self,
334
+ "rewards",
335
+ {group: dict(terms) for group, terms in self.rewards.items()},
336
+ )
337
+
338
+ def terms(self) -> dict[str, TermSpec]:
339
+ """Every term keyed by ``family/name``, the key used throughout a compilation's report.
340
+
341
+ Reward keys carry their group -- ``reward/rewards/track_lin_vel_xy_exp`` -- so that two
342
+ groups may use the same term name without colliding in the report.
343
+ """
344
+ out: dict[str, TermSpec] = {}
345
+ for group, group_spec in self.observations.items():
346
+ for name, term in group_spec.terms.items():
347
+ out[f"observation/{group}/{name}"] = term
348
+ for group, group_terms in self.rewards.items():
349
+ for name, term in group_terms.items():
350
+ out[f"reward/{group}/{name}"] = term
351
+ for family, mapping in (
352
+ ("action", self.actions),
353
+ ("termination", self.terminations),
354
+ ("event", self.events),
355
+ ("command", self.commands),
356
+ ("curriculum", self.curriculum),
357
+ ):
358
+ for name, term in mapping.items():
359
+ out[f"{family}/{name}"] = term
360
+ return out
361
+
362
+ def entity_refs(self) -> tuple[EntityRef, ...]:
363
+ """Every entity reference the MDP uses, including those passed through ``params``.
364
+
365
+ The backend needs all of them to check its selector support up front, and they hide in two
366
+ places: the ``target`` field, and parameters like an ``asset_cfg`` that a portable term
367
+ takes as an argument.
368
+ """
369
+ found: list[EntityRef] = []
370
+ for term in self.terms().values():
371
+ if term.target is not None:
372
+ found.append(term.target)
373
+ found.extend(
374
+ v
375
+ for v in walk_parameter_values(term.params.values())
376
+ if isinstance(v, EntityRef)
377
+ )
378
+ for overrides in term.engine_params.values():
379
+ found.extend(
380
+ v
381
+ for v in walk_parameter_values(overrides.values())
382
+ if isinstance(v, EntityRef)
383
+ )
384
+ return tuple(found)