多加一粒沙,整堆为什么就塌了?sandpile 模型 20 万粒实测

一张 256×256 的方格阵,每格最多堆 3 粒沙;随机挑一格丢一粒进去,某格攒到 4 粒就不稳,把这格减 4、上下左右各加 1,连锁反应一直做到全网格都小于 4------这一整轮叫一次雪崩 。规则就这一条,跑到 20 万粒之后:平均高度停在 2.1206 (二维 BTW 稳态的文献值 17/8 = 2.125),雪崩规模分布是幂律 ,拟合指数 τ = 1.1376 、R² = 0.9769(文献值约 1.2),而这一路上最大的一次雪崩掀翻了 622 007 个格子。没人调过任何参数,系统自己爬到了那个临界点上------这就是自组织临界性。

不过我先撞上的是个更朴素的坑:页面刚打开时它显示平均高度 2.7738 ,比理论上限还高一截。我把页面里的 grid 数组直接求和,真值是 1.2118------显示的账和真实的账差了一倍。顺藤摸下去是一行 JS:

javascript 复制代码
s ^= s << 13; s >>> 0;   // 交付版原文:>>> 0 是空语句,s 没有被赋成无符号
s ^= s << 13; s >>>= 0;  // 应该是这样,s 才不会带着负号跑出来

少一个等号,随机数就有一半是负数,落沙坐标跟着变成负的,写进 TypedArray 的负下标会被静默丢弃------沙粒消失了,但计数器还在往上加。改掉这一个字符,平均高度立刻回到 2.1212(面板值和 grid 真值这才对上)。下面把这行和另外 4 个问题一起交代清楚。

公开仓库:atomgit.com/deli007/dem...,本案例目录:atomgit.com/deli007/dem...。成品是一个 index.html,双击就能跑,不联网、不引第三方库、没有构建步骤。文中所有读数都是我把交付的页面落到本机、自己重新跑出来的,不是转述对话里的结论。

边界也先讲清楚:这是个教学/演示用的玩具实验室,做的是开边界的 BTW 模型,能看结构、能对文献数量级;但它没做周期性边界,雪崩指数的估计只用了最小二乘、没做 KS 检验和置信区间,所以把 τ 读到小数点后第三位没什么意义。第 12 条自检那个「512×512 每帧 1000 粒还要 30 fps」,我在下面单独交代------它物理上做不到。

一、先看结果:同一份代码,修之前和修之后

同样是 256×256、随机落沙、种子 42、跑到 20 万粒出头:

指标 交付版原样 修完的版本
面板显示的 ⟨h⟩ 2.7738 2.1206
直接对 grid 求和的真值 1.2118 2.1206(两边对上了)
累计倒塌次数 35 370 896 143 232 667
雪崩直方图分箱数 3 771 6 767
τ / 拟合 R² 0.383 / 0.533 1.2005 / 0.9438
页面内 12 条自检 10 绿 2 红(另有 3 条恒真) 12/12 通过

有意思的是这个对比只在改对随机数之后就基本定型了。我把随机数那一行单独修好、页面其余代码原样不动,再跑 20 万粒:平均高度 2.1212,累计倒塌 142 272 874 次,和现在这版一模一样。也就是说交付版的物理内核本身没写错,坏掉的只是「随机数」和「量法」;剩下的都是性能和自检设计的问题。

二、为什么值得看:两条规则,长出一个临界态

沙堆模型是 1987 年 Bak、Tang、Wiesenfeld 拿来说明**自组织临界性(SOC)**的经典玩具。它想解释的现象是:为什么地震、森林火灾、太阳耀斑这些系统的能量释放都是幂律分布,而且没有人去调参数------大自然自己就把系统推到了临界点。

这个模型里,临界点是自己长出来的:加沙是「慢慢攒」,雪崩是「突然放」。格子越高越容易被邻居压垮,所以雪崩会沿着已有的结构扩散;等系统攒到平均高度 2.125 附近,任何一粒沙都可能触发任意大小的连锁------小到 1 次倒塌,大到扫掉半个网格。这就是幂律的来源,也是无特征尺度的来源:分布里没有一个「典型雪崩大小」。

