test1978 commited on
Commit
7860f31
·
verified ·
1 Parent(s): 35fa4cc

Add handler.py

Browse files
Files changed (1) hide show
  1. handler.py +473 -0
handler.py ADDED
@@ -0,0 +1,473 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"}