test1978 commited on
Commit
9940d2a
Β·
verified Β·
1 Parent(s): 89accdb

Add handler.py

Browse files
Files changed (1) hide show
  1. handler.py +336 -0
handler.py ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/breakthrough-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
+ # β”œβ”€β”€ breakthrough_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", "breakthrough_mcvs.py"],
24
+ # "main": "breakthrough_mcvs"
25
+ # }
26
+ #
27
+ # config_data.json example:
28
+ #
29
+ # {
30
+ # "files": ["breakthrough_zone_db.npz"],
31
+ # "zone_db": "breakthrough_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": "breakthrough"
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
+ # Breakthrough board constants (must match the
69
+ # server's breakthrough_engine.py)
70
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
71
+ BOARD_SIZE = 8
72
+ _FILES = "abcdefgh"
73
+ _RANKS = "12345678"
74
+ WHITE, BLACK = "w", "b"
75
+ WHITE_PIECE, BLACK_PIECE, EMPTY = "W", "B", "."
76
+
77
+
78
+ class EndpointHandler:
79
+ """Custom handler for Breakthrough AI on HF Inference Endpoints.
80
+
81
+ Lifecycle:
82
+ 1. ``__init__`` is called once when the endpoint container starts.
83
+ Use it to load your model artifacts (modules, zone DB, etc.).
84
+ 2. ``__call__`` is called for every inference request.
85
+ It receives the parsed JSON body and must return a dict.
86
+ """
87
+
88
+ def __init__(self, path: str):
89
+ """Load model artefacts from *path* (the cloned repo directory).
90
+
91
+ Steps:
92
+ 1. Read ``config_model.json`` to discover module files and main module.
93
+ 2. Dynamically import each listed module.
94
+ 3. Read ``config_data.json``, download data files, load zone DB.
95
+ 4. Initialise the search engine.
96
+ """
97
+ self.path = Path(path)
98
+ self.predict_fn = None
99
+ self.zone_db = None
100
+ self.module = None
101
+
102
+ # ── 1. Read config_model.json ───────────
103
+ model_config_path = self.path / "config_model.json"
104
+ if not model_config_path.exists():
105
+ log.warning("No config_model.json found in %s", path)
106
+ model_config: dict = {}
107
+ else:
108
+ with open(model_config_path) as fh:
109
+ model_config = json.load(fh)
110
+
111
+ main_module_name = model_config.get("main", "")
112
+
113
+ # ── 2. Import listed modules ────────────
114
+ # Add repo dir to sys.path so relative imports work.
115
+ if str(self.path) not in sys.path:
116
+ sys.path.insert(0, str(self.path))
117
+
118
+ for mod_file in model_config.get("files", []):
119
+ mod_name = mod_file.removesuffix(".py")
120
+ mod_path = self.path / mod_file
121
+ if not mod_path.exists():
122
+ log.warning("Module file %s not found β€” skipping.", mod_file)
123
+ continue
124
+ spec = importlib.util.spec_from_file_location(mod_name, str(mod_path))
125
+ mod = importlib.util.module_from_spec(spec)
126
+ sys.modules[mod_name] = mod
127
+ spec.loader.exec_module(mod)
128
+ log.info("Loaded module: %s", mod_name)
129
+ # Mark the module matching "main" as the primary module.
130
+ if mod_name == main_module_name:
131
+ self.module = mod
132
+
133
+ # ── 3. Read config_data.json & load zone DB ──
134
+ data_config_path = self.path / "config_data.json"
135
+ if not data_config_path.exists():
136
+ log.warning("No config_data.json found in %s", path)
137
+ data_config: dict = {}
138
+ else:
139
+ with open(data_config_path) as fh:
140
+ data_config = json.load(fh)
141
+
142
+ data_repo = os.environ.get("HF_DATA_REPO_ID", "")
143
+ downloaded_files: dict[str, str] = {}
144
+
145
+ if data_repo:
146
+ try:
147
+ from huggingface_hub import hf_hub_download
148
+ except Exception:
149
+ hf_hub_download = None
150
+ log.warning(
151
+ "huggingface_hub not installed β€” cannot download data files. Add it to requirements.txt"
152
+ )
153
+
154
+ if hf_hub_download is not None:
155
+ for data_file in data_config.get("files", []):
156
+ try:
157
+ local_path = hf_hub_download(
158
+ repo_id=data_repo,
159
+ filename=data_file,
160
+ repo_type="dataset",
161
+ )
162
+ downloaded_files[data_file] = local_path
163
+ log.info("Downloaded %s from %s", data_file, data_repo)
164
+ except Exception:
165
+ log.warning(
166
+ "Failed to download %s from %s β€” skipping.",
167
+ data_file,
168
+ data_repo,
169
+ )
170
+ else:
171
+ log.warning(
172
+ "HF_DATA_REPO_ID not set β€” skipping data file downloads."
173
+ )
174
+
175
+ zone_db_filename = data_config.get("zone_db", "")
176
+ if zone_db_filename and zone_db_filename in downloaded_files:
177
+ self.zone_db = np.load(
178
+ downloaded_files[zone_db_filename], allow_pickle=True
179
+ )
180
+ log.info("Zone DB loaded from data repo %s", data_repo)
181
+ elif zone_db_filename:
182
+ local_zone = self.path / zone_db_filename
183
+ if local_zone.exists():
184
+ self.zone_db = np.load(str(local_zone), allow_pickle=True)
185
+ log.info("Zone DB loaded from local file %s", local_zone)
186
+ else:
187
+ log.warning("No zone DB found for '%s'.", zone_db_filename)
188
+
189
+ # ── 4. Initialise the predictor ─────────
190
+ self._init_predictor()
191
+ log.info("EndpointHandler initialised successfully.")
192
+
193
+ # ──────────────────────────────────────────
194
+ # Predictor initialisation
195
+ # ──────────────────────────────────────────
196
+ def _init_predictor(self):
197
+ """Discover and configure the prediction function from the
198
+ loaded module. Tries several common patterns:
199
+
200
+ 1. ``module.get_move(fen, player, zone_db=...)``
201
+ 2. ``module.UCTSearcher(zone_db=...).search(fen, player)``
202
+ 3. ``module.predict(fen, player)``
203
+ """
204
+ if self.module is None:
205
+ log.warning("No module loaded β€” predictions will use random fallback.")
206
+ return
207
+
208
+ # Pattern 1: module exposes a top-level get_move()
209
+ if hasattr(self.module, "get_move"):
210
+ raw_fn = self.module.get_move
211
+ self.predict_fn = lambda fen, player: raw_fn(
212
+ fen, player, zone_db=self.zone_db
213
+ )
214
+ log.info("Using module.get_move() for predictions.")
215
+ return
216
+
217
+ # Pattern 2: module exposes a UCTSearcher class
218
+ if hasattr(self.module, "UCTSearcher"):
219
+ searcher_cls = self.module.UCTSearcher
220
+ kwargs = {}
221
+ if self.zone_db is not None:
222
+ kwargs["zone_db"] = self.zone_db
223
+ searcher = searcher_cls(**kwargs)
224
+ self.predict_fn = lambda fen, player: searcher.search(fen, player)
225
+ log.info("Using module.UCTSearcher for predictions.")
226
+ return
227
+
228
+ # Pattern 3: module exposes a predict()
229
+ if hasattr(self.module, "predict"):
230
+ raw_fn = self.module.predict
231
+ self.predict_fn = lambda fen, player: raw_fn(fen, player)
232
+ log.info("Using module.predict() for predictions.")
233
+ return
234
+
235
+ log.warning(
236
+ "Module %s has no get_move / UCTSearcher / predict β€” "
237
+ "predictions will use random fallback.",
238
+ self.module.__name__,
239
+ )
240
+
241
+ # ──────────────────────────────────────────
242
+ # Inference entry point
243
+ # ──────────────────────────────────────────
244
+ def __call__(self, data: dict) -> dict:
245
+ """Handle an inference request.
246
+
247
+ Expected *data*::
248
+
249
+ {"inputs": {"fen": "...", "player": "w"}}
250
+
251
+ Returns::
252
+
253
+ {"move": "a2a3"}
254
+
255
+ Falls back to a random legal move if the model fails.
256
+ """
257
+ inputs = data.get("inputs", data)
258
+ fen = inputs.get("fen", "")
259
+ player = inputs.get("player", WHITE)
260
+
261
+ if not fen:
262
+ return {"error": "Missing 'fen' in request."}
263
+
264
+ # Try model prediction
265
+ if self.predict_fn is not None:
266
+ try:
267
+ move = self.predict_fn(fen, player)
268
+ if move and isinstance(move, str):
269
+ return {"move": move}
270
+ except Exception:
271
+ log.exception("Model prediction failed for FEN=%s", fen)
272
+
273
+ # Fallback: random legal move
274
+ moves = self._legal_moves(fen)
275
+ if not moves:
276
+ return {"error": "No legal moves available.", "move": "0000"}
277
+
278
+ # Prefer captures (diagonal moves in Breakthrough)
279
+ captures = [m for m in moves if m[0] != m[2]]
280
+ move = random.choice(captures) if captures else random.choice(moves)
281
+ return {"move": move}
282
+
283
+ # ──────────────────────────────────────────
284
+ # Minimal legal-move generator (self-contained)
285
+ # ──────────────────────────────────────────
286
+ @staticmethod
287
+ def _legal_moves(fen: str) -> list[str]:
288
+ """Return all legal UCI moves for the given Breakthrough FEN.
289
+
290
+ This is a minimal, self-contained implementation so the
291
+ handler does not depend on the server's breakthrough_engine.
292
+ """
293
+ parts = fen.strip().split()
294
+ turn = parts[1] if len(parts) > 1 else WHITE
295
+
296
+ # Parse ranks β†’ grid
297
+ grid: list[list[str]] = []
298
+ for rank_str in parts[0].split("/"):
299
+ row: list[str] = []
300
+ for ch in rank_str:
301
+ if ch.isdigit():
302
+ row.extend([EMPTY] * int(ch))
303
+ else:
304
+ row.append(ch)
305
+ grid.append(row)
306
+
307
+ piece = WHITE_PIECE if turn == WHITE else BLACK_PIECE
308
+ direction = -1 if turn == WHITE else 1
309
+ moves: list[str] = []
310
+
311
+ for r in range(BOARD_SIZE):
312
+ for c in range(BOARD_SIZE):
313
+ if grid[r][c] != piece:
314
+ continue
315
+ nr = r + direction
316
+ if nr < 0 or nr >= BOARD_SIZE:
317
+ continue
318
+ # Straight forward on empty
319
+ if grid[nr][c] == EMPTY:
320
+ moves.append(
321
+ f"{_FILES[c]}{_RANKS[BOARD_SIZE - 1 - r]}"
322
+ f"{_FILES[c]}{_RANKS[BOARD_SIZE - 1 - nr]}"
323
+ )
324
+ # Diagonals β€” capture or empty
325
+ for dc in (-1, 1):
326
+ nc = c + dc
327
+ if nc < 0 or nc >= BOARD_SIZE:
328
+ continue
329
+ if grid[nr][nc] == piece:
330
+ continue # own piece
331
+ moves.append(
332
+ f"{_FILES[c]}{_RANKS[BOARD_SIZE - 1 - r]}"
333
+ f"{_FILES[nc]}{_RANKS[BOARD_SIZE - 1 - nr]}"
334
+ )
335
+
336
+ return moves