Spatial-Gym 0.1.1__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.
- Spatial_Gym/Spatial_Gym.py +1314 -0
- Spatial_Gym/__init__.py +12 -0
- Spatial_Gym/register_env.py +8 -0
- Spatial_Gym/render/__init__.py +4 -0
- Spatial_Gym/render/human_renderer.py +451 -0
- Spatial_Gym/render/llm_renderer.py +229 -0
- spatial_gym-0.1.1.dist-info/METADATA +381 -0
- spatial_gym-0.1.1.dist-info/RECORD +11 -0
- spatial_gym-0.1.1.dist-info/WHEEL +4 -0
- spatial_gym-0.1.1.dist-info/entry_points.txt +2 -0
- spatial_gym-0.1.1.dist-info/licenses/LICENCE +21 -0
|
@@ -0,0 +1,1314 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
import json
|
|
3
|
+
from collections import Counter, deque
|
|
4
|
+
import gymnasium as gym
|
|
5
|
+
from gymnasium import spaces
|
|
6
|
+
import numpy as np
|
|
7
|
+
import yaml
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from .render import HumanRenderer, LLMRenderer
|
|
10
|
+
from datasets import load_dataset
|
|
11
|
+
|
|
12
|
+
class Actions(Enum):
|
|
13
|
+
"""
|
|
14
|
+
Enum class representing the possible actions the agent can take in the environment.
|
|
15
|
+
|
|
16
|
+
Actions:
|
|
17
|
+
right (int): Move the agent one step to the right.
|
|
18
|
+
up (int): Move the agent one step upward.
|
|
19
|
+
left (int): Move the agent one step to the left.
|
|
20
|
+
down (int): Move the agent one step downward.
|
|
21
|
+
"""
|
|
22
|
+
right = 0
|
|
23
|
+
up = 1
|
|
24
|
+
left = 2
|
|
25
|
+
down = 3
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class RegionData:
|
|
30
|
+
id: int
|
|
31
|
+
cells: list
|
|
32
|
+
area: int
|
|
33
|
+
symbols: dict
|
|
34
|
+
colors: dict
|
|
35
|
+
|
|
36
|
+
def to_summary(self):
|
|
37
|
+
return {
|
|
38
|
+
"id": self.id,
|
|
39
|
+
"area": self.area,
|
|
40
|
+
"symbol_counts": {k: len(v) for k, v in self.symbols.items()},
|
|
41
|
+
"colors": self.colors
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class Spatial_Gym(gym.Env):
|
|
45
|
+
metadata = {"render_modes": ["human", "llm"], "render_fps": 30}
|
|
46
|
+
def __init__(self, df_name='lkaesberg/SPaRC', df_split='all', df_set='test', render_mode=None, observation='new', traceback=False, max_steps=2000):
|
|
47
|
+
"""
|
|
48
|
+
Initialize the Spatial_Gym environment.
|
|
49
|
+
|
|
50
|
+
Loads the puzzle dataset, sets up rendering modes, and prepares the environment for agent interaction.
|
|
51
|
+
|
|
52
|
+
Parameters:
|
|
53
|
+
df_name (str): Name of the HuggingFace dataset to load (default: 'lkaesberg/SPaRC').
|
|
54
|
+
df_split (str): Dataset split to use (default: 'all').
|
|
55
|
+
df_set (str): Subset of the dataset for testing (default: 'test').
|
|
56
|
+
render_mode (str or None): Rendering mode, either 'human', 'llm', or None.
|
|
57
|
+
observation (str): Observation format, either 'new' (multi-array) or 'SPaRC' (JSON/text).
|
|
58
|
+
traceback (bool): If True, enables traceback.
|
|
59
|
+
max_steps (int): Maximum steps per episode before truncation.
|
|
60
|
+
|
|
61
|
+
Loads the puzzles, initializes renderers, sets up observation and action spaces, and loads the first puzzle.
|
|
62
|
+
"""
|
|
63
|
+
self.render_mode = render_mode
|
|
64
|
+
self.observation = observation
|
|
65
|
+
self.traceback = traceback
|
|
66
|
+
self.max_steps = max_steps
|
|
67
|
+
|
|
68
|
+
# Initialize renderers
|
|
69
|
+
self.human_renderer = None
|
|
70
|
+
self.llm_renderer = None
|
|
71
|
+
if render_mode == "human":
|
|
72
|
+
self.human_renderer = HumanRenderer(scale_factor=3.0)
|
|
73
|
+
elif render_mode == "llm":
|
|
74
|
+
self.llm_renderer = LLMRenderer()
|
|
75
|
+
|
|
76
|
+
# Load the puzzles
|
|
77
|
+
ds = load_dataset(df_name, df_split, split=df_set)
|
|
78
|
+
df = ds.to_pandas()
|
|
79
|
+
self.puzzles = df
|
|
80
|
+
self.current_puzzle_index = 0
|
|
81
|
+
self.current_step = 0
|
|
82
|
+
|
|
83
|
+
self.rule_status = {}
|
|
84
|
+
|
|
85
|
+
# Process the puzzles to extract relevant information
|
|
86
|
+
if self.puzzles is None or isinstance(self.puzzles, Exception):
|
|
87
|
+
raise ValueError("No valid dataframe provided")
|
|
88
|
+
self.puzzles = self._process_puzzles(self.puzzles)
|
|
89
|
+
# Load the first puzzle
|
|
90
|
+
self._load_puzzle(self.current_puzzle_index)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------- Puzzle Processing/Loading Functions ----------
|
|
94
|
+
|
|
95
|
+
def _load_puzzle(self, index):
|
|
96
|
+
'''
|
|
97
|
+
Function to load a puzzle from the dataset
|
|
98
|
+
and initialize the environment variables
|
|
99
|
+
aswell as the observation and action spaces
|
|
100
|
+
|
|
101
|
+
Parameters:
|
|
102
|
+
index : int
|
|
103
|
+
The index of the puzzle to load from the dataset.
|
|
104
|
+
|
|
105
|
+
--------------
|
|
106
|
+
puzzle variables:
|
|
107
|
+
|
|
108
|
+
difficulty : int
|
|
109
|
+
The difficulty of the puzzle
|
|
110
|
+
|
|
111
|
+
polyshapes : dict of 2d arrays
|
|
112
|
+
The polyshapes of the puzzle
|
|
113
|
+
|
|
114
|
+
x_size : int
|
|
115
|
+
The x size of the puzzle
|
|
116
|
+
|
|
117
|
+
y_size : int
|
|
118
|
+
The y size of the puzzle
|
|
119
|
+
|
|
120
|
+
obs_array : dict; Dictionary of 2D arrays
|
|
121
|
+
The observation array of the puzzle
|
|
122
|
+
or if observation == 'SPaRC':
|
|
123
|
+
The observation array in SPaRC format
|
|
124
|
+
|
|
125
|
+
unique_properties : int
|
|
126
|
+
The number of unique properties(star,polyshape, Co.) in the puzzle
|
|
127
|
+
|
|
128
|
+
start_location : tuple
|
|
129
|
+
The starting location of the agent in the puzzle
|
|
130
|
+
|
|
131
|
+
target_location : tuple
|
|
132
|
+
The target location of the agent in the puzzle
|
|
133
|
+
|
|
134
|
+
solution_paths : list
|
|
135
|
+
The solution paths of the puzzle
|
|
136
|
+
|
|
137
|
+
solution_count : int
|
|
138
|
+
The number of solution paths of the puzzle
|
|
139
|
+
|
|
140
|
+
'''
|
|
141
|
+
puzzle = self.puzzles[index]
|
|
142
|
+
|
|
143
|
+
self.difficulty = puzzle['difficulty']
|
|
144
|
+
self.polyshapes = puzzle['polyshapes']
|
|
145
|
+
|
|
146
|
+
self.x_size = puzzle['x_size']
|
|
147
|
+
self.y_size = puzzle['y_size']
|
|
148
|
+
|
|
149
|
+
self.obs_array = puzzle['obs_array']
|
|
150
|
+
self.color_array = puzzle['color_array']
|
|
151
|
+
self.additional_info = puzzle['additional_info']
|
|
152
|
+
|
|
153
|
+
if self.observation == 'SPaRC':
|
|
154
|
+
raw = puzzle['observ']
|
|
155
|
+
if isinstance(raw, np.ndarray) and raw.dtype == object and raw.ndim == 1:
|
|
156
|
+
grid_rows = [r.astype(str).tolist() for r in raw]
|
|
157
|
+
elif isinstance(raw, np.ndarray) and raw.ndim == 2:
|
|
158
|
+
grid_rows = raw.astype(str).tolist()
|
|
159
|
+
else:
|
|
160
|
+
grid_rows = [[str(c) for c in row] for row in raw]
|
|
161
|
+
w = len(grid_rows[0])
|
|
162
|
+
if any(len(r) != w for r in grid_rows):
|
|
163
|
+
raise ValueError("Non-rectangular SPaRC grid")
|
|
164
|
+
self.observ = grid_rows
|
|
165
|
+
|
|
166
|
+
self.start_location = puzzle['start_location']
|
|
167
|
+
self.target_location = puzzle['target_location']
|
|
168
|
+
|
|
169
|
+
self.solution_paths = puzzle['solution_paths']
|
|
170
|
+
self.solution_count = puzzle['solution_count']
|
|
171
|
+
|
|
172
|
+
# Initialize the agent's path with the starting location
|
|
173
|
+
self.path = [[self.start_location[0], self.start_location[1]]]
|
|
174
|
+
self.normal_reward = 0
|
|
175
|
+
self.outcome_reward = 0
|
|
176
|
+
|
|
177
|
+
self.rule_status = {}
|
|
178
|
+
|
|
179
|
+
self._agent_location = np.array([self.start_location[0], self.start_location[1]], dtype=np.int32)
|
|
180
|
+
self._target_location = np.array([self.target_location[0], self.target_location[1]], dtype=np.int32)
|
|
181
|
+
|
|
182
|
+
self._validate_rules(terminated=False, truncated=False)
|
|
183
|
+
|
|
184
|
+
# Mark the starting location as visited and set the agent's and target's positions in the observation array
|
|
185
|
+
self.obs_array['visited'][self._agent_location[0], self._agent_location[1]] = 1
|
|
186
|
+
self.obs_array['agent_location'][self._agent_location[0], self._agent_location[1]] = 1
|
|
187
|
+
self.obs_array['target_location'][self._target_location[0], self._target_location[1]] = 1
|
|
188
|
+
|
|
189
|
+
# Define the observation space for the environment
|
|
190
|
+
if self.observation == 'new':
|
|
191
|
+
keys = list(self.obs_array.keys())
|
|
192
|
+
self.observation_space = spaces.Dict({
|
|
193
|
+
'base': spaces.Dict({key: spaces.Box(low=0, high=1, shape=(self.x_size, self.y_size), dtype=np.int32) for key in keys}),
|
|
194
|
+
'color': spaces.Box(low=0, high=8, shape=(self.x_size, self.y_size), dtype=np.int32),
|
|
195
|
+
'additional_info': spaces.Box(low=0, high=143632, shape=(self.x_size, self.y_size), dtype=np.int64)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
elif self.observation == 'SPaRC':
|
|
199
|
+
init_json = self._build_json_obs()
|
|
200
|
+
overlay_chars = set("LV.")
|
|
201
|
+
charset = "".join(sorted(set(init_json) | overlay_chars))
|
|
202
|
+
max_length = int(len(init_json) * 2)
|
|
203
|
+
self._json_charset = charset
|
|
204
|
+
self.observation_space = spaces.Text(max_length=max_length, charset=charset)
|
|
205
|
+
|
|
206
|
+
else:
|
|
207
|
+
raise ValueError("Invalid observation type. Choose 'new' or 'SPaRC'.")
|
|
208
|
+
|
|
209
|
+
# Define the action space (4 discrete actions: right, up, left, down)
|
|
210
|
+
self.action_space = gym.spaces.Discrete(4)
|
|
211
|
+
# Map actions to directions
|
|
212
|
+
self._action_to_direction = {
|
|
213
|
+
Actions.right.value: np.array([1, 0]),
|
|
214
|
+
Actions.up.value: np.array([0, -1]),
|
|
215
|
+
Actions.left.value: np.array([-1, 0]),
|
|
216
|
+
Actions.down.value: np.array([0, 1]),
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
def _process_puzzles(self, df):
|
|
220
|
+
"""
|
|
221
|
+
Processes a DataFrame of puzzles and returns a list of puzzle dictionaries.
|
|
222
|
+
|
|
223
|
+
Parameters:
|
|
224
|
+
df (pd.DataFrame): The DataFrame containing puzzle data.
|
|
225
|
+
|
|
226
|
+
--------
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
list: A list of dictionaries, each representing a processed puzzle.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
puzzles = []
|
|
233
|
+
|
|
234
|
+
for i in range(len(df)):
|
|
235
|
+
|
|
236
|
+
puzzle = {}
|
|
237
|
+
|
|
238
|
+
# Extract difficulty
|
|
239
|
+
difficulty = df['difficulty_level'][i]
|
|
240
|
+
puzzle.update({'difficulty': difficulty})
|
|
241
|
+
|
|
242
|
+
# Extract grid size
|
|
243
|
+
grid_size = df['grid_size'][i]
|
|
244
|
+
x_size = grid_size['width']
|
|
245
|
+
y_size = grid_size['height']
|
|
246
|
+
x_size = x_size + x_size + 1
|
|
247
|
+
y_size = y_size + y_size + 1
|
|
248
|
+
puzzle.update({'x_size': x_size, 'y_size': y_size})
|
|
249
|
+
|
|
250
|
+
# Extract solution paths
|
|
251
|
+
solution_count = df['solution_count'][i]
|
|
252
|
+
solutions = df['solutions'][i]
|
|
253
|
+
solution_paths = []
|
|
254
|
+
for item in solutions:
|
|
255
|
+
path = [[point["x"], point["y"]] for point in item["path"]]
|
|
256
|
+
solution_paths.append(path)
|
|
257
|
+
puzzle.update({'solution_count': solution_count, 'solution_paths': solution_paths})
|
|
258
|
+
|
|
259
|
+
# Extract the polyshapes (eg. an L shape)
|
|
260
|
+
polyshapes = df['polyshapes'][i]
|
|
261
|
+
polyshapes_yaml = yaml.safe_load(polyshapes)
|
|
262
|
+
puzzle.update({'polyshapes': polyshapes_yaml})
|
|
263
|
+
|
|
264
|
+
# Extract start and target locations
|
|
265
|
+
text_visualization = df['text_visualization'][i]
|
|
266
|
+
text_yaml = yaml.safe_load(text_visualization)
|
|
267
|
+
start_location = (text_yaml["puzzle"]["start"]["x"], text_yaml["puzzle"]["start"]["y"])
|
|
268
|
+
target_location = (text_yaml["puzzle"]["end"]["x"], text_yaml["puzzle"]["end"]["y"])
|
|
269
|
+
puzzle.update({'start_location': start_location, 'target_location': target_location})
|
|
270
|
+
|
|
271
|
+
# Initialize observation arrays
|
|
272
|
+
obs_array = {
|
|
273
|
+
'visited': np.zeros((x_size, y_size), dtype=np.int32),
|
|
274
|
+
'gaps': np.zeros((x_size, y_size), dtype=np.int32),
|
|
275
|
+
'agent_location': np.zeros((x_size, y_size), dtype=np.int32),
|
|
276
|
+
'target_location': np.zeros((x_size, y_size), dtype=np.int32)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
color_array = np.zeros((x_size, y_size), dtype=np.int32)
|
|
280
|
+
additional_info = np.zeros((x_size, y_size), dtype=np.int64)
|
|
281
|
+
|
|
282
|
+
# Extract symbols, colors and additional info
|
|
283
|
+
for cell in text_yaml["puzzle"]["cells"]:
|
|
284
|
+
properties = cell.get("properties", {})
|
|
285
|
+
count = None
|
|
286
|
+
shape = None
|
|
287
|
+
color = None
|
|
288
|
+
for key, value in properties.items():
|
|
289
|
+
if key == 'type':
|
|
290
|
+
if value == 'star' or value == 'square':
|
|
291
|
+
symbol = f"{value}"
|
|
292
|
+
color = properties.get('color', '')
|
|
293
|
+
elif value == 'triangle':
|
|
294
|
+
symbol = f"{value}"
|
|
295
|
+
color = properties.get('color', '')
|
|
296
|
+
count = properties.get('count', '')
|
|
297
|
+
else:
|
|
298
|
+
symbol = f"{value}"
|
|
299
|
+
color = properties.get('color', '')
|
|
300
|
+
shape = properties.get('polyshape', '')
|
|
301
|
+
|
|
302
|
+
elif key == 'dot':
|
|
303
|
+
symbol = 'dot'
|
|
304
|
+
# Add new property to obs_array if not already present
|
|
305
|
+
if symbol not in obs_array:
|
|
306
|
+
obs_array.update({symbol: np.zeros((x_size, y_size), dtype=np.int32)})
|
|
307
|
+
|
|
308
|
+
# Update the colors
|
|
309
|
+
if color:
|
|
310
|
+
color_to_number = {"red": 1, "blue": 2, "yellow": 3, "green": 4, "black": 5, "purple": 6, "orange": 7, "white": 8}
|
|
311
|
+
position = cell.get("position", {})
|
|
312
|
+
x, y = position.get("x"), position.get("y")
|
|
313
|
+
for color_ in color_to_number:
|
|
314
|
+
if color_ == color:
|
|
315
|
+
color_array[x][y] = color_to_number[color_]
|
|
316
|
+
|
|
317
|
+
# update additional information
|
|
318
|
+
if count:
|
|
319
|
+
position = cell.get("position", {})
|
|
320
|
+
x, y = position.get("x"), position.get("y")
|
|
321
|
+
additional_info[x][y] = count
|
|
322
|
+
elif shape:
|
|
323
|
+
position = cell.get("position", {})
|
|
324
|
+
x, y = position.get("x"), position.get("y")
|
|
325
|
+
additional_info[x][y] = shape
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# Populate observation arrays
|
|
329
|
+
for cell in text_yaml["puzzle"]["cells"]:
|
|
330
|
+
position = cell.get("position", {})
|
|
331
|
+
properties = cell.get("properties", {})
|
|
332
|
+
x, y = position.get("x"), position.get("y")
|
|
333
|
+
|
|
334
|
+
for key, value in properties.items():
|
|
335
|
+
if key == 'type':
|
|
336
|
+
symbol = f"{value}"
|
|
337
|
+
elif key == 'dot':
|
|
338
|
+
symbol = 'dot'
|
|
339
|
+
elif key == 'gap':
|
|
340
|
+
symbol = 'gaps'
|
|
341
|
+
# Update the corresponding observation array
|
|
342
|
+
if symbol in obs_array:
|
|
343
|
+
obs_array[symbol][x, y] = 1
|
|
344
|
+
|
|
345
|
+
x_size = x_size - 1
|
|
346
|
+
y_size = y_size - 1
|
|
347
|
+
# Mark all the green cells as gaps
|
|
348
|
+
for k in range(x_size):
|
|
349
|
+
for j in range(y_size):
|
|
350
|
+
if k % 2 == 1 and j % 2 == 1:
|
|
351
|
+
obs_array['gaps'][k, j] = 1
|
|
352
|
+
|
|
353
|
+
puzzle.update({'obs_array': obs_array})
|
|
354
|
+
puzzle.update({'color_array': color_array})
|
|
355
|
+
puzzle.update({'additional_info': additional_info})
|
|
356
|
+
|
|
357
|
+
# If using the SPaRC observation format
|
|
358
|
+
if self.observation == 'SPaRC':
|
|
359
|
+
observ = df['puzzle_array'][i]
|
|
360
|
+
puzzle.update({'observ': observ})
|
|
361
|
+
|
|
362
|
+
id = df['id'][i]
|
|
363
|
+
puzzle.update({'id': id})
|
|
364
|
+
|
|
365
|
+
# Add the processed puzzle to the list
|
|
366
|
+
puzzles.append(puzzle)
|
|
367
|
+
|
|
368
|
+
return puzzles
|
|
369
|
+
|
|
370
|
+
# ---------- End Puzzle Processing/Loading Functions ----------
|
|
371
|
+
|
|
372
|
+
# ---------- Compute Regions Functions ----------
|
|
373
|
+
|
|
374
|
+
def _cell_index_grid(self):
|
|
375
|
+
'''
|
|
376
|
+
Helper Function for _compute_regions
|
|
377
|
+
Generate a mask for Rule cells.
|
|
378
|
+
'''
|
|
379
|
+
|
|
380
|
+
visited = self.obs_array['visited']
|
|
381
|
+
h, w = visited.shape
|
|
382
|
+
mask = np.zeros((h, w), dtype=bool)
|
|
383
|
+
for x in range(h):
|
|
384
|
+
for y in range(w):
|
|
385
|
+
if x % 2 == 1 and y % 2 == 1:
|
|
386
|
+
mask[x, y] = True
|
|
387
|
+
|
|
388
|
+
return mask
|
|
389
|
+
|
|
390
|
+
def _cell_index_grid2(self):
|
|
391
|
+
'''
|
|
392
|
+
Helper Function for _compute_regions
|
|
393
|
+
Generate a mask for visited cells and gaps.
|
|
394
|
+
'''
|
|
395
|
+
|
|
396
|
+
visited = self.obs_array['visited']
|
|
397
|
+
gaps = self.obs_array['gaps']
|
|
398
|
+
h, w = visited.shape
|
|
399
|
+
mask = np.zeros((h, w), dtype=bool)
|
|
400
|
+
path_nodes = {tuple(p) for p in self.path}
|
|
401
|
+
for x in range(h):
|
|
402
|
+
for y in range(w):
|
|
403
|
+
if gaps[x, y] == 1:
|
|
404
|
+
mask[x, y] = True
|
|
405
|
+
|
|
406
|
+
for x, y in path_nodes:
|
|
407
|
+
mask[x, y] = True
|
|
408
|
+
|
|
409
|
+
return mask
|
|
410
|
+
|
|
411
|
+
def _neighbours_cell(self, x, y, h, w):
|
|
412
|
+
"""
|
|
413
|
+
Helper Function for _compute_regions
|
|
414
|
+
Generate neighboring cell coordinates (1 unit away) in the grid.
|
|
415
|
+
"""
|
|
416
|
+
for dx, dy in ((0, 1), (0, -1), (1, 0), (-1, 0)):
|
|
417
|
+
nx, ny = x + dx, y + dy
|
|
418
|
+
if 0 <= nx < h and 0 <= ny < w:
|
|
419
|
+
yield nx, ny
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _compute_regions(self):
|
|
423
|
+
"""
|
|
424
|
+
Compute regions in the grid based on the observation layers.
|
|
425
|
+
"""
|
|
426
|
+
mask = self._cell_index_grid()
|
|
427
|
+
mask2 = self._cell_index_grid2()
|
|
428
|
+
h, w = mask.shape
|
|
429
|
+
region_map = -1 * np.ones((h, w), dtype=np.int32)
|
|
430
|
+
regions = []
|
|
431
|
+
rid = 0
|
|
432
|
+
|
|
433
|
+
for x in range(h):
|
|
434
|
+
for y in range(w):
|
|
435
|
+
if mask[x, y] and region_map[x, y] == -1:
|
|
436
|
+
enqueued_non_cells = np.zeros((h, w), dtype=bool)
|
|
437
|
+
q = deque([(x, y)])
|
|
438
|
+
region_map[x, y] = rid
|
|
439
|
+
cells = []
|
|
440
|
+
while q:
|
|
441
|
+
cx, cy = q.popleft()
|
|
442
|
+
if mask[cx, cy]:
|
|
443
|
+
cells.append((cx, cy))
|
|
444
|
+
for nx, ny in self._neighbours_cell(cx, cy, h, w):
|
|
445
|
+
if mask[nx, ny] and region_map[nx, ny] == -1:
|
|
446
|
+
region_map[nx, ny] = rid
|
|
447
|
+
q.append((nx, ny))
|
|
448
|
+
if not mask2[nx, ny] and not enqueued_non_cells[nx, ny]:
|
|
449
|
+
enqueued_non_cells[nx, ny] = True
|
|
450
|
+
q.append((nx, ny))
|
|
451
|
+
regions.append(RegionData(id=rid, cells=cells, area=len(cells), symbols={}, colors={}))
|
|
452
|
+
rid += 1
|
|
453
|
+
|
|
454
|
+
return regions, region_map
|
|
455
|
+
|
|
456
|
+
def _collect_region_symbols(self, regions, region_map):
|
|
457
|
+
"""
|
|
458
|
+
Collect symbols for each region based on the observation layers.
|
|
459
|
+
"""
|
|
460
|
+
|
|
461
|
+
if not regions:
|
|
462
|
+
return
|
|
463
|
+
|
|
464
|
+
# Build fast lookup
|
|
465
|
+
regions_by_id = {r.id: r for r in regions}
|
|
466
|
+
|
|
467
|
+
skip_layers = {'visited', 'gaps', 'agent_location', 'target_location'}
|
|
468
|
+
for layer, arr in self.obs_array.items():
|
|
469
|
+
if layer in skip_layers:
|
|
470
|
+
continue
|
|
471
|
+
# Collect all coordinates where symbol appears
|
|
472
|
+
xs, ys = np.where(arr == 1)
|
|
473
|
+
for x, y in zip(xs, ys):
|
|
474
|
+
rid = region_map[x, y]
|
|
475
|
+
if rid == -1:
|
|
476
|
+
continue
|
|
477
|
+
reg = regions_by_id[rid]
|
|
478
|
+
reg.symbols.setdefault(layer, []).append((x, y))
|
|
479
|
+
# Color info (if available)
|
|
480
|
+
color_val = self.color_array[x, y]
|
|
481
|
+
if color_val:
|
|
482
|
+
reg.colors[color_val] = reg.colors.get(color_val, 0) + 1
|
|
483
|
+
|
|
484
|
+
# ---------- End Compute Regions Functions ----------
|
|
485
|
+
|
|
486
|
+
# ---------- Rule Check Functions ----------
|
|
487
|
+
|
|
488
|
+
def _rule_reached_target(self):
|
|
489
|
+
'''
|
|
490
|
+
Checks:
|
|
491
|
+
The agent must reach the target location.
|
|
492
|
+
'''
|
|
493
|
+
return bool(np.array_equal(self._agent_location, self._target_location)), {
|
|
494
|
+
"agent_loc": self._agent_location.tolist(),
|
|
495
|
+
"target_loc": self._target_location.tolist()
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
def _rule_path_not_crossing(self):
|
|
499
|
+
'''
|
|
500
|
+
Checks:
|
|
501
|
+
No path nodes should be crossed more than once.
|
|
502
|
+
'''
|
|
503
|
+
path_nodes = [tuple(p) for p in self.path]
|
|
504
|
+
counts = Counter(path_nodes)
|
|
505
|
+
dup = {k: v for k, v in counts.items() if v > 1}
|
|
506
|
+
return len(dup) == 0, {"duplicates": dup}
|
|
507
|
+
|
|
508
|
+
def _rule_no_gap_violations(self):
|
|
509
|
+
'''
|
|
510
|
+
Checks:
|
|
511
|
+
No gaps should be crossed by the agent's path.
|
|
512
|
+
'''
|
|
513
|
+
gaps = self.obs_array['gaps']
|
|
514
|
+
violations = []
|
|
515
|
+
for (x, y) in self.path:
|
|
516
|
+
gx, gy = x, y
|
|
517
|
+
if gaps[gx, gy] == 1:
|
|
518
|
+
violations.append((gx, gy))
|
|
519
|
+
return len(violations) == 0, {"violations": violations}
|
|
520
|
+
|
|
521
|
+
def _rule_all_dots_collected(self):
|
|
522
|
+
"""
|
|
523
|
+
Checks:
|
|
524
|
+
All dots must be collected (visited) by the agent.
|
|
525
|
+
"""
|
|
526
|
+
if 'dot' not in self.obs_array:
|
|
527
|
+
return True, {"total": 0, "collected": 0}
|
|
528
|
+
dot_mask = self.obs_array['dot'] == 1
|
|
529
|
+
visited = self.obs_array['visited'] == 1
|
|
530
|
+
total = int(dot_mask.sum())
|
|
531
|
+
collected = int((dot_mask & visited).sum())
|
|
532
|
+
return (total == 0) or (collected == total), {"total": total, "collected": collected}
|
|
533
|
+
|
|
534
|
+
def _rule_color_square_separation(self, regions):
|
|
535
|
+
"""
|
|
536
|
+
Checks:
|
|
537
|
+
All squares inside any single region must have same color;
|
|
538
|
+
different colors must be separated by path -> no region with >1 square color.
|
|
539
|
+
"""
|
|
540
|
+
if 'square' not in self.obs_array:
|
|
541
|
+
return True, {"regions": []}
|
|
542
|
+
bad = []
|
|
543
|
+
details = []
|
|
544
|
+
for r in regions:
|
|
545
|
+
squares = r.symbols.get('square', [])
|
|
546
|
+
if not squares:
|
|
547
|
+
continue
|
|
548
|
+
colors = set(self.color_array[x, y] for (x, y) in squares if self.color_array[x, y] != 0)
|
|
549
|
+
if len(colors) > 1:
|
|
550
|
+
bad.append(r.id)
|
|
551
|
+
details.append({"region": r.id, "square_count": len(squares), "colors": list(colors)})
|
|
552
|
+
return len(bad) == 0, {"violating_regions": bad, "region_square_details": details}
|
|
553
|
+
|
|
554
|
+
def _rule_star_pairing_exact(self, regions):
|
|
555
|
+
"""
|
|
556
|
+
Checks:
|
|
557
|
+
Each star must share region with exactly one other same-color symbol.
|
|
558
|
+
=> For each region: for each star color, count must be 0 or 2 (not 1, not >2).
|
|
559
|
+
"""
|
|
560
|
+
if 'star' not in self.obs_array:
|
|
561
|
+
return True, {"regions": []}
|
|
562
|
+
violations = []
|
|
563
|
+
per_region = []
|
|
564
|
+
for r in regions:
|
|
565
|
+
star_coords = r.symbols.get('star', [])
|
|
566
|
+
if not star_coords:
|
|
567
|
+
continue
|
|
568
|
+
|
|
569
|
+
color_counts_all = {}
|
|
570
|
+
for layer, coords in r.symbols.items():
|
|
571
|
+
for (x, y) in coords:
|
|
572
|
+
c = self.color_array[x, y]
|
|
573
|
+
if c == 0:
|
|
574
|
+
continue
|
|
575
|
+
color_counts_all[c] = color_counts_all.get(c, 0) + 1
|
|
576
|
+
|
|
577
|
+
star_colors = {}
|
|
578
|
+
for (x, y) in star_coords:
|
|
579
|
+
c = self.color_array[x, y]
|
|
580
|
+
if c == 0:
|
|
581
|
+
violations.append({"region": r.id, "color": 0, "found_total": 1})
|
|
582
|
+
continue
|
|
583
|
+
star_colors[c] = star_colors.get(c, 0) + 1
|
|
584
|
+
|
|
585
|
+
region_ok = True
|
|
586
|
+
region_star_details = []
|
|
587
|
+
for c, star_count in star_colors.items():
|
|
588
|
+
total_c = color_counts_all.get(c, 0)
|
|
589
|
+
ok = (total_c == 2)
|
|
590
|
+
if not ok:
|
|
591
|
+
region_ok = False
|
|
592
|
+
violations.append({
|
|
593
|
+
"region": r.id,
|
|
594
|
+
"color": c,
|
|
595
|
+
"found_total": total_c,
|
|
596
|
+
"star_cells": star_count
|
|
597
|
+
})
|
|
598
|
+
region_star_details.append({
|
|
599
|
+
"color": c,
|
|
600
|
+
"total_symbols_of_color": total_c,
|
|
601
|
+
"star_cells": star_count,
|
|
602
|
+
"ok": ok
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
per_region.append({
|
|
606
|
+
"region": r.id,
|
|
607
|
+
"details": region_star_details,
|
|
608
|
+
"all_ok": region_ok
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
return len(violations) == 0, {
|
|
612
|
+
"violations": violations,
|
|
613
|
+
"per_region": per_region
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _rule_triangles_edges(self):
|
|
618
|
+
"""
|
|
619
|
+
Checks:
|
|
620
|
+
For each triangle cell: required count == number of touched edges.
|
|
621
|
+
"""
|
|
622
|
+
if 'triangle' not in self.obs_array:
|
|
623
|
+
return True, {"mismatches": []}
|
|
624
|
+
tri = self.obs_array['triangle']
|
|
625
|
+
h, w = tri.shape
|
|
626
|
+
mismatches = []
|
|
627
|
+
for x in range(1, h-1):
|
|
628
|
+
for y in range(1, w-1):
|
|
629
|
+
if tri[x, y] == 1:
|
|
630
|
+
required = int(self.additional_info[x, y])
|
|
631
|
+
if required <= 0:
|
|
632
|
+
continue
|
|
633
|
+
touches = self._triangle_touches(x, y)
|
|
634
|
+
if touches != required:
|
|
635
|
+
mismatches.append({"x": x, "y": y, "required": required, "touches": touches})
|
|
636
|
+
return len(mismatches) == 0, {"mismatches": mismatches}
|
|
637
|
+
|
|
638
|
+
def _triangle_touches(self, tri_x, tri_y):
|
|
639
|
+
path_nodes = {(p[0], p[1]) for p in self.path}
|
|
640
|
+
neighbor_nodes = [
|
|
641
|
+
(tri_x + 1, tri_y),
|
|
642
|
+
(tri_x - 1, tri_y),
|
|
643
|
+
(tri_x, tri_y - 1),
|
|
644
|
+
(tri_x, tri_y + 1)
|
|
645
|
+
]
|
|
646
|
+
return sum(1 for n in neighbor_nodes if n in path_nodes)
|
|
647
|
+
|
|
648
|
+
def _rule_poly_ylop_balance(self, regions):
|
|
649
|
+
"""
|
|
650
|
+
Combined check:
|
|
651
|
+
- area_ok: sum(poly) - sum(ylop) == region.area
|
|
652
|
+
- exact_ok: shapes can be placed exactly (no rotation/mirror) inside region
|
|
653
|
+
Rule passes only if both are True for all regions that contain any poly/ylop.
|
|
654
|
+
"""
|
|
655
|
+
instances = self._extract_poly_instances()
|
|
656
|
+
if not instances:
|
|
657
|
+
return True, {"regions": []}
|
|
658
|
+
|
|
659
|
+
# Map instances to regions
|
|
660
|
+
_, region_map = self._compute_regions()
|
|
661
|
+
by_region = {}
|
|
662
|
+
for inst in instances:
|
|
663
|
+
x, y = inst["x"], inst["y"]
|
|
664
|
+
if 0 <= x < region_map.shape[0] and 0 <= y < region_map.shape[1]:
|
|
665
|
+
rid = region_map[x, y]
|
|
666
|
+
if rid != -1:
|
|
667
|
+
by_region.setdefault(rid, []).append(inst)
|
|
668
|
+
|
|
669
|
+
regions_by_id = {r.id: r for r in regions}
|
|
670
|
+
violations = []
|
|
671
|
+
region_details = []
|
|
672
|
+
|
|
673
|
+
for rid, lst in by_region.items():
|
|
674
|
+
region = regions_by_id.get(rid)
|
|
675
|
+
if region is None:
|
|
676
|
+
continue
|
|
677
|
+
|
|
678
|
+
# area check
|
|
679
|
+
poly_area = sum(inst["area"] for inst in lst if inst["kind"] == "poly")
|
|
680
|
+
ylop_area = sum(inst["area"] for inst in lst if inst["kind"] == "ylop")
|
|
681
|
+
net = poly_area - ylop_area
|
|
682
|
+
area_ok = (region.area == net)
|
|
683
|
+
|
|
684
|
+
detail = {
|
|
685
|
+
"region": rid,
|
|
686
|
+
"area_check": {
|
|
687
|
+
"region_area": region.area,
|
|
688
|
+
"poly_area": poly_area,
|
|
689
|
+
"ylop_area": ylop_area,
|
|
690
|
+
"net": net,
|
|
691
|
+
"ok": area_ok
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
# exact-fit (only attempt if area looks right)
|
|
696
|
+
if area_ok:
|
|
697
|
+
exact_ok, exact_det = self._polyfit_region_exact(region, lst)
|
|
698
|
+
else:
|
|
699
|
+
exact_ok, exact_det = False, {"skipped": True}
|
|
700
|
+
|
|
701
|
+
detail["exact_fit"] = {"ok": exact_ok, **exact_det}
|
|
702
|
+
detail["ok"] = area_ok and exact_ok
|
|
703
|
+
region_details.append(detail)
|
|
704
|
+
|
|
705
|
+
violations = [d["region"] for d in region_details if not d["ok"]]
|
|
706
|
+
|
|
707
|
+
return len(violations) == 0, {
|
|
708
|
+
"violations": violations,
|
|
709
|
+
"region_details": region_details
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
# ---------- End Rule Check Functions ----------
|
|
713
|
+
|
|
714
|
+
# ---------- Poly/Ylop helpers ----------
|
|
715
|
+
|
|
716
|
+
def _extract_poly_instances(self):
|
|
717
|
+
"""
|
|
718
|
+
Helper Function for _rule_poly_ylop_balance
|
|
719
|
+
Extracts instances of polyshapes and ylop shapes from the additional_info array.
|
|
720
|
+
"""
|
|
721
|
+
instances = []
|
|
722
|
+
if not isinstance(self.polyshapes, dict):
|
|
723
|
+
return instances
|
|
724
|
+
h, w = self.additional_info.shape
|
|
725
|
+
for x in range(h):
|
|
726
|
+
for y in range(w):
|
|
727
|
+
val = self.additional_info[x, y]
|
|
728
|
+
if val != 0:
|
|
729
|
+
name = f'{val}'
|
|
730
|
+
if name not in self.polyshapes:
|
|
731
|
+
continue
|
|
732
|
+
shape_arr = np.array(self.polyshapes[name])
|
|
733
|
+
area = int(shape_arr.sum())
|
|
734
|
+
kind = 'poly' if (self.obs_array['poly'][x, y] == 1) else 'ylop'
|
|
735
|
+
instances.append({"name": name, "x": x, "y": y, "area": area, "kind": kind})
|
|
736
|
+
return instances
|
|
737
|
+
|
|
738
|
+
def _polyfit_region_exact(self, region, instances):
|
|
739
|
+
'''
|
|
740
|
+
Helper function to check exact fit of polygons in a region.
|
|
741
|
+
'''
|
|
742
|
+
H, W = self.x_size, self.y_size
|
|
743
|
+
|
|
744
|
+
region_center_mask = np.zeros((H, W), dtype=bool)
|
|
745
|
+
for (rx, ry) in region.cells:
|
|
746
|
+
region_center_mask[rx, ry] = True
|
|
747
|
+
region_size = int(region_center_mask[1::2, 1::2].sum())
|
|
748
|
+
|
|
749
|
+
polys, ylops = [], []
|
|
750
|
+
poly_area = 0
|
|
751
|
+
ylop_area = 0
|
|
752
|
+
for inst in instances:
|
|
753
|
+
name = inst["name"]
|
|
754
|
+
arr = np.array(self.polyshapes[name], dtype=np.int32)
|
|
755
|
+
area = int(arr.sum())
|
|
756
|
+
if inst["kind"] == "poly":
|
|
757
|
+
polys.append({"name": name, "array": arr})
|
|
758
|
+
poly_area += area
|
|
759
|
+
else:
|
|
760
|
+
ylops.append({"name": name, "array": arr})
|
|
761
|
+
ylop_area += area
|
|
762
|
+
|
|
763
|
+
net = poly_area - ylop_area
|
|
764
|
+
|
|
765
|
+
# If ylops cancel polys exactly, geometry poses no constraint
|
|
766
|
+
if net == 0:
|
|
767
|
+
poly_names = Counter(p["name"] for p in polys)
|
|
768
|
+
ylop_names = Counter(y["name"] for y in ylops)
|
|
769
|
+
if poly_names == ylop_names:
|
|
770
|
+
return True, {
|
|
771
|
+
"region_id": region.id,
|
|
772
|
+
"region_area": region_size,
|
|
773
|
+
"poly_area": poly_area,
|
|
774
|
+
"ylop_area": ylop_area,
|
|
775
|
+
"net": net
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
grid = np.zeros((H, W), dtype=np.int32)
|
|
779
|
+
if net > 0:
|
|
780
|
+
grid[region_center_mask] = -1
|
|
781
|
+
|
|
782
|
+
anchors_all = [(x, y) for x in range(1, H, 2) for y in range(1, W, 2)]
|
|
783
|
+
|
|
784
|
+
ok = self._polyfit_place_ylops(ylops, 0, polys, grid, anchors_all)
|
|
785
|
+
|
|
786
|
+
return ok, {
|
|
787
|
+
"region_id": region.id,
|
|
788
|
+
"region_area": region_size,
|
|
789
|
+
"poly_area": poly_area,
|
|
790
|
+
"ylop_area": ylop_area,
|
|
791
|
+
"net": net
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
def _polyfit_place_ylops(self, ylops, idx, polys, grid, anchors):
|
|
795
|
+
'''
|
|
796
|
+
Helper function to place ylop shapes in the grid.
|
|
797
|
+
'''
|
|
798
|
+
|
|
799
|
+
if idx == len(ylops):
|
|
800
|
+
# hand over to poly placement (to be implemented next)
|
|
801
|
+
return self._polyfit_place_polys(polys, grid)
|
|
802
|
+
|
|
803
|
+
ylop = ylops[idx]
|
|
804
|
+
arr_o = ylop["array"]
|
|
805
|
+
|
|
806
|
+
offsets = self._get_offsets(arr_o)
|
|
807
|
+
for (ax, ay) in anchors:
|
|
808
|
+
if not self._try_place_polys(grid, offsets, ax, ay, sign=-1):
|
|
809
|
+
continue
|
|
810
|
+
|
|
811
|
+
if self._polyfit_place_ylops(ylops, idx + 1, polys, grid, anchors):
|
|
812
|
+
return True
|
|
813
|
+
|
|
814
|
+
self._unplace_offsets(grid, offsets, ax, ay, sign=-1)
|
|
815
|
+
|
|
816
|
+
return False
|
|
817
|
+
|
|
818
|
+
def _polyfit_place_polys(self, polys, grid):
|
|
819
|
+
'''
|
|
820
|
+
Helper function to place poly shapes in the grid.
|
|
821
|
+
'''
|
|
822
|
+
if np.any(grid > 0):
|
|
823
|
+
return False
|
|
824
|
+
|
|
825
|
+
if not polys:
|
|
826
|
+
return not np.any(grid < 0)
|
|
827
|
+
|
|
828
|
+
negs = np.argwhere((grid < 0))
|
|
829
|
+
if negs.size == 0:
|
|
830
|
+
return True
|
|
831
|
+
nx, ny = negs[np.lexsort((negs[:,1], negs[:,0]))][0]
|
|
832
|
+
target = [(int(nx), int(ny))]
|
|
833
|
+
|
|
834
|
+
for (ax, ay) in target:
|
|
835
|
+
tried_names = set()
|
|
836
|
+
for i, poly in enumerate(polys):
|
|
837
|
+
name = poly["name"]
|
|
838
|
+
if name in tried_names:
|
|
839
|
+
continue
|
|
840
|
+
tried_names.add(name)
|
|
841
|
+
|
|
842
|
+
arr_o = poly["array"]
|
|
843
|
+
offsets = self._get_offsets(arr_o)
|
|
844
|
+
if not self._try_place_polys(grid, offsets, ax, ay, sign=+1):
|
|
845
|
+
continue
|
|
846
|
+
|
|
847
|
+
rem = polys[:i] + polys[i+1:]
|
|
848
|
+
if self._polyfit_place_polys(rem, grid):
|
|
849
|
+
return True
|
|
850
|
+
|
|
851
|
+
self._unplace_offsets(grid, offsets, ax, ay, sign=+1)
|
|
852
|
+
|
|
853
|
+
return False
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _get_offsets(self, shape_arr):
|
|
857
|
+
'''
|
|
858
|
+
Helper function to get the offsets of a shape array.
|
|
859
|
+
'''
|
|
860
|
+
shape = np.array(shape_arr, dtype=np.int32)
|
|
861
|
+
xs, ys = np.where(shape == 1)
|
|
862
|
+
if len(xs) == 0:
|
|
863
|
+
return []
|
|
864
|
+
ax = xs.min()
|
|
865
|
+
ay = ys[np.where(xs == ax)[0]].min()
|
|
866
|
+
offsets = []
|
|
867
|
+
for x, y in zip(xs, ys):
|
|
868
|
+
cx = 2 * (x - ax)
|
|
869
|
+
cy = 2 * (y - ay)
|
|
870
|
+
offsets.append((cx, cy))
|
|
871
|
+
return offsets
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _try_place_polys(self, grid, offsets, anchor_x, anchor_y, sign):
|
|
875
|
+
'''
|
|
876
|
+
Helper function to try placing polygons in the grid.
|
|
877
|
+
'''
|
|
878
|
+
H, W = grid.shape
|
|
879
|
+
targets = []
|
|
880
|
+
for dx, dy in offsets:
|
|
881
|
+
tx, ty = anchor_x + dx, anchor_y + dy
|
|
882
|
+
if tx < 0 or tx >= H or ty < 0 or ty >= W:
|
|
883
|
+
return False
|
|
884
|
+
targets.append((tx, ty))
|
|
885
|
+
for tx, ty in targets:
|
|
886
|
+
grid[tx, ty] += sign
|
|
887
|
+
return True
|
|
888
|
+
|
|
889
|
+
def _unplace_offsets(self, grid, offsets, anchor_x, anchor_y, sign):
|
|
890
|
+
'''
|
|
891
|
+
Helper function to unplace shapes from the grid.
|
|
892
|
+
'''
|
|
893
|
+
for dx, dy in offsets:
|
|
894
|
+
tx, ty = anchor_x + dx, anchor_y + dy
|
|
895
|
+
grid[tx, ty] -= sign
|
|
896
|
+
|
|
897
|
+
# ---------- End Poly/Ylop helpers ----------
|
|
898
|
+
|
|
899
|
+
# ---------- Validate Rules Functions ----------
|
|
900
|
+
|
|
901
|
+
def _run_rule_validators(self, regions, terminated, truncated):
|
|
902
|
+
'''
|
|
903
|
+
Run all rule validators on the given regions.
|
|
904
|
+
'''
|
|
905
|
+
|
|
906
|
+
rule_results = {}
|
|
907
|
+
|
|
908
|
+
def add(name, passed, detail):
|
|
909
|
+
rule_results[name] = {"passed": passed, "detail": detail}
|
|
910
|
+
|
|
911
|
+
# Global / path related
|
|
912
|
+
p, d = self._rule_reached_target()
|
|
913
|
+
add("reached_target", p, d)
|
|
914
|
+
p, d = self._rule_path_not_crossing()
|
|
915
|
+
add("path_not_crossing", p, d)
|
|
916
|
+
p, d = self._rule_no_gap_violations()
|
|
917
|
+
add("no_gap_violations", p, d)
|
|
918
|
+
p, d = self._rule_all_dots_collected()
|
|
919
|
+
add("all_dots_collected", p, d)
|
|
920
|
+
|
|
921
|
+
# Region & symbol based
|
|
922
|
+
p, d = self._rule_color_square_separation(regions)
|
|
923
|
+
add("square_color_separation", p, d)
|
|
924
|
+
p, d = self._rule_star_pairing_exact(regions)
|
|
925
|
+
add("star_pairing_exact", p, d)
|
|
926
|
+
p, d = self._rule_triangles_edges()
|
|
927
|
+
add("triangles_edge_count", p, d)
|
|
928
|
+
p, d = self._rule_poly_ylop_balance(regions)
|
|
929
|
+
add("poly_ylop_area", p, d)
|
|
930
|
+
|
|
931
|
+
# Aggregate success (only core puzzle rules, not termination flags)
|
|
932
|
+
core = [k for k in rule_results.keys()
|
|
933
|
+
if k not in ("_terminated", "_truncated", "all_rules_satisfied")]
|
|
934
|
+
all_pass = all(rule_results[k]["passed"] for k in core)
|
|
935
|
+
add("all_rules_satisfied", all_pass, {"rules_checked": core})
|
|
936
|
+
|
|
937
|
+
rule_results["_terminated"] = {"passed": True, "detail": terminated}
|
|
938
|
+
rule_results["_truncated"] = {"passed": True, "detail": truncated}
|
|
939
|
+
return rule_results
|
|
940
|
+
|
|
941
|
+
def _validate_rules(self, terminated=False, truncated=False):
|
|
942
|
+
'''
|
|
943
|
+
Validate the rules for the current puzzle state.
|
|
944
|
+
'''
|
|
945
|
+
regions, region_map = self._compute_regions()
|
|
946
|
+
self._collect_region_symbols(regions, region_map)
|
|
947
|
+
self.rule_status = self._run_rule_validators(regions, terminated, truncated)
|
|
948
|
+
# Attach region summaries
|
|
949
|
+
self.rule_status["_regions"] = {r.id: r.to_summary() for r in regions}
|
|
950
|
+
return self.rule_status
|
|
951
|
+
|
|
952
|
+
# ---------- End Validate Rules Functions ----------
|
|
953
|
+
|
|
954
|
+
# ---------- Get Observation/Info Functions ----------
|
|
955
|
+
|
|
956
|
+
def _get_obs(self):
|
|
957
|
+
'''
|
|
958
|
+
Function to return the current observation of the puzzle
|
|
959
|
+
if observation == 'new':
|
|
960
|
+
|
|
961
|
+
Returns: dictionary of:
|
|
962
|
+
dictionary of 2D arrays,
|
|
963
|
+
2D array,
|
|
964
|
+
2D array
|
|
965
|
+
----------
|
|
966
|
+
{
|
|
967
|
+
obs : dict; dictionary of 2D arrays
|
|
968
|
+
A dictionary containing the current locations of the properties of the puzzle
|
|
969
|
+
color_array : 2D array
|
|
970
|
+
A 2D array containing the colors of the properties in the puzzle
|
|
971
|
+
additional_info : 2D array
|
|
972
|
+
A 2D array containing additional information about the properties in the puzzle
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
if observation == 'SPaRC':
|
|
976
|
+
Returns a json string representation of the puzzle in the SPaRC format
|
|
977
|
+
'''
|
|
978
|
+
if self.observation == 'new':
|
|
979
|
+
return {'base': self.obs_array, 'color': self.color_array, 'additional_info': self.additional_info}
|
|
980
|
+
|
|
981
|
+
elif self.observation == 'SPaRC':
|
|
982
|
+
observ = self._build_json_obs()
|
|
983
|
+
return observ
|
|
984
|
+
|
|
985
|
+
else:
|
|
986
|
+
raise ValueError("Invalid observation type. Choose 'new' or 'SPaRC'.")
|
|
987
|
+
|
|
988
|
+
def _build_json_obs(self):
|
|
989
|
+
'''
|
|
990
|
+
Helper Function to turn the SPaRC observation into JSON format
|
|
991
|
+
'''
|
|
992
|
+
return json.dumps(self.observ, separators=(',', ':'))
|
|
993
|
+
|
|
994
|
+
def _get_info(self):
|
|
995
|
+
'''
|
|
996
|
+
Function to return extra information of the current puzzle
|
|
997
|
+
|
|
998
|
+
Returns:
|
|
999
|
+
info : dict
|
|
1000
|
+
A dictionary containing the following information:
|
|
1001
|
+
- solution_count: The number of solutions for the current puzzle
|
|
1002
|
+
- difficulty: The difficulty of the current puzzle
|
|
1003
|
+
- grid_y_size: The y size of the current puzzle
|
|
1004
|
+
- grid_x_size: The x size of the current puzzle
|
|
1005
|
+
- legal_actions: The legal actions for the current state of the agent
|
|
1006
|
+
- current_step: The current step of the agent
|
|
1007
|
+
- agent_location: The current location of the agent
|
|
1008
|
+
- rule_status: The current status of the rules
|
|
1009
|
+
- Rewards: The current rewards
|
|
1010
|
+
'''
|
|
1011
|
+
self._validate_rules(terminated=False, truncated=False)
|
|
1012
|
+
info = {"solution_count": self.solution_count,
|
|
1013
|
+
"difficulty": self.difficulty,
|
|
1014
|
+
"grid_x_size": self.x_size,
|
|
1015
|
+
"grid_y_size": self.y_size,
|
|
1016
|
+
"legal_actions": self._get_legal_actions(),
|
|
1017
|
+
"current_step": self.current_step,
|
|
1018
|
+
"agent_location": self._agent_location,
|
|
1019
|
+
"rule_status": self.rule_status,
|
|
1020
|
+
"Rewards": {"normal_reward": self.normal_reward, "outcome_reward": self.outcome_reward}
|
|
1021
|
+
}
|
|
1022
|
+
return info
|
|
1023
|
+
|
|
1024
|
+
def _get_legal_actions(self):
|
|
1025
|
+
'''
|
|
1026
|
+
Function to get the legal actions for the current state of the agent
|
|
1027
|
+
|
|
1028
|
+
Returns:
|
|
1029
|
+
legal : list
|
|
1030
|
+
A list of legal actions for the current state of the agent
|
|
1031
|
+
'''
|
|
1032
|
+
legal = []
|
|
1033
|
+
|
|
1034
|
+
for action, direction in self._action_to_direction.items():
|
|
1035
|
+
next_loc = self._agent_location + direction
|
|
1036
|
+
# np.clip to make sure we don't go out of bounds
|
|
1037
|
+
agent_location_temp = np.clip(next_loc, [0, 0], [self.x_size - 1, self.y_size - 1])
|
|
1038
|
+
# Check if the next location is not a gap
|
|
1039
|
+
if self.obs_array['gaps'][agent_location_temp[0], agent_location_temp[1]] == 0:
|
|
1040
|
+
if self.obs_array['visited'][agent_location_temp[0], agent_location_temp[1]] == 1:
|
|
1041
|
+
if self.traceback:
|
|
1042
|
+
if len(self.path) >= 2:
|
|
1043
|
+
last_loc = np.array([self.path[-2][0], self.path[-2][1]], dtype=np.int32)
|
|
1044
|
+
if np.array_equal(last_loc, agent_location_temp):
|
|
1045
|
+
if np.array_equal(next_loc, agent_location_temp):
|
|
1046
|
+
legal.append(action)
|
|
1047
|
+
else:
|
|
1048
|
+
if np.array_equal(next_loc, agent_location_temp):
|
|
1049
|
+
legal.append(action)
|
|
1050
|
+
|
|
1051
|
+
return legal
|
|
1052
|
+
|
|
1053
|
+
# ---------- End Get Observation/Info Functions ----------
|
|
1054
|
+
|
|
1055
|
+
# ---------- Core Gym Env Functions ----------
|
|
1056
|
+
|
|
1057
|
+
def reset(self, seed=None, options=None):
|
|
1058
|
+
'''
|
|
1059
|
+
Function to reset the environment and load the next puzzle
|
|
1060
|
+
Parameters:
|
|
1061
|
+
seed : int
|
|
1062
|
+
The seed for the random number generator
|
|
1063
|
+
options : dict
|
|
1064
|
+
Additional options for resetting the environment
|
|
1065
|
+
Only used option is 'puzzle_id' to load a specific puzzle by its ID
|
|
1066
|
+
----------
|
|
1067
|
+
Returns:
|
|
1068
|
+
obs : depends on obs type given in gym.make()
|
|
1069
|
+
The Observation of the current puzzle state
|
|
1070
|
+
info : dict
|
|
1071
|
+
A dictionary containing the extra information of the current puzzle
|
|
1072
|
+
'''
|
|
1073
|
+
super().reset(seed=seed)
|
|
1074
|
+
|
|
1075
|
+
puzzle_id = None
|
|
1076
|
+
if options is not None:
|
|
1077
|
+
# If a specific puzzle ID is given, load that puzzle
|
|
1078
|
+
puzzle_id = options.get('puzzle_id', None)
|
|
1079
|
+
for idx, puzzle in enumerate(self.puzzles):
|
|
1080
|
+
if puzzle['id'] == puzzle_id:
|
|
1081
|
+
self.current_puzzle_index = idx
|
|
1082
|
+
break
|
|
1083
|
+
else:
|
|
1084
|
+
if seed is not None:
|
|
1085
|
+
self.current_puzzle_index = int(self.np_random.integers(len(self.puzzles)))
|
|
1086
|
+
else:
|
|
1087
|
+
self.current_puzzle_index = (self.current_puzzle_index + 1) % len(self.puzzles)
|
|
1088
|
+
|
|
1089
|
+
# Also possible to randomly select a puzzle
|
|
1090
|
+
# self.current_puzzle_index = np.random.randint(0, len(self.puzzles))
|
|
1091
|
+
|
|
1092
|
+
self.current_step = 0
|
|
1093
|
+
|
|
1094
|
+
# Load the next puzzle
|
|
1095
|
+
self._load_puzzle(self.current_puzzle_index)
|
|
1096
|
+
|
|
1097
|
+
# Visualize the initial state of the environment
|
|
1098
|
+
if self.render_mode == "human":
|
|
1099
|
+
if self.human_renderer is None:
|
|
1100
|
+
self.human_renderer = HumanRenderer(scale_factor=3.0)
|
|
1101
|
+
self.render()
|
|
1102
|
+
elif self.render_mode == "llm":
|
|
1103
|
+
if self.llm_renderer is None:
|
|
1104
|
+
self.llm_renderer = LLMRenderer()
|
|
1105
|
+
self.render()
|
|
1106
|
+
|
|
1107
|
+
# Return the initial observation for the new puzzle
|
|
1108
|
+
return self._get_obs(), self._get_info()
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def step(self, action):
|
|
1112
|
+
'''
|
|
1113
|
+
Function to take a step in the environment
|
|
1114
|
+
Parameters:
|
|
1115
|
+
action : int
|
|
1116
|
+
The action to take in the environment
|
|
1117
|
+
----------
|
|
1118
|
+
Returns:
|
|
1119
|
+
obs : depends on obs type given in gym.make()
|
|
1120
|
+
The Observation of the current puzzle state
|
|
1121
|
+
reward : int
|
|
1122
|
+
The reward for taking the action
|
|
1123
|
+
terminated : bool
|
|
1124
|
+
Whether the episode has terminated
|
|
1125
|
+
truncated : bool
|
|
1126
|
+
Whether the episode has been truncated
|
|
1127
|
+
info : dict
|
|
1128
|
+
A dictionary containing the extra information of the current puzzle
|
|
1129
|
+
'''
|
|
1130
|
+
|
|
1131
|
+
orig_loc = self._agent_location.copy()
|
|
1132
|
+
self.current_step += 1
|
|
1133
|
+
self.normal_reward = 0
|
|
1134
|
+
truncated = self.current_step >= self.max_steps
|
|
1135
|
+
|
|
1136
|
+
# If the action is not in the legal actions, we do not move
|
|
1137
|
+
if action in self._get_legal_actions():
|
|
1138
|
+
direction = self._action_to_direction[action]
|
|
1139
|
+
agent_location_temp = self._agent_location + direction
|
|
1140
|
+
|
|
1141
|
+
if self.obs_array['visited'][agent_location_temp[0], agent_location_temp[1]] == 1:
|
|
1142
|
+
if self.traceback:
|
|
1143
|
+
last_loc = np.array([self.path[-2][0], self.path[-2][1]], dtype=np.int32)
|
|
1144
|
+
if np.array_equal(last_loc, agent_location_temp):
|
|
1145
|
+
# If the next location is already visited and is the last location, we are allowed to move back
|
|
1146
|
+
self.obs_array['agent_location'][self._agent_location[0]][self._agent_location[1]] = 0
|
|
1147
|
+
self.obs_array['visited'][self._agent_location[0]][self._agent_location[1]] = 0
|
|
1148
|
+
|
|
1149
|
+
# Update the SPaRC observation if it is active
|
|
1150
|
+
if self.observation == 'SPaRC':
|
|
1151
|
+
r, c = self._agent_location[0], self._agent_location[1]
|
|
1152
|
+
self.observ[c][r] = '.' if self.obs_array['gaps'][r, c] == 1 else '+'
|
|
1153
|
+
|
|
1154
|
+
self._agent_location = agent_location_temp
|
|
1155
|
+
|
|
1156
|
+
# Update the agent's location in the observation
|
|
1157
|
+
self.obs_array['visited'][self._agent_location[0]][self._agent_location[1]] = 1
|
|
1158
|
+
self.obs_array['agent_location'][self._agent_location[0]][self._agent_location[1]] = 1
|
|
1159
|
+
|
|
1160
|
+
# Also update the SPaRC observation if it is active
|
|
1161
|
+
if self.observation == 'SPaRC':
|
|
1162
|
+
r, c = self._agent_location[0], self._agent_location[1]
|
|
1163
|
+
self.observ[c][r] = 'L'
|
|
1164
|
+
|
|
1165
|
+
# Update the path
|
|
1166
|
+
del self.path[-1]
|
|
1167
|
+
else:
|
|
1168
|
+
self.obs_array['agent_location'][self._agent_location[0]][self._agent_location[1]] = 0
|
|
1169
|
+
|
|
1170
|
+
# Update the SPaRC observation if it is active
|
|
1171
|
+
if self.observation == 'SPaRC':
|
|
1172
|
+
r, c = self._agent_location[0], self._agent_location[1]
|
|
1173
|
+
self.observ[c][r] = 'V'
|
|
1174
|
+
|
|
1175
|
+
self._agent_location = agent_location_temp
|
|
1176
|
+
|
|
1177
|
+
# Update the agent's location in the observation
|
|
1178
|
+
self.obs_array['visited'][self._agent_location[0]][self._agent_location[1]] = 1
|
|
1179
|
+
self.obs_array['agent_location'][self._agent_location[0]][self._agent_location[1]] = 1
|
|
1180
|
+
|
|
1181
|
+
# Also update the SPaRC observation if it is active
|
|
1182
|
+
if self.observation == 'SPaRC':
|
|
1183
|
+
r, c = self._agent_location[0], self._agent_location[1]
|
|
1184
|
+
self.observ[c][r] = 'L'
|
|
1185
|
+
|
|
1186
|
+
# Update the path
|
|
1187
|
+
path = [self._agent_location[0], self._agent_location[1]]
|
|
1188
|
+
self.path.append(path)
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
# An episode is done if the agent has reached the target, does not mean success
|
|
1192
|
+
terminated = np.array_equal(self._agent_location, self._target_location)
|
|
1193
|
+
|
|
1194
|
+
# If there are no legal actions left (for the next step), the episode is truncated
|
|
1195
|
+
if self._get_legal_actions() == []:
|
|
1196
|
+
truncated = True
|
|
1197
|
+
|
|
1198
|
+
if terminated:
|
|
1199
|
+
truncated = False # If terminated, not truncated
|
|
1200
|
+
|
|
1201
|
+
# Reward logic:
|
|
1202
|
+
# Have an outcome reward and a normal reward
|
|
1203
|
+
# The normal reward is updated during the episode, the outcome reward is only updated at the end of the episode
|
|
1204
|
+
if terminated or truncated:
|
|
1205
|
+
for i in range(self.solution_count):
|
|
1206
|
+
if np.array_equal(self.path, self.solution_paths[i]):
|
|
1207
|
+
self.outcome_reward = 1
|
|
1208
|
+
self.normal_reward = 1
|
|
1209
|
+
break
|
|
1210
|
+
|
|
1211
|
+
if self.outcome_reward != 1:
|
|
1212
|
+
self.outcome_reward = -1
|
|
1213
|
+
self.normal_reward = -1
|
|
1214
|
+
else:
|
|
1215
|
+
self.outcome_reward = 0
|
|
1216
|
+
if not np.array_equal(orig_loc, self._agent_location):
|
|
1217
|
+
for i in range(self.solution_count):
|
|
1218
|
+
current_solution_path = self.solution_paths[i]
|
|
1219
|
+
if self._is_on_solution_path(self.path, current_solution_path):
|
|
1220
|
+
self.normal_reward = 0.01
|
|
1221
|
+
break
|
|
1222
|
+
else:
|
|
1223
|
+
self.normal_reward = -0.01
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
# Update the observation
|
|
1227
|
+
self._validate_rules(terminated=terminated, truncated=truncated)
|
|
1228
|
+
observation = self._get_obs()
|
|
1229
|
+
info = self._get_info()
|
|
1230
|
+
|
|
1231
|
+
# Unfortunately, I have to return the normal reward here, since gymnasium expects a reward to be a scalar value, not a dictionary of scalar values
|
|
1232
|
+
reward = self.normal_reward
|
|
1233
|
+
|
|
1234
|
+
# Visualize the current state of the environment
|
|
1235
|
+
if self.render_mode == "human" or self.render_mode == "llm":
|
|
1236
|
+
self.render()
|
|
1237
|
+
|
|
1238
|
+
return observation, reward, terminated, truncated, info
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
# ---------- End Core Gym Env Functions ----------
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def _is_on_solution_path(self, current_path, solution_path):
|
|
1245
|
+
"""
|
|
1246
|
+
Helper function for step function.
|
|
1247
|
+
Checks if the current path is still on the solution path.
|
|
1248
|
+
|
|
1249
|
+
Args:
|
|
1250
|
+
current_path (list): The path taken so far.
|
|
1251
|
+
solution_path (list): The correct solution path.
|
|
1252
|
+
|
|
1253
|
+
Returns:
|
|
1254
|
+
bool: True if the current path is on the solution path, False otherwise.
|
|
1255
|
+
"""
|
|
1256
|
+
# If the current path is longer than the solution path, return False
|
|
1257
|
+
if len(current_path) > len(solution_path):
|
|
1258
|
+
return False
|
|
1259
|
+
|
|
1260
|
+
# Compare each step in the current path with the solution path
|
|
1261
|
+
for i in range(len(current_path)):
|
|
1262
|
+
if current_path[i] != solution_path[i]:
|
|
1263
|
+
return False
|
|
1264
|
+
|
|
1265
|
+
return True
|
|
1266
|
+
|
|
1267
|
+
# ---------- Visualization Functions ----------
|
|
1268
|
+
|
|
1269
|
+
def render(self):
|
|
1270
|
+
"""
|
|
1271
|
+
Renders the environment using the appropriate renderer based on render_mode.
|
|
1272
|
+
|
|
1273
|
+
Returns:
|
|
1274
|
+
str or None: For LLM mode, returns a text representation. For human mode, returns None.
|
|
1275
|
+
"""
|
|
1276
|
+
if self.render_mode == "human" and self.human_renderer is not None:
|
|
1277
|
+
return self.human_renderer.render(
|
|
1278
|
+
self.obs_array,
|
|
1279
|
+
self.color_array,
|
|
1280
|
+
self.additional_info,
|
|
1281
|
+
self.polyshapes,
|
|
1282
|
+
self.x_size,
|
|
1283
|
+
self.y_size,
|
|
1284
|
+
self.start_location,
|
|
1285
|
+
self.target_location,
|
|
1286
|
+
self.path
|
|
1287
|
+
)
|
|
1288
|
+
elif self.render_mode == "llm" and self.llm_renderer is not None:
|
|
1289
|
+
return self.llm_renderer.render(
|
|
1290
|
+
self.obs_array,
|
|
1291
|
+
self.color_array,
|
|
1292
|
+
self.additional_info,
|
|
1293
|
+
self.polyshapes,
|
|
1294
|
+
self.x_size,
|
|
1295
|
+
self.y_size
|
|
1296
|
+
)
|
|
1297
|
+
else:
|
|
1298
|
+
# No rendering or unsupported mode
|
|
1299
|
+
return None
|
|
1300
|
+
|
|
1301
|
+
def close(self):
|
|
1302
|
+
"""
|
|
1303
|
+
Close the environment and cleanup any resources.
|
|
1304
|
+
"""
|
|
1305
|
+
if self.human_renderer is not None:
|
|
1306
|
+
self.human_renderer.close()
|
|
1307
|
+
self.human_renderer = None
|
|
1308
|
+
|
|
1309
|
+
if self.llm_renderer is not None:
|
|
1310
|
+
self.llm_renderer.close()
|
|
1311
|
+
self.llm_renderer = None
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
# ---------- End Visualization Functions ----------
|