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,726 @@
1
+ """
2
+ Trace generator for puzzle games.
3
+
4
+ Generates step-by-step solution traces compatible with chuk-gym-core.
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ from chuk_gym_core import Step, StepOperation, Trace
10
+
11
+ from chuk_puzzles_gym.games._base import PuzzleGame
12
+
13
+
14
+ class TraceGenerator:
15
+ """
16
+ Generates solution traces for puzzle games.
17
+
18
+ Traces are machine-verifiable sequences of steps that transform
19
+ the initial puzzle state into the solved state.
20
+
21
+ Usage:
22
+ game = SudokuGame(difficulty="medium", seed=42)
23
+ await game.generate_puzzle()
24
+
25
+ generator = TraceGenerator()
26
+ trace = generator.generate(game)
27
+
28
+ # Trace contains step-by-step solution
29
+ for step in trace.steps:
30
+ print(f"{step.explanation}")
31
+ """
32
+
33
+ def generate(self, game: PuzzleGame) -> Trace:
34
+ """
35
+ Generate a solution trace for a puzzle game.
36
+
37
+ Args:
38
+ game: A puzzle game instance with a generated puzzle
39
+
40
+ Returns:
41
+ Trace with step-by-step solution
42
+ """
43
+ # Normalize game name: lowercase, spaces to underscores, remove special chars
44
+ game_name = game.name.lower().replace(" ", "_")
45
+ # Remove apostrophes and other non-alphanumeric chars (except underscore)
46
+ game_name = "".join(c for c in game_name if c.isalnum() or c == "_")
47
+
48
+ # Dispatch to game-specific generator
49
+ if hasattr(self, f"_generate_{game_name}"):
50
+ return getattr(self, f"_generate_{game_name}")(game)
51
+
52
+ # Fall back to generic grid-based generator
53
+ if hasattr(game, "grid") and hasattr(game, "solution"):
54
+ return self._generate_grid_puzzle(game)
55
+
56
+ # Last resort: generate from canonical solution
57
+ if game.canonical_solution:
58
+ return self._generate_from_canonical(game)
59
+
60
+ # Empty trace if nothing else works
61
+ return Trace(
62
+ problem_id=f"{game_name}_{game.difficulty.value}_{game.seed}",
63
+ steps=[],
64
+ )
65
+
66
+ def _generate_grid_puzzle(self, game: PuzzleGame) -> Trace:
67
+ """Generate trace for a grid-based puzzle with solution."""
68
+ problem_id = f"{game.name.lower().replace(' ', '_')}_{game.difficulty.value}_{game.seed}"
69
+ steps: list[Step] = []
70
+
71
+ # Get initial and solution grids (dynamically accessed attributes)
72
+ grid = getattr(game, "grid", None)
73
+ initial = getattr(game, "initial_grid", grid)
74
+ solution = getattr(game, "solution", None)
75
+
76
+ if solution is None:
77
+ return Trace(problem_id=problem_id, steps=[])
78
+
79
+ # Find all cells that need to be filled
80
+ # Handle both 0 and -1 as empty values (different games use different conventions)
81
+ moves: list[tuple[int, int, Any]] = []
82
+ for row in range(len(solution)):
83
+ for col in range(len(solution[row])):
84
+ initial_val = initial[row][col]
85
+ solution_val = solution[row][col]
86
+ # Check if cell needs to be filled: initial is empty (0 or -1) and differs from solution
87
+ is_empty = initial_val == 0 or initial_val == -1
88
+ needs_fill = is_empty and solution_val != initial_val
89
+ if needs_fill:
90
+ moves.append((row, col, solution_val))
91
+
92
+ # Generate steps for each move
93
+ for i, (row, col, value) in enumerate(moves):
94
+ step = Step(
95
+ index=i,
96
+ operation=StepOperation.PLACE,
97
+ before_state=self._format_cell_state(row, col, None, is_empty=True),
98
+ after_state=self._format_cell_state(row, col, value, is_empty=False),
99
+ output_value=value,
100
+ position=(row + 1, col + 1), # 1-indexed for user display
101
+ rule_applied=self._infer_rule(game, row, col, value),
102
+ explanation=self._generate_explanation(game, row, col, value),
103
+ )
104
+ steps.append(step)
105
+
106
+ return Trace(
107
+ problem_id=problem_id,
108
+ steps=steps,
109
+ checkpoints=self._identify_checkpoints(steps),
110
+ )
111
+
112
+ def _generate_from_canonical(self, game: PuzzleGame) -> Trace:
113
+ """Generate trace from canonical solution list."""
114
+ problem_id = f"{game.name.lower().replace(' ', '_')}_{game.difficulty.value}_{game.seed}"
115
+ steps: list[Step] = []
116
+
117
+ canonical = game.canonical_solution
118
+ if not canonical:
119
+ return Trace(problem_id=problem_id, steps=[])
120
+
121
+ for i, move in enumerate(canonical):
122
+ if isinstance(move, tuple) and len(move) >= 3:
123
+ row, col, value = move[0], move[1], move[2]
124
+ step = Step(
125
+ index=i,
126
+ operation=StepOperation.PLACE,
127
+ before_state=f"cell({row},{col})=empty",
128
+ after_state=f"cell({row},{col})={value}",
129
+ output_value=value,
130
+ position=(row, col),
131
+ explanation=f"Place {value} at position ({row}, {col})",
132
+ )
133
+ else:
134
+ step = Step(
135
+ index=i,
136
+ operation=StepOperation.DEDUCE,
137
+ before_state=str(move),
138
+ after_state=str(move),
139
+ explanation=f"Move {i + 1}: {move}",
140
+ )
141
+ steps.append(step)
142
+
143
+ return Trace(problem_id=problem_id, steps=steps)
144
+
145
+ def _format_cell_state(self, row: int, col: int, value: Any, is_empty: bool = False) -> str:
146
+ """Format cell state for step display."""
147
+ if is_empty or value is None or value == -1:
148
+ return f"cell(r{row + 1},c{col + 1})=empty"
149
+ return f"cell(r{row + 1},c{col + 1})={value}"
150
+
151
+ def _infer_rule(self, game: PuzzleGame, row: int, col: int, value: Any) -> str:
152
+ """Infer the logical rule used to determine this value."""
153
+ game_name = game.name.lower()
154
+
155
+ # Game-specific rules
156
+ if "sudoku" in game_name:
157
+ return self._infer_sudoku_rule(game, row, col, value)
158
+ elif "kenken" in game_name or "kakuro" in game_name:
159
+ return "arithmetic_constraint"
160
+ elif "nonogram" in game_name:
161
+ return "line_constraint"
162
+ elif "binary" in game_name:
163
+ return "balance_constraint"
164
+
165
+ return "constraint_propagation"
166
+
167
+ def _infer_sudoku_rule(self, game: PuzzleGame, row: int, col: int, value: Any) -> str:
168
+ """Infer the Sudoku-specific rule used."""
169
+ # Get solution grid dynamically
170
+ solution = getattr(game, "solution", None)
171
+ if solution is None:
172
+ return "elimination"
173
+
174
+ # Check if this is the only candidate in the row
175
+ row_vals = set(solution[row]) - {0}
176
+ if len(row_vals) == 9:
177
+ return "naked_single_row"
178
+
179
+ # Check column
180
+ col_vals = {solution[r][col] for r in range(9)} - {0}
181
+ if len(col_vals) == 9:
182
+ return "naked_single_column"
183
+
184
+ # Check box
185
+ box_row, box_col = 3 * (row // 3), 3 * (col // 3)
186
+ box_vals = set()
187
+ for r in range(box_row, box_row + 3):
188
+ for c in range(box_col, box_col + 3):
189
+ if solution[r][c] != 0:
190
+ box_vals.add(solution[r][c])
191
+ if len(box_vals) == 9:
192
+ return "naked_single_box"
193
+
194
+ return "elimination"
195
+
196
+ def _generate_explanation(self, game: PuzzleGame, row: int, col: int, value: Any) -> str:
197
+ """Generate a natural language explanation for the step."""
198
+ game_name = game.name.lower()
199
+
200
+ if "sudoku" in game_name:
201
+ box_num = 3 * (row // 3) + (col // 3) + 1
202
+ return (
203
+ f"Place {value} at row {row + 1}, column {col + 1}. "
204
+ f"This is the only valid digit for this cell considering "
205
+ f"row {row + 1}, column {col + 1}, and box {box_num} constraints."
206
+ )
207
+ elif "binary" in game_name:
208
+ v = "1" if value == 1 else "0"
209
+ return (
210
+ f"Place {v} at row {row + 1}, column {col + 1}. "
211
+ f"This maintains the balance and avoids three consecutive same digits."
212
+ )
213
+ elif "futoshiki" in game_name:
214
+ return f"Place {value} at row {row + 1}, column {col + 1}. This satisfies the inequality constraints."
215
+ elif "kenken" in game_name:
216
+ return f"Place {value} at row {row + 1}, column {col + 1}. This satisfies the cage arithmetic constraint."
217
+ else:
218
+ return f"Place {value} at row {row + 1}, column {col + 1}."
219
+
220
+ def _identify_checkpoints(self, steps: list[Step]) -> list[int]:
221
+ """Identify key milestone steps for partial credit."""
222
+ if not steps:
223
+ return []
224
+
225
+ checkpoints = []
226
+ total = len(steps)
227
+
228
+ # Add checkpoints at 25%, 50%, 75%, 100%
229
+ for pct in [0.25, 0.5, 0.75, 1.0]:
230
+ idx = min(int(total * pct), total - 1)
231
+ if idx not in checkpoints:
232
+ checkpoints.append(idx)
233
+
234
+ return checkpoints
235
+
236
+ # Game-specific generators
237
+
238
+ def _generate_sudoku(self, game: PuzzleGame) -> Trace:
239
+ """Generate trace specifically for Sudoku."""
240
+ return self._generate_grid_puzzle(game)
241
+
242
+ def _generate_binary(self, game: PuzzleGame) -> Trace:
243
+ """Generate trace for Binary puzzle."""
244
+ return self._generate_grid_puzzle(game)
245
+
246
+ def _generate_futoshiki(self, game: PuzzleGame) -> Trace:
247
+ """Generate trace for Futoshiki puzzle."""
248
+ return self._generate_grid_puzzle(game)
249
+
250
+ def _generate_kenken(self, game: PuzzleGame) -> Trace:
251
+ """Generate trace for KenKen puzzle."""
252
+ return self._generate_grid_puzzle(game)
253
+
254
+ def _generate_kakuro(self, game: PuzzleGame) -> Trace:
255
+ """Generate trace for Kakuro puzzle."""
256
+ return self._generate_grid_puzzle(game)
257
+
258
+ def _generate_nonogram(self, game: PuzzleGame) -> Trace:
259
+ """Generate trace for Nonogram puzzle."""
260
+ problem_id = f"nonogram_{game.difficulty.value}_{game.seed}"
261
+ steps: list[Step] = []
262
+
263
+ if hasattr(game, "grid") and hasattr(game, "solution"):
264
+ initial = game.grid
265
+ solution = game.solution
266
+
267
+ step_idx = 0
268
+ for row in range(len(solution)):
269
+ for col in range(len(solution[row])):
270
+ if solution[row][col] != initial[row][col]:
271
+ is_filled = solution[row][col] == 1
272
+ step = Step(
273
+ index=step_idx,
274
+ operation=StepOperation.PLACE if is_filled else StepOperation.ELIMINATE,
275
+ before_state=f"cell(r{row + 1},c{col + 1})=unknown",
276
+ after_state=f"cell(r{row + 1},c{col + 1})={'filled' if is_filled else 'empty'}",
277
+ output_value=is_filled,
278
+ position=(row + 1, col + 1),
279
+ rule_applied="line_logic",
280
+ explanation=f"{'Fill' if is_filled else 'Mark empty'} cell at row {row + 1}, column {col + 1} based on row/column clues.",
281
+ )
282
+ steps.append(step)
283
+ step_idx += 1
284
+
285
+ return Trace(
286
+ problem_id=problem_id,
287
+ steps=steps,
288
+ checkpoints=self._identify_checkpoints(steps),
289
+ )
290
+
291
+ def _generate_mastermind(self, game: PuzzleGame) -> Trace:
292
+ """Generate trace for Mastermind (code-breaking)."""
293
+ problem_id = f"mastermind_{game.difficulty.value}_{game.seed}"
294
+ steps: list[Step] = []
295
+
296
+ if hasattr(game, "secret_code") and game.secret_code:
297
+ # For Mastermind, the solution is discovering the code
298
+ secret = game.secret_code
299
+ code_str = " ".join(str(c) for c in secret)
300
+ step = Step(
301
+ index=0,
302
+ operation=StepOperation.DEDUCE,
303
+ before_state="secret_code=unknown",
304
+ after_state=f"secret_code={code_str}",
305
+ output_value=secret,
306
+ rule_applied="deductive_elimination",
307
+ explanation=f"The secret code is {code_str}. Discovered through systematic guessing and feedback analysis.",
308
+ )
309
+ steps.append(step)
310
+
311
+ return Trace(problem_id=problem_id, steps=steps)
312
+
313
+ def _generate_einstein(self, game: PuzzleGame) -> Trace:
314
+ """Generate trace for Einstein puzzle."""
315
+ problem_id = f"einstein_{game.difficulty.value}_{game.seed}"
316
+ steps: list[Step] = []
317
+
318
+ if hasattr(game, "solution") and game.solution:
319
+ # Einstein has assignments: list of HouseAssignment objects
320
+ solution = game.solution
321
+ step_idx = 0
322
+ attributes = ["color", "nationality", "drink", "smoke", "pet"]
323
+
324
+ for house_idx, house_data in enumerate(solution):
325
+ for attr in attributes:
326
+ value = getattr(house_data, attr, None)
327
+ if value:
328
+ step = Step(
329
+ index=step_idx,
330
+ operation=StepOperation.DEDUCE,
331
+ before_state=f"house{house_idx + 1}.{attr}=unknown",
332
+ after_state=f"house{house_idx + 1}.{attr}={value}",
333
+ output_value=value,
334
+ position=(house_idx + 1,),
335
+ rule_applied="logical_deduction",
336
+ explanation=f"House {house_idx + 1} has {attr}={value}, deduced from the clues.",
337
+ )
338
+ steps.append(step)
339
+ step_idx += 1
340
+
341
+ return Trace(
342
+ problem_id=problem_id,
343
+ steps=steps,
344
+ checkpoints=self._identify_checkpoints(steps),
345
+ )
346
+
347
+ def _generate_logic(self, game: PuzzleGame) -> Trace:
348
+ """Generate trace for Logic Grid puzzle."""
349
+ problem_id = f"logic_grid_{game.difficulty.value}_{game.seed}"
350
+ steps: list[Step] = []
351
+
352
+ if hasattr(game, "solution") and game.solution:
353
+ # Logic Grid has solution: dict[person -> PersonAttributes]
354
+ solution = game.solution
355
+ step_idx = 0
356
+ attributes = ["color", "pet", "drink"]
357
+
358
+ for person, attrs in solution.items():
359
+ for category in attributes:
360
+ value = getattr(attrs, category, None)
361
+ if value:
362
+ step = Step(
363
+ index=step_idx,
364
+ operation=StepOperation.DEDUCE,
365
+ before_state=f"{person}.{category}=unknown",
366
+ after_state=f"{person}.{category}={value}",
367
+ output_value=value,
368
+ rule_applied="logical_elimination",
369
+ explanation=f"{person} is associated with {category}={value}.",
370
+ )
371
+ steps.append(step)
372
+ step_idx += 1
373
+
374
+ return Trace(
375
+ problem_id=problem_id,
376
+ steps=steps,
377
+ checkpoints=self._identify_checkpoints(steps),
378
+ )
379
+
380
+ def _generate_logic_grid(self, game: PuzzleGame) -> Trace:
381
+ """Generate trace for Logic Grid puzzle (alias for _generate_logic)."""
382
+ return self._generate_logic(game)
383
+
384
+ def _generate_hitori(self, game: PuzzleGame) -> Trace:
385
+ """Generate trace for Hitori puzzle."""
386
+ problem_id = f"hitori_{game.difficulty.value}_{game.seed}"
387
+ steps: list[Step] = []
388
+
389
+ if hasattr(game, "solution") and game.solution:
390
+ # Hitori has solution: 2D bool grid (True = shaded)
391
+ solution = game.solution
392
+ step_idx = 0
393
+
394
+ for row in range(len(solution)):
395
+ for col in range(len(solution[row])):
396
+ if solution[row][col]: # Cell should be shaded
397
+ step = Step(
398
+ index=step_idx,
399
+ operation=StepOperation.ELIMINATE,
400
+ before_state=f"cell(r{row + 1},c{col + 1})=unshaded",
401
+ after_state=f"cell(r{row + 1},c{col + 1})=shaded",
402
+ output_value=True,
403
+ position=(row + 1, col + 1),
404
+ rule_applied="duplicate_elimination",
405
+ explanation=f"Shade cell at row {row + 1}, column {col + 1} to eliminate duplicate.",
406
+ )
407
+ steps.append(step)
408
+ step_idx += 1
409
+
410
+ return Trace(
411
+ problem_id=problem_id,
412
+ steps=steps,
413
+ checkpoints=self._identify_checkpoints(steps),
414
+ )
415
+
416
+ def _generate_bridges(self, game: PuzzleGame) -> Trace:
417
+ """Generate trace for Bridges puzzle."""
418
+ problem_id = f"bridges_{game.difficulty.value}_{game.seed}"
419
+ steps: list[Step] = []
420
+
421
+ if hasattr(game, "solution") and game.solution:
422
+ # Bridges has solution: dict[(r1,c1,r2,c2) -> bridge_count]
423
+ solution = game.solution
424
+ step_idx = 0
425
+
426
+ for (r1, c1, r2, c2), count in solution.items():
427
+ if count > 0:
428
+ step = Step(
429
+ index=step_idx,
430
+ operation=StepOperation.PLACE,
431
+ before_state=f"bridge({r1 + 1},{c1 + 1})-({r2 + 1},{c2 + 1})=0",
432
+ after_state=f"bridge({r1 + 1},{c1 + 1})-({r2 + 1},{c2 + 1})={count}",
433
+ output_value=count,
434
+ position=(r1 + 1, c1 + 1, r2 + 1, c2 + 1),
435
+ rule_applied="connectivity_constraint",
436
+ explanation=f"Place {count} bridge(s) between islands at ({r1 + 1},{c1 + 1}) and ({r2 + 1},{c2 + 1}).",
437
+ )
438
+ steps.append(step)
439
+ step_idx += 1
440
+
441
+ return Trace(
442
+ problem_id=problem_id,
443
+ steps=steps,
444
+ checkpoints=self._identify_checkpoints(steps),
445
+ )
446
+
447
+ def _generate_knapsack(self, game: PuzzleGame) -> Trace:
448
+ """Generate trace for Knapsack puzzle."""
449
+ problem_id = f"knapsack_{game.difficulty.value}_{game.seed}"
450
+ steps: list[Step] = []
451
+
452
+ if hasattr(game, "optimal_selection") and game.optimal_selection:
453
+ # Knapsack has optimal_selection: list[bool]
454
+ items = getattr(game, "items", [])
455
+ step_idx = 0
456
+
457
+ for i, selected in enumerate(game.optimal_selection):
458
+ if selected:
459
+ item_name = items[i].name if i < len(items) else f"Item {i + 1}"
460
+ item_weight = items[i].weight if i < len(items) else 0
461
+ item_value = items[i].value if i < len(items) else 0
462
+ step = Step(
463
+ index=step_idx,
464
+ operation=StepOperation.PLACE,
465
+ before_state=f"item({item_name})=not_selected",
466
+ after_state=f"item({item_name})=selected",
467
+ output_value=True,
468
+ position=(i + 1,),
469
+ rule_applied="optimization",
470
+ explanation=f"Select {item_name} (weight: {item_weight}, value: {item_value}) for optimal value.",
471
+ )
472
+ steps.append(step)
473
+ step_idx += 1
474
+
475
+ return Trace(
476
+ problem_id=problem_id,
477
+ steps=steps,
478
+ checkpoints=self._identify_checkpoints(steps),
479
+ )
480
+
481
+ def _generate_lights_out(self, game: PuzzleGame) -> Trace:
482
+ """Generate trace for Lights Out puzzle."""
483
+ problem_id = f"lights_out_{game.difficulty.value}_{game.seed}"
484
+ steps: list[Step] = []
485
+
486
+ if hasattr(game, "presses") and game.presses:
487
+ # Lights Out has presses: 2D int grid (1 = press needed)
488
+ presses = game.presses
489
+ step_idx = 0
490
+ size = len(presses)
491
+
492
+ for row in range(size):
493
+ for col in range(len(presses[row])):
494
+ if presses[row][col] == 1:
495
+ step = Step(
496
+ index=step_idx,
497
+ operation=StepOperation.PLACE,
498
+ before_state=f"cell(r{row + 1},c{col + 1})=not_pressed",
499
+ after_state=f"cell(r{row + 1},c{col + 1})=pressed",
500
+ output_value=True,
501
+ position=(row + 1, col + 1),
502
+ rule_applied="xor_toggle",
503
+ explanation=f"Press light at row {row + 1}, column {col + 1} to toggle it and neighbors.",
504
+ )
505
+ steps.append(step)
506
+ step_idx += 1
507
+
508
+ return Trace(
509
+ problem_id=problem_id,
510
+ steps=steps,
511
+ checkpoints=self._identify_checkpoints(steps),
512
+ )
513
+
514
+ def _generate_minesweeper(self, game: PuzzleGame) -> Trace:
515
+ """Generate trace for Minesweeper puzzle."""
516
+ problem_id = f"minesweeper_{game.difficulty.value}_{game.seed}"
517
+ steps: list[Step] = []
518
+
519
+ if hasattr(game, "mines") and game.mines:
520
+ # Minesweeper has mines: 2D bool grid (True = mine)
521
+ mines = game.mines
522
+ size = len(mines)
523
+ step_idx = 0
524
+
525
+ # First, mark all mines as flagged
526
+ for row in range(size):
527
+ for col in range(len(mines[row])):
528
+ if mines[row][col]:
529
+ step = Step(
530
+ index=step_idx,
531
+ operation=StepOperation.ELIMINATE,
532
+ before_state=f"cell(r{row + 1},c{col + 1})=unknown",
533
+ after_state=f"cell(r{row + 1},c{col + 1})=mine",
534
+ output_value=True,
535
+ position=(row + 1, col + 1),
536
+ rule_applied="mine_identification",
537
+ explanation=f"Flag cell at row {row + 1}, column {col + 1} as a mine.",
538
+ )
539
+ steps.append(step)
540
+ step_idx += 1
541
+
542
+ # Then, reveal all safe cells
543
+ for row in range(size):
544
+ for col in range(len(mines[row])):
545
+ if not mines[row][col]:
546
+ counts = getattr(game, "counts", None)
547
+ count = counts[row][col] if counts else 0
548
+ step = Step(
549
+ index=step_idx,
550
+ operation=StepOperation.DEDUCE,
551
+ before_state=f"cell(r{row + 1},c{col + 1})=unknown",
552
+ after_state=f"cell(r{row + 1},c{col + 1})={count}",
553
+ output_value=count,
554
+ position=(row + 1, col + 1),
555
+ rule_applied="safe_reveal",
556
+ explanation=f"Reveal cell at row {row + 1}, column {col + 1} ({count} adjacent mines).",
557
+ )
558
+ steps.append(step)
559
+ step_idx += 1
560
+
561
+ return Trace(
562
+ problem_id=problem_id,
563
+ steps=steps,
564
+ checkpoints=self._identify_checkpoints(steps),
565
+ )
566
+
567
+ def _generate_scheduler(self, game: PuzzleGame) -> Trace:
568
+ """Generate trace for Scheduler puzzle."""
569
+ problem_id = f"scheduler_{game.difficulty.value}_{game.seed}"
570
+ steps: list[Step] = []
571
+
572
+ if hasattr(game, "optimal_schedule") and game.optimal_schedule:
573
+ # Scheduler has optimal_schedule: dict[task_id -> (worker_id, start_time)]
574
+ tasks = getattr(game, "tasks", [])
575
+ step_idx = 0
576
+
577
+ # Sort by start time for logical ordering
578
+ sorted_schedule = sorted(game.optimal_schedule.items(), key=lambda x: (x[1][1], x[0]))
579
+
580
+ for task_id, (worker_id, start_time) in sorted_schedule:
581
+ task = tasks[task_id] if task_id < len(tasks) else None
582
+ task_name = task.name if task else f"Task {task_id + 1}"
583
+ duration = task.duration if task else 0
584
+ end_time = start_time + duration
585
+
586
+ step = Step(
587
+ index=step_idx,
588
+ operation=StepOperation.PLACE,
589
+ before_state=f"task({task_name})=unscheduled",
590
+ after_state=f"task({task_name})=worker{worker_id}@{start_time}-{end_time}",
591
+ output_value=(worker_id, start_time),
592
+ position=(task_id + 1,),
593
+ rule_applied="scheduling_constraint",
594
+ explanation=f"Schedule {task_name} on Worker {worker_id} from time {start_time} to {end_time}.",
595
+ )
596
+ steps.append(step)
597
+ step_idx += 1
598
+
599
+ return Trace(
600
+ problem_id=problem_id,
601
+ steps=steps,
602
+ checkpoints=self._identify_checkpoints(steps),
603
+ )
604
+
605
+ def _generate_task_scheduler(self, game: PuzzleGame) -> Trace:
606
+ """Generate trace for Task Scheduler (alias for _generate_scheduler)."""
607
+ return self._generate_scheduler(game)
608
+
609
+ def _generate_slitherlink(self, game: PuzzleGame) -> Trace:
610
+ """Generate trace for Slitherlink puzzle."""
611
+ problem_id = f"slitherlink_{game.difficulty.value}_{game.seed}"
612
+ steps: list[Step] = []
613
+
614
+ step_idx = 0
615
+
616
+ # Horizontal edges
617
+ if hasattr(game, "solution_h_edges") and game.solution_h_edges:
618
+ for row in range(len(game.solution_h_edges)):
619
+ for col in range(len(game.solution_h_edges[row])):
620
+ if game.solution_h_edges[row][col] == 1:
621
+ step = Step(
622
+ index=step_idx,
623
+ operation=StepOperation.PLACE,
624
+ before_state=f"h_edge(r{row + 1},c{col + 1})=unknown",
625
+ after_state=f"h_edge(r{row + 1},c{col + 1})=line",
626
+ output_value=1,
627
+ position=(row + 1, col + 1),
628
+ rule_applied="loop_constraint",
629
+ explanation=f"Draw horizontal edge at row {row + 1}, column {col + 1}.",
630
+ )
631
+ steps.append(step)
632
+ step_idx += 1
633
+
634
+ # Vertical edges
635
+ if hasattr(game, "solution_v_edges") and game.solution_v_edges:
636
+ for row in range(len(game.solution_v_edges)):
637
+ for col in range(len(game.solution_v_edges[row])):
638
+ if game.solution_v_edges[row][col] == 1:
639
+ step = Step(
640
+ index=step_idx,
641
+ operation=StepOperation.PLACE,
642
+ before_state=f"v_edge(r{row + 1},c{col + 1})=unknown",
643
+ after_state=f"v_edge(r{row + 1},c{col + 1})=line",
644
+ output_value=1,
645
+ position=(row + 1, col + 1),
646
+ rule_applied="loop_constraint",
647
+ explanation=f"Draw vertical edge at row {row + 1}, column {col + 1}.",
648
+ )
649
+ steps.append(step)
650
+ step_idx += 1
651
+
652
+ return Trace(
653
+ problem_id=problem_id,
654
+ steps=steps,
655
+ checkpoints=self._identify_checkpoints(steps),
656
+ )
657
+
658
+ def _generate_sokoban(self, game: PuzzleGame) -> Trace:
659
+ """Generate trace for Sokoban puzzle."""
660
+ problem_id = f"sokoban_{game.difficulty.value}_{game.seed}"
661
+ steps: list[Step] = []
662
+
663
+ if hasattr(game, "goals") and game.goals:
664
+ # For Sokoban, the trace shows the goal state
665
+ # Each box needs to reach a goal position
666
+ step_idx = 0
667
+
668
+ # Find boxes in the initial grid (2 = box, 5 = box on goal)
669
+ grid = getattr(game, "grid", [])
670
+ boxes = []
671
+ for r in range(len(grid)):
672
+ for c in range(len(grid[r])):
673
+ if grid[r][c] in (2, 5):
674
+ boxes.append((r, c))
675
+
676
+ # Match boxes to goals (simplified - assumes 1:1 mapping)
677
+ for i, goal in enumerate(game.goals):
678
+ goal_r, goal_c = goal
679
+ box_pos = boxes[i] if i < len(boxes) else None
680
+
681
+ if box_pos:
682
+ box_r, box_c = box_pos
683
+ step = Step(
684
+ index=step_idx,
685
+ operation=StepOperation.PLACE,
686
+ before_state=f"box{i + 1}=({box_r + 1},{box_c + 1})",
687
+ after_state=f"box{i + 1}=({goal_r + 1},{goal_c + 1})",
688
+ output_value=(goal_r + 1, goal_c + 1),
689
+ position=(goal_r + 1, goal_c + 1),
690
+ rule_applied="goal_placement",
691
+ explanation=f"Push box {i + 1} from ({box_r + 1},{box_c + 1}) to goal at ({goal_r + 1},{goal_c + 1}).",
692
+ )
693
+ steps.append(step)
694
+ step_idx += 1
695
+
696
+ return Trace(
697
+ problem_id=problem_id,
698
+ steps=steps,
699
+ checkpoints=self._identify_checkpoints(steps),
700
+ )
701
+
702
+ def _generate_einstein_s_puzzle(self, game: PuzzleGame) -> Trace:
703
+ """Generate trace for Einstein's Puzzle (alias with different name format)."""
704
+ return self._generate_einstein(game)
705
+
706
+ def _generate_einstein_puzzle(self, game: PuzzleGame) -> Trace:
707
+ """Generate trace for Einstein's Puzzle (handles 'einstein's puzzle' -> 'einstein_puzzle')."""
708
+ return self._generate_einstein(game)
709
+
710
+ def _generate_einsteins_puzzle(self, game: PuzzleGame) -> Trace:
711
+ """Generate trace for Einstein's Puzzle (handles 'einstein's puzzle' -> 'einsteins_puzzle')."""
712
+ return self._generate_einstein(game)
713
+
714
+
715
+ def generate_trace(game: PuzzleGame) -> Trace:
716
+ """
717
+ Convenience function to generate a trace for a puzzle game.
718
+
719
+ Args:
720
+ game: A puzzle game instance with a generated puzzle
721
+
722
+ Returns:
723
+ Trace with step-by-step solution
724
+ """
725
+ generator = TraceGenerator()
726
+ return generator.generate(game)