Under Fog, En Passant Is Part of What You Can See
Misty plays fog of war chess by holding P, the set of every board consistent with what it has observed. P is stored as FEN strings, and one field of a FEN is written by a rule rather than by the position.
| Symptom | P fell from 625 boards to 41 in one ply, and the true board was not among the 41 |
| Impact | Misty finished that game drawing search roots from 41 worlds, none of them real |
| Cause | The en passant square recorded under standard-chess legality, in a variant with no king-safety rule |
| Fix | Record it on the pseudo-legal rule (EnPassantMode.XFEN), through one shared function |
| Frequency | 1 ply in 12,866, across 273 finished games |
| Detection | None. Found four months later while measuring belief sizes for something else |
The bug
Game e751c6c4, Misty as Black, move 29. White pushes the g-pawn two squares, beside Black’s pawn on f4. White’s queen on d6 attacks the black king.
Black’s f4 pawn can take on g3 en passant. Standard chess forbids it: Black is in check, and the capture does not address the check. Fog of war chess has no such rule. You cannot see the attack on your king, so you may leave it there, and the game ends on a captured king rather than at mate. f4xg3 is an ordinary move here.
Vision under fog is the set of squares your own pieces can move to. A pawn that can capture en passant sees the landing square, and sees the pawn it would take. The en passant square is therefore a fact about perception, not only about legality.
python-chess writes that field with EnPassantMode.LEGAL by default, recording the square only when the capture is legal by standard rules. So it wrote -:
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
The belief update rebuilds positions from those strings. Rebuilt from the stored one, the true position showed Black twelve squares instead of fourteen, no longer matched the observation it had itself produced, and dropped out of P.
The fix
Record the square when a pawn can take, regardless of whether taking is wise. That is EnPassantMode.XFEN, and it is the fog rule. It still omits the square when no enemy pawn is adjacent, so the belief does not split over a square nobody can use.
Both writers now go through one named function instead of each calling a serializer with its own defaults. The membership check takes a board and canonicalizes it, since passing a raw board.fen() was the shape of the original mistake.
At the failing ply P now goes 625 to 617, truth kept. Replaying all 271 games reports no lost-truth decisions outside the sampled games, against four before.
Why nothing caught it
The tripwire fires on the wrong extreme. The enumerator raises when P goes empty, which needs a belief so small that no candidate survives. Losing the truth from a set of 41 is silent, and has to be: in play the engine never knows the truth, so it has nothing to check against.
The differential test passed because both sides were wrong. Misty’s hot path is a Rust extension carrying a function written to reproduce python-chess byte for byte. It succeeded. The test comparing them across 178,145 move applications stayed green, comparing two encoders that had made the same mistake.
Replay could have seen it and was not looking. The analysis pass records whether the truth is in P, but that column was only read on games past the sampling cap, where losing the truth is expected. On uncapped games it has no innocent explanation. Nobody had run that query.
What generalizes
A serializer from a standard-rules library is not a neutral encoder. It writes that library’s judgments into the bytes, and those judgments are rules. Use its output as a key and you have imported a rulebook you never read.
Two implementations agreeing tells you they agree. Mine agreed for months, and the agreement hid the bug: whenever I wondered whether the Rust port had drifted, a green test said no. True, and not the question.
A belief that loses the truth gets sharper, not vaguer. 41 worlds is a more confident belief than the 617 that survive once the square is recorded correctly. The failure does not look like uncertainty from the inside.
Fixed in the engine on 2026-09-10; it ships in the next Misty release. Every board above is drawn by the engine’s own visibility code from that position; the script that draws them refuses to run if the two views stop differing by exactly g3 and g4.