Migrating from UniLab 1.4 to 1.5¶
UniLab 1.5 restores the IsaacLab/mjlab-style env_ids row-index form as the
public reset contract (issue
#2460, part of roadmap
#2459). The v1.4 fixed-width
bool mask form is removed without a compatibility layer (issue
#2474): passing a bool mask to
a public reset boundary raises TypeError.
This page covers the signature changes, the removed mask forms, how to port
mask-preserving terms back to env_ids, and how downstream tasks drop their
v1.4 temporal workarounds. The pre-1.4 implementations are the reference for
the restored form — for example
git show v1.3.4:src/unilab/managers/event_manager.py.
What changed¶
Surface |
v1.4 |
v1.5 |
|---|---|---|
|
|
|
|
|
|
Class term reset hooks (stateful reward/observation/termination/curriculum/metric terms) |
|
|
Action terms |
|
|
Event terms with |
class boundary |
|
|
|
|
|
the only reset-event entry point |
removed; use |
|
ignored for reset events |
per-row trigger filtering restored (1.3.x semantics) |
Reset owners |
|
|
Recorder hooks |
|
|
RL env contract |
|
|
env_ids is a one-dimensional integer tensor of row indices; None (and, at
manager boundaries, a slice) selects every row. The restored form matches
the v1.3.x public contract, so code written against 1.3.x works unchanged.
The internal conversion between the public env_ids form and the fixed-width
bool masks that backend transactions consume lives in exactly one place:
src/unilab/base/reset_selectors.py
(resolve_reset_env_ids, reset_env_ids_mask, resolve_reset_mask).
Backends are unaffected — see the FAQ below.
Removed mask forms¶
v1.5 — the public contract speaks
env_ids. Passing a fixed-width bool tensor positionally toreset(...)raisesTypeError. A class-based reset event term that still defines the v1.4 boundaryreset(env, reset_mask, **params)is no longer detected: reset terms are called asfunc(env, env_ids, **params)only.EventManager.dispatch_resetis removed; useapply(mode="reset", env_ids=...).
The keyword form fails the same way: env.reset(reset_mask=mask) raises
TypeError immediately in v1.5. Rename the argument to env_ids and pass row
indices.
Porting mask-preserving terms to env_ids¶
The mechanical rules are:
replace the mask parameter with a 1-D int64
env_idstensor;values.masked_fill_(mask, x)becomesvalues[env_ids] = x;torch.where(mask[:, None], new, old)becomesvalues[env_ids] = new_rows;to derive a mask from ids inside a term, use
reset_env_ids_mask(env_ids, num_envs=env.num_envs, device=env.device)fromunilab.base.reset_selectors— do not hand-roll conversions elsewhere;sample
env_ids.numel()rows instead ofint(mask.sum()).
Event terms (mode="reset")¶
v1.4 required every reset event term to expose a mask-preserving class boundary. v1.5 calls reset terms mjlab-style, so a plain function works again:
# v1.4 — mask-preserving class term
class randomize_link_friction:
def __init__(self, cfg: EventTermCfg, env: ManagerBasedRlEnv):
self._friction = torch.ones(env.num_envs, device=env.device)
def reset(
self,
env: ManagerBasedRlEnv,
reset_mask: torch.Tensor,
friction_range: tuple[float, float],
) -> None:
rows = torch.nonzero(reset_mask, as_tuple=False).flatten()
sampled = env.rng.uniform(*friction_range, rows.numel())
self._friction[reset_mask] = sampled
# v1.5 — the term is called as func(env, env_ids, **params); a class term
# moves the boundary onto __call__ (a plain function works too)
class randomize_link_friction:
def __init__(self, cfg: EventTermCfg, env: ManagerBasedRlEnv):
self._friction = torch.ones(env.num_envs, device=env.device)
def __call__(
self,
env: ManagerBasedRlEnv,
env_ids: torch.Tensor,
friction_range: tuple[float, float],
) -> None:
sampled = env.rng.uniform(*friction_range, env_ids.numel())
self._friction[env_ids] = sampled
For stateful class terms, note the two distinct hooks: func(env, env_ids, **params) is the reset event itself, while a one-argument
reset(self, env_ids) on the class remains the state-clearing hook (see
apply_body_impulse.reset in src/unilab/envs/mdp/events.py). The
two-argument reset(env, reset_mask, **params) form is removed and no longer
dispatched. Event terms that write simulator state keep staging through the
environment-owned packed reset transaction; only the row selector changes.
Reward, termination, curriculum, and metric class terms¶
# v1.4
class episode_length_reward:
def reset(self, reset_mask: torch.Tensor) -> None:
self._steps.masked_fill_(reset_mask, 0)
# v1.5
class episode_length_reward:
def reset(self, env_ids: torch.Tensor) -> None:
self._steps[env_ids] = 0
Observation class terms¶
Same hook change as reward terms (see _NamedSensorObservation.reset in
src/unilab/envs/mdp/observations.py). Custom noise models are the exception:
NoiseModel.reset(reset_mask) in src/unilab/managers/_noise/noise_model.py
keeps the mask form — the ObservationManager converts env_ids to a mask
for you, so custom noise models need no change.
Action terms¶
# v1.4
def reset(self, reset_mask: torch.Tensor) -> None:
self._raw_actions.masked_fill_(reset_mask.reshape(-1, 1), 0.0)
# v1.5 (matches src/unilab/envs/mdp/actions/actions.py)
def reset(self, env_ids: torch.Tensor | None = None) -> None:
if env_ids is None:
env_ids = slice(None)
self._raw_actions[env_ids] = 0.0
Command terms¶
No change needed. The public CommandTerm.reset(env_ids=None, ...) converts
to a mask at the manager boundary; the protected _resample_command(reset_mask)
subclass hook intentionally keeps the fixed-width mask form from v1.4.
Custom TorchEnv subclasses¶
Rename the selector and convert once, at the backend transaction edge:
from unilab.base.reset_selectors import reset_env_ids_mask, resolve_reset_env_ids
def reset(self, env_ids: torch.Tensor | None = None):
ids = resolve_reset_env_ids(
env_ids, num_envs=self.num_envs, device=self.device, owner="MyEnv.reset"
)
...
backend_mask = reset_env_ids_mask(ids, num_envs=self.num_envs, device=self.device)
resolve_reset_env_ids enforces the v1.5 contract for you: it rejects bool
masks with TypeError and rejects out-of-range indices, so env
implementations get the contract behavior for free.
Dropping task-level temporal workarounds¶
v1.4 removed manager-owned temporal state because it had no mask reset
boundary, so tasks stacked history themselves. The Wuji reorient task, for
example, carries a task-owned HistoryFeature class (own CircularBuffer,
a reset(reset_mask) hook that unpacks reset_mask.nonzero(), and a
NoisyHistoryFeature subclass that draws noise through env.rng before each
frame enters the stack).
With history and delay restored on ObservationTermCfg (issue
#2462; semantics identical to
1.3.4 — chronological oldest → newest layout, reset rows backfilled
with the first post-reset frame, and the per-term pipeline
compute → noise → clip → scale → delay → history, so noise enters before
history), the task-level classes can be deleted:
# v1.4 task YAML (task-owned history + hand-rolled noise)
joint:
func: wuji_unilab.tasks.reorient.observations.NoisyHistoryFeature
params:
kind: joint
history_length: 3
low: -0.06
high: 0.06
# v1.5 — plain term + native history_length and noise config
joint:
func: wuji_unilab.tasks.reorient.observations.feature
params: {kind: joint}
history_length: 3
noise:
_target_: unilab.managers._noise.UniformNoiseCfg
n_min: -0.06
n_max: 0.06
operation: add
The flattened width is unchanged (history_length × term_dim, term-major,
oldest first — the same layout HistoryFeature published), so trained policy
shapes carry over. Noise only applies to groups with
enable_corruption: true, which matches the workaround’s actor-only noise.
Class terms that additionally declare packed-read metadata
(tensor_state_entity / tensor_body_names, e.g. Wuji’s TipsFeature) can
stay class-based: keep __call__ and the metadata properties, delete the
buffer, the reset hook, and the hand-rolled noise, and move
history_length/scale/noise onto the term config.
For delayed observations use the native delay_min_lag / delay_max_lag /
delay_per_env / delay_hold_prob / delay_update_period /
delay_per_env_phase fields on ObservationTermCfg instead of task-level
delay queues; a group-level ObservationGroupCfg.history_length overrides all
terms in a group when every term shares one depth.
FAQ¶
Why does env.reset(reset_mask=mask) raise TypeError instead of
warning? The parameter itself is renamed to env_ids, and the v1.4 mask
form is removed without a deprecation window: both the keyword form and a
positional bool tensor raise TypeError immediately in v1.5. Failing fast
forces the one-word fix now instead of breaking harder later.
Does a custom backend or a custom reset transaction need changes? No.
SimBackend.apply_reset and the packed reset transaction still consume
fixed-width bool masks; only the public user-facing boundary moved back to
env_ids. src/unilab/base/reset_selectors.py is the sole conversion point.
Do custom command terms or noise models need changes? No. Command terms
override the protected _resample_command(reset_mask) hook, which keeps its
v1.4 mask form; custom NoiseModel subclasses keep reset(reset_mask). Both
managers convert at their public boundary.
How do I migrate a call to EventManager.dispatch_reset(mask, ...)?
dispatch_reset is removed in v1.5. Replace it with
event_manager.apply(mode="reset", env_ids=mask.nonzero(as_tuple=False).flatten(), global_env_step_count=...),
or keep passing ids you already have.
Where did min_step_count_between_reset filtering go in v1.4? v1.4’s
dispatch_reset ignored it; v1.5 restores the 1.3.x per-row filtering in
EventManager.apply(mode="reset", ...), so reset events again respect the
minimum step gap between triggers.