雾战象棋里,吃过路兵也是你能看见的一部分
Misty 下雾战国际象棋的方式,是维护 P,即与它观测到的一切都相容的全部棋盘的集合。P 以 FEN 字符串存储,而 FEN 里有一个字段不是由局面决定的,而是由一条规则写出来的。
| 症状 | P 在一着之内从 625 个棋盘降到 41 个,而真实棋盘不在这 41 个之中 |
| 影响 | Misty 在那盘棋余下的部分里,从 41 个世界中抽取搜索根节点,其中没有一个是真的 |
| 原因 | 吃过路兵格按标准国际象棋的合法性记录,而这个变体没有王的安全规则 |
| 修复 | 按伪合法规则(EnPassantMode.XFEN)记录它,并且只经由一个共享函数 |
| 频率 | 273 盘已完成的对局、12,866 着之中出现 1 着 |
| 检测 | 没有。四个月后,在为别的目的测量信念集合大小时才发现 |
这个 bug
对局 e751c6c4,Misty 执黑,第 29 回合。白方把 g 兵推进两格,停在黑方 f4 兵旁边。白后在 d6 攻击黑王。
黑方的 f4 兵可以在 g3 吃过路兵。标准国际象棋禁止这样做:黑方正被将军,而这一吃并不应将。雾战国际象棋没有这条规则。你看不见对你王的攻击,所以可以把王留在那里,对局以王被吃掉而不是以将死结束。在这里 f4xg3 是一步普通的着法。
雾战中的视野,是你自己的棋子能走到的格子的集合。一个能吃过路兵的兵看得见落点格,也看得见它将要吃掉的那个兵。因此,吃过路兵格是一个关于感知的事实,而不只是关于合法性的事实。
python-chess 默认用 EnPassantMode.LEGAL 写这个字段,只在按标准规则这一吃合法时才记录该格。于是它写下了 -:
stored 5k2/2Q4p/P2Qp3/4N3/5pP1/B7/4PP1P/RN2KB1R b KQ - 0 29
needed 5k2/2Q4p/P2Qp3/4N3/5pP1/B7/4PP1P/RN2KB1R b KQ g3 0 29
信念更新是从这些字符串重建局面的。从存储的字符串重建出来后,真实局面给黑方的视野是十二格而不是十四格,不再与它自己产生的那次观测相符,于是从 P 中掉了出去。
修复
只要有兵能吃,就记录这个格,不管吃是否明智。这就是 EnPassantMode.XFEN,也正是雾战的规则。没有敌兵相邻时它仍然省略该格,这样信念集合不会因为一个谁也用不上的格而分裂。
现在两个写入方都经过同一个具名函数,而不是各自调用一个带着自己默认值的序列化器。成员检查接收一个棋盘并将其规范化,因为直接传入原始的 board.fen() 正是最初那个错误的形态。
在出问题的那一着,P 现在从 625 变为 617,真实局面得以保留。重放全部 271 盘对局,在抽样对局之外没有报告任何丢失真实局面的决策,此前是四次。
为什么没有任何东西抓住它
绊线装在了错误的极端上。 枚举器在 P 变空时抛出异常,这要求信念集合小到没有任何候选存活。从 41 个里丢掉真实局面是无声的,也只能是无声的:对局中引擎从不知道真实局面,所以没有东西可供核对。
差分测试通过了,因为两边错得一样。 Misty 的热路径是一个 Rust 扩展,其中有一个函数是为了逐字节复现 python-chess 而写的。它做到了。跨越 178,145 次着法应用比较两者的测试一直是绿的,比较的是两个犯了同一个错误的编码器。
重放本可以看见它,只是没在看。 分析流程会记录真实局面是否在 P 中,但那一列只在超过抽样上限的对局上被读过,而在那些对局里丢失真实局面是预期之内的。在未超上限的对局上,它没有任何无害的解释。没人跑过那个查询。
可以推广的结论
来自标准规则库的序列化器不是中立的编码器。 它把那个库的判断写进字节里,而那些判断就是规则。把它的输出当作键来用,你就引入了一本从未读过的规则书。
两个实现一致,只能告诉你它们一致。 我的两个实现一致了几个月,而这种一致掩盖了 bug:每当我怀疑 Rust 移植是否已经偏离,一个绿色的测试都说没有。没错,但那不是问题所在。
丢失了真实局面的信念会变得更尖锐,而不是更模糊。 41 个世界比正确记录该格后存活下来的 617 个世界是一个更自信的信念。从内部看,这种失败并不像不确定性。
已于 2026-09-10 在引擎中修复,将随下一个 Misty 版本发布。上文的每个棋盘都由引擎自己的视野代码从该局面绘制;绘制脚本在两个视图不再恰好相差 g3 和 g4 时会拒绝运行。