diff --git a/.zenodo.json b/.zenodo.json index b349d52..3b0bccf 100644 --- a/.zenodo.json +++ b/.zenodo.json @@ -11,7 +11,7 @@ "affiliation": "Independent researcher" } ], - "description": "

webgpu-fly runs a whole-animal Drosophila nervous system inside a web browser with no installation and no server. The FlyWire FAFB whole-brain connectome (139,255 neurons, ~15 million synaptic connections) and the Janelia MANC ventral-nerve-cord connectome (23,188 neurons, 5.2 million connections) are each simulated as leaky integrate-and-fire (LIF) networks in fused WebGPU compute kernels — gather, integrate, threshold and reset in a single kernel, with presynaptic-neurotransmitter signs pre-baked into the connection weights so the inner loop never branches on excitatory/inhibitory type.

The brain's descending command neurons drive the spinal cord by cell-type name match (the same named cell on both sides of the brain–VNC boundary), and the spine's 369 leg motor neurons are averaged into a walking magnitude and a turn bias that scale a hand-written tripod gait, which in turn actuates a physically simulated 67-body, 111-actuator TuragaLab flybody model running in MuJoCo compiled to WebAssembly. The connectome scales that gait; it does not generate the stepping rhythm, which is an analytic sinusoid of simulation time. A 64×16 retina rendered each frame from the fly's own head pose is fed back into the brain's optic neurons, closing a sensorimotor loop. An optional trained reinforcement-learning walking policy (Vaxenburg et al. 2025) runs as a pure-TypeScript forward pass checked element-wise against an independent NumPy re-run of the same extracted weights; that check validates the port's arithmetic, not the assumed layer architecture against the original SavedModel.

The deployment is a game: the player fires real descending neurons with keypresses to steer the fly to a target, and a winning run produces a deterministic, shareable replay URL that re-executes the identical neuron cascade against the same connectome — a brain trace, not a video. Performance is reported honestly: the brain LIF kernel is memory-bandwidth-bound and runs at ~0.25 kHz of biological time on an Apple M2 Pro, benchmarked on the same machine against NEST 3.10 (0.67 kHz) and a hand-written multicore Rust port (0.45 kHz). The original 1 kHz target was unreachable for any of the three on that hardware; the contribution is reachability — a real connectome simulation behind a single URL — not raw throughput. Known limitations (RL-walker speed gap, closed-loop visual-reflex approximation, kinematic-assist options) are enumerated in LIMITATIONS.md.", + "description": "

webgpu-fly runs a whole-animal Drosophila nervous system inside a web browser with no installation and no server. The FlyWire FAFB whole-brain connectome (139,255 neurons, ~15 million synaptic connections) and the Janelia MANC ventral-nerve-cord connectome (23,188 neurons, 5.2 million connections) are each simulated as leaky integrate-and-fire (LIF) networks in fused WebGPU compute kernels — gather, integrate, threshold and reset in a single kernel, with presynaptic-neurotransmitter signs pre-baked into the connection weights so the inner loop never branches on excitatory/inhibitory type.

The brain's descending command neurons drive the spinal cord by cell-type name match (the same named cell on both sides of the brain–VNC boundary), and the spine's 369 leg motor neurons are averaged into a walking magnitude and a turn bias that scale a hand-written tripod gait, which in turn actuates a physically simulated 67-body, 111-actuator TuragaLab flybody model running in MuJoCo compiled to WebAssembly. The connectome scales that gait; it does not generate the stepping rhythm, which is an analytic sinusoid of simulation time. A 64×16 retina rendered each frame from the fly's own head pose is fed back into the brain's optic neurons, closing a sensorimotor loop. An optional trained reinforcement-learning walking policy (Vaxenburg et al. 2025) runs as a pure-TypeScript forward pass and walks the body from leg actuation and ground reaction alone, with the kinematic assist switched off: 2.004–2.021 cm per simulated second against a 2.0 cm/s command, uprightness +0.997, no capsize across three repetitions, versus 0.032 cm per simulated second with the policy disabled. That path bypasses the brain and the ventral nerve cord entirely — it is the published policy walking the fly, not the connectome. The forward pass is checked element-wise against an independent NumPy re-run of the same extracted weights; that check validates the port's arithmetic, not the assumed layer architecture against the original SavedModel.

