Kacemath commited on
Commit
47bba68
·
1 Parent(s): 88f3adc

feat: update with latest changes

Browse files

- Add store positions to initial_state format for consistent plan/compare behavior
- Change memory tracking from MB to KB for better precision
- Add plan paths visualization on grid with colored routes
- Clear plan paths when generating new grid
- Update favicon to graph icon and title to 'AI Project'
- Add comprehensive unit tests for backend

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. backend/app/algorithms/__init__.py +7 -12
  2. backend/app/algorithms/astar.py +29 -108
  3. backend/app/algorithms/bfs.py +27 -95
  4. backend/app/algorithms/dfs.py +27 -96
  5. backend/app/algorithms/greedy.py +29 -105
  6. backend/app/algorithms/ids.py +39 -124
  7. backend/app/algorithms/ucs.py +27 -98
  8. backend/app/api/__init__.py +1 -0
  9. backend/app/api/routes.py +42 -44
  10. backend/app/core/__init__.py +2 -2
  11. backend/app/core/delivery_planner.py +15 -77
  12. backend/app/core/delivery_search.py +4 -30
  13. backend/app/core/frontier.py +1 -0
  14. backend/app/core/generic_search.py +38 -157
  15. backend/app/core/node.py +10 -10
  16. backend/app/heuristics/__init__.py +5 -1
  17. backend/app/heuristics/euclidean.py +1 -0
  18. backend/app/heuristics/manhattan.py +1 -0
  19. backend/app/heuristics/traffic_weighted.py +4 -3
  20. backend/app/heuristics/tunnel_aware.py +10 -9
  21. backend/app/main.py +7 -2
  22. backend/app/models/__init__.py +10 -3
  23. backend/app/models/entities.py +10 -19
  24. backend/app/models/grid.py +11 -4
  25. backend/app/models/requests.py +29 -7
  26. backend/app/models/state.py +18 -9
  27. backend/app/services/__init__.py +1 -0
  28. backend/app/services/grid_generator.py +39 -26
  29. backend/app/services/metrics.py +19 -9
  30. backend/app/services/parser.py +41 -76
  31. backend/pyproject.toml +8 -0
  32. backend/tests/__init__.py +1 -0
  33. backend/tests/conftest.py +111 -0
  34. backend/tests/test_algorithms.py +315 -0
  35. backend/tests/test_api.py +394 -0
  36. backend/tests/test_core.py +372 -0
  37. backend/tests/test_models.py +293 -0
  38. backend/tests/test_services.py +329 -0
  39. backend/uv.lock +77 -0
  40. frontend/index.html +2 -2
  41. frontend/public/graph.svg +29 -0
  42. frontend/public/vite.svg +0 -1
  43. frontend/src/App.tsx +1 -1
  44. frontend/src/api/client.ts +3 -3
  45. frontend/src/components/Grid/Grid.tsx +72 -10
  46. frontend/src/components/Info/GroupInfo.tsx +9 -41
  47. frontend/src/components/Stats/ComparisonDashboard.tsx +2 -2
  48. frontend/src/components/Stats/MetricsPanel.tsx +1 -1
  49. frontend/src/components/Stats/PlanResultsModal.tsx +61 -27
  50. frontend/src/store/gridStore.ts +55 -1
backend/app/algorithms/__init__.py CHANGED
@@ -1,22 +1,17 @@
1
  """Search algorithms package."""
2
- from .bfs import bfs_search, bfs_search_generator
3
- from .dfs import dfs_search, dfs_search_generator
4
- from .ids import ids_search, ids_search_generator
5
- from .ucs import ucs_search, ucs_search_generator
6
- from .greedy import greedy_search, greedy_search_generator
7
- from .astar import astar_search, astar_search_generator
 
8
 
9
  __all__ = [
10
  "bfs_search",
11
- "bfs_search_generator",
12
  "dfs_search",
13
- "dfs_search_generator",
14
  "ids_search",
15
- "ids_search_generator",
16
  "ucs_search",
17
- "ucs_search_generator",
18
  "greedy_search",
19
- "greedy_search_generator",
20
  "astar_search",
21
- "astar_search_generator",
22
  ]
 
1
  """Search algorithms package."""
2
+
3
+ from .bfs import bfs_search
4
+ from .dfs import dfs_search
5
+ from .ids import ids_search
6
+ from .ucs import ucs_search
7
+ from .greedy import greedy_search
8
+ from .astar import astar_search
9
 
10
  __all__ = [
11
  "bfs_search",
 
12
  "dfs_search",
 
13
  "ids_search",
 
14
  "ucs_search",
 
15
  "greedy_search",
 
16
  "astar_search",
 
17
  ]
backend/app/algorithms/astar.py CHANGED
@@ -1,5 +1,6 @@
1
  """A* Search algorithm."""
2
- from typing import Tuple, Optional, List, Generator, Callable, TYPE_CHECKING
 
3
 
4
  if TYPE_CHECKING:
5
  from ..core.generic_search import GenericSearch
@@ -10,9 +11,9 @@ from ..models.state import PathResult, SearchStep
10
 
11
 
12
  def astar_search(
13
- problem: 'GenericSearch',
14
  heuristic: Callable[[Tuple[int, int], Tuple[int, int]], float],
15
- visualize: bool = False
16
  ) -> Tuple[PathResult, Optional[List[SearchStep]]]:
17
  """
18
  A* search using f(n) = g(n) + h(n).
@@ -32,7 +33,7 @@ def astar_search(
32
  start = problem.initial_state()
33
 
34
  # Get goal for heuristic calculation
35
- goal = getattr(problem, 'goal', None)
36
 
37
  h_value = heuristic(start, goal) if goal else 0
38
  f_value = 0 + h_value # g(n) + h(n)
@@ -48,24 +49,29 @@ def astar_search(
48
 
49
  # Record step for visualization
50
  if visualize:
51
- steps.append(SearchStep(
52
- step_number=nodes_expanded,
53
- current_node=node.state,
54
- action=node.action,
55
- frontier=frontier.get_states(),
56
- explored=list(explored),
57
- current_path=node.get_path(),
58
- path_cost=node.path_cost
59
- ))
 
 
60
 
61
  # Goal test
62
  if problem.goal_test(node.state):
63
- return PathResult(
64
- plan=node.get_solution(),
65
- cost=node.path_cost,
66
- nodes_expanded=nodes_expanded,
67
- path=node.get_path()
68
- ), steps
 
 
 
69
 
70
  # Skip if already explored
71
  if node.state in explored:
@@ -90,97 +96,12 @@ def astar_search(
90
  action=action,
91
  path_cost=g_value,
92
  depth=node.depth + 1,
93
- priority=f_value # Priority = f(n) = g(n) + h(n)
94
  )
95
  frontier.push(child)
96
 
97
  # No solution found
98
- return PathResult(
99
- plan="",
100
- cost=float('inf'),
101
- nodes_expanded=nodes_expanded,
102
- path=[]
103
- ), steps
104
-
105
-
106
- def astar_search_generator(
107
- problem: 'GenericSearch',
108
- heuristic: Callable[[Tuple[int, int], Tuple[int, int]], float]
109
- ) -> Generator[SearchStep, None, PathResult]:
110
- """
111
- Generator version of A* search that yields steps during execution.
112
-
113
- Args:
114
- problem: The search problem to solve
115
- heuristic: Heuristic function
116
-
117
- Yields:
118
- SearchStep objects
119
-
120
- Returns:
121
- Final PathResult
122
- """
123
- frontier = PriorityQueueFrontier()
124
- start = problem.initial_state()
125
- goal = getattr(problem, 'goal', None)
126
-
127
- h_value = heuristic(start, goal) if goal else 0
128
- f_value = 0 + h_value
129
- start_node = SearchNode(state=start, path_cost=0, depth=0, priority=f_value)
130
- frontier.push(start_node)
131
-
132
- explored: set = set()
133
- nodes_expanded = 0
134
-
135
- while not frontier.is_empty():
136
- node = frontier.pop()
137
-
138
- yield SearchStep(
139
- step_number=nodes_expanded,
140
- current_node=node.state,
141
- action=node.action,
142
- frontier=frontier.get_states(),
143
- explored=list(explored),
144
- current_path=node.get_path(),
145
- path_cost=node.path_cost
146
- )
147
-
148
- if problem.goal_test(node.state):
149
- return PathResult(
150
- plan=node.get_solution(),
151
- cost=node.path_cost,
152
- nodes_expanded=nodes_expanded,
153
- path=node.get_path()
154
- )
155
-
156
- if node.state in explored:
157
- continue
158
-
159
- explored.add(node.state)
160
- nodes_expanded += 1
161
-
162
- for action in problem.actions(node.state):
163
- child_state = problem.result(node.state, action)
164
-
165
- if child_state not in explored:
166
- step_cost = problem.step_cost(node.state, action, child_state)
167
- g_value = node.path_cost + step_cost
168
- h_value = heuristic(child_state, goal) if goal else 0
169
- f_value = g_value + h_value
170
-
171
- child = SearchNode(
172
- state=child_state,
173
- parent=node,
174
- action=action,
175
- path_cost=g_value,
176
- depth=node.depth + 1,
177
- priority=f_value
178
- )
179
- frontier.push(child)
180
-
181
- return PathResult(
182
- plan="",
183
- cost=float('inf'),
184
- nodes_expanded=nodes_expanded,
185
- path=[]
186
  )
 
1
  """A* Search algorithm."""
2
+
3
+ from typing import Tuple, Optional, List, Callable, TYPE_CHECKING
4
 
5
  if TYPE_CHECKING:
6
  from ..core.generic_search import GenericSearch
 
11
 
12
 
13
  def astar_search(
14
+ problem: "GenericSearch",
15
  heuristic: Callable[[Tuple[int, int], Tuple[int, int]], float],
16
+ visualize: bool = False,
17
  ) -> Tuple[PathResult, Optional[List[SearchStep]]]:
18
  """
19
  A* search using f(n) = g(n) + h(n).
 
33
  start = problem.initial_state()
34
 
35
  # Get goal for heuristic calculation
36
+ goal = getattr(problem, "goal", None)
37
 
38
  h_value = heuristic(start, goal) if goal else 0
39
  f_value = 0 + h_value # g(n) + h(n)
 
49
 
50
  # Record step for visualization
51
  if visualize:
52
+ steps.append(
53
+ SearchStep(
54
+ step_number=nodes_expanded,
55
+ current_node=node.state,
56
+ action=node.action,
57
+ frontier=frontier.get_states(),
58
+ explored=list(explored),
59
+ current_path=node.get_path(),
60
+ path_cost=node.path_cost,
61
+ )
62
+ )
63
 
64
  # Goal test
65
  if problem.goal_test(node.state):
66
+ return (
67
+ PathResult(
68
+ plan=node.get_solution(),
69
+ cost=node.path_cost,
70
+ nodes_expanded=nodes_expanded,
71
+ path=node.get_path(),
72
+ ),
73
+ steps,
74
+ )
75
 
76
  # Skip if already explored
77
  if node.state in explored:
 
96
  action=action,
97
  path_cost=g_value,
98
  depth=node.depth + 1,
99
+ priority=f_value, # Priority = f(n) = g(n) + h(n)
100
  )
101
  frontier.push(child)
102
 
103
  # No solution found
104
+ return (
105
+ PathResult(plan="", cost=float("inf"), nodes_expanded=nodes_expanded, path=[]),
106
+ steps,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  )
backend/app/algorithms/bfs.py CHANGED
@@ -1,5 +1,6 @@
1
  """Breadth-First Search algorithm."""
2
- from typing import Tuple, Optional, List, Generator, TYPE_CHECKING
 
3
 
4
  if TYPE_CHECKING:
5
  from ..core.generic_search import GenericSearch
@@ -10,8 +11,7 @@ from ..models.state import PathResult, SearchStep
10
 
11
 
12
  def bfs_search(
13
- problem: 'GenericSearch',
14
- visualize: bool = False
15
  ) -> Tuple[PathResult, Optional[List[SearchStep]]]:
16
  """
17
  Breadth-first search using FIFO queue.
@@ -40,24 +40,29 @@ def bfs_search(
40
 
41
  # Record step for visualization
42
  if visualize:
43
- steps.append(SearchStep(
44
- step_number=nodes_expanded,
45
- current_node=node.state,
46
- action=node.action,
47
- frontier=frontier.get_states(),
48
- explored=list(explored),
49
- current_path=node.get_path(),
50
- path_cost=node.path_cost
51
- ))
 
 
52
 
53
  # Goal test after pop (standard BFS)
54
  if problem.goal_test(node.state):
55
- return PathResult(
56
- plan=node.get_solution(),
57
- cost=node.path_cost,
58
- nodes_expanded=nodes_expanded,
59
- path=node.get_path()
60
- ), steps
 
 
 
61
 
62
  # Skip if already explored
63
  if node.state in explored:
@@ -76,85 +81,12 @@ def bfs_search(
76
  parent=node,
77
  action=action,
78
  path_cost=node.path_cost + step_cost,
79
- depth=node.depth + 1
80
  )
81
  frontier.push(child)
82
 
83
  # No solution found
84
- return PathResult(
85
- plan="",
86
- cost=float('inf'),
87
- nodes_expanded=nodes_expanded,
88
- path=[]
89
- ), steps
90
-
91
-
92
- def bfs_search_generator(
93
- problem: 'GenericSearch'
94
- ) -> Generator[SearchStep, None, PathResult]:
95
- """
96
- Generator version of BFS that yields steps during execution.
97
-
98
- Args:
99
- problem: The search problem to solve
100
-
101
- Yields:
102
- SearchStep objects
103
-
104
- Returns:
105
- Final PathResult
106
- """
107
- frontier = QueueFrontier()
108
- start = problem.initial_state()
109
- start_node = SearchNode(state=start, path_cost=0, depth=0)
110
- frontier.push(start_node)
111
-
112
- explored: set = set()
113
- nodes_expanded = 0
114
-
115
- while not frontier.is_empty():
116
- node = frontier.pop()
117
-
118
- yield SearchStep(
119
- step_number=nodes_expanded,
120
- current_node=node.state,
121
- action=node.action,
122
- frontier=frontier.get_states(),
123
- explored=list(explored),
124
- current_path=node.get_path(),
125
- path_cost=node.path_cost
126
- )
127
-
128
- if problem.goal_test(node.state):
129
- return PathResult(
130
- plan=node.get_solution(),
131
- cost=node.path_cost,
132
- nodes_expanded=nodes_expanded,
133
- path=node.get_path()
134
- )
135
-
136
- if node.state in explored:
137
- continue
138
-
139
- explored.add(node.state)
140
- nodes_expanded += 1
141
-
142
- for action in problem.actions(node.state):
143
- child_state = problem.result(node.state, action)
144
- if child_state not in explored and not frontier.contains_state(child_state):
145
- step_cost = problem.step_cost(node.state, action, child_state)
146
- child = SearchNode(
147
- state=child_state,
148
- parent=node,
149
- action=action,
150
- path_cost=node.path_cost + step_cost,
151
- depth=node.depth + 1
152
- )
153
- frontier.push(child)
154
-
155
- return PathResult(
156
- plan="",
157
- cost=float('inf'),
158
- nodes_expanded=nodes_expanded,
159
- path=[]
160
  )
 
1
  """Breadth-First Search algorithm."""
2
+
3
+ from typing import Tuple, Optional, List, TYPE_CHECKING
4
 
5
  if TYPE_CHECKING:
6
  from ..core.generic_search import GenericSearch
 
11
 
12
 
13
  def bfs_search(
14
+ problem: "GenericSearch", visualize: bool = False
 
15
  ) -> Tuple[PathResult, Optional[List[SearchStep]]]:
16
  """
17
  Breadth-first search using FIFO queue.
 
40
 
41
  # Record step for visualization
42
  if visualize:
43
+ steps.append(
44
+ SearchStep(
45
+ step_number=nodes_expanded,
46
+ current_node=node.state,
47
+ action=node.action,
48
+ frontier=frontier.get_states(),
49
+ explored=list(explored),
50
+ current_path=node.get_path(),
51
+ path_cost=node.path_cost,
52
+ )
53
+ )
54
 
55
  # Goal test after pop (standard BFS)
56
  if problem.goal_test(node.state):
57
+ return (
58
+ PathResult(
59
+ plan=node.get_solution(),
60
+ cost=node.path_cost,
61
+ nodes_expanded=nodes_expanded,
62
+ path=node.get_path(),
63
+ ),
64
+ steps,
65
+ )
66
 
67
  # Skip if already explored
68
  if node.state in explored:
 
81
  parent=node,
82
  action=action,
83
  path_cost=node.path_cost + step_cost,
84
+ depth=node.depth + 1,
85
  )
86
  frontier.push(child)
87
 
88
  # No solution found
89
+ return (
90
+ PathResult(plan="", cost=float("inf"), nodes_expanded=nodes_expanded, path=[]),
91
+ steps,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  )
backend/app/algorithms/dfs.py CHANGED
@@ -1,5 +1,6 @@
1
  """Depth-First Search algorithm."""
2
- from typing import Tuple, Optional, List, Generator, TYPE_CHECKING
 
3
 
4
  if TYPE_CHECKING:
5
  from ..core.generic_search import GenericSearch
@@ -10,8 +11,7 @@ from ..models.state import PathResult, SearchStep
10
 
11
 
12
  def dfs_search(
13
- problem: 'GenericSearch',
14
- visualize: bool = False
15
  ) -> Tuple[PathResult, Optional[List[SearchStep]]]:
16
  """
17
  Depth-first search using LIFO stack.
@@ -40,24 +40,29 @@ def dfs_search(
40
 
41
  # Record step for visualization
42
  if visualize:
43
- steps.append(SearchStep(
44
- step_number=nodes_expanded,
45
- current_node=node.state,
46
- action=node.action,
47
- frontier=frontier.get_states(),
48
- explored=list(explored),
49
- current_path=node.get_path(),
50
- path_cost=node.path_cost
51
- ))
 
 
52
 
53
  # Goal test
54
  if problem.goal_test(node.state):
55
- return PathResult(
56
- plan=node.get_solution(),
57
- cost=node.path_cost,
58
- nodes_expanded=nodes_expanded,
59
- path=node.get_path()
60
- ), steps
 
 
 
61
 
62
  # Skip if already explored
63
  if node.state in explored:
@@ -77,86 +82,12 @@ def dfs_search(
77
  parent=node,
78
  action=action,
79
  path_cost=node.path_cost + step_cost,
80
- depth=node.depth + 1
81
  )
82
  frontier.push(child)
83
 
84
  # No solution found
85
- return PathResult(
86
- plan="",
87
- cost=float('inf'),
88
- nodes_expanded=nodes_expanded,
89
- path=[]
90
- ), steps
91
-
92
-
93
- def dfs_search_generator(
94
- problem: 'GenericSearch'
95
- ) -> Generator[SearchStep, None, PathResult]:
96
- """
97
- Generator version of DFS that yields steps during execution.
98
-
99
- Args:
100
- problem: The search problem to solve
101
-
102
- Yields:
103
- SearchStep objects
104
-
105
- Returns:
106
- Final PathResult
107
- """
108
- frontier = StackFrontier()
109
- start = problem.initial_state()
110
- start_node = SearchNode(state=start, path_cost=0, depth=0)
111
- frontier.push(start_node)
112
-
113
- explored: set = set()
114
- nodes_expanded = 0
115
-
116
- while not frontier.is_empty():
117
- node = frontier.pop()
118
-
119
- yield SearchStep(
120
- step_number=nodes_expanded,
121
- current_node=node.state,
122
- action=node.action,
123
- frontier=frontier.get_states(),
124
- explored=list(explored),
125
- current_path=node.get_path(),
126
- path_cost=node.path_cost
127
- )
128
-
129
- if problem.goal_test(node.state):
130
- return PathResult(
131
- plan=node.get_solution(),
132
- cost=node.path_cost,
133
- nodes_expanded=nodes_expanded,
134
- path=node.get_path()
135
- )
136
-
137
- if node.state in explored:
138
- continue
139
-
140
- explored.add(node.state)
141
- nodes_expanded += 1
142
-
143
- actions = problem.actions(node.state)
144
- for action in reversed(actions):
145
- child_state = problem.result(node.state, action)
146
- if child_state not in explored and not frontier.contains_state(child_state):
147
- step_cost = problem.step_cost(node.state, action, child_state)
148
- child = SearchNode(
149
- state=child_state,
150
- parent=node,
151
- action=action,
152
- path_cost=node.path_cost + step_cost,
153
- depth=node.depth + 1
154
- )
155
- frontier.push(child)
156
-
157
- return PathResult(
158
- plan="",
159
- cost=float('inf'),
160
- nodes_expanded=nodes_expanded,
161
- path=[]
162
  )
 
1
  """Depth-First Search algorithm."""
2
+
3
+ from typing import Tuple, Optional, List, TYPE_CHECKING
4
 
5
  if TYPE_CHECKING:
6
  from ..core.generic_search import GenericSearch
 
11
 
12
 
13
  def dfs_search(
14
+ problem: "GenericSearch", visualize: bool = False
 
15
  ) -> Tuple[PathResult, Optional[List[SearchStep]]]:
16
  """
17
  Depth-first search using LIFO stack.
 
40
 
41
  # Record step for visualization
42
  if visualize:
43
+ steps.append(
44
+ SearchStep(
45
+ step_number=nodes_expanded,
46
+ current_node=node.state,
47
+ action=node.action,
48
+ frontier=frontier.get_states(),
49
+ explored=list(explored),
50
+ current_path=node.get_path(),
51
+ path_cost=node.path_cost,
52
+ )
53
+ )
54
 
55
  # Goal test
56
  if problem.goal_test(node.state):
57
+ return (
58
+ PathResult(
59
+ plan=node.get_solution(),
60
+ cost=node.path_cost,
61
+ nodes_expanded=nodes_expanded,
62
+ path=node.get_path(),
63
+ ),
64
+ steps,
65
+ )
66
 
67
  # Skip if already explored
68
  if node.state in explored:
 
82
  parent=node,
83
  action=action,
84
  path_cost=node.path_cost + step_cost,
85
+ depth=node.depth + 1,
86
  )
87
  frontier.push(child)
88
 
89
  # No solution found
90
+ return (
91
+ PathResult(plan="", cost=float("inf"), nodes_expanded=nodes_expanded, path=[]),
92
+ steps,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  )
backend/app/algorithms/greedy.py CHANGED
@@ -1,5 +1,6 @@
1
  """Greedy Best-First Search algorithm."""
2
- from typing import Tuple, Optional, List, Generator, Callable, TYPE_CHECKING
 
3
 
4
  if TYPE_CHECKING:
5
  from ..core.generic_search import GenericSearch
@@ -10,9 +11,9 @@ from ..models.state import PathResult, SearchStep
10
 
11
 
12
  def greedy_search(
13
- problem: 'GenericSearch',
14
  heuristic: Callable[[Tuple[int, int], Tuple[int, int]], float],
15
- visualize: bool = False
16
  ) -> Tuple[PathResult, Optional[List[SearchStep]]]:
17
  """
18
  Greedy best-first search using heuristic only.
@@ -33,7 +34,7 @@ def greedy_search(
33
  start = problem.initial_state()
34
 
35
  # Get goal for heuristic calculation (assume single goal)
36
- goal = getattr(problem, 'goal', None)
37
 
38
  h_value = heuristic(start, goal) if goal else 0
39
  start_node = SearchNode(state=start, path_cost=0, depth=0, priority=h_value)
@@ -48,24 +49,29 @@ def greedy_search(
48
 
49
  # Record step for visualization
50
  if visualize:
51
- steps.append(SearchStep(
52
- step_number=nodes_expanded,
53
- current_node=node.state,
54
- action=node.action,
55
- frontier=frontier.get_states(),
56
- explored=list(explored),
57
- current_path=node.get_path(),
58
- path_cost=node.path_cost
59
- ))
 
 
60
 
61
  # Goal test
62
  if problem.goal_test(node.state):
63
- return PathResult(
64
- plan=node.get_solution(),
65
- cost=node.path_cost,
66
- nodes_expanded=nodes_expanded,
67
- path=node.get_path()
68
- ), steps
 
 
 
69
 
70
  # Skip if already explored
71
  if node.state in explored:
@@ -88,94 +94,12 @@ def greedy_search(
88
  action=action,
89
  path_cost=node.path_cost + step_cost,
90
  depth=node.depth + 1,
91
- priority=h_value # Priority = h(n) only for Greedy
92
  )
93
  frontier.push(child)
94
 
95
  # No solution found
96
- return PathResult(
97
- plan="",
98
- cost=float('inf'),
99
- nodes_expanded=nodes_expanded,
100
- path=[]
101
- ), steps
102
-
103
-
104
- def greedy_search_generator(
105
- problem: 'GenericSearch',
106
- heuristic: Callable[[Tuple[int, int], Tuple[int, int]], float]
107
- ) -> Generator[SearchStep, None, PathResult]:
108
- """
109
- Generator version of Greedy search that yields steps during execution.
110
-
111
- Args:
112
- problem: The search problem to solve
113
- heuristic: Heuristic function
114
-
115
- Yields:
116
- SearchStep objects
117
-
118
- Returns:
119
- Final PathResult
120
- """
121
- frontier = PriorityQueueFrontier()
122
- start = problem.initial_state()
123
- goal = getattr(problem, 'goal', None)
124
-
125
- h_value = heuristic(start, goal) if goal else 0
126
- start_node = SearchNode(state=start, path_cost=0, depth=0, priority=h_value)
127
- frontier.push(start_node)
128
-
129
- explored: set = set()
130
- nodes_expanded = 0
131
-
132
- while not frontier.is_empty():
133
- node = frontier.pop()
134
-
135
- yield SearchStep(
136
- step_number=nodes_expanded,
137
- current_node=node.state,
138
- action=node.action,
139
- frontier=frontier.get_states(),
140
- explored=list(explored),
141
- current_path=node.get_path(),
142
- path_cost=node.path_cost
143
- )
144
-
145
- if problem.goal_test(node.state):
146
- return PathResult(
147
- plan=node.get_solution(),
148
- cost=node.path_cost,
149
- nodes_expanded=nodes_expanded,
150
- path=node.get_path()
151
- )
152
-
153
- if node.state in explored:
154
- continue
155
-
156
- explored.add(node.state)
157
- nodes_expanded += 1
158
-
159
- for action in problem.actions(node.state):
160
- child_state = problem.result(node.state, action)
161
-
162
- if child_state not in explored:
163
- step_cost = problem.step_cost(node.state, action, child_state)
164
- h_value = heuristic(child_state, goal) if goal else 0
165
-
166
- child = SearchNode(
167
- state=child_state,
168
- parent=node,
169
- action=action,
170
- path_cost=node.path_cost + step_cost,
171
- depth=node.depth + 1,
172
- priority=h_value
173
- )
174
- frontier.push(child)
175
-
176
- return PathResult(
177
- plan="",
178
- cost=float('inf'),
179
- nodes_expanded=nodes_expanded,
180
- path=[]
181
  )
 
1
  """Greedy Best-First Search algorithm."""
2
+
3
+ from typing import Tuple, Optional, List, Callable, TYPE_CHECKING
4
 
5
  if TYPE_CHECKING:
6
  from ..core.generic_search import GenericSearch
 
11
 
12
 
13
  def greedy_search(
14
+ problem: "GenericSearch",
15
  heuristic: Callable[[Tuple[int, int], Tuple[int, int]], float],
16
+ visualize: bool = False,
17
  ) -> Tuple[PathResult, Optional[List[SearchStep]]]:
18
  """
19
  Greedy best-first search using heuristic only.
 
34
  start = problem.initial_state()
35
 
36
  # Get goal for heuristic calculation (assume single goal)
37
+ goal = getattr(problem, "goal", None)
38
 
39
  h_value = heuristic(start, goal) if goal else 0
40
  start_node = SearchNode(state=start, path_cost=0, depth=0, priority=h_value)
 
49
 
50
  # Record step for visualization
51
  if visualize:
52
+ steps.append(
53
+ SearchStep(
54
+ step_number=nodes_expanded,
55
+ current_node=node.state,
56
+ action=node.action,
57
+ frontier=frontier.get_states(),
58
+ explored=list(explored),
59
+ current_path=node.get_path(),
60
+ path_cost=node.path_cost,
61
+ )
62
+ )
63
 
64
  # Goal test
65
  if problem.goal_test(node.state):
66
+ return (
67
+ PathResult(
68
+ plan=node.get_solution(),
69
+ cost=node.path_cost,
70
+ nodes_expanded=nodes_expanded,
71
+ path=node.get_path(),
72
+ ),
73
+ steps,
74
+ )
75
 
76
  # Skip if already explored
77
  if node.state in explored:
 
94
  action=action,
95
  path_cost=node.path_cost + step_cost,
96
  depth=node.depth + 1,
97
+ priority=h_value, # Priority = h(n) only for Greedy
98
  )
99
  frontier.push(child)
100
 
101
  # No solution found
102
+ return (
103
+ PathResult(plan="", cost=float("inf"), nodes_expanded=nodes_expanded, path=[]),
104
+ steps,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  )
backend/app/algorithms/ids.py CHANGED
@@ -1,5 +1,6 @@
1
  """Iterative Deepening Search algorithm."""
2
- from typing import Tuple, Optional, List, Generator, TYPE_CHECKING
 
3
 
4
  if TYPE_CHECKING:
5
  from ..core.generic_search import GenericSearch
@@ -14,11 +15,11 @@ FAILURE = "failure"
14
 
15
 
16
  def depth_limited_search(
17
- problem: 'GenericSearch',
18
  limit: int,
19
  visualize: bool = False,
20
  steps: Optional[List[SearchStep]] = None,
21
- base_expanded: int = 0
22
  ) -> Tuple[Optional[PathResult], str, int, Optional[List[SearchStep]]]:
23
  """
24
  Depth-limited search - DFS with depth limit.
@@ -38,43 +39,54 @@ def depth_limited_search(
38
  start_node = SearchNode(state=start, path_cost=0, depth=0)
39
 
40
  return _recursive_dls(
41
- problem, start_node, limit, set(), visualize,
 
 
 
 
42
  steps if steps is not None else ([] if visualize else None),
43
- base_expanded
44
  )
45
 
46
 
47
  def _recursive_dls(
48
- problem: 'GenericSearch',
49
  node: SearchNode,
50
  limit: int,
51
  explored: set,
52
  visualize: bool,
53
  steps: Optional[List[SearchStep]],
54
- nodes_expanded: int
55
  ) -> Tuple[Optional[PathResult], str, int, Optional[List[SearchStep]]]:
56
  """Recursive helper for depth-limited search."""
57
 
58
  # Record step for visualization
59
  if visualize and steps is not None:
60
- steps.append(SearchStep(
61
- step_number=nodes_expanded,
62
- current_node=node.state,
63
- action=node.action,
64
- frontier=[], # DLS doesn't maintain explicit frontier
65
- explored=list(explored),
66
- current_path=node.get_path(),
67
- path_cost=node.path_cost
68
- ))
 
 
69
 
70
  # Goal test
71
  if problem.goal_test(node.state):
72
- return PathResult(
73
- plan=node.get_solution(),
74
- cost=node.path_cost,
75
- nodes_expanded=nodes_expanded,
76
- path=node.get_path()
77
- ), "success", nodes_expanded, steps
 
 
 
 
 
78
 
79
  # Depth limit reached
80
  if node.depth >= limit:
@@ -97,7 +109,7 @@ def _recursive_dls(
97
  parent=node,
98
  action=action,
99
  path_cost=node.path_cost + step_cost,
100
- depth=node.depth + 1
101
  )
102
 
103
  result, status, nodes_expanded, steps = _recursive_dls(
@@ -116,9 +128,7 @@ def _recursive_dls(
116
 
117
 
118
  def ids_search(
119
- problem: 'GenericSearch',
120
- visualize: bool = False,
121
- max_depth: int = 1000
122
  ) -> Tuple[PathResult, Optional[List[SearchStep]]]:
123
  """
124
  Iterative deepening search - repeated DLS with increasing depth.
