GATO constraint layer — mechanisms, measured defaults, provenance#
Reference for the row-group constraint API on gato.BSQP.
The code docstrings carry the CONTRACT (what each call does); this page keeps
the measured tuning rulings, R1/R2 evaluation provenance and traps that used
to live inline; the docstrings carry a one-line summary and point here.
Ordering rule (enforced, raises): enable_limit_* FIRST, then appended groups
(add_lin_u_rows / enable_u_cone / add_fc_box / add_fc_cone / add_contact_pos_rows / enable_ee_terminal_equality
/ enable_collision) — a mechanism enable reinstalls the canonical limit groups.
Mechanisms: telemetry (report only) · barrier (relaxed log barrier, soft)
· admm (OSQP-style projection inner loop with PCG or reused BDSV factor,
approximately hard) · al (PHR augmented Lagrangian, outer loop = warm-started
repeat solves — pass xu_warm explicitly).
Mechanism comparison example#

Example 12 runs one development scenario (Indy7, seed 1, scenario 0, step reference) under all four mechanisms at N16, N32 and N64. All twelve runs reach three goals. ADMM reduces the sampled peak velocity to 1.01 to 1.02 times its limit, versus 1.97 to 2.20 without enforcement. This is one scenario, not a population result or a safety certificate. Torque is the recorded controller command; velocity is sampled at control ticks. The planner panel uses a symmetric log scale with a linear region below 1e-3 rad/s.
Figure data were collected on
source cf605df, with fixed simulation pacing. The figure and JSON omit solve
timings; task completion times in the JSON are simulated durations.
python examples/12_constraint_mechanisms.py --plot mechanisms.png --json mechanisms.json
set_admm_linsys(mode)#
ADMM inner-loop linear solver: “pcg” (default) | “bdsv_factor”.
“pcg” runs warm-started PCG per ADMM iteration (λ carries across the loop, no factorization); “bdsv_factor” factors the (constant-within- the-loop) Schur matrix once per SQP iteration and re-solves per ADMM iteration. Identical iterates up to linsys tolerance; only affects MECH_ADMM solves.
Default BOUND “pcg” by the 2026-08-01 quiet-box A/B: 1.4-2.5x faster per solve at identical tracking on every ADMM family (box fig8 16.5->6.6 ms indy7 / 18.9->9.7 ms iiwa14; cone 2.4x/2.2x; collision 1.6x/1.4x). The trade: PCG’s looser inner residual leaves transient box violations ~2-3x higher (same order, still enforced — e.g. 3.7e-2 -> 1.0e-1 on fig8 boxes). Pick “bdsv_factor” when tightest transient enforcement matters more than speed.
Per-step policy (October 15, 2026): MPCController(admm_linsys="auto", admm_threshold=tau) switches the arm per tick from the warm-startedness
signal that drives linsys="auto": a cold tick (pred_err above the
threshold, a PCG cap-out on the previous tick, or a re-seed) runs
“bdsv_factor”, a warm tick “pcg”; StepResult.admm_linsys reports the arm.
Resolution: an explicit argument, else a tuned entry’s admm_policy /
admm_tau (tools/autotune_linsys.py --admm, a quiet-window probe), else
auto at the SQP bdsv_threshold. Counts behind the crossover (Indy7 N32
figure-8 with kicks, residual exit at eps_rel 1e-2): warm ticks cost 2.3
inner iterations of 13 PCG steps each per SQP iteration, kicked ticks 7.7 of
37; the factor arm costs one factorization plus 2.5 (warm) to 8.5 (kicked)
back-solves. Measured on a quiet box (October 16, 2026; the table under
set_admm_exit): at N32 the PCG arm is the faster inner solver on quiet and
kicked ticks alike (0.43 against 0.78 ms per solve on the Indy7, quiet ticks)
and the fitted threshold is 0.027 (factor arm on 10 % of ticks); at N64 the
factor arm wins on every statistic (1.6 to 2.3 ms mean against 2.1 to 8.2 ms,
3.3 ms worst case against 12.7 to 33 ms) and the fit returns the factor arm for
every tick. The arm follows the horizon more than the tick: auto at the SQP
threshold 0.1 ran the factor arm on 0.2 % of quiet ticks and 6 % of kicked
ticks and timed as the PCG arm. The tool fits the threshold from the pcg arm’s
cost-vs-pred_err curve against the factor arm’s flat cost, exactly as the SQP
policy was fitted; W2b sets the controller default from these numbers.
set_admm_exit(eps_abs, eps_rel, check_every)#
Residual early exit of the ADMM inner loop (added October 15, 2026, the H2 ADMM
speed study). Off by default (eps_abs = eps_rel = 0): the inner loop runs the
fixed budget of enable_limit_admm(iters) bit for bit. With a tolerance the
budget is a cap and the loop stops, per batch entry, after the first inner
iteration whose iterate passes, for every ADMM row group g,
r_prim_g <= eps_abs + eps_rel * max|z_g|
r_dual_g <= eps_abs + eps_rel * rho_g * max|z_g|
(OSQP’s absolute-plus-relative form with the auxiliary variable as the scale;
per group because the rows of one group share units and a global scale would
let the torque rows excuse the velocity rows) and whose x-update converged:
PCG below its iteration cap on the “pcg” arm, an OK factor on “bdsv_factor”.
The second condition is the inexact-ADMM guard: r_dual = rho * max|z - z_prev|
is the dual residual only for an exact x-update, so an unconverged inner solve
is never allowed to read as a fixed point. The test runs on the device
(a per-entry skip mask seeded from the SQP convergence mask each iteration), so
batch entries exit at their own iteration without a host sync; the host breaks
the launch loop every check_every inner iterations once every entry has
exited (0 = never; exited entries are still skipped on the device, so only
empty launches remain). stats.admm_iters / stats.admm_linsys_iters report
the inner iterations run and the inner linear-system work (PCG steps, or one
per back-solve). The iterates do not depend on check_every
(test_admm_exit.py).
Measured (October 15, 2026, counts only; Indy7 N32 figure-8, velocity box ±1 rad/s, rho 0.01, cap 10, 5 SQP iterations per tick, fixed pacing):
inner linsys |
eps_rel |
inner iterations per SQP iteration (mean / p90) |
plan violation max |
simulated peak |
|---|---|---|---|---|
pcg |
off (fixed 10) |
10 / 10 |
3.5e-2 rad/s |
1.034 rad/s |
pcg |
1e-3 |
7.0 / 10 |
3.5e-2 |
1.034 |
pcg |
1e-2 |
1.9 / 2.6 |
2.4e-2 |
1.027 |
bdsv_factor |
1e-3 |
3.6 / 9.2 |
5.4e-3 |
1.005 |
At N64 (example 09, 3 s, cap 10 x 5 SQP iterations = 50 per tick) the exit at
1e-2 with admm_linsys="auto" spends 10.9 inner iterations per tick against
the fixed 50, at the same simulated peak (1.04 rad/s), a planner violation of
3.4e-2 against 3.9e-2 and a tracking error of 0.035 m against 0.030 m.
At 1e-2 the warm tick spends two inner iterations and the enforcement is
unchanged; 1e-3 is below what the inner loop reaches within the cap on the PCG
arm (the fixed point is approached to about 1e-2 relative, and the PCG
inexactness keeps r_dual above 1e-3 of the scale). The factor arm reaches a
tighter iterate and exits sooner at the same tolerance; its returned violation
is 6x smaller, as the August 1 A/B found. With disturbances (q kicked by
N(0, 0.1 rad) every 25 ticks) the PCG arm averages 3.1 inner iterations per
SQP iteration with 17 PCG steps each, rising to 7.7 and 37 on the kicked
ticks; the factor arm averages 2.5 with one back-solve each. On the solver-level
reach problem (iiwa14 N8, rho 10, test_admm_exit.py) a warm repeat exits
after 1-1.6 inner iterations per SQP iteration at 1e-2 with zero violation.
The inner loop’s fixed point is approached only to ~1e-2 relative within 10
iterations at these cost scales (rho 0.01 against a 1e-6 control weight is a
proximal step, not a Newton step): tighter tolerances buy budget-length loops,
not better enforcement.
Wall time (October 16, 2026, 02:28 local, RTX 5090, quiet box, commit 880a2d7,
examples/benchmarks/run_timing_w2a.sh, rows in
examples/benchmarks/data/admm_time_matrix/results.jsonl): closed-loop
figure-8 with the velocity box tightened to ±1 rad/s, rho 0.01, cap 10, one
SQP iteration per tick (the MPC default), fixed pacing; “kicked” adds a
measured-state kick every 25 ticks. Per-solve wall p50 / mean / max in ms:
case (figure-8, 600 ticks) |
free |
fixed 10, pcg |
fixed 10, factor |
exit 1e-2, pcg |
exit 1e-2, factor |
|---|---|---|---|---|---|
Indy7 N32 |
0.17 / 0.17 / 0.26 |
0.59 / 0.72 / 2.6 |
1.72 / 1.72 / 1.7 |
0.43 / 0.52 / 2.7 |
0.78 / 0.79 / 1.8 |
Indy7 N32, kicked |
0.17 / 0.18 / 0.30 |
0.95 / 1.11 / 4.3 |
1.72 / 1.72 / 1.8 |
0.57 / 0.89 / 4.5 |
0.78 / 1.00 / 1.8 |
Indy7 N64 |
0.31 / 0.31 / 0.80 |
3.21 / 3.81 / 12.7 |
3.19 / 3.19 / 3.4 |
0.97 / 2.08 / 12.7 |
1.66 / 1.57 / 3.3 |
Indy7 N64, kicked |
0.32 / 0.36 / 0.91 |
5.53 / 6.59 / 23.0 |
3.20 / 3.20 / 3.2 |
4.26 / 5.53 / 23.1 |
1.93 / 2.00 / 3.3 |
iiwa14 N32 |
0.17 / 0.18 / 0.28 |
0.65 / 0.77 / 5.3 |
1.96 / 1.96 / 2.1 |
0.33 / 0.47 / 5.4 |
0.89 / 0.88 / 2.1 |
iiwa14 N32, kicked |
0.18 / 0.20 / 0.41 |
1.04 / 1.37 / 6.1 |
1.96 / 1.96 / 2.3 |
0.70 / 1.12 / 6.2 |
1.07 / 1.28 / 2.1 |
iiwa14 N64 |
0.32 / 0.33 / 0.72 |
4.82 / 6.11 / 30.1 |
3.74 / 3.74 / 3.9 |
1.40 / 3.43 / 30.3 |
1.94 / 1.91 / 3.9 |
iiwa14 N64, kicked |
0.34 / 0.40 / 1.29 |
9.46 / 9.84 / 34.2 |
3.74 / 3.74 / 3.9 |
7.23 / 8.18 / 33.3 |
1.94 / 2.32 / 3.9 |
The exit runs 2.6 to 3.1 inner iterations per tick on quiet ticks and 4.5 to 5.7 on kicked ticks on both arms, 1.4 to 2.4 times faster than the fixed budget on the same arm, at unchanged row violation and a tracking cost (Indy7 N64: 0.028 m fixed against 0.037 to 0.040 m with the exit; N32: 0.027 against 0.030). The arm choice follows the horizon. At N32 the PCG arm costs 2.5 times the unconstrained step (median) with 15 to 22 PCG steps per inner iteration; at N64 PCG needs 52 to 80 steps per inner iteration and caps out on a tenth of the ticks, so the factor arm wins on mean and worst case (5 to 6 times the unconstrained step, 3.3 ms worst case against 12.7 ms). The factor arm’s inner iteration costs 0.15 ms at N32 and 0.29 to 0.34 ms at N64 regardless of kicks: the block-Cholesky back-solve sweeps the horizon serially, so one inner iteration costs as much as the whole unconstrained step. The 16 ms figure of October 12 was this fixed budget over five SQP iterations per tick.
Current default timing on October 19#
Quiet RTX 5090, source 34a4d30, grid-rbd 0.5.3, batch size 1, one SQP
iteration per tick, 600 fixed-pacing ticks per cell. The matrix tightens
the velocity box to 1 rad/s on a figure-8; disturbed cases inject a measured
state kick every 25 ticks. The default uses per-box penalties and the
horizon-based inner solver. Times below are native solve milliseconds,
not Python controller-call latency or end-to-end robot-cycle time.
Arm |
N |
Disturbed |
Unconstrained p50 |
Default p50 / mean / max |
|---|---|---|---|---|
Indy7 |
32 |
No |
0.172 |
0.311 / 0.326 / 1.131 |
Indy7 |
32 |
Yes |
0.174 |
0.335 / 0.404 / 1.738 |
Indy7 |
64 |
No |
0.306 |
1.112 / 1.137 / 3.019 |
Indy7 |
64 |
Yes |
0.319 |
1.114 / 1.346 / 3.297 |
iiwa14 |
32 |
No |
0.169 |
0.249 / 0.272 / 0.581 |
iiwa14 |
32 |
Yes |
0.182 |
0.280 / 0.389 / 1.979 |
iiwa14 |
64 |
No |
0.317 |
0.981 / 1.123 / 1.622 |
iiwa14 |
64 |
Yes |
0.331 |
1.297 / 1.399 / 3.848 |
At N64 under disturbances, the uniform-penalty exit-PCG arm has mean/max times of 5.525/23.081 ms on Indy7 and 8.273/33.672 ms on iiwa14. The corresponding factor arm measures 2.008/3.307 and 2.320/3.858 ms. This supports the N64 factor default for these workloads, not a claim that every factorized solve beats PCG or preserves identical tracking.
The exit-check variants use uniform rho 0.01, not the per-box default. Undisturbed median native solve times:
Arm and N |
Check every iteration |
Device gating only |
Check every two iterations |
Tighter eps_rel 1e-3 |
|---|---|---|---|---|
Indy7 N32 |
0.767 |
0.802 |
0.767 |
1.796 |
Indy7 N64 |
1.647 |
1.665 |
1.635 |
3.279 |
iiwa14 N32 |
0.888 |
0.919 |
0.878 |
2.081 |
iiwa14 N64 |
1.936 |
1.948 |
1.917 |
3.842 |
The host-check variants retain identical recorded tracking, iteration counts and row violations within each task. Their small timing differences in this single collection do not justify a default change. The tighter tolerance spends about 9 to 10 inner iterations rather than about 3 to 4 and has no consistent reduction in the recorded maximum row violation.
Calibration of the uniform-penalty disturbed workload recommends auto thresholds 0.04734 for Indy7 N32 and 0.04185 for iiwa14 N32, and always-factor at N64 for both arms. These are saved as proposals, not installed; they do not tune the per-box default configuration.
The 92 raw rows contain 80 matrix cells, four fits and eight separate controller-call measurements. The benchmark now records effective limit/exit settings and tests that free means no rows and fixed10 means ten inner iterations per SQP iteration. An earlier October 19 partial collection inherited controller defaults and was rejected, not included. This is one sequential collection, not a repeated confidence-interval study. Device-resident and new-arm timings remain open.
set_exact_hessian(on)#
Toggle the SO-SQP stage-Hessian PSD projection for subsequent solves.
Per-TASK feature: wins on EE-terminal tasks, neutral-to-worse on full-rank joint-terminal ones (numpy prototype study). Raises if the module was built without -DGATO_EXACT_HESSIAN=ON.
Constraint-mechanism pairing (measured): exact pairs with AL, not ADMM (R2 2026-07-30: un-parks AL-cone both plants; admm_ee diverges). The 2026-08-01 ADMM-fold cells extend the rule: exact x cone-ADMM parks BOTH plants (track 0.67-0.78 vs 0.02-0.03 GN) and exact x collision-ADMM parks iiwa14; the one healthy pairing is indy7 collision-ADMM (parity with GN, tighter inner residual). Don’t combine exact with ADMM row groups by default.
enable_limit_telemetry()#
Install the canonical limit row-groups (position/velocity/torque boxes
from the URDF limit tables) in TELEMETRY mode: every solve() reports each
group’s true violation of the returned trajectory in
stats.row_{max,sum}_violation (group order BOX_Q, BOX_QD, BOX_U).
Telemetry never touches the solver path — trajectories are bit-identical
with it on or off. Part of the constraint row-group layer (CL-0).
enable_limit_barrier(mu, delta)#
Bind the limit row-groups to the RELAXED log-barrier mechanism: a
C² barrier with bounded Hessian (quadratic extension within delta
of a bound) folded into the KKT cost and merit — infeasible-start safe,
the constraint layer’s soft prior mode. Additive to grid_plant’s own
clamped log barriers; zero q_lim/vel_lim/ctrl_lim_cost for a clean
comparison. Telemetry (stats.row_*_violation) stays on.
enable_limit_admm(rho, iters)#
Controller default since October 16, 2026: MPCController(limits=...) installs
these rows itself on a fixed-base arm whose solver has none (limits=None),
with per-box rho 0.1 (position rows), 0.03 (velocity rows) and 1e-5 (torque
rows), cap 10, the residual exit at eps_rel 1e-2 and the inner linear
solver by horizon (gato.linsys_autotune.ADMM_FACTOR_MIN_N = 64: warm PCG
below, the reused factorization from there; see set_admm_linsys). A solver
the caller configured stands, limits="off" installs nothing, and the raw
BSQP never installs rows on its own. Example 09 runs the default against
the fixed budget and telemetry.
Bind the limit row-groups to the ADMM-projection mechanism: an
OSQP-style inner loop per SQP iteration (warm PCG by default;
optionally a reused direct BDSV factorization) — the constraint layer’s
“approximately hard” mode. rho is the ADMM penalty (fixed within
a solve; adapt it between solves), iters the inner budget: fixed by
default, a cap once the residual exit is on (set_admm_exit, or the
eps_abs / eps_rel keywords here).
R1 default rho=0.01: the penalty must ride the COST-HESSIAN scale —
rho >= 1 swamps the u-block (natural scale u_cost=1e-6), freezing
controls at the warm start (closed-loop MPC parks); the measured
pocket is ~0.005-0.02.
That pocket was measured on a binding velocity box with one rho for the
three boxes. On tasks where no limit binds, one rho still costs tracking:
rho 0.01 on the torque rows against the 2e-6 control cost is a 5000x
control regularizer that the ten-iteration inner loop never unwinds
(October 16, 2026: applied torque peaks at 0.2 of the limit under ADMM
against 0.6 under the other mechanisms, and a feasible figure-8 tracks
25 to 65 % worse). Per-box weights (enable_limit_admm(rho_q=, rho_qd=, rho_u=), set_row_group_rho(g, rho)) put each fold at its block’s
scale; the controller default is 0.1 / 0.03 / 1e-5 for the position /
velocity / torque rows, set on the dev set below.
Dev set (October 16, 2026, examples/benchmarks/limits_holdout.py --set dev,
seed 1, 20 seeded scenarios per task and arm, N32 and N64, fixed pacing,
pinocchio plant, MPC default of one SQP iteration per tick; rows in
examples/benchmarks/data/limits_holdout/dev.jsonl). Tasks from
gato.tasks: figure-8s whose period makes the demanded joint speed 0.6 to
1.6 times the velocity limit (the arm lags rather than exceeds, so nothing
should bind), and three-goal pick-and-place with step or ramped references
(the step reference asks for maximal acceleration and the velocity box binds).
The simulated plant is graded against the URDF limits; “ticks over” is the
fraction of control periods with a joint above its velocity limit. Twenty
configurations were run (uniform ADMM rho 1e-3 to 3e-2, the fixed budget,
eps_rel 1e-3, barrier mu 1e-2, AL rho 10, three SQP iterations per tick,
per-box rho variants); the five that decide the default:
task |
arm, N |
mechanism |
sim peak qd / limit p50 / p90 / max |
ticks over |
tracking p50 (m) or goals, task time p50 (s) |
planner viol qd max |
|---|---|---|---|---|---|---|
fig8 |
indy7 N32 |
none |
0.81 / 0.95 / 1.02 |
0.0 % |
0.0618 |
2.0e-02 |
fig8 |
indy7 N32 |
barrier |
0.76 / 0.95 / 1.00 |
0.0 % |
0.0649 |
0.0e+00 |
fig8 |
indy7 N32 |
al |
0.70 / 0.84 / 0.86 |
0.0 % |
0.0677 |
0.0e+00 |
fig8 |
indy7 N32 |
admm, one rho 0.01 |
0.83 / 0.99 / 0.99 |
0.0 % |
0.0764 |
1.1e-01 |
fig8 |
indy7 N32 |
admm, per-box rho (default) |
0.71 / 0.84 / 0.87 |
0.0 % |
0.0647 |
0.0e+00 |
fig8 |
indy7 N64 |
none |
0.74 / 0.85 / 0.90 |
0.0 % |
0.0709 |
0.0e+00 |
fig8 |
indy7 N64 |
barrier |
0.69 / 0.72 / 0.85 |
0.0 % |
0.0756 |
0.0e+00 |
fig8 |
indy7 N64 |
al |
0.67 / 0.77 / 0.83 |
0.0 % |
0.0718 |
0.0e+00 |
fig8 |
indy7 N64 |
admm, one rho 0.01 |
0.64 / 0.76 / 0.78 |
0.0 % |
0.1054 |
4.6e-02 |
fig8 |
indy7 N64 |
admm, per-box rho (default) |
0.67 / 0.77 / 0.83 |
0.0 % |
0.0721 |
0.0e+00 |
fig8 |
iiwa14 N32 |
none |
0.78 / 0.96 / 1.04 |
0.1 % |
0.0378 |
1.4e-01 |
fig8 |
iiwa14 N32 |
barrier |
0.62 / 0.72 / 0.79 |
0.0 % |
0.0454 |
0.0e+00 |
fig8 |
iiwa14 N32 |
al |
0.78 / 0.95 / 1.03 |
0.0 % |
0.0377 |
1.3e-01 |
fig8 |
iiwa14 N32 |
admm, one rho 0.01 |
0.93 / 1.01 / 1.02 |
0.1 % |
0.0488 |
1.7e+00 |
fig8 |
iiwa14 N32 |
admm, per-box rho (default) |
0.79 / 0.92 / 0.93 |
0.0 % |
0.0377 |
7.0e-03 |
fig8 |
iiwa14 N64 |
none |
0.73 / 0.96 / 1.05 |
0.1 % |
0.0438 |
1.7e-01 |
fig8 |
iiwa14 N64 |
barrier |
0.60 / 0.74 / 0.79 |
0.0 % |
0.0519 |
0.0e+00 |
fig8 |
iiwa14 N64 |
al |
0.73 / 0.92 / 0.93 |
0.0 % |
0.0439 |
1.7e-01 |
fig8 |
iiwa14 N64 |
admm, one rho 0.01 |
1.00 / 1.09 / 1.11 |
0.3 % |
0.0716 |
5.6e-01 |
fig8 |
iiwa14 N64 |
admm, per-box rho (default) |
0.75 / 0.92 / 0.93 |
0.0 % |
0.0440 |
8.2e-03 |
pickplace |
indy7 N32 |
none |
0.49 / 0.95 / 1.98 |
0.6 % |
3.00 of 3, 3.07 s |
2.7e+00 |
pickplace |
indy7 N32 |
barrier |
0.48 / 0.80 / 1.29 |
0.1 % |
3.00 of 3, 3.10 s |
1.1e-01 |
pickplace |
indy7 N32 |
al |
0.47 / 0.83 / 1.42 |
0.1 % |
3.00 of 3, 3.07 s |
1.3e+00 |
pickplace |
indy7 N32 |
admm, one rho 0.01 |
0.47 / 0.97 / 1.10 |
0.3 % |
3.00 of 3, 3.14 s |
5.4e-01 |
pickplace |
indy7 N32 |
admm, per-box rho (default) |
0.47 / 0.82 / 1.02 |
0.0 % |
3.00 of 3, 3.08 s |
2.8e-02 |
pickplace |
indy7 N64 |
none |
0.47 / 0.86 / 1.97 |
0.4 % |
3.00 of 3, 3.07 s |
2.6e+00 |
pickplace |
indy7 N64 |
barrier |
0.44 / 0.81 / 1.26 |
0.1 % |
3.00 of 3, 3.08 s |
1.5e-01 |
pickplace |
indy7 N64 |
al |
0.45 / 0.84 / 1.51 |
0.2 % |
3.00 of 3, 3.06 s |
1.7e+00 |
pickplace |
indy7 N64 |
admm, one rho 0.01 |
0.49 / 0.97 / 1.09 |
0.3 % |
3.00 of 3, 3.26 s |
1.0e+00 |
pickplace |
indy7 N64 |
admm, per-box rho (default) |
0.45 / 0.84 / 1.01 |
0.0 % |
3.00 of 3, 3.06 s |
2.4e-02 |
pickplace |
iiwa14 N32 |
none |
1.59 / 3.00 / 3.54 |
19.2 % |
2.95 of 3, 1.70 s |
3.8e+00 |
pickplace |
iiwa14 N32 |
barrier |
0.91 / 1.08 / 1.18 |
0.1 % |
2.75 of 3, 2.20 s |
7.4e-01 |
pickplace |
iiwa14 N32 |
al |
1.03 / 1.35 / 1.50 |
3.7 % |
2.95 of 3, 2.08 s |
2.0e+00 |
pickplace |
iiwa14 N32 |
admm, one rho 0.01 |
1.11 / 1.42 / 1.47 |
5.0 % |
2.95 of 3, 2.97 s |
1.5e+00 |
pickplace |
iiwa14 N32 |
admm, per-box rho (default) |
0.96 / 1.02 / 1.05 |
0.1 % |
2.95 of 3, 2.24 s |
1.6e-01 |
pickplace |
iiwa14 N64 |
none |
1.59 / 2.88 / 3.13 |
15.4 % |
2.65 of 3, 1.72 s |
3.3e+00 |
pickplace |
iiwa14 N64 |
barrier |
0.91 / 1.07 / 1.13 |
0.1 % |
2.45 of 3, 1.92 s |
9.5e-01 |
pickplace |
iiwa14 N64 |
al |
1.00 / 1.20 / 1.43 |
3.3 % |
2.60 of 3, 1.99 s |
1.9e+00 |
pickplace |
iiwa14 N64 |
admm, one rho 0.01 |
1.11 / 1.35 / 1.44 |
4.5 % |
2.85 of 3, 3.04 s |
8.5e-01 |
pickplace |
iiwa14 N64 |
admm, per-box rho (default) |
0.95 / 1.00 / 1.01 |
0.1 % |
2.70 of 3, 2.01 s |
4.5e-02 |
Reading. Where nothing binds (figure-8), the per-box ADMM tracks like the unconstrained controller (within 2 % at N64 and 5 % at N32 on the Indy7, equal on the iiwa14) with a planner violation below 1e-2 rad/s, where one rho costs 25 to 65 % and on the iiwa14 lets the plan violate its own box by 0.5 to 1.7 rad/s; the barrier costs 5 to 20 % as a prior that always acts; AL costs nothing and enforces nothing it does not have to. Where the box binds (pick-and-place, step reference), the per-box ADMM holds the simulated peak to 1.01 to 1.05 of the limit with at most 0.1 % of ticks over, against 1.98 to 3.54 unconstrained, 1.13 to 1.29 for the barrier and 1.42 to 1.51 for AL, at the unconstrained controller’s goals and task time on the Indy7 and 0.3 s behind it on the iiwa14 (whose unconstrained runs reach goals faster by running at 1.6 to 3.5 times its velocity limit). Among the per-box variants, rho_qd 0.03 against 0.01 tightens the peak from 1.02 to 1.10 down to 0.98 to 1.02 at identical tracking; rho_u 1e-4 brings the control bias back (the torque rows regularize again), 2e-6 equals 1e-5. Three SQP iterations per tick make AL enforce (1.0 to 1.05) and make the unconstrained arm more aggressive; they do not change the ADMM verdict. The residual exit costs nothing in enforcement against the fixed budget on this set. The default is therefore rho 0.1 / 0.03 / 1e-5 with the exit at 1e-2; the holdout below is a fresh draw run once with it.
Holdout (October 16, 2026, --set holdout, seed 20261016, 100 fresh scenarios
per task and arm, N32 and N64, the four mechanisms at the frozen defaults, no
retuning; 3200 episodes in examples/benchmarks/data/limits_holdout/holdout.jsonl,
published as run):
task |
arm, N |
mechanism |
sim peak qd / limit p50 / p90 / max |
ticks over |
sim peak u / limit max |
tracking p50 / p90 (m) or goals, task time p50 (s) |
planner viol qd p50 / max |
|---|---|---|---|---|---|---|---|
fig8 |
indy7 N32 |
none |
0.79 / 1.02 / 1.18 |
0.1 % |
0.46 |
0.0661 / 0.0951 |
0.0e+00 / 1.6e-01 |
fig8 |
indy7 N32 |
barrier |
0.72 / 0.89 / 1.04 |
0.0 % |
0.45 |
0.0704 / 0.1008 |
0.0e+00 / 2.3e-02 |
fig8 |
indy7 N32 |
al |
0.66 / 0.88 / 0.96 |
0.0 % |
0.32 |
0.0731 / 0.1021 |
0.0e+00 / 1.5e-02 |
fig8 |
indy7 N32 |
admm (default) |
0.67 / 0.88 / 0.96 |
0.0 % |
0.33 |
0.0718 / 0.1009 |
0.0e+00 / 7.1e-03 |
fig8 |
indy7 N64 |
none |
0.69 / 0.88 / 1.09 |
0.0 % |
0.43 |
0.0762 / 0.1027 |
0.0e+00 / 1.1e-03 |
fig8 |
indy7 N64 |
barrier |
0.63 / 0.78 / 0.94 |
0.0 % |
0.42 |
0.0807 / 0.1101 |
0.0e+00 / 0.0e+00 |
fig8 |
indy7 N64 |
al |
0.62 / 0.84 / 0.91 |
0.0 % |
0.30 |
0.0767 / 0.1025 |
0.0e+00 / 8.9e-03 |
fig8 |
indy7 N64 |
admm (default) |
0.62 / 0.84 / 0.91 |
0.0 % |
0.30 |
0.0770 / 0.1028 |
0.0e+00 / 1.4e-04 |
fig8 |
iiwa14 N32 |
none |
0.80 / 0.95 / 1.05 |
0.1 % |
0.23 |
0.0360 / 0.0475 |
0.0e+00 / 1.6e-01 |
fig8 |
iiwa14 N32 |
barrier |
0.63 / 0.71 / 0.80 |
0.0 % |
0.23 |
0.0435 / 0.0548 |
0.0e+00 / 0.0e+00 |
fig8 |
iiwa14 N32 |
al |
0.80 / 0.94 / 1.04 |
0.0 % |
0.23 |
0.0360 / 0.0476 |
0.0e+00 / 1.5e-01 |
fig8 |
iiwa14 N32 |
admm (default) |
0.81 / 0.93 / 0.93 |
0.0 % |
0.23 |
0.0361 / 0.0476 |
0.0e+00 / 8.9e-03 |
fig8 |
iiwa14 N64 |
none |
0.77 / 0.95 / 1.04 |
0.0 % |
0.36 |
0.0417 / 0.0566 |
0.0e+00 / 1.6e-01 |
fig8 |
iiwa14 N64 |
barrier |
0.62 / 0.73 / 0.82 |
0.0 % |
0.34 |
0.0490 / 0.0641 |
0.0e+00 / 0.0e+00 |
fig8 |
iiwa14 N64 |
al |
0.77 / 0.93 / 0.97 |
0.0 % |
0.37 |
0.0421 / 0.0569 |
0.0e+00 / 1.5e-01 |
fig8 |
iiwa14 N64 |
admm (default) |
0.79 / 0.92 / 0.93 |
0.0 % |
0.36 |
0.0422 / 0.0570 |
0.0e+00 / 9.9e-03 |
pickplace |
indy7 N32 |
none |
0.68 / 2.02 / 2.57 |
3.3 % |
1.16 |
3.00 of 3, 1.93 s |
0.0e+00 / 4.0e+00 |
pickplace |
indy7 N32 |
barrier |
0.63 / 1.44 / 2.24 |
0.5 % |
0.80 |
3.00 of 3, 1.94 s |
0.0e+00 / 1.3e+00 |
pickplace |
indy7 N32 |
al |
0.66 / 1.84 / 2.13 |
1.2 % |
0.96 |
3.00 of 3, 1.92 s |
0.0e+00 / 3.1e+00 |
pickplace |
indy7 N32 |
admm (default) |
0.65 / 0.97 / 1.44 |
0.0 % |
0.79 |
3.00 of 3, 1.91 s |
0.0e+00 / 7.1e-02 |
pickplace |
indy7 N64 |
none |
0.67 / 1.96 / 2.57 |
2.6 % |
1.00 |
3.00 of 3, 2.00 s |
0.0e+00 / 3.9e+00 |
pickplace |
indy7 N64 |
barrier |
0.66 / 1.50 / 2.25 |
0.5 % |
0.81 |
3.00 of 3, 1.98 s |
0.0e+00 / 1.1e+00 |
pickplace |
indy7 N64 |
al |
0.64 / 1.79 / 2.51 |
1.2 % |
0.95 |
3.00 of 3, 1.91 s |
0.0e+00 / 4.1e+00 |
pickplace |
indy7 N64 |
admm (default) |
0.64 / 0.99 / 1.43 |
0.1 % |
0.81 |
3.00 of 3, 1.91 s |
0.0e+00 / 4.7e-02 |
pickplace |
iiwa14 N32 |
none |
1.39 / 2.87 / 4.17 |
16.3 % |
1.02 |
2.93 of 3, 2.01 s |
7.0e-01 / 5.6e+00 |
pickplace |
iiwa14 N32 |
barrier |
0.88 / 1.00 / 1.23 |
0.1 % |
0.98 |
2.78 of 3, 2.48 s |
0.0e+00 / 1.0e+00 |
pickplace |
iiwa14 N32 |
al |
0.96 / 1.29 / 1.62 |
2.2 % |
0.91 |
2.92 of 3, 2.28 s |
3.3e-01 / 2.0e+00 |
pickplace |
iiwa14 N32 |
admm (default) |
0.94 / 1.00 / 1.08 |
0.1 % |
0.99 |
2.93 of 3, 2.26 s |
1.3e-02 / 2.1e-01 |
pickplace |
iiwa14 N64 |
none |
1.48 / 2.57 / 4.02 |
14.4 % |
1.11 |
2.76 of 3, 2.08 s |
8.0e-01 / 5.4e+00 |
pickplace |
iiwa14 N64 |
barrier |
0.88 / 1.01 / 1.29 |
0.0 % |
0.97 |
2.56 of 3, 2.45 s |
0.0e+00 / 1.0e+00 |
pickplace |
iiwa14 N64 |
al |
0.99 / 1.23 / 1.56 |
2.0 % |
0.91 |
2.73 of 3, 2.30 s |
2.6e-01 / 2.0e+00 |
pickplace |
iiwa14 N64 |
admm (default) |
0.94 / 0.97 / 1.00 |
0.0 % |
0.96 |
2.73 of 3, 2.28 s |
1.2e-02 / 8.8e-02 |
Reading. On the figure-8 the default tracks within 1 % of the unconstrained controller on both arms at N64 (0.0770 against 0.0762 m; 0.0422 against 0.0417), 9 % at N32 on the Indy7 and equal on the iiwa14, with the planner violation of the velocity rows at most 1e-2 rad/s where the unconstrained plan reaches 0.16. On the step-reference pick-and-place the simulated peak velocity is at the limit at the 90th percentile (0.97 to 1.00 of the limit) with at most 0.1 % of control periods over, against 1.96 to 2.87 at the 90th percentile and 2.6 to 16 % of periods over unconstrained; the barrier holds the iiwa14 (1.00 to 1.01) but not the Indy7 (1.44 to 1.50) and loses goals on the iiwa14 (2.56 to 2.78 of 3); AL sits between (1.23 to 1.84). The worst single scenario under the default peaks at 1.43 to 1.44 on the Indy7 (one of 100; the plan itself violates by at most 7e-2 rad/s, so the excess is the sampled plant exceeding the plan’s velocity bound) and at 1.00 to 1.08 on the iiwa14. Goals reached and task time match the unconstrained controller on the Indy7 (3 of 3, 1.91 against 1.93 to 2.00 s) and come within 0.2 to 0.3 s on the iiwa14, whose unconstrained runs reach goals by running at up to four times its velocity limit. The applied torque stays inside the limit under the default (0.96 to 0.99 of the limit at the peak) where the unconstrained controller exceeds it (1.00 to 1.16), and no default run leaves the position box (the unconstrained controller leaves it by up to 0.24 rad). The exit spends 3.7 inner iterations per tick on average. These are sampled closed-loop results, with velocity overshoot that can exceed the planner’s reported residual. The aggregates do not bound the duration of an overshoot or establish continuous-time feasibility. iters=10 BOUND by R2: 2/5 park the feasible cone cell; box cells saturate by 10 (20 = marginal viol gains at 2x inner cost). Duals warm-start across solves (reset_dual() reinitializes) — EXCEPT equality rows (lo == hi, e.g. enable_ee_terminal_equality), whose (z, y) reinit every solve: a warm-started dual on a row the primal may not reach is an unbounded violation integrator (measured). stats gain admm_r_prim/admm_r_dual; telemetry stays on.
Closed loop (examples/09_mpc_with_limits.py, test_limits_closed_loop.py; Indy7
figure-8, velocity box tightened to ±1 rad/s, rho 0.01, 10 iterations, 5 SQP
steps per tick, fixed pacing, measured 2026-10-12): the planner’s residual
violation is 0.035–0.039 rad/s at N32/N64 and the SIMULATED joint velocity
peaks at 1.03–1.04 rad/s, versus 1.6–2.0 rad/s with telemetry only; tracking
error rises from 0.024 to 0.030 m (N64). rho 0.1 tightens the plan (0.015) but
costs tracking (0.045 m); horizons of 16 knots or fewer (≤160 ms lookahead)
cannot brake in time (1.10 rad/s at N16, 1.19 at N8), so the closed-loop gate
runs on N ≥ 32. On a quiet box (October 16, 2026, RTX 5090, one SQP iteration
per tick) the fixed-budget ADMM tick at N64 is 3.2 ms on the factor arm and
3.2 to 6.6 ms mean on the PCG arm (12 to 23 ms worst case) against 0.31 ms
unconstrained; the residual exit (set_admm_exit) brings the factor arm to
1.6 to 2.0 ms (3.3 ms worst case), five to six times the unconstrained step,
and costs about 0.01 m of tracking (table and reading in that section). The
16 ms of October 12 was the fixed budget over five SQP iterations per tick on
a shared box.
New arm holdout on October 18#
FR3, Gen3 and UR5e at N64, source cf605df, grid-rbd==0.5.3:
100 fresh scenarios per task and arm, seed 20261018, all four mechanisms at
unchanged defaults. All 2400 episodes completed with finite recorded metrics.
The raw rows
include every scenario and outcome. No settings were retuned on this holdout.
The plant uses Pinocchio RK4 with 1 ms integration steps and a fixed 10 ms
control period. Position and velocity maxima are sampled at control ticks;
the torque metric is the recorded StepResult.u command, not a measurement
at every actuator substep. The initial warmup command and final goal-event
state are not included in these maxima. The raw runtime fields are diagnostics
only (quotable_times: false), collected on a shared GPU.
Arm and task |
Mechanism |
Sampled peak qd / limit p50 / p90 / max |
Commanded peak u / limit max |
Position excursion max (rad) |
Tracking p50 / p90 (m), or three-goal arrivals |
|---|---|---|---|---|---|
fr3 fig8 |
none |
0.27 / 0.41 / 0.43 |
0.85 |
0.000 |
0.0752 / 0.0950 |
fr3 fig8 |
barrier |
0.27 / 0.40 / 0.50 |
0.98 |
0.000 |
0.0775 / 0.0971 |
fr3 fig8 |
al |
0.27 / 0.42 / 0.55 |
0.98 |
0.000 |
0.0748 / 0.0947 |
fr3 fig8 |
admm |
0.27 / 0.41 / 0.43 |
0.84 |
0.000 |
0.0748 / 0.0948 |
fr3 pickplace |
none |
0.42 / 0.88 / 1.37 |
2.02 |
0.000 |
100/100 |
fr3 pickplace |
barrier |
0.40 / 0.79 / 0.93 |
1.06 |
0.000 |
100/100 |
fr3 pickplace |
al |
0.42 / 0.90 / 1.05 |
1.27 |
0.000 |
100/100 |
fr3 pickplace |
admm |
0.42 / 0.90 / 0.97 |
1.31 |
0.000 |
100/100 |
gen3 fig8 |
none |
1.04 / 2.62 / 4.15 |
0.78 |
0.000 |
0.0293 / 0.0449 |
gen3 fig8 |
barrier |
0.69 / 0.80 / 0.89 |
0.44 |
0.000 |
0.0376 / 0.0553 |
gen3 fig8 |
al |
0.82 / 1.00 / 1.20 |
0.45 |
0.000 |
0.0311 / 0.0468 |
gen3 fig8 |
admm |
0.81 / 0.93 / 0.94 |
0.44 |
0.000 |
0.0313 / 0.0471 |
gen3 pickplace |
none |
1.01 / 1.90 / 3.24 |
2.12 |
0.000 |
100/100 |
gen3 pickplace |
barrier |
0.68 / 1.11 / 1.34 |
1.07 |
0.000 |
100/100 |
gen3 pickplace |
al |
0.77 / 1.55 / 1.93 |
1.16 |
0.000 |
100/100 |
gen3 pickplace |
admm |
0.77 / 0.96 / 1.42 |
1.05 |
0.000 |
100/100 |
ur5e fig8 |
none |
0.28 / 0.37 / 0.48 |
0.39 |
0.000 |
0.0684 / 0.0873 |
ur5e fig8 |
barrier |
0.26 / 0.35 / 0.39 |
0.38 |
0.000 |
0.0694 / 0.0892 |
ur5e fig8 |
al |
0.27 / 0.36 / 0.40 |
0.39 |
0.000 |
0.0683 / 0.0871 |
ur5e fig8 |
admm |
0.26 / 0.36 / 0.40 |
0.39 |
0.000 |
0.0684 / 0.0873 |
ur5e pickplace |
none |
0.43 / 1.17 / 1.49 |
1.18 |
0.000 |
100/100 |
ur5e pickplace |
barrier |
0.42 / 0.92 / 1.09 |
1.06 |
0.000 |
100/100 |
ur5e pickplace |
al |
0.44 / 1.04 / 1.11 |
1.04 |
0.000 |
100/100 |
ur5e pickplace |
admm |
0.44 / 0.97 / 0.97 |
1.01 |
0.000 |
100/100 |
All 1200 pick-and-place episodes reach all three goals, and every recorded position remains inside the URDF box. Default ADMM keeps the sampled figure-8 velocity below its limit on all three arms. On pick-and-place, however, FR3 commands exceed a torque limit in 14 of 100 default episodes (maximum 1.306); Gen3 exceeds a velocity limit in four (maximum 1.418) and a torque limit in ten (maximum 1.055); UR5e exceeds a torque limit in six (maximum 1.009). FR3’s worst default planner torque-row violation is 3.774 Nm, so that excess is visible in solver telemetry as well as in the command.
These results support reduced limit violations, not joint-feasible operation in every episode. The next enforcement study needs a separate development set and a new frozen holdout; this set must not become a tuning target. Control-tick samples do not certify substep extrema, violation duration or hardware safety.
Reproduce the protocol in a fresh output file without changing its settings:
python examples/benchmarks/limits_holdout.py --set holdout --seed 20261018 \
--plants fr3,gen3,ur5e --Ns 64 --workers 1 --out new-arm-holdout.jsonl
Torque saturation development comparison#
An experiment-only actuator clamp preserves goal completion in the tested
development scenarios. The controller and default simulator remain unchanged.
At source 1c92dcb, FR3, Gen3 and UR5e run N64 default ADMM with the same
tasks and settings as above: 20 scenarios per task and arm, paired with
clipping off and on, first at development seed 1 and then seed 2. The second
batch was added because seed 1 did not saturate Gen3 or UR5e. These are
exploratory development results, not a new confirmatory holdout.
The clamp applies clip(u, -effort_limit, effort_limit) before every 1 ms
Pinocchio RK4 step. The planner does not model saturation explicitly;
subsequent solves use the resulting measured state. Initial state, warmup
motion and final goal transitions are included in the substep audit.
Maxima cover integration endpoints, not extrema within an integration step.
All 480 episodes completed with finite results. Every pick-and-place episode reached all three goals: 120/120 with clipping and 120/120 without it. The arrival gate requires distance below 0.05 m and joint-velocity norm below 1 rad/s for 0.1 s; this does not test indefinite holding after arrival.
In the table, torque and velocity entries are unclipped / clipped maxima, normalized by URDF limits. The final column is the paired change in mean tracking error (figure-8, metres) or completion time (pickplace, simulated seconds), clipped minus unclipped. Runtime measurements are not reported.
Seed |
Arm and task |
Episodes with clipping |
Applied torque max off / on |
Velocity max off / on |
Paired change min / median / max |
|---|---|---|---|---|---|
1 |
fr3 fig8 |
1/20 |
1.0583 / 1.0000 |
0.3813 / 0.3814 |
0.000000 / 0.000000 / 0.000026 |
1 |
fr3 pickplace |
6/20 |
1.1782 / 1.0000 |
0.9665 / 0.9666 |
0.000000 / 0.000000 / 0.020000 |
1 |
gen3 fig8 |
0/20 |
0.6754 / 0.6754 |
0.9339 / 0.9339 |
0.000000 / 0.000000 / 0.000000 |
1 |
gen3 pickplace |
0/20 |
0.9998 / 0.9998 |
1.0191 / 1.0191 |
0.000000 / 0.000000 / 0.000000 |
1 |
ur5e fig8 |
0/20 |
0.3808 / 0.3808 |
0.3624 / 0.3624 |
0.000000 / 0.000000 / 0.000000 |
1 |
ur5e pickplace |
0/20 |
0.5334 / 0.5334 |
0.7267 / 0.7267 |
0.000000 / 0.000000 / 0.000000 |
2 |
fr3 fig8 |
1/20 |
1.0686 / 1.0000 |
0.4632 / 0.4632 |
0.000000 / 0.000000 / 0.000031 |
2 |
fr3 pickplace |
4/20 |
1.1297 / 1.0000 |
0.9662 / 0.9662 |
0.000000 / 0.000000 / 0.000000 |
2 |
gen3 fig8 |
0/20 |
0.6863 / 0.6863 |
0.9337 / 0.9337 |
0.000000 / 0.000000 / 0.000000 |
2 |
gen3 pickplace |
0/20 |
0.9896 / 0.9896 |
0.9997 / 0.9997 |
0.000000 / 0.000000 / 0.000000 |
2 |
ur5e fig8 |
0/20 |
0.4372 / 0.4372 |
0.4075 / 0.4075 |
0.000000 / 0.000000 / 0.000000 |
2 |
ur5e pickplace |
1/20 |
1.0132 / 1.0000 |
0.9724 / 0.9724 |
0.000000 / 0.000000 / 0.000000 |
Clipping activates in 12 FR3 scenarios and one UR5e scenario across both batches. The largest pick-and-place completion delay is 0.02 simulated seconds, with zero median paired change in every cell. Figure-8 mean tracking error increases by at most 0.000031 m. FR3 clipping affects 10 substeps in each figure-8 batch, 90 substeps in seed-1 pickplace and 60 in seed-2 pickplace; UR5e clipping affects ten substeps in seed-2 pickplace. This is evidence for brief saturation, not prolonged actuator limitation.
Applied torque stays within the cap whenever clipping is enabled. No integration endpoint leaves the position box. Gen3 never saturates in these development batches, so its identical pairs do not establish robustness to saturation. Its seed-1 pickplace velocity still peaks at 1.0191 times the limit: torque clipping does not enforce velocity limits. Every inactive pair has exactly equal saved correctness metrics.
Raw results: seed 1
and seed 2.
The earlier frozen holdout is unchanged. Reproduce with fresh output paths
from a clean committed checkout; --report reads results without GPU work:
python examples/benchmarks/torque_clip_ab.py --seed 1 --out clip-seed1.jsonl
python examples/benchmarks/torque_clip_ab.py --seed 2 --out clip-seed2.jsonl
python examples/benchmarks/torque_clip_ab.py --report clip-seed1.jsonl
Sustained Gen3 actuator derating#
Persistent torque clipping does not preserve joint feasibility. A fixed
development stress matrix on source 5391260 replays Gen3 seed-1 pickplace
scenarios 0, 1, 2, 3, 4 and 15 with unchanged N64 ADMM settings. The simulated
actuator is capped below its nominal URDF effort limit while the planner
continues to assume nominal torque authority. This deliberately mismatched
plant tests actuator derating, not nominal-limit robustness. All thirty
outcomes are retained, including aborts; durations below are simulated time.
Actuator cap |
Three goals reached |
Episodes with clipping |
Longest continuous clipping of one joint |
Peak velocity / nominal limit |
|---|---|---|---|---|
Unclipped |
6/6 |
0/6 |
0 s |
1.019 |
100% nominal |
6/6 |
0/6 |
0 s |
1.019 |
50% nominal |
6/6 |
3/6 |
0.190 s |
4.019 |
25% nominal |
1/6 |
6/6 |
2.279 s |
5.216 before abort |
10% nominal |
0/6 |
6/6 |
0.454 s |
5.035 before abort |
Five quarter-torque cases and all six tenth-torque cases abort at the numerical escape guard: velocity above five times its limit or position excursion above one radian. Their recorded maxima stop at that guard. At tenth torque, none of the six initial poses can be statically supported: the initial gravity torque requires 1.96 to 2.30 times the available cap. Quarter torque can support the initial poses, but this does not establish feasibility along the requested motions. Even the successful half-torque cases include a fourfold velocity excursion. Goal completion and torque clipping must not be presented as a safety or joint-feasibility guarantee.
Velocity prediction diagnosis#
The same collection includes three solver-budget comparisons on development scenario 15 and one unchanged diagnostic replay of holdout scenario 94. It does not add observations to the holdout or retune its defaults.
On development scenario 15, the peak velocity ratio is 1.0191 with the default, 1.0167 with ADMM early exit disabled, 0.9385 with three SQP iterations per tick, and 1.5942 with telemetry-only constraints. All four reach three goals. Additional SQP work helps this case; it is not a validated default or a measured runtime tradeoff.
Two effects separate a box-feasible plan from its simulated execution:
In the worst holdout replay, joint 4 reaches 1.97951 rad/s at 0.020 s, versus the plan’s next velocity of 1.29513 rad/s. The entire planned horizon stays within nominal velocity limits (maximum ratio 0.93373). Evaluating the plan’s own discrete dynamics accounts for 0.66627 rad/s of the 0.68438 rad/s discrepancy; integration contributes another 0.01811 rad/s. The finite-iteration trajectory has a substantial dynamics-consistency error despite its small velocity-row violation.
At development scenario 15’s velocity peak, joint 1 reaches -1.42297 rad/s versus the planned -1.29560. Of the -0.12737 rad/s gap, -0.12665 comes from integrating the held command with 1 ms RK4 rather than the planner’s 10 ms frozen-acceleration velocity update. The initial-state correction is about 0.000012 rad/s. More solver iterations alone do not make those integration schemes identical.
The decomposition uses Pinocchio at the recorded states and commands. Regression tests compare those predictions against GATO’s native device integrator within 0.0005 rad/s at the selected events. This separates finite-solve dynamics consistency from integration mismatch in these cases; it does not certify every trajectory. Further work should assess both, then validate any changed policy on a fresh holdout. Production defaults and simulator behavior remain unchanged.
Raw stress and diagnosis rows contain all 34 cases: 23 complete finite episodes and 11 guarded aborts. The launcher returns nonzero when any case aborts, after saving every case. Use a fresh output path; there is no automatic resume:
python examples/benchmarks/torque_stress.py --out gen3-stress.jsonl
python examples/benchmarks/torque_stress.py --report gen3-stress.jsonl
enable_limit_al(rho)#
Bind the limit row-groups to the PHR augmented-Lagrangian mechanism:
hinge-activated grad/GN-Hessian and C¹ AL value folded into the KKT
cost and merit, with the outer dual update
lam <- max(0, lam + rho*violation) run ONCE per solve on the final
trajectory (equality rows lo == hi always active) — warm-started
repeat solves are the outer loop, so feasibility converges across MPC
steps. The update is gated on TRUE-violation acceptance (feasible or
strictly improved), so a stalled primal freezes the duals instead of
drifting. rho is fixed per enable; duals persist across solves
(reset_dual() zeroes them). Violation is honestly telemetry-reported;
get_row_duals() exposes the multipliers. While active, solves use
the direct (bdsv) linear solver and freeze trust-region rho
adaptation — both required for outer convergence (measured; see
bsqp.cuh dispatch comments). REQUIRES the trust-region floor
(constructor rho > 0, the default): f32 bdsv on an unregularized
Schur system returns garbage steps (R1). R1 default rho=1.0 — the
fold lands rho on ACTIVE rows whose natural Hessian scale is tiny
(qd rows ~1e-4): rho >= 10 makes the f32 factor error large enough
that closed-loop MPC destabilizes on tight-limit plants (measured:
iiwa14 pickplace spins at 100 rad/s at rho=100, final 5mm at
rho=1). Higher rho = tighter transients — raise it only within the
f32 ceiling (rho ~ 1e4 x the block’s natural Hessian scale).
enable_ee_terminal_equality(target, rho)#
Append an EE terminal-position equality row-group: the returned
trajectory’s final-knot EE position is constrained to target (xyz,
lo == hi). The first non-selection row kind — evaluated by
on-device FK, in the SOLVER’s EE frame (ee_pos(q, frame="solver")
— the same frame the tracking cost optimizes; see ee_pos for the
frame-offset caveat). Mechanism follows the current mode: AL when
enable_limit_al() is active (always-active equality, signed
multiplier in lam_hi), ADMM when enable_limit_admm() is active
(linearized inner-loop projection: z pins to target, y accumulates
the equality multiplier), telemetry-only reporting otherwise. Call
AFTER enable_limit_* — mechanism enables reinstall the canonical
groups and drop appended ones. R1 binding ruling: ADMM binding measured
best for closed-loop MPC (2mm finals at rho=10); AL binding works at
SOFT rho (al rho=1, ee rho=1: ~5mm finals) — at rho=100 the equality
multiplier winds up through the f32 factor error and diverges.
set_row_group_bounds(g, lo, hi)#
Override group g’s interval bounds (arrays of n_rows each).
lo == hi rows become always-active equalities under AL. ADMM’s
auxiliary state reinitializes on the next solve (re-clip).
set_row_group_soft(g, sigma)#
Soft/slack toggle (TurboMPC delta_xi) for group g: sigma > 0
makes its rows ELASTIC — transient violation is traded against the
elastic weight instead of forced to zero. AL: L1 slack — the
effective multiplier saturates at sigma (the outer update caps
|lam| <= sigma; the principled lambda-cap for conflict regimes).
ADMM: quadratic slack — smoothed z-projection (slope
rho/(rho+sigma) past a bound; sigma -> inf recovers the hard clamp).
sigma = 0 restores the exact hard path. Telemetry always reports
the TRUE violation, slack notwithstanding.
set_admm_merit(on)#
R1 ablation toggle: include the AL-form ADMM constraint value y’(g - z) + (rho/2)|g - z|^2 (current row state) in the line-search merit. v1 ADMM’s merit is tracking-only, so the line search rejects steps that trade tracking for feasibility (measured: closed-loop MPC parks in conservative basins). Off by default — the exact v1 semantics; only read while ADMM mode is active.
⚠ R2 measured (2026-07-30): ON + a CONFLICTED cone group DIVERGES (NaN merit, violation blowup to 1e4..1e14 on 3/4 press-family cells); on feasible cells it merely matches OFF. Keep OFF unless the constraint set is known feasible-along-the-path.
set_admm_rho_adaptation(on)#
OSQP-style ADMM rho adaptation (opt-in; default OFF = bitwise pre-adaptation path). One per-solve SCALAR multiplier on top of every ADMM group’s rho baseline (the bound per-group ratios — cone u-block 0.01 vs collision Q-block 1.0+ — are preserved), updated once per SQP iteration from the inner loop’s final residuals: adapt when r_prim/r_dual is imbalanced by >5x, step by sqrt(ratio), clamp to [1e-2, 1e2] (OSQP’s rule). The dual form is unscaled, so rho changes need no y-rescaling; the rho*G’G fold refreshes each SQP iteration. The scale persists across solves (warm rho, like the (z, y) dual warm start); toggling resets it to 1. Telemetry: get_admm_rho_scale().
MEASURED (2026-08-01 recovery cells, both plants): a clear WIN on collision/Q-block rows — pillars at the bound cc_rho=1.0 tightens iiwa14 cc_viol_max 0.021 -> 0.0031 (beats even the static 5.0 binding’s 0.0099) and halves indy7’s mean violation at flat tracking, inner residual ~5x tighter. Do NOT enable on CONFLICTED cone cells: an irreducible primal residual reads as “under-penalized”, the rule adapts UP away from the sharp 0.01 u-block pocket, and the fixed-budget loop destabilizes (press_mild cone@1.0 got worse, not recovered). Rule of thumb: adapt where the imbalance is a SCALE problem (state-block rows), keep the bound static rho where the constraint fights the task.
add_lin_u_rows(C, d, lo, hi, mech, rho, delta, sigma, cone, knot_lo, knot_hi, admm_iters, equilibrate, normalize)#
Append a LIN_U row-group: m rows g = C @ u + d on the control
block (C shape (m, nu), FROZEN at a host-chosen configuration — the
cross-term audit’s contact-frame rule for config-dependent maps).
cone=True binds SECOND-ORDER-CONE semantics to the row vector
(row 0 = axis t, rows 1.. = x-bar; feasible iff ||x-bar|| <= t;
lo/hi unused): ADMM z-update = SOC projection (admm_soc), AL =
conic PHR (dual vector projected onto K each outer update; hard-only),
barrier = relaxed barrier on the margin t - ||x-bar||. cone=False
keeps interval semantics on the mapped rows (lo/hi required) — the
pyramid-facet path.
mech is “telemetry” | “barrier” | “admm” | “al” (None = follow the
active enable_limit_* mode). Mixing mechanisms across groups composes
(e.g. AL boxes + ADMM cone). Call AFTER enable_limit_* — mechanism
enables reinstall the canonical groups and drop appended ones.
rho defaults per mechanism, BOUND by the R2 round (2026-07-30,
measured): admm 0.01 (sharp optimum on
the feasible cone cell — 0.002 and 0.05 both park the closed loop),
al 1.0 (enforces in the stationary/hard regime; NO al rho tracks AND
enforces on transient cells — prefer admm there), barrier 3e-3 (soft
fallback; 1e-2 parks). The rho-scale law applies: the fold lands
rho * C^T C on the R block, so scale rho DOWN by ||C||^2 when the map
is large. Telemetry reports the cone margin violation
max(0, ||x-bar|| - t) (interval rows: interval violation).
equilibrate=True (interval rows only) rescales each row of
(C, d, lo, hi) by 1/||C_i||_2 at enable time — an exact
reformulation (same feasible set) that puts the group’s rho on
unit-norm rows (the TinyMPC-style normalization; the manual
rho-scale-law correction becomes automatic). Rejected for cone
rows: an SOC couples its rows, so per-row scaling would change
the cone — cone rows are instead normalized as a WHOLE map (below).
normalize=True (cone rows only): scale the whole (C, d) uniformly
by 1/||C||_2 before install. An SOC is invariant under uniform positive
scaling, so the feasible set is untouched — but the admm/al fold lands
rho * C^T C on the R block, so a large map (e.g. pinv(J^T) with
||C|| ~ 1/sigma_min(J) ~ 6) silently over-regularizes the controls at
the bound-default rho (measured 2026-08-09: 2x tracking loss on the
wipe task’s frozen-pinv cone). The R2 rho defaults are for unit-norm
maps; this makes that the installed contract. get_row_groups() returns
the NORMALIZED map. Pass normalize=False to install verbatim.
add_fc_box(lo, hi, slots)#
Box rows on contact-force slots (GATO_CONTACT_FORCES builds only):
selection LIN_U rows on control columns n_actuated+slots. slots
indexes into the fc block (default: all n_fc slots); lo/hi broadcast.
Pin the wrench torque rows of a point contact with
add_fc_box(0, 0, slots=range(3)) (wrench layout is [n; f]); on
multi-contact plants address a frame’s block with
solver.fc_slots(frame, "n"|"f") (frame = index or baked name from
solver.contact_frames; go2 bakes the four *_foot_joint frames).
Extra kwargs go to add_lin_u_rows (mech/rho/knot range/…). ⚠ Like every
appended row group the default mechanism is TELEMETRY (unless an
enable_limit_* set one) — pass mech="al" (or “admm”) for an
enforced pin; the standing gates pin the moment rows with AL.
enable_u_cone(C, d, mech, rho, form, facets, facet_scale)#
Cone constraint on a mapped control quantity g = C @ u + d (CL-2 demo surface: e.g. an EE contact-force friction cone with C = S @ pinv(J(q).T), rows [mu*f_n; f_t1; f_t2], frozen at q).
form=”soc”: exact second-order cone via add_lin_u_rows(cone=True).
form=”pyramid”: m must be 3; the cone is replaced by facets
one-sided linear rows h_j = cos(th_j) g1 + sin(th_j) g2 - s*g0 <= 0
riding the ordinary interval machinery (any mechanism, slack toggle
included). facet_scale=”inscribed” (s = cos(pi/facets), conservative:
facet-feasible => cone-feasible) or “circumscribed” (s = 1, outer
approximation). Returns the appended group index.
set_collision_environment(spheres, capsules, cuboids, planes)#
Upload the runtime obstacle set for the COLLISION clearance rows (CL-2). Lists of tuples, one per obstacle (all in world frame, meters):
spheres: (x, y, z, r)
capsules: (ax, ay, az, bx, by, bz, r) — segment endpoints + radius
cuboids: (cx, cy, cz, ux, uy, uz, hu, vx, vy, vz, hv, wx, wy, wz, hw) — oriented box: center, then 3 (unit axis, half-extent) pairs
planes: (nx, ny, nz, d) — half-space n·p >= d, n a UNIT normal pointing into FREE space (ground floor at z0: (0, 0, 1, z0))
Deep-copied to the device; callable between solves. An empty environment makes every clearance +1e30 (rows inert).
enable_collision(mech, margin, rho, delta, sigma, knot_lo, admm_iters)#
Append THE collision clearance group (one max): per-sphere rows
d_i(q_k) >= margin over knots [knot_lo, N] — d_i = signed clearance of
collision sphere i (baked at codegen, collision_res) to the
nearest obstacle from set_collision_environment (call that FIRST).
The covering spheres are already conservative (inflated by the
spherizer), so margin is extra safety on top.
mech: “admm” (linearized one-sided intervals in the inner loop —
the transient/MPC recommendation, mirroring the R2 cone verdict; AL
under-enforces on transient avoidance even at rho 5), “al” (PHR
hinge; +L1 elastic via sigma>0), “barrier” (soft), or “telemetry”
(report-only). None follows the active enable_limit_* mode.
rho defaults: admm 1.0 — BOUND by the 2b pillars round (2026-07-30): clearance rows fold onto the Q block (natural scale O(q_cost)), so the admm rho pocket is WIDE AND FLAT, unlike the cone’s sharp u-block pocket at 0.01 — violation drops monotonically over rho 0.01..5.0 at flat tracking cost (indy7 2.7mm / iiwa14 21mm sphere- margin violation at 1.0; 5.0 strictly clears both). Raise toward 5 for strict clearance; the rho-scale law applies per target block. al 1.0 / barrier 3e-3 as elsewhere. knot_lo >= 1 always: x_0 is data — a start pose in collision would make knot-0 rows unsatisfiable (the R1 windup lesson).
Telemetry group slot: the clearance group’s {max, sum} true violation
rides stats.row_{max,sum}_violation at the group’s index (get_row_groups() order) at this group’s index (see get_row_groups).
Call AFTER enable_limit_* (mechanism enables reinstall the canonical
groups, dropping appended ones).
add_contact_pos_rows(targets, lo, hi, mech, rho, delta, sigma, knot_lo, knot_hi, admm_iters)#
CONTACT_POS row-group (CL-4 §1.3, 2026-09-26): for every baked contact frame f
(contact_frames: the go2 feet, an arm’s EE) and every knot k of
[knot_lo, knot_hi) three rows g = p_f(q_k) - tgt_k[f] — the world position
of the contact ORIGIN (the point the fc wrench acts about) from on-device FK
over GRiD’s contact_frame_positions[_gradient] surface, with the interval
lo <= g <= hi on the RESIDUAL. Defaults lo = hi = 0 pin the frames to their
targets (an equality per knot); lo = clearance, hi = inf on a z row (the
others masked off) is a swing clearance. targets is (N, n_rows),
(n_frames, 3) or (n_rows,) (broadcast over knots; None = zeros = the raw
position). Unlike the terminal-only EE row, these fold at EVERY active knot
(the dense J^T J Gauss-Newton term on the tangent q block, like the collision
rows; ADMM linearizes per knot in its inner loop). The per-knot target table
(get_row_groups()[g]["tgt"], set_row_group_targets) lets one group hold a
different point per knot: a foothold frozen at touchdown, a swing curve.
Mask (knot, foot) rows with set_row_group_mask — knot 0 is the measured state,
so leave it masked off unless the target IS the measured foot position (the
AL multiplier would otherwise wind up on rows no step can change, the
limit-box lesson). Mechanism defaults follow enable_collision (the rows fold
onto the Q block: admm rho 1.0, al rho 1.0; the go2 foot gates run AL rho 1e3, GaitProgrammer defaults to the loop-stable 100).
Returns the group index.
Measured (2026-09-26). The AL outer loop converges/freezes after the first
solve on these rows, so they act as PENALTIES — rho sets the residual: on the
gentle indy7 scenario (tracking = stay, target 2 cm up) |p - tgt| is 2.3 cm at
rho 10, 1.3 cm at rho 100, 0.8 mm at rho 1e3 (the terminal EE_POS AL row does
not move the EE at all there); ADMM projects per inner iteration and reaches the
same at rho ~1e2. The telemetry residual at the measured state equals pinocchio’s
frame position to < 2e-4 (f32 FK); on go2 fc the feet stay < 1 mm under a lateral
imu goal and a solver-only foot lift (FR +3 cm from knot 4, wrench pinned, fn on
three feet) reaches ~3 cm at AL rho 1e3 with the stance feet held < 1 cm. The
merit’s row term is exact against the duals and residuals (zero-dual and dual
checks agree to 1e-6). CLOSED LOOP (open work): the stiffness the rows
need to hold feet within mm breaks the loop’s SQP — on the static stand with
stance rows AL rho 1e3 rejects every line-search step on 26/150 ticks and the base
sags 4 cm, every ADMM variant collapses, AL rho 100 keeps the stand with a 1.8 cm
residual. Two fixes landed since: the solver bumps the trust-region rho x100 on a
non-PD direct factor (the f32 Cholesky failure the stiff rows cause; settings.h
NON_PD_RHO_FACTOR), and the programmer re-anchors stance targets to the measured feet
every tick — the static stand with stance rows now holds (test_fc_mpc_stands_with_foot_rows).
These are horizon-local targets: the measured feet may accumulate world-frame
drift while the model’s residual stays small. The standing gate separately bounds
measured horizontal drift to 3 cm; it does not establish a no-slip locomotion gate.
Lifting a foot is still open: without a support-polygon base plan the model tilts and the
diagonal foot rises; S2 (weight shift) / S3 (lift-a-foot) closed-loop gates are not shipped.
set_row_group_targets(g, targets)#
Per-knot target table of a CONTACT_POS group ((N, n_rows), (n_frames, 3) or
(n_rows,)); ADMM z/y re-initialize on the next solve because the residual
moved. GaitProgrammer.apply(t, q) writes it every tick (stance targets
re-anchored to measured feet, future touchdown targets at planned landings, and
swing targets along GaitSchedule.swing_curve).