Release notes¶
v0.4.0 — the MAP rename (BREAKING, no shims)¶
Every public learner is renamed. This release is a migration guide.
There are no aliases, no deprecation warnings, and no compatibility shims. Old
code will fail at import with a plain ImportError, which is deliberate: the
previous rename (v0.2.0) changed what Isaacs* meant while keeping the name, so
existing code kept importing and silently trained a different algorithm. A hard
break cannot do that.
If you need the old names, pin v0.3.x:
safety_sb3 @ git+https://github.com/SafeRoboticsLab/safety-stable-baselines.git@v0.3.0
The rename is the largest change, but v0.4.0 also lands a certificate-soundness fix (the entropy bonus is removed from the safety critic target — §7, validated against an HJ oracle), a CUDA-graph SAC speedup (§8), and new docs (§9: the MAP convention and the oracle-validation study).
Why¶
Class names encoded paper lineage (IsaacsPPO, GameplaySAC), so using the library
required knowing which paper introduced which game — and, after v0.2.0, which
version of which name meant which game. Names now encode the algorithm instead:
Here's the MAP to navigate the codebase — Mode. Algorithm. Players.
M = Mode Safety | ReachAvoid | Cumulative (which Bellman operator)
A = Algorithm PPO | SAC | A2C | DQN (which RL method)
P = Players 1P | 2P (single | zero-sum game)
SafetyPPO1P, ReachAvoidSAC2P, CumulativePPO1P — read the name, know the
algorithm. The papers are still cited in the docstrings; you just no longer need
them to pick a class.
1. Rename table¶
| v0.3.x | v0.4.0 |
|---|---|
SafetyPPO |
SafetyPPO1P |
ReachAvoidPPO |
ReachAvoidPPO1P |
IsaacsPPO |
SafetyPPO2P (two-player avoid) |
GameplayPPO |
ReachAvoidPPO2P |
SafetySAC |
SafetySAC1P |
ReachAvoidSAC |
ReachAvoidSAC1P |
IsaacsSAC |
SafetySAC2P (two-player avoid) |
GameplaySAC |
ReachAvoidSAC2P |
SafetyA2C |
SafetyA2C1P |
SafetyDQN |
SafetyDQN1P |
IsaacsPolicy |
TwoPlayerSACPolicy (now in safety_sb3.policies) |
Buffers keep a Mode prefix and no player count — SafetyRolloutBuffer,
ReachAvoidRolloutBuffer, and the Tensor* twins are unchanged names. A buffer
cannot tell how many players filled it, and the two-player learners build two
instances of the very same class.
Module files were renamed to match; every isaacs_* file is gone:
| v0.3.x | v0.4.0 |
|---|---|
safety_ppo.py |
ppo_base.py + ppo_1p.py |
isaacs_ppo.py |
ppo_2p.py |
reach_avoid_ppo.py |
reach_avoid_mixin.py |
safety_sac.py, reach_avoid_sac.py |
sac_1p.py |
isaacs.py |
sac_2p.py |
safety_a2c.py / safety_dqn.py |
a2c.py / dqn.py |
safety_buffers.py |
buffers_rollout.py |
tensor_buffers.py |
buffers_tensor.py |
isaacs_buffers.py |
buffers_replay.py |
isaacs_policy.py |
policies.py |
2. New classes (the naming law implies them)¶
CumulativePPO1P,CumulativeSAC1P,CumulativeA2C1P,CumulativeDQN1P— ordinary reward-maximizing RL as a first-class Mode, so a nominal baseline runs through every line of the same code as the safety learners. Not safety learners: the reward is a reward, andV ≥ 0means nothing.ReachAvoidA2C1P— new, and free: the Mode axis costs one line.AbstractPPO1P/AbstractPPO2P/AbstractSAC1P/AbstractSAC2P/AbstractA2C/AbstractDQN— the extension points. The*1P/*2Ploops run directly withmode=.LeagueEvaluator(insafety_sb3.leaderboard) — the two-player league's scorer, lifted out of the SAC learner so all league machinery lives in one module.
ReachAvoidDQN1P does not exist and will not. SB3's discrete-action replay
buffer carries no target margin l(s), so the reach-avoid operator is not computable
there. mode="reach-avoid" raises at construction rather than computing something
else. Use ReachAvoidSAC1P, or add an l-carrying discrete replay buffer.
3. Removed¶
- All compatibility surface. The v0.2.0 rename warning in
__init__.pyand every "before v0.2.0…" note are gone. v0.2.0 is far enough back that the archaeology cost more than it explained;git loghas it. _is_reach_avoid. It existed only so avoid-mode subclasses could inherit reach-avoid machinery and switch it off. Under the new tree the avoid learners never receive that machinery — reach-avoid is a mixin (_ReachAvoidPlumbing) composed onto both player counts, not a base class — so the predicate became unreachable. Deleting it is a correctness win, not tidiness: previously everyl-touching line needed a guard, and a single missed guard fed a target margin to an avoid learner.numpy_rollout_buffer_class/tensor_rollout_buffer_classclass attributes. Buffers are now looked up from_MODEviabuffers_rollout.rollout_buffer_classes(mode), so a buffer can no longer disagree with the mode its owner declares. Passrollout_buffer_class=to override.
4. Behavioral changes¶
The refactor is otherwise behavior-preserving: seeded loss and parameter traces for all ten learners (4 PPO, 4 SAC, A2C, DQN) on the toy envs reproduce v0.3.x bitwise, including the two-player league operation traces. Three intentional exceptions:
- Both two-player rollout buffers now get the same kwargs.
terminal_typereached the ctrl buffer but not the dstb buffer, so with a non-defaultterminal_typethe two players scored terminal states differently. No effect at the default ("all"). - Two-player log keys
isaacs/*→game/*(game/phase_is_dstb,game/rollouts_done,game/archived_dstb,game/lr_ctrl,game/lr_dstb). Update any dashboard that plots them. - Default league directories renamed —
isaacs_ppo_leaderboard→ppo_2p_leaderboard,isaacs_leaderboard→sac_2p_leaderboard. Passleaderboard_dir=to keep an existing archive path.
Also, AbstractPPO and AbstractSAC now refuse to run instead of inheriting
SB3's stock loop. Instantiating a bare base previously would have converged to the
cumulative fixed point while _MODE claimed a safety operator.
5. Migrating¶
A mechanical rename covers almost all call sites:
sed -i -E 's/\bIsaacsPPO\b/SafetyPPO2P/g; s/\bGameplayPPO\b/ReachAvoidPPO2P/g;
s/\bIsaacsSAC\b/SafetySAC2P/g; s/\bGameplaySAC\b/ReachAvoidSAC2P/g;
s/\bReachAvoidPPO\b/ReachAvoidPPO1P/g; s/\bReachAvoidSAC\b/ReachAvoidSAC1P/g;
s/\bSafetyPPO\b/SafetyPPO1P/g; s/\bSafetySAC\b/SafetySAC1P/g;
s/\bSafetyA2C\b/SafetyA2C1P/g; s/\bSafetyDQN\b/SafetyDQN1P/g;
s/\bIsaacsPolicy\b/TwoPlayerSACPolicy/g' $(git ls-files '*.py')
Apply the two-player substitutions before the single-player ones (SafetySAC2P
contains SafetySAC). Saved checkpoints (.zip) store the class path, so a model
trained on v0.3.x will not load on v0.4.0 — pin v0.3.x to load it, or retrain.
6. Also in v0.4.0 — the abstract-DP refactor¶
This landed on the same branch and ships here. It is behavior-preserving on its own:
no default changed, and the learners reproduce v0.3.0 bit-for-bit except SafetyDQN1P
(~1e-6 relative, pure float reassociation) and ReachAvoidSAC1P when
min_alpha/max_alpha are set away from their defaults (a bug fix) — both below.
Class names below are the v0.4.0 ones.
- Every learner now takes its backup as a parameter.
AbstractSAC1Pholds the single-player SAC update loop and callsbackups.target(self._MODE, …);SafetySAC1P,ReachAvoidSAC1PandCumulativeSAC1Pare one-line_MODEspecializations of it (previouslyReachAvoidSAC.train()was a hand-copy ofSafetySAC.train()). The mode is also selectable per instance:Cls(…, mode="reach-avoid"). The two-player learners keep their owntrain()(twin actors, per-actor entropy) and share only the backup. - Bug fix —
min_alpha/max_alphanow apply toReachAvoidSAC1P. The copiedtrain()had dropped the_clamp_entropy_temps()call, so the entropy- temperature floor/ceiling silently did nothing on the single-player reach-avoid path (measured: α = 0.763 after 1500 steps withmin_alpha=0.9). Two-player SAC was never affected (it clamps inline). If you trained RA-1P with a non-defaultmin_alpha, α was unclamped; retrain for the intended behavior. - New mode
backups.CUMULATIVE— the standard discounted-return backupr + γ·V'. Plain reward-maximizing RL is now a mode of this library, so a nominal baseline runs through the same actor/critic/entropy code as the safety learners. v0.4.0 gives it first-class classes (CumulativeSAC1P,CumulativePPO1P,CumulativeA2C1P,CumulativeDQN1P);mode="cumulative"also still works on any learner, andCumulativeRolloutBuffer≡ SB3's GAE. It is not a safety operator: its first argument is a reward andV ≥ 0carries no certificate meaning. SafetyDQN1Pno longer inlines its own backup. It computed(1 − γ·nt)·g + γ·nt·min(g, V')by hand; that is the same expression asbackups.avoid_target, which it now calls. Reassociating the terms changes float rounding, so long DQN runs drift ~1e-6 relative from v0.3.0.- The on-policy buffers (
SafetyRolloutBuffer/ReachAvoidRolloutBufferand their tensor twins) likewise share one_targetthat dispatches on_MODE.
7. Certificate soundness — the entropy bonus is gone from the safety critic target¶
This is a behavioral fix for every SAC safety/reach-avoid learner; retrain to
benefit. The SAC critic target subtracted the maximum-entropy bonus
(next_q -= ent_coef · log_prob) inside the Bellman backup. That term is correct
for the cumulative backup (r + γ·V', entropy-regularized RL), but the
safety/reach-avoid backups are a min/max of margins, not a discounted
reward sum — adding an entropy bonus there biases the value and breaks the
{V ≥ 0} certificate. v0.4.0 removes the entropy term from the safety and
reach-avoid critic targets (sac_1p.py, sac_2p.py); it stays in the cumulative
target, where it belongs. PPO was never affected — its entropy lives in the loss,
not the value target.
Measured against a Hamilton-Jacobi reach-avoid oracle on the bicycle5d
env: on-policy certificate precision — P(policy reach-avoids | V̂ ≥ 0) — is
0.98 with the fix vs 0.05 with the bug. The full study, with the oracle
solve and the reliability plots, is the new Oracle validation
page. If you trained a SAC safety/RA twin on ≤ v0.3.x, its value function is
unsound as a certificate — retrain on v0.4.0.
8. Performance — the SAC update is CUDA-graph-capturable¶
The single- and two-player SAC update loops no longer issue in-loop host↔device syncs and use a fused Polyak target update, so the whole gradient step can be captured as a CUDA graph. On the GPU-resident tensor path this removes the per-step launch overhead that dominated at small batch sizes; no default or result changes (seeded traces are unchanged), it is purely a throughput fix.
9. New documentation¶
- The MAP convention — the canonical Mode·Algorithm·Players naming law, the full learner roster, the abstract class hierarchy, and how the environment layer composes with it. Read this first.
- Oracle validation — the
bicycle5dreach-avoid learner vs. a numerical HJ oracle: the soundness result behind §7.
v0.3.0 — two-player SAC + reference-faithful discount annealing¶
Additive release (no anchor/API breakage vs v0.2.x), with one behavioral default
change: reach-avoid learners now anneal the discount γ by default. Retrain
to benefit; pin the schedule off (gamma_schedule=None) to reproduce v0.2.x exactly.
1. Two-player SAC on the tensor path¶
GameplaySAC (two-player reach-avoid, Gameplay Filters eq. 6a) and IsaacsSAC
(two-player avoid, ISAACS) — the off-policy analog of GameplayPPO/IsaacsPPO:
a twin critic over the concatenated [ctrl, dstb] action, two soft actors, two
entropy temperatures, the safety/reach-avoid backup, and a self-play leaderboard.
2. Discount (γ) annealing — ON by default¶
Ports safe_adaptation_dev's reference-faithful schedule. StepGammaAnneal
(discrete jumps 0.99 → 0.999 → 0.9999, the default) and GeometricGammaAnneal
(continuous), via a GammaAnnealMixin across the PPO and SAC families. On each
discrete jump the entropy temperature α is reset (re-exploration) and floored
at min_alpha. This is what let two-player RA-SAC recover/beat the reference on
go2_stabilize.
3. Per-agent LR / entropy controls (two-player SAC)¶
Independent critic_lr / dstb_lr / ent_coef_lr / dstb_ent_coef_lr, fixed or
StepLR schedules, and a min_alpha/max_alpha entropy-temperature clamp.
4. Leaderboard throughput — on-device league eval¶
_eval_pair_tensor runs the self-play pairing evaluation entirely on-device
(no numpy VecEnv round-trip). With throughput-tuned defaults (larger eval
interval, fewer episodes) this is ~30× faster at 1024 envs — the leaderboard no
longer stalls collection.
5. Eval + reference env¶
SafeSuccessRateEvalCallback logs safe-rate / success-rate to wandb. Added the
Bicycle5D reference reach-avoid environment + docs showreel. Full knob reference
in docs/hyperparameters.md.
v0.2.0 — the reach-avoid anchor fix (BREAKING; retraining required)¶
If you trained a reach-avoid or ISAACS policy with v0.1.0, its value function is not the value function you think it is, and the policy needs retraining. This release fixes a wrong Bellman operator, renames two classes, and adds the two learners the library was missing. Read all four sections below before upgrading.
1. The bug: the reach-avoid anchor was g, not min(l, g)¶
Every backup here has the shape
target = (1 - γ)·anchor + γ·backup
1 - γ is the per-step termination probability, so the anchor is the
"episode terminates now" payoff. The two problems score that differently:
| problem | anchor | stopping now is a win iff |
|---|---|---|
| avoid | g |
you are safe |
| reach-avoid | min(l, g) |
you are in the target and safe |
v0.1.0's PPO family (ReachAvoidPPO, IsaacsPPO, and both rollout buffers)
anchored reach-avoid on g. Consequences:
- "Stay safe forever, never reach" became a fixed point at
V = g > 0— a win. Its true reach-avoid value ismaxₜ min(lₜ, min_{s≤t} gₛ) = maxₜ lₜ < 0, a loss. The operator had no term that punishes not reaching:lentered only throughmax(l, V'), which can only ever raise value near the target. In practice the learner reliably converged to a safe policy that never attempts the task — the loiter optimum — and the critic scored it as success. - The fixed point was neither the reach-avoid value nor the avoid value.
RSS'21's under-approximation theorem (
RA_γ ⊆ RA, nested increasing in γ) does not apply to it, so the critic is unsound to filter with: it can wrongly certify reachability. RSS'21 says of thisg-anchored form (its eq. 13) that it approximates "safety or liveness problems, but not both". - The SAC family (
ReachAvoidSAC, the oldIsaacsSAC) was already correct and is unchanged by the fix. v0.1.0 shipped two different fixed points under one name: PPO and SAC disagreed, and the docs asserted the PPO convention applied to all backups.
Reference: safe_adaptation_dev/utils/train.py, mode='reach-avoid'; Hsu,
Rubies-Royo, Tomlin, Fisac, RSS 2021, eq. 15; Hsu et al., Gameplay Filters,
eq. 6a. The anchor is literally the same expression as those papers'
finite-horizon terminal condition V_H = min(l, g) (Gameplay Filters eq. 5b).
Where the bad rule came from. A 2026-07-04 pendulum validation read a stall
under min(l,g) as a bug and "fixed" it by switching to the g anchor. The stall
was almost certainly the correct operator honestly reporting infeasible spawns
(reach-avoid returns V < 0 everywhere when the target is unreachable, the policy
goes indifferent, and episodes end early). The g anchor "fixed" it by handing
back a dense, always-achievable survive signal — ep_len went up, and that was
recorded as validation. The rule then propagated into the README, BEST_PRACTICES,
both buffers, and downstream projects.
2. BREAKING: Isaacs* renamed, and the name now means something else¶
ISAACS (Hsu, Nguyen, Fisac 2022) is a two-player avoid game: its paper defines
only a failure set and margin g, has no target set and no l anywhere, and
anchors on g. The string "reach-avoid" appears in it exactly once, in the
bibliography. Gameplay Filters is the paper that extends ISAACS to reach-avoid —
and changes the anchor when it does.
v0.1.0's IsaacsPPO/IsaacsSAC were the reach-avoid game, i.e. Gameplay Filters
wearing the ISAACS name, and the library had no two-player avoid learner at all.
The 2x2 is now complete and paper-faithful:
| avoid | reach-avoid | |
|---|---|---|
| single-player | SafetyPPO / SafetySAC / SafetyDQN / SafetyA2C |
ReachAvoidPPO / ReachAvoidSAC |
| two-player | IsaacsPPO / IsaacsSAC (NEW — eq. 7) |
GameplayPPO / GameplaySAC (renamed) |
⚠️
IsaacsPPOandIsaacsSACsilently changed meaning. An unmodifiedfrom safety_sb3 import IsaacsPPOstill imports, and now gives you the avoid game. If you were doing two-player reach-avoid, rename toGameplayPPO/GameplaySAC. If you were doing two-player avoid via thel_negtrick (below), the newIsaacsPPOis what you actually wanted.
3. l_neg / l_zero are dead: avoid is NOT a reach-avoid instance¶
A common workaround was to run an avoid task on a reach-avoid learner by pinning
l to a constant — usually l ≡ -CLAMP ("l_neg"), on the argument that
max(l, γV') = γV' reduces the backup to avoid. That argument only ever held
under the g anchor, which is the bug: it reasons about the recursion and never
mentions the anchor.
Under the correct operator, the reduction needs both
- the anchor to reduce:
min(l, g) = g⟹l ≥ g, and - the recursion to reduce:
max(l, V') = V'⟹l ≤ V',
and the avoid recursion caps V' ≤ g, so you would need l ≥ g ≥ V' ≥ l. No l
satisfies it. Concretely:
l_neg(large negative) buys the recursion and destroys the anchor:V ≡ leverywhere, independent of the dynamics ⟹{V ≥ 0} = ∅, an empty safe set — whileep_len,ep_rewandcritic_lossall look healthy. Silent.l_zero/l_pos(≥ 0 everywhere) buys the anchor and destroys the recursion: the target is the whole space, so you are "already done" att=0,max(l, ·)clips every negative future, andV ≡ g— a myopic "am I safe right now" with no lookahead. Coming failures never propagate. (This one was already known to destroy a warm-started policy within 25M steps.)
Use the avoid learner for avoid problems. That is what the reference does — it
switches operator (mode='safety') rather than hunting for a clever l. The new
two-player IsaacsPPO/IsaacsSAC exist precisely so this is possible.
4. What you need to do¶
- Retrain every
ReachAvoidPPO/ old-IsaacsPPOpolicy. The old value functions do not correspond to any reach-avoid problem; results computed from them are not backed by the theory.SafetyPPO/SafetySAC(avoid) andReachAvoidSAC/old-IsaacsSAC(reach-avoid) are unaffected — they were already correct. - Rename two-player reach-avoid uses:
IsaacsPPO→GameplayPPO,IsaacsSAC→GameplaySAC. - Delete
l_neg/l_zeroand move those tasks toIsaacsPPO/IsaacsSAC(two-player avoid) orSafetyPPO/SafetySAC(single-player avoid). - Re-examine your
g/ldesign. Now that reach-avoid actually punishes not-reaching, formulations tuned against the old operator will behave differently — often better, since the pathology the old anchor produced was exactly "never attempt". In particular, anyp*-style risk-dial calibration that assumed loitering is worthg > 0needs re-deriving: under the correct operator loitering is worthl < 0, so attempting only has to beat a negative baseline and the break-even attempt probability is lower. - If you consume a reach-avoid critic as a filter (value filtering, Q-CBF/R-CBF), re-derive your guarantee. The old critic had no under-approximation property.
Also in this release¶
safety_sb3/backups.py— both operators now defined once and shared by every learner. The v0.1.0 bug survived because the backup was re-implemented at four call sites, which drifted; that is now structurally impossible.ReachAvoid*acceptterminal_type("all"→min(l, g), default and the horizon condition;"g"→g), matching the reference's own parameter.- Tests: reach-avoid anchor and loiter fixed-point tests, a
terminal_typetest, and numpy↔torch buffer parity. (v0.1.0'stests/test_backups.pycovered only the numpy PPO buffers and never tested the anchor; its parity was untested, and its RA smoke test asserted an ordering that the avoid term alone already produced — so it passed without exercising reach at all.) - Fixed:
IsaacsPPO's dstb rollout buffer hardcodedReachAvoidRolloutBufferon the numpy path instead of honouring the class's buffer attribute.