@@ -154,102 +164,7 @@ def ids_search(
154
  break
155
 
156
  # No solution found within max_depth
157
- return PathResult(
158
- plan="",
159
- cost=float('inf'),
160
- nodes_expanded=total_expanded,
161
- path=[]
162
- ), all_steps
163
-
164
-
165
- def ids_search_generator(
166
- problem: 'GenericSearch',
167
- max_depth: int = 1000
168
- ) -> Generator[SearchStep, None, PathResult]:
169
- """
170
- Generator version of IDS that yields steps during execution.
171
-
172
- Args:
173
- problem: The search problem to solve
174
- max_depth: Maximum depth to search
175
-
176
- Yields:
177
- SearchStep objects
178
-
179
- Returns:
180
- Final PathResult
181
- """
182
- total_expanded = 0
183
-
184
- for depth in range(max_depth):
185
- # Run DLS and yield steps
186
- for step in _dls_generator(problem, depth, total_expanded):
187
- yield step
188
- total_expanded = step.step_number
189
-
190
- # Check if solution was found at this depth
191
- result, status, expanded, _ = depth_limited_search(
192
- problem, depth, False, None, total_expanded
193
- )
194
- total_expanded = expanded
195
-
196
- if status == "success" and result is not None:
197
- result.nodes_expanded = total_expanded
198
- return result
199
- elif status == FAILURE:
200
- break
201
-
202
- return PathResult(
203
- plan="",
204
- cost=float('inf'),
205
- nodes_expanded=total_expanded,
206
- path=[]
207
  )
208
-
209
-
210
- def _dls_generator(
211
- problem: 'GenericSearch',
212
- limit: int,
213
- base_expanded: int
214
- ) -> Generator[SearchStep, None, None]:
215
- """Generator helper for DLS."""
216
- start = problem.initial_state()
217
- start_node = SearchNode(state=start, path_cost=0, depth=0)
218
-
219
- stack = [(start_node, set())]
220
- nodes_expanded = base_expanded
221
-
222
- while stack:
223
- node, explored = stack.pop()
224
-
225
- yield SearchStep(
226
- step_number=nodes_expanded,
227
- current_node=node.state,
228
- action=node.action,
229
- frontier=[n.state for n, _ in stack],
230
- explored=list(explored),
231
- current_path=node.get_path(),
232
- path_cost=node.path_cost
233
- )
234
-
235
- if problem.goal_test(node.state):
236
- return
237
-
238
- if node.depth >= limit:
239
- continue
240
-
241
- explored = explored | {node.state}
242
- nodes_expanded += 1
243
-
244
- for action in reversed(problem.actions(node.state)):
245
- child_state = problem.result(node.state, action)
246
- if child_state not in explored:
247
- step_cost = problem.step_cost(node.state, action, child_state)
248
- child = SearchNode(
249
- state=child_state,
250
- parent=node,
251
- action=action,
252
- path_cost=node.path_cost + step_cost,
253
- depth=node.depth + 1
254
- )
255
- stack.append((child, explored))
 
1
  """Iterative Deepening Search algorithm."""
2
+
3
+ from typing import Tuple, Optional, List, TYPE_CHECKING
4
 
5
  if TYPE_CHECKING:
6
  from ..core.generic_search import GenericSearch
 
15
 
16
 
17
  def depth_limited_search(
18
+ problem: "GenericSearch",
19
  limit: int,
20
  visualize: bool = False,
21
  steps: Optional[List[SearchStep]] = None,
22
+ base_expanded: int = 0,
23
  ) -> Tuple[Optional[PathResult], str, int, Optional[List[SearchStep]]]:
24
  """
25
  Depth-limited search - DFS with depth limit.
 
39
  start_node = SearchNode(state=start, path_cost=0, depth=0)
40
 
41
  return _recursive_dls(
42
+ problem,
43
+ start_node,
44
+ limit,
45
+ set(),
46
+ visualize,
47
  steps if steps is not None else ([] if visualize else None),
48
+ base_expanded,
49
  )
50
 
51
 
52
  def _recursive_dls(
53
+ problem: "GenericSearch",
54
  node: SearchNode,
55
  limit: int,
56
  explored: set,
57
  visualize: bool,
58
  steps: Optional[List[SearchStep]],
59
+ nodes_expanded: int,
60
  ) -> Tuple[Optional[PathResult], str, int, Optional[List[SearchStep]]]:
61
  """Recursive helper for depth-limited search."""
62
 
63
  # Record step for visualization
64
  if visualize and steps is not None:
65
+ steps.append(
66
+ SearchStep(
67
+ step_number=nodes_expanded,
68
+ current_node=node.state,
69
+ action=node.action,
70
+ frontier=[], # DLS doesn't maintain explicit frontier
71
+ explored=list(explored),
72
+ current_path=node.get_path(),
73
+ path_cost=node.path_cost,
74
+ )
75
+ )
76
 
77
  # Goal test
78
  if problem.goal_test(node.state):
79
+ return (
80
+ PathResult(
81
+ plan=node.get_solution(),
82
+ cost=node.path_cost,
83
+ nodes_expanded=nodes_expanded,
84
+ path=node.get_path(),
85
+ ),
86
+ "success",
87
+ nodes_expanded,
88
+ steps,
89
+ )
90
 
91
  # Depth limit reached
92
  if node.depth >= limit:
 
109
  parent=node,
110
  action=action,
111
  path_cost=node.path_cost + step_cost,
112
+ depth=node.depth + 1,
113
  )
114
 
115
  result, status, nodes_expanded, steps = _recursive_dls(
 
128
 
129
 
130
  def ids_search(
131
+ problem: "GenericSearch", visualize: bool = False, max_depth: int = 1000
 
 
132
  ) -> Tuple[PathResult, Optional[List[SearchStep]]]:
133
  """
134
  Iterative deepening search - repeated DLS with increasing depth.
 
164
  break
165
 
166
  # No solution found within max_depth
167
+ return (
168
+ PathResult(plan="", cost=float("inf"), nodes_expanded=total_expanded, path=[]),
169
+ all_steps,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/algorithms/ucs.py CHANGED
@@ -1,5 +1,6 @@
1
  """Uniform Cost Search algorithm."""
2
- from typing import Tuple, Optional, List, Generator, TYPE_CHECKING
 
3
 
4
  if TYPE_CHECKING:
5
  from ..core.generic_search import GenericSearch
@@ -10,8 +11,7 @@ from ..models.state import PathResult, SearchStep
10
 
11
 
12
  def ucs_search(
13
- problem: 'GenericSearch',
14
- visualize: bool = False
15
  ) -> Tuple[PathResult, Optional[List[SearchStep]]]:
16
  """
17
  Uniform Cost Search using priority queue ordered by path cost.
@@ -40,24 +40,29 @@ def ucs_search(
40
 
41
  # Record step for visualization
42
  if visualize:
43
- steps.append(SearchStep(
44
- step_number=nodes_expanded,
45
- current_node=node.state,
46
- action=node.action,
47
- frontier=frontier.get_states(),
48
- explored=list(explored),
49
- current_path=node.get_path(),
50
- path_cost=node.path_cost
51
- ))
 
 
52
 
53
  # Goal test (after pop for UCS)
54
  if problem.goal_test(node.state):
55
- return PathResult(
56
- plan=node.get_solution(),
57
- cost=node.path_cost,
58
- nodes_expanded=nodes_expanded,
59
- path=node.get_path()
60
- ), steps
 
 
 
61
 
62
  # Skip if already explored
63
  if node.state in explored:
@@ -79,88 +84,12 @@ def ucs_search(
79
  action=action,
80
  path_cost=new_cost,
81
  depth=node.depth + 1,
82
- priority=new_cost # Priority = path cost for UCS
83
  )
84
  frontier.push(child)
85
 
86
  # No solution found
87
- return PathResult(
88
- plan="",
89
- cost=float('inf'),
90
- nodes_expanded=nodes_expanded,
91
- path=[]
92
- ), steps
93
-
94
-
95
- def ucs_search_generator(
96
- problem: 'GenericSearch'
97
- ) -> Generator[SearchStep, None, PathResult]:
98
- """
99
- Generator version of UCS that yields steps during execution.
100
-
101
- Args:
102
- problem: The search problem to solve
103
-
104
- Yields:
105
- SearchStep objects
106
-
107
- Returns:
108
- Final PathResult
109
- """
110
- frontier = PriorityQueueFrontier()
111
- start = problem.initial_state()
112
- start_node = SearchNode(state=start, path_cost=0, depth=0, priority=0)
113
- frontier.push(start_node)
114
-
115
- explored: set = set()
116
- nodes_expanded = 0
117
-
118
- while not frontier.is_empty():
119
- node = frontier.pop()
120
-
121
- yield SearchStep(
122
- step_number=nodes_expanded,
123
- current_node=node.state,
124
- action=node.action,
125
- frontier=frontier.get_states(),
126
- explored=list(explored),
127
- current_path=node.get_path(),
128
- path_cost=node.path_cost
129
- )
130
-
131
- if problem.goal_test(node.state):
132
- return PathResult(
133
- plan=node.get_solution(),
134
- cost=node.path_cost,
135
- nodes_expanded=nodes_expanded,
136
- path=node.get_path()
137
- )
138
-
139
- if node.state in explored:
140
- continue
141
-
142
- explored.add(node.state)
143
- nodes_expanded += 1
144
-
145
- for action in problem.actions(node.state):
146
- child_state = problem.result(node.state, action)
147
- step_cost = problem.step_cost(node.state, action, child_state)
148
- new_cost = node.path_cost + step_cost
149
-
150
- if child_state not in explored:
151
- child = SearchNode(
152
- state=child_state,
153
- parent=node,
154
- action=action,
155
- path_cost=new_cost,
156
- depth=node.depth + 1,
157
- priority=new_cost
158
- )
159
- frontier.push(child)
160
-
161
- return PathResult(
162
- plan="",
163
- cost=float('inf'),
164
- nodes_expanded=nodes_expanded,
165
- path=[]
166
  )
 
1
  """Uniform Cost Search algorithm."""
2
+
3
+ from typing import Tuple, Optional, List, TYPE_CHECKING
4
 
5
  if TYPE_CHECKING:
6
  from ..core.generic_search import GenericSearch
 
11
 
12
 
13
  def ucs_search(
14
+ problem: "GenericSearch", visualize: bool = False
 
15
  ) -> Tuple[PathResult, Optional[List[SearchStep]]]:
16
  """
17
  Uniform Cost Search using priority queue ordered by path cost.
 
40
 
41
  # Record step for visualization
42
  if visualize:
43
+ steps.append(
44
+ SearchStep(
45
+ step_number=nodes_expanded,
46
+ current_node=node.state,
47
+ action=node.action,
48
+ frontier=frontier.get_states(),
49
+ explored=list(explored),
50
+ current_path=node.get_path(),
51
+ path_cost=node.path_cost,
52
+ )
53
+ )
54
 
55
  # Goal test (after pop for UCS)
56
  if problem.goal_test(node.state):
57
+ return (
58
+ PathResult(
59
+ plan=node.get_solution(),
60
+ cost=node.path_cost,
61
+ nodes_expanded=nodes_expanded,
62
+ path=node.get_path(),
63
+ ),
64
+ steps,
65
+ )
66
 
67
  # Skip if already explored
68
  if node.state in explored:
 
84
  action=action,
85
  path_cost=new_cost,
86
  depth=node.depth + 1,
87
+ priority=new_cost, # Priority = path cost for UCS
88
  )
89
  frontier.push(child)
90
 
91
  # No solution found
92
+ return (
93
+ PathResult(plan="", cost=float("inf"), nodes_expanded=nodes_expanded, path=[]),
94
+ steps,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  )
backend/app/api/__init__.py CHANGED
@@ -1,4 +1,5 @@
1
  """API package."""
 
2
  from .routes import router
3
 
4
  __all__ = ["router"]
 
1
  """API package."""
2
+
3
  from .routes import router
4
 
5
  __all__ = ["router"]
backend/app/api/routes.py CHANGED
@@ -1,4 +1,5 @@
1
  """API routes for the delivery search application."""
 
2
  from fastapi import APIRouter, HTTPException
3
  from typing import List
4
 
@@ -33,42 +34,42 @@ ALGORITHMS = [
33
  AlgorithmInfo(
34
  code="BF",
35
  name="Breadth-First Search",
36
- description="Explores all nodes at current depth before moving deeper. Finds shortest path in terms of steps."
37
  ),
38
  AlgorithmInfo(
39
  code="DF",
40
  name="Depth-First Search",
41
- description="Explores as far as possible along each branch. Memory efficient but may not find optimal path."
42
  ),
43
  AlgorithmInfo(
44
  code="ID",
45
  name="Iterative Deepening",
46
- description="Combines BFS completeness with DFS space efficiency. Good for unknown depth goals."
47
  ),
48
  AlgorithmInfo(
49
  code="UC",
50
  name="Uniform Cost Search",
51
- description="Expands lowest-cost node first. Always finds the optimal (minimum cost) solution."
52
  ),
53
  AlgorithmInfo(
54
  code="GR1",
55
  name="Greedy (Manhattan)",
56
- description="Uses Manhattan distance heuristic. Fast but may not find optimal path."
57
  ),
58
  AlgorithmInfo(
59
  code="GR2",
60
  name="Greedy (Euclidean)",
61
- description="Uses Euclidean distance heuristic. Fast but may not find optimal path."
62
  ),
63
  AlgorithmInfo(
64
  code="AS1",
65
  name="A* (Manhattan)",
66
- description="A* with Manhattan distance. Optimal and complete with admissible heuristic."
67
  ),
68
  AlgorithmInfo(
69
  code="AS2",
70
  name="A* (Tunnel-Aware)",
71
- description="A* considering tunnel shortcuts. More informed for grids with tunnels."
72
  ),
73
  ]
74
 
@@ -95,7 +96,7 @@ async def generate_grid(config: GridConfig):
95
  num_stores=config.num_stores,
96
  num_destinations=config.num_destinations,
97
  num_tunnels=config.num_tunnels,
98
- obstacle_density=config.obstacle_density
99
  )
100
 
101
  # Convert to GridData for frontend
@@ -107,14 +108,16 @@ async def generate_grid(config: GridConfig):
107
  for s in state.stores
108
  ],
109
  destinations=[
110
- DestinationData(id=d.id, position=Position(x=d.position[0], y=d.position[1]))
 
 
111
  for d in state.destinations
112
  ],
113
  tunnels=[
114
  TunnelData(
115
  entrance1=Position(x=t.entrance1[0], y=t.entrance1[1]),
116
  entrance2=Position(x=t.entrance2[0], y=t.entrance2[1]),
117
- cost=t.cost
118
  )
119
  for t in state.tunnels
120
  ],
@@ -122,16 +125,14 @@ async def generate_grid(config: GridConfig):
122
  SegmentData(
123
  src=Position(x=seg.src[0], y=seg.src[1]),
124
  dst=Position(x=seg.dst[0], y=seg.dst[1]),
125
- traffic=seg.traffic
126
  )
127
  for seg in state.grid.segments.values()
128
- ]
129
  )
130
 
131
  return GenerateResponse(
132
- initial_state=initial_state,
133
- traffic=traffic,
134
- parsed=parsed
135
  )
136
  except Exception as e:
137
  raise HTTPException(status_code=500, detail=str(e))
@@ -148,16 +149,14 @@ async def find_path(request: PathRequest):
148
  grid = Grid(width=request.grid_width, height=request.grid_height)
149
  for seg in request.segments:
150
  grid.add_segment(
151
- (seg.src.x, seg.src.y),
152
- (seg.dst.x, seg.dst.y),
153
- seg.traffic
154
  )
155
 
156
  # Build tunnels
157
  tunnels = [
158
  Tunnel(
159
  entrance1=(t.entrance1.x, t.entrance1.y),
160
- entrance2=(t.entrance2.x, t.entrance2.y)
161
  )
162
  for t in request.tunnels
163
  ]
@@ -170,7 +169,7 @@ async def find_path(request: PathRequest):
170
  (request.goal.x, request.goal.y),
171
  tunnels,
172
  request.strategy.value,
173
- visualize=True
174
  )
175
  metrics.sample()
176
 
@@ -179,10 +178,10 @@ async def find_path(request: PathRequest):
179
  cost=result.cost,
180
  nodes_expanded=result.nodes_expanded,
181
  runtime_ms=metrics.runtime_ms,
182
- memory_mb=max(0, metrics.memory_mb),
183
  cpu_percent=metrics.cpu_percent,
184
  path=[Position(x=p[0], y=p[1]) for p in result.path],
185
- steps=[s.to_dict() for s in steps] if steps else None
186
  )
187
  except Exception as e:
188
  raise HTTPException(status_code=500, detail=str(e))
@@ -203,7 +202,7 @@ async def create_plan(request: SearchRequest):
203
  state.destinations,
204
  state.tunnels,
205
  request.strategy.value,
206
- request.visualize
207
  )
208
  metrics.sample()
209
 
@@ -213,8 +212,8 @@ async def create_plan(request: SearchRequest):
213
  total_cost=plan_result.total_cost,
214
  total_nodes_expanded=plan_result.total_nodes_expanded,
215
  runtime_ms=metrics.runtime_ms,
216
- memory_mb=max(0, metrics.memory_mb),
217
- cpu_percent=metrics.cpu_percent
218
  )
219
  except Exception as e:
220
  raise HTTPException(status_code=500, detail=str(e))
@@ -227,7 +226,7 @@ async def compare_algorithms(request: CompareRequest):
227
  state = parse_full_state(request.initial_state, request.traffic)
228
 
229
  results: List[ComparisonResult] = []
230
- optimal_cost = float('inf')
231
 
232
  # Run each algorithm
233
  for algo_info in ALGORITHMS:
@@ -238,7 +237,7 @@ async def compare_algorithms(request: CompareRequest):
238
  state.destinations,
239
  state.tunnels,
240
  algo_info.code,
241
- visualize=False
242
  )
243
  metrics.sample()
244
 
@@ -246,25 +245,24 @@ async def compare_algorithms(request: CompareRequest):
246
  if algo_info.code in ["UC", "AS1", "AS2"]:
247
  optimal_cost = min(optimal_cost, plan_result.total_cost)
248
 
249
- results.append(ComparisonResult(
250
- algorithm=algo_info.code,
251
- name=algo_info.name,
252
- plan=plan_result.to_string(),
253
- cost=plan_result.total_cost,
254
- nodes_expanded=plan_result.total_nodes_expanded,
255
- runtime_ms=metrics.runtime_ms,
256
- memory_mb=max(0, metrics.memory_mb),
257
- cpu_percent=metrics.cpu_percent,
258
- is_optimal=False # Will be set below
259
- ))
 
 
260
 
261
  # Mark optimal solutions
262
  for result in results:
263
- result.is_optimal = (result.cost == optimal_cost)
264
 
265
- return CompareResponse(
266
- comparisons=results,
267
- optimal_cost=optimal_cost
268
- )
269
  except Exception as e:
270
  raise HTTPException(status_code=500, detail=str(e))
 
1
  """API routes for the delivery search application."""
2
+
3
  from fastapi import APIRouter, HTTPException
4
  from typing import List
5
 
 
34
  AlgorithmInfo(
35
  code="BF",
36
  name="Breadth-First Search",
37
+ description="Explores all nodes at current depth before moving deeper. Finds shortest path in terms of steps.",
38
  ),
39
  AlgorithmInfo(
40
  code="DF",
41
  name="Depth-First Search",
42
+ description="Explores as far as possible along each branch. Memory efficient but may not find optimal path.",
43
  ),
44
  AlgorithmInfo(
45
  code="ID",
46
  name="Iterative Deepening",
47
+ description="Combines BFS completeness with DFS space efficiency. Good for unknown depth goals.",
48
  ),
49
  AlgorithmInfo(
50
  code="UC",
51
  name="Uniform Cost Search",
52
+ description="Expands lowest-cost node first. Always finds the optimal (minimum cost) solution.",
53
  ),
54
  AlgorithmInfo(
55
  code="GR1",
56
  name="Greedy (Manhattan)",
57
+ description="Uses Manhattan distance heuristic. Fast but may not find optimal path.",
58
  ),
59
  AlgorithmInfo(
60
  code="GR2",
61
  name="Greedy (Euclidean)",
62
+ description="Uses Euclidean distance heuristic. Fast but may not find optimal path.",
63
  ),
64
  AlgorithmInfo(
65
  code="AS1",
66
  name="A* (Manhattan)",
67
+ description="A* with Manhattan distance. Optimal and complete with admissible heuristic.",
68
  ),
69
  AlgorithmInfo(
70
  code="AS2",
71
  name="A* (Tunnel-Aware)",
72
+ description="A* considering tunnel shortcuts. More informed for grids with tunnels.",
73
  ),
74
  ]
75
 
 
96
  num_stores=config.num_stores,
97
  num_destinations=config.num_destinations,
98
  num_tunnels=config.num_tunnels,
99
+ obstacle_density=config.obstacle_density,
100
  )
101
 
102
  # Convert to GridData for frontend
 
108
  for s in state.stores
109
  ],
110
  destinations=[
111
+ DestinationData(
112
+ id=d.id, position=Position(x=d.position[0], y=d.position[1])
113
+ )
114
  for d in state.destinations
115
  ],
116
  tunnels=[
117
  TunnelData(
118
  entrance1=Position(x=t.entrance1[0], y=t.entrance1[1]),
119
  entrance2=Position(x=t.entrance2[0], y=t.entrance2[1]),
120
+ cost=t.cost,
121
  )
122
  for t in state.tunnels
123
  ],
 
125
  SegmentData(
126
  src=Position(x=seg.src[0], y=seg.src[1]),
127
  dst=Position(x=seg.dst[0], y=seg.dst[1]),
128
+ traffic=seg.traffic,
129
  )
130
  for seg in state.grid.segments.values()
131
+ ],
132
  )
133
 
134
  return GenerateResponse(
135
+ initial_state=initial_state, traffic=traffic, parsed=parsed
 
 
136
  )
137
  except Exception as e:
138
  raise HTTPException(status_code=500, detail=str(e))
 
149
  grid = Grid(width=request.grid_width, height=request.grid_height)
150
  for seg in request.segments:
151
  grid.add_segment(
152
+ (seg.src.x, seg.src.y), (seg.dst.x, seg.dst.y), seg.traffic
 
 
153
  )
154
 
155
  # Build tunnels
156
  tunnels = [
157
  Tunnel(
158
  entrance1=(t.entrance1.x, t.entrance1.y),
159
+ entrance2=(t.entrance2.x, t.entrance2.y),
160
  )
161
  for t in request.tunnels
162
  ]
 
169
  (request.goal.x, request.goal.y),
170
  tunnels,
171
  request.strategy.value,
172
+ visualize=True,
173
  )
174
  metrics.sample()
175
 
 
178
  cost=result.cost,
179
  nodes_expanded=result.nodes_expanded,
180
  runtime_ms=metrics.runtime_ms,
181
+ memory_kb=max(0, metrics.memory_kb),
182
  cpu_percent=metrics.cpu_percent,
183
  path=[Position(x=p[0], y=p[1]) for p in result.path],
184
+ steps=[s.to_dict() for s in steps] if steps else None,
185
  )
186
  except Exception as e:
187
  raise HTTPException(status_code=500, detail=str(e))
 
202
  state.destinations,
203
  state.tunnels,
204
  request.strategy.value,
205
+ request.visualize,
206
  )
207
  metrics.sample()
208
 
 
212
  total_cost=plan_result.total_cost,
213
  total_nodes_expanded=plan_result.total_nodes_expanded,
214
  runtime_ms=metrics.runtime_ms,
215
+ memory_kb=max(0, metrics.memory_kb),
216
+ cpu_percent=metrics.cpu_percent,
217
  )
218
  except Exception as e:
219
  raise HTTPException(status_code=500, detail=str(e))
 
226
  state = parse_full_state(request.initial_state, request.traffic)
227
 
228
  results: List[ComparisonResult] = []
229
+ optimal_cost = float("inf")
230
 
231
  # Run each algorithm
232
  for algo_info in ALGORITHMS:
 
237
  state.destinations,
238
  state.tunnels,
239
  algo_info.code,
240
+ visualize=False,
241
  )
242
  metrics.sample()
243
 
 
245
  if algo_info.code in ["UC", "AS1", "AS2"]:
246
  optimal_cost = min(optimal_cost, plan_result.total_cost)
247
 
248
+ results.append(
249
+ ComparisonResult(
250
+ algorithm=algo_info.code,
251
+ name=algo_info.name,
252
+ plan=plan_result.to_string(),
253
+ cost=plan_result.total_cost,
254
+ nodes_expanded=plan_result.total_nodes_expanded,
255
+ runtime_ms=metrics.runtime_ms,
256
+ memory_kb=max(0, metrics.memory_kb),
257
+ cpu_percent=metrics.cpu_percent,
258
+ is_optimal=False, # Will be set below
259
+ )
260
+ )
261
 
262
  # Mark optimal solutions
263
  for result in results:
264
+ result.is_optimal = result.cost == optimal_cost
265
 
266
+ return CompareResponse(comparisons=results, optimal_cost=optimal_cost)
 
 
 
267
  except Exception as e:
268
  raise HTTPException(status_code=500, detail=str(e))
backend/app/core/__init__.py CHANGED
@@ -1,7 +1,8 @@
1
  """Core search module."""
 
2
  from .node import SearchNode
3
  from .frontier import Frontier, QueueFrontier, StackFrontier, PriorityQueueFrontier
4
- from .generic_search import GenericSearch, graph_search, graph_search_generator
5
  from .delivery_search import DeliverySearch
6
  from .delivery_planner import DeliveryPlanner
7
 
@@ -13,7 +14,6 @@ __all__ = [
13
  "PriorityQueueFrontier",
14
  "GenericSearch",
15
  "graph_search",
16
- "graph_search_generator",
17
  "DeliverySearch",
18
  "DeliveryPlanner",
19
  ]
 
1
  """Core search module."""
2
+
3
  from .node import SearchNode
4
  from .frontier import Frontier, QueueFrontier, StackFrontier, PriorityQueueFrontier
5
+ from .generic_search import GenericSearch, graph_search
6
  from .delivery_search import DeliverySearch
7
  from .delivery_planner import DeliveryPlanner
8
 
 
14
  "PriorityQueueFrontier",
15
  "GenericSearch",
16
  "graph_search",
 
17
  "DeliverySearch",
18
  "DeliveryPlanner",
19
  ]
backend/app/core/delivery_planner.py CHANGED
@@ -1,4 +1,5 @@
1
  """DeliveryPlanner - Plans which trucks deliver which packages."""
 
2
  from typing import List, Dict, Tuple, Optional
3
  from .delivery_search import DeliverySearch
4
  from ..models.grid import Grid
@@ -18,7 +19,7 @@ class DeliveryPlanner:
18
  grid: Grid,
19
  stores: List[Store],
20
  destinations: List[Destination],
21
- tunnels: Optional[List[Tunnel]] = None
22
  ):
23
  """
24
  Initialize the delivery planner.
@@ -35,9 +36,7 @@ class DeliveryPlanner:
35
  self.tunnels = tunnels or []
36
 
37
  def plan(
38
- self,
39
- strategy: str,
40
- visualize: bool = False
41
  ) -> Tuple[PlanResult, Optional[Dict[int, List[SearchStep]]]]:
42
  """
43
  Create delivery plan assigning destinations to stores.
@@ -64,7 +63,7 @@ class DeliveryPlanner:
64
  best_store: Optional[Store] = None
65
  best_result: Optional[PathResult] = None
66
  best_steps: Optional[List[SearchStep]] = None
67
- best_cost = float('inf')
68
 
69
  # Try each store
70
  for store in self.stores:
@@ -74,7 +73,7 @@ class DeliveryPlanner:
74
  dest.position,
75
  self.tunnels,
76
  strategy,
77
- visualize
78
  )
79
 
80
  # Track nodes expanded
@@ -92,7 +91,7 @@ class DeliveryPlanner:
92
  assignment = DeliveryAssignment(
93
  store_id=best_store.id,
94
  destination_id=dest.id,
95
- path_result=best_result
96
  )
97
  assignments.append(assignment)
98
  total_cost += best_result.cost
@@ -100,75 +99,14 @@ class DeliveryPlanner:
100
  if visualize and best_steps:
101
  all_steps[dest.id] = best_steps
102
 
103
- return PlanResult(
104
- assignments=assignments,
105
- total_cost=total_cost,
106
- total_nodes_expanded=total_nodes
107
- ), all_steps
108
-
109
- def plan_all_from_store(
110
- self,
111
- store: Store,
112
- strategy: str,
113
- visualize: bool = False
114
- ) -> List[Tuple[Destination, PathResult, Optional[List[SearchStep]]]]:
115
- """
116
- Plan all deliveries from a single store.
117
-
118
- This variant finds paths from one store to all destinations,
119
- useful for comparing which destinations are closest.
120
-
121
- Args:
122
- store: The store to deliver from
123
- strategy: Search strategy to use
124
- visualize: If True, collect visualization steps
125
-
126
- Returns:
127
- List of (destination, path_result, steps) tuples
128
- """
129
- results = []
130
-
131
- for dest in self.destinations:
132
- result, steps = DeliverySearch.path(
133
- self.grid,
134
- store.position,
135
- dest.position,
136
- self.tunnels,
137
- strategy,
138
- visualize
139
- )
140
- results.append((dest, result, steps))
141
-
142
- # Sort by cost (closest first)
143
- results.sort(key=lambda x: x[1].cost)
144
- return results
145
-
146
- def plan_sequential(
147
- self,
148
- strategy: str,
149
- visualize: bool = False
150
- ) -> Tuple[PlanResult, Optional[Dict]]:
151
- """
152
- Plan deliveries where trucks return to store after each delivery.
153
-
154
- For each destination:
155
- 1. Find best store (minimum round-trip or just delivery cost)
156
- 2. Assign to that store
157
-
158
- This is the simplified version as per project spec where
159
- "once a delivery has been made, the truck immediately returns
160
- to the store and can now make a new delivery."
161
-
162
- Args:
163
- strategy: Search strategy to use
164
- visualize: If True, collect visualization steps
165
-
166
- Returns:
167
- Tuple of (PlanResult, Optional visualization data)
168
- """
169
- # For this simplified version, we use the same logic as plan()
170
- # since each delivery is independent (truck returns to store)
171
- return self.plan(strategy, visualize)
172
 
173
  @staticmethod
174
  def plan_from_state(
@@ -177,7 +115,7 @@ class DeliveryPlanner:
177
  destinations: List[Destination],
178
  tunnels: List[Tunnel],
179
  strategy: str,
180
- visualize: bool = False
181
  ) -> Tuple[PlanResult, Optional[Dict]]:
182
  """
183
  Static method to create and run planner.
 
1
  """DeliveryPlanner - Plans which trucks deliver which packages."""
2
+
3
  from typing import List, Dict, Tuple, Optional
4
  from .delivery_search import DeliverySearch
5
  from ..models.grid import Grid
 
19
  grid: Grid,
20
  stores: List[Store],
21
  destinations: List[Destination],
22
+ tunnels: Optional[List[Tunnel]] = None,
23
  ):
24
  """
25
  Initialize the delivery planner.
 
36
  self.tunnels = tunnels or []
37
 
38
  def plan(
39
+ self, strategy: str, visualize: bool = False
 
 
40
  ) -> Tuple[PlanResult, Optional[Dict[int, List[SearchStep]]]]:
41
  """
42
  Create delivery plan assigning destinations to stores.
 
63
  best_store: Optional[Store] = None
64
  best_result: Optional[PathResult] = None
65
  best_steps: Optional[List[SearchStep]] = None
66
+ best_cost = float("inf")
67
 
68
  # Try each store
69
  for store in self.stores:
 
73
  dest.position,
74
  self.tunnels,
75
  strategy,
76
+ visualize,
77
  )
78
 
79
  # Track nodes expanded
 
91
  assignment = DeliveryAssignment(
92
  store_id=best_store.id,
93
  destination_id=dest.id,
94
+ path_result=best_result,
95
  )
96
  assignments.append(assignment)
97
  total_cost += best_result.cost
 
99
  if visualize and best_steps:
100
  all_steps[dest.id] = best_steps
101
 
102
+ return (
103
+ PlanResult(
104
+ assignments=assignments,
105
+ total_cost=total_cost,
106
+ total_nodes_expanded=total_nodes,
107
+ ),
108
+ all_steps,
109
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
 
111
  @staticmethod
112
  def plan_from_state(
 
115
  destinations: List[Destination],
116
  tunnels: List[Tunnel],
117
  strategy: str,
118
+ visualize: bool = False,
119
  ) -> Tuple[PlanResult, Optional[Dict]]:
120
  """
