test1978 commited on
Commit
0cce719
ยท
verified ยท
1 Parent(s): f6ebe5e

Add handler.py

Browse files
Files changed (1) hide show
  1. handler.py +98 -456
handler.py CHANGED
@@ -1,473 +1,115 @@
1
- """Model handler used as a HuggingFace-style Endpoint inside the Agladiator sandbox.
2
-
3
- This single-file handler attempts to load model helper modules shipped with
4
- the model (e.g. `chess_mcvs.py`, `matrix_model.py`, `abc_model.py`), a
5
- zone database referenced in `config_data.json`, and instantiate a searcher
6
- (prefer `MCVSSearcher` then `UCTSearcher`).
7
-
8
- It exposes `EndpointHandler` with a simple `__call__(data: dict) -> dict`
9
- API returning `{"move": "e2e4"}` or `{"error": ..., "move": "0000"}`
10
- on failure. The implementation is defensive and logs all issues instead of
11
- raising.
12
- """
13
- from __future__ import annotations
14
-
15
- import importlib.util
16
  import json
17
- import logging
18
- import os
19
  import random
20
- import sys
21
- import traceback
22
  from pathlib import Path
23
- from typing import Any, Dict, Optional, Tuple
24
-
25
- import numpy as np
26
- import chess
27
-
28
- log = logging.getLogger(__name__)
29
-
30
 
31
- def _safe_load_json(path: Path) -> Dict[str, Any]:
32
- try:
33
- with open(path, encoding="utf-8") as fh:
34
- return json.load(fh)
35
- except Exception:
36
- log.debug("Could not load JSON %s", path, exc_info=True)
37
- return {}
38
-
39
-
40
- class SimpleGame:
41
- """Minimal game wrapper expected by many searcher implementations.
42
-
43
- It exposes a `.board` attribute (a python-chess Board) and a `copy()`
44
- method returning a shallow copy with its own Board instance.
45
- """
46
-
47
- def __init__(self, board: chess.Board):
48
- self.board = board
49
-
50
- def copy(self):
51
- return SimpleGame(self.board.copy())
52
 
 
53
 
54
  class EndpointHandler:
55
- """Official endpoint handler used inside sandboxed model runs.
56
-
57
- Usage: `h = EndpointHandler(path="/model"); h({"inputs": {"fen":..., "player":"w"}})`
58
- """
59
-
60
- def __init__(self, path: str):
61
  self.path = Path(path)
