Skip to content

feat: the arm measures its own joint travel — arm limits and the rolling frame (#43)#48

Open
OriNachum wants to merge 22 commits into
mainfrom
feat/arm-limits
Open

feat: the arm measures its own joint travel — arm limits and the rolling frame (#43)#48
OriNachum wants to merge 22 commits into
mainfrom
feat/arm-limits

Conversation

@OriNachum

Copy link
Copy Markdown
Contributor

Builds the spec+plan from #44. The hardware acceptance run has not happened yet — that is the next step, and it is a stop-gate (see below).

The problem (#43)

reported = (raw - Ofs) mod 4096, so the reported seam sits at raw == Ofs. At the factory Ofs = 85 that is raw 85 — inside several joints' physical travel. They hit the commandable bound while still physically free, and their real travel has never been seen. Three joints reached the bound with no contact, 2 / 3 / 11 raw ticks short; shoulder_lift then sagged through the seam under gravity with torque off.

So #35 is potentially a six-joint problem, and the per-joint ranges feeding arm explore's grid were artifacts of our own clamp.

The mechanism: a rolling frame

You cannot measure a joint's unreachable arc until you can see past the seam, and you cannot evict the seam until you know the arc.

Write a temporary offset mapping the joint's current raw position to reported 2048 — the seam is then half a turn away, the farthest it can possibly be, with ~2048 clear ticks each side. Creep outward, and re-centre whenever the creep nears the bound. The seam is rolled ahead of the joint and never obstructs it, so travel is unbounded by the frame.

Displacement accumulates in RAW ticks, which are frame-independent — the measurement survives the frame moving underneath it. Termination is by accumulated raw displacement: a full turn with no wall means the joint is continuous, and no offset can ever help it.

The register cannot hold residue 2048 ([-2047, +2047]), so "centre at reported 2048" is impossible for exactly one raw position (raw 0). Enumerated over all 4096, not reasoned about.

Four verdicts, carried per END

present_load saturates at Torque_Limit, so at the stop a real wall and a joint that is simply too weak are identical: load 500, not advancing. shoulder_lift carries the whole arm; a torque-limited stall recorded as a mechanical limit would be a permanent lie in arm_spec.

The discriminator is a distance, not a state. A wall is a position constraint — the loaded zone is the give in the gears, tens of ticks (gentle's backoff measured it from the other side). Torque exhaustion is a torque constraint — the load must climb ~45% of the cap as the moment arm grows, so the joint creeps while loaded over hundreds of ticks.

WALL iff loaded_run <= compliance and free_run >= free_run_needed. Everything else → TORQUE_LIMITED.

The margin is ~1.35x and the cutoff is currently derived from a simulation, not from the arm. That is stated plainly in the module docstring, pinned in a test named test_a_CRUSHING_load_can_still_fool_the_probe_and_this_is_exactly_where, and made cheap to fix: loaded_run_ticks is in the --json output per joint per end, and compliance is a flag. The first hardware run retunes it from data, at zero extra cost.

A TORQUE_LIMITED end is a lower bound, never a wall — and that error is safe: it makes a range too narrow, never too wide.

The corollary, which is easy to get backwards: "mechanical = the widest envelope over poses, because an obstacle can only ever shrink a range" is sound for a WALL and false for a TORQUE-LIMITED end. The arm's own weakness is not an obstacle you can pose your way out of — it shrinks the range in every pose. WallEnd and LowerBoundEnd are separate types whose constructors refuse each other's evidence, so a stall cannot be laundered into a wall by any path, including hand-edited JSON.

Persist RAW ticks

A re-zero invalidates history only because we persist reported ticks — a view through the current offset. Raw ticks are invariant under a re-zero. Storing raw eliminates the failure mode rather than detecting it, so the calibration-identity machinery proposed in #36 is not needed to protect data that cannot go stale.

Safety

  • Crash-safe. A temporary offset is a transaction: journalled and fsynced before the wire write. SIGKILL mid-probe (tested for real, with a subprocess and a file-backed EEPROM — an in-memory fake would make the test vacuous) and the next run restores the original.
  • The sweep is the arbiter, not the offset read-back. Reading an offset back proves it was applied, never that the seam moved. A joint whose offset reads back exactly right and whose sweep still shows a discontinuity is a FAILURE — proved with a servo whose arc was mis-measured, not with a mock.
  • ≥80% sweep coverage is preserved. It is what stopped three empty sweeps being declared a pass on elbow_flex. An unattended --apply --commit cannot pass itself.
  • Everything under torque_guard. Never a write to addr 9 or 11.

Live bugs found while building this

The retraction

arm_spec._REZERO_UNNECESSARY told the operator "four of the six joints do not wrap inside their travel." Withdrawn. It now says UNKNOWN, not unnecessary — and does not over-correct: it does not assert that they do wrap either. We don't know. wrist_roll's impossibility is the one refusal that is proven and stays.

The stop-gate, next

t12: the verb must re-derive the two answers we already know — elbow_flex → BOUNDED, wrist_roll → CONTINUOUS — from measurement alone, with no joint name anywhere in its classifier (enforced three ways, including a test that bars every joint-keyed dict and every callable taking a joint parameter).

If it cannot rediscover what a long human session already established, it is not measuring — it is guessing, and no verdict it gives on the other four joints may be believed.

Two claims in this PR can prove the work unnecessary, and the code will say so: if all four joints come back with the seam outside their travel, the old message was right; and if no joint's measured span differs from its EEPROM span by >100 ticks, the rationale for blocking #34 on this is false and the report says so plainly.

1683 tests, 97.12% coverage.

Closes #47. Refs #43 #35 #34 #45 #46

claude and others added 22 commits July 12, 2026 20:49
… write, restore (t3)

Writing the servo's Ofs register (EEPROM addr 31) to move the encoder seam out
of a probe's way is a PERSISTENT write dressed up as a temporary one. If the
process dies between the shift and the restore — SIGKILL, OOM reap, Ctrl-C at
the wrong instant, a transient bus fault — the arm is left holding a calibration
NOBODY RECORDED, and from that moment every tick in arm_spec, every reachability
map and every run-log silently means a different physical angle for that joint.
Nothing raises. The bench script that first probed this feature did exactly this
and left the arm in an unknown state.

New pure module arm101/hardware/journal.py: an append-only, fsynced JSONL
transaction log. The original offset is durable on disk BEFORE the first
write_offset goes out on the wire — a journal written after the write it exists
to undo protects nothing — and every temporary offset is journalled before it is
written, so there is never an offset in a servo that is not already named on
disk. An entry is dirty until the joint is restored or the calibration is
deliberately committed; restore_dirty() is the recovery path a verb runs at
startup, before anything else.

The restore survives its own failure: per-motor, independent, the sweep always
runs to the end (the bus that stranded one joint is the bus the others need), and
an entry whose restore cannot be VERIFIED by read-back stays dirty on purpose —
its original_offset is the only record of the truth, and closing it would destroy
that record while the joint is still mis-calibrated. It clears a latched overload
first via contextlib.suppress (bandit B110 rejects try/except/pass), because
write_offset opens with enable_torque(False), which is exactly the packet a
latched servo answers with the overload bit still set.

The SIGKILL path is tested for real, not reasoned about: a finally block does not
run on SIGKILL. tests/_journal_subject.py is a genuine subprocess that writes a
temporary offset to a file-backed "EEPROM" (a servo's addr-31 register outlives
the process that wrote it — an in-memory fake would tidy up the mess for us), is
genuinely os.kill'd with SIGKILL, and a FRESH process then detects the dirty
calibration, names the original offset, and restores it. Both ordering tests fail
if the journal is moved after the write; the SIGKILL test fails if the recovery
is a no-op (verified by mutation).

Journal location: $ARM101_CALIBRATION_JOURNAL, else ~/.arm101/calibration-journal.jsonl
— the same env-override pattern as the audit log and plan dir, and injectable
per-instance so xdist's parallel tests never race one path. conftest pins it into
tmp_path for every test, so no test can write a fake dirty entry into the
operator's real journal and have the next real run "restore" a joint that was
never shifted.

1275 tests green, 100% coverage on the new module, black/isort/flake8/bandit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
…/ TIMEOUT, per END (t2)

gentle_move calls contact when present_load is high AND the joint has stopped
advancing. That rule cannot tell a WALL from THE ARM BEING TOO WEAK: shoulder_lift
carries the whole arm, and lifting it against gentle_move's 500 Torque_Limit cap can
make it stall at high load with nothing in front of it. Recording that as a
mechanical limit writes a permanent lie into arm_spec.

New pure module (no bus, no I/O — pinned by an AST guard and a fresh-interpreter
sys.modules guard) carrying the verdict PER END, not per joint:

  LimitVerdict   WALL | TORQUE_LIMITED | EDGE | TIMEOUT. Exactly one vouches.
  EndObservation what ONE probe found at ONE end in ONE pose — environmental
                 evidence, never a limit. Stores origin_raw + signed RAW
                 displacement, so a probe crossing the seam (raw 4000 +200 -> raw
                 104) reads as 200 ticks further out, not a 3896-tick retreat.
  MeasuredEnd    ABSTRACT. Two concrete forms, and which one it is IS the verdict:
                   WallEnd       mechanical_limit -> int.  REFUSES non-WALL evidence.
                   LowerBoundEnd mechanical_limit -> None. REFUSES WALL evidence.
  JointTravel    mechanical_extent / mechanical_raw_ends are None unless BOTH ends
                 are walls. envelope_extent (what was OBSERVED) is always available
                 and claims nothing more.

The asymmetry this encodes: a limit found in one pose is environmental, and the
mechanical limit is the widest envelope over poses — sound for a WALL, because an
obstacle can only ever make a range SMALLER. It is FALSE for a TORQUE-LIMITED end:
the arm's own weakness shrinks the range in EVERY pose, so no number of poses ever
escapes it. merge_end_observations therefore takes the OUTERMOST observation and
promotes only a WALL — a stall observed BEYOND a wall demotes the end (the joint
demonstrably crossed that wall, so it was an obstacle), and a thousand
torque-limited poses still merge to a LowerBoundEnd.

The refusal lives in the constructors, so there is no path — no merge, no helper, no
hand-edited JSON — that lands a torque-limited stall in a WallEnd. Both mutations
that would launder one (drop the constructor check; "widest wall wins, ignore the
stall beyond it") were verified to fail the suite.

50 new tests, limits.py at 100% coverage. Suite 1296 passed; black/isort/flake8/
bandit clean; teken cli doctor --strict exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
…ban (t4)

Guard 1 (tests/test_seam_bound_arithmetic.py) — the arithmetic behind issue #43.
The STS3215 reports Present = (Actual - Ofs) mod 4096, so the seam sits at
raw == Ofs. Under the factory Ofs = 85 the commandable bound (reported 4095) is
raw 84 — one tick BELOW the seam at 85. Pins the worked example, then the general
property across every offset the register can hold: reported 0 IS the seam,
reported 4095 is always the tick just under it, the two ends of the "range" are
one raw tick apart, and the conversion is a bijection mod 4096.

Guard 2 (tests/test_eeprom_limit_write_guard.py) — nothing in arm101/ may write
servo address 9 or 11 (Min/Max_Position_Limit). In servo mode the firmware clamps
goal positions to that window, so a write narrows the reachable set permanently,
in EEPROM. LeRobot's write_calibration writes them; it must not be ported here.

Not a source grep: 9 and 11 are ordinary numbers, and bus.py legitimately READS
both. Instead the guard parses arm101/, asserts bus.py is the only module holding
an SDK packet handler, enumerates every writeNByteTxRx call site through it,
statically resolves each address argument (through named constants and through
write_id_baudrate's for-loop), and asserts the resulting byte spans never touch
9-12. It fails closed: an unrecognised SDK method, a write handed off by
reference, or an address that does not resolve to an int constant is a failure,
not a silent skip. Corroborated by driving the whole public bus surface against a
recording packet handler.

Falsified against four injected regressions: a ported write_position_limits() (3
tests fire, none of which call it), a generic write_register(motor, addr, value)
escape hatch, an SDK bypass in another module, and both a clamp-instead-of-wrap
and a sign-flip mutation of raw_from_reported.

Tests only — no runtime change, no version bump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
…he bus and everything else

A REPORTED tick is a view through the current offset, not a place. Re-zero a
joint and every previously-stored reported tick silently names a different
physical angle: nothing raises, and the number still looks fine. That bug has
now shipped twice (REZERO_ARCS' (126, 2020), fixed in #41; SOFT_LIMITS'
(100, 3995), fixed here). So: persist RAW, convert at the bus edge.

* NEW arm101/hardware/ticks.py owns reported<->raw outright and imports nothing,
  so arm_spec (forbidden from importing the bus) and the bus can both depend on
  it. It holds the frame arithmetic — raw_from_reported, reported_from_raw,
  seam_tick, offset_for_seam_at — plus raw_interval_to_reported, which REFUSES
  to convert an interval that wraps in the reported frame instead of returning a
  plausible-looking, wrong pair. rezero.raw_from_reported and arm_spec.seam_tick
  are now re-exports of the one implementation.

* THERE ARE TWO SEAMS, and the old table cleared the wrong one. The RAW seam
  (the magnet's own 4095->0 rollover) is immovable at raw 0. The REPORTED seam
  sits at raw == Ofs and MOVES when the offset is written — it is the one a goal
  write crosses. They are 85 ticks apart on a factory servo, which is exactly
  small enough for a table in the wrong frame to look right.

* arm_spec.SOFT_LIMITS is now RAW and DERIVED from seam_tick(FACTORY_ENCODER_
  OFFSET) + SEAM_CLEARANCE_TICKS — not typed, because a typed pair is a pair
  that can be in the wrong frame. wrist_roll: (185, 3995), a 285-tick dead arc
  spanning BOTH seams with 100 ticks to spare on each outer side.
  _require_seam_clearance() proves it at import time; the shipped (100, 3995)
  now fails `import arm101` with a message naming the frame.

* resolve_bounds() takes the servo's live homing_offset. It was frame-ambiguous:
  it intersected a (then reported) soft limit with the EEPROM angle limits, which
  are genuinely reported. Both call sites (arm flex / explore's grid, demo_sweep)
  pass read_info()["homing_offset"]; no default, because no servo holds 0.

Tests (tests/test_tick_frames.py, +new cases in test_arm_spec /
test_soft_limit_enforcement): the round-trip property across re-zeros; an
inventory of every persisted tick asserting each holds RAW, with the two known
REPORTED sites (profiles.JointCalibration, explore.reachmap) pinned so the
backlog can shrink but not grow; and an end-to-end flex against a FakeBus holding
Ofs=85 — every other soft-limit test runs at offset 0, where the frames coincide
and a frame error is invisible.

1275 tests, arm_spec and ticks at 100% coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
#43)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
…displacement alone (t7)

The classifier that decides what can be done about a joint's encoder seam, from
accumulated RAW displacement and the four-verdict ends — nothing else.

* BOUNDED — a WALL at both ends. The complement of the travel is a real arc the
  joint can never visit, and the seam can be parked in it.
* CONTINUOUS — the joint SWEPT a full turn (4096 raw ticks). Its travel covers
  every angle, so there is no angle to put the seam at: a re-zero is impossible in
  principle, and a soft limit is the only instrument. Decided by coverage of the
  circle, not by "no wall was found" — so a joint with hard stops that still
  over-rotates past 360deg is CONTINUOUS too, which is correct and which a
  "no wall" rule would get wrong.
* UNDETERMINED — one wall and one torque-limited end; two torque-limited ends; an
  EDGE; a TIMEOUT. No arc can be sited without two ends you can vouch for, and no
  full turn was seen. Neither, and saying so is the honest answer.

NO JOINT NAMES IN THE LOGIC. wrist_roll comes back CONTINUOUS because it IS, and
elbow_flex BOUNDED because it IS. A name-keyed special case would make the
hardware acceptance test vacuous — the verb would recite the answer it was handed,
and no verdict on the four unknown joints could then be believed. Pinned three
ways: an AST sweep of the module's executable code (docstrings stripped) against
arm_spec.JOINTS; an AST check that it imports/reads nothing keyed by a joint
(every joint-keyed dict AND every callable taking a `joint`, both derived from
arm_spec); and a behavioural check that the same measurement classifies the same
under every joint's name — including the two swapped.

Termination is by accumulated displacement, never by "we got back to where we
started": a joint that swept the circle and a joint that never moved end on the
same raw tick, and they classify differently.

The narrow-arc cutoff is MIN_EVICTABLE_ARC_TICKS = 2 * ARC_MARGIN_TICKS + 2 —
arm_spec's own inset at each wall, plus the two ticks of interior an arc needs to
have a tick to put the seam ON (a one-tick arc is what
arm_spec._require_evictable_seam already refuses). At the cutoff the seam keeps
exactly the margins the shipped elbow_flex re-zero keeps; one tick narrower and it
would not. A BOUNDED joint under it is reported with NO arc: soft-limit territory,
not a sliver to re-zero into.

74 tests, 100% of the new module, every mutation of the rules caught.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
…nbounded by the frame (t5)

A goal write is linear in the REPORTED frame, so a joint cannot be commanded
across its own seam (raw == Ofs). At the factory offset that seam sits at raw 85
— inside several joints' travel — so they hit the commandable bound while still
physically free, and their real travel has never been seen (#43). And you cannot
evict the seam until you know the arc, which you cannot measure until you can see
past the seam.

The escape: before probing, write a TEMPORARY offset that puts the joint's current
raw position at reported 2048. The seam is then half a turn away — the farthest it
can possibly be — with ~2048 clear ticks each side. Whenever a creep runs low on
frame, RE-CENTRE: a fresh offset that re-centres the joint where it now is. The
seam is rolled ahead of the joint and never obstructs it, so travel is bounded by
nothing at all.

- `centring_offset(raw) -> (offset, centre)` — the pure arithmetic. Residue 2048 is
  NOT representable in the sign-magnitude register, and it is required at exactly
  one raw tick (0), so raw 0 is centred at 2047 instead. That is FREE: 2048 clears
  2047 up / 2048 down, 2047 clears 2048 up / 2047 down — the mirror image, same
  worst-direction headroom. All 4096 raw ticks are enumerated in the tests, not
  reasoned about.
- `RollingFrame` — a joint's temporary frame as a TRANSACTION. `goal(direction, step)`
  re-centres when the frame can no longer promise `step + margin`, and hands back a
  target the goal register can hold. Re-centring happens BETWEEN moves, never during
  one. `origin_raw` + signed `displacement` are exactly what an EndObservation is
  built from, accumulated one signed_delta at a time so the raw seam is a labelling
  artefact rather than a 3896-tick retreat.
- Every offset write goes through `journal.shift_offset` — durable on disk before it
  reaches the wire — so a SIGKILL mid-roll leaves an arm `require_clean` can put back.
- After every shift the standing goal is re-pointed at the joint's own position while
  torque is still off: Goal_Position is a REPORTED tick, and a frame change makes the
  same number name a different angle. `test_a_stale_goal_really_WOULD_lurch` proves
  the fake can see the bug this prevents.

Writes addr 31 / 40 / 42 / 55 only — never 9 or 11, which clamp goals in servo mode
and would narrow the very reachable set this recovers.

44 new tests (1427 total, 100% on the new module).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
…s false claim (t8)

`REZERO_ARCS` was a hand-typed table with one entry and `require_rezeroable`
could only ever APPLY an arc a human had already found. Nothing MEASURED one, so
the procedure that fixed elbow_flex did not generalise: another joint meant
another session at the arm with a pencil.

Three things:

1. The derivation. `arm_spec.arc_from_measurement(TravelClassification)` bridges
   a live, classified travel to the RAW unreachable arc a re-zero needs, and
   `rezero_arc` / `rezero_offset` / `rezero_refusal` / `require_rezeroable` /
   `plan_rezero` / `sweep` all take `measured=`. A measurement overrides the
   table; the table stays the shipped default, answerable with no arm attached.
   arm_spec still imports no bus — the measurement is passed IN (TYPE_CHECKING
   import only, the shape safety.py and journal.py already use).

2. One derivation, never two. The offset is `UnreachableArc.offset` whatever the
   arc's provenance. Acid test: moving `_HIGH_WALL_OBSERVED` 2061 -> 2161 in the
   source moves the arc to (351, 2061) and the offset 1156 -> 1206, with no other
   edit and the suite green. `arc.evicts(current)` remains how "is it re-zeroed?"
   is asked — never `current == rezero_offset(joint)`.

3. The retraction (#43). `_REZERO_UNNECESSARY` told the operator four joints "do
   not wrap inside their travel at all". Hardware contradicts that: three joints
   reached the commandable bound with no contact 2, 3 and 11 ticks from the seam,
   and shoulder_lift then sagged THROUGH it under gravity. It is now
   `_REZERO_ARC_UNKNOWN`: no measured arc, so no re-zero can be derived — and it
   asserts neither that they wrap nor that they don't. wrist_roll's PROVEN
   impossibility is untouched, and "you don't need one" survives as an answer a
   measurement can EARN.

Also promotes `_ARC_MARGIN_TICKS` to a public `ARC_MARGIN_TICKS` (classify shares
it; a number two modules share should not be private). Private alias kept.

1523 tests (22 new, none copying a number out of arm_spec).
The probe creeps a joint away from wherever it currently is, one gentle_move at a
time, through a RollingFrame that keeps the encoder seam half a turn ahead of it,
and emits one EndObservation per end of travel.

THE CRUX — telling a WALL from a TORQUE-LIMITED stall.

present_load SATURATES at Torque_Limit (gentle_move caps it to 500), so AT THE
MOMENT OF THE STOP a real wall and a joint that has simply run out of torque are
identical: load 500, not advancing, contacted=True. That indistinguishability is
asserted, not merely described.

The verdict is therefore taken from the APPROACH, off gentle_move's observer seam
(the same sample stream the shipped _StallDetector is fed — not a re-implementation
of it). Two distances, walking backwards from the stop:

  loaded_run — ticks travelled while pushing PAST the joint's contact threshold
  free_run   — ticks travelled freely, immediately before that

  WALL  iff  loaded_run <= compliance  AND  free_run >= free_run_needed
  everything else -> TORQUE_LIMITED

Both cutoffs are derived, not typed. `compliance` = 2 x gentle's `backoff` — the
MEASURED 30-70 tick distance that relieves a contact on this arm, i.e. the give in
the gears and links, which is the scale of a real contact's loaded zone. A gravity
climb's loaded zone is hundreds of ticks, because the torque must rise ~45% of the
cap. `free_run_needed` = the distance the slowest joint cruises in `stall_samples`
polls: a probe must see a joint move freely for at least as long as the stall rule
must see it stand still.

The tie-break is absolute: any gap in the evidence falls to TORQUE_LIMITED — a
lower bound another pose can widen, never a false wall that no pose can dislodge.
So an already-jammed joint, a latched servo (error=32) and a wall too compliant to
tell from a torque climb are all recorded as lower bounds, and each is tested.

KNOWN HOLE, pinned rather than hidden: the margin is ~1.35x. The cutoff bites at a
load slope of ~1.8 units/tick; gravity caps a real joint at ~1.33
(sqrt(1000^2 - 500^2)/652). A joint loaded past anything this arm can be would be
recorded as a WALL, and it is not one. The margin narrows further as a joint's
threshold climbs toward the ceiling. `compliance` is a parameter so the first
hardware session can set it from measured loaded_run data rather than from this
reasoning. The decisive experiment — raise the cap and see if the joint moves — is
deliberately NOT taken: it is the incident the whole overload-safety layer exists
to prevent.

Also: the creep target is asked of the frame FRESH before every move (goal() syncs
and re-centres first), so no target is ever computed in a stale frame — the bug that
fired live on the arm. Every goal the probe writes is audited against the offset in
force when it was written. Displacement accumulates in RAW ticks and names the
CONTACT point, not the post-contact retreat: recording the retreat would shave
`backoff` off every wall, making the measured travel narrower and hence the
unreachable arc WIDER than it is — which is how a re-zero parks the seam on a tick
the joint can actually reach.

48 tests, 100% coverage of the new module. 1549 pass; black/isort/flake8/bandit and
teken --strict clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
… t11)

`arm101 arm limits [<joint>...]` turns the probe, the rolling frame and the
classifier into a run an operator can make. Per joint: roll the encoder seam out
of the joint's way, creep to BOTH ends under contact detection, rule on what
stopped it (WALL / TORQUE_LIMITED / EDGE / TIMEOUT — per END, because gravity
helps a joint down and fights it up), classify the travel, and diff the measured
span against the EEPROM-derived span `arm explore` builds its grid from today.

MEASURE-ONLY, and that is the whole shape of it. The frame restores the borrowed
offset on every exit path, and there is deliberately no `--commit`: a verb that
silently re-calibrated five joints because somebody asked it to *look* at them is
not one anybody should run. `require_clean` runs before the arm is touched, and
the whole probe sits inside a `torque_guard` that claims each motor the moment it
can first go hot and never disowns it — so a bus that dies while joint 5 is being
probed still releases joints 1-4, whose frames closed minutes ago and whose
servos may well still be holding.

`loaded_run_ticks` — the distance that decides WALL vs TORQUE_LIMITED — has a
cutoff currently derived from a SIMULATION, not from the arm. Retuning it from
real data is the point of the first hardware session, so it ships in `--json` per
joint per end, with free_run, peak_load, compliance, the verdict and the reason.
`--compliance` makes the retune a flag, not a diff.

The bounds diff (t11) is written to be able to LOSE. If no joint's measured span
differs from the EEPROM-derived one by more than MATERIAL_SPAN_DELTA_TICKS, the
report says so plainly — that the grid was NOT being fed artifacts and the
rationale for blocking issue #34 on this work does not hold. A report that could
only ever confirm the reason it was commissioned is not a measurement.

Also retracts, in the prose an operator actually reads, the claim hardware
withdrew in issue #43. "Only elbow_flex wraps inside its travel" / "the other
four — unnecessary" survived in learn.py, in `arm rezero --help`, and in the
explain catalog long after arm_spec's table had taken it back. The honest line is
UNKNOWN, not unnecessary — we do not know that those joints wrap either — and
wrist_roll's refusal stays PROVEN. All three surfaces now RENDER the wording from
`arm_spec.REZERO_UNKNOWN_HEADLINE` / `REZERO_ARC_UNKNOWN_SUMMARY` rather than
restating it, so prose that used to drift from the table now cannot.

- `MATERIAL_SPAN_DELTA_TICKS` lives in `hardware/limits.py` (a fact about how
  repeatable the hardware is, not about a CLI) and is rendered by both the verb
  and its explain page.
- `_resolve_explore_thresholds` -> `_resolve_contact_thresholds`: explore and
  limits resolve one threshold map, not two.
- Catalog lockstep: explain / overview / learn (text + json) / `arm overview`.

1614 tests (+43), coverage 97%, teken 26/26, black/isort/flake8/bandit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
…he read-back (t10)

`arm limits` measured and put everything back. This is the separate, explicitly
gated act that KEEPS what it measured — and refuses to keep what it could not
prove.

Which remedy a joint gets is a property of its travel, not a preference:

  BOUNDED (usable arc)  -> RE-ZERO. A persistent EEPROM write (Ofs, addr 31)
                           moving the seam into the arc the joint cannot reach —
                           AND THEN PROVEN BY A TORQUE-OFF HAND SWEEP.
  CONTINUOUS / narrow   -> SOFT LIMIT. Software only. A dead arc containing the
                           seam, stored and read back by every mover.
  UNDETERMINED          -> NOTHING. Measure again; do not pick.

Never, on any path: the servo's Min/Max_Position_Limit (addrs 9/11).

The read-back is not the arbiter
--------------------------------
An offset that reads back exactly right proves it was APPLIED. It proves nothing
about whether the seam MOVED, and there are two live ways for it not to have: the
firmware may not reduce the corrected position modulo 4096, or THE ARC MAY BE
WRONG — the arm's wall can be the table, a cable, or the pose, and every tick of
that error parks the seam somewhere the joint can plainly reach. So the joint goes
to a human, who walks it wall to wall while the verb watches.

A discontinuity is a FAILURE: the original offset is restored, the journal is
closed, and the run STOPS (a failed re-zero is a claim about the firmware and about
the method, and both are the ground every remaining joint's commit would stand on).
So is a sweep that covered less than rezero.MIN_COVERAGE of the travel —
INCONCLUSIVE, never a pass. That rule is reused whole, not re-implemented: it is
what stopped three EMPTY sweeps of elbow_flex (0, 0 and 376 ticks against an
expected ~2202) from being declared a success. An unattended --apply --commit is
therefore refused, which is the verb working.

Every commit is a transaction: journalled (fsynced) before the wire, and only a
PASSING sweep calls journal.commit(). A crash in between leaves it dirty and the
next run's require_clean restores the original. An unverified re-zero cannot
survive a crash — "it died before it could check" is not evidence it would have
passed.

Where a measured soft limit lands, and what reads it
----------------------------------------------------
arm_spec.SOFT_LIMITS is checked-in source; a CLI does not rewrite its own source.
So a measured soft limit is APPENDED TO A STORE (new arm101/hardware/soft_limit_store.py,
default ~/.arm101/soft-limits.jsonl, RAW ticks, with the offset it was derived
against and the pose it was measured in) — following the contact-threshold
precedent, with one deliberate difference: THE STORE IS LOADED ON EVERY RUN OF
EVERY MOTION VERB, not behind a flag. A fence that only binds when you remember
to ask for it is not a fence, and this repo has already shipped an inert soft
limit once.

It binds at arm_spec.resolve_bounds — the one function arm flex, arm explore's
grid and the demo sweep all take their move bounds from — via the new
resolve_soft_limits(), which merges file over table and REFUSES a contradiction
(a joint cannot have both a soft limit and a re-zero arc). The commit also prints
the arm_spec table entry to check in: the store makes the limit true for this arm,
the table is how it stops being local knowledge.

The derivation is now a function (arm_spec.soft_limit_for_offset), and the shipped
wrist_roll entry is pinned to be exactly what it derives — so a measured limit and
a hand-written one cannot come to mean different things.

Fixes #47 — the stale-goal lurch
--------------------------------
Goal_Position is a REPORTED tick. Change a joint's offset and that same stored
number names a DIFFERENT PHYSICAL ANGLE, with nothing having written to addr 42.
The servo does nothing while limp; it bolts the instant the next mover energises
it — and gentle_move/compliant_move enable torque BEFORE writing their first goal.
It is a race against the ~95-127ms motion-onset window that nothing in the code
bounds, on the path that is about to re-zero five more joints.

Fixed at the OFFSET WRITE, not at the mover: new safety.hold_in_place() re-points
the standing goal at the joint's own position while torque is still off, so
enabling torque becomes a no-op instead of a command. Applied at EVERY door an
offset reaches a servo through — rezero.apply_rezero, the new rezero.commit_rezero,
rezero.sweep (a human just moved the joint), and journal.restore_dirty (the
CRASH-RECOVERY path, which runs at the start of every motion verb and was writing
an offset with no hold — this one was missed). rolling_frame already did it.

tests/test_stale_goal.py enumerates those doors and asserts the invariant over all
of them at once, against RollingServoBus — ServoModelBus cannot see this bug, and
test_a_stale_goal_really_WOULD_lurch is the mutation check that proves the fake can.

1683 tests (was 1614). Lint, bandit and `teken cli doctor --strict` clean.
Closes the chicken-and-egg in #43: you cannot measure a joint's unreachable arc
until you can see past the encoder seam, and you cannot evict the seam until you
know the arc.

THE ROLLING FRAME. Write a temporary offset mapping the joint's current raw
position to reported 2048 — the seam is then half a turn away, the farthest it
can be — then RE-CENTRE whenever the creep nears the bound. The seam is rolled
ahead of the joint and never obstructs it. Displacement accumulates in RAW ticks,
which are frame-independent, so the measurement survives the frame moving
underneath it.

FOUR VERDICTS, PER END. present_load saturates at Torque_Limit, so a wall and a
joint that is simply too weak look identical at the stop: load 500, not advancing.
The discriminator is a DISTANCE, not a state — a wall is a POSITION constraint
(the loaded zone is gear give, tens of ticks) while torque exhaustion is a TORQUE
constraint (loaded over hundreds of ticks as the moment arm grows). A stall we
cannot vouch for is TORQUE-LIMITED: a LOWER BOUND, never a wall. That error is
safe — it makes a range too narrow, never too wide.

Three live bugs found while building it: #45, #46 (persisted REPORTED ticks) and
#47 (the stale-goal lurch, fixed here).

Closes #47. Refs #43 #35 #34

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
@sonarqubecloud

Copy link
Copy Markdown

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add arm limits rolling-frame travel measurement and crash-safe offset journal

✨ Enhancement 🐞 Bug fix 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Add arm limits to measure joint travel by rolling the encoder seam away.
• Persist limits and soft limits in RAW ticks; convert at the bus edge.
• Journal offset writes for crash recovery; prevent stale Goal_Position torque lurch (#47).
Diagram

graph TD
  A["arm101 CLI"] --> B["arm limits verb"] --> C["RollingFrame"] --> D["CalibrationJournal"] --> H{{"STS3215 servos"}}
  B --> E["probe_end"] --> F["limits record"] --> G["travel classify"]
  B --> I["arm_spec + soft limits"]

  subgraph Legend
    direction LR
    _mod(["Module"]) ~~~ _ext{{"Hardware"}}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use `finally`/context managers instead of a journal
  • ➕ Smaller code surface
  • ➕ No on-disk state machine
  • ➖ Does not survive SIGKILL/OOM/power loss (primary hazard)
  • ➖ Cannot guarantee restoring original calibration after mid-run death
2. Commit by writing servo Min/Max_Position_Limit (EEPROM addrs 9/11)
  • ➕ Firmware-enforced clamp; no runtime soft-limit resolution required
  • ➖ Persists pose-dependent constraints onto the servo (portable lie)
  • ➖ Narrows reachable set permanently, undermining the goal of recovering true travel
  • ➖ Explicitly guarded against; higher safety/bricking risk
3. Persist reported ticks plus calibration-identity/versioning
  • ➕ Avoids migrating tables/stores to RAW ticks immediately
  • ➕ Can detect stale data
  • ➖ More state propagation and more drift surfaces
  • ➖ RAW-tick persistence eliminates the stale-data class instead of detecting it

Recommendation: The PR’s strategy is the right one: rolling the frame removes seam-induced measurement artifacts; journaling makes temporary EEPROM writes recoverable under SIGKILL/power loss; and RAW-tick persistence prevents measurements/limits from silently going stale after re-zero. The alternatives either fail the core crash-safety requirement or increase long-term correctness risk.

Files changed (42) +17395 / -409

Enhancement (5) +4781 / -32
arm.pyAdd 'arm limits' verb and commit flow; share contact-threshold resolution +1602/-32

Add 'arm limits' verb and commit flow; share contact-threshold resolution

• Implements 'arm limits' measurement (rolling frame + probe + classifier) with JSON output and bounds-diff reporting against EEPROM-derived explore bounds. Adds an explicitly gated commit flow that either performs a sweep-verified re-zero or records a measured soft limit, and centralizes contact-threshold resolution for explore+limits.

arm101/cli/_commands/arm.py

classify.pyClassify travel as BOUNDED/CONTINUOUS/UNDETERMINED with no joint-name special-cases +602/-0

Classify travel as BOUNDED/CONTINUOUS/UNDETERMINED with no joint-name special-cases

• Introduces a pure classifier that consumes measured travel evidence and decides the correct seam remedy eligibility. Encodes arc-margin rules and enforces name-independence via AST/behavioral tests.

arm101/hardware/classify.py

limits.pyAdd four-verdict travel evidence model stored as RAW displacement +874/-0

Add four-verdict travel evidence model stored as RAW displacement

• Defines pure data types for per-end observations (WALL/TORQUE_LIMITED/EDGE/TIMEOUT) and merged joint travel, with RAW displacement to avoid seam artifacts and remain invariant under rolling-frame recenters.

arm101/hardware/limits.py

probe.pyProbe a joint end under RollingFrame and discriminate WALL vs TORQUE_LIMITED +888/-0

Probe a joint end under RollingFrame and discriminate WALL vs TORQUE_LIMITED

• Implements a creep probe that records approach samples and derives loaded/free run distances to distinguish a real wall from torque exhaustion. Defaults conservatively to lower-bound verdicts when evidence is ambiguous.

arm101/hardware/probe.py

rolling_frame.pyImplement RollingFrame that recenters to keep the seam half a turn away +815/-0

Implement RollingFrame that recenters to keep the seam half a turn away

• Adds the rolling-frame mechanism that re-centers reported ticks (with a raw=0 fallback due to sign-magnitude offset limits), accumulates RAW displacement, and uses the journal for transactional offset shifts. Rewrites standing Goal_Position after each shift to prevent torque-on lurches.

arm101/hardware/rolling_frame.py

Bug fix (4) +1081 / -289
arm_spec.pyMove soft limits to RAW ticks and resolve bounds using live offsets +809/-227

Move soft limits to RAW ticks and resolve bounds using live offsets

• Imports tick-frame arithmetic from 'hardware.ticks', records factory offset as a hardware fact, and redefines 'SoftLimit' as RAW ticks. Updates 'resolve_bounds'/'permitted_reported_range' to convert RAW limits into the servo’s reported frame using the live homing offset and to accept merged measured soft limits as data.

arm101/hardware/arm_spec.py

demo.pyPass live offset and resolved soft limits into bounds resolution +25/-5

Pass live offset and resolved soft limits into bounds resolution

• Updates demo sweep bounds to call 'arm_spec.resolve_bounds' with the servo’s live homing offset and an optional resolved soft-limit table, ensuring RAW-stored limits bind correctly during motion.

arm101/hardware/demo.py

rezero.pyIntegrate journal/hold-in-place and allow measured travel to override arc table +161/-57

Integrate journal/hold-in-place and allow measured travel to override arc table

• Threads 'CalibrationJournal' into offset writes and imports shared tick arithmetic ('raw_from_reported'). Extends rezero preflight to accept measured travel classifications and updates refusal messaging to include UNKNOWN vs unnecessary vs impossible cases.

arm101/hardware/rezero.py

safety.pyAdd 'hold_in_place' to prevent stale Goal_Position lurch after offset writes (#47) +86/-0

Add 'hold_in_place' to prevent stale Goal_Position lurch after offset writes (#47)

• Introduces 'hold_in_place()' which repoints Goal_Position to the joint’s current reported position while torque is off. Centralizes the policy so every offset-writing path (rezero, rolling frame, journal recovery) can enforce it consistently.

arm101/hardware/safety.py

Refactor (1) +279 / -0
ticks.pyCentralize raw↔reported conversions and seam/offset arithmetic in a no-import module +279/-0

Centralize raw↔reported conversions and seam/offset arithmetic in a no-import module

• Introduces a bottom-of-stack arithmetic module defining encoder constants, seam placement, sign-magnitude offset limits, and raw↔reported conversions. Provides safe helpers used by arm_spec, rolling_frame, and rezero to avoid duplicated frame math.

arm101/hardware/ticks.py

Tests (24) +9747 / -64
_fakes.pyExtend test fakes to support rolling-frame/journal/limits scenarios +47/-0

Extend test fakes to support rolling-frame/journal/limits scenarios

• Adds fake/test helpers needed by the new journaling, probing, and limits flows so unit tests can model required invariants without hardware.

tests/_fakes.py

_journal_subject.pyAdd a journal-focused test harness for failure/recovery paths +98/-0

Add a journal-focused test harness for failure/recovery paths

• Introduces a dedicated subject harness for driving the calibration journal through begin/shift/end states and validating ordering and dirty-entry behavior.

tests/_journal_subject.py

_rolling_servo.pyAdd seam-aware RollingServoBus model for offset/frame correctness tests +174/-0

Add seam-aware RollingServoBus model for offset/frame correctness tests

• Implements a servo simulation that models reported-frame behavior under offset changes (including seam-crossing and stale-goal hazards), enabling non-vacuous tests for this PR’s core behaviors.

tests/_rolling_servo.py

conftest.pyUpdate pytest fixtures for new env-controlled stores and isolation +13/-4

Update pytest fixtures for new env-controlled stores and isolation

• Adjusts fixtures/config to isolate journal/soft-limit store paths and support new fakes in parallel test execution.

tests/conftest.py

test_arm_explore_cli.pyUpdate explore tests for shared contact-threshold resolution and soft-limit-aware bounds +4/-4

Update explore tests for shared contact-threshold resolution and soft-limit-aware bounds

• Aligns 'arm explore' tests with the refactored threshold resolver and the updated bounds resolution that can incorporate measured soft limits.

tests/test_arm_explore_cli.py

test_arm_limits_cli.pyAdd end-to-end tests for 'arm limits' measurement (non-commit) behavior +911/-0

Add end-to-end tests for 'arm limits' measurement (non-commit) behavior

• Verifies measure-only defaults (offset restored, journal clean), torque_guard ownership/release on failures, required JSON evidence fields, and that reachability mapping code is not invoked.

tests/test_arm_limits_cli.py

test_arm_limits_commit.pyAdd tests for 'arm limits --commit' re-zero and soft-limit persistence paths +1044/-0

Add tests for 'arm limits --commit' re-zero and soft-limit persistence paths

• Covers commit behavior for bounded vs continuous/arc-too-narrow outcomes, including sweep-verified re-zero gating and soft-limit JSONL recording/merging semantics.

tests/test_arm_limits_commit.py

test_arm_rezero_cli.pyUpdate rezero CLI tests for measured-arc override and updated refusal reasons +51/-10

Update rezero CLI tests for measured-arc override and updated refusal reasons

• Adjusts rezero CLI expectations to reflect measured-travel overrides and the UNKNOWN vs unnecessary vs impossible refusal taxonomy.

tests/test_arm_rezero_cli.py

test_arm_spec.pyUpdate arm_spec tests for RAW soft limits and offset-aware bounds conversion +35/-7

Update arm_spec tests for RAW soft limits and offset-aware bounds conversion

• Refreshes unit tests around soft-limit invariants, seam clearance, and 'resolve_bounds' behavior now that limits are RAW but motion bounds are REPORTED and offset-dependent.

tests/test_arm_spec.py

test_calibration_journal.pyTest journal-first durability ordering and restore/commit semantics +560/-0

Test journal-first durability ordering and restore/commit semantics

• Pins the contract that the original offset is durably recorded (fsync) before any offset write is sent, and that dirty entries are restored/cleared correctly across failure paths.

tests/test_calibration_journal.py

test_calibration_journal_sigkill.pyProve SIGKILL recovery restores original offsets using a subprocess +165/-0

Prove SIGKILL recovery restores original offsets using a subprocess

• Kills a real subprocess mid-transaction against a persistent EEPROM model and asserts a subsequent run restores offsets via the journal, proving recovery works beyond 'finally' blocks.

tests/test_calibration_journal_sigkill.py

test_classify.pyTest classifier logic, arc-margin cutoffs, and name-independence enforcement +1093/-0

Test classifier logic, arc-margin cutoffs, and name-independence enforcement

• Covers BOUNDED/CONTINUOUS/UNDETERMINED classification outcomes, arc usability rules, and enforces that logic does not branch on joint names via AST and behavioral invariants.

tests/test_classify.py

test_eeprom_limit_write_guard.pyAdd static+runtime guard preventing writes to servo EEPROM addrs 9/11 +626/-0

Add static+runtime guard preventing writes to servo EEPROM addrs 9/11

• Introduces a fail-closed scan of all SDK write call sites to ensure no code writes Min/Max_Position_Limit registers, corroborated by execution-time recording of wire writes.

tests/test_eeprom_limit_write_guard.py

test_limits.pyTest RAW-displacement merging and four-verdict semantics for measured travel +777/-0

Test RAW-displacement merging and four-verdict semantics for measured travel

• Validates the limits record model, signed delta/seam-safe arithmetic, and merge rules that keep torque-limited stalls from being represented as mechanical walls.

tests/test_limits.py

test_measured_rezero.pyTest re-zero derivation from measured travel classifications +480/-0

Test re-zero derivation from measured travel classifications

• Ensures re-zero targets/arcs can be derived from measured travel output and that refusal behavior remains correct when measurements are missing or mismatched.

tests/test_measured_rezero.py

test_probe.pyTest probe approach-run analysis and conservative WALL vs TORQUE_LIMITED discriminator +943/-0

Test probe approach-run analysis and conservative WALL vs TORQUE_LIMITED discriminator

• Exercises loaded/free run extraction from approach traces, compliance cutoffs, and tie-break behavior that defaults to lower bounds rather than false walls.

tests/test_probe.py

test_rezero.pyUpdate rezero tests for shared tick arithmetic and hold-in-place policy +72/-15

Update rezero tests for shared tick arithmetic and hold-in-place policy

• Adjusts rezero tests to use the centralized tick conversions and to validate goal-hold safety around offset changes.

tests/test_rezero.py

test_rezero_retraction_prose.pyPin operator-facing prose: UNKNOWN is not 'unnecessary' until measured +221/-0

Pin operator-facing prose: UNKNOWN is not 'unnecessary' until measured

• Adds tests ensuring operator-facing messaging reflects the retraction: unmeasured joints are UNKNOWN rather than asserted safe/unnecessary by default.

tests/test_rezero_retraction_prose.py

test_rolling_frame.pyTest RollingFrame centering (raw=0 fallback), recentering, and displacement invariants +852/-0

Test RollingFrame centering (raw=0 fallback), recentering, and displacement invariants

• Validates centering-offset computation including the sign-magnitude 2048 residue hole, correct RAW displacement accumulation under frame shifts, and transactional restore behavior.

tests/test_rolling_frame.py

test_seam_bound_arithmetic.pyTest seam/offset arithmetic edge cases and representability limits +252/-0

Test seam/offset arithmetic edge cases and representability limits

• Covers seam placement math, sign-magnitude offset constraints, and correctness across the full 4096-tick domain to prevent corner-case calibration bugs.

tests/test_seam_bound_arithmetic.py

test_soft_limit_enforcement.pyEnsure soft limits bind via resolve_bounds and prevent seam-crossing commands +64/-24

Ensure soft limits bind via resolve_bounds and prevent seam-crossing commands

• Tests that RAW-stored soft limits are converted into REPORTED bounds using the live offset and that movers are prevented from commanding through the reported seam.

tests/test_soft_limit_enforcement.py

test_soft_limit_store.pyTest measured soft-limit JSONL store load/merge and validation rules +391/-0

Test measured soft-limit JSONL store load/merge and validation rules

• Validates default path/env override behavior, append-only recording, last-write-wins merge semantics, and refusal of invalid hand-edited ranges.

tests/test_soft_limit_store.py

test_stale_goal.pyReproduce and prevent stale Goal_Position lurch across all offset-write doors +302/-0

Reproduce and prevent stale Goal_Position lurch across all offset-write doors

• Reproduces the lurch hazard with a seam-aware servo model, then asserts 'hold_in_place' is applied by every path that writes offsets (rezero, rolling frame, journal recovery).

tests/test_stale_goal.py

test_tick_frames.pyEnforce RAW persistence and a single reported↔raw conversion boundary +572/-0

Enforce RAW persistence and a single reported↔raw conversion boundary

• Pins round-trip conversion correctness, asserts persisted tick tables are RAW and seam-safe, and enumerates persisted tick sources to prevent reintroducing reported-frame persistence bugs.

tests/test_tick_frames.py

Documentation (5) +435 / -23
arm101-cli__public.jsonlRecord spec/plan and hardware findings for rolling-frame limits work +1/-0

Record spec/plan and hardware findings for rolling-frame limits work

• Adds public eidetic memory entries capturing the converged 'arm limits' spec/plan and related hardware observations (factory offset, seam behavior, six-joint risk).

.eidetic/memory/arm101-cli__public.jsonl

CHANGELOG.mdChangelog entry for 0.23.0 ('arm limits', journal, RAW ticks, safety fixes) +16/-0

Changelog entry for 0.23.0 ('arm limits', journal, RAW ticks, safety fixes)

• Documents the new 'arm limits' verb, new hardware modules (ticks/journal/rolling_frame), commit behavior, and the stale-goal safety fix and rezero messaging retraction.

CHANGELOG.md

learn.pyUpdate learn prompt for 'arm limits' and render rezeroability prose from arm_spec +131/-14

Update learn prompt for 'arm limits' and render rezeroability prose from arm_spec

• Extends the self-teaching prompt with 'arm limits' semantics and safety notes. Switches rezeroability text to be rendered from 'arm_spec' to prevent drift when the underlying facts change.

arm101/cli/_commands/learn.py

overview.pyList 'arm limits' in the CLI overview help +9/-0

List 'arm limits' in the CLI overview help

• Adds operator-facing overview text for 'arm limits', including per-end verdicts and commit semantics (re-zero vs soft-limit vs nothing) and the 9/11 EEPROM write prohibition.

arm101/cli/_commands/overview.py

catalog.pyAdd 'explain arm limits' and update rezero docs for UNKNOWN vs impossible joints +278/-9

Add 'explain arm limits' and update rezero docs for UNKNOWN vs impossible joints

• Adds a detailed 'arm limits' explain page and updates verb listings and rezero rationale to distinguish proven-impossible ('wrist_roll') from UNKNOWN travel. Imports and surfaces 'MATERIAL_SPAN_DELTA_TICKS' to keep docs consistent with the measurement constant.

arm101/explain/catalog.py

Other (3) +1072 / -1
journal.pyAdd calibration journal to make temporary offset writes transactional and recoverable +746/-0

Add calibration journal to make temporary offset writes transactional and recoverable

• Adds an append-only, fsynced JSONL journal that records original offsets before wire writes and marks entries dirty until restored/committed. Provides restore/require_clean APIs used as crash recovery at the start of motion verbs.

arm101/hardware/journal.py

soft_limit_store.pyPersist measured soft limits in an append-only JSONL store +325/-0

Persist measured soft limits in an append-only JSONL store

• Adds a default soft-limit store (~/.arm101/soft-limits.jsonl, relocatable by env var) that records RAW-tick soft limits plus provenance. Enables 'arm limits --commit' to persist continuous-joint remedies without rewriting checked-in source tables.

arm101/hardware/soft_limit_store.py

pyproject.tomlBump version to 0.23.0 +1/-1

Bump version to 0.23.0

• Updates the project version to reflect the new 'arm limits' feature and associated safety changes.

pyproject.toml

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (1) 📎 Requirement gaps (0) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 8 rules
✅ Skills: cicd

Grey Divider


Action required

1. Limits skips torque release 🐞 Bug ⛨ Security
Description
cmd_arm_limits() sets a stop CliError during the run but raises it only after leaving
torque_guard and after bus.close(), so TorqueGuard.__exit__ sees a clean exit and will not
release torque. On commit/restore failure paths that return a failed restore outcome instead of
raising, motors can remain energized (because gentle_move() enables torque and does not disable
it) and the bus is already closed so the guard cannot recover.
Code

arm101/cli/_commands/arm.py[R3404-3412]

+    finally:
+        bus.close()
+
+    _emit_limits_result(
+        _limits_payload(role, port, pose, measurements, commit_mode=commit_mode),
+        json_mode=json_mode,
+    )
+    if stop is not None:
+        raise stop
Relevance

⭐⭐⭐ High

Team consistently accepts torque-safety cleanup changes (TorqueGuard/try-finally) to avoid leaving
motors energized (PRs #38, #6).

PR-#38
PR-#6

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The stop error is raised after the torque_guard block and after closing the bus, so
TorqueGuard.__exit__ is invoked with exc_type is None and returns without releasing. Since
gentle_move() enables torque and does not disable it on success, and journal restore failures are
reported via return values rather than raised exceptions, this control flow can leave torque enabled
with no chance for the guard to release once the port is closed.

arm101/cli/_commands/arm.py[3316-3412]
arm101/hardware/safety.py[421-449]
arm101/hardware/gentle.py[807-884]
arm101/hardware/journal.py[584-610]
arm101/hardware/bus.py[1118-1124]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`cmd_arm_limits()` records a fatal `stop` condition inside the `with torque_guard(...)` block, but raises it only *after* the guard has exited and *after* the serial bus is closed. Since `TorqueGuard.__exit__` releases torque only when an exception unwinds through the context manager, this path skips the safety release sweep exactly when the command is about to fail.

### Issue Context
This is especially risky on commit failure paths where restore logic returns `restored=False` without raising; `gentle_move()` leaves torque enabled on success, and `bus.close()` does not disable torque.

### Fix Focus Areas
- arm101/cli/_commands/arm.py[3327-3412]

### Implementation notes
Restructure so that if `stop` is set, you emit the results *and then raise* **before** exiting the `torque_guard` context (and therefore before `bus.close()` runs). For example:
- compute `payload` outside/inside the loop,
- if `stop` is not `None`, call `_emit_limits_result(...)` and `raise stop` while still inside the `with torque_guard(...)` block,
- otherwise emit results after the `with` as today.

Alternative: explicitly call `guard.release()` (best-effort) before closing the bus and raising, but raising inside the context is simpler and preserves the guard’s existing semantics.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

2. Eidetic spec duplicates code docs 📘 Rule violation ⚙ Maintainability
Description
A new eidetic memory entry records a detailed arm limits/rolling-frame spec that is already
directly represented in repository code/docstrings, making it repository-derivable and increasing
drift/maintenance risk. Eidetic memory should capture non-derivable rationale only, or link to the
canonical in-repo source instead of duplicating it.
Code

.eidetic/memory/arm101-cli__public.jsonl[20]

+{"id": "arm-limits-rolling-frame-spec-2026-07-12", "hash": "ad3959942e012ce0398e9240937006cd7c947086e90e33a98196204b6cd3bde0", "content": "SPEC CONVERGED + PR #44 (2026-07-12, v0.22.2, /think -> /spec-to-plan): `arm limits` \u2014 the arm measures its own joint travel. Handles issue #43 (the software bound lands EXACTLY on the encoder seam for every factory-offset joint: reported 4095 under Ofs=85 is raw 84, one tick below the seam at raw 85).\n\nTHE MECHANISM \u2014 THE ROLLING FRAME. Breaks the chicken-and-egg (you cannot measure a joint's unreachable arc until you can see past the seam; you cannot evict the seam until you know the arc). Escape: write a TEMPORARY offset mapping the joint's CURRENT raw position to reported 2048 \u2014 the seam is then half a turn away, the farthest it can possibly be, with ~2048 clear ticks each side. Creep outward, and RE-CENTRE whenever the creep nears the reported bound. The seam is rolled ahead of the joint and never obstructs it, so travel is unbounded by the frame. Displacement accumulates in RAW ticks (frame-independent), so the measurement survives the frame moving underneath it. Termination by accumulated raw displacement: a full 4096 ticks with no wall => the joint is CONTINUOUS and no offset can ever help it \u2014 which is how the verb DERIVES wrist_roll's known verdict instead of being told it.\n\nUSER RULING 1 \u2014 NAME THE FAKE WALL HONESTLY. gentle_move calls contact when load is high AND the joint has stopped advancing. A gravity-loaded joint (shoulder_lift carries the whole arm) lifting against the 500 Torque_Limit cap can stall at high load with NOTHING in front of it. FOUR verdicts, carried PER END not per joint: WALL / TORQUE-LIMITED / EDGE / TIMEOUT. A TORQUE-LIMITED end is a LOWER BOUND, never a wall. KEY INSIGHT that makes naming it sufficient: this failure is SAFE \u2014 a torque-limited stall makes a range too NARROW, never too wide, so the arm UNDER-claims its reach. arm_spec therefore stores 'the widest envelope OBSERVED', not 'the mechanical limit'.\n\nCOROLLARY \u2014 THE MAX-OVER-POSES RULE IS ONLY HALF TRUE. 'Mechanical = the widest envelope across poses, because an obstacle can only ever make a range smaller' is SOUND for a WALL and FALSE for a TORQUE-LIMITED end: the arm's own weakness is not an obstacle you can pose your way out of \u2014 it shrinks the range in EVERY pose, so no number of poses ever escapes it. A torque-limited end stays a lower bound FOREVER and must never be promoted on pose evidence alone.\n\nUSER RULING 2 \u2014 PERSIST RAW TICKS EVERYWHERE. A re-zero invalidates every recorded tick ONLY BECAUSE we persist REPORTED ticks, which are a view through the current offset. RAW ticks are INVARIANT under a re-zero (the offset changes, the encoder does not). So arm_spec + the map store RAW; reported is a display view converted at the bus edge. This ELIMINATES the failure mode rather than detecting it \u2014 the calibration-identity machinery proposed in the #36 spec is NOT needed to protect data that cannot go stale. REZERO_ARCS already made this move in PR #41.\n\nSTOP-GATE (plan task t12): if the verb cannot re-derive the two answers we ALREADY know \u2014 elbow_flex => BOUNDED, wrist_roll => CONTINUOUS \u2014 from measurement alone, it is not measuring, it is guessing, and NO verdict it gives on the other four joints may be believed. Everything downstream halts and returns to the user.\n\nFALSIFIABLE ACCUSATIONS the frame forced (each can prove this work unnecessary): (a) arm_spec._REZERO_UNNECESSARY asserts 'four of the six joints do not wrap' \u2014 if all four come back with the seam OUTSIDE their travel, the message was RIGHT and the claim is retracted; (b) >=1 joint's measured span must differ from its EEPROM span by >100 ticks \u2014 if none does, the rationale for blocking #34 on this work is FALSE and must be withdrawn.\n\nPLAN: 15 tasks / 10 waves. Wave 0 = 4 file-disjoint pure tasks (raw-tick boundary, four-verdict record, crash-safe calibration journal, guard tests incl. 'nothing writes servo addr 9/11' \u2014 LeRobot's write_calibration DOES write them and must NOT be ported: addrs 9/11 are Min/Max_Position_Limit and CLAMP goals in servo mode, which would narrow the very reachable set this work recovers). Then the probe engine (serial: rolling frame -> creep -> classifier -> measured arcs), the verb, then 4 human-gated hardware waves.", "scope": {"name": "arm101-cli", "visibility": "public"}, "metadata": {"type": "decision", "record_metadata": {"source": "think-spec", "area": "arm101/hardware/", "issue": "43,35,34", "pr": "44", "version": "0.22.2", "spec": "docs/specs/2026-07-12-arm101-finds-its-own-joint-limits-a-new-gated-verb.md", "plan": "docs/plans/2026-07-12-arm101-finds-its-own-joint-limits-a-new-gated-verb.md", "status": "spec-converged, build in progress"}, "created": "2026-07-12T17:39:13.978719+00:00", "last_recall": null, "recall_count": 0, "links": [], "supersedes": null, "lifecycle": "active", "added_by": null}}
Relevance

⭐ Low

Similar “eidetic duplicates repo docs” suggestion was explicitly rejected in PR #41 for same memory
file.

PR-#41

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1497307 forbids adding eidetic memory entries that merely restate information
already present in the repository. The added eidetic entry is a full spec description of the
rolling-frame mechanism, while the same mechanism is already documented in-repo in
arm101/hardware/rolling_frame.py’s module docstring, making the memory record repository-derivable
and redundant.

Rule 1497307: Avoid storing repository-derivable information in the eidetic memory store
.eidetic/memory/arm101-cli__public.jsonl[20-20]
arm101/hardware/rolling_frame.py[1-35]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A new record in `.eidetic/memory/arm101-cli__public.jsonl` duplicates repository-derivable design/spec content (already present in in-repo docstrings/code), which violates the guideline to avoid storing repo-derivable information in the eidetic memory store.

## Issue Context
The newly added `arm-limits-rolling-frame-spec-2026-07-12` entry largely restates the rolling-frame mechanism and rationale that already exists in `arm101/hardware/rolling_frame.py`’s module docstring.

## Fix Focus Areas
- .eidetic/memory/arm101-cli__public.jsonl[20-20]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines +3404 to +3412
finally:
bus.close()

_emit_limits_result(
_limits_payload(role, port, pose, measurements, commit_mode=commit_mode),
json_mode=json_mode,
)
if stop is not None:
raise stop

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Limits skips torque release 🐞 Bug ⛨ Security

cmd_arm_limits() sets a stop CliError during the run but raises it only after leaving
torque_guard and after bus.close(), so TorqueGuard.__exit__ sees a clean exit and will not
release torque. On commit/restore failure paths that return a failed restore outcome instead of
raising, motors can remain energized (because gentle_move() enables torque and does not disable
it) and the bus is already closed so the guard cannot recover.
Agent Prompt
### Issue description
`cmd_arm_limits()` records a fatal `stop` condition inside the `with torque_guard(...)` block, but raises it only *after* the guard has exited and *after* the serial bus is closed. Since `TorqueGuard.__exit__` releases torque only when an exception unwinds through the context manager, this path skips the safety release sweep exactly when the command is about to fail.

### Issue Context
This is especially risky on commit failure paths where restore logic returns `restored=False` without raising; `gentle_move()` leaves torque enabled on success, and `bus.close()` does not disable torque.

### Fix Focus Areas
- arm101/cli/_commands/arm.py[3327-3412]

### Implementation notes
Restructure so that if `stop` is set, you emit the results *and then raise* **before** exiting the `torque_guard` context (and therefore before `bus.close()` runs). For example:
- compute `payload` outside/inside the loop,
- if `stop` is not `None`, call `_emit_limits_result(...)` and `raise stop` while still inside the `with torque_guard(...)` block,
- otherwise emit results after the `with` as today.

Alternative: explicitly call `guard.release()` (best-effort) before closing the bus and raising, but raising inside the context is simpler and preserves the guard’s existing semantics.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A re-zero leaves a STALE Goal_Position in the old frame — the next mover enables torque with it live (~988 ticks off on elbow_flex)

2 participants