import pyomo.environ as pyo from quantum.utils.logger import get_logger def bfs_reachable_sets(reachable, start, max_steps): """Plain (non-aggressive) BFS from start, allowed to stay in place or revisit cells each step — reachable[v] must already include v itself. Returns a list of sets indexed by step count 0..max_steps: sets[k] is every vertex the robot could occupy after exactly k moves. Unlike QUBO's non-backtracking reachable_positions_aggressive(), this is exact (a true over-approximation is impossible here, and staying/backtracking are both legal ILP moves), so it never excludes a feasible cell — it only shrinks the ILP's search space, it can't change the optimal solution. The set is monotonically non-decreasing and saturates once it covers the robot's whole connected component, so this stops growing it early rather than recomputing an unchanged set out to max_steps.""" sets = [{start}] for step in range(1, max_steps + 1): prev = sets[-1] nxt = prev | {n for v in prev for n in reachable[v]} sets.append(nxt) if len(nxt) == len(prev): sets.extend([nxt] * (max_steps - step)) break return sets def reverse_adjacency(reachable): """Build the reverse of a forward adjacency map: reverse[v] lists every u with v in reachable[u]. Needed for a goal-anchored backward BFS — reusing `reachable` directly for that would silently assume the graph is undirected, which grid movement happens to satisfy but isn't guaranteed for an arbitrary loaded graph. bfs_reachable_sets(reverse_adjacency(adj), goal, k) then gives exactly the set of vertices that can reach `goal` within k moves in the original (forward) graph.""" reverse = {v: [] for v in reachable} for u, neighbors in reachable.items(): for v in neighbors: reverse[v].append(u) return reverse class BaseILPBuilder: """ Shared scaffolding for ILP builders: robot-state reset, penalty-set stub metadata (ILP has no penalty weights, only hard constraints), and the common constructor shape. Concrete grid/graph subclasses implement build(), vars_per_time, and local_index(v) — the vertex-representation- specific pieces (grid vertices are (i, j) tuples, graph vertices are plain node ints). Solves the whole time horizon in one shot — no windowing, no Q dict — so this does not inherit from BaseQUBO. """ def __init__(self, problem, name="ilp", verbose_level=2): self.problem = problem self.name = name self.model = None self.verbose_level = verbose_level self.logger = get_logger() # No penalty weights — ILP uses hard constraints instead. Named here # (fixed by build()'s structure, not config-driven like QUBO's K_* # weights) so BenchmarkRunner's `qubobuilder.penalties.get("name", ...)` # duck-typing works unchanged and the benchmark JSON still records # what's actually enforcing correctness. self.penalties = { "name": "ilp_hard_constraints", "constraints": [ "one_hot", "adjacency", "start", "goal", "goal_lock", "crash", "swap", ], } def build(self, preprocess=True): """Build the Pyomo ILP model for the current problem state and return it. Args: preprocess: When True (default), fixes x[a, v, t] to 0 for every (v, t) that fails a forward-from-start BFS reachability check *or* a backward-from-goal one (see bfs_reachable_sets() / reverse_adjacency()) — cells robot a couldn't possibly have reached by t, or couldn't possibly still reach its goal from by the deadline. Both are exact: any feasible path's own prefix/suffix proves its cells pass both checks, so this can only shrink the search space HiGHS branches over, never exclude a feasible (or optimal) solution. When False, only the per-robot active time window is fixed (the minimum needed for correctness); the full free-cell set is left open at every in-window t. """ raise NotImplementedError def local_index(self, v): """Map a builder-native vertex (grid tuple or graph node id) to the position component of the flat (robot, time, position) variable index — the inverse of decode_position()'s per-format unpacking.""" raise NotImplementedError def reset_problem(self): """Restore every robot to its initial start state (mirrors BaseQUBO.reset_problem).""" for robot in self.problem.robots.values(): robot.reset() class GridILPBuilder(BaseILPBuilder): """ILP builder for grid pathfinding problems.""" def __init__(self, problem, name="ilp_grid", verbose_level=2): if problem.grid is None: raise ValueError("Grid representation not available in this problem") super().__init__(problem, name=name, verbose_level=verbose_level) self.vars_per_time = problem.grid.M * problem.grid.N def local_index(self, v): i, j = v return i * self.problem.grid.N + j def build(self, preprocess=True): problem = self.problem grid = problem.grid robots = problem.robots model = pyo.ConcreteModel() model.A = pyo.Set(initialize=list(robots.keys())) model.V = pyo.Set( initialize=[ (i, j) for i in range(grid.M) for j in range(grid.N) if (i, j) not in grid.obstacles ] ) model.T = pyo.RangeSet(0, problem.T - 1) model.T_minus = pyo.RangeSet(0, problem.T - 2) # ILP allows staying in place; QUBO's grid.adjacency is kept strict-neighbor-only # since it relies on that for its own penalty terms, so extend it locally here. reachable = {v: grid.adjacency[v] + [v] for v in model.V} # Each robot only exists for its own [start_time, start_time + T - 1] window, # matching the QUBO: no variables before start_time, none after the robot's # own goal is reached (regardless of how long the shared horizon runs). active_range = { a: range(robots[a].start_time, robots[a].start_time + robots[a].T) for a in robots } # Forward-from-start and backward-from-goal BFS reachability per robot: # cells it cannot possibly have reached by t, or cannot possibly still # reach goal from by the deadline, are fixed to 0. See # bfs_reachable_sets() / reverse_adjacency() docstrings for why the # intersection of both is exact. goal_deadline = {a: robots[a].start_time + robots[a].T - 1 for a in robots} if preprocess: reverse_reachable = reverse_adjacency(reachable) forward_sets = { a: bfs_reachable_sets( reachable, robots[a].current_position, robots[a].T - 1 ) for a in robots } backward_sets = { a: bfs_reachable_sets(reverse_reachable, robots[a].goal, robots[a].T - 1) for a in robots } else: forward_sets = backward_sets = None self.logger.debug(f"Forward sets: {forward_sets}\nBackward sets: {backward_sets}") # Decision variables x[robot, position, time] model.x = pyo.Var(model.A, model.V, model.T, within=pyo.Binary) in_window_vars = 0 bfs_fixed = 0 for a in model.A: for v in model.V: for t in model.T: if t not in active_range[a]: model.x[a, v, t].fix(0) continue in_window_vars += 1 if preprocess: forward_ok = v in forward_sets[a][t - robots[a].start_time] backward_ok = v in backward_sets[a][goal_deadline[a] - t] if not (forward_ok and backward_ok): model.x[a, v, t].fix(0) bfs_fixed += 1 self.bfs_stats = { "window": 0, "preprocess": preprocess, "initial_variables": in_window_vars, "variables_reduced": bfs_fixed, "final_variables": in_window_vars - bfs_fixed, "reduction_ratio": round(bfs_fixed / in_window_vars, 4) if in_window_vars else 0, } # exactly one cell per robot per timestep, only while the robot exists def one_hot_rule(m, a, t): if t not in active_range[a]: return pyo.Constraint.Skip return sum(m.x[a, v, t] for v in m.V) == 1 model.one_hot = pyo.Constraint(model.A, model.T, rule=one_hot_rule) # if at v at time t, must move to a neighbor of v at time t+1, only while # both t and t+1 fall inside the robot's own window def adjacency_rule(m, a, i, j, t): if t not in active_range[a] or (t + 1) not in active_range[a]: return pyo.Constraint.Skip return m.x[a, (i, j), t] <= sum( m.x[a, vp, t + 1] for vp in reachable[(i, j)] ) model.adjacency = pyo.Constraint( model.A, model.V, model.T_minus, rule=adjacency_rule ) # at start position at each robot's own start time model.start = pyo.Constraint( model.A, rule=lambda m, a: ( m.x[a, robots[a].current_position, robots[a].start_time] == 1 ), ) # at goal position at each robot's own goal time model.goal = pyo.Constraint( model.A, rule=lambda m, a: ( m.x[a, robots[a].goal, robots[a].start_time + robots[a].T - 1] == 1 ), ) # once at goal, stay at goal, only within the robot's own window def goal_lock_rule(m, a, t): if t not in active_range[a] or (t + 1) not in active_range[a]: return pyo.Constraint.Skip return m.x[a, robots[a].goal, t] <= m.x[a, robots[a].goal, t + 1] model.goal_lock = pyo.Constraint(model.A, model.T_minus, rule=goal_lock_rule) # at most one robot per cell per timestep model.crash = pyo.Constraint( model.V, model.T, rule=lambda m, i, j, t: sum(m.x[a, (i, j), t] for a in m.A) <= 1, ) # no two robots may swap positions across an edge between t and t+1 model.swap = pyo.ConstraintList() robot_list = list(robots.keys()) for t in model.T_minus: for idx_i in range(len(robot_list)): for idx_j in range(idx_i + 1, len(robot_list)): ai, aj = robot_list[idx_i], robot_list[idx_j] for v in model.V: for w in grid.adjacency[v]: model.swap.add( model.x[ai, v, t] + model.x[aj, w, t] + model.x[ai, w, t + 1] + model.x[aj, v, t + 1] <= 3 ) # minimize total timesteps spent away from goal (equivalent to sum of arrival times, # given goal_lock forces x[a, g_a, ·] to be monotone) model.obj = pyo.Objective( sense=pyo.minimize, expr=sum( model.x[a, v, t] for a in model.A for t in model.T for v in model.V if v != robots[a].goal ), ) self.logger.standard( f"ILP model built: {len(model.A)} robots, {len(model.V)} free cells, " f"{problem.T} timesteps" f"\nBFS Stats: {self.bfs_stats}" ) self.model = model return self.model class GraphILPBuilder(BaseILPBuilder): """ILP builder for graph pathfinding problems. Same constraint structure as GridILPBuilder, written over the graph's native vertex set instead of (i, j) grid cells — see BaseILPBuilder's docstring for what differs.""" def __init__(self, problem, name="ilp_graph", verbose_level=2): if problem.graph is None: raise ValueError("Graph representation not available in this problem") super().__init__(problem, name=name, verbose_level=verbose_level) self.vars_per_time = len(problem.graph.nodes) def local_index(self, v): return v def build(self, preprocess=True): problem = self.problem graph = problem.graph robots = problem.robots model = pyo.ConcreteModel() model.A = pyo.Set(initialize=list(robots.keys())) model.V = pyo.Set(initialize=list(range(len(graph.nodes)))) model.T = pyo.RangeSet(0, problem.T - 1) model.T_minus = pyo.RangeSet(0, problem.T - 2) # graph.adjacency[v] is a set of (neighbor_id, weight) pairs — drop the # weight and add the self-loop, same "stay in place" allowance grid gets. reachable = {v: [n for n, _w in graph.adjacency[v]] + [v] for v in model.V} # Robot start/goal may be stored as raw node ids or as coordinates, # depending on how the problem was built — resolve both to node ids # once, up front, via the same helper the rest of the codebase uses # for graph robot state (see PathfindingProblem.get_graph_robot_current_goal). start_goal = {a: problem.get_graph_robot_current_goal(a) for a in robots} active_range = { a: range(robots[a].start_time, robots[a].start_time + robots[a].T) for a in robots } # Forward-from-start and backward-from-goal BFS reachability per robot: # nodes it cannot possibly have reached by t, or cannot possibly still # reach goal from by the deadline, are fixed to 0. See # bfs_reachable_sets() / reverse_adjacency() docstrings for why the # intersection of both is exact. goal_deadline = {a: robots[a].start_time + robots[a].T - 1 for a in robots} if preprocess: reverse_reachable = reverse_adjacency(reachable) forward_sets = { a: bfs_reachable_sets(reachable, start_goal[a][0], robots[a].T - 1) for a in robots } backward_sets = { a: bfs_reachable_sets( reverse_reachable, start_goal[a][1], robots[a].T - 1 ) for a in robots } else: forward_sets = backward_sets = None self.logger.debug(f"Forward sets: {forward_sets}\nBackward sets: {backward_sets}") # Decision variables x[robot, node, time] model.x = pyo.Var(model.A, model.V, model.T, within=pyo.Binary) in_window_vars = 0 bfs_fixed = 0 for a in model.A: for v in model.V: for t in model.T: if t not in active_range[a]: model.x[a, v, t].fix(0) continue in_window_vars += 1 if preprocess: forward_ok = v in forward_sets[a][t - robots[a].start_time] backward_ok = v in backward_sets[a][goal_deadline[a] - t] if not (forward_ok and backward_ok): model.x[a, v, t].fix(0) bfs_fixed += 1 self.bfs_stats = { "window": 0, "preprocess": preprocess, "initial_variables": in_window_vars, "variables_reduced": bfs_fixed, "final_variables": in_window_vars - bfs_fixed, "reduction_ratio": round(bfs_fixed / in_window_vars, 4) if in_window_vars else 0, } # exactly one node per robot per timestep, only while the robot exists def one_hot_rule(m, a, t): if t not in active_range[a]: return pyo.Constraint.Skip return sum(m.x[a, v, t] for v in m.V) == 1 model.one_hot = pyo.Constraint(model.A, model.T, rule=one_hot_rule) # if at v at time t, must move to a neighbor of v at time t+1, only while # both t and t+1 fall inside the robot's own window def adjacency_rule(m, a, v, t): if t not in active_range[a] or (t + 1) not in active_range[a]: return pyo.Constraint.Skip return m.x[a, v, t] <= sum(m.x[a, vp, t + 1] for vp in reachable[v]) model.adjacency = pyo.Constraint( model.A, model.V, model.T_minus, rule=adjacency_rule ) # at start node at each robot's own start time model.start = pyo.Constraint( model.A, rule=lambda m, a: m.x[a, start_goal[a][0], robots[a].start_time] == 1, ) # at goal node at each robot's own goal time model.goal = pyo.Constraint( model.A, rule=lambda m, a: ( m.x[a, start_goal[a][1], robots[a].start_time + robots[a].T - 1] == 1 ), ) # once at goal, stay at goal, only within the robot's own window def goal_lock_rule(m, a, t): if t not in active_range[a] or (t + 1) not in active_range[a]: return pyo.Constraint.Skip goal_node = start_goal[a][1] return m.x[a, goal_node, t] <= m.x[a, goal_node, t + 1] model.goal_lock = pyo.Constraint(model.A, model.T_minus, rule=goal_lock_rule) # at most one robot per node per timestep model.crash = pyo.Constraint( model.V, model.T, rule=lambda m, v, t: sum(m.x[a, v, t] for a in m.A) <= 1 ) # no two robots may swap positions across an edge between t and t+1 model.swap = pyo.ConstraintList() robot_list = list(robots.keys()) for t in model.T_minus: for idx_i in range(len(robot_list)): for idx_j in range(idx_i + 1, len(robot_list)): ai, aj = robot_list[idx_i], robot_list[idx_j] for v in model.V: for w, _weight in graph.adjacency[v]: model.swap.add( model.x[ai, v, t] + model.x[aj, w, t] + model.x[ai, w, t + 1] + model.x[aj, v, t + 1] <= 3 ) # minimize total timesteps spent away from goal (equivalent to sum of arrival times, # given goal_lock forces x[a, g_a, ·] to be monotone) model.obj = pyo.Objective( sense=pyo.minimize, expr=sum( model.x[a, v, t] for a in model.A for t in model.T for v in model.V if v != start_goal[a][1] ), ) self.logger.standard( f"ILP model built: {len(model.A)} robots, {len(model.V)} nodes, " f"{problem.T} timesteps" f"\nBFS Stats: {self.bfs_stats}" ) self.model = model return self.model