Skip to content

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 the panda_link5↔panda_link7 ACM exemption — the one open item the collision-safety alternatives survey §2.2 left on the ACM theme. panda_link5 and panda_link7 declare tight_geometry too, 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_collision was in this table as "out of the asked scope" until

191, 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_collision itself 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_exempts receives no CollisionModel and gained no argument; support_patch_withholds keys on SupportPatch only, and the bridge switches on the attached payload's wire primitives.
  • check_attached_voxel_collision's CollisionModel parameter is still literally unnamed.
  • check_attached_self_collision does read robot link boxes — and still reads exactly the boxes it read before, because tight geometry lives in box_hull / hulls / hull_vertices and is consulted at one call site, the box pass of check_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:

  1. 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
  2. The broad phase was verified, not merely left alone — reach formula unchanged, subset property asserted at load, fail-closed. ✔ §6
  3. Conservatism is declared in both directions — never over-reports against the truth; deliberately less conservative than the shipped box. ✔ §4.3
  4. The change cannot add a stop — by construction, pinned by test. ✔ §4.2
  5. Every real-time bound fails conservative — iteration cap, refinement budget, vertex ceiling, overlap fallback. ✔ §4.3
  6. check_attached_self_collision is unaffected, unlike the change #161 costed. ✔ §6
  7. 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
  8. 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.
  9. ~~Issue #155 sequencing is unchanged~~ Superseded by #191, which regenerates the panda_mobile / panda_mobile_vslam ACM 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 + allowance bound keeps reading the shipped box distance — the model the declaration's geometry is adjudicated on.
  • check_attached_self_collision (payload OBB ↔ link OBB) and check_attached_world_collision (payload ↔ world capsule; retired 2026-09-23 by ADR-0109): 3 % of measured stops, and attached_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_hull as a kernel-side disclosure) — §10.8 is the payload half of it.

See also