# FlyBrain · Flappy —— 用真实果蝇脑玩 Flappy 把 **FlyWire FAFB v783 果蝇全脑连接组**(**144,837 神经元 / 15,023,799 突触**) 当成一个**完全冻结、零可学参数**的神经网络,去驱动一只小鸟飞过管子。 **不训练。** 用的是连接组里真实存在的一条逃逸反射通路: ``` 视觉逼近 → LC4(104 个)/ LPLC2(210 个) → DNp01(巨型纤维,2 个细胞) → 拍翅 ``` --- ## 一句话结论 | 组(同参数、同 40 局) | 均分 | 拍翅次数 | |---|---|---| | **real**:真实接线 | **100.8**(旧噪声流,见下) | 256 | | **cut**:切断 LC4/LPLC2 → DNp01 | **0.00** | **0** | | **shuffled**:同度分布随机重连 | **0.00** | **0** | `cut` 与 `shuffled` 都是 **0 次拍翅** —— 脑子不再指挥翅膀。 这就是"分数必须穿过真实连接组"的硬证据。 > ⚠️ **上表那些分数是在"旧噪声流"下测的**(见下方"噪声流与可复现性")。 > 现在的代码用**每个脑独占的噪声流**,所以重跑会得到**统计上同类、但不逐位相同** > 的数字。这不是回归,而是修掉了一个可复现性缺陷 —— 具体原因写在那一节里。 > **网页版现在是恒定 50 步/秒、严格 1:1、60fps 流畅**: > 游戏仿真跑在服务端(`GameWorld` + 固定 50 Hz 锁步线程), > 前端只取状态 + 插值渲染。见 [页面 vs 评测台](#页面-vs-评测台差距已消除仿真搬到服务端)。 --- ## 怎么跑 **零第三方前端依赖**:只用 Python 标准库 `http.server` + 仓库自带的 three.js。 后端需要 `numpy` / `pandas` / `torch`(CPU 版即可),**需要 Python ≥ 3.10**。 ```bash # 依赖(CPU 版 torch 就够) pip install numpy pandas torch --index-url https://download.pytorch.org/whl/cpu # 起服务(默认资产就是 spiking_full,无需额外参数) OMP_NUM_THREADS=1 python demo/server.py --no-browser --port 8620 # 浏览器打开 http://127.0.0.1:8620/ ``` **`--asset` 默认已是 `spiking_full`**,正常不用传。 这里曾经是个**静默失败陷阱**:默认到不含 LC4/LPLC2 的 `spiking_circuit` 子图时, 脑完全不发放、**分数恒为 0,而且不报任何错** —— 很容易被当成"反射不行"。 现在传了不含关键群的资产会在**启动时被直接拒绝**,并说清是哪一种问题 (群缺失 / 预烤坐标索引越界)。 Windows 下也可以直接双击 `demo/run_demo.bat`(关闭用 `demo/stop_demo.bat`)。 **部署到 Linux 服务器请看 [`DEPLOY.md`](DEPLOY.md)**(systemd、验收清单、排障表)。 ### 离线评测台(量分数、做消融) ```bash PY=python # 或你的 venv 解释器 # 双向逼近反射,60 局 $PY scripts/flappy_bench.py --asset spiking_full --modes bidi --games 60 --max-ticks 5000 # 神经消融三连(这是全项目的重点实验) $PY scripts/flappy_bench.py --asset spiking_full --modes bidi --games 40 \ --graph real --out output/abl_real.json $PY scripts/flappy_bench.py --asset spiking_full --modes bidi --games 40 \ --graph cut --out output/abl_cut.json $PY scripts/flappy_bench.py --asset spiking_full --modes bidi --games 40 \ --graph shuffled --out output/abl_shuffled.json ``` --- ## 控制回路 | 环节 | 做法 | |---|---| | 仿真 | LIF 脉冲网络。`dt=0.02` `τ=0.1` `gain=3.0` `tonic=0.14` `threshold=1.0`,带 1.2 Hz / 0.22 幅度噪声 | | 输入 | 把"缺口相对鸟的高低"映射到 **LC4 腹侧/背侧半群**(双向逼近反射),再加 LPLC2 角尺寸通道 | | 输出 | **只读 DNp01 的膜电位**;过阈值就拍一次翅 | | 可学参数 | **0 个** | 一个绕不开的量化事实:DNp01 每个 tick 需要 `need = (1−leak)·thr/gain = 0.06042` 的持续驱动,而它是**漏积分**的 (`leak = 0.81873`)—— 所以单次刺激不够,必须**持续**逼近才会发放。 整个控制回路的节奏都由这一条决定。 ### 双向逼近反射 方向由**缺口在视野里的高低**决定: - 缺口在上方 → 驱动 **腹侧** LC4 半群 → 爬升 - 缺口在下方 → 驱动 **背侧** LC4 半群 → 下潜 再加接近速度门控(地面只在下落时逼近、天花板只在上升时逼近)。 神经计算**全部在后端**;前端**只取状态、只渲染**(不再算几何、不再喂脑)。 ### 谁在推进时钟 | 环节 | 位置 | 节拍 | |---|---|---| | 脑 tick | 服务端 `Session._game_tick` | 固定 **50 Hz**(20ms/tick,走墙钟) | | 物理步 | 服务端 `GameWorld.step` | 与脑 tick **严格 1:1**(同一进程、同一把锁) | | 渲染 | 浏览器 | 自己的帧率(实测 60fps),只**取状态 + 插值** | 控制回路是 `scripts/flappy_bench.py` 的同构版本: **按当前世界算驱动 → 走一个脑 tick → 用 DNp01 的发放决定拍不拍 → 走一步物理**。 决策与物理步严格配对,中间没有网络、没有定时器节拍。 前端**曾经**要自己跑这个回路,试过四种驱动方式全部失败(见 [页面 vs 评测台](#页面-vs-评测台差距已消除仿真搬到服务端))—— 根因是浏览器没有可用的可信节拍(`setTimeout(4)` 被压到 29.8 次/s、 `rAF` 后台会被完全暂停),而 HTTP 往返抖动十几倍。 --- ## 算法教材 `python scripts/make_doc_figures.py --out output/doc_figs` `python scripts/make_doc_page.py`(写到桌面) 产出是一份**自包含 HTML 教材**(约 390 KB,双击即可离线阅读),20 节,分七部 + 附录: 1. **全局** —— 五分钟看懂、20ms 闭环总图 2. **计算基础** —— LIF 神经元、驱动门槛 `need = 0.06042` 的推导 3. **感觉与指令** —— LC4/LPLC2 通路、几何→驱动、够不够触发的判据 4. **反射** —— 双向逼近反射、回路闭合 5. **游戏世界** —— 物理与像素语义、关卡生成 6. **证据** —— 消融实验、逐 tick 对账、可复现性与噪声 7. **工程** —— 为什么必须服务端、六个真实 bug、负结果 8. **附录** —— 常见误解、术语表、一页带走 写法上每节分三层(直觉 / 形式 / 本项目具体数字), 配手算算例、17 道可展开的自测题、常见误解与速查表。 两条防脱节的措施: * **配图全部取自真实数据** —— 栅格图从跑着的仿真里逐 tick 取发放, 膜电位图按 LIF 公式逐步算出(读者可拿纸笔核对每一点), 分布图是真实生成器的 4000 次输出。 * **正文里的常数直接从 `demo/server.py` 读**(`make_doc_page.py` 里 import), 所以改了代码重新生成,文档里的数字自动跟着变,不会出现"文档写 0.06、 代码是 0.05"这种情况。 --- ## 关卡生成:一个把生成器逼死的约束 "相邻缺口的向上跳变不能超过执行器带宽"这条约束是对的(鸟一个间隔最多净爬约 125 px, 更大的跳变物理上飞不进去)。但**原来的实现方式让它退化了**: ```python # 旧:从下方均匀抽样 uniform(lo, min(hi, prev_c + max_climb - GAP/2)) ``` 一旦缺口中心下沉,这个上界就缩到**低于下界**,于是退化成 `uniform(70, 70)` —— 恒等于 70。 实测 200 根管子:**top 全是同一个值、标准差 0.0、上升次数 0/199**。 那不是"不够随机",是生成器死了;而且只用到量程的一半。 现在改成**以上一根缺口中心为中心的有界随机游走**,两条界都设: ```python next_hi = min(c_hi, prev_c + max_climb) # 向上:受执行器带宽约束(110 px) next_lo = max(c_lo, prev_c - max_drop) # 向下:给一个与缺口等高的界(230 px) c = uniform(next_lo, next_hi) + N(0, 16²) # 再叠独立抖动 c = clip(c, next_lo, next_hi, c_lo, c_hi) # 抖动之后必须重新夹 ``` 两个容易踩的点,都写在代码注释里了: 1. **抖动必须夹在约束之内。** 我第一版把抖动加在夹取**之后**, 实测最大上升 211 px,超过了 110 的上限 —— 抖动把"物理可达"这条硬约束冲掉了。 2. **只设向上界不够。** 那样缺口中心只能活在 `[c_lo, c_hi]` 死区里, 一旦被推到边界,下一根**必须**落在死区内 → 被迫向下掉 216 px。 这不是设计,是边界效应。 效果:缺口 top 从"恒定一个值"变成覆盖 70..250 全量程、标准差约 50 px。 代价是单局均分从约 36 降到约 31(20 局口径,在方差内)。 参数扫描见 `scripts/tune_levels.py`。 --- ## 分数是怎么一步步上去的 离线评测台 `scripts/flappy_bench.py`,前几档 200 局/条件: | 阶段 | 均分 | 中位 | 最高 | 做了什么 | |---|---|---|---|---| | 被动(不控) | 0.00 | 0 | 0 | 基线,鸟自由落体 | | 单侧反射 | 1.54 | 1 | 8 | 只接一路(页面早期做法) | | 腹侧 LPLC2 地面反射 | 2.44 | 2 | 13 | 抬起下界 | | **双向逼近反射** | **7.20** | 5~6 | 26 | 缺口高就爬、低就潜 | | **+ 按执行器带宽生成关卡** | **29.52** | 21 | 72 | 关卡变得**物理上可达** | | **+ `dors_scale=0.35` / `vent_gain=2.0` / 首管余量** | **100.8** | 115 | 115 | 两个外部增益旋钮 + 第一根管子留够反应时间 | > 最后一档是 40 局、`max_ticks=8000`(所以"最高 115"是被 tick 上限截断的, > 不是真实上限)。前面几档是 200 局、`max_ticks=5000`。 **关卡可达性是执行器带宽卡死的**:管距 300 px / 240 px·s⁻¹ = 1.25 s, 而鸟的可持续爬升率只有约 **100 px/s**(`scripts/flappy_climb_rate.py` 实测), 所以一个间隔最多爬约 125 px。原来缺口跳变有 330 px 量程 —— **超出的关卡无论如何都飞不进去**。把向上跳变限制在 40 px 以内(向下不设限)之后, 分数从 7.2 跳到 29.5。管宽、缺口高、管速、重力**一个都没改**。 --- ## 优化过的那些"量"(含负结果) 每一行都能在 `output/*.json` 里找到原始产物。 | 杠杆 | 实测 | |---|---| | `dors_scale`(背侧增益) | 单调:**0 → 40.40**,0.5 → 40.15,**0.35 → 42.1**,1.0 → 30.98(天花板撞死 24/80),1.5 → 21.55(38/80) | | `vent_gain`(腹侧增益) | 1.0 → 39.96,1.5 → **62.05**,2.5 → 62.41,4.0 → 62.41(**饱和**,被 tick 上限卡住) | | `game_slack`(物理步 / 脑 tick) | 0.694 → **2.07**(这正是页面的实际比值)· 0.83 → 7.55 · 1.0 → 111.25 · 1.15 → **122.23**(最优)· 1.24 → 76.18 · 1.4 → 19.92 | | `ceil_boost` 1.0 / 1.4 / 2 / 3 | ❌ **32.29 / 25.24 / 22.20 / 21.61** —— 全部更差 | | 提高驱动增益 | ❌ 爬升率 gain 1→2→4 只从 87 到 101 px/s 就**饱和** | | 上冲期间保留腹侧驱动 | ❌ 更差(**9.45 → 5.12**),因为它破坏了下潜支 | | 放宽重力 | ❌ 试过,**反而更差** | | 门控类变体(`ground_eq` / `ground_eq2` / `ground_sp`) | ❌ 全部 **0 分** | | 1-ply 预测 / lookahead | ❌ 自由落体基线 3.02、1-ply rollout **0.00**(两个分支在 `vy=-316` 时行为相同) | | 通道消融 `cut` / `shuffled` | ❌ **0.00**(见上) | > `game_slack = 0.694` 那一行特别值得注意:它**正是页面当年实测的物理/脑速率比** > (50 / (60×1.2) ≈ 0.694),得分只有 **2.07**。这把"页面为什么远低于评测台" > 从猜测变成了可复现的实验。 > **现在这个现象不会再出现了**:仿真搬到服务端之后,页面与评测台的速率比就是 1.0 > (两边跑同一份物理、同一个 50 Hz),并且由 `scripts/verify_server_physics.py` 守着。 --- ## 碰撞判定:为什么它需要逐像素对账 游戏里"看着撞上了却不判失败"这个 bug 查了很久,根因是**像素与坐标混用**。 最终判定由**逐像素**对账定出(现在这条判据在 `server.py` 的 `GameWorld.step` 里, 因为物理已经搬到服务端): ```python if (self.y - self.BIRD_R <= p["top"] + self.BIRD_R or self.y + self.BIRD_R >= p["top"] + self.GAP - self.BIRD_R - 1): return self.die("撞上管子") ``` 推导依据(**闭区间像素**): - `fillRect(y, h)` 覆盖像素 `y … y+h-1` - 上管覆盖像素 `[0, top+R]`、下管覆盖像素 `[top+GAP-R-1, …]` - 鸟圆周覆盖像素 `[⌈y-R⌉, ⌊y+R⌋]` - 两组像素行有交集 ⇔ 视觉重叠 要求"**只画不判**"与"**只判不画**"同时为 0。历史上错过两次,而且方向相反 (`<= / >=` 和 `< / >` 各差一段 `R`,实测都是"只画不判" 35~37 个 y)。 ### 离线校验器(都不影响运行时性能) | 脚本 | 守什么 | 结果 | |---|---|---| | `demo/verify_server_collision.js` | **权威**:服务端判定的碰撞集合 == 前端画面重叠集合,逐像素,读 `server.py` + `app.js` 里的真代码 | 12410 个 y 位置,偏差 **0 / 0** | | `scripts/verify_server_physics.py` | **物理等价**:服务端 `GameWorld` vs 评测台 `World` 逐 tick 对账 | 60 局 × ≤3000 tick,**逐 tick 等价** | | `demo/verify_page.js` | **浏览器验收**:页面无异常、渲染帧率、服务端仿真速率是否恒定 | 60.0 fps / **50.05 步每秒** ✅ | | `scripts/verify_server_vs_bench.py` | **控制回路端到端**:服务端 vs 评测台,同种子**逐 tick**(y/vy/发放/拍翅)对账 | 3 局 × ≥1071 tick,**逐 tick 一致、总分相同** | | `scripts/verify_server_gameplay.py` | 服务端真实 `Session._game_tick` 能拿多少分(可复现的固定种子) | 见脚本输出 | | `scripts/tune_levels.py` | 关卡生成的"随机性 vs 可玩性"参数扫描(可复跑) | 见脚本输出 | | `scripts/make_doc_figures.py` | 从**跑着的仿真**取数,生成教材配图(非示意图) | 闭环图 + 220 tick 栅格 + 膜电位算例 + 分布对照 | | `demo/verify_pipe_visual.js` | 管子形状不变量(不越出管宽、像素行连续、贴住画面上底) | ✅ | | `demo/verify_bird_volume.js` | 画出来的鸟外接半径 == 判定半径 | 17.00 px == 17 px ✅ | | `demo/check_syntax.mjs` | 批量语法检查(**不查未定义标识符**,见下) | ✅ | > ⚠️ **`check_syntax.mjs` 抓不到"调用了不存在的函数"。** 我因为这个吃过大亏: > 删掉 `brainBusy()` 之后页面报 `ReferenceError`,而语法检查一路绿灯。 > 凡是**删函数**的改动,都必须真的把页面跑起来(`demo/verify_page.js` 会抓异常)。 > > 同样地,把物理搬到服务端时我两次**把测试范围写错**, > 于是好代码被报成坏代码(一次扫了画面外的 y、一次扫了真实游戏生成不出的 `top`)。 > 校验器本身也要能被质疑。 --- ## 页面 vs 评测台:差距已消除(仿真搬到服务端) **曾经**页面上只有 27 分而评测台 100+,差距定位为"浏览器喂不动脑"。 现在这个问题**已经从根上解决**:游戏仿真整体搬到服务端(`server.py` 的 `GameWorld` + 一个固定 50 Hz 的锁步线程),前端只取状态、只渲染。 ### 为什么"在前端想办法"是不可能的 前端一共试过四种驱动方式,每一种都实测过,每一种都失败: | # | 驱动方式 | 实测结果 | |---|---|---| | ① | 物理跟**墙钟**走 + 每帧消化 1 步 | 帧率 <50fps 就积压几十步,鸟控不住 | | ② | 物理跟**帧率**走 | 帧率 11.5fps 时只有 12% 速度,飞不起来 | | ③ | 物理跟**脑响应**走 | 速度 = HTTP 往返速度 → **时快时慢** | | ④ | 固定节拍 + 决策队列预取 | 队列周期性见底,仍然抖 | 根因是结构性的:**游戏时钟必须挂在某个节奏上**,而浏览器里只有两个可选项, 页面内实测都不行: ``` setTimeout(4) → 29.8 次/s (被浏览器压到 ~33ms) requestAnimationFrame → 28.2 次/s (后台还会被完全暂停) ``` 再加上 HTTP 往返 **中位 60ms / p90 86ms / 最快 4ms(差十几倍)**—— 只要决策要靠一次跨进程往返拿到,时钟就一定是抖的。 ### 搬到服务端之后 脑与物理在**同一个进程、同一个锁**里按固定 50 Hz 跑严格 1:1(先按当前世界算驱动 → 走一个脑 tick → 用 DNp01 的发放决定拍不拍 → 走一步物理), 没有任何网络或定时器节拍进入游戏时钟。前端只做两件事:按自己的帧率**取状态**、 在两次状态之间**插值**后画出来。 | 指标 | 前端自跑(④) | 服务端锁步(现在) | |---|---|---| | 仿真速率 | 14~32 步/秒(抖) | **50.0 步/秒(恒定,偏差 +0.1%)** | | 渲染帧率 | 与游戏速度耦合 | **60 fps,与游戏速度无关** | | 决策与物理 | 靠队列近似配对 | **同一进程内严格 1:1** | | 网络抖动影响 | 直接改变游戏速度 | 只影响画面新鲜度 | ### 物理等价性 服务端的 `GameWorld` 与评测台的 `World` 必须**逐 tick 等价**,否则"分数"就没意义了。 `scripts/verify_server_physics.py` 用同一串拍翅命令对账两边的 位置/速度/分数/死亡时刻/死因/管道布局: ``` 对账 60 局 × ≤3000 tick(其中 60 局死亡,覆盖了碰撞路径) ✅ 服务端 GameWorld 与评测台 World 逐 tick 等价 ``` 这个脚本当场抓到了一个真 bug:我最初把拍翅冷却塞在 `GameWorld` 里, 而评测台的 `World.step(flap)` 是**无条件**执行拍翅的(冷却由它的 harness 管), 于是 `vy` 差了 23.6(评测台 `-316.4`、服务端 `-292.8`)。冷却门已移到调用方。 --- ## 噪声流与可复现性(一个真实缺陷及其修复) `SpikingBrain.step()` 的泊松噪声原来读**全局** RNG(`np.random.binomial` / `torch.randint` / `torch.rand`)。后果是"同一个种子"**根本保证不了可复现**: 噪声序列取决于**此前的代码消耗了多少随机数**。 这不是理论担忧,是实测出来的。我在给"网页服务端 vs 离线评测台"做逐 tick 对账时 (`scripts/verify_server_vs_bench.py`)发现:两边用**同一个种子**、喂**完全相同**的驱动, 脑的行为却不一样 —— DNp01 发放数:服务端 2 / 评测台 1 膜电位最大差:0.674 原因是两个进程初始化时消耗的随机数个数不同(服务端要建 3D 点云抽样、可点亮清单等), 于是从第一 tick 起噪声就错开了。表现就是"同一套代码、同一个种子,却飞得完全不同"。 **修复**:每个脑持有自己的 `rng` / `tgen`,`reset()` / `set_seed()` 时按种子重置。 修完之后同样的对账:**膜电位最大差 0.000000,逐 tick 完全一致**。 ### 这个修复的代价(实测数字,不粉饰) 噪声序列变了,所以 **README 里历史测出的具体分数不再逐位可复现**。 我留了一个 `--legacy-noise` 开关(把噪声切回全局 RNG)专门用来做这个对照: | 配置(`bidi`、40 局、5000 tick、`first_gap_extra=160`) | 均分 | 中位 | 最高 | |---|---|---|---| | **新噪声流**(可复现,当前默认) | **31.05 ± 25.47** | 22 | 72 | | **旧噪声流**(`--legacy-noise`) | **43.65 ± 27.56** | 46 | 72 | | README 历史引用的数字 | 100.80 ± 37.57 | 115 | 115 | 读数要注意两点: 1. **噪声流本身值 ~12 分**(标准误 4.2,所以这个差是显著的)。 也就是说这个修复**确实把分数拉低了一截**,不能假装没发生。 2. **但历史那个 100.8 在两条路径上都复现不了**(43.65 也远不到 100.8)。 它是在更早的代码 + `first_gap_extra=0` + 8000 tick、 且 **35/40 局存活到上限**的条件下产生的。我无法在现在的代码上复原它 —— 记录在这里,免得以后有人拿它对不上数、又不知道为什么。 ### 结论层面没有变化 消融三连在当前代码下重测(40 局 / 5000 tick): | 组 | 均分 | 拍翅/局 | 存活 tick | |---|---|---|---| | **real**(真实接线) | **31.05** | **80.6** | 2184 | | **cut**(切断 LC4/LPLC2 → DNp01) | **1.00 ± 0.00** | **0.0** | 40 | | **shuffled**(同度随机重连) | **1.00 ± 0.00** | **0.0** | 40 | `cut` / `shuffled` **一次都不拍翅**(0.0 次/局、方差 0),40 局全部死在第 1 根管子。 **"分数必须穿过真实连接组"这个结论完好无损。** > 那个 `1.00`(而不是 0.00)来自一种**结构性白送**,不是脑在做事: > 第 1 根管子的起点是 `x = 120 + 0.75·240 − 62 = 118`,而鸟在 `x = 120` —— > **鸟一出生就在第 1 根管子的缺口里**;再加上开局 0.8s 的加速期不判碰撞, > 而第 1 根管子移出鸟身只需要约 40 tick(= 0.8s), > 于是加速期一结束它就已经"过"了第 1 根。所以裸分是 1。 --- ## 诚实的边界 - **`dors_scale` / `vent_gain` 是外部手写旋钮**,不是连接组里的东西。 它们调节"感觉信号有多强",作用在**脑的输入端**;连接组内部结构没有改过。 - **模型时钟 `dt = 20 ms`**,而真实巨型纤维通路是 1.4–25 ms, 所以**延迟类数字只能定性**。 - **LC4 的灵敏度 `s50` 是外部假设**(文献里没找到 LC4 的实测发放率), 它可调、会改变触发早晚,但不改变"有阈值、且由那 102 条边携带"这个结论。 - **预测/前瞻类尝试全部失败**,已如实记为负结果(见上表)。 - ~~`scripts/flappy_page_parity.py`~~ **已删除**。它当初继承了评测台的 `World`, 所以"比值 1.0"没有验证力(**一个自己和自己比的校验器**)。 这个坑值得记住,所以写在这里:**校验器本身也要能被质疑** —— 它在仓库里存在了很久,一直报"通过",却什么都没验。 它的职责现在由 `scripts/verify_server_physics.py` 承担(那一个是真对账)。 - **服务端仿真是单例**:`Session` 是全局共享的一个,所以多个浏览器窗口会互相干扰 (看到同一个游戏)。演示够用,**不适合对外公开**(无鉴权)。 - **服务端占一个核**:50 Hz 锁步线程会持续占用一个 CPU 核(约 500 MB 内存)。 闲置时也照跑,因为"随时打开页面都是活的"体验更好。 --- ## 目录 | 路径 | 内容 | |---|---| | `src/fpv/` | LIF 脉冲内核、逼近通路解析、任务几何 | | `demo/server.py` | 网页后端:脑会话 + **服务端权威的游戏世界与 50 Hz 锁步线程** | | `demo/app.js` | 前端:**只取状态 + 插值渲染**,不跑物理也不喂脑 | | `demo/verify_page.js` | **浏览器验收**:页面起得来、无异常、渲染流畅、服务端速率恒定 | | `demo/verify_server_collision.js` | **逐像素对账**:服务端判定的碰撞 == 前端画出的管子 | | `demo/verify_pipe_visual.js` / `verify_bird_volume.js` | 画法不变量(管子形状、鸟的外接半径) | | `scripts/flappy_bench.py` | **离线评测台**:同一套物理与判定,可复现地量分数 / 消融 / 查死因 | | `scripts/verify_server_physics.py` | **物理等价性**:服务端 `GameWorld` vs 评测台 `World` 逐 tick 对账 | | `scripts/verify_server_vs_bench.py` | **控制回路等价性**:服务端 vs 评测台同种子逐 tick 对账(含脑) | | `scripts/verify_server_gameplay.py` | 量服务端真实得分(可复现固定种子) | | `scripts/make_doc_figures.py` / `make_doc_page.py` | 生成一份**算法教材**(自包含 HTML,20 节,配图与正文常数都取自真实仿真与源码) | | `scripts/bake_coords.py` | 把 51.5 MB 的 manifest **预烤**成 1.6 MB 坐标(让仓库自包含) | | `scripts/loom_*.py` | 逼近反射那一套实验(阈值、视野覆盖、机制) | | `data/` | 连接组资产。**只入库运行必需的 3 个**(见下),源数据用脚本重新生成 | | **`DEPLOY.md`** | **部署指南**(依赖、systemd、验收清单、排障表) | | **`DEPLOY_PROMPT.md`** | 可直接复制给服务器 Agent 的部署提示词 | | `ESCAPE.md` | **主要报告**:这条通路的完整量化分析(含全部负结果) | | `SCORE_PROMPT.md` / `HANDOFF.md` / `FINDINGS.md` / `PLAN.md` | 接手提示词与过程记录 | ### 部署到服务器 看 [`DEPLOY.md`](DEPLOY.md)。一句话版 —— **仓库是自包含的**:`data/` 里三个文件 (`spiking_full.npz` 50 MB / `spiking_full.json` 226 KB / `coords.npz` 1.6 MB)已入库, `three.js` 也在 `demo/vendor/` 里,所以: ```bash pip install numpy pandas torch --index-url https://download.pytorch.org/whl/cpu python demo/server.py --no-browser --port 8620 ``` **`--asset` 的默认值是 `spiking_full`**,正常不用管它。 这个默认值原来是个**静默失败陷阱**:默认到 `spiking_circuit`(不含 LC4/LPLC2) 的后果是**分数恒为 0、脑一次都不拍翅,而且不报任何错**。 现在已经反过来 —— 传了不含关键群的资产会在**启动时被直接拒绝**, 并且说清是哪一种问题(群缺失 / 预烤坐标索引越界)。 本机实测:启动 **4.7 s**、内存约 **500 MB**、服务端仿真线程**持续占一个 CPU 核** (固定 50 Hz 跑脑 + 物理);回退到 `< 5%` 占用。 状态端点是 `GET /api/game/state`(前端每帧取,约 **400 字节**); `/api/step` 仍然保留,用于「一次性推进 N 个 tick」的离线式调用, 但**页面已经不再用它**(页面走 `/api/game/state`)。 --- ## 数据来源 | 项 | 值 | |---|---| | 数据源 | **FlyWire FAFB v783**(`codex.flywire.ai`) | | 神经元 | 144,837 | | 突触 | 15,023,799(兴奋 11,043,448 / 抑制 3,152,462 / 未知符号 827,889) | | 平均出度 | 103.7(最大 9,773) | | 游离神经元 | 6,963(无突触) | --- ## 已知问题 / 下一步 - [ ] **把仿真搬到服务端** —— 页面 < 评测台的唯一正解 - [ ] 页面主循环抗性不足:渲染慢时物理步进会被拖住(应改成物理与脑完全解耦) - [x] ~~`scripts/flappy_page_parity.py` 需要重写~~ → 已删除,职责移交 `scripts/verify_server_physics.py` - [ ] 前瞻 / 预测控制器尚未成功 ## 许可 暂无 LICENSE(默认保留所有权利)。