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/__init__.py +1 -0
- rulesmith/ablate.py +99 -0
- rulesmith/arena.py +172 -0
- rulesmith/bench.py +1068 -0
- rulesmith/calibrate.py +457 -0
- rulesmith/chat_judge.py +205 -0
- rulesmith/chess.py +526 -0
- rulesmith/clef.py +66 -0
- rulesmith/cli.py +1234 -0
- rulesmith/diagram.py +226 -0
- rulesmith/doom.py +550 -0
- rulesmith/extract.py +77 -0
- rulesmith/grade.py +85 -0
- rulesmith/graph.py +975 -0
- rulesmith/label.py +67 -0
- rulesmith/level.py +389 -0
- rulesmith/maps.py +96 -0
- rulesmith/mine.py +313 -0
- rulesmith/optimize.py +931 -0
- rulesmith/rules.py +1017 -0
- rulesmith/runtime.py +711 -0
- rulesmith/serve.py +68 -0
- rulesmith/tuning.py +134 -0
- rulesmith-0.1.0.dist-info/METADATA +131 -0
- rulesmith-0.1.0.dist-info/RECORD +28 -0
- rulesmith-0.1.0.dist-info/WHEEL +4 -0
- rulesmith-0.1.0.dist-info/entry_points.txt +2 -0
- rulesmith-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|