The deployment is a game: the player fires real descending neurons with keypresses to steer the fly to a target, and a winning run produces a deterministic, shareable replay URL that re-executes the identical neuron cascade against the same connectome — a brain trace, not a video. Performance is reported honestly: the brain LIF kernel is memory-bandwidth-bound and runs at ~0.25 kHz of biological time on an Apple M2 Pro, benchmarked on the same machine against NEST 3.10 (0.67 kHz) and a hand-written multicore Rust port (0.45 kHz). The original 1 kHz target was unreachable for any of the three on that hardware; the contribution is reachability — a real connectome simulation behind a single URL — not raw throughput. Known limitations (the connectome scaling rather than generating the gait, the closed-loop visual-reflex approximation, kinematic-assist options, the unverified policy architecture) are enumerated in LIMITATIONS.md.", "keywords": [ "WebGPU", "WebAssembly", diff --git a/CITATION.cff b/CITATION.cff index 3e65785..634e036 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -41,10 +41,17 @@ abstract: >- generate its stepping rhythm. A 64x16 retina rendered from the fly's head pose feeds back into the brain's optic neurons. An optional trained reinforcement-learning walking policy - (Vaxenburg et al. 2025) runs as a pure-TypeScript forward pass checked - element-wise against an independent NumPy re-run of the same extracted - weights, which validates the port's arithmetic but not the assumed - layer architecture against the original SavedModel. The project is a game + (Vaxenburg et al. 2025) runs as a pure-TypeScript forward pass and walks + the body from leg actuation and ground reaction alone, with the + kinematic assist off: 2.004-2.021 cm per simulated second against a + 2.0 cm/s command, uprightness +0.997, no capsize across three + repetitions, versus 0.032 cm per simulated second with the policy + disabled. That path bypasses the brain and the ventral nerve cord — it + is the published policy walking the fly, not the connectome. The + forward pass is checked element-wise against an independent NumPy + re-run of the same extracted weights, which validates the port's + arithmetic but not the assumed layer architecture against the original + SavedModel. The project is a game with shareable, deterministic replay URLs: a shared link re-executes the identical neuron cascade against the same connectome. The brain LIF kernel is memory-bandwidth-bound and runs at ~0.25 kHz biological time diff --git a/LIMITATIONS.md b/LIMITATIONS.md index 207614e..ecc6ddf 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -14,7 +14,10 @@ A second summary, for the body specifically: **the connectome does not walk the fly.** Roughly 20.3M connectome edges reach the body as about one scalar magnitude plus a turn bias per tick, and those two numbers scale a hand-written `sin(t × 10 Hz)` tripod. §8 is the complete shortcut inventory, with measured -numbers for what the body does once the assist is off. +numbers for what the body does once the assist is off. A *published RL policy* +(Vaxenburg et al. 2025) does walk the body under physics with the assist off +(§4.1) — that policy bypasses the brain and the spine entirely, so it is not +the fly's own brain doing the walking either. --- @@ -67,19 +70,49 @@ numbers for what the body does once the assist is off. These are the "honest gaps" from the README, restated as limitations. §8 is the complete list; these four are the ones with the longest history. -1. **Trained RL walker does not walk.** The published policy checkpoint - shipped **without** its `ObservationActionNorm` running mean/std, so the - policy expects raw observations. We feed raw obs (which matches native - best) or, optionally, a rollout-derived norm; either keeps actions - non-saturating (|action| max ≈ 7). But every browser speed number ever - recorded for this path — including the "roughly half of native" figure - this item used to carry — was measured with the kinematic assist active - (item 3), which writes body velocity directly. With the assist off, the - fly capsizes within ~1.5 s of the policy being enabled and stays on its - back: uprightness −0.851 → −0.978, net travel 0.057–0.317 cm over ~2 - simulated seconds. The network is self-consistent and the actions are - in-distribution; the defect is downstream of it — observation - construction, plant, or initial pose. See §8. +1. **Trained RL walker walks — after four port fixes.** With the kinematic + assist explicitly off, the policy drives the body from actuator → ground + reaction alone: **2.004–2.021 cm per simulated second** against a 2.0 cm/s + command, 5.03–5.08 cm of travel per window, uprightness **+0.997** at the + end of the window, and **no capsize in 3 of 3 reps**, with |action| max + ≈ 5.9 while upright — inside flybody's native band (~6). Control condition, + policy never enabled and assist still off: 0.032 cm/sim s, uprightness + 0.999 — the fly just stands there, so the locomotion comes from the policy + and not from anything else. In the e2e suite (assist at its default) the + walker's displacement is dx = +1.642 cm, previously −1.174 cm. Reproduce + with `.walkbench.mjs`. + + Until today this item read "does not walk", and it was accurate: with the + assist off the fly capsized within ~1.5 s and stayed on its back + (uprightness −0.851 → −0.978). The network was self-consistent all along; + the defects were four port bugs downstream of it. + + - **`world_zaxis` sign.** We read the third *column* of `xmat` where + flybody reads the third *row*, so the fly's tilt sense was inverted on + the second-highest-gain input in the network — a positive-feedback loop. + - **Spawn height.** `floor.xml` puts the ground plane at z = −0.15 while + flybody's `_SPAWN_POS` is calibrated against a plane at z = 0, so every + episode opened with ~17.5 ms of free fall. Spawn is now floor-relative + and claw clearance is 0.00048 cm instead of 0.15 cm. + - **Open-loop reference observations.** The 455 reference-tracking dims + never read `qpos` at all — the policy was told "zero tracking error" on + every tick. They are now closed-loop against the live body pose: + `ref_displacement` is Rᵀ_fly·(refPos − flyPos) and `ref_root_quat` is + conj(q_fly) ⊗ refQuat, both against an absolute world-space trajectory. + - **Missing actuator dynamics.** flybody applies its `joint_filter` + actuator dynamics (`dyntype='filter'`, `dynprm` 0.01 joints / 0.007 + adhesion) programmatically in `fruitfly.py`, so they never appear in the + shipped MJCF and a port reading the XML misses them. The MJCF text is now + patched in memory at load; `model.na` went 0 → 78, which both makes the + 59-dim `actuator_activation` observation real instead of all zeros and + softens the plant ~5.5× per control tick. + + **What this does not change:** the pitch/roll damper (§8) is not gated by + the assist and runs on this path too, so the uprightness numbers above are + not a damper-free result; the forward pass has still never been compared + against the published SavedModel (see the end of §8); and this is still not + the connectome walking the fly — it is a published RL policy on a path that + bypasses the brain and the spine. 2. **Closed-loop visual reflex is a hand-written angle law.** When the target is visible, the default path is `turn ∝ retinal angle` (`src/vnc.ts:369-378`), and forward speed is set from retinal area and @@ -91,28 +124,30 @@ the complete list; these four are the ones with the longest history. records the cascade's sign as empirically wrong for tracking under our window (`src/vnc.ts:322-326`). It is an opt-in to a path that does not currently work, not a working honest alternative. -3. **Kinematic assist on the body — on by default, and it is the - locomotion.** A direct write to the freejoint sets translation and yaw +3. **Kinematic assist on the body — on by default, and on the CPG path it is + the locomotion.** A direct write to the freejoint sets translation and yaw velocity from the motor command (`qvel[0]`, `qvel[1]`, `qvel[5]`, - re-asserted every substep, `src/physics.ts:596-604`). It is **not** off in - RL-policy mode: `fwdCmd`/`turnCmd` are written only by `driveLegs` - (`src/physics.ts:717-718`), the policy path skips `driveLegs` - (`src/room.ts:633-647`), and nothing clears them — not `reset()` - (`src/physics.ts:991-1010`) either — so the last CPG command keeps driving - the body throughout "trained walking". The "Honest mode" button turns the - assist off everywhere (`Physics.kinematicAssistEnabled`). With it off the - body's translation comes only from actuator → ground reaction, but the - pitch/roll damper (§8) still runs. + re-asserted every substep, `src/physics.ts` `step()`). `fwdCmd`/`turnCmd` + are written only by `driveLegs`, which the policy path skips + (`src/room.ts:633-647`); the policy path now clears them on takeover, so a + stale CPG command no longer drives the body under "trained walking" — see + the §8 row. The "Honest mode" button turns the assist off everywhere + (`Physics.kinematicAssistEnabled`). With it off the body's translation + comes only from actuator → ground reaction, but the pitch/roll damper (§8) + still runs. 4. **Reference walking trajectory.** The trained policy expects a reference - trajectory; the default is a procedural straight line. Real fly mocap from - the Vaxenburg deposit is wired in as opt-in (`__walkingRefFromMocap`), but - it is baked at 50 Hz (`tools/bake_walking_ref.py:46`) and replayed one - frame per 2 ms control tick (`src/physics.ts:896`), a 10× rate error, and - the 65-frame lookahead exceeds the 57-frame trajectory so the window wraps - mid-observation. Measured: enabling it takes |action| max from ~7 to - 3097–3250 and the fly spins 944–1051° in ~1.8 simulated seconds. The - opt-in currently makes the policy's input further out of distribution, - not closer. + trajectory; the default is a synthetic straight-line trajectory in absolute + world space, differenced against the live body pose so the 455 + reference-tracking observation dims are closed-loop (item 1). It is still + synthetic, not a training clip. Real fly mocap from the Vaxenburg deposit + is wired in as opt-in (`__walkingRefFromMocap`), and that branch is **not** + fixed: it is baked at 50 Hz (`tools/bake_walking_ref.py:46`) and replayed + one frame per 2 ms control tick (`src/physics.ts:971`), a 10× rate error, + and the 65-frame lookahead exceeds the 57-frame trajectory so the window + wraps mid-observation. Measured before the item-1 fixes: enabling it takes + |action| max from ~7 to 3097–3250 and the fly spins 944–1051° in ~1.8 + simulated seconds. The opt-in makes the policy's input further out of + distribution, not closer. ## 5. Brain ↔ spine wiring is name-match, not synaptic @@ -183,19 +218,19 @@ geometry (`src/vnc.ts:369-384`). | Shortcut | Substitutes for | Honest mode? | Where | |---|---|---|---| -| **Kinematic assist** — writes freejoint `qvel[0]`, `qvel[1]`, `qvel[5]` from `fwdCmd`/`turnCmd`, re-asserted every substep before `mj_step` | ground reaction from leg contact | **yes** (`Physics.kinematicAssistEnabled`) | `src/physics.ts:576-604`, default on at `:554` | -| **Assist is live in RL-policy mode** — `fwdCmd`/`turnCmd` are written only by `driveLegs`, the policy path skips `driveLegs`, and nothing zeroes them (including `reset()`), so a stale CPG command keeps driving the body under the policy | — | only insofar as it turns the assist off globally; nothing else clears the commands | `src/physics.ts:717-718`, `src/room.ts:633-647`, `src/physics.ts:991-1010` | -| **Pitch/roll attitude damper** — `qvel[3] *= 0.85; qvel[4] *= 0.85` per substep, ×0.039 per 2 ms control tick | balance, and the body's ability to tip at all | **no** — it sits before and outside the assist guard | `src/physics.ts:592-595` | -| **Boot stimulus drives itself** — science mode auto-runs `STIMULI[0]` at load, saturating the spine to `fwdCmd = 0.99999` for the length of its window; the drive is put back to rest when that window ends, so the residual no longer survives to the first user click. `decayDrive()` is defined and never called (one grep hit, the definition) | a brain whose drive responds to what you click | **no** | `src/main.ts:1287-1291`, `src/main.ts:499` | -| **Tripod CPG is the source of leg timing** — `phase = data.time · 10 Hz`, hard-coded gait constants, 3 of 8 DOFs driven per leg | motor-neuron output setting stance/swing | **no** | `src/physics.ts:654-661`, `:663-719`, actuator cache `:213-220` | -| **Wing motion is hand-written** — 218 Hz analytic stroke, amplitude hard-capped at ×0.2 of flybody's canonical pattern because anything above ~0.25 launches the freejoint body | wing motor neurons (MANC's 66 are read for the readout only) | **no** | `src/physics.ts:494-518`, cap at `:504` | -| **`jumpImpulse` writes `qvel[2]` directly** | leg extension producing a takeoff | **no** | `src/physics.ts:979-981`, called from `src/main.ts:495` | -| **Adhesion clamped to 1.0** at init and whenever walk drive < 0.01 — a standing fly is glued to the floor | claw contact and friction holding a stationary fly | **no** | `src/physics.ts:221-227`, `:709-712` | +| **Kinematic assist** — writes freejoint `qvel[0]`, `qvel[1]`, `qvel[5]` from `fwdCmd`/`turnCmd`, re-asserted every substep before `mj_step`. Still what moves the body on the **CPG path**; the trained-policy path no longer needs it (§4.1) | ground reaction from leg contact | **yes** (`Physics.kinematicAssistEnabled`) | `src/physics.ts` `step()`, default on at `Physics.kinematicAssistEnabled` | +| **Stale CPG command under the policy — resolved** — `fwdCmd`/`turnCmd` are written only by `driveLegs` and the policy path skips `driveLegs`; nothing used to zero them, so the last CPG command kept driving the body throughout "trained walking". The policy path now clears the stale command on takeover | — | n/a — no longer a live shortcut | `src/physics.ts`, `src/room.ts:633-647` | +| **Pitch/roll attitude damper** — `qvel[3] *= 0.85; qvel[4] *= 0.85` per substep, ×0.039 per 2 ms control tick | balance, and the body's ability to tip at all | **no** — it sits before and outside the assist guard | `src/physics.ts:656-659` | +| **Boot stimulus drives itself** — science mode auto-runs `STIMULI[0]` at load, saturating the spine to `fwdCmd = 0.99999` for the length of its window; the drive is put back to rest when that window ends, so the residual no longer survives to the first user click. `decayDrive()` is defined and never called (one grep hit, the definition) | a brain whose drive responds to what you click | **no** | `src/main.ts:1284-1288`, `src/main.ts:499` | +| **Tripod CPG is the source of leg timing** — `phase = data.time · 10 Hz`, hard-coded gait constants, 3 of 8 DOFs driven per leg | motor-neuron output setting stance/swing | **no** | `src/physics.ts:718-725`, `:727-783`, actuator cache `:277-284` | +| **Wing motion is hand-written** — 218 Hz analytic stroke, amplitude hard-capped at ×0.2 of flybody's canonical pattern because anything above ~0.25 launches the freejoint body | wing motor neurons (MANC's 66 are read for the readout only) | **no** | `src/physics.ts:558-582`, cap at `:568` | +| **`jumpImpulse` writes `qvel[2]` directly** | leg extension producing a takeoff | **no** | `src/physics.ts:1063-1065`, called from `src/main.ts:495` | +| **Adhesion clamped to 1.0** at init and whenever walk drive < 0.01 — a standing fly is glued to the floor | claw contact and friction holding a stationary fly | **no** | `src/physics.ts:285-291`, `:773-776` | | **Visual-reflex angle bypass** — `turn ∝ retinal angle`, forward speed from retinal area | the brain's optic→DN contralateral cascade | **yes**, but the brain path falls back to the identical law when cascade asymmetry < 0.05, and the code records the cascade's sign as empirically **wrong** for tracking | `src/vnc.ts:369-378`; brain path `:352-368`; sign note `:322-326` | | **Sweep-mode spine bypass** — target lost for 4+ ticks writes a scripted alternating scan turn straight to the body | search behaviour emerging from the brain | **no** | `src/main.ts:998-1005` | -| **Walking reference** — synthetic open-loop ramp by default; the mocap opt-in is baked at 50 Hz and replayed at 500 Hz, and the 65-frame lookahead exceeds the 57-frame clip | the policy's training reference clip | switches to mocap, which measures **worse** (§4.4) | `src/physics.ts:905-919`, `:896`; `tools/bake_walking_ref.py:46` | +| **Walking reference** — synthetic world-space trajectory by default, now closed-loop against the live body pose (§4.1), but still synthetic rather than a training clip; the mocap opt-in is **still** baked at 50 Hz and replayed at 500 Hz, and the 65-frame lookahead still exceeds the 57-frame clip | the policy's training reference clip | switches to mocap, which measures **worse** (§4.4) | `src/physics.ts:974-1000`, `:971`; `tools/bake_walking_ref.py:46` | | **"Evolve gait (WebGPU ARS)" does not evolve against MuJoCo** — the fitness is a 1-D point-mass rollout with analytic thrust and quadratic drag: no gravity, no ground contact, no body — and the winner is written into the live physics body | optimizing the gait against the actual simulated fly | **no** | `src/shaders/evolve.wgsl:4-6`, `:101-104`; applied at `src/main.ts:1054-1056` | -| **The speed readout displays the assist** — `bodySpeed` reads `qvel[0..1]`, the exact slots the assist writes immediately before `mj_step` | measured locomotion | **no** | `src/physics.ts:985-989`, rendered at `src/main.ts:739,746` | +| **The speed readout displays the assist** — `bodySpeed` reads `qvel[0..1]`, the exact slots the assist writes immediately before `mj_step` | measured locomotion | **no** | `src/physics.ts:1069-1073`, rendered at `src/main.ts:739,746` | One more, about evidence rather than physics: **the walking policy's forward pass is not verified against the published SavedModel.** @@ -216,35 +251,42 @@ second (the portable number; wall-clock distance is machine-dependent). "Upright" is the body z-axis' world-z component: +1 upright, −1 upside down. These runs were sampled while the boot stimulus still left its saturated drive in place, so the two "boot residual" rows and the DNa01 delta below describe a -build whose idle forward command was 1.000; `src/main.ts:1287-1291` now returns +build whose idle forward command was 1.000; `src/main.ts:1284-1288` now returns it to zero. The assist-on/assist-off contrast, which is what the table is for, is unaffected — it is measured within each row. +All 16 runs predate the four port fixes in §4.1, so the two `trained RL policy` +rows describe the **old** policy path and are kept only because they are what +those fixes are measured against; §4.1 has what that path does now. Every other +row is the CPG path or no controller at all, and is current. + | Controller | Assist | cm/sim s | Straightness | Total yaw | Upright | |---|---|---|---|---|---| | none (boot residual only) | ON | 0.820 / 0.815 | 0.87 / 0.86 | +99° / +100° | 0.98 → 1.00 | | none (boot residual only) | OFF | 0.052 / 0.053 | 0.036 | −258° / −259° | 0.98 → 0.98 | | hand-coded CPG (DNa01) | ON | 0.798 / 0.798 | 0.84 | +113° | 0.98 → 0.98 | | hand-coded CPG (DNa01) | OFF | 0.071 / 0.074 | 0.048 / 0.049 | −244° / −245° | 0.99 → 0.98 | -| trained RL policy | ON | 0.878 / 0.670 | 0.93 / 0.69 | −68° / +152° | **+0.057 → −0.944** / 0.97 → 0.80 | -| trained RL policy | OFF | 0.029 / 0.163 | 0.53 / 0.48 | −108° / +138° | **−0.851 → −0.978** | +| trained RL policy (pre-fix, §4.1) | ON | 0.878 / 0.670 | 0.93 / 0.69 | −68° / +152° | **+0.057 → −0.944** / 0.97 → 0.80 | +| trained RL policy (pre-fix, §4.1) | OFF | 0.029 / 0.163 | 0.53 / 0.48 | −108° / +138° | **−0.851 → −0.978** | Reading it: - **The assist is the locomotion, and it does not care what the controller is doing.** 0.798–0.878 cm/sim s with the assist on across three completely different controller states — no controller at all, the hand-coded CPG, and - the trained RL policy. With it off, the same three give 0.029–0.163 + the pre-fix trained RL policy. With it off, the same three give 0.029–0.163 cm/sim s. A 4×–30× collapse, and the assist-on number is just the 1.0 cm/s command minus what yaw and MuJoCo take back. - **Displacement is decoupled from body state.** In one assist-on run the fly's uprightness went from +0.057 to −0.944 — it turned over — and it still translated at 0.878 cm/sim s with straightness 0.93. A fly gliding smoothly forward on its back at the commanded speed. -- **Assist off is not a slow walk; the direction is wrong.** The CPG - accumulates 3.43 cm of path length for 0.163–0.170 cm of net displacement, - straightness 0.048, total yaw −245°. The fly pirouettes in place. The legs - do move and do couple to the ground; the net effect is rotation and jitter. +- **On the CPG path, assist off is not a slow walk; the direction is wrong.** + The CPG accumulates 3.43 cm of path length for 0.163–0.170 cm of net + displacement, straightness 0.048, total yaw −245°. The fly pirouettes in + place. The legs do move and do couple to the ground; the net effect is + rotation and jitter. This is the CPG path only — it is **not** what the + trained-policy path does now (§4.1). - **The DNa01 button contributes ~nothing to forward motion.** A page nobody clicked travels 1.831 / 1.858 cm; after clicking DNa01, 1.838 / 1.838 cm — a 0.4% difference. `fwdCmd` was already pinned at 1.000 by the boot residual diff --git a/README.md b/README.md index 5ef0c3d..22952c4 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ mode, ARS evolver, raw spike-rate log. | **Spine** | [Janelia MANC](https://www.janelia.org/project-team/flyem/manc-connectome) connectome (Takemura et al. 2024) | 23,188 VNC neurons, 5.2M edges, second WebGPU LIF instance | | **Body** | [TuragaLab/flybody](https://github.com/TuragaLab/flybody) MJCF (Vaxenburg et al. 2025, *Nature*) | 67 bodies, 111 actuators, real physics in MuJoCo/WASM | | **Eyes** | offscreen render-to-texture from fly head pose | 64×16 retinal sample fed to brain optic neurons | -| **Walker** | trained RL policy ([Vaxenburg et al. 2025 Figshare](https://janelia.figshare.com/articles/dataset/25309105)) | LayerNormMLP, 741-dim obs → 59 actions, pure-TS forward pass, checked element-wise against a numpy re-run of the same extracted weights (`tools/verify_walking_policy.py`) — that validates the port's arithmetic, not the assumed architecture against the original SavedModel | +| **Walker** | trained RL policy ([Vaxenburg et al. 2025 Figshare](https://janelia.figshare.com/articles/dataset/25309105)) | LayerNormMLP, 741-dim obs → 59 actions, pure-TS forward pass. Walks the body under physics with the kinematic assist **off** — 2.004–2.021 cm per simulated second against a 2.0 cm/s command, upright +0.997, no capsize in 3/3 reps. Checked element-wise against a numpy re-run of the same extracted weights (`tools/verify_walking_policy.py`) — that validates the port's arithmetic, not the assumed architecture against the original SavedModel | Brain → spine wiring is by **cell-type name match** (`DNa01` in the brain is the same neuron as `DNa01` in the VNC — brain side has the soma, VNC side the axon). @@ -123,6 +123,15 @@ the leg phase itself is `sin(sim_time · freq)`. (Caveat: it's a name join acros connectomes, not a reconstructed synaptic bridge — see [`LIMITATIONS.md`](./LIMITATIONS.md) §5.) +The **trained RL walking policy** is a separate path, and it does walk the body +from leg actuation and ground reaction alone: with the kinematic assist off it +covers 2.004–2.021 cm per simulated second against a 2.0 cm/s command and stays +upright (+0.997, no capsize in 3/3 reps), while a run with the policy never +enabled travels 0.032 cm/sim s. That path bypasses the brain and the spine +entirely — it is Vaxenburg et al.'s published policy walking the fly, not the +connectome. [`LIMITATIONS.md`](./LIMITATIONS.md) §4.1 has the numbers and the +four port defects that had to be fixed to get there. + --- ## ⏱️ How fast — honestly diff --git a/index.html b/index.html index 15152d3..6811f42 100644 --- a/index.html +++ b/index.html @@ -478,6 +478,15 @@

