quantik-models 1.0.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.
Files changed (68) hide show
  1. quantik_models/__init__.py +12 -0
  2. quantik_models/arena/__init__.py +25 -0
  3. quantik_models/arena/agents.py +305 -0
  4. quantik_models/arena/autoplay.py +379 -0
  5. quantik_models/arena/match.py +232 -0
  6. quantik_models/arena/pack.py +406 -0
  7. quantik_models/arena/parallel.py +97 -0
  8. quantik_models/arena/probe.py +149 -0
  9. quantik_models/arena/registry.py +213 -0
  10. quantik_models/data/__init__.py +5 -0
  11. quantik_models/data/dataset.py +171 -0
  12. quantik_models/data/exact_corpus.py +153 -0
  13. quantik_models/data/labels.py +44 -0
  14. quantik_models/data/materialize.py +242 -0
  15. quantik_models/data/merge_corpus.py +158 -0
  16. quantik_models/env/__init__.py +21 -0
  17. quantik_models/env/fastboard.py +414 -0
  18. quantik_models/eval/__init__.py +1 -0
  19. quantik_models/eval/shift.py +379 -0
  20. quantik_models/export/__init__.py +1 -0
  21. quantik_models/export/cards.py +113 -0
  22. quantik_models/export/checkpoint.py +205 -0
  23. quantik_models/export/devdata.py +571 -0
  24. quantik_models/export/digest.py +22 -0
  25. quantik_models/export/huggingface.py +923 -0
  26. quantik_models/hub.py +526 -0
  27. quantik_models/model/__init__.py +1 -0
  28. quantik_models/model/attention_net.py +147 -0
  29. quantik_models/model/constraint_pool_net.py +220 -0
  30. quantik_models/model/mlp_net.py +107 -0
  31. quantik_models/model/policy_value_net.py +114 -0
  32. quantik_models/model/registry.py +191 -0
  33. quantik_models/model/spec.py +68 -0
  34. quantik_models/model_spec.py +9 -0
  35. quantik_models/play/__init__.py +32 -0
  36. quantik_models/play/__main__.py +132 -0
  37. quantik_models/play/export.py +128 -0
  38. quantik_models/play/opponents.py +216 -0
  39. quantik_models/play/puzzles.py +275 -0
  40. quantik_models/play/record.py +244 -0
  41. quantik_models/play/registry.py +187 -0
  42. quantik_models/play/server.py +311 -0
  43. quantik_models/play/service.py +479 -0
  44. quantik_models/play/store.py +348 -0
  45. quantik_models/py.typed +0 -0
  46. quantik_models/report/__init__.py +1 -0
  47. quantik_models/report/build_figures.py +144 -0
  48. quantik_models/report/figures.py +419 -0
  49. quantik_models/selfplay/__init__.py +12 -0
  50. quantik_models/selfplay/duel.py +90 -0
  51. quantik_models/selfplay/evaluator.py +125 -0
  52. quantik_models/selfplay/generate.py +155 -0
  53. quantik_models/selfplay/mcts.py +330 -0
  54. quantik_models/train/__init__.py +1 -0
  55. quantik_models/train/alphazero.py +330 -0
  56. quantik_models/train/convergence.py +25 -0
  57. quantik_models/train/freezing.py +131 -0
  58. quantik_models/train/metrics.py +25 -0
  59. quantik_models/train/preflight.py +345 -0
  60. quantik_models/train/provenance.py +172 -0
  61. quantik_models/train/supervised.py +437 -0
  62. quantik_models/train/trainer.py +304 -0
  63. quantik_models-1.0.0.dist-info/METADATA +294 -0
  64. quantik_models-1.0.0.dist-info/RECORD +68 -0
  65. quantik_models-1.0.0.dist-info/WHEEL +5 -0
  66. quantik_models-1.0.0.dist-info/entry_points.txt +7 -0
  67. quantik_models-1.0.0.dist-info/licenses/LICENSE +21 -0
  68. quantik_models-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,12 @@
