test1978 commited on
Commit
8bd38af
·
verified ·
1 Parent(s): 61f5cfb

Add handler.py

Browse files
Files changed (1) hide show
  1. handler.py +419 -0
handler.py ADDED
@@ -0,0 +1,419 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # Import files listed in config_model.json
94
+ files = model_config.get("files") or []
95
+ for f in files:
96
+ try:
97
+ mod_name = Path(f).stem
98
+ mod_path = self.path / f
99
+ if not mod_path.exists():
100
+ log.debug("Listed module %s not found at %s", f, mod_path)
101
+ continue
102
+ spec = importlib.util.spec_from_file_location(mod_name, str(mod_path))
103
+ if spec is None or spec.loader is None:
104
+ log.warning("Could not create spec for %s", mod_path)
105
+ continue
106
+ mod = importlib.util.module_from_spec(spec)
107
+ sys.modules[mod_name] = mod
108
+ spec.loader.exec_module(mod)
109
+ self.loaded_modules[mod_name] = mod
110
+ log.info("Loaded module %s", mod_name)
111
+ except Exception:
112
+ log.exception("Failed to import listed module %s", f)
113
+
114
+ # Determine main module
115
+ main_name_raw = (model_config.get("main") or "").strip()
116
+ if main_name_raw:
117
+ main_name = Path(main_name_raw).stem
118
+ self.main_module = self.loaded_modules.get(main_name) or None
119
+
120
+ # If chess_mcvs.py is present in repo root, import it (preferred source of Chess/MCVSSearcher)
121
+ try:
122
+ chess_mcvs_path = self.path / "chess_mcvs.py"
123
+ if chess_mcvs_path.exists():
124
+ spec = importlib.util.spec_from_file_location("chess_mcvs", str(chess_mcvs_path))
125
+ if spec and spec.loader:
126
+ cm = importlib.util.module_from_spec(spec)
127
+ sys.modules["chess_mcvs"] = cm
128
+ spec.loader.exec_module(cm)
129
+ self.loaded_modules["chess_mcvs"] = cm
130
+ if self.main_module is None:
131
+ self.main_module = cm
132
+ log.info("Imported chess_mcvs from model path")
133
+ except Exception:
134
+ log.debug("Could not import chess_mcvs.py", exc_info=True)
135
+
136
+ # Load zone DB if referenced
137
+ zone_filename = (
138
+ data_config.get("zone_db")
139
+ or data_config.get("zone_db_filename")
140
+ or model_config.get("zone_db")
141
+ or model_config.get("zone_db_filename")
142
+ or None
143
+ )
144
+
145
+ if zone_filename:
146
+ # Try local candidates first
147
+ candidates = [self.path / zone_filename, self.path / "data" / zone_filename]
148
+ loaded = False
149
+ for cand in candidates:
150
+ try:
151
+ if cand.exists():
152
+ self.zone_db = np.load(str(cand), allow_pickle=True)
153
+ log.info("Loaded zone DB from %s", cand)
154
+ loaded = True
155
+ break
156
+ except Exception:
157
+ log.debug("Failed to load zone DB from %s", cand, exc_info=True)
158
+
159
+ # Optional: try to download from HF_DATA_REPO_ID if set and huggingface_hub present
160
+ if not loaded:
161
+ hf_repo = os.environ.get("HF_DATA_REPO_ID")
162
+ if hf_repo:
163
+ try:
164
+ from huggingface_hub import hf_hub_download
165
+
166
+ # Download into model path so we don't write outside repo
167
+ try:
168
+ dl_path = hf_hub_download(repo_id=hf_repo, filename=zone_filename, cache_dir=str(self.path))
169
+ except TypeError:
170
+ # Older hf_hub_download signature
171
+ dl_path = hf_hub_download(repo_id=hf_repo, filename=zone_filename)
172
+ if Path(dl_path).exists():
173
+ try:
174
+ self.zone_db = np.load(str(dl_path), allow_pickle=True)
175
+ loaded = True
176
+ log.info("Downloaded and loaded zone DB %s from HF repo %s", zone_filename, hf_repo)
177
+ except Exception:
178
+ log.debug("Downloaded zone DB but failed to load %s", dl_path, exc_info=True)
179
+ except Exception:
180
+ log.debug("Could not download zone DB from HF repo (hf_hub missing or download failed)", exc_info=True)
181
+
182
+ # Initialise a searcher class if available
183
+ self._init_searcher()
184
+
185
+ def _find_searcher_class(self):
186
+ # Prefer MCVSSearcher in chess_mcvs module, then look through loaded modules
187
+ candidates = []
188
+ # If chess_mcvs loaded explicitly, check it first
189
+ cm = self.loaded_modules.get("chess_mcvs")
190
+ if cm is not None:
191
+ for name in ("MCVSSearcher", "ChessSearcher", "UCTSearcher"):
192
+ if hasattr(cm, name):
193
+ return getattr(cm, name), cm
194
+
195
+ # Otherwise check main module then all loaded modules
196
+ if self.main_module is not None:
197
+ for name in ("MCVSSearcher", "ChessSearcher", "UCTSearcher"):
198
+ if hasattr(self.main_module, name):
199
+ return getattr(self.main_module, name), self.main_module
200
+
201
+ for mod_name, mod in self.loaded_modules.items():
202
+ for name in ("MCVSSearcher", "ChessSearcher", "UCTSearcher"):
203
+ if hasattr(mod, name):
204
+ return getattr(mod, name), mod
205
+
206
+ # Last resort: try to import a module named 'mcvs' or 'searcher' in path
207
+ try:
208
+ import importlib
209
+
210
+ for candidate in ("mcvs", "searcher", "uct_searcher"):
211
+ try:
212
+ m = importlib.import_module(candidate)
213
+ for name in ("MCVSSearcher", "ChessSearcher", "UCTSearcher"):
214
+ if hasattr(m, name):
215
+ return getattr(m, name), m
216
+ except Exception:
217
+ continue
218
+ except Exception:
219
+ pass
220
+
221
+ return None, None
222
+
223
+ def _instantiate_searcher(self, searcher_cls):
224
+ # Try a series of sensible constructor signatures
225
+ kwargs_variants = [
226
+ {
227
+ "policy_net": None,
228
+ "value_net": None,
229
+ "zone_db": self.zone_db,
230
+ "use_nets": False,
231
+ "lambda_zone": float(self.lambda_zone),
232
+ "k_zone": int(self.k_zone),
233
+ "device": self.device,
234
+ "dirichlet_alpha": float(self.dirichlet_alpha),
235
+ },
236
+ {
237
+ "policy_net": None,
238
+ "value_net": None,
239
+ "zone_db": self.zone_db,
240
+ "use_nets": False,
241
+ "lambda_zone": float(self.lambda_zone),
242
+ "k_zone": int(self.k_zone),
243
+ "device": self.device,
244
+ },
245
+ {"zone_db": self.zone_db, "use_nets": False},
246
+ {"zone_db": self.zone_db},
247
+ {},
248
+ ]
249
+
250
+ for kwargs in kwargs_variants:
251
+ try:
252
+ # Filter out None values which some constructors may not accept
253
+ filtered = {k: v for k, v in kwargs.items() if v is not None}
254
+ inst = searcher_cls(**filtered)
255
+ log.info("Instantiated searcher %s with kwargs %s", getattr(searcher_cls, "__name__", str(searcher_cls)), list(filtered.keys()))
256
+ return inst
257
+ except TypeError:
258
+ # Signature mismatch — try next variant
259
+ continue
260
+ except Exception:
261
+ log.debug("Failed instantiating %s with %s", getattr(searcher_cls, "__name__", str(searcher_cls)), kwargs, exc_info=True)
262
+ continue
263
+
264
+ log.warning("Could not instantiate searcher class %s; search will be disabled.", getattr(searcher_cls, "__name__", str(searcher_cls)))
265
+ return None
266
+
267
+ def _init_searcher(self):
268
+ try:
269
+ searcher_cls, mod = self._find_searcher_class()
270
+ if searcher_cls is None:
271
+ log.info("No searcher class found in model files.")
272
+ self.searcher = None
273
+ return
274
+ self.searcher = self._instantiate_searcher(searcher_cls)
275
+ except Exception:
276
+ log.exception("Failed to initialise searcher")
277
+ self.searcher = None
278
+
279
+ def _extract_move_from_search_result(self, res, board: chess.Board) -> Optional[str]:
280
+ """Extract a best UCI move string from a search result.
281
+
282
+ Handles tuples (visits, sims), visits dicts, and simple string returns.
283
+ """
284
+ try:
285
+ # Case: tuple (visits, sims)
286
+ if isinstance(res, tuple) and len(res) >= 1:
287
+ visits = res[0]
288
+ if isinstance(visits, dict) and visits:
289
+ best = max(visits.items(), key=lambda kv: float(kv[1]))[0]
290
+ if hasattr(best, "uci"):
291
+ return best.uci()
292
+ return str(best)
293
+
294
+ # Case: visits dict
295
+ if isinstance(res, dict):
296
+ # If it looks like a visits dict (values numeric)
297
+ if res and all(isinstance(v, (int, float)) for v in res.values()):
298
+ best = max(res.items(), key=lambda kv: float(kv[1]))[0]
299
+ if hasattr(best, "uci"):
300
+ return best.uci()
301
+ return str(best)
302
+ # Otherwise maybe {"move": "e2e4"}
303
+ if "move" in res and isinstance(res["move"], str):
304
+ return res["move"].strip()
305
+
306
+ # Case: list of candidates
307
+ if isinstance(res, list) and res:
308
+ first = res[0]
309
+ if isinstance(first, dict) and "move" in first:
310
+ return first["move"].strip()
311
+ if isinstance(first, str):
312
+ return first.strip()
313
+
314
+ # Case: string
315
+ if isinstance(res, str) and res:
316
+ return res.strip()
317
+
318
+ except Exception:
319
+ log.debug("Failed extracting move from search result", exc_info=True)
320
+ return None
321
+
322
+ def __call__(self, data: Dict[str, Any]) -> Dict[str, Any]:
323
+ """Run a single prediction call.
324
+
325
+ Accepts either `{"inputs": {...}}` or a plain dict with `fen`, `player`,
326
+ and optional `time_budget`.
327
+ """
328
+ try:
329
+ inputs = data.get("inputs") if isinstance(data, dict) and "inputs" in data else data
330
+ if not isinstance(inputs, dict):
331
+ return {"error": "invalid_inputs", "move": "0000"}
332
+
333
+ fen = inputs.get("fen", "")
334
+ player = inputs.get("player", "w")
335
+
336
+ # Validate FEN
337
+ try:
338
+ board = chess.Board(fen)
339
+ except Exception as e:
340
+ return {"error": "invalid_fen", "message": str(e), "move": "0000"}
341
+
342
+ # Resolve time budget
343
+ try:
344
+ tb = inputs.get("time_budget", 1.0)
345
+ tb = float(tb) if tb is not None and tb != "" else 1.0
346
+ except Exception:
347
+ tb = 1.0
348
+ # Default and clamp
349
+ if tb <= 0:
350
+ tb = 1.0
351
+ tb = min(float(tb), 12.0)
352
+
353
+ # Build a Game object for the searcher
354
+ # Prefer chess_mcvs.Chess() if available (it may provide richer API)
355
+ game_obj = None
356
+ try:
357
+ cm = self.loaded_modules.get("chess_mcvs") or self.loaded_modules.get("chess_mcvs.py")
358
+ if cm and hasattr(cm, "Chess"):
359
+ try:
360
+ game_obj = getattr(cm, "Chess")()
361
+ # Set board attribute expected by searchers
362
+ game_obj.board = board
363
+ except Exception:
364
+ game_obj = None
365
+ # Fallback to a minimal wrapper
366
+ except Exception:
367
+ game_obj = None
368
+ if game_obj is None:
369
+ game_obj = SimpleGame(board)
370
+
371
+ # If we have a searcher with search_with_time_budget, run it
372
+ if self.searcher is not None and hasattr(self.searcher, "search_with_time_budget"):
373
+ try:
374
+ # Try calling with game.copy() and tb
375
+ try:
376
+ result = self.searcher.search_with_time_budget(game_obj.copy(), tb)
377
+ except TypeError:
378
+ # Try alternative signatures
379
+ try:
380
+ result = self.searcher.search_with_time_budget(game_obj, tb)
381
+ except TypeError:
382
+ try:
383
+ result = self.searcher.search_with_time_budget(tb)
384
+ except Exception:
385
+ result = None
386
+
387
+ # Attempt to extract a move
388
+ mv = self._extract_move_from_search_result(result, board)
389
+ if mv:
390
+ # Verify legality
391
+ try:
392
+ candidate = chess.Move.from_uci(mv)
393
+ if candidate in board.legal_moves:
394
+ return {"move": mv}
395
+ except Exception:
396
+ # Not a valid UCI or illegal on this board
397
+ log.debug("Searcher returned invalid or illegal move %s", mv)
398
+
399
+ except Exception:
400
+ log.exception("Searcher raised an exception during search_with_time_budget")
401
+
402
+ # Final fallback: choose a legal move locally (prefer captures)
403
+ try:
404
+ legal = list(board.legal_moves)
405
+ if not legal:
406
+ return {"error": "no_legal_moves", "move": "0000"}
407
+ captures = [m for m in legal if board.is_capture(m)]
408
+ if captures:
409
+ pick = random.choice(captures)
410
+ else:
411
+ pick = random.choice(legal)
412
+ return {"move": pick.uci()}
413
+ except Exception:
414
+ log.exception("Fallback legal-move selection failed")
415
+ return {"error": "fallback_failed", "move": "0000"}
416
+
417
+ except Exception:
418
+ log.exception("Unhandled exception in EndpointHandler.__call__")
419
+ return {"error": "internal_error", "move": "0000"}