breakthrough-model / handler.py
test1978's picture
Add handler.py
9940d2a verified
Raw History Blame
13.3 kB
# ──────────────────────────────────────────────
# handler.py
#
# Custom handler for HF Inference Endpoints.
#
# When HF Inference Endpoints detects a handler.py
# in the repo root, it uses this class to serve
# predictions instead of the default Transformers
# pipeline.
#
# Expected repo layout:
#
# your-username/breakthrough-model/
# β”œβ”€β”€ handler.py ← this file
# β”œβ”€β”€ config_model.json ← lists your Python modules + main
# β”œβ”€β”€ config_data.json ← lists data files + zone_db key
# β”œβ”€β”€ breakthrough_mcvs.py ← your UCT search / eval module
# └── requirements.txt ← runtime deps (numpy, huggingface_hub)
#
# config_model.json example:
#
# {
# "files": ["matrix_model.py", "abc_model.py", "breakthrough_mcvs.py"],
# "main": "breakthrough_mcvs"
# }
#
# config_data.json example:
#
# {
# "files": ["breakthrough_zone_db.npz"],
# "zone_db": "breakthrough_zone_db.npz"
# }
#
# Set the HF_DATA_REPO_ID environment variable to
# the HF dataset repo holding your data files.
#
# Request format (sent by Agladiator):
#
# POST /
# {
# "inputs": {
# "fen": "BBBBBBBB/BBBBBBBB/8/8/8/8/WWWWWWWW/WWWWWWWW w",
# "player": "w",
# "game_type": "breakthrough"
# }
# }
#
# Response format (what you must return):
#
# {"move": "a2a3"}
#
# ──────────────────────────────────────────────
from __future__ import annotations
import importlib.util
import json
import logging
import os
import random
import sys
from pathlib import Path
import numpy as np
log = logging.getLogger(__name__)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Breakthrough board constants (must match the
# server's breakthrough_engine.py)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
BOARD_SIZE = 8
_FILES = "abcdefgh"
_RANKS = "12345678"
WHITE, BLACK = "w", "b"
WHITE_PIECE, BLACK_PIECE, EMPTY = "W", "B", "."
class EndpointHandler:
"""Custom handler for Breakthrough AI on HF Inference Endpoints.
Lifecycle:
1. ``__init__`` is called once when the endpoint container starts.
Use it to load your model artifacts (modules, zone DB, etc.).
2. ``__call__`` is called for every inference request.
It receives the parsed JSON body and must return a dict.
"""
def __init__(self, path: str):
"""Load model artefacts from *path* (the cloned repo directory).
Steps:
1. Read ``config_model.json`` to discover module files and main module.
2. Dynamically import each listed module.
3. Read ``config_data.json``, download data files, load zone DB.
4. Initialise the search engine.
"""
self.path = Path(path)
self.predict_fn = None
self.zone_db = None
self.module = None
# ── 1. Read config_model.json ───────────
model_config_path = self.path / "config_model.json"
if not model_config_path.exists():
log.warning("No config_model.json found in %s", path)
model_config: dict = {}
else:
with open(model_config_path) as fh:
model_config = json.load(fh)
main_module_name = model_config.get("main", "")
# ── 2. Import listed modules ────────────
# Add repo dir to sys.path so relative imports work.
if str(self.path) not in sys.path:
sys.path.insert(0, str(self.path))
for mod_file in model_config.get("files", []):
mod_name = mod_file.removesuffix(".py")
mod_path = self.path / mod_file
if not mod_path.exists():
log.warning("Module file %s not found β€” skipping.", mod_file)
continue
spec = importlib.util.spec_from_file_location(mod_name, str(mod_path))
mod = importlib.util.module_from_spec(spec)
sys.modules[mod_name] = mod
spec.loader.exec_module(mod)
log.info("Loaded module: %s", mod_name)
# Mark the module matching "main" as the primary module.
if mod_name == main_module_name:
self.module = mod
# ── 3. Read config_data.json & load zone DB ──
data_config_path = self.path / "config_data.json"
if not data_config_path.exists():
log.warning("No config_data.json found in %s", path)
data_config: dict = {}
else:
with open(data_config_path) as fh:
data_config = json.load(fh)
data_repo = os.environ.get("HF_DATA_REPO_ID", "")
downloaded_files: dict[str, str] = {}
if data_repo:
try:
from huggingface_hub import hf_hub_download
except Exception:
hf_hub_download = None
log.warning(
"huggingface_hub not installed β€” cannot download data files. Add it to requirements.txt"
)
if hf_hub_download is not None:
for data_file in data_config.get("files", []):
try:
local_path = hf_hub_download(
repo_id=data_repo,
filename=data_file,
repo_type="dataset",
)
downloaded_files[data_file] = local_path
log.info("Downloaded %s from %s", data_file, data_repo)
except Exception:
log.warning(
"Failed to download %s from %s β€” skipping.",
data_file,
data_repo,
)
else:
log.warning(
"HF_DATA_REPO_ID not set β€” skipping data file downloads."
)
zone_db_filename = data_config.get("zone_db", "")
if zone_db_filename and zone_db_filename in downloaded_files:
self.zone_db = np.load(
downloaded_files[zone_db_filename], allow_pickle=True
)
log.info("Zone DB loaded from data repo %s", data_repo)
elif zone_db_filename:
local_zone = self.path / zone_db_filename
if local_zone.exists():
self.zone_db = np.load(str(local_zone), allow_pickle=True)
log.info("Zone DB loaded from local file %s", local_zone)
else:
log.warning("No zone DB found for '%s'.", zone_db_filename)
# ── 4. Initialise the predictor ─────────
self._init_predictor()
log.info("EndpointHandler initialised successfully.")
# ──────────────────────────────────────────
# Predictor initialisation
# ──────────────────────────────────────────
def _init_predictor(self):
"""Discover and configure the prediction function from the
loaded module. Tries several common patterns:
1. ``module.get_move(fen, player, zone_db=...)``
2. ``module.UCTSearcher(zone_db=...).search(fen, player)``
3. ``module.predict(fen, player)``
"""
if self.module is None:
log.warning("No module loaded β€” predictions will use random fallback.")
return
# Pattern 1: module exposes a top-level get_move()
if hasattr(self.module, "get_move"):
raw_fn = self.module.get_move
self.predict_fn = lambda fen, player: raw_fn(
fen, player, zone_db=self.zone_db
)
log.info("Using module.get_move() for predictions.")
return
# Pattern 2: module exposes a UCTSearcher class
if hasattr(self.module, "UCTSearcher"):
searcher_cls = self.module.UCTSearcher
kwargs = {}
if self.zone_db is not None:
kwargs["zone_db"] = self.zone_db
searcher = searcher_cls(**kwargs)
self.predict_fn = lambda fen, player: searcher.search(fen, player)
log.info("Using module.UCTSearcher for predictions.")
return
# Pattern 3: module exposes a predict()
if hasattr(self.module, "predict"):
raw_fn = self.module.predict
self.predict_fn = lambda fen, player: raw_fn(fen, player)
log.info("Using module.predict() for predictions.")
return
log.warning(
"Module %s has no get_move / UCTSearcher / predict β€” "
"predictions will use random fallback.",
self.module.__name__,
)
# ──────────────────────────────────────────
# Inference entry point
# ──────────────────────────────────────────
def __call__(self, data: dict) -> dict:
"""Handle an inference request.
Expected *data*::
{"inputs": {"fen": "...", "player": "w"}}
Returns::
{"move": "a2a3"}
Falls back to a random legal move if the model fails.
"""
inputs = data.get("inputs", data)
fen = inputs.get("fen", "")
player = inputs.get("player", WHITE)
if not fen:
return {"error": "Missing 'fen' in request."}
# Try model prediction
if self.predict_fn is not None:
try:
move = self.predict_fn(fen, player)
if move and isinstance(move, str):
return {"move": move}
except Exception:
log.exception("Model prediction failed for FEN=%s", fen)
# Fallback: random legal move
moves = self._legal_moves(fen)
if not moves:
return {"error": "No legal moves available.", "move": "0000"}
# Prefer captures (diagonal moves in Breakthrough)
captures = [m for m in moves if m[0] != m[2]]
move = random.choice(captures) if captures else random.choice(moves)
return {"move": move}
# ──────────────────────────────────────────
# Minimal legal-move generator (self-contained)
# ──────────────────────────────────────────
@staticmethod
def _legal_moves(fen: str) -> list[str]:
"""Return all legal UCI moves for the given Breakthrough FEN.
This is a minimal, self-contained implementation so the
handler does not depend on the server's breakthrough_engine.
"""
parts = fen.strip().split()
turn = parts[1] if len(parts) > 1 else WHITE
# Parse ranks β†’ grid
grid: list[list[str]] = []
for rank_str in parts[0].split("/"):
row: list[str] = []
for ch in rank_str:
if ch.isdigit():
row.extend([EMPTY] * int(ch))
else:
row.append(ch)
grid.append(row)
piece = WHITE_PIECE if turn == WHITE else BLACK_PIECE
direction = -1 if turn == WHITE else 1
moves: list[str] = []
for r in range(BOARD_SIZE):
for c in range(BOARD_SIZE):
if grid[r][c] != piece:
continue
nr = r + direction
if nr < 0 or nr >= BOARD_SIZE:
continue
# Straight forward on empty
if grid[nr][c] == EMPTY:
moves.append(
f"{_FILES[c]}{_RANKS[BOARD_SIZE - 1 - r]}"
f"{_FILES[c]}{_RANKS[BOARD_SIZE - 1 - nr]}"
)
# Diagonals β€” capture or empty
for dc in (-1, 1):
nc = c + dc
if nc < 0 or nc >= BOARD_SIZE:
continue
if grid[nr][nc] == piece:
continue # own piece
moves.append(
f"{_FILES[c]}{_RANKS[BOARD_SIZE - 1 - r]}"
f"{_FILES[nc]}{_RANKS[BOARD_SIZE - 1 - nr]}"
)
return moves