feat(cascade): wasm seam v0.8 — the host can finally state its tick rate (breaking 0.7→0.8) - #382
feat(cascade): wasm seam v0.8 — the host can finally state its tick rate (breaking 0.7→0.8)#382avrabe wants to merge 6 commits into
Conversation
…nnot hold altitude
A colleague asked whether Gazebo had ever been run against our WebAssembly
components. It had not, and asking the question found something worse than the
missing test.
WHAT WAS TRUE. examples/falcon-sitl-gz links the NATIVE crates (relay-iekf,
relay-pos, relay-att, relay-rate, relay-mix-quad) and contains no wasmtime and
no .wasm. Every SITL/Gazebo result — hover, the Monte-Carlo campaigns,
rotor-out recovery, touchdown — is evidence about the native binary. The
published wasm components had never flown anything.
The nearest prior art, tests/rate-loop-proof, does close a loop across the wasm
seam, but one stage against a single-axis rigid body with a constant quaternion
and no gravity. A real loop; not a plant, and not the cascade.
WHAT WAS NOT TRUE: that they "can't be connected". They can, and now do.
`falcon-cascade` alone is not instantiable — it imports the five stage
interfaces — but `wac plug` satisfies them from the leaf components, exactly as
BUILD.bazel already does. Over the PUBLISHED artifacts that yields a 98 KB
self-contained component: 0 memory.grow, 0 wasi, instantiable with an EMPTY
wasmtime linker.
Worth recording: the bazel `falcon-cascade-composed` is NOT this. It is a
different build path — 15.7 MB, 18 wasi imports, 6 memory.grow. Testing it
would not have validated what we ship.
THE ACTUAL FINDING. Same plant (MockPhysics, included by #[path] so it is the
same code, not a copy), same target, same schedule as the native scenario:
2500 ticks @ dt=0.004 (250 Hz), hold 2.0 m
native --scenario=flightcore final_dist 0.38 m PASS
published wasm cascade reached 29.17 m FAIL (|err| 27.17 m)
The cause is structural and readable straight off the shipped WIT:
interface ekf { estimate: func(imu: imu-sample) -> vehicle-state; }
IMU ONLY. No published interface accepts a position fix, a barometer, a
magnetometer or a heading — while the native FlightBackend the bench flies
supplies read_position, read_mag, read_heading AND read_motor_rpm. Altitude is
unobservable across this seam, so the estimator can only dead-reckon the
accelerometer and the altitude loop chases a diverging estimate. Commanding a
deeper target climbs FURTHER (-2 m -> 12.4 m, -5 m -> 29.9 m, -20 m -> 46.2 m):
a loop tracking its setpoint while its feedback runs away, not a controller
ignoring the command.
So the shipped wasm cascade is a SUBSET of the flight stack, not a packaging of
it. No amount of retuning fixes that from outside the component.
A FAIRNESS CHECK THAT NEARLY WENT WRONG. The first comparison used
`--scenario=hover`, which FAILS natively on MockPhysics too (final_dist 24.94 m)
— had I stopped there I would have reported a wasm-specific defect that the
native path shares. `--scenario=flightcore` is the one that passes on this
plant, and is therefore the only fair reference.
The harness reports its two verdicts SEPARATELY — "the loop closes" and "the
altitude is held" have different answers, and one threshold covering both would
hide the second.
Also adds scripts/compose-cascade.sh. Its glob is anchored on `-v<digit>`
because `falcon-cascade-*.wasm` also matches falcon-cascade-stream-{composed,
fused} and `ls` sorts those FIRST — the naive version plugged the stages into
the wrong socket and died with "invalid leading byte for component defined
type". That is precisely the artifact ambiguity jess raised on #202, and it cost
a cycle here within minutes of writing the script.
Standalone [workspace], like tests/rate-loop-proof: wasmtime must not enter the
main workspace graph. No existing crate is modified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG
The previous commit reported 29.17 m of altitude divergence and blamed the
IMU-only WIT seam. The measurement was right; the attribution was wrong.
The estimator hardcodes its integration step:
wasm/cm/iekf/src/lib.rs: f.propagate(RImu { gyro, accel }, 0.001);
1 kHz, always. My harness ticked at 250 Hz, so the component integrated 1 ms of
motion per 4 ms of real time. Running at the rate it actually assumes:
10000 ticks @ dt=0.001, hold 2.0 m -> reached 2.00 m, |err| 0.00 m PASS
THE PUBLISHED WASM CASCADE HOLDS ALTITUDE. That is the headline, and I had it
backwards.
TWO DEFECTS, now separated instead of merged:
DEFECT 1 (bug, and the more serious): no interface lets a host declare its tick
rate, and the component cannot detect one. Off-rate hosts get a confidently
wrong answer with no error at all:
1000 Hz -> 2.00 m |err| 0.00 (what it assumes)
400 Hz -> 12.43 m |err| 10.43
250 Hz -> 29.17 m |err| 27.17
DEFECT 2 (structural, and now evidenced properly): IMU-only. At the component's
own 1 kHz, sweeping accelerometer noise (m/s^2):
0.00 -> 2.00 m 0.01 -> 2.01 m
0.05 -> 2.01 m 0.20 -> -121.46 m
Dead reckoning survives a quiet plant and collapses at noise a real MEMS IMU
produces. The seam gap costs nothing on a noiseless bench and everything on
hardware — which is exactly why the quiet PASS is not evidence of
flightworthiness, and why the acceptance criterion for the fix is the 0.2 case.
WHY I GOT IT WRONG, recorded in the source next to the test: I read the WIT,
found a real structural gap, and stopped. The first explanation fit the symptom,
so I never looked for a second — and the 1 kHz constant was eleven lines into
the component I was already running. A structural argument read off an interface
is cheap and feels conclusive; it is not a substitute for varying the parameter
and watching the number move.
#380 carries the same correction rather than a silent edit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG
Driving the published components from a host (v1.136's tests/cascade-sitl-wasm)
found three defects. This fixes one conclusively, makes a second addressable,
and leaves a third open with much better evidence than it had.
DEFECT 1 — FIXED. Both estimator components hardcoded 1 kHz:
wasm/cm/iekf: f.propagate(RImu { gyro, accel }, 0.001);
wasm/cm/ekf: TICK_MS += 1 // "the cascade runs at 1 kHz"
No interface let a host say otherwise and neither component could detect a
mismatch, so an off-rate host got a confidently wrong answer and no error at
all. Measured against the SITL plant, holding 2.0 m:
before after
1000 Hz 0.00 m 0.00 m
400 Hz 10.43 m 0.00 m
250 Hz 27.17 m 0.00 m
100 Hz - 0.01 m
`dt-s` is clamped to [0.1 ms, 100 ms]; a non-finite value falls back to the
v0.7 constant, the only value that was ever safe to assume. The Mahony
component's clock moved from integer milliseconds to nanoseconds because 400 Hz
is 2.5 ms and integer-ms accumulation truncates that to 2, drifting 20% a tick.
DEFECT 2 — the interface is fixed, the behaviour is NOT. `sensor-frame` now
carries optional position/mag/heading, the iekf component fuses them at
falcon-core's own variances (grav 0.5 / pos 0.01 / mag 0.1), and a covariance
floor of (0.30, 0.05) matches what both native FlightCore scenarios set — its
absence was making the filter go deaf, so the 5 Hz fixes the host offered were
being NIS-gated away (-123.18 m with fixes vs -123.32 m without: identical, i.e.
discarded).
With all of that in place the composed cascade still destabilises:
noise (m/s^2) 0.00 0.05 0.06 0.08 0.10 0.20
wasm cascade 0.00 0.15 0.03 -11.00 -47.51 -123.19
native 0.38 0.39 - - - 0.39 (PASS)
A cliff between 0.06 and 0.08, not accumulating drift — a stability boundary,
not dead reckoning. So the remaining gap is NOT the seam: the seam now carries
what is needed and the component consumes it. Something in the composed control
path diverges where the native FlightCore (ADRC, thrust limits, mode logic,
arming) does not. #380 carries the numbers; it is not claimed fixed here.
DEFECT 3, found on the way — CONFIG PARITY. The component constructed
`Iekf::level()` and configured nothing, while falcon-core sets a process floor.
A wasm component that instantiates a verified crate with different settings than
the native path is not the same vehicle, and nothing was comparing them.
MODEL AND CONTRACT, kept in step:
spar/falcon_types.aadl + SensorFrame, Vec3
spar/falcon_cascade.aadl EkfThread.imu_in -> sensors_in, process port + c0
wit/ package 0.7.0 -> 0.8.0 (breaking, deliberately once)
Note for the record: wit/falcon-cascade/cascade.wit is in NO spar drift check —
only relay-transport, dronecan and param are gated — so the cascade's WIT and
its AADL have been hand-synced all along. They agree again now; wiring this root
into the drift gate is a follow-up.
`spar parse` clean; `spar analyze --root Falcon_System::Falcon.Quad` reports no
errors. All 8 components build; composed bundle is 0 memory.grow, 0 wasi.
Breaking change for consumers of pulseengine:falcon-cascade — jess is the known
one and gets the diff before this is tagged, per the #202 commitment. Both
defects needed a signature change, so they land as ONE bump rather than two.
Refs #380
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG
The thing being demonstrated is numeric: a wrong integration step inside a wasm
component. A Gazebo screen capture would look IDENTICAL before and after, so
footage of a flying quad would show something this release did not measure.
Instead the footage IS the measurement. plot-trace-video.py animates the
altitude traces the harness wrote; every pixel comes from a CSV in
bench-evidence/wasm-sitl/.
host rate v0.7 declared 1 kHz v0.8 declares its own
250 Hz 29.17 m 2.00 m
400 Hz 12.43 m 2.00 m
1000 Hz 2.00 m 2.00 m
The 1 kHz row is the story: at the one rate v0.7 assumed, the two builds are
indistinguishable. The renderer draws v0.7 thicker and underneath so the green
sits ON the red there and both stay visible, with the panel labelled — that
panel is why this survived to v1.136.
The "before" trace is NOT an old build. It is the same v0.8 component told to
assume 1 kHz, which is exactly what v0.7 did unconditionally, so the comparison
needs one binary and one component instead of a cross-build diff. It reproduces
the original measurement to the centimetre (29.17 m).
Per-panel y-scaling, deliberately: a shared axis sized for the 29 m runaway
squashes the held traces into the frame edge and hides the thing being shown.
The target line is drawn in every panel so the comparison stays honest.
Narration via macOS `say` — the Speaches host (192.168.178.28:8000) was
unreachable at render time, checked before falling back rather than after.
bench-evidence/wasm-sitl/README.md states what the video does NOT cover: these
runs supply no position fix and no IMU noise, isolating the tick-rate defect.
The separate IMU-only limitation (#380) is not fixed and is not implied here.
mp4 stays gitignored per bench-evidence/gz-sim/recordings/.gitignore; the
channel entry is the tracked artifact, matching every prior release.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG
BACKEND=gazebo selects the same GazeboPhysics bridge examples/falcon-sitl-gz flies, so a wasm result and a native result are comparable rather than merely adjacent. BACKEND=mock (default) is unchanged and stays dependency-free — it is the analytic plant every number in #380 came from. The gz deps are pinned to the SAME versions falcon-sitl-gz uses. A different gz-transport here would be a different bridge than the one every native SITL result was produced with, and the comparison would stop meaning anything. Builds verified both ways on this machine: `cargo build --release` (mock) and `cargo build --release --features gazebo` against brew's gz-transport13, both exit 0. RUNNING it against a live world is currently blocked by a broken local Gazebo, not by anything here: gz-sim8 8.14.0 links libswscale.9.dylib while the installed ffmpeg 9.0.1 ships libswscale.10, so `gz sim` dies in dlopen before the world loads. Also note `gz sim` is not registered as a subcommand unless GZ_CONFIG_PATH includes /opt/homebrew/opt/gz-sim8/share/gz — worth writing down, because the symptom is `gz` printing its help text with no error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG
Measured: this PR will NOT fix the Gazebo run. Relevant to whether it should land.Asked directly whether the v0.8 seam improves the wasm flight in gz. It does not, The gz run and the working mock run differed in TWO variables — plant and tick v0.7 component, MOCK plant v0.7 component, GAZEBO plant Opposite failure directions. If the hardcoded dt were driving the gz The remaining candidate is the one #388 names: the components wrap the legacy PID What this means for this PRv0.8 is NECESSARY and NOT SUFFICIENT. It makes the components correct at any host Combined with the earlier concern — that #388 proposes replacing the five stage Concretely, the options: (a) Hold until #388 is decided. If the components collapse to (b) Land the dt half only. Extract (c) Land as-is. Gets correctness-at-any-rate now, at the cost of designing a I would take (b): the tick rate is wrong today and jess may be lowering images Holding for jess either way — this is input for that conversation, not a 🤖 Generated with Claude Code |
… + ADRC (#393) The published cascade wraps falcon_core::FlightCore directly. What we ship is what we verify and fly. MEASURED, SITL plant, 5 Hz position fix, against the component it replaces: accel noise (m/s^2) 0.00 0.05 0.20 0.50 OLD (legacy PID) 0.00 m 0.15 m -123.19 m -169.67 m NEW (flight core) 0.22 m 0.22 m 0.23 m 0.23 m native reference 0.38 m 0.39 m 0.39 m - Essentially unmoved across the whole range — the same signature as the native cascade, where the old one inverted catastrophically. That is #380's noise cliff gone, and it was never a seam problem or a tuning problem: it was a different control law. RATE INDEPENDENCE, exactly: 100 Hz -> 0.22 m 250 Hz -> 0.22 m 1000 Hz -> 0.22 m identical at every rate, because there is now ONE clock. The old five stages each advanced their own hardcoded timeline (estimator 1 ms, position 20 ms, attitude 4 ms, rate 1 ms) while the cascade called all five once per tick, so they disagreed with each other by up to 20x and with the host entirely. At 250 Hz the old component accumulated 29.17 m of altitude error; this one does not have the failure mode at all. WHY ONE COMPONENT AND NOT FIVE. The five-stage decomposition was a wasm-side invention that never corresponded to the flight architecture — nothing upstream depended on it being right, which is exactly how it drifted onto relay-pos / relay-att / relay-rate after falcon-core dropped them on 2026-06-03 and moved to geometric SE(3) + ADRC. Three months, ~40 signed releases (#388). falcon-core already defines the real partition (PART-P01: estimator on M4, cascade on M7). Wrapping FlightCore matches the architecture instead of inventing one, and it removes the timebase problem structurally rather than plumbing `dt-s` into five components that should not exist. The component now imports NOTHING but the records-only `types` interface: world root { import pulseengine:falcon-cascade/types@0.8.0; export pulseengine:falcon-cascade/controller@0.8.0; } 42,615 bytes, 0 memory.grow, 0 wasi, instantiable with an empty wasmtime linker. No `wac plug` composition step at all — the artifact a host runs is the artifact we build. SEAM v0.8. `sensor-frame` carries the host's actual `dt-s` plus optional position / mag / heading, because FlightCore fuses all three and a seam that cannot carry them cannot carry the flight core. All-`none` remains a valid IMU-only host. This supersedes #382, which widened a seam around the five stages this change removes — one breaking bump for consumers instead of two. STILL OPEN: wasm/cm/{position,attitude,rate,ekf} continue to build and publish, so scripts/audit-component-deps.rs still reports its four waivers. Retiring them, with the harnesses built on them, is the follow-up — the audit fails the moment a NEW divergence appears, which is what it is for. NOT CLAIMED: this is the SITL plant. Gazebo reached 0.00 m with the old component; whether the flight core flies there is the next measurement, and the gz job in CI will answer it rather than an argument. Refs #388, #380, #382 Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
Closing — superseded by #393, with everything salvageable carried across first. Why, in one lineBoth this PR and #393 bump What this PR did vs what #393 doesThis PR widened the seam across five stage components so each could be told
Nothing here is lost
The #394 salvage mattered: I had believed the video work landed with #387, and it Two things NOT carried, deliberately, and tracked
|
…rom #382 (#394) #382 is superseded by #393 — both bump the WIT to 0.8.0 and rewrite the same three files, as competing implementations of one version bump. But #382 also carried work that exists NOWHERE else, and closing it would have taken that with it: examples/falcon-sitl-gz/tools/plot-trace-video.py the v1.137 video renderer bench-evidence/wasm-sitl/* the CSVs it plots channel-metadata.md the v1.137 channel entry spar/falcon_types.aadl + SensorFrame, Vec3 spar/falcon_cascade.aadl EkfThread takes a frame I had believed the video work landed with #387. It did not: #387 was the CI recording job and touched only gazebo.yml. The renderer and every trace it plots were sitting on a branch about to be closed — the same post-squash stranding that hid the equivalence test (#392), now the fourth occurrence. The AADL changes remain correct under #393. AADL models THREADS, not components, so EkfThread taking a SensorFrame is still the right architecture even though the five stage interfaces are no longer composed at the wasm level. `spar parse` clean, `spar analyze --root Falcon_System::Falcon.Quad` 0 errors. The README is corrected rather than copied: it referenced the retired compose-cascade.sh, and it now says plainly that these traces measure the v0.7 five-stage cascade whose defect #393 removed by a different route. Kept as the RECORD that found it — the video was rendered from exactly these numbers — not as a description of current behaviour. Refs #382, #393, #387 Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Verify-Filter: (has-tag "oci")
Breaking:
pulseengine:falcon-cascade0.7.0 → 0.8.0. jess is the knownconsumer and gets the diff before this is tagged, per the #202 commitment.
Both defects needed a signature change, so they land as one bump, not two.
Stacked on #381 (the harness). Full analysis in #380.
Defect 1 — FIXED, and this is what the release video shows
Both estimator components hardcoded 1 kHz:
No interface let a host say otherwise; neither component could detect a
mismatch. Holding 2.0 m on the SITL plant:
The 1000 Hz row is why this survived: at the one rate v0.7 assumed, before and
after are indistinguishable.
dt-sis clamped to[0.1 ms, 100 ms]; non-finite falls back to the v0.7constant. The Mahony component's clock moved from integer milliseconds to
nanoseconds — 400 Hz is 2.5 ms, and integer-ms accumulation truncates that to 2,
drifting 20% per tick.
Defect 2 — interface fixed, behaviour NOT. Not claimed closed.
sensor-framenow carries optionalposition-ned/mag-body/heading-rad;the iekf component fuses them at falcon-core's own variances (grav 0.5 / pos 0.01
/ mag 0.1). With all of that in place it still destabilises:
A cliff between 0.06 and 0.08 — a stability boundary, not accumulating drift. So
the remaining gap is not the seam: the seam now carries what is needed and
the component consumes it. Something in the composed control path diverges where
the native
FlightCore(ADRC, thrust limits, mode logic, arming) does not.Tracked in #380, open.
Defect 3, found on the way — config parity
The component built
Iekf::level()and configured nothing, while both nativescenarios call
set_process_floor(0.30, 0.05). Without it the covariancecollapses and the NIS gate rejects correct position fixes — measured as
−123.18 m with fixes vs −123.32 m without, i.e. they were being thrown away.
A wasm component that instantiates a verified crate with different settings than
the native path is not the same vehicle, and nothing was comparing them.
Model and contract kept in step
spar parseclean;spar analyze --root Falcon_System::Falcon.Quadno errors.Worth flagging:
wit/falcon-cascade/cascade.witis in no spar driftcheck — only relay-transport, dronecan and param are gated — so the cascade's WIT
and its AADL have been hand-synced all along, for the most important interface in
the repo. They agree again now; wiring this root into the drift gate is a
follow-up I'd like to do.
Evidence
All 8 components build; composed bundle 0
memory.grow, 0 wasi. Video traces inbench-evidence/wasm-sitl/, rendered byplot-trace-video.py— every pixel froma CSV, no staged footage.
Refs #380, #202
🤖 Generated with Claude Code
https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG