Skip to content

fix: the re-zero is proven on hardware — and the arc was in the wrong frame (#35)#41

Merged
OriNachum merged 10 commits into
mainfrom
fix/rezero-frame-and-bus-robustness
Jul 12, 2026
Merged

fix: the re-zero is proven on hardware — and the arc was in the wrong frame (#35)#41
OriNachum merged 10 commits into
mainfrom
fix/rezero-frame-and-bus-robustness

Conversation

@OriNachum

Copy link
Copy Markdown
Contributor

Refs #35. A live hardware session on the follower, operator present throughout.

The result: the re-zero works, and the spike's caveat is closed by measurement

The STS3215 reduces the corrected position modulo 4096 — a homing offset genuinely
relocates the encoder seam rather than merely relabelling positions. That was the one
unproven assumption the whole of issue #35 rested on. The t5 spike
said GO-WITH-CAVEAT precisely because no document anywhere states it. Now it's measured.

Same joint, same hand sweep, torque off, before and after:

offset monotonic discontinuities span
before 0 False 1 4093
after 1073 True 0 2196

And it survived a power cycle, read back cold. PR #21's ghost is laid.

We found the mechanism, not just the symptom

Every SO-101 ships with Ofs = +85 — not the factory 0 the spike assumed. (Uniform
across all six servos, so it's a vendor default, not a calibration.)

That is why elbow_flex wraps. The seam sits where Actual == Ofs — at raw 85 — while
the unreachable arc doesn't begin until raw 251. The seam was sitting inside the joint's
travel
, by 166 ticks. Nothing in the issue, the spike, or the code knew that.

The frame bug

REZERO_ARCS held reported ticks — read off a servo already carrying that factory offset
— and used them as raw. The old target landed inside the true arc anyway, with ~866 ticks
of margin, so it worked and every read-back looked correct. That is the most dangerous way
for a frame bug to behave.

The arc is now raw by construction, and raw = (reported + offset) mod 4096 on every path.

The current in (0, target) guard is gone. It refused outright on a servo holding the
factory 85 — i.e. on every fresh SO-101. The goal is now stated as a place (the seam is
out of the travel), not a magic number: an arm already holding a seam-evicting offset is a
clean no-op, not a rewrite that spends an EEPROM cycle to slide a seam from one unreachable
tick to another.

The arm measured its own walls — better than the human did

Once the axis was linear, gentle_move could be driven past the known travel and left to
find each wall by feel: creep under torque, watch the load, stop on contact, back off, hold.

arm flex elbow_flex --to  900 --gentle  ->  contacted  988   load 500 (SATURATED)
arm flex elbow_flex --to 3400 --gentle  ->  contacted 3274   load 500 (SATURATED)

The second one is the whole day's work in a single move. Commanding 3400 crosses the raw
4095→0 boundary — and in the corrected frame it's a plain linear climb, which converged. That
exact command would have rotated the elbow the long way round into a wall yesterday.

wall (raw) by hand by the arm
low band 218 251
high band 2107 2061

A human pushes until it feels firm — successive sweeps gave 206, then 218. The arm presses
to a fixed load every time, so its walls are further out and repeatable. Both contacts
saturated at the 500 torque cap: the signature of a real wall, not of an operator's judgement.

The arc is inset from those walls, so a harder push can never make the table contradict the
arm — which is exactly what a tight arc did, within minutes of being declared.

The tests stopped copying the table

~125 arc-coupled literals were hard-coded across two test files, so re-measuring a table that
exists to be re-measured
broke 34 tests. They now derive every expectation from arm_spec.
Acid test: change _ARC_MARGIN_TICKS or either wall and the suite stays green.

Same rule now applies to prose — the docstrings and the explain catalog no longer quote
ticks at all
. A doc that names a measurement is a doc that goes stale, and explain arm rezero
is what prints to whoever is standing at the arm.

Two bus bugs, both hit live on a healthy bus

  • A dropped packet raised a bare IndexError from inside the vendor SDK instead of a
    CliError, leaking a raw traceback and violating the repo's contract. Reads now convert and
    retry; writes are never retried, because a failed write may in fact have landed — that
    ambiguity is exactly how fix: persist EEPROM id/baud writes by opening the Lock register #21's Lock bug and feat: motion verbs release the arm on any abnormal exit (closes #33) #38's id-transfer bug arose. OverloadError is
    never retried: it's a latched state, not a dropped packet.
  • A read immediately after an EEPROM write returned 0 while the servo genuinely held 3387.
    A plausible-looking wrong value is far more dangerous than an error. EEPROM writes now settle.

Gates

1242 tests pass (1202 on main). black / isort / flake8 / bandit clean. teken cli doctor --strict passes. Run-log: docs/hardware-rezero-run-2026-07-12.md.

  • arm101-cli (Claude)

OriNachum and others added 8 commits July 12, 2026 15:13
…d reads retry, writes never do

Observed live on hardware today: scservo_sdk's read2ByteTxRx can raise a
bare IndexError internally on a short/corrupt packet (it indexes data[0]/
data[1] whenever result==COMM_SUCCESS, even when the buffer is short),
escaping this repo's no-traceback-ever-leaks contract before the
result/error check ever runs. FeetechBus reads (read_position, the shared
_read_register path behind read_info/read_offset/read_torque_limit, and
read_lock) now funnel through _sdk_read (converts any bare SDK exception
into a CliError) and _retry_read (bounded 3-attempt retry, since a read is
idempotent) — OverloadError is never retried or converted, it propagates
on the first occurrence exactly as before.

Second finding: a read issued immediately after an EEPROM write (the Lock
dance) can return a silently-wrong-but-plausible value — a genuine
offset-0/position-3387 write read back as position 0 about 0.2s later,
then correctly and stably moments after. _set_lock now settles
(_EEPROM_SETTLE_SECONDS) after closing the Lock, before returning control
to a caller that might read straight back.

Writes are never retried (a "failed" write may have landed — see #21's
Lock bug and #38's id-transfer-window bug) — pinned by call-count
assertions in the new tests.
…ed in the wrong frame

The re-zero shipped in PR #40 worked, but by luck, not by construction.

Two hardware findings, follower /dev/ttyACM1, 2026-07-12:

* The servos ship holding `Ofs = 85`, not the factory 0 the spike assumed —
  uniform across all six joints, so a vendor default. `Present = Actual − Ofs`,
  confirmed by a reversible probe.
* `elbow_flex`'s far wall, measured for the first time by a torque-off hand
  sweep with the seam evicted: travel is 2196 ticks, reported 1034..3230 at
  `Ofs = 1073`. The same sweep CLOSES the spike's §4 caveat — `Ofs=0` gave
  `discontinuities: 1`, `Ofs=1073` gave `0`. The correction IS reduced mod 4096;
  the seam relocates.

The bug: REZERO_ARCS held `elbow_flex: (126, 2020)`, and those ticks were read
off a servo already holding Ofs=85 — they are REPORTED-frame ticks, used as if
they were RAW. The offset register operates in the raw frame (the seam lands
where Actual == Ofs), so the target was off by the pre-existing offset. It
landed inside the true arc anyway, with ~866 ticks of margin — the most
dangerous way for a frame bug to behave, because every read-back looked right.

* `UnreachableArc` is RAW ticks, said loudly: a property of the joint's travel,
  independent of whatever is in the register. Arc corrected to the measured
  `(207, 2107)` — raw travel `[2107, 4095] ∪ [0, 207]`, which wraps. Midpoint
  1157.
* `plan_rezero` is frame-aware: reads the live offset, converts
  `raw = (reported + offset) mod 4096`, reasons entirely in raw. The
  `current in (0, target)` refusal is GONE — it blocked the verb outright on a
  factory-fresh servo, i.e. on every real SO-101.
* The goal is "the seam is OUT of the joint's travel", not "the offset equals
  N". `UnreachableArc.evicts()` is the new question, and an offset already
  inside the arc is a NO-OP — our arm holds 1073 and is left alone rather than
  rewritten to 1157 for cosmetic centring. `SweepReport.rezeroed` asks the same
  question, so --verify on this arm PASSES instead of reporting "inconclusive".

Every safety property holds: no motion on any path, clear_overload first,
torque-off → unlock → addr 31 → re-lock, addr 9/11 never touched, wrist_roll
still refused with its own distinct reason.

1222 tests pass (was 1202); four linters and teken --strict clean.

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

The 34 failing tests were not failing because the arc changed. They were
failing because they had copied it.

`REZERO_ARCS["elbow_flex"]` was re-derived on hardware today and given a
deliberate margin — (207, 2107) -> (318, 2007), midpoint 1157 -> 1162 —
because the first cut declared the arc AT the extremes of a single hand
sweep and FALSE-REFUSED within minutes: the joint came to rest at raw 218,
eleven ticks past an edge taken from a sweep the operator had simply
stopped short of, and `arm rezero` correctly reported that the joint
"cannot be where it says it is". A hand-found wall is not a crisp number
(206..218 depending on push force), and at least one wall may be the TABLE
rather than the joint's own mechanical stop.

So the arc is a measurement that WILL be taken again — and re-taking it
must cost ONE table edit. It cannot cost 34 broken tests every time, or
the arm's own measurements start losing arguments with the test suite.

Replaces ~125 arc-coupled literals across test_rezero.py and
test_arm_rezero_cli.py with values derived from arm_spec: the target from
`rezero_offset()` / `UnreachableArc.midpoint`, the bounds from `.low` /
`.high`, the travel from `.travel_ticks`, the walls and inset from
`_LOW_WALL_OBSERVED` / `_HIGH_WALL_OBSERVED` / `_ARC_MARGIN_TICKS`.
Positions inside/outside the arc and "what the servo will then report" are
computed (`reported_at`, `_full_sweep`), never baked in — including the
sweep LENGTHS, which must scale with the travel or a re-measured arc turns
a passing sweep into a silent INCONCLUSIVE.

Kept literal on purpose, because each is a fact rather than a derived
quantity: 85 (the factory offset every SO-101 ships with), 1073 (what our
follower actually holds — it evicts the seam, so a re-zero is a NO-OP),
126 (where elbow_flex rests), 4096 (the encoder modulus), and the 2026-07-12
sweep's own readings (1034/3230 reported at Ofs=1073 -> raw 2107/207).

No assertion was weakened, skipped or deleted, and no production code was
touched. Two assertions were ADDED where derivation exposed a claim worth
pinning: the arc may never contain a tick the joint has actually been seen
at (the false-refusal, stated as an invariant), and the raw 4095->0
roll-over becomes a ONE-TICK step (issue #35's deliverable, previously only
implied across parametrize rows). `test_OUR_ARM_at_1073_is_a_clean_NO_OP_
not_a_rewrite_to_1157` is renamed to `..._not_a_rewrite_to_the_arcs_midpoint`
— it named a number that went stale the same day, and the claim was never
about the number.

Acid test: setting _ARC_MARGIN_TICKS to 150 (and, separately, to 300, and
re-measuring BOTH walls so the midpoint moves 1162 -> 1195) leaves all 1242
tests green.

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

Refs #35. A live session on the follower with the operator present.

THE RESULT. The re-zero WORKS. The STS3215 reduces the corrected position
MODULO 4096, so a homing offset genuinely RELOCATES the encoder seam rather than
merely relabelling positions. That was the one unproven assumption the whole of
issue #35 rested on — the t5 spike said GO-WITH-CAVEAT precisely because no
document states it — and it is now settled by measurement. Same joint, same hand
sweep, before and after:

    offset 0    -> monotonic FALSE, 1 discontinuity, span 4093
    offset 1073 -> monotonic TRUE,  0 discontinuities, span 2196

It survived a power cycle, read back cold. PR #21's ghost is laid.

THE MECHANISM, not just the symptom. Every SO-101 ships with Ofs = +85, NOT the
factory 0 the spike assumed (uniform across six servos => a vendor default). And
that is exactly WHY elbow_flex wraps: the seam sits where Actual == Ofs, i.e. at
raw 85, which is BELOW the unreachable arc and therefore INSIDE the joint's
travel. We did not merely fix the bug; we now know why it happened.

THE FRAME BUG. REZERO_ARCS held REPORTED ticks — read off a servo already
carrying that factory offset — and used them as RAW. The old target landed inside
the true arc anyway with ~866 ticks of margin, so it worked, and every read-back
looked correct. That is the most dangerous way for a frame bug to behave. The arc
is now RAW by construction, and raw = (reported + offset) mod 4096 on every path.

The `current in (0, target)` guard is gone. It refused outright on a servo holding
the factory 85 — i.e. on EVERY FRESH SO-101. The goal is now stated as a PLACE
(the seam is out of the travel), not a magic number: an arm already holding a
seam-evicting offset is a clean NO-OP, not a rewrite.

THE ARM MEASURED ITS OWN WALLS. Once the axis was linear, gentle_move could be
driven past the known travel and left to find each wall by feel — creep, watch the
load, stop on contact. Both contacts saturated at the 500 torque cap. It
out-measured the human on BOTH sides (raw 251 vs 218; 2061 vs 2107), because it
presses to a fixed load every time instead of to whatever felt firm. The arc is
INSET from those walls, so a harder push can never make the table contradict the
arm — which is exactly what a tight arc did, within minutes.

And the second wall-find crossed the raw 4095->0 boundary as a plain linear climb.
That command would have rotated the elbow the LONG WAY ROUND into a wall
yesterday. It is the whole day's work in one move.

TESTS NO LONGER COPY THE TABLE. ~125 arc-coupled literals were hard-coded across
two files, so re-measuring a table that EXISTS to be re-measured broke 34 tests.
They now derive from arm_spec; changing a wall or the margin leaves the suite
green. Same rule for prose: the docstrings and the explain catalog no longer quote
ticks at all — and explain is what prints to whoever is standing at the arm.

TWO BUS BUGS, both hit live on a healthy bus:
  - a dropped packet raised a bare IndexError from inside the vendor SDK instead
    of a CliError, leaking a raw traceback. Reads now convert and RETRY; writes
    are NEVER retried, because a failed write may in fact have landed — that
    ambiguity is exactly how #21 and #38 arose. OverloadError is never retried: it
    is a latched state, not a dropped packet.
  - a read immediately after an EEPROM write returned 0 while the servo genuinely
    held 3387. A plausible-looking wrong value is far more dangerous than an
    error. EEPROM writes now settle.

1242 tests pass (was 1202).

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

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

qodo-code-review Bot commented Jul 12, 2026

Copy link
Copy Markdown

Code Review by Qodo

Grey Divider

New Review Started

This review has been superseded by a new analysis

Grey Divider

Qodo Logo

…nts (S5864, S125)

S5864 — _sdk_read took 'fn: object', which Sonar correctly flags as not callable
(and which needed a type: ignore[operator] to call). Typed as
Callable[..., tuple[int, int, int]]; the ignore is gone.

S125 x2 — Sonar read '# 251 + 100 = 351' as commented-out code. It is not, but it
should go anyway, and the reason is the point of this whole PR: those comments NAME
TICKS. They restate constants that exist to be re-measured, and they would go stale
the next time the arm finds its own walls — which is exactly the failure mode this
PR removes from the tests and the docstrings. Deleting them is consistent, not a
concession.

1242 tests still pass.

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

Copy link
Copy Markdown

PR Summary by Qodo

Fix rezero tick-frame handling and harden FeetechBus read robustness

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

Grey Divider

AI Description

• Compute rezero arcs in RAW ticks and convert every live read from REPORTED ticks.
• Treat “already re-zeroed” as seam-evicted (no-op), including factory Ofs=85 servos.
• Make FeetechBus reads retry/settle safely; expand tests and hardware-run documentation.
Diagram

graph TD
  cli(["arm rezero CLI"]) --> rezero["rezero.py"] --> bus["bus.py"] --> sdk{{"scservo_sdk"}} --> servo[("STS3215 servo")]
  rezero --> spec["arm_spec.py"]
  subgraph Legend
    direction LR
    _cli(["CLI"]) ~~~ _mod["Module"] ~~~ _ext{{"External"}} ~~~ _hw[("Hardware")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Introduce explicit tick-frame types (RawTick vs ReportedTick)
  • ➕ Prevents future RAW/REPORTED mixups at type-check time
  • ➕ Makes conversion boundaries explicit across the codebase
  • ➖ More invasive refactor across many call sites and tests
  • ➖ Adds conceptual overhead for a small project if not consistently applied
2. Vendor-SDK shim module (single choke point) instead of per-method wrappers
  • ➕ Centralizes retry/exception translation in one place
  • ➕ Easier to test/extend for additional SDK quirks
  • ➖ Requires reorganizing current FeetechBus structure
  • ➖ Still needs careful “writes never retry” policy enforcement
3. Upstream patch to scservo_sdk (handle short buffers safely)
  • ➕ Fixes the root cause of IndexError for all downstream users
  • ➕ Could reduce need for defensive wrappers long-term
  • ➖ Release/packaging latency; hardware users still need a local workaround now
  • ➖ Doesn’t address EEPROM post-write ‘plausible wrong read’ settle requirement

Recommendation: Keep the PR’s approach (RAW arcs by construction + explicit REPORTED→RAW conversion + no-op-by-eviction) because it directly addresses the proven failure mode while minimizing behavior changes outside rezero. Consider the tick-frame type wrappers as a follow-up hardening step once the functional fix is merged, especially if more calibration logic is added.

Files changed (15) +2507 / -457

Bug fix (4) +858 / -224
arm.pyImprove rezero CLI plan/write/no-op reporting with frame + seam context +43/-12

Improve rezero CLI plan/write/no-op reporting with frame + seam context

• Extends rezero plan output to state that arcs are RAW ticks, include conversion guidance, and reflect factory offset 85. Updates no-op behavior to mean “seam already evicted” (and reports the fresh-midpoint target separately) rather than “offset equals target”.

arm101/cli/_commands/arm.py

arm_spec.pyCorrect REZERO_ARCS to RAW-frame measurements; add seam/eviction helpers +300/-53

Correct REZERO_ARCS to RAW-frame measurements; add seam/eviction helpers

• Re-derives 'REZERO_ARCS' from hardware-measured RAW walls (with an explicit margin) and documents the factory offset (85). Adds 'seam_tick()' and 'UnreachableArc.evicts()'/'.offset' to express the real goal (seam outside travel) and to prevent RAW↔SIGNED↔RAW mismatches.

arm101/hardware/arm_spec.py

bus.pyMake FeetechBus reads robust: retry, SDK exception conversion, EEPROM settle +228/-36

Make FeetechBus reads robust: retry, SDK exception conversion, EEPROM settle

• Introduces '_sdk_read()' to wrap vendor SDK exceptions (e.g., IndexError on short packets) into 'CliError', and '_retry_read()' to retry idempotent reads with bounded attempts. Adds a post-EEPROM re-lock settle delay to avoid plausible-but-wrong immediate reads; preserves the rule that writes are never retried and overload is never retried.

arm101/hardware/bus.py

rezero.pyFix rezero planning for RAW/REPORTED frames and seam-eviction semantics +287/-123

Fix rezero planning for RAW/REPORTED frames and seam-eviction semantics

• Makes 'raw_from_reported()' the mandatory bridge for comparing live readings against RAW arcs, eliminating the prior frame confusion. Removes the strict offset guard and instead converts from whatever offset the servo reports; treats already-evicted seams as a true no-op (no EEPROM write). Updates sweep reporting to judge re-zeroed-ness by seam location, not exact offset equality.

arm101/hardware/rezero.py

Tests (4) +1285 / -165
test_arm_rezero_cli.pyAlign rezero CLI tests to derived arc data and new no-op semantics +200/-33

Align rezero CLI tests to derived arc data and new no-op semantics

• Refactors tests to import derived constants from 'tests.test_rezero' (which in turn derives from 'arm_spec'). Adds coverage for factory offset 85 planning/apply, no-op behavior for any seam-evicting offset (including the arm’s 1073), and frame-aware expectations for reported vs raw positions.

tests/test_arm_rezero_cli.py

test_bus_offset.pyClarify offset test scenario vs fresh midpoint target +9/-3

Clarify offset test scenario vs fresh midpoint target

• Updates commentary and expectations to treat 1073 as a valid seam-evicting offset (not necessarily the current midpoint), aligning with the new ‘goal is seam eviction’ semantics.

tests/test_bus_offset.py

test_bus_read_robustness.pyAdd comprehensive tests for SDK exception conversion, read retry, and EEPROM settle +424/-0

Add comprehensive tests for SDK exception conversion, read retry, and EEPROM settle

• Introduces a new packet-handler stub to script per-call read outcomes and failing writes. Tests that SDK-raised exceptions become 'CliError', reads retry boundedly and never retry 'OverloadError', writes are never retried (pinned by call counts), and EEPROM re-lock settling occurs only when appropriate.

tests/test_bus_read_robustness.py

test_rezero.pyDerive all rezero expectations from arm_spec; pin frame conversion and eviction logic +652/-129

Derive all rezero expectations from arm_spec; pin frame conversion and eviction logic

• Removes arc-copied literals by deriving expected values from 'arm_spec.REZERO_ARCS' and adds explicit tests for RAW/REPORTED conversion, factory offset 85 behavior, seam-tick modulo behavior, and no-op-by-eviction semantics. Updates sweep/planning tests to reflect the corrected frame and arc margin policy.

tests/test_rezero.py

Documentation (6) +363 / -67
arm101-cli__public.jsonlRecord hardware-verified rezero findings as eidetic notes +2/-0

Record hardware-verified rezero findings as eidetic notes

• Adds durable notes capturing the 2026-07-12 hardware session results: offset semantics, factory default offset, proof that seam relocation works, and related operational cautions.

.eidetic/memory/arm101-cli__public.jsonl

CHANGELOG.mdAdd 0.22.0 release notes for hardware-proven rezero + bus robustness +19/-0

Add 0.22.0 release notes for hardware-proven rezero + bus robustness

• Introduces a 0.22.0 entry documenting the hardware-verified seam relocation, the RAW-frame arc correction, test de-literalization, and bus read retry/EEPROM settle fixes.

CHANGELOG.md

catalog.pyUpdate explain text: RAW vs REPORTED frames and seam-eviction no-op goal +61/-19

Update explain text: RAW vs REPORTED frames and seam-eviction no-op goal

• Expands 'explain arm rezero' to clearly distinguish RAW vs REPORTED ticks, note factory Ofs=85, and describe ‘re-zeroed’ as seam eviction rather than a specific register value. Removes/avoids duplicating measured tick literals in prose to reduce staleness.

arm101/explain/catalog.py

hardware-rezero-procedure.mdRewrite procedure around RAW ticks, factory offset 85, and no-op-by-eviction +106/-47

Rewrite procedure around RAW ticks, factory offset 85, and no-op-by-eviction

• Updates operator steps with the corrected RAW arc framing, factory default Ofs=85, new midpoint value, and explicit 'raw = (reported + offset) mod 4096' guidance. Adjusts expected outputs and clarifies that a seam-evicting existing offset should result in a clean no-op.

docs/hardware-rezero-procedure.md

hardware-rezero-run-2026-07-12.mdAdd full 2026-07-12 hardware run-log +157/-0

Add full 2026-07-12 hardware run-log

• Adds a detailed run-log documenting the measured proof that the STS3215 applies offsets modulo 4096, discovery of factory Ofs=85, persistence across power cycle, and the arm-measured wall findings used to set margins.

docs/hardware-rezero-run-2026-07-12.md

sts3215-offset-register.mdMark t5 spike as hardware-resolved; summarize corrected facts +18/-1

Mark t5 spike as hardware-resolved; summarize corrected facts

• Adds a prominent banner summarizing what the hardware session confirmed and corrected (factory offset, RAW arc numbers, modulo behavior), while keeping the original spike text as historical record.

docs/spikes/sts3215-offset-register.md

Other (1) +1 / -1
pyproject.tomlBump project version to 0.22.0 +1/-1

Bump project version to 0.22.0

• Updates the package version to reflect the new release described in the changelog.

pyproject.toml

@qodo-code-review

qodo-code-review Bot commented Jul 12, 2026

Copy link
Copy Markdown

Code Review by Qodo

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

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

Grey Divider


Remediation recommended

1. Stale ticks in explain ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
arm101 explain says the live tick measurements are only in arm_spec.REZERO_ARCS and that the
text “does not repeat them”, but it still hard-codes specific raw tick ranges and travel ticks. This
makes the operator-facing explanation brittle and likely to become incorrect after future
re-measurements or margin updates.
Code

arm101/explain/catalog.py[R888-894]

+`arm_spec.REZERO_ARCS` is **RAW ticks**. The live numbers are in that table and
+nowhere else — they are re-measured on hardware, so this text does not repeat
+them (a doc that names a tick is a doc that goes stale). The arc comes from
+a torque-off hand sweep that measured its travel at 2196 ticks — raw
+`[2107, 4095] ∪ [0, 207]`, which *wraps*, which is exactly the fact a `[min, max]`
+pair cannot express. Every live reading is converted (`raw = (reported + offset)
+mod 4096`) before it is compared against it.
Relevance

⭐⭐⭐ High

PR40 stated explain/prose must not quote ticks; this finding matches that enforced direction.

PR-#40

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The catalog entry explicitly claims it will not repeat live tick numbers, then immediately embeds
concrete tick measurements; meanwhile the authoritative values are defined in arm_spec.REZERO_ARCS
and are explicitly described as hardware-measured and subject to change, so duplicating them in
prose will drift.

arm101/explain/catalog.py[875-894]
arm101/hardware/arm_spec.py[933-979]

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

## Issue description
`arm101/explain/catalog.py` embeds specific tick measurements (raw ranges and travel ticks) while also claiming the numbers live only in `arm_spec.REZERO_ARCS` and are intentionally not repeated. This makes the output stale-prone when `REZERO_ARCS` (or its margins) changes.

## Issue Context
The PR explicitly emphasizes deriving expectations from `arm_spec` and avoiding tick literals in prose; the current explain entry still contains literals.

## Fix Focus Areas
- arm101/explain/catalog.py[888-894]

## Implementation notes
- Remove the literal tick set and tick count from the explanation, or rephrase to refer the operator to `arm_spec.REZERO_ARCS` / the CLI’s computed plan output.
- If you want to keep numbers in `explain`, generate them dynamically from `arm_spec` at render time (so the explain output cannot drift from the table).

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



Informational

2. Eidetic notes duplicate repo docs 📘 Rule violation ⚙ Maintainability
Description
Two new public eidetic memory entries largely restate information already captured in repository
documentation and the changelog, making the memory store redundant and harder to maintain. This
violates the rule to avoid storing repository-derivable information in eidetic memory.
Code

.eidetic/memory/arm101-cli__public.jsonl[R16-17]

+{"id": "arm-explore-rebuild-state-2026-07-12", "hash": "fd81c2c306a1f6a022a691980850c13e8f68535bcafcbaf93c7db3f0c6c2b7b2", "content": "STATE OF THE arm explore REBUILD (2026-07-12, main @ v0.21.0, 1202 tests). Three PRs merged today off the converged spec docs/specs/2026-07-12-arm-explore-now-maps-the-arm-s-real-joint-space-it.md and its 21-task plan:\n\n- PR #38 (v0.19.x) \u2014 CLOSES #33. torque_guard: motion verbs release the arm on any ABNORMAL exit (exception/bus fault/SIGINT). HOLD ON SUCCESS, RELEASE ON ABNORMAL (clean exit = ZERO release writes, so gentle_move stop-and-hold survives).\n- PR #39 (v0.20.x) \u2014 `arm profile <joint>`: ramps Goal_Speed and finds the highest speed at which CONTACT DETECTION STILL WORKS (not merely the highest the servo survives). Structurally refuses to certify a speed on free motion alone.\n- PR #40 (v0.21.0) \u2014 encoder linearity (#35 code, NOT yet hardware-verified): `arm rezero` + --verify, offset primitives (addr 31), wrist_roll soft limit + enforcement, and the t5 spike doc.\n\nSTILL OPEN AND WHY:\n- #35 stays OPEN deliberately \u2014 the code shipped but the SEAM-EVICTION PROOF has not run on hardware. docs/hardware-rezero-procedure.md is the procedure; step 6 (torque-off hand-sweep of elbow_flex through its full travel, asserting monotonic position) is what settles whether the servo actually MOVES the seam. If it reports `seam-not-evicted`, the re-zero achieves nothing and wave 2a returns to the user for a re-decision (the ARCS fallback was explicitly NOT chosen).\n- #34 (grid) \u2014 t16 (per-joint bucket sizes from span + target bucket COUNT) is BUILT on branch feat/explore-grid, NOT yet PR`d. t17 (bounds from a supplied map, EEPROM only as fallback) and t18 (honest probe-cost budget + pre-flight wall-clock estimate) are NOT built and are BLOCKED on t15 \u2014 the hardware run of `arm profile` that yields real per-joint speeds.\n- #37 \u2014 does the STS3215 have a comms-loss torque watchdog? Filed because a physical USB yank CANNOT be recovered in software (12V powers the servos; USB is only data).\n\nHUMAN-GATED HARDWARE STILL OWED: t4 (SIGINT + port-contention safety acceptance), t6 (re-confirm elbow_flex wrap BEFORE re-zeroing destroys the evidence), t5-hardware/t11 (the rezero power-cycle + seam sweep), t12/t15 (baseline timing + the arm profile run).", "scope": {"name": "arm101-cli", "visibility": "public"}, "metadata": {"type": "note", "record_metadata": {"source": "session", "area": "arm101", "version": "0.21.0", "issues": "33,34,35,37"}, "created": "2026-07-12T10:51:26.916283+00:00", "last_recall": null, "recall_count": 0, "links": ["wrist-roll-cannot-be-re-zeroed", "sts3215-offset-register-facts", "seam-verification-false-pass-traps", "torque-release-must-use-clear-overload", "usb-yank-cannot-be-fixed-in-software"], "supersedes": null, "lifecycle": "active", "added_by": null}}
+{"id": "rezero-proven-on-hardware-2026-07-12", "hash": "44eb11b90e77d60071dd52a29fc154808c74a7f85aac3bf462ae0be28e1a2703", "content": "SETTLED ON HARDWARE (2026-07-12, follower /dev/ttyACM1, human present): THE ENCODER RE-ZERO WORKS, and the spike`s open caveat is CLOSED BY MEASUREMENT.\n\nTHE BIG RESULT \u2014 the STS3215 DOES reduce the corrected position MODULO 4096, so the offset genuinely RELOCATES the seam (it does not merely relabel positions). Proven by a torque-off hand sweep of elbow_flex, before/after, same joint same motion:\n  Ofs=0    -> monotonic FALSE, 1 discontinuity, span 4093 (the wrap, captured)\n  Ofs=1073 -> monotonic TRUE,  0 discontinuities, span 2196, largest jump 28 ticks\nIssue #35 is FIXED for elbow_flex on the physical arm. The pessimistic reading (plain signed subtraction, seam stays pinned) is REFUTED.\n\nFACTORY OFFSET IS 85, NOT 0. All six servos ship with Ofs=+85 at addr 31 (uniform across six joints => a vendor default, not a calibration). The t5 spike ASSUMED factory 0. CONSEQUENCE: every tick ever recorded on this arm was measured in the 85-shifted frame. This is WHY elbow_flex wraps \u2014 the reported seam sits where Actual==Ofs, i.e. at raw 85, which is BELOW the unreachable arc`s lower edge (raw 211) and therefore INSIDE the joint`s travel.\n\nSEMANTICS CONFIRMED TWICE, reversibly: writing Ofs 85->185 dropped reported position by EXACTLY 100; zeroing 85->0 raised it by EXACTLY 85. So Present_Position = Actual - Ofs.\n\nELBOW_FLEX`S FAR WALL, MEASURED FOR THE FIRST TIME (nothing could see across the seam before): travel spans 2196 ticks, reading 1034..3230 in the corrected frame at Ofs=1073. Converting to RAW (raw = corrected + 1073 mod 4096): raw travel = [2107,4095] U [0,207], so the TRUE RAW UNREACHABLE ARC = (207, 2107), width 1900, midpoint 1157. The shipped table said (126,2020)/midpoint 1073 \u2014 those were REPORTED-frame ticks in the 85 frame. 1073 lands inside (207,2107) with 866/1034 margin, so the shipped code WORKED BY LUCK, not by construction.\n\nALSO VALIDATED LIVE: PR #38`s torque_guard fired on real hardware \u2014 a failing `arm rezero` released motor 3 on its way out (`Torque released on motors 3 after an abnormal exit`). And t8`s false-pass guards rejected THREE sweeps as INCONCLUSIVE (0, 0 and 376 ticks of coverage) before the real one \u2014 a naive `no discontinuity seen => PASS` would have declared victory on an empty sweep with the arm untouched.\n\nSTILL UNPROVEN: PERSISTENCE. The offset reads back as 1073 but has NOT survived a power cycle yet \u2014 PR #21 exists because exactly this read back fine and reverted on power-up. Cut and restore the 12V BUS POWER (not USB) and re-read addr 31.\n\nGOTCHA: a read issued immediately after an EEPROM write can return GARBAGE \u2014 a read_position ~0.2s after write_offset returned 0 while the servo genuinely held 3387 (re-reads were stable and correct). A plausible-looking wrong value is worse than an error.", "scope": {"name": "arm101-cli", "visibility": "public"}, "metadata": {"type": "note", "record_metadata": {"source": "hardware-run", "area": "arm101/hardware/rezero.py", "issue": "35", "port": "/dev/ttyACM1", "version": "0.21.0", "status": "seam-evicted-persistence-pending"}, "created": "2026-07-12T12:01:22.414446+00:00", "last_recall": null, "recall_count": 0, "links": ["sts3215-offset-register-facts", "seam-verification-false-pass-traps", "wrist-roll-cannot-be-re-zeroed", "encoder-wrap-two-joints"], "supersedes": null, "lifecycle": "active", "added_by": null}}
Relevance

⭐ Low

Team keeps detailed eidetic notes; similar “eidetic duplicates CHANGELOG/docs” feedback in PR21
wasn’t acted on.

PR-#21
PR-#36
PR-#40

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The rule prohibits adding eidetic memory entries that merely restate information obtainable from the
repo. The newly-added eidetic entries recap the hardware run outcome and project state, which are
already documented in-repo in the hardware run-log and changelog sections for the same date/version.

Rule 1497307: Avoid storing repository-derivable information in the eidetic memory store
.eidetic/memory/arm101-cli__public.jsonl[16-17]
docs/hardware-rezero-run-2026-07-12.md[1-55]
CHANGELOG.md[71-120]

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

## Issue description
New entries in `.eidetic/memory/arm101-cli__public.jsonl` duplicate information already present in repo docs/CHANGELOG, violating the requirement to avoid storing repository-derivable information in the eidetic memory store.

## Issue Context
The added entries `arm-explore-rebuild-state-2026-07-12` and `rezero-proven-on-hardware-2026-07-12` summarize release/run-log details that already exist in `CHANGELOG.md` and `docs/hardware-rezero-run-2026-07-12.md`. The eidetic store should capture only non-derivable rationale/insights, or just link to the canonical repo documents.

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

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


Grey Divider

Qodo Logo

Comment thread arm101/explain/catalog.py Outdated
…hot of it

qodo caught the sharpest possible version of this PR's own bug: the explain text
CLAIMED 'this text does not repeat them (a doc that names a tick is a doc that goes
stale)' — and then went on to name ticks. Raw ranges and a travel count, already
stale by the time they were written, because the arm re-measured its own walls an
hour later.

A doc that lies about its own staleness is worse than one that is merely stale.

Deleting the numbers was the easy fix; qodo suggested the better one, and it is the
right one — RENDER them. _ARM_REZERO is now an f-string evaluated at import against
arm_spec.REZERO_ARCS, so the operator standing at the arm gets numbers that are
current BY CONSTRUCTION and physically cannot drift from the table. Change the
margin or re-measure a wall, and # arm101-cli arm rezero <joint>

Shift a joint's **encoder zero** — the servo's `Ofs` / `Homing_Offset` register
(EEPROM addr 31) — so that the encoder's 4095->0 **seam** falls inside the arc
the joint physically cannot reach. **Commands no motion, on any path.**

## The bug this fixes (issue #35)

`elbow_flex`'s 12-bit encoder wraps *inside its own physical travel*. Driven far
enough it crosses the raw 4095->0 seam and reads back near zero, so its reported
position is **not monotonic with joint angle**: its two measured endpoints sort
into a `[min, max]` pair describing exactly the arc it CANNOT reach, and every
position comparison in this codebase — `gentle_move`'s arrival check,
`clamp_goal`, the reachability map's ranges — is silently wrong for it. It
currently rests at raw ~126, i.e. *past* its wrap.

Move the seam into the joint's unreachable arc and every tick it can actually
reach lies on one side of it. The tick axis is linear again — genuinely, not by
assumption.

## Why it commands no motion — the bootstrap problem

The tool that MAKES the axis linear cannot itself rely on the axis being linear.
"Drive the joint to mid-travel, then centre it" is the natural procedure and it
is exactly the one that must not run: from its rest position at raw ~126, a
linear goal of 3121 (its mid-travel) looks like a modest move and is in fact a
rotation *the long way round* — down through 0, across the whole 1894-tick arc
the joint cannot reach, and into a wall. So this verb reads where the joint
physically **is**, computes the offset from the joint's known unreachable arc (a
measured table fact, in `arm_spec.REZERO_ARCS`), and writes it. No goal position
is ever written.

Torque is disabled before the EEPROM write and left off: a servo must not be
*holding* while its own frame of reference changes underneath it.

## Which joints

Only `elbow_flex`. Every other joint is refused **with the reason** — and there
are two different reasons, which the verb keeps apart:

- **`wrist_roll` — impossible.** A re-zero only *relocates* a seam; it can never
  *evict* one. Eviction needs an arc the joint cannot reach, and exploration
  found no wall anywhere in `wrist_roll`'s travel (measured free range
  `[21, 4073]`) — it turns freely all the way round, so every angle is reachable,
  including whichever one the seam is moved to. It is handled instead by a
  software **soft limit** (`arm_spec.SOFT_LIMITS`), already in force.
- **The other four — unnecessary.** Their encoders do not wrap inside their
  travel, so there is no seam in the way and nothing to evict.

## `--verify` — the seam-eviction proof

**Reading the offset back only proves it was APPLIED. It does not prove the seam
MOVED.** One undocumented bit of firmware semantics decides which:

    Present = (raw - Ofs) mod 4096     seam RELOCATES  -> the fix works
    Present =  raw - Ofs   (signed)    seam STAYS      -> the fix does NOTHING

Every source (and LeRobot's shipped SO-101 calibration) implies the first; no
primary Feetech source states it. `--verify` settles it: torque goes **off** and
stays off, a **human hand-moves** the joint through its entire travel, and the
verb polls `present_position` and asserts there is **no discontinuity anywhere**.
A human arm is the right instrument precisely because it is the only actuator
available that does not need a linear tick axis to work.

It reports the range reached, whether the sweep was monotonic, and the largest
single-sample jump — a seam crossing is ~1949-4095 ticks; sensor noise and a
human hand are tens. It also measures `elbow_flex`'s **far wall** for the first
time (nothing could see across the seam before).

Four verdicts, because "did not fail" is not the same claim as "proved it works":

- `seam-evicted` — re-zeroed, continuous, and the sweep actually covered the
  travel. The fix works.
- `seam-not-evicted` — re-zeroed and **still** discontinuous. **STOP.** The
  re-zero achieves nothing; exit code 2, and the decision goes back to the user.
- `seam-present-baseline` — not re-zeroed, discontinuous. The bug, photographed.
  Expected before the write; not a failure.
- `inconclusive` — continuous, but either no offset was in force or the joint was
  not moved through enough of its travel for "no seam" to mean anything.

`--verify` deliberately ends with the joint **limp** — the operator's hand is on
it. If the arm is holding a pose it will sag: support it.

## Consent modes

Same three-mode gate as `arm flex` / `arm explore` (1-step tier):

1. **TTY (interactive)** — confirm at a prompt.
2. **Non-TTY without `--apply`** — dry-run: prints the exact register writes and
   opens **no bus at all**.
3. **Non-TTY with `--apply`** — executes.

## Usage

    arm101-cli arm rezero elbow_flex                   # dry-run: the exact writes
    arm101-cli arm rezero elbow_flex --apply           # write the offset
    arm101-cli arm rezero elbow_flex --verify --apply  # prove the seam moved
    arm101-cli arm rezero elbow_flex --verify --duration 45 --apply
    arm101-cli arm rezero wrist_roll                   # refused, with the reason
    arm101-cli arm rezero elbow_flex --json

## After the write — what is NOT yet proven

The read-back proves the offset was **applied**, not that it **persists**: PR #21
exists because id/baud EEPROM writes read back correctly and silently reverted on
the next power-cycle. **Power-cycle the servo** (cut and restore bus power, not
just the serial link), re-read with `arm read`, then run `--verify`. The full
hand-run procedure is in `docs/hardware-rezero-procedure.md`.

## Exit codes

- `0` success, clean abort, a non-TTY dry-run plan, or an informative sweep
  (baseline / inconclusive).
- `1` user/usage error (an unknown joint, a joint that cannot be re-zeroed, a
  `--duration` too short to collect two samples).
- `2` environment error (no port, SDK absent, comms failure), the offset failing
  to read back, the servo holding an unrecognised offset, the joint reporting a
  raw position inside its own unreachable arc — **and the `seam-not-evicted`
  verdict**, which is a stop condition, not a retryable error.

## Hardware / TTY behavior

Requires a real motor bus and the Feetech SDK (the `[seeed]` extra). The result
and the sweep report go to stdout; the prompt, the live sample feed, and every
warning go to stderr. changes with it.

Acid test: margin 100 -> explain says (351, 1961); margin 300 -> explain says
(551, 1761). It tracks.

Also removed the last stale literals: the ~126 rest position (the operator has since
moved the arm), the 1900-tick arc width, and the 2196-tick sweep length (the arm's
own wall-find measured 2286).

1242 tests pass.

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

Copy link
Copy Markdown
Contributor Author

FIX — and this is the sharpest possible catch, because it is this PR's own thesis, violated in this PR's own text.

The explain entry literally says:

"the live numbers are in that table and nowhere else … so this text does not repeat them (a doc that names a tick is a doc that goes stale)"

…and then repeats them. Raw ranges and a travel count. A doc that lies about its own staleness is worse than one that is merely stale, and these were already wrong: an hour after I wrote them the arm re-measured its own walls (gentle_move driven past the known travel until present_load saturated) and moved both of them.

Deleting the numbers was the easy fix. Your second suggestion is the better one and I took it: render them. _ARM_REZERO is now an f-string evaluated at import against arm_spec.REZERO_ARCS, so arm101 explain arm rezero — the text that prints to whoever is standing at the arm — is current by construction and physically cannot drift from the table.

Acid test, run both ways:

_ARC_MARGIN_TICKS = 100  ->  explain: unreachable arc is currently `(351, 1961)`
_ARC_MARGIN_TICKS = 300  ->  explain: unreachable arc is currently `(551, 1761)`

It tracks. Same guarantee the tests now have (they derive from arm_spec rather than copying it, so re-measuring a table that exists to be re-measured no longer breaks 34 of them).

Also swept out the last three literals you didn't flag but which had the same rot: the ~126 rest position (the operator has since moved the arm), the 1900-tick arc width, and the 2196-tick sweep length (the arm's own wall-find measured 2286).

Shipped in b2eed82. No circular-import risk — arm_spec is a pure stdlib data module.

  • arm101-cli (Claude)

@sonarqubecloud

Copy link
Copy Markdown

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.

1 participant