Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
7a78daf
Consume the pinned v2.2 controller model release
rshi159 Jul 27, 2026
35a5738
Sync the v2.2 int8 deployment lock
rshi159 Jul 27, 2026
a4ec22e
Sync the channel-scale model release lock
rshi159 Jul 27, 2026
fb40058
Make the reach-avoid backup unit-testable and ship its witness
rshi159 Jul 27, 2026
272406c
Restore the v2.2 SSB reach-avoid grid contract
rshi159 Jul 27, 2026
038d4af
Bind DRABE properties to the shipped operator
rshi159 Jul 28, 2026
72214ff
Document the v2.2 model-release dependency
rshi159 Jul 28, 2026
5f418f6
Ignore preserved certificate-investigation outputs
rshi159 Jul 28, 2026
88f96bd
Sync the audited v2.2 model release lock
rshi159 Jul 28, 2026
a075bc6
Add the v2.1 to v2.2 certified-safe-set comparison figure
rshi159 Jul 28, 2026
69efd97
Consume the central ODD contract lazily
rshi159 Jul 28, 2026
d113e4f
Fail closed on unconverged ODD grids
rshi159 Jul 28, 2026
14177e6
Pin the historical model-update figure domain
rshi159 Jul 28, 2026
9c48c29
Regenerate the symmetric v2.2 ODD grid
rshi159 Jul 28, 2026
20b8645
Fix the historical v2.2 grid provenance pin
rshi159 Jul 28, 2026
72fb2ca
Sync the policy-tier ODD contract lock
rshi159 Jul 28, 2026
e697284
Add the active-contract safe-set figure and re-pin sidecars
rshi159 Jul 28, 2026
9d9e453
Sync the policy-tier ODD contract lock
rshi159 Jul 28, 2026
1d464ed
Measure the simulated tipping threshold versus wheel width
rshi159 Jul 28, 2026
9625097
witness: static bilateral rollover measurement on release mass proper…
rshi159 Jul 28, 2026
10d8d24
consume the generated roll-constraint artifact; add relock
rshi159 Jul 28, 2026
f1a418e
relock to d54308fa after the roll sign-convention release
rshi159 Jul 28, 2026
c593804
regenerate the reach-avoid grid under the corrected roll bound
rshi159 Jul 28, 2026
56a2303
figure: safe set under the corrected roll bound
rshi159 Jul 28, 2026
f220051
re-pin to the final release; grid re-solve reproduces exactly
rshi159 Jul 28, 2026
0e2052e
codify the ownership boundary; record what changed under the math layer
rshi159 Jul 28, 2026
b5053c4
grid: re-solve the ODD reach-avoid grid under the v2.2 release
rshi159 Jul 29, 2026
968d8d3
re-pin to the corrected controller release; propagate the +-6 ruling
rshi159 Jul 29, 2026
b1f6040
checkpoints: authorize the v2.2 reach-avoid policy for EVALUATION ONLY
rshi159 Jul 29, 2026
8dc28ba
docs: addendum for Jaime -- governor fix, and four limitations charac…
rshi159 Jul 29, 2026
dc63538
docs: the three things Jaime needs before he can rule
rshi159 Jul 29, 2026
02a3c5e
docs: correct 10(a) -- the early-stopping residual is unbounded, with…
rshi159 Jul 29, 2026
7299f23
model layer: correct an inverted sign comment, a misnomer, and a no-o…
rshi159 Jul 29, 2026
cc599e2
docs: results figure -- model-layer corrections, both safe-set views,…
rshi159 Jul 29, 2026
4fc0497
docs: section 14 -- ownership tables, open-item registry, and agent p…
rshi159 Jul 29, 2026
f251e45
SANITIZE: this repository is public -- no robot model values may live…
rshi159 Jul 29, 2026
bea9856
mujoco_model: release-mesh visual overlay, resolved from the private …
rshi159 Jul 30, 2026
36e1ae2
mujoco_model: dress ALL mesh links, not just chassis+wheels
rshi159 Jul 30, 2026
24afa99
mujoco_model: pose the mesh overlay at the TUCKED configuration
rshi159 Jul 30, 2026
d941104
mujoco_model: quaternion conversion valid for ALL rotations -- pi was…
rshi159 Jul 30, 2026
9c7a9e2
mujoco_model: hide contact-branch primitives under the mesh overlay too
rshi159 Jul 30, 2026
c04f364
test: pin the pi-fold orientation -- the w=0 quaternion collapse stay…
rshi159 Jul 30, 2026
c3da83f
mesh overlay: close the five robustness findings from the independent…
rshi159 Jul 30, 2026
dd89a98
re-pin to controller release a9595e99 (knee-wheel geometry addition)
rshi159 Jul 30, 2026
50d1c49
fix: my lock re-pin blocked the only authorized checkpoint, breaking …
rshi159 Jul 30, 2026
5c5515c
docs: known limitations, and correct commit 50d1c49's overstated veri…
rshi159 Jul 30, 2026
b18f215
re-pin to controller release 512fc77d (BC_ISFINITE f32 soft-float fix)
rshi159 Jul 31, 2026
9b34c02
re-pin to controller release e9ac4b9d (BC_ISFINITE + 3x3 fallback)
rshi159 Jul 31, 2026
5467e29
re-pin to controller release 05ce740d (iterative projection removed)
rshi159 Jul 31, 2026
70d2df2
re-pin to controller release 5d7c918f (fused batched value evaluation)
rshi159 Jul 31, 2026
0aa8f22
re-pin to controller release 335e3138 (dead value_after removed)
rshi159 Jul 31, 2026
9c7b1f5
tests: enforce the public-repo boundary — no robot model values under…
rshi159 Jul 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,17 @@ experiments/
*.tar
*.rar
*.7z
*.mp4
*.mp4

