From cb74df69a362a38c4ae5695706aa92d2c50ef120 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ahmet=20Bar=C4=B1=C5=9F=20G=C3=BCnayd=C4=B1n?=
Date: Fri, 31 Jul 2026 16:54:39 +0700
Subject: [PATCH 1/3] fix(physics): make the trained walker actually walk
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Four places where this port disagreed with upstream flybody. Each was
silent, each had zero error at spawn and grew from there, and together they
meant the published Vaxenburg policy could not walk the body — the demo's
forward motion was the kinematic assist gliding a falling fly.
1. world_zaxis read xmat's third COLUMN where flybody reads the third ROW
(fruitfly.py: MJCFFeature('xmat', root_body)[6:]). Those are transposes,
so pitch and roll were sign-inverted on what is, by input-weight L2 norm,
the 2nd-highest-gain row of the 741x512 input layer. The fly's righting
response therefore added to a tilt instead of opposing it. Confirmed
against the shipped normalization stats: mean(accelerometer) /
mean(world_zaxis) = 981, exactly fruitfly.xml's gravity.
2. The spawn height ignored the floor. floor.xml puts the plane at
pos="0 0 -.15"; flybody's _SPAWN_POS is calibrated against dm_control's
floors.Floor() at z=0. The lowest claw started 0.15 cm up instead of
0.00048, so every episode opened with ~17.5 ms of free fall, all six
claws off the ground and the touch/force observations reading zero,
while the policy was establishing its gait. Spawn is now floor-relative,
with the plane read out of the compiled model.
3. 455 of the 741 observation dims never read the body. Both branches of
the ref block differenced the reference against ITSELF, so a tracking
controller was told "zero error, perfectly aligned" on every tick,
straight through the capsize. ref_displacement is now
R^T_fly·(refPos − flyPos) and ref_root_quat is conj(q_fly) ⊗ refQuat,
both against the live pose, indexed from f=0 so entry 0 is the current
error, over an absolute world-space trajectory the fly can fall behind.
4. flybody's actuator filter dynamics were missing entirely. Upstream sets
dyntype='filter' programmatically in fruitfly.py:_build, so it never
appears in the shipped MJCF and a port that reads the MJCF cannot see
it. model.na was 0: every control tick drove an actuator 100% of the way
to its target instead of 1-exp(-0.002/0.01) = 18%, a plant ~5.5x stiffer
than the one the policy was trained against, and the 59-dim
actuator_activation observable was all zeros. Patched into the MJCF text
in memory at load (the runtime reads fruitfly.xml from the baked bundle,
not from public/), asserted via model.na === 78 so a no-op patch throws.
data.act is now indexed through actuator_actadr, which is only correct
once na > 0.
Measured with tools/walkbench.mjs, kinematic assist explicitly off, 3 reps:
2.004-2.021 cm per simulated second against a 2.0 cm/s command, uprightness
+0.997, never capsizing, actions ~5.9 while upright (flybody's native band
is ~6). Before: 0.15 cm/sim s, on its back in every rep. Control with the
policy disabled: 0.032 cm/sim s — it stands still, so the locomotion comes
from the policy and nothing else.
Target speed goes 1.0 -> 2.0 cm/s to match upstream's inference default.
The old comment attributed the 1.0 tuning to observation-normalization
drift; the real cause was defect 3.
The kinematic assist is also cleared under the policy now, restoring what
a9e24f1 reverted. That revert was right at the time — removing the assist
from a broken walker made the feature visibly fail. It walks now, so the
policy path no longer needs it. The CPG path still does.
Co-Authored-By: Claude Opus 5
---
src/main.ts | 15 ++-
src/physics.ts | 242 +++++++++++++++++++++++++++++++++----------------
2 files changed, 170 insertions(+), 87 deletions(-)
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);
}
From 316ee5fb55a2b1b8bfb8683d6d18102727c79fb3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ahmet=20Bar=C4=B1=C5=9F=20G=C3=BCnayd=C4=B1n?=
Date: Fri, 31 Jul 2026 16:55:00 +0700
Subject: [PATCH 2/3] test: gate the walker on real locomotion, and ship the
yardstick
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The RL walker test asserted only that the policy ticked and its actions
were finite, with a comment explaining that forward progress was
deliberately not asserted because the walker did not walk. Both are now
stale.
It closes its window on the sim clock and asserts net displacement per
simulated second plus end-of-window uprightness, matching the neighbouring
walk-gate test. Threshold 0.5 sits 4x below the measured 2.016-2.020 and
~15x above the policy-disabled floor of 0.03, and the pre-fix value of
-1.174 cm fails outright — so a regression of any of the four fixes is
caught here, and a regression of the actuator filter fails even earlier at
the model.na assertion in create().
|action|max is deliberately not gated: __rlActionStats starts accumulating
when the toggle is clicked, before the test's reset, so it captures the
handover transient and has been logged at 6.3, 65.8 and 166.2 across runs
that were otherwise identical. Locomotion was unaffected in all three.
tools/walkbench.mjs is the harness the LIMITATIONS numbers come from. It
disables the kinematic assist explicitly and samples time-resolved, because
every quantity worth knowing is "while the fly is still upright" — a
running max over the whole window is dominated by the post-capsize blow-up
and made the policy look far worse than it was. Committing it so the
published numbers are reproducible rather than asserted.
Co-Authored-By: Claude Opus 5
---
tests/smoke.spec.ts | 93 +++++++++++++++++++++++++------------
tools/walkbench.mjs | 111 ++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 175 insertions(+), 29 deletions(-)
create mode 100644 tools/walkbench.mjs
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();
From 30693c92c8c24d31cf90295893f291bb03dab353 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ahmet=20Bar=C4=B1=C5=9F=20G=C3=BCnayd=C4=B1n?=
Date: Fri, 31 Jul 2026 16:55:00 +0700
Subject: [PATCH 3/3] docs: the trained walker walks
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two days ago these files were made honest on the evidence that the trained
walker did not walk. That specific fact has changed, so the claim has to
change with it.
LIMITATIONS.md item 1 was "Trained RL walker does not walk". It now records
what is measured — ~2.0 cm per simulated second against a 2.0 cm/s command,
upright +0.997, no capsize across 3 reps with the kinematic assist off —
and, more usefully, why it used to fail: four port defects, each a silent
disagreement with upstream flybody.
Section 8's inventory is qualified rather than rewritten. The assist row,
and every "assist off = the fly pirouettes in place" measurement, is now
explicitly labelled as the CPG path, where it remains true. The row about
the assist being live under the policy is stale and says so.
Deliberately unchanged, because none of it is affected:
- The connectome still does not walk the body. Brain to spine still yields
a walking magnitude and a turn bias that scale a hand-written tripod CPG;
~20.3M edges still reach the body as about one scalar per tick. What
works now is a published RL policy, not the fly's own brain.
- The forward pass has still never been compared against the released
SavedModel. tools/verify_walking_policy.py imports no TensorFlow. The
walker working is strong circumstantial evidence the architecture guess
is right, but it is not that comparison, and the caveat stays.
Co-Authored-By: Claude Opus 5
---
.zenodo.json | 2 +-
CITATION.cff | 15 +++--
LIMITATIONS.md | 146 +++++++++++++++++++++++++++++++------------------
README.md | 11 +++-
index.html | 9 +++
5 files changed, 125 insertions(+), 58 deletions(-)
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.
+