121
  Static method to create and run planner.
backend/app/core/delivery_search.py CHANGED
@@ -1,4 +1,5 @@
1
  """DeliverySearch - Search problem for package delivery."""
 
2
  from typing import List, Tuple, Optional, Dict
3
  from .generic_search import GenericSearch
4
  from ..models.grid import Grid
@@ -20,7 +21,7 @@ class DeliverySearch(GenericSearch):
20
  grid: Grid,
21
  start: Tuple[int, int],
22
  goal: Tuple[int, int],
23
- tunnels: Optional[List[Tunnel]] = None
24
  ):
25
  """
26
  Initialize the delivery search problem.
@@ -120,10 +121,7 @@ class DeliverySearch(GenericSearch):
120
  raise ValueError(f"Unknown action: {action}")
121
 
122
  def step_cost(
123
- self,
124
- state: Tuple[int, int],
125
- action: str,
126
- next_state: Tuple[int, int]
127
  ) -> float:
128
  """
129
  Return the cost of taking an action.
@@ -167,7 +165,7 @@ class DeliverySearch(GenericSearch):
167
  goal: Tuple[int, int],
168
  tunnels: List[Tunnel],
169
  strategy: str,
170
- visualize: bool = False
171
  ) -> Tuple[PathResult, Optional[List[SearchStep]]]:
172
  """
173
  Find path from start to goal using specified strategy.
@@ -185,27 +183,3 @@ class DeliverySearch(GenericSearch):
185
  """
186
  search = DeliverySearch(grid, start, goal, tunnels)
187
  return search.solve(strategy, visualize)
188
-
189
- @staticmethod
190
- def path_string(
191
- grid: Grid,
192
- start: Tuple[int, int],
193
- goal: Tuple[int, int],
194
- tunnels: List[Tunnel],
195
- strategy: str
196
- ) -> str:
197
- """
198
- Find path and return formatted string.
199
-
200
- Args:
201
- grid: The city grid
202
- start: Starting position
203
- goal: Goal position
204
- tunnels: Available tunnels
205
- strategy: Search strategy
206
-
207
- Returns:
208
- String in format "plan;cost;nodesExpanded"
209
- """
210
- result, _ = DeliverySearch.path(grid, start, goal, tunnels, strategy)
211
- return result.to_string()
 
1
  """DeliverySearch - Search problem for package delivery."""
2
+
3
  from typing import List, Tuple, Optional, Dict
4
  from .generic_search import GenericSearch
5
  from ..models.grid import Grid
 
21
  grid: Grid,
22
  start: Tuple[int, int],
23
  goal: Tuple[int, int],
24
+ tunnels: Optional[List[Tunnel]] = None,
25
  ):
26
  """
27
  Initialize the delivery search problem.
 
121
  raise ValueError(f"Unknown action: {action}")
122
 
123
  def step_cost(
124
+ self, state: Tuple[int, int], action: str, next_state: Tuple[int, int]
 
 
 
125
  ) -> float:
126
  """
127
  Return the cost of taking an action.
 
165
  goal: Tuple[int, int],
166
  tunnels: List[Tunnel],
167
  strategy: str,
168
+ visualize: bool = False,
169
  ) -> Tuple[PathResult, Optional[List[SearchStep]]]:
170
  """
171
  Find path from start to goal using specified strategy.
 
183
  """
184
  search = DeliverySearch(grid, start, goal, tunnels)
185
  return search.solve(strategy, visualize)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/core/frontier.py CHANGED
@@ -1,4 +1,5 @@
1
  """Frontier data structures for search algorithms."""
 
2
  from abc import ABC, abstractmethod
3
  from collections import deque
4
  import heapq
 
1
  """Frontier data structures for search algorithms."""
2
+
3
  from abc import ABC, abstractmethod
4
  from collections import deque
5
  import heapq
backend/app/core/generic_search.py CHANGED
@@ -1,6 +1,7 @@
1
  """Generic search problem abstract base class."""
 
2
  from abc import ABC, abstractmethod
3
- from typing import List, Tuple, Optional, Generator
4
  from .node import SearchNode
5
  from .frontier import Frontier
6
  from ..models.state import PathResult, SearchStep
@@ -53,9 +54,7 @@ class GenericSearch(ABC):
53
  return 0.0
54
 
55
  def solve(
56
- self,
57
- strategy: str,
58
- visualize: bool = False
59
  ) -> Tuple[PathResult, Optional[List[SearchStep]]]:
60
  """
61
  Solve the search problem using the specified strategy.
@@ -86,62 +85,16 @@ class GenericSearch(ABC):
86
 
87
  # Map strategy codes to search functions
88
  strategy_map = {
89
- 'BF': lambda: bfs_search(self, visualize),
90
- 'DF': lambda: dfs_search(self, visualize),
91
- 'ID': lambda: ids_search(self, visualize),
92
- 'UC': lambda: ucs_search(self, visualize),
93
- 'GR1': lambda: greedy_search(self, manhattan_heuristic, visualize),
94
- 'GR2': lambda: greedy_search(self, euclidean_heuristic, visualize),
95
- 'AS1': lambda: astar_search(self, manhattan_heuristic, visualize),
96
- 'AS2': lambda: astar_search(self, tunnel_aware_wrapper, visualize), # Tunnel-aware
97
- }
98
-
99
- if strategy not in strategy_map:
100
- raise ValueError(f"Unknown strategy: {strategy}")
101
-
102
- return strategy_map[strategy]()
103
-
104
- def solve_with_steps(
105
- self, strategy: str
106
- ) -> Generator[SearchStep, None, PathResult]:
107
- """
108
- Generator version of solve that yields steps for real-time visualization.
109
-
110
- Args:
111
- strategy: Search strategy code
112
-
113
- Yields:
114
- SearchStep objects during search
115
-
116
- Returns:
117
- Final PathResult
118
- """
119
- from ..algorithms import (
120
- bfs_search_generator,
121
- dfs_search_generator,
122
- ids_search_generator,
123
- ucs_search_generator,
124
- greedy_search_generator,
125
- astar_search_generator,
126
- )
127
- from ..heuristics import (
128
- manhattan_heuristic,
129
- euclidean_heuristic,
130
- )
131
-
132
- # Wrap instance heuristic to match expected signature (state, goal) -> float
133
- def tunnel_aware_wrapper(state, goal):
134
- return self.heuristic(state)
135
-
136
- strategy_map = {
137
- 'BF': lambda: bfs_search_generator(self),
138
- 'DF': lambda: dfs_search_generator(self),
139
- 'ID': lambda: ids_search_generator(self),
140
- 'UC': lambda: ucs_search_generator(self),
141
- 'GR1': lambda: greedy_search_generator(self, manhattan_heuristic),
142
- 'GR2': lambda: greedy_search_generator(self, euclidean_heuristic),
143
- 'AS1': lambda: astar_search_generator(self, manhattan_heuristic),
144
- 'AS2': lambda: astar_search_generator(self, tunnel_aware_wrapper),
145
  }
146
 
147
  if strategy not in strategy_map:
@@ -151,9 +104,7 @@ class GenericSearch(ABC):
151
 
152
 
153
  def graph_search(
154
- problem: GenericSearch,
155
- frontier: Frontier,
156
- visualize: bool = False
157
  ) -> Tuple[PathResult, Optional[List[SearchStep]]]:
158
  """
159
  Generic graph search algorithm.
@@ -180,24 +131,29 @@ def graph_search(
180
 
181
  # Record step for visualization
182
  if visualize:
183
- steps.append(SearchStep(
184
- step_number=nodes_expanded,
185
- current_node=node.state,
186
- action=node.action,
187
- frontier=frontier.get_states(),
188
- explored=list(explored),
189
- current_path=node.get_path(),
190
- path_cost=node.path_cost
191
- ))
 
 
192
 
193
  # Goal test
194
  if problem.goal_test(node.state):
195
- return PathResult(
196
- plan=node.get_solution(),
197
- cost=node.path_cost,
198
- nodes_expanded=nodes_expanded,
199
- path=node.get_path()
200
- ), steps
 
 
 
201
 
202
  # Skip if already explored
203
  if node.state in explored:
@@ -217,87 +173,12 @@ def graph_search(
217
  parent=node,
218
  action=action,
219
  path_cost=node.path_cost + step_cost,
220
- depth=node.depth + 1
221
  )
222
  frontier.push(child)
223
 
224
  # No solution found
225
- return PathResult(
226
- plan="",
227
- cost=float('inf'),
228
- nodes_expanded=nodes_expanded,
229
- path=[]
230
- ), steps
231
-
232
-
233
- def graph_search_generator(
234
- problem: GenericSearch,
235
- frontier: Frontier
236
- ) -> Generator[SearchStep, None, PathResult]:
237
- """
238
- Generator version of graph search that yields steps during execution.
239
-
240
- Args:
241
- problem: The search problem to solve
242
- frontier: The frontier data structure
243
-
244
- Yields:
245
- SearchStep objects
246
-
247
- Returns:
248
- Final PathResult
249
- """
250
- start = problem.initial_state()
251
- start_node = SearchNode(state=start, path_cost=0, depth=0)
252
- frontier.push(start_node)
253
- explored: set = set()
254
- nodes_expanded = 0
255
-
256
- while not frontier.is_empty():
257
- node = frontier.pop()
258
-
259
- # Yield current step
260
- yield SearchStep(
261
- step_number=nodes_expanded,
262
- current_node=node.state,
263
- action=node.action,
264
- frontier=frontier.get_states(),
265
- explored=list(explored),
266
- current_path=node.get_path(),
267
- path_cost=node.path_cost
268
- )
269
-
270
- # Goal test
271
- if problem.goal_test(node.state):
272
- return PathResult(
273
- plan=node.get_solution(),
274
- cost=node.path_cost,
275
- nodes_expanded=nodes_expanded,
276
- path=node.get_path()
277
- )
278
-
279
- if node.state in explored:
280
- continue
281
-
282
- explored.add(node.state)
283
- nodes_expanded += 1
284
-
285
- for action in problem.actions(node.state):
286
- child_state = problem.result(node.state, action)
287
- if child_state not in explored and not frontier.contains_state(child_state):
288
- step_cost = problem.step_cost(node.state, action, child_state)
289
- child = SearchNode(
290
- state=child_state,
291
- parent=node,
292
- action=action,
293
- path_cost=node.path_cost + step_cost,
294
- depth=node.depth + 1
295
- )
296
- frontier.push(child)
297
-
298
- return PathResult(
299
- plan="",
300
- cost=float('inf'),
301
- nodes_expanded=nodes_expanded,
302
- path=[]
303
  )
 
1
  """Generic search problem abstract base class."""
2
+
3
  from abc import ABC, abstractmethod
4
+ from typing import List, Tuple, Optional
5
  from .node import SearchNode
6
  from .frontier import Frontier
7
  from ..models.state import PathResult, SearchStep
 
54
  return 0.0
55
 
56
  def solve(
57
+ self, strategy: str, visualize: bool = False
 
 
58
  ) -> Tuple[PathResult, Optional[List[SearchStep]]]:
59
  """
60
  Solve the search problem using the specified strategy.
 
85
 
86
  # Map strategy codes to search functions
87
  strategy_map = {
88
+ "BF": lambda: bfs_search(self, visualize),
89
+ "DF": lambda: dfs_search(self, visualize),
90
+ "ID": lambda: ids_search(self, visualize),
91
+ "UC": lambda: ucs_search(self, visualize),
92
+ "GR1": lambda: greedy_search(self, manhattan_heuristic, visualize),
93
+ "GR2": lambda: greedy_search(self, euclidean_heuristic, visualize),
94
+ "AS1": lambda: astar_search(self, manhattan_heuristic, visualize),
95
+ "AS2": lambda: astar_search(
96
+ self, tunnel_aware_wrapper, visualize
97
+ ), # Tunnel-aware
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  }
99
 
100
  if strategy not in strategy_map:
 
104
 
105
 
106
  def graph_search(
107
+ problem: GenericSearch, frontier: Frontier, visualize: bool = False
 
 
108
  ) -> Tuple[PathResult, Optional[List[SearchStep]]]:
109
  """
110
  Generic graph search algorithm.
 
131
 
132
  # Record step for visualization
133
  if visualize:
134
+ steps.append(
135
+ SearchStep(
136
+ step_number=nodes_expanded,
137
+ current_node=node.state,
138
+ action=node.action,
139
+ frontier=frontier.get_states(),
140
+ explored=list(explored),
141
+ current_path=node.get_path(),
142
+ path_cost=node.path_cost,
143
+ )
144
+ )
145
 
146
  # Goal test
147
  if problem.goal_test(node.state):
148
+ return (
149
+ PathResult(
150
+ plan=node.get_solution(),
151
+ cost=node.path_cost,
152
+ nodes_expanded=nodes_expanded,
153
+ path=node.get_path(),
154
+ ),
155
+ steps,
156
+ )
157
 
158
  # Skip if already explored
159
  if node.state in explored:
 
173
  parent=node,
174
  action=action,
175
  path_cost=node.path_cost + step_cost,
176
+ depth=node.depth + 1,
177
  )
178
  frontier.push(child)
179
 
180
  # No solution found
181
+ return (
182
+ PathResult(plan="", cost=float("inf"), nodes_expanded=nodes_expanded, path=[]),
183
+ steps,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  )
backend/app/core/node.py CHANGED
@@ -1,4 +1,5 @@
1
  """SearchNode class for the search tree."""
 
2
  from dataclasses import dataclass, field
3
  from typing import Optional, List, Tuple, Any
4
 
@@ -15,15 +16,16 @@ class SearchNode:
15
  path_cost: g(n) - cost from start to this node
16
  depth: Depth in search tree
17
  """
 
18
  state: Tuple[int, int]
19
- parent: Optional['SearchNode'] = None
20
  action: Optional[str] = None
21
  path_cost: float = 0.0
22
  depth: int = 0
23
  # For priority queue - lower is better
24
  priority: float = field(default=0.0, compare=False)
25
 
26
- def __lt__(self, other: 'SearchNode') -> bool:
27
  """Compare nodes by priority for priority queue."""
28
  return self.priority < other.priority
29
 
@@ -78,12 +80,8 @@ class SearchNode:
78
  return ",".join(actions) if actions else ""
79
 
80
  def expand(
81
- self,
82
- actions_func,
83
- result_func,
84
- cost_func,
85
- heuristic_func=None
86
- ) -> List['SearchNode']:
87
  """
88
  Expand this node by generating all child nodes.
89
 
@@ -105,7 +103,7 @@ class SearchNode:
105
  parent=self,
106
  action=action,
107
  path_cost=self.path_cost + step_cost,
108
- depth=self.depth + 1
109
  )
110
  # Set priority if heuristic is provided (for A*)
111
  if heuristic_func is not None:
@@ -116,4 +114,6 @@ class SearchNode:
116
  return children
117
 
118
  def __repr__(self) -> str:
119
- return f"SearchNode(state={self.state}, depth={self.depth}, cost={self.path_cost})"
 
 
 
1
  """SearchNode class for the search tree."""
2
+
3
  from dataclasses import dataclass, field
4
  from typing import Optional, List, Tuple, Any
5
 
 
16
  path_cost: g(n) - cost from start to this node
17
  depth: Depth in search tree
18
  """
19
+
20
  state: Tuple[int, int]
21
+ parent: Optional["SearchNode"] = None
22
  action: Optional[str] = None
23
  path_cost: float = 0.0
24
  depth: int = 0
25
  # For priority queue - lower is better
26
  priority: float = field(default=0.0, compare=False)
27
 
28
+ def __lt__(self, other: "SearchNode") -> bool:
29
  """Compare nodes by priority for priority queue."""
30
  return self.priority < other.priority
31
 
 
80
  return ",".join(actions) if actions else ""
81
 
82
  def expand(
83
+ self, actions_func, result_func, cost_func, heuristic_func=None
84
+ ) -> List["SearchNode"]:
 
 
 
 
85
  """
86
  Expand this node by generating all child nodes.
87
 
 
103
  parent=self,
104
  action=action,
105
  path_cost=self.path_cost + step_cost,
106
+ depth=self.depth + 1,
107
  )
108
  # Set priority if heuristic is provided (for A*)
109
  if heuristic_func is not None:
 
114
  return children
115
 
116
  def __repr__(self) -> str:
117
+ return (
118
+ f"SearchNode(state={self.state}, depth={self.depth}, cost={self.path_cost})"
119
+ )
backend/app/heuristics/__init__.py CHANGED
@@ -1,7 +1,11 @@
1
  """Heuristics package for informed search algorithms."""
 
2
  from .manhattan import manhattan_heuristic
3
  from .euclidean import euclidean_heuristic
4
- from .traffic_weighted import traffic_weighted_heuristic, create_traffic_weighted_heuristic
 
 
 
5
  from .tunnel_aware import tunnel_aware_heuristic, create_tunnel_aware_heuristic
6
 
7
  __all__ = [
 
1
  """Heuristics package for informed search algorithms."""
2
+
3
  from .manhattan import manhattan_heuristic
4
  from .euclidean import euclidean_heuristic
5
+ from .traffic_weighted import (
6
+ traffic_weighted_heuristic,
7
+ create_traffic_weighted_heuristic,
8
+ )
9
  from .tunnel_aware import tunnel_aware_heuristic, create_tunnel_aware_heuristic
10
 
11
  __all__ = [
backend/app/heuristics/euclidean.py CHANGED
@@ -1,4 +1,5 @@
1
  """Euclidean distance heuristic."""
 
2
  import math
3
  from typing import Tuple
4
 
 
1
  """Euclidean distance heuristic."""
2
+
3
  import math
4
  from typing import Tuple
5
 
backend/app/heuristics/manhattan.py CHANGED
@@ -1,4 +1,5 @@
1
  """Manhattan distance heuristic."""
 
2
  from typing import Tuple
3
 
4
 
 
1
  """Manhattan distance heuristic."""
2
+
3
  from typing import Tuple
4
 
5
 
backend/app/heuristics/traffic_weighted.py CHANGED
@@ -1,11 +1,10 @@
1
  """Traffic-weighted Manhattan heuristic."""
 
2
  from typing import Tuple
3
 
4
 
5
  def traffic_weighted_heuristic(
6
- state: Tuple[int, int],
7
- goal: Tuple[int, int],
8
- min_traffic: float = 1.0
9
  ) -> float:
10
  """
11
  Traffic-weighted Manhattan distance heuristic.
@@ -38,6 +37,8 @@ def create_traffic_weighted_heuristic(min_traffic: float = 1.0):
38
  Returns:
39
  Heuristic function
40
  """
 
41
  def heuristic(state: Tuple[int, int], goal: Tuple[int, int]) -> float:
42
  return traffic_weighted_heuristic(state, goal, min_traffic)
 
43
  return heuristic
 
1
  """Traffic-weighted Manhattan heuristic."""
2
+
3
  from typing import Tuple
4
 
5
 
6
  def traffic_weighted_heuristic(
7
+ state: Tuple[int, int], goal: Tuple[int, int], min_traffic: float = 1.0
 
 
8
  ) -> float:
9
  """
10
  Traffic-weighted Manhattan distance heuristic.
 
37
  Returns:
38
  Heuristic function
39
  """
40
+
41
  def heuristic(state: Tuple[int, int], goal: Tuple[int, int]) -> float:
42
  return traffic_weighted_heuristic(state, goal, min_traffic)
43
+
44
  return heuristic
backend/app/heuristics/tunnel_aware.py CHANGED
@@ -1,12 +1,11 @@
1
  """Tunnel-aware Manhattan heuristic."""
 
2
  from typing import Tuple, List, Optional
3
  from .manhattan import manhattan_heuristic
4
 
5
 
6
  def tunnel_aware_heuristic(
7
- state: Tuple[int, int],
8
- goal: Tuple[int, int],
9
- tunnels: Optional[List] = None
10
  ) -> float:
11
  """
12
  Tunnel-aware Manhattan distance heuristic.
@@ -45,16 +44,16 @@ def tunnel_aware_heuristic(
45
 
46
  # Path: state -> entrance1 -> (tunnel) -> entrance2 -> goal
47
  via_tunnel_1 = (
48
- manhattan_heuristic(state, entrance1) +
49
- tunnel_cost +
50
- manhattan_heuristic(entrance2, goal)
51
  )
52
 
53
  # Path: state -> entrance2 -> (tunnel) -> entrance1 -> goal
54
  via_tunnel_2 = (
55
- manhattan_heuristic(state, entrance2) +
56
- tunnel_cost +
57
- manhattan_heuristic(entrance1, goal)
58
  )
59
 
60
  best = min(best, via_tunnel_1, via_tunnel_2)
@@ -72,6 +71,8 @@ def create_tunnel_aware_heuristic(tunnels: List):
72
  Returns:
73
  Heuristic function
74
  """
 
75
  def heuristic(state: Tuple[int, int], goal: Tuple[int, int]) -> float:
76
  return tunnel_aware_heuristic(state, goal, tunnels)
 
77
  return heuristic
 
1
  """Tunnel-aware Manhattan heuristic."""
2
+
3
  from typing import Tuple, List, Optional
4
  from .manhattan import manhattan_heuristic
5
 
6
 
7
  def tunnel_aware_heuristic(
8
+ state: Tuple[int, int], goal: Tuple[int, int], tunnels: Optional[List] = None
 
 
9
  ) -> float:
10
  """
11
  Tunnel-aware Manhattan distance heuristic.
 
44
 
45
  # Path: state -> entrance1 -> (tunnel) -> entrance2 -> goal
46
  via_tunnel_1 = (
47
+ manhattan_heuristic(state, entrance1)
48
+ + tunnel_cost
49
+ + manhattan_heuristic(entrance2, goal)
50
  )
51
 
52
  # Path: state -> entrance2 -> (tunnel) -> entrance1 -> goal
53
  via_tunnel_2 = (
54
+ manhattan_heuristic(state, entrance2)
55
+ + tunnel_cost
56
+ + manhattan_heuristic(entrance1, goal)
57
  )
58
 
59
  best = min(best, via_tunnel_1, via_tunnel_2)
 
71
  Returns:
72
  Heuristic function
73
  """
74
+
75
  def heuristic(state: Tuple[int, int], goal: Tuple[int, int]) -> float:
76
  return tunnel_aware_heuristic(state, goal, tunnels)
77
+
78
  return heuristic
backend/app/main.py CHANGED
@@ -1,4 +1,5 @@
1
  """FastAPI application entry point."""
 
2
  from pathlib import Path
3
  from fastapi import FastAPI
4
  from fastapi.middleware.cors import CORSMiddleware
@@ -31,7 +32,9 @@ app.include_router(router)
31
  static_dir = Path("/app/frontend/dist")
32
  if static_dir.exists():
33
  # Mount assets directory
34
- app.mount("/assets", StaticFiles(directory=str(static_dir / "assets")), name="assets")
 
 
35
 
36
  # Serve index.html for root and all non-API routes
37
  @app.get("/")
@@ -51,7 +54,9 @@ if static_dir.exists():
51
 
52
  # Otherwise serve index.html (SPA routing)
53
  return FileResponse(str(static_dir / "index.html"))
 
54
  else:
 
55
  @app.get("/")
56
  async def root():
57
  """Root endpoint."""
@@ -65,7 +70,7 @@ else:
65
  "path": "/api/search/path",
66
  "plan": "/api/search/plan",
67
  "compare": "/api/search/compare",
68
- }
69
  }
70
 
71
 
 
1
  """FastAPI application entry point."""
2
+
3
  from pathlib import Path
4
  from fastapi import FastAPI
5
  from fastapi.middleware.cors import CORSMiddleware
 
32
  static_dir = Path("/app/frontend/dist")
33
  if static_dir.exists():
34
  # Mount assets directory
35
+ app.mount(
36
+ "/assets", StaticFiles(directory=str(static_dir / "assets")), name="assets"
37
+ )
38
 
39
  # Serve index.html for root and all non-API routes
40
  @app.get("/")
 
54
 
55
  # Otherwise serve index.html (SPA routing)
56
  return FileResponse(str(static_dir / "index.html"))
57
+
58
  else:
59
+
60
  @app.get("/")
61
  async def root():
62
  """Root endpoint."""
 
70
  "path": "/api/search/path",
71
  "plan": "/api/search/plan",
72
  "compare": "/api/search/compare",
73
+ },
74
  }
75
 
76
 
backend/app/models/__init__.py CHANGED
@@ -1,7 +1,15 @@
1
  """Models package - exports all model classes."""
 
2
  from .grid import Grid, Segment
3
- from .entities import Store, Destination, Tunnel, Truck
4
- from .state import SearchState, PathResult, DeliveryAssignment, PlanResult, SearchStep, SearchMetrics
 
 
 
 
 
 
 
5
  from .requests import (
6
  Algorithm,
7
  Position,
@@ -32,7 +40,6 @@ __all__ = [
32
  "Store",
33
  "Destination",
34
  "Tunnel",
35
- "Truck",
36
  # State models
37
  "SearchState",
38
  "PathResult",
 
1
  """Models package - exports all model classes."""
2
+
3
  from .grid import Grid, Segment
4
+ from .entities import Store, Destination, Tunnel
5
+ from .state import (
6
+ SearchState,
7
+ PathResult,
8
+ DeliveryAssignment,
9
+ PlanResult,
10
+ SearchStep,
11
+ SearchMetrics,
12
+ )
13
  from .requests import (
14
  Algorithm,
15
  Position,
 
40
  "Store",
41
  "Destination",
42
  "Tunnel",
 
43
  # State models
44
  "SearchState",
45
  "PathResult",
backend/app/models/entities.py CHANGED
@@ -1,4 +1,5 @@
1
  """Entity models for stores, destinations, tunnels, and trucks."""
 
2
  from dataclasses import dataclass
3
  from typing import Tuple
4
 
@@ -6,39 +7,44 @@ from typing import Tuple
6
  @dataclass
7
  class Store:
8
  """Represents a storage location / starting point for trucks."""
 
9
  id: int
10
  position: Tuple[int, int]
11
 
12
  def to_dict(self) -> dict:
13
  return {
14
  "id": self.id,
15
- "position": {"x": self.position[0], "y": self.position[1]}
16
  }
17
 
18
 
19
  @dataclass
20
  class Destination:
21
  """Represents a customer destination for package delivery."""
 
22
  id: int
23
  position: Tuple[int, int]
24
 
25
  def to_dict(self) -> dict:
26
  return {
27
  "id": self.id,
28
- "position": {"x": self.position[0], "y": self.position[1]}
29
  }
30
 
31
 
32
  @dataclass
33
  class Tunnel:
34
  """Represents an underground tunnel connecting two points."""
 
35
  entrance1: Tuple[int, int]
36
  entrance2: Tuple[int, int]
37
 
38
  @property
39
  def cost(self) -> int:
40
  """Tunnel cost is Manhattan distance between entrances."""
41
- return abs(self.entrance1[0] - self.entrance2[0]) + abs(self.entrance1[1] - self.entrance2[1])
 
 
42
 
43
  def get_other_entrance(self, entrance: Tuple[int, int]) -> Tuple[int, int]:
44
  """Get the other entrance of the tunnel."""
@@ -56,20 +62,5 @@ class Tunnel:
56
  return {
57
  "entrance1": {"x": self.entrance1[0], "y": self.entrance1[1]},
58
  "entrance2": {"x": self.entrance2[0], "y": self.entrance2[1]},
59
- "cost": self.cost
60
- }
61
-
62
-
63
- @dataclass
64
- class Truck:
65
- """Represents a delivery truck."""
66
- id: int
67
- store_id: int
68
- current_position: Tuple[int, int]
69
-
70
- def to_dict(self) -> dict:
71
- return {
72
- "id": self.id,
73
- "store_id": self.store_id,
74
- "position": {"x": self.current_position[0], "y": self.current_position[1]}
75
  }
 
1
  """Entity models for stores, destinations, tunnels, and trucks."""
2
+
3
  from dataclasses import dataclass
4
  from typing import Tuple
5
 
 
7
  @dataclass
8
  class Store:
9
  """Represents a storage location / starting point for trucks."""
10
+
11
  id: int
12
  position: Tuple[int, int]
13
 
14
  def to_dict(self) -> dict:
15
  return {
16
  "id": self.id,
17
+ "position": {"x": self.position[0], "y": self.position[1]},
18
  }
19
 
20
 
21
  @dataclass
22
  class Destination:
23
  """Represents a customer destination for package delivery."""
24
+
25
  id: int
26
  position: Tuple[int, int]
27
 
28
  def to_dict(self) -> dict:
29
  return {
30
  "id": self.id,
31
+ "position": {"x": self.position[0], "y": self.position[1]},
32
  }
33
 
34
 
35
  @dataclass
36
  class Tunnel:
37
  """Represents an underground tunnel connecting two points."""
38
+
39
  entrance1: Tuple[int, int]
40
  entrance2: Tuple[int, int]
41
 
42
  @property
43
  def cost(self) -> int:
44
  """Tunnel cost is Manhattan distance between entrances."""
45
+ return abs(self.entrance1[0] - self.entrance2[0]) + abs(
46
+ self.entrance1[1] - self.entrance2[1]
47
+ )
48
 
49
  def get_other_entrance(self, entrance: Tuple[int, int]) -> Tuple[int, int]:
50
  """Get the other entrance of the tunnel."""
 
62
  return {
63
  "entrance1": {"x": self.entrance1[0], "y": self.entrance1[1]},
64
  "entrance2": {"x": self.entrance2[0], "y": self.entrance2[1]},
65
+ "cost": self.cost,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  }
backend/app/models/grid.py CHANGED
@@ -1,4 +1,5 @@
1
  """Grid and Segment models for the delivery search problem."""
 
2
  from dataclasses import dataclass, field
3
  from typing import Dict, Tuple, Optional
4
 
@@ -6,6 +7,7 @@ from typing import Dict, Tuple, Optional
6
  @dataclass
7
  class Segment:
8
  """Represents a road segment between two adjacent grid points."""
 
9
  src: Tuple[int, int]
10
  dst: Tuple[int, int]
11
  traffic: int # 0 = blocked, 1-4 = traffic level
@@ -27,11 +29,16 @@ class Segment:
27
  @dataclass
28
  class Grid:
29
  """Represents the city grid with all road segments."""
 
30
  width: int
31
  height: int
32
- segments: Dict[Tuple[Tuple[int, int], Tuple[int, int]], Segment] = field(default_factory=dict)
 
 
33
 
34
- def get_segment(self, src: Tuple[int, int], dst: Tuple[int, int]) -> Optional[Segment]:
 
 
35
  """Get segment between two points (order doesn't matter)."""
36
  key = (src, dst) if src < dst else (dst, src)
37
  return self.segments.get(key)
@@ -77,8 +84,8 @@ class Grid:
77
  {
78
  "src": {"x": seg.src[0], "y": seg.src[1]},
79
  "dst": {"x": seg.dst[0], "y": seg.dst[1]},
80
- "traffic": seg.traffic
81
  }
82
  for seg in self.segments.values()
83
- ]
84
  }
 
1
  """Grid and Segment models for the delivery search problem."""
2
+
3
  from dataclasses import dataclass, field
4
  from typing import Dict, Tuple, Optional
5
 
 
7
  @dataclass
8
  class Segment:
9
  """Represents a road segment between two adjacent grid points."""
10
+
11
  src: Tuple[int, int]
12
  dst: Tuple[int, int]
13
  traffic: int # 0 = blocked, 1-4 = traffic level
 
29
  @dataclass
30
  class Grid:
31
  """Represents the city grid with all road segments."""
32
+
33
  width: int
34
  height: int
35
+ segments: Dict[Tuple[Tuple[int, int], Tuple[int, int]], Segment] = field(
36
+ default_factory=dict
37
+ )
38
 
39
+ def get_segment(
40
+ self, src: Tuple[int, int], dst: Tuple[int, int]
41
+ ) -> Optional[Segment]:
42
  """Get segment between two points (order doesn't matter)."""
43
  key = (src, dst) if src < dst else (dst, src)
44
  return self.segments.get(key)
 
84
  {
85
  "src": {"x": seg.src[0], "y": seg.src[1]},
86
  "dst": {"x": seg.dst[0], "y": seg.dst[1]},
87
+ "traffic": seg.traffic,
88
  }
89
  for seg in self.segments.values()
90
+ ],
91
  }
