Spaces:
Sleeping
Sleeping
Simple deployment: Grid Search Pathfinding with frontend and backend
Browse files- Minimal Dockerfile using Node base image with Python
- Runs frontend (Vite preview) on port 7860
- Runs backend (FastAPI) on port 8000
- Simple startup script to launch both services
This view is limited to 50 files because it contains too many changes.
See raw diff
- .gitignore +27 -0
- Dockerfile +43 -0
- README.md +21 -5
- backend/.dockerignore +46 -0
- backend/.python-version +1 -0
- backend/app/__init__.py +0 -0
- backend/app/algorithms/__init__.py +22 -0
- backend/app/algorithms/astar.py +186 -0
- backend/app/algorithms/bfs.py +160 -0
- backend/app/algorithms/dfs.py +162 -0
- backend/app/algorithms/greedy.py +181 -0
- backend/app/algorithms/ids.py +255 -0
- backend/app/algorithms/ucs.py +166 -0
- backend/app/api/__init__.py +5 -0
- backend/app/api/routes.py +270 -0
- backend/app/core/__init__.py +19 -0
- backend/app/core/delivery_planner.py +197 -0
- backend/app/core/delivery_search.py +211 -0
- backend/app/core/frontier.py +182 -0
- backend/app/core/generic_search.py +303 -0
- backend/app/core/node.py +119 -0
- backend/app/heuristics/__init__.py +14 -0
- backend/app/heuristics/euclidean.py +24 -0
- backend/app/heuristics/manhattan.py +22 -0
- backend/app/heuristics/traffic_weighted.py +43 -0
- backend/app/heuristics/tunnel_aware.py +77 -0
- backend/app/main.py +47 -0
- backend/app/models/__init__.py +63 -0
- backend/app/models/entities.py +75 -0
- backend/app/models/grid.py +84 -0
- backend/app/models/requests.py +169 -0
- backend/app/models/state.py +133 -0
- backend/app/services/__init__.py +28 -0
- backend/app/services/grid_generator.py +281 -0
- backend/app/services/metrics.py +126 -0
- backend/app/services/parser.py +249 -0
- backend/pyproject.toml +18 -0
- backend/uv.lock +584 -0
- frontend/.dockerignore +34 -0
- frontend/.env.example +3 -0
- frontend/.gitignore +24 -0
- frontend/README.md +73 -0
- frontend/components.json +22 -0
- frontend/eslint.config.js +23 -0
- frontend/index.html +13 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +41 -0
- frontend/public/vite.svg +1 -0
- frontend/src/App.css +42 -0
- frontend/src/App.tsx +262 -0
.gitignore
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
.venv/
|
| 6 |
+
venv/
|
| 7 |
+
|
| 8 |
+
# Node
|
| 9 |
+
node_modules/
|
| 10 |
+
.npm/
|
| 11 |
+
|
| 12 |
+
# Build
|
| 13 |
+
dist/
|
| 14 |
+
build/
|
| 15 |
+
|
| 16 |
+
# IDE
|
| 17 |
+
.vscode/
|
| 18 |
+
.idea/
|
| 19 |
+
|
| 20 |
+
# OS
|
| 21 |
+
.DS_Store
|
| 22 |
+
|
| 23 |
+
# Logs
|
| 24 |
+
*.log
|
| 25 |
+
|
| 26 |
+
# Environment
|
| 27 |
+
.env
|
Dockerfile
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Simple Hugging Face Spaces Dockerfile
|
| 2 |
+
FROM node:20-slim
|
| 3 |
+
|
| 4 |
+
ENV DEBIAN_FRONTEND=noninteractive
|
| 5 |
+
|
| 6 |
+
# Install Python
|
| 7 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 8 |
+
python3 \
|
| 9 |
+
python3-pip \
|
| 10 |
+
python3-venv \
|
| 11 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
+
|
| 13 |
+
# Create user with UID 1000 (required by HF Spaces)
|
| 14 |
+
RUN useradd -m -u 1000 user
|
| 15 |
+
|
| 16 |
+
WORKDIR /app
|
| 17 |
+
|
| 18 |
+
# Copy and build frontend
|
| 19 |
+
COPY --chown=user frontend/package*.json ./frontend/
|
| 20 |
+
RUN cd frontend && npm ci
|
| 21 |
+
COPY --chown=user frontend/ ./frontend/
|
| 22 |
+
RUN cd frontend && npm run build
|
| 23 |
+
|
| 24 |
+
# Copy and install backend
|
| 25 |
+
COPY --chown=user backend/ ./backend/
|
| 26 |
+
RUN pip3 install --no-cache-dir --break-system-packages \
|
| 27 |
+
fastapi uvicorn pydantic pydantic-settings \
|
| 28 |
+
httpx psutil python-dotenv websockets
|
| 29 |
+
|
| 30 |
+
# Switch to non-root user
|
| 31 |
+
USER user
|
| 32 |
+
|
| 33 |
+
ENV HOME=/home/user \
|
| 34 |
+
PYTHONPATH=/app/backend \
|
| 35 |
+
PYTHONUNBUFFERED=1
|
| 36 |
+
|
| 37 |
+
EXPOSE 7860
|
| 38 |
+
|
| 39 |
+
# Simple startup script
|
| 40 |
+
COPY --chown=user start.sh /app/
|
| 41 |
+
RUN chmod +x /app/start.sh
|
| 42 |
+
|
| 43 |
+
CMD ["/app/start.sh"]
|
README.md
CHANGED
|
@@ -1,10 +1,26 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Grid Search Pathfinding
|
| 3 |
+
emoji: 🗺️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# Grid Search - Pathfinding Algorithms Visualization
|
| 12 |
+
|
| 13 |
+
An interactive web application to visualize and compare various pathfinding algorithms on a grid-based map.
|
| 14 |
+
|
| 15 |
+
## Features
|
| 16 |
+
|
| 17 |
+
- **Multiple Algorithms**: A*, BFS, DFS, Greedy Best-First, UCS, IDS
|
| 18 |
+
- **Real-time Visualization**: Watch algorithms explore the grid step by step
|
| 19 |
+
- **Algorithm Comparison**: Compare performance metrics across different algorithms
|
| 20 |
+
- **Customizable Grid**: Generate grids with obstacles, tunnels, and delivery points
|
| 21 |
+
|
| 22 |
+
## Tech Stack
|
| 23 |
+
|
| 24 |
+
- **Frontend**: React + TypeScript + Vite + TailwindCSS
|
| 25 |
+
- **Backend**: FastAPI + Python
|
| 26 |
+
- **Visualization**: Real-time WebSocket updates
|
backend/.dockerignore
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
.venv/
|
| 8 |
+
venv/
|
| 9 |
+
ENV/
|
| 10 |
+
env/
|
| 11 |
+
|
| 12 |
+
# Testing
|
| 13 |
+
.pytest_cache/
|
| 14 |
+
.coverage
|
| 15 |
+
htmlcov/
|
| 16 |
+
.tox/
|
| 17 |
+
.nox/
|
| 18 |
+
|
| 19 |
+
# IDE
|
| 20 |
+
.vscode/
|
| 21 |
+
.idea/
|
| 22 |
+
*.swp
|
| 23 |
+
*.swo
|
| 24 |
+
|
| 25 |
+
# Build
|
| 26 |
+
*.egg-info/
|
| 27 |
+
dist/
|
| 28 |
+
build/
|
| 29 |
+
eggs/
|
| 30 |
+
.eggs/
|
| 31 |
+
|
| 32 |
+
# OS
|
| 33 |
+
.DS_Store
|
| 34 |
+
Thumbs.db
|
| 35 |
+
|
| 36 |
+
# Logs
|
| 37 |
+
*.log
|
| 38 |
+
|
| 39 |
+
# Environment
|
| 40 |
+
.env
|
| 41 |
+
.env.*
|
| 42 |
+
!.env.example
|
| 43 |
+
|
| 44 |
+
# UV
|
| 45 |
+
.uv/
|
| 46 |
+
uv.lock
|
backend/.python-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.10
|
backend/app/__init__.py
ADDED
|
File without changes
|
backend/app/algorithms/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
]
|
backend/app/algorithms/astar.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 6 |
+
|
| 7 |
+
from ..core.node import SearchNode
|
| 8 |
+
from ..core.frontier import PriorityQueueFrontier
|
| 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).
|
| 19 |
+
|
| 20 |
+
Optimal if heuristic is admissible (never overestimates).
|
| 21 |
+
Complete if step costs are positive.
|
| 22 |
+
|
| 23 |
+
Args:
|
| 24 |
+
problem: The search problem to solve
|
| 25 |
+
heuristic: Function(state, goal) -> estimated cost to goal
|
| 26 |
+
visualize: If True, collect visualization steps
|
| 27 |
+
|
| 28 |
+
Returns:
|
| 29 |
+
Tuple of (PathResult, Optional[List[SearchStep]])
|
| 30 |
+
"""
|
| 31 |
+
frontier = PriorityQueueFrontier()
|
| 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)
|
| 39 |
+
start_node = SearchNode(state=start, path_cost=0, depth=0, priority=f_value)
|
| 40 |
+
frontier.push(start_node)
|
| 41 |
+
|
| 42 |
+
explored: set = set()
|
| 43 |
+
nodes_expanded = 0
|
| 44 |
+
steps: List[SearchStep] = [] if visualize else None
|
| 45 |
+
|
| 46 |
+
while not frontier.is_empty():
|
| 47 |
+
node = frontier.pop()
|
| 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:
|
| 72 |
+
continue
|
| 73 |
+
|
| 74 |
+
explored.add(node.state)
|
| 75 |
+
nodes_expanded += 1
|
| 76 |
+
|
| 77 |
+
# Expand node
|
| 78 |
+
for action in problem.actions(node.state):
|
| 79 |
+
child_state = problem.result(node.state, action)
|
| 80 |
+
|
| 81 |
+
if child_state not in explored:
|
| 82 |
+
step_cost = problem.step_cost(node.state, action, child_state)
|
| 83 |
+
g_value = node.path_cost + step_cost
|
| 84 |
+
h_value = heuristic(child_state, goal) if goal else 0
|
| 85 |
+
f_value = g_value + h_value
|
| 86 |
+
|
| 87 |
+
child = SearchNode(
|
| 88 |
+
state=child_state,
|
| 89 |
+
parent=node,
|
| 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 |
+
)
|
backend/app/algorithms/bfs.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 6 |
+
|
| 7 |
+
from ..core.node import SearchNode
|
| 8 |
+
from ..core.frontier import QueueFrontier
|
| 9 |
+
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.
|
| 18 |
+
|
| 19 |
+
Finds path with minimum number of steps (not minimum cost).
|
| 20 |
+
Complete and optimal for unweighted graphs.
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
problem: The search problem to solve
|
| 24 |
+
visualize: If True, collect visualization steps
|
| 25 |
+
|
| 26 |
+
Returns:
|
| 27 |
+
Tuple of (PathResult, Optional[List[SearchStep]])
|
| 28 |
+
"""
|
| 29 |
+
frontier = QueueFrontier()
|
| 30 |
+
start = problem.initial_state()
|
| 31 |
+
start_node = SearchNode(state=start, path_cost=0, depth=0)
|
| 32 |
+
frontier.push(start_node)
|
| 33 |
+
|
| 34 |
+
explored: set = set()
|
| 35 |
+
nodes_expanded = 0
|
| 36 |
+
steps: List[SearchStep] = [] if visualize else None
|
| 37 |
+
|
| 38 |
+
while not frontier.is_empty():
|
| 39 |
+
node = frontier.pop()
|
| 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:
|
| 64 |
+
continue
|
| 65 |
+
|
| 66 |
+
explored.add(node.state)
|
| 67 |
+
nodes_expanded += 1
|
| 68 |
+
|
| 69 |
+
# Expand node
|
| 70 |
+
for action in problem.actions(node.state):
|
| 71 |
+
child_state = problem.result(node.state, action)
|
| 72 |
+
if child_state not in explored and not frontier.contains_state(child_state):
|
| 73 |
+
step_cost = problem.step_cost(node.state, action, child_state)
|
| 74 |
+
child = SearchNode(
|
| 75 |
+
state=child_state,
|
| 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 |
+
)
|
backend/app/algorithms/dfs.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 6 |
+
|
| 7 |
+
from ..core.node import SearchNode
|
| 8 |
+
from ..core.frontier import StackFrontier
|
| 9 |
+
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.
|
| 18 |
+
|
| 19 |
+
Not guaranteed to find optimal solution.
|
| 20 |
+
Complete in finite state spaces with cycle detection.
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
problem: The search problem to solve
|
| 24 |
+
visualize: If True, collect visualization steps
|
| 25 |
+
|
| 26 |
+
Returns:
|
| 27 |
+
Tuple of (PathResult, Optional[List[SearchStep]])
|
| 28 |
+
"""
|
| 29 |
+
frontier = StackFrontier()
|
| 30 |
+
start = problem.initial_state()
|
| 31 |
+
start_node = SearchNode(state=start, path_cost=0, depth=0)
|
| 32 |
+
frontier.push(start_node)
|
| 33 |
+
|
| 34 |
+
explored: set = set()
|
| 35 |
+
nodes_expanded = 0
|
| 36 |
+
steps: List[SearchStep] = [] if visualize else None
|
| 37 |
+
|
| 38 |
+
while not frontier.is_empty():
|
| 39 |
+
node = frontier.pop()
|
| 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:
|
| 64 |
+
continue
|
| 65 |
+
|
| 66 |
+
explored.add(node.state)
|
| 67 |
+
nodes_expanded += 1
|
| 68 |
+
|
| 69 |
+
# Expand node (reverse order so first action is processed last -> depth-first)
|
| 70 |
+
actions = problem.actions(node.state)
|
| 71 |
+
for action in reversed(actions):
|
| 72 |
+
child_state = problem.result(node.state, action)
|
| 73 |
+
if child_state not in explored and not frontier.contains_state(child_state):
|
| 74 |
+
step_cost = problem.step_cost(node.state, action, child_state)
|
| 75 |
+
child = SearchNode(
|
| 76 |
+
state=child_state,
|
| 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 |
+
)
|
backend/app/algorithms/greedy.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 6 |
+
|
| 7 |
+
from ..core.node import SearchNode
|
| 8 |
+
from ..core.frontier import PriorityQueueFrontier
|
| 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.
|
| 19 |
+
|
| 20 |
+
Expands node that appears closest to goal.
|
| 21 |
+
Not guaranteed to find optimal solution.
|
| 22 |
+
Not complete in general.
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
problem: The search problem to solve
|
| 26 |
+
heuristic: Function(state, goal) -> estimated cost to goal
|
| 27 |
+
visualize: If True, collect visualization steps
|
| 28 |
+
|
| 29 |
+
Returns:
|
| 30 |
+
Tuple of (PathResult, Optional[List[SearchStep]])
|
| 31 |
+
"""
|
| 32 |
+
frontier = PriorityQueueFrontier()
|
| 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)
|
| 40 |
+
frontier.push(start_node)
|
| 41 |
+
|
| 42 |
+
explored: set = set()
|
| 43 |
+
nodes_expanded = 0
|
| 44 |
+
steps: List[SearchStep] = [] if visualize else None
|
| 45 |
+
|
| 46 |
+
while not frontier.is_empty():
|
| 47 |
+
node = frontier.pop()
|
| 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:
|
| 72 |
+
continue
|
| 73 |
+
|
| 74 |
+
explored.add(node.state)
|
| 75 |
+
nodes_expanded += 1
|
| 76 |
+
|
| 77 |
+
# Expand node
|
| 78 |
+
for action in problem.actions(node.state):
|
| 79 |
+
child_state = problem.result(node.state, action)
|
| 80 |
+
|
| 81 |
+
if child_state not in explored:
|
| 82 |
+
step_cost = problem.step_cost(node.state, action, child_state)
|
| 83 |
+
h_value = heuristic(child_state, goal) if goal else 0
|
| 84 |
+
|
| 85 |
+
child = SearchNode(
|
| 86 |
+
state=child_state,
|
| 87 |
+
parent=node,
|
| 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 |
+
)
|
backend/app/algorithms/ids.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 6 |
+
|
| 7 |
+
from ..core.node import SearchNode
|
| 8 |
+
from ..models.state import PathResult, SearchStep
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
# Sentinel values for DLS results
|
| 12 |
+
CUTOFF = "cutoff"
|
| 13 |
+
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.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
problem: The search problem
|
| 28 |
+
limit: Maximum depth to search
|
| 29 |
+
visualize: If True, collect visualization steps
|
| 30 |
+
steps: Existing steps list to append to
|
| 31 |
+
base_expanded: Starting count for nodes expanded
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
Tuple of (PathResult or None, status, nodes_expanded, steps)
|
| 35 |
+
status is CUTOFF if limit reached, FAILURE if no solution exists
|
| 36 |
+
"""
|
| 37 |
+
start = problem.initial_state()
|
| 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:
|
| 81 |
+
return None, CUTOFF, nodes_expanded, steps
|
| 82 |
+
|
| 83 |
+
# Mark as explored for this path
|
| 84 |
+
explored.add(node.state)
|
| 85 |
+
nodes_expanded += 1
|
| 86 |
+
|
| 87 |
+
cutoff_occurred = False
|
| 88 |
+
|
| 89 |
+
# Expand node
|
| 90 |
+
for action in problem.actions(node.state):
|
| 91 |
+
child_state = problem.result(node.state, action)
|
| 92 |
+
|
| 93 |
+
if child_state not in explored:
|
| 94 |
+
step_cost = problem.step_cost(node.state, action, child_state)
|
| 95 |
+
child = SearchNode(
|
| 96 |
+
state=child_state,
|
| 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(
|
| 104 |
+
problem, child, limit, explored.copy(), visualize, steps, nodes_expanded
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
if status == "success":
|
| 108 |
+
return result, status, nodes_expanded, steps
|
| 109 |
+
elif status == CUTOFF:
|
| 110 |
+
cutoff_occurred = True
|
| 111 |
+
|
| 112 |
+
if cutoff_occurred:
|
| 113 |
+
return None, CUTOFF, nodes_expanded, steps
|
| 114 |
+
else:
|
| 115 |
+
return None, FAILURE, nodes_expanded, steps
|
| 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.
|
| 125 |
+
|
| 126 |
+
Combines BFS's completeness and optimality (for unweighted)
|
| 127 |
+
with DFS's space efficiency.
|
| 128 |
+
|
| 129 |
+
Args:
|
| 130 |
+
problem: The search problem to solve
|
| 131 |
+
visualize: If True, collect visualization steps
|
| 132 |
+
max_depth: Maximum depth to search (prevents infinite loops)
|
| 133 |
+
|
| 134 |
+
Returns:
|
| 135 |
+
Tuple of (PathResult, Optional[List[SearchStep]])
|
| 136 |
+
"""
|
| 137 |
+
total_expanded = 0
|
| 138 |
+
all_steps: List[SearchStep] = [] if visualize else None
|
| 139 |
+
|
| 140 |
+
for depth in range(max_depth):
|
| 141 |
+
result, status, expanded, steps = depth_limited_search(
|
| 142 |
+
problem, depth, visualize, all_steps, total_expanded
|
| 143 |
+
)
|
| 144 |
+
total_expanded = expanded
|
| 145 |
+
|
| 146 |
+
if visualize and steps:
|
| 147 |
+
all_steps = steps
|
| 148 |
+
|
| 149 |
+
if status == "success" and result is not None:
|
| 150 |
+
result.nodes_expanded = total_expanded
|
| 151 |
+
return result, all_steps
|
| 152 |
+
elif status == FAILURE:
|
| 153 |
+
# No solution exists
|
| 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))
|
backend/app/algorithms/ucs.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 6 |
+
|
| 7 |
+
from ..core.node import SearchNode
|
| 8 |
+
from ..core.frontier import PriorityQueueFrontier
|
| 9 |
+
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.
|
| 18 |
+
|
| 19 |
+
Always finds the optimal (minimum cost) solution.
|
| 20 |
+
Complete if step costs are positive.
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
problem: The search problem to solve
|
| 24 |
+
visualize: If True, collect visualization steps
|
| 25 |
+
|
| 26 |
+
Returns:
|
| 27 |
+
Tuple of (PathResult, Optional[List[SearchStep]])
|
| 28 |
+
"""
|
| 29 |
+
frontier = PriorityQueueFrontier()
|
| 30 |
+
start = problem.initial_state()
|
| 31 |
+
start_node = SearchNode(state=start, path_cost=0, depth=0, priority=0)
|
| 32 |
+
frontier.push(start_node)
|
| 33 |
+
|
| 34 |
+
explored: set = set()
|
| 35 |
+
nodes_expanded = 0
|
| 36 |
+
steps: List[SearchStep] = [] if visualize else None
|
| 37 |
+
|
| 38 |
+
while not frontier.is_empty():
|
| 39 |
+
node = frontier.pop()
|
| 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:
|
| 64 |
+
continue
|
| 65 |
+
|
| 66 |
+
explored.add(node.state)
|
| 67 |
+
nodes_expanded += 1
|
| 68 |
+
|
| 69 |
+
# Expand node
|
| 70 |
+
for action in problem.actions(node.state):
|
| 71 |
+
child_state = problem.result(node.state, action)
|
| 72 |
+
step_cost = problem.step_cost(node.state, action, child_state)
|
| 73 |
+
new_cost = node.path_cost + step_cost
|
| 74 |
+
|
| 75 |
+
if child_state not in explored:
|
| 76 |
+
child = SearchNode(
|
| 77 |
+
state=child_state,
|
| 78 |
+
parent=node,
|
| 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 |
+
)
|
backend/app/api/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API package."""
|
| 2 |
+
from .routes import router
|
| 3 |
+
from .websocket import handle_visualization, manager
|
| 4 |
+
|
| 5 |
+
__all__ = ["router", "handle_visualization", "manager"]
|
backend/app/api/routes.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API routes for the delivery search application."""
|
| 2 |
+
from fastapi import APIRouter, HTTPException
|
| 3 |
+
from typing import List
|
| 4 |
+
|
| 5 |
+
from ..models.requests import (
|
| 6 |
+
GridConfig,
|
| 7 |
+
SearchRequest,
|
| 8 |
+
PathRequest,
|
| 9 |
+
CompareRequest,
|
| 10 |
+
Position,
|
| 11 |
+
GenerateResponse,
|
| 12 |
+
SearchResponse,
|
| 13 |
+
PlanResponse,
|
| 14 |
+
ComparisonResult,
|
| 15 |
+
CompareResponse,
|
| 16 |
+
AlgorithmInfo,
|
| 17 |
+
AlgorithmsResponse,
|
| 18 |
+
GridData,
|
| 19 |
+
StoreData,
|
| 20 |
+
DestinationData,
|
| 21 |
+
TunnelData,
|
| 22 |
+
SegmentData,
|
| 23 |
+
)
|
| 24 |
+
from ..services import gen_grid, parse_full_state, measure_performance
|
| 25 |
+
from ..core import DeliverySearch, DeliveryPlanner
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
router = APIRouter()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# Algorithm metadata
|
| 32 |
+
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 |
+
|
| 75 |
+
|
| 76 |
+
@router.get("/api/health")
|
| 77 |
+
async def health_check():
|
| 78 |
+
"""Health check endpoint."""
|
| 79 |
+
return {"status": "ok"}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@router.get("/api/algorithms", response_model=AlgorithmsResponse)
|
| 83 |
+
async def list_algorithms():
|
| 84 |
+
"""List available search algorithms."""
|
| 85 |
+
return AlgorithmsResponse(algorithms=ALGORITHMS)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@router.post("/api/grid/generate", response_model=GenerateResponse)
|
| 89 |
+
async def generate_grid(config: GridConfig):
|
| 90 |
+
"""Generate a random grid configuration."""
|
| 91 |
+
try:
|
| 92 |
+
initial_state, traffic, state = gen_grid(
|
| 93 |
+
width=config.width,
|
| 94 |
+
height=config.height,
|
| 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
|
| 102 |
+
parsed = GridData(
|
| 103 |
+
width=state.grid.width,
|
| 104 |
+
height=state.grid.height,
|
| 105 |
+
stores=[
|
| 106 |
+
StoreData(id=s.id, position=Position(x=s.position[0], y=s.position[1]))
|
| 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 |
+
],
|
| 121 |
+
segments=[
|
| 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))
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
@router.post("/api/search/path", response_model=SearchResponse)
|
| 141 |
+
async def find_path(request: PathRequest):
|
| 142 |
+
"""Find path from start to goal using specified strategy."""
|
| 143 |
+
try:
|
| 144 |
+
from ..models.grid import Grid
|
| 145 |
+
from ..models.entities import Tunnel
|
| 146 |
+
|
| 147 |
+
# Build grid from request
|
| 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 |
+
]
|
| 164 |
+
|
| 165 |
+
# Run search with metrics
|
| 166 |
+
with measure_performance() as metrics:
|
| 167 |
+
result, steps = DeliverySearch.path(
|
| 168 |
+
grid,
|
| 169 |
+
(request.start.x, request.start.y),
|
| 170 |
+
(request.goal.x, request.goal.y),
|
| 171 |
+
tunnels,
|
| 172 |
+
request.strategy.value,
|
| 173 |
+
visualize=True
|
| 174 |
+
)
|
| 175 |
+
metrics.sample()
|
| 176 |
+
|
| 177 |
+
return SearchResponse(
|
| 178 |
+
plan=result.plan,
|
| 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))
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@router.post("/api/search/plan", response_model=PlanResponse)
|
| 192 |
+
async def create_plan(request: SearchRequest):
|
| 193 |
+
"""Create full delivery plan for all trucks and destinations."""
|
| 194 |
+
try:
|
| 195 |
+
# Parse state
|
| 196 |
+
state = parse_full_state(request.initial_state, request.traffic)
|
| 197 |
+
|
| 198 |
+
# Run planner with metrics
|
| 199 |
+
with measure_performance() as metrics:
|
| 200 |
+
plan_result, viz_data = DeliveryPlanner.plan_from_state(
|
| 201 |
+
state.grid,
|
| 202 |
+
state.stores,
|
| 203 |
+
state.destinations,
|
| 204 |
+
state.tunnels,
|
| 205 |
+
request.strategy.value,
|
| 206 |
+
request.visualize
|
| 207 |
+
)
|
| 208 |
+
metrics.sample()
|
| 209 |
+
|
| 210 |
+
return PlanResponse(
|
| 211 |
+
output=plan_result.to_string(),
|
| 212 |
+
assignments=[a.to_dict() for a in plan_result.assignments],
|
| 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))
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
@router.post("/api/search/compare", response_model=CompareResponse)
|
| 224 |
+
async def compare_algorithms(request: CompareRequest):
|
| 225 |
+
"""Run all algorithms on same problem and return comparison."""
|
| 226 |
+
try:
|
| 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:
|
| 234 |
+
with measure_performance() as metrics:
|
| 235 |
+
plan_result, _ = DeliveryPlanner.plan_from_state(
|
| 236 |
+
state.grid,
|
| 237 |
+
state.stores,
|
| 238 |
+
state.destinations,
|
| 239 |
+
state.tunnels,
|
| 240 |
+
algo_info.code,
|
| 241 |
+
visualize=False
|
| 242 |
+
)
|
| 243 |
+
metrics.sample()
|
| 244 |
+
|
| 245 |
+
# Track optimal cost (from UCS or A*)
|
| 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))
|
backend/app/core/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
| 8 |
+
__all__ = [
|
| 9 |
+
"SearchNode",
|
| 10 |
+
"Frontier",
|
| 11 |
+
"QueueFrontier",
|
| 12 |
+
"StackFrontier",
|
| 13 |
+
"PriorityQueueFrontier",
|
| 14 |
+
"GenericSearch",
|
| 15 |
+
"graph_search",
|
| 16 |
+
"graph_search_generator",
|
| 17 |
+
"DeliverySearch",
|
| 18 |
+
"DeliveryPlanner",
|
| 19 |
+
]
|
backend/app/core/delivery_planner.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 5 |
+
from ..models.entities import Store, Destination, Tunnel
|
| 6 |
+
from ..models.state import PathResult, PlanResult, DeliveryAssignment, SearchStep
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class DeliveryPlanner:
|
| 10 |
+
"""
|
| 11 |
+
Plans the assignment of destinations to stores/trucks.
|
| 12 |
+
|
| 13 |
+
For each destination, finds the optimal store to deliver from and computes the delivery path.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
def __init__(
|
| 17 |
+
self,
|
| 18 |
+
grid: Grid,
|
| 19 |
+
stores: List[Store],
|
| 20 |
+
destinations: List[Destination],
|
| 21 |
+
tunnels: Optional[List[Tunnel]] = None
|
| 22 |
+
):
|
| 23 |
+
"""
|
| 24 |
+
Initialize the delivery planner.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
grid: The city grid with traffic information
|
| 28 |
+
stores: List of store locations (each has a truck)
|
| 29 |
+
destinations: List of customer destinations
|
| 30 |
+
tunnels: Available tunnels
|
| 31 |
+
"""
|
| 32 |
+
self.grid = grid
|
| 33 |
+
self.stores = stores
|
| 34 |
+
self.destinations = destinations
|
| 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.
|
| 44 |
+
|
| 45 |
+
Algorithm:
|
| 46 |
+
1. For each destination, compute path cost from each store
|
| 47 |
+
2. Assign each destination to the store with minimum path cost
|
| 48 |
+
3. Compile results
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
strategy: Search strategy to use
|
| 52 |
+
visualize: If True, collect visualization steps
|
| 53 |
+
|
| 54 |
+
Returns:
|
| 55 |
+
Tuple of (PlanResult, Optional visualization data)
|
| 56 |
+
"""
|
| 57 |
+
assignments: List[DeliveryAssignment] = []
|
| 58 |
+
all_steps: Dict[int, List[SearchStep]] = {} if visualize else None
|
| 59 |
+
total_cost = 0.0
|
| 60 |
+
total_nodes = 0
|
| 61 |
+
|
| 62 |
+
# For each destination, find best store
|
| 63 |
+
for dest in self.destinations:
|
| 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:
|
| 71 |
+
result, steps = DeliverySearch.path(
|
| 72 |
+
self.grid,
|
| 73 |
+
store.position,
|
| 74 |
+
dest.position,
|
| 75 |
+
self.tunnels,
|
| 76 |
+
strategy,
|
| 77 |
+
visualize
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# Track nodes expanded
|
| 81 |
+
total_nodes += result.nodes_expanded
|
| 82 |
+
|
| 83 |
+
# Check if this is better
|
| 84 |
+
if result.cost < best_cost:
|
| 85 |
+
best_cost = result.cost
|
| 86 |
+
best_store = store
|
| 87 |
+
best_result = result
|
| 88 |
+
best_steps = steps
|
| 89 |
+
|
| 90 |
+
# Create assignment
|
| 91 |
+
if best_store and best_result:
|
| 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
|
| 99 |
+
|
| 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(
|
| 175 |
+
grid: Grid,
|
| 176 |
+
stores: List[Store],
|
| 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.
|
| 184 |
+
|
| 185 |
+
Args:
|
| 186 |
+
grid: The city grid
|
| 187 |
+
stores: List of stores
|
| 188 |
+
destinations: List of destinations
|
| 189 |
+
tunnels: Available tunnels
|
| 190 |
+
strategy: Search strategy
|
| 191 |
+
visualize: If True, collect visualization steps
|
| 192 |
+
|
| 193 |
+
Returns:
|
| 194 |
+
Tuple of (PlanResult, Optional visualization data)
|
| 195 |
+
"""
|
| 196 |
+
planner = DeliveryPlanner(grid, stores, destinations, tunnels)
|
| 197 |
+
return planner.plan(strategy, visualize)
|
backend/app/core/delivery_search.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 5 |
+
from ..models.entities import Tunnel
|
| 6 |
+
from ..models.state import PathResult, SearchStep
|
| 7 |
+
from ..heuristics import create_tunnel_aware_heuristic
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class DeliverySearch(GenericSearch):
|
| 11 |
+
"""
|
| 12 |
+
Search problem for finding path from a store to a destination.
|
| 13 |
+
|
| 14 |
+
Implements the GenericSearch interface for the package delivery problem.
|
| 15 |
+
Supports movement in 4 directions (up/down/left/right) and tunnel travel.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
def __init__(
|
| 19 |
+
self,
|
| 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.
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
grid: The city grid with traffic information
|
| 30 |
+
start: Starting position (store location)
|
| 31 |
+
goal: Goal position (destination location)
|
| 32 |
+
tunnels: List of available tunnels
|
| 33 |
+
"""
|
| 34 |
+
self.grid = grid
|
| 35 |
+
self.start = start
|
| 36 |
+
self.goal = goal
|
| 37 |
+
self.tunnels = tunnels or []
|
| 38 |
+
|
| 39 |
+
# Create tunnel lookup by entrance position
|
| 40 |
+
self._tunnel_entrances: Dict[Tuple[int, int], Tunnel] = {}
|
| 41 |
+
for tunnel in self.tunnels:
|
| 42 |
+
self._tunnel_entrances[tunnel.entrance1] = tunnel
|
| 43 |
+
self._tunnel_entrances[tunnel.entrance2] = tunnel
|
| 44 |
+
|
| 45 |
+
# Create tunnel-aware heuristic
|
| 46 |
+
self._tunnel_heuristic = create_tunnel_aware_heuristic(self.tunnels)
|
| 47 |
+
|
| 48 |
+
def initial_state(self) -> Tuple[int, int]:
|
| 49 |
+
"""Return the starting position."""
|
| 50 |
+
return self.start
|
| 51 |
+
|
| 52 |
+
def goal_test(self, state: Tuple[int, int]) -> bool:
|
| 53 |
+
"""Check if current state is the goal."""
|
| 54 |
+
return state == self.goal
|
| 55 |
+
|
| 56 |
+
def actions(self, state: Tuple[int, int]) -> List[str]:
|
| 57 |
+
"""
|
| 58 |
+
Return list of valid actions from current state.
|
| 59 |
+
|
| 60 |
+
Actions:
|
| 61 |
+
- up: Move up (y+1)
|
| 62 |
+
- down: Move down (y-1)
|
| 63 |
+
- left: Move left (x-1)
|
| 64 |
+
- right: Move right (x+1)
|
| 65 |
+
- tunnel: Use tunnel if at entrance
|
| 66 |
+
|
| 67 |
+
Returns:
|
| 68 |
+
List of valid action strings
|
| 69 |
+
"""
|
| 70 |
+
x, y = state
|
| 71 |
+
valid_actions = []
|
| 72 |
+
|
| 73 |
+
# Check each direction
|
| 74 |
+
directions = [
|
| 75 |
+
("up", (x, y + 1)),
|
| 76 |
+
("down", (x, y - 1)),
|
| 77 |
+
("left", (x - 1, y)),
|
| 78 |
+
("right", (x + 1, y)),
|
| 79 |
+
]
|
| 80 |
+
|
| 81 |
+
for action, new_pos in directions:
|
| 82 |
+
if self.grid.is_valid_position(new_pos):
|
| 83 |
+
if not self.grid.is_blocked(state, new_pos):
|
| 84 |
+
valid_actions.append(action)
|
| 85 |
+
|
| 86 |
+
# Check for tunnel
|
| 87 |
+
if state in self._tunnel_entrances:
|
| 88 |
+
valid_actions.append("tunnel")
|
| 89 |
+
|
| 90 |
+
return valid_actions
|
| 91 |
+
|
| 92 |
+
def result(self, state: Tuple[int, int], action: str) -> Tuple[int, int]:
|
| 93 |
+
"""
|
| 94 |
+
Apply action to state and return new state.
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
state: Current position
|
| 98 |
+
action: Action to take
|
| 99 |
+
|
| 100 |
+
Returns:
|
| 101 |
+
New position after action
|
| 102 |
+
"""
|
| 103 |
+
x, y = state
|
| 104 |
+
|
| 105 |
+
if action == "up":
|
| 106 |
+
return (x, y + 1)
|
| 107 |
+
elif action == "down":
|
| 108 |
+
return (x, y - 1)
|
| 109 |
+
elif action == "left":
|
| 110 |
+
return (x - 1, y)
|
| 111 |
+
elif action == "right":
|
| 112 |
+
return (x + 1, y)
|
| 113 |
+
elif action == "tunnel":
|
| 114 |
+
if state in self._tunnel_entrances:
|
| 115 |
+
tunnel = self._tunnel_entrances[state]
|
| 116 |
+
return tunnel.get_other_entrance(state)
|
| 117 |
+
else:
|
| 118 |
+
raise ValueError(f"No tunnel entrance at {state}")
|
| 119 |
+
else:
|
| 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.
|
| 130 |
+
|
| 131 |
+
For regular moves: cost = traffic level of the segment
|
| 132 |
+
For tunnels: cost = Manhattan distance between entrances
|
| 133 |
+
|
| 134 |
+
Args:
|
| 135 |
+
state: Current position
|
| 136 |
+
action: Action taken
|
| 137 |
+
next_state: Resulting position
|
| 138 |
+
|
| 139 |
+
Returns:
|
| 140 |
+
Cost of the action
|
| 141 |
+
"""
|
| 142 |
+
if action == "tunnel":
|
| 143 |
+
tunnel = self._tunnel_entrances.get(state)
|
| 144 |
+
if tunnel:
|
| 145 |
+
return tunnel.cost
|
| 146 |
+
return 0.0
|
| 147 |
+
else:
|
| 148 |
+
# Regular movement - cost is traffic level
|
| 149 |
+
return self.grid.get_traffic(state, next_state)
|
| 150 |
+
|
| 151 |
+
def heuristic(self, state: Tuple[int, int]) -> float:
|
| 152 |
+
"""
|
| 153 |
+
Tunnel-aware heuristic for A* search.
|
| 154 |
+
|
| 155 |
+
Args:
|
| 156 |
+
state: Current position
|
| 157 |
+
|
| 158 |
+
Returns:
|
| 159 |
+
Estimated cost to goal
|
| 160 |
+
"""
|
| 161 |
+
return self._tunnel_heuristic(state, self.goal)
|
| 162 |
+
|
| 163 |
+
@staticmethod
|
| 164 |
+
def path(
|
| 165 |
+
grid: Grid,
|
| 166 |
+
start: Tuple[int, int],
|
| 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.
|
| 174 |
+
|
| 175 |
+
Args:
|
| 176 |
+
grid: The city grid
|
| 177 |
+
start: Starting position
|
| 178 |
+
goal: Goal position
|
| 179 |
+
tunnels: Available tunnels
|
| 180 |
+
strategy: Search strategy (BF, DF, ID, UC, GR1, GR2, AS1, AS2)
|
| 181 |
+
visualize: If True, collect visualization steps
|
| 182 |
+
|
| 183 |
+
Returns:
|
| 184 |
+
Tuple of (PathResult, Optional[List[SearchStep]])
|
| 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()
|
backend/app/core/frontier.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Frontier data structures for search algorithms."""
|
| 2 |
+
from abc import ABC, abstractmethod
|
| 3 |
+
from collections import deque
|
| 4 |
+
import heapq
|
| 5 |
+
from typing import List, Optional, Set, Dict
|
| 6 |
+
from .node import SearchNode
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class Frontier(ABC):
|
| 10 |
+
"""Abstract base class for frontier data structures."""
|
| 11 |
+
|
| 12 |
+
@abstractmethod
|
| 13 |
+
def push(self, node: SearchNode) -> None:
|
| 14 |
+
"""Add a node to the frontier."""
|
| 15 |
+
pass
|
| 16 |
+
|
| 17 |
+
@abstractmethod
|
| 18 |
+
def pop(self) -> SearchNode:
|
| 19 |
+
"""Remove and return the next node from the frontier."""
|
| 20 |
+
pass
|
| 21 |
+
|
| 22 |
+
@abstractmethod
|
| 23 |
+
def is_empty(self) -> bool:
|
| 24 |
+
"""Check if the frontier is empty."""
|
| 25 |
+
pass
|
| 26 |
+
|
| 27 |
+
@abstractmethod
|
| 28 |
+
def __len__(self) -> int:
|
| 29 |
+
"""Return the number of nodes in the frontier."""
|
| 30 |
+
pass
|
| 31 |
+
|
| 32 |
+
@abstractmethod
|
| 33 |
+
def contains_state(self, state) -> bool:
|
| 34 |
+
"""Check if a state is in the frontier."""
|
| 35 |
+
pass
|
| 36 |
+
|
| 37 |
+
def get_states(self) -> List:
|
| 38 |
+
"""Get all states in the frontier (for visualization)."""
|
| 39 |
+
return []
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class QueueFrontier(Frontier):
|
| 43 |
+
"""FIFO queue frontier for Breadth-First Search."""
|
| 44 |
+
|
| 45 |
+
def __init__(self):
|
| 46 |
+
self._queue: deque[SearchNode] = deque()
|
| 47 |
+
self._states: Set = set()
|
| 48 |
+
|
| 49 |
+
def push(self, node: SearchNode) -> None:
|
| 50 |
+
self._queue.append(node)
|
| 51 |
+
self._states.add(node.state)
|
| 52 |
+
|
| 53 |
+
def pop(self) -> SearchNode:
|
| 54 |
+
node = self._queue.popleft()
|
| 55 |
+
self._states.discard(node.state)
|
| 56 |
+
return node
|
| 57 |
+
|
| 58 |
+
def is_empty(self) -> bool:
|
| 59 |
+
return len(self._queue) == 0
|
| 60 |
+
|
| 61 |
+
def __len__(self) -> int:
|
| 62 |
+
return len(self._queue)
|
| 63 |
+
|
| 64 |
+
def contains_state(self, state) -> bool:
|
| 65 |
+
return state in self._states
|
| 66 |
+
|
| 67 |
+
def get_states(self) -> List:
|
| 68 |
+
return [node.state for node in self._queue]
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class StackFrontier(Frontier):
|
| 72 |
+
"""LIFO stack frontier for Depth-First Search."""
|
| 73 |
+
|
| 74 |
+
def __init__(self):
|
| 75 |
+
self._stack: List[SearchNode] = []
|
| 76 |
+
self._states: Set = set()
|
| 77 |
+
|
| 78 |
+
def push(self, node: SearchNode) -> None:
|
| 79 |
+
self._stack.append(node)
|
| 80 |
+
self._states.add(node.state)
|
| 81 |
+
|
| 82 |
+
def pop(self) -> SearchNode:
|
| 83 |
+
node = self._stack.pop()
|
| 84 |
+
self._states.discard(node.state)
|
| 85 |
+
return node
|
| 86 |
+
|
| 87 |
+
def is_empty(self) -> bool:
|
| 88 |
+
return len(self._stack) == 0
|
| 89 |
+
|
| 90 |
+
def __len__(self) -> int:
|
| 91 |
+
return len(self._stack)
|
| 92 |
+
|
| 93 |
+
def contains_state(self, state) -> bool:
|
| 94 |
+
return state in self._states
|
| 95 |
+
|
| 96 |
+
def get_states(self) -> List:
|
| 97 |
+
return [node.state for node in self._stack]
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class PriorityQueueFrontier(Frontier):
|
| 101 |
+
"""
|
| 102 |
+
Priority queue frontier for UCS, Greedy, and A* search.
|
| 103 |
+
|
| 104 |
+
Uses heapq with a counter to break ties and ensure FIFO ordering for nodes with equal priority.
|
| 105 |
+
"""
|
| 106 |
+
|
| 107 |
+
def __init__(self):
|
| 108 |
+
self._heap: List[tuple] = [] # (priority, counter, node)
|
| 109 |
+
self._counter: int = 0
|
| 110 |
+
self._states: Dict = {} # state -> (priority, node) for updates
|
| 111 |
+
self._removed: Set = set() # Track removed entries
|
| 112 |
+
|
| 113 |
+
def push(self, node: SearchNode) -> None:
|
| 114 |
+
"""
|
| 115 |
+
Add a node to the priority queue.
|
| 116 |
+
|
| 117 |
+
If a node with the same state already exists with higher priority, it will be updated.
|
| 118 |
+
"""
|
| 119 |
+
state = node.state
|
| 120 |
+
priority = node.priority
|
| 121 |
+
|
| 122 |
+
# If state exists with higher or equal priority, skip
|
| 123 |
+
if state in self._states:
|
| 124 |
+
existing_priority, _ = self._states[state]
|
| 125 |
+
if existing_priority <= priority:
|
| 126 |
+
return
|
| 127 |
+
# Mark old entry as removed
|
| 128 |
+
self._removed.add((existing_priority, state))
|
| 129 |
+
|
| 130 |
+
# Add new entry
|
| 131 |
+
entry = (priority, self._counter, node)
|
| 132 |
+
heapq.heappush(self._heap, entry)
|
| 133 |
+
self._states[state] = (priority, node)
|
| 134 |
+
self._counter += 1
|
| 135 |
+
|
| 136 |
+
def pop(self) -> SearchNode:
|
| 137 |
+
"""Remove and return the node with lowest priority."""
|
| 138 |
+
while self._heap:
|
| 139 |
+
priority, _, node = heapq.heappop(self._heap)
|
| 140 |
+
|
| 141 |
+
# Skip removed entries
|
| 142 |
+
if (priority, node.state) in self._removed:
|
| 143 |
+
self._removed.discard((priority, node.state))
|
| 144 |
+
continue
|
| 145 |
+
|
| 146 |
+
# Skip if this is not the current entry for this state
|
| 147 |
+
if node.state in self._states:
|
| 148 |
+
current_priority, current_node = self._states[node.state]
|
| 149 |
+
if current_priority != priority:
|
| 150 |
+
continue
|
| 151 |
+
del self._states[node.state]
|
| 152 |
+
|
| 153 |
+
return node
|
| 154 |
+
|
| 155 |
+
raise IndexError("Pop from empty frontier")
|
| 156 |
+
|
| 157 |
+
def is_empty(self) -> bool:
|
| 158 |
+
# Account for lazy deletions
|
| 159 |
+
return len(self._states) == 0
|
| 160 |
+
|
| 161 |
+
def __len__(self) -> int:
|
| 162 |
+
return len(self._states)
|
| 163 |
+
|
| 164 |
+
def contains_state(self, state) -> bool:
|
| 165 |
+
return state in self._states
|
| 166 |
+
|
| 167 |
+
def get_node(self, state) -> Optional[SearchNode]:
|
| 168 |
+
"""Get the node for a given state if it exists."""
|
| 169 |
+
if state in self._states:
|
| 170 |
+
_, node = self._states[state]
|
| 171 |
+
return node
|
| 172 |
+
return None
|
| 173 |
+
|
| 174 |
+
def get_states(self) -> List:
|
| 175 |
+
return list(self._states.keys())
|
| 176 |
+
|
| 177 |
+
def get_priority(self, state) -> Optional[float]:
|
| 178 |
+
"""Get the priority of a state if it exists."""
|
| 179 |
+
if state in self._states:
|
| 180 |
+
priority, _ = self._states[state]
|
| 181 |
+
return priority
|
| 182 |
+
return None
|
backend/app/core/generic_search.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class GenericSearch(ABC):
|
| 10 |
+
"""
|
| 11 |
+
Abstract base class for search problems.
|
| 12 |
+
|
| 13 |
+
Subclasses must implement:
|
| 14 |
+
- initial_state(): Return the starting state
|
| 15 |
+
- goal_test(state): Return True if state is a goal
|
| 16 |
+
- actions(state): Return list of valid actions from state
|
| 17 |
+
- result(state, action): Return new state after action
|
| 18 |
+
- step_cost(state, action, next_state): Return cost of action
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
@abstractmethod
|
| 22 |
+
def initial_state(self) -> Tuple[int, int]:
|
| 23 |
+
"""Return the initial state."""
|
| 24 |
+
pass
|
| 25 |
+
|
| 26 |
+
@abstractmethod
|
| 27 |
+
def goal_test(self, state: Tuple[int, int]) -> bool:
|
| 28 |
+
"""Return True if state is a goal state."""
|
| 29 |
+
pass
|
| 30 |
+
|
| 31 |
+
@abstractmethod
|
| 32 |
+
def actions(self, state: Tuple[int, int]) -> List[str]:
|
| 33 |
+
"""Return list of valid actions from given state."""
|
| 34 |
+
pass
|
| 35 |
+
|
| 36 |
+
@abstractmethod
|
| 37 |
+
def result(self, state: Tuple[int, int], action: str) -> Tuple[int, int]:
|
| 38 |
+
"""Return the state resulting from taking action in given state."""
|
| 39 |
+
pass
|
| 40 |
+
|
| 41 |
+
@abstractmethod
|
| 42 |
+
def step_cost(
|
| 43 |
+
self, state: Tuple[int, int], action: str, next_state: Tuple[int, int]
|
| 44 |
+
) -> float:
|
| 45 |
+
"""Return the cost of taking action from state to next_state."""
|
| 46 |
+
pass
|
| 47 |
+
|
| 48 |
+
def heuristic(self, state: Tuple[int, int]) -> float:
|
| 49 |
+
"""
|
| 50 |
+
Heuristic function h(n) estimating cost from state to goal.
|
| 51 |
+
Override in subclass for informed search.
|
| 52 |
+
"""
|
| 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.
|
| 62 |
+
|
| 63 |
+
Args:
|
| 64 |
+
strategy: One of 'BF', 'DF', 'ID', 'UC', 'GR1', 'GR2', 'AS1', 'AS2'
|
| 65 |
+
visualize: If True, collect visualization steps
|
| 66 |
+
|
| 67 |
+
Returns:
|
| 68 |
+
Tuple of (PathResult, Optional[List[SearchStep]])
|
| 69 |
+
"""
|
| 70 |
+
from ..algorithms import (
|
| 71 |
+
bfs_search,
|
| 72 |
+
dfs_search,
|
| 73 |
+
ids_search,
|
| 74 |
+
ucs_search,
|
| 75 |
+
greedy_search,
|
| 76 |
+
astar_search,
|
| 77 |
+
)
|
| 78 |
+
from ..heuristics import (
|
| 79 |
+
manhattan_heuristic,
|
| 80 |
+
euclidean_heuristic,
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
# Wrap instance heuristic to match expected signature (state, goal) -> float
|
| 84 |
+
def tunnel_aware_wrapper(state, goal):
|
| 85 |
+
return self.heuristic(state)
|
| 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:
|
| 148 |
+
raise ValueError(f"Unknown strategy: {strategy}")
|
| 149 |
+
|
| 150 |
+
return strategy_map[strategy]()
|
| 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.
|
| 160 |
+
|
| 161 |
+
Args:
|
| 162 |
+
problem: The search problem to solve
|
| 163 |
+
frontier: The frontier data structure (Queue, Stack, or PriorityQueue)
|
| 164 |
+
visualize: If True, collect visualization steps
|
| 165 |
+
|
| 166 |
+
Returns:
|
| 167 |
+
Tuple of (PathResult, Optional[List[SearchStep]])
|
| 168 |
+
"""
|
| 169 |
+
# Initialize
|
| 170 |
+
start = problem.initial_state()
|
| 171 |
+
start_node = SearchNode(state=start, path_cost=0, depth=0)
|
| 172 |
+
frontier.push(start_node)
|
| 173 |
+
explored: set = set()
|
| 174 |
+
nodes_expanded = 0
|
| 175 |
+
steps: List[SearchStep] = [] if visualize else None
|
| 176 |
+
|
| 177 |
+
while not frontier.is_empty():
|
| 178 |
+
# Get next node
|
| 179 |
+
node = frontier.pop()
|
| 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:
|
| 204 |
+
continue
|
| 205 |
+
|
| 206 |
+
# Mark as explored and count
|
| 207 |
+
explored.add(node.state)
|
| 208 |
+
nodes_expanded += 1
|
| 209 |
+
|
| 210 |
+
# Expand node
|
| 211 |
+
for action in problem.actions(node.state):
|
| 212 |
+
child_state = problem.result(node.state, action)
|
| 213 |
+
if child_state not in explored and not frontier.contains_state(child_state):
|
| 214 |
+
step_cost = problem.step_cost(node.state, action, child_state)
|
| 215 |
+
child = SearchNode(
|
| 216 |
+
state=child_state,
|
| 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 |
+
)
|
backend/app/core/node.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SearchNode class for the search tree."""
|
| 2 |
+
from dataclasses import dataclass, field
|
| 3 |
+
from typing import Optional, List, Tuple, Any
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@dataclass
|
| 7 |
+
class SearchNode:
|
| 8 |
+
"""
|
| 9 |
+
Represents a node in the search tree.
|
| 10 |
+
|
| 11 |
+
Attributes:
|
| 12 |
+
state: Current position (x, y) on the grid
|
| 13 |
+
parent: Parent node for path reconstruction
|
| 14 |
+
action: Action taken to reach this node (up/down/left/right/tunnel)
|
| 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 |
+
|
| 30 |
+
def __eq__(self, other: Any) -> bool:
|
| 31 |
+
"""Nodes are equal if they have the same state."""
|
| 32 |
+
if not isinstance(other, SearchNode):
|
| 33 |
+
return False
|
| 34 |
+
return self.state == other.state
|
| 35 |
+
|
| 36 |
+
def __hash__(self) -> int:
|
| 37 |
+
"""Hash by state for set membership."""
|
| 38 |
+
return hash(self.state)
|
| 39 |
+
|
| 40 |
+
def get_path(self) -> List[Tuple[int, int]]:
|
| 41 |
+
"""
|
| 42 |
+
Reconstruct the path from root to this node.
|
| 43 |
+
|
| 44 |
+
Returns:
|
| 45 |
+
List of positions from start to current node
|
| 46 |
+
"""
|
| 47 |
+
path = []
|
| 48 |
+
node: Optional[SearchNode] = self
|
| 49 |
+
while node is not None:
|
| 50 |
+
path.append(node.state)
|
| 51 |
+
node = node.parent
|
| 52 |
+
path.reverse()
|
| 53 |
+
return path
|
| 54 |
+
|
| 55 |
+
def get_actions(self) -> List[str]:
|
| 56 |
+
"""
|
| 57 |
+
Reconstruct the sequence of actions from root to this node.
|
| 58 |
+
|
| 59 |
+
Returns:
|
| 60 |
+
List of actions taken from start to current node
|
| 61 |
+
"""
|
| 62 |
+
actions = []
|
| 63 |
+
node: Optional[SearchNode] = self
|
| 64 |
+
while node is not None and node.action is not None:
|
| 65 |
+
actions.append(node.action)
|
| 66 |
+
node = node.parent
|
| 67 |
+
actions.reverse()
|
| 68 |
+
return actions
|
| 69 |
+
|
| 70 |
+
def get_solution(self) -> str:
|
| 71 |
+
"""
|
| 72 |
+
Get the solution as a comma-separated string of actions.
|
| 73 |
+
|
| 74 |
+
Returns:
|
| 75 |
+
String in format "action1,action2,action3,..."
|
| 76 |
+
"""
|
| 77 |
+
actions = self.get_actions()
|
| 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 |
+
|
| 90 |
+
Args:
|
| 91 |
+
actions_func: Function(state) -> List[str] of valid actions
|
| 92 |
+
result_func: Function(state, action) -> new_state
|
| 93 |
+
cost_func: Function(state, action, new_state) -> step_cost
|
| 94 |
+
heuristic_func: Optional Function(state, goal) -> h(n) for A*/Greedy
|
| 95 |
+
|
| 96 |
+
Returns:
|
| 97 |
+
List of child SearchNode objects
|
| 98 |
+
"""
|
| 99 |
+
children = []
|
| 100 |
+
for action in actions_func(self.state):
|
| 101 |
+
new_state = result_func(self.state, action)
|
| 102 |
+
step_cost = cost_func(self.state, action, new_state)
|
| 103 |
+
child = SearchNode(
|
| 104 |
+
state=new_state,
|
| 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:
|
| 112 |
+
child.priority = child.path_cost + heuristic_func(new_state)
|
| 113 |
+
else:
|
| 114 |
+
child.priority = child.path_cost
|
| 115 |
+
children.append(child)
|
| 116 |
+
return children
|
| 117 |
+
|
| 118 |
+
def __repr__(self) -> str:
|
| 119 |
+
return f"SearchNode(state={self.state}, depth={self.depth}, cost={self.path_cost})"
|
backend/app/heuristics/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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__ = [
|
| 8 |
+
"manhattan_heuristic",
|
| 9 |
+
"euclidean_heuristic",
|
| 10 |
+
"traffic_weighted_heuristic",
|
| 11 |
+
"create_traffic_weighted_heuristic",
|
| 12 |
+
"tunnel_aware_heuristic",
|
| 13 |
+
"create_tunnel_aware_heuristic",
|
| 14 |
+
]
|
backend/app/heuristics/euclidean.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Euclidean distance heuristic."""
|
| 2 |
+
import math
|
| 3 |
+
from typing import Tuple
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def euclidean_heuristic(state: Tuple[int, int], goal: Tuple[int, int]) -> float:
|
| 7 |
+
"""
|
| 8 |
+
Euclidean distance heuristic.
|
| 9 |
+
|
| 10 |
+
h(n) = sqrt((x1 - x2)^2 + (y1 - y2)^2)
|
| 11 |
+
|
| 12 |
+
Admissible: Straight-line distance is always <= actual path distance.
|
| 13 |
+
Since we can only move in cardinal directions, this will never overestimate the actual cost.
|
| 14 |
+
|
| 15 |
+
Args:
|
| 16 |
+
state: Current position (x, y)
|
| 17 |
+
goal: Goal position (x, y)
|
| 18 |
+
|
| 19 |
+
Returns:
|
| 20 |
+
Estimated cost to reach goal
|
| 21 |
+
"""
|
| 22 |
+
if goal is None:
|
| 23 |
+
return 0.0
|
| 24 |
+
return math.sqrt((state[0] - goal[0]) ** 2 + (state[1] - goal[1]) ** 2)
|
backend/app/heuristics/manhattan.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Manhattan distance heuristic."""
|
| 2 |
+
from typing import Tuple
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def manhattan_heuristic(state: Tuple[int, int], goal: Tuple[int, int]) -> float:
|
| 6 |
+
"""
|
| 7 |
+
Manhattan distance heuristic.
|
| 8 |
+
|
| 9 |
+
h(n) = |x1 - x2| + |y1 - y2|
|
| 10 |
+
|
| 11 |
+
Admissible: Assumes minimum cost of 1 per step, which is the minimum possible traffic level.
|
| 12 |
+
|
| 13 |
+
Args:
|
| 14 |
+
state: Current position (x, y)
|
| 15 |
+
goal: Goal position (x, y)
|
| 16 |
+
|
| 17 |
+
Returns:
|
| 18 |
+
Estimated cost to reach goal
|
| 19 |
+
"""
|
| 20 |
+
if goal is None:
|
| 21 |
+
return 0.0
|
| 22 |
+
return abs(state[0] - goal[0]) + abs(state[1] - goal[1])
|
backend/app/heuristics/traffic_weighted.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 12 |
+
|
| 13 |
+
h(n) = manhattan_distance * minimum_traffic_cost
|
| 14 |
+
|
| 15 |
+
Admissible: Uses the minimum possible traffic cost to ensure we never overestimate the actual cost.
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
state: Current position (x, y)
|
| 19 |
+
goal: Goal position (x, y)
|
| 20 |
+
min_traffic: Minimum traffic level in the grid (default 1.0)
|
| 21 |
+
|
| 22 |
+
Returns:
|
| 23 |
+
Estimated cost to reach goal
|
| 24 |
+
"""
|
| 25 |
+
if goal is None:
|
| 26 |
+
return 0.0
|
| 27 |
+
manhattan = abs(state[0] - goal[0]) + abs(state[1] - goal[1])
|
| 28 |
+
return manhattan * min_traffic
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def create_traffic_weighted_heuristic(min_traffic: float = 1.0):
|
| 32 |
+
"""
|
| 33 |
+
Factory function to create a traffic-weighted heuristic with specific min_traffic.
|
| 34 |
+
|
| 35 |
+
Args:
|
| 36 |
+
min_traffic: Minimum traffic level in the grid
|
| 37 |
+
|
| 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
|
backend/app/heuristics/tunnel_aware.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 13 |
+
|
| 14 |
+
h(n) = min(direct_manhattan, tunnel_shortcuts)
|
| 15 |
+
|
| 16 |
+
Considers potential tunnel shortcuts that might reduce the distance.
|
| 17 |
+
For each tunnel, calculates the cost of going to entrance, through tunnel,
|
| 18 |
+
and from exit to goal.
|
| 19 |
+
|
| 20 |
+
Admissible: Takes minimum of all options, so never overestimates.
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
state: Current position (x, y)
|
| 24 |
+
goal: Goal position (x, y)
|
| 25 |
+
tunnels: List of Tunnel objects with entrance1, entrance2, and cost
|
| 26 |
+
|
| 27 |
+
Returns:
|
| 28 |
+
Estimated cost to reach goal
|
| 29 |
+
"""
|
| 30 |
+
if goal is None:
|
| 31 |
+
return 0.0
|
| 32 |
+
|
| 33 |
+
# Direct Manhattan distance
|
| 34 |
+
direct = manhattan_heuristic(state, goal)
|
| 35 |
+
|
| 36 |
+
if not tunnels:
|
| 37 |
+
return direct
|
| 38 |
+
|
| 39 |
+
# Check each tunnel for potential shortcut
|
| 40 |
+
best = direct
|
| 41 |
+
for tunnel in tunnels:
|
| 42 |
+
entrance1 = tunnel.entrance1
|
| 43 |
+
entrance2 = tunnel.entrance2
|
| 44 |
+
tunnel_cost = tunnel.cost
|
| 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)
|
| 61 |
+
|
| 62 |
+
return best
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def create_tunnel_aware_heuristic(tunnels: List):
|
| 66 |
+
"""
|
| 67 |
+
Factory function to create a tunnel-aware heuristic with specific tunnels.
|
| 68 |
+
|
| 69 |
+
Args:
|
| 70 |
+
tunnels: List of Tunnel objects
|
| 71 |
+
|
| 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
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI application entry point."""
|
| 2 |
+
from fastapi import FastAPI
|
| 3 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
+
import uvicorn
|
| 5 |
+
|
| 6 |
+
from .api.routes import router
|
| 7 |
+
|
| 8 |
+
# Create FastAPI app
|
| 9 |
+
app = FastAPI(
|
| 10 |
+
title="Package Delivery Search API",
|
| 11 |
+
description="Search algorithms for package delivery optimization",
|
| 12 |
+
version="1.0.0",
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
# Configure CORS
|
| 16 |
+
app.add_middleware(
|
| 17 |
+
CORSMiddleware,
|
| 18 |
+
allow_origins=["*"],
|
| 19 |
+
allow_credentials=True,
|
| 20 |
+
allow_methods=["*"],
|
| 21 |
+
allow_headers=["*"],
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
# Include routes
|
| 25 |
+
app.include_router(router)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@app.get("/")
|
| 29 |
+
async def root():
|
| 30 |
+
"""Root endpoint."""
|
| 31 |
+
return {
|
| 32 |
+
"name": "Package Delivery Search API",
|
| 33 |
+
"version": "1.0.0",
|
| 34 |
+
"endpoints": {
|
| 35 |
+
"health": "/api/health",
|
| 36 |
+
"algorithms": "/api/algorithms",
|
| 37 |
+
"generate": "/api/grid/generate",
|
| 38 |
+
"path": "/api/search/path",
|
| 39 |
+
"plan": "/api/search/plan",
|
| 40 |
+
"compare": "/api/search/compare",
|
| 41 |
+
"visualize": "ws://localhost:8000/ws/visualize"
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
if __name__ == "__main__":
|
| 47 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
backend/app/models/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 8 |
+
SegmentData,
|
| 9 |
+
StoreData,
|
| 10 |
+
DestinationData,
|
| 11 |
+
TunnelData,
|
| 12 |
+
GridConfig,
|
| 13 |
+
SearchRequest,
|
| 14 |
+
PathRequest,
|
| 15 |
+
CompareRequest,
|
| 16 |
+
PathData,
|
| 17 |
+
GridData,
|
| 18 |
+
GenerateResponse,
|
| 19 |
+
SearchResponse,
|
| 20 |
+
PlanResponse,
|
| 21 |
+
ComparisonResult,
|
| 22 |
+
CompareResponse,
|
| 23 |
+
AlgorithmInfo,
|
| 24 |
+
AlgorithmsResponse,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
__all__ = [
|
| 28 |
+
# Grid models
|
| 29 |
+
"Grid",
|
| 30 |
+
"Segment",
|
| 31 |
+
# Entity models
|
| 32 |
+
"Store",
|
| 33 |
+
"Destination",
|
| 34 |
+
"Tunnel",
|
| 35 |
+
"Truck",
|
| 36 |
+
# State models
|
| 37 |
+
"SearchState",
|
| 38 |
+
"PathResult",
|
| 39 |
+
"DeliveryAssignment",
|
| 40 |
+
"PlanResult",
|
| 41 |
+
"SearchStep",
|
| 42 |
+
"SearchMetrics",
|
| 43 |
+
# Request/Response models
|
| 44 |
+
"Algorithm",
|
| 45 |
+
"Position",
|
| 46 |
+
"SegmentData",
|
| 47 |
+
"StoreData",
|
| 48 |
+
"DestinationData",
|
| 49 |
+
"TunnelData",
|
| 50 |
+
"GridConfig",
|
| 51 |
+
"SearchRequest",
|
| 52 |
+
"PathRequest",
|
| 53 |
+
"CompareRequest",
|
| 54 |
+
"PathData",
|
| 55 |
+
"GridData",
|
| 56 |
+
"GenerateResponse",
|
| 57 |
+
"SearchResponse",
|
| 58 |
+
"PlanResponse",
|
| 59 |
+
"ComparisonResult",
|
| 60 |
+
"CompareResponse",
|
| 61 |
+
"AlgorithmInfo",
|
| 62 |
+
"AlgorithmsResponse",
|
| 63 |
+
]
|
backend/app/models/entities.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Entity models for stores, destinations, tunnels, and trucks."""
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
from typing import Tuple
|
| 4 |
+
|
| 5 |
+
|
| 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."""
|
| 45 |
+
if entrance == self.entrance1:
|
| 46 |
+
return self.entrance2
|
| 47 |
+
elif entrance == self.entrance2:
|
| 48 |
+
return self.entrance1
|
| 49 |
+
raise ValueError(f"Position {entrance} is not an entrance of this tunnel")
|
| 50 |
+
|
| 51 |
+
def has_entrance_at(self, pos: Tuple[int, int]) -> bool:
|
| 52 |
+
"""Check if tunnel has an entrance at given position."""
|
| 53 |
+
return pos == self.entrance1 or pos == self.entrance2
|
| 54 |
+
|
| 55 |
+
def to_dict(self) -> dict:
|
| 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 |
+
}
|
backend/app/models/grid.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Grid and Segment models for the delivery search problem."""
|
| 2 |
+
from dataclasses import dataclass, field
|
| 3 |
+
from typing import Dict, Tuple, Optional
|
| 4 |
+
|
| 5 |
+
|
| 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
|
| 12 |
+
|
| 13 |
+
def __post_init__(self):
|
| 14 |
+
# Normalize segment direction (ensure src < dst lexicographically)
|
| 15 |
+
if self.src > self.dst:
|
| 16 |
+
self.src, self.dst = self.dst, self.src
|
| 17 |
+
|
| 18 |
+
@property
|
| 19 |
+
def is_blocked(self) -> bool:
|
| 20 |
+
return self.traffic == 0
|
| 21 |
+
|
| 22 |
+
def get_key(self) -> Tuple[Tuple[int, int], Tuple[int, int]]:
|
| 23 |
+
"""Get normalized key for segment lookup."""
|
| 24 |
+
return (self.src, self.dst)
|
| 25 |
+
|
| 26 |
+
|
| 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)
|
| 38 |
+
|
| 39 |
+
def get_traffic(self, src: Tuple[int, int], dst: Tuple[int, int]) -> int:
|
| 40 |
+
"""Get traffic level for segment between two points."""
|
| 41 |
+
segment = self.get_segment(src, dst)
|
| 42 |
+
return segment.traffic if segment else 0
|
| 43 |
+
|
| 44 |
+
def is_blocked(self, src: Tuple[int, int], dst: Tuple[int, int]) -> bool:
|
| 45 |
+
"""Check if segment between two points is blocked."""
|
| 46 |
+
return self.get_traffic(src, dst) == 0
|
| 47 |
+
|
| 48 |
+
def is_valid_position(self, pos: Tuple[int, int]) -> bool:
|
| 49 |
+
"""Check if position is within grid bounds."""
|
| 50 |
+
x, y = pos
|
| 51 |
+
return 0 <= x < self.width and 0 <= y < self.height
|
| 52 |
+
|
| 53 |
+
def add_segment(self, src: Tuple[int, int], dst: Tuple[int, int], traffic: int):
|
| 54 |
+
"""Add or update a segment."""
|
| 55 |
+
segment = Segment(src, dst, traffic)
|
| 56 |
+
self.segments[segment.get_key()] = segment
|
| 57 |
+
|
| 58 |
+
def get_neighbors(self, pos: Tuple[int, int]) -> list[Tuple[int, int]]:
|
| 59 |
+
"""Get all valid neighboring positions (not blocked)."""
|
| 60 |
+
x, y = pos
|
| 61 |
+
neighbors = []
|
| 62 |
+
directions = [(0, 1), (0, -1), (1, 0), (-1, 0)] # up, down, right, left
|
| 63 |
+
|
| 64 |
+
for dx, dy in directions:
|
| 65 |
+
new_pos = (x + dx, y + dy)
|
| 66 |
+
if self.is_valid_position(new_pos) and not self.is_blocked(pos, new_pos):
|
| 67 |
+
neighbors.append(new_pos)
|
| 68 |
+
|
| 69 |
+
return neighbors
|
| 70 |
+
|
| 71 |
+
def to_dict(self) -> dict:
|
| 72 |
+
"""Convert grid to dictionary for JSON serialization."""
|
| 73 |
+
return {
|
| 74 |
+
"width": self.width,
|
| 75 |
+
"height": self.height,
|
| 76 |
+
"segments": [
|
| 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 |
+
}
|
backend/app/models/requests.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 5 |
+
|
| 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
|
| 16 |
+
AS2 = "AS2" # A* with Tunnel-aware heuristic
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class Position(BaseModel):
|
| 20 |
+
"""A position on the grid."""
|
| 21 |
+
x: int
|
| 22 |
+
y: int
|
| 23 |
+
|
| 24 |
+
def to_tuple(self) -> Tuple[int, int]:
|
| 25 |
+
return (self.x, self.y)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class SegmentData(BaseModel):
|
| 29 |
+
"""Segment data for API."""
|
| 30 |
+
src: Position
|
| 31 |
+
dst: Position
|
| 32 |
+
traffic: int = Field(ge=0, le=4)
|
| 33 |
+
|
| 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
|
| 52 |
+
|
| 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)
|
| 61 |
+
num_destinations: Optional[int] = Field(None, ge=1, le=10)
|
| 62 |
+
num_tunnels: Optional[int] = Field(None, ge=0, le=10)
|
| 63 |
+
obstacle_density: float = Field(0.1, ge=0.0, le=0.5)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class SearchRequest(BaseModel):
|
| 67 |
+
"""Request for running a search/plan."""
|
| 68 |
+
initial_state: str
|
| 69 |
+
traffic: str
|
| 70 |
+
strategy: Algorithm
|
| 71 |
+
visualize: bool = False
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class PathRequest(BaseModel):
|
| 75 |
+
"""Request for finding a single path."""
|
| 76 |
+
grid_width: int
|
| 77 |
+
grid_height: int
|
| 78 |
+
start: Position
|
| 79 |
+
goal: Position
|
| 80 |
+
segments: List[SegmentData]
|
| 81 |
+
tunnels: List[TunnelData] = []
|
| 82 |
+
strategy: Algorithm
|
| 83 |
+
|
| 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
|
| 98 |
+
path: List[Position]
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class GridData(BaseModel):
|
| 102 |
+
"""Complete grid state data."""
|
| 103 |
+
width: int
|
| 104 |
+
height: int
|
| 105 |
+
stores: List[StoreData]
|
| 106 |
+
destinations: List[DestinationData]
|
| 107 |
+
tunnels: List[TunnelData]
|
| 108 |
+
segments: List[SegmentData]
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
class GenerateResponse(BaseModel):
|
| 112 |
+
"""Response from grid generation."""
|
| 113 |
+
initial_state: str
|
| 114 |
+
traffic: str
|
| 115 |
+
parsed: GridData
|
| 116 |
+
|
| 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
|
| 128 |
+
|
| 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
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
class AlgorithmsResponse(BaseModel):
|
| 168 |
+
"""List of available algorithms."""
|
| 169 |
+
algorithms: List[AlgorithmInfo]
|
backend/app/models/state.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 5 |
+
from .entities import Store, Destination, Tunnel
|
| 6 |
+
|
| 7 |
+
|
| 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]
|
| 14 |
+
tunnels: List[Tunnel]
|
| 15 |
+
|
| 16 |
+
def get_tunnel_at(self, pos: Tuple[int, int]) -> Optional[Tunnel]:
|
| 17 |
+
"""Get tunnel with entrance at given position."""
|
| 18 |
+
for tunnel in self.tunnels:
|
| 19 |
+
if tunnel.has_entrance_at(pos):
|
| 20 |
+
return tunnel
|
| 21 |
+
return None
|
| 22 |
+
|
| 23 |
+
def to_dict(self) -> dict:
|
| 24 |
+
return {
|
| 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"""
|
| 42 |
+
return f"{self.plan};{self.cost};{self.nodes_expanded}"
|
| 43 |
+
|
| 44 |
+
def to_dict(self) -> dict:
|
| 45 |
+
return {
|
| 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
|
| 59 |
+
|
| 60 |
+
def to_dict(self) -> dict:
|
| 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
|
| 74 |
+
|
| 75 |
+
def to_string(self) -> str:
|
| 76 |
+
"""Format output as specified."""
|
| 77 |
+
parts = []
|
| 78 |
+
for assignment in self.assignments:
|
| 79 |
+
parts.append(
|
| 80 |
+
f"({assignment.store_id},{assignment.destination_id}):{assignment.path_result.to_string()}"
|
| 81 |
+
)
|
| 82 |
+
return ";".join(parts)
|
| 83 |
+
|
| 84 |
+
def to_dict(self) -> dict:
|
| 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]
|
| 98 |
+
frontier: List[Tuple[int, int]]
|
| 99 |
+
explored: List[Tuple[int, int]]
|
| 100 |
+
current_path: List[Tuple[int, int]]
|
| 101 |
+
path_cost: float
|
| 102 |
+
|
| 103 |
+
def to_dict(self) -> dict:
|
| 104 |
+
return {
|
| 105 |
+
"stepNumber": self.step_number,
|
| 106 |
+
"currentNode": {"x": self.current_node[0], "y": self.current_node[1]},
|
| 107 |
+
"action": self.action,
|
| 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
|
| 123 |
+
path_length: int
|
| 124 |
+
|
| 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 |
+
}
|
backend/app/services/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Services package."""
|
| 2 |
+
from .parser import (
|
| 3 |
+
parse_initial_state,
|
| 4 |
+
parse_traffic,
|
| 5 |
+
parse_full_state,
|
| 6 |
+
format_initial_state,
|
| 7 |
+
format_traffic,
|
| 8 |
+
)
|
| 9 |
+
from .grid_generator import gen_grid
|
| 10 |
+
from .metrics import (
|
| 11 |
+
MetricsCollector,
|
| 12 |
+
measure_performance,
|
| 13 |
+
run_with_metrics,
|
| 14 |
+
format_metrics,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
__all__ = [
|
| 18 |
+
"parse_initial_state",
|
| 19 |
+
"parse_traffic",
|
| 20 |
+
"parse_full_state",
|
| 21 |
+
"format_initial_state",
|
| 22 |
+
"format_traffic",
|
| 23 |
+
"gen_grid",
|
| 24 |
+
"MetricsCollector",
|
| 25 |
+
"measure_performance",
|
| 26 |
+
"run_with_metrics",
|
| 27 |
+
"format_metrics",
|
| 28 |
+
]
|
backend/app/services/grid_generator.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 5 |
+
from ..models.entities import Store, Destination, Tunnel
|
| 6 |
+
from ..models.state import SearchState
|
| 7 |
+
from .parser import format_initial_state, format_traffic
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def gen_grid(
|
| 11 |
+
width: Optional[int] = None,
|
| 12 |
+
height: Optional[int] = None,
|
| 13 |
+
num_stores: Optional[int] = None,
|
| 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.
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
width: Grid width (random 5-15 if None)
|
| 24 |
+
height: Grid height (random 5-15 if None)
|
| 25 |
+
num_stores: Number of stores (random 1-3 if None)
|
| 26 |
+
num_destinations: Number of destinations (random 1-10 if None)
|
| 27 |
+
num_tunnels: Number of tunnels (random 0-5 if None)
|
| 28 |
+
obstacle_density: Fraction of segments to block (0.0-0.5)
|
| 29 |
+
seed: Random seed for reproducibility
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
Tuple of (initial_state_string, traffic_string, SearchState)
|
| 33 |
+
"""
|
| 34 |
+
if seed is not None:
|
| 35 |
+
random.seed(seed)
|
| 36 |
+
|
| 37 |
+
# Set defaults
|
| 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
|
| 45 |
+
num_stores = min(num_stores, 3)
|
| 46 |
+
num_destinations = min(num_destinations, 10)
|
| 47 |
+
|
| 48 |
+
# Track occupied positions
|
| 49 |
+
occupied: Set[Tuple[int, int]] = set()
|
| 50 |
+
|
| 51 |
+
# Generate stores
|
| 52 |
+
stores = _generate_stores(width, height, num_stores, occupied)
|
| 53 |
+
|
| 54 |
+
# Generate destinations
|
| 55 |
+
destinations = _generate_destinations(width, height, num_destinations, occupied)
|
| 56 |
+
|
| 57 |
+
# Generate tunnels
|
| 58 |
+
tunnels = _generate_tunnels(width, height, num_tunnels, occupied)
|
| 59 |
+
|
| 60 |
+
# Generate grid with traffic
|
| 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)
|
| 68 |
+
traffic = format_traffic(grid)
|
| 69 |
+
|
| 70 |
+
return initial_state, traffic, state
|
| 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 = []
|
| 81 |
+
|
| 82 |
+
# Prefer corners
|
| 83 |
+
corners = [
|
| 84 |
+
(0, 0),
|
| 85 |
+
(width - 1, 0),
|
| 86 |
+
(0, height - 1),
|
| 87 |
+
(width - 1, height - 1),
|
| 88 |
+
]
|
| 89 |
+
random.shuffle(corners)
|
| 90 |
+
|
| 91 |
+
for i, pos in enumerate(corners[:num_stores]):
|
| 92 |
+
stores.append(Store(id=i + 1, position=pos))
|
| 93 |
+
occupied.add(pos)
|
| 94 |
+
|
| 95 |
+
# If need more, use edges
|
| 96 |
+
if len(stores) < num_stores:
|
| 97 |
+
edges = []
|
| 98 |
+
for x in range(1, width - 1):
|
| 99 |
+
edges.append((x, 0))
|
| 100 |
+
edges.append((x, height - 1))
|
| 101 |
+
for y in range(1, height - 1):
|
| 102 |
+
edges.append((0, y))
|
| 103 |
+
edges.append((width - 1, y))
|
| 104 |
+
|
| 105 |
+
random.shuffle(edges)
|
| 106 |
+
for pos in edges:
|
| 107 |
+
if pos not in occupied and len(stores) < num_stores:
|
| 108 |
+
stores.append(Store(id=len(stores) + 1, position=pos))
|
| 109 |
+
occupied.add(pos)
|
| 110 |
+
|
| 111 |
+
return 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 = []
|
| 122 |
+
|
| 123 |
+
# Try to spread destinations across the grid
|
| 124 |
+
available = []
|
| 125 |
+
for x in range(width):
|
| 126 |
+
for y in range(height):
|
| 127 |
+
if (x, y) not in occupied:
|
| 128 |
+
available.append((x, y))
|
| 129 |
+
|
| 130 |
+
random.shuffle(available)
|
| 131 |
+
|
| 132 |
+
for i, pos in enumerate(available[:num_destinations]):
|
| 133 |
+
destinations.append(Destination(id=i + 1, position=pos))
|
| 134 |
+
occupied.add(pos)
|
| 135 |
+
|
| 136 |
+
return 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 = []
|
| 147 |
+
|
| 148 |
+
# Find available positions for tunnel entrances
|
| 149 |
+
available = []
|
| 150 |
+
for x in range(width):
|
| 151 |
+
for y in range(height):
|
| 152 |
+
if (x, y) not in occupied:
|
| 153 |
+
available.append((x, y))
|
| 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 |
+
|
| 172 |
+
def _generate_traffic(
|
| 173 |
+
width: int,
|
| 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.
|
| 181 |
+
|
| 182 |
+
Ensures connectivity between stores and destinations.
|
| 183 |
+
"""
|
| 184 |
+
grid = Grid(width=width, height=height)
|
| 185 |
+
|
| 186 |
+
# First, add all segments with random traffic
|
| 187 |
+
all_segments = []
|
| 188 |
+
|
| 189 |
+
# Horizontal segments
|
| 190 |
+
for x in range(width - 1):
|
| 191 |
+
for y in range(height):
|
| 192 |
+
all_segments.append(((x, y), (x + 1, y)))
|
| 193 |
+
|
| 194 |
+
# Vertical segments
|
| 195 |
+
for x in range(width):
|
| 196 |
+
for y in range(height - 1):
|
| 197 |
+
all_segments.append(((x, y), (x, y + 1)))
|
| 198 |
+
|
| 199 |
+
# Add segments with traffic
|
| 200 |
+
for src, dst in all_segments:
|
| 201 |
+
# Random traffic level 1-4 or blocked (0)
|
| 202 |
+
if random.random() < obstacle_density:
|
| 203 |
+
traffic = 0 # Blocked
|
| 204 |
+
else:
|
| 205 |
+
traffic = random.randint(1, 4)
|
| 206 |
+
grid.add_segment(src, dst, traffic)
|
| 207 |
+
|
| 208 |
+
# Ensure connectivity - make sure there's a path from each store to each destination
|
| 209 |
+
_ensure_connectivity(grid, stores, destinations)
|
| 210 |
+
|
| 211 |
+
return grid
|
| 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.
|
| 221 |
+
|
| 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
|
| 229 |
+
|
| 230 |
+
# Check connectivity from first store to all destinations
|
| 231 |
+
start = stores[0].position if stores else important_positions[0]
|
| 232 |
+
|
| 233 |
+
# BFS to find reachable positions
|
| 234 |
+
visited = {start}
|
| 235 |
+
queue = [start]
|
| 236 |
+
|
| 237 |
+
while queue:
|
| 238 |
+
current = queue.pop(0)
|
| 239 |
+
for neighbor in grid.get_neighbors(current):
|
| 240 |
+
if neighbor not in visited:
|
| 241 |
+
visited.add(neighbor)
|
| 242 |
+
queue.append(neighbor)
|
| 243 |
+
|
| 244 |
+
# Check if all important positions are reachable
|
| 245 |
+
unreachable = [pos for pos in important_positions if pos not in visited]
|
| 246 |
+
|
| 247 |
+
# If some positions are unreachable, create paths to them
|
| 248 |
+
for pos in unreachable:
|
| 249 |
+
_create_path_to(grid, start, pos, visited)
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
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
|
| 260 |
+
closest = min(visited, key=lambda p: abs(p[0] - goal[0]) + abs(p[1] - goal[1]))
|
| 261 |
+
|
| 262 |
+
# Create path from closest to goal
|
| 263 |
+
current = closest
|
| 264 |
+
while current != goal:
|
| 265 |
+
dx = 0 if goal[0] == current[0] else (1 if goal[0] > current[0] else -1)
|
| 266 |
+
dy = 0 if goal[1] == current[1] else (1 if goal[1] > current[1] else -1)
|
| 267 |
+
|
| 268 |
+
# Prefer moving in direction with larger difference
|
| 269 |
+
if abs(goal[0] - current[0]) >= abs(goal[1] - current[1]) and dx != 0:
|
| 270 |
+
next_pos = (current[0] + dx, current[1])
|
| 271 |
+
elif dy != 0:
|
| 272 |
+
next_pos = (current[0], current[1] + dy)
|
| 273 |
+
else:
|
| 274 |
+
next_pos = (current[0] + dx, current[1])
|
| 275 |
+
|
| 276 |
+
# Unblock segment if blocked
|
| 277 |
+
if grid.is_blocked(current, next_pos):
|
| 278 |
+
grid.add_segment(current, next_pos, random.randint(1, 4))
|
| 279 |
+
|
| 280 |
+
visited.add(next_pos)
|
| 281 |
+
current = next_pos
|
backend/app/services/metrics.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Performance metrics collection service."""
|
| 2 |
+
import time
|
| 3 |
+
import psutil
|
| 4 |
+
from contextlib import contextmanager
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from typing import Generator, Callable, Any, Tuple
|
| 7 |
+
from ..models.state import SearchMetrics
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class MetricsCollector:
|
| 12 |
+
"""Collects performance metrics during search execution."""
|
| 13 |
+
|
| 14 |
+
def __init__(self):
|
| 15 |
+
self.start_time: float = 0
|
| 16 |
+
self.end_time: float = 0
|
| 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 |
+
|
| 23 |
+
def start(self) -> None:
|
| 24 |
+
"""Start collecting metrics."""
|
| 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()
|
| 31 |
+
|
| 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 |
+
|
| 38 |
+
def stop(self) -> None:
|
| 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 |
+
|
| 45 |
+
@property
|
| 46 |
+
def runtime_ms(self) -> float:
|
| 47 |
+
"""Get runtime in milliseconds."""
|
| 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:
|
| 57 |
+
"""Get average CPU percentage."""
|
| 58 |
+
if not self.cpu_samples:
|
| 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 |
+
|
| 74 |
+
@contextmanager
|
| 75 |
+
def measure_performance() -> Generator[MetricsCollector, None, None]:
|
| 76 |
+
"""
|
| 77 |
+
Context manager for measuring search performance.
|
| 78 |
+
|
| 79 |
+
Usage:
|
| 80 |
+
with measure_performance() as metrics:
|
| 81 |
+
result = search.solve(strategy)
|
| 82 |
+
print(f"Runtime: {metrics.runtime_ms}ms")
|
| 83 |
+
"""
|
| 84 |
+
collector = MetricsCollector()
|
| 85 |
+
collector.start()
|
| 86 |
+
try:
|
| 87 |
+
yield collector
|
| 88 |
+
finally:
|
| 89 |
+
collector.stop()
|
| 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.
|
| 99 |
+
|
| 100 |
+
Args:
|
| 101 |
+
func: Function to run
|
| 102 |
+
*args: Positional arguments for func
|
| 103 |
+
**kwargs: Keyword arguments for func
|
| 104 |
+
|
| 105 |
+
Returns:
|
| 106 |
+
Tuple of (function result, MetricsCollector)
|
| 107 |
+
"""
|
| 108 |
+
collector = MetricsCollector()
|
| 109 |
+
collector.start()
|
| 110 |
+
try:
|
| 111 |
+
result = func(*args, **kwargs)
|
| 112 |
+
finally:
|
| 113 |
+
collector.stop()
|
| 114 |
+
return result, collector
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def format_metrics(metrics: SearchMetrics) -> str:
|
| 118 |
+
"""Format metrics for display."""
|
| 119 |
+
return (
|
| 120 |
+
f"Runtime: {metrics.runtime_ms:.2f}ms | "
|
| 121 |
+
f"Memory: {metrics.memory_mb:.2f}MB | "
|
| 122 |
+
f"CPU: {metrics.cpu_percent:.1f}% | "
|
| 123 |
+
f"Nodes: {metrics.nodes_expanded} | "
|
| 124 |
+
f"Cost: {metrics.path_cost} | "
|
| 125 |
+
f"Path Length: {metrics.path_length}"
|
| 126 |
+
)
|
backend/app/services/parser.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 18 |
+
|
| 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
|
| 26 |
+
height = int(parts[1]) # n
|
| 27 |
+
|
| 28 |
+
# Number of packages/customers and stores
|
| 29 |
+
num_packages = int(parts[2]) # P
|
| 30 |
+
num_stores = int(parts[3]) # S
|
| 31 |
+
|
| 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])
|
| 39 |
+
y = int(customer_coords[i + 1])
|
| 40 |
+
dest_id = len(destinations) + 1
|
| 41 |
+
destinations.append(Destination(id=dest_id, position=(x, y)))
|
| 42 |
+
|
| 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])
|
| 50 |
+
y1 = int(tunnel_coords[i + 1])
|
| 51 |
+
x2 = int(tunnel_coords[i + 2])
|
| 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.
|
| 116 |
+
|
| 117 |
+
Format:
|
| 118 |
+
SrcX_1,SrcY_1,DstX_1,DstY_1,Traffic_1;SrcX_2,SrcY_2,DstX_2,DstY_2,Traffic_2;...
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
traffic_str: Traffic string
|
| 122 |
+
width: Grid width
|
| 123 |
+
height: Grid height
|
| 124 |
+
|
| 125 |
+
Returns:
|
| 126 |
+
Grid with traffic information
|
| 127 |
+
"""
|
| 128 |
+
grid = Grid(width=width, height=height)
|
| 129 |
+
|
| 130 |
+
if not traffic_str:
|
| 131 |
+
# Initialize all segments with default traffic level 1
|
| 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])
|
| 143 |
+
dst_x = int(parts[2])
|
| 144 |
+
dst_y = int(parts[3])
|
| 145 |
+
traffic = int(parts[4])
|
| 146 |
+
grid.add_segment((src_x, src_y), (dst_x, dst_y), traffic)
|
| 147 |
+
|
| 148 |
+
return grid
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _initialize_default_traffic(grid: Grid, default_traffic: int = 1) -> None:
|
| 152 |
+
"""
|
| 153 |
+
Initialize all grid segments with default traffic.
|
| 154 |
+
|
| 155 |
+
Creates horizontal and vertical segments between adjacent cells.
|
| 156 |
+
"""
|
| 157 |
+
for x in range(grid.width):
|
| 158 |
+
for y in range(grid.height):
|
| 159 |
+
# Horizontal segment (right)
|
| 160 |
+
if x + 1 < grid.width:
|
| 161 |
+
grid.add_segment((x, y), (x + 1, y), default_traffic)
|
| 162 |
+
# Vertical segment (up)
|
| 163 |
+
if y + 1 < grid.height:
|
| 164 |
+
grid.add_segment((x, y), (x, y + 1), default_traffic)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def parse_full_state(initial_state: str, traffic_str: str) -> SearchState:
|
| 168 |
+
"""
|
| 169 |
+
Parse both initial state and traffic into a complete SearchState.
|
| 170 |
+
|
| 171 |
+
Args:
|
| 172 |
+
initial_state: Initial state string
|
| 173 |
+
traffic_str: Traffic string
|
| 174 |
+
|
| 175 |
+
Returns:
|
| 176 |
+
Complete SearchState object
|
| 177 |
+
"""
|
| 178 |
+
width, height, stores, destinations, tunnels = parse_initial_state(initial_state)
|
| 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 |
+
|
| 189 |
+
def format_initial_state(
|
| 190 |
+
width: int,
|
| 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.
|
| 198 |
+
|
| 199 |
+
Args:
|
| 200 |
+
width: Grid width
|
| 201 |
+
height: Grid height
|
| 202 |
+
stores: List of stores
|
| 203 |
+
destinations: List of destinations
|
| 204 |
+
tunnels: List of tunnels
|
| 205 |
+
|
| 206 |
+
Returns:
|
| 207 |
+
Formatted initial state string
|
| 208 |
+
"""
|
| 209 |
+
parts = [
|
| 210 |
+
str(width),
|
| 211 |
+
str(height),
|
| 212 |
+
str(len(destinations)),
|
| 213 |
+
str(len(stores)),
|
| 214 |
+
]
|
| 215 |
+
|
| 216 |
+
# Customer coordinates
|
| 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:
|
| 235 |
+
"""
|
| 236 |
+
Format grid traffic into traffic string.
|
| 237 |
+
|
| 238 |
+
Args:
|
| 239 |
+
grid: Grid with traffic information
|
| 240 |
+
|
| 241 |
+
Returns:
|
| 242 |
+
Formatted traffic string
|
| 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)
|
backend/pyproject.toml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "backend"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
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",
|
| 11 |
+
"pydantic>=2.12.5",
|
| 12 |
+
"pydantic-settings>=2.12.0",
|
| 13 |
+
"pytest>=9.0.1",
|
| 14 |
+
"pytest-asyncio>=1.3.0",
|
| 15 |
+
"python-dotenv>=1.2.1",
|
| 16 |
+
"uvicorn>=0.38.0",
|
| 17 |
+
"websockets>=15.0.1",
|
| 18 |
+
]
|
backend/uv.lock
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version = 1
|
| 2 |
+
revision = 2
|
| 3 |
+
requires-python = ">=3.10"
|
| 4 |
+
|
| 5 |
+
[[package]]
|
| 6 |
+
name = "annotated-doc"
|
| 7 |
+
version = "0.0.4"
|
| 8 |
+
source = { registry = "https://pypi.org/simple" }
|
| 9 |
+
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
|
| 10 |
+
wheels = [
|
| 11 |
+
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
|
| 12 |
+
]
|
| 13 |
+
|
| 14 |
+
[[package]]
|
| 15 |
+
name = "annotated-types"
|
| 16 |
+
version = "0.7.0"
|
| 17 |
+
source = { registry = "https://pypi.org/simple" }
|
| 18 |
+
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
| 19 |
+
wheels = [
|
| 20 |
+
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
[[package]]
|
| 24 |
+
name = "anyio"
|
| 25 |
+
version = "4.12.0"
|
| 26 |
+
source = { registry = "https://pypi.org/simple" }
|
| 27 |
+
dependencies = [
|
| 28 |
+
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
| 29 |
+
{ name = "idna" },
|
| 30 |
+
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
| 31 |
+
]
|
| 32 |
+
sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" }
|
| 33 |
+
wheels = [
|
| 34 |
+
{ url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" },
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
[[package]]
|
| 38 |
+
name = "backend"
|
| 39 |
+
version = "0.1.0"
|
| 40 |
+
source = { virtual = "." }
|
| 41 |
+
dependencies = [
|
| 42 |
+
{ name = "fastapi" },
|
| 43 |
+
{ name = "httpx" },
|
| 44 |
+
{ name = "psutil" },
|
| 45 |
+
{ name = "pydantic" },
|
| 46 |
+
{ name = "pydantic-settings" },
|
| 47 |
+
{ name = "pytest" },
|
| 48 |
+
{ name = "pytest-asyncio" },
|
| 49 |
+
{ name = "python-dotenv" },
|
| 50 |
+
{ name = "uvicorn" },
|
| 51 |
+
{ name = "websockets" },
|
| 52 |
+
]
|
| 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" },
|
| 59 |
+
{ name = "pydantic", specifier = ">=2.12.5" },
|
| 60 |
+
{ name = "pydantic-settings", specifier = ">=2.12.0" },
|
| 61 |
+
{ name = "pytest", specifier = ">=9.0.1" },
|
| 62 |
+
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
| 63 |
+
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
| 64 |
+
{ name = "uvicorn", specifier = ">=0.38.0" },
|
| 65 |
+
{ name = "websockets", specifier = ">=15.0.1" },
|
| 66 |
+
]
|
| 67 |
+
|
| 68 |
+
[[package]]
|
| 69 |
+
name = "backports-asyncio-runner"
|
| 70 |
+
version = "1.2.0"
|
| 71 |
+
source = { registry = "https://pypi.org/simple" }
|
| 72 |
+
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
|
| 73 |
+
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"
|
| 80 |
+
source = { registry = "https://pypi.org/simple" }
|
| 81 |
+
sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
|
| 82 |
+
wheels = [
|
| 83 |
+
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
|
| 84 |
+
]
|
| 85 |
+
|
| 86 |
+
[[package]]
|
| 87 |
+
name = "click"
|
| 88 |
+
version = "8.3.1"
|
| 89 |
+
source = { registry = "https://pypi.org/simple" }
|
| 90 |
+
dependencies = [
|
| 91 |
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
| 92 |
+
]
|
| 93 |
+
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
| 94 |
+
wheels = [
|
| 95 |
+
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
| 96 |
+
]
|
| 97 |
+
|
| 98 |
+
[[package]]
|
| 99 |
+
name = "colorama"
|
| 100 |
+
version = "0.4.6"
|
| 101 |
+
source = { registry = "https://pypi.org/simple" }
|
| 102 |
+
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
| 103 |
+
wheels = [
|
| 104 |
+
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
| 105 |
+
]
|
| 106 |
+
|
| 107 |
+
[[package]]
|
| 108 |
+
name = "exceptiongroup"
|
| 109 |
+
version = "1.3.1"
|
| 110 |
+
source = { registry = "https://pypi.org/simple" }
|
| 111 |
+
dependencies = [
|
| 112 |
+
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
| 113 |
+
]
|
| 114 |
+
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
| 115 |
+
wheels = [
|
| 116 |
+
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
| 117 |
+
]
|
| 118 |
+
|
| 119 |
+
[[package]]
|
| 120 |
+
name = "fastapi"
|
| 121 |
+
version = "0.122.0"
|
| 122 |
+
source = { registry = "https://pypi.org/simple" }
|
| 123 |
+
dependencies = [
|
| 124 |
+
{ name = "annotated-doc" },
|
| 125 |
+
{ name = "pydantic" },
|
| 126 |
+
{ name = "starlette" },
|
| 127 |
+
{ name = "typing-extensions" },
|
| 128 |
+
]
|
| 129 |
+
sdist = { url = "https://files.pythonhosted.org/packages/b2/de/3ee97a4f6ffef1fb70bf20561e4f88531633bb5045dc6cebc0f8471f764d/fastapi-0.122.0.tar.gz", hash = "sha256:cd9b5352031f93773228af8b4c443eedc2ac2aa74b27780387b853c3726fb94b", size = 346436, upload-time = "2025-11-24T19:17:47.95Z" }
|
| 130 |
+
wheels = [
|
| 131 |
+
{ url = "https://files.pythonhosted.org/packages/7a/93/aa8072af4ff37b795f6bbf43dcaf61115f40f49935c7dbb180c9afc3f421/fastapi-0.122.0-py3-none-any.whl", hash = "sha256:a456e8915dfc6c8914a50d9651133bd47ec96d331c5b44600baa635538a30d67", size = 110671, upload-time = "2025-11-24T19:17:45.96Z" },
|
| 132 |
+
]
|
| 133 |
+
|
| 134 |
+
[[package]]
|
| 135 |
+
name = "h11"
|
| 136 |
+
version = "0.16.0"
|
| 137 |
+
source = { registry = "https://pypi.org/simple" }
|
| 138 |
+
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
| 139 |
+
wheels = [
|
| 140 |
+
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
| 141 |
+
]
|
| 142 |
+
|
| 143 |
+
[[package]]
|
| 144 |
+
name = "httpcore"
|
| 145 |
+
version = "1.0.9"
|
| 146 |
+
source = { registry = "https://pypi.org/simple" }
|
| 147 |
+
dependencies = [
|
| 148 |
+
{ name = "certifi" },
|
| 149 |
+
{ name = "h11" },
|
| 150 |
+
]
|
| 151 |
+
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
| 152 |
+
wheels = [
|
| 153 |
+
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
| 154 |
+
]
|
| 155 |
+
|
| 156 |
+
[[package]]
|
| 157 |
+
name = "httpx"
|
| 158 |
+
version = "0.28.1"
|
| 159 |
+
source = { registry = "https://pypi.org/simple" }
|
| 160 |
+
dependencies = [
|
| 161 |
+
{ name = "anyio" },
|
| 162 |
+
{ name = "certifi" },
|
| 163 |
+
{ name = "httpcore" },
|
| 164 |
+
{ name = "idna" },
|
| 165 |
+
]
|
| 166 |
+
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
| 167 |
+
wheels = [
|
| 168 |
+
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
| 169 |
+
]
|
| 170 |
+
|
| 171 |
+
[[package]]
|
| 172 |
+
name = "idna"
|
| 173 |
+
version = "3.11"
|
| 174 |
+
source = { registry = "https://pypi.org/simple" }
|
| 175 |
+
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
| 176 |
+
wheels = [
|
| 177 |
+
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
| 178 |
+
]
|
| 179 |
+
|
| 180 |
+
[[package]]
|
| 181 |
+
name = "iniconfig"
|
| 182 |
+
version = "2.3.0"
|
| 183 |
+
source = { registry = "https://pypi.org/simple" }
|
| 184 |
+
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
| 185 |
+
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"
|
| 192 |
+
source = { registry = "https://pypi.org/simple" }
|
| 193 |
+
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
|
| 194 |
+
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"
|
| 201 |
+
source = { registry = "https://pypi.org/simple" }
|
| 202 |
+
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
| 203 |
+
wheels = [
|
| 204 |
+
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
| 205 |
+
]
|
| 206 |
+
|
| 207 |
+
[[package]]
|
| 208 |
+
name = "psutil"
|
| 209 |
+
version = "7.1.3"
|
| 210 |
+
source = { registry = "https://pypi.org/simple" }
|
| 211 |
+
sdist = { url = "https://files.pythonhosted.org/packages/e1/88/bdd0a41e5857d5d703287598cbf08dad90aed56774ea52ae071bae9071b6/psutil-7.1.3.tar.gz", hash = "sha256:6c86281738d77335af7aec228328e944b30930899ea760ecf33a4dba66be5e74", size = 489059, upload-time = "2025-11-02T12:25:54.619Z" }
|
| 212 |
+
wheels = [
|
| 213 |
+
{ url = "https://files.pythonhosted.org/packages/bd/93/0c49e776b8734fef56ec9c5c57f923922f2cf0497d62e0f419465f28f3d0/psutil-7.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0005da714eee687b4b8decd3d6cc7c6db36215c9e74e5ad2264b90c3df7d92dc", size = 239751, upload-time = "2025-11-02T12:25:58.161Z" },
|
| 214 |
+
{ url = "https://files.pythonhosted.org/packages/6f/8d/b31e39c769e70780f007969815195a55c81a63efebdd4dbe9e7a113adb2f/psutil-7.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:19644c85dcb987e35eeeaefdc3915d059dac7bd1167cdcdbf27e0ce2df0c08c0", size = 240368, upload-time = "2025-11-02T12:26:00.491Z" },
|
| 215 |
+
{ url = "https://files.pythonhosted.org/packages/62/61/23fd4acc3c9eebbf6b6c78bcd89e5d020cfde4acf0a9233e9d4e3fa698b4/psutil-7.1.3-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95ef04cf2e5ba0ab9eaafc4a11eaae91b44f4ef5541acd2ee91d9108d00d59a7", size = 287134, upload-time = "2025-11-02T12:26:02.613Z" },
|
| 216 |
+
{ url = "https://files.pythonhosted.org/packages/30/1c/f921a009ea9ceb51aa355cb0cc118f68d354db36eae18174bab63affb3e6/psutil-7.1.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1068c303be3a72f8e18e412c5b2a8f6d31750fb152f9cb106b54090296c9d251", size = 289904, upload-time = "2025-11-02T12:26:05.207Z" },
|
| 217 |
+
{ url = "https://files.pythonhosted.org/packages/a6/82/62d68066e13e46a5116df187d319d1724b3f437ddd0f958756fc052677f4/psutil-7.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:18349c5c24b06ac5612c0428ec2a0331c26443d259e2a0144a9b24b4395b58fa", size = 249642, upload-time = "2025-11-02T12:26:07.447Z" },
|
| 218 |
+
{ url = "https://files.pythonhosted.org/packages/df/ad/c1cd5fe965c14a0392112f68362cfceb5230819dbb5b1888950d18a11d9f/psutil-7.1.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c525ffa774fe4496282fb0b1187725793de3e7c6b29e41562733cae9ada151ee", size = 245518, upload-time = "2025-11-02T12:26:09.719Z" },
|
| 219 |
+
{ url = "https://files.pythonhosted.org/packages/2e/bb/6670bded3e3236eb4287c7bcdc167e9fae6e1e9286e437f7111caed2f909/psutil-7.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b403da1df4d6d43973dc004d19cee3b848e998ae3154cc8097d139b77156c353", size = 239843, upload-time = "2025-11-02T12:26:11.968Z" },
|
| 220 |
+
{ url = "https://files.pythonhosted.org/packages/b8/66/853d50e75a38c9a7370ddbeefabdd3d3116b9c31ef94dc92c6729bc36bec/psutil-7.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad81425efc5e75da3f39b3e636293360ad8d0b49bed7df824c79764fb4ba9b8b", size = 240369, upload-time = "2025-11-02T12:26:14.358Z" },
|
| 221 |
+
{ url = "https://files.pythonhosted.org/packages/41/bd/313aba97cb5bfb26916dc29cf0646cbe4dd6a89ca69e8c6edce654876d39/psutil-7.1.3-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f33a3702e167783a9213db10ad29650ebf383946e91bc77f28a5eb083496bc9", size = 288210, upload-time = "2025-11-02T12:26:16.699Z" },
|
| 222 |
+
{ url = "https://files.pythonhosted.org/packages/c2/fa/76e3c06e760927a0cfb5705eb38164254de34e9bd86db656d4dbaa228b04/psutil-7.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fac9cd332c67f4422504297889da5ab7e05fd11e3c4392140f7370f4208ded1f", size = 291182, upload-time = "2025-11-02T12:26:18.848Z" },
|
| 223 |
+
{ url = "https://files.pythonhosted.org/packages/0f/1d/5774a91607035ee5078b8fd747686ebec28a962f178712de100d00b78a32/psutil-7.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:3792983e23b69843aea49c8f5b8f115572c5ab64c153bada5270086a2123c7e7", size = 250466, upload-time = "2025-11-02T12:26:21.183Z" },
|
| 224 |
+
{ url = "https://files.pythonhosted.org/packages/00/ca/e426584bacb43a5cb1ac91fae1937f478cd8fbe5e4ff96574e698a2c77cd/psutil-7.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:31d77fcedb7529f27bb3a0472bea9334349f9a04160e8e6e5020f22c59893264", size = 245756, upload-time = "2025-11-02T12:26:23.148Z" },
|
| 225 |
+
{ url = "https://files.pythonhosted.org/packages/ef/94/46b9154a800253e7ecff5aaacdf8ebf43db99de4a2dfa18575b02548654e/psutil-7.1.3-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2bdbcd0e58ca14996a42adf3621a6244f1bb2e2e528886959c72cf1e326677ab", size = 238359, upload-time = "2025-11-02T12:26:25.284Z" },
|
| 226 |
+
{ url = "https://files.pythonhosted.org/packages/68/3a/9f93cff5c025029a36d9a92fef47220ab4692ee7f2be0fba9f92813d0cb8/psutil-7.1.3-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc31fa00f1fbc3c3802141eede66f3a2d51d89716a194bf2cd6fc68310a19880", size = 239171, upload-time = "2025-11-02T12:26:27.23Z" },
|
| 227 |
+
{ url = "https://files.pythonhosted.org/packages/ce/b1/5f49af514f76431ba4eea935b8ad3725cdeb397e9245ab919dbc1d1dc20f/psutil-7.1.3-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb428f9f05c1225a558f53e30ccbad9930b11c3fc206836242de1091d3e7dd3", size = 263261, upload-time = "2025-11-02T12:26:29.48Z" },
|
| 228 |
+
{ url = "https://files.pythonhosted.org/packages/e0/95/992c8816a74016eb095e73585d747e0a8ea21a061ed3689474fabb29a395/psutil-7.1.3-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d974e02ca2c8eb4812c3f76c30e28836fffc311d55d979f1465c1feeb2b68b", size = 264635, upload-time = "2025-11-02T12:26:31.74Z" },
|
| 229 |
+
{ url = "https://files.pythonhosted.org/packages/55/4c/c3ed1a622b6ae2fd3c945a366e64eb35247a31e4db16cf5095e269e8eb3c/psutil-7.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:f39c2c19fe824b47484b96f9692932248a54c43799a84282cfe58d05a6449efd", size = 247633, upload-time = "2025-11-02T12:26:33.887Z" },
|
| 230 |
+
{ url = "https://files.pythonhosted.org/packages/c9/ad/33b2ccec09bf96c2b2ef3f9a6f66baac8253d7565d8839e024a6b905d45d/psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1", size = 244608, upload-time = "2025-11-02T12:26:36.136Z" },
|
| 231 |
+
]
|
| 232 |
+
|
| 233 |
+
[[package]]
|
| 234 |
+
name = "pydantic"
|
| 235 |
+
version = "2.12.5"
|
| 236 |
+
source = { registry = "https://pypi.org/simple" }
|
| 237 |
+
dependencies = [
|
| 238 |
+
{ name = "annotated-types" },
|
| 239 |
+
{ name = "pydantic-core" },
|
| 240 |
+
{ name = "typing-extensions" },
|
| 241 |
+
{ name = "typing-inspection" },
|
| 242 |
+
]
|
| 243 |
+
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
| 244 |
+
wheels = [
|
| 245 |
+
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
| 246 |
+
]
|
| 247 |
+
|
| 248 |
+
[[package]]
|
| 249 |
+
name = "pydantic-core"
|
| 250 |
+
version = "2.41.5"
|
| 251 |
+
source = { registry = "https://pypi.org/simple" }
|
| 252 |
+
dependencies = [
|
| 253 |
+
{ name = "typing-extensions" },
|
| 254 |
+
]
|
| 255 |
+
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
|
| 256 |
+
wheels = [
|
| 257 |
+
{ url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" },
|
| 258 |
+
{ url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" },
|
| 259 |
+
{ url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" },
|
| 260 |
+
{ url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" },
|
| 261 |
+
{ url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" },
|
| 262 |
+
{ url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" },
|
| 263 |
+
{ url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" },
|
| 264 |
+
{ url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" },
|
| 265 |
+
{ url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" },
|
| 266 |
+
{ url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" },
|
| 267 |
+
{ url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" },
|
| 268 |
+
{ url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" },
|
| 269 |
+
{ url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" },
|
| 270 |
+
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
|
| 271 |
+
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
|
| 272 |
+
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
|
| 273 |
+
{ url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
|
| 274 |
+
{ url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
|
| 275 |
+
{ url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
|
| 276 |
+
{ url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
|
| 277 |
+
{ url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
|
| 278 |
+
{ url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
|
| 279 |
+
{ url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
|
| 280 |
+
{ url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
|
| 281 |
+
{ url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
|
| 282 |
+
{ url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
|
| 283 |
+
{ url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
|
| 284 |
+
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
|
| 285 |
+
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
|
| 286 |
+
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
|
| 287 |
+
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
|
| 288 |
+
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
|
| 289 |
+
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
|
| 290 |
+
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
|
| 291 |
+
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
|
| 292 |
+
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
|
| 293 |
+
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
|
| 294 |
+
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
|
| 295 |
+
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
|
| 296 |
+
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
|
| 297 |
+
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
|
| 298 |
+
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
|
| 299 |
+
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
|
| 300 |
+
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
|
| 301 |
+
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
|
| 302 |
+
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
|
| 303 |
+
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
|
| 304 |
+
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
|
| 305 |
+
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
|
| 306 |
+
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
|
| 307 |
+
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
|
| 308 |
+
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
|
| 309 |
+
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
|
| 310 |
+
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
|
| 311 |
+
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
|
| 312 |
+
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
|
| 313 |
+
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
|
| 314 |
+
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
|
| 315 |
+
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
|
| 316 |
+
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
|
| 317 |
+
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
|
| 318 |
+
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
|
| 319 |
+
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
|
| 320 |
+
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
|
| 321 |
+
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
|
| 322 |
+
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
|
| 323 |
+
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
|
| 324 |
+
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
|
| 325 |
+
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
|
| 326 |
+
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
|
| 327 |
+
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
|
| 328 |
+
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
|
| 329 |
+
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
|
| 330 |
+
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
|
| 331 |
+
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
|
| 332 |
+
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
|
| 333 |
+
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
|
| 334 |
+
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
|
| 335 |
+
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
|
| 336 |
+
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
|
| 337 |
+
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
|
| 338 |
+
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
|
| 339 |
+
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
| 340 |
+
{ url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
|
| 341 |
+
{ url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
|
| 342 |
+
{ url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
|
| 343 |
+
{ url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
|
| 344 |
+
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
|
| 345 |
+
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
|
| 346 |
+
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
|
| 347 |
+
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
|
| 348 |
+
{ url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" },
|
| 349 |
+
{ url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" },
|
| 350 |
+
{ url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" },
|
| 351 |
+
{ url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" },
|
| 352 |
+
{ url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" },
|
| 353 |
+
{ url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" },
|
| 354 |
+
{ url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" },
|
| 355 |
+
{ url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" },
|
| 356 |
+
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
|
| 357 |
+
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
|
| 358 |
+
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
|
| 359 |
+
{ url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
|
| 360 |
+
{ url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
|
| 361 |
+
{ url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
|
| 362 |
+
{ url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
|
| 363 |
+
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
|
| 364 |
+
]
|
| 365 |
+
|
| 366 |
+
[[package]]
|
| 367 |
+
name = "pydantic-settings"
|
| 368 |
+
version = "2.12.0"
|
| 369 |
+
source = { registry = "https://pypi.org/simple" }
|
| 370 |
+
dependencies = [
|
| 371 |
+
{ name = "pydantic" },
|
| 372 |
+
{ name = "python-dotenv" },
|
| 373 |
+
{ name = "typing-inspection" },
|
| 374 |
+
]
|
| 375 |
+
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" }
|
| 376 |
+
wheels = [
|
| 377 |
+
{ url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
|
| 378 |
+
]
|
| 379 |
+
|
| 380 |
+
[[package]]
|
| 381 |
+
name = "pygments"
|
| 382 |
+
version = "2.19.2"
|
| 383 |
+
source = { registry = "https://pypi.org/simple" }
|
| 384 |
+
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
| 385 |
+
wheels = [
|
| 386 |
+
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
| 387 |
+
]
|
| 388 |
+
|
| 389 |
+
[[package]]
|
| 390 |
+
name = "pytest"
|
| 391 |
+
version = "9.0.1"
|
| 392 |
+
source = { registry = "https://pypi.org/simple" }
|
| 393 |
+
dependencies = [
|
| 394 |
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
| 395 |
+
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
| 396 |
+
{ name = "iniconfig" },
|
| 397 |
+
{ name = "packaging" },
|
| 398 |
+
{ name = "pluggy" },
|
| 399 |
+
{ name = "pygments" },
|
| 400 |
+
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
| 401 |
+
]
|
| 402 |
+
sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" }
|
| 403 |
+
wheels = [
|
| 404 |
+
{ url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" },
|
| 405 |
+
]
|
| 406 |
+
|
| 407 |
+
[[package]]
|
| 408 |
+
name = "pytest-asyncio"
|
| 409 |
+
version = "1.3.0"
|
| 410 |
+
source = { registry = "https://pypi.org/simple" }
|
| 411 |
+
dependencies = [
|
| 412 |
+
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
|
| 413 |
+
{ name = "pytest" },
|
| 414 |
+
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
| 415 |
+
]
|
| 416 |
+
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
|
| 417 |
+
wheels = [
|
| 418 |
+
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
| 419 |
+
]
|
| 420 |
+
|
| 421 |
+
[[package]]
|
| 422 |
+
name = "python-dotenv"
|
| 423 |
+
version = "1.2.1"
|
| 424 |
+
source = { registry = "https://pypi.org/simple" }
|
| 425 |
+
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
|
| 426 |
+
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"
|
| 433 |
+
source = { registry = "https://pypi.org/simple" }
|
| 434 |
+
dependencies = [
|
| 435 |
+
{ name = "anyio" },
|
| 436 |
+
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
| 437 |
+
]
|
| 438 |
+
sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" }
|
| 439 |
+
wheels = [
|
| 440 |
+
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
|
| 441 |
+
]
|
| 442 |
+
|
| 443 |
+
[[package]]
|
| 444 |
+
name = "tomli"
|
| 445 |
+
version = "2.3.0"
|
| 446 |
+
source = { registry = "https://pypi.org/simple" }
|
| 447 |
+
sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" }
|
| 448 |
+
wheels = [
|
| 449 |
+
{ url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" },
|
| 450 |
+
{ url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" },
|
| 451 |
+
{ url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" },
|
| 452 |
+
{ url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" },
|
| 453 |
+
{ url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" },
|
| 454 |
+
{ url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" },
|
| 455 |
+
{ url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" },
|
| 456 |
+
{ url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" },
|
| 457 |
+
{ url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" },
|
| 458 |
+
{ url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" },
|
| 459 |
+
{ url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" },
|
| 460 |
+
{ url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" },
|
| 461 |
+
{ url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" },
|
| 462 |
+
{ url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" },
|
| 463 |
+
{ url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" },
|
| 464 |
+
{ url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" },
|
| 465 |
+
{ url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" },
|
| 466 |
+
{ url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" },
|
| 467 |
+
{ url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" },
|
| 468 |
+
{ url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" },
|
| 469 |
+
{ url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" },
|
| 470 |
+
{ url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" },
|
| 471 |
+
{ url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" },
|
| 472 |
+
{ url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" },
|
| 473 |
+
{ url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" },
|
| 474 |
+
{ url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" },
|
| 475 |
+
{ url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" },
|
| 476 |
+
{ url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" },
|
| 477 |
+
{ url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" },
|
| 478 |
+
{ url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" },
|
| 479 |
+
{ url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" },
|
| 480 |
+
{ url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" },
|
| 481 |
+
{ url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" },
|
| 482 |
+
{ url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" },
|
| 483 |
+
{ url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" },
|
| 484 |
+
{ url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" },
|
| 485 |
+
{ url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" },
|
| 486 |
+
{ url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" },
|
| 487 |
+
{ url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" },
|
| 488 |
+
{ url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" },
|
| 489 |
+
{ url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" },
|
| 490 |
+
]
|
| 491 |
+
|
| 492 |
+
[[package]]
|
| 493 |
+
name = "typing-extensions"
|
| 494 |
+
version = "4.15.0"
|
| 495 |
+
source = { registry = "https://pypi.org/simple" }
|
| 496 |
+
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
| 497 |
+
wheels = [
|
| 498 |
+
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
| 499 |
+
]
|
| 500 |
+
|
| 501 |
+
[[package]]
|
| 502 |
+
name = "typing-inspection"
|
| 503 |
+
version = "0.4.2"
|
| 504 |
+
source = { registry = "https://pypi.org/simple" }
|
| 505 |
+
dependencies = [
|
| 506 |
+
{ name = "typing-extensions" },
|
| 507 |
+
]
|
| 508 |
+
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
| 509 |
+
wheels = [
|
| 510 |
+
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
| 511 |
+
]
|
| 512 |
+
|
| 513 |
+
[[package]]
|
| 514 |
+
name = "uvicorn"
|
| 515 |
+
version = "0.38.0"
|
| 516 |
+
source = { registry = "https://pypi.org/simple" }
|
| 517 |
+
dependencies = [
|
| 518 |
+
{ name = "click" },
|
| 519 |
+
{ name = "h11" },
|
| 520 |
+
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
| 521 |
+
]
|
| 522 |
+
sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" }
|
| 523 |
+
wheels = [
|
| 524 |
+
{ url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
|
| 525 |
+
]
|
| 526 |
+
|
| 527 |
+
[[package]]
|
| 528 |
+
name = "websockets"
|
| 529 |
+
version = "15.0.1"
|
| 530 |
+
source = { registry = "https://pypi.org/simple" }
|
| 531 |
+
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
|
| 532 |
+
wheels = [
|
| 533 |
+
{ url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" },
|
| 534 |
+
{ url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" },
|
| 535 |
+
{ url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" },
|
| 536 |
+
{ url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" },
|
| 537 |
+
{ url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" },
|
| 538 |
+
{ url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" },
|
| 539 |
+
{ url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" },
|
| 540 |
+
{ url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" },
|
| 541 |
+
{ url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" },
|
| 542 |
+
{ url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" },
|
| 543 |
+
{ url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" },
|
| 544 |
+
{ url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" },
|
| 545 |
+
{ url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" },
|
| 546 |
+
{ url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" },
|
| 547 |
+
{ url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" },
|
| 548 |
+
{ url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" },
|
| 549 |
+
{ url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" },
|
| 550 |
+
{ url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" },
|
| 551 |
+
{ url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" },
|
| 552 |
+
{ url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" },
|
| 553 |
+
{ url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" },
|
| 554 |
+
{ url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" },
|
| 555 |
+
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
|
| 556 |
+
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
|
| 557 |
+
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
|
| 558 |
+
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
|
| 559 |
+
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
|
| 560 |
+
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
|
| 561 |
+
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
|
| 562 |
+
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
|
| 563 |
+
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
|
| 564 |
+
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
|
| 565 |
+
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
|
| 566 |
+
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
|
| 567 |
+
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
|
| 568 |
+
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
|
| 569 |
+
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
|
| 570 |
+
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
|
| 571 |
+
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
|
| 572 |
+
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
|
| 573 |
+
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
|
| 574 |
+
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
|
| 575 |
+
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
|
| 576 |
+
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
|
| 577 |
+
{ url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" },
|
| 578 |
+
{ url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" },
|
| 579 |
+
{ url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" },
|
| 580 |
+
{ url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" },
|
| 581 |
+
{ url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" },
|
| 582 |
+
{ url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" },
|
| 583 |
+
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
|
| 584 |
+
]
|
frontend/.dockerignore
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dependencies
|
| 2 |
+
node_modules/
|
| 3 |
+
|
| 4 |
+
# Build output
|
| 5 |
+
dist/
|
| 6 |
+
build/
|
| 7 |
+
|
| 8 |
+
# IDE
|
| 9 |
+
.vscode/
|
| 10 |
+
.idea/
|
| 11 |
+
*.swp
|
| 12 |
+
*.swo
|
| 13 |
+
|
| 14 |
+
# Testing
|
| 15 |
+
coverage/
|
| 16 |
+
.nyc_output/
|
| 17 |
+
|
| 18 |
+
# OS
|
| 19 |
+
.DS_Store
|
| 20 |
+
Thumbs.db
|
| 21 |
+
|
| 22 |
+
# Logs
|
| 23 |
+
*.log
|
| 24 |
+
npm-debug.log*
|
| 25 |
+
|
| 26 |
+
# Environment
|
| 27 |
+
.env
|
| 28 |
+
.env.*
|
| 29 |
+
!.env.example
|
| 30 |
+
|
| 31 |
+
# Cache
|
| 32 |
+
.cache/
|
| 33 |
+
.parcel-cache/
|
| 34 |
+
.turbo/
|
frontend/.env.example
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API URL for development
|
| 2 |
+
# Leave empty for production (uses relative URLs)
|
| 3 |
+
VITE_API_URL=http://localhost:8000
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
frontend/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# React + TypeScript + Vite
|
| 2 |
+
|
| 3 |
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
| 4 |
+
|
| 5 |
+
Currently, two official plugins are available:
|
| 6 |
+
|
| 7 |
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
| 8 |
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
| 9 |
+
|
| 10 |
+
## React Compiler
|
| 11 |
+
|
| 12 |
+
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
| 13 |
+
|
| 14 |
+
## Expanding the ESLint configuration
|
| 15 |
+
|
| 16 |
+
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
| 17 |
+
|
| 18 |
+
```js
|
| 19 |
+
export default defineConfig([
|
| 20 |
+
globalIgnores(['dist']),
|
| 21 |
+
{
|
| 22 |
+
files: ['**/*.{ts,tsx}'],
|
| 23 |
+
extends: [
|
| 24 |
+
// Other configs...
|
| 25 |
+
|
| 26 |
+
// Remove tseslint.configs.recommended and replace with this
|
| 27 |
+
tseslint.configs.recommendedTypeChecked,
|
| 28 |
+
// Alternatively, use this for stricter rules
|
| 29 |
+
tseslint.configs.strictTypeChecked,
|
| 30 |
+
// Optionally, add this for stylistic rules
|
| 31 |
+
tseslint.configs.stylisticTypeChecked,
|
| 32 |
+
|
| 33 |
+
// Other configs...
|
| 34 |
+
],
|
| 35 |
+
languageOptions: {
|
| 36 |
+
parserOptions: {
|
| 37 |
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 38 |
+
tsconfigRootDir: import.meta.dirname,
|
| 39 |
+
},
|
| 40 |
+
// other options...
|
| 41 |
+
},
|
| 42 |
+
},
|
| 43 |
+
])
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
| 47 |
+
|
| 48 |
+
```js
|
| 49 |
+
// eslint.config.js
|
| 50 |
+
import reactX from 'eslint-plugin-react-x'
|
| 51 |
+
import reactDom from 'eslint-plugin-react-dom'
|
| 52 |
+
|
| 53 |
+
export default defineConfig([
|
| 54 |
+
globalIgnores(['dist']),
|
| 55 |
+
{
|
| 56 |
+
files: ['**/*.{ts,tsx}'],
|
| 57 |
+
extends: [
|
| 58 |
+
// Other configs...
|
| 59 |
+
// Enable lint rules for React
|
| 60 |
+
reactX.configs['recommended-typescript'],
|
| 61 |
+
// Enable lint rules for React DOM
|
| 62 |
+
reactDom.configs.recommended,
|
| 63 |
+
],
|
| 64 |
+
languageOptions: {
|
| 65 |
+
parserOptions: {
|
| 66 |
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 67 |
+
tsconfigRootDir: import.meta.dirname,
|
| 68 |
+
},
|
| 69 |
+
// other options...
|
| 70 |
+
},
|
| 71 |
+
},
|
| 72 |
+
])
|
| 73 |
+
```
|
frontend/components.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://ui.shadcn.com/schema.json",
|
| 3 |
+
"style": "new-york",
|
| 4 |
+
"rsc": false,
|
| 5 |
+
"tsx": true,
|
| 6 |
+
"tailwind": {
|
| 7 |
+
"config": "",
|
| 8 |
+
"css": "src/index.css",
|
| 9 |
+
"baseColor": "neutral",
|
| 10 |
+
"cssVariables": true,
|
| 11 |
+
"prefix": ""
|
| 12 |
+
},
|
| 13 |
+
"iconLibrary": "lucide",
|
| 14 |
+
"aliases": {
|
| 15 |
+
"components": "@/components",
|
| 16 |
+
"utils": "@/lib/utils",
|
| 17 |
+
"ui": "@/components/ui",
|
| 18 |
+
"lib": "@/lib",
|
| 19 |
+
"hooks": "@/hooks"
|
| 20 |
+
},
|
| 21 |
+
"registries": {}
|
| 22 |
+
}
|
frontend/eslint.config.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
import tseslint from 'typescript-eslint'
|
| 6 |
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
| 7 |
+
|
| 8 |
+
export default defineConfig([
|
| 9 |
+
globalIgnores(['dist']),
|
| 10 |
+
{
|
| 11 |
+
files: ['**/*.{ts,tsx}'],
|
| 12 |
+
extends: [
|
| 13 |
+
js.configs.recommended,
|
| 14 |
+
tseslint.configs.recommended,
|
| 15 |
+
reactHooks.configs.flat.recommended,
|
| 16 |
+
reactRefresh.configs.vite,
|
| 17 |
+
],
|
| 18 |
+
languageOptions: {
|
| 19 |
+
ecmaVersion: 2020,
|
| 20 |
+
globals: globals.browser,
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
])
|
frontend/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 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>
|
| 11 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc -b && vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"@tailwindcss/vite": "^4.1.17",
|
| 14 |
+
"@tanstack/react-query": "^5.90.11",
|
| 15 |
+
"axios": "^1.13.2",
|
| 16 |
+
"class-variance-authority": "^0.7.1",
|
| 17 |
+
"clsx": "^2.1.1",
|
| 18 |
+
"lucide-react": "^0.555.0",
|
| 19 |
+
"react": "^19.2.0",
|
| 20 |
+
"react-dom": "^19.2.0",
|
| 21 |
+
"recharts": "^3.5.1",
|
| 22 |
+
"tailwind-merge": "^3.4.0",
|
| 23 |
+
"tailwindcss": "^4.1.17",
|
| 24 |
+
"zustand": "^5.0.8"
|
| 25 |
+
},
|
| 26 |
+
"devDependencies": {
|
| 27 |
+
"@eslint/js": "^9.39.1",
|
| 28 |
+
"@types/node": "^24.10.1",
|
| 29 |
+
"@types/react": "^19.2.5",
|
| 30 |
+
"@types/react-dom": "^19.2.3",
|
| 31 |
+
"@vitejs/plugin-react": "^5.1.1",
|
| 32 |
+
"eslint": "^9.39.1",
|
| 33 |
+
"eslint-plugin-react-hooks": "^7.0.1",
|
| 34 |
+
"eslint-plugin-react-refresh": "^0.4.24",
|
| 35 |
+
"globals": "^16.5.0",
|
| 36 |
+
"tw-animate-css": "^1.4.0",
|
| 37 |
+
"typescript": "~5.9.3",
|
| 38 |
+
"typescript-eslint": "^8.46.4",
|
| 39 |
+
"vite": "^7.2.4"
|
| 40 |
+
}
|
| 41 |
+
}
|
frontend/public/vite.svg
ADDED
|
|
frontend/src/App.css
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#root {
|
| 2 |
+
max-width: 1280px;
|
| 3 |
+
margin: 0 auto;
|
| 4 |
+
padding: 2rem;
|
| 5 |
+
text-align: center;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
.logo {
|
| 9 |
+
height: 6em;
|
| 10 |
+
padding: 1.5em;
|
| 11 |
+
will-change: filter;
|
| 12 |
+
transition: filter 300ms;
|
| 13 |
+
}
|
| 14 |
+
.logo:hover {
|
| 15 |
+
filter: drop-shadow(0 0 2em #646cffaa);
|
| 16 |
+
}
|
| 17 |
+
.logo.react:hover {
|
| 18 |
+
filter: drop-shadow(0 0 2em #61dafbaa);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
@keyframes logo-spin {
|
| 22 |
+
from {
|
| 23 |
+
transform: rotate(0deg);
|
| 24 |
+
}
|
| 25 |
+
to {
|
| 26 |
+
transform: rotate(360deg);
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
@media (prefers-reduced-motion: no-preference) {
|
| 31 |
+
a:nth-of-type(2) .logo {
|
| 32 |
+
animation: logo-spin infinite 20s linear;
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.card {
|
| 37 |
+
padding: 2em;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.read-the-docs {
|
| 41 |
+
color: #888;
|
| 42 |
+
}
|
frontend/src/App.tsx
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react';
|
| 2 |
+
import { useGridStore } from './store/gridStore';
|
| 3 |
+
import { Grid } from './components/Grid/Grid';
|
| 4 |
+
import { GridGenerator } from './components/Controls/GridGenerator';
|
| 5 |
+
import { AlgorithmSelector } from './components/Controls/AlgorithmSelector';
|
| 6 |
+
import { PlaybackControls } from './components/Controls/PlaybackControls';
|
| 7 |
+
import { MetricsPanel } from './components/Stats/MetricsPanel';
|
| 8 |
+
import { ComparisonDashboard } from './components/Stats/ComparisonDashboard';
|
| 9 |
+
import { GroupInfo } from './components/Info/GroupInfo';
|
| 10 |
+
import { PlanResultsModal } from './components/Stats/PlanResultsModal';
|
| 11 |
+
import { Button } from './components/ui/button';
|
| 12 |
+
import {
|
| 13 |
+
ChevronLeft,
|
| 14 |
+
ChevronRight,
|
| 15 |
+
Play,
|
| 16 |
+
FileText,
|
| 17 |
+
Eye,
|
| 18 |
+
BarChart3,
|
| 19 |
+
Truck,
|
| 20 |
+
AlertCircle,
|
| 21 |
+
Users,
|
| 22 |
+
Circle,
|
| 23 |
+
Square,
|
| 24 |
+
X,
|
| 25 |
+
} from 'lucide-react';
|
| 26 |
+
|
| 27 |
+
type Tab = 'visualize' | 'compare' | 'info';
|
| 28 |
+
|
| 29 |
+
function App() {
|
| 30 |
+
const [activeTab, setActiveTab] = useState<Tab>('visualize');
|
| 31 |
+
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
| 32 |
+
const [showPlanModal, setShowPlanModal] = useState(false);
|
| 33 |
+
const {
|
| 34 |
+
grid,
|
| 35 |
+
planResult,
|
| 36 |
+
runPlan,
|
| 37 |
+
runSearch,
|
| 38 |
+
isLoading,
|
| 39 |
+
error,
|
| 40 |
+
} = useGridStore();
|
| 41 |
+
|
| 42 |
+
// Open modal when planResult is updated
|
| 43 |
+
useEffect(() => {
|
| 44 |
+
if (planResult) {
|
| 45 |
+
setShowPlanModal(true);
|
| 46 |
+
}
|
| 47 |
+
}, [planResult]);
|
| 48 |
+
|
| 49 |
+
const handleRunSearch = () => {
|
| 50 |
+
if (!grid || grid.stores.length === 0 || grid.destinations.length === 0) return;
|
| 51 |
+
const start = grid.stores[0].position;
|
| 52 |
+
const goal = grid.destinations[0].position;
|
| 53 |
+
runSearch(start, goal);
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
const handleRunPlan = async () => {
|
| 57 |
+
await runPlan();
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
return (
|
| 61 |
+
<div className="h-screen w-screen bg-zinc-950 text-zinc-100 flex flex-col overflow-hidden">
|
| 62 |
+
{/* Header */}
|
| 63 |
+
<header className="bg-zinc-900/80 backdrop-blur-sm border-b border-zinc-800 px-6 py-3 flex items-center justify-between shrink-0">
|
| 64 |
+
<div className="flex items-center gap-4">
|
| 65 |
+
<div className="flex items-center gap-3">
|
| 66 |
+
<div className="p-2 bg-zinc-800 rounded-lg">
|
| 67 |
+
<Truck className="w-5 h-5 text-zinc-300" />
|
| 68 |
+
</div>
|
| 69 |
+
<div>
|
| 70 |
+
<h1 className="text-lg font-semibold text-zinc-100">Pathfinding Visualizer</h1>
|
| 71 |
+
<p className="text-xs text-zinc-500">Search Algorithm Analysis</p>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
{/* Tab navigation */}
|
| 77 |
+
<div className="flex gap-1 p-1 bg-zinc-800/50 rounded-lg">
|
| 78 |
+
<Button
|
| 79 |
+
onClick={() => setActiveTab('visualize')}
|
| 80 |
+
variant={activeTab === 'visualize' ? 'secondary' : 'ghost'}
|
| 81 |
+
size="sm"
|
| 82 |
+
className="gap-2"
|
| 83 |
+
>
|
| 84 |
+
<Eye className="w-4 h-4" />
|
| 85 |
+
Visualize
|
| 86 |
+
</Button>
|
| 87 |
+
<Button
|
| 88 |
+
onClick={() => setActiveTab('compare')}
|
| 89 |
+
variant={activeTab === 'compare' ? 'secondary' : 'ghost'}
|
| 90 |
+
size="sm"
|
| 91 |
+
className="gap-2"
|
| 92 |
+
>
|
| 93 |
+
<BarChart3 className="w-4 h-4" />
|
| 94 |
+
Compare
|
| 95 |
+
</Button>
|
| 96 |
+
<Button
|
| 97 |
+
onClick={() => setActiveTab('info')}
|
| 98 |
+
variant={activeTab === 'info' ? 'secondary' : 'ghost'}
|
| 99 |
+
size="sm"
|
| 100 |
+
className="gap-2"
|
| 101 |
+
>
|
| 102 |
+
<Users className="w-4 h-4" />
|
| 103 |
+
Group Info
|
| 104 |
+
</Button>
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
<div className="flex items-center gap-2">
|
| 108 |
+
{grid && (
|
| 109 |
+
<>
|
| 110 |
+
<Button
|
| 111 |
+
onClick={handleRunSearch}
|
| 112 |
+
disabled={isLoading}
|
| 113 |
+
variant="primary"
|
| 114 |
+
size="sm"
|
| 115 |
+
className="gap-2"
|
| 116 |
+
>
|
| 117 |
+
<Play className="w-4 h-4" />
|
| 118 |
+
{isLoading ? 'Running...' : 'Run Search'}
|
| 119 |
+
</Button>
|
| 120 |
+
<Button
|
| 121 |
+
onClick={handleRunPlan}
|
| 122 |
+
disabled={isLoading}
|
| 123 |
+
variant="outline"
|
| 124 |
+
size="sm"
|
| 125 |
+
className="gap-2"
|
| 126 |
+
>
|
| 127 |
+
<FileText className="w-4 h-4" />
|
| 128 |
+
Full Plan
|
| 129 |
+
</Button>
|
| 130 |
+
</>
|
| 131 |
+
)}
|
| 132 |
+
</div>
|
| 133 |
+
</header>
|
| 134 |
+
|
| 135 |
+
{/* Main content */}
|
| 136 |
+
<div className="flex-1 flex overflow-hidden">
|
| 137 |
+
{/* Sidebar */}
|
| 138 |
+
<aside className={`bg-zinc-900/50 border-r border-zinc-800 flex flex-col transition-all duration-300 ${sidebarCollapsed ? 'w-12' : 'w-80'}`}>
|
| 139 |
+
<button
|
| 140 |
+
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
| 141 |
+
className="p-3 text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/50 border-b border-zinc-800 transition-colors"
|
| 142 |
+
>
|
| 143 |
+
{sidebarCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
|
| 144 |
+
</button>
|
| 145 |
+
|
| 146 |
+
{!sidebarCollapsed && (
|
| 147 |
+
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
| 148 |
+
<GridGenerator />
|
| 149 |
+
<AlgorithmSelector />
|
| 150 |
+
<PlaybackControls />
|
| 151 |
+
<MetricsPanel />
|
| 152 |
+
</div>
|
| 153 |
+
)}
|
| 154 |
+
</aside>
|
| 155 |
+
|
| 156 |
+
{/* Main area */}
|
| 157 |
+
<main className="flex-1 flex flex-col overflow-hidden" style={{ backgroundColor: '#0a0a0b' }}>
|
| 158 |
+
{/* Error display */}
|
| 159 |
+
{error && (
|
| 160 |
+
<div className="m-4 mb-0 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 flex items-center gap-3 text-sm">
|
| 161 |
+
<AlertCircle className="w-4 h-4 shrink-0" />
|
| 162 |
+
<span>{error}</span>
|
| 163 |
+
</div>
|
| 164 |
+
)}
|
| 165 |
+
|
| 166 |
+
{/* Content */}
|
| 167 |
+
<div className="flex-1 overflow-hidden relative">
|
| 168 |
+
{activeTab === 'visualize' ? (
|
| 169 |
+
<>
|
| 170 |
+
<Grid />
|
| 171 |
+
{/* Legend overlay */}
|
| 172 |
+
<div className="absolute bottom-4 left-4 bg-zinc-900/95 backdrop-blur-sm border border-zinc-800 rounded-lg p-3 shadow-xl">
|
| 173 |
+
<p className="text-[10px] uppercase tracking-wider text-zinc-500 font-medium mb-2">Legend</p>
|
| 174 |
+
<div className="flex gap-4 text-xs">
|
| 175 |
+
<div className="flex flex-col gap-1.5">
|
| 176 |
+
<div className="flex items-center gap-2">
|
| 177 |
+
<Circle className="w-2.5 h-2.5 fill-amber-500 text-amber-500" />
|
| 178 |
+
<span className="text-zinc-400">Current</span>
|
| 179 |
+
</div>
|
| 180 |
+
<div className="flex items-center gap-2">
|
| 181 |
+
<Circle className="w-2.5 h-2.5 fill-emerald-500 text-emerald-500" />
|
| 182 |
+
<span className="text-zinc-400">Path</span>
|
| 183 |
+
</div>
|
| 184 |
+
<div className="flex items-center gap-2">
|
| 185 |
+
<Circle className="w-2.5 h-2.5 fill-sky-500 text-sky-500" />
|
| 186 |
+
<span className="text-zinc-400">Frontier</span>
|
| 187 |
+
</div>
|
| 188 |
+
<div className="flex items-center gap-2">
|
| 189 |
+
<Circle className="w-2.5 h-2.5 fill-zinc-500 text-zinc-500" />
|
| 190 |
+
<span className="text-zinc-400">Explored</span>
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
<div className="border-l border-zinc-800 pl-4 flex flex-col gap-1.5">
|
| 194 |
+
<div className="flex items-center gap-2">
|
| 195 |
+
<Square className="w-2.5 h-2.5 fill-blue-500 text-blue-500 rounded-sm" />
|
| 196 |
+
<span className="text-zinc-400">Store</span>
|
| 197 |
+
</div>
|
| 198 |
+
<div className="flex items-center gap-2">
|
| 199 |
+
<Circle className="w-2.5 h-2.5 fill-emerald-500 text-emerald-500" />
|
| 200 |
+
<span className="text-zinc-400">Destination</span>
|
| 201 |
+
</div>
|
| 202 |
+
<div className="flex items-center gap-2">
|
| 203 |
+
<Circle className="w-2.5 h-2.5 fill-purple-500 text-purple-500" />
|
| 204 |
+
<span className="text-zinc-400">Tunnel</span>
|
| 205 |
+
</div>
|
| 206 |
+
<div className="flex items-center gap-2">
|
| 207 |
+
<X className="w-2.5 h-2.5 text-red-500" />
|
| 208 |
+
<span className="text-zinc-400">Blocked</span>
|
| 209 |
+
</div>
|
| 210 |
+
</div>
|
| 211 |
+
<div className="border-l border-zinc-800 pl-4">
|
| 212 |
+
<p className="text-zinc-600 text-[10px] mb-1">Edge Costs</p>
|
| 213 |
+
<div className="flex gap-2 text-xs font-mono">
|
| 214 |
+
<span className="text-zinc-300">1</span>
|
| 215 |
+
<span className="text-zinc-400">2</span>
|
| 216 |
+
<span className="text-zinc-500">3</span>
|
| 217 |
+
<span className="text-zinc-600">4</span>
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
</>
|
| 223 |
+
) : activeTab === 'compare' ? (
|
| 224 |
+
<div className="h-full p-4">
|
| 225 |
+
<ComparisonDashboard />
|
| 226 |
+
</div>
|
| 227 |
+
) : (
|
| 228 |
+
<GroupInfo />
|
| 229 |
+
)}
|
| 230 |
+
</div>
|
| 231 |
+
</main>
|
| 232 |
+
</div>
|
| 233 |
+
|
| 234 |
+
{/* Status bar */}
|
| 235 |
+
<footer className="bg-zinc-900/50 border-t border-zinc-800 px-6 py-2 flex items-center justify-between text-xs text-zinc-600 shrink-0">
|
| 236 |
+
<div className="flex items-center gap-4 font-mono">
|
| 237 |
+
{grid && (
|
| 238 |
+
<>
|
| 239 |
+
<span>Grid {grid.width}x{grid.height}</span>
|
| 240 |
+
<span className="text-zinc-700">|</span>
|
| 241 |
+
<span>S:{grid.stores.length}</span>
|
| 242 |
+
<span>D:{grid.destinations.length}</span>
|
| 243 |
+
<span>T:{grid.tunnels.length}</span>
|
| 244 |
+
</>
|
| 245 |
+
)}
|
| 246 |
+
</div>
|
| 247 |
+
<div className="flex items-center gap-2">
|
| 248 |
+
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
|
| 249 |
+
<span>Connected</span>
|
| 250 |
+
</div>
|
| 251 |
+
</footer>
|
| 252 |
+
|
| 253 |
+
{/* Plan Results Modal */}
|
| 254 |
+
<PlanResultsModal
|
| 255 |
+
isOpen={showPlanModal}
|
| 256 |
+
onClose={() => setShowPlanModal(false)}
|
| 257 |
+
/>
|
| 258 |
+
</div>
|
| 259 |
+
);
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
export default App;
|