blindpath 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- blindpath/Constants/__init__.py +37 -0
- blindpath/Constants/env.py +24 -0
- blindpath/Constants/tiles.py +19 -0
- blindpath/DataClasses/__init__.py +7 -0
- blindpath/DataClasses/env_config.py +40 -0
- blindpath/DataClasses/generated_map.py +15 -0
- blindpath/DataClasses/step_result.py +18 -0
- blindpath/DataClasses/vector2.py +26 -0
- blindpath/Util/__init__.py +9 -0
- blindpath/Util/action_parsing.py +36 -0
- blindpath/Util/astar.py +102 -0
- blindpath/Util/grid_formatting.py +138 -0
- blindpath/__init__.py +53 -0
- blindpath/agents/__init__.py +11 -0
- blindpath/agents/base.py +39 -0
- blindpath/agents/llm_agent.py +69 -0
- blindpath/agents/pomcp_agent.py +300 -0
- blindpath/agents/random_agent.py +38 -0
- blindpath/core/__init__.py +18 -0
- blindpath/core/env.py +284 -0
- blindpath/core/grid.py +151 -0
- blindpath/core/map_generation.py +221 -0
- blindpath/eval/__init__.py +4 -0
- blindpath/eval/benchmark_results.py +109 -0
- blindpath/eval/episode_metrics.py +128 -0
- blindpath/eval/metrics.py +11 -0
- blindpath/session.py +249 -0
- blindpath-0.1.0.dist-info/METADATA +255 -0
- blindpath-0.1.0.dist-info/RECORD +32 -0
- blindpath-0.1.0.dist-info/WHEEL +5 -0
- blindpath-0.1.0.dist-info/licenses/LICENSE +21 -0
- blindpath-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from blindpath.Constants.tiles import (
|
|
2
|
+
AGENT,
|
|
3
|
+
EMPTY,
|
|
4
|
+
OBSTACLE,
|
|
5
|
+
GOAL,
|
|
6
|
+
BOUNDARY,
|
|
7
|
+
TILE_WORDS,
|
|
8
|
+
)
|
|
9
|
+
from blindpath.Constants.env import (
|
|
10
|
+
DEFAULT_SEED,
|
|
11
|
+
DEFAULT_ENV_SIZE,
|
|
12
|
+
DEFAULT_NUM_GOALS,
|
|
13
|
+
DEFAULT_OBSTACLE_COUNT,
|
|
14
|
+
DEFAULT_VISION_SIZE,
|
|
15
|
+
MIN_ENV_DIMENSION,
|
|
16
|
+
MIN_VISION_SIZE,
|
|
17
|
+
MIN_NUM_GOALS,
|
|
18
|
+
MIN_OBSTACLE_COUNT,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"DEFAULT_SEED",
|
|
23
|
+
"DEFAULT_ENV_SIZE",
|
|
24
|
+
"DEFAULT_NUM_GOALS",
|
|
25
|
+
"DEFAULT_OBSTACLE_COUNT",
|
|
26
|
+
"DEFAULT_VISION_SIZE",
|
|
27
|
+
"MIN_ENV_DIMENSION",
|
|
28
|
+
"MIN_VISION_SIZE",
|
|
29
|
+
"MIN_NUM_GOALS",
|
|
30
|
+
"MIN_OBSTACLE_COUNT",
|
|
31
|
+
"AGENT",
|
|
32
|
+
"EMPTY",
|
|
33
|
+
"OBSTACLE",
|
|
34
|
+
"GOAL",
|
|
35
|
+
"BOUNDARY",
|
|
36
|
+
"TILE_WORDS",
|
|
37
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Constants for BlindPath environment configuration.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Tuple
|
|
6
|
+
|
|
7
|
+
# ---------------------------------------------------------------------------
|
|
8
|
+
# Default values
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
DEFAULT_SEED: None = None
|
|
12
|
+
DEFAULT_ENV_SIZE: Tuple[int, int] = (40, 40)
|
|
13
|
+
DEFAULT_NUM_GOALS: int = 1
|
|
14
|
+
DEFAULT_OBSTACLE_COUNT: int = 0
|
|
15
|
+
DEFAULT_VISION_SIZE: int = 11
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Minimum allowed values
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
MIN_ENV_DIMENSION: int = 5
|
|
22
|
+
MIN_VISION_SIZE: int = 3
|
|
23
|
+
MIN_NUM_GOALS: int = 1
|
|
24
|
+
MIN_OBSTACLE_COUNT: int = 0
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
blindpath/Constants/tiles.py
|
|
3
|
+
|
|
4
|
+
Single-character tile labels used throughout the grid system.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
AGENT = "A"
|
|
8
|
+
EMPTY = "E"
|
|
9
|
+
OBSTACLE = "O"
|
|
10
|
+
GOAL = "G"
|
|
11
|
+
BOUNDARY = "B"
|
|
12
|
+
|
|
13
|
+
TILE_WORDS: dict[str, str] = {
|
|
14
|
+
AGENT: "Agent",
|
|
15
|
+
EMPTY: "Empty",
|
|
16
|
+
OBSTACLE: "Obstacle",
|
|
17
|
+
GOAL: "Goal",
|
|
18
|
+
BOUNDARY: "Boundary",
|
|
19
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from blindpath.DataClasses.vector2 import Vector2
|
|
2
|
+
from blindpath.core.grid import Grid
|
|
3
|
+
from blindpath.DataClasses.step_result import StepResult
|
|
4
|
+
from blindpath.DataClasses.env_config import EnvConfig
|
|
5
|
+
from blindpath.DataClasses.generated_map import GeneratedMap
|
|
6
|
+
|
|
7
|
+
__all__ = ["Vector2", "Grid", "StepResult", "EnvConfig", "GeneratedMap"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional, Tuple
|
|
3
|
+
|
|
4
|
+
from blindpath.Constants.env import (
|
|
5
|
+
DEFAULT_SEED,
|
|
6
|
+
DEFAULT_ENV_SIZE,
|
|
7
|
+
DEFAULT_NUM_GOALS,
|
|
8
|
+
DEFAULT_OBSTACLE_COUNT,
|
|
9
|
+
DEFAULT_VISION_SIZE,
|
|
10
|
+
MIN_ENV_DIMENSION,
|
|
11
|
+
MIN_VISION_SIZE,
|
|
12
|
+
MIN_NUM_GOALS,
|
|
13
|
+
MIN_OBSTACLE_COUNT,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class EnvConfig:
|
|
19
|
+
"""All hyperparameters for one BlindPath environment instance."""
|
|
20
|
+
|
|
21
|
+
seed: Optional[int] = DEFAULT_SEED
|
|
22
|
+
env_size: Tuple[int, int] = DEFAULT_ENV_SIZE
|
|
23
|
+
num_goals: int = DEFAULT_NUM_GOALS
|
|
24
|
+
obstacle_count: int = DEFAULT_OBSTACLE_COUNT
|
|
25
|
+
vision_size: int = DEFAULT_VISION_SIZE
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
rows, cols = self.env_size
|
|
29
|
+
if rows < MIN_ENV_DIMENSION or cols < MIN_ENV_DIMENSION:
|
|
30
|
+
raise ValueError(f"env_size must be at least {MIN_ENV_DIMENSION}x{MIN_ENV_DIMENSION}")
|
|
31
|
+
if self.vision_size < MIN_VISION_SIZE:
|
|
32
|
+
raise ValueError(f"vision_size must be at least {MIN_VISION_SIZE}")
|
|
33
|
+
if self.vision_size % 2 == 0:
|
|
34
|
+
raise ValueError("vision_size must be odd")
|
|
35
|
+
if self.vision_size > min(rows, cols):
|
|
36
|
+
raise ValueError("vision_size cannot exceed the environment size")
|
|
37
|
+
if self.num_goals < MIN_NUM_GOALS:
|
|
38
|
+
raise ValueError(f"num_goals must be >= {MIN_NUM_GOALS}")
|
|
39
|
+
if self.obstacle_count < MIN_OBSTACLE_COUNT:
|
|
40
|
+
raise ValueError(f"obstacle_count must be >= {MIN_OBSTACLE_COUNT}")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
from blindpath.DataClasses.vector2 import Vector2
|
|
5
|
+
from blindpath.core.grid import Grid
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class GeneratedMap:
|
|
10
|
+
grid: Grid
|
|
11
|
+
agent_start: Vector2
|
|
12
|
+
goals: List[Vector2]
|
|
13
|
+
optimal_path_len: int
|
|
14
|
+
accessible_tiles: int
|
|
15
|
+
accessible_vision_tiles: int
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from blindpath.DataClasses.vector2 import Vector2
|
|
4
|
+
from blindpath.core.grid import Grid
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class StepResult:
|
|
9
|
+
"""Returned by BlindPathEnv.step()."""
|
|
10
|
+
vision_grid: Grid # NxN vision window (LLM input)
|
|
11
|
+
full_grid: Grid # Full grid (debug only)
|
|
12
|
+
agent_position: Vector2
|
|
13
|
+
goals_remaining: int
|
|
14
|
+
done: bool # episode finished (success OR timeout)
|
|
15
|
+
success: bool # all goals reached
|
|
16
|
+
timed_out: bool
|
|
17
|
+
legal: bool # was the attempted action legal?
|
|
18
|
+
iteration: int
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Vector2:
|
|
8
|
+
x: int # column
|
|
9
|
+
y: int # row (0 = top)
|
|
10
|
+
|
|
11
|
+
def __add__(self, other: Vector2) -> Vector2:
|
|
12
|
+
return Vector2(self.x + other.x, self.y + other.y)
|
|
13
|
+
|
|
14
|
+
def __eq__(self, other: object) -> bool:
|
|
15
|
+
if not isinstance(other, Vector2):
|
|
16
|
+
return NotImplemented
|
|
17
|
+
return self.x == other.x and self.y == other.y
|
|
18
|
+
|
|
19
|
+
def __hash__(self) -> int:
|
|
20
|
+
return hash((self.x, self.y))
|
|
21
|
+
|
|
22
|
+
def __repr__(self) -> str:
|
|
23
|
+
return f"Vector2({self.x}, {self.y})"
|
|
24
|
+
|
|
25
|
+
def manhattan(self, other: Vector2) -> int:
|
|
26
|
+
return abs(self.x - other.x) + abs(self.y - other.y)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""
|
|
2
|
+
blindpath/Util/__init__.py
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from blindpath.Util.astar import astar
|
|
6
|
+
from blindpath.Util.grid_formatting import grid_to_ascii, grid_to_json, grid_to_chess, grid_to_word, grid_to_row_narrative, grid_to_col_narrative
|
|
7
|
+
from blindpath.Util.action_parsing import parse_action
|
|
8
|
+
|
|
9
|
+
__all__ = ["astar", "grid_to_ascii", "grid_to_json", "grid_to_chess", "grid_to_word", "grid_to_row_narrative", "grid_to_col_narrative", "parse_action"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
blindpath/Util/action_parsing.py
|
|
3
|
+
|
|
4
|
+
Extracts a valid BlindPath action from raw LLM output.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from blindpath.core.env import ACTIONS
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_action(raw: str) -> Optional[str]:
|
|
17
|
+
"""
|
|
18
|
+
Try to extract a valid action string from raw LLM output.
|
|
19
|
+
|
|
20
|
+
Extracts the first JSON object/array from the response,
|
|
21
|
+
then checks for a valid "action" field.
|
|
22
|
+
Returns None if no valid action could be extracted.
|
|
23
|
+
"""
|
|
24
|
+
match = re.search(r'(\{.*?\}|\[.*?\])', raw, re.DOTALL)
|
|
25
|
+
if not match:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
data = json.loads(match.group(0))
|
|
30
|
+
action = data.get("action", "")
|
|
31
|
+
if action in ACTIONS:
|
|
32
|
+
return action
|
|
33
|
+
except (json.JSONDecodeError, AttributeError):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
return None
|
blindpath/Util/astar.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
blindpath/Util/astar.py
|
|
3
|
+
|
|
4
|
+
A* pathfinding on a BlindPath grid.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import heapq
|
|
8
|
+
import math
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from blindpath.DataClasses.vector2 import Vector2
|
|
14
|
+
from blindpath.Constants.tiles import OBSTACLE
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Directional offsets (shared with other grid algorithms via import)
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
NEIGHBOURS: List[Vector2] = [
|
|
21
|
+
Vector2(0, -1), # Up
|
|
22
|
+
Vector2(0, 1), # Down
|
|
23
|
+
Vector2(-1, 0), # Left
|
|
24
|
+
Vector2( 1, 0), # Right
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def astar(
|
|
29
|
+
grid: List[List[str]],
|
|
30
|
+
start: Vector2,
|
|
31
|
+
goal: Vector2,
|
|
32
|
+
*,
|
|
33
|
+
cost_grid: Optional[np.ndarray] = None,
|
|
34
|
+
) -> Optional[List[Vector2]]:
|
|
35
|
+
"""
|
|
36
|
+
A* from *start* to *goal*.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
grid : Grid
|
|
41
|
+
The map. 'O' cells are impassable.
|
|
42
|
+
start, goal : Vector2
|
|
43
|
+
Source and target positions (column=x, row=y).
|
|
44
|
+
cost_grid : np.ndarray | None
|
|
45
|
+
If given, adds per-cell movement cost on top of the base cost-1.
|
|
46
|
+
Used during generation to produce naturalistically winding paths.
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
-------
|
|
50
|
+
List[Vector2] | None
|
|
51
|
+
Ordered list of positions (excluding *start*, including *goal*),
|
|
52
|
+
or None if no path exists.
|
|
53
|
+
"""
|
|
54
|
+
rows = len(grid)
|
|
55
|
+
cols = len(grid[0]) if rows else 0
|
|
56
|
+
|
|
57
|
+
def h(pos: Vector2) -> float:
|
|
58
|
+
return pos.manhattan(goal)
|
|
59
|
+
|
|
60
|
+
def cell_cost(pos: Vector2) -> float:
|
|
61
|
+
base = 1.0
|
|
62
|
+
if cost_grid is not None:
|
|
63
|
+
base += float(cost_grid[pos.y, pos.x])
|
|
64
|
+
return base
|
|
65
|
+
|
|
66
|
+
counter = 0 # tie-breaker so Vector2 is never compared by heapq
|
|
67
|
+
open_heap: list = []
|
|
68
|
+
heapq.heappush(open_heap, (h(start), 0.0, counter, start))
|
|
69
|
+
came_from: dict[Vector2, Optional[Vector2]] = {start: None}
|
|
70
|
+
g_score: dict[Vector2, float] = {start: 0.0}
|
|
71
|
+
|
|
72
|
+
while open_heap:
|
|
73
|
+
_, g, _, current = heapq.heappop(open_heap)
|
|
74
|
+
|
|
75
|
+
if g > g_score.get(current, math.inf):
|
|
76
|
+
continue # stale entry
|
|
77
|
+
|
|
78
|
+
if current == goal:
|
|
79
|
+
# Reconstruct
|
|
80
|
+
path: List[Vector2] = []
|
|
81
|
+
node: Optional[Vector2] = current
|
|
82
|
+
while node is not None and node != start:
|
|
83
|
+
path.append(node)
|
|
84
|
+
node = came_from[node]
|
|
85
|
+
path.reverse()
|
|
86
|
+
return path
|
|
87
|
+
|
|
88
|
+
for delta in NEIGHBOURS:
|
|
89
|
+
nb = current + delta
|
|
90
|
+
if not (0 <= nb.x < cols and 0 <= nb.y < rows):
|
|
91
|
+
continue
|
|
92
|
+
if grid[nb.y][nb.x] == OBSTACLE and nb != goal:
|
|
93
|
+
continue
|
|
94
|
+
tentative_g = g_score[current] + cell_cost(nb)
|
|
95
|
+
if tentative_g < g_score.get(nb, math.inf):
|
|
96
|
+
g_score[nb] = tentative_g
|
|
97
|
+
came_from[nb] = current
|
|
98
|
+
f = tentative_g + h(nb)
|
|
99
|
+
counter += 1
|
|
100
|
+
heapq.heappush(open_heap, (f, tentative_g, counter, nb))
|
|
101
|
+
|
|
102
|
+
return None # no path
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
blindpath/Util/grid_formatting.py
|
|
3
|
+
|
|
4
|
+
Display utilities for BlindPath grids.
|
|
5
|
+
|
|
6
|
+
Cartesian formats — each tile as an individual data point with coordinates.
|
|
7
|
+
Topography formats — full grid rendered as a 2D map.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import string
|
|
14
|
+
from typing import List
|
|
15
|
+
|
|
16
|
+
from blindpath.Constants.tiles import TILE_WORDS
|
|
17
|
+
from blindpath.core.grid import Grid
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Cartesian
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
def grid_to_json(grid: Grid) -> str:
|
|
25
|
+
"""
|
|
26
|
+
Each grid point as a JSON object with x, y coordinates and label,
|
|
27
|
+
wrapped in a root object with grid dimensions.
|
|
28
|
+
|
|
29
|
+
Example: {"rows": 3, "cols": 3, "tiles": [{"x": 0, "y": 0, "label": "E"}, ...]}
|
|
30
|
+
"""
|
|
31
|
+
tiles: List[dict] = []
|
|
32
|
+
for y, row in enumerate(grid):
|
|
33
|
+
for x, label in enumerate(row):
|
|
34
|
+
tiles.append({"x": x, "y": y, "label": label})
|
|
35
|
+
return json.dumps({"matrix_y_size": len(grid), "matrix_x_size": len(grid[0]), "tiles": tiles})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def grid_to_chess(grid: Grid) -> str:
|
|
39
|
+
"""
|
|
40
|
+
Each grid point as a JSON object using chess notation for coordinates,
|
|
41
|
+
wrapped in a root object with grid dimensions.
|
|
42
|
+
|
|
43
|
+
Columns map to letters (a-z), rows count from the bottom (1-based).
|
|
44
|
+
Example: {"rows": 3, "cols": 3, "tiles": [{"pos": "a3", "label": "E"}, ...]}
|
|
45
|
+
"""
|
|
46
|
+
rows = len(grid)
|
|
47
|
+
tiles: List[dict] = []
|
|
48
|
+
for y, row in enumerate(grid):
|
|
49
|
+
rank = rows - y # chess ranks count from bottom
|
|
50
|
+
for x, label in enumerate(row):
|
|
51
|
+
file = string.ascii_lowercase[x] if x < 26 else f"c{x}"
|
|
52
|
+
tiles.append({"pos": f"{file}{rank}", "label": label})
|
|
53
|
+
return json.dumps({"matrix_y_size": rows, "matrix_x_size": len(grid[0]), "tiles": tiles})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Topography
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def grid_to_ascii(grid: Grid) -> str:
|
|
61
|
+
"""Convert a 2D grid to a compact ASCII string, one row per line."""
|
|
62
|
+
return "\n".join(" ".join(row) for row in grid)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def grid_to_word(grid: Grid) -> str:
|
|
66
|
+
"""
|
|
67
|
+
Like ASCII but each tile is a full word instead of a single letter.
|
|
68
|
+
|
|
69
|
+
Uses TILE_WORDS mapping from Constants. Unknown labels pass through as-is.
|
|
70
|
+
Words are space-separated, one row per line.
|
|
71
|
+
"""
|
|
72
|
+
lines: List[str] = []
|
|
73
|
+
for row in grid:
|
|
74
|
+
words = [TILE_WORDS.get(tile, tile) for tile in row]
|
|
75
|
+
lines.append(" ".join(words))
|
|
76
|
+
return "\n".join(lines)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_UNIQUE_TILES = {"A", "G"}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _ordinal(n: int) -> str:
|
|
83
|
+
"""Return the ordinal string for a 1-based number: 1st, 2nd, 3rd, 4th..."""
|
|
84
|
+
if 11 <= n % 100 <= 13:
|
|
85
|
+
return f"{n}th"
|
|
86
|
+
return f"{n}{('th','st','nd','rd')[min(n % 10, 4)] if n % 10 < 4 else 'th'}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _describe_group(label: str, count: int) -> str:
|
|
90
|
+
"""Describe a run of identical tiles in natural language."""
|
|
91
|
+
word = TILE_WORDS.get(label, label).lower()
|
|
92
|
+
if label in _UNIQUE_TILES:
|
|
93
|
+
return f"the {word}"
|
|
94
|
+
cell = "cell" if count == 1 else "cells"
|
|
95
|
+
return f"{count} {word} {cell}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _run_length_groups(tiles: List[str]) -> List[tuple[str, int]]:
|
|
99
|
+
"""Group consecutive identical tiles into (label, count) pairs."""
|
|
100
|
+
groups: List[tuple[str, int]] = []
|
|
101
|
+
for tile in tiles:
|
|
102
|
+
if groups and groups[-1][0] == tile:
|
|
103
|
+
groups[-1] = (tile, groups[-1][1] + 1)
|
|
104
|
+
else:
|
|
105
|
+
groups.append((tile, 1))
|
|
106
|
+
return groups
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def grid_to_row_narrative(grid: Grid) -> str:
|
|
110
|
+
"""
|
|
111
|
+
Natural language description of the grid, row by row.
|
|
112
|
+
|
|
113
|
+
Groups consecutive identical tiles and describes each run.
|
|
114
|
+
Example: "The 1st row has 3 empty cells, then the goal, then 1 empty cell."
|
|
115
|
+
"""
|
|
116
|
+
sentences: List[str] = []
|
|
117
|
+
for i, row in enumerate(grid):
|
|
118
|
+
groups = _run_length_groups(list(row))
|
|
119
|
+
parts = [_describe_group(label, count) for label, count in groups]
|
|
120
|
+
sentences.append(f"The {_ordinal(i + 1)} row has {', then '.join(parts)}.")
|
|
121
|
+
return "\n".join(sentences)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def grid_to_col_narrative(grid: Grid) -> str:
|
|
125
|
+
"""
|
|
126
|
+
Natural language description of the grid, column by column.
|
|
127
|
+
|
|
128
|
+
Groups consecutive identical tiles and describes each run.
|
|
129
|
+
Example: "The 1st column has 3 empty cells, then the agent, then 1 empty cell."
|
|
130
|
+
"""
|
|
131
|
+
sentences: List[str] = []
|
|
132
|
+
cols = grid.cols if hasattr(grid, 'cols') else len(grid[0])
|
|
133
|
+
for x in range(cols):
|
|
134
|
+
column = [grid[y][x] for y in range(len(grid))]
|
|
135
|
+
groups = _run_length_groups(column)
|
|
136
|
+
parts = [_describe_group(label, count) for label, count in groups]
|
|
137
|
+
sentences.append(f"The {_ordinal(x + 1)} column has {', then '.join(parts)}.")
|
|
138
|
+
return "\n".join(sentences)
|
blindpath/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
blindpath — Public API
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from blindpath.core.env import BlindPathEnv, ACTIONS
|
|
6
|
+
from blindpath.DataClasses.env_config import EnvConfig
|
|
7
|
+
from blindpath.DataClasses.step_result import StepResult
|
|
8
|
+
from blindpath.DataClasses.vector2 import Vector2
|
|
9
|
+
from blindpath.DataClasses.generated_map import GeneratedMap
|
|
10
|
+
from blindpath.core.grid import Grid
|
|
11
|
+
from blindpath.Constants.tiles import AGENT, EMPTY, OBSTACLE, GOAL, BOUNDARY
|
|
12
|
+
from blindpath.Util.grid_formatting import grid_to_ascii, grid_to_json, grid_to_chess, grid_to_word, grid_to_row_narrative, grid_to_col_narrative
|
|
13
|
+
from blindpath.Util.action_parsing import parse_action
|
|
14
|
+
from blindpath.core.map_generation import generate_map
|
|
15
|
+
from blindpath.agents.base import BaseAgent
|
|
16
|
+
from blindpath.agents.random_agent import RandomAgent
|
|
17
|
+
from blindpath.agents.pomcp_agent import POMCPAgent
|
|
18
|
+
from blindpath.agents.llm_agent import LLMAgent
|
|
19
|
+
from blindpath.eval.episode_metrics import EpisodeMetrics
|
|
20
|
+
from blindpath.eval.benchmark_results import BenchmarkResults
|
|
21
|
+
from blindpath.session import Session
|
|
22
|
+
from importlib.metadata import version as _version
|
|
23
|
+
|
|
24
|
+
__version__ = _version("blindpath")
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
# Environment
|
|
28
|
+
"BlindPathEnv",
|
|
29
|
+
"EnvConfig",
|
|
30
|
+
"StepResult",
|
|
31
|
+
"ACTIONS",
|
|
32
|
+
# Grid
|
|
33
|
+
"Vector2",
|
|
34
|
+
"Grid",
|
|
35
|
+
"generate_map",
|
|
36
|
+
"grid_to_ascii",
|
|
37
|
+
"grid_to_json",
|
|
38
|
+
"grid_to_chess",
|
|
39
|
+
"grid_to_word",
|
|
40
|
+
"grid_to_row_narrative",
|
|
41
|
+
"grid_to_col_narrative",
|
|
42
|
+
"parse_action",
|
|
43
|
+
# Agents
|
|
44
|
+
"BaseAgent",
|
|
45
|
+
"RandomAgent",
|
|
46
|
+
"POMCPAgent",
|
|
47
|
+
"LLMAgent",
|
|
48
|
+
# Metrics
|
|
49
|
+
"EpisodeMetrics",
|
|
50
|
+
"BenchmarkResults",
|
|
51
|
+
# Session
|
|
52
|
+
"Session",
|
|
53
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from blindpath.agents.base import BaseAgent
|
|
2
|
+
from blindpath.agents.random_agent import RandomAgent
|
|
3
|
+
from blindpath.agents.pomcp_agent import POMCPAgent
|
|
4
|
+
|
|
5
|
+
__all__ = ["BaseAgent", "RandomAgent", "POMCPAgent"]
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from blindpath.agents.llm_agent import LLMAgent
|
|
9
|
+
__all__.append("LLMAgent")
|
|
10
|
+
except ImportError:
|
|
11
|
+
pass
|
blindpath/agents/base.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
blindpath/agents/base.py
|
|
3
|
+
|
|
4
|
+
Abstract base class for all BlindPath agents.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
|
|
11
|
+
from blindpath.core.env import BlindPathEnv
|
|
12
|
+
from blindpath.DataClasses.step_result import StepResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BaseAgent(ABC):
|
|
16
|
+
"""
|
|
17
|
+
Every agent must implement `step()`.
|
|
18
|
+
|
|
19
|
+
Parameters passed to `step()`:
|
|
20
|
+
- env : the live environment (agents may read public properties
|
|
21
|
+
but must NOT modify internal state directly).
|
|
22
|
+
- result : the StepResult from the previous step (or initial reset).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def step(
|
|
27
|
+
self,
|
|
28
|
+
env: BlindPathEnv,
|
|
29
|
+
result: StepResult,
|
|
30
|
+
) -> str:
|
|
31
|
+
"""
|
|
32
|
+
Return one of: "Up", "Down", "Left", "Right".
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def reset(self) -> None:
|
|
36
|
+
"""Called at the beginning of every new episode. Override if needed."""
|
|
37
|
+
|
|
38
|
+
def observe(self, action: str, result: StepResult) -> None:
|
|
39
|
+
"""Called after env.step() with the action taken and result. Override if needed."""
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
blindpath/agents/llm_agent.py
|
|
3
|
+
|
|
4
|
+
Abstract base class for LLM-powered BlindPath agents.
|
|
5
|
+
|
|
6
|
+
Subclasses implement `_prompt()` to call their LLM of choice.
|
|
7
|
+
The base class handles prompt assembly and action parsing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import random
|
|
13
|
+
from abc import abstractmethod
|
|
14
|
+
from typing import Callable, Optional
|
|
15
|
+
|
|
16
|
+
from blindpath.agents.base import BaseAgent
|
|
17
|
+
from blindpath.core.env import ACTIONS, BlindPathEnv
|
|
18
|
+
from blindpath.DataClasses.step_result import StepResult
|
|
19
|
+
from blindpath.Util.action_parsing import parse_action
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
PromptBuilder = Callable[[BlindPathEnv, StepResult], Optional[str]]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LLMAgent(BaseAgent):
|
|
26
|
+
"""
|
|
27
|
+
Abstract base for LLM-powered agents.
|
|
28
|
+
|
|
29
|
+
Subclasses must implement `_prompt()` to call their LLM and return
|
|
30
|
+
the raw response string. The base class assembles the full prompt
|
|
31
|
+
(custom + static + vision) and parses the action from the response.
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
prompt_builder : PromptBuilder | None
|
|
36
|
+
Optional callback that receives (env, result) and returns a custom
|
|
37
|
+
prompt string to prepend before the static prompt.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, *, prompt_builder: Optional[PromptBuilder] = None) -> None:
|
|
41
|
+
self._prompt_builder = prompt_builder
|
|
42
|
+
self.compliance_failures: int = 0
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def _prompt(self, prompt: str) -> str:
|
|
46
|
+
"""Send the assembled prompt to the LLM and return the raw response."""
|
|
47
|
+
|
|
48
|
+
def reset(self) -> None:
|
|
49
|
+
self.compliance_failures = 0
|
|
50
|
+
|
|
51
|
+
def step(self, env: BlindPathEnv, result: StepResult) -> str:
|
|
52
|
+
parts: list[str] = []
|
|
53
|
+
|
|
54
|
+
if self._prompt_builder is not None:
|
|
55
|
+
custom = self._prompt_builder(env, result)
|
|
56
|
+
if custom:
|
|
57
|
+
parts.append(custom.strip())
|
|
58
|
+
|
|
59
|
+
parts.append(env.get_static_prompt())
|
|
60
|
+
parts.append(f"\nCURRENT VISION GRID:\n{env.get_vision_string()}")
|
|
61
|
+
|
|
62
|
+
raw = self._prompt("\n\n".join(parts))
|
|
63
|
+
action = parse_action(raw)
|
|
64
|
+
|
|
65
|
+
if action is None:
|
|
66
|
+
self.compliance_failures += 1
|
|
67
|
+
action = random.choice(list(ACTIONS.keys()))
|
|
68
|
+
|
|
69
|
+
return action
|