diff --git a/.zenodo.json b/.zenodo.json index aa7f334..b349d52 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 motor neurons actuate a physically simulated 67-body, 111-actuator TuragaLab flybody model running in MuJoCo compiled to WebAssembly. 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 verified element-wise against the published SavedModel checkpoint.

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 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.", "keywords": [ "WebGPU", "WebAssembly", diff --git a/CITATION.cff b/CITATION.cff index 2ccd971..3e65785 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -33,13 +33,18 @@ abstract: >- leaky integrate-and-fire networks in fused WebGPU compute kernels, with presynaptic-neurotransmitter signs baked into the weights so the inner loop never branches on excitatory/inhibitory type. Brain command - neurons drive the spine by cell-type name match; the spine's motor - neurons drive a physically simulated 67-body, 111-actuator TuragaLab - flybody model running in MuJoCo compiled to WebAssembly. A 64x16 retina + neurons drive the spine by cell-type name match; 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 actuates a physically simulated + 67-body, 111-actuator TuragaLab flybody model running in MuJoCo + compiled to WebAssembly — the connectome scales that gait but does not + 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 verified - element-wise against the published checkpoint. The project is a game + (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 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 a0f9321..207614e 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -10,6 +10,12 @@ for "a real connectome on a phone in 30 seconds." Treat it as a teaching, demonstration, and intuition-building tool — not as a validated platform for publishing fly-brain dynamics. +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. + --- ## 1. Performance — what "0.25 kHz" means and doesn't @@ -58,30 +64,55 @@ publishing fly-brain dynamics. ## 4. The brain → spine → body path has documented approximations -These are the "honest gaps" from the README, restated as limitations: - -1. **Trained RL walker walks slower than native.** 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, but per-simulated-second walking speed is roughly - **half** of native flybody. Part of the remaining gap is sim-time vs - wall-time budget — we run ~25% of real time at 60 fps. -2. **Closed-loop visual reflex is approximated.** When the target is visible, - the default path can fall back to `turn ∝ retinal angle` because the - brain's genuine optic→DN contralateral cascade takes longer than our 50 ms - tick to develop. An opt-in "honest mode" replaces this with a - brain-cascade-derived turn (dnLeft/dnRight asymmetry), which is slower and - weaker but real. The shortcut is a usability default, not a biological - claim. -3. **Optional kinematic assist on the body.** In CPG mode (RL policy off), - a soft assist on the freejoint translation, scaled by motor command, makes - the demo watchable. It is **off** in RL-policy mode and can be turned off - everywhere via the "Honest mode" button (`Physics.kinematicAssistEnabled`). - With it off, the body moves only via actuator → ground reaction. +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. +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 + alignment regardless of branch (`src/vnc.ts:380-384`). The brain's genuine + optic→DN contralateral cascade takes longer than our 50 ms tick to + develop. An opt-in "honest mode" switches to a brain-cascade-derived turn + (dnLeft/dnRight asymmetry) — but that path falls back to the identical + hand-written law whenever cascade asymmetry is below 0.05, and the code + 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 + 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. 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`). + 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. ## 5. Brain ↔ spine wiring is name-match, not synaptic @@ -112,6 +143,128 @@ These are the "honest gaps" from the README, restated as limitations: - Not a synaptically continuous brain-to-body reconstruction. - Not benchmarked beyond a single M2 Pro / Chromium configuration. +## 8. Full shortcut inventory — what actually moves the body + +The "Honest mode" button flips exactly three flags (`src/main.ts:669-683`). +The table below has thirteen rows, and nine of them are behind no toggle at +all. This section is all of them — the ones the button covers and the ones it +does not — so the button is not the only place they are disclosed. + +Nothing here is a claim about the neural simulation. The FlyWire and MANC +connectomes are real, both LIF networks genuinely run on the GPU, and the +stimulus→cascade dynamics are connectome-derived. What follows is about how +the *body* is driven, which is a different and much weaker story. + +### The information bottleneck + +Start here, because it frames every row of the table: + +``` +FlyWire 139,255 neurons / 15,091,983 edges + ↓ name join: 7 DN types → 16 of 23,188 VNC neurons (0.069%) main.ts:383-391 + ↓ L and R copies collapsed to one scalar each main.ts:387-390 +MANC 23,188 neurons / 5,243,574 edges + ↓ 369 leg motor neurons → 6 leg-group means main.ts:396-400 + ↓ → mancTotal, mancAsym main.ts:452-453 + ↓ and the direction sign is discarded — it comes from the + hand-wired 200-neuron synthetic spine main.ts:470 + ↓ driveLegs(walk, turn) room.ts:640 + = sin(t · 10 Hz), 18 of 48 leg actuators physics.ts:663-719 +``` + +**Roughly 20.3 million connectome edges reach the body as about one scalar +magnitude plus a turn bias per tick, and those two numbers scale a +`sin(t × 10 Hz)` tripod.** In the "Track target" closed loop the connectome +contribution to locomotion is zero: MANC is skipped entirely +(`src/main.ts:457-465`) and both forward and turn are overwritten by retinal +geometry (`src/vnc.ts:369-384`). + +### The inventory + +| 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` | +| **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` | +| **"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` | + +One more, about evidence rather than physics: **the walking policy's forward +pass is not verified against the published SavedModel.** +`tools/verify_walking_policy.py` imports only `json`, `struct`, `pathlib` and +`numpy` — no TensorFlow. It reads `public/walking-policy.bin`, the file +`extract_walking_policy.py` wrote, and re-implements the same *assumed* +architecture. The passing fixture therefore shows TS ≡ a numpy +re-implementation of the same guess, not TS ≡ flybody's deployed policy. The +extractor's own comments record the guess as unresolved +(`tools/extract_walking_policy.py:43`, "layernorm bias (?) — actually +unsure"). + +### What the body does with the assist off (measured) + +16 controlled browser runs, headed Chromium, one M-series Mac, ~2 simulated +seconds of sampling per window. "cm/sim s" is net displacement per *simulated* +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 +it to zero. The assist-on/assist-off contrast, which is what the table is for, +is unaffected — it is measured within each row. + +| 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** | + +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 + 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. +- **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 + these runs still carried; DNa01 moved only `turnCmd` (0.012 → 0.119). What + the measurement shows about the button is that its forward axis is a no-op + whenever anything has already saturated the drive. +- **Nothing is numerically unstable.** Zero non-finite `qpos` entries across + all 16 runs; vertical drift within 0.16 cm of spawn everywhere. The + simulation is healthy and simply produces no net thrust. +- On the CPG path, "Honest mode" is *exactly* equivalent to "assist off" — the + honest-mode CPG runs reproduce the plain assist-off CPG runs to 3–4 + significant figures, because the other two flags are only read in code paths + CPG mode never enters. + +The pitch/roll damper is not in that table, because **the project has never +been run with it off.** It has been ×0.005 per CPG render frame for the entire +life of the codebase, in every mode including Honest mode, so no measurement +here — or in any commit message — describes a fly that could tip over. That +baseline is unmeasured. + --- *Found something here that's worse than described, or a claim in the README diff --git a/README.md b/README.md index abcc845..5ef0c3d 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,13 @@ You control a fly by firing real **descending neurons** in the [FlyWire](https://flywire.ai) connectome. The spike cascade propagates through -the real wiring; the fly walks because the connectome says so. There is no -scripted animation — every step you see is an LIF cascade through a real fly's -brain map, into a real fly's spinal cord, driving a physically simulated body. +the real wiring, into a real fly's spinal cord, and what comes out of the spine +is a walking magnitude and a turn bias. Those two numbers modulate a hand-written +tripod gait and — with the kinematic assist that ships on by default — are also +written straight into the body's velocity. The connectome simulation is real and +runs on the GPU every frame; the locomotion layered on top of it is an +approximation, and every approximation and shortcut is inventoried in +[`LIMITATIONS.md`](./LIMITATIONS.md). --- @@ -37,7 +41,7 @@ brain map, into a real fly's spinal cord, driving a physically simulated body. - A whole-animal *Drosophila* nervous system — brain, spinal cord, and body — running end-to-end in a browser tab on WebGPU, no install and no server. - Two real connectomes (FlyWire brain + Janelia MANC spine) simulated as leaky integrate-and-fire networks, with gather, integrate, threshold and reset fused into a single LIF kernel per timestep. -- A physically simulated fly body (TuragaLab flybody in MuJoCo/WASM) driven by the spine's motor neurons, with a retina feeding vision back into the brain. +- A physically simulated fly body (TuragaLab flybody in MuJoCo/WASM) driven by a hand-written tripod gait that the spine's motor output scales, with a retina feeding vision back into the brain. - A game with **replay-as-URL**: a shared link re-fires your keystrokes at the same simulation steps, so the identical neuron cascade re-runs against the same connectome and the same seeded target. @@ -57,7 +61,7 @@ brain map, into a real fly's spinal cord, driving a physically simulated body. - **The curious public.** A real animal brain you can poke, with a 30-second time-to-first-spike and no setup. - **Educators.** Every key fires a named command neuron and you watch the consequence ripple to the legs — the connectome made tangible. -- **Connectome / WebGPU folks.** A real ~140k-neuron LIF kernel benchmarked honestly in the browser, with the brain→spine→body path wired from real biology. +- **Connectome / WebGPU folks.** A real ~140k-neuron LIF kernel benchmarked honestly in the browser, with the brain→spine path wired from real biology. - **Anyone who wants reproducibility.** Runs are URLs; a replay link is a verifiable brain trace, not a video. @@ -103,13 +107,20 @@ 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, verified element-wise vs the published checkpoint | +| **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 | 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). -VNC motor neurons drive the leg actuators. Wire-by-wire from real biology, no -learned shortcuts in the brain→spine path. (Caveat: it's a name join across two -*different* animals' connectomes, not a reconstructed synaptic bridge — see +That path — sensory drive → brain LIF → named DNs → VNC LIF — is a real spike +cascade across two real connectomes, with nothing learned or scripted in it. +Below the spine it is an approximation: the VNC's 369 leg motor neurons are +averaged into six leg-group means, and those become a walking magnitude and a +turn bias (the forward/backward sign is not MANC's — it comes from the +hand-wired synthetic spine in `src/vnc.ts`). Those two scalars scale a +hand-written tripod CPG (`driveLegs`, `src/physics.ts`) that writes the leg +actuators. The connectome scales that gait; it does not generate its rhythm, and +the leg phase itself is `sin(sim_time · freq)`. (Caveat: it's a name join across two *different* animals' +connectomes, not a reconstructed synaptic bridge — see [`LIMITATIONS.md`](./LIMITATIONS.md) §5.) --- diff --git a/index.html b/index.html index bfcd749..15152d3 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ webgpu-fly — a real fly brain you can play, in your browser You drive a fly by firing its real neuronsNothing here is animated by hand. + wiring, and the fly walks. The cascade is real; the leg rhythm it + drives is hand-written, and we tell you which is which.

▸ Launch the simulator @@ -437,12 +438,13 @@

A whole animal nervous system, end to end, running on y

The brain talks to the fly's spinal cord - (a second real connectome), which drives the legs of a + (a second real connectome), and what the spinal cord decides steers a physically simulated body. Press a key and you inject current into specific command neurons, then watch the - consequences ripple all the way down to the feet. If the wiring says - "this makes the fly walk forward," it walks forward. If it doesn't, it - doesn't. + consequences ripple all the way down to the legs. What comes out of + the connectome is a walking speed and a turn — the stepping rhythm + itself is a hand-written tripod gait, not neuron-by-neuron muscle + control.

@@ -457,7 +459,7 @@

Four real datasets, wired together.

02
Spine
-
Janelia MANC connectome — the fly's ventral nerve cord, its "spinal cord": 23,188 neurons, 5.2 million connections. A second live network that turns brain commands into leg movement.
+
Janelia MANC connectome — the fly's ventral nerve cord, its "spinal cord": 23,188 neurons, 5.2 million connections. A second live network that turns brain commands into a walking drive.
03
Body
@@ -471,8 +473,10 @@

Four real datasets, wired together.

The brain and spine are joined the way real biology does it: a command neuron named DNa01 in the brain is the same cell as - DNa01 in the spinal cord. No learned shortcuts — wire for - wire from the real animal. + DNa01 in the spinal cord. Nothing in that path is learned + or scripted — it is wire for wire from the real animal. Past the + spinal cord it changes: the motor neurons set a walking speed and a + turn, and a hand-written leg rhythm does the actual stepping.

diff --git a/src/main.ts b/src/main.ts index 3accdc9..079bd31 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1277,7 +1277,18 @@ async function main() { }).catch(() => {/* boot stage already logged */}); } else { // Science mode: auto-run the first preset so there's something on screen. - runStimulus(STIMULI[0], buttons[0]); + // Nobody asked for this one, so put the drive back to rest when its + // window ends. A user-clicked stim deliberately leaves the drive at its + // end-of-window value (see runStimulus); if the boot preset did the same + // the fly would already be walking at a saturated forward command before + // anyone touched a button, and any DN that commands a similar forward + // drive would look like a no-op (measured on DNa01: 0.4% difference in + // net travel between clicking it and never touching the page). + runStimulus(STIMULI[0], buttons[0]).then(() => { + driveFwd = 0; + driveTurn = 0; + room.setDrive(0, 0); + }); } } diff --git a/src/physics.ts b/src/physics.ts index 82fdb5e..b1ddfd4 100644 --- a/src/physics.ts +++ b/src/physics.ts @@ -527,8 +527,9 @@ export class Physics { } /** Body-velocity command from the VNC layer; re-asserted by step() - * each substep so MuJoCo damping doesn't drain it. Set by driveLegs - * each frame. */ + * 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. */ private fwdCmd = 0; private turnCmd = 0; @@ -540,19 +541,38 @@ export class Physics { * 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": - * (window as any).Physics.kinematicAssistEnabled = false */ + * (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. */ static kinematicAssistEnabled = true; /** Step physics N times. * - * Body is driven by leg/wing actuators only — leg motion produces - * ground reaction, ground reaction moves body. No qvel writes on - * the freejoint translation or yaw (was a kinematic-assist hack - * before; toggleable now via Physics.kinematicAssistEnabled). + * Leg and wing actuators, contacts and ground reaction are real + * MuJoCo. Two direct freejoint qvel writes are not, and both happen + * inside the substep loop, immediately before each mj_step: + * + * 1. Pitch/roll damper — qvel[3] and qvel[4] are multiplied by 0.85 + * per substep (×0.039 over a 20-substep control tick, ×0.005 over + * a 32-substep render frame). This is NOT gated by + * kinematicAssistEnabled: it runs in honest mode and under the + * trained policy. Leg and gravity torques about those two axes are + * largely absorbed instead of integrated. It has never been run in + * the off state, so the undamped baseline is unmeasured — do not + * assume the fly stands up without it. * - * Stabilizer: pitch/roll angular damper (×0.85 per substep) keeps - * the body upright without pinning orientation; the fly can still - * tip if it genuinely loses balance. */ + * 2. Kinematic assist — when kinematicAssistEnabled and a drive + * command is set, translation (qvel[0], qvel[1]) and yaw (qvel[5]) + * are written from fwdCmd/turnCmd, so body motion is an algebraic + * function of the drive scalars rather than of ground reaction. + * See kinematicAssistEnabled for the measured size of this. */ step(substeps = 1) { const assist = Physics.kinematicAssistEnabled; const hasCmd = assist && (Math.abs(this.fwdCmd) > 0.01 || Math.abs(this.turnCmd) > 0.01); diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 7890dfc..7198335 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -222,19 +222,79 @@ test.describe("webgpu-fly e2e", () => { expect(log).toContain("applied evolved gait to live body"); }); - test("body moves after DN-driven walking command", async ({ page }) => { + // The "does it walk" gate. Displacement is measured from qpos rather + // than read off #drive-readout on purpose: that readout is + // physics.bodySpeed = |qvel[0..1]| (src/physics.ts:984-989), and the + // kinematic assist assigns those exact qvel slots immediately before + // mj_step (src/physics.ts:596-604). A speed assertion therefore reads + // back the command that was just written, not the distance the body + // covered — with the assist off that readout showed 0.42-2.65 cm/s + // while true net travel was 0.07 cm per simulated second (jitter, not + // locomotion). Uprightness is asserted alongside because the assist + // decouples translation from body state entirely: the fly has been + // measured sliding forward at the commanded speed while upside down. + test("body covers ground and stays upright under a DN walking command", async ({ page }) => { await waitForLog(page, "flybody attached", 120_000); - // Click a forward DN; let the brain run, the motor drive set, - // and the body integrate for a few seconds. + // Click a forward DN; let the brain run and the motor drive set. await clickButton(page, "DNa02"); await waitButtonIdle(page, "DNa02"); - await page.waitForTimeout(5_000); + + const before = await page.evaluate(() => { + 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], t: phys.data.time }; + }); + expect(before, "physics handle missing").not.toBeNull(); + + // Wall time buys machine-dependent amounts of simulated time (6 s of + // wall clock bought 1.8-2.3 sim s on the reference Mac), so the + // window is closed on the sim clock, and the threshold below is + // cm per SIMULATED second — the portable quantity. + 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; time: number } } }).__physicsForTest; + 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: q[0], y: q[1], t: phys.data.time, upright: 1 - 2 * (q[4] * q[4] + q[5] * q[5]) }; + }); + expect(after, "physics handle missing").not.toBeNull(); + + const dx = after!.x - before!.x; + const dy = after!.y - before!.y; + const simDt = after!.t - before!.t; + const netDisp = Math.hypot(dx, dy); + const cmPerSimS = netDisp / simDt; + console.log(`[walk-gate] net=${netDisp.toFixed(3)} cm over ${simDt.toFixed(2)} sim s = ${cmPerSimS.toFixed(3)} cm/sim s, upright=${after!.upright.toFixed(3)}`); + + // Reference measurements on the shipped default (kinematic assist + // on): 0.80 cm/sim s. Same DN stim with the assist off: 0.07 + // cm/sim s, the fly pirouetting in place. 0.3 leaves ~2.7x margin + // below the pass case and ~4x above the fail case. + expect(cmPerSimS, `body didn't cover ground under DNa02 (${netDisp.toFixed(3)} cm in ${simDt.toFixed(2)} sim s)`) + .toBeGreaterThan(0.3); + // A fly on its back is not walking however far it slid. + expect(after!.upright, `fly ended the window not upright (uprightness=${after!.upright.toFixed(3)})`) + .toBeGreaterThan(0.5); + + // The readout is still checked, but only for liveness/finiteness — + // see above for why its value is not the walking evidence. const drive = await page.locator("#drive-readout").innerText(); // drive-readout has "speed X.YZ cm/s" appended each frame. const m = drive.match(/speed (-?[\d.]+) cm\/s/); expect(m, `drive readout missing speed: "${drive}"`).not.toBeNull(); - const speed = parseFloat(m![1]); - expect(speed, `body didn't move under DNa02 (speed=${speed} cm/s)`).toBeGreaterThan(0.1); + expect(Number.isFinite(parseFloat(m![1])), `drive readout speed non-finite: "${drive}"`).toBe(true); }); // Literature-grounded firing-rate assertions. KC sparsity is the @@ -521,13 +581,32 @@ test.describe("webgpu-fly e2e", () => { 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)}`); - // Forward progress: ref says "+x at 1 cm/s for 4s = 4 cm"; we - // require some non-trivial advance. - expect(dx, `body did not advance forward under RL (dx=${dx.toFixed(3)} cm)`).toBeGreaterThan(0.1); - // Lateral drift bounded — body shouldn't strafe sideways more than - // it walks forward. - expect(Math.abs(dy), `body strafed sideways (dy=${dy.toFixed(3)} cm)`).toBeLessThan(Math.abs(dx) + 1.0); - // Body should remain roughly upright (z within a centimeter of spawn). + // 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. + 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). + 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); });