chuk-puzzles-gym 0.9__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 (112) hide show
  1. chuk_puzzles_gym/__init__.py +19 -0
  2. chuk_puzzles_gym/constants.py +9 -0
  3. chuk_puzzles_gym/eval.py +763 -0
  4. chuk_puzzles_gym/export/__init__.py +20 -0
  5. chuk_puzzles_gym/export/dataset.py +376 -0
  6. chuk_puzzles_gym/games/__init__.py +94 -0
  7. chuk_puzzles_gym/games/_base/__init__.py +6 -0
  8. chuk_puzzles_gym/games/_base/commands.py +91 -0
  9. chuk_puzzles_gym/games/_base/game.py +337 -0
  10. chuk_puzzles_gym/games/binary/__init__.py +6 -0
  11. chuk_puzzles_gym/games/binary/config.py +23 -0
  12. chuk_puzzles_gym/games/binary/game.py +434 -0
  13. chuk_puzzles_gym/games/bridges/__init__.py +6 -0
  14. chuk_puzzles_gym/games/bridges/config.py +24 -0
  15. chuk_puzzles_gym/games/bridges/game.py +489 -0
  16. chuk_puzzles_gym/games/einstein/__init__.py +6 -0
  17. chuk_puzzles_gym/games/einstein/config.py +23 -0
  18. chuk_puzzles_gym/games/einstein/constants.py +13 -0
  19. chuk_puzzles_gym/games/einstein/game.py +366 -0
  20. chuk_puzzles_gym/games/einstein/models.py +35 -0
  21. chuk_puzzles_gym/games/fillomino/__init__.py +6 -0
  22. chuk_puzzles_gym/games/fillomino/config.py +24 -0
  23. chuk_puzzles_gym/games/fillomino/game.py +516 -0
  24. chuk_puzzles_gym/games/futoshiki/__init__.py +6 -0
  25. chuk_puzzles_gym/games/futoshiki/config.py +23 -0
  26. chuk_puzzles_gym/games/futoshiki/game.py +391 -0
  27. chuk_puzzles_gym/games/hidato/__init__.py +6 -0
  28. chuk_puzzles_gym/games/hidato/config.py +24 -0
  29. chuk_puzzles_gym/games/hidato/game.py +403 -0
  30. chuk_puzzles_gym/games/hitori/__init__.py +6 -0
  31. chuk_puzzles_gym/games/hitori/config.py +23 -0
  32. chuk_puzzles_gym/games/hitori/game.py +451 -0
  33. chuk_puzzles_gym/games/kakuro/__init__.py +6 -0
  34. chuk_puzzles_gym/games/kakuro/config.py +24 -0
  35. chuk_puzzles_gym/games/kakuro/game.py +399 -0
  36. chuk_puzzles_gym/games/kenken/__init__.py +6 -0
  37. chuk_puzzles_gym/games/kenken/config.py +24 -0
  38. chuk_puzzles_gym/games/kenken/enums.py +13 -0
  39. chuk_puzzles_gym/games/kenken/game.py +486 -0
  40. chuk_puzzles_gym/games/kenken/models.py +15 -0
  41. chuk_puzzles_gym/games/killer_sudoku/__init__.py +6 -0
  42. chuk_puzzles_gym/games/killer_sudoku/config.py +23 -0
  43. chuk_puzzles_gym/games/killer_sudoku/game.py +502 -0
  44. chuk_puzzles_gym/games/killer_sudoku/models.py +15 -0
  45. chuk_puzzles_gym/games/knapsack/__init__.py +6 -0
  46. chuk_puzzles_gym/games/knapsack/config.py +24 -0
  47. chuk_puzzles_gym/games/knapsack/enums.py +10 -0
  48. chuk_puzzles_gym/games/knapsack/game.py +340 -0
  49. chuk_puzzles_gym/games/knapsack/models.py +13 -0
  50. chuk_puzzles_gym/games/lights_out/__init__.py +6 -0
  51. chuk_puzzles_gym/games/lights_out/config.py +24 -0
  52. chuk_puzzles_gym/games/lights_out/game.py +249 -0
  53. chuk_puzzles_gym/games/logic_grid/__init__.py +6 -0
  54. chuk_puzzles_gym/games/logic_grid/config.py +24 -0
  55. chuk_puzzles_gym/games/logic_grid/constants.py +12 -0
  56. chuk_puzzles_gym/games/logic_grid/game.py +333 -0
  57. chuk_puzzles_gym/games/logic_grid/models.py +24 -0
  58. chuk_puzzles_gym/games/mastermind/__init__.py +6 -0
  59. chuk_puzzles_gym/games/mastermind/config.py +25 -0
  60. chuk_puzzles_gym/games/mastermind/game.py +297 -0
  61. chuk_puzzles_gym/games/minesweeper/__init__.py +6 -0
  62. chuk_puzzles_gym/games/minesweeper/config.py +24 -0
  63. chuk_puzzles_gym/games/minesweeper/enums.py +12 -0
  64. chuk_puzzles_gym/games/minesweeper/game.py +432 -0
  65. chuk_puzzles_gym/games/nonogram/__init__.py +6 -0
  66. chuk_puzzles_gym/games/nonogram/config.py +23 -0
  67. chuk_puzzles_gym/games/nonogram/game.py +296 -0
  68. chuk_puzzles_gym/games/nurikabe/__init__.py +6 -0
  69. chuk_puzzles_gym/games/nurikabe/config.py +24 -0
  70. chuk_puzzles_gym/games/nurikabe/enums.py +14 -0
  71. chuk_puzzles_gym/games/nurikabe/game.py +586 -0
  72. chuk_puzzles_gym/games/scheduler/__init__.py +6 -0
  73. chuk_puzzles_gym/games/scheduler/config.py +25 -0
  74. chuk_puzzles_gym/games/scheduler/constants.py +15 -0
  75. chuk_puzzles_gym/games/scheduler/enums.py +10 -0
  76. chuk_puzzles_gym/games/scheduler/game.py +431 -0
  77. chuk_puzzles_gym/games/scheduler/models.py +14 -0
  78. chuk_puzzles_gym/games/shikaku/__init__.py +6 -0
  79. chuk_puzzles_gym/games/shikaku/config.py +24 -0
  80. chuk_puzzles_gym/games/shikaku/game.py +419 -0
  81. chuk_puzzles_gym/games/slitherlink/__init__.py +6 -0
  82. chuk_puzzles_gym/games/slitherlink/config.py +23 -0
  83. chuk_puzzles_gym/games/slitherlink/game.py +386 -0
  84. chuk_puzzles_gym/games/sokoban/__init__.py +6 -0
  85. chuk_puzzles_gym/games/sokoban/config.py +24 -0
  86. chuk_puzzles_gym/games/sokoban/game.py +671 -0
  87. chuk_puzzles_gym/games/star_battle/__init__.py +6 -0
  88. chuk_puzzles_gym/games/star_battle/config.py +24 -0
  89. chuk_puzzles_gym/games/star_battle/game.py +390 -0
  90. chuk_puzzles_gym/games/sudoku/__init__.py +7 -0
  91. chuk_puzzles_gym/games/sudoku/commands.py +96 -0
  92. chuk_puzzles_gym/games/sudoku/config.py +22 -0
  93. chuk_puzzles_gym/games/sudoku/game.py +328 -0
  94. chuk_puzzles_gym/games/tents/__init__.py +6 -0
  95. chuk_puzzles_gym/games/tents/config.py +24 -0
  96. chuk_puzzles_gym/games/tents/game.py +416 -0
  97. chuk_puzzles_gym/gym_env.py +465 -0
  98. chuk_puzzles_gym/models/__init__.py +47 -0
  99. chuk_puzzles_gym/models/base.py +30 -0
  100. chuk_puzzles_gym/models/config.py +11 -0
  101. chuk_puzzles_gym/models/enums.py +104 -0
  102. chuk_puzzles_gym/models/evaluation.py +487 -0
  103. chuk_puzzles_gym/models/games.py +12 -0
  104. chuk_puzzles_gym/server.py +1171 -0
  105. chuk_puzzles_gym/trace/__init__.py +10 -0
  106. chuk_puzzles_gym/trace/generator.py +726 -0
  107. chuk_puzzles_gym/utils/__init__.py +4 -0
  108. chuk_puzzles_gym-0.9.dist-info/METADATA +1471 -0
  109. chuk_puzzles_gym-0.9.dist-info/RECORD +112 -0
  110. chuk_puzzles_gym-0.9.dist-info/WHEEL +5 -0
  111. chuk_puzzles_gym-0.9.dist-info/entry_points.txt +4 -0
  112. chuk_puzzles_gym-0.9.dist-info/top_level.txt +1 -0
