Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ concurrency:

jobs:
build:
name: typecheck · build
name: typecheck · unit · build
runs-on: ubuntu-latest

steps:
Expand All @@ -36,6 +36,10 @@ jobs:
- name: Typecheck
run: npm run typecheck

- name: Unit tests
run: npm run test:unit
# Needs node >= 22.18 for unflagged TypeScript stripping.

- name: Build
run: npm run build
# vite copies whatever is in public/; the large connectome binaries
Expand Down
2 changes: 1 addition & 1 deletion .zenodo.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"affiliation": "Independent researcher"
}
],
"description": "<p><strong>webgpu-fly</strong> runs a whole-animal <em>Drosophila</em> 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 &mdash; one dispatch per timestep, with presynaptic-neurotransmitter signs pre-baked into the connection weights so the inner loop never branches on excitatory/inhibitory type.</p><p>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&ndash;VNC boundary), and the spine's motor neurons actuate a physically simulated 67-body, 111-actuator <strong>TuragaLab flybody</strong> model running in MuJoCo compiled to WebAssembly. A 64&times;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.</p><p>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 &mdash; 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 &mdash; a real connectome simulation behind a single URL &mdash; not raw throughput. Known limitations (RL-walker speed gap, closed-loop visual-reflex approximation, kinematic-assist options) are enumerated in LIMITATIONS.md.",
"description": "<p><strong>webgpu-fly</strong> runs a whole-animal <em>Drosophila</em> 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 &mdash; 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.</p><p>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&ndash;VNC boundary), and the spine's motor neurons actuate a physically simulated 67-body, 111-actuator <strong>TuragaLab flybody</strong> model running in MuJoCo compiled to WebAssembly. A 64&times;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.</p><p>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 &mdash; 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 &mdash; a real connectome simulation behind a single URL &mdash; 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",
Expand Down
27 changes: 14 additions & 13 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,22 @@
## Goal

Realtime LIF (leaky integrate-and-fire) simulation of the FlyWire FAFB
*Drosophila* whole-brain connectome on WebGPU. ~140k neurons, ~5M aggregated
edges. One fused dispatch per timestep, target ≥1 kHz biological-time on
*Drosophila* whole-brain connectome on WebGPU. ~140k neurons, ~15M aggregated
edges. One fused LIF kernel per timestep, target ≥1 kHz biological-time on
M2 Pro 16 GB.