backend/app/models/requests.py CHANGED
@@ -1,4 +1,5 @@
1
  """Pydantic models for API requests and responses."""
 
2
  from pydantic import BaseModel, Field
3
  from typing import Optional, List, Tuple
4
  from enum import Enum
@@ -6,10 +7,11 @@ from enum import Enum
6
 
7
  class Algorithm(str, Enum):
8
  """Available search algorithms."""
9
- BF = "BF" # Breadth-first search
10
- DF = "DF" # Depth-first search
11
- ID = "ID" # Iterative deepening
12
- UC = "UC" # Uniform cost search
 
13
  GR1 = "GR1" # Greedy with Manhattan heuristic
14
  GR2 = "GR2" # Greedy with Euclidean heuristic
15
  AS1 = "AS1" # A* with Manhattan heuristic
@@ -18,6 +20,7 @@ class Algorithm(str, Enum):
18
 
19
  class Position(BaseModel):
20
  """A position on the grid."""
 
21
  x: int
22
  y: int
23
 
@@ -27,6 +30,7 @@ class Position(BaseModel):
27
 
28
  class SegmentData(BaseModel):
29
  """Segment data for API."""
 
30
  src: Position
31
  dst: Position
32
  traffic: int = Field(ge=0, le=4)
@@ -34,18 +38,21 @@ class SegmentData(BaseModel):
34
 
35
  class StoreData(BaseModel):
36
  """Store data for API."""
 
37
  id: int
38
  position: Position
39
 
40
 
41
  class DestinationData(BaseModel):
42
  """Destination data for API."""
 
43
  id: int
44
  position: Position
45
 
46
 
47
  class TunnelData(BaseModel):
48
  """Tunnel data for API."""
 
49
  entrance1: Position
50
  entrance2: Position
51
  cost: Optional[int] = None
@@ -53,8 +60,10 @@ class TunnelData(BaseModel):
53
 
54
  # Request Models
55
 
 
56
  class GridConfig(BaseModel):
57
  """Configuration for grid generation."""
 
58
  width: Optional[int] = Field(None, ge=5, le=50)
59
  height: Optional[int] = Field(None, ge=5, le=50)
60
  num_stores: Optional[int] = Field(None, ge=1, le=3)
@@ -65,6 +74,7 @@ class GridConfig(BaseModel):
65
 
66
  class SearchRequest(BaseModel):
67
  """Request for running a search/plan."""
 
68
  initial_state: str
69
  traffic: str
70
  strategy: Algorithm
@@ -73,6 +83,7 @@ class SearchRequest(BaseModel):
73
 
74
  class PathRequest(BaseModel):
75
  """Request for finding a single path."""
 
76
  grid_width: int
77
  grid_height: int
78
  start: Position
@@ -84,14 +95,17 @@ class PathRequest(BaseModel):
84
 
85
  class CompareRequest(BaseModel):
86
  """Request for comparing all algorithms."""
 
87
  initial_state: str
88
  traffic: str
89
 
90
 
91
  # Response Models
92
 
 
93
  class PathData(BaseModel):
94
  """Path result data."""
 
95
  plan: str
96
  cost: float
97
  nodes_expanded: int
@@ -100,6 +114,7 @@ class PathData(BaseModel):
100
 
101
  class GridData(BaseModel):
102
  """Complete grid state data."""
 
103
  width: int
104
  height: int
105
  stores: List[StoreData]
@@ -110,6 +125,7 @@ class GridData(BaseModel):
110
 
111
  class GenerateResponse(BaseModel):
112
  """Response from grid generation."""
 
113
  initial_state: str
114
  traffic: str
115
  parsed: GridData
@@ -117,11 +133,12 @@ class GenerateResponse(BaseModel):
117
 
118
  class SearchResponse(BaseModel):
119
  """Response from search/plan execution."""
 
120
  plan: str
121
  cost: float
122
  nodes_expanded: int
123
  runtime_ms: float
124
- memory_mb: float
125
  cpu_percent: float
126
  path: List[Position]
127
  steps: Optional[List[dict]] = None
@@ -129,36 +146,40 @@ class SearchResponse(BaseModel):
129
 
130
  class PlanResponse(BaseModel):
131
  """Response from delivery planning."""
 
132
  output: str
133
  assignments: List[dict]
134
  total_cost: float
135
  total_nodes_expanded: int
136
  runtime_ms: float
137
- memory_mb: float
138
  cpu_percent: float
139
 
140
 
141
  class ComparisonResult(BaseModel):
142
  """Result of comparing a single algorithm."""
 
143
  algorithm: str
144
  name: str
145
  plan: str
146
  cost: float
147
  nodes_expanded: int
148
  runtime_ms: float
149
- memory_mb: float
150
  cpu_percent: float
151
  is_optimal: bool = False
152
 
153
 
154
  class CompareResponse(BaseModel):
155
  """Response from algorithm comparison."""
 
156
  comparisons: List[ComparisonResult]
157
  optimal_cost: float
158
 
159
 
160
  class AlgorithmInfo(BaseModel):
161
  """Information about an algorithm."""
 
162
  code: str
163
  name: str
164
  description: str
@@ -166,4 +187,5 @@ class AlgorithmInfo(BaseModel):
166
 
167
  class AlgorithmsResponse(BaseModel):
168
  """List of available algorithms."""
 
169
  algorithms: List[AlgorithmInfo]
 
1
  """Pydantic models for API requests and responses."""
2
+
3
  from pydantic import BaseModel, Field
4
  from typing import Optional, List, Tuple
5
  from enum import Enum
 
7
 
8
  class Algorithm(str, Enum):
9
  """Available search algorithms."""
10
+
11
+ BF = "BF" # Breadth-first search
12
+ DF = "DF" # Depth-first search
13
+ ID = "ID" # Iterative deepening
14
+ UC = "UC" # Uniform cost search
15
  GR1 = "GR1" # Greedy with Manhattan heuristic
16
  GR2 = "GR2" # Greedy with Euclidean heuristic
17
  AS1 = "AS1" # A* with Manhattan heuristic
 
20
 
21
  class Position(BaseModel):
22
  """A position on the grid."""
23
+
24
  x: int
25
  y: int
26
 
 
30
 
31
  class SegmentData(BaseModel):
32
  """Segment data for API."""
33
+
34
  src: Position
35
  dst: Position
36
  traffic: int = Field(ge=0, le=4)
 
38
 
39
  class StoreData(BaseModel):
40
  """Store data for API."""
41
+
42
  id: int
43
  position: Position
44
 
45
 
46
  class DestinationData(BaseModel):
47
  """Destination data for API."""
48
+
49
  id: int
50
  position: Position
51
 
52
 
53
  class TunnelData(BaseModel):
54
  """Tunnel data for API."""
55
+
56
  entrance1: Position
57
  entrance2: Position
58
  cost: Optional[int] = None
 
60
 
61
  # Request Models
62
 
63
+
64
  class GridConfig(BaseModel):
65
  """Configuration for grid generation."""
66
+
67
  width: Optional[int] = Field(None, ge=5, le=50)
68
  height: Optional[int] = Field(None, ge=5, le=50)
69
  num_stores: Optional[int] = Field(None, ge=1, le=3)
 
74
 
75
  class SearchRequest(BaseModel):
76
  """Request for running a search/plan."""
77
+
78
  initial_state: str
79
  traffic: str
80
  strategy: Algorithm
 
83
 
84
  class PathRequest(BaseModel):
85
  """Request for finding a single path."""
86
+
87
  grid_width: int
88
  grid_height: int
89
  start: Position
 
95
 
96
  class CompareRequest(BaseModel):
97
  """Request for comparing all algorithms."""
98
+
99
  initial_state: str
100
  traffic: str
101
 
102
 
103
  # Response Models
104
 
105
+
106
  class PathData(BaseModel):
107
  """Path result data."""
108
+
109
  plan: str
110
  cost: float
111
  nodes_expanded: int
 
114
 
115
  class GridData(BaseModel):
116
  """Complete grid state data."""
117
+
118
  width: int
119
  height: int
120
  stores: List[StoreData]
 
125
 
126
  class GenerateResponse(BaseModel):
127
  """Response from grid generation."""
128
+
129
  initial_state: str
130
  traffic: str
131
  parsed: GridData
 
133
 
134
  class SearchResponse(BaseModel):
135
  """Response from search/plan execution."""
136
+
137
  plan: str
138
  cost: float
139
  nodes_expanded: int
140
  runtime_ms: float
141
+ memory_kb: float
142
  cpu_percent: float
143
  path: List[Position]
144
  steps: Optional[List[dict]] = None
 
146
 
147
  class PlanResponse(BaseModel):
148
  """Response from delivery planning."""
149
+
150
  output: str
151
  assignments: List[dict]
152
  total_cost: float
153
  total_nodes_expanded: int
154
  runtime_ms: float
155
+ memory_kb: float
156
  cpu_percent: float
157
 
158
 
159
  class ComparisonResult(BaseModel):
160
  """Result of comparing a single algorithm."""
161
+
162
  algorithm: str
163
  name: str
164
  plan: str
165
  cost: float
166
  nodes_expanded: int
167
  runtime_ms: float
168
+ memory_kb: float
169
  cpu_percent: float
170
  is_optimal: bool = False
171
 
172
 
173
  class CompareResponse(BaseModel):
174
  """Response from algorithm comparison."""
175
+
176
  comparisons: List[ComparisonResult]
177
  optimal_cost: float
178
 
179
 
180
  class AlgorithmInfo(BaseModel):
181
  """Information about an algorithm."""
182
+
183
  code: str
184
  name: str
185
  description: str
 
187
 
188
  class AlgorithmsResponse(BaseModel):
189
  """List of available algorithms."""
190
+
191
  algorithms: List[AlgorithmInfo]
backend/app/models/state.py CHANGED
@@ -1,4 +1,5 @@
1
  """State models for search and planning results."""
 
2
  from dataclasses import dataclass, field
3
  from typing import List, Optional, Tuple
4
  from .grid import Grid
@@ -8,6 +9,7 @@ from .entities import Store, Destination, Tunnel
8
  @dataclass
9
  class SearchState:
10
  """Represents the complete state for a delivery search problem."""
 
11
  grid: Grid
12
  stores: List[Store]
13
  destinations: List[Destination]
@@ -25,17 +27,20 @@ class SearchState:
25
  "grid": self.grid.to_dict(),
26
  "stores": [s.to_dict() for s in self.stores],
27
  "destinations": [d.to_dict() for d in self.destinations],
28
- "tunnels": [t.to_dict() for t in self.tunnels]
29
  }
30
 
31
 
32
  @dataclass
33
  class PathResult:
34
  """Result of finding a path from start to goal."""
 
35
  plan: str # Comma-separated actions: "up,down,left,right,tunnel"
36
  cost: float # Total traffic cost
37
  nodes_expanded: int # Number of nodes expanded during search
38
- path: List[Tuple[int, int]] = field(default_factory=list) # Actual positions in path
 
 
39
 
40
  def to_string(self) -> str:
41
  """Format as required: plan;cost;nodesExpanded"""
@@ -46,13 +51,14 @@ class PathResult:
46
  "plan": self.plan,
47
  "cost": self.cost,
48
  "nodes_expanded": self.nodes_expanded,
49
- "path": [{"x": p[0], "y": p[1]} for p in self.path]
50
  }
51
 
52
 
53
  @dataclass
54
  class DeliveryAssignment:
55
  """Assignment of a destination to a store/truck."""
 
56
  store_id: int
57
  destination_id: int
58
  path_result: PathResult
@@ -61,13 +67,14 @@ class DeliveryAssignment:
61
  return {
62
  "store_id": self.store_id,
63
  "destination_id": self.destination_id,
64
- "path": self.path_result.to_dict()
65
  }
66
 
67
 
68
  @dataclass
69
  class PlanResult:
70
  """Result of the complete delivery planning."""
 
71
  assignments: List[DeliveryAssignment]
72
  total_cost: float
73
  total_nodes_expanded: int
@@ -85,13 +92,14 @@ class PlanResult:
85
  return {
86
  "assignments": [a.to_dict() for a in self.assignments],
87
  "total_cost": self.total_cost,
88
- "total_nodes_expanded": self.total_nodes_expanded
89
  }
90
 
91
 
92
  @dataclass
93
  class SearchStep:
94
  """Represents a single step in the search process for visualization."""
 
95
  step_number: int
96
  current_node: Tuple[int, int]
97
  action: Optional[str]
@@ -108,15 +116,16 @@ class SearchStep:
108
  "frontier": [{"x": p[0], "y": p[1]} for p in self.frontier],
109
  "explored": [{"x": p[0], "y": p[1]} for p in self.explored],
110
  "currentPath": [{"x": p[0], "y": p[1]} for p in self.current_path],
111
- "pathCost": self.path_cost
112
  }
113
 
114
 
115
  @dataclass
116
  class SearchMetrics:
117
  """Performance metrics for a search execution."""
 
118
  runtime_ms: float
119
- memory_mb: float
120
  cpu_percent: float
121
  nodes_expanded: int
122
  path_cost: float
@@ -125,9 +134,9 @@ class SearchMetrics:
125
  def to_dict(self) -> dict:
126
  return {
127
  "runtime_ms": self.runtime_ms,
128
- "memory_mb": self.memory_mb,
129
  "cpu_percent": self.cpu_percent,
130
  "nodes_expanded": self.nodes_expanded,
131
  "path_cost": self.path_cost,
132
- "path_length": self.path_length
133
  }
 
1
  """State models for search and planning results."""
2
+
3
  from dataclasses import dataclass, field
4
  from typing import List, Optional, Tuple
5
  from .grid import Grid
 
9
  @dataclass
10
  class SearchState:
11
  """Represents the complete state for a delivery search problem."""
12
+
13
  grid: Grid
14
  stores: List[Store]
15
  destinations: List[Destination]
 
27
  "grid": self.grid.to_dict(),
28
  "stores": [s.to_dict() for s in self.stores],
29
  "destinations": [d.to_dict() for d in self.destinations],
30
+ "tunnels": [t.to_dict() for t in self.tunnels],
31
  }
32
 
33
 
34
  @dataclass
35
  class PathResult:
36
  """Result of finding a path from start to goal."""
37
+
38
  plan: str # Comma-separated actions: "up,down,left,right,tunnel"
39
  cost: float # Total traffic cost
40
  nodes_expanded: int # Number of nodes expanded during search
41
+ path: List[Tuple[int, int]] = field(
42
+ default_factory=list
43
+ ) # Actual positions in path
44
 
45
  def to_string(self) -> str:
46
  """Format as required: plan;cost;nodesExpanded"""
 
51
  "plan": self.plan,
52
  "cost": self.cost,
53
  "nodes_expanded": self.nodes_expanded,
54
+ "path": [{"x": p[0], "y": p[1]} for p in self.path],
55
  }
56
 
57
 
58
  @dataclass
59
  class DeliveryAssignment:
60
  """Assignment of a destination to a store/truck."""
61
+
62
  store_id: int
63
  destination_id: int
64
  path_result: PathResult
 
67
  return {
68
  "store_id": self.store_id,
69
  "destination_id": self.destination_id,
70
+ "path": self.path_result.to_dict(),
71
  }
72
 
73
 
74
  @dataclass
75
  class PlanResult:
76
  """Result of the complete delivery planning."""
77
+
78
  assignments: List[DeliveryAssignment]
79
  total_cost: float
80
  total_nodes_expanded: int
 
92
  return {
93
  "assignments": [a.to_dict() for a in self.assignments],
94
  "total_cost": self.total_cost,
95
+ "total_nodes_expanded": self.total_nodes_expanded,
96
  }
97
 
98
 
99
  @dataclass
100
  class SearchStep:
101
  """Represents a single step in the search process for visualization."""
102
+
103
  step_number: int
104
  current_node: Tuple[int, int]
105
  action: Optional[str]
 
116
  "frontier": [{"x": p[0], "y": p[1]} for p in self.frontier],
117
  "explored": [{"x": p[0], "y": p[1]} for p in self.explored],
118
  "currentPath": [{"x": p[0], "y": p[1]} for p in self.current_path],
119
+ "pathCost": self.path_cost,
120
  }
121
 
122
 
123
  @dataclass
124
  class SearchMetrics:
125
  """Performance metrics for a search execution."""
126
+
127
  runtime_ms: float
128
+ memory_kb: float
129
  cpu_percent: float
130
  nodes_expanded: int
131
  path_cost: float
 
134
  def to_dict(self) -> dict:
135
  return {
136
  "runtime_ms": self.runtime_ms,
137
+ "memory_kb": self.memory_kb,
138
  "cpu_percent": self.cpu_percent,
139
  "nodes_expanded": self.nodes_expanded,
140
  "path_cost": self.path_cost,
141
+ "path_length": self.path_length,
142
  }
backend/app/services/__init__.py CHANGED
@@ -1,4 +1,5 @@
1
  """Services package."""
 