62
- self.loaded_modules: Dict[str, Any] = {}
63
- self.main_module = None
64
- self.zone_db = None
65
- self.searcher = None
66
-
67
- # Tunables from environment
68
- self.device = os.environ.get("DEVICE", "cpu")
69
- try:
70
- self.lambda_zone = float(os.environ.get("LAMBDA_ZONE", "0.0"))
71
- except Exception:
72
- self.lambda_zone = 0.0
73
- try:
74
- self.k_zone = int(os.environ.get("K_ZONE", "5"))
75
- except Exception:
76
- self.k_zone = 5
77
- try:
78
- self.dirichlet_alpha = float(os.environ.get("DIRICHLET_ALPHA", "0.3"))
79
- except Exception:
80
- self.dirichlet_alpha = 0.3
81
-
82
- # Make the model path importable for relative imports
83
- try:
84
- if str(self.path) not in sys.path:
85
- sys.path.insert(0, str(self.path))
86
- except Exception:
87
- log.debug("Could not insert model path into sys.path", exc_info=True)
88
-
89
- # Load config files if present
90
- model_config = _safe_load_json(self.path / "config_model.json")
91
- data_config = _safe_load_json(self.path / "config_data.json")
92
-
93
- # Override env-var tunables with values from config_model.json if present
94
- try:
95
- self.lambda_zone = float(model_config.get("lambda_zone", self.lambda_zone))
96
- except Exception:
97
- pass
98
- try:
99
- self.k_zone = int(model_config.get("k_zone", self.k_zone))
100
- except Exception:
101
- pass
102
- try:
103
- self.time_budget = float(model_config.get("time_per_move", os.environ.get("TIME_PER_MOVE", "1.0")))
104
- except Exception:
105
- self.time_budget = 1.0
106
-
107
- # Import files listed in config_model.json
108
- files = model_config.get("files") or []
109
- for f in files:
110
- try:
111
- mod_name = Path(f).stem
112
- mod_path = self.path / f
113
- if not mod_path.exists():
114
- log.debug("Listed module %s not found at %s", f, mod_path)
115
- continue
116
- spec = importlib.util.spec_from_file_location(mod_name, str(mod_path))
117
- if spec is None or spec.loader is None:
118
- log.warning("Could not create spec for %s", mod_path)
119
- continue
120
- mod = importlib.util.module_from_spec(spec)
121
- sys.modules[mod_name] = mod
122
- spec.loader.exec_module(mod)
123
- self.loaded_modules[mod_name] = mod
124
- log.info("Loaded module %s", mod_name)
125
- except Exception:
126
- log.exception("Failed to import listed module %s", f)
127
-
128
- # Determine main module
129
- main_name_raw = (model_config.get("main") or "").strip()
130
- if main_name_raw:
131
- main_name = Path(main_name_raw).stem
132
- self.main_module = self.loaded_modules.get(main_name) or None
133
-
134
- # If chess_mcvs.py is present in repo root, import it explicitly.
135
- # This MUST happen before zone DB loading so HilbertOrderedZoneDatabase
136
- # is available when we try to instantiate it below.
137
- try:
138
- chess_mcvs_path = self.path / "chess_mcvs.py"
139
- if chess_mcvs_path.exists():
140
- spec = importlib.util.spec_from_file_location("chess_mcvs", str(chess_mcvs_path))
141
- if spec and spec.loader:
142
- cm = importlib.util.module_from_spec(spec)
143
- sys.modules["chess_mcvs"] = cm
144
- spec.loader.exec_module(cm)
145
- self.loaded_modules["chess_mcvs"] = cm
146
- if self.main_module is None:
147
- self.main_module = cm
148
- log.info("Imported chess_mcvs from model path")
149
- except Exception:
150
- log.debug("Could not import chess_mcvs.py", exc_info=True)
151
-
152
- # Resolve HilbertOrderedZoneDatabase class from loaded modules (must exist
153
- # before we can build the zone DB object from a filepath).
154
- _ZoneDBClass = None
155
- cm_mod = self.loaded_modules.get("chess_mcvs")
156
- if cm_mod and hasattr(cm_mod, "HilbertOrderedZoneDatabase"):
157
- _ZoneDBClass = cm_mod.HilbertOrderedZoneDatabase
158
- if _ZoneDBClass is None:
159
- for _mod in self.loaded_modules.values():
160
- if hasattr(_mod, "HilbertOrderedZoneDatabase"):
161
- _ZoneDBClass = _mod.HilbertOrderedZoneDatabase
162
- break
163
-
164
- # Load zone DB if referenced
165
- zone_filename = (
166
- data_config.get("zone_db")
167
- or data_config.get("zone_db_filename")
168
- or model_config.get("zone_db")
169
- or model_config.get("zone_db_filename")
170
- or None
171
- )
172
-
173
- if zone_filename:
174
- candidates = [self.path / zone_filename, self.path / "data" / zone_filename]
175
- loaded = False
176
- for cand in candidates:
177
- if not cand.exists():
178
- continue
179
- try:
180
- if _ZoneDBClass is not None:
181
- # Correct path: let HilbertOrderedZoneDatabase load itself
182
- self.zone_db = _ZoneDBClass(filepath=str(cand))
183
- log.info("Loaded zone DB (HilbertOrderedZoneDatabase) from %s", cand)
184
- else:
185
- # Fallback: raw numpy load (zone guidance won't work but won't crash)
186
- self.zone_db = np.load(str(cand), allow_pickle=True)
187
- log.warning("Loaded zone DB as raw NpzFile (HilbertOrderedZoneDatabase not found); zone guidance disabled")
188
- loaded = True
189
- break
190
- except Exception:
191
- log.debug("Failed to load zone DB from %s", cand, exc_info=True)
192
-
193
- # Try to download from HF_DATA_REPO_ID if not found locally
194
- if not loaded:
195
- hf_repo = os.environ.get("HF_DATA_REPO_ID")
196
- if hf_repo:
197
- try:
198
- from huggingface_hub import hf_hub_download
199
- try:
200
- dl_path = hf_hub_download(repo_id=hf_repo, filename=zone_filename, cache_dir=str(self.path))
201
- except TypeError:
202
- dl_path = hf_hub_download(repo_id=hf_repo, filename=zone_filename)
203
- if Path(dl_path).exists():
204
- try:
205
- if _ZoneDBClass is not None:
206
- self.zone_db = _ZoneDBClass(filepath=str(dl_path))
207
- log.info("Downloaded and loaded zone DB (HilbertOrderedZoneDatabase) from HF repo %s", hf_repo)
208
- else:
209
- self.zone_db = np.load(str(dl_path), allow_pickle=True)
210
- log.warning("Downloaded zone DB as raw NpzFile; zone guidance disabled")
211
- loaded = True
212
- except Exception:
213
- log.debug("Downloaded zone DB but failed to load %s", dl_path, exc_info=True)
214
- except Exception:
215
- log.debug("Could not download zone DB from HF repo", exc_info=True)
216
-
217
- # Initialise a searcher class if available
218
- self._init_searcher()
219
-
220
- def _find_searcher_class(self):
221
- # Prefer MCVSSearcher in chess_mcvs module, then look through loaded modules
222
- candidates = []
223
- # If chess_mcvs loaded explicitly, check it first
224
- cm = self.loaded_modules.get("chess_mcvs")
225
- if cm is not None:
226
- for name in ("MCVSSearcher", "ChessSearcher", "UCTSearcher"):
227
- if hasattr(cm, name):
228
- return getattr(cm, name), cm
229
 
