test1978 commited on
Commit
2c2d8d7
·
verified ·
1 Parent(s): 9ba7b0d

Add handler.py

Browse files
Files changed (1) hide show
  1. handler.py +485 -0
handler.py ADDED
@@ -0,0 +1,485 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ──────────────────────────────────────────────
2
+ # handler.py
3
+ #
4
+ # Custom handler for HF Inference Endpoints.
5
+ #
6
+ # When HF Inference Endpoints detects a handler.py
7
+ # in the repo root, it uses this class to serve
8
+ # predictions instead of the default Transformers
9
+ # pipeline.
10
+ #
11
+ # Expected repo layout:
12
+ #
13
+ # your-username/chess-model/
14
+ # ├── handler.py ← this file
15
+ # ├── config_model.json ← lists your Python modules + main
16
+ # ├── config_data.json ← lists data files + zone_db key
17
+ # ├── chess_mcvs.py ← your UCT search / eval module
18
+ # └── requirements.txt ← runtime deps (numpy, huggingface_hub)
19
+ #
20
+ # config_model.json example:
21
+ #
22
+ # {
23
+ # "files": ["matrix_model.py", "abc_model.py", "chess_mcvs.py"],
24
+ # "main": "chess_mcvs"
25
+ # }
26
+ #
27
+ # config_data.json example:
28
+ #
29
+ # {
30
+ # "files": ["chess_zone_db.npz"],
31
+ # "zone_db": "chess_zone_db.npz"
32
+ # }
33
+ #
34
+ # Set the HF_DATA_REPO_ID environment variable to
35
+ # the HF dataset repo holding your data files.
36
+ #
37
+ # Request format (sent by Agladiator):
38
+ #
39
+ # POST /
40
+ # {
41
+ # "inputs": {
42
+ # "fen": "BBBBBBBB/BBBBBBBB/8/8/8/8/WWWWWWWW/WWWWWWWW w",
43
+ # "player": "w",
44
+ # "game_type": "chess"
45
+ # }
46
+ # }
47
+ #
48
+ # Response format (what you must return):
49
+ #
50
+ # {"move": "a2a3"}
51
+ #
52
+ # ──────────────────────────────────────────────
53
+ from __future__ import annotations
54
+
55
+ import importlib.util
56
+ import json
57
+ import logging
58
+ import os
59
+ import random
60
+ import sys
61
+ from pathlib import Path
62
+
63
+ import numpy as np
64
+
65
+ log = logging.getLogger(__name__)
66
+
67
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
68
+ # Chess board constants (used by the minimal legal-move generator)
69
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
70
+ BOARD_SIZE = 8
71
+ _FILES = "abcdefgh"
72
+ _RANKS = "12345678"
73
+ WHITE, BLACK = "w", "b"
74
+ EMPTY = "."
75
+
76
+
77
+ class EndpointHandler:
78
+ """Custom handler for chess AI on HF Inference Endpoints.
79
+
80
+ Lifecycle:
81
+ 1. ``__init__`` is called once when the endpoint container starts.
82
+ Use it to load your model artifacts (modules, zone DB, etc.).
83
+ 2. ``__call__`` is called for every inference request.
84
+ It receives the parsed JSON body and must return a dict.
85
+ """
86
+
87
+ def __init__(self, path: str):
88
+ """Load model artefacts from *path* (the cloned repo directory).
89
+
90
+ Steps:
91
+ 1. Read ``config_model.json`` to discover module files and main module.
92
+ 2. Dynamically import each listed module.
93
+ 3. Read ``config_data.json``, download data files, load zone DB.
94
+ 4. Initialise the search engine.
95
+ """
96
+ self.path = Path(path)
97
+ self.predict_fn = None
98
+ self.zone_db = None
99
+ self.module = None
100
+
101
+ # ── 1. Read config_model.json ───────────
102
+ model_config_path = self.path / "config_model.json"
103
+ if not model_config_path.exists():
104
+ log.warning("No config_model.json found in %s", path)
105
+ model_config: dict = {}
106
+ else:
107
+ with open(model_config_path) as fh:
108
+ model_config = json.load(fh)
109
+
110
+ main_module_name = model_config.get("main", "")
111
+
112
+ # ── 2. Import listed modules ────────────
113
+ # Add repo dir to sys.path so relative imports work.
114
+ if str(self.path) not in sys.path:
115
+ sys.path.insert(0, str(self.path))
116
+
117
+ for mod_file in model_config.get("files", []):
118
+ mod_name = mod_file.removesuffix(".py")
119
+ mod_path = self.path / mod_file
120
+ if not mod_path.exists():
121
+ log.warning("Module file %s not found — skipping.", mod_file)
122
+ continue
123
+ spec = importlib.util.spec_from_file_location(mod_name, str(mod_path))
124
+ mod = importlib.util.module_from_spec(spec)
125
+ sys.modules[mod_name] = mod
126
+ spec.loader.exec_module(mod)
127
+ log.info("Loaded module: %s", mod_name)
128
+ # Mark the module matching "main" as the primary module.
129
+ if mod_name == main_module_name:
130
+ self.module = mod
131
+
132
+ # ── 3. Read config_data.json & load zone DB ──
133
+ data_config_path = self.path / "config_data.json"
134
+ if not data_config_path.exists():
135
+ log.warning("No config_data.json found in %s", path)
136
+ data_config: dict = {}
137
+ else:
138
+ with open(data_config_path) as fh:
139
+ data_config = json.load(fh)
140
+
141
+ data_repo = os.environ.get("HF_DATA_REPO_ID", "")
142
+ downloaded_files: dict[str, str] = {}
143
+
144
+ if data_repo:
145
+ try:
146
+ from huggingface_hub import hf_hub_download
147
+ except Exception:
148
+ hf_hub_download = None
149
+ log.warning(
150
+ "huggingface_hub not installed — cannot download data files. Add it to requirements.txt"
151
+ )
152
+
153
+ if hf_hub_download is not None:
154
+ for data_file in data_config.get("files", []):
155
+ try:
156
+ local_path = hf_hub_download(
157
+ repo_id=data_repo,
158
+ filename=data_file,
159
+ repo_type="dataset",
160
+ )
161
+ downloaded_files[data_file] = local_path
162
+ log.info("Downloaded %s from %s", data_file, data_repo)
163
+ except Exception:
164
+ log.warning(
165
+ "Failed to download %s from %s — skipping.",
166
+ data_file,
167
+ data_repo,
168
+ )
169
+ else:
170
+ log.warning(
171
+ "HF_DATA_REPO_ID not set — skipping data file downloads."
172
+ )
173
+
174
+ zone_db_filename = data_config.get("zone_db", "")
175
+ if zone_db_filename and zone_db_filename in downloaded_files:
176
+ self.zone_db = np.load(
177
+ downloaded_files[zone_db_filename], allow_pickle=True
178
+ )
179
+ log.info("Zone DB loaded from data repo %s", data_repo)
180
+ elif zone_db_filename:
181
+ local_zone = self.path / zone_db_filename
182
+ if local_zone.exists():
183
+ self.zone_db = np.load(str(local_zone), allow_pickle=True)
184
+ log.info("Zone DB loaded from local file %s", local_zone)
185
+ else:
186
+ log.warning("No zone DB found for '%s'.", zone_db_filename)
187
+
188
+ # ── 4. Initialise the predictor ─────────
189
+ self._init_predictor()
190
+ log.info("EndpointHandler initialised successfully.")
191
+
192
+ # ──────────────────────────────────────────
193
+ # Predictor initialisation
194
+ # ──────────────────────────────────────────
195
+ def _init_predictor(self):
196
+ """Discover and configure the prediction function from the
197
+ loaded module. Tries several common patterns:
198
+
199
+ 1. ``module.get_move(fen, player, zone_db=...)``
200
+ 2. ``module.UCTSearcher(zone_db=...).search(fen, player)``
201
+ 3. ``module.predict(fen, player)``
202
+ """
203
+ if self.module is None:
204
+ log.warning("No module loaded — predictions will use random fallback.")
205
+ return
206
+
207
+ # Pattern 1: module exposes a top-level get_move()
208
+ if hasattr(self.module, "get_move"):
209
+ raw_fn = self.module.get_move
210
+ self.predict_fn = lambda fen, player: raw_fn(
211
+ fen, player, zone_db=self.zone_db
212
+ )
213
+ log.info("Using module.get_move() for predictions.")
214
+ return
215
+
216
+ # Pattern 2: module exposes a UCTSearcher class
217
+ if hasattr(self.module, "UCTSearcher"):
218
+ searcher_cls = self.module.UCTSearcher
219
+ kwargs = {}
220
+ if self.zone_db is not None:
221
+ kwargs["zone_db"] = self.zone_db
222
+ searcher = searcher_cls(**kwargs)
223
+ self.predict_fn = lambda fen, player: searcher.search(fen, player)
224
+ log.info("Using module.UCTSearcher for predictions.")
225
+ return
226
+
227
+ # Pattern 3: module exposes a predict()
228
+ if hasattr(self.module, "predict"):
229
+ raw_fn = self.module.predict
230
+ self.predict_fn = lambda fen, player: raw_fn(fen, player)
231
+ log.info("Using module.predict() for predictions.")
232
+ return
233
+
234
+ log.warning(
235
+ "Module %s has no get_move / UCTSearcher / predict — "
236
+ "predictions will use random fallback.",
237
+ self.module.__name__,
238
+ )
239
+
240
+ # ──────────────────────────────────────────
241
+ # Inference entry point
242
+ # ──────────────────────────────────────────
243
+ def __call__(self, data: dict) -> dict:
244
+ """Handle an inference request.
245
+
246
+ Expected *data*::
247
+
248
+ {"inputs": {"fen": "...", "player": "w"}}
249
+
250
+ Returns::
251
+
252
+ {"move": "a2a3"}
253
+
254
+ Falls back to a random legal move if the model fails.
255
+ """
256
+ inputs = data.get("inputs", data)
257
+ fen = inputs.get("fen", "")
258
+ player = inputs.get("player", WHITE)
259
+
260
+ if not fen:
261
+ return {"error": "Missing 'fen' in request."}
262
+
263
+ # Try model prediction
264
+ if self.predict_fn is not None:
265
+ try:
266
+ move = self.predict_fn(fen, player)
267
+ if move and isinstance(move, str):
268
+ return {"move": move}
269
+ except Exception:
270
+ log.exception("Model prediction failed for FEN=%s", fen)
271
+
272
+ # Fallback: random legal move
273
+ moves = self._legal_moves(fen)
274
+ if not moves:
275
+ return {"error": "No legal moves available.", "move": "0000"}
276
+
277
+ # Prefer captures (diagonal moves in chess)
278
+ captures = [m for m in moves if m[0] != m[2]]
279
+ move = random.choice(captures) if captures else random.choice(moves)
280
+ return {"move": move}
281
+
282
+ # ──────────────────────────────────────────
283
+ # Minimal legal-move generator (self-contained)
284
+ # ──────────────────────────────────────────
285
+ @staticmethod
286
+ def _legal_moves(fen: str) -> list[str]:
287
+ """Return a list of pseudo-legal UCI moves for a chess FEN.
288
+
289
+ This minimal generator supports pawn moves (including promotions
290
+ and en-passant), knights, bishops, rooks, queens, kings and
291
+ basic castling availability. It does not perform check detection
292
+ and therefore may return some pseudo-legal moves in checked positions.
293
+ """
294
+ parts = fen.strip().split()
295
+ board_part = parts[0] if parts else ""
296
+ turn = parts[1] if len(parts) > 1 else WHITE
297
+ castling = parts[2] if len(parts) > 2 else "-"
298
+ ep_square = parts[3] if len(parts) > 3 else "-"
299
+
300
+ # Parse FEN → grid (8 ranks, top (rank 8) → bottom (rank 1))
301
+ grid: list[list[str]] = []
302
+ for rank_str in board_part.split("/"):
303
+ row: list[str] = []
304
+ for ch in rank_str:
305
+ if ch.isdigit():
306
+ row.extend([EMPTY] * int(ch))
307
+ else:
308
+ row.append(ch)
309
+ # normalize row length
310
+ if len(row) != BOARD_SIZE:
311
+ row = (row + [EMPTY] * BOARD_SIZE)[:BOARD_SIZE]
312
+ grid.append(row)
313
+ while len(grid) < BOARD_SIZE:
314
+ grid.append([EMPTY] * BOARD_SIZE)
315
+
316
+ side_white = turn == WHITE
317
+
318
+ def is_enemy(ch: str) -> bool:
319
+ return ch != EMPTY and ch.isupper() != side_white
320
+
321
+ def is_own(ch: str) -> bool:
322
+ return ch != EMPTY and ch.isupper() == side_white
323
+
324
+ moves: list[str] = []
325
+
326
+ # En-passant target square (if any)
327
+ ep_r = ep_c = None
328
+ if ep_square and ep_square != "-":
329
+ try:
330
+ ep_c = _FILES.index(ep_square[0])
331
+ ep_r = BOARD_SIZE - int(ep_square[1])
332
+ except Exception:
333
+ ep_r = ep_c = None
334
+
335
+ start_row = 6 if side_white else 1
336
+ last_row = 0 if side_white else 7
337
+ direction = -1 if side_white else 1
338
+
339
+ for r in range(BOARD_SIZE):
340
+ for c in range(BOARD_SIZE):
341
+ ch = grid[r][c]
342
+ if ch == EMPTY:
343
+ continue
344
+ if ch.isupper() != side_white:
345
+ continue
346
+ piece = ch.lower()
347
+ from_sq = f"{_FILES[c]}{_RANKS[BOARD_SIZE - 1 - r]}"
348
+
349
+ if piece == "p":
350
+ nr = r + direction
351
+ # forward one
352
+ if 0 <= nr < BOARD_SIZE and grid[nr][c] == EMPTY:
353
+ # promotion
354
+ if nr == last_row:
355
+ for promo in ("q", "r", "b", "n"):
356
+ moves.append(
357
+ f"{from_sq}{_FILES[c]}{_RANKS[BOARD_SIZE - 1 - nr]}{promo}"
358
+ )
359
+ else:
360
+ moves.append(
361
+ f"{from_sq}{_FILES[c]}{_RANKS[BOARD_SIZE - 1 - nr]}"
362
+ )
363
+ # double move from start
364
+ if r == start_row:
365
+ nr2 = r + 2 * direction
366
+ if 0 <= nr2 < BOARD_SIZE and grid[nr2][c] == EMPTY:
367
+ moves.append(
368
+ f"{from_sq}{_FILES[c]}{_RANKS[BOARD_SIZE - 1 - nr2]}"
369
+ )
370
+ # captures (including en-passant)
371
+ for dc in (-1, 1):
372
+ nc = c + dc
373
+ if nc < 0 or nc >= BOARD_SIZE:
374
+ continue
375
+ if 0 <= nr < BOARD_SIZE:
376
+ target = grid[nr][nc]
377
+ if is_enemy(target):
378
+ if nr == last_row:
379
+ for promo in ("q", "r", "b", "n"):
380
+ moves.append(
381
+ f"{from_sq}{_FILES[nc]}{_RANKS[BOARD_SIZE - 1 - nr]}{promo}"
382
+ )
383
+ else:
384
+ moves.append(
385
+ f"{from_sq}{_FILES[nc]}{_RANKS[BOARD_SIZE - 1 - nr]}"
386
+ )
387
+ # en-passant capture
388
+ if ep_r is not None and ep_r == nr and ep_c == nc:
389
+ cap_r = r
390
+ cap_c = nc
391
+ if (
392
+ 0 <= cap_r < BOARD_SIZE
393
+ and grid[cap_r][cap_c].lower() == "p"
394
+ and is_enemy(grid[cap_r][cap_c])
395
+ ):
396
+ moves.append(
397
+ f"{from_sq}{_FILES[nc]}{_RANKS[BOARD_SIZE - 1 - nr]}"
398
+ )
399
+
400
+ elif piece == "n":
401
+ for dr, dc in (
402
+ (-2, -1),
403
+ (-2, 1),
404
+ (-1, -2),
405
+ (-1, 2),
406
+ (1, -2),
407
+ (1, 2),
408
+ (2, -1),
409
+ (2, 1),
410
+ ):
411
+ nr = r + dr
412
+ nc = c + dc
413
+ if 0 <= nr < BOARD_SIZE and 0 <= nc < BOARD_SIZE:
414
+ target = grid[nr][nc]
415
+ if not is_own(target):
416
+ moves.append(
417
+ f"{from_sq}{_FILES[nc]}{_RANKS[BOARD_SIZE - 1 - nr]}"
418
+ )
419
+
420
+ elif piece in ("b", "r", "q"):
421
+ directions = []
422
+ if piece in ("b", "q"):
423
+ directions += [(-1, -1), (-1, 1), (1, -1), (1, 1)]
424
+ if piece in ("r", "q"):
425
+ directions += [(-1, 0), (1, 0), (0, -1), (0, 1)]
426
+ for dr, dc in directions:
427
+ nr = r + dr
428
+ nc = c + dc
429
+ while 0 <= nr < BOARD_SIZE and 0 <= nc < BOARD_SIZE:
430
+ target = grid[nr][nc]
431
+ if target == EMPTY:
432
+ moves.append(
433
+ f"{from_sq}{_FILES[nc]}{_RANKS[BOARD_SIZE - 1 - nr]}"
434
+ )
435
+ else:
436
+ if is_enemy(target):
437
+ moves.append(
438
+ f"{from_sq}{_FILES[nc]}{_RANKS[BOARD_SIZE - 1 - nr]}"
439
+ )
440
+ break
441
+ nr += dr
442
+ nc += dc
443
+
444
+ elif piece == "k":
445
+ for dr in (-1, 0, 1):
446
+ for dc in (-1, 0, 1):
447
+ if dr == 0 and dc == 0:
448
+ continue
449
+ nr = r + dr
450
+ nc = c + dc
451
+ if 0 <= nr < BOARD_SIZE and 0 <= nc < BOARD_SIZE:
452
+ target = grid[nr][nc]
453
+ if not is_own(target):
454
+ moves.append(
455
+ f"{from_sq}{_FILES[nc]}{_RANKS[BOARD_SIZE - 1 - nr]}"
456
+ )
457
+ # Basic castling checks (does not verify check conditions)
458
+ if side_white:
459
+ if "K" in castling and r == 7 and c == 4:
460
+ if grid[7][5] == EMPTY and grid[7][6] == EMPTY and grid[7][7] != EMPTY and grid[7][7].isupper():
461
+ moves.append(f"{from_sq}g1")
462
+ if "Q" in castling and r == 7 and c == 4:
463
+ if (
464
+ grid[7][3] == EMPTY
465
+ and grid[7][2] == EMPTY
466
+ and grid[7][1] == EMPTY
467
+ and grid[7][0] != EMPTY
468
+ and grid[7][0].isupper()
469
+ ):
470
+ moves.append(f"{from_sq}c1")
471
+ else:
472
+ if "k" in castling and r == 0 and c == 4:
473
+ if grid[0][5] == EMPTY and grid[0][6] == EMPTY and grid[0][7] != EMPTY and grid[0][7].islower():
474
+ moves.append(f"{from_sq}g8")
475
+ if "q" in castling and r == 0 and c == 4:
476
+ if (
477
+ grid[0][3] == EMPTY
478
+ and grid[0][2] == EMPTY
479
+ and grid[0][1] == EMPTY
480
+ and grid[0][0] != EMPTY
481
+ and grid[0][0].islower()
482
+ ):
483
+ moves.append(f"{from_sq}c8")
484
+
485
+ return moves