rulesmith 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rulesmith/chess.py ADDED
@@ -0,0 +1,526 @@
1
+ """Score decision graphs by playing chess, one graph execution per move.
2
+
3
+ A graph cannot name a move: there are thousands and which are legal changes every ply. It names
4
+ a policy instead -- take the best capture, get the king castled, save the piece that is hanging --
5
+ and the board turns that into a move, deterministically. The labels are the policies, so the same
6
+ rule language that presses buttons in Doom plays chess here.
7
+ """
8
+
9
+ import random
10
+ from typing import Literal, Self
11
+
12
+ import chess
13
+ import chess.engine
14
+ from pydantic import Field, model_validator
15
+
16
+ from rulesmith.graph import Plan, StrictModel, Text
17
+ from rulesmith.optimize import Outcome
18
+ from rulesmith.runtime import DecisionProgram, undecided
19
+
20
+ VALUES = {chess.PAWN: 1, chess.KNIGHT: 3, chess.BISHOP: 3, chess.ROOK: 5, chess.QUEEN: 9}
21
+ CENTER = [chess.D4, chess.E4, chess.D5, chess.E5]
22
+ # Material left on both sides, below which the game is played as an endgame.
23
+ ENDGAME = 24
24
+ # The edge that decides a game stopped at the move limit: a minor piece, not a pawn.
25
+ MARGIN = 3
26
+
27
+
28
+ def worth(piece: chess.Piece | None) -> int:
29
+ return VALUES.get(piece.piece_type, 0) if piece else 0
30
+
31
+
32
+ def gain(board: chess.Board, move: chess.Move) -> int:
33
+ """What a move wins in material, less the mover if the square can be taken back. An
34
+ approximation of an exchange search: one recapture deep, which is what the labels need."""
35
+ taken = worth(board.piece_at(move.to_square))
36
+ if board.is_en_passant(move):
37
+ taken = VALUES[chess.PAWN]
38
+ mover = worth(board.piece_at(move.from_square))
39
+ if move.promotion:
40
+ # The pawn becomes the new piece, which is both the gain and what can be taken back.
41
+ taken += VALUES[move.promotion] - VALUES[chess.PAWN]
42
+ mover = VALUES[move.promotion]
43
+ board.push(move)
44
+ exposed = board.is_attacked_by(board.turn, move.to_square)
45
+ board.pop()
46
+ return taken - (mover if exposed else 0)
47
+
48
+
49
+ def centrality(square: int) -> int:
50
+ """How near the middle a square is; ties between developing moves break on this."""
51
+ file, rank = chess.square_file(square), chess.square_rank(square)
52
+ return -(abs(2 * file - 7) + abs(2 * rank - 7))
53
+
54
+
55
+ def hanging(board: chess.Board) -> list[tuple[int, int]]:
56
+ """Own pieces the opponent attacks and this side does not defend, worst first."""
57
+ loose = []
58
+ for square, piece in board.piece_map().items():
59
+ if piece.color != board.turn or piece.piece_type == chess.KING:
60
+ continue
61
+ if board.is_attacked_by(not board.turn, square) and not board.is_attacked_by(
62
+ board.turn, square
63
+ ):
64
+ loose.append((worth(piece), square))
65
+ return sorted(loose, reverse=True)
66
+
67
+
68
+ def quiet(board: chess.Board, move: chess.Move) -> bool:
69
+ return not board.is_capture(move) and not board.gives_check(move)
70
+
71
+
72
+ def take(board: chess.Board) -> chess.Move | None:
73
+ captures = [move for move in board.legal_moves if board.is_capture(move)]
74
+ return max(captures, key=lambda move: (gain(board, move), move.uci()), default=None)
75
+
76
+
77
+ def mates(board: chess.Board, move: chess.Move) -> bool:
78
+ board.push(move)
79
+ over = board.is_checkmate()
80
+ board.pop()
81
+ return over
82
+
83
+
84
+ def check(board: chess.Board) -> chess.Move | None:
85
+ """A checking move, and if one of them ends the game, that one: a policy that says 'give
86
+ check' and passes over mate in one is not doing what it says."""
87
+ checks = [move for move in board.legal_moves if board.gives_check(move)]
88
+ return max(
89
+ checks, key=lambda move: (mates(board, move), gain(board, move), move.uci()), default=None
90
+ )
91
+
92
+
93
+ def castle(board: chess.Board) -> chess.Move | None:
94
+ castles = [move for move in board.legal_moves if board.is_castling(move)]
95
+ return min(castles, key=lambda move: move.uci(), default=None)
96
+
97
+
98
+ def develop(board: chess.Board) -> chess.Move | None:
99
+ """A knight or bishop off its own back rank, towards the middle."""
100
+ home = 0 if board.turn == chess.WHITE else 7
101
+ moves = [
102
+ move
103
+ for move in board.legal_moves
104
+ if (piece := board.piece_at(move.from_square))
105
+ and piece.piece_type in (chess.KNIGHT, chess.BISHOP)
106
+ and chess.square_rank(move.from_square) == home
107
+ and gain(board, move) >= 0
108
+ ]
109
+ return max(moves, key=lambda move: (centrality(move.to_square), move.uci()), default=None)
110
+
111
+
112
+ def advance(board: chess.Board) -> chess.Move | None:
113
+ """A pawn forward: into the middle early, up the board once the pieces have gone."""
114
+ pawns = [
115
+ move
116
+ for move in board.legal_moves
117
+ if (piece := board.piece_at(move.from_square))
118
+ and piece.piece_type == chess.PAWN
119
+ and gain(board, move) >= 0
120
+ ]
121
+ ahead = 1 if board.turn == chess.WHITE else -1
122
+ return max(
123
+ pawns,
124
+ key=lambda move: (
125
+ move.to_square in CENTER,
126
+ ahead * (chess.square_rank(move.to_square) - chess.square_rank(move.from_square)),
127
+ centrality(move.to_square),
128
+ move.uci(),
129
+ ),
130
+ default=None,
131
+ )
132
+
133
+
134
+ def save(board: chess.Board) -> chess.Move | None:
135
+ """Move the most valuable hanging piece somewhere it is not attacked."""
136
+ for _, square in hanging(board):
137
+ escapes = [
138
+ move
139
+ for move in board.legal_moves
140
+ if move.from_square == square and gain(board, move) >= 0
141
+ ]
142
+ safe = [move for move in escapes if not exposes(board, move)]
143
+ if safe or escapes:
144
+ return max(
145
+ safe or escapes, key=lambda move: (gain(board, move), centrality(move.to_square))
146
+ )
147
+ return None
148
+
149
+
150
+ def exposes(board: chess.Board, move: chess.Move) -> bool:
151
+ board.push(move)
152
+ attacked = board.is_attacked_by(board.turn, move.to_square)
153
+ board.pop()
154
+ return attacked
155
+
156
+
157
+ def settle(board: chess.Board) -> chess.Move:
158
+ """The move played when a policy has nothing to offer: a quiet move that keeps the piece it
159
+ moves, else the one that loses least. It must not reach for a capture the policy did not
160
+ ask for -- a fallback that grabs material makes every policy the same policy."""
161
+ moves = sorted(board.legal_moves, key=lambda move: move.uci())
162
+ calm = [move for move in moves if quiet(board, move) and not exposes(board, move)]
163
+ if calm:
164
+ return max(calm, key=lambda move: (centrality(move.to_square), move.uci()))
165
+ return max(moves, key=lambda move: (gain(board, move), move.uci()))
166
+
167
+
168
+ def loose_for(board: chess.Board, color: bool) -> list[tuple[int, int]]:
169
+ """Pieces of one color the other side attacks and that color does not defend, worst first."""
170
+ found = []
171
+ for square, piece in board.piece_map().items():
172
+ if piece.color != color or piece.piece_type == chess.KING:
173
+ continue
174
+ if board.is_attacked_by(not color, square) and not board.is_attacked_by(color, square):
175
+ found.append((worth(piece), square))
176
+ return sorted(found, reverse=True)
177
+
178
+
179
+ def ring(board: chess.Board, color: bool) -> int:
180
+ """How many squares around a king the other side attacks: pressure before it becomes check."""
181
+ king = board.king(color)
182
+ if king is None:
183
+ return 0
184
+ around = chess.SquareSet(chess.BB_KING_ATTACKS[king])
185
+ return sum(1 for square in around if board.is_attacked_by(not color, square))
186
+
187
+
188
+ def passed(board: chess.Board, color: bool, square: int) -> bool:
189
+ """No enemy pawn ahead of this one on its file or the ones beside it."""
190
+ ahead = 1 if color == chess.WHITE else -1
191
+ file, rank = chess.square_file(square), chess.square_rank(square)
192
+ return not any(
193
+ chess.square_file(other) in (file - 1, file, file + 1)
194
+ and (chess.square_rank(other) - rank) * ahead > 0
195
+ for other in board.pieces(chess.PAWN, not color)
196
+ )
197
+
198
+
199
+ def passers(board: chess.Board, color: bool) -> list[int]:
200
+ """Own passed pawns, by how far up the board they are."""
201
+ return sorted(
202
+ (
203
+ chess.square_rank(square) if color == chess.WHITE else 7 - chess.square_rank(square)
204
+ for square in board.pieces(chess.PAWN, color)
205
+ if passed(board, color, square)
206
+ ),
207
+ reverse=True,
208
+ )
209
+
210
+
211
+ def castled(board: chess.Board, color: bool) -> bool:
212
+ """Whether this side castled, not merely moved its king. Replaying the game is the only
213
+ way to tell, so it is done only when the king stands where castling puts it."""
214
+ home = (chess.G1, chess.C1) if color == chess.WHITE else (chess.G8, chess.C8)
215
+ if board.king(color) not in home:
216
+ return False
217
+ replay = board.root()
218
+ for move in board.move_stack:
219
+ if replay.turn == color and replay.is_castling(move):
220
+ return True
221
+ replay.push(move)
222
+ return False
223
+
224
+
225
+ def promote(board: chess.Board) -> chess.Move | None:
226
+ """Push the passed pawn that is furthest up the board, promoting when it can."""
227
+ ahead = 1 if board.turn == chess.WHITE else -1
228
+ free = {
229
+ square
230
+ for square in board.pieces(chess.PAWN, board.turn)
231
+ if passed(board, board.turn, square)
232
+ }
233
+ pushes = [
234
+ move
235
+ for move in board.legal_moves
236
+ if move.from_square in free
237
+ and (piece := board.piece_at(move.from_square))
238
+ and piece.piece_type == chess.PAWN
239
+ and (chess.square_rank(move.to_square) - chess.square_rank(move.from_square)) * ahead > 0
240
+ and gain(board, move) >= 0
241
+ ]
242
+ if not pushes:
243
+ return None
244
+
245
+ def reach(move: chess.Move) -> int:
246
+ rank = chess.square_rank(move.to_square)
247
+ return rank if board.turn == chess.WHITE else 7 - rank
248
+
249
+ return max(pushes, key=lambda move: (move.promotion or 0, reach(move), move.uci()))
250
+
251
+
252
+ POLICIES = {
253
+ "CAPTURE": take,
254
+ "CHECK": check,
255
+ "CASTLE": castle,
256
+ "DEVELOP": develop,
257
+ "ADVANCE": advance,
258
+ "SAVE": save,
259
+ "PROMOTE": promote,
260
+ "QUIET": lambda board: None,
261
+ }
262
+
263
+
264
+ def chosen(board: chess.Board, label: str) -> chess.Move:
265
+ """The move a policy names in this position; every policy always yields a legal move."""
266
+ if label not in POLICIES:
267
+ raise ValueError(f"unknown policy {label!r}; the task allows {sorted(POLICIES)}")
268
+ return POLICIES[label](board) or settle(board)
269
+
270
+
271
+ def observe(board: chess.Board) -> dict:
272
+ """What the side to move can see, as numbers a read node can take by path."""
273
+ mine = sum(worth(piece) for piece in board.piece_map().values() if piece.color == board.turn)
274
+ theirs = sum(worth(piece) for piece in board.piece_map().values() if piece.color != board.turn)
275
+ loose = hanging(board)
276
+ theirs_loose = loose_for(board, not board.turn)
277
+ mine_passed = passers(board, board.turn)
278
+ best = take(board)
279
+ checking = check(board)
280
+ back = 0 if board.turn == chess.WHITE else 7
281
+ return {
282
+ "ply": board.ply(),
283
+ "phase": "endgame"
284
+ if mine + theirs <= ENDGAME
285
+ else "opening"
286
+ if board.ply() < 20
287
+ else "middlegame",
288
+ "material": {"mine": mine, "theirs": theirs, "balance": mine - theirs},
289
+ "king": {
290
+ "in_check": int(board.is_check()),
291
+ "attackers": len(board.attackers(not board.turn, board.king(board.turn))),
292
+ "can_castle": int(bool(castle(board))),
293
+ "castled": int(castled(board, board.turn)),
294
+ },
295
+ "capture": {
296
+ "best_gain": gain(board, best) if best else 0,
297
+ "count": sum(1 for move in board.legal_moves if board.is_capture(move)),
298
+ },
299
+ "check": {
300
+ "available": int(bool(checking)),
301
+ "gain": gain(board, checking) if checking else 0,
302
+ },
303
+ "hanging": {"count": len(loose), "worst": loose[0][0] if loose else 0},
304
+ # What is loose on the other side is a reason to go looking, not only a capture to take.
305
+ "target": {
306
+ "loose_count": len(theirs_loose),
307
+ "loose_worst": theirs_loose[0][0] if theirs_loose else 0,
308
+ "king_ring": ring(board, not board.turn),
309
+ },
310
+ "pawns": {
311
+ "passed": len(mine_passed),
312
+ "furthest": mine_passed[0] if mine_passed else 0,
313
+ "theirs_passed": len(passers(board, not board.turn)),
314
+ },
315
+ "safety": {"king_ring": ring(board, board.turn)},
316
+ "development": {
317
+ "minor_home": sum(
318
+ 1
319
+ for square, piece in board.piece_map().items()
320
+ if piece.color == board.turn
321
+ and piece.piece_type in (chess.KNIGHT, chess.BISHOP)
322
+ and chess.square_rank(square) == back
323
+ )
324
+ },
325
+ "moves": {"count": board.legal_moves.count()},
326
+ }
327
+
328
+
329
+ class ChessTask(StrictModel):
330
+ game: Literal["chess"] = "chess"
331
+ description: Text
332
+ labels: list[Text] = Field(min_length=2, description="Policies the graph may name")
333
+ opening_plies: int = Field(default=4, ge=0, description="Random legal moves opening a game")
334
+ move_limit: int = Field(default=120, ge=2, description="Plies before the game is adjudicated")
335
+ material_weight: float = Field(
336
+ default=0.2,
337
+ ge=0,
338
+ le=0.5,
339
+ description="Share of the score decided by material rather than by the result, so that "
340
+ "a game nobody wins still says which graph stood better",
341
+ )
342
+ survival_weight: float = Field(
343
+ default=0,
344
+ ge=0,
345
+ le=0.5,
346
+ description="Share of the score decided by how long a lost or drawn game lasted, so "
347
+ "that against an opponent no graph can beat, holding out longer still scores higher",
348
+ )
349
+ train: list[int] = Field(min_length=1)
350
+ validation: list[int] = Field(min_length=1)
351
+ test: list[int] = Field(min_length=1)
352
+
353
+ @model_validator(mode="after")
354
+ def consistent(self) -> Self:
355
+ if len(set(self.labels)) != len(self.labels):
356
+ raise ValueError("labels must be distinct")
357
+ unknown = set(self.labels) - set(POLICIES)
358
+ if unknown:
359
+ raise ValueError(f"unknown policies: {sorted(unknown)}; allowed {sorted(POLICIES)}")
360
+ seeds = self.train + self.validation + self.test
361
+ if len(set(seeds)) != len(seeds):
362
+ raise ValueError("seeds must be distinct across splits")
363
+ # A win has to outrank any other result whatever the material, which holds while the
364
+ # result's share of the score is more than twice material's.
365
+ if 1 - 3 * self.material_weight - self.survival_weight <= 0:
366
+ raise ValueError("material and survival weights leave the result too little weight")
367
+ return self
368
+
369
+ def baseline_plan(self) -> Plan:
370
+ return Plan.constant(self.labels[0])
371
+
372
+ def opening(self, seed: int) -> chess.Board:
373
+ """A board a few random legal moves in, so that one seed is one game."""
374
+ board = chess.Board()
375
+ rng = random.Random(seed)
376
+ for _ in range(self.opening_plies):
377
+ moves = sorted(board.legal_moves, key=lambda move: move.uci())
378
+ if not moves or board.is_game_over():
379
+ break
380
+ board.push(rng.choice(moves))
381
+ return board
382
+
383
+
384
+ class Engine:
385
+ """A UCI engine at a fixed strength, as a reference opponent that does not drift the way
386
+ Elo fitted over a pool of graphs does. Stockfish's skill level 0 is calibrated near 1320
387
+ CCRL blitz Elo."""
388
+
389
+ def __init__(self, path: str, skill: int, seconds: float):
390
+ self.path, self.skill, self.limit = path, skill, chess.engine.Limit(time=seconds)
391
+
392
+ def __enter__(self) -> Self:
393
+ self.engine = chess.engine.SimpleEngine.popen_uci(self.path)
394
+ self.engine.configure({"Skill Level": self.skill})
395
+ return self
396
+
397
+ def __exit__(self, *raised) -> None:
398
+ self.engine.quit()
399
+
400
+ def move(self, board: chess.Board) -> chess.Move:
401
+ return self.engine.play(board, self.limit).move
402
+
403
+
404
+ Player = DecisionProgram | Engine
405
+
406
+
407
+ def played(task: ChessTask, white: Player, black: Player, seed: int) -> dict:
408
+ """One game. Returns the result from white's side, with what each side did along the way."""
409
+ board = task.opening(seed)
410
+ sides = {chess.WHITE: white, chess.BLACK: black}
411
+ moves, models, error, loser = [], set(), None, None
412
+ first: dict[bool, dict] = {}
413
+ while not board.is_game_over() and board.ply() < task.move_limit:
414
+ program = sides[board.turn]
415
+ if isinstance(program, Engine):
416
+ move = program.move(board)
417
+ moves.append({"policy": "engine", "move": move.uci()})
418
+ board.push(move)
419
+ continue
420
+ seen = observe(board)
421
+ first.setdefault(board.turn, seen)
422
+ prediction = program(state=seen)
423
+ models.update(prediction.models)
424
+ if prediction.undecided:
425
+ error, loser = undecided(program.plan, prediction), board.turn
426
+ break
427
+ try:
428
+ move = chosen(board, prediction.label)
429
+ except ValueError as failure:
430
+ error, loser = str(failure), board.turn
431
+ break
432
+ moves.append({"policy": prediction.label, "move": move.uci()})
433
+ board.push(move)
434
+ edge = material(board, chess.WHITE)
435
+ if board.is_game_over():
436
+ outcome = board.result(claim_draw=True)
437
+ else:
438
+ # Two graphs that cannot mate will shuffle to the move limit, and calling every one of
439
+ # those a draw leaves search nothing to climb. A game stopped a piece down was lost.
440
+ outcome = "1-0" if edge >= MARGIN else "0-1" if edge <= -MARGIN else "1/2-1/2"
441
+ if loser is not None:
442
+ outcome = "0-1" if loser == chess.WHITE else "1-0"
443
+ return {
444
+ "result": outcome,
445
+ "moves": moves,
446
+ "fen": board.fen(),
447
+ "material": edge,
448
+ "adjudicated": not board.is_game_over() and loser is None,
449
+ "models": sorted(models),
450
+ "error": error,
451
+ "failed": loser,
452
+ # What each side saw on its first move, which is where its choices began.
453
+ "first": first,
454
+ }
455
+
456
+
457
+ def material(board: chess.Board, color: bool) -> int:
458
+ ours = sum(worth(p) for p in board.piece_map().values() if p.color == color)
459
+ theirs = sum(worth(p) for p in board.piece_map().values() if p.color != color)
460
+ return ours - theirs
461
+
462
+
463
+ def graded(task: ChessTask, points: float, edge: int, plies: int) -> float:
464
+ """A game's score: mostly its result, partly the material left, and partly how long it
465
+ lasted. A win counts as lasting the whole game, since ending it sooner is no worse."""
466
+ material, survival = task.material_weight, task.survival_weight
467
+ held = 1.0 if points == 1 else min(1.0, plies / (task.move_limit - task.opening_plies))
468
+ return (
469
+ (1 - material - survival) * points
470
+ + material * (0.5 + max(-15, min(15, edge)) / 30)
471
+ + survival * held
472
+ )
473
+
474
+
475
+ class Games:
476
+ """Scores a graph by how it fares against a fixed opponent, one game per seed."""
477
+
478
+ def __init__(self, task: ChessTask, opponent: Player):
479
+ self.task = task
480
+ self.opponent = opponent
481
+
482
+ def __call__(self, program: DecisionProgram, seed: int) -> Outcome:
483
+ # Colors alternate by seed, so across a split a graph plays as many games as each side.
484
+ mine = chess.WHITE if seed % 2 == 0 else chess.BLACK
485
+ white, black = (program, self.opponent) if mine == chess.WHITE else (self.opponent, program)
486
+ game = played(self.task, white, black, seed)
487
+ points = {"1-0": 1.0, "0-1": 0.0, "1/2-1/2": 0.5, "*": 0.5}[game["result"]]
488
+ if mine == chess.BLACK:
489
+ points = 1 - points
490
+ edge = game["material"] if mine == chess.WHITE else -game["material"]
491
+ score = graded(self.task, points, edge, len(game["moves"]))
492
+ said = "won" if points == 1 else "lost" if points == 0 else "drew"
493
+ # Only this graph's own failure is its error; an opponent that breaks simply loses.
494
+ error = game["error"] if game["failed"] == mine else None
495
+ if error is None and game["error"] is not None:
496
+ said = f"won because the opponent failed ({game['error']})"
497
+ return Outcome(
498
+ score=score if error is None else 0.0,
499
+ record={
500
+ "seed": seed,
501
+ "color": "white" if mine == chess.WHITE else "black",
502
+ "result": game["result"],
503
+ "points": points,
504
+ "material": edge,
505
+ "plies": len(game["moves"]),
506
+ "policies": {
507
+ policy: sum(1 for m in game["moves"] if m["policy"] == policy)
508
+ for policy in sorted({m["policy"] for m in game["moves"]})
509
+ },
510
+ "score": score,
511
+ "error": error,
512
+ "fen": game["fen"],
513
+ },
514
+ trace={
515
+ "Inputs": game["first"].get(mine, {}),
516
+ "Generated Outputs": {"moves": game["moves"][-8:]},
517
+ "Feedback": error
518
+ or (
519
+ f"Playing {'white' if mine == chess.WHITE else 'black'} from seed {seed} you "
520
+ f"{said} in {len(game['moves'])} plies, ending {edge:+d} in material "
521
+ f"(score {score:.3f}). Higher is better."
522
+ ),
523
+ },
524
+ models=game["models"],
525
+ error=error,
526
+ )
rulesmith/clef.py ADDED
@@ -0,0 +1,66 @@
1
+ """Clef, Cloudflare's decision model, served from its own release: `POST /v1/systemone`
2
+ answered as Jev answers it, on an NVIDIA GPU, Apple's, or a CPU, for `--backend clef`."""
3
+
4
+ import importlib
5
+ import sys
6
+ import threading
7
+ import traceback
8
+ from collections.abc import Callable
9
+ from http import HTTPStatus
10
+ from http.server import ThreadingHTTPServer
11
+ from pathlib import Path
12
+
13
+ from rulesmith.serve import JSONHandler
14
+
15
+ PATH = "/v1/systemone"
16
+
17
+ Answer = Callable[[dict], dict]
18
+
19
+
20
+ def load(weights: str, device: str | None) -> tuple[Answer, str]:
21
+ """The release's own `systemone` bound to its model, and the device it runs on: `device`,
22
+ or else the best this machine has. `weights` is a Hugging Face repo or a downloaded copy."""
23
+ # The clef extra's packages, imported only here so the rest of rulesmith installs without.
24
+ import torch
25
+ from huggingface_hub import snapshot_download
26
+
27
+ if device is None:
28
+ if torch.cuda.is_available():
29
+ device = "cuda"
30
+ elif torch.backends.mps.is_available():
31
+ device = "mps"
32
+ else:
33
+ device = "cpu"
34
+ print("warning: no GPU found; Clef will run on the CPU, slowly", file=sys.stderr)
35
+ path = Path(weights) if Path(weights).is_dir() else Path(snapshot_download(weights))
36
+ # The model's code ships beside its weights, as the release's model card loads it.
37
+ sys.path.insert(0, str(path))
38
+ release = importlib.import_module("joint_schema_model")
39
+ model, processor = release.load_release_model(path, device=device)
40
+ return (lambda request: release.systemone(model, processor, request)), device
41
+
42
+
43
+ def make_server(answer: Answer, host: str, port: int) -> ThreadingHTTPServer:
44
+ """A server that answers each systemone request with `answer`, one at a time."""
45
+
46
+ class Handler(JSONHandler):
47
+ def post(self, request):
48
+ if self.path != PATH:
49
+ return HTTPStatus.NOT_FOUND, {"error": f"nothing at {self.path}; ask {PATH}"}
50
+ if not isinstance(request, dict):
51
+ return HTTPStatus.BAD_REQUEST, {"error": "a systemone request is a JSON object"}
52
+ try:
53
+ with self.server.lock:
54
+ return HTTPStatus.OK, answer(request)
55
+ # The release checks a request as it reads it, so a malformed one fails here.
56
+ except (ValueError, KeyError, TypeError, AttributeError) as error:
57
+ return HTTPStatus.BAD_REQUEST, {"error": f"not a systemone request: {error}"}
58
+ except RuntimeError as error:
59
+ # Out of memory, most often: the caller hears why, the log keeps where.
60
+ traceback.print_exc()
61
+ return HTTPStatus.INTERNAL_SERVER_ERROR, {"error": f"the model failed: {error}"}
62
+
63
+ server = ThreadingHTTPServer((host, port), Handler)
64
+ # One model on one device, which the release does not make safe to run from two threads.
65
+ server.lock = threading.Lock()
66
+ return server