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 29 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.
|
#43) The stop-gate for `arm limits` tripped: the verb returned CONTINUOUS for wrist_roll, a joint arm_spec recorded as PROVEN to turn freely all the way round. The verb was right and the ground truth was wrong. wrist_roll's contact threshold was 400. Re-probed, its walls press at 272 and 288. `_StallDetector.is_contact` needs `load > threshold`, so contact could NEVER fire, however hard the joint pushed. The "measured free range [21, 4073], no wall anywhere" that founded every decision about this joint was not a measurement of a free joint — it was a joint driving into two solid walls with the software deaf to it. It is BOUNDED: walls at raw 1700 and 1491, travel 3887, unreachable arc 209 ticks. The instrument now announces this class of blind spot instead of recording it as fact: * LimitVerdict.UNFIRABLE_THRESHOLD (a fifth verdict). A probe that times out having never reached peak_load > threshold PROVABLY could not have called a contact. It reports which number blinded it and what to lower it to. Distinguished from a genuine TIMEOUT by whether the joint pushed HARDER at the stop than it ever did while moving — its own free-motion baseline, taken as a MEDIAN because the sample where a joint reaches a wall shows it both advancing and loaded, and a max would swallow exactly the excess being looked for. * The threshold's real band. The floor was thought to be each joint's free-motion peak; it is not, because an ADVANCING joint never stalls and so can never false-contact however hard it pulls. The ceiling was taken as 500, where present_load saturates — but that is where the SENSOR stops, not where the JOINT does. Both errors pushed thresholds UP, and up is the direction that blinds them. wrist_roll 400 -> 150. shoulder_lift is flagged: its threshold is 250 against the only contact ever measured on it, 252. * Four joints' wall loads remain UNMEASURED, and a test now pins that gap so it cannot pass for a pass. * MIN_EVICTABLE_ARC_TICKS: 2*margin+2 -> 3*margin. The old cutoff left the seam two ticks to stand on and called that "the same risk profile elbow_flex runs". A margin absorbs error in ONE wall; the INTERIOR absorbs error in both at once. wrist_roll's 209-tick arc cleared the old cutoff by 7 ticks, on a joint whose walls move by ~12. It is now refused — and the tightening DELETES the unrepresentable-seam corner case rather than handling it. * arm_spec._REZERO_IMPOSSIBLE -> _REZERO_REFUSED. wrist_roll is still refused a re-zero and still soft-limited — but for a measured number (209 < 300) instead of an impossibility in principle. An impossibility forecloses the question; a number can be re-measured. The refusal is now RENDERED into explain/learn/help from arm_spec, because hard-coding it there is how the withdrawn claim survived in the operator-facing prose after the table was corrected. Also fixes `arm limits --help`, which has crashed with a raw TypeError since the flag shipped: a rendered "80% of" in --sweep-duration's help parsed as the %o format code. argparse raises inside its own formatter, upstream of _dispatch, so the CLI's no-traceback contract could not catch it. A new test formats the help of EVERY verb in the tree. Refs #43 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
The hardware run the stop-gate demanded. All six joints probed on the follower; every threshold now checked against a load its joint has actually been seen to produce. THE HEADLINE: only wrist_roll fails to saturate. Five of six joints can push present_load to the 500 cap against a stop, so almost any threshold under 500 fires on them — which is exactly why "the ceiling is 500" survived so long. It is true of the easy cases. wrist_roll tops out at 272, its threshold was 400, and a BOUNDED joint with two real walls was therefore catalogued as turning freely all the way round. A rule validated on the easy cases is not validated. THE NEAR-MISS: the gripper's two ends are not alike. Its low wall saturates at 500; its HIGH wall pushes only 284. Against the old 250 threshold that is a 34-tick margin. It fired — but a weaker wall, a warmer joint, a lighter grip, and it goes the way of wrist_roll, silently. The ceiling is the WEAKEST wall, not the average one, and a joint may have one of each. Lowered to 200. Measured (follower, 2026-07-13): shoulder_pan BOUNDED walls 500/500 thr 250 arc 3330 (wraps the seam) shoulder_lift UNDET. swept 53 ticks thr 200 pose-limited, not measured elbow_flex BOUNDED walls 500 thr 280 arc ~1600 wrist_flex TORQUE_L walls 500/500 thr 250 pose-limited, overload-prone wrist_roll BOUNDED walls 272/288 thr 150 arc 209 (too narrow: soft limit) gripper BOUNDED walls 500/284 thr 200 arc 2587 (wraps the seam) shoulder_pan and gripper both WRAP the encoder seam and have arcs wide enough to re-zero into — neither was previously known to. elbow_flex is no longer the only re-zeroable joint. shoulder_lift and wrist_flex could not be measured in the pose the arm was in: shoulder_lift swept 53 ticks total (30 free, then a wall; the other way it never moved at all against the torque cap), and wrist_flex tripped the servo's own overload latch after 298. Their limits HERE are environmental, not mechanical. Both verbs said so rather than guessing, which is the contract working. Also fixes bus.clear_overload, which trusted its own write. The overload latch is NOT instant — measured, a read straight after a successful torque-disable still carried error=32 while the same read 250ms later was clean. gentle_move cleared the latch, immediately re-read, got a still-faulted servo, suppressed it, and lost the position; the NEXT probe then ran on a motor that had never recovered. That is why wrist_flex's high end came back as 5 ticks of travel and a verdict describing the leftover fault instead of the joint. It now polls until the latch really drops, and raises if it never does — same rule write_offset already follows: the register is the arbiter, not the status byte. Refs #43 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
Two numbers corrected by the arm, not by reasoning.
_PRESSING_EXCESS_LOAD 25 -> 100. It separates "the joint was pressing on
something the threshold could not see" (UNFIRABLE_THRESHOLD) from "the joint
just stopped" (TIMEOUT). I set it to 25 this morning by reasoning DOWN from the
~212 excess wrist_roll develops driving into a real wall, on the argument that
anything well under that was safe. It was not. On the first probe that exercised
it, wrist_roll stalled against NOTHING at a peak of 92 over a cruising load of
~60 — an excess of 32 — and the verb confidently reported an unfirable threshold
for a wall that did not exist. The constant was under the noise floor. Measured
either side of the line now:
false stall, nothing there 32 <- the noise floor
real wall, gripper's weak end ~208 <- the WEAKEST real wall on this arm
real wall, wrist_roll ~212
100 sits ~3x above the noise and ~2x below the weakest real wall, and a test
pins BOTH bounds so it cannot be reasoned back down.
wrist_roll threshold 150 -> 120. Probed from a mid-travel start, its two ends
are not alike at all: the low end saturates (476-500) while the HIGH end tops
out at just 172-196 — the weakest stop anywhere on this arm. A threshold has to
beat the WEAK end or the joint is blind on that side, and 150 cleared 172 by
only 22 ticks. Same 34-tick near-miss the gripper had, on the joint that already
burned us once.
UNFIRABLE_THRESHOLD is now confirmed on hardware, on a genuine case: at threshold
250 wrist_roll's high end peaks at 196, the threshold is unreachable, and the verb
says so instead of inventing a verdict.
CAVEAT, recorded honestly: `arm limits` is NOT reproducible on wrist_roll. Three
runs gave BOUNDED, UNDETERMINED, UNDETERMINED. Travel replicates well (3887 /
3903 / 3905) but the END VERDICTS do not, because the ends are COMPLIANT — the
arm squashes ~65-70 ticks into each one, and a hand-placed end sits ~70 ticks
short of where the arm gets. A compliant stop reads differently every time you
lean on it. The joint IS bounded (a human hand finds two ends); the arm cannot
reliably prove it. The "BOUNDED, arc 209" recorded in v0.24.0 rests on a single
run that has not replicated.
Refs #43
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
#43) The session stops here to think. The authoritative write-up is the latest comment on #43; this file is the breadcrumb that finds it, plus the strict work order (#51 -> #52 -> #34) and the gotchas that cost real time. Refs #43 #51 #52 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
Review round 1 on PR #48 — Qodo review comment 3567079004, SonarCloud python:S3516 and python:S1192. `cmd_arm_limits` recorded its STOP condition and broke out of the measuring loop, then raised the CliError after the `with torque_guard(...)` block had closed and after `bus.close()`. TorqueGuard sweeps only when an exception is unwinding through it, so a `break` reads as a clean exit: on the one fatal path the guard exists to catch, it held torque instead of releasing it, and by the time the error flew the bus was shut. Motors gentle_move had energised could be left hot with nothing able to reach them. Emit and raise from INSIDE the guard. The report still goes out in full first — that never changes — but the exception now reaches __exit__ while the bus is still open, which is the only instant both are true. Also: RollingFrame.__exit__ returned False on every path while claiming `-> bool`. TorqueGuard.__exit__ had already answered this — a manager that restores hardware does not adjudicate the caller's exception — so the two now agree at the signature. And three dataclasses in limits.py each carried a byte-identical joint-name guard; they share `_require_joint_name` now, next to the `_require_raw_tick` it mirrors. 1698 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
Review round 1 — triageFour findings (2 Qodo, 2 SonarCloud). Three fixed in FIXED — Bug: "Limits skips torque release." A real safety bug, replied inline. The STOP path left the arm energized on the one failure the guard exists to catch. FIXED — SonarCloud FIXED — SonarCloud PUSHBACK — Rule violation: "Eidetic spec duplicates code docs." Declining this one, and I want to be precise about why rather than just wave the convention at you. The premise — "memory should capture non-derivable rationale only, or link to the canonical in-repo source" — is right, and it's this repo's own stated rule ( And the mitigation you ask for is already in the record. Its metadata carries On drift risk, which is the part of the finding I do take seriously: the record is scoped to a converged decision at a point in time, and the store has explicit machinery for exactly this ( Leaving this thread open rather than resolving it — it's a judgement call about a convention, and that's the human's to overturn, not mine.
|
SonarCloud python:S5778 on the regression test added in 10f4108. `_args(joint=[ELBOW])` was being built INSIDE `pytest.raises(CliError)`, so a CliError from the argument builder would have been indistinguishable from the STOP the test exists to pin. Hoisted out. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
|



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