Skip to content

Limit Enforcement

Limit enforcement is a safety interlock: a per-variable [min, max] envelope applied to every edit, before the value reaches the thing it controls. An out-of-range command — whether from a bypassed slider, a number box typed past the slider’s range, a buggy controller, or a hand-crafted mutation over the wire — is clamped to the boundary rather than applied.

This is the enforce-and-continue counterpart to Watchpoints. A watchpoint detects a bad value and halts the loop; a limit prevents the bad value from ever reaching memory and keeps running. They compose: arm a watchpoint to stop on anomalies, and set a limit to guarantee the actuator never sees an out-of-envelope command in the meantime.

The dashboard slider’s (min, max, step) range (the bounds= argument to TrainingLoop) is a UI hint. It positions the slider, but it does not constrain anything else — the number box beside the slider will happily submit a value outside it, and nothing enforces it against a programmatic edit or a hand-crafted command. A limit is a hard constraint applied to every mutation, including loop.set(...) calls and raw IPC commands. Set both: bounds for an ergonomic tuning range, limits for the safety envelope.

A worked example — capping the one hyperparameter that can destroy an RL run outright:

loop = TrainingLoop.from_dataclass(
cfg.ppo,
tunable=["learning_rate"],
bind={"learning_rate": optimizer},
bounds={"learning_rate": (1e-5, 1e-2, 1e-5)}, # slider range
limits={"learning_rate": (1e-5, 5e-3)}, # above ~5e-3 Adam diverges
)

The slider still spans up to 1e-2, but an edit above 5e-3 arrives clamped — in the config object and in the optimizer it is bound to.

loop = TrainingLoop.from_dataclass(
cfg,
tunable=["joint_torque", "gripper_speed"],
bounds={"joint_torque": (-8.0, 8.0, 0.1)}, # slider range (UI)
limits={"joint_torque": (-10.0, 10.0), # hard safety envelope
"gripper_speed": (0.0, 1.0)},
)

A reversed pair is ordered internally; a non-finite bound (NaN/inf) is rejected — a limit that isn’t finite is not a limit.

from omniloop import tracker
tracker.set_bounds("joint_torque", -10.0, 10.0) # arm the limit
tracker.clear_bounds("joint_torque") # drop one limit
tracker.clear_all_bounds() # drop every limit

Enforcement is never silent, and it is visible in three places at once — because a clamp that only shows up in the trace after the run is indistinguishable, at the moment it happens, from a broken slider.

On the dashboard, as it happens. The clamped control gets a CLAMPED → <value> badge naming the value that was actually applied, with the full requested → applied transition and the envelope on hover. The badge clears itself after a few seconds: it marks an event, not a property of the parameter.

In the Event Console, durably. The same clamp writes a row you can scroll back to, so nothing is lost by not watching the control at the instant it fired.

In the journal, permanently. The TrainingLoop writes one limit_clamp control event per change, which is what omniloop why and the causality queries read.

You can also read the raw event yourself:

event = tracker.take_clamp_event()
# {"name": "joint_torque", "requested": "150", "clamped": "10",
# "min": "-10", "max": "10"} or None

Only the most recent clamp is retained, and reading it clears the slot — so a sustained out-of-range command reports one enforcement, not one per tick.

On the wire, the clamp rides out under the reserved limit_clamp key for a short window (~1.5 s) rather than on a single frame. That is not redundancy: the telemetry channel is a single-slot “hot latest value”, so a loop publishing faster than a consumer polls overwrites its own frames, and a value present on exactly one frame is dropped outright rather than merely delayed. Republishing makes delivery depend on the consumer’s poll rate instead of the loop’s publish rate. Consumers dedupe by content, so one enforcement is still one Event Console row.

A limit is enforced at two independent choke points, because a tunable can reach its destination by two different routes:

  • Python-side, in TrainingLoop — for a tunable derived from a config object (the common case: learning_rate, a reward weight, a PPO clip ratio). These have no memory address behind them; the value is applied by assigning to the config attribute and to whatever bind= target owns it. The clamp is applied before either write.
  • In the Rust core, inside sync_to_pointers — for a registered pointer, the memory a target process exposes for a variable (e.g. a MuJoCo reflection field). This is the single choke point where a mutation reaches actuator memory, and the clamp sits there after the existing NaN/inf rejection.

Both paths report through the same limit_clamp event, so it does not matter which one caught the edit.

Enforcement is a single dictionary lookup per edit on a variable that has a limit; variables without one short-circuit immediately. This runs once per mutation, not once per tick, so the cost is invisible against any real control loop.

For the Rust internals, see ClampEvent and StateRegistry::{set_bounds, clear_bounds, clear_all_bounds, take_last_clamp, enforce_bounds} in omniloop-core/src/registry/, enforced within sync_to_pointers; the Python-side counterpart is TrainingLoop._clamp_to_limits.