Companion to `~/Downloads/webgpu-dna`. Same thesis (Geant4-class simulator
Companion to `webgpu-dna`. Same thesis (Geant4-class simulator
ported to WebGPU via kernel fusion), different physics. Crucially the fusion
shape is different — see README.

## Architecture

- **Data pipeline** (`tools/build_csr.py`): FlyWire connectivity feather +
annotations TSV → `public/brain.bin` (binary CSR, ~45 MB). Pre-signs
annotations TSV → `public/brain.bin` (binary CSR, ~120 MB). Pre-signs
weights using presynaptic neurotransmitter so kernel never branches on
E/I at runtime.
- **Kernel** (`src/shaders/lif.wgsl`): one fused dispatch per timestep.
- **Kernel** (`src/shaders/lif.wgsl`): one fused LIF dispatch per timestep,
preceded by a bitset-clear dispatch.
Per neuron: gather presynaptic spikes via CSR row, integrate Vm with
leak, threshold + reset, write spike bit to output buffer.
- **Snapshot exporter** (planned): every N ms, copy `vm` or `spike_count`
Expand Down Expand Up @@ -50,7 +51,7 @@ CSR weight E×f32:
pre-signed: sign(pre_nt) × synapse_count
```

E ≈ 5M (aggregated proofread pairs). Total bin ≈ 45 MB.
E ≈ 15M (aggregated proofread pairs). Total bin ≈ 120 MB.

## Neurotransmitter → sign mapping

Expand All @@ -77,7 +78,7 @@ Use `soma_*` if non-null, fall back to `pos_*` (synapse-cloud centroid).
or a giant interneuron).
- **Dynamics sanity**: with no input, network goes silent within ~50 ms
(no runaway). With Poisson input to ORNs, downstream Kenyon cells fire
sparsely (~1–5% population), antennal lobe PNs show characteristic
sparsely (~5–15% population), antennal lobe PNs show characteristic
rates.
- **Quantitative**: cross-check firing rates against published FlyWire
LIF simulations (Shiu et al. 2024 or Lappalainen et al. visual-system
Expand All @@ -86,7 +87,7 @@ Use `soma_*` if non-null, fall back to `pos_*` (synapse-cloud centroid).
## Known design decisions

- **Aggregated pairs, not raw synapses.** `proofread_connections_783.feather`
pre-aggregates by (pre, post) pair. We use that directly — gives ~5M edges
pre-aggregates by (pre, post) pair. We use that directly — gives ~15M edges
vs ~54M raw synapses. v1 LIF doesn't care about per-synapse spatial
position; if we add dendritic compartments later, switch to the 9.5 GB
raw table.
Expand All @@ -104,13 +105,13 @@ Use `soma_*` if non-null, fall back to `pos_*` (synapse-cloud centroid).
bash tools/download_data.sh # ~855 MB Zenodo pull
python3 tools/build_csr.py # → public/brain.bin
npm run dev # localhost:8766
npm run test
npm run test:e2e # Playwright; needs WebGPU + assets
npm run typecheck
```

## Cross-refs

- `~/Downloads/webgpu-dna/CLAUDE.md` — sister project; the kernel-fusion
pattern that motivated this.
- `~/Documents/github/webgpu-fly/tools/build_csr.py` — authoritative
binary format spec lives in this file's docstring.
- https://github.com/abgnydn/webgpu-dna — sister project; the kernel-fusion
pattern that motivated this. Its `CLAUDE.md` has the details.
- `tools/build_csr.py` — authoritative binary format spec lives in this
file's docstring.
22 changes: 11 additions & 11 deletions DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Asset budget:
| `assets/mujoco-*.wasm` | 8.6 MB | Pages |
| `public/flybody/*.obj` (85 files) | 134 MB total, biggest 31 MB | R2 |
| `public/flybody/*.xml` (2 files) | <1 MB | R2 |
| `public/flybody.bundle.bin` | ~140 MB | R2 |
| `public/brain.bin` | 120 MB | R2 |
| `public/brain.meta.json` | 1 KB | R2 |
| `public/vnc.bin` | 43 MB | R2 |
Expand Down Expand Up @@ -46,11 +47,17 @@ VITE_BRAIN_META_URL=https://<r2-public-host>/brain.meta.json
VITE_VNC_URL=https://<r2-public-host>/vnc.bin
VITE_VNC_META_URL=https://<r2-public-host>/vnc.meta.json
VITE_FLYBODY_URL=https://<r2-public-host>/flybody
VITE_FLYBODY_BUNDLE_URL=https://<r2-public-host>/flybody.bundle.bin
VITE_WALKING_POLICY_URL=https://<r2-public-host>/walking-policy.bin
VITE_WALKING_OBS_NORM_URL=https://<r2-public-host>/walking-obs-norm.bin
```

`<r2-public-host>` is the bucket's r2.dev subdomain (printed by the
`dev-url enable` command), or your custom domain.

`VITE_FLYBODY_URL` is legacy — `src/physics.ts` loads only the baked
bundle, so `VITE_FLYBODY_BUNDLE_URL` is the one that must be set.

`r2-cors.json`:
```json
{
Expand All @@ -67,17 +74,10 @@ VITE_FLYBODY_URL=https://<r2-public-host>/flybody
```

`public/_headers` already sets long immutable cache on the JS bundle
and WASM. The R2 bucket should also serve `Cache-Control:
public, max-age=31536000, immutable` — set this once via:

```bash
wrangler r2 bucket cors put webgpu-fly-assets --cors-rules '[
{ "AllowedOrigins": ["https://your-pages.pages.dev","https://your-domain.com"],
"AllowedMethods": ["GET","HEAD"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 86400 }
]'
```
and WASM. The R2 objects get `Cache-Control: public, max-age=31536000,
immutable` from `tools/upload_to_r2.sh` at upload time
(`--cache-control`), and CORS comes from `r2-cors.json` via the
`wrangler r2 bucket cors set` step above — nothing further to configure.

## Path 2 — Vercel

Expand Down
9 changes: 5 additions & 4 deletions LIMITATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ publishing fly-brain dynamics.
- Neurons are **leaky integrate-and-fire** with a two-state alpha synapse.
No Hodgkin-Huxley channels, no dendritic compartments, no spatial synapse
positions, no neuromodulation dynamics.
- We use the **aggregated** proofread connection table (~5M unique (pre,
post) pairs, ~15M synapse count summed into weights), **not** the ~54M raw
synapses. v1 LIF does not use per-synapse spatial position. Dendritic
compartment models would require switching to the much larger raw table.
- We use the **aggregated** proofread connection table (~15M unique (pre,
post) pairs, with each pair's synapse count summed into its weight),
**not** the ~54M raw synapses. v1 LIF does not use per-synapse spatial
position. Dendritic compartment models would require switching to the much
larger raw table.
- **Neurotransmitter → sign is a hard mapping**, baked into the weights at
build time: acetylcholine → +1, GABA/glutamate → −1, and the modulatory
transmitters (dopamine, serotonin, octopamine) plus any prediction below
Expand Down
25 changes: 17 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ brain map, into a real fly's spinal cord, driving a physically simulated body.
**What this is**

- 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, one fused GPU dispatch per timestep.
- 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 game with **replay-as-URL**: a shared link deterministically re-executes the identical neuron cascade against the same connectome.
- 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.

</td>
<td valign="top" width="33%">
Expand Down Expand Up @@ -80,9 +80,12 @@ R DNg13 turning F MDN backward
M science view
```

Win → copy the replay URL. The recipient sees the **identical** simulation —
deterministic seeded target + recorded keystrokes against the same connectome.
Daily-challenge mode uses the same target seed for everyone on the same UTC day.
Win → copy the replay URL. The recipient's brain re-runs the **identical**
cascade — your keystrokes replayed at the same simulation steps, against the
same connectome and the same seeded target. The body trajectory can drift: the
physics advances a fixed number of substeps per animation frame, so it depends
on display refresh rate. Daily-challenge mode uses the same target seed for
everyone on the same UTC day.

The landing page at [`/`](https://webgpu-fly.pages.dev) explains the project in
plain language; the simulator itself lives at
Expand Down Expand Up @@ -142,16 +145,19 @@ Roughly the same idea as:
Differentiators: **(1)** a browser-tab game with a URL — the others need Python,
a GPU, and a setup hour; **(2)** all three layers (brain + spine + body) wired
together, not just brain+body; **(3)** replay-as-URL — every shared run is a
deterministic re-execution anyone can verify, study, or remix.
re-executable brain trace, not a video.

---

## ⏱️ Quickstart (local dev)

```bash
# One-time Python env (numpy/pandas/pyarrow for the connectomes, TF for the policy)
uv venv .venv-tf && uv pip install --python .venv-tf/bin/python numpy pandas pyarrow tensorflow

# Brain (~855 MB FlyWire pull from Zenodo)
bash tools/download_data.sh
python3 tools/build_csr.py # → public/brain.bin (120 MB)
.venv-tf/bin/python tools/build_csr.py # → public/brain.bin (120 MB)

# Spine (~88 MB MANC pull from Janelia GCS)
bash tools/download_manc.sh
Expand All @@ -161,7 +167,10 @@ bash tools/download_manc.sh
bash tools/download_flybody_policies.sh
.venv-tf/bin/python tools/extract_walking_policy.py

# TuragaLab flybody MJCF + 85 OBJ meshes (~149 MB) — see public/flybody/meshes.txt
# TuragaLab flybody MJCF + 85 OBJ meshes (~149 MB) — not redistributed here (Apache-2.0, see NOTICE)
git clone --depth 1 https://github.com/TuragaLab/flybody /tmp/flybody
cp /tmp/flybody/flybody/fruitfly/assets/*.obj public/flybody/
.venv-tf/bin/python tools/bake_flybody_bundle.py # → public/flybody.bundle.bin

npm install
npm run dev # http://localhost:8766
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"data": "bash tools/download_data.sh",
"convert": ".venv/bin/python tools/build_csr.py",
"test:e2e": "playwright test",
"test:unit": "node --test tests-unit/*.test.ts",
"bench:brain": "playwright test tests/bench.spec.ts --reporter=list",
"build:slim": "npm run build && rm -rf dist/flybody dist/flybody.bundle.bin dist/brain.bin dist/brain.meta.json dist/vnc.bin dist/vnc.meta.json dist/walking-policy.bin dist/walking-obs-norm.bin dist/walking-ref.bin dist/walking-policy-fixtures.json",
"deploy": "npm run build:slim && npx --yes wrangler pages deploy dist --project-name=webgpu-fly --branch=main",
Expand Down
2 changes: 1 addition & 1 deletion src/brain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export async function loadBrain(url: string = "/brain.bin"): Promise<Brain> {
// skip the ~125 MB network fetch (~30s on the dev server). Cache
// key is the full URL including ?v=<sha> from assets.json, so a
// new build naturally invalidates the cache (different URL = new
// entry). Old entries are eventually evicted under storage pressure.
// entry), and idbPut deletes the previous generation of the same asset.
const buf = await getOrFetch(url, url);
return parseBrain(buf);
}
Expand Down
19 changes: 15 additions & 4 deletions src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
// only download once per machine. Survives hard refreshes (vite's
// no-cache header otherwise re-downloads on Cmd+Shift+R).
//
// Single object store keyed by filename. Stores an `{etag, size, bytes}`
// blob; on hit we revalidate cheaply by comparing size against the new
// HEAD/Content-Length. If size matches, we trust IDB.
// Single object store keyed by the full asset URL. Stores an
// `{etag, size, bytes}` blob; on hit we serve the cached bytes directly.
// Invalidation comes from the ?v=<sha> in the key — a new build asks for
// a different key, and idbPut drops the previous generation.

const DB_NAME = "webgpu-fly-cache";
const DB_VERSION = 1;
Expand Down Expand Up @@ -46,7 +47,17 @@ async function idbPut(key: string, value: Entry): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
tx.objectStore(STORE).put(value, key);
const store = tx.objectStore(STORE);
store.put(value, key);
// Drop older generations of the same asset — the key is the versioned
// URL, so a rebuild would otherwise orphan the previous ~140 MB entry
// forever. IDB evicts whole origins, never individual records.
const base = key.split("?")[0];
store.getAllKeys().onsuccess = (e) => {
for (const k of (e.target as IDBRequest<IDBValidKey[]>).result) {
if (typeof k === "string" && k !== key && k.split("?")[0] === base) store.delete(k);
}
};
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
Expand Down
Loading
Loading