230
- # Otherwise check main module then all loaded modules
231
- if self.main_module is not None:
232
- for name in ("MCVSSearcher", "ChessSearcher", "UCTSearcher"):
233
- if hasattr(self.main_module, name):
234
- return getattr(self.main_module, name), self.main_module
235
-
236
- for mod_name, mod in self.loaded_modules.items():
237
- for name in ("MCVSSearcher", "ChessSearcher", "UCTSearcher"):
238
- if hasattr(mod, name):
239
- return getattr(mod, name), mod
240
-
241
- # Last resort: try to import a module named 'mcvs' or 'searcher' in path
242
- try:
243
- import importlib
244
-
245
- for candidate in ("mcvs", "searcher", "uct_searcher"):
246
- try:
247
- m = importlib.import_module(candidate)
248
- for name in ("MCVSSearcher", "ChessSearcher", "UCTSearcher"):
249
- if hasattr(m, name):
250
- return getattr(m, name), m
251
- except Exception:
252
- continue
253
- except Exception:
254
- pass
255
-
256
- return None, None
257
-
258
- def _instantiate_searcher(self, searcher_cls):
259
- # Try a series of sensible constructor signatures
260
- kwargs_variants = [
261
- {
262
- "policy_net": None,
263
- "value_net": None,
264
- "zone_db": self.zone_db,
265
- "use_nets": False,
266
- "lambda_zone": float(self.lambda_zone),
267
- "k_zone": int(self.k_zone),
268
- "device": self.device,
269
- "dirichlet_alpha": float(self.dirichlet_alpha),
270
- },
271
- {
272
- "policy_net": None,
273
- "value_net": None,
274
- "zone_db": self.zone_db,
275
- "use_nets": False,
276
- "lambda_zone": float(self.lambda_zone),
277
- "k_zone": int(self.k_zone),
278
- "device": self.device,
279
- },
280
- {"zone_db": self.zone_db, "use_nets": False},
281
- {"zone_db": self.zone_db},
282
- {},
283
- ]
284
-
285
- import inspect
286
-
287
- for kwargs in kwargs_variants:
288
- try:
289
- # Filter out None values which some constructors may not accept,
290
- # but keep None for parameters that are required (no default)
291
- sig = None
292
  try:
