← Writing
refusals

The silent crash: When collision checkers don't know the jaws closed

A look at a planner failure mode where the Tesseract scene graph only saw static geometry, and how we fix it by binding geometric state directly to capabilities.

Derived from: adr-0027-collision-geometry-derived-or-checked.md, adr-0028-capabilities-actuate-joints.md

A robot plans a path through a pneumatic nest. The transit is calculated as clear. The PackML clamp step executes, and the pneumatic jaws close. The robot moves into the nest, and the arm collides with the jaws.

The planner failed because it did not know the jaws moved. The Tesseract scene graph only saw authored static geometry. The machine's process state and its geometric state were completely disconnected descriptions. clamp was fully specified as a PackML transition and completely unspecified as a physical event. A collision check run at any point in the plan checked against jaws that were always in their authored position, which happened to be open.

Collision geometry that is too large is merely conservative. A refused plan forces an engineer to investigate. Collision geometry that is too small is a silent failure. A missed volume results in a physical crash. We require our resolver to exclude this failure mode.

To fix this temporal gap, we first had to eliminate the spatial gap in our collision geometry.

Historically, a module's collision geometry was a single mesh path hand-produced from CAD. Component instances carried their own pose in the module frame, but those component poses never entered the scene graph. Moving or deleting a component changed the manifest and the visual render, but it changed nothing the planner saw.

We fixed the spatial disconnect by requiring a published module to declare its collision_source. Modules can declare derived, where the resolver builds the collision proxy from each component instance's transcribed envelope placed at its declared pose. If a component lacks a pose, or a referenced component lacks a geometry.envelope, the system refuses. We do not fall back to a bounding box of what we happen to have. Alternatively, a module can declare authored and provide a mesh. In authored mode, every component instance's envelope must physically lie within the authored collision geometry. A component protruding outside the authored mesh is a refusal.

Closing the spatial gap ensured the physical footprint of static components matched the planner's state. But we still had to address the temporal gap for moving parts.

Every non-robot module fragment in our repository used to be a single static link. The pneumatic nest's fragment was an axis-aligned box with a comment admitting it was a stub.

We fixed this by forcing capabilities to declare the geometric reality of their verbs. A capability now includes an actuates field. It specifies the joints the verb drives and the exact physical values it drives them to.

capabilities:
- name: clamp
  summary: "Close both jaws and hold the part at a target force."
  preconditions:
  - clamped == false
  - part_present == true
  postconditions:
  - clamped == true
  actuates:
  - {joint: jaw_left,  to:  6.0, units: mm}
  - {joint: jaw_right, to: -6.0, units: mm}
  nominal_duration_s: 0.6
  timeout_s: 3.0
  on_timeout: hold

The to field defines the value the joint holds when the postcondition is true. Joint state carries forward through a plan, seeded by the cell's initial state. By explicitly tying the process transition to the kinematic joints, the collision proxy is forced to update its state mid-cycle.

The data must be exact, and the engine refuses to guess if it is not. A bare float means two different things depending on the joint type. In standard conventions, a bare float is treated as radians. Authored as 12, a 12 mm clamp stroke will render a jaw twelve metres away.

We require units on every actuates entry. These are strictly type-checked against the joint declared in the module's own urdf_fragment. A linear unit on a revolute joint results in OCM_ACTUATION_UNIT_MISMATCH. An angular unit on a prismatic joint is refused identically.

We also check actuation targets against the joint's own limits. A joint driven through its own physical stop is a fabricated machine state.

If an actuation target falls outside the joint's declared limits, we refuse. If the urdf_fragment provides an incomplete limit, or omits the <limit> element entirely on a non-continuous joint, we refuse. Both the module-layer capabilities and the cell-layer initial states undergo this check.

Our system enforces the following core checks before it will build the scene graph:

  • `OCM_ACTUATION_JOINT_UNKNOWN`: Refuses if a capability actuates a joint absent from the module's urdf_fragment.
  • `OCM_ACTUATION_JOINT_FIXED`: Refuses if a capability actuates a fixed joint that has no configurable position.
  • `OCM_ACTUATION_OUT_OF_LIMIT`: Refuses if a capability drives a joint to a value outside its declared limit.
  • `OCM_FRAGMENT_MALFORMED`: Refuses if the urdf_fragment contains unparseable XML. We parse fragments through the scene's own loader at validation. We do not swallow XML parse errors.

We do not decouple geometry from the process verb. A separate actuation block referenced by name invites the state table and the capability set to drift. A capability that changes the machine's physical state and a capability that does not are fundamentally different things.

If a capability moves nothing, omitting the actuates field is correct. If a module declares a movable joint in its fragment but no capability actuates it, we surface an OCM_JOINT_UNACTUATED advisory. The engine cannot tell a genuinely passive joint from an unfinished module fragment, so it advises the user rather than halting the resolve.

A partial collision proxy that looks complete is a massive liability. By requiring explicit joint actuation on transitions, we ensure the process state and the collision geometry are always identical. The resolver demands the facts, and refuses the plan if they are missing.

Read as plain markdown

Working on something similar?

If you have an assembly you keep re-engineering, the details help shape what gets built first.

Get in touch