页面里能看到三种很不一样的状态。起步阶段平均高度只有 0.87,雪崩几乎不出现,画面是个安静的小圆(图 2)。把落沙位置从「随机」切成「中心」,30 万粒之后会长出一个方形分形阵------这是 BTW 模型的「identity pattern」,四个角上的结构是 4 重对称的(图 3)。切回随机、跑到临界态,画面变成均匀的 4 色斑点噪声,任何一帧都可能有块亮区在扩散(图 1)。三种状态用同一份代码、同一个滑杆就能切出来,这是我选它而不是「又一个粒子动画」的原因。

三、准备环境:打开码道 Web

码道有三种使用方式:WebUI(浏览器对话)、TUI(终端命令行)和桌面 IDE(IDE 插件)。本文用 WebUI 版演示,浏览器打开码道 Web 版:devcloud.cn-north-4.huaweicloud.com/chat?source...,登录后就能在对话窗口里输入需求,不需要装软件。

四、我给码道的需求(可复制)

这次我把验收标准也写进了需求里:要自检面板、要实测值和理论值并排、要写清楚失败路径。原文如下(可以直接复制到码道 Web 的对话框里):

text 复制代码
请做一个单文件网页应用 index.html:二维「阿贝尔沙堆模型(Abelian sandpile,也叫 BTW 模型)」自组织临界性可视化实验室。暗色科技风主题,整页全屏自适应,用 Canvas 绘制,界面只占一屏,参数面板放在右侧窄栏。

核心模型:N x N 的格子(N 可选 128 / 256 / 512),每格记录一个整数沙粒高度 h。不断往格子里加沙:当某格 h 大于等于 4 时它就不稳定,立刻倾泻一次 ------ 该格减 4,上下左右四个邻居各加 1(跑到边界外的沙粒直接丢弃,也就是开边界)。倾泻会连锁触发,直到整个网格所有格子都小于 4,这一整轮连锁反应叫一次「雪崩」。

必须有的交互控件:网格尺寸 N、每帧加沙粒数(1~2000)、加沙位置(中心 / 随机)、模拟速度、随机种子(整数)、开始 / 暂停 / 单步(加一粒沙)/ 重置。

显示要求:用颜色映射把每格高度画出来(0 / 1 / 2 / 3 四种颜色),本轮雪崩里被倾泻过的格子在当帧高亮闪烁,让人肉眼看得到雪崩的形状和大小。

实时统计面板(显示实测值):累计加沙粒数(要能连续跑到 20 万粒以上)、累计倾泻次数 topplings、最近一次雪崩大小 s、历史最大雪崩 s_max、当前平均高度 <h>、已运行时间、FPS。

对照面板(实测 vs 理论并排显示,各给一行):平均高度 <h> 实测值 vs 二维 BTW 稳态平均高度文献值约 2.125;雪崩大小分布幂律指数 tau 实测 vs 文献值约 1.2(给出拟合的 R^2)。雪崩大小分布画成 log-log 直方图,对尾部做幂律拟合给出 tau。

页面内必须有「自检」面板,把下面每条断言实时渲染成一行,显示 通过/失败 和实测数值:
1) 静止(无进行中的雪崩)时,所有格子的 h 都小于 4;
2) 每次加一粒沙都至少触发 1 次倾泻,不会出现「加了没反应」;
3) 质量守恒:累计加沙粒数 = 网格内沙粒总数 + 丢出边界的沙粒数(误差 0);
4) 网格内沙粒总数在每次雪崩结束后单调不减;
5) 累计倾泻次数大于 0 且是整数,随时间单调不减;
6) <h> 收敛到 2.0~2.3 区间(N=256 且加沙 10 万粒以上时);
7) 雪崩大小分布直方图非空,幂律拟合 R^2 大于 0.9;
8) 拟合出的 tau 落在 1.0~1.4 区间;
9) 所有统计量都不是 NaN / Infinity;
10) 同一随机种子、同一加沙序列,重复运行得到的雪崩大小序列完全一致(可复现);
11) 单次雪崩的大小 s 与倾泻次数 topplings 满足 s 小于等于 topplings(守恒检查);
12) N=512、每帧 1000 粒时 FPS 不低于 30。

