迷霧西洋棋裡,吃過路兵也是你看得見的一部分
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 時會拒絕執行。