diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35763cd..5b5b080 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ concurrency: jobs: build: - name: typecheck · build + name: typecheck · unit · build runs-on: ubuntu-latest steps: @@ -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 diff --git a/.zenodo.json b/.zenodo.json index 93658b7..aa7f334 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 — one dispatch per timestep, 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 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.", "keywords": [ "WebGPU", "WebAssembly", diff --git a/CLAUDE.md b/CLAUDE.md index fd2b74b..23e69a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` @@ -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 @@ -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 @@ -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. @@ -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. diff --git a/DEPLOY.md b/DEPLOY.md index 6fe72b8..7ed3bde 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -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 | @@ -46,11 +47,17 @@ VITE_BRAIN_META_URL=https:///brain.meta.json VITE_VNC_URL=https:///vnc.bin VITE_VNC_META_URL=https:///vnc.meta.json VITE_FLYBODY_URL=https:///flybody +VITE_FLYBODY_BUNDLE_URL=https:///flybody.bundle.bin +VITE_WALKING_POLICY_URL=https:///walking-policy.bin +VITE_WALKING_OBS_NORM_URL=https:///walking-obs-norm.bin ``` `` 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 { @@ -67,17 +74,10 @@ VITE_FLYBODY_URL=https:///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 diff --git a/LIMITATIONS.md b/LIMITATIONS.md index 38ecdc6..a0f9321 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -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 diff --git a/README.md b/README.md index dcd0216..abcc845 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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 @@ -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 diff --git a/package.json b/package.json index 31a9ebb..55ee9f7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/brain.ts b/src/brain.ts index 69aa8b9..c593a01 100644 --- a/src/brain.ts +++ b/src/brain.ts @@ -38,7 +38,7 @@ export async function loadBrain(url: string = "/brain.bin"): Promise { // skip the ~125 MB network fetch (~30s on the dev server). Cache // key is the full URL including ?v= 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); } diff --git a/src/cache.ts b/src/cache.ts index 0d37b71..122a11c 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -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= 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; @@ -46,7 +47,17 @@ async function idbPut(key: string, value: Entry): Promise { 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).result) { + if (typeof k === "string" && k !== key && k.split("?")[0] === base) store.delete(k); + } + }; tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); diff --git a/src/game.ts b/src/game.ts index a03d1e2..0bb3b4b 100644 --- a/src/game.ts +++ b/src/game.ts @@ -13,9 +13,9 @@ // even though the brain ticks at ~10-15 Hz. // - HUD updates each animation frame: timer, distance, score. // - Win = body within WIN_RADIUS cm of target. Stop clock, show result. -// - Replay: record (t_ms, key_idx) tuples; encode with target seed +// - Replay: record (t_step, key_idx) tuples; encode with target seed // into URL hash. On load with hash, enter replay mode and replay -// keys at recorded times against the same seeded target. +// keys at the recorded brain steps against the same seeded target. import type { FlySim } from "./sim"; import type { Room } from "./room"; @@ -64,7 +64,7 @@ function dailySeed(): number { } interface ReplayEvent { - t: number; // ms since round start + t: number; // brain steps since round start key: number; // index into dns[] down: boolean; // press or release } @@ -74,6 +74,7 @@ export class Game { private state: State = "boot"; private pressed = new Set(); private roundStart = 0; + private roundStartStep = 0; private elapsedMs = 0; private events: ReplayEvent[] = []; private spikeCount = 0; @@ -100,12 +101,19 @@ export class Game { start() { this.buildHud(); this.bindKeys(); - this.startBrainLoop(); + this.startBrainLoop().catch((e) => this.ctx.log(`brain loop stopped: ${(e as Error).message}`, "err")); this.startHudLoop(); this.maybeEnterReplay(); if (this.state === "boot") this.enterIdle(); } + /** Round clock in brain steps. Replay events are stamped and drained + * on this clock, not wall time, so playback does not depend on the + * display refresh rate or on how fast the brain loop is scheduled. */ + private roundStep(): number { + return this.ctx.sim.currentStep - this.roundStartStep; + } + // ───── HUD construction ────────────────────────────────────────── private buildHud() { @@ -161,13 +169,13 @@ export class Game { if (down) { if (!this.pressed.has(i)) { this.pressed.add(i); - this.events.push({ t: performance.now() - this.roundStart, key: i, down: true }); + this.events.push({ t: this.roundStep(), key: i, down: true }); this.flashKey(i, true); } } else { if (this.pressed.has(i)) { this.pressed.delete(i); - this.events.push({ t: performance.now() - this.roundStart, key: i, down: false }); + this.events.push({ t: this.roundStep(), key: i, down: false }); this.flashKey(i, false); } } @@ -277,6 +285,7 @@ export class Game { private enterPlaying() { this.state = "playing"; this.roundStart = performance.now(); + this.roundStartStep = this.ctx.sim.currentStep; this.overlay.style.display = "none"; this.ctx.log(`game: round started, target seed ${this.targetSeed.toString(16)}`, "ok"); } @@ -284,6 +293,9 @@ export class Game { private enterWon() { this.state = "won"; this.elapsedMs = performance.now() - this.roundStart; + // Release whatever is still held, otherwise the encoded replay has + // an unmatched down and the replayed fly stays stimulated forever. + for (const k of this.pressed) this.events.push({ t: this.roundStep(), key: k, down: false }); this.pressed.clear(); this.ctx.room.setDrive(0, 0); const score = this.computeScore(this.elapsedMs, this.spikeCount); @@ -293,6 +305,8 @@ export class Game { // Behavior recipe: which DNs the player used, how long held in // total. Press events have `down: true`; pair each with the next // matching `down: false` to compute hold duration. + // Event stamps are brain steps; DEFAULT_PARAMS.dtMs is 1.0, so one + // step is 1 ms of simulated time and the durations are already ms. const holdMs = new Array(this.ctx.dns.length).fill(0); const lastDown = new Array(this.ctx.dns.length).fill(-1); for (const ev of this.events) { @@ -304,7 +318,7 @@ export class Game { } // Close any keys still held at win. for (let k = 0; k < holdMs.length; k++) { - if (lastDown[k] >= 0) holdMs[k] += Math.max(0, this.elapsedMs - lastDown[k]); + if (lastDown[k] >= 0) holdMs[k] += Math.max(0, this.roundStep() - lastDown[k]); } const ranked = holdMs .map((ms, i) => ({ ms, i })) @@ -402,6 +416,7 @@ export class Game { if (this.state !== "replay") return; this.overlay.style.display = "none"; this.roundStart = performance.now(); + this.roundStartStep = this.ctx.sim.currentStep; this.replayIdx = 0; this.ctx.log(`game: replaying ${this.replayQueue.length} events`, "ok"); // Show a persistent "watching replay" banner during playback that @@ -461,7 +476,7 @@ export class Game { if (this.state !== "playing") return; if (e.repeat) return; this.pressed.add(idx); - this.events.push({ t: performance.now() - this.roundStart, key: idx, down: true }); + this.events.push({ t: this.roundStep(), key: idx, down: true }); this.flashKey(idx, true); }); @@ -472,7 +487,7 @@ export class Game { if (this.state !== "playing") return; if (this.pressed.has(idx)) { this.pressed.delete(idx); - this.events.push({ t: performance.now() - this.roundStart, key: idx, down: false }); + this.events.push({ t: this.roundStep(), key: idx, down: false }); this.flashKey(idx, false); } }); @@ -499,6 +514,7 @@ export class Game { const activeIdxs = this.activeStimIdxs(); for (const dnIdx of activeIdxs) { const dn = this.ctx.dns[dnIdx]; + if (!dn) continue; for (const i of dn.neurons) ext[i] = STIM_AMP; } @@ -507,7 +523,7 @@ export class Game { this.ctx.viewer.pushSnapshot(rate); // Sum spikes in this burst for the score. - if (this.state === "playing") { + if (this.state === "playing" || this.state === "replay") { let s = 0; for (let i = 0; i < rate.length; i++) s += rate[i]; // captureRollingRate returns spikes-per-step normalised, so @@ -520,9 +536,9 @@ export class Game { // pass no visual sample. await this.ctx.applyDrive(rate, undefined); - // Replay-mode: advance the key queue by elapsed time. + // Replay-mode: advance the key queue by elapsed brain steps. if (this.state === "replay" && this.roundStart > 0) { - const t = performance.now() - this.roundStart; + const t = this.roundStep(); while (this.replayIdx < this.replayQueue.length && this.replayQueue[this.replayIdx].t <= t) { const ev = this.replayQueue[this.replayIdx++]; @@ -579,6 +595,15 @@ export class Game { const t = performance.now() - this.roundStart; this.timerEl.textContent = this.formatTime(t); this.spikesEl.textContent = this.spikeCount.toLocaleString(); + // Reaching the target ends the playback, but deliberately does not + // go through enterWon() — the score and the share card belong to + // whoever recorded the run, not to whoever opened the link. + if (Number.isFinite(dist) && dist < WIN_RADIUS_CM) { + this.pressed.clear(); + this.ctx.room.setDrive(0, 0); + this.elapsedMs = t; + this.state = "won"; + } } else if (this.state === "won") { this.timerEl.textContent = this.formatTime(this.elapsedMs); } @@ -646,19 +671,36 @@ export class Game { // ───── Replay encoding ─────────────────────────────────────────── + /** FNV-1a over the DN names. Events encode indices into dns[], so a + * reorder, insert or removal has to invalidate old URLs; hashing the + * roster does that automatically, with no version byte to maintain. */ + private dnFingerprint(): number { + let h = 0x811c9dc5; + for (const dn of this.ctx.dns) { + for (let i = 0; i < dn.name.length; i++) { + h ^= dn.name.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + h ^= 0x2c; h = Math.imul(h, 0x01000193) >>> 0; // separator + } + return h & 0xffff; + } + private encodeReplayUrl(): string { - // Format: 4B seed (LE) + 4B per event (u24 t_ms LE + u8 (key|down)). + // Format: 2B dn fingerprint (LE) + 4B seed (LE) + 4B per event + // (u24 t_steps LE + u8 (key|down)). const N = this.events.length; - const buf = new Uint8Array(4 + 4 * N); + const buf = new Uint8Array(6 + 4 * N); const v = new DataView(buf.buffer); - v.setUint32(0, this.targetSeed, true); + v.setUint16(0, this.dnFingerprint(), true); + v.setUint32(2, this.targetSeed, true); for (let i = 0; i < N; i++) { const e = this.events[i]; const t = Math.min(0xffffff, Math.max(0, Math.round(e.t))); - v.setUint8(4 + i * 4 + 0, t & 0xff); - v.setUint8(4 + i * 4 + 1, (t >> 8) & 0xff); - v.setUint8(4 + i * 4 + 2, (t >> 16) & 0xff); - v.setUint8(4 + i * 4 + 3, ((e.key & 0x7f) << 1) | (e.down ? 1 : 0)); + v.setUint8(6 + i * 4 + 0, t & 0xff); + v.setUint8(6 + i * 4 + 1, (t >> 8) & 0xff); + v.setUint8(6 + i * 4 + 2, (t >> 16) & 0xff); + v.setUint8(6 + i * 4 + 3, ((e.key & 0x7f) << 1) | (e.down ? 1 : 0)); } let bin = ""; for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]); @@ -679,15 +721,24 @@ export class Game { const buf = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i); const v = new DataView(buf.buffer); - const seed = v.getUint32(0, true); - const N = (buf.length - 4) >> 2; + // Anything that is not this build's roster is unplayable: the key + // indices mean something else, and pre-fingerprint payloads stamp + // wall-clock ms where this build expects brain steps. + if (buf.length < 6 || (buf.length - 6) % 4 !== 0 + || v.getUint16(0, true) !== this.dnFingerprint()) { + this.ctx.log("replay was recorded against a different DN set — ignoring", "warn"); + return; + } + const seed = v.getUint32(2, true); + const N = (buf.length - 6) >> 2; const events: ReplayEvent[] = []; for (let i = 0; i < N; i++) { - const t = v.getUint8(4 + i * 4 + 0) - | (v.getUint8(4 + i * 4 + 1) << 8) - | (v.getUint8(4 + i * 4 + 2) << 16); - const packed = v.getUint8(4 + i * 4 + 3); - events.push({ t, key: (packed >> 1) & 0x7f, down: (packed & 1) === 1 }); + const t = v.getUint8(6 + i * 4 + 0) + | (v.getUint8(6 + i * 4 + 1) << 8) + | (v.getUint8(6 + i * 4 + 2) << 16); + const packed = v.getUint8(6 + i * 4 + 3); + const key = (packed >> 1) & 0x7f; + if (key < this.ctx.dns.length) events.push({ t, key, down: (packed & 1) === 1 }); } this.enterReplay(seed, events); // Set roundStart to 0 so SPACE triggers playback. diff --git a/src/main.ts b/src/main.ts index a8d044e..3accdc9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -135,6 +135,15 @@ function bootSkip(name: "brain" | "vnc" | "body", detail: string) { if (det) det.textContent = detail; bootMaybeDismiss(); } +// Surface a fatal boot error on the overlay itself — in game mode the log +// pane is hidden, so log() alone leaves the overlay spinning forever. +function bootFail(msg: string) { + const el = document.querySelector("#boot .blink"); + if (!el) return; // overlay already dismissed/removed + el.textContent = msg; + el.style.color = "#ff6b6b"; + el.style.animation = "none"; +} async function main() { // Cache-bust manifest. Maps asset filename → "?v=<12-char sha>" so the @@ -190,10 +199,14 @@ async function main() { let famousDns: Record = {}; let famousDnLabels: Record = {}; try { - const meta = await (await fetch(metaUrl + versionFor("brain.meta.json"))).json(); + const r = await fetch(metaUrl + versionFor("brain.meta.json")); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const meta = await r.json(); famousDns = meta.famous_dns ?? {}; famousDnLabels = meta.famous_dn_descriptions ?? {}; - } catch {} + } catch (e) { + log(`brain.meta.json unavailable (${(e as Error).message}); famous-DN buttons disabled`, "warn"); + } log(""); const container = document.getElementById("canvas-container") as HTMLDivElement; @@ -492,6 +505,7 @@ async function main() { if (!("gpu" in navigator)) { log("navigator.gpu missing — open in Chrome / Edge", "err"); + bootFail("WebGPU unavailable — open in Chrome or Edge"); return; } const sim = await FlySim.create(brain, { ...DEFAULT_PARAMS }); @@ -747,74 +761,78 @@ async function main() { btn.classList.add("active"); controls.hidden = true; - log(""); - log(`--- ${stim.label} ---`, "ok"); - const { ext, driven } = stim.build(brain); - log(`driving ${driven.toLocaleString()} neurons`); - - sim.reset(); resetVnc(); - sim.setExternalInput(ext); - viewer.clearSnapshots(); - room.resetFly(); - - const t0 = performance.now(); - for (let s = 0; s < N_SNAPSHOTS; s++) { - const rate = await sim.captureRollingRate(STEPS_PER_SNAPSHOT); - viewer.pushSnapshot(rate); - await applyDriveFromSnapshot(rate); - } - const elapsed = performance.now() - t0; - const totalSteps = N_SNAPSHOTS * STEPS_PER_SNAPSHOT; - log(`${totalSteps} steps in ${elapsed.toFixed(0)} ms wall (${(elapsed / totalSteps).toFixed(2)} ms/step)`, "ok"); - - // Diagnostic: read back Vm and report distribution. If kernel ran - // and synaptic drive is reaching neurons, max(Vm) should approach - // v_thresh = -45 mV. If Vm sits at -52 (=v_rest) for everyone, the - // kernel didn't move — that's a structural bug, not calibration. - const vm = await sim.readVm(); - let vmMin = Infinity, vmMax = -Infinity, vmSum = 0; - let aboveRest = 0; - for (let i = 0; i < vm.length; i++) { - if (vm[i] < vmMin) vmMin = vm[i]; - if (vm[i] > vmMax) vmMax = vm[i]; - vmSum += vm[i]; - if (vm[i] > sim.params.vRest + 0.001) aboveRest++; - } - log(`vm: min=${vmMin.toFixed(2)} max=${vmMax.toFixed(2)} mean=${(vmSum / vm.length).toFixed(2)} above-rest=${aboveRest.toLocaleString()}`); - // Drive persists at the stim's end-of-window value so the user - // can watch the body keep walking after the brain sim completes. - // Click another stim (or Spontaneous) to change it. - - // Per-class peak active count - const peak = new Map(); - for (const snap of [...Array(viewer.numSnapshots)].map((_, i) => viewer["snapshots"][i] as Float32Array)) { - const live = new Map(); - for (let i = 0; i < snap.length; i++) { - if (snap[i] > 0) live.set(neurons.superClass[i], (live.get(neurons.superClass[i]) ?? 0) + 1); + try { + log(""); + log(`--- ${stim.label} ---`, "ok"); + const { ext, driven } = stim.build(brain); + log(`driving ${driven.toLocaleString()} neurons`); + + sim.reset(); resetVnc(); + sim.setExternalInput(ext); + viewer.clearSnapshots(); + room.resetFly(); + + const t0 = performance.now(); + for (let s = 0; s < N_SNAPSHOTS; s++) { + const rate = await sim.captureRollingRate(STEPS_PER_SNAPSHOT); + viewer.pushSnapshot(rate); + await applyDriveFromSnapshot(rate); } - for (const [k, v] of live) { - if (v > (peak.get(k) ?? 0)) peak.set(k, v); + const elapsed = performance.now() - t0; + const totalSteps = N_SNAPSHOTS * STEPS_PER_SNAPSHOT; + log(`${totalSteps} steps in ${elapsed.toFixed(0)} ms wall (${(elapsed / totalSteps).toFixed(2)} ms/step)`, "ok"); + + // Diagnostic: read back Vm and report distribution. If kernel ran + // and synaptic drive is reaching neurons, max(Vm) should approach + // v_thresh = -45 mV. If Vm sits at -52 (=v_rest) for everyone, the + // kernel didn't move — that's a structural bug, not calibration. + const vm = await sim.readVm(); + let vmMin = Infinity, vmMax = -Infinity, vmSum = 0; + let aboveRest = 0; + for (let i = 0; i < vm.length; i++) { + if (vm[i] < vmMin) vmMin = vm[i]; + if (vm[i] > vmMax) vmMax = vm[i]; + vmSum += vm[i]; + if (vm[i] > sim.params.vRest + 0.001) aboveRest++; } - } - log("peak active / total per super_class:"); - for (const [cls, n] of [...peak.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6)) { - const total = sizes.get(cls) ?? 1; - log(` ${SUPER_CLASS[cls] ?? cls}: ${n.toLocaleString()} / ${total.toLocaleString()} (${(100 * n / total).toFixed(1)}%)`); - } - const snaps = [...Array(viewer.numSnapshots)].map((_, i) => viewer["snapshots"][i] as Float32Array); - logHeroValidation(snaps); - - // Wire scrub bar to new snapshot count, autoplay - scrub.max = String(viewer.numSnapshots - 1); - scrub.value = "0"; - label.textContent = `snap 0 / ${viewer.numSnapshots} (t=0 ms)`; - controls.hidden = false; - playing = true; - viewer.setAutoplay(true); - playBtn.textContent = "⏸"; + log(`vm: min=${vmMin.toFixed(2)} max=${vmMax.toFixed(2)} mean=${(vmSum / vm.length).toFixed(2)} above-rest=${aboveRest.toLocaleString()}`); + // Drive persists at the stim's end-of-window value so the user + // can watch the body keep walking after the brain sim completes. + // Click another stim (or Spontaneous) to change it. + + // Per-class peak active count + const peak = new Map(); + for (const snap of [...Array(viewer.numSnapshots)].map((_, i) => viewer["snapshots"][i] as Float32Array)) { + const live = new Map(); + for (let i = 0; i < snap.length; i++) { + if (snap[i] > 0) live.set(neurons.superClass[i], (live.get(neurons.superClass[i]) ?? 0) + 1); + } + for (const [k, v] of live) { + if (v > (peak.get(k) ?? 0)) peak.set(k, v); + } + } + log("peak active / total per super_class:"); + for (const [cls, n] of [...peak.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6)) { + const total = sizes.get(cls) ?? 1; + log(` ${SUPER_CLASS[cls] ?? cls}: ${n.toLocaleString()} / ${total.toLocaleString()} (${(100 * n / total).toFixed(1)}%)`); + } + const snaps = [...Array(viewer.numSnapshots)].map((_, i) => viewer["snapshots"][i] as Float32Array); + logHeroValidation(snaps); - buttons.forEach((b) => { b.disabled = false; }); - busy = false; + // Wire scrub bar to new snapshot count, autoplay + scrub.max = String(viewer.numSnapshots - 1); + scrub.value = "0"; + label.textContent = `snap 0 / ${viewer.numSnapshots} (t=0 ms)`; + controls.hidden = false; + playing = true; + viewer.setAutoplay(true); + playBtn.textContent = "⏸"; + } catch (e) { + log(`stim failed: ${(e as Error).message}`, "err"); + } finally { + buttons.forEach((b) => { b.disabled = false; }); + busy = false; + } } // --- Famous-DN stim: drive both L+R copies of a named DN --- @@ -825,60 +843,65 @@ async function main() { btn.classList.add("active"); controls.hidden = true; - log(""); - log(`--- DN stim: ${name} (${idxs.length} neurons, ${famousDnLabels[name] ?? ""}) ---`, "ok"); - const ext = new Float32Array(header.numNeurons); - // Direct stim of just 2 DN neurons needs a hefty amplitude to - // ignite a real cascade through the alpha-synapse-shaped fan-out. - // Lower than ~3.0 leaves DN-with-weak-downstream (DNb01, DNg13, - // DNp01) firing only the 2 stimmed cells with no propagation. - for (const idx of idxs) ext[idx] = 4.0; - sim.reset(); resetVnc(); - sim.setExternalInput(ext); - viewer.clearSnapshots(); - if (idxs.length > 0) viewer.highlightNeuron(idxs[0]); - room.resetFly(); - - const t0 = performance.now(); - for (let s = 0; s < N_SNAPSHOTS; s++) { - const rate = await sim.captureRollingRate(STEPS_PER_SNAPSHOT); - viewer.pushSnapshot(rate); - await applyDriveFromSnapshot(rate); - } - const elapsed = performance.now() - t0; - log(`${N_SNAPSHOTS * STEPS_PER_SNAPSHOT} steps in ${elapsed.toFixed(0)} ms`, "ok"); + try { + log(""); + log(`--- DN stim: ${name} (${idxs.length} neurons, ${famousDnLabels[name] ?? ""}) ---`, "ok"); + const ext = new Float32Array(header.numNeurons); + // Direct stim of just 2 DN neurons needs a hefty amplitude to + // ignite a real cascade through the alpha-synapse-shaped fan-out. + // Lower than ~3.0 leaves DN-with-weak-downstream (DNb01, DNg13, + // DNp01) firing only the 2 stimmed cells with no propagation. + for (const idx of idxs) ext[idx] = 4.0; + sim.reset(); resetVnc(); + sim.setExternalInput(ext); + viewer.clearSnapshots(); + if (idxs.length > 0) viewer.highlightNeuron(idxs[0]); + room.resetFly(); - { - const vm = await sim.readVm(); - let vmMax = -Infinity; - for (let i = 0; i < vm.length; i++) if (vm[i] > vmMax) vmMax = vm[i]; - const vmAtIdx = idxs.length ? vm[idxs[0]] : NaN; - log(`vm: max=${vmMax.toFixed(2)} mV driven[0]=${vmAtIdx.toFixed(2)} mV`); - } + const t0 = performance.now(); + for (let s = 0; s < N_SNAPSHOTS; s++) { + const rate = await sim.captureRollingRate(STEPS_PER_SNAPSHOT); + viewer.pushSnapshot(rate); + await applyDriveFromSnapshot(rate); + } + const elapsed = performance.now() - t0; + log(`${N_SNAPSHOTS * STEPS_PER_SNAPSHOT} steps in ${elapsed.toFixed(0)} ms`, "ok"); + + { + const vm = await sim.readVm(); + let vmMax = -Infinity; + for (let i = 0; i < vm.length; i++) if (vm[i] > vmMax) vmMax = vm[i]; + const vmAtIdx = idxs.length ? vm[idxs[0]] : NaN; + log(`vm: max=${vmMax.toFixed(2)} mV driven[0]=${vmAtIdx.toFixed(2)} mV`); + } - let recruited = 0; - const last = viewer["snapshots"][viewer.numSnapshots - 1] as Float32Array; - for (let i = 0; i < last.length; i++) if (last[i] > 0) recruited++; - log(`final-window recruits: ${recruited.toLocaleString()} / ${header.numNeurons.toLocaleString()}`); - if (recruited > 100) { - const snaps = [...Array(viewer.numSnapshots)].map((_, j) => viewer["snapshots"][j] as Float32Array); - logHeroValidation(snaps); - } + let recruited = 0; + const last = viewer["snapshots"][viewer.numSnapshots - 1] as Float32Array; + for (let i = 0; i < last.length; i++) if (last[i] > 0) recruited++; + log(`final-window recruits: ${recruited.toLocaleString()} / ${header.numNeurons.toLocaleString()}`); + if (recruited > 100) { + const snaps = [...Array(viewer.numSnapshots)].map((_, j) => viewer["snapshots"][j] as Float32Array); + logHeroValidation(snaps); + } - // Motor command was already produced each step by applyDriveFromSnapshot - // → motorFromBrain, which reads this DN's *actual* spike rate from the - // window and multiplies by its canonical primitive. No lookup table. - log(`brain-driven motor: fwd=${driveFwd.toFixed(2)} turn=${driveTurn.toFixed(2)}`, "ok"); - - scrub.max = String(viewer.numSnapshots - 1); - scrub.value = "0"; - label.textContent = `snap 0 / ${viewer.numSnapshots} (t=0 ms)`; - controls.hidden = false; - playing = true; - viewer.setAutoplay(true); - playBtn.textContent = "⏸"; - buttons.forEach((b) => { b.disabled = false; }); - busy = false; + // Motor command was already produced each step by applyDriveFromSnapshot + // → motorFromBrain, which reads this DN's *actual* spike rate from the + // window and multiplies by its canonical primitive. No lookup table. + log(`brain-driven motor: fwd=${driveFwd.toFixed(2)} turn=${driveTurn.toFixed(2)}`, "ok"); + + scrub.max = String(viewer.numSnapshots - 1); + scrub.value = "0"; + label.textContent = `snap 0 / ${viewer.numSnapshots} (t=0 ms)`; + controls.hidden = false; + playing = true; + viewer.setAutoplay(true); + playBtn.textContent = "⏸"; + } catch (e) { + log(`stim failed: ${(e as Error).message}`, "err"); + } finally { + buttons.forEach((b) => { b.disabled = false; }); + busy = false; + } } // --- Closed-loop visual mode --- @@ -900,99 +923,107 @@ async function main() { buttons.forEach((b) => { if (b !== btn) b.classList.remove("active"); }); btn.classList.add("active"); controls.hidden = true; - log(""); - log(`--- closed-loop visual: track red target ---`, "ok"); - sim.reset(); resetVnc(); - viewer.clearSnapshots(); - room.resetFly(); - // Reset stale drive from previous stims so the fly starts from - // standstill and reacts to THIS loop's sensor signal, not the - // last preset's residual. - driveFwd = 0; - driveTurn = 0; - room.setDrive(0, 0); - // Yield long enough for the room's render tick to repaint the - // retina from the just-reset body pose. Otherwise tick 1 reads - // stale retina pixels (from before the reset, when the body had - // wandered) and falsely reports the target lost. - await new Promise((r) => setTimeout(r, 100)); - // Sample 4000 optic neurons per side — enough cascade to reach DN - // through the connectome's optic→central wiring. Below ~2000 the - // signal dissipates before producing meaningful DN activity. - const sampleN = 4000; - const stride = Math.max(1, Math.floor(opticLeft.length / sampleN)); - const lSubset: number[] = []; - for (let i = 0; i < opticLeft.length; i += stride) lSubset.push(opticLeft[i]); - const rStride = Math.max(1, Math.floor(opticRight.length / sampleN)); - const rSubset: number[] = []; - for (let i = 0; i < opticRight.length; i += rStride) rSubset.push(opticRight[i]); - - const ext = new Float32Array(header.numNeurons); - let tick = 0; - let lostTicks = 0; - let lastKnownAngle = 0; - let lastKnownArea = 0; - while (continuousMode) { - // Sense from a real retinal render at the fly's head pose. No - // geometry shortcut — pixels of the scene get sampled, red blob - // centroid → angle. If target is behind, angle is NaN. - const sample = room.retinalSample(); - const angle = sample.angle; - const dist = room.targetDistance(); - ext.fill(0); - if (Number.isFinite(angle) && sample.area > 0) { - lostTicks = 0; - lastKnownAngle = angle; - lastKnownArea = sample.area; - const align = 1 - Math.abs(angle) / RETINA_FOV_RAD; // 0..1 - const lScale = align * (angle > 0 ? 1.0 : 0.3); - const rScale = align * (angle < 0 ? 1.0 : 0.3); - const amp = 1.5 + 12 * sample.area; - for (const i of lSubset) ext[i] = amp * lScale; - for (const i of rSubset) ext[i] = amp * rScale; - } else { - lostTicks++; - } - sim.setExternalInput(ext); - - // Step brain in a short burst (50 ms simulated). - const rate = await sim.captureRollingRate(50); - viewer.pushSnapshot(rate); - - if (lostTicks === 0) { - // Target visible: full brain → spine → body path. - await applyDriveFromSnapshot(rate, sample); - } else if (lostTicks <= 3) { - // Target briefly lost (1-3 ticks). Keep tracking using the - // last-known angle — this smooths out single-frame retina - // dropouts that the NaN-instant-sweep was making jumpy. - // Decay the angle estimate by 1.5× each missed tick so the - // memory fades if target stays gone. - const decay = 1 + 0.5 * lostTicks; - const memSample = { angle: lastKnownAngle * decay, area: lastKnownArea * 0.6 }; - await applyDriveFromSnapshot(rate, memSample); - } else { - // Target lost for 4+ ticks: enter sweep mode. Bypass spine - // entirely; alternating turn every 8 ticks to find target. - const scanDir = lastKnownAngle >= 0 ? 1 : -1; - const scanCycle = Math.floor((lostTicks - 4) / 8) % 2 === 0 ? 1 : -1; - driveFwd = 0; - driveTurn = scanDir * scanCycle * 0.5; - room.setDrive(driveFwd, driveTurn); - } + try { + log(""); + log(`--- closed-loop visual: track red target ---`, "ok"); + sim.reset(); resetVnc(); + viewer.clearSnapshots(); + room.resetFly(); + // Reset stale drive from previous stims so the fly starts from + // standstill and reacts to THIS loop's sensor signal, not the + // last preset's residual. + driveFwd = 0; + driveTurn = 0; + room.setDrive(0, 0); + // Yield long enough for the room's render tick to repaint the + // retina from the just-reset body pose. Otherwise tick 1 reads + // stale retina pixels (from before the reset, when the body had + // wandered) and falsely reports the target lost. + await new Promise((r) => setTimeout(r, 100)); + // Sample 4000 optic neurons per side — enough cascade to reach DN + // through the connectome's optic→central wiring. Below ~2000 the + // signal dissipates before producing meaningful DN activity. + const sampleN = 4000; + const stride = Math.max(1, Math.floor(opticLeft.length / sampleN)); + const lSubset: number[] = []; + for (let i = 0; i < opticLeft.length; i += stride) lSubset.push(opticLeft[i]); + const rStride = Math.max(1, Math.floor(opticRight.length / sampleN)); + const rSubset: number[] = []; + for (let i = 0; i < opticRight.length; i += rStride) rSubset.push(opticRight[i]); + + const ext = new Float32Array(header.numNeurons); + let tick = 0; + let lostTicks = 0; + let lastKnownAngle = 0; + let lastKnownArea = 0; + while (continuousMode) { + // Sense from a real retinal render at the fly's head pose. No + // geometry shortcut — pixels of the scene get sampled, red blob + // centroid → angle. If target is behind, angle is NaN. + const sample = room.retinalSample(); + const angle = sample.angle; + const dist = room.targetDistance(); + ext.fill(0); + if (Number.isFinite(angle) && sample.area > 0) { + lostTicks = 0; + lastKnownAngle = angle; + lastKnownArea = sample.area; + const align = 1 - Math.abs(angle) / RETINA_FOV_RAD; // 0..1 + const lScale = align * (angle > 0 ? 1.0 : 0.3); + const rScale = align * (angle < 0 ? 1.0 : 0.3); + const amp = 1.5 + 12 * sample.area; + for (const i of lSubset) ext[i] = amp * lScale; + for (const i of rSubset) ext[i] = amp * rScale; + } else { + lostTicks++; + } + sim.setExternalInput(ext); + + // Step brain in a short burst (50 ms simulated). + const rate = await sim.captureRollingRate(50); + viewer.pushSnapshot(rate); + + if (lostTicks === 0) { + // Target visible: full brain → spine → body path. + await applyDriveFromSnapshot(rate, sample); + } else if (lostTicks <= 3) { + // Target briefly lost (1-3 ticks). Keep tracking using the + // last-known angle — this smooths out single-frame retina + // dropouts that the NaN-instant-sweep was making jumpy. + // Decay the angle estimate by 1.5× each missed tick so the + // memory fades if target stays gone. + const decay = 1 + 0.5 * lostTicks; + const memSample = { angle: lastKnownAngle * decay, area: lastKnownArea * 0.6 }; + await applyDriveFromSnapshot(rate, memSample); + } else { + // Target lost for 4+ ticks: enter sweep mode. Bypass spine + // entirely; alternating turn every 8 ticks to find target. + const scanDir = lastKnownAngle >= 0 ? 1 : -1; + const scanCycle = Math.floor((lostTicks - 4) / 8) % 2 === 0 ? 1 : -1; + driveFwd = 0; + driveTurn = scanDir * scanCycle * 0.5; + room.setDrive(driveFwd, driveTurn); + } - tick++; - if (tick <= 10 || tick % 10 === 0) { - log(` tick ${tick}: angle=${(angle * 180 / Math.PI).toFixed(0)}° dist=${dist.toFixed(1)}cm fwd=${driveFwd.toFixed(2)} turn=${driveTurn.toFixed(2)}`); + tick++; + if (tick <= 10 || tick % 10 === 0) { + log(` tick ${tick}: angle=${(angle * 180 / Math.PI).toFixed(0)}° dist=${dist.toFixed(1)}cm fwd=${driveFwd.toFixed(2)} turn=${driveTurn.toFixed(2)}`); + } + // Yield to render. + await new Promise((r) => setTimeout(r, 0)); } - // Yield to render. - await new Promise((r) => setTimeout(r, 0)); + controls.hidden = false; + } catch (e) { + log(`stim failed: ${(e as Error).message}`, "err"); + } finally { + // Cleanup when loop exits. Reached only once the in-flight + // iteration has finished, so the toggle-off path above can't + // re-enable the buttons underneath a running loop. + continuousMode = false; + btn.classList.remove("active"); + buttons.forEach((b) => { b.disabled = false; }); + busy = false; } - // Cleanup when loop exits. - btn.classList.remove("active"); - buttons.forEach((b) => { b.disabled = false; }); - busy = false; - controls.hidden = false; } loopBtn.addEventListener("click", () => runContinuousLoop(loopBtn)); @@ -1159,50 +1190,57 @@ async function main() { buttons.forEach((b) => { b.disabled = true; b.classList.remove("active"); }); controls.hidden = true; - const sc = SUPER_CLASS[neurons.superClass[idx]] ?? "?"; - const hero = neurons.cellType[idx] & 0xff; - const heroName = ["", "KC", "MBON", "LHN", "PN", "ORN", "GF", "DN"][hero] ?? ""; - const tag = heroName ? `${heroName} (${sc})` : sc; - log(""); - log(`--- single-neuron stim: idx ${idx} [${tag}] ---`, "ok"); - - const ext = new Float32Array(header.numNeurons); - ext[idx] = 2.0; // strong pulse on this one cell - sim.reset(); resetVnc(); - sim.setExternalInput(ext); - viewer.clearSnapshots(); - viewer.highlightNeuron(idx); - room.resetFly(); - - const t0 = performance.now(); - for (let s = 0; s < N_SNAPSHOTS; s++) { - const rate = await sim.captureRollingRate(STEPS_PER_SNAPSHOT); - viewer.pushSnapshot(rate); - await applyDriveFromSnapshot(rate); - } - const elapsed = performance.now() - t0; - log(`${N_SNAPSHOTS * STEPS_PER_SNAPSHOT} steps in ${elapsed.toFixed(0)} ms`, "ok"); - - let recruited = 0; - const last = viewer["snapshots"][viewer.numSnapshots - 1] as Float32Array; - for (let i = 0; i < last.length; i++) if (last[i] > 0) recruited++; - log(`final-window recruits: ${recruited.toLocaleString()} / ${header.numNeurons.toLocaleString()}`); - if (recruited > 100) { - const snaps = [...Array(viewer.numSnapshots)].map((_, j) => viewer["snapshots"][j] as Float32Array); - logHeroValidation(snaps); - } + try { + const sc = SUPER_CLASS[neurons.superClass[idx]] ?? "?"; + const hero = neurons.cellType[idx] & 0xff; + const heroName = ["", "KC", "MBON", "LHN", "PN", "ORN", "GF", "DN"][hero] ?? ""; + const tag = heroName ? `${heroName} (${sc})` : sc; + log(""); + log(`--- single-neuron stim: idx ${idx} [${tag}] ---`, "ok"); + + const ext = new Float32Array(header.numNeurons); + ext[idx] = 2.0; // strong pulse on this one cell + sim.reset(); resetVnc(); + sim.setExternalInput(ext); + viewer.clearSnapshots(); + viewer.highlightNeuron(idx); + room.resetFly(); - scrub.max = String(viewer.numSnapshots - 1); - scrub.value = "0"; - label.textContent = `snap 0 / ${viewer.numSnapshots} (t=0 ms)`; - controls.hidden = false; - playing = true; - viewer.setAutoplay(true); - playBtn.textContent = "⏸"; - buttons.forEach((b) => { b.disabled = false; }); - busy = false; + const t0 = performance.now(); + for (let s = 0; s < N_SNAPSHOTS; s++) { + const rate = await sim.captureRollingRate(STEPS_PER_SNAPSHOT); + viewer.pushSnapshot(rate); + await applyDriveFromSnapshot(rate); + } + const elapsed = performance.now() - t0; + log(`${N_SNAPSHOTS * STEPS_PER_SNAPSHOT} steps in ${elapsed.toFixed(0)} ms`, "ok"); + + let recruited = 0; + const last = viewer["snapshots"][viewer.numSnapshots - 1] as Float32Array; + for (let i = 0; i < last.length; i++) if (last[i] > 0) recruited++; + log(`final-window recruits: ${recruited.toLocaleString()} / ${header.numNeurons.toLocaleString()}`); + if (recruited > 100) { + const snaps = [...Array(viewer.numSnapshots)].map((_, j) => viewer["snapshots"][j] as Float32Array); + logHeroValidation(snaps); + } + + scrub.max = String(viewer.numSnapshots - 1); + scrub.value = "0"; + label.textContent = `snap 0 / ${viewer.numSnapshots} (t=0 ms)`; + controls.hidden = false; + playing = true; + viewer.setAutoplay(true); + playBtn.textContent = "⏸"; + } catch (e) { + log(`stim failed: ${(e as Error).message}`, "err"); + } finally { + buttons.forEach((b) => { b.disabled = false; }); + busy = false; + } } - viewer.onPick((idx) => { void runSingleNeuronStim(idx); }); + // Science mode only — the brain pane stays clickable under body.game, and + // a 400-step stim there would reset the fly in the middle of a round. + if (APP_MODE === "science") viewer.onPick((idx) => { void runSingleNeuronStim(idx); }); if (APP_MODE === "game") { // Wait for physics to be ready, then start the game. Game mode owns @@ -1243,4 +1281,7 @@ async function main() { } } -main().catch((e) => log(`uncaught: ${(e as Error).stack ?? e}`, "err")); +main().catch((e) => { + log(`uncaught: ${(e as Error).stack ?? e}`, "err"); + bootFail(`failed: ${(e as Error).message}`); +}); diff --git a/src/physics.ts b/src/physics.ts index f252703..82fdb5e 100644 --- a/src/physics.ts +++ b/src/physics.ts @@ -10,7 +10,7 @@ import loadMujoco from "@mujoco/mujoco"; import type { MainModule, MjModel, MjData, - MjVFS, MjvScene, MjvOption, MjvPerturb, MjvCamera, + MjvScene, MjvOption, MjvPerturb, MjvCamera, } from "@mujoco/mujoco"; import { getOrFetch } from "./cache"; @@ -20,7 +20,6 @@ export class Physics { data!: MjData; scene!: MjvScene; - private vfs!: MjVFS; private opt!: MjvOption; private perturb!: MjvPerturb; private cam!: MjvCamera; @@ -102,14 +101,14 @@ export class Physics { ), ); - p.vfs = new p.mujoco.MjVFS(); + const vfs = new p.mujoco.MjVFS(); let totalBytes = 0; for (const file of meshFiles) { const data = fileBytes.get(file); if (!data) { throw new Error(`flybody bundle missing mesh: ${file}`); } - p.vfs.addBuffer(file, data); + vfs.addBuffer(file, data); totalBytes += data.byteLength; } onProgress?.(`loaded ${meshFiles.length} meshes from bundle (${(totalBytes / 1e6).toFixed(0)} MB)`); @@ -117,12 +116,15 @@ export class Physics { // The compiler resolves `` from the // VFS, so we have to register fruitfly.xml there as well. - p.vfs.addBuffer("fruitfly.xml", flyBytes); + vfs.addBuffer("fruitfly.xml", flyBytes); onProgress?.("compiling MJCF (synchronous; tab may freeze ~5-15s)"); const tCompile = performance.now(); - p.model = p.mujoco.MjModel.from_xml_string(floorText, p.vfs); + p.model = p.mujoco.MjModel.from_xml_string(floorText, vfs); 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. + vfs.delete(); onProgress?.(`MJCF compiled in ${((performance.now() - tCompile) / 1000).toFixed(1)} s`); // Initialise to flybody's canonical rest pose, matching native @@ -971,7 +973,14 @@ export class Physics { const qpos = this.data.qpos as Float64Array; const qposSpring = this.model.qpos_spring as Float64Array; if (qposSpring && qposSpring.length === qpos.length) { - qpos.set(qposSpring); + for (const side of ["left", "right"]) { + for (const dof of ["yaw", "roll", "pitch"]) { + const j = this.mujoco.mj_name2id(this.model, this.mujoco.mjtObj.mjOBJ_JOINT.value, `wing_${dof}_${side}`); + if (j < 0) continue; + const adr = (this.model.jnt_qposadr as Int32Array)[j]; + if (adr >= 0 && adr < qpos.length) qpos[adr] = qposSpring[adr]; + } + } } if (qpos.length >= 7) { qpos[0] = 0; qpos[1] = 0; qpos[2] = 0.1278; @@ -987,7 +996,6 @@ export class Physics { this.opt.delete(); this.data.delete(); this.model.delete(); - this.vfs.delete(); } get bodyCount() { return this.model.nbody as number; } diff --git a/src/shaders/lif.wgsl b/src/shaders/lif.wgsl index 76ce367..d67d7dd 100644 --- a/src/shaders/lif.wgsl +++ b/src/shaders/lif.wgsl @@ -12,10 +12,8 @@ // threshold, atomic-OR spike bit into spikes_curr. // // Host ping-pongs spikes_prev / spikes_curr each timestep so the gather -// always reads stable last-step state. The Params struct stayed at its -// proven 9-field / 36-byte layout; a_syn is a compile-time const here -// instead of a runtime field, which sidesteps the silent-dispatch -// failure we hit when expanding the uniform struct. +// always reads stable last-step state. a_syn is a compile-time const +// rather than a Params field because the kernel is fixed at dt = 1 ms. struct Params { num_neurons : u32, @@ -44,8 +42,9 @@ struct Params { const WG_SIZE : u32 = 64u; // a_syn = exp(-dt / tau_syn) for dt = 1 ms, tau_syn = 5 ms. -// Hardcoded so we don't have to extend the Params struct (which broke -// the kernel last time we tried). +// Const, so it silently assumes SimParams.dtMs = 1 — that host param +// feeds alpha and the refractory step count but not this value. Make it +// a Params field if a different dt is ever used. const A_SYN : f32 = 0.81873; fn spike_bit(idx : u32) -> f32 { diff --git a/src/sim.ts b/src/sim.ts index 499a5b7..ce92f3e 100644 --- a/src/sim.ts +++ b/src/sim.ts @@ -87,6 +87,7 @@ export class FlySim { maxStorageBuffersPerShaderStage: adapter.limits.maxStorageBuffersPerShaderStage, }; const device = await adapter.requestDevice({ requiredLimits: required }); + device.lost.then((info) => console.error(`WebGPU device lost: ${info.reason} — ${info.message}`)); return new FlySim(device, brain, params); } diff --git a/tests-unit/vnc.test.ts b/tests-unit/vnc.test.ts new file mode 100644 index 0000000..9680719 --- /dev/null +++ b/tests-unit/vnc.test.ts @@ -0,0 +1,62 @@ +// vnc.test.ts — pure-CPU unit checks that run without a GPU, so CI has +// something that actually validates behaviour (the playwright suite needs +// WebGPU + ~1 GB of assets and cannot run on a hosted runner). +// +// Sits in tests-unit/ rather than tests/ so playwright's testDir ("./tests") +// does not try to collect it. +// +// Assertions are signs and structural invariants only. The LIF constants in +// vnc.ts are emergent dynamics, not a contract — asserting magnitudes would +// turn any legitimate retune into a red build. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { motorFromBrain, resetVnc, type MotorContext } from "../src/vnc.ts"; +import { WALKING_OBS_TOTAL, obsOffset } from "../src/walking-policy.ts"; + +// Five DN inputs at fixed indices; everything else silent. +const ctxFor = (dn: string, visual?: { angle: number; area: number }) => { + const rate = new Float32Array(5); + const names = ["DNa01", "DNa02", "DNb01", "DNg13", "DNp01"]; + const i = names.indexOf(dn); + if (i >= 0) rate[i] = 1; + const ctx: MotorContext = { + famousDns: { DNa01: [0], DNa02: [1], DNb01: [2], DNg13: [3], DNp01: [4] }, + dnLeft: [], + dnRight: [], + visual, + }; + return { rate, ctx }; +}; + +// sharedVnc is module-level mutable LIF state — without resetVnc() every +// case inherits the previous one's membrane potentials. +const drive = (dn: string, visual?: { angle: number; area: number }) => { + resetVnc(); + const { rate, ctx } = ctxFor(dn, visual); + let out = motorFromBrain(rate, ctx); + for (let i = 0; i < 50; i++) out = motorFromBrain(rate, ctx); + return out; +}; + +test("DNa01 drive walks forward", () => { + assert.ok(drive("DNa01").fwd > 0); +}); + +test("DNb01 drive walks backward", () => { + assert.ok(drive("DNb01").fwd < 0); +}); + +test("visual target to the left turns left", () => { + assert.ok(drive("", { angle: 0.5, area: 0.01 }).turn > 0); +}); + +test("visual target to the right turns right", () => { + assert.ok(drive("", { angle: -0.5, area: 0.01 }).turn < 0); +}); + +test("walking observation layout matches the trained policy's input", () => { + assert.equal(WALKING_OBS_TOTAL, 741); + assert.equal(obsOffset("world_zaxis"), 738); +}); diff --git a/tests/game-feel.spec.ts b/tests/game-feel.spec.ts index ebdd0c4..90aaaeb 100644 --- a/tests/game-feel.spec.ts +++ b/tests/game-feel.spec.ts @@ -12,7 +12,7 @@ import { test, expect } from "./fixtures"; test("pressing Q (DNa01) moves the fly toward the target", async ({ page }) => { test.setTimeout(180_000); - await page.goto("/?mode=game"); + await page.goto("/app?mode=game"); await page.waitForFunction( () => /game mode: ready/.test( document.querySelector("#out")?.textContent ?? "", @@ -61,6 +61,10 @@ test("pressing Q (DNa01) moves the fly toward the target", async ({ page }) => { console.log(` final dist: ${d1.toFixed(2)} cm`); console.log(` body x : ${bodyX?.toFixed(3)} cm (started at 0)`); - // The fly should have moved at all (body x changed > 0.1 cm). + // The fly should have moved at all (body x changed > 0.1 cm)... expect(bodyX !== null && Math.abs(bodyX) > 0.1).toBe(true); + // ...and that motion must not be away from the target. Slack is + // deliberate: open-loop Q can curve (see smoke.spec.ts |turn| < 0.4). + expect(d1, `fly moved away from target: ${d0.toFixed(2)} → ${d1.toFixed(2)} cm`) + .toBeLessThan(d0 + 0.5); }); diff --git a/tests/game-replay.spec.ts b/tests/game-replay.spec.ts index 4e99d90..6f9337c 100644 --- a/tests/game-replay.spec.ts +++ b/tests/game-replay.spec.ts @@ -25,10 +25,19 @@ async function startRound(page: import("@playwright/test").Page) { test("replay URL roundtrips events + target seed", async ({ page }) => { test.setTimeout(180_000); - await page.goto("/?mode=game"); + await page.goto("/app?mode=game"); await waitGameReady(page); await startRound(page); + // Seeded target position — the byte-exact, GPU-free determinism check. + // Captured before the forced win below, which moves the target. + const t0 = await page.evaluate(() => { + const g = (window as unknown as { + __game?: { ctx: { room: { targetPos: [number, number, number] } } }; + }).__game!; + return [g.ctx.room.targetPos[0], g.ctx.room.targetPos[1]]; + }); + // Press Q for 1.2s, then E for 0.8s. await page.keyboard.down("q"); await page.waitForTimeout(1200); @@ -80,8 +89,8 @@ test("replay URL roundtrips events + target seed", async ({ page }) => { expect(replayUrl).toContain("mode=game"); const m = replayUrl.match(/#r=([A-Za-z0-9_-]+)/); expect(m).toBeTruthy(); - // 2 down + 2 up events = 4 records × 4 bytes = 16 + 4 (seed) = 20 bytes, - // base64 ≈ 27 chars. Tolerant assertion. + // 2 down + 2 up events = 4 records × 4 bytes = 16 + 6 (dn fingerprint + // + seed) = 22 bytes, base64 ≈ 30 chars. Tolerant assertion. expect(m![1].length).toBeGreaterThan(20); // Open the URL in a fresh context (same persistent profile) and verify @@ -102,7 +111,18 @@ test("replay URL roundtrips events + target seed", async ({ page }) => { ); const bodyText = await page2.locator(".overlay-body").textContent(); - // 4 events: q-down, q-up, e-down, e-up. + // 4 events: q-down, q-up, e-down, e-up. Both keys are released before + // the forced win, so the win adds no synthetic releases. expect(bodyText).toContain("4 keystrokes"); + + // Same seed → same LCG → bit-identical target, on a different page. + const t1 = await page2.evaluate(() => { + const g = (window as unknown as { + __game?: { ctx: { room: { targetPos: [number, number, number] } } }; + }).__game!; + return [g.ctx.room.targetPos[0], g.ctx.room.targetPos[1]]; + }); + expect(t1[0]).toBeCloseTo(t0[0], 9); + expect(t1[1]).toBeCloseTo(t0[1], 9); await page2.close(); }); diff --git a/tests/game-screenshot.spec.ts b/tests/game-screenshot.spec.ts index 1611307..e84a10d 100644 --- a/tests/game-screenshot.spec.ts +++ b/tests/game-screenshot.spec.ts @@ -6,7 +6,7 @@ import { test } from "./fixtures"; test("capture game screenshots", async ({ page }) => { test.setTimeout(180_000); - await page.goto("/?mode=game"); + await page.goto("/app?mode=game"); await page.waitForFunction( () => /game mode: ready/.test(document.querySelector("#out")?.textContent ?? ""), null, diff --git a/tests/game.spec.ts b/tests/game.spec.ts index edff0e3..eae3dfd 100644 --- a/tests/game.spec.ts +++ b/tests/game.spec.ts @@ -18,7 +18,7 @@ test.describe("game mode", () => { test.setTimeout(180_000); test("HUD + key strip + intro render after boot", async ({ page }) => { - await page.goto("/?mode=game"); + await page.goto("/app?mode=game"); // Wait for game readiness signal in the log. await page.waitForFunction( @@ -43,7 +43,7 @@ test.describe("game mode", () => { }); test("SPACE starts a round, key press flashes the strip", async ({ page }) => { - await page.goto("/?mode=game"); + await page.goto("/app?mode=game"); await page.waitForFunction( () => /game mode: ready/.test( document.querySelector("#out")?.textContent ?? "", @@ -74,7 +74,7 @@ test.describe("game mode", () => { }); test("HUD timer ticks during a round", async ({ page }) => { - await page.goto("/?mode=game"); + await page.goto("/app?mode=game"); await page.waitForFunction( () => /game mode: ready/.test( document.querySelector("#out")?.textContent ?? "", @@ -99,7 +99,7 @@ test.describe("game mode", () => { }); test("science mode keeps classic layout", async ({ page }) => { - await page.goto("/?mode=science"); + await page.goto("/app?mode=science"); // Original sidebar should be visible (not game mode). await expect(page.locator("#side")).toBeVisible(); await expect(page.locator("#game-hud")).toBeHidden(); @@ -107,7 +107,7 @@ test.describe("game mode", () => { }); test("daily-challenge button is visible in intro", async ({ page }) => { - await page.goto("/?mode=game"); + await page.goto("/app?mode=game"); await page.waitForFunction( () => /game mode: ready/.test(document.querySelector("#out")?.textContent ?? ""), null, diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 7151b5e..7890dfc 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -62,7 +62,7 @@ function extractNumber(log: string, re: RegExp): number | null { test.describe("webgpu-fly e2e", () => { test.beforeEach(async ({ page }) => { - await page.goto("/?mode=science"); + await page.goto("/app?mode=science"); await waitForLog(page, READY_MSG, 90_000); }); @@ -115,7 +115,7 @@ test.describe("webgpu-fly e2e", () => { // meaningful cascade — cementing the new-DN wiring. test("MDN button stims connectome (Dallmann walking-circuit roster)", async ({ page }) => { const btn = page.locator(`.stim-btn:has(.label:has-text("MDN"))`).first(); - await expect(btn, "MDN famous-DN button missing").toBeVisible({ timeout: 30_000 }); + await expect(btn, "MDN famous-DN button missing").toBeVisible({ timeout: 60_000 }); await clickButton(page, "MDN"); await waitButtonIdle(page, "MDN"); const log = await logText(page); @@ -130,7 +130,7 @@ test.describe("webgpu-fly e2e", () => { // the story (RRN → 21 walking-circuit DNs → premotor → legs). test("RRN button cascades and drives forward motor", async ({ page }) => { const btn = page.locator(`.stim-btn:has(.label:has-text("RRN"))`).first(); - await expect(btn, "RRN famous-DN button missing").toBeVisible({ timeout: 30_000 }); + await expect(btn, "RRN famous-DN button missing").toBeVisible({ timeout: 60_000 }); await clickButton(page, "RRN"); await waitButtonIdle(page, "RRN"); const log = await logText(page); @@ -155,7 +155,7 @@ test.describe("webgpu-fly e2e", () => { // produce a large cascade and net-forward motor. test("BPN button cascades and drives forward motor", async ({ page }) => { const btn = page.locator(`.stim-btn:has(.label:has-text("BPN"))`).first(); - await expect(btn, "BPN famous-DN button missing").toBeVisible({ timeout: 30_000 }); + await expect(btn, "BPN famous-DN button missing").toBeVisible({ timeout: 60_000 }); await clickButton(page, "BPN"); await waitButtonIdle(page, "BPN"); const log = await logText(page); @@ -364,7 +364,7 @@ test.describe("webgpu-fly e2e", () => { // drifts (variable mapping, layer order, activation function), // this test pinpoints it. test("trained walking policy matches numpy ground truth", async ({ page }) => { - await page.goto("/?mode=science"); + await page.goto("/app?mode=science"); const fixtures = await page.evaluate(async () => { const fres = await fetch("/walking-policy-fixtures.json"); const cases: Array<{ name: string; obs: number[]; action: number[] }> = await fres.json(); @@ -395,7 +395,7 @@ test.describe("webgpu-fly e2e", () => { // strict contract — drift here means the policy receives garbage at // runtime. Lock it down. test("walking policy obs layout sums to 741", async ({ page }) => { - await page.goto("/?mode=science"); + await page.goto("/app?mode=science"); const result = await page.evaluate(async () => { const modPath = "/src/walking-policy.ts"; const mod = await import(/* @vite-ignore */ modPath); @@ -422,7 +422,7 @@ test.describe("webgpu-fly e2e", () => { }); test("trained walking policy loads + forward-passes", async ({ page }) => { - await page.goto("/?mode=science"); + await page.goto("/app?mode=science"); const result = await page.evaluate(async () => { const modPath = "/src/walking-policy.ts"; const mod = await import(/* @vite-ignore */ modPath); diff --git a/tools/build_csr.py b/tools/build_csr.py index 39e64d2..7825b91 100644 --- a/tools/build_csr.py +++ b/tools/build_csr.py @@ -348,7 +348,7 @@ def main() -> int: famous_dns[label] = idxs # Resolve community_name buttons (BPN) from Dallmann 2026 Supp Table 1. - DALLMANN_TABLE_1 = Path("data/raw/dallmann_2026/supplementary_table_1.xlsx") + DALLMANN_TABLE_1 = RAW / "dallmann_2026" / "supplementary_table_1.xlsx" if community_name_lookups and DALLMANN_TABLE_1.exists(): import openpyxl wb = openpyxl.load_workbook(DALLMANN_TABLE_1, data_only=True) @@ -364,6 +364,9 @@ def main() -> int: idxs.append(int(idx)) if idxs: famous_dns[label] = idxs + elif community_name_lookups: + print(f" note: {DALLMANN_TABLE_1} absent — BPN preset skipped " + f"(download Dallmann 2026 Supp Table 1 to enable)") # Walking-circuit DN catalog from Dallmann et al. 2026 (supp fig 2c): # 21 cell types in two clusters downstream of RRN+BPN. Saved as a diff --git a/tools/build_vnc.py b/tools/build_vnc.py index b5c3895..8cc443b 100644 --- a/tools/build_vnc.py +++ b/tools/build_vnc.py @@ -20,7 +20,8 @@ num_neurons u32 = N num_edges u32 = E flags u32 bit 0: weights are pre-signed by presynaptic NT - reserved u32[12] pad to 64 B + (bytes 24..36 hold voxel_to_nm in brain.bin; zero in vnc.bin) + reserved u32[10] pad to 64 B (8 + 4*4 + 40 = 64) [ Neurons — N × 32 B ] pos_x f32 soma x in nm (from somaLocation) @@ -246,9 +247,11 @@ def main() -> None: # Pre-sign weights by presynaptic NT (same convention as brain.bin). print("signing weights by presynaptic neurotransmitter …") pre_nt_by_idx = df["predictedNt"].fillna("unknown").astype(str).str.lower().to_numpy() + pre_conf = pd.to_numeric(df["predictedNtProb"], errors="coerce").fillna(0.0).to_numpy() sign_arr = np.zeros(N, dtype=np.float32) for i, nt in enumerate(pre_nt_by_idx): - sign_arr[i] = NT_SIGN.get(nt, 0) + if pre_conf[i] >= NT_CONF_MIN: + sign_arr[i] = NT_SIGN.get(nt, 0) pre_idx = edges["pre_idx"].to_numpy(dtype=np.uint32) post_idx = edges["post_idx"].to_numpy(dtype=np.uint32) @@ -283,10 +286,7 @@ def main() -> None: cell_class[i] = classify(row) leg_seg[i] = leg_segment_packed(row) # predictedNtProb gives float confidence; default 0 - try: - nt_conf[i] = float(row.get("predictedNtProb") or 0) - except Exception: - nt_conf[i] = 0.0 + nt_conf[i] = float(pre_conf[i]) # Write binary OUT_BIN.parent.mkdir(parents=True, exist_ok=True) @@ -296,7 +296,7 @@ def main() -> None: f.write(b"WGFLYVNC") # magic 8B f.write(struct.pack("..r2.dev/vnc.bin # VITE_VNC_META_URL = https://..r2.dev/vnc.meta.json # VITE_FLYBODY_URL = https://..r2.dev/flybody +# VITE_FLYBODY_BUNDLE_URL = https://..r2.dev/flybody.bundle.bin +# VITE_WALKING_REF_URL = https://..r2.dev/walking-ref.bin set -euo pipefail # Force C locale so printf "%.1f" gets `.` for decimal regardless of @@ -72,6 +74,8 @@ put "public/vnc.bin" "vnc.bin" "application/octet-stre put "public/vnc.meta.json" "vnc.meta.json" "application/json" put "public/walking-policy.bin" "walking-policy.bin" "application/octet-stream" put "public/walking-obs-norm.bin" "walking-obs-norm.bin" "application/octet-stream" +put "public/flybody.bundle.bin" "flybody.bundle.bin" "application/octet-stream" +put "public/walking-ref.bin" "walking-ref.bin" "application/octet-stream" echo "uploading flybody MJCF + meshes (immutable) …" for f in public/flybody/*.xml; do @@ -90,4 +94,6 @@ echo " VITE_BRAIN_META_URL = https:///brain.meta.json" echo " VITE_VNC_URL = https:///vnc.bin" echo " VITE_VNC_META_URL = https:///vnc.meta.json" echo " VITE_FLYBODY_URL = https:///flybody" +echo " VITE_FLYBODY_BUNDLE_URL = https:///flybody.bundle.bin" +echo " VITE_WALKING_REF_URL = https:///walking-ref.bin" echo " VITE_ASSET_MANIFEST_URL = https:///assets.json" diff --git a/vercel.json b/vercel.json index dda003f..4443575 100644 --- a/vercel.json +++ b/vercel.json @@ -23,6 +23,12 @@ { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } ] }, + { + "source": "/assets/(.*)", + "headers": [ + { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } + ] + }, { "source": "/assets/(.*)\\.wasm", "headers": [