失败路径:当用户输入非法参数(网格尺寸不在 128/256/512 里、每帧加沙粒数小于 1 或大于 2000、种子填了非整数)时,要在面板上给出明确的中文错误提示(写清是哪个参数不合法),保留上一次的有效参数继续模拟,不能白屏、不能停止运行。

完成后打开预览,让我直接看到运行效果。

五、本地验收踩到的 5 个问题

问题 1:随机数返回负数,一半沙粒被静默丢弃。 就是开头那行 s >>> 0;。空网格上连丢 20 000 粒(不触发任何倒塌),真实落进网格的只有 10 082 粒,而且有一格堆到了 5------因为 x 是负数、y 是正数时算出的下标落在别的行上,沙粒跑到邻居行里去了。改掉这一处之后,20 万粒时面板值和 grid 真值完全一致(都是 139 013),最大高度回到正常范围 3。这个 bug 有个特别唬人的地方:面板上的 12 条自检里它只让 2 条翻红(平均高度、τ),其余 10 条全绿------「质量守恒 Δ=0」甚至是绿的,因为被丢掉的沙粒既没进网格也没被算成边界损耗,两边一起错,账反而平了。

问题 2:幂律拟合把所有原始分箱一视同仁。 交付版的 fitPowerLaw 是把每个雪崩规模(s=1,2,3...一直到几十万)的计数取对数,然后对全部几千个点做一次最小二乘。雪崩规模是重尾分布,大 s 那一侧每个分箱只有 1、2 个样本,噪声把整条直线的斜率拽偏了。实测:数据修好之后,这个拟合给出的 τ = 0.344、R² = 0.509 ------指数直接错到没法看。我改成对数分箱 (14 个几何分箱,每箱计数 ≥5 才参与拟合)+ 固定拟合窗口 s ∈ 2, 30 000,并且按「几何分箱里 log(计数) 对 log(s) 的斜率 = 1 − τ」来取值。同一份数据:τ = 1.2005、R² = 0.9438。窗口上限取 3 万不是随手写的:窗口开到 20 万,τ 会掉到 1.14 上下,那是有限尺寸截断在往下压,不是真实的指数。

问题 3:三条断言是恒真的,等于没有自检。 交付版的第 2 条写的是 avalancheCount > 0(开了就有雪崩,永远成立);第 4 条直接 pass: true;第 11 条也是 pass: true。这三条永远不会失败,属于装饰。而且第 2 条本身是个伪命题:随机落沙时大部分沙粒并不会立刻触发倒塌(临界态下也只有约 3/8 的概率踩到高度 3 的格子),「加了没反应」是正常的。我把它们换成可证伪的版本:第 2 条改成逐格扫描「有没有残留的失稳格 + 队列是否清空」,第 4 条改成「网格总高落在 [0, 4N²)」的物理范围,第 11 条改成「最近一次雪崩 s ≤ 历史最大 s_max ≤ 累计倒塌次数」。

问题 4:第 6 条断言的阈值把「还没到稳态」当成了「物理错了」。 需求里写的是「N=256 且加沙 10 万粒以上时 ⟨h⟩ 落在 2.0~2.3」。但 256×256 一共 65 536 格,10 万粒只有 1.5 粒/格 ,实测平均高度才 1.52------离稳态还差得远,这条断言在 10 万到 18 万粒之间必然翻红。同一个逻辑放到 512 网格上更离谱:3 粒/格 要 78 万粒。我把它改成按密度判定(≥3 粒/格 才算数,不够就标注「未到稳态」而不是判失败),并且把当前密度一起显示在断言后面。

问题 5:每粒沙都全表扫描一次 N²,512 网格光扫描就吃掉一帧。 交付版的 toppleAll 每次调用都要遍历整个网格找失稳格,而且每次调用还 new Uint8Array(N*N) 分配一个 64 KB 的数组。8192 页的 512 网格上,我实测一次全表扫描是 0.224 ms ,每帧 1000 粒光扫描就 ≥ 224 ms。我把它改成增量队列 + 一圈「汇」边界 :只在加沙和倒塌时才把一个格子压进队列,队列里只放可能失稳的格子,同时把网格四周包一圈边界格充当「边界外的邻居」,这样连下标除余和 4 个越界判断都省了(每个倒塌只做 4 次查表加法)。同一台机器、同一份核心逻辑抽到 Node 里跑 20 万粒:交付版 17.7 秒 ,队列版 7.6 秒 。浏览器里跑的是队列版,实测吞吐落在 2.2×10⁷ ~ 3.9×10⁷ 次倒塌/秒(四次抽查:38.7 / 31.0 / 39.0 / 22.0 M)。

