Skip to content

Commit cc4e6a8

Browse files
committed
Prevent duplicate move history corruption
1 parent 2e76ca1 commit cc4e6a8

10 files changed

Lines changed: 833 additions & 56 deletions
Lines changed: 396 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,396 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import asyncio
5+
from dataclasses import dataclass
6+
from typing import Any
7+
8+
import pyffish as sf
9+
from catalogued_variants import ensure_catalogued_variant_from_game_doc
10+
from compress import R2C
11+
from const import CASUAL, INVALIDMOVE, STARTED
12+
from convert import mirror5, mirror9, zero2grand
13+
from fairy import FairyBoard
14+
from fairy.jieqi import make_initial_mapping
15+
from pymongo import AsyncMongoClient
16+
from settings import MONGO_DB_NAME, MONGO_HOST
17+
from variants import C2V, GRANDS, TWO_BOARD_VARIANT_CODES, get_server_variant
18+
19+
20+
class UnsafeRepair(RuntimeError):
21+
"""A suspicious game cannot be repaired without guessing."""
22+
23+
24+
class _CataloguedVariantState:
25+
def __init__(self) -> None:
26+
self.catalogued_variants: dict[str, dict[str, Any]] = {}
27+
28+
29+
CATALOGUED_VARIANT_STATE = _CataloguedVariantState()
30+
31+
32+
@dataclass(frozen=True)
33+
class RepairPlan:
34+
game_id: str
35+
original_moves: list[str]
36+
repaired_moves: list[str]
37+
removed_indexes: tuple[int, ...]
38+
original_fen: str
39+
original_status: int
40+
set_fields: dict[str, Any]
41+
can_reopen_correspondence: bool
42+
43+
44+
def _has_adjacent_duplicate(moves: list[str]) -> bool:
45+
return any(move == moves[index - 1] for index, move in enumerate(moves[1:], start=1))
46+
47+
48+
def _ensure_variant_available(doc: dict[str, Any]) -> None:
49+
code = str(doc.get("v") or "")
50+
if code in C2V:
51+
return
52+
ini = doc.get("vini")
53+
if not isinstance(ini, str) or not ini:
54+
raise UnsafeRepair(f"unknown variant code {code!r} without inline variant rules")
55+
sf.load_variant_config(ini)
56+
ensure_catalogued_variant_from_game_doc(CATALOGUED_VARIANT_STATE, doc)
57+
58+
59+
def _decode_position(doc: dict[str, Any]) -> tuple[str, str, bool, list[str]]:
60+
_ensure_variant_available(doc)
61+
variant = C2V[str(doc["v"])]
62+
chess960 = bool(doc.get("z"))
63+
initial_fen = doc.get("if")
64+
65+
usi_format = variant.endswith("shogi") and doc.get("uci") is None
66+
if usi_format and isinstance(initial_fen, str):
67+
parts = initial_fen.split()
68+
if len(parts) > 3 and parts[1] in "wb":
69+
pockets = f"[{parts[2]}]" if parts[2] not in "-0" else ""
70+
initial_fen = (
71+
parts[0] + pockets + (" w" if parts[1] == "b" else " b") + " 0 " + parts[3]
72+
)
73+
else:
74+
initial_fen = parts[0] + (" w" if parts[1] == "b" else " b") + " 0"
75+
76+
server_variant = get_server_variant(variant, chess960)
77+
moves = [server_variant.move_decoding(move) for move in doc["m"]]
78+
if usi_format and variant in ("shogi", "shoshogi"):
79+
moves = [mirror9(move) for move in moves]
80+
elif usi_format and variant in ("minishogi", "kyotoshogi"):
81+
moves = [mirror5(move) for move in moves]
82+
elif variant in GRANDS:
83+
moves = [zero2grand(move) for move in moves]
84+
85+
return variant, initial_fen or "", chess960, moves
86+
87+
88+
def _repair_parallel_array(
89+
doc: dict[str, Any],
90+
field: str,
91+
*,
92+
original_length: int,
93+
repaired_length: int,
94+
removed_indexes: tuple[int, ...],
95+
offset: int = 0,
96+
) -> list[Any] | None:
97+
values = doc.get(field)
98+
if values is None:
99+
return None
100+
if not isinstance(values, list):
101+
raise UnsafeRepair(f"{field} is not an array")
102+
if not values:
103+
return values
104+
105+
expected_original = original_length + offset
106+
expected_repaired = repaired_length + offset
107+
if len(values) == expected_repaired:
108+
return values
109+
if len(values) != expected_original:
110+
raise UnsafeRepair(
111+
f"{field} length {len(values)} matches neither original history "
112+
f"{expected_original} nor repaired history {expected_repaired}"
113+
)
114+
115+
removed = {index + offset for index in removed_indexes}
116+
return [value for index, value in enumerate(values) if index not in removed]
117+
118+
119+
def build_repair_plan(doc: dict[str, Any]) -> RepairPlan | None:
120+
if int(doc.get("y", -1)) != int(CASUAL):
121+
return None
122+
users = doc.get("us")
123+
if not isinstance(users, list) or len(users) != 2:
124+
return None
125+
if doc.get("v") in TWO_BOARD_VARIANT_CODES:
126+
return None
127+
128+
raw_moves = doc.get("m")
129+
if not isinstance(raw_moves, list) or len(raw_moves) < 2:
130+
return None
131+
if not all(isinstance(move, str) for move in raw_moves):
132+
raise UnsafeRepair("move history contains a non-string value")
133+
if not _has_adjacent_duplicate(raw_moves):
134+
return None
135+
if doc.get("mct"):
136+
raise UnsafeRepair("manual-count intervals require game-specific reconstruction")
137+
138+
variant, initial_fen, chess960, decoded_moves = _decode_position(doc)
139+
server_variant = get_server_variant(variant, chess960)
140+
board = FairyBoard(
141+
variant,
142+
initial_fen,
143+
chess960,
144+
show_promoted=server_variant.show_promoted,
145+
legal_moves_need_history=server_variant.legal_moves_need_history,
146+
)
147+
if variant == "jieqi":
148+
black_pieces = doc.get("bj")
149+
white_pieces = doc.get("wj")
150+
if not isinstance(black_pieces, list) or not isinstance(white_pieces, list):
151+
raise UnsafeRepair("Jieqi history is missing its covered-piece mapping")
152+
board.jieqi_covered_pieces = make_initial_mapping(black_pieces, white_pieces)
153+
154+
removed_indexes: list[int] = []
155+
for index, move in enumerate(decoded_moves):
156+
if not board.push(move, raise_on_error=False):
157+
if index == 0 or raw_moves[index] != raw_moves[index - 1]:
158+
raise UnsafeRepair(
159+
f"first invalid move is not an adjacent duplicate at ply {index + 1}: {move}"
160+
)
161+
removed_indexes.append(index)
162+
163+
if not removed_indexes:
164+
return None
165+
if board.fen != doc.get("f"):
166+
raise UnsafeRepair("repaired history does not reproduce the stored final FEN")
167+
168+
removed_tuple = tuple(removed_indexes)
169+
removed_set = set(removed_tuple)
170+
repaired_moves = [move for index, move in enumerate(raw_moves) if index not in removed_set]
171+
set_fields: dict[str, Any] = {
172+
"m": repaired_moves,
173+
"p": len(repaired_moves),
174+
}
175+
176+
raw_byost = doc.get("byost")
177+
if isinstance(raw_byost, list) and len(raw_byost) == len(repaired_moves) and raw_byost:
178+
# This shape means a later move reached the board but failed before its
179+
# byoyomi snapshot was appended (the HEbciizV cascade). Keeping the
180+
# existing array is safe only when the affected tail did not change.
181+
tail_start = max(0, min(removed_tuple) - 1)
182+
if any(state != raw_byost[tail_start] for state in raw_byost[tail_start:]):
183+
raise UnsafeRepair(
184+
"byost omits a post-duplicate snapshot and its affected states differ"
185+
)
186+
187+
repaired_byost = _repair_parallel_array(
188+
doc,
189+
"byost",
190+
original_length=len(raw_moves),
191+
repaired_length=len(repaired_moves),
192+
removed_indexes=removed_tuple,
193+
)
194+
if repaired_byost is not None:
195+
set_fields["byost"] = repaired_byost
196+
197+
repaired_analysis = _repair_parallel_array(
198+
doc,
199+
"a",
200+
original_length=len(raw_moves),
201+
repaired_length=len(repaired_moves),
202+
removed_indexes=removed_tuple,
203+
offset=1,
204+
)
205+
if repaired_analysis is not None:
206+
set_fields["a"] = repaired_analysis
207+
208+
status = int(doc.get("s", STARTED))
209+
can_reopen_correspondence = (
210+
doc.get("c") is True
211+
and status == int(INVALIDMOVE)
212+
and not any(doc.get(field) for field in ("tid", "aid", "sid"))
213+
)
214+
return RepairPlan(
215+
game_id=str(doc["_id"]),
216+
original_moves=raw_moves,
217+
repaired_moves=repaired_moves,
218+
removed_indexes=removed_tuple,
219+
original_fen=str(doc["f"]),
220+
original_status=status,
221+
set_fields=set_fields,
222+
can_reopen_correspondence=can_reopen_correspondence,
223+
)
224+
225+
226+
async def apply_repair_plan(
227+
collection: Any,
228+
plan: RepairPlan,
229+
*,
230+
reopen_correspondence: bool = False,
231+
) -> bool:
232+
set_fields = dict(plan.set_fields)
233+
if reopen_correspondence:
234+
if not plan.can_reopen_correspondence:
235+
raise UnsafeRepair(f"game {plan.game_id} is not safe to reopen")
236+
set_fields.update({"s": int(STARTED), "r": R2C["*"]})
237+
238+
result = await collection.update_one(
239+
{
240+
"_id": plan.game_id,
241+
"y": int(CASUAL),
242+
"m": plan.original_moves,
243+
"f": plan.original_fen,
244+
"s": plan.original_status,
245+
},
246+
{"$set": set_fields},
247+
)
248+
return result.modified_count == 1
249+
250+
251+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
252+
parser = argparse.ArgumentParser(
253+
description=(
254+
"Repair casual single-board games containing an accidentally duplicated move. "
255+
"A repair is accepted only when skipping engine-rejected adjacent duplicates "
256+
"reproduces the stored final FEN. Dry-run by default. Run with PYTHONPATH=server."
257+
)
258+
)
259+
parser.add_argument("--mongo-host", default=MONGO_HOST)
260+
parser.add_argument("--mongo-db", default=MONGO_DB_NAME)
261+
parser.add_argument(
262+
"--game-id",
263+
action="append",
264+
default=[],
265+
help="Only inspect this game id; repeat for multiple games. The default scans all casual games.",
266+
)
267+
parser.add_argument(
268+
"--limit", type=int, default=0, help="Maximum documents to scan; 0 is unlimited."
269+
)
270+
parser.add_argument(
271+
"--progress-every",
272+
type=int,
273+
default=250_000,
274+
help="Print progress every N scanned games; 0 disables progress output.",
275+
)
276+
parser.add_argument("--apply", action="store_true", help="Write safe repairs to MongoDB.")
277+
parser.add_argument(
278+
"--reopen-correspondence-invalid",
279+
action="store_true",
280+
help=(
281+
"With --apply, reopen repaired correspondence games whose only terminal status "
282+
"is INVALIDMOVE. Tournament, arrangement, and simul games are never reopened."
283+
),
284+
)
285+
args = parser.parse_args(argv)
286+
if args.limit < 0:
287+
parser.error("--limit must be >= 0")
288+
if args.progress_every < 0:
289+
parser.error("--progress-every must be >= 0")
290+
if args.reopen_correspondence_invalid and not args.apply:
291+
parser.error("--reopen-correspondence-invalid requires --apply")
292+
return args
293+
294+
295+
async def main() -> None:
296+
args = parse_args()
297+
client = AsyncMongoClient(args.mongo_host, tz_aware=True)
298+
collection = client[args.mongo_db].game
299+
300+
query: dict[str, Any] = {
301+
"y": int(CASUAL),
302+
"us.2": {"$exists": False},
303+
"m.1": {"$exists": True},
304+
}
305+
if args.game_id:
306+
query["_id"] = {"$in": list(dict.fromkeys(args.game_id))}
307+
308+
scanned = 0
309+
suspicious = 0
310+
candidates = 0
311+
modified = 0
312+
unsafe = 0
313+
314+
try:
315+
cursor = collection.find(query).sort("d", 1)
316+
if args.limit:
317+
cursor = cursor.limit(args.limit)
318+
319+
async for doc in cursor:
320+
scanned += 1
321+
if args.progress_every and scanned % args.progress_every == 0:
322+
print(
323+
"PROGRESS scanned=%d suspicious=%d candidates=%d unsafe=%d"
324+
% (scanned, suspicious, candidates, unsafe),
325+
flush=True,
326+
)
327+
raw_moves = doc.get("m")
328+
if not isinstance(raw_moves, list) or not _has_adjacent_duplicate(raw_moves):
329+
continue
330+
suspicious += 1
331+
332+
try:
333+
plan = build_repair_plan(doc)
334+
except (KeyError, RuntimeError, SystemError, TypeError, ValueError) as exc:
335+
unsafe += 1
336+
print(f"UNSAFE id={doc.get('_id')} reason={exc}", flush=True)
337+
continue
338+
if plan is None:
339+
continue
340+
341+
candidates += 1
342+
removed_plies = ",".join(str(index + 1) for index in plan.removed_indexes)
343+
reopen = args.reopen_correspondence_invalid and plan.can_reopen_correspondence
344+
print(
345+
"CANDIDATE id=%s removed_plies=%s moves=%d->%d status=%s reopen=%s"
346+
% (
347+
plan.game_id,
348+
removed_plies,
349+
len(plan.original_moves),
350+
len(plan.repaired_moves),
351+
plan.original_status,
352+
reopen,
353+
),
354+
flush=True,
355+
)
356+
357+
if not args.apply:
358+
continue
359+
360+
if await apply_repair_plan(
361+
collection,
362+
plan,
363+
reopen_correspondence=reopen,
364+
):
365+
modified += 1
366+
else:
367+
print(
368+
f"STALE id={plan.game_id} changed after inspection; no repair applied",
369+
flush=True,
370+
)
371+
372+
print(
373+
"SUMMARY mode=%s scanned=%d suspicious=%d candidates=%d unsafe=%d modified=%d"
374+
% (
375+
"apply" if args.apply else "dry-run",
376+
scanned,
377+
suspicious,
378+
candidates,
379+
unsafe,
380+
modified,
381+
),
382+
flush=True,
383+
)
384+
if not args.apply:
385+
print("No changes written. Review candidates, then rerun with --apply.", flush=True)
386+
elif modified:
387+
print(
388+
"Restart every running server process so repaired games are reloaded from MongoDB.",
389+
flush=True,
390+
)
391+
finally:
392+
await client.close()
393+
394+
395+
if __name__ == "__main__":
396+
asyncio.run(main())

0 commit comments

Comments
 (0)