The staged hull narrow phase — what shipped, what it cost, and what it did not do
Status: implemented, safety-WG review pending — hazard-log Entry 018. (Drafted as Entry 014; renumbered because 014 was already taken.) This is the change record for the staged 26-DOP → exact-convex-hull narrow phase in
check_voxel_collision's box pass, scoped to arm-link vs world-voxel checks only.Extended 2026-09-02 by issue #191 to
check_self_collision's box↔box pass, which is why §9 exists. That extension retired thepanda_link5↔panda_link7ACM exemption — the one open item the collision-safety alternatives survey §2.2 left on the ACM theme.panda_link5andpanda_link7declaretight_geometrytoo, and since 2026-09-07 all seven links do (§5.1, §5.2).The evidence this rests on was produced by four earlier studies and is cited, not re-argued: the collision-primitive study (#157), its picture book (#158), the RoboCasa start-state census (#159), the validation-evidence ledger (#160), and above all tight link geometry in the safety kernel (#161), whose §12.2 is the design this implements. Every number in §2 and §3 below was re-measured on this machine against this implementation, not carried over.
1. The question that was asked, and the answer
The request was: do the arm-to-world-voxel safety check via convex hull rather than primitives, and deprecate the primitive code — if it is faster and more precise.
More precise: yes, unconditionally. The shipped OBB over-reports the link's
reach by 53.27 mm on panda_link1 and 46.83 mm on panda_link2; the 26-DOP cuts
that to 25.69 mm and 23.01 mm, and the exact convex hull to 0.00 mm, by
definition (h_hull(u) = h_mesh(u)).
Faster: yes, but only when the exact stage is bounded — and the bounds are the substance of this change:
- the 26-DOP stage is 8.5–10× cheaper per query than
box_box_distance(30–36 ns against 307 ns) and is the reason the whole thing pays; - the exact hull stage is 2–2.5× more expensive per query, so it is affordable only because it runs on the small minority of cells the DOP cannot clear;
- an unbounded exact hull is a straightforward loss.
panda_link1's hull is 1588 vertices and measured 0.77× the shipped routine's speed at 400 occupied cells. The study predicted this (§12.2: "Do not ship the raw 1588-vertex hull on an always-on path") and the implementation refuses it.
Deprecate the primitive code: no, and a blanket removal would be wrong (§5).
One result came out against the framing, and it changed the implementation: a 26-DOP is a strictly smaller solid than the OBB that contains it, but it does not follow that its separating-axis bound is always the larger number (§4.2). The kernel folds the shipped bound back in, and the change is provably unable to introduce a stop that does not already happen today.
2. Precision, measured
Max support excess max_u [h_C(u) - h_mesh(u)] over a 20 000-direction
Fibonacci set, against robosuite 1.5.2's link<N>_collision meshes placed by
the geom transform only (#161 §11), matching
161 §3 to 0.1 mm on all seven links.
| link | shipped OBB | 26-DOP (stage 1) | exact hull (stage 2) |
|---|---|---|---|
panda_link1 |
53.27 mm | 25.69 mm | 0.00 mm |
panda_link2 |
46.83 mm | 23.01 mm | 0.00 mm |
| link3 | 75.57 | 23.84 | 0.00 |
| link4 | 76.12 | 23.15 | 0.00 |
| link5 | 45.20 | 18.99 | 0.00 |
| link6 | 52.70 | 21.53 | 0.00 |
| link7 | 28.25 | 12.97 | 0.00 |
Links 3–7 were measured but not shipped when this was written — see §5.2; all
seven ship now. tests/unit/test_collision_tight_geometry.py pins all seven rows, so a
change that quietly stopped tightening fails rather than going unnoticed.
3. Cost, measured
Host: Intel Core i5-8600K, 6C/6T, 4.2 GHz sustained, x86_64, Linux 6.8.0-137,
g++ 13.3.0, -std=c++17 -O2 -g -DNDEBUG, no -march — the kernel's real
flags, the same machine #161 measured on, where box_box_distance reproduces at
307.0 ns against its published 304.0 ns. taskset-pinned, median of 151
repetitions. Allocation freedom was verified, not assumed: 0 allocations
inside every timed region, pinned in CI by
NoAlloc.ForwardKinematicsAndSelfCollisionAreAllocationFree, which now drives
the staged path (GJK simplex included) 10 000 times under the same counter.
3.1 Per query
One link primitive against one occupied 25 mm cell, in the kernel's own streaming pattern.
| narrow phase | ns/query | vs shipped |
|---|---|---|
box_box_distance (ships today) |
307.0 | 1.00× |
stage 1 — 26-DOP SAT, link1 |
28.7 | 10.7× faster |
stage 1 — 26-DOP SAT, link2 |
30.3–35.3 | 8.7–10.1× faster |
stage 2 — GJK on link2's 152-vertex hull, warm |
~590–740 | 1.9–2.4× slower |
stage 2 — GJK on link1's 1588-vertex hull, warm |
~3 700 | 12× slower |
Stage 1 beats #161's published 62.8 ns because every term depending only on the
link pose is hoisted out of the cell loop by tight_pose_init — free, because
the kernel streams cell centres against a fixed pose.
3.2 End to end, through the real check_voxel_collision
Seven-link Panda at a mid-reach configuration, 96×96×60 grid at 25 mm (a
room-sized 0.55 MB map), 10 475 cells across the seven link windows.
Occupancy is the N cells closest to the arm, ranked by the shipped routine so
every variant sees an identical grid — deliberately adverse. world_voxel_margin_m
is 0.0 in sim and 0.02 on real hardware
(packages/openral_rskill_ros/launch/deploy_e2e.launch.py), so both are measured.
At the sim margin (0.0 m) — the configuration panda_mobile actually runs:
| occupied cells | shipped | staged (as shipped) | raw 1588-vtx hull on link1 |
|---|---|---|---|
| 0 | 8.71 µs | 8.81 µs (0.99×) | 8.81 µs |
| 20 (the census's measured occupancy) | 22.34 | 21.49 (1.04×) | 22.28 |
| 100 | 70.75 | 64.01 (1.11×) | 89.32 (0.79×) |
| 400 | 255.06 | 235.83 (1.08×) | 345.56 (0.74×) |
| 1200 | 728.48 | 665.73 (1.09×) | 995.57 (0.73×) |
At the real-HAL margin (0.02 m):
| occupied cells | shipped | staged (as shipped) |
|---|---|---|
| 20 | 26.30 µs | 27.22 (0.97×) |
| 100 | 79.86 | 97.13 (0.82×) |
| 400 | 287.10 | 309.11 (0.93×) |
| 1200 | 841.99 | 823.08 (1.02×) |
Read the second table as the standing caveat it is. A wider margin pushes
more cells past stage 1's clear test into stage 2, the expensive half.
panda_mobile is sim-only (hal.real: null), so the shipped configuration is
the first table — but any real-HAL rollout must re-measure at that robot's own
margin before enabling it, a condition that belongs in the hazard entry.
3.3 Why the totals move so little
Because the empty-cell scan dominates, exactly as #161 §9.2 found: at 20
occupied cells in a 10 475-cell window, more than 99.8 % of the loop is the
if (occupancy[idx] == 0) continue; fast path, so a 10× cheaper narrow phase
moves a term that is a third of the cost. This confirms #161 §12.7 from the
implementation side: the remaining work is on the world grid, not the robot.
4. The safety case
4.1 The containment chain
link mesh ⊆ exact convex hull ⊆ 26-DOP ⊆ shipped OBB
Every link is definitional, not fitted — no optimiser tolerance anywhere:
| link | why it holds | where it is proved |
|---|---|---|
mesh ⊆ hull |
the hull is conv(mesh vertices) |
tests/unit/test_collision_tight_geometry.py, facet by facet against the real robosuite mesh |
hull ⊆ 26-DOP |
the slabs are tangent halfspaces u·x ≤ max_mesh(u·x), so every mesh point satisfies every constraint by construction |
TightCollisionGeometry validator, and again in validate_tight_geometry at on_configure |
26-DOP ⊆ OBB |
a 26-DOP lies inside its own first three slabs, and those three axes are the box's axes | LinkCollisionGeometry validator, validate_tight_geometry, and the Python test — with no tolerance, because the margin here is real |
Measured inward margin of the DOP inside the shipped box: 0.0831 mm on
link1, 0.0546 mm on link2 — reproducing #157 §7.3's observation that the
shipped boxes carry 0.055–0.132 mm of accidental headroom and nothing may
consume it. The one floating-point tolerance is hull ⊆ DOP, at
kTightContainmentEpsilonM = 1e-9 m: that relation is an equality on at least
one vertex per axis by construction, so one nanometre buys numerical agreement,
not geometric room.
4.2 The result that changed the design
check_voxel_collision sizes its broad-phase window from half_extents alone —
157's one place where a representation change can make the kernel unsafe
rather than merely tighter, since an under-sized window never visits cells that
genuinely intersect the solid. Because both stages are proved subsets of the
shipped OBB at configure time, that window is not touched: the formula at
collision.cpp remains Σ_j |R_kj|·he_j about the box origin.
What did not survive contact with a test is the weaker claim that "the DOP is a
smaller solid, so its bound must be at least as large as the box's." That is
false: the DOP's 16 separating axes (13 body + 3 world) are not a superset of
box_box_distance's 15 (6 face + 9 edge-cross), so the tighter solid can yield
the looser bound. That is not unsafe — a smaller reported distance is more
conservative — but it would mean new false stops. So check_voxel_collision
folds box_box_distance back in for any cell the staged path is about to stop on
and takes the maximum; the maximum of two lower bounds is a lower bound, so this
costs no soundness and buys a strong property:
The staged path can never stop where the shipped path would not have. It removes stops the box was causing; it cannot add one.
Pinned by VoxelCollisionTightGeometry.NeverStopsWhereTheShippedPathWouldNotHave
over ~22 000 single-cell grids on the real seven-link Panda, and by
TightNarrowPhase.TheDopAloneIsSometimesTheMoreConservativeOfTheTwo, which
fails if the fold ever becomes redundant so nobody deletes it as dead weight.
4.3 Conservatism, stated in the right direction
- Against the true geometry, the staged path never over-reports clearance. Every value it can return — stage 1's SAT bound, stage 2's converged distance, stage 2's truncated bound, the overlap fallback, the budget-exhausted fallback — is a lower bound on the true link-mesh-to-cell distance.
- Against the shipped OBB, it deliberately reports more clearance, by up
to 53 mm on
link1. Removing excess conservatism is the change; keeping the kernel sound is the constraint. A hazard entry blurring the two would be the failure mode CLAUDE.md §1.2 exists to prevent.
Every early exit is conservative by construction, which is what makes the real-time bounds safe rather than merely convenient:
| bound | what happens when it binds | direction |
|---|---|---|
kGjkMaxIterations = 24 |
returns the best supporting-hyperplane bound so far | more conservative |
early exit once the bound clears margin |
stops sharpening a diagnostic | more conservative |
kMaxStage2PerCheck = 32 |
falls back to stage 1 + box_box_distance |
today's answer or better |
kMaxTightHullVertices = 320 |
link never gets stage 2 at all | stage 1 only, still a tightening |
| GJK detects overlap | returns stage 1's bound, which is ≤ 0 there because the DOP contains the hull | more conservative |
4.4 Why the support scan is exhaustive
hull_cell_distance's support function is a linear scan of the vertex list, a
deliberate refusal of the standard optimisation. Hill-climbing over the hull's
edge graph is correct for a convex polytope under exact arithmetic, but under
floating point it can stop one vertex short of the true support — and a support
that is not the true maximum turns the supporting-hyperplane bound into an
over-report of clearance. The kernel buys soundness with a linear scan and
bounds the cost with kMaxTightHullVertices instead, which is the direct cause
of panda_link1 shipping stage 1 only (§5.2).
5. Scope — what was replaced, and what was not
5.1 Replaced
Two call sites, both gated on a manifest declaring tight_geometry: the
box_box_distance in check_voxel_collision's box pass (this document's
original scope, then panda_link1 and panda_link2) and the box_box_distance
in check_self_collision's box↔box pass, added by #191 and described in §9,
which panda_link5 and panda_link7 joined for. Since 2026-09-07 all seven
panda_mobile links declare it: panda_link1 stage 1 only, the other six both
stages.
5.2 Not replaced, and why
| surface | keeps the primitive path because |
|---|---|
| ~~attached payloads~~ | Reversed 2026-09-11 by #266 — check_attached_voxel_collision now runs the staged path. The original reason ("no measured motivation") was true when written and stopped being true: the 25→15 mm resolution A/B (#253) returned a null because the voxel term is the small one, and 424 samples across 4 scenes put the payload box's corners a median 50.78 mm (max 88.22 mm) proud of the mesh — 2.3–3.9× the whole quantisation term — while payload-vs-world rose from 27/34 (79 %) of stops at 25 mm to 36/37 (97 %) at 15 mm. §10. check_attached_self_collision is still the primitive path (check_attached_world_collision was retired 2026-09-23 with the capsule world phase, ADR-0109) |
~~world-capsule obstacles~~ (check_world_collision) |
out of scope; not voxel geometry. Retired 2026-09-23 with the capsule world phase (ADR-0109). |
capsule-lowered robots (h1, rizon4, every MJCF-lowered model) |
tight geometry refines a BoxShape; a capsule has no box to state the containment proof against, and the schema refuses it |
| the broad-phase window | §4.2 — this is the one thing that must not move |
~~panda_link3, link4, link6~~ |
Reversed 2026-09-07 — they now ship. The original reason (#159: "zero of the 72 census stops") was measured on start states, and it was true of them. The 120-run #204 battery measured the carry phase instead and found panda_link6 dominating 18 of 29 link-class stops, with 33.1 mm of median link-class excess surviving once the voxel term is subtracted. The three DOPs recover 51.7 / 53.0 / 31.2 mm of support excess (75.6→23.8, 76.1→23.2, 52.7→21.5 mm), and link6's 31.2 mm is almost exactly the measured excess. Hulls are 152/152/102 vertices — the same class as the already-shipped link2/link5/link7, well inside kMaxTightHullVertices. See the programme note §5 in collision-validation-evidence.md |
panda_link1's exact hull |
1588 vertices, over kMaxTightHullVertices, measured 0.74–0.79× the shipped routine's speed. It gets stage 1 only |
check_self_collisionwas in this table as "out of the asked scope" until191, on the reasoning that leaving it untouched kept
check_attached_self_collision's conservatism unchanged. That has been overtaken twice: #191 moved the self path (§9) and #266 the attached world-voxel path (§10).check_attached_self_collisionitself is still box↔box — 3 % of the measured stops, and its adjudication budget (attached_payload_mesh_slop) is stated against exactly that box.
box_box_distance itself is not deprecated. It remains the narrow phase for
every link without tight geometry, the fold in §4.2, and the routine every other
check uses. What is deprecated is relying on the OBB as the arm-link↔world-voxel
narrow phase for a link that could declare tight geometry — a manifest-authoring
posture, not dead code.
5.3 What #171 measured against the live map, and what it means for this change
PR #171 re-scored the question against the live octomap rather than an idealised grid, apportioning each stop by whether exact link geometry clears it:
| n | share | |
|---|---|---|
| link-side — exact geometry clears it | 6 | 26 % |
| world-side — exact geometry still stops | 17 | 74 % |
(23 live stops across four scenes.) Within the world-side residue, 11
stops — 48 % of all stops — were held by the octree→grid bridge's rasterisation
dilation (mechanism;
issue #173), the single largest term in the apportionment. That dilation
was removed on 2026-08-25: the grid is now published on the octree's own lattice
(octree_to_grid.hpp). The other
world-side mechanism, non-collidable geometry being real to the depth camera, is
issue #174.
So this change is necessary and correct, but not sufficient. It fixes
the link-side term exactly; the majority term was elsewhere (#173, since removed).
The link-side excess #171 removes is large (median 28.72 mm, up to 42.56 mm
on panda_link2), and it is real clearance the robot has today and is not being
credited with.
The utensil scene: a measured recovery attributable to this change
robocasa_drawer_utensil at its shipped pin takes a marginal
initial-configuration stop the ideal-grid census never saw: the live map reads
−1.94 mm, the same state under exact geometry +20.01 mm, and #171 calls
it entirely link-side. But the stop is on panda_link1, the link that ships
stage 1 only, so the question is whether the 26-DOP alone clears it.
Decomposing as d_C = d_true − E_C(u*), the shipped box's realised excess along
the approach direction is E_OBB(u*) = 21.95 mm and the DOP clears iff
E_DOP(u*) < 20.01 mm; u* is not published, so it was bracketed over a
200 000-direction Fibonacci set on the real link1 mesh:
| direction set | n | max E_DOP |
median | clears? |
|---|---|---|---|---|
E_OBB(u) within ±0.5 mm of the measured 21.95 mm |
5 507 | 18.04 mm | 4.27 mm | yes, 1.97 mm spare |
E_OBB(u) ≥ 20.01 mm (a strictly larger, more adverse set) |
155 529 | 25.70 mm | 4.95 mm | not guaranteed |
| all directions | 200 000 | 25.70 mm | 4.40 mm | not guaranteed |
The first row is conditioned on what was measured; the rows below it are the
sensitivity. Three caveats keep this a strong expectation rather than a
proof: the 1.97 mm of spare is a geometry term and the DOP's own
separating-axis deficit eats into it (#161 §6.2 measures mean 0.159 mm near
contact, exact 80.2 % of the time, with a 22.5 mm tail); the decomposition
assumes the approach direction is the same for both representations; and #171's
EXACT column is a densely sampled surface distance, bracketed rather than
exact (it is not affected by the mj_geomDistance defect #170 fixed: #171
never used that probe). The floor is firm regardless, because the kernel folds box_box_distance
back in and the reported distance is never worse than today's −1.94 mm.
5.4 What this does not buy
161 §7.3's ceiling has not moved: **perfect link geometry
recovers 27 of the census's 72 stopping states; 45 are held by the world grid.
The recommended configuration (exact hull on link1 and link2) scores 25/72;
what shipped here is less, because link1 gets the DOP rather than its hull.
This change is justified by cost and correctness**, not by unblocking scenes,
and it should not be described as unblocking anything. The 25 mm voxel grid — its
resolution, its 20.3 mm lattice-phase swing (#161 §7.6), and the map-fidelity
term #160 measured — is still where the remaining recovery lives.
6. Hazard-log Entry 012 — not engaged, re-verified against this implementation
Entry 012 obliges the kernel's support_contact_exempts and the octomap
bridge's payload_clearing.support_patch_withholds to move in lockstep so
withheld ⊆ exempt holds by construction. #157 and #161 both concluded it is not
engaged by a robot-link representation change; re-verified here against what
was actually written, not inherited:
support_contact_exemptsreceives noCollisionModeland gained no argument;support_patch_withholdskeys onSupportPatchonly, and the bridge switches on the attached payload's wire primitives.check_attached_voxel_collision'sCollisionModelparameter is still literally unnamed.check_attached_self_collisiondoes read robot link boxes — and still reads exactly the boxes it read before, because tight geometry lives inbox_hull/hulls/hull_verticesand is consulted at one call site, the box pass ofcheck_voxel_collision. So unlike the change #161 costed, this one does not make it less conservative at all; its conservatism is bit-identical.
**Verdict: Entry 012 is not engaged, and the payload-vs-robot check named in
161 §10.5 item 6 is not affected either.**
6.1 The kernel_predicates.py lockstep — checked, and it does not bite
PR #169 added
packages/openral_safety/openral_safety/kernel_predicates.py, a line-by-line
Python mirror of the kernel's narrow phase, with a duplication-watch obligation
to move in lockstep with collision.cpp, naming this PR explicitly. Checked
rather than complied with: the mirror exists so the offline ACM sweep asks
the kernel's own question, and that sweep is about link-vs-link
self-collision. This change is confined to check_voxel_collision's box pass —
check_self_collision, box_box_distance, box_capsule_distance and
capsule_distance are byte-identical on this branch, so an ACM regenerated with
or without it is the same matrix. kernel_predicates.py is therefore
unchanged, deliberately, and docs/methods/14-duplication-watch.md item 12
was corrected to scope the obligation to the narrow phase it actually mirrors, in
both directions. The broad-phase reach formula, stated explicitly: for a link
transform (R, t), per world axis k, the window half-reach is
e_k = Σ_j |R_kj| · half_extents_j (about t, plus margin + half_side)
— unchanged, with hull ⊆ DOP ⊆ Box(half_extents) asserted at configure time
by validate_tight_geometry, which is what licenses leaving it alone.
7. Reproducing
# Containment against the real meshes, and the manifest-vs-mesh check.
just sync
python -m pytest tests/unit/test_collision_tight_geometry.py tests/unit/test_collision_params.py
# Re-derive the manifest blocks (emit) or verify them (check, exit 3 on drift).
python tools/generate_tight_geometry.py check --robot robots/panda_mobile/robot.yaml
# The kernel's own conservatism suite.
just safety-kernel-build && just safety-kernel-test
The benchmark in §3 is not checked in (CLAUDE.md §1.11 keeps fixtures for tests,
not studies). It builds cpp/openral_safety_kernel/src/collision.cpp with the
flags above against a generated fixture holding the shipped OBBs, the hulls, and
MuJoCo-computed link world poses at robosuite's init_qpos; occupancy is the N
cells nearest the arm ranked by box_box_distance.
8. What the safety-WG must assert
Restating #161 §10.5 against what actually shipped, so a reviewer checks claims rather than intentions:
- Containment is proved per link, not sampled —
mesh ⊆ hull ⊆ DOP ⊆ OBB, margin 0.0831 / 0.0546 mm, re-derived from the real mesh in CI. ✔ §4.1 - The broad phase was verified, not merely left alone — reach formula unchanged, subset property asserted at load, fail-closed. ✔ §6
- Conservatism is declared in both directions — never over-reports against the truth; deliberately less conservative than the shipped box. ✔ §4.3
- The change cannot add a stop — by construction, pinned by test. ✔ §4.2
- Every real-time bound fails conservative — iteration cap, refinement budget, vertex ceiling, overlap fallback. ✔ §4.3
check_attached_self_collisionis unaffected, unlike the change #161 costed. ✔ §6- The recovery claim is the one the evidence supports — a cost and correctness change; #171 apportions 26 % link-side / 74 % world-side, 48 % of all stops held by the octree→grid rasterisation (issue #173, removed 2026-08-25). ✔ §5.3, §5.4
- The real-HAL margin is an open condition — §3.2 measures 0.82× at
world_voxel_margin_m = 0.02; re-measurement is a precondition for any robot that would ship there. - ~~Issue #155 sequencing is unchanged~~ Superseded by #191, which regenerates the
panda_mobile/panda_mobile_vslamACM precisely so that it stops carrying the exemption. §9.
9. The self-collision extension (issue #191)
check_self_collision's box↔box pass now re-asks the exact hulls when both
links declare tight_geometry (hull_hull_distance). The box stays the broad
phase and the fallback: a pair the OBBs already clear never reaches the GJK, and
a manifest declaring no tight geometry is bit-for-bit unchanged.
9.1 Why — the pair the boxes cannot answer
panda_link5↔panda_link7 shipped ACM-exempted from
#155 and stayed that way through
#169. Measured over the pair's
entire relative-DoF subspace — panda_joint6 × panda_joint7, on the same
121 × 121 = 14 641 grid #169 used, at self_collision_margin_m = 0:
| representation | fires | of which real | false |
|---|---|---|---|
| shipped OBB | 12 647 (86.38 %) | 967 | 11 680 (79.78 %) |
| exact hull | 967 (6.60 %) | 967 | 0 |
and no margin separates the OBB's populations: the deepest real collision
sits at a box gap of −8.37 mm while the shallowest false one is at
−36.64 mm. The boxes cannot separate that pair at any margin; the hulls
separate it exactly — link5 and link7 are convex to 1e-4 in volume ratio
(primitive study §4.3), so conv(mesh) is
the mesh here and the hull verdict is the mesh verdict. Tightening the boxes,
the retirement path the SRDF comment named, does not reach: #157 §4.3 measures
7.5 mm on link5 and 1.9 mm on link7 against the ~28 mm needed.
9.2 What the un-exempted pair costs at box fidelity
Simply deleting the ACM row — the issue's Option A — E-stops the robot at its
own reset pose. Measured through the kernel at robosuite's PandaOmron.init_qpos:
| model | link5↔link7 gap | verdict |
|---|---|---|
| shipped OBB | −11.68 mm | STOP |
| exact hull | +21.81 mm | clear |
The SRDF's own ready and extended group states are the same story (−9.14 mm
box, +22.13 mm hull). Its transport state is the other half of the case: box
−11.68 mm, hull −5.64 mm, and both agree it is a stop — a real self-collision
the exemption was hiding.
9.3 Cost
check_self_collision on the shipped panda_mobile at its reset pose, -O3,
200 000 calls:
| variant | µs/call | vs before |
|---|---|---|
| A — before #191 (pair exempt, no hulls) | 0.758 | 1.00× |
| B — after #191 (pair checked, hulls) | 2.303 | 3.04× |
| C — Option A (pair checked, no hulls) | 0.820 | 1.08× |
The multiplier is large because the routine it sits in is small: +1.545 µs, or 24.7 µs over a 16-step horizon, 0.25 % of a 10 ms budget. It is sustained rather than a spike, since this pair's boxes fail to clear on 86.38 % of its configuration space. The next lever, if a future robot makes that bite, is a DOP-vs-DOP stage 1 in front of the GJK, not a cheaper support function (§4.4).
9.4 The interaction with #188's graded velocity band
hull_hull_distance deliberately omits hull_cell_distance's "stop at the first
bound clearing margin" exit. There the exit is right — hundreds of cells are
visited and only their minimum is reported. Here the answer is the reported
sweep_min_distance, and the early exit was measured returning 4.2 mm for a pair
genuinely 60.0 mm apart: sound, but it would crawl the arm past a clear pose.
More consequently, the self-collision check's sweep_min_distance is no
longer folded into the graded band's slack at all. Over all 14 641 poses,
panda_link5↔panda_link7 never opens past 22.72 mm and zero clear a
50 mm band, so folding it in pinned the scale at a constant — 0.21 on the live
kernel at a 100 mm band with k = 20 — after which the world term, the one a chunk
can act on, only mattered below 22 mm: a permanent speed limit, not a slowdown.
The self-collision latch is untouched. Pinned by
LifecycleKernelTest.GradedScalingIgnoresTheRobotsOwnSelfClearance and
…TheSelfCollisionLatchSurvivesTheBandExclusion.
9.5 Two consequences worth naming
The all-zero Panda arm self-collides for real — 5.65 mm at its own collision
meshes. Two existing kernel tests flew that configuration and only passed because
the pair was exempt; both now seed panda_joint6 at the SRDF's ready value.
This is the "new stop class" #191 asked to be quantified, and it is a true
positive, not a regression.
The cuMotion emitter needed its own matrix. render_cumotion_config lowers
each link to a containing capsule, and for that geometry the pair really is
always-colliding — 100.00 % of the same grid, shallowest by 1.03 mm — so
copying the kernel's matrix would have handed cuRobo a constraint that rejects
ready. The emitter now re-derives the ACM against the spheres it actually
writes (sphere_model_geometry + acm_for_geometry), keeping the planner's
model looser than the kernel's, the only safe direction (#169). It also now
sources those spheres from the manifest the kernel checks rather than from
LoweredCollisionModel.collision_geometry, a PCA capsule for a mesh collision
(#157 §8.5). Symmetrically, _certified_always_colliding withholds for any
pair whose links both declare tight_geometry, since it reasons with the box and
hull_gap >= box_gap everywhere, so "the boxes always overlap" no longer implies
"the kernel always trips". No shipped robot loses an entry to this today: a
fail-closed guard, not a behaviour change.
9.6 Reproducing §9
# The grid, the verdicts, and the pair through the real kernel node.
python -m pytest tests/sim/safety/test_kernel_panda_link5_link7.py
python -m pytest packages/openral_safety/test/test_urdf_lowering_always_colliding.py
# The kernel mechanics.
just safety-kernel-build && ./build/openral_safety_kernel/test_collision \
--gtest_filter='SelfCollisionHull.*'
The 14 641-pose sweep and the §9.3 benchmark are one-shot analysis, not checked
in (§7). The sweep drives openral_hal.convex_distance over MuJoCo FK of
robosuite's Panda at every node of the (joint6, joint7) grid, comparing
kernel_predicates.box_box_distance on the shipped OBBs against conv(mesh).
10. The attached-payload extension (issue #266)
check_attached_voxel_collision now runs the same staged narrow phase for a
carried payload primitive that declares a proved-contained refinement. The box
stays the broad phase and the fallback; a primitive with no refinement — every
pre-#266 publisher, and every sphere/box/capsule geom, which lower exactly — is
bit-for-bit unchanged.
10.1 Why — the term that was left standing
Robot links got stage 2 in #166. Attached payloads never did:
extract_body_primitives lowered a carried mesh geom to its local AABB, the
only lowering a carried mesh had. Measured across all 80 rounds of the
2026-09-10 resolution A/B (424 samples, 4 scenes), identical in every scene:
| payload box corner slop | value |
|---|---|
| median | 50.78 mm |
| max | 88.22 mm |
For scale, the world-voxel half-diagonal that #253 proposed to shrink is 21.65 mm at 25 mm cells and 12.99 mm at 15 mm, so the payload box contributed 2.3–3.9× the entire quantisation term — which is why that A/B ran clean (80 rounds, 79 valid, 0 bond teardowns) and returned a null:
same-party paired shift +5.9 mm 95% CI [-5.7, +17.7]
predicted -8.66 mm → OUTSIDE the interval, 2.6 se away
sign test 11 neg / 12 pos, p = 1.000
Stop composition says the same from the other side: payload-vs-world was 27/34 (79 %) of stops at 25 mm and 36/37 (97 %) at 15 mm, and roughly half were not contacts at all — true certified clearance at the moment of the stop was >10 mm for 9 of 19 payload stops at 25 mm and 16 of 32 at 15 mm, median true gap 9.6 mm. Exactly what a 50 mm box bound predicts. The check stays: the ≤2 mm bucket contains real interpenetration (−7.7, −1.7, −1.5, −1.3 mm). The fix is fidelity, not removal.
10.2 What changed
| robot link (#166) | carried payload (#266) | |
|---|---|---|
| producer | tools/generate_tight_geometry.py, offline, once per robot release |
_sim_attachment_evidence._tight_geometry_from_points, online, at every grasp |
| over the vertex budget | refine_dop_to_budget (a loop of halfspace intersections) |
stage 1 only — that search is not affordable per grasp |
| carrier | RobotDescription manifest |
openral_msgs/AttachedCollisionPrimitive.tight_dop_lo / tight_dop_hi / tight_hull_vertices |
| proof | validate_tight_geometry at on_configure, fail-closed |
ingest_attached_objects per message, fail-down |
| store | CollisionModel::box_hull / hulls / hull_vertices, fixed |
AttachedPrimitive::hull_index / AttachedModel::hulls / hull_vertices, refilled in place |
| scope | arm-link↔world-voxel, and self-collision when both links declare | payload↔world-voxel only |
Both sides share validate_tight_hull (the kernel) and
openral_core.check_tight_geometry_fits_box (the schema), so a payload hull is
never held to a weaker standard than a link's.
Fail-down, not fail-closed, and why. A payload's hulls arrive on every
world-state message from a live producer, and refusing the whole attachment over
an unprovable refinement would drop the payload's geometry entirely — the
unsafe direction — while dropping only the refinement leaves the primitive
checked as the plain box, exactly the pre-#266 behaviour. An over-budget hull is
refused whole, never truncated to the first kMaxTightHullVertices, because
a truncated hull no longer contains its mesh.
10.3 The safety case
Identical in shape to §4, with one extra link at the top because the payload's geometry is derived online rather than shipped:
mesh ⊆ hull = conv(surface points) ⊆ 26-DOP ⊆ local AABB (the box)
Every link is definitional, not fitted: the slabs are tangent halfspaces, the hull is the convex hull of those points, the box is the points' own AABB grown by 1e-4 m. No optimiser tolerance appears anywhere, so the broad-phase window — sized from the box alone — never moves.
The first link holds only for a POLYTOPE, and that is not a technicality.
solid ⊆ conv(sampled surface points) is an equality for a mesh or a box and
runs the wrong way for anything curved. geom_surface_points samples a sphere
at its six axis poles, so the support of those points along the 26-DOP's first
corner axis is r/√3 = 0.577 r while the sphere reaches r — a 50 mm sphere
cut 21.13 mm inside its own surface, larger than the 12.99 mm half-diagonal
of a 15 mm voxel, which would make the kernel report the payload farther from an
occupied cell than it is: a missed stop. Nothing downstream can catch it, since
the schema validator and validate_tight_hull compare only the three box axes.
So geom_is_polytope gates both lowering paths, and one ungroundable geom
disqualifies its whole cluster, since a DOP is a single solid bounding all of
them. Pinned by test_a_curved_geom_never_grounds_a_refinement, which asserts
the 21.13 mm deficit rather than describing it. validate_tight_hull also
requires positive extent on the three box axes, rejecting the one degeneracy the
slabs can express and the proof cannot exclude — 13 zero lo and 13 zero hi,
finite and inside any box, yet collapsing the payload to a point.
Conservatism, stated in the right direction: every stage is a lower bound on
the true mesh-to-cell clearance and the kernel takes the max of the ones it
computed, so the refinement can only ever raise the reported clearance and
therefore only ever remove a stop, never add one.
AttachedVoxelTightGeometry.RefiningAPayloadOnlyEverRemovesStops pins that
through the shipped entry point over a sweep of single-cell grids.
No convex-hull solver runs: stage 2 is a support-function scan and the support of
conv(V) is the support of V, so the deduplicated surface points are
already a correct stage-2 representation and a SciPy hull would cost 4–17 ms per
call to return the input unchanged.
10.4 What actually fires, measured
Re-lowering every free payload in the four A/B scenes with the shipped producer:
| scene | payload | primitives | stage-2 vertices | wire | box corner slop | box support excess (max) | hull support excess |
|---|---|---|---|---|---|---|---|
| CounterToCabinet | obj_main |
1 | 98 | 2.6 kB | 19.58 mm | 13.91 mm | ≤ −0.77 mm |
| CounterToCabinet | distr_cab_main |
7 | 474 | 12.8 kB | 13.56 mm | 8.66 mm | ≤ −0.00 mm |
| CounterToDrawer | distr_main |
16 | 750 | 21.3 kB | 15.35 mm | 8.89 mm | ≤ −0.48 mm |
| CounterToSink | obj_main |
2 | 0 | 0.4 kB | 22.22 mm | 12.32 mm | stage 1 |
| CounterToSink | distr_counter_main |
5 | 147 | 4.6 kB | 21.29 mm | 13.95 mm | ≤ −1.86 mm |
| CounterToSink | distr_sink_main |
5 | 0 | 1.0 kB | 23.00 mm | 11.80 mm | stage 1 |
| FridgeShelfToDrawer | obj_main |
2 | 438 | 10.9 kB | 20.00 mm | 7.97 mm | ≤ −1.28 mm |
| FridgeShelfToDrawer | distr_main |
2 | 0 | 0.4 kB | 20.15 mm | 0.36 mm | stage 1 |
Support excess is the max over 4 000 random unit directions of
support(model, u) − support(payload surface, u), measured against the payload's
whole surface point cloud in each primitive's own box frame; corner slop is
attached_payload_mesh_slop's own metric. A negative hull figure is the
multi-primitive artefact of that shared point cloud, not a containment failure.
Stage 1 does the work. A real RoboCasa collision mesh carries 749–3319
vertices and is already convex, so three of these eight payloads are over
MAX_TIGHT_HULL_VERTICES and ship the DOP alone — the same representation, for
the same reason, that panda_link1 ships. The DOP is the uncapped stage and it
is tangent to the real surface along all 13 axes, which is exactly where the
corner slop lives. 13–23 mm of corner slop, and 8–14 mm of worst-direction
support excess, is what the box was adding in these scenes — the shipped scenes
at seed 1, not the A/B's own payload set (median 50.78 mm), whose re-scoring is
§10.8's owed work. The wire costs 0.4–21 kB per payload, and the producer went
0.37–0.44 ms → 0.51–1.10 ms per lowering.
10.5 What the real kernel gives back, measured
§10.4 is geometry. This is the kernel's own verdict, through the shipped
binary: real safety_kernel_node, real openral_msgs IDL, a real dense
occupancy grid, and a real RoboCasa payload's lowered primitives on a 1-DoF
prismatic carriage, so the commanded joint value is the payload's position.
Zero margins and no declaration, so the refusal boundary is purely geometric.
Swept twice against byte-identical everything else — identical payload poses
through every condition, no policy in the loop — once with the wire's tight_*
fields stripped (the pre-#266 box), once with them. 16 orientations each, rotated
about the primitive's own centre so the sweep measures the shape; boundary
located by ascending scan to 0.1 mm.
| payload | stage | recovered travel: min / median / max |
|---|---|---|
PickPlaceCounterToCabinet obj_main (1 primitive, 98-vertex hull) |
1 + 2 | 5.10 / 17.65 / 28.40 mm |
PickPlaceCounterToSink obj_main (2 primitives, over budget → DOP only) |
1 only | 0.00 / 8.75 / 18.70 mm |
The recovery is real and it is the right size: #253 was shrinking the small term, its predicted gain 8.66 mm, and it measured null; the stage-1-only payload here — the field-typical case, since a real collision mesh is over the vertex budget — gives back 8.75 mm median through the same kernel, and stage 2 roughly doubles that. Never negative, 32 orientations out of 32, which is §10.3's soundness argument observed rather than argued. 0.00 mm happens and is correct: at a face-on approach the box is already tight, so the median matters and the minimum is not a defect.
What this is not. One occupied cell is not a kitchen and a carriage is not a policy: it measures the size of the conservatism removed, not how often it was costing a task. Stop-rate and completion-rate are still §10.8's owed work.
Reproduce (one-shot analysis, not checked in — §7's rule): dump a payload's
lowered primitives with extract_body_primitives against a RoboCasa scene, then
drive start_kernel / activate_kernel_node from
tests/sim/safety/_kernel_subprocess.py with the carriage rig of
tests/integration/test_safety_kernel_place_allowance_band.py. Two traps cost a
rebuild each: the occupancy grid has a deadline, and a sweep that outlives it
turns every chunk into a voxel_unavailable refusal that reads exactly like a
geometric one (assert FailureTrigger.KIND_COLLISION, never a bare refusal); and
the kernel latches on refusal, so a reset is only evidence the latch is gone
once a known-safe chunk is accepted again.
10.6 The live A/B, and why it answered nothing
Run 2026-09-11 on the RTX 5070 laptop host (4 scenes x 5 rounds x 2 arms, gate ON, 25 mm, the arms being two worktrees rather than an env var), the primary endpoint — paired over-approximation shift — came back a null that proves nothing: median −2.83 mm, 95 % CI [−22.09, +8.26], 6 neg / 5 pos over n = 11 pairs, sd = 25.7 mm giving power 0.20 against the DOP-only recovery. More rounds would not fix it, because the arms are different code and the refinement changes when the kernel stops, so the right instrument is replay, not a battery — which is what §10.5's carriage sweep is.
base (f06ac63) |
hull (438516d) |
|
|---|---|---|
| rounds / stops / payload stops | 20 / 18 / 12 | 20 / 16 / 12 |
| budget charged, median | 88.22 mm | 34.45 mm |
| verdicts (wq / fp / rc / unadj) | 12 / 1 / 2 / 3 | 9 / 1 / 3 / 3 |
The budget column is §10.8's fix working on live rounds, and the round also found
a bug in that fix: estop_ground_truth_snapshot fills voxel_half_diagonal_m
only from an evidence_voxel it was handed, so with none delivered the payload
budget composed as overhang + 0 and convicted 6 correct stops as
false-positive; the term is now re-derived from the round's known resolution,
and with none to re-derive from the budget is None — unadjudicated rather
than a number that convicts.
10.7 What is deliberately untouched
- ADR-0098's place-target adjudication, whose
target_distance ≤ d + allowancebound keeps reading the shipped box distance — the model the declaration's geometry is adjudicated on. check_attached_self_collision(payload OBB ↔ link OBB) andcheck_attached_world_collision(payload ↔ world capsule; retired 2026-09-23 by ADR-0109): 3 % of measured stops, andattached_payload_mesh_slop's budget is stated against exactly the box those still use.support_witness_still_in_contact— the witness exempts cells, so a tighter payload would shorten the exemption's life.
10.8 Adjudicating a round after this change
attached_payload_mesh_slop now publishes n_stage2_primitives per object.
corner_slop_m remains the right budget for an attached-payload self stop;
charging it to a world-voxel payload stop over-budgets a refined primitive by
exactly what the refinement recovered, which is how a real defect hides behind a
generous gap. This is the has_stage2_hull lesson of
#260 on the payload side: the
fact is published rather than inferred from the round's date.
Landed, and it was a fix rather than an addition. There was no
payload-vs-world block at all: hal_admissible_gap_m routed that class to the
top-level one, which composes the worst robot link's corner slop with the
cell half-diagonal — 88.22 mm (panda_link4) on the 2026-08-23 rounds where the
payload's own model needs ~31 mm, and 97 % of the 15 mm A/B's stops. An
over-large budget does not fail loudly; it silently excuses.
adjudication_budget.payload_world_voxel now carries
payload_model_overhang + voxel_half_diagonal, the overhang measured over
whichever solid the kernel actually checks — the refinement's DOP where one
ships, the box otherwise — by enumerating that solid's vertices, because the
maximum of a convex function over a polytope is attained at one. Deliberately the
DOP and not the stage-2 hull: the hull is budget-capped
(kMaxStage2PerCheck) and the kernel falls back to the DOP whenever the cap
binds, so the DOP is the bound that always holds.
Measured on three real RoboCasa payloads at 25 mm:
| payload | box corner slop | model overhang | budget was | budget is |
|---|---|---|---|---|
CounterToCabinet obj_main |
34.88 mm | 10.18 mm | 56.53 mm | 31.83 mm |
CounterToSink obj_main |
29.18 mm | 8.73 mm | 50.83 mm | 30.38 mm |
FridgeShelfToDrawer obj_main |
33.12 mm | 8.99 mm | 54.77 mm | 30.64 mm |
(The "was" column is the payload-termed composition; the shipped behaviour was looser still, at the link's 88.22 mm.) Two independent routes agree on the magnitude: the 8.7–10.2 mm overhang measured here against the mesh, and the 8.75 mm median recovery §10.5 measured through the live kernel. A snapshot recorded before the block existed still resolves to the old number rather than losing its budget — absence must read as "this round predates the block", never as "this stop has no budget" (#260's lesson, again).
10.9 Relationship to the other open items
- #253 (25 → 15 mm voxels) — orthogonal, and this is the larger lever. #266 does not need that ruling.
- #259 (place-allowance scope) — reaches the 33 placing stops. #266 reaches all 53 stops with a payload grasped, regardless of phase.
- #264 (
has_stage2_hullas a kernel-side disclosure) — §10.8 is the payload half of it.
See also
- Tight link geometry in the safety kernel (#161) — the study this implements, and the recovery ceiling it must not be described as exceeding.
- Collision-primitive study (#157) — the broad-phase hazard this change is built to avoid touching.
- RoboCasa start-state census (#159) — why the scope is
link1andlink2. - Collision-stack validation evidence (#160) — the world-side terms no link geometry reaches.
cpp/openral_safety_kernel/README.md— the kernel's own geometry documentation.