* proto: add the four robot.* intent methods
move and head are continuous, sent as notifications: no id, no reply,
last-writer-wins. stop and enable are discrete requests, answered,
because a caller needs to know whether it was refused and why. That
split is not a stylistic choice — it is the same one WebRTC needs
later, where continuous input belongs on the unreliable channel and a
retransmitted 80ms-old stick position is worse than useless. The
JSON-RPC message family already carries the distinction, so nothing has
to remember a routing rule.
Units and frame are stated once, in the type docs: radians, trunk
frame, right-handed, x forward, y left, z up. The prototype grew five
sign flags because that convention lived nowhere.
`IntentResult` carries accepted plus a reason rather than using a
JSON-RPC error, because refusal is a normal outcome — safety declining
to enable a policy on a fallen robot is not "something broke".
every_call() is a hand-written list, so a new variant would silently
never be round-tripped. Added a count assertion so adding a Call
without extending the list fails loudly.
Assisted-by: Claude:claude-opus-5
* duck-control: the 61-D observation builder
One layout, not the prototype's five. Every alpha policy is
obs[1,61] -> actions[1,14] — verified by reading the ONNX graphs for
walking, standing, ground pick, ball kick and sit — so the 51/54-D
legacy, 49-D wheeled and 85-D tracking layouts leave with the variants.
This is the highest-risk code in the crate. A wrong offset does not
fail loudly; it produces a plausible robot that falls over, and the
symptom reads as a tuning or timing problem rather than an indexing
one. So the layout is a table in the module docs and eight tests pin
the boundaries with distinguishable values.
Three details in the command block were confirmed against the
prototype's control_step rather than assumed, because each is
individually plausible and wrong:
* Body x, y and yaw are hardcoded zero — unbound in training. An
all-zero body command is the nominal encoding, not a placeholder.
* Head targets ride in the command and are NOT added on top of the
policy output. The prototype does both in different modes and
gates the post-hoc addition behind `if !new_cmd_obs`. Doing both
bends the head twice.
* The body block is ordered z, roll, pitch — not z, pitch, roll.
Swapping the last two tilts the robot sideways when asked to lean
forward.
`scatter_action` maps 14 policy outputs back over 15 joints, skipping
the mouth. Getting that wrong shifts every joint after index 9 by one,
silently, which is why it has its own test with distinct values rather
than a nonzero check.
Assisted-by: Claude:claude-opus-5
* duck-control: the ONNX policy runner
Two sessions, walking and standing, selected on velocity magnitude at
the prototype's 0.05 threshold. No skill abstraction — that seam can
arrive with the third skill, and building it now would be a shape
nothing has tested.
Everything is validated at load rather than at inference: observation
width, action count, and — because load-dynamic dlopens ONNX Runtime
lazily — whether the runtime is present and usable at all. A warm-up
inference does double duty, proving the dylib resolved and paying the
first-call cost off the hot path, where it would otherwise look exactly
like a control loop that missed its deadline.
That front-loading is what makes a broken bundle survivable. robotd
turns a load failure into hold-the-pose-and-report-unhealthy, so the
updater rolls the release back, instead of the robot discovering the
problem sixty ticks in, mid-stride.
ort is pinned to =2.0.0-rc.11, the version microduck_runtime runs
today, so the board keeps an ABI it already works against rather than
getting a fresh one to debug. load-dynamic also means the aarch64
cross-build needs no target-arch ONNX Runtime, and a laptop with none
installed still compiles and runs every test that does not open a
session.
intra_threads is 1, against the prototype's 2. On a four-core A55 a
pool means the control thread blocks on workers it does not own, and
for a network this small the synchronisation likely costs more than the
parallelism returns. Worth measuring on the board rather than trusting
either number.
Assisted-by: Claude:claude-opus-5
* duck-control: the safety authority, holding the only writer
Safety owns the RobotIo. Nothing above it — not the policy, not the
arbiter, not a client — has a handle, so nothing above it *can* command
a motor. The borrow checker enforces that, rather than a rule someone
has to remember when adding the eighth skill the night before a demo.
Three unconditional rules plus a deadman:
* Non-finite targets are refused, not clamped. Clamping NaN yields a
boundary value, which is a plausible-looking joint angle — the
robot would lurch to a limit instead of holding still.
* Targets are clamped to the actuator's travel and the clamp is
reported, because a client whose command was silently altered
cannot tell why the robot is not obeying.
* A sustained tilt goes limp, debounced 200ms in both directions so
the impulse from a firm footfall is not read as a fall.
* If intents stop arriving the twist is zeroed — and only the twist.
Stop is not limp: losing comms should leave a biped standing,
because standing is its safe state, while losing balance should let
it yield. Two events, two responses.
Going limp needed real teeth, so RobotIo grew set_gain. Refusing to
command a fallen robot only freezes it in the pose it fell in; dropping
kP from 200 to 50 lets it yield, which is what the prototype does. The
gain is written on transition rather than per tick — the naive version
is 750 extra bus writes a second on the bus the control loop needs.
One honest limitation, recorded in the code rather than papered over:
the clamp is the *actuator's* range, not per-joint anatomical limits.
Those live in the MJCF, which is not vendored here. So this catches
NaN, a bad action scale and a garbage tensor; it will not stop a joint
being driven somewhere mechanically unwise. A limit that looked
per-joint but was not would imply protection nobody has.
In the prototype fall detection is a flag, evaluated among the gamepad
handling, and skipped while a scripted move runs. Safety the
interesting cases opt out of is the anti-pattern architecture.md §6
names.
Assisted-by: Claude:claude-opus-5
* robotd: drive the robot — policy, safety and intents in the loop
The tick becomes read -> observe -> gate -> policy -> safety.apply.
Safety holds the only RobotIo, so everything above it proposes targets
and nothing above it can command a motor.
A policy that cannot load is survivable by design: the loop keeps
running at rate, holds its pose, and robot.health says why, so the
updater rolls the release back. Refusing to start would instead become
a crashloop under Restart=always and reach the gate as Unreachable,
which blames the wrong thing in the journal.
Making that work turned up a real trap. ort does not return an error
when ONNX Runtime is missing — it `expect`s inside setup_api, on a lazy
path reachable from any API call. So the control thread died, no tick
ever landed, and health reported "the loop has not completed a cycle"
forever: the daemon looked wedged instead of naming the cause, which is
worse than the crashloop we rejected. policy::ensure_runtime now probes
for the dylib with the same loader and the same search rule ort uses,
before ort is touched at all, turning it into an ordinary error.
`policy.enabled` distinguishes a policy nobody wanted from one that
would not load. The first is healthy and is the right configuration for
bench updater testing — nothing falls over when a broken release lands.
The second is unhealthy. Collapsing them would either make a bench
robot look broken or let an unusable bundle pass the gate.
Intents live in two lock-free slots, twist and head, separately
stamped. Separate because a combined slot needs read-modify-write, and
two clients would silently lose each other's updates. Stamped because
the loop's question is not what the value is but how old it is, which
is what the deadman reads. The twist starts maximally stale, so a robot
never comes up believing it has a live driver.
safeToRestart is now false while walking and true when standing or
already down — a robot going nowhere is safe to interrupt.
On disable, the hold pose is captured once at the transition rather
than re-read each tick, or it would sag under gravity.
301 tests. The gate tests pass --no-policy, since neither CI nor a
laptop has ONNX Runtime and what they exercise is the update gate.
Assisted-by: Claude:claude-opus-5
* padd: drive the robot from a gamepad, as an ordinary client
A separate process, not a thread in robotd, and with no privileged
access: it reads a pad and sends intents over the socket like anything
else would. That is the point. The intent API is the path the app, the
SDK and any remote client will use, and routing the pad through it means
whoever is working on the robot exercises it daily — so it cannot
quietly rot the way an API only the phone app touches inevitably does.
The cost is a socket hop, tens of microseconds against a 20 ms tick.
Continuous intents go as notifications; enable and stop go as requests
and their answers are read, because "refused, and here is why" is a
real outcome — safety declines to enable a policy on a fallen robot —
and swallowing it would leave the operator wondering why nothing
happened. Added Request::notify for the first half.
Head mode zeroes the twist explicitly. The deadman would catch it, but a
robot that keeps walking because you started posing its head is a bad
enough surprise to be deliberate about. A disconnected pad sends
nothing at all, so the deadman stops the robot — inventing a zero
command would mask a dead pad as a chosen stop.
⚠ It does not cross-compile to the board yet, so it is not in a
release. gilrs depends on libudev-sys unconditionally on Linux — no
feature disables it — and that wants a pkg-config sysroot, which is
precisely what building with zig was chosen to avoid. The board build
now names its packages instead of using --bins.
It is useful today regardless: run it on a laptop against a board over
`ssh -L /tmp/robotd.sock:/run/robotd.sock`. Getting it onto the robot
means choosing between a cross sysroot, a pure-Rust evdev backend, or
building it natively on the board — recorded in padd/Cargo.toml as an
open decision rather than left as an oversight.
install.sh now installs ONNX Runtime, so a fresh board is provisioned
in one command. It is a prerequisite rather than release cargo: it
changes far less often than the daemon, and shipping ~20 MB in every
artifact would enlarge every update for nothing. Found two bugs writing
it — `info` is not a function in that script (it is `say`), and under
`set -eu` that would have aborted a real install; and tar and find were
not in the required-tools check.
Assisted-by: Claude:claude-opus-5
* docs: §11.2 resolved, and record what slice 2 actually is
The 61-D command encoding was flagged as unknown and needing settling
against microduck_brain. It did not: reading control_step answered all
three sub-questions, and they are now pinned by tests. Golden vectors
from the training env are still worth having, but as a regression check
rather than the source of truth they were going to be.
§5.3 records how policy loading really behaves, including that ort
panics rather than erroring when ONNX Runtime is missing, and why
policy.enabled distinguishes "no policy wanted" from "policy broken".
Two new gaps recorded rather than left implicit. padd does not reach the
board — gilrs wants a libudev sysroot, which is what zig was chosen to
avoid — so gamepad-on-the-robot is still an open decision. And per-joint
limits do not exist: safety clamps the actuator's travel, which catches
NaN and a bad action scale but will not stop a joint being driven
somewhere mechanically unwise.
The status lines in README and roadmap now say plainly that none of
slice 2 has met a robot. The tests establish the logic is
self-consistent, not that it walks — and "301 tests" invites exactly
the wrong inference if that is not stated.
Assisted-by: Claude:claude-opus-5
* ci: install libudev so padd builds and ships
Keeping gilrs, and paying for it. gilrs depends on libudev-sys
unconditionally on Linux, with no feature to disable it, so the
workspace does not build anywhere without libudev present — not just
for the board. The last run failed in `check`, the native Ubuntu job,
before it checked anything.
Three jobs now install it, and the cross-build additionally needs the
target architecture's copy plus the pkg-config hints that let it answer
for another arch at all. With that, padd cross-compiles and goes back
into the release: a dev board is where anyone drives the robot from,
and the intent API only stays honest if the thing exercising it daily is
actually on the robot.
The alternative was dropping gilrs for a pure-Rust evdev backend.
Rejected because gilrs's value is precisely the SDL controller
database — without it every pad needs a hand-written mapping of raw
event codes, and the same Xbox controller reports different codes over
USB and Bluetooth, which is the transport these robots use.
Worth stating as a standing cost rather than a solved problem: the same
expense returns for the next C dependency that has to reach the board.
That is an argument for preferring pure-Rust crates everywhere else on
that path.
Also rebased onto main so this branch stops predating the coverage job —
merging it as it stood would have silently reverted #3.
Assisted-by: Claude:claude-opus-5
* ci: get libudev for aarch64 from ports, not the main archive
The previous attempt assumed multiarch was just `dpkg
--add-architecture` away. It is not: Ubuntu's main archive carries no
arm64 at all — those packages live on ports.ubuntu.com — so apt asked
the existing sources for arm64 indexes, got a 404 for every one, and
failed the update outright.
So the existing sources are pinned to amd64 and a ports source is added
for arm64. That is fiddly enough, and needed in three workflows, that it
belongs in a script rather than copied three times.
The script ends by asserting libudev.pc actually exists for the target.
A silent miss would surface much later as a confusing link error, or
worse as pkg-config answering with the host's library and producing a
binary that cannot run on the robot.
Worth reading before adding another C dependency to anything that
reaches the board: this script is the whole cost of the one exception we
have. Everything else on that path is pure Rust or configured to avoid
it — serialport with default-features = false, ort with load-dynamic.
Assisted-by: Claude:claude-opus-5
* robotd: the state stream, so the robot can be watched
§5.6 of the design was in slice 2's scope and did not get built. The
design says there are two vocabularies, intents in and state out; only
intents existed. Limit was computed in the safety layer every tick and
then discarded, which made safety's "reported, not silently altered"
claim half empty — a client watching the robot ignore its command had
no way to tell a limit from a bug.
robot.subscribe now turns a connection into a stream of robot.state.
Each frame carries what was requested beside what was applied and why
they differ, plus the loop rate, missed ticks, fall state, and measured
joints against commanded targets.
The loop publishes into a bounded broadcast and never waits on a
subscriber: a client that cannot keep up gets a gap, never backpressure
onto the control loop. Same rule the updater already applies to
progress. Decimation is server-side and per-subscriber, so a 10 Hz
dashboard really does cost the robot less than a 50 Hz digital twin.
Two details worth their comments. Nothing is assembled when nobody is
subscribed — the normal state of a robot — because building a frame
allocates on the thread that should not be visiting the allocator
without reason. And the limit names are spelled out for the wire rather
than derived from the Rust enum, so renaming a variant cannot quietly
break a client branching on limited_by.
`robotctl monitor` is the first consumer. It is also what makes the
first hardware run diagnosable: logs say what happened, this says what
is happening.
Four new tests, including the one that matters — with the policy
enabled and intents already stale, a frame must arrive showing the
twist requested, zero applied, and "deadman" as the reason.
Assisted-by: Claude:claude-opus-5
* obs: build the observation by named blocks, not a cursor
`build` was C with Rust syntax: a running `at` index, `at += 1` after
every write, and the command block addressed by hand as data[at + 9].
The `debug_assert_eq!(at, OBS_LEN)` on the way out was the tell — a
runtime guard for an invariant the structure should hold.
Now the buffer is split into the blocks of the layout table, by name.
The widths are checked once, by the split itself, instead of by every
branch remembering to advance a cursor correctly. The command block is
written as a literal list that reads in the same order as the docs,
which matters because it is the one block with no second source of
truth.
Two helpers carry the invariants that were previously implicit.
`policy_joints` is the single definition of "the joints a policy sees,
mouth skipped", used for positions, velocities and the home pose — so
those three cannot disagree about which joints they cover or in what
order. `scatter_action` now mirrors it exactly, skipping the mouth on
the way out with the same filter it is skipped with on the way in.
`fill` asserts each block was filled exactly. That is not ceremony:
`zip` stops at the shorter side, so a source yielding too few values
would leave the tail silently at zero — and a zero in the observation
means a joint sitting at its home pose, which is a plausible robot the
policy will act on.
No behaviour change, and the eight layout tests are what makes that
claim checkable: they pin every block boundary with distinguishable
values and passed unaltered through the rewrite. That is what they were
written for.
Assisted-by: Claude:claude-opus-5
* obs: let the compiler check block widths instead of counting
The `written += 1` in `fill` was doing at runtime what the type system
can do at compile time, and only existed because I had made the source
a generic IntoIterator with no known length. A filtered iterator is not
ExactSizeIterator — Filter cannot know how many elements pass — so
there was nothing to compare against.
Fixing the shape removes the need for the check. `policy_joints` now
returns [f64; OBS_JOINTS] rather than an iterator, so its width is part
of its type, and the buffer is carved with `split_first_chunk_mut` so
each block is an array rather than a slice. `fill` becomes
`fill<const N>(&mut [f32; N], [f64; N])`: a block and its source cannot
disagree about width, because the compiler rejects it.
Verified rather than assumed — feeding a 3-wide source into the 14-wide
positions block now fails with "expected an array with a size of 14,
found one with a size of 3", where before it would have compiled,
written three values, and left eleven zeros. Those zeros are not
inert: a zero in the positions block is a joint sitting exactly at its
home pose, so the policy would act confidently on a robot that does not
exist.
`policy_joints` also carries a const assertion that skipping one index
leaves exactly one fewer element, so that reasoning is enforced rather
than believed.
No behaviour change; the eight layout tests pass unaltered again.
Assisted-by: Claude:claude-opus-5
* obs: assign arrays instead of copying element by element
Four hand-rolled loops gone. The only thing that ever forced them was
the f64 -> f32 narrowing, which copy_from_slice cannot do because it
needs matching types — but `array::map` produces a [f32; N], and then
it is just an assignment:
fn fill<const N: usize>(block: &mut [f32; N], values: [f64; N]) {
*block = values.map(|value| value as f32);
}
The previous-action block needed no conversion at all: it is already
f32 of the same width, being the policy's own output fed back, so it is
now a plain array copy.
`policy_joints` and the relative-position subtraction both become
`array::from_fn`, which also removed the last place a zip could have
silently under-filled a block.
The nicer consequence is `joint_of`: a const fn saying that slots below
the mouth map straight through and everything at or above shifts up by
one. `policy_joints` reads through it and `scatter_action` writes
through it, so the two directions cannot disagree about where the
policy's n-th value belongs. That symmetry used to be two similar
filters that happened to match.
No behaviour change. The eight layout tests pass unaltered for the
third refactor running, which is the whole reason they were worth
writing with distinguishable values.
Assisted-by: Claude:claude-opus-5
* docs: diagram the dataflow, which prose was carrying badly
Three diagrams, placed where each belongs rather than collected into an
appendix nobody opens.
§3.1 is the way in: who talks to robotd and over what, plus the crate
boundary. §5.10 is the per-tick dataflow — bus to observation to policy
to safety and back to the bus — followed by the decision structure
around it, which the dataflow cannot show: what makes the loop drive,
and the two edge transitions that exist to stop the robot lurching.
§7.1 gains the thread-to-thread picture, where the point is that no
channel runs the other way.
Inserted as 3.1, 5.10 and inline, specifically to avoid renumbering:
there are 187 section cross-references in the repo and this is a
documentation change, not a chance to break every one of them.
The README points at all three. The dataflow is the part of this system
hardest to reconstruct from prose, and it was only ever written down as
prose.
Assisted-by: Claude:claude-opus-5
* docs: hand off the slice 2 hardware bring-up
One open problem, written down so it can be picked up cold: ort panics rather than
returning an error, which kills the control thread and makes robot.health blame the
wrong thing.
Records what is already proven on the board — 15 servos and the IMU answering, 50.0
Hz with 3 missed ticks in 15022, health earned, the update path exercised including
auto-rollback — so a reader does not re-investigate the bus, the rate or the
updater.
Names the falsified assumption rather than leaving it to be rediscovered:
ensure_runtime's doc comment claims a successful probe means "the panic cannot
fire". The probe proves only that the library loads, and 1.20.1 loads fine; ort's
own version check then rejects it and panics where the probe cannot see. So the
guard closes the missing case and not the wrong-version case.
Points at the graceful path that already exists and that the panic bypasses, and
lists the decisions to settle while wiring it up: catch narrowly, carry the panic
message into the health reason or lose the version numbers that make it actionable,
and the AssertUnwindSafe justification. Confirms there is no custom
[profile.release], so catch_unwind is available.
Also merges main, which brings #17 — the ONNX Runtime version fix that removes the
first of the two causes.
Assisted-by: Claude:claude-opus-5
* Ship the alpha policies in the release
deploy/robotd.toml says policies live inside the release directory, "so an ordinary
update carries the policy alongside the binaries that were trained against it".
Nothing implemented that: there were no .onnx files in the repository and neither
workflow shipped any. So slice 2 rolled back on hardware with
policy unavailable: loading .../policies/alpha_walking.onnx:
File at `.../policies/alpha_walking.onnx` does not exist
which is the config documenting behaviour that did not exist, not a bug in the
loader — the graceful path worked exactly as intended and the updater reverted for
a stated reason.
The eventual home is the Hugging Face Hub, as a model component that versions
independently of the daemon: a gait retrain should not need a daemon release, and a
daemon fix should not re-download unchanged weights. deploy/updater.toml already
describes that component. The policies were not on the Hub yet, so this is the
temporary answer, chosen to be removable in two edits — drop this directory and the
two --include lines.
Only two of the prototype's twenty-two megabytes are copied. The rest belong to
skills this daemon does not implement, and would put weight in every robot's update
for capabilities it cannot use.
Names here are roles, not training runs: alpha_walking.onnx is
BEST_alpha_walking_flat.onnx from apirrone/microduck_runtime at 567fdcd, following
the symlinks that repository uses for exactly this indirection. Deciding which run
is "the walking policy" should not mean editing config on every robot.
Verified by packaging a real artifact: both files land at policies/*.onnx, which is
where robotd.toml looks. The 61-input/14-output contract is not verified here —
neither onnx nor onnxruntime is importable on this machine — but Policy::load checks
it and reports a mismatch through robot.health.
Assisted-by: Claude:claude-opus-5
* Ship the policies microduck_runtime actually loads
The vendored pair was chosen by filename: deploy/robotd.toml asked for
alpha_walking.onnx, the prototype had a file with that name, and nothing checked
which policies the working system loads. It loads different ones — src/main.rs
defaults to policies/walking.onnx and policies/standing.onnx, which resolve to
new_policies/vel_noise_walk.onnx and new_policies/standup_gentle_more_range.onnx.
They are distinct generations, not aliases: everything in new_policies/ is exactly
772527 bytes and every BEST_alpha_* is ~793700. So the robot has been running
weights with no track record while a working set sat unused.
Default to the proven pair and ship the alpha pair alongside it. Both, because
robotd validates the 61-input/14-output shape at load but nothing detects "right
shape, wrong robot", so which family suits alpha is a question only hardware
answers — and with both on the board that experiment is a config edit and a
restart rather than a release cycle. 1.5 MB more in an artifact that is temporary
by construction; the Hugging Face model component removes all of it.
Assisted-by: Claude:claude-opus-5
* Revert to the 61-D alpha policies
The swap to microduck_runtime's default pair was wrong, and the robot said so
exactly:
policy unavailable: .../walking.onnx: observation width is 51, expected 61
They are not an older generation of the same thing — they are a different
observation format. 51 is 3 gyro + 3 gravity + 42 joints + 3 command, the legacy
[vx, vy, vtheta]. 61 is the same sensors with the unified 13-value command this
daemon builds. The prototype runs the legacy path by default and only reaches the
61-D policies under --new-cmd-obs, so "what the working runtime loads by default"
was the wrong question to ask of it; the right one is which policies match the
observation we build, and that is the BEST_alpha_* family the original vendoring
picked.
Drop the 51-D pair rather than shipping it as an alternative: robotd refuses it at
load, so it is weight in every update for something no configuration can use.
Kept in the README with the error text, because the two families are
indistinguishable by name and size alone and the reasoning is not re-derivable from
the files.
Assisted-by: Claude:claude-opus-5
* robotd init: set the gain before ramping to the home pose
`init` enabled torque and interpolated, leaving position_p_gain at whatever the
servos happened to hold. That register lives in RAM and survives this process, so a
previous fall leaves gain_limp (50) there and `init` then takes the robot to its
home pose at a quarter of the intended stiffness. Observed on a board: kP read back
as 50 with the robot standing.
Soft enough to matter, in the one command whose whole job is establishing a known
state — it is the moment the robot takes its own weight, and it was doing so at the
gain chosen for yielding to the floor.
The control loop sets its own gain on the first tick, so this governs the ramp and
the window before the daemon starts. That window is exactly when the robot is
standing up unsupported.
Not covered by a test: run_init is Linux-only and drives DynamixelIo directly,
because interpolate_to is an inherent method rather than part of the RobotIo trait.
Making it fake-testable means widening a trait every backend implements, which is a
design change and not part of this fix.
Assisted-by: Claude:claude-opus-5
* safety: do not judge a fall on an orientation filter that has not converged
`observe` read gravity[2] unconditionally. The SFLP filter needs a few seconds of
samples before its quaternion means anything, and until then projected gravity is
whatever the filter is mid-way through deciding — which sits above the fall
threshold, which reads as "on its side". Two hundred milliseconds of that and an
upright robot on a bench latches `fallen` at startup.
The consequences were spread far enough apart to be hard to connect. `apply` writes
`gain_limp`, so the servos go to kP 50. `padd` is refused with "the robot is down;
stand it up first". A few seconds later the filter converges and `fallen` clears —
but the gain is only written when it changes, so 50 stays until something moves it,
and `robotctl monitor`, sampled after convergence, honestly reports `ok`. Nothing
observable connected a soft robot to a fall that was over before anyone looked.
Measured on a board: a joint set to 137 by hand read back 50 after five seconds of
robotd, with monitor showing `held` and `ok`.
`imu_ready()` already existed and was already plumbed to health, and the doc comment
on `SflpDecoder::ready` says "Slice 2's fall detection must not run before it". The
requirement was written down and never wired up.
Holding the previous verdict is the safe default both ways: at startup that is "not
fallen", which is what a robot standing on a bench is, and a filter that stops being
ready mid-run leaves a fallen robot fallen.
The paired tests are each other's control — an unconverged filter must not declare a
fall, and a converged one must still detect the same sample — so the guard cannot
quietly become "fall detection is off". FakeIo grows an `imu_ready` knob to express
the difference.
Assisted-by: Claude:claude-opus-5
* monitor: show projected gravity and the gain actually written
`fallen` was reported without the number it is derived from, and the gain without
the fact that safety overrides it. So an upright robot holding kP 50 was
undiagnosable from outside: "the robot is down" and "the IMU frame is not what this
build assumes" produce an identical verdict, and the only way to see the gain was to
stop the daemon and reach for another tool.
That gap cost most of an afternoon and four wrong explanations from me, each of
which could have been settled by three numbers the daemon already had.
`SafetyState` gains `gravity` — the trunk-frame vector `observe` thresholds on,
upright being about [0, 0, -1] — and `gain`, the value last written to the servos
rather than the value requested. `Eq` comes off the derive, since gravity is a
measurement and exact equality on one is not a comparison worth offering.
17.77 held 50.1Hz miss=0 ok g[-0.01 +0.02 -0.99] kp=200
req[+0.00 +0.00 +0.00] app[+0.00 +0.00 +0.00] [deadman]
Both fields are `#[serde(default)]`, so an older robotd that does not send them
still parses.
Assisted-by: Claude:claude-opus-5