Misty recorded the en passant square by standard chess rules
python-chess writes the en passant field with EnPassantMode.LEGAL by default: it records the square only when the capture is legal in standard chess, and that includes not leaving the capturing side's king in check. Misty's Rust hot path copied the same filter on purpose, so its strings would match python-chess byte for byte.
So the square went missing in one kind of position: a pawn has just moved two squares to stand beside an enemy pawn, and taking it en passant would leave the capturer's king attacked, as it does when that king is already in check. With the square gone, the rebuilt position no longer sees the landing square or the pawn it could take, so the real board stops matching what Misty observed and drops out of P. The trigger came up 1 ply in 12,866, in 1 of 273 finished games.
One game lost the real board at move 29
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 is in check, so standard chess forbids f4xg3 en passant. Under fog it is an ordinary move, and it decides what Black sees.
Under the standard rule the square was dropped:
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 29Misty built the position after 29.g4 without the square, counted 12 visible squares for Black, compared them with the 14 in the real observation, and threw out the true move. P fell from 625 boards to 41 in that ply, and the real board was not among the 41. Misty played its last four decisions of the game drawing search roots from those 41.
Move the black king one square over, where nothing attacks it, and change nothing else: the square is recorded as g3 and the real board stays in P.
The fix records the square whenever a pawn can take
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.
The Rust path now checks only that an enemy pawn stands beside the pushed one. On the Python side every board written into P goes through one function, belief_fen, and the membership check takes a board and spells it the same way, since passing a raw board.fen() was how the mistake got in.
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 enumerator raises only when P goes empty, and losing the truth from a set of 41 is silent, as it has to be: in play the engine never knows the truth. The test comparing the Rust port with python-chess stayed green across 178,145 move applications, because both made the same mistake. Replay 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.
Fixed in the engine on 2026-09-10 and released in Misty 0.1.3. 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.