1
+ """Quantik policy/value networks, training, evaluation and play.
2
+
3
+ Published weights for the four architectures live on the Hugging Face Hub
4
+ rather than in this wheel — see `quantik_models.hub` and `docs/models.md`.
5
+ """
6
+
7
+ # The single source of truth for the version. `pyproject.toml` reads this
8
+ # attribute statically (`[tool.setuptools.dynamic]`), so the number is
9
+ # declared once and cannot drift between the package and its metadata.
10
+ __version__ = "1.0.0"
11
+
12
+ __all__ = ["__version__"]
@@ -0,0 +1,25 @@
1
+ """Head-to-head evaluation of Quantik agents."""
2
+
3
+ from .agents import (
4
+ Agent,
5
+ BeamAgent,
6
+ CoreMCTSAgent,
7
+ MinimaxAgent,
8
+ NetMCTSAgent,
9
+ PolicyAgent,
10
+ RandomAgent,
11
+ )
12
+ from .match import MatchResult, play_match, round_robin
13
+
14
+ __all__ = [
15
+ "Agent",
16
+ "BeamAgent",
17
+ "CoreMCTSAgent",
18
+ "MinimaxAgent",
19
+ "NetMCTSAgent",
20
+ "PolicyAgent",
21
+ "RandomAgent",
22
+ "MatchResult",
23
+ "play_match",
24
+ "round_robin",
25
+ ]
@@ -0,0 +1,305 @@
1
+ """Agents playable in the arena, all speaking the 64-slot action index.
2
+
3
+ The classical agents wrap `quantik_core`'s engines unchanged — they are the
4
+ incumbents the network has to beat, so they must be the real thing rather
5
+ than a reimplementation. The network agents drive `BatchedMCTS` (or the raw
6
+ policy head) over `fastboard`.
7
+
8
+ Every agent takes a board as an `(8,) uint16` array and returns one action
9
+ index. Agents carry a `name` and a `config_label` for reporting, mirroring
10
+ the Rust `EngineAdapter` surface.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import random
16
+ from typing import Protocol
17
+
18
+ import numpy as np
19
+ import numpy.typing as npt
20
+
21
+ from quantik_core import State
22
+ from quantik_core.beam_search import BeamSearchConfig, BeamSearchEngine
23
+ from quantik_core.mcts import MCTSConfig, MCTSEngine
24
+ from quantik_core.minimax import MinimaxConfig, MinimaxEngine
25
+ from quantik_core.move import generate_legal_moves_list
26
+
27
+ from ..env import fastboard as fb
28
+ from ..selfplay.mcts import BatchedMCTS, MCTSParams
29
+
30
+ Board = npt.NDArray[np.uint16]
31
+
32
+
33
+ class Agent(Protocol):
34
+ name: str
35
+
36
+ def select(self, board: Board, seed: int) -> int:
37
+ ...
38
+
39
+ def config_label(self) -> str:
40
+ ...
41
+
42
+
43
+ def _bitboard(board: Board) -> tuple[int, int, int, int, int, int, int, int]:
44
+ """A `Board` row as the fixed-width tuple `quantik-core` is typed for.
45
+
46
+ `Board` is `(8,) uint16` by construction, but a comprehension over it is
47
+ a `tuple[int, ...]` to a type checker, which is not the same type as the
48
+ eight-element tuple `State` and `generate_legal_moves_list` accept. The
49
+ cast is here, once, rather than at each call site.
50
+ """
51
+ values = tuple(int(v) for v in board)
52
+ # The length check is not decoration: it is what narrows `tuple[int, ...]`
53
+ # to the eight-element tuple, so no cast is needed and a malformed board
54
+ # fails here rather than inside the Rust extension.
55
+ assert len(values) == 8, f"a Board is 8 planes wide, got {len(values)}"
56
+ return values
57
+
58
+
59
+ def _state(board: Board) -> State:
60
+ return State(_bitboard(board))
61
+
62
+
63
+ def _ply(board: Board) -> int:
64
+ return int(fb.popcount(fb.occupancy(board[None, :]))[0])
65
+
66
+
67
+ def _sample(weights: np.ndarray, temperature: float, seed: int) -> int:
68
+ """Pick an index from `weights` at `temperature`, or its argmax at 0.
69
+
70
+ `weights` is a non-negative score per action — MCTS visit counts, or
71
+ policy priors — already zero everywhere illegal, so an action with a
72
+ zero score can never be drawn and the legality check upstream stays
73
+ intact.
74
+
75
+ The exponent is taken on weights divided by their maximum rather than
76
+ on the raw scores. Both forms are proportional and so describe the same
77
+ distribution, but the raw one overflows: `visits ** (1 / 0.01)` is
78
+ `128 ** 100`, which is `inf`, and one `inf` turns the normalised vector
79
+ into `nan` and `rng.choice` into a `ValueError`. Dividing first caps
80
+ the base at 1.0, so a small temperature underflows the losers to zero
81
+ and converges on the argmax — which is the limit it should converge on.
82
+ """
83
+ if temperature <= 0.0:
84
+ return int(weights.argmax())
85
+ top = float(weights.max())
86
+ if top <= 0.0:
87
+ return int(weights.argmax())
88
+ scaled = np.zeros(weights.shape, dtype=np.float64)
89
+ positive = weights > 0.0
90
+ scaled[positive] = (weights[positive] / top) ** (1.0 / temperature)
91
+ total = scaled.sum()
92
+ if not np.isfinite(total) or total <= 0.0:
93
+ return int(weights.argmax())
94
+ return int(np.random.default_rng(seed).choice(scaled.size, p=scaled / total))
95
+
96
+
97
+ class RandomAgent:
98
+ """Uniform choice among legal actions."""
99
+
100
+ def __init__(self, name: str = "random") -> None:
101
+ self.name = name
102
+
103
+ def select(self, board: Board, seed: int) -> int:
104
+ legal = np.flatnonzero(fb.legal_masks(board[None, :])[0])
105
+ return int(random.Random(seed).choice(legal.tolist()))
106
+
107
+ def config_label(self) -> str:
108
+ return "random"
109
+
110
+
111
+ class MinimaxAgent:
112
+ """`quantik_core.minimax.MinimaxEngine` — iterative-deepening alpha-beta."""
113
+
114
+ def __init__(self, time_limit_s: float | None = 0.1, max_depth: int = 16, name: str | None = None):
115
+ self.time_limit_s = time_limit_s
116
+ self.max_depth = max_depth
117
+ self.name = name or (
118
+ f"minimax@{time_limit_s}s" if time_limit_s else f"minimax-d{max_depth}"
119
+ )
120
+
121
+ def select(self, board: Board, seed: int) -> int:
122
+ engine = MinimaxEngine(
123
+ MinimaxConfig(
124
+ max_depth=self.max_depth,
125
+ time_limit_s=self.time_limit_s,
126
+ random_seed=seed,
127
+ )
128
+ )
129
+ move = engine.search(_state(board)).best_move
130
+ return move.shape * 16 + move.position
131
+
132
+ def config_label(self) -> str:
133
+ return f"minimax(depth={self.max_depth},time={self.time_limit_s})"
134
+
135
+
136
+ class CoreMCTSAgent:
137
+ """`quantik_core.mcts.MCTSEngine` — UCB1 MCTS with random rollouts."""
138
+
139
+ def __init__(
140
+ self,
141
+ max_iterations: int = 2000,
142
+ time_limit_s: float | None = 0.1,
143
+ name: str | None = None,
144
+ ):
145
+ self.max_iterations = max_iterations
146
+ self.time_limit_s = time_limit_s
147
+ self.name = name or (
148
+ f"mcts@{time_limit_s}s" if time_limit_s else f"mcts-{max_iterations}"
149
+ )
150
+
151
+ def select(self, board: Board, seed: int) -> int:
152
+ engine = MCTSEngine(
153
+ MCTSConfig(
154
+ max_iterations=self.max_iterations,
155
+ time_limit_s=self.time_limit_s,
156
+ random_seed=seed,
157
+ )
158
+ )
159
+ move, _ = engine.search(_state(board))
160
+ return move.shape * 16 + move.position
161
+
162
+ def config_label(self) -> str:
163
+ return f"mcts(iters={self.max_iterations},time={self.time_limit_s})"
164
+
165
+
166
+ class BeamAgent:
167
+ """`quantik_core.beam_search.BeamSearchEngine` — level-by-level beam."""
168
+
169
+ def __init__(
170
+ self,
171
+ beam_width: int = 64,
172
+ rollouts: int = 8,
173
+ time_limit_s: float | None = 0.1,
174
+ name: str | None = None,
175
+ ):
176
+ self.beam_width = beam_width
177
+ self.rollouts = rollouts
178
+ self.time_limit_s = time_limit_s
179
+ self.name = name or (
180
+ f"beam@{time_limit_s}s" if time_limit_s else f"beam-w{beam_width}"
181
+ )
182
+
183
+ def select(self, board: Board, seed: int) -> int:
184
+ engine = BeamSearchEngine(
185
+ BeamSearchConfig(
186
+ beam_width=self.beam_width,
187
+ rollouts_per_candidate=self.rollouts,
188
+ time_limit_s=self.time_limit_s,
189
+ random_seed=seed,
190
+ )
191
+ )
192
+ result = engine.search(_state(board))
193
+ # BeamSearchResult exposes root moves aggregated from its sampled
194
+ # leaves rather than a single best move.
195
+ ranked = result.ranked_root_moves(top_k=1)
196
+ move = ranked[0].move if ranked else generate_legal_moves_list(
197
+ _bitboard(board)
198
+ )[0]
199
+ return move.shape * 16 + move.position
200
+
201
+ def config_label(self) -> str:
202
+ return f"beam(width={self.beam_width},rollouts={self.rollouts},time={self.time_limit_s})"
203
+
204
+
205
+ class PolicyAgent:
206
+ """The network's policy head alone — one forward pass, zero search."""
207
+
208
+ def __init__(
209
+ self,
210
+ evaluator,
211
+ name: str = "net-policy",
212
+ temperature: float = 0.0,
213
+ temperature_plies: int | None = None,
214
+ ):
215
+ self.evaluator = evaluator
216
+ self.name = name
217
+ self.temperature = temperature
218
+ self.temperature_plies = temperature_plies
219
+
220
+ def _temperature_at(self, board: Board) -> float:
221
+ """The temperature in force at this position.
222
+
223
+ `temperature_plies` bounds sampling to the opening: `None` applies
224
+ it to the whole game, an integer applies it while fewer than that
225
+ many pieces are on the board. The board carries its own ply, so no
226
+ caller has to thread one through `select`.
227
+ """
228
+ if self.temperature <= 0.0:
229
+ return 0.0
230
+ if self.temperature_plies is None:
231
+ return self.temperature
232
+ return self.temperature if _ply(board) < self.temperature_plies else 0.0
233
+
234
+ def select(self, board: Board, seed: int) -> int:
235
+ boards = board[None, :]
236
+ legal = fb.legal_masks(boards)
237
+ priors, _ = self.evaluator(boards, legal)
238
+ # `np.where` rather than trusting the evaluator's own masking: a
239
+ # prior that leaked onto an illegal action would otherwise become a
240
+ # drawable outcome once the temperature stops being zero.
241
+ weights = np.where(legal[0], priors[0], 0.0)
242
+ return _sample(weights, self._temperature_at(board), seed)
243
+
244
+ def config_label(self) -> str:
245
+ return (
246
+ f"net-policy(temperature={self.temperature},"
247
+ f"plies={self.temperature_plies})"
248
+ )
249
+
250
+
251
+ class NetMCTSAgent:
252
+ """The network inside `BatchedMCTS` — the full AlphaZero-style player.
253
+
254
+ Configure `params.time_limit_s` to play on the same clock as a
255
+ time-limited classical engine; otherwise `params.simulations` is the
256
+ budget.
257
+ """
258
+
259
+ def __init__(self, evaluator, simulations: int = 128, params: MCTSParams | None = None,
260
+ name: str | None = None, temperature: float = 0.0,
261
+ temperature_plies: int | None = None):
262
+ self.evaluator = evaluator
263
+ self.params = params or MCTSParams(simulations=simulations)
264
+ self.temperature = temperature
265
+ self.temperature_plies = temperature_plies
266
+ if name:
267
+ self.name = name
268
+ elif self.params.time_limit_s:
269
+ self.name = f"net-mcts@{self.params.time_limit_s * 1000:.0f}ms"
270
+ else:
271
+ self.name = f"net-mcts-{self.params.simulations}"
272
+
273
+ def _temperature_at(self, board: Board) -> float:
274
+ """See `PolicyAgent._temperature_at` — the same schedule, so an
275
+ opponent's opening variety does not depend on which of the two
276
+ kinds it happens to be."""
277
+ if self.temperature <= 0.0:
278
+ return 0.0
279
+ if self.temperature_plies is None:
280
+ return self.temperature
281
+ return self.temperature if _ply(board) < self.temperature_plies else 0.0
282
+
283
+ def select(self, board: Board, seed: int) -> int:
284
+ search = BatchedMCTS(self.evaluator, self.params, np.random.default_rng(seed))
285
+ visits, _ = search.search(board[None, :], add_noise=False)
286
+ # Sampling the *visit counts*, not the priors: the visits are what
287
+ # the search converged on, so a temperature here trades strength
288
+ # for variety along the search's own ranking rather than throwing
289
+ # the search away. Root Dirichlet noise (`add_noise`) is the other
290
+ # place variety could come from and is deliberately not it — it
291
+ # perturbs the tree the search is built on, so its cost is spread
292
+ # through every simulation instead of landing on one choice.
293
+ return _sample(visits[0].astype(np.float64), self._temperature_at(board), seed)
294
+
295
+ def config_label(self) -> str:
296
+ budget = (
297
+ f"time={self.params.time_limit_s}"
298
+ if self.params.time_limit_s
299
+ else f"sims={self.params.simulations}"
300
+ )
301
+ return (
302
+ f"net-mcts({budget},c_puct={self.params.c_puct},"
303
+ f"leaf_batch={self.params.leaf_batch},"
304
+ f"temperature={self.temperature},plies={self.temperature_plies})"
305
+ )