2
  from .parser import (
3
  parse_initial_state,
4
  parse_traffic,
 
1
  """Services package."""
2
+
3
  from .parser import (
4
  parse_initial_state,
5
  parse_traffic,
backend/app/services/grid_generator.py CHANGED
@@ -1,4 +1,5 @@
1
  """Grid generator service for random grid creation."""
 
2
  import random
3
  from typing import Tuple, List, Set, Optional
4
  from ..models.grid import Grid
@@ -14,7 +15,7 @@ def gen_grid(
14
  num_destinations: Optional[int] = None,
15
  num_tunnels: Optional[int] = None,
16
  obstacle_density: float = 0.1,
17
- seed: Optional[int] = None
18
  ) -> Tuple[str, str, SearchState]:
19
  """
20
  Randomly generate a valid grid configuration.
@@ -38,7 +39,9 @@ def gen_grid(
38
  width = width or random.randint(5, 15)
39
  height = height or random.randint(5, 15)
40
  num_stores = num_stores or random.randint(1, 3)
41
- num_destinations = num_destinations or random.randint(1, min(10, width * height // 4))
 
 
42
  num_tunnels = num_tunnels or random.randint(0, min(5, width * height // 10))
43
 
44
  # Validate constraints
@@ -61,7 +64,9 @@ def gen_grid(
61
  grid = _generate_traffic(width, height, obstacle_density, stores, destinations)
62
 
63
  # Create search state
64
- state = SearchState(grid=grid, stores=stores, destinations=destinations, tunnels=tunnels)
 
 
65
 
66
  # Format strings
67
  initial_state = format_initial_state(width, height, stores, destinations, tunnels)
@@ -71,10 +76,7 @@ def gen_grid(
71
 
72
 
73
  def _generate_stores(
74
- width: int,
75
- height: int,
76
- num_stores: int,
77
- occupied: Set[Tuple[int, int]]
78
  ) -> List[Store]:
79
  """Generate store positions at corners/edges."""
80
  stores = []
@@ -112,10 +114,7 @@ def _generate_stores(
112
 
113
 
114
  def _generate_destinations(
115
- width: int,
116
- height: int,
117
- num_destinations: int,
118
- occupied: Set[Tuple[int, int]]
119
  ) -> List[Destination]:
120
  """Generate random destination positions."""
121
  destinations = []
@@ -137,10 +136,7 @@ def _generate_destinations(
137
 
138
 
139
  def _generate_tunnels(
140
- width: int,
141
- height: int,
142
- num_tunnels: int,
143
- occupied: Set[Tuple[int, int]]
144
  ) -> List[Tunnel]:
145
  """Generate random tunnel pairs."""
146
  tunnels = []
@@ -154,18 +150,35 @@ def _generate_tunnels(
154
 
155
  random.shuffle(available)
156
 
157
- # Need at least 2 positions per tunnel
158
- for i in range(min(num_tunnels, len(available) // 2)):
159
- entrance1 = available[i * 2]
160
- entrance2 = available[i * 2 + 1]
 
 
 
 
161
 
162
- # Ensure tunnels are useful (span reasonable distance)
 
 
 
 
 
 
 
163
  dist = abs(entrance1[0] - entrance2[0]) + abs(entrance1[1] - entrance2[1])
164
  if dist >= 3: # Only create if Manhattan distance >= 3
165
  tunnels.append(Tunnel(entrance1=entrance1, entrance2=entrance2))
166
  occupied.add(entrance1)
167
  occupied.add(entrance2)
168
 
 
 
 
 
 
 
169
  return tunnels
170
 
171
 
@@ -174,7 +187,7 @@ def _generate_traffic(
174
  height: int,
175
  obstacle_density: float,
176
  stores: List[Store],
177
- destinations: List[Destination]
178
  ) -> Grid:
179
  """
180
  Generate traffic levels for all segments.
@@ -212,9 +225,7 @@ def _generate_traffic(
212
 
213
 
214
  def _ensure_connectivity(
215
- grid: Grid,
216
- stores: List[Store],
217
- destinations: List[Destination]
218
  ) -> None:
219
  """
220
  Ensure the grid is connected between stores and destinations.
@@ -222,7 +233,9 @@ def _ensure_connectivity(
222
  Uses BFS to check connectivity and unblocks segments if needed.
223
  """
224
  # Get all important positions
225
- important_positions = [s.position for s in stores] + [d.position for d in destinations]
 
 
226
 
227
  if len(important_positions) < 2:
228
  return
@@ -253,7 +266,7 @@ def _create_path_to(
253
  grid: Grid,
254
  start: Tuple[int, int],
255
  goal: Tuple[int, int],
256
- visited: Set[Tuple[int, int]]
257
  ) -> None:
258
  """Create a path from visited area to goal by unblocking segments."""
259
  # Simple approach: find closest visited cell to goal and unblock path
 
1
  """Grid generator service for random grid creation."""
2
+
3
  import random
4
  from typing import Tuple, List, Set, Optional
5
  from ..models.grid import Grid
 
15
  num_destinations: Optional[int] = None,
16
  num_tunnels: Optional[int] = None,
17
  obstacle_density: float = 0.1,
18
+ seed: Optional[int] = None,
19
  ) -> Tuple[str, str, SearchState]:
20
  """
21
  Randomly generate a valid grid configuration.
 
39
  width = width or random.randint(5, 15)
40
  height = height or random.randint(5, 15)
41
  num_stores = num_stores or random.randint(1, 3)
42
+ num_destinations = num_destinations or random.randint(
43
+ 1, min(10, width * height // 4)
44
+ )
45
  num_tunnels = num_tunnels or random.randint(0, min(5, width * height // 10))
46
 
47
  # Validate constraints
 
64
  grid = _generate_traffic(width, height, obstacle_density, stores, destinations)
65
 
66
  # Create search state
67
+ state = SearchState(
68
+ grid=grid, stores=stores, destinations=destinations, tunnels=tunnels
69
+ )
70
 
71
  # Format strings
72
  initial_state = format_initial_state(width, height, stores, destinations, tunnels)
 
76
 
77
 
78
  def _generate_stores(
79
+ width: int, height: int, num_stores: int, occupied: Set[Tuple[int, int]]
 
 
 
80
  ) -> List[Store]:
81
  """Generate store positions at corners/edges."""
82
  stores = []
 
114
 
115
 
116
  def _generate_destinations(
117
+ width: int, height: int, num_destinations: int, occupied: Set[Tuple[int, int]]
 
 
 
118
  ) -> List[Destination]:
119
  """Generate random destination positions."""
120
  destinations = []
 
136
 
137
 
138
  def _generate_tunnels(
139
+ width: int, height: int, num_tunnels: int, occupied: Set[Tuple[int, int]]
 
 
 
140
  ) -> List[Tunnel]:
141
  """Generate random tunnel pairs."""
142
  tunnels = []
 
150
 
151
  random.shuffle(available)
152
 
153
+ i = 0
154
+ attempts = 0
155
+ max_attempts = len(available) * 2
156
+
157
+ while i < num_tunnels and len(available) >= 2 and attempts < max_attempts:
158
+ attempts += 1
159
+ idx1 = random.randint(0, len(available) - 1)
160
+ entrance1 = available[idx1]
161
 
162
+ remaining = [pos for j, pos in enumerate(available) if j != idx1]
163
+ if not remaining:
164
+ break
165
+
166
+ idx2 = random.randint(0, len(remaining) - 1)
167
+ entrance2 = remaining[idx2]
168
+
169
+ # Ensure tunnels are useful
170
  dist = abs(entrance1[0] - entrance2[0]) + abs(entrance1[1] - entrance2[1])
171
  if dist >= 3: # Only create if Manhattan distance >= 3
172
  tunnels.append(Tunnel(entrance1=entrance1, entrance2=entrance2))
173
  occupied.add(entrance1)
174
  occupied.add(entrance2)
175
 
176
+ # Remove used positions from available
177
+ available.remove(entrance1)
178
+ available.remove(entrance2)
179
+
180
+ i += 1
181
+
182
  return tunnels
183
 
184
 
 
187
  height: int,
188
  obstacle_density: float,
189
  stores: List[Store],
190
+ destinations: List[Destination],
191
  ) -> Grid:
192
  """
193
  Generate traffic levels for all segments.
 
225
 
226
 
227
  def _ensure_connectivity(
228
+ grid: Grid, stores: List[Store], destinations: List[Destination]
 
 
229
  ) -> None:
230
  """
231
  Ensure the grid is connected between stores and destinations.
 
233
  Uses BFS to check connectivity and unblocks segments if needed.
234
  """
235
  # Get all important positions
236
+ important_positions = [s.position for s in stores] + [
237
+ d.position for d in destinations
238
+ ]
239
 
240
  if len(important_positions) < 2:
241
  return
 
266
  grid: Grid,
267
  start: Tuple[int, int],
268
  goal: Tuple[int, int],
269
+ visited: Set[Tuple[int, int]],
270
  ) -> None:
271
  """Create a path from visited area to goal by unblocking segments."""
272
  # Simple approach: find closest visited cell to goal and unblock path
backend/app/services/metrics.py CHANGED
@@ -1,4 +1,5 @@
1
  """Performance metrics collection service."""
 
2
  import time
3
  import psutil
4
  from contextlib import contextmanager
@@ -17,6 +18,7 @@ class MetricsCollector:
17
  self.start_memory: int = 0
18
  self.end_memory: int = 0
19
  self.peak_memory: int = 0
 
20
  self.cpu_samples: list = []
21
  self._process = psutil.Process()
22
 
@@ -25,6 +27,7 @@ class MetricsCollector:
25
  self.start_time = time.perf_counter()
26
  self.start_memory = self._process.memory_info().rss
27
  self.peak_memory = self.start_memory
 
28
  self.cpu_samples = []
29
  # Initial CPU sample
30
  self._process.cpu_percent()
@@ -32,6 +35,7 @@ class MetricsCollector:
32
  def sample(self) -> None:
33
  """Take a sample of current metrics."""
34
  current_memory = self._process.memory_info().rss
 
35
  self.peak_memory = max(self.peak_memory, current_memory)
36
  self.cpu_samples.append(self._process.cpu_percent())
37
 
@@ -39,6 +43,8 @@ class MetricsCollector:
39
  """Stop collecting metrics."""
40
  self.end_time = time.perf_counter()
41
  self.end_memory = self._process.memory_info().rss
 
 
42
  # Final CPU sample
43
  self.cpu_samples.append(self._process.cpu_percent())
44
 
@@ -48,9 +54,13 @@ class MetricsCollector:
48
  return (self.end_time - self.start_time) * 1000
49
 
50
  @property
51
- def memory_mb(self) -> float:
52
- """Get peak memory usage in MB."""
53
- return (self.peak_memory - self.start_memory) / (1024 * 1024)
 
 
 
 
54
 
55
  @property
56
  def cpu_percent(self) -> float:
@@ -59,15 +69,17 @@ class MetricsCollector:
59
  return 0.0
60
  return sum(self.cpu_samples) / len(self.cpu_samples)
61
 
62
- def to_metrics(self, nodes_expanded: int, path_cost: float, path_length: int) -> SearchMetrics:
 
 
63
  """Convert to SearchMetrics object."""
64
  return SearchMetrics(
65
  runtime_ms=self.runtime_ms,
66
- memory_mb=max(0, self.memory_mb), # Ensure non-negative
67
  cpu_percent=self.cpu_percent,
68
  nodes_expanded=nodes_expanded,
69
  path_cost=path_cost,
70
- path_length=path_length
71
  )
72
 
73
 
@@ -90,9 +102,7 @@ def measure_performance() -> Generator[MetricsCollector, None, None]:
90
 
91
 
92
  def run_with_metrics(
93
- func: Callable[..., Any],
94
- *args,
95
- **kwargs
96
  ) -> Tuple[Any, MetricsCollector]:
97
  """
98
  Run a function and collect performance metrics.
 
1
  """Performance metrics collection service."""
2
+
3
  import time
4
  import psutil
5
  from contextlib import contextmanager
 
18
  self.start_memory: int = 0
19
  self.end_memory: int = 0
20
  self.peak_memory: int = 0
21
+ self.memory_samples: list = []
22
  self.cpu_samples: list = []
23
  self._process = psutil.Process()
24
 
 
27
  self.start_time = time.perf_counter()
28
  self.start_memory = self._process.memory_info().rss
29
  self.peak_memory = self.start_memory
30
+ self.memory_samples = [self.start_memory]
31
  self.cpu_samples = []
32
  # Initial CPU sample
33
  self._process.cpu_percent()
 
35
  def sample(self) -> None:
36
  """Take a sample of current metrics."""
37
  current_memory = self._process.memory_info().rss
38
+ self.memory_samples.append(current_memory)
39
  self.peak_memory = max(self.peak_memory, current_memory)
40
  self.cpu_samples.append(self._process.cpu_percent())
41
 
 
43
  """Stop collecting metrics."""
44
  self.end_time = time.perf_counter()
45
  self.end_memory = self._process.memory_info().rss
46
+ self.memory_samples.append(self.end_memory)
47
+ self.peak_memory = max(self.peak_memory, self.end_memory)
48
  # Final CPU sample
49
  self.cpu_samples.append(self._process.cpu_percent())
50
 
 
54
  return (self.end_time - self.start_time) * 1000
55
 
56
  @property
57
+ def memory_kb(self) -> float:
58
+ """Get memory usage in KB (peak minus baseline)."""
59
+ if len(self.memory_samples) > 1:
60
+ # Use max sample minus start for more accurate peak measurement
61
+ max_sample = max(self.memory_samples)
62
+ return (max_sample - self.start_memory) / 1024
63
+ return (self.peak_memory - self.start_memory) / 1024
64
 
65
  @property
66
  def cpu_percent(self) -> float:
 
69
  return 0.0
70
  return sum(self.cpu_samples) / len(self.cpu_samples)
71
 
72
+ def to_metrics(
73
+ self, nodes_expanded: int, path_cost: float, path_length: int
74
+ ) -> SearchMetrics:
75
  """Convert to SearchMetrics object."""
76
  return SearchMetrics(
77
  runtime_ms=self.runtime_ms,
78
+ memory_kb=max(0, self.memory_kb), # Ensure non-negative
79
  cpu_percent=self.cpu_percent,
80
  nodes_expanded=nodes_expanded,
81
  path_cost=path_cost,
82
+ path_length=path_length,
83
  )
84
 
85
 
 
102
 
103
 
104
  def run_with_metrics(
105
+ func: Callable[..., Any], *args, **kwargs
 
 
106
  ) -> Tuple[Any, MetricsCollector]:
107
  """
108
  Run a function and collect performance metrics.
backend/app/services/parser.py CHANGED
@@ -1,17 +1,21 @@
1
  """Parser service for initial state and traffic strings."""
 
2
  from typing import Tuple, List
3
  from ..models.grid import Grid
4
  from ..models.entities import Store, Destination, Tunnel
5
  from ..models.state import SearchState
6
 
7
 
8
- def parse_initial_state(initial_state: str) -> Tuple[int, int, List[Store], List[Destination], List[Tunnel]]:
 
 
9
  """
10
  Parse the initial state string.
11
 
12
  Format:
13
  m;n;P;S;CustomerX_1,CustomerY_1,CustomerX_2,CustomerY_2,...;
14
- TunnelX_1,TunnelY_1,TunnelX_1',TunnelY_1',TunnelX_2,TunnelY_2,TunnelX_2',TunnelY_2',...
 
15
 
16
  Args:
17
  initial_state: The initial state string
@@ -19,7 +23,7 @@ def parse_initial_state(initial_state: str) -> Tuple[int, int, List[Store], List
19
  Returns:
20
  Tuple of (width, height, stores, destinations, tunnels)
21
  """
22
- parts = initial_state.strip().split(';')
23
 
24
  # Grid dimensions
25
  width = int(parts[0]) # m
@@ -32,7 +36,7 @@ def parse_initial_state(initial_state: str) -> Tuple[int, int, List[Store], List
32
  # Parse customer locations
33
  destinations: List[Destination] = []
34
  if len(parts) > 4 and parts[4]:
35
- customer_coords = parts[4].split(',')
36
  for i in range(0, len(customer_coords), 2):
37
  if i + 1 < len(customer_coords):
38
  x = int(customer_coords[i])
@@ -43,7 +47,7 @@ def parse_initial_state(initial_state: str) -> Tuple[int, int, List[Store], List
43
  # Parse tunnel locations
44
  tunnels: List[Tunnel] = []
45
  if len(parts) > 5 and parts[5]:
46
- tunnel_coords = parts[5].split(',')
47
  for i in range(0, len(tunnel_coords), 4):
48
  if i + 3 < len(tunnel_coords):
49
  x1 = int(tunnel_coords[i])
@@ -52,64 +56,20 @@ def parse_initial_state(initial_state: str) -> Tuple[int, int, List[Store], List
52
  y2 = int(tunnel_coords[i + 3])
53
  tunnels.append(Tunnel(entrance1=(x1, y1), entrance2=(x2, y2)))
54
 
55
- # Generate stores (positions need to be provided or generated)
56
- # For now, place stores at corners/edges
57
  stores: List[Store] = []
58
- store_positions = _generate_store_positions(width, height, num_stores, destinations, tunnels)
59
- for i, pos in enumerate(store_positions):
60
- stores.append(Store(id=i + 1, position=pos))
 
 
 
 
 
61
 
62
  return width, height, stores, destinations, tunnels
63
 
64
 
65
- def _generate_store_positions(
66
- width: int,
67
- height: int,
68
- num_stores: int,
69
- destinations: List[Destination],
70
- tunnels: List[Tunnel]
71
- ) -> List[Tuple[int, int]]:
72
- """
73
- Generate store positions avoiding conflicts.
74
-
75
- Places stores at corners and edges of the grid.
76
- """
77
- occupied = set()
78
- for dest in destinations:
79
- occupied.add(dest.position)
80
- for tunnel in tunnels:
81
- occupied.add(tunnel.entrance1)
82
- occupied.add(tunnel.entrance2)
83
-
84
- # Preferred positions (corners first, then edges)
85
- preferred = [
86
- (0, 0),
87
- (width - 1, 0),
88
- (0, height - 1),
89
- (width - 1, height - 1),
90
- (width // 2, 0),
91
- (0, height // 2),
92
- (width - 1, height // 2),
93
- (width // 2, height - 1),
94
- ]
95
-
96
- positions = []
97
- for pos in preferred:
98
- if pos not in occupied and len(positions) < num_stores:
99
- positions.append(pos)
100
- occupied.add(pos)
101
-
102
- # If still need more positions, find any valid position
103
- if len(positions) < num_stores:
104
- for x in range(width):
105
- for y in range(height):
106
- if (x, y) not in occupied and len(positions) < num_stores:
107
- positions.append((x, y))
108
- occupied.add((x, y))
109
-
110
- return positions
111
-
112
-
113
  def parse_traffic(traffic_str: str, width: int, height: int) -> Grid:
114
  """
115
  Parse the traffic string and create a Grid.
@@ -132,11 +92,11 @@ def parse_traffic(traffic_str: str, width: int, height: int) -> Grid:
132
  _initialize_default_traffic(grid)
133
  return grid
134
 
135
- segments = traffic_str.strip().split(';')
136
  for segment in segments:
137
  if not segment:
138
  continue
139
- parts = segment.split(',')
140
  if len(parts) >= 5:
141
  src_x = int(parts[0])
142
  src_y = int(parts[1])
@@ -179,10 +139,7 @@ def parse_full_state(initial_state: str, traffic_str: str) -> SearchState:
179
  grid = parse_traffic(traffic_str, width, height)
180
 
181
  return SearchState(
182
- grid=grid,
183
- stores=stores,
184
- destinations=destinations,
185
- tunnels=tunnels
186
  )
187
 
188
 
@@ -191,7 +148,7 @@ def format_initial_state(
191
  height: int,
192
  stores: List[Store],
193
  destinations: List[Destination],
194
- tunnels: List[Tunnel]
195
  ) -> str:
196
  """
197
  Format state back into initial state string.
@@ -217,18 +174,28 @@ def format_initial_state(
217
  customer_coords = []
218
  for dest in destinations:
219
  customer_coords.extend([str(dest.position[0]), str(dest.position[1])])
220
- parts.append(','.join(customer_coords))
221
 
222
  # Tunnel coordinates
223
  tunnel_coords = []
224
  for tunnel in tunnels:
225
- tunnel_coords.extend([
226
- str(tunnel.entrance1[0]), str(tunnel.entrance1[1]),
227
- str(tunnel.entrance2[0]), str(tunnel.entrance2[1])
228
- ])
229
- parts.append(','.join(tunnel_coords))
 
 
 
 
230
 
231
- return ';'.join(parts)
 
 
 
 
 
 
232
 
233
 
234
  def format_traffic(grid: Grid) -> str:
@@ -243,7 +210,5 @@ def format_traffic(grid: Grid) -> str:
243
  """
244
  segments = []
245
  for (src, dst), segment in grid.segments.items():
246
- segments.append(
247
- f"{src[0]},{src[1]},{dst[0]},{dst[1]},{segment.traffic}"
248
- )
249
- return ';'.join(segments)
 
1
  """Parser service for initial state and traffic strings."""
2
+
3
  from typing import Tuple, List
4
  from ..models.grid import Grid
5
  from ..models.entities import Store, Destination, Tunnel
6
  from ..models.state import SearchState
7
 
8
 
9
+ def parse_initial_state(
10
+ initial_state: str,
11
+ ) -> Tuple[int, int, List[Store], List[Destination], List[Tunnel]]:
12
  """
13
  Parse the initial state string.
14
 
15
  Format:
16
  m;n;P;S;CustomerX_1,CustomerY_1,CustomerX_2,CustomerY_2,...;
17
+ TunnelX_1,TunnelY_1,TunnelX_1',TunnelY_1',TunnelX_2,TunnelY_2,TunnelX_2',TunnelY_2',...;
18
+ StoreX_1,StoreY_1,StoreX_2,StoreY_2,...
19
 
20
  Args:
21
  initial_state: The initial state string
 
23
  Returns:
24
  Tuple of (width, height, stores, destinations, tunnels)
25
  """
26
+ parts = initial_state.strip().split(";")
27
 
28
  # Grid dimensions
29
  width = int(parts[0]) # m
 
36
  # Parse customer locations
37
  destinations: List[Destination] = []
38
  if len(parts) > 4 and parts[4]:
39
+ customer_coords = parts[4].split(",")
40
  for i in range(0, len(customer_coords), 2):
41
  if i + 1 < len(customer_coords):
42
  x = int(customer_coords[i])
 
47
  # Parse tunnel locations
48
  tunnels: List[Tunnel] = []
49
  if len(parts) > 5 and parts[5]:
50
+ tunnel_coords = parts[5].split(",")
51
  for i in range(0, len(tunnel_coords), 4):
52
  if i + 3 < len(tunnel_coords):
53
  x1 = int(tunnel_coords[i])
 
56
  y2 = int(tunnel_coords[i + 3])
57
  tunnels.append(Tunnel(entrance1=(x1, y1), entrance2=(x2, y2)))
58
 
59
+ # Parse store locations
 
60
  stores: List[Store] = []
61
+ if len(parts) > 6 and parts[6]:
62
+ store_coords = parts[6].split(",")
63
+ for i in range(0, len(store_coords), 2):
64
+ if i + 1 < len(store_coords):
65
+ x = int(store_coords[i])
66
+ y = int(store_coords[i + 1])
67
+ store_id = len(stores) + 1
68
+ stores.append(Store(id=store_id, position=(x, y)))
69
 
70
  return width, height, stores, destinations, tunnels
71
 
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  def parse_traffic(traffic_str: str, width: int, height: int) -> Grid:
74
  """
75
  Parse the traffic string and create a Grid.
 
92
  _initialize_default_traffic(grid)
93
  return grid
94
 
95
+ segments = traffic_str.strip().split(";")
96
  for segment in segments:
97
  if not segment:
98
  continue
99
+ parts = segment.split(",")
100
  if len(parts) >= 5:
101
  src_x = int(parts[0])
102
  src_y = int(parts[1])
 
139
  grid = parse_traffic(traffic_str, width, height)
140
 
141
  return SearchState(
142
+ grid=grid, stores=stores, destinations=destinations, tunnels=tunnels
 
 
 
143
  )
144
 
145
 
 
148
  height: int,
149
  stores: List[Store],
150
  destinations: List[Destination],
151
+ tunnels: List[Tunnel],
152
  ) -> str:
153
  """
154
  Format state back into initial state string.
 
174
  customer_coords = []
175
  for dest in destinations:
176
  customer_coords.extend([str(dest.position[0]), str(dest.position[1])])
177
+ parts.append(",".join(customer_coords))
178
 
179
  # Tunnel coordinates
180
  tunnel_coords = []
181
  for tunnel in tunnels:
182
+ tunnel_coords.extend(
183
+ [
184
+ str(tunnel.entrance1[0]),
185
+ str(tunnel.entrance1[1]),
186
+ str(tunnel.entrance2[0]),
187
+ str(tunnel.entrance2[1]),
188
+ ]
189
+ )
190
+ parts.append(",".join(tunnel_coords))
191
 
192
+ # Store coordinates
193
+ store_coords = []
194
+ for store in stores:
195
+ store_coords.extend([str(store.position[0]), str(store.position[1])])
196
+ parts.append(",".join(store_coords))
197
+
198
+ return ";".join(parts)
199
 
200
 
201
  def format_traffic(grid: Grid) -> str:
 
210
  """
211
  segments = []
212
  for (src, dst), segment in grid.segments.items():
213
+ segments.append(f"{src[0]},{src[1]},{dst[0]},{dst[1]},{segment.traffic}")
214
+ return ";".join(segments)
 
 
backend/pyproject.toml CHANGED
@@ -5,6 +5,7 @@ description = "Add your description here"
5
  readme = "README.md"
6
  requires-python = ">=3.10"
7
  dependencies = [
 
8
  "fastapi>=0.122.0",
9
  "httpx>=0.28.1",
10
  "psutil>=7.1.3",
@@ -16,3 +17,10 @@ dependencies = [
16
  "uvicorn>=0.38.0",
17
  "websockets>=15.0.1",
18
  ]
 
 
 
 
 
 
 
 
5
  readme = "README.md"
6
  requires-python = ">=3.10"
7
  dependencies = [
8
+ "black>=25.11.0",
9
  "fastapi>=0.122.0",
10
  "httpx>=0.28.1",
11
  "psutil>=7.1.3",
 
17
  "uvicorn>=0.38.0",
18
  "websockets>=15.0.1",
19
  ]
20
+
21
+ [tool.pytest.ini_options]
22
+ testpaths = ["tests"]
23
+ python_files = ["test_*.py"]
24
+ python_classes = ["Test*"]
25
+ python_functions = ["test_*"]
26
+ addopts = "-v --tb=short"
backend/tests/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Test package for the delivery search backend."""
backend/tests/conftest.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pytest configuration and fixtures for backend tests."""
2
+
3
+ import pytest
4
+ from app.models.grid import Grid, Segment
5
+ from app.models.entities import Store, Destination, Tunnel
6
+ from app.models.state import SearchState
7
+
8
+
9
+ @pytest.fixture
10
+ def simple_grid():
11
+ """Create a simple 3x3 grid with all segments having traffic level 1."""
12
+ grid = Grid(width=3, height=3)
13
+ # Horizontal segments
14
+ for x in range(2):
15
+ for y in range(3):
16
+ grid.add_segment((x, y), (x + 1, y), 1)
17
+ # Vertical segments
18
+ for x in range(3):
19
+ for y in range(2):
20
+ grid.add_segment((x, y), (x, y + 1), 1)
21
+ return grid
22
+
23
+
24
+ @pytest.fixture
25
+ def grid_with_blocked():
26
+ """Create a 3x3 grid with some blocked segments."""
27
+ grid = Grid(width=3, height=3)
28
+ # Add all segments with traffic level 1
29
+ for x in range(2):
30
+ for y in range(3):
31
+ grid.add_segment((x, y), (x + 1, y), 1)
32
+ for x in range(3):
33
+ for y in range(2):
34
+ grid.add_segment((x, y), (x, y + 1), 1)
35
+ # Block segment from (1,1) to (2,1)
36
+ grid.add_segment((1, 1), (2, 1), 0)
37
+ return grid
38
+
39
+
40
+ @pytest.fixture
41
+ def grid_with_varied_traffic():
42
+ """Create a 3x3 grid with varied traffic levels."""
43
+ grid = Grid(width=3, height=3)
44
+ # Horizontal segments with different traffic
45
+ grid.add_segment((0, 0), (1, 0), 1)
46
+ grid.add_segment((1, 0), (2, 0), 2)
47
+ grid.add_segment((0, 1), (1, 1), 3)
48
+ grid.add_segment((1, 1), (2, 1), 4)
49
+ grid.add_segment((0, 2), (1, 2), 1)
50
+ grid.add_segment((1, 2), (2, 2), 1)
51
+ # Vertical segments
52
+ grid.add_segment((0, 0), (0, 1), 2)
53
+ grid.add_segment((0, 1), (0, 2), 1)
54
+ grid.add_segment((1, 0), (1, 1), 1)
55
+ grid.add_segment((1, 1), (1, 2), 2)
56
+ grid.add_segment((2, 0), (2, 1), 1)
57
+ grid.add_segment((2, 1), (2, 2), 1)
58
+ return grid
59
+
60
+
61
+ @pytest.fixture
62
+ def sample_stores():
63
+ """Create sample stores."""
64
+ return [
65
+ Store(id=1, position=(0, 0)),
66
+ Store(id=2, position=(2, 2)),
67
+ ]
68
+
69
+
70
+ @pytest.fixture
71
+ def sample_destinations():
72
+ """Create sample destinations."""
73
+ return [
74
+ Destination(id=1, position=(2, 0)),
75
+ Destination(id=2, position=(0, 2)),
76
+ Destination(id=3, position=(1, 1)),
77
+ ]
78
+
79
+
80
+ @pytest.fixture
81
+ def sample_tunnels():
82
+ """Create sample tunnels."""
83
+ return [
84
+ Tunnel(entrance1=(0, 0), entrance2=(2, 2)),
85
+ Tunnel(entrance1=(0, 2), entrance2=(2, 0)),
86
+ ]
87
+
88
+
89
+ @pytest.fixture
90
+ def sample_search_state(simple_grid, sample_stores, sample_destinations, sample_tunnels):
91
+ """Create a complete search state."""
92
+ return SearchState(
93
+ grid=simple_grid,
94
+ stores=sample_stores,
95
+ destinations=sample_destinations,
96
+ tunnels=sample_tunnels,
97
+ )
98
+
99
+
100
+ @pytest.fixture
101
+ def larger_grid():
102
+ """Create a 5x5 grid for more complex tests."""
103
+ grid = Grid(width=5, height=5)
104
+ # Add all segments with traffic level 1
105
+ for x in range(4):
106
+ for y in range(5):
107
+ grid.add_segment((x, y), (x + 1, y), 1)
108
+ for x in range(5):
109
+ for y in range(4):
110
+ grid.add_segment((x, y), (x, y + 1), 1)
111
+ return grid
backend/tests/test_algorithms.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for search algorithms (BFS, DFS, UCS, A*, Greedy, IDS)."""
2
+
3
+ import pytest
4
+ from app.algorithms.bfs import bfs_search
5
+ from app.algorithms.dfs import dfs_search
6
+ from app.algorithms.ucs import ucs_search
7
+ from app.algorithms.astar import astar_search
8
+ from app.algorithms.greedy import greedy_search
9
+ from app.algorithms.ids import ids_search
10
+ from app.heuristics import manhattan_heuristic, euclidean_heuristic
11
+ from app.core.delivery_search import DeliverySearch
12
+ from app.models.grid import Grid
13
+
14
+
15
+ class TestBFS:
16
+ """Tests for Breadth-First Search."""
17
+
18
+ def test_bfs_finds_path(self, simple_grid):
19
+ """Test BFS finds a path."""
20
+ search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
21
+ result, steps = bfs_search(search, visualize=False)
22
+
23
+ assert result.plan != ""
24
+ assert result.cost < float("inf")
25
+ assert len(result.path) > 0
26
+ assert result.path[0] == (0, 0)
27
+ assert result.path[-1] == (2, 2)
28
+
29
+ def test_bfs_shortest_path(self, simple_grid):
30
+ """Test BFS finds shortest path by steps."""
31
+ search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
32
+ result, _ = bfs_search(search, visualize=False)
33
+
34
+ # BFS should find path with minimum steps
35
+ # From (0,0) to (2,2) needs at least 4 steps
36
+ assert len(result.path) == 5 # 5 nodes including start and end
37
+
38
+ def test_bfs_no_path(self):
39
+ """Test BFS when no path exists."""
40
+ grid = Grid(width=3, height=3)
41
+ # Create disconnected grid - only add vertical segment
42
+ grid.add_segment((0, 0), (0, 1), 1)
43
+
44
+ search = DeliverySearch(grid, (0, 0), (2, 2), [])
45
+ result, _ = bfs_search(search, visualize=False)
46
+
47
+ assert result.plan == ""
48
+ assert result.cost == float("inf")
49
+ assert len(result.path) == 0
50
+
51
+ def test_bfs_same_start_goal(self, simple_grid):
52
+ """Test BFS when start equals goal."""
53
+ search = DeliverySearch(simple_grid, (1, 1), (1, 1), [])
54
+ result, _ = bfs_search(search, visualize=False)
55
+
56
+ assert result.cost == 0
57
+ assert len(result.path) == 1
58
+ assert result.path[0] == (1, 1)
59
+
60
+ def test_bfs_visualization(self, simple_grid):
61
+ """Test BFS with visualization enabled."""
62
+ search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
63
+ result, steps = bfs_search(search, visualize=True)
64
+
65
+ assert steps is not None
66
+ assert len(steps) > 0
67
+ # First step should have start node
68
+ assert steps[0].current_node == (0, 0)
69
+
70
+
71
+ class TestDFS:
72
+ """Tests for Depth-First Search."""
73
+
74
+ def test_dfs_finds_path(self, simple_grid):
75
+ """Test DFS finds a path."""
76
+ search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
77
+ result, steps = dfs_search(search, visualize=False)
78
+
79
+ assert result.plan != ""
80
+ assert result.cost < float("inf")
81
+ assert len(result.path) > 0
82
+ assert result.path[0] == (0, 0)
83
+ assert result.path[-1] == (2, 2)
84
+
85
+ def test_dfs_no_path(self):
86
+ """Test DFS when no path exists."""
87
+ grid = Grid(width=3, height=3)
88
+ grid.add_segment((0, 0), (0, 1), 1)
89
+
90
+ search = DeliverySearch(grid, (0, 0), (2, 2), [])
91
+ result, _ = dfs_search(search, visualize=False)
92
+
93
+ assert result.plan == ""
94
+ assert result.cost == float("inf")
95
+
96
+ def test_dfs_same_start_goal(self, simple_grid):
97
+ """Test DFS when start equals goal."""
98
+ search = DeliverySearch(simple_grid, (1, 1), (1, 1), [])
99
+ result, _ = dfs_search(search, visualize=False)
100
+
101
+ assert result.cost == 0
102
+ assert len(result.path) == 1
103
+
104
+
105
+ class TestUCS:
106
+ """Tests for Uniform Cost Search."""
107
+
108
+ def test_ucs_finds_optimal_path(self, grid_with_varied_traffic):
109
+ """Test UCS finds optimal (minimum cost) path."""
110
+ search = DeliverySearch(grid_with_varied_traffic, (0, 0), (2, 2), [])
111
+ result, _ = ucs_search(search, visualize=False)
112
+
113
+ assert result.plan != ""
114
+ assert result.cost < float("inf")
115
+
116
+ def test_ucs_prefers_low_traffic(self, grid_with_varied_traffic):
117
+ """Test UCS prefers lower traffic segments."""
118
+ search = DeliverySearch(grid_with_varied_traffic, (0, 0), (2, 2), [])
119
+ ucs_result, _ = ucs_search(search, visualize=False)
120
+ bfs_result, _ = bfs_search(search, visualize=False)
121
+
122
+ # UCS should find equal or better cost than BFS
123
+ assert ucs_result.cost <= bfs_result.cost
124
+
125
+ def test_ucs_no_path(self):
126
+ """Test UCS when no path exists."""
127
+ grid = Grid(width=3, height=3)
128
+ grid.add_segment((0, 0), (0, 1), 1)
129
+
130
+ search = DeliverySearch(grid, (0, 0), (2, 2), [])
131
+ result, _ = ucs_search(search, visualize=False)
132
+
133
+ assert result.cost == float("inf")
134
+
135
+ def test_ucs_same_start_goal(self, simple_grid):
136
+ """Test UCS when start equals goal."""
137
+ search = DeliverySearch(simple_grid, (1, 1), (1, 1), [])
138
+ result, _ = ucs_search(search, visualize=False)
139
+
140
+ assert result.cost == 0
141
+
142
+
143
+ class TestAStar:
144
+ """Tests for A* Search."""
145
+
146
+ def test_astar_manhattan_finds_path(self, simple_grid):
147
+ """Test A* with Manhattan heuristic finds a path."""
148
+ search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
149
+ result, _ = astar_search(search, manhattan_heuristic, visualize=False)
150
+
151
+ assert result.plan != ""
152
+ assert result.cost < float("inf")
153
+ assert result.path[0] == (0, 0)
154
+ assert result.path[-1] == (2, 2)
155
+
156
+ def test_astar_euclidean_finds_path(self, simple_grid):
157
+ """Test A* with Euclidean heuristic finds a path."""
158
+ search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
159
+ result, _ = astar_search(search, euclidean_heuristic, visualize=False)
160
+
161
+ assert result.plan != ""
162
+ assert result.cost < float("inf")
163
+
164
+ def test_astar_optimal(self, grid_with_varied_traffic):
165
+ """Test A* finds optimal path (same as UCS with admissible heuristic)."""
166
+ search = DeliverySearch(grid_with_varied_traffic, (0, 0), (2, 2), [])
167
+ astar_result, _ = astar_search(search, manhattan_heuristic, visualize=False)
168
+ ucs_result, _ = ucs_search(search, visualize=False)
169
+
170
+ # A* with admissible heuristic should find same cost as UCS
171
+ assert astar_result.cost == ucs_result.cost
172
+
173
+ def test_astar_fewer_nodes_than_ucs(self, larger_grid):
174
+ """Test A* expands fewer nodes than UCS."""
175
+ search = DeliverySearch(larger_grid, (0, 0), (4, 4), [])
176
+ astar_result, _ = astar_search(search, manhattan_heuristic, visualize=False)
177
+ ucs_result, _ = ucs_search(search, visualize=False)
178
+
179
+ # A* should expand fewer or equal nodes
180
+ assert astar_result.nodes_expanded <= ucs_result.nodes_expanded
181
+
182
+ def test_astar_no_path(self):
183
+ """Test A* when no path exists."""
184
+ grid = Grid(width=3, height=3)
185
+ grid.add_segment((0, 0), (0, 1), 1)
186
+
187
+ search = DeliverySearch(grid, (0, 0), (2, 2), [])
188
+ result, _ = astar_search(search, manhattan_heuristic, visualize=False)
189
+
190
+ assert result.cost == float("inf")
191
+
192
+
193
+ class TestGreedy:
194
+ """Tests for Greedy Best-First Search."""
195
+
196
+ def test_greedy_manhattan_finds_path(self, simple_grid):
197
+ """Test Greedy with Manhattan heuristic finds a path."""
198
+ search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
199
+ result, _ = greedy_search(search, manhattan_heuristic, visualize=False)
200
+
201
+ assert result.plan != ""
202
+ assert result.cost < float("inf")
203
+
204
+ def test_greedy_euclidean_finds_path(self, simple_grid):
205
+ """Test Greedy with Euclidean heuristic finds a path."""
206
+ search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
207
+ result, _ = greedy_search(search, euclidean_heuristic, visualize=False)
208
+
209
+ assert result.plan != ""
210
+ assert result.cost < float("inf")
211
+
212
+ def test_greedy_fast_but_suboptimal(self, grid_with_varied_traffic):
213
+ """Test Greedy is fast but may not find optimal path."""
214
+ search = DeliverySearch(grid_with_varied_traffic, (0, 0), (2, 2), [])
215
+ greedy_result, _ = greedy_search(search, manhattan_heuristic, visualize=False)
216
+ ucs_result, _ = ucs_search(search, visualize=False)
217
+
218
+ # Greedy may find suboptimal path
219
+ assert greedy_result.cost >= ucs_result.cost
220
+
221
+ def test_greedy_no_path(self):
222
+ """Test Greedy when no path exists."""
223
+ grid = Grid(width=3, height=3)
224
+ grid.add_segment((0, 0), (0, 1), 1)
225
+
226
+ search = DeliverySearch(grid, (0, 0), (2, 2), [])
227
+ result, _ = greedy_search(search, manhattan_heuristic, visualize=False)
228
+
229
+ assert result.cost == float("inf")
230
+
231
+
232
+ class TestIDS:
233
+ """Tests for Iterative Deepening Search."""
234
+
235
+ def test_ids_finds_path(self, simple_grid):
236
+ """Test IDS finds a path."""
237
+ search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
238
+ result, _ = ids_search(search, visualize=False)
239
+
240
+ assert result.plan != ""
241
+ assert result.cost < float("inf")
242
+ assert result.path[0] == (0, 0)
243
+ assert result.path[-1] == (2, 2)
244
+
245
+ def test_ids_optimal_steps(self, simple_grid):
246
+ """Test IDS finds path with optimal number of steps."""
247
+ search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
248
+ ids_result, _ = ids_search(search, visualize=False)
249
+ bfs_result, _ = bfs_search(search, visualize=False)
250
+
251
+ # IDS should find same path length as BFS
252
+ assert len(ids_result.path) == len(bfs_result.path)
253
+
254
+ def test_ids_no_path(self):
255
+ """Test IDS when no path exists."""
256
+ grid = Grid(width=3, height=3)
257
+ grid.add_segment((0, 0), (0, 1), 1)
258
+
259
+ search = DeliverySearch(grid, (0, 0), (2, 2), [])
260
+ result, _ = ids_search(search, visualize=False)
261
+
262
+ assert result.cost == float("inf")
263
+
264
+ def test_ids_same_start_goal(self, simple_grid):
265
+ """Test IDS when start equals goal."""
266
+ search = DeliverySearch(simple_grid, (1, 1), (1, 1), [])
267
+ result, _ = ids_search(search, visualize=False)
268
+
269
+ assert result.cost == 0
270
+ assert len(result.path) == 1
271
+
272
+
273
+ class TestAlgorithmComparison:
274
+ """Tests comparing different algorithms."""
275
+
276
+ def test_all_algorithms_find_same_goal(self, simple_grid):
277
+ """Test all algorithms reach the same goal."""
278
+ search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
279
+
280
+ bfs_result, _ = bfs_search(search, visualize=False)
281
+ dfs_result, _ = dfs_search(search, visualize=False)
282
+ ucs_result, _ = ucs_search(search, visualize=False)
283
+ astar_result, _ = astar_search(search, manhattan_heuristic, visualize=False)
284
+ greedy_result, _ = greedy_search(search, manhattan_heuristic, visualize=False)
285
+ ids_result, _ = ids_search(search, visualize=False)
286
+
287
+ # All should find a path ending at goal
288
+ assert bfs_result.path[-1] == (2, 2)
289
+ assert dfs_result.path[-1] == (2, 2)
290
+ assert ucs_result.path[-1] == (2, 2)
291
+ assert astar_result.path[-1] == (2, 2)
292
+ assert greedy_result.path[-1] == (2, 2)
293
+ assert ids_result.path[-1] == (2, 2)
294
+
295
+ def test_optimal_algorithms_same_cost(self, grid_with_varied_traffic):
296
+ """Test UCS and A* find same optimal cost."""
297
+ search = DeliverySearch(grid_with_varied_traffic, (0, 0), (2, 2), [])
298
+
299
+ ucs_result, _ = ucs_search(search, visualize=False)
300
+ astar_result, _ = astar_search(search, manhattan_heuristic, visualize=False)
301
+
302
+ assert ucs_result.cost == astar_result.cost
303
+
304
+ def test_visualization_consistency(self, simple_grid):
305
+ """Test visualization steps are consistent across algorithms."""
306
+ search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
307
+
308
+ _, bfs_steps = bfs_search(search, visualize=True)
309
+ _, ucs_steps = ucs_search(search, visualize=True)
310
+
311
+ # Both should have visualization steps
312
+ assert bfs_steps is not None
313
+ assert ucs_steps is not None
314
+ assert len(bfs_steps) > 0
315
+ assert len(ucs_steps) > 0
backend/tests/test_api.py ADDED
@@ -0,0 +1,394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for API routes."""
2
+
3
+ import pytest
4
+ from fastapi.testclient import TestClient
5
+ from app.main import app
6
+
7
+
8
+ @pytest.fixture
9
+ def client():
10
+ """Create test client."""
11
+ return TestClient(app)
12
+
13
+
14
+ @pytest.fixture
15
+ def sample_initial_state():
16
+ """Sample initial state string."""
17
+ return "5;5;2;1;2,2,4,4;;0,0"
18
+
19
+
20
+ @pytest.fixture
21
+ def sample_traffic():
22
+ """Sample traffic string for 5x5 grid."""
23
+ segments = []
24
+ # Horizontal segments
25
+ for x in range(4):
26
+ for y in range(5):
27
+ segments.append(f"{x},{y},{x+1},{y},1")
28
+ # Vertical segments
29
+ for x in range(5):
30
+ for y in range(4):
31
+ segments.append(f"{x},{y},{x},{y+1},1")
32
+ return ";".join(segments)
33
+
34
+
35
+ class TestHealthEndpoint:
36
+ """Tests for health check endpoint."""
37
+
38
+ def test_health_check(self, client):
39
+ """Test health check returns ok."""
40
+ response = client.get("/api/health")
41
+ assert response.status_code == 200
42
+ assert response.json()["status"] == "ok"
43
+
44
+
45
+ class TestAlgorithmsEndpoint:
46
+ """Tests for algorithms endpoint."""
47
+
48
+ def test_list_algorithms(self, client):
49
+ """Test listing available algorithms."""
50
+ response = client.get("/api/algorithms")
51
+ assert response.status_code == 200
52
+
53
+ data = response.json()
54
+ assert "algorithms" in data
55
+ assert len(data["algorithms"]) == 8 # 8 algorithms
56
+
57
+ # Check algorithm structure
58
+ algo = data["algorithms"][0]
59
+ assert "code" in algo
60
+ assert "name" in algo
61
+ assert "description" in algo
62
+
63
+ def test_algorithms_codes(self, client):
64
+ """Test all expected algorithm codes are present."""
65
+ response = client.get("/api/algorithms")
66
+ data = response.json()
67
+
68
+ codes = [a["code"] for a in data["algorithms"]]
69
+ expected = ["BF", "DF", "ID", "UC", "GR1", "GR2", "AS1", "AS2"]
70
+ assert set(codes) == set(expected)
71
+
72
+
73
+ class TestGenerateGridEndpoint:
74
+ """Tests for grid generation endpoint."""
75
+
76
+ def test_generate_default_grid(self, client):
77
+ """Test generating grid with default config."""
78
+ response = client.post("/api/grid/generate", json={})
79
+ assert response.status_code == 200
80
+
81
+ data = response.json()
82
+ assert "initial_state" in data
83
+ assert "traffic" in data
84
+ assert "parsed" in data
85
+
86
+ def test_generate_custom_grid(self, client):
87
+ """Test generating grid with custom config."""
88
+ response = client.post(
89
+ "/api/grid/generate",
90
+ json={
91
+ "width": 8,
92
+ "height": 8,
93
+ "num_stores": 2,
94
+ "num_destinations": 3,
95
+ "num_tunnels": 1,
96
+ "obstacle_density": 0.1,
97
+ },
98
+ )
99
+ assert response.status_code == 200
100
+
101
+ data = response.json()
102
+ parsed = data["parsed"]
103
+ assert parsed["width"] == 8
104
+ assert parsed["height"] == 8
105
+ assert len(parsed["stores"]) == 2
106
+ assert len(parsed["destinations"]) == 3
107
+
108
+ def test_generate_grid_parsed_structure(self, client):
109
+ """Test parsed grid structure."""
110
+ response = client.post("/api/grid/generate", json={"width": 5, "height": 5})
111
+ data = response.json()
112
+ parsed = data["parsed"]
113
+
114
+ assert "width" in parsed
115
+ assert "height" in parsed
116
+ assert "stores" in parsed
117
+ assert "destinations" in parsed
118
+ assert "tunnels" in parsed
119
+ assert "segments" in parsed
120
+
121
+ def test_generate_grid_stores_structure(self, client):
122
+ """Test store structure in response."""
123
+ response = client.post(
124
+ "/api/grid/generate",
125
+ json={"width": 5, "height": 5, "num_stores": 1},
126
+ )
127
+ data = response.json()
128
+ store = data["parsed"]["stores"][0]
129
+
130
+ assert "id" in store
131
+ assert "position" in store
132
+ assert "x" in store["position"]
133
+ assert "y" in store["position"]
134
+
135
+
136
+ class TestPathEndpoint:
137
+ """Tests for path finding endpoint."""
138
+
139
+ def test_find_path(self, client):
140
+ """Test finding path between two points."""
141
+ # First generate a grid
142
+ gen_response = client.post(
143
+ "/api/grid/generate",
144
+ json={"width": 5, "height": 5, "num_stores": 1, "num_destinations": 1},
145
+ )
146
+ grid_data = gen_response.json()["parsed"]
147
+
148
+ # Find path from store to destination
149
+ store = grid_data["stores"][0]
150
+ dest = grid_data["destinations"][0]
151
+
152
+ response = client.post(
153
+ "/api/search/path",
154
+ json={
155
+ "grid_width": 5,
156
+ "grid_height": 5,
157
+ "start": store["position"],
158
+ "goal": dest["position"],
159
+ "segments": grid_data["segments"],
160
+ "tunnels": grid_data["tunnels"],
161
+ "strategy": "BF",
162
+ },
163
+ )
164
+ assert response.status_code == 200
165
+
166
+ data = response.json()
167
+ assert "plan" in data
168
+ assert "cost" in data
169
+ assert "nodes_expanded" in data
170
+ assert "path" in data
171
+ assert "runtime_ms" in data
172
+ assert "memory_kb" in data
173
+
174
+ def test_find_path_all_strategies(self, client):
175
+ """Test path finding with all strategies."""
176
+ gen_response = client.post(
177
+ "/api/grid/generate",
178
+ json={"width": 5, "height": 5, "num_stores": 1, "num_destinations": 1},
179
+ )
180
+ grid_data = gen_response.json()["parsed"]
181
+ store = grid_data["stores"][0]
182
+ dest = grid_data["destinations"][0]
183
+
184
+ strategies = ["BF", "DF", "ID", "UC", "GR1", "GR2", "AS1", "AS2"]
185
+ for strategy in strategies:
186
+ response = client.post(
187
+ "/api/search/path",
188
+ json={
189
+ "grid_width": 5,
190
+ "grid_height": 5,
191
+ "start": store["position"],
192
+ "goal": dest["position"],
193
+ "segments": grid_data["segments"],
194
+ "tunnels": grid_data["tunnels"],
195
+ "strategy": strategy,
196
+ },
197
+ )
198
+ assert response.status_code == 200, f"Strategy {strategy} failed"
199
+
200
+ def test_find_path_with_visualization(self, client):
201
+ """Test path finding returns visualization steps."""
202
+ gen_response = client.post(
203
+ "/api/grid/generate",
204
+ json={"width": 5, "height": 5, "num_stores": 1, "num_destinations": 1},
205
+ )
206
+ grid_data = gen_response.json()["parsed"]
207
+ store = grid_data["stores"][0]
208
+ dest = grid_data["destinations"][0]
209
+
210
+ response = client.post(
211
+ "/api/search/path",
212
+ json={
213
+ "grid_width": 5,
214
+ "grid_height": 5,
215
+ "start": store["position"],
216
+ "goal": dest["position"],
217
+ "segments": grid_data["segments"],
218
+ "tunnels": grid_data["tunnels"],
219
+ "strategy": "BF",
220
+ },
221
+ )
222
+ data = response.json()
223
+
224
+ # Steps should be included
225
+ assert "steps" in data
226
+ assert data["steps"] is not None
227
+ assert len(data["steps"]) > 0
228
+
229
+
230
+ class TestPlanEndpoint:
231
+ """Tests for delivery planning endpoint."""
232
+
233
+ def test_create_plan(self, client, sample_initial_state, sample_traffic):
234
+ """Test creating delivery plan."""
235
+ response = client.post(
236
+ "/api/search/plan",
237
+ json={
238
+ "initial_state": sample_initial_state,
239
+ "traffic": sample_traffic,
240
+ "strategy": "BF",
241
+ "visualize": False,
242
+ },
243
+ )
244
+ assert response.status_code == 200
245
+
246
+ data = response.json()
247
+ assert "output" in data
248
+ assert "assignments" in data
249
+ assert "total_cost" in data
250
+ assert "total_nodes_expanded" in data
251
+ assert "runtime_ms" in data
252
+ assert "memory_kb" in data
253
+
254
+ def test_create_plan_all_strategies(self, client, sample_initial_state, sample_traffic):
255
+ """Test planning with all strategies."""
256
+ strategies = ["BF", "DF", "ID", "UC", "GR1", "GR2", "AS1", "AS2"]
257
+ for strategy in strategies:
258
+ response = client.post(
259
+ "/api/search/plan",
260
+ json={
261
+ "initial_state": sample_initial_state,
262
+ "traffic": sample_traffic,
263
+ "strategy": strategy,
264
+ "visualize": False,
265
+ },
266
+ )
267
+ assert response.status_code == 200, f"Strategy {strategy} failed"
268
+
269
+ def test_plan_assignments_structure(self, client, sample_initial_state, sample_traffic):
270
+ """Test plan assignments structure."""
271
+ response = client.post(
272
+ "/api/search/plan",
273
+ json={
274
+ "initial_state": sample_initial_state,
275
+ "traffic": sample_traffic,
276
+ "strategy": "BF",
277
+ "visualize": False,
278
+ },
279
+ )
280
+ data = response.json()
281
+
282
+ # Should have 2 assignments (2 destinations in sample)
283
+ assert len(data["assignments"]) == 2
284
+
285
+ assignment = data["assignments"][0]
286
+ assert "store_id" in assignment
287
+ assert "destination_id" in assignment
288
+ assert "path" in assignment
289
+
290
+
291
+ class TestCompareEndpoint:
292
+ """Tests for algorithm comparison endpoint."""
293
+
294
+ def test_compare_algorithms(self, client, sample_initial_state, sample_traffic):
295
+ """Test comparing all algorithms."""
296
+ response = client.post(
297
+ "/api/search/compare",
298
+ json={
299
+ "initial_state": sample_initial_state,
300
+ "traffic": sample_traffic,
301
+ },
302
+ )
303
+ assert response.status_code == 200
304
+
305
+ data = response.json()
306
+ assert "comparisons" in data
307
+ assert "optimal_cost" in data
308
+ assert len(data["comparisons"]) == 8 # 8 algorithms
309
+
310
+ def test_compare_result_structure(self, client, sample_initial_state, sample_traffic):
311
+ """Test comparison result structure."""
312
+ response = client.post(
313
+ "/api/search/compare",
314
+ json={
315
+ "initial_state": sample_initial_state,
316
+ "traffic": sample_traffic,
317
+ },
318
+ )
319
+ data = response.json()
320
+ result = data["comparisons"][0]
321
+
322
+ assert "algorithm" in result
323
+ assert "name" in result
324
+ assert "plan" in result
325
+ assert "cost" in result
326
+ assert "nodes_expanded" in result
327
+ assert "runtime_ms" in result
328
+ assert "memory_kb" in result
329
+ assert "is_optimal" in result
330
+
331
+ def test_compare_marks_optimal(self, client, sample_initial_state, sample_traffic):
332
+ """Test that optimal solutions are marked."""
333
+ response = client.post(
334
+ "/api/search/compare",
335
+ json={
336
+ "initial_state": sample_initial_state,
337
+ "traffic": sample_traffic,
338
+ },
339
+ )
340
+ data = response.json()
341
+
342
+ # At least one should be marked optimal
343
+ optimal_count = sum(1 for r in data["comparisons"] if r["is_optimal"])
344
+ assert optimal_count >= 1
345
+
346
+ # Optimal results should have cost equal to optimal_cost
347
+ for result in data["comparisons"]:
348
+ if result["is_optimal"]:
349
+ assert result["cost"] == data["optimal_cost"]
350
+
351
+ def test_compare_ucs_astar_optimal(self, client, sample_initial_state, sample_traffic):
352
+ """Test UCS and A* find optimal solutions."""
353
+ response = client.post(
354
+ "/api/search/compare",
355
+ json={
356
+ "initial_state": sample_initial_state,
357
+ "traffic": sample_traffic,
358
+ },
359
+ )
360
+ data = response.json()
361
+
362
+ ucs = next(r for r in data["comparisons"] if r["algorithm"] == "UC")
363
+ astar = next(r for r in data["comparisons"] if r["algorithm"] == "AS1")
364
+
365
+ # Both should find optimal cost
366
+ assert ucs["cost"] == data["optimal_cost"]
367
+ assert astar["cost"] == data["optimal_cost"]
368
+
369
+
370
+ class TestErrorHandling:
371
+ """Tests for API error handling."""
372
+
373
+ def test_invalid_strategy(self, client, sample_initial_state, sample_traffic):
374
+ """Test error on invalid strategy."""
375
+ response = client.post(
376
+ "/api/search/plan",
377
+ json={
378
+ "initial_state": sample_initial_state,
379
+ "traffic": sample_traffic,
380
+ "strategy": "INVALID",
381
+ "visualize": False,
382
+ },
383
+ )
384
+ # Should return validation error or internal error
385
+ assert response.status_code in [400, 422, 500]
386
+
387
+ def test_invalid_grid_dimensions(self, client):
388
+ """Test error on invalid grid dimensions."""
389
+ response = client.post(
390
+ "/api/grid/generate",
391
+ json={"width": -1, "height": 5},
392
+ )
393
+ # Should return validation error
394
+ assert response.status_code in [400, 422, 500]
backend/tests/test_core.py ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for core modules (delivery_search, delivery_planner)."""
2
+
3
+ import pytest
4
+ from app.core.delivery_search import DeliverySearch
5
+ from app.core.delivery_planner import DeliveryPlanner
6
+ from app.core.node import SearchNode
7
+ from app.core.frontier import QueueFrontier, StackFrontier, PriorityQueueFrontier
8
+ from app.models.grid import Grid
9
+ from app.models.entities import Store, Destination, Tunnel
10
+
11
+
12
+ class TestSearchNode:
13
+ """Tests for SearchNode."""
14
+
15
+ def test_node_creation(self):
16
+ """Test basic node creation."""
17
+ node = SearchNode(state=(0, 0), path_cost=0, depth=0)
18
+ assert node.state == (0, 0)
19
+ assert node.path_cost == 0
20
+ assert node.depth == 0
21
+ assert node.parent is None
22
+ assert node.action is None
23
+
24
+ def test_node_with_parent(self):
25
+ """Test node with parent."""
26
+ parent = SearchNode(state=(0, 0), path_cost=0, depth=0)
27
+ child = SearchNode(
28
+ state=(1, 0),
29
+ parent=parent,
30
+ action="right",
31
+ path_cost=1,
32
+ depth=1,
33
+ )
34
+ assert child.parent == parent
35
+ assert child.action == "right"
36
+ assert child.depth == 1
37
+
38
+ def test_get_path(self):
39
+ """Test path reconstruction."""
40
+ root = SearchNode(state=(0, 0), path_cost=0, depth=0)
41
+ child1 = SearchNode(state=(1, 0), parent=root, action="right", path_cost=1, depth=1)
42
+ child2 = SearchNode(state=(2, 0), parent=child1, action="right", path_cost=2, depth=2)
43
+
44
+ path = child2.get_path()
45
+ assert path == [(0, 0), (1, 0), (2, 0)]
46
+
47
+ def test_get_solution(self):
48
+ """Test solution string generation."""
49
+ root = SearchNode(state=(0, 0), path_cost=0, depth=0)
50
+ child1 = SearchNode(state=(1, 0), parent=root, action="right", path_cost=1, depth=1)
51
+ child2 = SearchNode(state=(1, 1), parent=child1, action="up", path_cost=2, depth=2)
52
+
53
+ solution = child2.get_solution()
54
+ assert solution == "right,up"
55
+
56
+ def test_get_solution_single_node(self):
57
+ """Test solution for single node (start = goal)."""
58
+ root = SearchNode(state=(0, 0), path_cost=0, depth=0)
59
+ assert root.get_solution() == ""
60
+
61
+
62
+ class TestFrontiers:
63
+ """Tests for frontier data structures."""
64
+
65
+ def test_queue_frontier_fifo(self):
66
+ """Test queue frontier is FIFO."""
67
+ frontier = QueueFrontier()
68
+ node1 = SearchNode(state=(0, 0), path_cost=0, depth=0)
69
+ node2 = SearchNode(state=(1, 0), path_cost=1, depth=1)
70
+ node3 = SearchNode(state=(2, 0), path_cost=2, depth=2)
71
+
72
+ frontier.push(node1)
73
+ frontier.push(node2)
74
+ frontier.push(node3)
75
+
76
+ assert frontier.pop().state == (0, 0)
77
+ assert frontier.pop().state == (1, 0)
78
+ assert frontier.pop().state == (2, 0)
79
+
80
+ def test_stack_frontier_lifo(self):
81
+ """Test stack frontier is LIFO."""
82
+ frontier = StackFrontier()
83
+ node1 = SearchNode(state=(0, 0), path_cost=0, depth=0)
84
+ node2 = SearchNode(state=(1, 0), path_cost=1, depth=1)
85
+ node3 = SearchNode(state=(2, 0), path_cost=2, depth=2)
86
+
87
+ frontier.push(node1)
88
+ frontier.push(node2)
89
+ frontier.push(node3)
90
+
91
+ assert frontier.pop().state == (2, 0)
92
+ assert frontier.pop().state == (1, 0)
93
+ assert frontier.pop().state == (0, 0)
94
+
95
+ def test_priority_queue_frontier(self):
96
+ """Test priority queue frontier orders by priority."""
97
+ frontier = PriorityQueueFrontier()
98
+ # Priority is stored in node.priority attribute
99
+ node1 = SearchNode(state=(0, 0), path_cost=5, depth=0)
100
+ node1.priority = 5
101
+ node2 = SearchNode(state=(1, 0), path_cost=1, depth=1)
102
+ node2.priority = 1
103
+ node3 = SearchNode(state=(2, 0), path_cost=3, depth=2)
104
+ node3.priority = 3
105
+
106
+ frontier.push(node1)
107
+ frontier.push(node2)
108
+ frontier.push(node3)
109
+
110
+ assert frontier.pop().state == (1, 0) # Lowest priority first
111
+ assert frontier.pop().state == (2, 0)
112
+ assert frontier.pop().state == (0, 0)
113
+
114
+ def test_frontier_is_empty(self):
115
+ """Test frontier empty check."""
116
+ frontier = QueueFrontier()
117
+ assert frontier.is_empty() is True
118
+
119
+ frontier.push(SearchNode(state=(0, 0), path_cost=0, depth=0))
120
+ assert frontier.is_empty() is False
121
+
122
+ frontier.pop()
123
+ assert frontier.is_empty() is True
124
+
125
+ def test_frontier_contains_state(self):
126
+ """Test frontier state containment check."""
127
+ frontier = QueueFrontier()
128
+ frontier.push(SearchNode(state=(0, 0), path_cost=0, depth=0))
129
+ frontier.push(SearchNode(state=(1, 0), path_cost=1, depth=1))
130
+
131
+ assert frontier.contains_state((0, 0)) is True
132
+ assert frontier.contains_state((1, 0)) is True
133
+ assert frontier.contains_state((2, 0)) is False
134
+
135
+ def test_frontier_get_states(self):
136
+ """Test getting all states in frontier."""
137
+ frontier = QueueFrontier()
138
+ frontier.push(SearchNode(state=(0, 0), path_cost=0, depth=0))
139
+ frontier.push(SearchNode(state=(1, 0), path_cost=1, depth=1))
140
+
141
+ states = frontier.get_states()
142
+ assert (0, 0) in states
143
+ assert (1, 0) in states
144
+
145
+
146
+ class TestDeliverySearch:
147
+ """Tests for DeliverySearch."""
148
+
149
+ def test_initial_state(self, simple_grid):
150
+ """Test initial state is set correctly."""
151
+ search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
152
+ assert search.initial_state() == (0, 0)
153
+
154
+ def test_goal_test(self, simple_grid):
155
+ """Test goal test function."""
156
+ search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
157
+ assert search.goal_test((2, 2)) is True
158
+ assert search.goal_test((0, 0)) is False
159
+ assert search.goal_test((1, 1)) is False
160
+
161
+ def test_actions(self, simple_grid):
162
+ """Test available actions."""
163
+ search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
164
+
165
+ # Corner has 2 actions
166
+ actions = search.actions((0, 0))
167
+ assert "up" in actions
168
+ assert "right" in actions
169
+ assert "down" not in actions
170
+ assert "left" not in actions
171
+
172
+ # Center has 4 actions
173
+ actions = search.actions((1, 1))
174
+ assert len(actions) == 4
175
+
176
+ def test_actions_with_tunnel(self, simple_grid):
177
+ """Test tunnel action availability."""
178
+ tunnel = Tunnel(entrance1=(0, 0), entrance2=(2, 2))
179
+ search = DeliverySearch(simple_grid, (0, 0), (2, 2), [tunnel])
180
+
181
+ actions = search.actions((0, 0))
182
+ assert "tunnel" in actions
183
+
184
+ actions = search.actions((1, 1))
185
+ assert "tunnel" not in actions
186
+
187
+ def test_result_movement(self, simple_grid):
188
+ """Test result of movement actions."""
189
+ search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
190
+
191
+ assert search.result((1, 1), "up") == (1, 2)
192
+ assert search.result((1, 1), "down") == (1, 0)
193
+ assert search.result((1, 1), "left") == (0, 1)
194
+ assert search.result((1, 1), "right") == (2, 1)
195
+
196
+ def test_result_tunnel(self, simple_grid):
197
+ """Test result of tunnel action."""
198
+ tunnel = Tunnel(entrance1=(0, 0), entrance2=(2, 2))
199
+ search = DeliverySearch(simple_grid, (0, 0), (2, 2), [tunnel])
200
+
201
+ assert search.result((0, 0), "tunnel") == (2, 2)
202
+ assert search.result((2, 2), "tunnel") == (0, 0)
203
+
204
+ def test_step_cost_movement(self, grid_with_varied_traffic):
205
+ """Test step cost for movement."""
206
+ search = DeliverySearch(grid_with_varied_traffic, (0, 0), (2, 2), [])
207
+
208
+ # Cost should equal traffic level
209
+ cost = search.step_cost((0, 0), "right", (1, 0))
210
+ assert cost == grid_with_varied_traffic.get_traffic((0, 0), (1, 0))
211
+
212
+ def test_step_cost_tunnel(self, simple_grid):
213
+ """Test step cost for tunnel (Manhattan distance)."""
214
+ tunnel = Tunnel(entrance1=(0, 0), entrance2=(2, 2))
215
+ search = DeliverySearch(simple_grid, (0, 0), (2, 2), [tunnel])
216
+
217
+ cost = search.step_cost((0, 0), "tunnel", (2, 2))
218
+ assert cost == tunnel.cost # Manhattan distance = 4
219
+
220
+ def test_solve_all_strategies(self, simple_grid):
221
+ """Test solve method with all strategies."""
222
+ search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
223
+
224
+ strategies = ["BF", "DF", "ID", "UC", "GR1", "GR2", "AS1", "AS2"]
225
+ for strategy in strategies:
226
+ result, _ = search.solve(strategy, visualize=False)
227
+ assert result.path[-1] == (2, 2), f"Strategy {strategy} failed"
228
+
229
+ def test_solve_invalid_strategy(self, simple_grid):
230
+ """Test solve with invalid strategy."""
231
+ search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
232
+
233
+ with pytest.raises(ValueError):
234
+ search.solve("INVALID", visualize=False)
235
+
236
+ def test_path_static_method(self, simple_grid):
237
+ """Test static path method."""
238
+ result, steps = DeliverySearch.path(
239
+ simple_grid,
240
+ (0, 0),
241
+ (2, 2),
242
+ [],
243
+ "BF",
244
+ visualize=False,
245
+ )
246
+
247
+ assert result.path[0] == (0, 0)
248
+ assert result.path[-1] == (2, 2)
249
+
250
+
251
+ class TestDeliveryPlanner:
252
+ """Tests for DeliveryPlanner."""
253
+
254
+ def test_planner_single_destination(self, simple_grid):
255
+ """Test planner with single destination."""
256
+ stores = [Store(id=1, position=(0, 0))]
257
+ destinations = [Destination(id=1, position=(2, 2))]
258
+
259
+ planner = DeliveryPlanner(simple_grid, stores, destinations, [])
260
+ result, _ = planner.plan("BF", visualize=False)
261
+
262
+ assert len(result.assignments) == 1
263
+ assert result.assignments[0].store_id == 1
264
+ assert result.assignments[0].destination_id == 1
265
+ assert result.total_cost > 0
266
+
267
+ def test_planner_multiple_destinations(self, simple_grid):
268
+ """Test planner with multiple destinations."""
269
+ stores = [Store(id=1, position=(0, 0))]
270
+ destinations = [
271
+ Destination(id=1, position=(2, 0)),
272
+ Destination(id=2, position=(0, 2)),
273
+ Destination(id=3, position=(2, 2)),
274
+ ]
275
+
276
+ planner = DeliveryPlanner(simple_grid, stores, destinations, [])
277
+ result, _ = planner.plan("BF", visualize=False)
278
+
279
+ assert len(result.assignments) == 3
280
+ assert result.total_cost > 0
281
+
282
+ def test_planner_multiple_stores(self, simple_grid):
283
+ """Test planner assigns to nearest store."""
284
+ stores = [
285
+ Store(id=1, position=(0, 0)),
286
+ Store(id=2, position=(2, 2)),
287
+ ]
288
+ destinations = [Destination(id=1, position=(2, 0))]
289
+
290
+ planner = DeliveryPlanner(simple_grid, stores, destinations, [])
291
+ result, _ = planner.plan("UC", visualize=False)
292
+
293
+ # Should assign to store 1 (closer to destination (2,0))
294
+ assert len(result.assignments) == 1
295
+
296
+ def test_planner_with_tunnels(self, simple_grid):
297
+ """Test planner uses tunnels."""
298
+ stores = [Store(id=1, position=(0, 0))]
299
+ destinations = [Destination(id=1, position=(2, 2))]
300
+ tunnels = [Tunnel(entrance1=(0, 0), entrance2=(2, 2))]
301
+
302
+ planner = DeliveryPlanner(simple_grid, stores, destinations, tunnels)
303
+ result, _ = planner.plan("UC", visualize=False)
304
+
305
+ # Tunnel cost is 4 (Manhattan distance)
306
+ # Without tunnel, minimum cost would be 4 (each segment has traffic 1)
307
+ assert result.total_cost == 4
308
+
309
+ def test_planner_from_state(self, simple_grid):
310
+ """Test static plan_from_state method."""
311
+ stores = [Store(id=1, position=(0, 0))]
312
+ destinations = [Destination(id=1, position=(2, 2))]
313
+
314
+ result, _ = DeliveryPlanner.plan_from_state(
315
+ simple_grid,
316
+ stores,
317
+ destinations,
318
+ [],
319
+ "BF",
320
+ visualize=False,
321
+ )
322
+
323
+ assert len(result.assignments) == 1
324
+ assert result.total_cost > 0
325
+
326
+ def test_planner_total_nodes(self, simple_grid):
327
+ """Test total nodes expanded tracking."""
328
+ stores = [Store(id=1, position=(0, 0))]
329
+ destinations = [
330
+ Destination(id=1, position=(2, 0)),
331
+ Destination(id=2, position=(0, 2)),
332
+ ]
333
+
334
+ planner = DeliveryPlanner(simple_grid, stores, destinations, [])
335
+ result, _ = planner.plan("BF", visualize=False)
336
+
337
+ assert result.total_nodes_expanded > 0
338
+
339
+ def test_planner_visualization(self, simple_grid):
340
+ """Test planner with visualization."""
341
+ stores = [Store(id=1, position=(0, 0))]
342
+ destinations = [Destination(id=1, position=(2, 2))]
343
+
344
+ planner = DeliveryPlanner(simple_grid, stores, destinations, [])
345
+ result, viz_data = planner.plan("BF", visualize=True)
346
+
347
+ assert viz_data is not None
348
+ assert 1 in viz_data # Destination ID 1 should have steps
349
+
350
+ def test_planner_result_to_string(self, simple_grid):
351
+ """Test plan result string formatting."""
352
+ stores = [Store(id=1, position=(0, 0))]
353
+ destinations = [Destination(id=1, position=(2, 2))]
354
+
355
+ planner = DeliveryPlanner(simple_grid, stores, destinations, [])
356
+ result, _ = planner.plan("BF", visualize=False)
357
+
358
+ output = result.to_string()
359
+ assert isinstance(output, str)
360
+ assert len(output) > 0
361
+
362
+ def test_planner_all_strategies(self, simple_grid):
363
+ """Test planner works with all strategies."""
364
+ stores = [Store(id=1, position=(0, 0))]
365
+ destinations = [Destination(id=1, position=(2, 2))]
366
+
367
+ strategies = ["BF", "DF", "ID", "UC", "GR1", "GR2", "AS1", "AS2"]
368
+ for strategy in strategies:
369
+ result, _ = DeliveryPlanner.plan_from_state(
370
+ simple_grid, stores, destinations, [], strategy, False
371
+ )
372
+ assert len(result.assignments) == 1, f"Strategy {strategy} failed"
backend/tests/test_models.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for models (Grid, entities, state)."""
2
+
3
+ import pytest
4
+ from app.models.grid import Grid, Segment
5
+ from app.models.entities import Store, Destination, Tunnel
6
+ from app.models.state import SearchState, PathResult, SearchStep
7
+
8
+
9
+ class TestSegment:
10
+ """Tests for Segment model."""
11
+
12
+ def test_segment_creation(self):
13
+ """Test basic segment creation."""
14
+ segment = Segment(src=(0, 0), dst=(1, 0), traffic=2)
15
+ assert segment.src == (0, 0)
16
+ assert segment.dst == (1, 0)
17
+ assert segment.traffic == 2
18
+
19
+ def test_segment_normalization(self):
20
+ """Test that segment normalizes direction (src < dst)."""
21
+ segment = Segment(src=(1, 0), dst=(0, 0), traffic=1)
22
+ assert segment.src == (0, 0)
23
+ assert segment.dst == (1, 0)
24
+
25
+ def test_segment_is_blocked(self):
26
+ """Test blocked segment detection."""
27
+ blocked = Segment(src=(0, 0), dst=(1, 0), traffic=0)
28
+ passable = Segment(src=(0, 0), dst=(1, 0), traffic=1)
29
+ assert blocked.is_blocked is True
30
+ assert passable.is_blocked is False
31
+
32
+ def test_segment_get_key(self):
33
+ """Test segment key generation."""
34
+ segment = Segment(src=(0, 0), dst=(1, 0), traffic=1)
35
+ assert segment.get_key() == ((0, 0), (1, 0))
36
+
37
+
38
+ class TestGrid:
39
+ """Tests for Grid model."""
40
+
41
+ def test_grid_creation(self):
42
+ """Test basic grid creation."""
43
+ grid = Grid(width=5, height=5)
44
+ assert grid.width == 5
45
+ assert grid.height == 5
46
+ assert len(grid.segments) == 0
47
+
48
+ def test_add_segment(self):
49
+ """Test adding segments to grid."""
50
+ grid = Grid(width=3, height=3)
51
+ grid.add_segment((0, 0), (1, 0), 2)
52
+ assert len(grid.segments) == 1
53
+ segment = grid.get_segment((0, 0), (1, 0))
54
+ assert segment is not None
55
+ assert segment.traffic == 2
56
+
57
+ def test_add_segment_reversed(self):
58
+ """Test adding segment with reversed coordinates."""
59
+ grid = Grid(width=3, height=3)
60
+ grid.add_segment((1, 0), (0, 0), 2)
61
+ # Should still be accessible both ways
62
+ segment = grid.get_segment((0, 0), (1, 0))
63
+ assert segment is not None
64
+ assert segment.traffic == 2
65
+
66
+ def test_get_traffic(self):
67
+ """Test getting traffic level."""
68
+ grid = Grid(width=3, height=3)
69
+ grid.add_segment((0, 0), (1, 0), 3)
70
+ assert grid.get_traffic((0, 0), (1, 0)) == 3
71
+ assert grid.get_traffic((1, 0), (0, 0)) == 3 # Reversed
72
+ assert grid.get_traffic((0, 0), (0, 1)) == 0 # Non-existent
73
+
74
+ def test_is_blocked(self):
75
+ """Test blocked segment detection."""
76
+ grid = Grid(width=3, height=3)
77
+ grid.add_segment((0, 0), (1, 0), 0)
78
+ grid.add_segment((0, 0), (0, 1), 1)
79
+ assert grid.is_blocked((0, 0), (1, 0)) is True
80
+ assert grid.is_blocked((0, 0), (0, 1)) is False
81
+ assert grid.is_blocked((1, 1), (2, 1)) is True # Non-existent = blocked
82
+
83
+ def test_is_valid_position(self):
84
+ """Test position validation."""
85
+ grid = Grid(width=3, height=3)
86
+ assert grid.is_valid_position((0, 0)) is True
87
+ assert grid.is_valid_position((2, 2)) is True
88
+ assert grid.is_valid_position((3, 0)) is False
89
+ assert grid.is_valid_position((0, 3)) is False
90
+ assert grid.is_valid_position((-1, 0)) is False
91
+
92
+ def test_get_neighbors(self, simple_grid):
93
+ """Test getting neighbors."""
94
+ # Corner (0,0) has 2 neighbors
95
+ neighbors = simple_grid.get_neighbors((0, 0))
96
+ assert len(neighbors) == 2
97
+ assert (1, 0) in neighbors
98
+ assert (0, 1) in neighbors
99
+
100
+ # Center (1,1) has 4 neighbors
101
+ neighbors = simple_grid.get_neighbors((1, 1))
102
+ assert len(neighbors) == 4
103
+
104
+ def test_get_neighbors_with_blocked(self, grid_with_blocked):
105
+ """Test neighbors exclude blocked paths."""
106
+ # (1,1) normally has 4 neighbors but one path is blocked
107
+ neighbors = grid_with_blocked.get_neighbors((1, 1))
108
+ assert (2, 1) not in neighbors # Blocked
109
+ assert (0, 1) in neighbors
110
+ assert (1, 0) in neighbors
111
+ assert (1, 2) in neighbors
112
+
113
+ def test_to_dict(self, simple_grid):
114
+ """Test grid serialization."""
115
+ result = simple_grid.to_dict()
116
+ assert result["width"] == 3
117
+ assert result["height"] == 3
118
+ assert "segments" in result
119
+ assert len(result["segments"]) > 0
120
+
121
+
122
+ class TestStore:
123
+ """Tests for Store entity."""
124
+
125
+ def test_store_creation(self):
126
+ """Test store creation."""
127
+ store = Store(id=1, position=(5, 3))
128
+ assert store.id == 1
129
+ assert store.position == (5, 3)
130
+
131
+ def test_store_to_dict(self):
132
+ """Test store serialization."""
133
+ store = Store(id=2, position=(1, 2))
134
+ result = store.to_dict()
135
+ assert result["id"] == 2
136
+ assert result["position"]["x"] == 1
137
+ assert result["position"]["y"] == 2
138
+
139
+
140
+ class TestDestination:
141
+ """Tests for Destination entity."""
142
+
143
+ def test_destination_creation(self):
144
+ """Test destination creation."""
145
+ dest = Destination(id=1, position=(3, 4))
146
+ assert dest.id == 1
147
+ assert dest.position == (3, 4)
148
+
149
+ def test_destination_to_dict(self):
150
+ """Test destination serialization."""
151
+ dest = Destination(id=3, position=(2, 5))
152
+ result = dest.to_dict()
153
+ assert result["id"] == 3
154
+ assert result["position"]["x"] == 2
155
+ assert result["position"]["y"] == 5
156
+
157
+
158
+ class TestTunnel:
159
+ """Tests for Tunnel entity."""
160
+
161
+ def test_tunnel_creation(self):
162
+ """Test tunnel creation."""
163
+ tunnel = Tunnel(entrance1=(0, 0), entrance2=(5, 5))
164
+ assert tunnel.entrance1 == (0, 0)
165
+ assert tunnel.entrance2 == (5, 5)
166
+
167
+ def test_tunnel_cost(self):
168
+ """Test tunnel cost calculation (Manhattan distance)."""
169
+ tunnel = Tunnel(entrance1=(0, 0), entrance2=(3, 4))
170
+ assert tunnel.cost == 7 # |3-0| + |4-0| = 7
171
+
172
+ def test_tunnel_cost_same_row(self):
173
+ """Test tunnel cost on same row."""
174
+ tunnel = Tunnel(entrance1=(0, 5), entrance2=(10, 5))
175
+ assert tunnel.cost == 10
176
+
177
+ def test_tunnel_cost_same_column(self):
178
+ """Test tunnel cost on same column."""
179
+ tunnel = Tunnel(entrance1=(3, 0), entrance2=(3, 7))
180
+ assert tunnel.cost == 7
181
+
182
+ def test_get_other_entrance(self):
183
+ """Test getting other entrance."""
184
+ tunnel = Tunnel(entrance1=(0, 0), entrance2=(5, 5))
185
+ assert tunnel.get_other_entrance((0, 0)) == (5, 5)
186
+ assert tunnel.get_other_entrance((5, 5)) == (0, 0)
187
+
188
+ def test_get_other_entrance_invalid(self):
189
+ """Test error on invalid entrance."""
190
+ tunnel = Tunnel(entrance1=(0, 0), entrance2=(5, 5))
191
+ with pytest.raises(ValueError):
192
+ tunnel.get_other_entrance((1, 1))
193
+
194
+ def test_has_entrance_at(self):
195
+ """Test entrance detection."""
196
+ tunnel = Tunnel(entrance1=(0, 0), entrance2=(5, 5))
197
+ assert tunnel.has_entrance_at((0, 0)) is True
198
+ assert tunnel.has_entrance_at((5, 5)) is True
199
+ assert tunnel.has_entrance_at((1, 1)) is False
200
+
201
+ def test_tunnel_to_dict(self):
202
+ """Test tunnel serialization."""
203
+ tunnel = Tunnel(entrance1=(1, 2), entrance2=(4, 6))
204
+ result = tunnel.to_dict()
205
+ assert result["entrance1"]["x"] == 1
206
+ assert result["entrance1"]["y"] == 2
207
+ assert result["entrance2"]["x"] == 4
208
+ assert result["entrance2"]["y"] == 6
209
+ assert result["cost"] == 7
210
+
211
+
212
+ class TestSearchState:
213
+ """Tests for SearchState model."""
214
+
215
+ def test_search_state_creation(self, simple_grid, sample_stores, sample_destinations, sample_tunnels):
216
+ """Test search state creation."""
217
+ state = SearchState(
218
+ grid=simple_grid,
219
+ stores=sample_stores,
220
+ destinations=sample_destinations,
221
+ tunnels=sample_tunnels,
222
+ )
223
+ assert state.grid == simple_grid
224
+ assert len(state.stores) == 2
225
+ assert len(state.destinations) == 3
226
+ assert len(state.tunnels) == 2
227
+
228
+
229
+ class TestPathResult:
230
+ """Tests for PathResult model."""
231
+
232
+ def test_path_result_creation(self):
233
+ """Test path result creation."""
234
+ result = PathResult(
235
+ plan="right,right,up",
236
+ cost=5.0,
237
+ nodes_expanded=10,
238
+ path=[(0, 0), (1, 0), (2, 0), (2, 1)],
239
+ )
240
+ assert result.plan == "right,right,up"
241
+ assert result.cost == 5.0
242
+ assert result.nodes_expanded == 10
243
+ assert len(result.path) == 4
244
+
245
+ def test_path_result_no_solution(self):
246
+ """Test path result when no solution exists."""
247
+ result = PathResult(
248
+ plan="",
249
+ cost=float("inf"),
250
+ nodes_expanded=50,
251
+ path=[],
252
+ )
253
+ assert result.plan == ""
254
+ assert result.cost == float("inf")
255
+ assert len(result.path) == 0
256
+
257
+
258
+ class TestSearchStep:
259
+ """Tests for SearchStep model."""
260
+
261
+ def test_search_step_creation(self):
262
+ """Test search step creation."""
263
+ step = SearchStep(
264
+ step_number=5,
265
+ current_node=(2, 3),
266
+ action="right",
267
+ frontier=[(3, 3), (2, 4)],
268
+ explored=[(0, 0), (1, 0), (2, 0)],
269
+ current_path=[(0, 0), (1, 0), (2, 0), (2, 1), (2, 2), (2, 3)],
270
+ path_cost=6.0,
271
+ )
272
+ assert step.step_number == 5
273
+ assert step.current_node == (2, 3)
274
+ assert step.action == "right"
275
+ assert len(step.frontier) == 2
276
+ assert len(step.explored) == 3
277
+
278
+ def test_search_step_to_dict(self):
279
+ """Test search step serialization."""
280
+ step = SearchStep(
281
+ step_number=0,
282
+ current_node=(0, 0),
283
+ action=None,
284
+ frontier=[(1, 0)],
285
+ explored=[],
286
+ current_path=[(0, 0)],
287
+ path_cost=0.0,
288
+ )
289
+ result = step.to_dict()
290
+ assert result["stepNumber"] == 0
291
+ assert result["currentNode"]["x"] == 0
292
+ assert result["currentNode"]["y"] == 0
293
+ assert result["action"] is None
backend/tests/test_services.py ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for services (parser, grid_generator, metrics)."""
2
+
3
+ import pytest
4
+ from app.services.parser import (
5
+ parse_initial_state,
6
+ parse_traffic,
7
+ parse_full_state,
8
+ format_initial_state,
9
+ format_traffic,
10
+ )
11
+ from app.services.grid_generator import gen_grid
12
+ from app.services.metrics import MetricsCollector, measure_performance
13
+ from app.models.entities import Store, Destination, Tunnel
14
+
15
+
16
+ class TestParseInitialState:
17
+ """Tests for parse_initial_state function."""
18
+
19
+ def test_parse_basic(self):
20
+ """Test parsing basic initial state."""
21
+ initial_state = "5;5;2;1;1,2,3,4;;0,0"
22
+ width, height, stores, destinations, tunnels = parse_initial_state(initial_state)
23
+
24
+ assert width == 5
25
+ assert height == 5
26
+ assert len(stores) == 1
27
+ assert len(destinations) == 2
28
+ assert len(tunnels) == 0
29
+
30
+ def test_parse_with_tunnels(self):
31
+ """Test parsing initial state with tunnels."""
32
+ initial_state = "10;10;3;2;1,1,5,5,8,8;0,0,9,9,2,2,7,7;0,0,9,9"
33
+ width, height, stores, destinations, tunnels = parse_initial_state(initial_state)
34
+
35
+ assert width == 10
36
+ assert height == 10
37
+ assert len(stores) == 2
38
+ assert len(destinations) == 3
39
+ assert len(tunnels) == 2
40
+
41
+ # Check tunnel entrances
42
+ assert tunnels[0].entrance1 == (0, 0)
43
+ assert tunnels[0].entrance2 == (9, 9)
44
+ assert tunnels[1].entrance1 == (2, 2)
45
+ assert tunnels[1].entrance2 == (7, 7)
46
+
47
+ def test_parse_store_positions(self):
48
+ """Test parsing store positions."""
49
+ initial_state = "5;5;1;2;2,2;;0,0,4,4"
50
+ width, height, stores, destinations, tunnels = parse_initial_state(initial_state)
51
+
52
+ assert len(stores) == 2
53
+ assert stores[0].position == (0, 0)
54
+ assert stores[1].position == (4, 4)
55
+
56
+ def test_parse_destination_positions(self):
57
+ """Test parsing destination positions."""
58
+ initial_state = "5;5;3;1;1,1,2,2,3,3;;0,0"
59
+ width, height, stores, destinations, tunnels = parse_initial_state(initial_state)
60
+
61
+ assert len(destinations) == 3
62
+ assert destinations[0].position == (1, 1)
63
+ assert destinations[1].position == (2, 2)
64
+ assert destinations[2].position == (3, 3)
65
+
66
+ def test_parse_empty_tunnels(self):
67
+ """Test parsing with no tunnels."""
68
+ initial_state = "3;3;1;1;1,1;;0,0"
69
+ width, height, stores, destinations, tunnels = parse_initial_state(initial_state)
70
+
71
+ assert len(tunnels) == 0
72
+
73
+ def test_parse_empty_destinations(self):
74
+ """Test parsing with no destinations."""
75
+ initial_state = "3;3;0;1;;;0,0"
76
+ width, height, stores, destinations, tunnels = parse_initial_state(initial_state)
77
+
78
+ assert len(destinations) == 0
79
+
80
+
81
+ class TestParseTraffic:
82
+ """Tests for parse_traffic function."""
83
+
84
+ def test_parse_basic_traffic(self):
85
+ """Test parsing basic traffic string."""
86
+ traffic_str = "0,0,1,0,2;0,0,0,1,3;1,0,1,1,1"
87
+ grid = parse_traffic(traffic_str, 3, 3)
88
+
89
+ assert grid.width == 3
90
+ assert grid.height == 3
91
+ assert grid.get_traffic((0, 0), (1, 0)) == 2
92
+ assert grid.get_traffic((0, 0), (0, 1)) == 3
93
+ assert grid.get_traffic((1, 0), (1, 1)) == 1
94
+
95
+ def test_parse_blocked_segment(self):
96
+ """Test parsing blocked segment (traffic=0)."""
97
+ traffic_str = "0,0,1,0,0;0,0,0,1,1"
98
+ grid = parse_traffic(traffic_str, 2, 2)
99
+
100
+ assert grid.is_blocked((0, 0), (1, 0)) is True
101
+ assert grid.is_blocked((0, 0), (0, 1)) is False
102
+
103
+ def test_parse_empty_traffic(self):
104
+ """Test parsing empty traffic string - should create default traffic."""
105
+ grid = parse_traffic("", 3, 3)
106
+
107
+ assert grid.width == 3
108
+ assert grid.height == 3
109
+ # Should have default traffic level 1
110
+ assert grid.get_traffic((0, 0), (1, 0)) == 1
111
+ assert grid.get_traffic((0, 0), (0, 1)) == 1
112
+
113
+
114
+ class TestParseFullState:
115
+ """Tests for parse_full_state function."""
116
+
117
+ def test_parse_full_state(self):
118
+ """Test parsing complete state."""
119
+ initial_state = "5;5;2;1;1,1,3,3;;0,0"
120
+ traffic_str = "0,0,1,0,2;0,0,0,1,1"
121
+
122
+ state = parse_full_state(initial_state, traffic_str)
123
+
124
+ assert state.grid.width == 5
125
+ assert state.grid.height == 5
126
+ assert len(state.stores) == 1
127
+ assert len(state.destinations) == 2
128
+ assert len(state.tunnels) == 0
129
+
130
+
131
+ class TestFormatInitialState:
132
+ """Tests for format_initial_state function."""
133
+
134
+ def test_format_basic(self):
135
+ """Test formatting basic state."""
136
+ stores = [Store(id=1, position=(0, 0))]
137
+ destinations = [Destination(id=1, position=(2, 2))]
138
+ tunnels = []
139
+
140
+ result = format_initial_state(5, 5, stores, destinations, tunnels)
141
+
142
+ assert result == "5;5;1;1;2,2;;0,0"
143
+
144
+ def test_format_with_tunnels(self):
145
+ """Test formatting state with tunnels."""
146
+ stores = [Store(id=1, position=(0, 0)), Store(id=2, position=(4, 4))]
147
+ destinations = [Destination(id=1, position=(2, 2))]
148
+ tunnels = [Tunnel(entrance1=(1, 1), entrance2=(3, 3))]
149
+
150
+ result = format_initial_state(5, 5, stores, destinations, tunnels)
151
+
152
+ assert result == "5;5;1;2;2,2;1,1,3,3;0,0,4,4"
153
+
154
+ def test_format_roundtrip(self):
155
+ """Test that format and parse are inverses."""
156
+ stores = [Store(id=1, position=(0, 0)), Store(id=2, position=(4, 4))]
157
+ destinations = [
158
+ Destination(id=1, position=(1, 1)),
159
+ Destination(id=2, position=(3, 3)),
160
+ ]
161
+ tunnels = [Tunnel(entrance1=(0, 4), entrance2=(4, 0))]
162
+
163
+ formatted = format_initial_state(5, 5, stores, destinations, tunnels)
164
+ width, height, parsed_stores, parsed_dests, parsed_tunnels = parse_initial_state(formatted)
165
+
166
+ assert width == 5
167
+ assert height == 5
168
+ assert len(parsed_stores) == 2
169
+ assert len(parsed_dests) == 2
170
+ assert len(parsed_tunnels) == 1
171
+ assert parsed_stores[0].position == (0, 0)
172
+ assert parsed_stores[1].position == (4, 4)
173
+
174
+
175
+ class TestFormatTraffic:
176
+ """Tests for format_traffic function."""
177
+
178
+ def test_format_traffic(self, simple_grid):
179
+ """Test formatting traffic."""
180
+ result = format_traffic(simple_grid)
181
+
182
+ # Should contain semicolon-separated segments
183
+ assert ";" in result or len(simple_grid.segments) <= 1
184
+
185
+ # Parse it back and verify
186
+ parsed_grid = parse_traffic(result, 3, 3)
187
+ assert parsed_grid.width == 3
188
+ assert parsed_grid.height == 3
189
+
190
+
191
+ class TestGridGenerator:
192
+ """Tests for grid generator."""
193
+
194
+ def test_gen_grid_basic(self):
195
+ """Test basic grid generation."""
196
+ initial_state, traffic, state = gen_grid(
197
+ width=5,
198
+ height=5,
199
+ num_stores=1,
200
+ num_destinations=2,
201
+ obstacle_density=0.0,
202
+ seed=42,
203
+ )
204
+
205
+ assert state.grid.width == 5
206
+ assert state.grid.height == 5
207
+ assert len(state.stores) == 1
208
+ assert len(state.destinations) == 2
209
+ # Tunnels may be generated randomly since 0 is treated as falsy
210
+
211
+ def test_gen_grid_with_tunnels(self):
212
+ """Test grid generation with tunnels."""
213
+ initial_state, traffic, state = gen_grid(
214
+ width=10,
215
+ height=10,
216
+ num_stores=2,
217
+ num_destinations=3,
218
+ num_tunnels=2,
219
+ obstacle_density=0.1,
220
+ seed=42,
221
+ )
222
+
223
+ assert len(state.stores) == 2
224
+ assert len(state.destinations) == 3
225
+ # Tunnels might be fewer if generation fails to find valid positions
226
+ assert len(state.tunnels) <= 2
227
+
228
+ def test_gen_grid_reproducible(self):
229
+ """Test that same seed produces same grid."""
230
+ result1 = gen_grid(width=5, height=5, seed=12345)
231
+ result2 = gen_grid(width=5, height=5, seed=12345)
232
+
233
+ assert result1[0] == result2[0] # Same initial_state
234
+ assert result1[1] == result2[1] # Same traffic
235
+
236
+ def test_gen_grid_stores_limited(self):
237
+ """Test that stores are limited to max 3."""
238
+ _, _, state = gen_grid(
239
+ width=10,
240
+ height=10,
241
+ num_stores=10, # Request 10, should get max 3
242
+ num_destinations=1,
243
+ seed=42,
244
+ )
245
+
246
+ assert len(state.stores) <= 3
247
+
248
+ def test_gen_grid_destinations_limited(self):
249
+ """Test that destinations are limited to max 10."""
250
+ _, _, state = gen_grid(
251
+ width=10,
252
+ height=10,
253
+ num_stores=1,
254
+ num_destinations=20, # Request 20, should get max 10
255
+ seed=42,
256
+ )
257
+
258
+ assert len(state.destinations) <= 10
259
+
260
+ def test_gen_grid_connectivity(self):
261
+ """Test that generated grid has connected paths."""
262
+ _, _, state = gen_grid(
263
+ width=5,
264
+ height=5,
265
+ num_stores=1,
266
+ num_destinations=1,
267
+ obstacle_density=0.2,
268
+ seed=42,
269
+ )
270
+
271
+ # Should be able to reach destination from store
272
+ # This is ensured by _ensure_connectivity in grid_generator
273
+ store_pos = state.stores[0].position
274
+ dest_pos = state.destinations[0].position
275
+
276
+ # BFS to check connectivity
277
+ visited = {store_pos}
278
+ queue = [store_pos]
279
+ while queue:
280
+ current = queue.pop(0)
281
+ if current == dest_pos:
282
+ break
283
+ for neighbor in state.grid.get_neighbors(current):
284
+ if neighbor not in visited:
285
+ visited.add(neighbor)
286
+ queue.append(neighbor)
287
+
288
+ assert dest_pos in visited, "Destination should be reachable from store"
289
+
290
+
291
+ class TestMetricsCollector:
292
+ """Tests for MetricsCollector."""
293
+
294
+ def test_metrics_collector_basic(self):
295
+ """Test basic metrics collection."""
296
+ collector = MetricsCollector()
297
+ collector.start()
298
+
299
+ # Do some work
300
+ _ = [i**2 for i in range(1000)]
301
+
302
+ collector.sample()
303
+ collector.stop()
304
+
305
+ assert collector.runtime_ms > 0
306
+ assert collector.memory_kb >= 0
307
+ assert collector.cpu_percent >= 0
308
+
309
+ def test_metrics_collector_multiple_samples(self):
310
+ """Test multiple samples."""
311
+ collector = MetricsCollector()
312
+ collector.start()
313
+
314
+ for _ in range(3):
315
+ collector.sample()
316
+
317
+ collector.stop()
318
+
319
+ assert len(collector.memory_samples) >= 3
320
+ assert len(collector.cpu_samples) >= 3
321
+
322
+ def test_measure_performance_context_manager(self):
323
+ """Test measure_performance context manager."""
324
+ with measure_performance() as metrics:
325
+ # Do some work
326
+ _ = sum(range(10000))
327
+ metrics.sample()
328
+
329
+ assert metrics.runtime_ms > 0
backend/uv.lock CHANGED
@@ -39,6 +39,7 @@ name = "backend"
39
  version = "0.1.0"
40
  source = { virtual = "." }
41
  dependencies = [
 
42
  { name = "fastapi" },
43
  { name = "httpx" },
44
  { name = "psutil" },
@@ -53,6 +54,7 @@ dependencies = [
53
 
54
  [package.metadata]
55
  requires-dist = [
 
56
  { name = "fastapi", specifier = ">=0.122.0" },
57
  { name = "httpx", specifier = ">=0.28.1" },
58
  { name = "psutil", specifier = ">=7.1.3" },
@@ -74,6 +76,45 @@ wheels = [
74
  { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
75
  ]
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  [[package]]
78
  name = "certifi"
79
  version = "2025.11.12"
@@ -186,6 +227,15 @@ wheels = [
186
  { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
187
  ]
188
 
 
 
 
 
 
 
 
 
 
189
  [[package]]
190
  name = "packaging"
191
  version = "25.0"
@@ -195,6 +245,24 @@ wheels = [
195
  { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
196
  ]
197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  [[package]]
199
  name = "pluggy"
200
  version = "1.6.0"
@@ -427,6 +495,15 @@ wheels = [
427
  { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
428
  ]
429
 
 
 
 
 
 
 
 
 
 
430
  [[package]]
431
  name = "starlette"
432
  version = "0.50.0"
 
39
  version = "0.1.0"
40
  source = { virtual = "." }
41
  dependencies = [
42
+ { name = "black" },
43
  { name = "fastapi" },
44
  { name = "httpx" },
45
  { name = "psutil" },
 
54
 
55
  [package.metadata]
56
  requires-dist = [
57
+ { name = "black", specifier = ">=25.11.0" },
58
  { name = "fastapi", specifier = ">=0.122.0" },
59
  { name = "httpx", specifier = ">=0.28.1" },
60
  { name = "psutil", specifier = ">=7.1.3" },
 
76
  { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
77
  ]
78
 
79
+ [[package]]
80
+ name = "black"
81
+ version = "25.11.0"
82
+ source = { registry = "https://pypi.org/simple" }
83
+ dependencies = [
84
+ { name = "click" },
85
+ { name = "mypy-extensions" },
86
+ { name = "packaging" },
87
+ { name = "pathspec" },
88
+ { name = "platformdirs" },
89
+ { name = "pytokens" },
90
+ { name = "tomli", marker = "python_full_version < '3.11'" },
91
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
92
+ ]
93
+ sdist = { url = "https://files.pythonhosted.org/packages/8c/ad/33adf4708633d047950ff2dfdea2e215d84ac50ef95aff14a614e4b6e9b2/black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08", size = 655669, upload-time = "2025-11-10T01:53:50.558Z" }
94
+ wheels = [
95
+ { url = "https://files.pythonhosted.org/packages/b3/d2/6caccbc96f9311e8ec3378c296d4f4809429c43a6cd2394e3c390e86816d/black-25.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ec311e22458eec32a807f029b2646f661e6859c3f61bc6d9ffb67958779f392e", size = 1743501, upload-time = "2025-11-10T01:59:06.202Z" },
96
+ { url = "https://files.pythonhosted.org/packages/69/35/b986d57828b3f3dccbf922e2864223197ba32e74c5004264b1c62bc9f04d/black-25.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1032639c90208c15711334d681de2e24821af0575573db2810b0763bcd62e0f0", size = 1597308, upload-time = "2025-11-10T01:57:58.633Z" },
97
+ { url = "https://files.pythonhosted.org/packages/39/8e/8b58ef4b37073f52b64a7b2dd8c9a96c84f45d6f47d878d0aa557e9a2d35/black-25.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0f7c461df55cf32929b002335883946a4893d759f2df343389c4396f3b6b37", size = 1656194, upload-time = "2025-11-10T01:57:10.909Z" },
98
+ { url = "https://files.pythonhosted.org/packages/8d/30/9c2267a7955ecc545306534ab88923769a979ac20a27cf618d370091e5dd/black-25.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:f9786c24d8e9bd5f20dc7a7f0cdd742644656987f6ea6947629306f937726c03", size = 1347996, upload-time = "2025-11-10T01:57:22.391Z" },
99
+ { url = "https://files.pythonhosted.org/packages/c4/62/d304786b75ab0c530b833a89ce7d997924579fb7484ecd9266394903e394/black-25.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:895571922a35434a9d8ca67ef926da6bc9ad464522a5fe0db99b394ef1c0675a", size = 1727891, upload-time = "2025-11-10T02:01:40.507Z" },
100
+ { url = "https://files.pythonhosted.org/packages/82/5d/ffe8a006aa522c9e3f430e7b93568a7b2163f4b3f16e8feb6d8c3552761a/black-25.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb4f4b65d717062191bdec8e4a442539a8ea065e6af1c4f4d36f0cdb5f71e170", size = 1581875, upload-time = "2025-11-10T01:57:51.192Z" },
101
+ { url = "https://files.pythonhosted.org/packages/cb/c8/7c8bda3108d0bb57387ac41b4abb5c08782b26da9f9c4421ef6694dac01a/black-25.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d81a44cbc7e4f73a9d6ae449ec2317ad81512d1e7dce7d57f6333fd6259737bc", size = 1642716, upload-time = "2025-11-10T01:56:51.589Z" },
102
+ { url = "https://files.pythonhosted.org/packages/34/b9/f17dea34eecb7cc2609a89627d480fb6caea7b86190708eaa7eb15ed25e7/black-25.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:7eebd4744dfe92ef1ee349dc532defbf012a88b087bb7ddd688ff59a447b080e", size = 1352904, upload-time = "2025-11-10T01:59:26.252Z" },
103
+ { url = "https://files.pythonhosted.org/packages/7f/12/5c35e600b515f35ffd737da7febdb2ab66bb8c24d88560d5e3ef3d28c3fd/black-25.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:80e7486ad3535636657aa180ad32a7d67d7c273a80e12f1b4bfa0823d54e8fac", size = 1772831, upload-time = "2025-11-10T02:03:47Z" },
104
+ { url = "https://files.pythonhosted.org/packages/1a/75/b3896bec5a2bb9ed2f989a970ea40e7062f8936f95425879bbe162746fe5/black-25.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cced12b747c4c76bc09b4db057c319d8545307266f41aaee665540bc0e04e96", size = 1608520, upload-time = "2025-11-10T01:58:46.895Z" },
105
+ { url = "https://files.pythonhosted.org/packages/f3/b5/2bfc18330eddbcfb5aab8d2d720663cd410f51b2ed01375f5be3751595b0/black-25.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb2d54a39e0ef021d6c5eef442e10fd71fcb491be6413d083a320ee768329dd", size = 1682719, upload-time = "2025-11-10T01:56:55.24Z" },
106
+ { url = "https://files.pythonhosted.org/packages/96/fb/f7dc2793a22cdf74a72114b5ed77fe3349a2e09ef34565857a2f917abdf2/black-25.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae263af2f496940438e5be1a0c1020e13b09154f3af4df0835ea7f9fe7bfa409", size = 1362684, upload-time = "2025-11-10T01:57:07.639Z" },
107
+ { url = "https://files.pythonhosted.org/packages/ad/47/3378d6a2ddefe18553d1115e36aea98f4a90de53b6a3017ed861ba1bd3bc/black-25.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a1d40348b6621cc20d3d7530a5b8d67e9714906dfd7346338249ad9c6cedf2b", size = 1772446, upload-time = "2025-11-10T02:02:16.181Z" },
108
+ { url = "https://files.pythonhosted.org/packages/ba/4b/0f00bfb3d1f7e05e25bfc7c363f54dc523bb6ba502f98f4ad3acf01ab2e4/black-25.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51c65d7d60bb25429ea2bf0731c32b2a2442eb4bd3b2afcb47830f0b13e58bfd", size = 1607983, upload-time = "2025-11-10T02:02:52.502Z" },
109
+ { url = "https://files.pythonhosted.org/packages/99/fe/49b0768f8c9ae57eb74cc10a1f87b4c70453551d8ad498959721cc345cb7/black-25.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:936c4dd07669269f40b497440159a221ee435e3fddcf668e0c05244a9be71993", size = 1682481, upload-time = "2025-11-10T01:57:12.35Z" },
110
+ { url = "https://files.pythonhosted.org/packages/55/17/7e10ff1267bfa950cc16f0a411d457cdff79678fbb77a6c73b73a5317904/black-25.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:f42c0ea7f59994490f4dccd64e6b2dd49ac57c7c84f38b8faab50f8759db245c", size = 1363869, upload-time = "2025-11-10T01:58:24.608Z" },
111
+ { url = "https://files.pythonhosted.org/packages/67/c0/cc865ce594d09e4cd4dfca5e11994ebb51604328489f3ca3ae7bb38a7db5/black-25.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35690a383f22dd3e468c85dc4b915217f87667ad9cce781d7b42678ce63c4170", size = 1771358, upload-time = "2025-11-10T02:03:33.331Z" },
112
+ { url = "https://files.pythonhosted.org/packages/37/77/4297114d9e2fd2fc8ab0ab87192643cd49409eb059e2940391e7d2340e57/black-25.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dae49ef7369c6caa1a1833fd5efb7c3024bb7e4499bf64833f65ad27791b1545", size = 1612902, upload-time = "2025-11-10T01:59:33.382Z" },
113
+ { url = "https://files.pythonhosted.org/packages/de/63/d45ef97ada84111e330b2b2d45e1dd163e90bd116f00ac55927fb6bf8adb/black-25.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bd4a22a0b37401c8e492e994bce79e614f91b14d9ea911f44f36e262195fdda", size = 1680571, upload-time = "2025-11-10T01:57:04.239Z" },
114
+ { url = "https://files.pythonhosted.org/packages/ff/4b/5604710d61cdff613584028b4cb4607e56e148801ed9b38ee7970799dab6/black-25.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa211411e94fdf86519996b7f5f05e71ba34835d8f0c0f03c00a26271da02664", size = 1382599, upload-time = "2025-11-10T01:57:57.427Z" },
115
+ { url = "https://files.pythonhosted.org/packages/00/5d/aed32636ed30a6e7f9efd6ad14e2a0b0d687ae7c8c7ec4e4a557174b895c/black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b", size = 204918, upload-time = "2025-11-10T01:53:48.917Z" },
116
+ ]
117
+
118
  [[package]]
119
  name = "certifi"
120
  version = "2025.11.12"
 
227
  { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
228
  ]
229
 
230
+ [[package]]
231
+ name = "mypy-extensions"
232
+ version = "1.1.0"
233
+ source = { registry = "https://pypi.org/simple" }
234
+ sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
235
+ wheels = [
236
+ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
237
+ ]
238
+
239
  [[package]]
240
  name = "packaging"
241
  version = "25.0"
 
245
  { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
246
  ]
247
 
248
+ [[package]]
249
+ name = "pathspec"
250
+ version = "0.12.1"
251
+ source = { registry = "https://pypi.org/simple" }
252
+ sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" }
253
+ wheels = [
254
+ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
255
+ ]
256
+
257
+ [[package]]
258
+ name = "platformdirs"
259
+ version = "4.5.1"
260
+ source = { registry = "https://pypi.org/simple" }
261
+ sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" }
262
+ wheels = [
263
+ { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
264
+ ]
265
+
266
  [[package]]
267
  name = "pluggy"
268
  version = "1.6.0"
 
495
  { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
496
  ]
497
 
498
+ [[package]]
499
+ name = "pytokens"
500
+ version = "0.3.0"
501
+ source = { registry = "https://pypi.org/simple" }
502
+ sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/a762be14dae1c3bf280202ba3172020b2b0b4c537f94427435f19c413b72/pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a", size = 17644, upload-time = "2025-11-05T13:36:35.34Z" }
503
+ wheels = [
504
+ { url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" },
505
+ ]
506
+
507
  [[package]]
508
  name = "starlette"
509
  version = "0.50.0"
frontend/index.html CHANGED
@@ -2,9 +2,9 @@
2
  <html lang="en">
3
  <head>
4
  <meta charset="UTF-8" />
5
- <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <title>frontend</title>
8
  </head>
9
  <body>
10
  <div id="root"></div>
 
2
  <html lang="en">
3
  <head>
4
  <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/graph.svg" />
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>AI Project</title>
8
  </head>
9
  <body>
10
  <div id="root"></div>
frontend/public/graph.svg ADDED
frontend/public/vite.svg DELETED
frontend/src/App.tsx CHANGED
@@ -115,7 +115,7 @@ function App() {
115
  className="gap-2"
116
  >
117
  <Play className="w-4 h-4" />
118
- {isLoading ? 'Running...' : 'Run Search'}
119
  </Button>
120
  <Button
121
  onClick={handleRunPlan}
 
115
  className="gap-2"
116
  >
117
  <Play className="w-4 h-4" />
118
+ {isLoading ? 'Running...' : 'Run Search for S1'}
119
  </Button>
120
  <Button
121
  onClick={handleRunPlan}
frontend/src/api/client.ts CHANGED
@@ -87,7 +87,7 @@ export async function findPath(
87
  cost: response.data.cost,
88
  nodesExpanded: response.data.nodes_expanded,
89
  runtimeMs: response.data.runtime_ms,
90
- memoryMb: response.data.memory_mb,
91
  cpuPercent: response.data.cpu_percent,
92
  path: response.data.path,
93
  steps: response.data.steps,
@@ -113,7 +113,7 @@ export async function createPlan(
113
  totalCost: response.data.total_cost,
114
  totalNodesExpanded: response.data.total_nodes_expanded,
115
  runtimeMs: response.data.runtime_ms,
116
- memoryMb: response.data.memory_mb,
117
  cpuPercent: response.data.cpu_percent,
118
  };
119
  }
@@ -135,7 +135,7 @@ export async function compareAlgorithms(
135
  cost: c.cost,
136
  nodesExpanded: c.nodes_expanded,
137
  runtimeMs: c.runtime_ms,
138
- memoryMb: c.memory_mb,
139
  cpuPercent: c.cpu_percent,
140
  isOptimal: c.is_optimal,
141
  })),
 
87
  cost: response.data.cost,
88
  nodesExpanded: response.data.nodes_expanded,
89
  runtimeMs: response.data.runtime_ms,
90
+ memoryKb: response.data.memory_kb,
91
  cpuPercent: response.data.cpu_percent,
92
  path: response.data.path,
93
  steps: response.data.steps,
 
113
  totalCost: response.data.total_cost,
114
  totalNodesExpanded: response.data.total_nodes_expanded,
115
  runtimeMs: response.data.runtime_ms,
116
+ memoryKb: response.data.memory_kb,
117
  cpuPercent: response.data.cpu_percent,
118
  };
119
  }
 
135
  cost: c.cost,
136
  nodesExpanded: c.nodes_expanded,
137
  runtimeMs: c.runtime_ms,
138
+ memoryKb: c.memory_kb,
139
  cpuPercent: c.cpu_percent,
140
  isOptimal: c.is_optimal,
141
  })),
frontend/src/components/Grid/Grid.tsx CHANGED
@@ -1,6 +1,7 @@
1
  import React, { useMemo } from 'react';
2
  import { useGridStore } from '../../store/gridStore';
3
- import { Map } from 'lucide-react';
 
4
  import type { SearchStep } from '../../types';
5
 
6
  const CELL_SIZE = 90;
@@ -46,7 +47,7 @@ const COLORS = {
46
  };
47
 
48
  export const Grid: React.FC = () => {
49
- const { grid, steps, currentStep, searchResult } = useGridStore();
50
 
51
  const stepData = useMemo(() => {
52
  const step: SearchStep | null = steps[currentStep] || null;
@@ -59,12 +60,41 @@ export const Grid: React.FC = () => {
59
  };
60
  }, [steps, currentStep, searchResult]);
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  if (!grid) {
63
  return (
64
  <div className="flex items-center justify-center h-full" style={{ backgroundColor: '#0a0a0b' }}>
65
  <div className="text-center">
66
  <div className="w-20 h-20 rounded-2xl bg-zinc-900 border border-zinc-800 flex items-center justify-center mx-auto mb-6">
67
- <Map className="w-10 h-10 text-zinc-700" />
68
  </div>
69
  <p className="text-zinc-400 text-sm font-medium">No Grid Generated</p>
70
  <p className="text-zinc-600 text-xs mt-2">Configure and generate a grid from the sidebar</p>
@@ -77,14 +107,19 @@ export const Grid: React.FC = () => {
77
  const height = grid.height * CELL_SIZE + PADDING * 2;
78
 
79
  const { step, exploredSet, frontierSet, pathSet, finalPathSet } = stepData;
 
80
 
81
- const getNodeState = (x: number, y: number): string => {
82
  const key = `${x},${y}`;
83
- if (step?.currentNode.x === x && step?.currentNode.y === y) return 'current';
84
- if (pathSet.has(key) || (!step && finalPathSet.has(key))) return 'path';
85
- if (frontierSet.has(key)) return 'frontier';
86
- if (exploredSet.has(key)) return 'explored';
87
- return 'default';
 
 
 
 
88
  };
89
 
90
  const getEdgeStyle = (traffic: number) => {
@@ -340,12 +375,34 @@ export const Grid: React.FC = () => {
340
  );
341
  })}
342
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  {/* Nodes */}
344
  {Array.from({ length: grid.width }).map((_, x) =>
345
  Array.from({ length: grid.height }).map((_, y) => {
346
  const cx = toSvgX(x);
347
  const cy = toSvgY(y);
348
- const state = getNodeState(x, y);
 
349
 
350
  let fill = COLORS.nodeDefault;
351
  let stroke = COLORS.nodeDefaultStroke;
@@ -376,6 +433,11 @@ export const Grid: React.FC = () => {
376
  fill = COLORS.explored;
377
  stroke = COLORS.exploredLight;
378
  break;
 
 
 
 
 
379
  }
380
 
381
  return (
 
1
  import React, { useMemo } from 'react';
2
  import { useGridStore } from '../../store/gridStore';
3
+ import type { ColoredPath } from '../../store/gridStore';
4
+ import { Map as MapIcon } from 'lucide-react';
5
  import type { SearchStep } from '../../types';
6
 
7
  const CELL_SIZE = 90;
 
47
  };
48
 
49
  export const Grid: React.FC = () => {
50
+ const { grid, steps, currentStep, searchResult, showPlanPaths, planPaths } = useGridStore();
51
 
52
  const stepData = useMemo(() => {
53
  const step: SearchStep | null = steps[currentStep] || null;
 
60
  };
61
  }, [steps, currentStep, searchResult]);
62
 
63
+ // Build a map of position -> color for plan paths visualization
64
+ const planPathData = useMemo(() => {
65
+ if (!showPlanPaths || planPaths.length === 0) {
66
+ return { pathColorMap: new Map<string, string>(), pathEdges: [] as { from: string; to: string; color: string }[] };
67
+ }
68
+
69
+ const pathColorMap = new Map<string, string>();
70
+ const pathEdges: { from: string; to: string; color: string }[] = [];
71
+
72
+ planPaths.forEach((coloredPath: ColoredPath) => {
73
+ const path = coloredPath.path;
74
+ for (let i = 0; i < path.length; i++) {
75
+ const key = `${path[i].x},${path[i].y}`;
76
+ // First path to claim a position gets to color it
77
+ if (!pathColorMap.has(key)) {
78
+ pathColorMap.set(key, coloredPath.color);
79
+ }
80
+ // Build edges
81
+ if (i < path.length - 1) {
82
+ const fromKey = `${path[i].x},${path[i].y}`;
83
+ const toKey = `${path[i + 1].x},${path[i + 1].y}`;
84
+ pathEdges.push({ from: fromKey, to: toKey, color: coloredPath.color });
85
+ }
86
+ }
87
+ });
88
+
89
+ return { pathColorMap, pathEdges };
90
+ }, [showPlanPaths, planPaths]);
91
+
92
  if (!grid) {
93
  return (
94
  <div className="flex items-center justify-center h-full" style={{ backgroundColor: '#0a0a0b' }}>
95
  <div className="text-center">
96
  <div className="w-20 h-20 rounded-2xl bg-zinc-900 border border-zinc-800 flex items-center justify-center mx-auto mb-6">
97
+ <MapIcon className="w-10 h-10 text-zinc-700" />
98
  </div>
99
  <p className="text-zinc-400 text-sm font-medium">No Grid Generated</p>
100
  <p className="text-zinc-600 text-xs mt-2">Configure and generate a grid from the sidebar</p>
 
107
  const height = grid.height * CELL_SIZE + PADDING * 2;
108
 
109
  const { step, exploredSet, frontierSet, pathSet, finalPathSet } = stepData;
110
+ const { pathColorMap, pathEdges } = planPathData;
111
 
112
+ const getNodeState = (x: number, y: number): { state: string; color?: string } => {
113
  const key = `${x},${y}`;
114
+ if (step?.currentNode.x === x && step?.currentNode.y === y) return { state: 'current' };
115
+ if (pathSet.has(key) || (!step && finalPathSet.has(key))) return { state: 'path' };
116
+ if (frontierSet.has(key)) return { state: 'frontier' };
117
+ if (exploredSet.has(key)) return { state: 'explored' };
118
+ // Check if this node is part of a plan path
119
+ if (showPlanPaths && pathColorMap.has(key)) {
120
+ return { state: 'planPath', color: pathColorMap.get(key) };
121
+ }
122
+ return { state: 'default' };
123
  };
124
 
125
  const getEdgeStyle = (traffic: number) => {
 
375
  );
376
  })}
377
 
378
+ {/* Plan Path Edges (rendered before nodes) */}
379
+ {showPlanPaths && pathEdges.map((edge, i) => {
380
+ const [fromX, fromY] = edge.from.split(',').map(Number);
381
+ const [toX, toY] = edge.to.split(',').map(Number);
382
+ const x1 = toSvgX(fromX);
383
+ const y1 = toSvgY(fromY);
384
+ const x2 = toSvgX(toX);
385
+ const y2 = toSvgY(toY);
386
+
387
+ return (
388
+ <line
389
+ key={`plan-edge-${i}`}
390
+ x1={x1} y1={y1} x2={x2} y2={y2}
391
+ stroke={edge.color}
392
+ strokeWidth={5}
393
+ strokeLinecap="round"
394
+ opacity={0.8}
395
+ />
396
+ );
397
+ })}
398
+
399
  {/* Nodes */}
400
  {Array.from({ length: grid.width }).map((_, x) =>
401
  Array.from({ length: grid.height }).map((_, y) => {
402
  const cx = toSvgX(x);
403
  const cy = toSvgY(y);
404
+ const nodeState = getNodeState(x, y);
405
+ const state = nodeState.state;
406
 
407
  let fill = COLORS.nodeDefault;
408
  let stroke = COLORS.nodeDefaultStroke;
 
433
  fill = COLORS.explored;
434
  stroke = COLORS.exploredLight;
435
  break;
436
+ case 'planPath':
437
+ fill = nodeState.color || COLORS.path;
438
+ stroke = nodeState.color || COLORS.pathLight;
439
+ strokeWidth = 2;
440
+ break;
441
  }
442
 
443
  return (
frontend/src/components/Info/GroupInfo.tsx CHANGED
@@ -1,5 +1,5 @@
1
  import React from 'react';
2
- import { BookOpen, Users, Code, FileText, GraduationCap, Calendar, Building } from 'lucide-react';
3
 
4
  export const GroupInfo: React.FC = () => {
5
  return (
@@ -16,7 +16,7 @@ export const GroupInfo: React.FC = () => {
16
  </h1>
17
  <div className="flex items-center justify-center gap-2 text-xs text-zinc-500 mt-6">
18
  <Calendar className="w-3.5 h-3.5" />
19
- <span>Academic Year 2024-2025</span>
20
  </div>
21
  </header>
22
 
@@ -24,14 +24,14 @@ export const GroupInfo: React.FC = () => {
24
  <section className="mb-10">
25
  <div className="flex items-center gap-2 mb-4">
26
  <Users className="w-4 h-4 text-zinc-500" />
27
- <h2 className="text-sm font-semibold text-zinc-300 uppercase tracking-wider">Authors</h2>
28
  </div>
29
  <div className="grid grid-cols-2 gap-4">
30
  {[
31
- { name: 'Kacem Mathlouthi', role: 'Developer' },
32
- { name: 'Mohamed Amine Houas', role: 'Developer' },
33
- { name: 'Oussema Kraiem', role: 'Developer' },
34
- { name: 'Alaeddine El Zaouali', role: 'Developer' },
35
  ].map((author) => (
36
  <div key={author.name} className="bg-zinc-900/50 border border-zinc-800 rounded-lg p-4">
37
  <p className="text-zinc-200 font-medium">{author.name}</p>
@@ -83,7 +83,7 @@ export const GroupInfo: React.FC = () => {
83
  <div className="space-y-3">
84
  <div>
85
  <p className="text-zinc-500 text-xs uppercase tracking-wider mb-1">Visualization</p>
86
- <p className="text-zinc-300">SVG + Recharts</p>
87
  </div>
88
  <div>
89
  <p className="text-zinc-500 text-xs uppercase tracking-wider mb-1">Styling</p>
@@ -91,7 +91,7 @@ export const GroupInfo: React.FC = () => {
91
  </div>
92
  <div>
93
  <p className="text-zinc-500 text-xs uppercase tracking-wider mb-1">Communication</p>
94
- <p className="text-zinc-300">REST API + WebSocket</p>
95
  </div>
96
  </div>
97
  </div>
@@ -160,40 +160,8 @@ export const GroupInfo: React.FC = () => {
160
  </div>
161
  </section>
162
 
163
- {/* References */}
164
- <section className="mb-10">
165
- <div className="flex items-center gap-2 mb-4">
166
- <BookOpen className="w-4 h-4 text-zinc-500" />
167
- <h2 className="text-sm font-semibold text-zinc-300 uppercase tracking-wider">References</h2>
168
- </div>
169
- <div className="space-y-3 text-sm">
170
- <div className="flex gap-3">
171
- <span className="text-zinc-600 font-mono">[1]</span>
172
- <p className="text-zinc-400">
173
- Russell, S., & Norvig, P. (2020). <span className="italic">Artificial Intelligence: A Modern Approach</span> (4th ed.). Pearson.
174
- </p>
175
- </div>
176
- <div className="flex gap-3">
177
- <span className="text-zinc-600 font-mono">[2]</span>
178
- <p className="text-zinc-400">
179
- Hart, P. E., Nilsson, N. J., & Raphael, B. (1968). A Formal Basis for the Heuristic Determination of Minimum Cost Paths. <span className="italic">IEEE Transactions on Systems Science and Cybernetics</span>, 4(2), 100-107.
180
- </p>
181
- </div>
182
- <div className="flex gap-3">
183
- <span className="text-zinc-600 font-mono">[3]</span>
184
- <p className="text-zinc-400">
185
- Korf, R. E. (1985). Depth-first iterative-deepening: An optimal admissible tree search. <span className="italic">Artificial Intelligence</span>, 27(1), 97-109.
186
- </p>
187
- </div>
188
- </div>
189
- </section>
190
-
191
  {/* Footer */}
192
  <footer className="pt-8 border-t border-zinc-800 text-center">
193
- <div className="flex items-center justify-center gap-2 text-xs text-zinc-600">
194
- <Building className="w-3.5 h-3.5" />
195
- <span>Department of Computer Science</span>
196
- </div>
197
  <p className="text-xs text-zinc-700 mt-2">
198
  This project was developed as part of the Artificial Intelligence curriculum
199
  </p>
 
1
  import React from 'react';
2
+ import { BookOpen, Users, Code, FileText, GraduationCap, Calendar } from 'lucide-react';
3
 
4
  export const GroupInfo: React.FC = () => {
5
  return (
 
16
  </h1>
17
  <div className="flex items-center justify-center gap-2 text-xs text-zinc-500 mt-6">
18
  <Calendar className="w-3.5 h-3.5" />
19
+ <span>Academic Year 2025-2026</span>
20
  </div>
21
  </header>
22
 
 
24
  <section className="mb-10">
25
  <div className="flex items-center gap-2 mb-4">
26
  <Users className="w-4 h-4 text-zinc-500" />
27
+ <h2 className="text-sm font-semibold text-zinc-300 uppercase tracking-wider">Team Members</h2>
28
  </div>
29
  <div className="grid grid-cols-2 gap-4">
30
  {[
31
+ { name: 'Kacem Mathlouthi', role: 'GL 4/2' },
32
+ { name: 'Mohamed Amine Houas', role: 'GL 4/1' },
33
+ { name: 'Oussema Kraiem', role: 'GL 4/2' },
34
+ { name: 'Alaeddine El Zaouali', role: 'GL 4/2' },
35
  ].map((author) => (
36
  <div key={author.name} className="bg-zinc-900/50 border border-zinc-800 rounded-lg p-4">
37
  <p className="text-zinc-200 font-medium">{author.name}</p>
 
83
  <div className="space-y-3">
84
  <div>
85
  <p className="text-zinc-500 text-xs uppercase tracking-wider mb-1">Visualization</p>
86
+ <p className="text-zinc-300">SVG + ShadCN</p>
87
  </div>
88
  <div>
89
  <p className="text-zinc-500 text-xs uppercase tracking-wider mb-1">Styling</p>
 
91
  </div>
92
  <div>
93
  <p className="text-zinc-500 text-xs uppercase tracking-wider mb-1">Communication</p>
94
+ <p className="text-zinc-300">REST API</p>
95
  </div>
96
  </div>
97
  </div>
 
160
  </div>
161
  </section>
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  {/* Footer */}
164
  <footer className="pt-8 border-t border-zinc-800 text-center">
 
 
 
 
165
  <p className="text-xs text-zinc-700 mt-2">
166
  This project was developed as part of the Artificial Intelligence curriculum
167
  </p>
frontend/src/components/Stats/ComparisonDashboard.tsx CHANGED
@@ -42,7 +42,7 @@ export const ComparisonDashboard: React.FC = () => {
42
  nodesExpanded: r.nodesExpanded,
43
  runtime: r.runtimeMs,
44
  cost: r.cost === Infinity ? 0 : r.cost,
45
- memory: r.memoryMb,
46
  isOptimal: r.isOptimal,
47
  }));
48
 
@@ -306,7 +306,7 @@ export const ComparisonDashboard: React.FC = () => {
306
  {result.runtimeMs.toFixed(2)}ms
307
  </td>
308
  <td className="px-4 py-3 text-right font-mono text-zinc-400">
309
- {result.memoryMb.toFixed(3)}MB
310
  </td>
311
  <td className="px-4 py-3 text-center">
312
  {result.isOptimal && (
 
42
  nodesExpanded: r.nodesExpanded,
43
  runtime: r.runtimeMs,
44
  cost: r.cost === Infinity ? 0 : r.cost,
45
+ memory: r.memoryKb,
46
  isOptimal: r.isOptimal,
47
  }));
48
 
 
306
  {result.runtimeMs.toFixed(2)}ms
307
  </td>
308
  <td className="px-4 py-3 text-right font-mono text-zinc-400">
309
+ {result.memoryKb.toFixed(2)}KB
310
  </td>
311
  <td className="px-4 py-3 text-center">
312
  {result.isOptimal && (
frontend/src/components/Stats/MetricsPanel.tsx CHANGED
@@ -30,7 +30,7 @@ export const MetricsPanel: React.FC = () => {
30
  },
31
  {
32
  label: 'Memory',
33
- value: `${result.memoryMb.toFixed(2)}MB`,
34
  icon: HardDrive,
35
  },
36
  ];
 
30
  },
31
  {
32
  label: 'Memory',
33
+ value: `${result.memoryKb.toFixed(2)}KB`,
34
  icon: HardDrive,
35
  },
36
  ];
frontend/src/components/Stats/PlanResultsModal.tsx CHANGED
@@ -12,15 +12,31 @@ import {
12
  HardDrive,
13
  ArrowRight,
14
  CheckCircle,
 
 
15
  } from 'lucide-react';
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  interface PlanResultsModalProps {
18
  isOpen: boolean;
19
  onClose: () => void;
20
  }
21
 
22
  export const PlanResultsModal: React.FC<PlanResultsModalProps> = ({ isOpen, onClose }) => {
23
- const { planResult, selectedAlgorithm } = useGridStore();
24
 
25
  if (!isOpen || !planResult) return null;
26
 
@@ -94,7 +110,7 @@ export const PlanResultsModal: React.FC<PlanResultsModalProps> = ({ isOpen, onCl
94
  Memory
95
  </div>
96
  <p className="text-lg font-semibold text-zinc-200 font-mono">
97
- {planResult.memoryMb.toFixed(2)}MB
98
  </p>
99
  </CardContent>
100
  </Card>
@@ -124,42 +140,60 @@ export const PlanResultsModal: React.FC<PlanResultsModalProps> = ({ isOpen, onCl
124
  {planResult.assignments.length === 0 ? (
125
  <p className="text-zinc-500 text-sm">No assignments made</p>
126
  ) : (
127
- planResult.assignments.map((assignment, index) => (
128
- <div
129
- key={index}
130
- className="bg-zinc-800/50 rounded-lg p-3 border border-zinc-800"
131
- >
132
- <div className="flex items-center justify-between mb-2">
133
- <div className="flex items-center gap-3">
134
- <div className="flex items-center gap-2">
135
- <div className="w-6 h-6 rounded bg-blue-500/20 border border-blue-500/30 flex items-center justify-center">
136
- <span className="text-xs font-mono text-blue-400">S{assignment.store_id}</span>
137
- </div>
138
- <ArrowRight className="w-4 h-4 text-zinc-600" />
139
- <div className="w-6 h-6 rounded-full bg-emerald-500/20 border border-emerald-500/30 flex items-center justify-center">
140
- <span className="text-xs font-mono text-emerald-400">D{assignment.destination_id}</span>
 
 
 
 
 
 
 
 
 
141
  </div>
 
 
 
 
 
142
  </div>
143
- <CheckCircle className="w-4 h-4 text-emerald-500" />
144
  </div>
145
- <div className="flex items-center gap-4 text-xs text-zinc-500">
146
- <span>Cost: <span className="text-zinc-300 font-mono">{assignment.path.cost}</span></span>
147
- <span>Nodes: <span className="text-zinc-300 font-mono">{assignment.path.nodes_expanded}</span></span>
148
  </div>
149
  </div>
150
- <div className="text-xs text-zinc-500">
151
- <span className="text-zinc-600">Path: </span>
152
- <span className="font-mono text-zinc-400">{assignment.path.plan || 'Direct'}</span>
153
- </div>
154
- </div>
155
- ))
156
  )}
157
  </CardContent>
158
  </Card>
159
  </div>
160
 
161
  {/* Footer */}
162
- <div className="px-6 py-3 border-t border-zinc-800 bg-zinc-800/20 flex justify-end">
 
 
 
 
 
 
 
 
 
163
  <Button variant="secondary" size="sm" onClick={onClose}>
164
  Close
165
  </Button>
 
12
  HardDrive,
13
  ArrowRight,
14
  CheckCircle,
15
+ Eye,
16
+ EyeOff,
17
  } from 'lucide-react';
18
 
19
+ // Colors matching the store
20
+ const PATH_COLORS = [
21
+ '#f97316', // orange
22
+ '#06b6d4', // cyan
23
+ '#ec4899', // pink
24
+ '#84cc16', // lime
25
+ '#a855f7', // purple
26
+ '#14b8a6', // teal
27
+ '#f43f5e', // rose
28
+ '#eab308', // yellow
29
+ '#6366f1', // indigo
30
+ '#22c55e', // green
31
+ ];
32
+
33
  interface PlanResultsModalProps {
34
  isOpen: boolean;
35
  onClose: () => void;
36
  }
37
 
38
  export const PlanResultsModal: React.FC<PlanResultsModalProps> = ({ isOpen, onClose }) => {
39
+ const { planResult, selectedAlgorithm, showPlanPaths, setShowPlanPaths } = useGridStore();
40
 
41
  if (!isOpen || !planResult) return null;
42
 
 
110
  Memory
111
  </div>
112
  <p className="text-lg font-semibold text-zinc-200 font-mono">
113
+ {planResult.memoryKb.toFixed(2)}KB
114
  </p>
115
  </CardContent>
116
  </Card>
 
140
  {planResult.assignments.length === 0 ? (
141
  <p className="text-zinc-500 text-sm">No assignments made</p>
142
  ) : (
143
+ planResult.assignments.map((assignment, index) => {
144
+ const pathColor = PATH_COLORS[index % PATH_COLORS.length];
145
+ return (
146
+ <div
147
+ key={index}
148
+ className="bg-zinc-800/50 rounded-lg p-3 border border-zinc-800"
149
+ style={{ borderLeftWidth: 4, borderLeftColor: pathColor }}
150
+ >
151
+ <div className="flex items-center justify-between mb-2">
152
+ <div className="flex items-center gap-3">
153
+ {/* Color indicator */}
154
+ <div
155
+ className="w-3 h-3 rounded-full"
156
+ style={{ backgroundColor: pathColor }}
157
+ />
158
+ <div className="flex items-center gap-2">
159
+ <div className="w-6 h-6 rounded bg-blue-500/20 border border-blue-500/30 flex items-center justify-center">
160
+ <span className="text-xs font-mono text-blue-400">S{assignment.store_id}</span>
161
+ </div>
162
+ <ArrowRight className="w-4 h-4 text-zinc-600" />
163
+ <div className="w-6 h-6 rounded-full bg-emerald-500/20 border border-emerald-500/30 flex items-center justify-center">
164
+ <span className="text-xs font-mono text-emerald-400">D{assignment.destination_id}</span>
165
+ </div>
166
  </div>
167
+ <CheckCircle className="w-4 h-4 text-emerald-500" />
168
+ </div>
169
+ <div className="flex items-center gap-4 text-xs text-zinc-500">
170
+ <span>Cost: <span className="text-zinc-300 font-mono">{assignment.path.cost}</span></span>
171
+ <span>Nodes: <span className="text-zinc-300 font-mono">{assignment.path.nodes_expanded}</span></span>
172
  </div>
 
173
  </div>
174
+ <div className="text-xs text-zinc-500">
175
+ <span className="text-zinc-600">Path: </span>
176
+ <span className="font-mono text-zinc-400">{assignment.path.plan || 'Direct'}</span>
177
  </div>
178
  </div>
179
+ );
180
+ })
 
 
 
 
181
  )}
182
  </CardContent>
183
  </Card>
184
  </div>
185
 
186
  {/* Footer */}
187
+ <div className="px-6 py-3 border-t border-zinc-800 bg-zinc-800/20 flex justify-between">
188
+ <Button
189
+ variant={showPlanPaths ? 'primary' : 'outline'}
190
+ size="sm"
191
+ onClick={() => setShowPlanPaths(!showPlanPaths)}
192
+ className="gap-2"
193
+ >
194
+ {showPlanPaths ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
195
+ {showPlanPaths ? 'Hide Paths on Grid' : 'Show Paths on Grid'}
196
+ </Button>
197
  <Button variant="secondary" size="sm" onClick={onClose}>
198
  Close
199
  </Button>
frontend/src/store/gridStore.ts CHANGED
@@ -7,9 +7,18 @@ import type {
7
  Algorithm,
8
  SearchStep,
9
  GridConfig,
 
10
  } from '../types';
11
  import { generateGrid, createPlan, compareAlgorithms, findPath } from '../api/client';
12
 
 
 
 
 
 
 
 
 
13
  interface GridStore {
14
  // Grid state
15
  grid: GridState | null;
@@ -27,6 +36,10 @@ interface GridStore {
27
  isPlaying: boolean;
28
  playbackSpeed: number; // ms per step
29
 
 
 
 
 
30
  // UI state
31
  selectedAlgorithm: Algorithm;
32
  isLoading: boolean;
@@ -46,8 +59,24 @@ interface GridStore {
46
  reset: () => void;
47
  nextStep: () => void;
48
  prevStep: () => void;
 
 
49
  }
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  export const useGridStore = create<GridStore>((set, get) => ({
52
  // Initial state
53
  grid: null,
@@ -60,6 +89,8 @@ export const useGridStore = create<GridStore>((set, get) => ({
60
  steps: [],
61
  isPlaying: false,
62
  playbackSpeed: 100,
 
 
63
  selectedAlgorithm: 'BF',
64
  isLoading: false,
65
  error: null,
@@ -76,6 +107,8 @@ export const useGridStore = create<GridStore>((set, get) => ({
76
  currentStep: 0,
77
  steps: [],
78
  isPlaying: false,
 
 
79
  error: null,
80
  });
81
  },
@@ -94,6 +127,8 @@ export const useGridStore = create<GridStore>((set, get) => ({
94
  currentStep: 0,
95
  steps: [],
96
  isPlaying: false,
 
 
97
  isLoading: false,
98
  });
99
  } catch (error) {
@@ -108,7 +143,7 @@ export const useGridStore = create<GridStore>((set, get) => ({
108
  const { grid, selectedAlgorithm } = get();
109
  if (!grid) return;
110
 
111
- set({ isLoading: true, error: null });
112
  try {
113
  const result = await findPath(
114
  grid.width,
@@ -141,8 +176,19 @@ export const useGridStore = create<GridStore>((set, get) => ({
141
  set({ isLoading: true, error: null });
142
  try {
143
  const result = await createPlan(initialState, traffic, selectedAlgorithm, visualize);
 
 
 
 
 
 
 
 
 
144
  set({
145
  planResult: result,
 
 
146
  isLoading: false,
147
  });
148
  } catch (error) {
@@ -215,4 +261,12 @@ export const useGridStore = create<GridStore>((set, get) => ({
215
  set({ currentStep: currentStep - 1 });
216
  }
217
  },
 
 
 
 
 
 
 
 
218
  }));
 
7
  Algorithm,
8
  SearchStep,
9
  GridConfig,
10
+ Position,
11
  } from '../types';
12
  import { generateGrid, createPlan, compareAlgorithms, findPath } from '../api/client';
13
 
14
+ // Path with color for visualization
15
+ export interface ColoredPath {
16
+ storeId: number;
17
+ destinationId: number;
18
+ path: Position[];
19
+ color: string;
20
+ }
21
+
22
  interface GridStore {
23
  // Grid state
24
  grid: GridState | null;
 
36
  isPlaying: boolean;
37
  playbackSpeed: number; // ms per step
38
 
39
+ // Plan visualization
40
+ showPlanPaths: boolean;
41
+ planPaths: ColoredPath[];
42
+
43
  // UI state
44
  selectedAlgorithm: Algorithm;
45
  isLoading: boolean;
 
59
  reset: () => void;
60
  nextStep: () => void;
61
  prevStep: () => void;
62
+ setShowPlanPaths: (show: boolean) => void;
63
+ clearPlanPaths: () => void;
64
  }
65
 
66
+ // Colors for different delivery paths
67
+ const PATH_COLORS = [
68
+ '#f97316', // orange
69
+ '#06b6d4', // cyan
70
+ '#ec4899', // pink
71
+ '#84cc16', // lime
72
+ '#a855f7', // purple
73
+ '#14b8a6', // teal
74
+ '#f43f5e', // rose
75
+ '#eab308', // yellow
76
+ '#6366f1', // indigo
77
+ '#22c55e', // green
78
+ ];
79
+
80
  export const useGridStore = create<GridStore>((set, get) => ({
81
  // Initial state
82
  grid: null,
 
89
  steps: [],
90
  isPlaying: false,
91
  playbackSpeed: 100,
92
+ showPlanPaths: false,
93
+ planPaths: [],
94
  selectedAlgorithm: 'BF',
95
  isLoading: false,
96
  error: null,
 
107
  currentStep: 0,
108
  steps: [],
109
  isPlaying: false,
110
+ showPlanPaths: false,
111
+ planPaths: [],
112
  error: null,
113
  });
114
  },
 
127
  currentStep: 0,
128
  steps: [],
129
  isPlaying: false,
130
+ showPlanPaths: false,
131
+ planPaths: [],
132
  isLoading: false,
133
  });
134
  } catch (error) {
 
143
  const { grid, selectedAlgorithm } = get();
144
  if (!grid) return;
145
 
146
+ set({ isLoading: true, error: null, showPlanPaths: false, planPaths: [] });
147
  try {
148
  const result = await findPath(
149
  grid.width,
 
176
  set({ isLoading: true, error: null });
177
  try {
178
  const result = await createPlan(initialState, traffic, selectedAlgorithm, visualize);
179
+
180
+ // Extract paths for visualization with different colors
181
+ const coloredPaths: ColoredPath[] = result.assignments.map((assignment, index) => ({
182
+ storeId: assignment.store_id,
183
+ destinationId: assignment.destination_id,
184
+ path: assignment.path.path,
185
+ color: PATH_COLORS[index % PATH_COLORS.length],
186
+ }));
187
+
188
  set({
189
  planResult: result,
190
+ planPaths: coloredPaths,
191
+ showPlanPaths: true,
192
  isLoading: false,
193
  });
194
  } catch (error) {
 
261
  set({ currentStep: currentStep - 1 });
262
  }
263
  },
264
+
265
+ setShowPlanPaths: (show) => {
266
+ set({ showPlanPaths: show });
267
+ },
268
+
269
+ clearPlanPaths: () => {
270
+ set({ showPlanPaths: false, planPaths: [] });
271
+ },
272
  }));