293
- sig = inspect.signature(searcher_cls.__init__)
294
- except Exception:
295
- sig = None
296
-
297
- filtered = {}
298
- for k, v in kwargs.items():
299
- if v is not None:
300
- filtered[k] = v
301
- else:
302
- # include None if constructor expects this param without a default
303
- if sig is not None and k in sig.parameters:
304
- param = sig.parameters[k]
305
- if param.default is inspect._empty:
306
- filtered[k] = v
307
-
308
- inst = searcher_cls(**filtered)
309
- log.info("Instantiated searcher %s with kwargs %s", getattr(searcher_cls, "__name__", str(searcher_cls)), list(filtered.keys()))
310
- return inst
311
- except TypeError:
312
- # Signature mismatch โ€” try next variant
313
- continue
314
- except Exception:
315
- log.debug("Failed instantiating %s with %s", getattr(searcher_cls, "__name__", str(searcher_cls)), kwargs, exc_info=True)
316
- continue
317
-
318
- log.warning("Could not instantiate searcher class %s; search will be disabled.", getattr(searcher_cls, "__name__", str(searcher_cls)))
319
- return None
320
-
321
- def _init_searcher(self):
322
- try:
323
- searcher_cls, mod = self._find_searcher_class()
324
- if searcher_cls is None:
325
- log.info("No searcher class found in model files.")
326
- self.searcher = None
327
- return
328
- self.searcher = self._instantiate_searcher(searcher_cls)
329
- except Exception:
330
- log.exception("Failed to initialise searcher")
331
- self.searcher = None
332
-
333
- def _extract_move_from_search_result(self, res, board: chess.Board) -> Optional[str]:
334
- """Extract a best UCI move string from a search result.
335
-
336
- Handles tuples (visits, sims), visits dicts, and simple string returns.
337
- """
338
- try:
339
- # Case: tuple (visits, sims)
340
- if isinstance(res, tuple) and len(res) >= 1:
341
- visits = res[0]
342
- if isinstance(visits, dict) and visits:
343
- best = max(visits.items(), key=lambda kv: float(kv[1]))[0]
344
- if hasattr(best, "uci"):
345
- return best.uci()
346
- return str(best)
347
-
348
- # Case: visits dict
349
- if isinstance(res, dict):
350
- # If it looks like a visits dict (values numeric)
351
- if res and all(isinstance(v, (int, float)) for v in res.values()):
352
- best = max(res.items(), key=lambda kv: float(kv[1]))[0]
353
- if hasattr(best, "uci"):
354
- return best.uci()
355
- return str(best)
356
- # Otherwise maybe {"move": "e2e4"}
357
- if "move" in res and isinstance(res["move"], str):
358
- return res["move"].strip()
359
 
360
- # Case: list of candidates
361
- if isinstance(res, list) and res:
362
- first = res[0]
363
- if isinstance(first, dict) and "move" in first:
364
- return first["move"].strip()
365
- if isinstance(first, str):
366
- return first.strip()
367
 
368
- # Case: string
369
- if isinstance(res, str) and res:
370
- return res.strip()
371
-
372
- except Exception:
373
- log.debug("Failed extracting move from search result", exc_info=True)
374
- return None
375
-
376
- def __call__(self, data: Dict[str, Any]) -> Dict[str, Any]:
377
- """Run a single prediction call.
378
-
379
- Accepts either `{"inputs": {...}}` or a plain dict with `fen`, `player`,
380
- and optional `time_budget`.
381
- """
382
- try:
383
- inputs = data.get("inputs") if isinstance(data, dict) and "inputs" in data else data
384
- if not isinstance(inputs, dict):
385
- return {"error": "invalid_inputs", "move": "0000"}
386
-
387
- fen = inputs.get("fen", "")
388
- player = inputs.get("player", "w")
389
 
390
- # Validate FEN
391
  try:
392
- board = chess.Board(fen)
 
 
 
393
  except Exception as e:
