Skip to content

arm limits reports a COMPLIANT stop (a cable) as a WALL — the verdict does not replicate, and a WALL is what authorises a re-zero #51

Description

@OriNachum

Summary

arm limits reports a compliant stop (a cable/loom that stiffens progressively) as a
LimitVerdict.WALL, with nothing to distinguish it from a hard mechanical stop. On
wrist_roll this is not hypothetical: the verdict does not replicate, and it came within
one commit of writing a permanent false fact into arm_spec.

A WALL is the only verdict that vouches for a mechanical limit
(LimitVerdict.vouches_for_a_wall, arm101/hardware/limits.py). It is what authorises a
re-zero. If a compliant end can pass as a WALL, then the seam can be parked in an arc that
is not actually unreachable — which is the exact class of bug #43 exists to kill.

The evidence (follower /dev/ttyACM1, 2026-07-13)

wrist_roll, probed three times:

run start verdict travel arc
A mid-travel BOUNDED (walls raw 1700 / 1491) 3887 209
B mid-travel, --threshold-joint wrist_roll=150 UNDETERMINED 3903
C mid-travel, --threshold-joint wrist_roll=250 UNDETERMINED 3905

Travel replicates (3887 / 3903 / 3905). The END VERDICTS DO NOT.

Per-end detail from B and C:

threshold low end high end
150 WALL, peak 476 TORQUE_LIMITED, peak 172
250 WALL, peak 500 UNFIRABLE_THRESHOLD, peak 196

The load at the stop, on the same joint, at the same ends, has been observed at
172, 196, 272, 288, 476 and 500.

Ground truth from a human hand

The operator posed wrist_roll to each mechanical end by hand (torque off, at-rest reads):

end A end B
human hand raw 1769 raw 1429
the arm raw 1700 raw 1491

The arm reached PAST the hand at both ends — 69 and 62 ticks. It squashes ~65-70 ticks
into each stop. Operator's words: "one side can be stopped a few ticks, and the other side is
not moving to the end."

A hard mechanical wall does not compress. A cable does. Corroborating: wrist_roll is the
only joint on the arm that does not saturate present_load against a stop — every other joint
shoves the full 500 cap into its walls.

Why this is dangerous, precisely

The unreachable arc is not a fixed quantity. It shrinks the harder something pushes:

gentle human hand   ->  arc ~340 ticks
the arm (capped)    ->  arc ~209 ticks
a harder push       ->  smaller still

A seam parked at the midpoint of the gentle arc (~raw 1599) sits inside travel the arm
reaches whenever it leans in a little harder
(it got to 1491 and 1700). That is a seam inside
reachable travel — the precise failure #43/#35 are about.

Note the near-miss: MIN_EVICTABLE_ARC_TICKS is now 3 * ARC_MARGIN_TICKS = 300. The
hand-measured arc (340) passes that cutoff. Only the arm's more-aggressive 209 fails it. We
reached the right answer (soft-limit wrist_roll) by luck — the reasoning recorded in
arm_spec._REZERO_REFUSED ("209 < 300") is sound only because the arm happened to be the
harder pusher. If a future run probes more gently (lower threshold => earlier stop => WIDER
arc), the classifier will happily offer a re-zero into a zone that is not unreachable.

A gentler probe over-estimates the arc, and over-estimating the arc is the dangerous
direction for seam eviction.
Nothing in the code knows this today.

Proposed fix — a compliance probe

The discriminator is available and cheap: push twice, at two different strengths, and see if
the stop MOVES.

  • A hard stop is a position constraint: it lands on the same tick regardless of force.
  • A compliant stop is a force constraint: where it lands is a function of how hard you push.

Sketch:

  1. Probe an end normally -> record stop tick t1 at push strength p1.
  2. Retreat, re-probe the same end at a materially different push strength p2 (a different
    contact threshold, and/or a different Torque_Limit cap -- the cap is the honest lever,
    since the threshold only decides when we STOP, not how hard the servo pushes).
  3. compliance_ticks = |t2 - t1|.
  4. If compliance_ticks exceeds a (measured) tolerance, the end is COMPLIANT:
    • it must NOT vouch for a wall -> a new LimitVerdict (COMPLIANT_STOP?) or a flag on
      the existing WallEnd;
    • it must NEVER authorise a re-zero (rezero_arc / classify.SeamRemedy.REZERO);
    • the reported arc must be the one from the HARDEST push seen (the conservative,
      smallest arc), never the gentlest.

Open design questions (deliberately not settled here):

  • Is COMPLIANT_STOP a sixth LimitVerdict, or an attribute of WallEnd? It behaves like a
    WALL for bounds (the joint really is limited there) but like a LOWER BOUND for
    eviction (you cannot certify the arc). That asymmetry may not fit the current
    vouches_for_a_wall single-predicate design (limits.py:~196).
  • The tolerance. Do NOT reason it down from a real number the way _PRESSING_EXCESS_LOAD
    was (see Every factory-offset joint is clamped exactly ON its encoder seam — #35 may affect all six #43 thread) -- measure the run-to-run spread of a KNOWN HARD stop
    (shoulder_pan, elbow_flex, gripper low end all saturate cleanly) and put the
    tolerance above that noise.
  • Cost: this doubles the probes per end. Acceptable for arm limits; probably not for
    arm explore.

Acceptance criteria

  • A repeat-probe at two push strengths is implemented and its delta recorded per end.
  • wrist_roll's ends are classified COMPLIANT on hardware (they move ~65-70 ticks).
  • shoulder_pan / elbow_flex / gripper low end are classified as HARD (they should not
    move materially -- if they do, the tolerance is wrong, not the joints).
  • A COMPLIANT end can never produce SeamRemedy.REZERO, and there is a test that says so.
  • The arc reported for a joint with a compliant end is the one from the hardest push.
  • arm limits wrist_roll gives the same verdict twice in a row on hardware. Today it
    does not, and that is the headline symptom.

Where the code is

what where
the 5 verdicts + vouches_for_a_wall arm101/hardware/limits.py
the WALL/TORQUE_LIMITED discriminator (loaded_run <= compliance AND free_run >= needed) arm101/hardware/probe.py _rule_on_a_stop
wall_compliance() / free_run_needed() -- the existing (static!) compliance model arm101/hardware/probe.py
BOUNDED/CONTINUOUS/UNDETERMINED + MIN_EVICTABLE_ARC_TICKS arm101/hardware/classify.py
_REZERO_REFUSED, REZERO_ARCS, SOFT_LIMITS, DEFAULT_CONTACT_THRESHOLDS arm101/hardware/arm_spec.py
the verb arm101/cli/_commands/arm.py (arm limits)
Torque_Limit cap (addr 48), the honest "push strength" lever arm101/hardware/gentle.py _CONTACT_TORQUE_LIMIT = 500

Note probe.wall_compliance() already encodes a static 100-tick "give" allowance. This
issue is that compliance is not static and not a constant -- it is a per-joint, per-end,
measurable property, and on wrist_roll it is the dominant effect.

Reproduce

# from a MID-TRAVEL start (NOT sitting on an end -- a probe that starts on a wall has no
# free approach and its result is degenerate; this bit me):
uv run arm101 arm limits wrist_roll --threshold-joint wrist_roll=150 --port /dev/ttyACM1 --apply --json
uv run arm101 arm limits wrist_roll --threshold-joint wrist_roll=250 --port /dev/ttyACM1 --apply --json
# -> different end verdicts, same travel.

Depends on / relates to

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinghardware-gatedNeeds the physical arm and a human operatormeasurement-integrityThe instrument may be lying — a wrong number here becomes a permanent false fact

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions