|
| 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