The canonical contract for the algorithm layer. This is the source of truth
for what safety_sb3 exposes and what it expects from an environment; the
environment side (mjlab tasks, margins, end_criterion) is documented in
robot-safety-sandbox docs/API.md.
Keep the two in sync — the g/l channel contract below is shared, and this
document defines it.
- Orientation & rationale: Home
- Training recipes & pitfalls: Best practices
- What changed in v0.2.0 (breaking): Release notes
Every learner consumes an environment through two channels plus a done signal.
Get these right and everything works; get g wrong and the value function's zero
level set — the safety boundary — moves.
| channel | symbol | meaning | where |
|---|---|---|---|
| reward | g(s) |
safety margin. g ≥ 0 ⟺ outside the failure set. |
the reward field |
| info | l(s) |
target margin. l ≥ 0 ⟺ inside the target set. Reach-avoid only. |
info["l_x"] (numpy) / step_tensor's 5th return (tensor) |
| dones | terminate when g < 0; timeouts are never value-bootstrapped by default |
standard gym |
Rules, each of which has cost real debugging (see BEST_PRACTICES):
- Never normalize the reward.
gis the margin.VecNormalize(norm_reward=True)or any reward scaler silently destroys the backup. Observation normalization is fine. - Terminate on
g < 0. Letting the sim run past a violation leaks post-failure states into the value target. lis read only by the reach-avoid column (ReachAvoid*,Gameplay*). The avoid column (Safety*,Isaacs*) ignores it. An avoid task should not invent one — see §5.
- Numpy path (standard SB3
VecEnv):gonreward,loninfo["l_x"]. Used by the SAC family and by PPO on CPU envs. - Tensor path (
TensorVecEnv, GPU-resident): the env implementsstep_tensor(actions) -> (obs, reward_g, dones, timeouts, l_x), all device tensors.TensorVecNormalizenormalizes observations only. This is the ~50k-step/s regime for massively-parallel envs; the PPO family auto-detects it.
| avoid (stay safe forever) | reach-avoid (reach it, staying safe) | |
|---|---|---|
| single-player | SafetyPPO SafetySAC SafetyDQN SafetyA2C |
ReachAvoidPPO ReachAvoidSAC |
| two-player (ctrl max + dstb min) | IsaacsPPO IsaacsSAC |
GameplayPPO GameplaySAC |
Isaacs*= ISAACS (Hsu et al. 2022), the two-player avoid game — no target set, nol.Gameplay*= Gameplay Filters (Hsu et al. 2024), which extends ISAACS to reach-avoid.
These names changed meaning in v0.2.0. Pre-v0.2.0
Isaacs*were reach-avoid. See RELEASE_NOTES.md before upgrading old code.
Pick the row by your problem (does the task have a target to reach?) and the column by whether you train against a worst-case disturbance adversary.
Both operators are defined once in safety_sb3/backups.py and every learner
routes through it. Convention: g ≥ 0 safe, l ≥ 0 in-target, V maximized,
V ≥ 0 ⟺ in the solution set.
target = nt · ( (1 − γ)·anchor + γ·backup ) + (1 − nt)·terminal
nt = 1 on non-terminal steps, 0 on terminal steps. The anchor is the
"episode terminates now" payoff (1 − γ is the per-step termination probability):
| problem | backup (v_to_go) |
anchor |
|---|---|---|
| avoid | min(g, V') |
g |
| reach-avoid | min(g, max(l, V')) |
min(l, g) |
- avoid — Fisac et al. 2019; ISAACS eq. 6/7.
- reach-avoid — Hsu et al. RSS'21 eq. 15; Gameplay Filters eq. 6a. The anchor
min(l, g)is the same expression as the finite-horizon terminal conditionV_H = min(l, g)(Gameplay Filters eq. 5b).
Do not anchor reach-avoid on g. That makes "stay safe forever, never reach"
a fixed point at V = g > 0 — a win — when its true value is maxₜ lₜ < 0. The
result is neither problem's value, RSS'21's under-approximation theorem stops
applying, and the critic is unsound to shield with. This was the v0.1.0 bug.
Public functions:
backups.avoid_target(g, v_next, not_done, gamma)
backups.reach_avoid_target(g, l, v_next, not_done, gamma, terminal_type="all")
backups.target(mode, g, v_next, not_done, gamma, l=None, terminal_type="all")
backups.AVOID, backups.REACH_AVOID # the two mode strings
backups.check_terminal_type(s) # validates "all" | "g"All are elementwise and accept numpy arrays or torch tensors interchangeably.
How a terminal step is valued (the non-terminal blend is unaffected):
terminal_type |
terminal target | meaning |
|---|---|---|
"all" (default) |
min(l, g) |
the reach-avoid horizon condition (V_H, eq. 5b) |
"g" |
g |
the avoid terminal; also offered by the reference impl |
It is a first-class constructor kwarg on every reach-avoid learner and is ignored (harmlessly) by the avoid learners:
ReachAvoidPPO("MlpPolicy", env, terminal_type="all") # default
ReachAvoidSAC("MlpPolicy", env, terminal_type="g")
GameplayPPO("MlpPolicy", env, ctrl_action_dim=2, terminal_type="all")terminal_type is the algorithm half of a pairing whose environment half is the
task's end_criterion (when the episode ends). The composition matters:
end_criterion (env) |
terminal_type (algo) |
learned behavior |
|---|---|---|
failure (don't stop on reach) |
all |
reach deeper — value climbs with l up to the g ceiling |
reach-avoid (stop on reach) |
all |
reach and stop — terminal value capped at min(l, g) ≈ 0 at the boundary |
failure |
g |
viability-flavored; the terminal is the avoid margin |
end_criterion lives on the environment/task — see the sandbox API reference.
The two knobs are orthogonal; all pairings are constructible.
Do not run an avoid task on a reach-avoid learner by pinning l to a constant.
The reduction needs the anchor to reduce (min(l,g)=g ⟹ l ≥ g) and the
recursion to reduce (max(l,V')=V' ⟹ l ≤ V'); since V' ≤ g, that requires
l ≥ g ≥ V' ≥ l, which nothing satisfies:
l ≡ −C(large negative):V ≡ −Ceverywhere → empty safe set, with healthy-lookingep_len/ep_rew/critic_loss.l ≡ 0or+C:V ≡ g, no lookahead — coming failures never propagate.
Use the avoid column (SafetyPPO single-player, IsaacsPPO two-player). That
is what the reference does — it switches operator, never degenerates l.
from safety_sb3 import (
# single-player avoid
SafetyPPO, SafetySAC, SafetyDQN, SafetyA2C,
# single-player reach-avoid
ReachAvoidPPO, ReachAvoidSAC,
# two-player avoid (ISAACS eq. 7)
IsaacsPPO, IsaacsSAC,
# two-player reach-avoid (Gameplay Filters)
GameplayPPO, GameplaySAC,
# tensor path
TensorVecEnv, TensorVecNormalize,
TensorSafetyRolloutBuffer, TensorReachAvoidRolloutBuffer,
# buffers / policy / callbacks
SafetyRolloutBuffer, ReachAvoidRolloutBuffer,
ReachAvoidReplayBuffer, IsaacsPolicy, StdCapCallback,
SafeSuccessRateEvalCallback,
# discount (gamma) annealing — ON by default in every Safety* learner
GammaAnnealMixin, StepGammaAnneal, GeometricGammaAnneal,
make_default_gamma_schedule,
# the operators
backups,
)Training controls (see hyperparameters.md)
Every learner takes these as constructor arguments (defaults matched to the reference ISAACS codebase):
gamma_anneal(defaultTrue) — discount annealing, on by default for every learner (PPO and SAC families). The reach-avoid boundary only sharpens as γ→1, so γ is annealed from a contractive start. Default = the reference-faithful discrete-jumpStepGammaAnneal(0.99 → 0.999 @20% → 0.9999 @40%, hold); each jump resets the entropy temperature on the SAC learners.GeometricGammaAnnealis the smooth variant;Falsekeeps γ constant. Logged astrain/gamma.min_alpha/max_alpha(SAC, default floor1e-3) — clamp the learned entropy temperature (SB3's auto-α is otherwise unbounded).- Per-agent learning rates (two-player SAC) —
critic_learning_rate,dstb_learning_rate,ent_coef_lr,dstb_ent_coef_lr(eachNone→ the sharedlearning_rate), plus an optional StepLR decay (lr_schedule/lr_period/…). - Leaderboard (two-player) —
use_leaderboard,leaderboard_freq,n_eval_episodes,softmax_rationality. Throughput note: the league eval cost scales withn_eval_episodes × pairings × episode_len × (1/freq)and can dominate wall-clock (~97% at 1024 envs with the old 100k-freq / 10-episode settings). Pass aTensorVecEnvleaderboard eval env (dispatches to the on-device_eval_pair_tensor— no numpy VecEnv, no per-step host↔device sync) and prefer a highleaderboard_freq(e.g. 2M) with fewn_eval_episodes(e.g. 3) — the league is a relative ranking. This was a ~30× throughput lever at 1024 envs (≈500 → ≈19k FPS). SafeSuccessRateEvalCallback— logseval/safe_rateandeval/success_rateover N rollouts (reach-avoid vs avoid-only aware).
Isaacs* / Gameplay* take a single concatenated action Box(ctrl_dim + dstb_dim)
split by ctrl_action_dim, and require ctrl_action_dim at construction:
GameplayPPO("MlpPolicy", env, ctrl_action_dim=12, terminal_type="all")self.policy is always the control policy, so predict(), save(), and
downstream filter wrappers see the deployable controller.
bootstrap_on_timeout=False (default) treats timeouts as terminal for the backup
— correct for the g/l margin semantics, which are absolute, not returns to
bootstrap. Under terminal_type="all" a timeout gets min(l, g), which is the
horizon cutoff by construction.
# single-player reach-avoid, tensor path
from safety_sb3 import ReachAvoidPPO
model = ReachAvoidPPO("MlpPolicy", tensor_env, normalize_obs=True,
terminal_type="all", n_steps=48, batch_size=24576)
model.learn(2_000_000_000)
# two-player avoid (ISAACS), numpy path
from safety_sb3 import IsaacsPPO
model = IsaacsPPO("MlpPolicy", adv_env, ctrl_action_dim=2) # no l, no terminal_type
model.learn(5_000_000)