# Preserved on exploratory/v2.2-safety-investigation; do not let local
# certificate-investigation outputs leak into the focused model consumer.
vault/data/articulated_fcert_residuals_v2_2.npz*
vault/data/ssb_eval_odd_grid_v2_2_local_residual.npz*
vault/data/ssb_eval_odd_grid_v2_2_mean_corrected.npz*
vault/diagnostics/

# PUBLIC REPO: the controller lock and all model-derived artifacts live in the
# PRIVATE vault-controller sibling. Only sha256 pins and hash sidecars are tracked.
vault/data/MODEL_INPUTS.lock.json
vault/data/grid_reachavoid_odd.npz
vault/docs/*.pdf
59 changes: 59 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Ownership boundary for the vault safety package.
#
# This repository holds two layers with different authorities, and the split is
# currently only visible through `git log`. Writing it down is the point of this
# file: the reach-avoid safety-filter mathematics is Jaime's domain, and the
# four-state model and its contracts are Robert's. Changes should respect that
# regardless of who is holding the keyboard.
#
# The rule that matters:
#
# * MODEL/CONTRACT changes are ANNOUNCED to the math layer's owner before
# landing. They move the ground the math stands on -- a corrected bound or a
# widened ODD changes results without changing a line of the math.
# * MATH-LAYER changes are the math owner's call. A defect found there is
# reported with analysis, not silently fixed.
#
# Everything below is descriptive of the actual authorship (see `git log --reverse`
# on each file), not an aspiration.

# ---------------------------------------------------------------------------
# Reach-avoid safety-filter mathematics -- Jaime Fernandez Fisac
# Introduced in 2c4f73d (2026-07-24).
# ---------------------------------------------------------------------------
/vault/reach_avoid_sac.py @jfisac
/vault/reach_avoid_value.py @jfisac
/vault/reach_avoid_eval.py @jfisac
/vault/reach_avoid_slice.py @jfisac
/vault/train_reach_avoid.py @jfisac
/vault/safety_filter.py @jfisac
/vault/tests/test_drabe_operator.py @jfisac

# ---------------------------------------------------------------------------
# Upstream avoid-only SAC -- Haimin Hu, vendored.
# The entropy-bonus defect documented in reach_avoid_sac.py originates here, not
# in the reach-avoid extension.
# ---------------------------------------------------------------------------
/safety_sb3/ @haiminhu

# ---------------------------------------------------------------------------
# Four-state model, certified plant, and contracts -- Robert Shi.
# Introduced in 4340e3b (2026-06-25) as the "4-state balance-safety package".
# These define what the math layer is reasoning ABOUT.
# ---------------------------------------------------------------------------
/vault/f_cert.py @rshi159
/vault/grid.py @rshi159
/vault/filter.py @rshi159
/vault/distill.py @rshi159
/vault/config.py @rshi159
/vault/dynamics.py @rshi159
/vault/env.py @rshi159
/vault/model_release.py @rshi159
/vault/mujoco_*.py @rshi159
/vault/tools/ @rshi159
/vault/data/ @rshi159

# The ODD contract, the roll constraint and the generated model artifacts live in
# the vault-controller repository and are pinned here by MODEL_INPUTS.lock.json.
# They are the model layer's authority; this repo consumes them and must never
# restate their values as literals.
26 changes: 17 additions & 9 deletions vault/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,18 @@ Certifiable reach-avoid safety for the Vault robot's balance subsystem, over the
value filter + the SafetySAC RL env + a MuJoCo plant for adversarial RL / ISAACS.

## Scope / boundaries
- This package shares ONLY the 4-state reduced dynamics + the safety method + the MuJoCo balance
plant. It must NOT grow a dependency on the Vault hybrid simulator, the Julia/symbolic framework,
or the full robot model.
- The reduced dynamics live in `dynamics.py` (the vendored opt6 kernel `csrc/reduced_opt6.c`,
geometry baked in). `f_cert.py` is the ONE source of the certified one-step model + margins —
the env, the grid solver, and the filter all call it. Don't fork the dynamics.
- This package shares ONLY the 4-state reduced dynamics + the safety method +
the MuJoCo balance plant. It consumes the pinned model release from the
sibling `vault-controller` checkout and must NOT grow a dependency on the
Vault hybrid simulator, Julia/symbolic framework, or full robot model.
- `model_release.py` locates `vault-controller` through
`VAULT_CONTROLLER_ROOT` (default `../vault-controller`), requires this
package's `data/MODEL_INPUTS.lock.json` to byte-match the release lock, and
hash-checks artifacts on first access.
- The reduced dynamics live in `dynamics.py`, which compiles the release's
single opt6 kernel. `f_cert.py` is the ONE source of the one-step model +
margins; the env, grid solver, and filter all call it. Don't fork the
dynamics or vendor another kernel.
- The deployed controller is NOT here — it's the separate private `vault-controller` repo.
`evaluate.py` imports it and fails loud if absent (no fallback controller, by design).

Expand All @@ -19,13 +25,15 @@ Run as modules from the repo root: `python -m vault.<name>`.
- `python -m vault.grid` regenerate the value function -> `data/grid_reachavoid_odd.npz`
- `python -m vault.distill` conservative `V_mlp` from the grid -> `models/`
- `python -m vault.train` SafetySAC reach-avoid V + pi_safe
- `python -m vault.evaluate` filter on the deployed controller (needs `vault-controller`)
The reduced-dynamics kernel compiles on first import (needs `cc`/`gcc`); it is cached.
- `python -m vault.evaluate` filter using the controller source checkout
The reduced-dynamics kernel compiles on first artifact use (needs `cc`/`gcc`);
it is cached.

## Conventions
- Python, Google style. State `x=[v,theta,theta_dot,psi_dot]`; control `u=[tau_L,tau_R]` (N·m);
`mu` = friction. The value function is mu-aware.
- Constants live in `config.py` — import from there, don't hard-code params or ODD bounds.
- Import runtime constants through `config.py`. Robot and ODD values originate
in the hash-locked controller release; do not hard-code or duplicate them.
- Keep modules importable without heavy deps unless used (torch only in distill/filter/train;
mujoco only in mujoco_plant/evaluate).

Expand Down
57 changes: 57 additions & 0 deletions vault/KNOWN_LIMITATIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Known limitations of the v2.2 consumer (read before trusting a demo run)

## The joystick/teleop demo has no v2.2-valid monitor checkpoint

`vault/teleop.py` is unmodified and its wiring works, but on the v2.2 release there
is no correct policy to give it. A partial run can easily look like a working demo,
so this is stated explicitly.

- The default monitor, `contact_safety_sac_v3`, is quarantined by
`vault/data/checkpoints_manifest.json` as incompatible with this release. That
quarantine is **correct and pre-existing**: it is a v2.0/v2.1-era contact policy
that was never retrained against v2.2. `_dry_run` loads a policy
unconditionally, so with the default model the demo does not start at all.
- The only checkpoint authorized under this release,
`reach_avoid_safety_sac_v22`, is **not a substitute**:
1. **Value semantics differ.** `ValueMonitor` (`vault/safety_filter.py`) reads the
critic as an avoid-only safety value. `ReachAvoidSafetySAC`
(`vault/reach_avoid_sac.py`) deliberately changes the backup to
`V(x) = min(g(x), max(l(x), V(x_next)))`, so reaching the target set "cashes
in" value. Those numbers do not mean the same thing. `teleop` loads via
`load_safety_sac` (the parent class); `load_reach_avoid_safety_sac` exists but
is unused here, and **nothing gates monitor class against checkpoint class**.
2. It is **undertrained and evaluation-only** (300k steps; 61.7% reached / 17.3%
limbo / 21.0% failed at mu=0.8). It has never been claimed as a control policy.

### Correction to commit 50d1c49's message

That commit reports `teleop --dry-run 150` completing with "margins positive
throughout". That validated the **control-loop wiring only** and overstates what was
shown. Two corrections:

- The run used `reach_avoid_safety_sac_v22` as the monitor, i.e. the semantic
mismatch above. The resulting "filter overrode 100% of steps" is an **artifact of
that mismatch** — it is not evidence the filter is working hard, nor that it is
broken. Do not cite that number in either direction.
- The margins stayed positive largely because the robot barely moves in the run's
1.5 s window (150 steps x 0.01 s). With the filter disabled the same scripted
command only reaches about 0.05 m/s, so filtered-vs-unfiltered is a far smaller
difference than "override on every step" suggests.

### What would fix it

Operator/maintainer decision — replacement RL training is authorized-only and was
not performed here. Either retrain a contact/avoid-only monitor against v2.2, or
point `teleop` at a monitor whose value semantics match its checkpoint. Either way,
gating the monitor/checkpoint pairing would stop this mismatch recurring silently.

Longer form, with the release-side context: `vault-controller`
`docs/ssb_handoff/CHANGES_TO_THE_MODEL_LAYER.md` section 15.

## Not a limitation here: the missing opt6 MVE/batch4 entries

The v2.2 released opt6 kernel lacks its Arm Helium (M55) vector entries. That is a
**firmware-side** gap and does not affect this repo: `vault/dynamics.py` binds only
the scalar `reduced_struct_fjac_f32` entry, whose numerics are unchanged, and
nothing here references `batch4`. Recorded so the question does not have to be
re-derived.
55 changes: 38 additions & 17 deletions vault/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,22 @@ This is **one of two repos**:
| repo | contents |
|------|----------|
| **this** (`safety-stable-baselines/vault`) | the safety method + RL + MuJoCo sim |
| **`vault-controller`** (private) | the deployed C iLQR balance controller — `evaluate.py` imports it |
| **`vault-controller`** (private) | authoritative v2.2 model release and C iLQR controller source |

## Module map
| module | what it is |
|--------|-----------|
| `config.py` | single source of robot params, the ODD, and disturbance bounds |
| `dynamics.py` (+ `csrc/`) | opt6 reduced 4-state dynamics — `f` + analytic Jacobians (vendored C kernel) |
| `model_release.py` | strict loader for the sibling controller release and model-input lock |
| `config.py` | lazy adapter for the release-backed robot and central ODD contract |
| `dynamics.py` | opt6 reduced dynamics compiled from the single kernel in `vault-controller` |
| `f_cert.py` | the certified one-step model + ODD margins (one source for env/grid/filter) |
| `grid.py` | 4D grid HJ reach-avoid value iteration — **regenerate the value function** |
| `distill.py` | conservative deployable `V_mlp` distilled from the grid value |
| `filter.py` | least-restrictive CBF-QP value filter (`from_mlp` / `from_grid`) |
| `filter.py` | ODD grid filter (`from_grid`), release MLP, and explicit capability-grid loader |
| `env.py` | `BalanceSafetyEnv` — fast f_cert reach-avoid RL env (SafetySAC) |
| `train.py` | SafetySAC training (reach-avoid V + `pi_safe`) |
| `mujoco_plant.py` (+ `mujoco_model.py`) | high-fidelity MuJoCo plant + adversary disturbance hook |
| `evaluate.py` | in-the-loop filter eval on the **deployed controller** (needs `vault-controller`) |
| `evaluate.py` | in-the-loop filter evaluation using the controller source checkout |

## Install
Requires **Python 3.10+** and a **C compiler** (`cc`/`gcc`) on PATH — the reduced-dynamics kernel
Expand All @@ -40,13 +41,29 @@ cd safety-stable-baselines
pip install -e . # provides safety_sb3 + stable-baselines3
pip install -r vault/requirements.txt

# 3. verify the install (compiles the kernel, then a ~10s grid solve)
# 3. clone the exact controller model release beside this checkout
git clone <vault-controller-url> ../vault-controller
git -C ../vault-controller checkout feature/v2.2-model-release
export VAULT_CONTROLLER_ROOT="$PWD/../vault-controller"

# 4. verify the install (validates the lock, compiles the kernel, then solves a smoke grid)
python -m vault.grid --smoke # should converge + print a safe-set %
```

To run `evaluate.py` (the filter on the deployed controller), also clone + build **vault-controller**:
All model-dependent modules require the pinned `vault-controller` checkout. By
default it is located at `../vault-controller`; set `VAULT_CONTROLLER_ROOT` to
an alternate path. On first artifact access, `model_release.py` requires
`vault/data/MODEL_INPUTS.lock.json` to be byte-identical to
`vault-controller/models/MODEL_INPUTS.lock.json` and verifies each consumed
artifact hash.

The ODD bounds, grid axes and resolutions, control bound, friction slices, and
disturbance assumptions are declared once in
`vault-controller/models/source/odd_contract.json`. This package reads them
through `config.py`; it does not maintain a second set of literals.

To run `evaluate.py`, also build the controller library:
```bash
git clone <vault-controller-url> ../vault-controller
make -C ../vault-controller/balance_controller/c lib
PYTHONPATH=$PWD:../vault-controller python -m vault.evaluate
```
Expand All @@ -58,7 +75,7 @@ Run everything as a module from the `safety-stable-baselines` repo root: `python
python -m vault.grid # 4D HJ reach-avoid -> data/grid_reachavoid_odd.npz
python -m vault.distill # conservative V_mlp -> models/v_mlp.{pt,json}
python -m vault.train --steps 300000 # SafetySAC reach-avoid V + pi_safe
python -m vault.evaluate # filter on the deployed controller (needs vault-controller)
python -m vault.evaluate # filter using the pinned controller source checkout
```
`grid.py --smoke` runs a small foreground solve to sanity-check changes. The full grid solve writes
the oracle the deployable net is verified conservative against (`{V_mlp≥0} ⊆ {V_grid≥0}`).
Expand All @@ -73,23 +90,27 @@ the oracle the deployable net is verified conservative against (`{V_mlp≥0} ⊆
A typical ISAACS loop: ego controls `step(u)`, adversary sets `apply_disturbance(...)` each step,
both trained against the reach-avoid margin (`f_cert.margin` / `f_cert.odd_margin`).

## Evaluating the filter on the real controller
`evaluate.py` requires the **vault-controller** repo (the deployed C iLQR; there is no fallback
controller — by design, so results reflect what ships). Clone it beside this repo and:
## Evaluating the filter with the controller
`evaluate.py` requires the **vault-controller** source and C library; there is
no fallback controller. This is a source/simulation evaluation and does not by
itself establish what firmware image is flashed on hardware.
```bash
make -C ../vault-controller/balance_controller/c lib
PYTHONPATH=../vault-controller python -m vault.evaluate
```

## Vendored vs regenerable
- `data/` (committed): `composite_params.json`, `coupled_residual_fit.json`, and
`grid_reachavoid_odd.npz` (the exact value function — regenerate with `grid.py`).
## Release-backed vs regenerable
- `data/` (committed): the byte-matching release lock, checkpoint quarantine
manifest, and `grid_reachavoid_odd.npz` with its model-hash sidecar.
- Robot parameters, residuals, controller limits, the opt6 kernel, capability
grids, and release MLPs are loaded from the pinned controller release rather
than copied here.
- `models/` (git-ignored): `v_mlp.*` and the SafetySAC checkpoint — regenerate with
`distill.py` / `train.py`.

## The method (brief)
`f_cert` = opt6 lossless coupled dynamics + per-wheel friction-cone cap (slip) + the measured
coupled friction residual, one symplectic-Euler step. The grid solver computes the robust
`f_cert` = release opt6 coupled dynamics + per-wheel friction-cone cap (slip) +
the release coupled residual fit, one symplectic-Euler step. The grid solver computes the robust
reach-avoid value `V(x;μ) = min(g_odd, max_u min_d V(f_cert(x,u)+d))`, where `g_odd` is the signed
distance to the ODD (negative outside → leaving the envelope is failure by construction). The safe
set is `{V ≥ 0}`. The filter admits the task control while the worst-case one-step value stays
Expand Down
10 changes: 6 additions & 4 deletions vault/calibration_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,23 @@
import argparse

import numpy as np
from safety_sb3 import SafetySAC

from . import config as C
from . import contact_margin as CM
from . import f_cert as F
from .checkpoints import load_safety_sac
from .mujoco_plant import MujocoPlant


def _fallback_action(model, x, mu, tau_max=C.TAU_MAX):
def _fallback_action(model, x, mu, tau_max=None):
tau_max = C.TAU_MAX if tau_max is None else tau_max
obs5 = np.append(np.asarray(x, np.float32), np.float32(mu))
a, _ = model.predict(obs5, deterministic=True)
return np.clip(a, -1.0, 1.0) * tau_max


def _q_at(model, x, mu, u, tau_max=C.TAU_MAX):
def _q_at(model, x, mu, u, tau_max=None):
tau_max = C.TAU_MAX if tau_max is None else tau_max
import torch
obs5 = np.append(np.asarray(x, np.float32), np.float32(mu))[None]
u_norm = np.clip(np.asarray(u, float) / tau_max, -1.0, 1.0).astype(np.float32)[None]
Expand All @@ -45,7 +47,7 @@ def _q_at(model, x, mu, u, tau_max=C.TAU_MAX):


def run(model_path: str, n: int, horizon: int, mu: float, seed: int):
model = SafetySAC.load(model_path)
model = load_safety_sac(model_path)
rng = np.random.default_rng(seed)
plant = MujocoPlant(wheel="cylinder", mu=mu, dt=0.0005, substeps=20, contact_geometry=True)

Expand Down
27 changes: 27 additions & 0 deletions vault/checkpoints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Model-release gates for SafetySAC checkpoint loading."""
from __future__ import annotations

from pathlib import Path
from typing import Any, TypeVar

from .model_release import get_model_release

ModelT = TypeVar("ModelT")


def load_checkpoint(model_class: type[ModelT], path: str | Path, **kwargs: Any) -> ModelT:
"""Validate checkpoint provenance before delegating to an SB3 loader."""
verified = get_model_release().verify_checkpoint(path)
return model_class.load(str(verified), **kwargs)


def load_safety_sac(path: str | Path, **kwargs: Any):
from safety_sb3 import SafetySAC

return load_checkpoint(SafetySAC, path, **kwargs)


def load_reach_avoid_safety_sac(path: str | Path, **kwargs: Any):
from .reach_avoid_sac import ReachAvoidSafetySAC

return load_checkpoint(ReachAvoidSafetySAC, path, **kwargs)
Loading