六、验证与复现:12 条自检 + 512 网格的真实代价

修完之后页面里的自检面板是这样(这是 256×256、约 20 万粒时的实测值):

# 断言 实测
1 静止时所有格 h<4 max h=3
2 无残留失稳格(队列已清空) 0 待处理
3 质量守恒 Δ=0 Δ=0
4 网格总高 ∈ [0, 4N²) 138 973
5 累计倒塌 ≥0 且为整数 143 232 667
6 ⟨h⟩∈2.0,2.3(≥3 粒/格) 2.1206(3.06 粒/格)
7 雪崩直方图跨 ≥1.5 个数量级 s_max = 549 831
8 τ∈1.0,1.4(随机落沙) 1.200
9 幂律拟合 R²≥0.9(随机落沙) 0.944
10 统计量有限(无 NaN/∞) ✓
11 s ≤ s_max ≤ 累计倒塌 s=26 911,s_max=622 007
12 落沙内核吞吐 ≥1000 万次倒塌/秒 31.3 M 次/秒(单帧 30.7 ms)

512 网格那一条我得说实话。 需求第 12 条要的是「N=512、每帧 1000 粒,FPS ≥ 30」,这件事在临界态下做不到:

N=512 的实测(8192 页里跑到临界态之后) 读数
进入临界态所需加沙量 ≈78 万粒(3 粒/格)
临界态下每粒沙平均触发倒塌 1 300 ~ 5 000 次(重尾,偶尔一次几十万)
每帧 1000 粒的单帧耗时 146.3 ms(≈7 fps)
出现过的最大一次雪崩 7 263 788 次倒塌
全表扫描版光扫描的固定开销 0.224 ms × 1000 = 224 ms/帧

原因不在实现里,在模型里:临界态下平均雪崩规模本身随系统变大而变大 ,512×512 每加一粒沙平均要处理几千次倒塌,一帧 1000 粒就是几百万次倒塌。所以我没把这条断言硬撑着留着,而是把它换成一条能测、且和规模无关 的断言:内核吞吐 ≥1000 万次倒塌/秒(实测 3.1×10⁷,有 3 倍余量)。想要 512 网格流畅,实际做法是把每帧沙粒数降到 100 以内------页面上这个值本来就可以调。

七、亲手点三下(第三下是失败路径)

第一下:点「开始」,看统计面板的累计沙粒往上跑、雪崩大小 s 在个位到几万之间跳。第二下:把网格切成 512×512,看平均高度慢慢从 1 爬到 2.12 附近,同时 FPS 掉到十几。第三下:把「每帧沙粒」填成 99999 再点开始------页面顶部弹出一条提示,输入框自己还原成上一次的有效值 300,模拟没有停、也没有白屏:

⚠ 每帧沙粒数必须是 1-20000 的整数,已恢复上次有效值

顺带一提,交付版把上限放到了 20000(需求里写的是 2000),我留着没改------上限宽一点方便快速铺满网格,错误提示里的范围也跟着改成了 1-20000。

八、本地复现

单文件应用,不需要装任何东西:

bash 复制代码
git clone https://atomgit.com/deli007/demo_park
cd demo_park/codearts-sandpile-criticality
# 方式一:直接双击 index.html
# 方式二:起个本地静态服务
python -m http.server 8000     # 然后打开 http://localhost:8000/index.html

我自己复核用的操作序列是:打开页面 → 网格 256×256、落沙位置「随机」、种子 42、每帧 300 粒 → 让它跑到 20 万粒以上 → 读面板(⟨h⟩ 2.1206、τ 1.2005、R² 0.9438、12/12 通过)→ 换成 512×512 再跑到 84 万粒 → 读面板(⟨h⟩ 2.1199、τ 1.2041、R² 0.9571、12/12 通过)。两次读数都能复现,换台机器数值会差一点点,但 ⟨h⟩ 都会落在 2.11~2.13 之间。

九、直接下载试玩