@@ -0,0 +1,337 @@
1
+ """Abstract base class for all puzzle games."""
2
+
3
+ import random
4
+ from abc import ABC, abstractmethod
5
+ from typing import Any
6
+
7
+ from ...models import DifficultyLevel, DifficultyProfile, MoveResult, SolverConfig
8
+
9
+
10
+ class PuzzleGame(ABC):
11
+ """Base class for all puzzle games in the arcade.
12
+
13
+ This defines the common interface that all puzzle types must implement.
14
+ Games are pure puzzle generators - they don't solve, they just validate.
15
+ The solving happens client-side (LLMs with MCP solver access).
16
+
17
+ All games support deterministic seeding for reproducibility:
18
+ - Pass a seed to __init__ to get the same puzzle every time
19
+ - Use self._rng for all random operations in subclasses
20
+ - The seed is exposed so players can share puzzles
21
+
22
+ Metrics tracked for evaluation:
23
+ - moves_made: Valid moves (state-changing actions)
24
+ - invalid_moves: Rejected/invalid move attempts
25
+ - hints_used: Solver hints consumed
26
+ - retries: Attempts on same cell (backtracking indicator)
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ difficulty: DifficultyLevel | str = DifficultyLevel.EASY,
32
+ seed: int | None = None,
33
+ solver_config: SolverConfig | None = None,
34
+ ):
35
+ """Initialize a new puzzle game.
36
+
37
+ Args:
38
+ difficulty: Game difficulty level (easy, medium, hard)
39
+ seed: Random seed for reproducible puzzle generation.
40
+ If None, a random seed is generated.
41
+ solver_config: Configuration for solver/hint usage.
42
+ If None, uses default (solver allowed, no penalty).
43
+ """
44
+ # Convert string to enum if needed (for backwards compatibility)
45
+ if isinstance(difficulty, str):
46
+ self.difficulty = DifficultyLevel(difficulty)
47
+ else:
48
+ self.difficulty = difficulty
49
+
50
+ # Initialize deterministic random number generator
51
+ self.seed = seed if seed is not None else random.randint(0, 2**32 - 1)
52
+ self._rng = random.Random(self.seed)
53
+
54
+ # Solver configuration
55
+ self.solver_config = solver_config or SolverConfig()
56
+
57
+ # Core metrics
58
+ self.moves_made = 0
59
+ self.invalid_moves = 0
60
+ self.hints_used = 0
61
+ self.retries = 0 # Attempts on same cell
62
+
63
+ # State tracking
64
+ self.game_started = False
65
+ self._last_move_position: tuple[Any, ...] | None = None # For retry detection
66
+
67
+ @abstractmethod
68
+ async def generate_puzzle(self) -> None:
69
+ """Generate a new puzzle with a unique solution.
70
+
71
+ This should create the puzzle grid, store the solution,
72
+ and prepare the initial state for play.
73
+
74
+ This is async to allow for non-blocking generation of complex puzzles.
75
+ """
76
+ pass
77
+
78
+ @abstractmethod
79
+ async def validate_move(self, *args: Any, **kwargs: Any) -> MoveResult:
80
+ """Validate a player's move.
81
+
82
+ Args:
83
+ *args: Move parameters (game-specific)
84
+ **kwargs: Additional move parameters (game-specific)
85
+
86
+ Returns:
87
+ MoveResult containing success status and message
88
+ """
89
+ pass
90
+
91
+ @abstractmethod
92
+ def is_complete(self) -> bool:
93
+ """Check if the puzzle is completely and correctly solved.
94
+
95
+ Returns:
96
+ True if puzzle is solved correctly, False otherwise
97
+ """
98
+ pass
99
+
100
+ @abstractmethod
101
+ async def get_hint(self) -> tuple[Any, str] | None:
102
+ """Get a hint for the next move.
103
+
104
+ Returns:
105
+ Tuple of (hint_data, hint_message) or None if no hints available
106
+
107
+ This is async to allow for complex hint computation.
108
+ """
109
+ pass
110
+
111
+ @abstractmethod
112
+ def render_grid(self) -> str:
113
+ """Render the current puzzle state as ASCII art.
114
+
115
+ This should be clean and parseable for LLM clients.
116
+
117
+ Returns:
118
+ String representation of the puzzle grid
119
+ """
120
+ pass
121
+
122
+ @abstractmethod
123
+ def get_rules(self) -> str:
124
+ """Get the rules description for this puzzle type.
125
+
126
+ Returns:
127
+ Multi-line string describing the puzzle rules
128
+ """
129
+ pass
130
+
131
+ @abstractmethod
132
+ def get_commands(self) -> str:
133
+ """Get the available commands for this puzzle type.
134
+
135
+ Returns:
136
+ Multi-line string describing available commands
137
+ """
138
+ pass
139
+
140
+ def get_stats(self) -> str:
141
+ """Get current game statistics.
142
+
143
+ Returns:
144
+ String with game stats (moves, completion, etc.)
145
+ """
146
+ parts = [f"Moves: {self.moves_made}"]
147
+ if self.invalid_moves > 0:
148
+ parts.append(f"Invalid: {self.invalid_moves}")
149
+ if self.hints_used > 0:
150
+ parts.append(f"Hints: {self.hints_used}")
151
+ parts.append(f"Seed: {self.seed}")
152
+ return " | ".join(parts)
153
+
154
+ def record_move(self, position: tuple[Any, ...], success: bool) -> None:
155
+ """Record a move attempt for metrics tracking.
156
+
157
+ Call this from validate_move() implementations to track metrics.
158
+
159
+ Args:
160
+ position: The position/target of the move (for retry detection)
161
+ success: Whether the move was valid
162
+ """
163
+ if success:
164
+ self.moves_made += 1
165
+ else:
166
+ self.invalid_moves += 1
167
+
168
+ # Detect retries (same position attempted again)
169
+ if self._last_move_position == position:
170
+ self.retries += 1
171
+ self._last_move_position = position
172
+
173
+ def record_hint(self) -> bool:
174
+ """Record a hint request and check if allowed.
175
+
176
+ Returns:
177
+ True if hint is allowed, False if budget exceeded or solver disabled.
178
+ """
179
+ if not self.solver_config.solver_allowed:
180
+ return False
181
+ if self.hints_used >= self.solver_config.hint_budget:
182
+ return False
183
+ self.hints_used += 1
184
+ return True
185
+
186
+ def can_use_hint(self) -> bool:
187
+ """Check if hints are available without consuming one.
188
+
189
+ Returns:
190
+ True if solver is allowed and budget not exceeded.
191
+ """
192
+ if not self.solver_config.solver_allowed:
193
+ return False
194
+ return self.hints_used < self.solver_config.hint_budget
195
+
196
+ @property
197
+ def hints_remaining(self) -> int:
198
+ """Number of hints remaining in budget."""
199
+ if not self.solver_config.solver_allowed:
200
+ return 0
201
+ return max(0, self.solver_config.hint_budget - self.hints_used)
202
+
203
+ @property
204
+ @abstractmethod
205
+ def name(self) -> str:
206
+ """The display name of this puzzle type."""
207
+ pass
208
+
209
+ @property
210
+ @abstractmethod
211
+ def description(self) -> str:
212
+ """A one-line description of this puzzle type."""
213
+ pass
214
+
215
+ @property
216
+ def constraint_types(self) -> list[str]:
217
+ """The types of constraints this puzzle demonstrates.
218
+
219
+ Examples: all_different, linear_sum, boolean_sat, optimization,
220
+ connectivity, global_loop, feedback, probabilistic
221
+ """
222
+ return []
223
+
224
+ @property
225
+ def business_analogies(self) -> list[str]:
226
+ """Real-world business problems this puzzle models.
227
+
228
+ Examples: scheduling, resource_allocation, portfolio_selection,
229
+ routing, capacity_planning, constraint_satisfaction
230
+ """
231
+ return []
232
+
233
+ @property
234
+ def complexity_profile(self) -> dict[str, str]:
235
+ """Complexity characteristics of this puzzle.
236
+
237
+ Returns dict with:
238
+ - reasoning_type: deductive, probabilistic, optimization, hybrid
239
+ - search_space: small, medium, large, exponential
240
+ - constraint_density: sparse, moderate, dense
241
+ """
242
+ return {"reasoning_type": "deductive", "search_space": "medium", "constraint_density": "moderate"}
243
+
244
+ @property
245
+ def complexity_metrics(self) -> dict[str, int | float]:
246
+ """Quantified complexity metrics for this puzzle instance.
247
+
248
+ Returns dict with:
249
+ - variable_count: Number of decision variables (cells to fill)
250
+ - constraint_count: Number of constraints
251
+ - domain_size: Average domain size per variable
252
+ - branching_factor: Estimated branching factor
253
+ - empty_cells: Number of cells still to be filled
254
+
255
+ Override in subclasses for accurate values.
256
+ """
257
+ return {
258
+ "variable_count": 0,
259
+ "constraint_count": 0,
260
+ "domain_size": 0,
261
+ "branching_factor": 0.0,
262
+ "empty_cells": 0,
263
+ }
264
+
265
+ @property
266
+ def difficulty_profile(self) -> DifficultyProfile:
267
+ """Detailed difficulty characteristics for curriculum learning.
268
+
269
+ Goes beyond simple easy/medium/hard to enable:
270
+ - Curriculum learning with skill ladders
271
+ - Fair comparisons across identical difficulty profiles
272
+ - Automated training runs with reproducible difficulty scaling
273
+
274
+ Override in subclasses for accurate values based on puzzle instance.
275
+ """
276
+ # Default values based on difficulty level
277
+ base_logic = {DifficultyLevel.EASY.value: 2, DifficultyLevel.MEDIUM.value: 4, DifficultyLevel.HARD.value: 6}
278
+ base_branching = {
279
+ DifficultyLevel.EASY.value: 2.0,
280
+ DifficultyLevel.MEDIUM.value: 4.0,
281
+ DifficultyLevel.HARD.value: 6.0,
282
+ }
283
+ base_density = {
284
+ DifficultyLevel.EASY.value: 0.6,
285
+ DifficultyLevel.MEDIUM.value: 0.5,
286
+ DifficultyLevel.HARD.value: 0.4,
287
+ }
288
+
289
+ diff_str = self.difficulty.value
290
+ return DifficultyProfile(
291
+ logic_depth=base_logic.get(diff_str, 3),
292
+ branching_factor=base_branching.get(diff_str, 3.0),
293
+ state_observability=1.0, # Most puzzles are fully observable
294
+ constraint_density=base_density.get(diff_str, 0.5),
295
+ )
296
+
297
+ @property
298
+ def optimal_steps(self) -> int | None:
299
+ """Minimum number of steps to solve this puzzle (from solver).
300
+
301
+ Returns None if not computed or not applicable.
302
+ Override in subclasses that can compute optimal solutions.
303
+
304
+ For grid-based puzzles, this is typically the number of empty cells.
305
+ For optimization puzzles, this may be the number of decisions.
306
+ """
307
+ # Default: estimate from complexity metrics
308
+ metrics = self.complexity_metrics
309
+ empty_cells = metrics.get("empty_cells", 0)
310
+ if empty_cells > 0:
311
+ return int(empty_cells)
312
+ return None
313
+
314
+ @property
315
+ def canonical_solution(self) -> list[tuple[Any, ...]] | None:
316
+ """Optimal solution trace as a list of moves.
317
+
318
+ Returns None if not available.
319
+ Override in subclasses that can provide solution traces.
320
+
321
+ Each move is a tuple of (row, col, value) or game-specific format.
322
+ """
323
+ return None
324
+
325
+ def get_solution_efficiency(self, steps_taken: int) -> float:
326
+ """Calculate efficiency score compared to optimal solution.
327
+
328
+ Args:
329
+ steps_taken: Actual steps taken to solve
330
+
331
+ Returns:
332
+ Efficiency score from 0.0 to 1.0 (1.0 = optimal)
333
+ """
334
+ optimal = self.optimal_steps
335
+ if optimal is None or steps_taken == 0:
336
+ return 0.0
337
+ return min(1.0, optimal / steps_taken)
@@ -0,0 +1,6 @@
1
+ """BinaryPuzzle puzzle game module."""
2
+
3
+ from .config import BinaryConfig
4
+ from .game import BinaryPuzzleGame
5
+
6
+ __all__ = ["BinaryPuzzleGame", "BinaryConfig"]
@@ -0,0 +1,23 @@
1
+ """Configuration for Binary Puzzle game."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from ...models.enums import DifficultyLevel
6
+
7
+
8
+ class BinaryConfig(BaseModel):
9
+ """Configuration for Binary Puzzle game."""
10
+
11
+ difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY, description="Game difficulty level")
12
+ size: int = Field(ge=4, le=14, description="Grid size (NxN, must be even)")
13
+
14
+ @classmethod
15
+ def from_difficulty(cls, difficulty: DifficultyLevel) -> "BinaryConfig":
16
+ """Create config from difficulty level."""
17
+ config_map = {
18
+ DifficultyLevel.EASY: {"size": 6},
19
+ DifficultyLevel.MEDIUM: {"size": 8},
20
+ DifficultyLevel.HARD: {"size": 10},
21
+ }
22
+ params = config_map[difficulty]
23
+ return cls(difficulty=difficulty, **params)