简而言之

  • Misty 会跟踪所有与它所观察到的信息相符的棋盘,以 FEN 字符串存储。在一局棋中,它是对着41个棋盘下完的,而其中没有一个是真实棋盘。
  • python-chess 只在这步吃子在标准象棋中合法时才写出吃过路兵格。在迷雾规则下,即使被将军这步吃子也合法,而能吃过路兵的兵看得见那个格子,于是真实局面不再与 Misty 观察到的信息相符。
  • 修正后,只要有兵能吃就记录该格。重放全部271局,抽样局面之外丢失真相的情形由四例降为零。
  • 没有任何机制发现它:Rust 移植版逐字节照搬 python-chess,所以对比两者的测试通过了;而一个丢失真相的信念只会变得更自信,不会变得更不确定。

Misty 下战争迷雾国际象棋的方式是维护 P,即所有与它观察到的信息相符的棋盘集合。P 以 FEN 字符串存储,而 FEN 中有一个字段是由规则写出的,而不是由局面写出的。

   
症状 P 在一着之内从625个棋盘降到41个,而真实棋盘不在这41个之中
影响 Misty 下完了那局棋,搜索根节点都是从41个世界中抽取的,没有一个是真实的
原因 在一个没有王安全规则的变体中,按标准象棋的合法性记录吃过路兵格
修正 按伪合法规则(EnPassantMode.XFEN)记录,且统一经过一个共享函数
频率 273局已完成对局中,12,866着里出现1着
发现方式 无。四个月后在为别的事情测量信念集合大小时才发现

这个缺陷

对局 e751c6c4,Misty 执黑,第29回合。白方把 g 兵推进两格,落在黑方 f4 兵的旁边。白方位于 d6 的后正攻击着黑王。

. . . . . k . .
. . Q . . . . p
P . . Q p . . .
. . . . N . . .
. . . . . p P .
B . . . . . . .
. . . . P P . P
R N . . K B . R
After 29.g4, from Black's side. Tinted: White's pawn on g4, Black's pawn on f4, and the empty square g3 between them. The arrow is White's queen attacking the black king.

黑方 f4 的兵可以在 g3 吃过路兵。标准象棋禁止这样走:黑方正被将军,而这步吃子并不解将。战争迷雾国际象棋没有这条规则。你看不见对你王的攻击,所以你可以放着不管,而且对局是以王被吃掉而不是以将死结束的。在这里 f4xg3 是一步普通的着法。

迷雾规则下的视野,就是自己棋子能走到的格子集合。能吃过路兵的兵看得见落点格,也看得见它将要吃掉的那个兵。因此吃过路兵格是关于感知的事实,而不只是关于合法性的事实。

. . . . . k . .
. . . . . . . p
. . . . p . . .
. . . . . . . .
. . . . . p P .
. . . . . . . .
. . . . . . . .
. . . . . . . .
With the en passant square. 14 squares. Black sees g3, and sees the pawn on g4.
. . . . . k . .
. . . . . . . p
. . . . p . . .
. . . . . . . .
. . . . . p . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
Without it. 12 squares. g3 and g4 go dark. Same pieces, same board.

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 中,但那一列只在超过抽样上限的对局上被读取过,而在那种情况下丢失真相是预期行为。在未触及上限的对局里,它没有任何无害的解释。没人跑过那条查询。

可以推广的结论

来自标准规则库的序列化器不是中立的编码器。 它会把那个库的判断写进字节里,而那些判断就是规则。把它的输出当作键来用,你就引入了一本自己从未读过的规则书。

两个实现一致,只说明它们一致。 我的两个实现一致了好几个月,而这种一致恰恰掩盖了缺陷:每当我怀疑 Rust 移植版是否已经偏离,一个绿色的测试就说没有。这是真的,但答的不是我问的问题。

丢失真相的信念会变得更锐利,而不是更模糊。 41个世界比正确记录该格后存活下来的617个更自信。从内部看,这种失败并不像是不确定性。

已于2026-09-10在引擎中修正,并随 Misty 0.1.3 发布。上面每一张棋盘都是引擎自己的可见性代码从该局面绘制的;绘图脚本一旦发现两个视图的差异不再恰好是 g3 和 g4,就会拒绝运行。