Writing Terms¶
Every observation, reward, termination, command, event, curriculum, metric, action and recorder in an Eden task is a term: a small unit that a manager builds from its options, calls on a fixed schedule, and resets with the episode. This page is the contract for writing one — which form to pick, what each lifecycle hook is for, where per-step state belongs, what validation raises, and how terms are registered. Read it before you write or review a term; the per-kind pages (actions, observations, rewards and terminations, commands, events, curricula, metrics and recorders) build on it.
Coding agents
In an Eden checkout, the writing-eden-terms skill (.claude/skills/writing-eden-terms/) turns this page
into a checklist to verify a term against. It is repo-only: pip install ships eden/, not .claude/.
Terms at a glance¶
Each manager owns one kind of term and resolves it from one registry. A task config holds one options object
per manager (reward_options, observation_options, …), and each named entry in it is one term.
| Kind | Config field | Registry (eden.managers) |
Function terms? | Class base to subclass | Function wrapper (eden.terms) |
|---|---|---|---|---|---|
| action | action_options |
ACTION_TERM_REGISTRY |
no | eden.managers.action_manager.ActionTerm |
— |
| observation | observation_options |
OBSERVATION_TERM_REGISTRY |
yes | eden.managers.observation_manager.ObservationTerm |
ObsTerm |
| reward | reward_options |
REWARD_TERM_REGISTRY |
yes | eden.managers.reward_manager.RewardTerm |
RewardTerm |
| termination | termination_options |
TERMINATION_TERM_REGISTRY |
yes | eden.managers.termination_manager.TerminationTerm |
DoneTerm |
| command | command_options |
COMMAND_TERM_REGISTRY |
no | eden.managers.command_manager.CommandTerm |
— |
| event | event_options |
EVENT_TERM_REGISTRY |
yes | eden.managers.event_manager.EventTerm |
EventTerm |
| curriculum | curriculum_options |
CURRICULUM_TERM_REGISTRY |
yes | eden.managers.curriculum_manager.CurriculumTerm |
CurrTerm |
| metric | metric_options |
METRIC_TERM_REGISTRY |
yes | eden.managers.metric_manager.MetricTerm |
MetricTerm |
| recorder | recorder_options |
RECORDER_TERM_REGISTRY |
no | eden.managers.recorder_manager.RecorderTerm |
— |
What compute() returns, whichever form you use:
| Kind | Output |
|---|---|
| observation | (num_envs, ...) tensor; usually (num_envs, dim) |
| reward | (num_envs,) float |
| termination | (num_envs,) bool |
| metric | (num_envs,) float |
Three names mean two different things
eden.terms.RewardTerm, eden.terms.EventTerm and eden.terms.MetricTerm are the function wrappers
— you call .configure(func=...) on them. The class bases with the same names live in the manager modules
(eden.managers.reward_manager.RewardTerm, eden.managers.event_manager.EventTerm,
eden.managers.metric_manager.MetricTerm, also re-exported from eden.managers) — you subclass those.
Import from the module that matches the form you are writing. Subclassing eden.terms.RewardTerm gives you
a function wrapper with no function in it.
Function term or class term?¶
Pick by whether the term has anything to keep between calls:
| Function term | Class term | |
|---|---|---|
| Use when | the term is a cheap, pure function of the current env state, with nothing to cache and nothing to carry | the term resolves something once (link or DOF indices, a constant tensor, another manager's term) and reuses it, or it carries per-step or per-episode state (an integral, a counter, a latch) |
| Written as | a plain function def f(env, *, param: type) -> torch.Tensor |
a subclass of the kind's class base, with config fields as class attributes |
| Wired in as | RewardTerm.configure(func=f, params={...}) |
MyTerm.configure(field=...) |
| Hooks | none: compute() only |
build(), compute(), reset(), post_reset(), plus per-kind hooks |
Function terms¶
A function term takes the env and keyword-only parameters, and returns a tensor. This is the built-in
dofs_vel_limits reward (eden/managers/terms/rewards/common.py):
import torch
from eden.managers.reward_manager import REWARD_TERM_REGISTRY
@REWARD_TERM_REGISTRY.register()
def dofs_vel_limits(env, *, entity_name: str, max_vel: float | torch.Tensor) -> torch.Tensor:
"""Quadratic hinge penalty on joint velocities exceeding a symmetric limit."""
dofs_vel = env.get_entity(entity_name).get_dofs_vel()
excess = (dofs_vel.abs() - max_vel).clamp_min(0.0)
return (excess**2).sum(dim=-1)
You wire it into a config with the kind's function wrapper. params holds the function's keyword arguments;
the term-level fields (weight here) sit beside it:
from eden import rewards
from eden.options.managers.rewards import RewardManagerOptions
from eden.terms import RewardTerm
reward_options = RewardManagerOptions(
joint_vel_hinge=RewardTerm.configure(
func=rewards.dofs_vel_limits,
weight=-0.01,
params={"entity_name": "robot", "max_vel": 0.5},
),
)
That is an excerpt of eden/tasks/benchmark/reacher/config.py.
A function term is stateless by construction. The manager never calls reset(), post_reset() or any
per-step hook on it, so state kept in a closure or a module global is never zeroed at an episode reset, and it
advances on every one of the several compute() calls a step can make (see
compute() is a read). Nothing warns you. A term that has to remember
anything between steps is a class term.
Class terms¶
A class term declares its configuration as class attributes with defaults, resolves static data once in
build(), and reads state in compute(). This is the built-in UndesiredContacts reward, lightly trimmed:
import torch
from eden.managers.reward_manager import REWARD_TERM_REGISTRY, RewardTerm
from eden.managers.terms.utils import resolve_link_idx
@REWARD_TERM_REGISTRY.register()
class UndesiredContacts(RewardTerm):
"""Penalty count of links whose net contact-force magnitude exceeds ``threshold``."""
entity_name: str = "robot"
links_name: list[str] = []
threshold: float = 1.0
def build(self) -> None:
# Touch the entity so a bad name raises at env construction, and keep only derived data.
entity = self.resolve_entity(self.entity_name)
self._link_idx = resolve_link_idx(entity, self.links_name, self.device, term=type(self).__name__)
def compute(self) -> torch.Tensor:
forces = self.resolve_entity(self.entity_name).get_links_net_contact_force() # (N, n_links, 3)
magnitudes = torch.norm(forces[:, self._link_idx], dim=-1)
return (magnitudes > self.threshold).float().sum(dim=-1)
You configure a class term by calling configure() on the class itself, with its fields as keyword arguments —
not func= and not params=:
from eden import rewards
knee_contacts = rewards.UndesiredContacts.configure(
weight=-0.1,
entity_name="robot",
links_name=[".*knee.*"],
)
Mutable defaults are deep-copied for each configured instance, so links_name: list[str] = [] is safe.
Every public class attribute becomes a parameter; a name with a leading underscore does not, which is how class
constants such as _function_era_params and _requires_env_options stay out of the config.
A class term must be registered
Eden resolves every term — function and class — by its registry key, never by the object you passed.
A class term that was never decorated with @<KIND>_TERM_REGISTRY.register() configures without error and
then fails to build with a KeyError from the registry. See Registries.
.configure() returns a specification, not a term¶
configure() builds a Pydantic options object (RewardTermOptions, ObservationTermOptions, …) that names
the term by its registry key and carries its parameters. Nothing is constructed until the env builds its
managers. That is what lets a config be validated when it is written, saved to JSON or YAML next to a
checkpoint, and loaded back.
The term lifecycle¶
A class term goes through these hooks, in this order:
__init__— wiring only. The scene is not built yet; do not touch entities here.build()— called once, right after construction, while the env builds its managers. Validate names (entities, links, DOFs, other terms), resolve indices and constant tensors, allocate persistent buffers.compute()— the hot path, called every step (several times a step for observations). Read state and return the output. No name resolution, no constant tensors built here.reset(envs_idx)— called when envs restart. Zero the per-episode rows of your buffers.post_reset(envs_idx)— called after every manager has finished resetting those envs. Rebuild caches that derive from the final post-reset scene.
Some kinds add hooks of their own:
| Kind | Extra hooks | Where they are documented |
|---|---|---|
| observation | post_physics_refresh() — the once-per-step tick (phase 7) |
compute() is a read |
| command | pre_physics_advance() (phase 3), post_physics_refresh() (phase 5), _resample_command(envs_idx), _update_command() (phase 17), advance_reference() and rewind_reference() (deployment) |
Which hook, When a command term runs |
| action | compute(actions) once per control step, apply_actions() once per physics step |
Actions |
| event | mode and priority decide when and in what order it fires; compute_batch(terms, envs_idx) for batched reset terms |
When events fire, Ordering events, Batched reset terms |
Managers are constructed in their reset order, producers before consumers (_manager_order(); see
Manager order). For an RL env that is action → command → metric →
termination → reward → curriculum → event → observation → recorder. So in build() a term can already resolve
a term of any manager earlier in that list: a reward term can look up an action term, an observation term
can look up a command term, a curriculum can look up the reward term it drives. A wrong name then raises at env
construction rather than mid-rollout. Don't defer such a lookup to the first compute().
Two managers may be absent. env.metric_manager is None when the task declares no metric terms, and on an
RL env env.command_manager and env.curriculum_manager are None when the task declares none. Test with
if env.metric_manager is not None: — these attributes exist, set to None, even before the env is built.
The step loop's full twenty-phase order, and the test that keeps it honest, are in The step loop. The phase numbers on this page refer to that table.
Writing good terms: state, caching, and sharing¶
A few rules keep terms fast and correct. Each of them has been broken in-tree at least once, and none of the breakages raised an exception.
Cache derived state in build() — never on env¶
Resolve link and DOF indices and constant tensors once, in build(), and store them on the term instance.
Never stash them on the env object:
# WRONG: leaks across terms, never reset, and re-checked on every step
cache_key = f"_upright_{entity_name}_{link_name}"
if not hasattr(env, cache_key):
setattr(env, cache_key, {"ls_idx": ..., "grav": ...})
# RIGHT: resolve once in build(), store on the term
def build(self) -> None:
robot = self.resolve_entity(self.entity_name)
self._ls_idx = resolve_link_idx(robot, [self.link_name], self.device)
self._grav = gravity_vec(self.num_envs, self.device)
A value written to env is visible to every other term, has no reset, and forces a hasattr branch onto the
hot path. If you find a function term doing this, convert it to a class term
(Converting a function term to a class term).
Use the shared helpers in eden/managers/terms/utils.py rather than open-coding the resolution; they are what
the built-in terms call:
| Helper | What it does |
|---|---|
resolve_link_idx(entity, names, device, *, term=None) |
link-name patterns → a long index tensor; raises naming the entity's links |
resolve_per_dof_std(std_dict, robot, device, *, default=None) |
a regex -> value dict → a per-DOF tensor. A DOF no pattern matches raises; add a ".*" entry (or pass default=) for "everything else" |
gravity_vec(num_envs, device) |
the world-frame [0, 0, -1], expanded to (num_envs, 3) |
projected_gravity(quat, grav) |
grav expressed in the frame of a wxyz quat |
soft_dof_pos_violation(dofs_pos, soft_limits) |
per-DOF soft-limit violation |
parallel_jaw_is_grasping(...) |
whether both pads of a parallel-jaw gripper press the same link |
resolve_metric_manager(env, *, term, metric_name) |
the metric manager, after checking it exists and has metric_name |
A hand-rolled find_named_links_idx_local(...) plus torch.tensor(...) at the call site, every step, is what
to avoid.
Resolve entities in compute(), cache only what they derive¶
The one thing build() must not cache is the entity handle itself. Deployment swaps env.entities[name]
for a RobotStateEntity so observation terms read the real robot; a handle captured in build() keeps pointing
at the simulated entity, and the policy is silently fed simulated numbers on hardware. Reach the entity through
ManagerTermBase.resolve_entity() each time instead — it is a dictionary lookup on env.entities, cheap
enough for the hot path, and an unknown name raises listing the names the scene has:
# WRONG: the handle goes stale when deployment swaps the entity
def build(self) -> None:
self._robot = self._env.entities[self.entity_name]
def compute(self) -> torch.Tensor:
return projected_gravity(self._robot.get_quat(), self._grav)
# RIGHT: touch it in build() to fail fast, re-resolve it in compute()
def build(self) -> None:
self.resolve_entity(self.entity_name) # a bad name raises at env construction
self._grav = gravity_vec(self.num_envs, self.device)
def compute(self) -> torch.Tensor:
return projected_gravity(self.resolve_entity(self.entity_name).get_quat(), self._grav)
Lazy resolution does not mean lazy validation: still touch the entity, and any link or DOF names you index, in
build(), so a typo fails at env construction rather than on the first step. Derived data — resolved link and
DOF indices, constant tensors — stays valid across the swap and belongs in build(). IllegalLinkContact
(eden/managers/terms/terminations/common.py) and UndesiredContacts above are the pattern to copy.
Term state lifecycle: buffers, reset, and torch modes¶
Terms that carry per-step state (latched contacts, air-time accumulators, action histories, pose caches) live inside two framework contracts that are easy to violate silently.
Allocate persistent buffers in build(), mutate them in place, never rebind. Some manager entry points
run under @torch.inference_mode() — RewardManager.compute, MetricManager.compute, MetricManager.reset
and the post_reset() fan-out — and others do not (reset() on most managers, a plain step loop such as
eden smoke). A tensor created inside an inference-mode region is an inference tensor for good, and the
first in-place write to it from outside raises
RuntimeError: Inplace update to inference tensor outside InferenceMode. Training usually hides this, because
rsl_rl wraps the whole rollout in inference mode, so the crash surfaces later, in eden smoke or an eval
script. The discipline is the latch idiom of FeetAirTime (eden/managers/terms/rewards/locomotion.py):
def build(self) -> None:
# Allocated outside any inference-mode region: writable from every path.
self._last_contacts = torch.zeros(self.num_envs, n_feet, dtype=torch.bool, device=self.device)
def compute(self) -> torch.Tensor:
contact = forces.norm(dim=-1) > self.threshold
contact_filt = contact | self._last_contacts # a fresh temporary is fine
self._last_contacts.copy_(contact) # copy_, not `self._last_contacts = contact`
...
copy_, mul_, add_ into a build()-time buffer are legal under inference mode. Rebinding the attribute to
a tensor produced there is the trap.
build() runs outside the inference-mode entry points because no caller in the tree builds an env inside
torch.inference_mode() — that is the caller's doing, not the term's. A term that would rather own the
guarantee wraps its allocation in with torch.inference_mode(False):, as ActionRateL2Smooth
(eden/managers/terms/rewards/common.py) does for the buffer it sizes off action_manager.prev_action.
When build() genuinely cannot size a buffer, allocate it lazily under torch.inference_mode(False). This
is the sanctioned exception, for a shape no manager knows yet — not for one that is merely inconvenient to
reach (that is an ordering problem; fix the order instead):
def compute(self) -> torch.Tensor:
if self._buf is None:
# A normal tensor even inside RewardManager.compute's inference mode,
# so reset() can still write it from a non-inference path.
with torch.inference_mode(False):
self._buf = torch.zeros_like(value)
...
eden/utils/buffers/circular_buffer.py uses this for storage sized by the first value pushed into it. Every
later write still follows the in-place rule.
Per-step caches must be coherent for reset envs before observations read them. The step loop, abridged to the phases this rule turns on:
- 1–4 — actions, then the physics sub-steps (
command_manager.pre_physics_advance()is phase 3) - 5 —
command_manager.post_physics_refresh(): per-step command caches refresh here - 6–9 — metrics, the observation-term tick (7), terminations, rewards — all graded on the pre-reset state
- 13 — auto-reset of done envs: scene reset,
reset-mode events, and every manager'sreset() - 14 —
post_reset()on every term, for the envs phase 13 reset - 17–18 — command resample and publish,
interval-mode events - 19 — observations: the reset envs are observed now, in their new episode
A term that refreshes its caches at phase 5 and does nothing at reset serves phase 5's pre-reset caches to phase 19's post-reset observations: the freshly reset envs observe the dead episode's contact forces or poses, and nothing raises. Keeping the caches coherent is your job, and the split is principled:
- Zero in
reset()what is honestly unknown until the next physics step: contact forces, latched contact states, air time. - Recompute in
post_reset()what is derivable from the post-reset scene: poses, targets, phase-derived quantities.
Why two hooks: phase 13 resets managers in sequence, so your reset() runs while other managers still have
theirs to go, and anything you derive there from another entity or term can still change before phase 19.
post_reset(envs_idx) runs after the whole sequence — including the episode_length_buf zeroing and any
subclass's extra reset work, such as the mimic env refilling its motion buffer — so it is the first moment the
scene is final:
def reset(self, envs_idx=None):
idx = slice(None) if envs_idx is None else envs_idx
self.foot_contact_force[idx] = 0.0 # unknowable until the next physics step
def post_reset(self, envs_idx):
self._refresh_kinematic_caches(envs_idx) # derivable now, from the final scene state
envs_idx reaches post_reset sanitized and never None, but index with it, never measure it: the
auto-reset path hands you the full-length boolean reset_buf, while env.reset() hands you a slice or an
index tensor. buf[envs_idx] = ... is always right; len(envs_idx) silently means num_envs on the step
path. The fan-out runs under torch.inference_mode(), so the in-place rule applies with full force.
SkateCommand in eden/tasks/husky/custom_terms/commands.py is the worked example, split exactly this way.
Audit any term with a post_physics_refresh or a per-step cache against this rule.
compute() is a read, not a tick¶
An observation term's compute() is not called exactly once per control step, and nothing promises which
call is "the" step. It runs:
| When | Why | History advances? |
|---|---|---|
once per term at build, before any reset() |
the manager learns the output shape from it (term dimensions are not declared) | — |
in EnvBase.reset() |
the fresh observation the reset returns | yes |
at phase 19 of RLEnvBase.step |
the canonical observation | yes (update_history=True) |
| at phase 12, before the auto-reset | the final-observation snapshot, when EnvOptions.record_final_observations is on |
no |
from RslRlVecEnvWrapper.get_observations() |
rsl_rl reads observations outside the step, at the top of learn() |
no |
| from the deploy pre-flight probe | shapes the policy's probe input; the manager is reset afterwards | no |
The cross-group cache goes the other way: two identically configured instances (a policy and a critic copy
of one term) compute once per observation pass, so one instance's compute() may never run in a step.
The "history advances?" column is also when a term's stateful observation
modifiers advance: a moving average or a delay moves on each
update_history=True pass, together with the history window, and never on the reads.
So a term that advances state inside compute() — self._integral += err * dt, a step counter, a contact
latch — double-counts under the snapshot, counts the build probe and the reset as steps, and drifts apart across
the cache. The failure is a wrong number, not an exception. Eden's own Extreme Parkour port paid for this:
its stateful observation terms guarded compute() with a step-counter stamp, which still advanced before the
reset with the snapshot on and after it with the snapshot off, so one config observed a freshly reset env two
ways depending on a recording flag. They now tick in post_physics_refresh.
The contract:
compute()is a read. It returns a value derived from the current env state and the term's buffers, and changes nothing a second call would see. Under that rule every caller above is harmless, and so is the cache.- Per-step state advances in
ObservationTerm.post_physics_refresh(), dispatched at phase 7 byobservation_manager.post_physics_refresh(), exactly once per control step: after the command caches (5) and the metrics (6), so a term may integrate either and see this step's value; and above the graded layer and the auto-reset. That position gives the state the semantics you expect: the phase-12 snapshot carries this step's increment,reset()then zeroes the envs that restart, and the new episode's first observation reads zero.EnvBase.reset()and the build probe run no phase 7, so the term reads zero there too. Every instance is ticked, including the duplicates the cross-group cache computes once, which keeps their states identical. - On hardware, the deploy loop ticks it once per control tick (
DeploymentBase.state_to_observation(..., post_physics_refresh=True)) after the robot state is synced, and resets the manager (reset, thenpost_reset) on each episode's first read. Unlike a command term'spost_physics_refresh, this one runs on the robot: the policy's observations are the hardware path. build()allocates,reset(envs_idx)zeroes rows,post_reset(envs_idx)rebuilds what the post-reset scene determines, as for any per-step state.
VelocityTrackingErrorIntegral in eden/managers/terms/observations/locomotion.py is the shipped worked
example — ∫ (commanded − measured) dt over [vx, vy, wz], clamped — and the shape to copy (validation
trimmed):
import torch
from eden.managers.observation_manager import OBSERVATION_TERM_REGISTRY, ObservationTerm
@OBSERVATION_TERM_REGISTRY.register()
class VelocityTrackingErrorIntegral(ObservationTerm):
entity_name: str = "robot"
command_name: str = "velocity_command"
max_abs: float = 1.0
def build(self) -> None:
self._command = self._env.command_manager.get_term(self.command_name) # a bad name raises here
self.resolve_entity(self.entity_name) # so does a bad entity
self._integral = torch.zeros(self.num_envs, 3, device=self.device) # allocated once, written in place
def post_physics_refresh(self) -> None: # phase 7: exactly once per control step
entity = self.resolve_entity(self.entity_name)
measured = torch.cat([entity.get_vel(frame="body")[:, :2], entity.get_ang(frame="body")[:, 2:3]], dim=-1)
self._integral.add_((self._command.command[:, :3] - measured) * self._env.dt).clamp_(
-self.max_abs, self.max_abs
)
def reset(self, envs_idx=None) -> None: # phase 13: the restarting rows
self._integral[slice(None) if envs_idx is None else envs_idx] = 0.0
def compute(self) -> torch.Tensor: # a read, however often it runs
return self._integral
tests/envs/test_base.py runs the real RLEnvBase.step against a probe term like this one and pins the spec:
zero after env.reset(), exactly k·dt after k steps, unchanged by get_observations() and by the
final-observation snapshot, and zero again for an env the step auto-resets while the others keep counting.
Which hook: grade the step, or prepare the next one¶
The step loop has two epochs, split by the auto-reset:
- Grading — metrics, the observation-term tick, terminations and rewards at phases 6–9, fed by
command_manager.post_physics_refresh()at 5. Everything here describes the step that just happened, read off the pre-reset state the action produced. - Preparation — curricula,
command_manager.compute(),intervalevents and observations at phases 16–20. Everything here sets up the next step, on post-reset state.
Which hook a piece of per-step state belongs in follows from what that state is. For a command term it is
three hooks and one rule (an observation term with per-step state has the same first row, ticked at phase 7
rather than 5, with compute() as its read):
| The state is… | Write it in | Phase |
|---|---|---|
| a measurement of what this step produced — progress counters, hold or contact latches, tracking errors, an observation's integral: anything derived from the post-physics robot | post_physics_refresh() |
5 (command), 7 (observation) |
| the command itself — the value the policy is given, resampled and published for the step to come | _resample_command(), _update_command() |
17, and 13 for a resample on reset |
a time-advancing reference the physics must be graded against ("advance, then grade" — MotionCommand's clip clock) |
pre_physics_advance() |
3 |
The split is what the state is, not who reads it. Rewards read the command too — TrackingLinVel reads
self._command.command off UniformVelocityCommand, and GaitPhaseCommand's phase is its command — and
those stay at 17. Ask whether the value measures the outcome the action produced (grading) or is the command
the policy was given and is about to be given next (publication).
Resampling runs twice: CommandTerm.reset() also calls _resample(), so _resample_command fires at phase 13
on the auto-reset path as well as at 17. Either way it receives a (num_envs,) bool mask with at least one
True, every control step — which is why its docstring asks you to write through torch.where rather than
index with the mask.
Every other kind that derives state other terms read is pinned to one epoch by its manager: a metric computes
at phase 6 and a reward at 9 (grading); an observation reads at 19 (preparation). Command terms and stateful
observation terms are the two kinds with hooks on both sides of the reset, so they are the two kinds that can
put a value in the wrong epoch — for an observation term the wrong place is compute() itself. (Recorder terms
straddle the reset too, but they only write data out, so there is nothing downstream to serve stale.)
Grading state written in _update_command is read one step late, and whatever that same call clears is
never read at all. The reward at phase 9 of step k+1 sees what _update_command wrote at phase 17 of step
k. A counter that call zeroes — on a goal switch, a resample, a phase wrap — takes its last value with it,
so the step that earned it is graded through the cleared one. Nothing raises; the reward is simply 0 where it
should have been largest, which is invisible in a training curve.
That is a real bug, and it is where this rule comes from. InHandReorientCommand
(eden/tasks/reorient/custom_terms/commands.py, in-hand cube reorientation) once ran its whole success state
machine — on-target hold counter, success window, window timer — in _update_command, and paid its escalating
hold reward through reward_* shadow buffers snapshotted just before the goal switch cleared the live ones.
The snapshot covered the counter but not the gate beside it, so the highest-paying step of every goal cycle
paid zero for a whole training run:
Snapshotting the gate too would patch one term. The rule removes the class of bug: grade in
post_physics_refresh, four phases above the reward in the same step, and there is no lag to carry a value
back across and nothing to shadow. The goal switch stays in _update_command, because a switch is
publication. InHandReorientCommand, SkateCommand (eden/tasks/husky/custom_terms/commands.py) and
ParkourCommand (eden/tasks/extreme_parkour/custom_terms/commands.py) are split this way today; none carries
a shadow buffer. A reward_* shadow buffer in a command term is the smell.
A reward and an observation read different epochs of the same attribute
Phase 9 and phase 19 read the same command attribute at opposite ends of the step, with phase 17 between
them, so one attribute can mean two things depending on which manager reads it. The reorient task's
command_state_progress observation once reported a full success window to the critic for a goal that had
been replaced on that very step. When a quantity has both kinds of reader, compute it in
post_physics_refresh and leave it alone for the rest of the step; then both see one value.
Deployment runs two of the three command hooks
The rule above is about the training loop. DeploymentBase (and the backends that route their own step
through its _update_commands) advances the reference with command_manager.advance_reference() — which
defaults to each term's pre_physics_advance() — then publishes with command_manager.compute() and
draw_vis(). A command term's post_physics_refresh never runs on hardware: there are no physics
sub-steps to bracket and no reward to grade. A command term whose deployed observations depend on a side
effect of that hook reads a value frozen at its last simulated step, silently.
The advance reaches hardware only because the deploy loop drives it explicitly. A refactor once moved
MotionCommand's clip clock from _update_command (which deployment reached, through compute()) into
pre_physics_advance (which it then did not): correct in simulation, and a clip frozen at its first frame on
every deployed MotionCommand policy. tests/extensions/deployment/test_base.py now pins both reference
paths. A term whose simulation advance also teleports or resamples the robot (a clip wrap) overrides
advance_reference() with a clock-only advance.
A term with a clock needs the reset side too. CommandTerm.rewind_reference() runs only on the
deployment path — DeploymentBase.reset_motion_reference drives it at deploy reset, at START and on every
policy switch — and it must rewind the clock alone: the simulation's way of restarting a clip places the
robot and perturbs its pose, which is right for a new episode and meaningless for a robot already standing
where it is.
What survives the crossing is an idempotent refresh — a cache recomputed from current state, safe to
run in two hooks. MotionCommand refreshes its robot-state cache in post_physics_refresh for the reward
and again in compute() for the post-reset state the observation reads, which also covers the hardware
path. A non-idempotent advance — a counter, a latch, a state machine — cannot simply be duplicated,
because running it twice per control step is a different bug. So: if the term is training-only, follow the
rule and stop. If it will be deployed, check what the policy's observations read off it (the reward is
not on the hardware path) and make sure compute() — or pre_physics_advance, for a clock — produces that.
Why not move the command update above the reward and have one epoch? Because the policy chose this step's
action from observations computed at phase 19 of the previous step, against the command those observations
carried. Grading at phase 9 against that same command is what keeps observation → action → reward aligned on
one command. Resampling before the reward would grade the robot against a goal drawn after the physics that
produced its state — a goal the policy never saw — giving a reward drop attributed to an action that could not
have caused it. Phase 17 is the only position that keeps the triple aligned, so what moves is the hook you
pick, not the step order.
Share a computation across terms with a MetricTerm¶
If more than one term needs the same quantity — a tracking error used by a reward and an observation, a
contact state used by a reward and a termination — compute it once as a metric term and pull it by name,
instead of recomputing it in each term or caching it on env. The metric manager runs at phase 6, before the
observation tick (7), terminations (8) and rewards (9), so the pulled value is current. Metric terms themselves
are covered in Metrics and recorders.
from eden import observations, rewards, terminations
from eden.options import ObservationGroupOptions
from eden.options.managers.rewards import RewardManagerOptions
from eden.options.managers.terminations import TerminationManagerOptions
reward_options = RewardManagerOptions(
# the metric's value times the weight (negative for a penalty)
score=rewards.MetricReward.configure(metric_name="my_score", weight=1.0),
)
policy = ObservationGroupOptions(
# the same value as a (num_envs, 1) observation
score=observations.MetricObservation.configure(metric_name="my_score"),
)
termination_options = TerminationManagerOptions(
too_far=terminations.MetricAbove.configure(metric_name="my_score", threshold=0.5),
)
The pull terms read metric_manager.get_value(name), the manager's stable, in-place-updated buffer:
| Pull term | Reads | Notes |
|---|---|---|
rewards.MetricReward(metric_name, allow_cumulative=False) |
get_value |
refuses a cumulative metric (its value is the running episode sum, which a per-step reward would integrate again) unless allow_cumulative=True |
observations.MetricObservation(metric_name) |
get_value, as (num_envs, 1) |
an env that reset this step reads the metric's post-reset value |
terminations.OnMetricSuccess(metric_name) |
the metric's own current-step success check | INTERVAL metrics that contribute to success only |
terminations.MetricAbove(metric_name, threshold=0.0) |
get_value(name) > threshold |
also accepts a contributes_to_success=False metric, such as a collision flag; on a cumulative metric it bounds the episode total |
rewards.TaskSuccess() |
metric_manager.success_buf |
1 on a step where every success metric holds |
All of them resolve their metric in build() through resolve_metric_manager, so a missing metric manager or
an unknown metric_name raises at env construction, listing the active metrics. Write your own pull terms the
same way.
When several terms derive different values from one intermediate that is not itself a metric — a
body-position and a body-rotation error from the same anchor-relative transform, say — compute it through
env.compute_once(key, producer). producer() runs at most once per pass for each key, and the memo is
dropped when the pass ends; outside a pass it simply runs, so a value is never served stale. Each manager's term
loop opens a pass, and RLEnvBase.step opens one over its whole graded phase (metrics, the observation tick,
terminations, rewards), so a key a metric produced is served to a reward that asks for it. A key must therefore
name its value completely — the producer and everything it depends on (entity, link names, …) — and every
caller receives the same object, so treat it as read-only. anchor_state_once and anchor_relative_links_once
in eden/utils/motion_reference/lookup.py are the in-tree users. Each observation_manager.compute() is a
pass of its own, because it runs after the auto-reset on a different state.
Validation fails fast¶
Eden surfaces config mistakes at the earliest well-defined point — construction, configure(), or build() —
rather than inside a compute() step. When you write or wire up terms, expect these to raise:
- Unknown fields on fixed-schema options raise at construction. Options classes default to
extra="forbid", soEnvOptions(decimatoin=4)is apydantic.ValidationError. Only classes that hold dynamically named children (manager options,ObservationGroupOptions,SceneOptions, camera and sensor holders) allow extras. - Misspelled term parameters raise.
RewardTerm.configure(func=..., wieght=3.0)raisesValueError; so doesRewardTermOptions(name="upright", wieght=2.0)— at construction whennameis set and the kind's registry is importable, on later attribute assignment (rolled back), onupdated(...)andmodel_copy(update=...), and as a backstop at build. The message names the term and the bad parameter and lists every accepted one with its type and default, in the form the Term Catalog uses. - Override one field of an inherited term with
updated().term_options.updated(weight=0.0)is a deep copy with those fields replaced and checked.model_copy(update={"weigth": 0.0})checks the names too (its values stay unvalidated, as in pydantic), for term options, manager options, observation groups and every fixed-schema class. - Observation groups check their entries. An entry that is neither a group field nor a term
(
terms_ordr=[...]) raisesTypeErrorat construction, onmodel_copy/updated, and on assignment. A term written as a plaindictmust name its term ({"name": "base_lin_vel", ...}); one that does not is treated as the misspelled group field it usually is. Details are in Observations. - Manager options check an assigned entry.
reward_options.bogus = "x"raises on that line, not at build. - A term cannot take a name its holder's class already has (
lock,clone,keys,term_keys, …), in a manager's options or an observation group — it would shadow the method. Both construction and assignment raiseTypeError; rename the entry (the piano task'skeysbecamekey_positions). - An unregistered
func=or a missing required parameter raises atconfigure(), before any env is built. The message names the function, the missing parameters and the full set it accepts. func=must be the function, not its name.RewardTerm.configure(func="dofs_vel_limits")raisesTypeError.params=on a class term raises — unless the term used to be a function term; see Converting a function term to a class term.- Build errors name the entry. An exception raised while a manager constructs or builds a term carries a
note naming the entry and its registry key, for example
while building RewardManager term 'lift_milk' (name='metric_reward'), since a task can hold many entries of one term. A nameless entry (name=None) raisesValueErrorsaying so. - An entity-bound term opens its own errors with
term_subject(self). Fromeden.utils.string, it renders"<Class> on entity '<name>'":raise ValueError(f"{term_subject(self)}: std must be > 0, got {self.std}."). Don't write the class or entity by hand, and don't add the config key — the build note already does. How to word the rest of the message is in Error messages. - Env-options preconditions raise while the env is built. A few terms need a scene-wide
EnvOptionsflag no parameter of theirs can supply — the per-env domain-randomization terms need batched solver data (RandomizeKpKdGains→batch_dofs_info;RandomizeComShift,RandomizeMassShift,RandomizeLinkMassScale→batch_links_info). Declare one as a_requires_env_optionstuple — a class attribute on a class term, or a registration keyword on a function term (@EVENT_TERM_REGISTRY.register(_requires_env_options=("batch_links_info",))) — and the term raisesValueErrorat construction naming itself and the flag. A malformed declaration (a bare string, or a name that is not anEnvOptionsfield) is refused when the class is defined.eden termsprints the flags on arequires:line and the catalog has aRequirescolumn. Use it for env-options flags only; a requirement the term can inspect itself (a sibling term, a named link) stays a check in its ownbuild(). Keep the leading underscore, or the name becomes a configurable parameter. - Action terms refuse a multi-DOF joint in
dofs_name, two terms commanding one DOF, and a term that neither draws an action column nor commands a DOF. See Rules the action manager enforces.
Gotchas when writing terms¶
- DOF ordering. Full-width DOF reads (
get_dofs_*()),get_dofs_limit, thedefault_dofs_*tensors andsoft_dofs_pos_limitsare in the entity's configureddofs_nameorder — the column space.get_jacobianuses the same order after six base columns on a floating base: index it withcolumns + 6unlessentity.has_immobile_base.find_named_dofs_idx_localreturns Genesis tree order — the local space — which goes only into adofs_idx_local=argument. Resolve once inbuild()withfind_named_dofs(names)and hand its.columnsto tensors and its.dofs_idx_localto Genesis calls; read configured properties by name withget_default_dofs_*inbuild(). See Entities. - Quaternions are wxyz everywhere in Eden, config fields included.
- Particle entities can't be moved at runtime.
ParticleEntity.set_pos()/set_quat()(MPM, SPH, PBD) raiseNotImplementedError; bake the pose into the morph at creation time. - Auto-reset happens before observation. In
RLEnvBase.step(), resets run after rewards and terminations but before observations, so the observations returned for done envs are their new episode's first frame. Turn onEnvOptions.record_final_observationsto get the terminal frame inextras["final_observations"]. This ordering is also why a term with per-step caches needs the reset discipline. eval_modeturns training off, not envs.eval_modesetsenv.training = False, which skips observation noise and other training-only behaviour;num_envsis unchanged. Gate randomization onenv.training.- Observation post-compute fields are options-only.
modifiers,noise,clip,scale,history_length,flatten_history_dimandbackfillare accepted byconfigure()but not mirrored as term attributes, so acompute()cannot read one. Don't declare a field namedscaleon an observation term.noiseandscaleare resolved once at build; change a scale afterwards withenv.observation_manager.set_term_scale(group, name, scale). See Observations. - Stay sync-free on the hot path. No
.item(),.cpu(),.tolist()orif tensor.any():incompute()unless you must — and then say why in a comment. A boolean mask used as an index syncs too:x[mask]andx[mask] = yare sized on the host. Prefertorch.where(mask, y, x)or a scalar fill (x[mask] = 0.0, which stays on the device). This matters most inreset(), which the auto-reset calls with the boolreset_bufevery step: reduce a log value witheden.utils.misc.sum_over_envs/mean_over_envs, and write rows from a full-batch source witheden.utils.misc.assign_over_envs. Where a reset must gather the selected rows, takeids = eden.utils.misc.env_indices(envs_idx): the auto-reset shares onenonzero()ofreset_bufwith every consumer. Build constant tensors inbuild(), or witheden.utils.torch.device_constant; index with a 0-dim CUDA tensor throughindex_select, notx[:, idx[0]]; usetorch.linalg.solve_ex/inv_exwhere the system cannot be singular.torch.cuda.set_sync_debug_mode("warn")flags syncs as they happen, andcount_cuda_syncsintests/conftest.pypins a budget in tests. - Pass
envs_idx=Nonefor "every env", not an all-envs index tensor: the default is generated on the device. An index tensor you cached inbuild()is fine to pass to aRigidEntityaccessor, which resolves it to arangeonce per tensor. - Build every tensor with
device=. There is no torch default device — neither Genesis nor Eden sets one, because torch implements a default device as a PythonTorchFunctionModethat every torch call is dispatched through, whicheden/__init__.pymeasured at 1.7–2.0 ms per velocity-tracking control step.torch.zeros(n)is a CPU tensor, as in plain torch. Allocate withdevice=self.device(in a term) ordevice=env.device, derive from an existing tensor (torch.zeros_like,x.new_zeros), or passdevice="cpu"deliberately, with a comment saying why. The default dtype is set byeden.initto Genesis' float, so a dtype-less float tensor matches Genesis' own. The test suite runs on CPU, where a missingdevice=cannot show, sotests/envs/test_base.pysteps a few tasks under aTorchFunctionModeand fails on any factory call without one. - Compile pure tensor math with
@compile_model(eden.utils.torch) in a free function and call it fromcompute(), as the locomotion and tracking terms do.
Profiling a term you just wrote¶
A term that is correct but expensive does not announce itself — you get a training run that takes hours longer
than it should. The profiler's own docstring (eden/utils/profiling/term_profiler.py) records two runs of the
same task on the same GPU differing 35× in throughput, purely because of per-call allocations inside the task's
own terms. Before tuning hyperparameters or adding envs, measure where the step goes:
This builds the task, steps it for a short window (--profile_warmup, default 10 steps; --profile_steps,
default 50), attributes wall time to each per-step term, prints a ranked table and writes term_cost.json to
Eden's log directory (eden.get_log_dir()):
{"num_steps": 50, "step_us": 2684.8,
"terms": [{"manager": "observation", "term": "policy.slow_upright",
"us_per_step": 2488.2, "pct_step": 92.7, "calls_per_step": 1.0}]}
One term at 92% of the step is the bug, and the fix is almost always the advice above: hoist constants and
index resolution into build(), drop the host syncs. Profiling is opt-in: without --profile, nothing is
wrapped and the framework pays no profiler overhead.
Registries¶
Every term kind has its own TermRegistry, importable from eden.managers (see the table
above). A registry maps a key to the term, and every manager resolves the terms of its
options through it.
Naming convention¶
- The key is always
to_snake_case(__name__)— for classes and functions alike:RelativeLinkPosition→"relative_link_position",UniformT3Command→"uniform_t3_command",distance_penalty→"distance_penalty". - A term registry's
register()takes noname=.configure()stampsto_snake_case(__name__)intooptions.nameand the manager resolves the term back by that name, so a registration cannot choose a different key —@REWARD_TERM_REGISTRY.register(name="custom_key")raisesValueError. Rename the term to change its key. to_snake_caseleaves digit suffixes alone:Modern1→"modern1", not"modern_1".- Other keyword arguments to
register()are the term's defaults, function terms included:@METRIC_TERM_REGISTRY.register(direction=MetricDirection.LIB, is_cumulative=True)on a function makes those its defaults whether it is configured withMetricTerm.configure(func=...)or named withMetricTermOptions(name=...). A field you pass explicitly still wins. Naming a class term in its options fills the fields you leave out from the class's own attributes the same way, soEventTermOptions(name="push_by_velocity")is an interval event, asPushByVelocity.configure()is. - One flat namespace per kind. Two terms of one kind with the same key raise at import, naming both
modules. An out-of-tree term whose class name collides with a built-in must be renamed (
MyExtUpright, notUpright). - A key starting with
_warns — that is usually an internal class that should not be registered. - Every module a registry's terms live in must be listed in its
populated_by, becauseimport edenimports no term module; the registry imports those modules on its first read. A term defined in a module the registry does not list reads as "no such term". This is about Eden's built-in term modules; a term defined in a task's own module is registered when that module is imported, which happens when the task's config is.
Asset, robot and task registries are the opposite case: they are looked up only by key, so name= there is a
normal alias (@TASK_REGISTRY.register(name="beyondmimic")).
Renaming a term¶
Renaming a term changes its key, so configs saved to disk next to checkpoints stop resolving. If you need the old key to keep loading, register a subclass under the old name:
@REWARD_TERM_REGISTRY.register()
class NewRestHeight(RewardTerm):
...
@REWARD_TERM_REGISTRY.register()
class OldRestHeight(NewRestHeight):
"""Deprecated alias for NewRestHeight, kept so saved configs still load."""
Both keys now exist and old_rest_height builds the subclass, which inherits every field and behaviour.
Converting a function term to a class term¶
A function term that needs to resolve something once, or keep state, becomes a class term. To keep saved configs loading:
- Name the class after the function (
foo_bar→FooBar), so the registry key does not change. - If the function took
params, list those keys in_function_era_params. A config saved before the conversion still carriesparams={"metric_name": ...}, which a class term never reads. At construction, aparamsdict holding only keys from_function_era_paramsis moved onto the class's top-level fields; any other key,paramson a term that was never a function, or a key given both ways still raises.
DofsPos (eden/managers/terms/observations/proprio.py) is an example:
@OBSERVATION_TERM_REGISTRY.register()
class DofsPos(_DofsObservation):
offset_from_default: bool = False
_function_era_params = frozenset({"entity_name", "offset_from_default", "terms_idx"})
MetricReward (metric_name, allow_cumulative), MetricObservation and OnMetricSuccess (metric_name)
were converted the same way.
Inline registration¶
You can define and register a term right in a task's config module; no separate module is needed. The reacher
task registers its reaching_reward this way (eden/tasks/benchmark/reacher/config.py). The shape is:
import torch
from eden.managers import REWARD_TERM_REGISTRY
from eden.options.managers.rewards import RewardManagerOptions
from eden.terms import RewardTerm
@REWARD_TERM_REGISTRY.register()
def my_inline_reward(env, *, scale: float) -> torch.Tensor:
return torch.full((env.num_envs,), scale, device=env.device)
reward_options = RewardManagerOptions(
bonus=RewardTerm.configure(func=my_inline_reward, weight=1.0, params={"scale": 2.0}),
)
Larger tasks keep their terms in a custom_terms package next to config.py; the layout is in
Task configs.
Finding built-in terms¶
Every registered term, with the parameters it accepts, their types and defaults, and which are required, is in the generated Term Catalog. The same listing is available from the shell:
eden terms # every term, grouped by kind
eden terms --kind reward # one kind
eden terms --grep height # name, parameters and summary are all searched
eden terms --kind reward --grep "feet|foot" # --grep is a case-insensitive regex
eden terms --json # machine-readable, for tooling
--grep is the form to reach for first, and the one a failed registry lookup points you at: it searches
parameter names too, so "which term takes max_vel?" is eden terms --grep max_vel. It exits 1 when nothing
matches, so a script can branch on it.
An index of the catalog — every term's key, class, module and summary, without parameters — ships inside the
installed package at eden/resources/term_catalog.md, for an Eden installed without the docs. Both pages are
generated from the registries: after adding, renaming or re-parameterizing a term, regenerate them from a repo
checkout with uv run python -m eden.managers.catalog (--check reports staleness without writing).
tests/resources/test_term_catalog.py fails while either is stale.
Look a term's parameters up before writing it into a config: they are not guessable from its name, and getting them wrong costs an env build.
See also¶
- Actions, Observations, Rewards and terminations
- Commands, Events, Curricula, Metrics and recorders
- Environments — the step loop and manager order
- Error messages — what a term's error must say
- Testing — how to test a term
- Term Catalog and the managers API