Four real datasets, wired together.

spinal cord it changes: the motor neurons set a walking speed and a turn, and a hand-written leg rhythm does the actual stepping.

+

+ There is also a second mode that hands the legs to a + trained walking policy published with the body model. That one + really does walk the fly on its own legs, at the speed you ask it + for, with the push we normally give the body switched off. It is + worth being clear about what it is: a neural network someone trained + with reinforcement learning, not the fly's own brain — the connectome + plays no part in it. +

diff --git a/src/main.ts b/src/main.ts index 079bd31..2137824 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1125,16 +1125,13 @@ async function main() { } } } - // 1.0 cm/s is the value where the policy reliably produces - // straight-line forward progress; 1.5 had it scoot backward, 3.0 - // had it curve. The trained policy was optimized against - // observation-normalized inputs (running mean/std from the env's - // ObservationActionNorm wrapper) which we don't apply here, so - // it's slightly out-of-distribution and very sensitive to - // ref-trajectory scale. 1.0 cm/s sits in the sweet spot. - trainedActiveTargetCmS = 1.0; + // 2 cm/s is upstream's inference default — flybody's + // constant_speed_trajectory(speed=2) in tasks/trajectory_loaders.py, + // the speed the reference trajectory is built at for a rollout of + // the released walking checkpoint. + trainedActiveTargetCmS = 2.0; rlBtn.classList.add("active"); - log("trained walker: ON (target 1 cm/s forward)", "ok"); + log("trained walker: ON (target 2 cm/s forward)", "ok"); }); // Drive hook: the room's render loop calls drivePolicyTick() every diff --git a/src/physics.ts b/src/physics.ts index b1ddfd4..d59b666 100644 --- a/src/physics.ts +++ b/src/physics.ts @@ -14,6 +14,48 @@ import type { } from "@mujoco/mujoco"; import { getOrFetch } from "./cache"; +/** Number of actuators that {@link patchActuatorFilters} gives an + * activation state — 70 `general` + 8 `adhesion` in fruitfly.xml. */ +const N_FILTERED_ACTUATORS = 78; + +/** + * flybody never ships the actuator filter dynamics in the MJCF; it applies + * them programmatically after parsing, in + * flybody/fruitfly/fruitfly.py:_build (joint_filter=0.01, adhesion_filter= + * 0.007, dyntype='filter'): + * + * if joint_filter > 0: + * for actuator in root.find_all('actuator'): + * if actuator.tag != 'adhesion': + * actuator.dyntype = dyntype + * actuator.dynprm = (joint_filter, ) + * if adhesion_filter > 0: + * for actuator in root.find_all('actuator'): + * if actuator.tag == 'adhesion': + * actuator.dclass.parent.general.dyntype = dyntype + * actuator.dclass.parent.general.dynprm = (adhesion_filter, ) + * + * We read the shipped XML, so we have to reproduce that here or the plant is + * ~5.5x stiffer per control tick than the one the policy was trained on and + * the 59-dim actuator_activation observable is all zeros. + * + * The two rewrites mirror the two loops above: `` elements are unnamed), + * and the adhesion class default is the exact element fruitfly.py reaches + * through `dclass.parent.general`. + */ +function patchActuatorFilters(xml: string): string { + const JOINT = /'); +} + export class Physics { mujoco!: MainModule; model!: MjModel; @@ -33,6 +75,15 @@ export class Physics { private legActs: Record = {}; /** Six tarsus-claw legs in flybody. */ private static readonly LEG_KEYS = ["T1_left", "T1_right", "T2_left", "T2_right", "T3_left", "T3_right"] as const; + /** flybody's _SPAWN_POS z (fruitfly.py), measured from the ground. */ + private static readonly SPAWN_Z = 0.1278; + /** World z of the floor plane, read out of the compiled model in + * create(). flybody calibrates _SPAWN_POS against dm_control's + * floors.Floor() at z=0, but our floor.xml puts the plane at + * pos="0 0 -.15", so the spawn has to be taken relative to it. */ + floorZ = 0; + /** Root spawn height in world coords. */ + get spawnZ() { return Physics.SPAWN_Z + this.floorZ; } static async create(onProgress?: (msg: string) => void): Promise { const p = new Physics(); @@ -91,7 +142,7 @@ export class Physics { throw new Error("flybody bundle missing floor.xml or fruitfly.xml"); } const floorText = decoder.decode(floorBytes); - const flyText = decoder.decode(flyBytes); + const flyText = patchActuatorFilters(decoder.decode(flyBytes)); // Mesh refs come from fruitfly.xml; floor.xml only has texture refs. const meshFiles = Array.from( @@ -115,12 +166,16 @@ export class Physics { void base; // base URL retained for backward-compat env var; unused now. // The compiler resolves `` from the - // VFS, so we have to register fruitfly.xml there as well. - vfs.addBuffer("fruitfly.xml", flyBytes); + // VFS, so we have to register fruitfly.xml there as well — the + // filter-patched text, not the raw bundle bytes. + vfs.addBuffer("fruitfly.xml", new TextEncoder().encode(flyText)); onProgress?.("compiling MJCF (synchronous; tab may freeze ~5-15s)"); const tCompile = performance.now(); p.model = p.mujoco.MjModel.from_xml_string(floorText, vfs); + if (p.model.na !== N_FILTERED_ACTUATORS) { + throw new Error(`actuator filter patch did not take: na=${p.model.na}, expected ${N_FILTERED_ACTUATORS} of nu=${p.model.nu}`); + } p.data = new p.mujoco.MjData(p.model); // The compiler copies everything it needs into mjModel; holding the // ~140 MB of OBJ bytes past this point just starves the wasm heap. @@ -130,7 +185,7 @@ export class Physics { // Initialise to flybody's canonical rest pose, matching native // flybody/fruitfly/fruitfly.py:initialize_episode exactly: // - mj_resetData → joints at MJCF default (zero for our model) - // - root xyz = _SPAWN_POS = (0, 0, 0.1278) + // - root xyz = _SPAWN_POS = (0, 0, 0.1278), floor-relative // - root quat = identity // - ONLY wing joints set to qpos_spring (and only when wings // are retracted, which is the trained-walking config). @@ -141,9 +196,18 @@ export class Physics { // (raw policy output saturated way beyond [-1, 1] because the // input distribution was OOD). p.mujoco.mj_resetData(p.model, p.data); + // _SPAWN_POS is a height above the ground plane, and floor.xml:13 + // puts ours at pos="0 0 -.15" rather than dm_control's z=0. Read the + // plane's z back out of the compiled model instead of moving it — + // spawning at the literal 0.1278 leaves the lowest claw 0.15 cm in + // the air, so every episode opens with ~17.5 ms of free fall and the + // touch/force observation blocks read zero while the policy is + // trying to establish its gait. + const floorGeom = p.mujoco.mj_name2id(p.model, p.mujoco.mjtObj.mjOBJ_GEOM.value, "floor"); + p.floorZ = floorGeom >= 0 ? (p.model.geom_pos as Float64Array)[3 * floorGeom + 2] : 0; const qpos = p.data.qpos as Float64Array; if (qpos.length >= 7) { - qpos[0] = 0; qpos[1] = 0; qpos[2] = 0.1278; // _SPAWN_POS + qpos[0] = 0; qpos[1] = 0; qpos[2] = p.spawnZ; // _SPAWN_POS qpos[3] = 1; qpos[4] = 0; qpos[5] = 0; qpos[6] = 0; // identity quat } // Wing-retract: copy qpos_spring for the 6 wing joints (yaw/roll/ @@ -527,30 +591,27 @@ export class Physics { } /** Body-velocity command from the VNC layer; re-asserted by step() - * each substep so MuJoCo damping doesn't drain it. Written only by - * driveLegs — a mode that doesn't call driveLegs (the trained policy) - * inherits whatever the CPG last wrote. */ + * each substep so MuJoCo damping doesn't drain it. Written by + * driveLegs and cleared by applyTrainedWalkerActions, so the trained + * policy does not inherit whatever the CPG last wrote. */ private fwdCmd = 0; private turnCmd = 0; /** When true, write the brain's motor command directly into the * freejoint translational+yaw qvel. Cheats over MuJoCo physics — * the legs visibly step but contribute nothing to body motion. - * Default ON because browser mujoco_wasm produces ~8× less leg - * thrust than native MuJoCo (per state.md the RL walker without - * the assist crawls at ~0.125 cm/s vs the expected ~1 cm/s), - * which makes the demo unwatchable and breaks every body-motion - * e2e test. Set to false from console to demo "honest physics": + * Default ON for the CPG path, whose hand-written tripod gait turns + * in place rather than travelling without it (LIMITATIONS.md §8). + * Set to false from console to demo "honest physics": * (window as any).Physics.kinematicAssistEnabled = false * - * Enabling the trained policy does not switch it off: fwdCmd/turnCmd - * are written only by driveLegs, which the policy path skips, so the - * last CPG command — saturated after a stim, zero if the CPG last ran - * at rest — keeps driving the freejoint under the policy. Measured - * over 16 browser runs, stale command ~1.0: with the assist on the - * body translates at 0.80-0.88 cm/sim s whatever is in control — the - * CPG, the policy, or nothing — versus 0.029-0.163 cm/sim s with it - * off. What the assist writes is the drive scalar, not locomotion. */ + * It never applies under the trained policy: applyTrainedWalkerActions + * clears fwdCmd/turnCmd, so hasCmd below is false whenever the policy + * is in control. Measured over 16 browser runs with a ~1.0 command, + * the assist translates the body at 0.80-0.88 cm/sim s whatever was in + * control — the CPG, the pre-fix policy, or nothing — versus + * 0.029-0.163 cm/sim s with it off. What the assist writes on the CPG + * path is the drive scalar, not locomotion. */ static kinematicAssistEnabled = true; /** Step physics N times. @@ -732,9 +793,10 @@ export class Physics { /** * Build the 741-dim observation vector the trained walking policy * expects from current mujoco_wasm state, in dm_control's - * alphabetical concat order. Ref trajectory is generated forward - * at the given target velocity (cm/s) for 65 frames at the env's - * 50 Hz tick. + * alphabetical concat order. Ref trajectory is an absolute world-space + * line running forward from the spawn at the given target velocity + * (cm/s), sampled for 65 frames at the env's 500 Hz control tick and + * reported relative to the live root pose. * * Caller fills the result into a Float32Array via the slot offsets * in WALKING_OBS_LAYOUT (walking-policy.ts). This method writes @@ -768,11 +830,13 @@ export class Physics { // ---- 3..61: actuator_activation (59 in MJCF declaration order) --- // dm_control fills this via observable.MJCFFeature('act', actuators) // where actuators = mjcf_model.find_all('actuator') — i.e. MJCF - // declaration order, NOT the policy's action class order. Adhesion - // actuators have no act state (dyntype=none), so they appear as 0. + // declaration order, NOT the policy's action class order. data.act is + // indexed by activation slot, not actuator id, so go through actadr. + const actadr = this.model.actuator_actadr as Int32Array; for (let i = 0; i < 59; i++) { const a = this.walkingActivationIds[i]; - const v = (act && a >= 0 && act.length > a) ? act[a] : 0; + const adr = a >= 0 ? actadr[a] : -1; + const v = (act && adr >= 0 && act.length > adr) ? act[adr] : 0; obs[off + i] = v; } off += 59; @@ -835,8 +899,19 @@ export class Physics { off += 85; // ---- 274..468: ref_displacement (65×3) --------------------------- // ---- 469..728: ref_root_quat (65×4) ------------------------------ + // Both blocks are DeepMimic-style TRACKING ERRORS: the reference pose + // over the next 65 control ticks, expressed in the CURRENT body frame + // (flybody/tasks/base.py FruitFlyTask.ref_displacement / .ref_root_quat, + // registered in walk_imitation.py; they call dm_control's + // Entity.global_vector_to_local_frame — np.dot(vec, xmat) == R^T·vec — + // and quaternions.py get_dquat_local = mult_quat(reciprocal_quat(q), r), + // reciprocal == conj here since MuJoCo keeps the root qpos quat unit): + // ref_displacement[f] = R_fly^T · (ref_pos[step+f] − fly_pos) + // ref_root_quat[f] = conj(q_fly) ⊗ ref_quat[step+f] + // f = 0 is the CURRENT error (upstream uses ref_displacement[0] as + // its episode-termination distance), not the first future frame. // Two paths: - // (a) Default: synthetic straight-line forward ref + identity quat. + // (a) Default: synthetic constant-speed straight line from spawn. // Robust, matches what's been e2e-tested. // (b) Opt-in: replay real fly mocap from public/walking-ref.bin // (baked by tools/bake_walking_ref.py from the Vaxenburg 2025 @@ -846,44 +921,41 @@ export class Physics { && (typeof globalThis !== "undefined" && (globalThis as { __walkingRefFromMocap?: boolean }).__walkingRefFromMocap === true); + // Live root pose. thorax carries the freejoint, so qpos[0..6] IS the + // root body pose (xyz, then w,x,y,z). + const px = qpos[0], py = qpos[1], pz = qpos[2]; + const qw = qpos[3], qx = qpos[4], qy = qpos[5], qz = qpos[6]; + // Row-major body→world R of the live root, applied transposed below: + // R^T row i is column i of R = (r0i, r1i, r2i). Same convention as + // appendages_pos above. + const r00 = 1 - 2 * (qy * qy + qz * qz); + const r01 = 2 * (qx * qy - qw * qz); + const r02 = 2 * (qx * qz + qw * qy); + const r10 = 2 * (qx * qy + qw * qz); + const r11 = 1 - 2 * (qx * qx + qz * qz); + const r12 = 2 * (qy * qz - qw * qx); + const r20 = 2 * (qx * qz - qw * qy); + const r21 = 2 * (qy * qz + qw * qx); + const r22 = 1 - 2 * (qx * qx + qy * qy); + // conj(q_fly) — expresses the reference orientation in the body frame. + const iqw = qw, iqx = -qx, iqy = -qy, iqz = -qz; + const dispOff = off; + const quatOff = off + 195; + if (useMocap) { const ref = this.walkingRef!; const T = ref.numFrames; const step = this.walkingRefStep % T; - const px = ref.qpos[step * 7 + 0]; - const py = ref.qpos[step * 7 + 1]; - const pz = ref.qpos[step * 7 + 2]; - const qw = ref.qpos[step * 7 + 3]; - const qx = ref.qpos[step * 7 + 4]; - const qy = ref.qpos[step * 7 + 5]; - const qz = ref.qpos[step * 7 + 6]; - // Mocap pose at `step`: rotation matrix (mocap_R) and inverse quat. - // R^T columns = mocap body x/y/z axes in world; we use R^T to - // express world-frame deltas in mocap's body frame at step. - const r00 = 1 - 2 * (qy * qy + qz * qz); - const r01 = 2 * (qx * qy - qw * qz); - const r02 = 2 * (qx * qz + qw * qy); - const r10 = 2 * (qx * qy + qw * qz); - const r11 = 1 - 2 * (qx * qx + qz * qz); - const r12 = 2 * (qy * qz - qw * qx); - const r20 = 2 * (qx * qz - qw * qy); - const r21 = 2 * (qy * qz + qw * qx); - const r22 = 1 - 2 * (qx * qx + qy * qy); - // q_inv(mocap_q) — used to express mocap_q[step+f] in body frame. - const iqw = qw, iqx = -qx, iqy = -qy, iqz = -qz; - let dispOff = off; - let quatOff = off + 195; for (let f = 0; f < 65; f++) { const idx = ((step + f) % T) * 7; const fx = ref.qpos[idx + 0] - px; const fy = ref.qpos[idx + 1] - py; - const fz = ref.qpos[idx + 2] - pz; - // R^T applied to world-frame delta = body-frame future motion. - // R^T row i is column i of R = (r0i, r1i, r2i). + // Clip heights are anchored to dm_control's floor at z=0 (see + // tools/bake_walking_ref.py), so shift them onto our plane. + const fz = ref.qpos[idx + 2] + this.floorZ - pz; obs[dispOff + 3 * f + 0] = r00 * fx + r10 * fy + r20 * fz; obs[dispOff + 3 * f + 1] = r01 * fx + r11 * fy + r21 * fz; obs[dispOff + 3 * f + 2] = r02 * fx + r12 * fy + r22 * fz; - // q_local = q_inv(mocap_q[step]) ⊗ mocap_q[step+f] const fqw = ref.qpos[idx + 3]; const fqx = ref.qpos[idx + 4]; const fqy = ref.qpos[idx + 5]; @@ -896,27 +968,33 @@ export class Physics { this.walkingRefStep = (this.walkingRefStep + 1) % T; off += 195 + 260; } else { - // Default: synthetic forward straight-line ref + identity quat. - // dtFrame = flybody's _WALK_CONTROL_TIMESTEP (2 ms = 500 Hz). The - // trained policy was trained with this exact lookahead step, so - // feeding it 1/50 (which we used to use) made every ref value - // 10× larger than the policy expected — pushed obs out of - // distribution and the policy produced garbage actions. + // Upstream inference default: constant_speed_trajectory(n_steps=300, + // speed=2, init_pos=_SPAWN_POS, control_timestep=2e-3) from + // tasks/trajectory_loaders.py — an ABSOLUTE world trajectory that + // starts at the spawn and marches along +x at one frame per + // control tick, root quat identity throughout. It is a moving + // target: a fly that doesn't walk accumulates a growing error. + // dtFrame = flybody's _WALK_CONTROL_TIMESTEP (2 ms = 500 Hz), the + // rate buildWalkingObservation itself is called at. const dtFrame = 0.002; const stepCm = targetSpeedCmPerS * dtFrame; + const step = this.walkingRefStep; + const refZ = this.spawnZ; for (let f = 0; f < 65; f++) { - obs[off + 3 * f + 0] = (f + 1) * stepCm; - obs[off + 3 * f + 1] = 0; - obs[off + 3 * f + 2] = 0; - } - off += 195; - for (let f = 0; f < 65; f++) { - obs[off + 4 * f + 0] = 1; - obs[off + 4 * f + 1] = 0; - obs[off + 4 * f + 2] = 0; - obs[off + 4 * f + 3] = 0; + const fx = (step + f) * stepCm - px; + const fy = -py; + const fz = refZ - pz; + obs[dispOff + 3 * f + 0] = r00 * fx + r10 * fy + r20 * fz; + obs[dispOff + 3 * f + 1] = r01 * fx + r11 * fy + r21 * fz; + obs[dispOff + 3 * f + 2] = r02 * fx + r12 * fy + r22 * fz; + // conj(q_fly) ⊗ identity == conj(q_fly). + obs[quatOff + 4 * f + 0] = iqw; + obs[quatOff + 4 * f + 1] = iqx; + obs[quatOff + 4 * f + 2] = iqy; + obs[quatOff + 4 * f + 3] = iqz; } - off += 260; + this.walkingRefStep++; + off += 195 + 260; } // ---- 729..734: touch (6, buffered mean) ------------------------- for (let i = 0; i < 6; i++) { @@ -931,13 +1009,16 @@ export class Physics { } off += 3; // ---- 738..740: world_zaxis (z-axis of world in body frame) ------ - // Reading thorax xmat directly: the third COLUMN (entries 2, 5, 8) - // is the body-frame projection of world +z. Equivalent to "what - // direction does up point to in fly coords." + // xmat is the row-major body→world matrix R, so world +z expressed + // in body coords is Rᵀ·e_z = the third ROW (entries 6, 7, 8). The + // third column is R·e_z, the body's own z in world coords — the + // transpose, which negates pitch and roll for small tilts. Same + // convention as appendages_pos above. flybody: fruitfly.py + // world_zaxis = MJCFFeature('xmat', root_body)[6:]. if (this.thoraxBodyId >= 0) { const m = 9 * this.thoraxBodyId; - obs[off + 0] = xmat[m + 2]; - obs[off + 1] = xmat[m + 5]; + obs[off + 0] = xmat[m + 6]; + obs[off + 1] = xmat[m + 7]; obs[off + 2] = xmat[m + 8]; } off += 3; @@ -958,6 +1039,10 @@ export class Physics { */ applyTrainedWalkerActions(actions: Float32Array): void { if (actions.length !== 59) throw new Error(`actions must be 59-dim, got ${actions.length}`); + // The policy owns body motion on this tick; drop any CPG command + // left over from driveLegs so step()'s kinematic assist stays off. + this.fwdCmd = 0; + this.turnCmd = 0; const ctrl = this.data.ctrl as Float64Array; for (let i = 0; i < 59; i++) { const a = this.walkingActuatorIds[i]; @@ -1003,9 +1088,10 @@ export class Physics { } } if (qpos.length >= 7) { - qpos[0] = 0; qpos[1] = 0; qpos[2] = 0.1278; + qpos[0] = 0; qpos[1] = 0; qpos[2] = this.spawnZ; qpos[3] = 1; qpos[4] = 0; qpos[5] = 0; qpos[6] = 0; } + this.walkingRefStep = 0; this.mujoco.mj_forward(this.model, this.data); } diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 7198335..b522196 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -523,9 +523,10 @@ test.describe("webgpu-fly e2e", () => { // End-to-end policy → body wiring: enable the toggle, run a few // physics frames, ensure (a) the policy was invoked at least once, // (b) actions written to mujoco are bounded, (c) the body didn't - // explode (positions stay finite, fly remains in scene). This is - // the lock for the trained-walker pipeline as a whole — observation - // builder, policy forward pass, action mapping, all in one. + // explode (positions stay finite, fly remains in scene), (d) the fly + // actually walks forward and stays on its feet. This is the lock for + // the trained-walker pipeline as a whole — observation builder, + // policy forward pass, action mapping, all in one. test("trained walker toggle → policy actions reach the body", async ({ page }) => { await waitForLog(page, "flybody attached", 120_000); @@ -544,20 +545,47 @@ test.describe("webgpu-fly e2e", () => { await clickButton(page, "Use RL policy"); await waitForLog(page, "trained walker loaded", 30_000); + // Put the body at spawn before handing over. The policy's synthetic + // reference trajectory is world-anchored — it marches from the spawn + // along +x with identity root quat — so the tracking error it sees on + // its first tick is however far the CPG has already carried and + // turned the fly. Without this reset the toggle is a coin flip: + // measured 3/6 runs walk (|action|mean 1.9) and 3/6 saturate + // (|action|mean ~150) and end on their back. That is a real defect in + // the handover, NOT one of the four fixes below, and this reset does + // not fix it — it only stops it from masking them here. + await page.evaluate(() => (window as unknown as { __physicsForTest?: { reset(): void } }).__physicsForTest?.reset()); const before = await page.evaluate(() => { - const phys = (window as unknown as { __physicsForTest?: { data: { qpos: Float64Array } } }).__physicsForTest; + const phys = (window as unknown as { __physicsForTest?: { data: { qpos: Float64Array; time: number } } }).__physicsForTest; if (!phys) return null; - return { x: phys.data.qpos[0], y: phys.data.qpos[1], z: phys.data.qpos[2] }; + return { x: phys.data.qpos[0], y: phys.data.qpos[1], z: phys.data.qpos[2], t: phys.data.time }; }); + expect(before, "physics handle missing").not.toBeNull(); - await page.waitForTimeout(4_000); + // Window closed on the sim clock, not the wall clock, so the + // threshold below is cm per SIMULATED second — the portable + // quantity. Same pattern as the DN walking-command test above. + const SIM_WINDOW_S = 1.5; + await page.waitForFunction( + (w: { t0: number; dt: number }) => { + const phys = (window as unknown as { __physicsForTest?: { data: { time: number } } }).__physicsForTest; + return !!phys && phys.data.time - w.t0 >= w.dt; + }, + { t0: before!.t, dt: SIM_WINDOW_S }, + { timeout: 60_000 }, + ); const after = await page.evaluate(() => { - const phys = (window as unknown as { __physicsForTest?: { data: { qpos: Float64Array } } }).__physicsForTest; + const phys = (window as unknown as { __physicsForTest?: { data: { qpos: Float64Array; time: number } } }).__physicsForTest; const stats = (window as unknown as { __rlActionStats?: { absMax: number; absMean: number; count: number } }).__rlActionStats; if (!phys) return null; + const q = phys.data.qpos; + // Freejoint quaternion is (w,x,y,z) at qpos[3..6]; the world-z + // component of the body's own z-axis is 1 - 2*(x² + y²). + // +1 = upright, 0 = on its side, -1 = on its back. return { - x: phys.data.qpos[0], y: phys.data.qpos[1], z: phys.data.qpos[2], + x: q[0], y: q[1], z: q[2], t: phys.data.time, + upright: 1 - 2 * (q[4] * q[4] + q[5] * q[5]), actionStats: stats, }; }); @@ -574,37 +602,44 @@ test.describe("webgpu-fly e2e", () => { const speed = parseFloat(m![1]); expect(Number.isFinite(speed), `body speed went non-finite: ${speed}`).toBe(true); - expect(before, "physics handle missing").not.toBeNull(); expect(after, "physics handle missing").not.toBeNull(); const dx = after!.x - before!.x; const dy = after!.y - before!.y; const dz = after!.z - before!.z; - console.log(`[rl-walker] before=(${before!.x.toFixed(3)},${before!.y.toFixed(3)},${before!.z.toFixed(3)}) after=(${after!.x.toFixed(3)},${after!.y.toFixed(3)},${after!.z.toFixed(3)}) dx=${dx.toFixed(3)} dy=${dy.toFixed(3)} dz=${dz.toFixed(3)}`); - - // This test gates what its name says — the policy runs and its - // actions reach the actuators — and deliberately does NOT assert - // forward progress, because the trained walker does not produce - // net forward locomotion in this port. - // - // It used to assert dx > 0.1, and passed. That was an artifact: - // science mode auto-ran a stim at boot which left fwdCmd saturated - // at ~1.0, the policy branch never calls driveLegs and nothing - // zeroed it, so the kinematic assist kept gliding the body forward - // while the policy was nominally in control. With the boot drive - // now returned to rest (src/main.ts), the assist is idle here and - // the honest number appears: dx = -1.174 cm over the same window. - // Under the assist the same window gives +1.227 cm, and the fly has - // been measured finishing it upside down. See LIMITATIONS.md §8. - // - // Re-adding a locomotion assertion here is only meaningful once the - // walker actually walks; until then it would encode the artifact. + const simDt = after!.t - before!.t; + const cmPerSimS = dx / simDt; + console.log(`[rl-walker] before=(${before!.x.toFixed(3)},${before!.y.toFixed(3)},${before!.z.toFixed(3)}) after=(${after!.x.toFixed(3)},${after!.y.toFixed(3)},${after!.z.toFixed(3)}) dx=${dx.toFixed(3)} dy=${dy.toFixed(3)} dz=${dz.toFixed(3)} over ${simDt.toFixed(2)} sim s = ${cmPerSimS.toFixed(3)} cm/sim s, upright=${after!.upright.toFixed(3)}`); + + // Forward progress is asserted on the signed x displacement, and + // applyTrainedWalkerActions zeroes the CPG command, so this is the + // policy walking and not the kinematic assist. It is the single + // gate on the four port fixes that turned this window around: + // world_zaxis reading xmat's column instead of its row (tilt sense + // inverted), a spawn height not offset for floor.xml's z=-0.15 + // plane (episode began in free fall), reference-tracking obs that + // never read qpos (policy told its tracking error was always zero), + // and the missing joint_filter actuator dynamics (model.na = 0). + // Measured against a 2.0 cm/s command: 2.016-2.020 cm/sim s over 3 + // runs, ending uprightness +0.997. Policy never enabled, assist off: + // 0.03 cm/sim s. 0.5 leaves ~4x margin below the pass case and ~15x + // above the fail case. |action|max is deliberately not gated: the + // stats also cover the pre-reset handover ticks, where it has been + // logged at 166 before settling into flybody's native ~6 band. + expect(cmPerSimS, `trained walker didn't walk forward (dx=${dx.toFixed(3)} cm in ${simDt.toFixed(2)} sim s)`) + .toBeGreaterThan(0.5); + // A fly on its back is not walking however far it slid — the + // pre-fix policy path was measured ending a window upside down + // (LIMITATIONS.md §8). + expect(after!.upright, `fly ended the window not upright (uprightness=${after!.upright.toFixed(3)})`) + .toBeGreaterThan(0.5); + expect(after!.actionStats, "policy action stats missing").toBeTruthy(); expect(after!.actionStats!.count, "policy never ticked — actions never reached the body") .toBeGreaterThan(0); expect(Number.isFinite(after!.actionStats!.absMax), "policy actions went non-finite").toBe(true); expect(after!.actionStats!.absMax, "policy emitted all-zero actions").toBeGreaterThan(0); // Body stays in the world: no NaN, no falling through the floor, - // no launching. Displacement direction is not asserted (see above). + // no launching. expect(Number.isFinite(dx) && Number.isFinite(dy) && Number.isFinite(dz), `body position went non-finite (dx=${dx}, dy=${dy}, dz=${dz})`).toBe(true); expect(Math.abs(dz), `body z drifted (dz=${dz.toFixed(3)} cm)`).toBeLessThan(1.0); diff --git a/tools/walkbench.mjs b/tools/walkbench.mjs new file mode 100644 index 0000000..cc8c97c --- /dev/null +++ b/tools/walkbench.mjs @@ -0,0 +1,111 @@ +// Measuring stick for the trained walker. Enables the policy with the +// kinematic assist OFF and samples time-resolved, because the interesting +// quantities are all "while the fly is still upright" — a running max taken +// over the whole window is dominated by the post-capsize blow-up and hides +// whether the policy was behaving before it fell. +// +// Reports per rep: +// tCapsize simulated seconds from policy takeover until uprightness <= 0 +// (null = never capsized, which is the goal) +// actMaxUp |action|max sampled only while upright (native band ~6) +// cmPerSimS forward progress while upright +// uprightEnd uprightness at the end of the window (+1 upright, -1 on back) +// +// The numbers quoted for the trained walker in LIMITATIONS.md come from this +// script, so they are reproducible rather than asserted. +// +// Needs `npm run dev` on :8766 and the connectome/body binaries in public/ +// (see README Quickstart) — same prerequisites as the Playwright suite. +// +// Usage: node tools/walkbench.mjs [reps] (default 3) +import { chromium } from "@playwright/test"; + +const REPS = Number(process.argv[2] ?? 3); +const URL = "http://127.0.0.1:8766/app?mode=science"; +const SIM_WINDOW_S = 2.5; + +const browser = await chromium.launch({ + headless: false, + args: ["--enable-unsafe-webgpu", "--enable-features=Vulkan,WebGPU", "--disable-dawn-features=disallow_unsafe_apis"], +}); + +const rows = []; +for (let rep = 0; rep < REPS; rep++) { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 720 } }); + const page = await ctx.newPage(); + await page.goto(URL, { waitUntil: "domcontentloaded" }); + await page.waitForFunction( + () => /flybody attached/.test(document.querySelector("#out")?.textContent ?? ""), + null, { timeout: 240_000 }, + ); + + const assistOff = await page.evaluate(() => { + const p = window.__physicsForTest; + if (!p) return false; + p.constructor.kinematicAssistEnabled = false; + return p.constructor.kinematicAssistEnabled === false; + }); + + await page.locator('.stim-btn:has(.label:has-text("Use RL policy"))').first().click(); + await page.waitForFunction( + () => /trained walker loaded/.test(document.querySelector("#out")?.textContent ?? ""), + null, { timeout: 60_000 }, + ).catch(() => {}); + + const sample = () => page.evaluate(() => { + const p = window.__physicsForTest; + const q = p.data.qpos; + return { + x: q[0], y: q[1], z: q[2], t: p.data.time, + upright: 1 - 2 * (q[4] * q[4] + q[5] * q[5]), + actMax: window.__rlActionStats?.absMax ?? null, + ticks: window.__rlActionStats?.count ?? 0, + }; + }); + + // Wait for the policy to actually take the loop before t0. + await page.waitForFunction(() => (window.__rlActionStats?.count ?? 0) > 5, null, { timeout: 60_000 }).catch(() => {}); + const s0 = await sample(); + + let tCapsize = null, lastUp = s0, prevActMax = s0.actMax ?? 0, actMaxUp = 0, capsizeSample = null; + for (;;) { + const s = await sample(); + // __rlActionStats.absMax is a running max; its per-interval delta tells + // us what the policy emitted during THIS interval. + if (s.actMax !== null && s.actMax > prevActMax) { + if (s.upright > 0) actMaxUp = Math.max(actMaxUp, s.actMax); + prevActMax = s.actMax; + } + if (tCapsize === null && s.upright <= 0) { + tCapsize = +(s.t - s0.t).toFixed(3); + capsizeSample = s; + } + if (s.upright > 0) lastUp = s; + if (s.t - s0.t >= SIM_WINDOW_S) { lastUp = s.upright > 0 ? s : lastUp; break; } + await page.waitForTimeout(60); + } + const sEnd = await sample(); + + const upDt = lastUp.t - s0.t; + rows.push({ + rep, + assistOff, + tCapsize, + actMaxUp: +actMaxUp.toFixed(2), + cmPerSimS: upDt > 0.05 ? +(Math.hypot(lastUp.x - s0.x, lastUp.y - s0.y) / upDt).toFixed(3) : null, + dxUpright: +(lastUp.x - s0.x).toFixed(3), + uprightStart: +s0.upright.toFixed(3), + uprightEnd: +sEnd.upright.toFixed(3), + ticks: sEnd.ticks, + capsizeAt: capsizeSample ? { x: +capsizeSample.x.toFixed(2), z: +capsizeSample.z.toFixed(3) } : null, + }); + await ctx.close(); +} + +console.log(JSON.stringify(rows, null, 1)); +const survived = rows.filter((r) => r.tCapsize === null).length; +const mean = (k) => { const v = rows.map((r) => r[k]).filter((x) => x !== null); return v.length ? +(v.reduce((a, b) => a + b, 0) / v.length).toFixed(3) : null; }; +console.log(`\nSURVIVED (never capsized): ${survived}/${rows.length}`); +console.log(`mean tCapsize = ${mean("tCapsize")} sim s mean actMax-while-upright = ${mean("actMaxUp")} mean cm/sim s upright = ${mean("cmPerSimS")}`); +console.log(`mean uprightEnd = ${mean("uprightEnd")} assist off all reps: ${rows.every((r) => r.assistOff)}`); +await browser.close();