n4ze3m commited on
Commit
eaf91fb
·
verified ·
1 Parent(s): 1b8ef67

Store reproduction and web application source

Browse files
source/.gitignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ data/
2
+ runs/
3
+ web/model/
4
+ node_modules/
5
+ dist/
6
+ .venv/
7
+ __pycache__/
8
+ *.pyc
9
+ .env
10
+ *.log
source/AGENTS.md ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Fly XOX
2
+
3
+ - Source authoring only on this computer. Do not install dependencies, download datasets/checkpoints, run training, or create local build/cache output.
4
+ - Run installation, data preparation, tests, training, and export on the authorized Vast.ai instance.
5
+ - Pilot spending ceiling: USD 5, including compute, storage, and transfer. Check costs and stop compute when done. Preserve remote artifacts until storage/export is resolved.
6
+ - Preserve the retained MaleCNS neuron graph. Disclose retention filters, engineered input/output mappings, rate dynamics, and trained internal parameters. Do not call this a living brain or biological learning.
7
+ - Browser moves must come from the exported graph executing in WebGPU. No minimax, lookup table, or server policy fallback at inference.
8
+ - Record training, validation, and symmetry-grouped held-out test results separately. Report failures and compare against the untrained model.
9
+ - Keep credentials out of code, artifacts, version control, and logs.
source/LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fly XOX contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
source/PROGRESS.md ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Fly XOX — resume here
2
+
3
+ Last updated: 2026-09-14 07:45 UTC. Training and WGSL validation completed; private artifact upload and cleanup pending.
4
+
5
+ ## Latest result (supersedes older pending notes below)
6
+
7
+ - Training completed: 1,200 updates, ~42.8 seconds reported training-loop duration. Best validation checkpoint at step 1,100.
8
+ - Training optimal-move accuracy: 98.17% on 3,560 positions.
9
+ - Validation optimal-move accuracy: 90.04% on 492 positions.
10
+ - **Held-out test: 91.67% on 468 positions, versus 62.82% for the untrained same model and 56.91% uniform-random expected optimal-move rate.**
11
+ - Fixed input/output interfaces unchanged. Internal parameters changed: 153,090 gains, 165,455 biases, 165,455 leaks.
12
+ - 500,100 trainable internal parameters; ~1.01 GB peak GPU allocation. One seed only, no biological/superiority claim.
13
+ - Tool installation completed. `train/evaluate_games.py`, `train/export.py`, `train/verify_webgpu.py` have been launched sequentially remotely; inspect their process/output before relaunching.
14
+ - Source `train/verify_webgpu.py` and all web files uploaded. Local source remains dependency/data free.
15
+ - Complete-game evaluation (500 games each): vs random, fly first **474 W / 26 D / 0 L**, fly second **425 W / 70 D / 5 L**; vs minimax, fly first **0 W / 500 D / 0 L**, fly second **0 W / 480 D / 20 L**. Game trajectories can include training positions; this is separate from the held-out-position score.
16
+ - Export completed: **224,749,428 bytes** (~225 MB), retaining every edge, 16 MiB chunks.
17
+ - **Exact browser WGSL passed wgpu-native verification** on all 12 golden boards; all moves match PyTorch, max absolute logit difference **0.00000500679**.
18
+ - Native validation adapter is **software Vulkan / llvmpipe (CPU)**, not the RTX GPU. Median ~762 ms is a software-backend timing, NOT expected browser GPU latency. Browser/device performance remains unmeasured.
19
+ - Remote apt installed old Node 12, which rejected valid optional chaining during syntax checks. Obtain a current Node binary on the REMOTE host only, then rerun JS checks. Do not rewrite modern browser code merely to accommodate old Node.
20
+
21
+ ## User's requirements and authorization
22
+
23
+ - Build a tic-tac-toe (XOX) player using the actual MaleCNS fly connectome, inspired by the September 2026 fly-brain projects.
24
+ - Train internal model parameters, rather than only putting a normal game AI behind a fly graphic.
25
+ - Train on Vast.ai. **USD 5 total pilot ceiling**, including compute, storage, transfer.
26
+ - Author source locally in `/Users/n4ze3m/personal/fun/fly-xox`.
27
+ - **Do not install dependencies, download datasets/checkpoints, or run training locally.** All dependencies, model files, preparation, tests, training, and exports belong on the remote machine.
28
+ - Finished browser application should execute the model via WebGPU.
29
+ - Keep model and checkpoints in a **private Hugging Face repository**. User explicitly chose private. Do not make the repository or artifacts public.
30
+ - User asked for this progress file because the session may end soon.
31
+
32
+ ## Active infrastructure — CHECK FIRST ON RESUME
33
+
34
+ - Vast.ai instance: **50989186**, label `fly-xox-pilot-5usd`.
35
+ - GPU: RTX 3090, 24 GiB VRAM; about 43 GB available host RAM; 30 GB rented disk.
36
+ - Offer 30239913, quoted total **$0.1388888889/hour** including disk. Disk component **$0.0055555556/hour** continues when stopped.
37
+ - Container: `pytorch/pytorch:2.8.0-cuda12.8-cudnn9-runtime`.
38
+ - Direct SSH: `ssh -p 44245 root@77.48.24.240`.
39
+ - Proxy SSH: `ssh -p 29186 root@ssh8.vast.ai`.
40
+ - Existing local `~/.ssh/id_ed25519` is registered. Do not print private key material.
41
+ - Remote project: `/workspace/fly-xox`.
42
+ - Port 8080 is mapped to host port 44232; do NOT use public unauthenticated serving for private model assets.
43
+ - Vast CLI is already installed at `/Users/n4ze3m/.local/bin/vastai` and authenticated. Use `--raw`, and filter account output to avoid printing identity/credentials.
44
+ - Credit before creating this instance: **$9.1766269051**. No other instances were present. Budget floor for this pilot: $4.1766269051, with a safety margin above that.
45
+ - A local detached fallback command was started near creation: `sleep 10800; vastai stop instance 50989186 --raw`. It is only a fallback (local sleep/offline can delay it), not a durable cloud budget cap. Stop compute yourself when done. Do not assume stopping deletes disk charges.
46
+ - Do not destroy the instance until remote artifacts have been verified on Hugging Face; then destroy it to stop all charges.
47
+
48
+ ## Hugging Face
49
+
50
+ - Existing local `hf` CLI and authenticated account `n4ze3m`; `hf auth whoami` successfully refreshed existing OAuth credentials.
51
+ - Existing token file: `~/.cache/huggingface/token`; **never print it or store it in project files**.
52
+ - Created private model repository: **https://huggingface.co/n4ze3m/fly-xox-malecns**.
53
+ - At this checkpoint it has no uploaded model artifacts yet.
54
+ - `remote/publish_hf.py` uploads directly from Vast, requires a token on stdin only, and refuses if the model repository isn't private.
55
+ - If uploading via SSH, pipe the existing refreshed token to the remote process stdin. Never include it in command arguments, logs, Git, or persistent remote files. No dataset/checkpoint bytes should pass through local files.
56
+
57
+ ## Completed so far
58
+
59
+ - Read Vast.ai skill and relevant Sites guidance. User's no-local-install/download rule overrides normal local scaffolding/install steps. Native HTML/JS/WGSL source has no browser package dependencies.
60
+ - Created source under `fly-xox/`, with task constraints also in `AGENTS.md`.
61
+ - Remote dependency installation succeeded: PyTorch 2.8.0+cu128, numpy 2.2.6, scipy 1.15.3, pandas 2.2.3, pyarrow 20.0.0, wgpu 0.24.0.
62
+ - Downloaded three official MaleCNS v1.0 Feather files **on Vast only**. Checked upstream MD5 when supplied and recorded SHA256 source lock.
63
+ - Prepared graph: **166,700 neurons, 25,582,938 directed neuron-pair edges, 124,177,617 retained synaptic contacts**, 17,937 annotated sensory neurons.
64
+ - Retention: assigned nonempty superclass, excluding explicit Glia; all released connections between retained IDs; no extra edge threshold.
65
+ - Remote `python train/test_core.py`: **3 tests passed** (exact game solver, 4,520 nonterminal reachable positions with symmetry-grouped split/legality, sparse forward and state gradients vs dense reference).
66
+ - Initial pilot hit a float64 readout vs float32 state mismatch; fixed by casting the normalized readout to float32 in `train/model.py`, uploaded fix, and restarted.
67
+ - Remote installation of Vulkan loader/Mesa/nodejs and huggingface_hub was launched separately for WebGPU validation and upload. Check its log for completion.
68
+
69
+ ## Training design
70
+
71
+ - Full retained sparse graph is present for every forward pass. No normal-policy/minimax fallback in the browser.
72
+ - Engineered **symbolic board input**: 27 one-hot channels (empty/self/opponent for each square), fixed random projection into annotated sensory neurons. **This is not pixel vision.**
73
+ - Signed, incoming-normalized contact weights. GABA/glutamate are assigned negative signs, others/unknown positive; this is a disclosed crude assumption, not receptor physiology.
74
+ - Four recurrent softsign rate updates, reset state each move. No biological time claim.
75
+ - Learn three internal parameters per neuron: incoming gain, offset, leak (**500,100 trainable parameters**). Graph topology and external input/output adapters stay fixed.
76
+ - Gain is 0.1 + 5.9*sigmoid(raw); leak is 0.05 + 0.9*sigmoid(raw).
77
+ - Readout: fixed signed random projection of 2,048 non-sensory neurons selected by response variance on training boards only; nine move scores.
78
+ - Exact minimax teacher is training/evaluation only. Loss maximizes total probability of all tied optimal legal moves.
79
+ - Train/validation/test split groups player-relative rotations/reflections by SHA256, roughly 80/10/10.
80
+ - Training invocation currently launched: `python -u train/pilot.py --updates 1200 --seconds 2400`, default batch 32 and LR 0.025.
81
+ - Select best checkpoint on validation only; held-out test evaluated after selection. Compare against untrained same model and uniform random optimal-move rate.
82
+
83
+ ## Remote logs / outputs
84
+
85
+ - Setup: `/workspace/fly-xox-bootstrap.log` (completed data import).
86
+ - Training: `/workspace/fly-xox-training.log`.
87
+ - Vulkan/node/HF tool setup: `/workspace/fly-xox-tools.log`.
88
+ - Graph: `/workspace/fly-xox/data/graph/`.
89
+ - Boards: `/workspace/fly-xox/data/boards.npz`.
90
+ - Checkpoints/results: `/workspace/fly-xox/runs/pilot/` (`untrained.pt`, `best.pt`, `last.pt`, history/results when generated).
91
+ - Exports will be `/workspace/fly-xox/web/model/`. **Do not rsync this back locally.**
92
+
93
+ ## Source files and next actions
94
+
95
+ 1. Check instance status and current training/tool logs. Ensure no duplicate training process is started.
96
+ 2. `train/pilot.py`: bounded training, validation selection, final held-out results. Do not assert successful learning until results exist.
97
+ 3. After source uploads finish, run remotely:
98
+ - `python train/evaluate_games.py` — 500 random/minimax games per side using cached model predictions inside evaluator only.
99
+ - `python train/export.py` — bakes gains into effective graph weights and encoder, writes 16 MiB binary chunks, manifest, 12 golden positions.
100
+ - `python train/verify_webgpu.py` — runs the app's exact WGSL through wgpu-native, compares PyTorch logits/moves, records actual adapter and timing. Not browser end-to-end testing.
101
+ 4. `web/brain.wgsl`, `web/readout.wgsl`: full sparse graph + fixed output projection. Seven storage bindings in brain shader; largest buffer ~102 MB, fits typical 128 MiB binding limit (verify actual device).
102
+ 5. `web/engine.js`: streamed chunks with SHA256 checks, WebGPU device/buffer validation, identical update loop, legal-move masking. Loads `./model/` by default. Model assets must be served with authenticated access or bundled into a private host; don't leak a HF token into client code.
103
+ 6. `web/index.html`, `style.css`, `app.js`: authored game UI, actual activity bars, legal turns, win/draw handling, model loading/error states, reset and fly-first controls. **Not yet runtime-validated.**
104
+ 7. `remote/publish_hf.py`: preserve exports, checkpoints, graph, source directly on private HF after checks pass. Verify remote contents/hashes before deleting Vast disk.
105
+ 8. Run JavaScript syntax checks remotely (node is being installed). Add focused frontend/state tests as needed. Do not install/test locally.
106
+ 9. Finish README with actual results, reproduction, limitations, and spending. Update this file with final status and instance cleanup.
107
+
108
+ ## Open issues / cautions
109
+
110
+ - Successful learning has not yet been established. Pilot accuracy and WebGPU parity are pending.
111
+ - Native WebGPU initially found no adapters because libvulkan was absent. NVIDIA ICD and 610.57.04 graphics libraries are present; remote apt installation of libvulkan1/mesa-vulkan-drivers should resolve. Prefer NVIDIA adapter; report if only software Vulkan is available.
112
+ - HF is private by explicit choice. Do not silently expose model chunks on public HTTP, public Spaces, or public Sites.
113
+ - A private static Hugging Face Space is a possible way to host the game without an embedded token or paid inference server. Official docs say Static Spaces are free and support private access. This has NOT been created or promised yet; user selected HF as artifact storage, not explicitly a Space.
114
+ - No local node_modules, graph/model/checkpoint downloads, training runs, build outputs, or Python environments were created. Local SSH known-hosts entries and existing HF OAuth token refresh are normal connection metadata.
115
+ - Browser play itself eventually fetches model bytes into the visitor's memory; user was informed of this distinction. Do not open the game on their computer automatically during this no-download development phase.
source/README.md ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Fly XOX
2
+
3
+ A tic-tac-toe experiment using the retained MaleCNS v1.0 fruit-fly wiring, with internal parameters trained on Vast.ai and full-graph inference written in WebGPU.
4
+
5
+ **This folder contains source only. Do not install dependencies or download model data here.**
6
+
7
+ See [PROGRESS.md](PROGRESS.md) for current infrastructure, the USD 5 budget, results, and resume instructions.
8
+
9
+ ## Pilot result
10
+
11
+ One training seed; 1,200 optimizer updates on an RTX 3090. Best checkpoint selected using validation only.
12
+
13
+ | Evaluation | Result |
14
+ | --- | --- |
15
+ | Held-out optimal moves, before training | 62.82% |
16
+ | Held-out optimal moves, after training | 91.67% |
17
+ | Uniform random expected optimal-move rate | 56.91% |
18
+ | Held-out positions | 468 |
19
+ | Validation positions | 492 |
20
+ | Training positions | 3,560 |
21
+ | Trainable internal parameters | 500,100 |
22
+ | Peak allocated CUDA memory | 1.01 GB |
23
+
24
+ Rotations and reflections of player-relative board states stay in the same split. The exact solver supplies labels only during training/evaluation. Browser inference has no solver or policy lookup fallback.
25
+
26
+ | Opponent / fly order | Wins | Draws | Losses |
27
+ | --- | ---: | ---: | ---: |
28
+ | Random / first | 474 | 26 | 0 |
29
+ | Random / second | 425 | 70 | 5 |
30
+ | Minimax / first | 0 | 500 | 0 |
31
+ | Minimax / second | 0 | 480 | 20 |
32
+
33
+ These complete games can traverse training boards; they are not a second unseen-data test. Perfect play is not established.
34
+
35
+ ## What is biological, engineered, and trained
36
+
37
+ - **Measured structure:** 166,700 retained neurons, 25,582,938 directed neuron-pair connections, 124,177,617 retained synaptic contacts. All released edges between retained neurons remain.
38
+ - **Engineered input:** 27 symbolic board channels, fixed random mapping into 17,937 sensory neurons. No optical vision claim.
39
+ - **Engineered dynamics:** signed, incoming-normalized contact weights; four recurrent softsign rate updates; zero initial state each move. Approximate transmitter signs, without receptor-specific dynamics or biological timing.
40
+ - **Trained inside the graph:** per-neuron incoming gains, offsets, and leak rates. Effective connection strengths change while topology stays fixed.
41
+ - **Fixed output:** random projection from 2,048 downstream neurons selected using training response variance. Neither input nor output adapter is trained.
42
+
43
+ This is not a recreated living fly or a validated model of biological learning. The pilot does not show that fly wiring outperforms a conventional network or shuffled wiring.
44
+
45
+ ## Remote reproduction
46
+
47
+ Use a CUDA-enabled remote container with Python 3.11 and PyTorch 2.8.0. From `/workspace/fly-xox`:
48
+
49
+ ```sh
50
+ bash remote/bootstrap.sh
51
+ python train/test_core.py
52
+ python -u train/pilot.py --updates 1200 --seconds 2400
53
+ python train/evaluate_games.py
54
+ python train/export.py
55
+ XDG_RUNTIME_DIR=/tmp python train/verify_webgpu.py
56
+ ```
57
+
58
+ Vulkan loader/driver support is needed for the WGSL check. The pilot used wgpu 0.24.0 with software Vulkan because the NVIDIA Vulkan adapter was not exposed. The exact WGSL matched PyTorch on 12 positions (maximum logit error ~5e-6, identical selected moves). This is **not browser end-to-end verification or a browser GPU speed measurement**.
59
+
60
+ ## Web application
61
+
62
+ `web/` is a dependency-free HTML/CSS/JavaScript app with WGSL compute shaders. Serve the directory over authenticated HTTPS (or localhost) with the remote export in `web/model/`.
63
+
64
+ - Browser requests a compatible WebGPU adapter, checks its buffer limits, downloads SHA256-checked chunks, and uploads them to GPU memory.
65
+ - Each move executes the complete retained graph. Legal-move masking and game rules are ordinary application code.
66
+ - Activity bars are actual sampled model rate states; captions are scripted jokes.
67
+ - The float32 export is ~225 MB. Browser/device memory requirements and GPU speed must be measured; mobile compatibility is not promised.
68
+ - Model load happens only after pressing **Wake the fly**. Do not automatically open/load the model on the development computer under the user's no-download constraint.
69
+ - Keep the pilot private. Never embed a Hugging Face access token into browser code.
70
+
71
+ ## Storage and attribution
72
+
73
+ Private artifact destination: https://huggingface.co/n4ze3m/fly-xox-malecns
74
+
75
+ Remote uploader reads an existing authorized token from stdin and uploads directly from Vast. It verifies private visibility and required paths. Do not delete the rented disk before verifying the saved artifacts.
76
+
77
+ MaleCNS data: HHMI Janelia/FlyEM, University of Cambridge, MRC Laboratory of Molecular Biology, Google Research, and collaborators. [Dataset and licensing](https://male-cns.janelia.org/), CC BY 4.0. Source URLs, retention policies, and SHA256 hashes are recorded in the exported manifest. This implementation was informed by the published descriptions of [Fly OCR](https://github.com/jerryjliu/fly_ocr), [DOOMFLY](https://github.com/nftechie/doomfly), and [Flyhard](https://github.com/MarkUnthank/flyhard); it does not reproduce their dynamics or claim their validation.
78
+
79
+ Original project source is MIT licensed; dataset rights remain separate.
source/remote/bootstrap.sh ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ cd /workspace/fly-xox
4
+ test -n "${CONTAINER_ID:-}" || test -f /.dockerenv || { echo 'Run on the remote training container only'; exit 1; }
5
+ python -m pip install --no-cache-dir -r requirements-remote.txt
6
+ python -c 'import torch; assert torch.cuda.is_available(); print(torch.cuda.get_device_name(0))'
7
+ python -u train/prepare.py
source/remote/publish_hf.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Upload from Vast directly to a PRIVATE Hugging Face repo. Token via stdin only."""
2
+ import argparse
3
+ import hashlib
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+ from huggingface_hub import HfApi,CommitOperationAdd
8
+
9
+ ROOT=Path(__file__).resolve().parents[1]
10
+
11
+ def main():
12
+ p=argparse.ArgumentParser();p.add_argument('--repo',default='n4ze3m/fly-xox-malecns');args=p.parse_args()
13
+ token=sys.stdin.readline().strip()
14
+ if not token:raise SystemExit('Pass a token on stdin; never in source or command arguments')
15
+ api=HfApi(token=token)
16
+ info=api.repo_info(args.repo,repo_type='model')
17
+ if not info.private:raise SystemExit('Refusing upload: repository must be private')
18
+ api.upload_folder(repo_id=args.repo,folder_path=ROOT/'web/model',path_in_repo='webgpu',commit_message='Add measured-graph WebGPU export and validation')
19
+ api.upload_folder(repo_id=args.repo,folder_path=ROOT/'runs/pilot',path_in_repo='training',ignore_patterns=['*.partial'],commit_message='Preserve pilot checkpoints and results')
20
+ api.upload_folder(repo_id=args.repo,folder_path=ROOT/'data/graph',path_in_repo='graph',commit_message='Preserve retained graph and fixed board adapter')
21
+ api.upload_folder(repo_id=args.repo,folder_path=ROOT,path_in_repo='source',ignore_patterns=['data/**','runs/**','web/model/**','__pycache__/**','*.pyc','.git/**','*.log'],commit_message='Store reproduction and web application source')
22
+ api.upload_file(repo_id=args.repo,path_or_fileobj=ROOT/'web/model/README.md',path_in_repo='README.md')
23
+ files=api.list_repo_files(args.repo)
24
+ required=['webgpu/manifest.json','webgpu/webgpu-verification.json','training/best.pt','training/results.json','graph/connectome.npz','source/web/engine.js']
25
+ if not all(f in files for f in required):raise RuntimeError('Remote file verification failed')
26
+ metadata={f.rfilename:f for f in api.repo_info(args.repo,files_metadata=True).siblings}
27
+ verified=0
28
+ for prefix,folder in [('webgpu',ROOT/'web/model'),('training',ROOT/'runs/pilot'),('graph',ROOT/'data/graph')]:
29
+ for path in folder.rglob('*'):
30
+ if not path.is_file() or path.suffix=='.partial':continue
31
+ name=prefix+'/'+path.relative_to(folder).as_posix(); remote=metadata[name]
32
+ if remote.size!=path.stat().st_size:raise RuntimeError('Uploaded size mismatch: '+name)
33
+ if remote.lfs:
34
+ digest=hashlib.sha256()
35
+ with path.open('rb') as f:
36
+ while block:=f.read(8*1024*1024):digest.update(block)
37
+ if digest.hexdigest()!=remote.lfs.sha256:raise RuntimeError('Uploaded SHA256 mismatch: '+name)
38
+ else:
39
+ blob=path.read_bytes(); expected=hashlib.sha1(b'blob '+str(len(blob)).encode()+b'\0'+blob).hexdigest()
40
+ if expected!=remote.blob_id:raise RuntimeError('Uploaded Git blob mismatch: '+name)
41
+ verified+=1
42
+ print(json.dumps({'repo':args.repo,'private':api.repo_info(args.repo).private,'files':len(files),'required_files_verified':True,'artifact_hashes_verified':verified}),flush=True)
43
+
44
+ if __name__=='__main__':main()
source/remote/test_web.mjs ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Run with Node >=22 on Vast only. Tests actual app handlers with a controlled
2
+ // inference boundary; WGSL math is independently tested by verify_webgpu.py.
3
+ import assert from 'node:assert/strict';
4
+ import fs from 'node:fs/promises';
5
+ import vm from 'node:vm';
6
+ import {fileURLToPath} from 'node:url';
7
+
8
+ class Element {
9
+ constructor(){this.children=[];this.handlers={};this.attributes={};this.disabled=false;this.classes=new Set();this.width=640;this.height=280;this.textContent='';this.innerHTML='';this.classList={add:(x)=>this.classes.add(x),remove:(x)=>this.classes.delete(x),toggle:(x,on)=>on?this.classes.add(x):this.classes.delete(x)};}
10
+ append(x){this.children.push(x);}setAttribute(k,v){this.attributes[k]=v;}addEventListener(k,fn){this.handlers[k]=fn;}
11
+ getContext(){return {clearRect(){},beginPath(){},moveTo(){},lineTo(){},stroke(){},fillRect(){}};}
12
+ click(){return this.handlers.click?.();}
13
+ }
14
+ const elements=new Map();const get=id=>{if(!elements.has(id))elements.set(id,new Element());return elements.get(id);};
15
+ let moves=[4,6];let calls=[];let fail=false;
16
+ class FakeEngine {
17
+ async load(_,progress){progress(1);return {neurons:166700,edges:25582938,total_bytes:224749428,results:{test:{optimal_move_rate:.9167},untrained_test:{optimal_move_rate:.6282}}};}
18
+ async predict(board,turn){calls.push({board:board.slice(),turn});if(fail)throw new Error('GPU disconnected');return {move:moves.shift(),logits:Array(9).fill(0),activity:new Float32Array(128),milliseconds:10};}
19
+ destroy(){}
20
+ }
21
+ const context=vm.createContext({document:{getElementById:get,createElement:()=>new Element()},console,Float32Array,Array,Math,Number,Error});
22
+ const dependency=new vm.SyntheticModule(['FlyEngine'],function(){this.setExport('FlyEngine',FakeEngine);},{context});
23
+ const source=await fs.readFile(fileURLToPath(new URL('../web/app.js',import.meta.url)),'utf8');
24
+ const app=new vm.SourceTextModule(source,{context});await app.link(()=>dependency);await app.evaluate();
25
+ const cells=get('board').children;assert.equal(cells.length,9);assert(cells.every(c=>c.disabled));
26
+ await get('load').click();assert(!cells[0].disabled);assert.equal(get('status').textContent,'Your move. You are X.');
27
+ await cells[0].click();assert.equal(cells[0].textContent,'X');assert.equal(cells[4].textContent,'O');assert.equal(calls.length,1);assert.equal(calls[0].turn,-1);
28
+ await cells[0].click();assert.equal(calls.length,1,'occupied square must not invoke the model');
29
+ await cells[1].click();await cells[2].click();assert(get('status').textContent.startsWith('You win'));assert(cells.every(c=>c.disabled));assert.equal(calls.length,2,'terminal game must not invoke the model');
30
+ get('reset').click();assert(cells.every(c=>c.textContent===''));assert(cells.every(c=>!c.disabled));
31
+ fail=true;await cells[0].click();assert.equal(get('status').textContent,'GPU disconnected');assert(cells.every(c=>c.disabled));assert.equal(get('load').hidden,false);
32
+ console.log('PASS: load, legal alternating turns, occupied-cell rejection, terminal win, reset, GPU failure recovery');
source/requirements-remote.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # Install on Vast.ai only. PyTorch is supplied by the pinned container image.
2
+ numpy==2.2.6
3
+ scipy==1.15.3
4
+ pandas==2.2.3
5
+ pyarrow==20.0.0
6
+ wgpu==0.24.0
source/train/evaluate_games.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Game evaluation using cached model predictions, only within this evaluator."""
2
+ import argparse
3
+ import json
4
+ from pathlib import Path
5
+ import numpy as np
6
+ import torch
7
+ from model import FlyBrain
8
+ from pilot import evaluate
9
+ from game import targets,winner
10
+
11
+ ROOT=Path(__file__).resolve().parents[1]
12
+
13
+ def main():
14
+ p=argparse.ArgumentParser();p.add_argument('--name',default='pilot');args=p.parse_args()
15
+ torch.set_num_threads(6); run=ROOT/'runs'/args.name
16
+ cp=torch.load(run/'best.pt',weights_only=False); net=FlyBrain(ROOT/'data/graph',steps=cp['steps']);net.load_state_dict(cp['model'])
17
+ data=dict(np.load(ROOT/'data/boards.npz')); _,scores=evaluate(net,data,np.arange(len(data['boards'])))
18
+ index={tuple(b):i for i,b in enumerate(data['boards'])}
19
+ rng=np.random.default_rng(9014); results={}
20
+ for opponent in ['random','minimax']:
21
+ for side in [1,-1]:
22
+ counts={'wins':0,'draws':0,'losses':0}
23
+ for _ in range(500):
24
+ board=(0,)*9;turn=1
25
+ while not winner(board) and not all(board):
26
+ legal=np.flatnonzero(np.array(board)==0)
27
+ if turn==side:
28
+ logits=scores[index[board]];move=int(legal[np.argmax(logits[legal])])
29
+ elif opponent=='random': move=int(rng.choice(legal))
30
+ else: move=int(rng.choice(np.flatnonzero(targets(board,turn)[0])))
31
+ board=board[:move]+(turn,)+board[move+1:];turn=-turn
32
+ w=winner(board);counts['draws' if w==0 else 'wins' if w==side else 'losses']+=1
33
+ results[f'{opponent}_fly_{"first" if side==1 else "second"}']=counts
34
+ result=json.loads((run/'results.json').read_text());result['games']=results
35
+ result['games_note']='500 games per opponent and side, deterministic learned policy, seeded random opponent/tie choices. Complete games can include training boards; held-out position accuracy is reported separately.'
36
+ (run/'results.json').write_text(json.dumps(result,indent=2));print(json.dumps(results),flush=True)
37
+
38
+ if __name__=='__main__':main()
source/train/export.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bake trained gains into measured edges, export chunked WebGPU buffers remotely."""
2
+ import argparse
3
+ import hashlib
4
+ import json
5
+ from pathlib import Path
6
+ import numpy as np
7
+ import scipy.sparse as sp
8
+ import torch
9
+ from model import FlyBrain
10
+
11
+ ROOT=Path(__file__).resolve().parents[1]
12
+
13
+ def write_array(root,name,array):
14
+ data=np.ascontiguousarray(array).tobytes(); parts=[]; chunk=16*1024*1024
15
+ for start in range(0,len(data),chunk):
16
+ block=data[start:start+chunk]; filename=f'{name}.{start//chunk:03}.bin'
17
+ (root/filename).write_bytes(block)
18
+ parts.append({'file':filename,'bytes':len(block),'sha256':hashlib.sha256(block).hexdigest()})
19
+ return {'bytes':len(data),'dtype':str(array.dtype),'shape':list(array.shape),'parts':parts}
20
+
21
+ def main():
22
+ p=argparse.ArgumentParser(); p.add_argument('--name',default='pilot'); args=p.parse_args()
23
+ run=ROOT/'runs'/args.name; dest=ROOT/'web/model'; dest.mkdir(parents=True,exist_ok=True)
24
+ cp=torch.load(run/'best.pt',map_location='cpu',weights_only=False)
25
+ net=FlyBrain(ROOT/'data/graph',steps=cp['steps']); net.load_state_dict(cp['model']); net.eval()
26
+ state=cp['model']; gain=(.1+5.9*torch.sigmoid(state['gain'])).numpy()
27
+ leak=(.05+.9*torch.sigmoid(state['leak'])).numpy(); bias=state['bias'].numpy()
28
+ matrix=sp.load_npz(ROOT/'data/graph/connectome.npz')
29
+ row_ids=np.repeat(np.arange(net.n,dtype=np.int32),np.diff(matrix.indptr))
30
+ # Internal learned incoming gains become effective weights, preserving every edge.
31
+ matrix.data*=gain[row_ids]
32
+ arrays={
33
+ 'rowptr':matrix.indptr.astype('<u4'),'column':matrix.indices.astype('<u4'),'weight':matrix.data.astype('<f4'),
34
+ 'encoder':(state['encoder'].numpy()*gain[:,None]).astype('<f4'),
35
+ 'parameters':np.stack((bias,leak),axis=1).astype('<f4'),
36
+ 'neurons':state['readout_ids'].numpy().astype('<u4'),'projection':state['readout'].numpy().astype('<f4'),
37
+ }
38
+ files={name:write_array(dest,name,a) for name,a in arrays.items()}
39
+ boards=dict(np.load(ROOT/'data/boards.npz')); ids=np.flatnonzero(boards['split']==2)[:12]
40
+ with torch.no_grad():
41
+ logits=net(torch.tensor(boards['x'][ids],device='cuda')).cpu().numpy()
42
+ golden={'boards':boards['boards'][ids].tolist(),'turns':boards['turns'][ids].tolist(),'inputs':boards['x'][ids].tolist(),'logits':logits.tolist()}
43
+ (dest/'golden.json').write_text(json.dumps(golden))
44
+ source=json.loads((ROOT/'data/graph/manifest.json').read_text())
45
+ results=json.loads((run/'results.json').read_text())
46
+ manifest={'format':'fly-xox-webgpu-v1','neurons':net.n,'edges':int(matrix.nnz),'steps':net.steps,
47
+ 'readout_count':len(state['readout_ids']),'activity_samples':128,'arrays':files,
48
+ 'total_bytes':sum(f['bytes'] for f in files.values()),'source':source,'results':results,
49
+ 'dynamics':'Four recurrent softsign rate updates by default; reset each move; no biological timing claim.',
50
+ 'learned':'Internal per-neuron incoming gain, bias, and leak. Fixed input and output adapters.',
51
+ 'webgpu_verified':False}
52
+ (dest/'manifest.json').write_text(json.dumps(manifest,indent=2))
53
+ (dest/'README.md').write_text('# Fly XOX — experimental MaleCNS game model\n\n'
54
+ 'Full retained MaleCNS graph with trained internal rate-model parameters for tic-tac-toe. '
55
+ 'Symbolic board input; engineered fixed input/output mappings. Not a living fly, validated biological dynamics, or evidence of connectome superiority.\n\n'
56
+ 'Source data: HHMI Janelia/FlyEM, Cambridge, MRC LMB, Google Research and collaborators. '
57
+ 'https://male-cns.janelia.org/ — CC BY 4.0. Original code: MIT.\n\n'
58
+ 'See manifest.json for exact retention, source hashes, assumptions, held-out scores, and WebGPU verification.\n')
59
+ print(json.dumps({'export_bytes':manifest['total_bytes'],'neurons':net.n,'edges':int(matrix.nnz),'destination':str(dest)}),flush=True)
60
+
61
+ if __name__=='__main__': main()
source/train/game.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Exact teacher and symmetry-grouped dataset. Never shipped as the game policy."""
2
+ from functools import lru_cache
3
+ import hashlib
4
+ import numpy as np
5
+
6
+ LINES = ((0,1,2),(3,4,5),(6,7,8),(0,3,6),(1,4,7),(2,5,8),(0,4,8),(2,4,6))
7
+
8
+ def winner(b):
9
+ for a,c,d in LINES:
10
+ if b[a] and b[a] == b[c] == b[d]: return b[a]
11
+ return 0
12
+
13
+ @lru_cache(None)
14
+ def value(b, turn):
15
+ w = winner(b)
16
+ if w: return w * turn
17
+ if all(b): return 0
18
+ return max(-value(b[:i]+(turn,)+b[i+1:], -turn) for i in range(9) if not b[i])
19
+
20
+ def targets(b, turn):
21
+ scores = np.full(9, -2, dtype=np.float32)
22
+ for i in range(9):
23
+ if not b[i]: scores[i] = -value(b[:i]+(turn,)+b[i+1:], -turn)
24
+ return (scores == scores.max()).astype(np.float32), scores
25
+
26
+ def encode(b, turn):
27
+ # Each cell: empty, current player's mark, opponent's mark.
28
+ b = np.asarray(b) * turn
29
+ return np.stack((b == 0, b == 1, b == -1), axis=1).astype(np.float32).reshape(27)
30
+
31
+ def canonical(b, turn):
32
+ a = (np.asarray(b).reshape(3,3) * turn).astype(int)
33
+ return min(tuple(x.reshape(-1)) for k in range(4) for x in (np.rot90(a,k), np.fliplr(np.rot90(a,k))))
34
+
35
+ def dataset():
36
+ seen = set(); records = []
37
+ def visit(b, turn):
38
+ if b in seen: return
39
+ seen.add(b)
40
+ if winner(b) or all(b): return
41
+ key = canonical(b, turn)
42
+ bucket = int(hashlib.sha256(bytes(v+1 for v in key)).hexdigest()[:8],16) % 10
43
+ split = 0 if bucket < 8 else 1 if bucket == 8 else 2
44
+ y, q = targets(b, turn)
45
+ records.append((b,turn,encode(b,turn),y,q,split,key))
46
+ for i in range(9):
47
+ if not b[i]: visit(b[:i]+(turn,)+b[i+1:],-turn)
48
+ visit((0,)*9,1)
49
+ return {
50
+ 'boards':np.asarray([r[0] for r in records],np.int8),
51
+ 'turns':np.asarray([r[1] for r in records],np.int8),
52
+ 'x':np.asarray([r[2] for r in records],np.float32),
53
+ 'optimal':np.asarray([r[3] for r in records],np.float32),
54
+ 'q':np.asarray([r[4] for r in records],np.float32),
55
+ 'split':np.asarray([r[5] for r in records],np.int8),
56
+ 'canonical':np.asarray([r[6] for r in records],np.int8),
57
+ }
58
+
59
+ if __name__ == '__main__':
60
+ d=dataset(); print({s:int((d['split']==i).sum()) for i,s in enumerate(['train','validation','test'])})
source/train/model.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Trainable rate dynamics over the retained graph; no trainable external head."""
2
+ from pathlib import Path
3
+ import numpy as np
4
+ import scipy.sparse as sp
5
+ import torch
6
+ from torch import nn
7
+
8
+ class FixedSparse(torch.autograd.Function):
9
+ @staticmethod
10
+ def forward(ctx, x, matrix, transpose):
11
+ ctx.transpose=transpose
12
+ return torch.sparse.mm(matrix,x)
13
+ @staticmethod
14
+ def backward(ctx, grad):
15
+ # Only dense state gradients; never materialize an N x N derivative.
16
+ return torch.sparse.mm(ctx.transpose,grad.contiguous()),None,None
17
+
18
+ def csr(m,device):
19
+ return torch.sparse_csr_tensor(
20
+ torch.as_tensor(m.indptr.astype(np.int32),device=device),
21
+ torch.as_tensor(m.indices.astype(np.int32),device=device),
22
+ torch.as_tensor(m.data,device=device),size=m.shape,device=device)
23
+
24
+ class FlyBrain(nn.Module):
25
+ def __init__(self,graph,device='cuda',steps=4,readout_ids=None):
26
+ super().__init__(); graph=Path(graph)
27
+ matrix=sp.load_npz(graph/'connectome.npz').astype(np.float32)
28
+ self.n=matrix.shape[0]; self.steps=steps
29
+ self.register_buffer('graph',csr(matrix,device),persistent=False)
30
+ self.register_buffer('transpose',csr(matrix.T.tocsr(),device),persistent=False)
31
+ self.register_buffer('encoder',torch.tensor(np.load(graph/'encoder.npy'),device=device))
32
+ # Incoming connection gains, cell offsets, and leaks are internal parameters.
33
+ self.gain=nn.Parameter(torch.full((self.n,),-0.2,device=device))
34
+ self.bias=nn.Parameter(torch.zeros(self.n,device=device))
35
+ self.leak=nn.Parameter(torch.zeros(self.n,device=device))
36
+ if readout_ids is None:
37
+ readout_ids=np.flatnonzero(~np.load(graph/'sensory.npy'))[:2048]
38
+ self.register_buffer('readout_ids',torch.tensor(readout_ids,dtype=torch.long,device=device))
39
+ rng=np.random.default_rng(903)
40
+ readout=(rng.choice([-1.,1.],size=(9,len(readout_ids)))/np.sqrt(len(readout_ids))).astype(np.float32)
41
+ self.register_buffer('readout',torch.tensor(readout,device=device))
42
+
43
+ def states(self,x):
44
+ stimulus=self.encoder@x.T
45
+ state=torch.zeros_like(stimulus)
46
+ gain=(0.1+5.9*torch.sigmoid(self.gain))[:,None]
47
+ leak=(0.05+0.9*torch.sigmoid(self.leak))[:,None]
48
+ bias=self.bias[:,None]
49
+ for _ in range(self.steps):
50
+ message=FixedSparse.apply(state,self.graph,self.transpose)
51
+ drive=gain*(message+stimulus)+bias
52
+ candidate=drive/(1+drive.abs()) # softsign, identical in WGSL
53
+ state=(1-leak)*state+leak*candidate
54
+ return state
55
+
56
+ def forward(self,x):
57
+ return (8.0*(self.readout@self.states(x)[self.readout_ids])).T
58
+
59
+ @torch.no_grad()
60
+ def select_readout(self,x,sensory,count=2048):
61
+ # Selection uses training boards only. Output weights remain fixed.
62
+ s=self.states(x)
63
+ variance=s.var(dim=1); variance[torch.tensor(sensory,device=s.device)]=-1
64
+ chosen=variance.topk(min(count,int((~sensory).sum()))).indices
65
+ self.readout_ids=chosen
66
+ return chosen.cpu().numpy()
source/train/pilot.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bounded train/validate/test cycle. Execute only on the rented GPU."""
2
+ import argparse
3
+ import json
4
+ import time
5
+ from pathlib import Path
6
+ import numpy as np
7
+ import torch
8
+ from model import FlyBrain
9
+
10
+ ROOT=Path(__file__).resolve().parents[1]
11
+
12
+ @torch.no_grad()
13
+ def evaluate(net,data,indices,batch=32):
14
+ logits=[]
15
+ for part in np.array_split(indices,max(1,int(np.ceil(len(indices)/batch)))):
16
+ logits.append(net(torch.tensor(data['x'][part],device='cuda')).cpu().numpy())
17
+ logits=np.concatenate(logits)
18
+ legal=data['boards'][indices]==0
19
+ moves=np.where(legal,logits,-1e9).argmax(1)
20
+ optimal=data['optimal'][indices,np.arange(9)[moves]]
21
+ return {'positions':len(indices),'optimal_move_rate':float(optimal.mean()),'illegal_moves':0},logits
22
+
23
+ def checkpoint(path,net,optimizer,step,score):
24
+ tmp=path.with_suffix('.partial')
25
+ torch.save({'model':net.state_dict(),'optimizer':optimizer.state_dict(),'step':step,'score':score,
26
+ 'steps':net.steps,'torch_rng':torch.get_rng_state(),'cuda_rng':torch.cuda.get_rng_state()},tmp)
27
+ tmp.replace(path)
28
+
29
+ def main():
30
+ p=argparse.ArgumentParser(); p.add_argument('--steps',type=int,default=4)
31
+ p.add_argument('--updates',type=int,default=1200); p.add_argument('--seconds',type=int,default=2400)
32
+ p.add_argument('--batch',type=int,default=32); p.add_argument('--lr',type=float,default=.025)
33
+ p.add_argument('--resume',action='store_true'); p.add_argument('--name',default='pilot')
34
+ args=p.parse_args(); torch.set_num_threads(6); torch.manual_seed(140926)
35
+ torch.backends.cuda.matmul.allow_tf32=False
36
+ if not torch.cuda.is_available(): raise SystemExit('CUDA training is required; do not train locally')
37
+ run=ROOT/'runs'/args.name; run.mkdir(parents=True,exist_ok=True)
38
+ d=dict(np.load(ROOT/'data/boards.npz')); train=np.flatnonzero(d['split']==0); val=np.flatnonzero(d['split']==1); test=np.flatnonzero(d['split']==2)
39
+ net=FlyBrain(ROOT/'data/graph',steps=args.steps)
40
+ net.select_readout(torch.tensor(d['x'][train[:64]],device='cuda'),np.load(ROOT/'data/graph/sensory.npy'))
41
+ optimizer=torch.optim.Adam(net.parameters(),lr=args.lr)
42
+ start_step=0; best=-1.; began=time.monotonic(); rng=np.random.default_rng(140926)
43
+ initial={k:v.detach().clone() for k,v in net.state_dict().items()}
44
+ if args.resume:
45
+ cp=torch.load(run/'last.pt',weights_only=False); net.load_state_dict(cp['model']); optimizer.load_state_dict(cp['optimizer']); start_step=cp['step']; best=cp['score']
46
+ else:
47
+ before,_=evaluate(net,d,val)
48
+ checkpoint(run/'untrained.pt',net,optimizer,0,before['optimal_move_rate'])
49
+ (run/'before.json').write_text(json.dumps(before,indent=2))
50
+ print('BEFORE',json.dumps(before),flush=True)
51
+ history=[]
52
+ x=torch.tensor(d['x'],device='cuda'); y=torch.tensor(d['optimal'],device='cuda'); legal=torch.tensor(d['boards']==0,device='cuda')
53
+ first_update=time.monotonic()
54
+ for step in range(start_step+1,args.updates+1):
55
+ if time.monotonic()-began>args.seconds: print('Training time limit reached',flush=True); break
56
+ idx=torch.tensor(rng.choice(train,args.batch),device='cuda')
57
+ optimizer.zero_grad(set_to_none=True)
58
+ logits=net(x[idx]).masked_fill(~legal[idx],-1e9)
59
+ # Probability mass on ALL optimal actions; never penalize a tied best move.
60
+ loss=(torch.logsumexp(logits,1)-torch.logsumexp(logits.masked_fill(y[idx]==0,-1e9),1)).mean()
61
+ loss.backward()
62
+ if not torch.isfinite(loss): raise ValueError('Nonfinite training loss')
63
+ grad_norm=torch.nn.utils.clip_grad_norm_(net.parameters(),5.)
64
+ if not torch.isfinite(grad_norm): raise ValueError('Nonfinite gradient')
65
+ optimizer.step()
66
+ if step==1:
67
+ torch.cuda.synchronize()
68
+ print('BENCHMARK',json.dumps({'first_update_seconds':time.monotonic()-first_update,'peak_gpu_gb':torch.cuda.max_memory_allocated()/1e9,
69
+ 'gain_nonzero_gradients':int((net.gain.grad!=0).sum()),'bias_nonzero_gradients':int((net.bias.grad!=0).sum()),'leak_nonzero_gradients':int((net.leak.grad!=0).sum())}),flush=True)
70
+ if step%50==0 or step==args.updates:
71
+ scores,_=evaluate(net,d,val)
72
+ record={'step':step,'loss':float(loss),'seconds':round(time.monotonic()-began,2),**scores}
73
+ history.append(record); print(json.dumps(record),flush=True)
74
+ if scores['optimal_move_rate']>best:
75
+ best=scores['optimal_move_rate']; checkpoint(run/'best.pt',net,optimizer,step,best)
76
+ checkpoint(run/'last.pt',net,optimizer,step,best)
77
+ (run/'history.json').write_text(json.dumps(history,indent=2))
78
+ if not (run/'best.pt').exists(): checkpoint(run/'best.pt',net,optimizer,step,best)
79
+ cp=torch.load(run/'best.pt',weights_only=False); net.load_state_dict(cp['model'])
80
+ result={'validation_selected_step':cp['step'],'elapsed_seconds':time.monotonic()-began,'training':evaluate(net,d,train)[0],
81
+ 'validation':evaluate(net,d,val)[0],'test':evaluate(net,d,test)[0],
82
+ 'trainable_parameters':sum(p.numel() for p in net.parameters()),
83
+ 'changed_internal_parameters':{k:int((net.state_dict()[k]!=initial[k]).sum()) for k in ['gain','bias','leak']},
84
+ 'fixed_interfaces_unchanged':all(torch.equal(net.state_dict()[k],initial[k]) for k in ['encoder','readout','readout_ids']),
85
+ 'one_training_seed':True,'split':'D4 symmetry groups of player-relative boards, SHA256 bucket; 80/10/10 approximate',
86
+ 'device':torch.cuda.get_device_name(0),'peak_gpu_gb':torch.cuda.max_memory_allocated()/1e9}
87
+ result['uniform_random_optimal_rate']=float((d['optimal'][test].sum(1)/(d['boards'][test]==0).sum(1)).mean())
88
+ # Compare the same held-out boards to the untrained network after selection ends.
89
+ net.load_state_dict(torch.load(run/'untrained.pt',weights_only=False)['model'])
90
+ result['untrained_test']=evaluate(net,d,test)[0]
91
+ net.load_state_dict(cp['model'])
92
+ (run/'results.json').write_text(json.dumps(result,indent=2)); print('RESULT',json.dumps(result),flush=True)
93
+
94
+ if __name__=='__main__': main()
source/train/prepare.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Download and prepare MaleCNS on the remote machine only."""
2
+ from pathlib import Path
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import urllib.request
8
+ import numpy as np
9
+ import pandas as pd
10
+ import pyarrow.feather as feather
11
+ import scipy.sparse as sp
12
+ from game import dataset
13
+
14
+ ROOT = Path(__file__).resolve().parents[1]
15
+ BASE = 'https://storage.googleapis.com/flyem-male-cns/v1.0/connectome-data/flat-connectome/'
16
+ FILES = {
17
+ 'annotations': 'body-annotations-male-cns-v1.0-minconf-0.5.feather',
18
+ 'neurotransmitters': 'body-neurotransmitters-male-cns-v1.0.feather',
19
+ 'edges': 'connectome-weights-male-cns-v1.0-minconf-0.5.feather',
20
+ }
21
+
22
+ def download(folder):
23
+ manifest = {}
24
+ for short,name in FILES.items():
25
+ path=folder/(short+'.feather'); url=BASE+name
26
+ if not path.exists():
27
+ print('Downloading remotely:', short, flush=True)
28
+ with urllib.request.urlopen(url,timeout=120) as response, path.with_suffix('.partial').open('wb') as out:
29
+ expected = dict(x.strip().split('=',1) for x in response.headers.get('x-goog-hash','').split(',') if '=' in x).get('md5')
30
+ digest=hashlib.md5()
31
+ while chunk:=response.read(8*1024*1024): out.write(chunk); digest.update(chunk)
32
+ if expected and base64.b64encode(digest.digest()).decode()!=expected:
33
+ raise ValueError('Upstream MD5 mismatch: '+short)
34
+ path.with_suffix('.partial').replace(path)
35
+ digest=hashlib.sha256()
36
+ with path.open('rb') as f:
37
+ while chunk:=f.read(8*1024*1024): digest.update(chunk)
38
+ manifest[short]={'url':url,'bytes':path.stat().st_size,'sha256':digest.hexdigest()}
39
+ lock=folder/'source-lock.json'
40
+ if lock.exists() and json.loads(lock.read_text())!=manifest: raise ValueError('Source lock changed')
41
+ lock.write_text(json.dumps(manifest,indent=2))
42
+ return manifest
43
+
44
+ def main():
45
+ if not Path('/.dockerenv').exists() and not os.environ.get('FLY_XOX_REMOTE'):
46
+ raise SystemExit('Run on the Vast.ai container only.')
47
+ raw=ROOT/'data/raw'; raw.mkdir(parents=True,exist_ok=True)
48
+ manifest=download(raw)
49
+ out=ROOT/'data/graph'; out.mkdir(parents=True,exist_ok=True)
50
+ if (out/'manifest.json').exists(): print('Prepared graph already exists.'); return
51
+ a=feather.read_feather(raw/'annotations.feather')
52
+ nt=feather.read_feather(raw/'neurotransmitters.feather')
53
+ keep=a['superclass'].notna() & a['superclass'].astype(str).ne('') & a['status'].ne('Glia')
54
+ a=a.loc[keep].sort_values('bodyId').reset_index(drop=True)
55
+ ids=a.bodyId.to_numpy(dtype=np.int64)
56
+ assert len(np.unique(ids))==len(ids)
57
+ print('Retained neurons:',len(ids),'classes:',a.superclass.value_counts().to_dict(),flush=True)
58
+ e=feather.read_feather(raw/'edges.feather',columns=['body_pre','body_post','weight'])
59
+ raw_edges=len(e); raw_contacts=int(e.weight.sum())
60
+ pre=np.searchsorted(ids,e.body_pre.to_numpy()); post=np.searchsorted(ids,e.body_post.to_numpy())
61
+ valid=(pre<len(ids)) & (post<len(ids))
62
+ valid &= ids[np.minimum(pre,len(ids)-1)]==e.body_pre.to_numpy()
63
+ valid &= ids[np.minimum(post,len(ids)-1)]==e.body_post.to_numpy()
64
+ counts=e.weight.to_numpy()[valid].astype(np.float32)
65
+ pre=pre[valid].astype(np.int32); post=post[valid].astype(np.int32)
66
+ assert np.isfinite(counts).all() and (counts>0).all()
67
+ contact_total=int(counts.sum(dtype=np.float64)); del e,valid
68
+ # Incoming normalized contact counts, not measured physiological efficacy.
69
+ matrix=sp.csr_matrix((counts,(post,pre)),shape=(len(ids),len(ids)),dtype=np.float32)
70
+ matrix.sum_duplicates(); matrix.sort_indices()
71
+ incoming=np.asarray(matrix.sum(axis=1)).ravel()
72
+ rows=np.repeat(np.arange(len(ids),dtype=np.int32),np.diff(matrix.indptr))
73
+ matrix.data/=np.maximum(incoming[rows],1)
74
+ # A crude sign convention; actual receptors/modulators are not reconstructed.
75
+ labels=a.bodyId.map(nt.set_index('body').consensus_nt).fillna('unknown').astype(str)
76
+ inhibitory=labels.str.lower().isin(['gaba','glutamate'])
77
+ signs=np.where(inhibitory,-1.,1.).astype(np.float32)
78
+ matrix.data*=signs[matrix.indices]
79
+ np.save(out/'ids.npy',ids)
80
+ sp.save_npz(out/'connectome.npz',matrix)
81
+ sensory=a.superclass.astype(str).str.contains('sensory',case=False).to_numpy()
82
+ if not sensory.any(): raise ValueError('No annotated sensory neurons')
83
+ rng=np.random.default_rng(140926)
84
+ encoder=np.zeros((len(ids),27),np.float32)
85
+ # Symbolic board adapter, not a claim of reconstructed fly vision.
86
+ encoder[sensory]=rng.normal(0,0.65,size=(int(sensory.sum()),27)).astype(np.float32)
87
+ np.save(out/'encoder.npy',encoder)
88
+ np.save(out/'sensory.npy',sensory)
89
+ np.savez(ROOT/'data/boards.npz',**dataset())
90
+ report={
91
+ 'dataset':'MaleCNS v1.0','source':'https://male-cns.janelia.org/download/',
92
+ 'license':'CC BY 4.0','sources':manifest,'neurons':len(ids),'edges':int(matrix.nnz),
93
+ 'retained_synaptic_contacts':contact_total,'raw_edge_rows':raw_edges,'raw_contacts':raw_contacts,
94
+ 'sensory_neurons':int(sensory.sum()),'neurotransmitters':labels.value_counts().to_dict(),
95
+ 'retention':'Assigned nonempty neuronal superclass; exclude explicit Glia. All released connections between retained IDs; no extra threshold.',
96
+ 'sign_assumption':'GABA and glutamate negative; all other or unknown transmitters positive. Receptor-specific effects and neuromodulation unmodeled.',
97
+ 'input':'Fixed random projection of 27 symbolic board channels into annotated sensory neurons. No learned input adapter; not optical vision.',
98
+ 'biological_dynamics_validated':False,
99
+ }
100
+ (out/'manifest.json').write_text(json.dumps(report,indent=2))
101
+ print(json.dumps({k:report[k] for k in ['neurons','edges','retained_synaptic_contacts','sensory_neurons']}),flush=True)
102
+
103
+ if __name__=='__main__': main()
source/train/test_core.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run on Vast, with no local installation or test caches."""
2
+ import unittest
3
+ import numpy as np
4
+ import torch
5
+ from game import dataset,targets,value,winner,canonical,encode
6
+ from model import FixedSparse,csr
7
+ import scipy.sparse as sp
8
+
9
+ class GameTests(unittest.TestCase):
10
+ def test_solver(self):
11
+ self.assertEqual(value((0,)*9,1),0)
12
+ b=(1,1,0,-1,-1,0,0,0,0)
13
+ self.assertEqual(targets(b,1)[0][2],1)
14
+ self.assertEqual(winner((1,1,1,0,0,0,0,0,0)),1)
15
+ def test_split_and_legality(self):
16
+ d=dataset(); groups={}
17
+ self.assertEqual(len(d['boards']),4520)
18
+ for b,t,y,s,c in zip(d['boards'],d['turns'],d['optimal'],d['split'],d['canonical']):
19
+ self.assertFalse(winner(b)); self.assertGreater(y.sum(),0)
20
+ self.assertTrue(np.all(y[b!=0]==0))
21
+ key=tuple(c); self.assertEqual(groups.setdefault(key,int(s)),int(s))
22
+ self.assertEqual(canonical(b,t),canonical(np.rot90(b.reshape(3,3)).reshape(-1),t))
23
+ self.assertEqual(encode(b,t).sum(),9)
24
+ def test_sparse_state_gradient(self):
25
+ device='cuda' if torch.cuda.is_available() else 'cpu'
26
+ a=np.array([[0,.2,-.7],[1,0,.3],[0,0,.1]],np.float32)
27
+ m=sp.csr_matrix(a); x=torch.randn(3,4,device=device,requires_grad=True)
28
+ actual=FixedSparse.apply(x,csr(m,device),csr(m.T.tocsr(),device))
29
+ expected=torch.tensor(a,device=device)@x
30
+ self.assertTrue(torch.allclose(actual,expected,atol=1e-6))
31
+ g1=torch.autograd.grad(actual.square().sum(),x,retain_graph=True)[0]
32
+ g2=torch.autograd.grad(expected.square().sum(),x)[0]
33
+ self.assertTrue(torch.allclose(g1,g2,atol=1e-6))
34
+
35
+ if __name__=='__main__': unittest.main()
source/train/verify_webgpu.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Execute the app's exact WGSL via wgpu-native on the remote machine."""
2
+ import hashlib
3
+ import json
4
+ import time
5
+ from pathlib import Path
6
+ import numpy as np
7
+ import wgpu
8
+
9
+ ROOT=Path(__file__).resolve().parents[1]
10
+
11
+ def main():
12
+ folder=ROOT/'web/model'; manifest=json.loads((folder/'manifest.json').read_text())
13
+ adapter=wgpu.gpu.request_adapter_sync(power_preference='high-performance')
14
+ largest=max(a['bytes'] for a in manifest['arrays'].values())
15
+ d=adapter.request_device_sync(required_limits={'max-storage-buffer-binding-size':largest,'max-buffer-size':largest,'max-storage-buffers-per-shader-stage':7})
16
+ buffers={}
17
+ for name,spec in manifest['arrays'].items():
18
+ blocks=[]
19
+ for part in spec['parts']:
20
+ data=(folder/part['file']).read_bytes()
21
+ assert len(data)==part['bytes'] and hashlib.sha256(data).hexdigest()==part['sha256']
22
+ blocks.append(data)
23
+ buffers[name]=d.create_buffer_with_data(data=b''.join(blocks),usage=wgpu.BufferUsage.STORAGE)
24
+ states=[d.create_buffer(size=manifest['neurons']*4,usage=wgpu.BufferUsage.STORAGE|wgpu.BufferUsage.COPY_DST) for _ in range(2)]
25
+ config=d.create_buffer(size=128,usage=wgpu.BufferUsage.UNIFORM|wgpu.BufferUsage.COPY_DST)
26
+ out_config=d.create_buffer_with_data(data=np.array([manifest['readout_count'],manifest['activity_samples'],0,0],np.uint32),usage=wgpu.BufferUsage.UNIFORM)
27
+ result=d.create_buffer(size=(9+manifest['activity_samples'])*4,usage=wgpu.BufferUsage.STORAGE|wgpu.BufferUsage.COPY_SRC)
28
+ def pipeline(file):
29
+ module=d.create_shader_module(code=(ROOT/'web'/file).read_text())
30
+ return d.create_compute_pipeline(layout='auto',compute={'module':module,'entry_point':'main'})
31
+ brain=pipeline('brain.wgsl'); readout=pipeline('readout.wgsl')
32
+ def group(pipe,bs):
33
+ return d.create_bind_group(layout=pipe.get_bind_group_layout(0),entries=[{'binding':i,'resource':{'buffer':b}} for i,b in enumerate(bs)])
34
+ groups=[group(brain,[buffers['rowptr'],buffers['column'],buffers['weight'],states[i],states[1-i],buffers['parameters'],buffers['encoder'],config]) for i in range(2)]
35
+ out_group=group(readout,[states[manifest['steps']%2],buffers['neurons'],buffers['projection'],result,out_config])
36
+ golden=json.loads((folder/'golden.json').read_text()); outputs=[]; times=[]
37
+ for features in golden['inputs']:
38
+ cfg=bytearray(128); np.frombuffer(cfg,dtype=np.uint32)[0]=manifest['neurons']; np.frombuffer(cfg,dtype=np.float32,offset=16)[:27]=features
39
+ d.queue.write_buffer(config,0,cfg)
40
+ encoder=d.create_command_encoder(); encoder.clear_buffer(states[0]); encoder.clear_buffer(states[1]); start=time.perf_counter()
41
+ for step in range(manifest['steps']):
42
+ p=encoder.begin_compute_pass(); p.set_pipeline(brain); p.set_bind_group(0,groups[step%2]); p.dispatch_workgroups((manifest['neurons']+63)//64); p.end()
43
+ p=encoder.begin_compute_pass(); p.set_pipeline(readout); p.set_bind_group(0,out_group); p.dispatch_workgroups(max(9,manifest['activity_samples'])); p.end()
44
+ d.queue.submit([encoder.finish()]); outputs.append(np.frombuffer(d.queue.read_buffer(result),np.float32)[:9].copy()); times.append((time.perf_counter()-start)*1000)
45
+ actual=np.asarray(outputs); expected=np.asarray(golden['logits']); error=float(np.abs(actual-expected).max())
46
+ legal=np.array(golden['boards'])==0
47
+ moves=np.where(legal,actual,-1e9).argmax(1); reference=np.where(legal,expected,-1e9).argmax(1)
48
+ report={'adapter':dict(adapter.info),'positions':len(actual),'maximum_absolute_logit_error':error,'all_moves_match':bool(np.array_equal(moves,reference)),
49
+ 'median_move_ms':float(np.median(times[1:])),'cold_move_ms':times[0],
50
+ 'backend':'wgpu-native executing the browser WGSL; not a browser end-to-end test',
51
+ 'passed':bool(error<.002 and np.array_equal(moves,reference))}
52
+ (folder/'webgpu-verification.json').write_text(json.dumps(report,indent=2))
53
+ manifest['webgpu_verified']=report['passed']; manifest['webgpu_verification']=report
54
+ (folder/'manifest.json').write_text(json.dumps(manifest,indent=2))
55
+ print(json.dumps(report),flush=True)
56
+ if not report['passed']: raise SystemExit('WebGPU parity failed')
57
+
58
+ if __name__=='__main__': main()
source/web/app.js ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {FlyEngine} from './engine.js';
2
+ const $=id=>document.getElementById(id);
3
+ const lines=[[0,1,2],[3,4,5],[6,7,8],[0,3,6],[1,4,7],[2,5,8],[0,4,8],[2,4,6]];
4
+ let board=Array(9).fill(0),engine=null,ready=false,busy=false,ended=false,flyTurn=false;
5
+ const cells=board.map((_,i)=>{const b=document.createElement('button');b.className='cell';b.disabled=true;b.setAttribute('aria-label',`Row ${Math.floor(i/3)+1}, column ${i%3+1}, empty`);b.addEventListener('click',()=>human(i));$('board').append(b);return b;});
6
+ const scoreCells=board.map((_,i)=>{const div=document.createElement('div');div.className='score';div.innerHTML=`<span>${i+1}</span><span>—</span>`;$('scores').append(div);return div;});
7
+ function render(){cells.forEach((cell,i)=>{cell.textContent=board[i]===1?'X':board[i]===-1?'O':'';cell.classList.toggle('o',board[i]===-1);cell.disabled=!ready||busy||ended||flyTurn||board[i]!==0;cell.setAttribute('aria-label',`Row ${Math.floor(i/3)+1}, column ${i%3+1}, ${board[i]===1?'your X':board[i]===-1?'fly O':'empty'}`);});$('reset').disabled=!ready||busy;$('first').disabled=!ready||busy;}
8
+ function finish(){const win=lines.find(([a,b,c])=>board[a]&&board[a]===board[b]&&board[a]===board[c]);if(win){ended=true;win.forEach(i=>cells[i].classList.add('win'));const fly=board[win[0]]===-1;$('status').textContent=fly?'The fly wins. Take a moment.':'You win. The banana is safe.';$('quip').textContent=fly?'“Put that on my résumé.”':'“I have other talents.”';}else if(board.every(Boolean)){ended=true;$('status').textContent='A draw. Both species retain their dignity.';$('quip').textContent='“Same time tomorrow?”';}render();return ended;}
9
+ async function human(i){if(!ready||busy||ended||flyTurn||board[i])return;board[i]=1;if(!finish())await moveFly();}
10
+ async function moveFly(){busy=true;flyTurn=true;render();$('status').textContent='The fly is thinking…';$('brain-status').textContent='COMPUTING A MOVE';try{const prediction=await engine.predict(board,-1);if(prediction.move<0||board[prediction.move])throw new Error('No legal model move.');const old=board.slice();showScores(prediction.logits,old,prediction.move);draw(prediction.activity);$('latency').textContent=`${Math.round(prediction.milliseconds)} ms`;cells.forEach(c=>c.classList.remove('chosen'));cells[prediction.move].classList.add('chosen');board[prediction.move]=-1;$('brain-status').textContent='BRAIN ONLINE';$('quip').textContent='“That square looked delicious.”';}catch(error){$('status').textContent=error.message;ready=false;$('load').hidden=false;$('load').disabled=false;$('load').textContent='Reconnect the fly';$('brain-status').textContent='CONNECTION LOST';$('dot').classList.remove('awake');}finally{busy=false;flyTurn=false;if(ready&&!finish())$('status').textContent='Your move. You are X.';render();}}
11
+ function newGame(first=false){if(busy||!ready)return;board.fill(0);ended=false;flyTurn=false;cells.forEach(c=>c.classList.remove('win','chosen'));scoreCells.forEach((c,i)=>{c.className='score';c.innerHTML=`<span>${i+1}</span><span>—</span>`;});$('status').textContent='Your move. You are X.';render();if(first)moveFly();}
12
+ function showScores(logits,old,pick){const legal=logits.filter((_,i)=>!old[i]);const max=Math.max(...legal);const total=legal.reduce((s,v)=>s+Math.exp(v-max),0);scoreCells.forEach((cell,i)=>{cell.className=`score ${old[i]?'used':''} ${i===pick?'picked':''}`;cell.innerHTML=`<span>${i+1}</span><span>${old[i]?'—':`${Math.round(100*Math.exp(logits[i]-max)/total)}%`}</span>`;});}
13
+ function draw(activity=new Float32Array(128)){const canvas=$('activity'),ctx=canvas.getContext('2d');ctx.clearRect(0,0,canvas.width,canvas.height);const gap=3,width=(canvas.width-gap*127)/128;ctx.strokeStyle='#344137';ctx.beginPath();ctx.moveTo(0,140);ctx.lineTo(640,140);ctx.stroke();activity.forEach((v,i)=>{ctx.fillStyle=v>=0?'#d4fc6b':'#e8a879';const h=Math.max(2,Math.abs(v)*130);ctx.fillRect(i*(width+gap),v>=0?140-h:140,width,h);});}
14
+ $('load').addEventListener('click',async()=>{if(busy)return;busy=true;$('load').disabled=true;$('progress').hidden=false;$('status').textContent='Loading the fly model…';try{engine?.destroy();engine=new FlyEngine();const manifest=await engine.load('./model/',p=>{$('progress').value=p;$('status').textContent=`Loading the fly model… ${Math.round(p*100)}%`;});$('neuron-count').textContent=manifest.neurons.toLocaleString();$('edge-count').textContent=`${(manifest.edges/1e6).toFixed(1)}m`;$('evaluation').textContent=`One pilot run. Held-out optimal moves: ${(manifest.results.test.optimal_move_rate*100).toFixed(1)}%; untrained: ${(manifest.results.untrained_test.optimal_move_rate*100).toFixed(1)}%. No claim of perfect play or a biological advantage.`;$('brain-status').textContent='BRAIN ONLINE';$('dot').classList.add('awake');$('load').hidden=true;$('progress').hidden=true;$('download-note').textContent=`${(manifest.total_bytes/1e6).toFixed(0)} MB loaded · moves now run on your GPU.`;ready=true;busy=false;newGame();}catch(error){engine?.destroy();engine=null;$('status').textContent=error.message;$('load').disabled=false;$('progress').hidden=true;}finally{busy=false;render();}});
15
+ $('reset').addEventListener('click',()=>newGame());$('first').addEventListener('click',()=>newGame(true));draw();
source/web/brain.wgsl ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ struct Config { n: u32, unused0: u32, unused1: u32, unused2: u32, board: array<vec4<f32>, 7> }
2
+ @group(0) @binding(0) var<storage, read> rowptr: array<u32>;
3
+ @group(0) @binding(1) var<storage, read> column: array<u32>;
4
+ @group(0) @binding(2) var<storage, read> weight: array<f32>;
5
+ @group(0) @binding(3) var<storage, read> previous: array<f32>;
6
+ @group(0) @binding(4) var<storage, read_write> next: array<f32>;
7
+ @group(0) @binding(5) var<storage, read> parameters: array<vec2<f32>>;
8
+ @group(0) @binding(6) var<storage, read> encoder: array<f32>;
9
+ @group(0) @binding(7) var<uniform> config: Config;
10
+
11
+ @compute @workgroup_size(64)
12
+ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
13
+ let i = id.x;
14
+ if (i >= config.n) { return; }
15
+ var drive = 0.0;
16
+ for (var edge = rowptr[i]; edge < rowptr[i + 1u]; edge++) {
17
+ drive += weight[edge] * previous[column[edge]];
18
+ }
19
+ for (var channel = 0u; channel < 27u; channel++) {
20
+ drive += encoder[i * 27u + channel] * config.board[channel / 4u][channel % 4u];
21
+ }
22
+ drive += parameters[i].x;
23
+ let candidate = drive / (1.0 + abs(drive));
24
+ let leak = parameters[i].y;
25
+ next[i] = (1.0 - leak) * previous[i] + leak * candidate;
26
+ }
source/web/engine.js ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The move policy executes the retained graph in WebGPU. No solver fallback.
2
+ export class FlyEngine {
3
+ constructor() { this.buffers=[]; this.busy=false; this.lost=false; }
4
+ async load(base='./model/',progress=()=>{}) {
5
+ if (!globalThis.isSecureContext) throw new Error('WebGPU needs HTTPS or localhost.');
6
+ if (!navigator.gpu) throw new Error('This browser does not expose WebGPU. Try a browser with WebGPU enabled.');
7
+ this.base=new URL(base,location.href);
8
+ const response=await fetch(new URL('manifest.json',this.base));
9
+ if (!response.ok) throw new Error('Model unavailable. This private pilot needs its exported model served alongside the app.');
10
+ const m=await response.json(); this.manifest=m;
11
+ if (m.format!=='fly-xox-webgpu-v1') throw new Error('Unsupported model format');
12
+ const adapter=await navigator.gpu.requestAdapter({powerPreference:'high-performance'});
13
+ if (!adapter) throw new Error('No suitable WebGPU adapter was found.');
14
+ const largest=Math.max(...Object.values(m.arrays).map(a=>a.bytes),m.neurons*4);
15
+ if (adapter.limits.maxStorageBufferBindingSize<largest || adapter.limits.maxBufferSize<largest)
16
+ throw new Error('This GPU cannot fit a required model buffer. The full connectome is retained.');
17
+ this.device=await adapter.requestDevice({requiredLimits:{maxStorageBufferBindingSize:largest,maxBufferSize:largest,maxStorageBuffersPerShaderStage:7}});
18
+ this.device.lost.then(()=>{this.lost=true;});
19
+ const d=this.device; let loaded=0; const arrays={};
20
+ for (const [name,info] of Object.entries(m.arrays)) {
21
+ const buffer=this.buffer(info.bytes,GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_DST);
22
+ let offset=0;
23
+ for (const part of info.parts) {
24
+ const res=await fetch(new URL(part.file,this.base));
25
+ if (!res.ok) throw new Error(`Model download failed (${res.status}).`);
26
+ const bytes=await res.arrayBuffer();
27
+ if (bytes.byteLength!==part.bytes) throw new Error('Model file is incomplete.');
28
+ const digest=await crypto.subtle.digest('SHA-256',bytes);
29
+ const hex=Array.from(new Uint8Array(digest),b=>b.toString(16).padStart(2,'0')).join('');
30
+ if (hex!==part.sha256) throw new Error('Model checksum mismatch.');
31
+ d.queue.writeBuffer(buffer,offset,bytes); offset+=bytes.byteLength;
32
+ loaded+=bytes.byteLength; progress(loaded/m.total_bytes);
33
+ }
34
+ if (offset!==info.bytes) throw new Error('Model manifest size mismatch');
35
+ arrays[name]=buffer;
36
+ }
37
+ this.states=[0,1].map(()=>this.buffer(m.neurons*4,GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_DST));
38
+ this.config=this.buffer(128,GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST);
39
+ this.outConfig=this.buffer(16,GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST);
40
+ this.resultSize=(9+m.activity_samples)*4;
41
+ this.result=this.buffer(this.resultSize,GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_SRC);
42
+ this.readback=this.buffer(this.resultSize,GPUBufferUsage.MAP_READ|GPUBufferUsage.COPY_DST);
43
+ d.queue.writeBuffer(this.outConfig,0,new Uint32Array([m.readout_count,m.activity_samples,0,0]));
44
+ const sources=await Promise.all(['brain.wgsl','readout.wgsl'].map(async file=>{
45
+ const res=await fetch(new URL(file,import.meta.url)); if (!res.ok) throw new Error('Shader unavailable'); return res.text();
46
+ }));
47
+ this.brain=await this.pipeline(sources[0]); this.readout=await this.pipeline(sources[1]);
48
+ this.brainGroups=[0,1].map(i=>this.group(this.brain,[arrays.rowptr,arrays.column,arrays.weight,this.states[i],this.states[1-i],arrays.parameters,arrays.encoder,this.config]));
49
+ this.outputGroup=this.group(this.readout,[this.states[m.steps%2],arrays.neurons,arrays.projection,this.result,this.outConfig]);
50
+ await d.queue.onSubmittedWorkDone();
51
+ return m;
52
+ }
53
+ buffer(size,usage) { const b=this.device.createBuffer({size:Math.max(4,size),usage}); this.buffers.push(b); return b; }
54
+ group(pipeline,buffers) { return this.device.createBindGroup({layout:pipeline.getBindGroupLayout(0),entries:buffers.map((buffer,binding)=>({binding,resource:{buffer}}))}); }
55
+ async pipeline(code) {
56
+ const module=this.device.createShaderModule({code});
57
+ const info=await module.getCompilationInfo();
58
+ if (info.messages.some(m=>m.type==='error')) throw new Error(info.messages.filter(m=>m.type==='error').map(m=>m.message).join('\n'));
59
+ return this.device.createComputePipelineAsync({layout:'auto',compute:{module,entryPoint:'main'}});
60
+ }
61
+ async predict(board,turn) {
62
+ if (this.lost) throw new Error('GPU connection lost. Reload to reconnect.');
63
+ if (this.busy) throw new Error('A move is already being computed.');
64
+ this.busy=true;
65
+ const started=performance.now();
66
+ try {
67
+ const m=this.manifest,d=this.device; const cfg=new ArrayBuffer(128);
68
+ new Uint32Array(cfg)[0]=m.neurons;
69
+ const features=new Float32Array(cfg,16);
70
+ for(let i=0;i<9;i++) features[i*3+(board[i]===0?0:board[i]===turn?1:2)]=1;
71
+ d.queue.writeBuffer(this.config,0,cfg);
72
+ const encoder=d.createCommandEncoder();
73
+ encoder.clearBuffer(this.states[0]); encoder.clearBuffer(this.states[1]);
74
+ for(let step=0;step<m.steps;step++) {
75
+ const pass=encoder.beginComputePass(); pass.setPipeline(this.brain); pass.setBindGroup(0,this.brainGroups[step%2]);
76
+ pass.dispatchWorkgroups(Math.ceil(m.neurons/64)); pass.end();
77
+ }
78
+ const out=encoder.beginComputePass(); out.setPipeline(this.readout); out.setBindGroup(0,this.outputGroup);
79
+ out.dispatchWorkgroups(Math.max(9,m.activity_samples)); out.end();
80
+ encoder.copyBufferToBuffer(this.result,0,this.readback,0,this.resultSize); d.queue.submit([encoder.finish()]);
81
+ await this.readback.mapAsync(GPUMapMode.READ);
82
+ const values=new Float32Array(this.readback.getMappedRange()).slice(); this.readback.unmap();
83
+ const logits=Array.from(values.slice(0,9));
84
+ if (!logits.every(Number.isFinite)) throw new Error('The model returned invalid scores.');
85
+ const legal=board.map((v,i)=>v===0?i:-1).filter(i=>i>=0);
86
+ const move=legal.reduce((best,i)=>best<0||logits[i]>logits[best]?i:best,-1);
87
+ return {move,logits,activity:values.slice(9),milliseconds:performance.now()-started};
88
+ } finally { this.busy=false; }
89
+ }
90
+ destroy() { for(const b of this.buffers) b.destroy(); this.device?.destroy(); }
91
+ }
source/web/index.html ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
5
+ <meta name="description" content="Play tic-tac-toe against an experimental model built from a fruit fly's measured neural wiring. Inference runs in your browser with WebGPU.">
6
+ <meta name="color-scheme" content="dark"><title>Fly XOX · one very small opponent</title>
7
+ <link rel="stylesheet" href="./style.css">
8
+ </head>
9
+ <body>
10
+ <main>
11
+ <header><a class="wordmark" href="./">FLY / XOX <span>🪰</span></a><span class="tag">MALECNS EXPERIMENT 01</span></header>
12
+ <section class="game-layout" aria-label="Tic-tac-toe game">
13
+ <div class="play-area">
14
+ <div class="eyebrow">YOU VS. A FRUIT FLY</div>
15
+ <h1>Pick on someone<br>your own size.</h1>
16
+ <div class="players"><span><b class="x">X</b> YOU</span><span class="versus">VS</span><span><b class="o">O</b> THE FLY</span></div>
17
+ <div id="board" class="board" role="group" aria-label="Tic-tac-toe board"></div>
18
+ <p id="status" role="status" aria-live="polite">Wake the fly to start a game.</p>
19
+ <div class="actions"><button id="load" class="primary">Wake the fly <span>↗</span></button><button id="reset" disabled>New game</button><button id="first" disabled>Let the fly start</button></div>
20
+ <progress id="progress" value="0" max="1" aria-label="Model loading" hidden></progress>
21
+ <p id="download-note" class="small">The first load fetches the full model into your browser. No game server chooses its moves.</p>
22
+ </div>
23
+ <aside>
24
+ <div class="panel-top"><span class="dot" id="dot"></span><span id="brain-status">BRAIN ASLEEP</span><span class="small">WEBGPU</span></div>
25
+ <div class="brain-label">A little activity.</div>
26
+ <canvas id="activity" width="640" height="280" aria-label="Measured activity of 128 selected model neurons"></canvas>
27
+ <p class="small">128 selected model neurons · actual computed rate states</p>
28
+ <div class="stats"><div><strong id="neuron-count">—</strong><span>neurons</span></div><div><strong id="edge-count">—</strong><span>connections</span></div><div><strong id="latency">—</strong><span>last move</span></div></div>
29
+ <div class="score-heading">WHERE IT WANTS TO GO <span>relative move scores</span></div>
30
+ <div id="scores" class="scores"></div>
31
+ <blockquote id="quip">“I was promised a banana.”</blockquote>
32
+ <details><summary>What is actually running?</summary><p>The measured MaleCNS wiring, with simplified rate dynamics. Internal neuron gains, offsets, and leaks were trained for XOX. The board input and move decoder are engineered, fixed mappings. This is an experimental model, not a recreated living fly.</p><p id="evaluation">Evaluation loads with the model.</p><p><a href="https://male-cns.janelia.org/" target="_blank" rel="noreferrer">MaleCNS dataset ↗</a> · CC BY 4.0</p></details>
33
+ </aside>
34
+ </section>
35
+ <footer><span>166,000-ish neurons. Nine squares. No pressure.</span><span>REAL WIRING / EXPERIMENTAL LEARNING</span></footer>
36
+ </main>
37
+ <script type="module" src="./app.js"></script>
38
+ </body>
39
+ </html>
source/web/readout.wgsl ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ struct Config { count: u32, samples: u32, unused0: u32, unused1: u32 }
2
+ @group(0) @binding(0) var<storage, read> state: array<f32>;
3
+ @group(0) @binding(1) var<storage, read> neurons: array<u32>;
4
+ @group(0) @binding(2) var<storage, read> projection: array<f32>;
5
+ @group(0) @binding(3) var<storage, read_write> result: array<f32>;
6
+ @group(0) @binding(4) var<uniform> config: Config;
7
+ @compute @workgroup_size(1)
8
+ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
9
+ let i = id.x;
10
+ if (i < 9u) {
11
+ var score = 0.0;
12
+ for (var j = 0u; j < config.count; j++) {
13
+ score += projection[i * config.count + j] * state[neurons[j]];
14
+ }
15
+ result[i] = 8.0 * score;
16
+ }
17
+ if (i < config.samples) { result[9u + i] = state[neurons[i]]; }
18
+ }
source/web/style.css ADDED
@@ -0,0 +1 @@
 
 
1
+ :root{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#f4f3eb;background:#101311;--green:#d4fc6b;--muted:#9aaba1;--line:#344137}*{box-sizing:border-box}body{margin:0}button,a{touch-action:manipulation}button{font:inherit;cursor:pointer}button:disabled{cursor:default;opacity:.4}button:focus-visible,a:focus-visible,summary:focus-visible{outline:3px solid var(--green);outline-offset:5px}main{max-width:1280px;margin:0 auto;padding:0 48px}header{height:100px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.wordmark{font-size:23px;font-weight:850;letter-spacing:-1px;text-decoration:none;color:inherit}.wordmark span{font-size:30px;margin-left:8px}.tag,.eyebrow,.panel-top,.score-heading,footer{font:12px ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:1px}.tag{color:var(--muted)}.game-layout{display:grid;grid-template-columns:minmax(360px,1fr) minmax(300px,430px);gap:90px;padding:56px 0 46px}.eyebrow{color:var(--green)}h1{font-size:clamp(34px,4vw,52px);font-weight:600;line-height:1.06;letter-spacing:-2px;margin:18px 0 32px}.players{display:flex;align-items:center;gap:22px;font-size:13px;font-weight:600;letter-spacing:1px;margin-bottom:18px}.players span{display:flex;align-items:center;gap:10px}.players b{font-size:25px}.x{color:var(--green)}.o{color:#e8a879}.versus{color:#68776c;font-size:12px}.board{display:grid;grid-template-columns:repeat(3,1fr);gap:2px;background:var(--line);max-width:410px;aspect-ratio:1}.cell{border:0;background:#171e18;color:var(--green);font-size:clamp(40px,5vw,68px);font-weight:400;line-height:1;transition:background .15s}.cell:not(:disabled):hover{background:#253321}.cell:disabled{opacity:1}.cell.o{color:#e8a879}.cell.win{background:#344427}.cell.chosen{box-shadow:inset 0 0 0 2px #e8a879}#status{font-size:16px;min-height:24px;margin:20px 0}.actions{display:flex;gap:10px;flex-wrap:wrap}.actions button{padding:12px 15px;border:1px solid var(--line);background:transparent;border-radius:5px;color:inherit;font-size:14px}.actions .primary{background:var(--green);border-color:var(--green);color:#101311;font-weight:650;min-width:175px;display:flex;justify-content:space-between;gap:18px}progress{width:100%;max-width:410px;margin-top:18px;accent-color:var(--green)}.small{font-size:12px;line-height:1.6;color:var(--muted);letter-spacing:0}#download-note{max-width:410px;margin-top:14px}aside{align-self:start;border:1px solid var(--line);padding:24px;border-radius:10px;background:#151b16;margin-top:3px}.panel-top{display:flex;align-items:center;gap:8px;color:var(--green)}.panel-top .small{margin-left:auto}.dot{width:7px;height:7px;border-radius:50%;background:#6b796c}.dot.awake{background:var(--green);box-shadow:0 0 12px #d4fc6b44}.brain-label{font-size:27px;letter-spacing:-1px;margin-top:35px}canvas{width:100%;height:170px;display:block;margin-top:16px;border-bottom:1px solid var(--line)}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:28px 0;padding-bottom:28px;border-bottom:1px solid var(--line)}.stats strong{display:block;font-size:23px;letter-spacing:-.8px;font-weight:500}.stats span{display:block;color:var(--muted);font-size:12px;margin-top:6px}.score-heading{display:flex;justify-content:space-between;align-items:center;font-size:11px;gap:12px}.score-heading span{font-size:11px;letter-spacing:0;color:var(--muted)}.scores{display:grid;grid-template-columns:repeat(3,1fr);gap:5px;margin-top:14px}.score{padding:12px;background:#202920;color:#9daf9d;border-radius:3px;font:13px ui-monospace,monospace;display:flex;justify-content:space-between}.score.picked{background:var(--green);color:#101311}.score.used{opacity:.35}blockquote{font-size:20px;line-height:1.4;margin:32px 0 25px;font-style:italic;color:#d2dacd}details{border-top:1px solid var(--line);padding-top:18px}summary{font-size:14px;color:var(--muted);cursor:pointer}details p{font-size:14px;line-height:1.7;color:var(--muted)}details a{color:var(--green)}footer{border-top:1px solid var(--line);padding:22px 0 30px;color:var(--muted);display:flex;justify-content:space-between;gap:20px;font-size:11px;line-height:1.8}@media(max-width:900px){main{padding:0 24px}.game-layout{gap:30px;grid-template-columns:1fr 1fr}aside{padding:18px}.stats strong{font-size:19px}.score-heading{flex-direction:column;align-items:flex-start;gap:6px}}@media(max-width:680px){header{height:75px}.tag{font-size:10px}.game-layout{grid-template-columns:1fr;padding-top:32px;gap:35px}.board{max-width:none}.play-area{max-width:470px;width:100%;margin:auto}h1{font-size:42px}.cell{font-size:64px}aside{max-width:470px;width:100%;margin:auto}footer{flex-direction:column;gap:4px}.score-heading{flex-direction:row}.stats strong{font-size:24px}}@media(prefers-reduced-motion:reduce){*{transition:none!important}}