394
- return {"error": "invalid_fen", "message": str(e), "move": "0000"}
395
-
396
- # Resolve time budget (default from config_model.json โ†’ env โ†’ 1.0)
397
- try:
398
- tb = inputs.get("time_budget", self.time_budget)
399
- tb = float(tb) if tb is not None and tb != "" else self.time_budget
400
- except Exception:
401
- tb = 1.0
402
- # Default and clamp
403
- if tb <= 0:
404
- tb = 1.0
405
- tb = min(float(tb), 12.0)
406
-
407
- # Build a Game object for the searcher
408
- # Prefer chess_mcvs.Chess() if available (it may provide richer API)
409
- game_obj = None
410
- try:
411
- cm = self.loaded_modules.get("chess_mcvs") or self.loaded_modules.get("chess_mcvs.py")
412
- if cm and hasattr(cm, "Chess"):
413
- try:
414
- game_obj = getattr(cm, "Chess")()
415
- # Set board attribute expected by searchers
416
- game_obj.board = board
417
- except Exception:
418
- game_obj = None
419
- # Fallback to a minimal wrapper
420
- except Exception:
421
- game_obj = None
422
- if game_obj is None:
423
- game_obj = SimpleGame(board)
424
-
425
- # If we have a searcher with search_with_time_budget, run it
426
- if self.searcher is not None and hasattr(self.searcher, "search_with_time_budget"):
427
- try:
428
- # Try calling with game.copy() and tb
429
- try:
430
- result = self.searcher.search_with_time_budget(game_obj.copy(), tb)
431
- except TypeError:
432
- # Try alternative signatures
433
- try:
434
- result = self.searcher.search_with_time_budget(game_obj, tb)
435
- except TypeError:
436
- try:
437
- result = self.searcher.search_with_time_budget(tb)
438
- except Exception:
439
- result = None
440
-
441
- # Attempt to extract a move
442
- mv = self._extract_move_from_search_result(result, board)
443
- if mv:
444
- # Verify legality
445
- try:
446
- candidate = chess.Move.from_uci(mv)
447
- if candidate in board.legal_moves:
448
- return {"move": mv}
449
- except Exception:
450
- # Not a valid UCI or illegal on this board
451
- log.debug("Searcher returned invalid or illegal move %s", mv)
452
-
453
- except Exception:
454
- log.exception("Searcher raised an exception during search_with_time_budget")
455
 
456
- # Final fallback: choose a legal move locally (prefer captures)
457
- try:
458
- legal = list(board.legal_moves)
459
- if not legal:
460
- return {"error": "no_legal_moves", "move": "0000"}
461
- captures = [m for m in legal if board.is_capture(m)]
462
- if captures:
463
- pick = random.choice(captures)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
  else:
465
- pick = random.choice(legal)
466
- return {"move": pick.uci()}
467
- except Exception:
468
- log.exception("Fallback legal-move selection failed")
469
- return {"error": "fallback_failed", "move": "0000"}
470
-
471
- except Exception:
472
- log.exception("Unhandled exception in EndpointHandler.__call__")
473
- return {"error": "internal_error", "move": "0000"}
 
 
 
 
 
 
 
 
1
+ import sys
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import json
 
 
3
  import random
 
 
4
  from pathlib import Path
 
 
 
 
 
 
 
5
 
6
+ model_path = Path("/model")
7
+ if str(model_path) not in sys.path:
8
+ sys.path.insert(0, str(model_path))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
+ import chess_mcvs
11
 
12
  class EndpointHandler:
13
+ def __init__(self, path: str, game_model: dict = None):
 
 
 
 
 
14
  self.path = Path(path)
