~/blogmicroduck-safety-stops-at-the-servo.md
cchu@nycu:~/blog$ cat microduck-safety-stops-at-the-servo.md
2026.08.284 min[robotics][reinforcement-learning][rust][mujoco]

Microduck's Safety Layer Stops at the Servo

Twelve passing Rust tests and a MuJoCo model expose the difference between rejecting a bad tensor and respecting a robot's joint geometry.

A target of one radian passes Microduck's generic actuator clamp. For the left hip yaw joint, the checked kinematic model allows only about half that angle in the positive direction.

That gap explains more about deploying a learned controller than a walking video does. A network can produce finite, correctly shaped output and still ask for a mechanically inappropriate pose. The runtime has to decide which of those properties it actually enforces.

Retrospective for August 28. Code and model checks below were run on September 12, 2026, against commit 590b986bd8c0d50ae02cb3ea2f59c463b6828168, available on August 27.

Start at the Motor Write

Microduck's Rust control stack puts the RobotIo handle inside Safety<T>. The controller produces targets; the safety object performs the write. Its private field prevents ordinary callers from obtaining that object's motor interface. This makes the final output checks a concrete part of the call path. The relevant implementation is small enough to inspect directly in safety.rs.

apply first selects the requested gain. If any target is non-finite, it reports NotFinite and writes the supplied hold pose. Otherwise, it clamps every target to the same interval, from negative pi to positive pi, and reports Range when a value changes. A rejected tensor therefore falls back to a caller-provided pose; this function does not independently prove that the hold pose is anatomically valid.

The uniform interval represents actuator travel. It contains no lookup for a particular hip, ankle or neck joint.

The actuator interval spans minus 180 to 180 degrees; left hip yaw spans minus 25 to 30 degrees, while the test target is 57.3 degrees.

Intervals read from the pinned Rust implementation and MJCF. The marked target is a numerical probe, never a command sent to hardware.

What Passed on a Laptop

I built and ran the control crate's safety tests with Rust 1.97.1 on Windows:

cargo +1.97 test --locked -p duck-control --lib safety::
12 passed; 0 failed; 48 filtered out

The tests exercise the real safety implementation with test I/O. They cover non-finite rejection, range clamping, gain changes, stale commands and fall reporting. They do not measure servo behavior or validate a walking policy.

CaseBehavior checked by the suite
Ordinary finite targetPassed through unchanged
Target beyond actuator travelClamped and reported
Non-finite targetHold pose written; rejection reported
Expired commandVelocity command zeroed
Sustained tiltFall state reported
Repeated identical gainRedundant gain write avoided

The fall distinction matters. At this revision, the safety layer records a fall without automatically preempting the caller's targets. The daemon implements its limp-fall response above this layer, using a fall predictor and issuing ordinary gain and pose requests through the same write path. Reading only a method named fallen would miss that division of responsibility. See the daemon's state handling.

Fourteen Actions, Fifteen Servos

The policy interface is another place where a plausible assumption fails. The physical model table contains 15 joints, but the policy emits 14 actions. The mouth has its own control path. The observation packs 61 values, including 14 relative joint positions, 14 velocities and 14 previous actions; the same omitted joint must stay omitted in each block. The observation builder defines that mapping explicitly.

I initially expected the walking MJCF to contain 15 hinge joints. Loading it with MuJoCo 3.3.7 returned 14, with no mouth joint. The result agrees with the walking policy's action width. Its free base brings the model to 21 position coordinates and 20 velocity coordinates.

This particular XML contains zero geoms and zero actuators. It can support a forward kinematic check, which I ran, but it is not a complete contact-and-actuation simulation for a walking benchmark. Loading an XML successfully should not be reported as reproducing locomotion.

The reproduction script then compares a synthetic 1.0 radian target with both intervals:

left_hip_yaw MJCF range: -25 to +30 degrees
test target:           +57.3 degrees
generic actuator clamp: inside
joint-specific range:   outside

The checked XML supplies the narrow range; the Rust constants supply the broad one. This comparison establishes a difference between two representations, not a measured collision or injury threshold.

Before adapting this controller to another body, I would make the final write layer consume a reviewed per-joint limit table and test its ordering against the policy mapping. That still leaves balance, contact and thermal behavior to validate. It does close a specific gap: a finite tensor would no longer receive a pass solely because every number fits inside one servo revolution.