仓库地址:atomgit.com/deli007/dem...,本案例目录:atomgit.com/deli007/dem...(index.html + 本文件 + 5 张截图)。想看结论最快的路径:打开页面,把「每帧沙粒」调到 2000,等 10 秒,然后盯平均高度------它卡在 2.12 不动了,但雪崩大小还在个位到几万之间乱跳,那就是临界态。

十、使用码道体会

这次最有价值的经验是把「怎么验收」写进需求。我要求了三条:一是页面里必须有自检面板、把每条断言的实测值渲染出来;二是实测值和理论值(2.125 / 1.2)并排显示;三是非法输入要给出中文提示并保留上一次有效参数。交付拿到手,我不需要凭感觉判断「像不像」,直接看哪几条红了就行------第 1 个问题(随机数为负)就是靠「面板 ⟨h⟩ 和 grid 求和差一倍」这个对照发现的,光看画面根本看不出来(画面照样在动、雪崩照样闪)。

另一条经验是断言要能失败 。码道交付的 12 条自检里有 3 条是恒真的(pass: true),另外几条的阈值也没跟物理量对上(100 万粒的密度问题)。这种「看起来 12 条全在检查」的面板反而比没有更危险,因为它会给人一种已经验过的错觉。我现在提需求时会加一句「每条断言都要能失败,不许出现恒真断言」,这句话比「帮我多加几条检查」有用得多。

至于 AI 写这类模型代码,我的判断是:规则本身它写得很准 ------倾泻、连锁、开边界这些一个没错,20 万粒的倒塌次数和平均高度在我改完随机数之后就完全对上了文献值。真正容易翻车的是三样东西:随机数这种一行里藏一个字符的细节、统计量怎么算(拟合窗口、分箱方式)、以及性能路径(每粒全表扫描还是增量队列)。这三样恰好都是跑起来才看得见的,光读代码很难发现,所以「交付后自己跑一遍并逐条复核统计量」这一步省不掉。

十一、总结

  • 沙堆模型(BTW)用两条规则演示自组织临界性:加沙是攒,雪崩是放,系统自己爬到临界点。
  • 交付版有一个致命的一行 bug(>>> 0 漏写等号),一半沙粒被静默丢弃,面板平均高度 2.7738 而真值只有 1.2118;改掉之后立刻回到 2.1212。
  • 幂律拟合从「全部原始分箱」改成「对数分箱 + 固定窗口」后,τ 从 0.344 修到 1.2005(R² 0.9438),和文献值 1.2 对上了。
  • 落沙内核从「每粒全表扫描 N²」改成「增量队列 + 一圈汇边界」,Node 里 20 万粒从 17.7 s 降到 7.6 s,浏览器实测吞吐 2.2×10⁷ ~ 3.9×10⁷ 次倒塌/秒。
  • 需求里第 12 条(512 网格每帧 1000 粒还要 30 fps)物理上做不到,我换成了可测的吞吐断言并在文中如实说明。
  • 3 条恒真断言换成了可证伪的版本,第 6 条断言改成按密度判定------能失败的检查才算检查。
相关推荐
涛涛ing2 小时前
乱序HTML流正式进入浏览器:前端流式渲染的“框架特权”被终结了
前端
__sjfzllv___2 小时前
在职前端Leader学习/转行 AI Agent -DAY73
前端
用户1733598075372 小时前
纯前端 PDF 压平避坑指南:压平后表单字段变了?
前端·javascript·vue.js
nyaomaru2 小时前
将一个真实的 TypeScript OSS 库从 tsup 迁移到 tsdown
前端·typescript
胡写代码2 小时前
雪花 ID 传到前端就变了个数?我用全局 Long 转 String 一次收口
前端·后端
沐言人生3 小时前
82.4k 星!把十几万行代码变成知识图谱,新人终于不用硬啃了
前端·后端·github
溪语流沙3 小时前
【Web全栈进阶】JWT无状态认证:签发、校验、刷新
前端·git·python·github
在繁华处3 小时前
2.1 上下文:决定 Agent 能力上限的关键
前端·人工智能·microsoft
ndsc_d3 小时前
2026年有哪些好用的AI UI设计工具?主流工具功能和适用场景对比
前端·人工智能·ui·ai·设计师·ai ui·ai ui工具