15
+ self.config = game_model or {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
+ # Load both config files
18
+ for cfg_file in ["config_model.json", "config_data.json"]:
19
+ p = self.path / cfg_file
20
+ if p.exists():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  try:
22
+ self.config.update(json.loads(p.read_text(encoding="utf-8")))
23
+ except:
24
+ pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
+ self.lambda_zone = float(self.config.get("lambda_zone", 0.6))
27
+ self.k_zone = int(self.config.get("k_zone", 8))
28
+ self.time_per_move = float(self.config.get("time_per_move", 3.0))
29
+ self.temperature = float(self.config.get("temperature", 0.25))
 
 
 
30
 
31
+ # === Load Zone Database ===
32
+ self.zone_db = None
33
+ zone_path = self.path / "chess_zone_db.npz"
34
+ if not zone_path.exists():
35
+ zone_path = Path("/data") / "chess_zone_db.npz" # sandbox mount
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
+ if zone_path.exists():
38
  try:
39
+ self.zone_db = chess_mcvs.HilbertOrderedZoneDatabase(filepath=str(zone_path))
40
+ print(f"โœ… Zone DB LOADED | W={len(self.zone_db.winning_matrices)} | "
41
+ f"L={len(self.zone_db.losing_matrices)} | D={len(self.zone_db.draw_matrices)}",
42
+ file=sys.stderr)
43
  except Exception as e:
44
+ print(f"โŒ Zone DB failed: {e}", file=sys.stderr)
45
+ else:
46
+ print("โš ๏ธ chess_zone_db.npz NOT FOUND", file=sys.stderr)
47
+
48
+ # === Create MCVSSearcher (same as your local experiment) ===
49
+ self.searcher = chess_mcvs.MCVSSearcher(
50
+ policy_net=None,
51
+ value_net=None,
52
+ zone_db=self.zone_db,
53
+ use_nets=False,
54
+ lambda_zone=self.lambda_zone,
55
+ k_zone=self.k_zone,
56
+ cpuct=float(self.config.get("cpuct", 1.5)),
57
+ dirichlet_alpha=float(self.config.get("dirichlet_alpha", 0.3)),
58
+ dirichlet_noise_fraction=0.25,
59
+ device="cpu"
60
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
+ print(f"๐Ÿš€ Handler ready | lambda_zone={self.lambda_zone} | time={self.time_per_move}s | temp={self.temperature}",
63
+ file=sys.stderr)
64
+
65
+ def __call__(self, data: dict):
66
+ inputs = data.get("inputs", data)
67
+ fen = inputs.get("fen")
68
+ player = inputs.get("player", "w")
69
+ time_budget = float(inputs.get("time_budget", self.time_per_move))
70
+
71
+ print(f"๐Ÿ” Search start | FEN={fen[:40]}... | time_budget={time_budget:.1f}s", file=sys.stderr)
72
+
73
+ game = chess_mcvs.Chess()
74
+ game.board.set_fen(fen)
75
+ if str(player).lower().startswith("b"):
76
+ game.board.turn = False
77
+
78
+ # Run the same searcher you used in your experiment
79
+ visits, sims = self.searcher.search_with_time_budget(game, time_budget)
80
+
81
+ print(f"๐Ÿ“Š Search done โ†’ {sims} simulations, explored {len(visits)} moves", file=sys.stderr)
82
+
83
+ if visits:
84
+ # Use temperature like in your successful local tests
85
+ if self.temperature > 0.01:
86
+ moves = list(visits.items())
87
+ counts = [c for _, c in moves]
88
+ logits = [c ** (1.0 / self.temperature) for c in counts]
89
+ total = sum(logits)
90
+ probs = [l / total for l in logits]
91
+
92
+ r = random.random()
93
+ cum = 0.0
94
+ for (move, _), p in zip(moves, probs):
95
+ cum += p
96
+ if r <= cum:
97
+ chosen = move
98
+ break
99
  else:
100
+ chosen = moves[-1][0]
101
+ else:
102
+ chosen = max(visits.items(), key=lambda x: x[1])[0]
103
+
104
+ print(f"โœ… Final move: {chosen.uci()}", file=sys.stderr)
105
+ return {"move": chosen.uci()}
106
+ else:
107
+ print("โš ๏ธ No visits, using fallback", file=sys.stderr)
108
+ legal = list(game.get_legal_moves())
109
+ move = max(legal, key=lambda m: game.board.is_capture(m)) if legal else legal[0]
110
+ return {"move": move.uci()}
111
+
112
+
113
+ def get_move(data: dict, path: str = ".", game_model: dict = None):
114
+ h = EndpointHandler(path=path, game_model=game_model)
115
+ return h(data)