feat: the arm measures its own joint travel — arm limits and the rolling frame (#43)#48
feat: the arm measures its own joint travel — arm limits and the rolling frame (#43)#48OriNachum wants to merge 22 commits into
arm limits and the rolling frame (#43)#48Conversation
… 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
…rd, with the bounds diff
…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.
…tale-goal door (#47)
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
|
PR Summary by QodoAdd
AI Description
Diagram
High-Level Assessment
Files changed (42)
|
Code Review by Qodo
Context used✅ Tickets:
🎫 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) 🎫 arm explore's grid is derived from the wrong things — EEPROM bounds and a global bucket size (r2)✅ Compliance rules (platform):
8 rules✅ Skills:
cicd 1. Limits skips torque release
|
| 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 |
There was a problem hiding this comment.
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



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 atraw == Ofs. At the factoryOfs = 85that 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_liftthen 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_loadsaturates atTorque_Limit, so at the stop a real wall and a joint that is simply too weak are identical: load 500, not advancing.shoulder_liftcarries the whole arm; a torque-limited stall recorded as a mechanical limit would be a permanent lie inarm_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'sbackoffmeasured 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.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_ticksis in the--jsonoutput per joint per end, andcomplianceis a flag. The first hardware run retunes it from data, at zero extra cost.A
TORQUE_LIMITEDend 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.
WallEndandLowerBoundEndare 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
elbow_flex. An unattended--apply --commitcannot pass itself.torque_guard. Never a write to addr 9 or 11.Live bugs found while building this
Goal_Positionin the old frame — ~988 ticks off onelbow_flex— and the next mover enabled torque with it live. Enumerating every door an offset reaches a servo through found two nobody had named:journal.restore_dirty, which runs at the start of every motion verb, and the end ofrezero.sweep, where a human has just walked the joint through its travel by hand and the next torque-on would drive it back with their hand still on it.JointCalibrationandReachMappersist reported ticks. Any profile or map written before theelbow_flexre-zero is silently wrong by ~1071 ticks.The retraction
arm_spec._REZERO_UNNECESSARYtold 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 ajointparameter).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