File size: 32,235 Bytes
e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 | from pathlib import Path
from datetime import datetime
import heapq
import json
import time
from quantum.utils.validation import is_valid_move, get_position_representation
from quantum.utils.logger import get_logger
class BenchmarkRunner:
def __init__(
self,
qubobuilder,
solver,
num_runs=10,
output_dir="results/benchmarks",
level=2,
preprocess=True,
):
"""
Run benchmark on a given solver and problem.
Args:
qubobuilder (QUBOBuilder): A fully initialized QUBO builder
solver (QUBOSolver): A solver implementing `.solve(Q)`
num_runs (int): Number of times to run the solver
output_dir (str): Where to save results
level (int): Benchmark verbosity level (1=Summary, 2=Paths, 3=Full)
- Level 1: Only statistics, timing, energy, validation pass/fail
- Level 2: Level 1 + robot paths and per-robot validation details
- Level 3: Level 2 + raw bit solution for debugging
preprocess (bool): Forwarded to solver.solve() each run β toggles
each solver's own variable-reduction step (BFS reachability
pruning for both QUBO and ILP, plus diagonal fixing/windowing
for QUBO specifically).
"""
self.builder = qubobuilder
self.problem = qubobuilder.problem
self.penalty_set = qubobuilder.penalties
self.solver = solver
self.num_runs = num_runs
self.preprocess = preprocess
self.level = max(1, min(3, level)) # Clamp to 1-3
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
self.logger = get_logger() # Use global logger level
# Store metadata once, not per-run
self.results = {
"metadata": {
"problem": self.problem.to_dict(),
"solver": self.solver.to_dict(),
"penalty_set": self.penalty_set,
"benchmark_level": self.level,
"num_runs": num_runs,
"timestamp": datetime.now().isoformat(),
},
"runs": [],
}
def run_build(self):
"""Run the benchmark multiple times and store results"""
self.logger.minimal(f"\nBenchmarking Problem: {self.problem.name}")
self.logger.minimal(f"Using Solver: {self.solver.name}")
self.logger.minimal(f"Penalty Set: {self.penalty_set.get('name', 'unnamed')}")
self.logger.minimal(
f"Benchmark Level: {self.level} ({'Summary' if self.level == 1 else 'Paths' if self.level == 2 else 'Full'})"
)
self.logger.minimal("-" * 60)
valid_count = 0
total_solve_time = 0.0
# Run multiple trials
for run_id in range(1, self.num_runs + 1):
self.builder.reset_problem()
build_start = time.time()
# ILP builders rebuild themselves inside solver.solve() so the
# preprocess flag always takes effect (see ILPSolver.solve());
# pre-building here would just be redundant work and duplicate
# logging. QUBO builders still need this: their preprocess=False
# path reads builder.Q directly without calling build() itself.
if not hasattr(self.builder, "local_index"):
self.builder.build()
build_duration = time.time() - build_start
solve_start = time.time()
solution = self.solver.solve(self.builder, preprocess=self.preprocess)
solve_duration = time.time() - solve_start
self.logger.minimal(
f"Build time: {build_duration:.4f}s, Solve time: {solve_duration:.4f}s"
)
# Decode path for validation
path = self.solver.decode_path(solution["solution"], self.problem)
validation = is_solution_valid(path, self.problem)
# If invalid, work out whether pre-processing forced the conflict
# (bypassing K_crash/K_swap entirely) or the solver actually
# sampled a bitstring that violates a penalty that was present.
forced_collisions = solution.get("metadata", {}).get(
"forced_collisions", []
)
invalid_cause = _attribute_invalid_cause(validation, forced_collisions)
if invalid_cause:
validation["invalid_cause"] = invalid_cause
# Calculate total energy (handle both scalar and list energies)
if isinstance(solution["energy"], list):
total_energy = sum(solution["energy"])
else:
total_energy = solution["energy"]
valid_count += int(validation["valid"])
total_solve_time += solve_duration
# Build result based on level
result = {
"run_id": run_id,
"timestamp": datetime.now().isoformat(),
"valid": validation["valid"],
"energy": total_energy,
"execution_time_sec": round(solve_duration, 3),
}
if invalid_cause:
result["invalid_cause"] = invalid_cause
# Exact solvers (ILP, CBS) report whether they actually proved
# optimality or just returned their best satisfy solution on a
# time/node-limit timeout
termination_condition = solution.get("metadata", {}).get(
"termination_condition"
)
if termination_condition is not None:
result["termination_condition"] = termination_condition
# Add variable stats from solver based on level
window_stats = solution.get("metadata", {}).get("window_stats", [])
if window_stats:
# Always compute totals
total_initial = sum(ws["initial_variables"] for ws in window_stats)
total_reduced = sum(ws["variables_reduced"] for ws in window_stats)
total_final = sum(ws["final_variables"] for ws in window_stats)
avg_reduction = (
total_reduced / total_initial if total_initial > 0 else 0
)
# Level 1: Just totals (aggregated across all windows)
result["variable_stats"] = {
"total_initial_variables": total_initial,
"total_variables_reduced": total_reduced,
"total_final_variables": total_final,
"average_reduction_ratio": round(avg_reduction, 4),
"num_windows": len(window_stats),
}
# Level 2+: Add detailed per-window breakdown
if self.level >= 2:
result["window_variable_stats"] = window_stats
# Level 2+: Add robot paths and validation details
if self.level >= 2:
# Extract robot paths from robot.path (already corrected/merged)
robot_paths = {}
for robot_id, robot in self.problem.robots.items():
robot_paths[robot_id] = robot.path
result["robot_paths"] = robot_paths
# Add per-robot validation details
validation_details = {}
for key, value in validation.get("details", {}).items():
if key.startswith("robot_"):
validation_details[key] = value
result["validation_details"] = validation_details
result["solution_statistics"] = _compute_solution_statistics(
self.problem, robot_paths, validation_details, validation["valid"]
)
# Also store per-window energies for analysis
if isinstance(solution["energy"], list):
result["window_energies"] = solution["energy"]
# Level 3: Add raw bit solution for debugging
if self.level >= 3:
result["raw_solution"] = solution["solution"]
self.results["runs"].append(result)
status = "β
Valid" if validation["valid"] else "β Invalid"
if not validation["valid"]:
self.logger.standard(
"Validation details:", validation.get("details", {})
)
self.logger.standard("Reason:", validation.get("reason", "unknown"))
self.logger.standard("Message:", validation.get("message", ""))
if invalid_cause:
self.logger.standard("Cause:", invalid_cause)
self.logger.minimal(
f"Run {run_id}: {status} | Time: {solve_duration:.2f}s | "
f"Energy: {total_energy:.4f}"
)
self.logger.minimal(f"Path: {path}")
for robot_id, robot in self.problem.robots.items():
self.logger.minimal(f" Robot {robot_id} path: {robot.path}")
avg_solve_time = total_solve_time / self.num_runs if self.num_runs else 0
self.logger.minimal(
f"\nAccuracy: {valid_count}/{self.num_runs} valid "
f"({valid_count / self.num_runs:.1%}) | "
f"Average solve time: {avg_solve_time:.3f}s"
)
self.results["summary"] = {
"valid_runs": valid_count,
"total_runs": self.num_runs,
"accuracy": round(valid_count / self.num_runs, 4) if self.num_runs else 0,
"average_solve_time_sec": round(avg_solve_time, 4),
}
self.save_results()
return self.results
def save_results(self):
filename = f"benchmark_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
filepath = self.output_dir / filename
with open(filepath, "w") as f:
# Convert tuple keys to strings for JSON serialization
serializable_results = convert_tuple_keys_to_str(self.results)
json.dump(serializable_results, f, indent=2, default=str)
self.logger.minimal(f"\nBenchmark complete. Results saved to {filepath}")
_FIX_MECHANISM_LABELS = {
"bfs": "bfs_fixing",
"diag": "diag_fixing",
"locked_inactive": "goal_lock",
}
def _graph_shortest_distance(graph, start_node, goal_node):
"""Dijkstra shortest-path distance between two node indices over a
quantum.map.Graph's adjacency (node -> set of (neighbor, weight)).
Used as the "optimal length" baseline for graph-problem path
efficiency β abs(goal_node - start_node) only means anything if node
IDs happen to be laid out linearly along the path, which isn't true
for a real graph topology."""
if start_node == goal_node:
return 0.0
best = {start_node: 0.0}
visited = set()
heap = [(0.0, start_node)]
while heap:
dist, node = heapq.heappop(heap)
if node in visited:
continue
visited.add(node)
if node == goal_node:
return dist
for neighbor, weight in graph.adjacency.get(node, ()):
new_dist = dist + weight
if new_dist < best.get(neighbor, float("inf")):
best[neighbor] = new_dist
heapq.heappush(heap, (new_dist, neighbor))
return float("inf") # goal unreachable from start
def _compute_solution_statistics(problem, robot_paths, validation_details, overall_valid):
"""
Per-robot and aggregate path-quality statistics for one run: path
length, whether the goal was reached, and path efficiency (optimal
length / moves actually taken). Complements is_solution_valid()'s
pass/fail check with a continuous quality measure.
robot_paths: {robot_id: [(i, j, t), ...]} as stored on
problem.robots[...].path. path[0] is always the robot's start
position (decode_path() includes it), so "moves taken" is
len(path) - 1, not len(path) β dividing optimal_length (a move
count) by len(path) directly undercounts efficiency by one step
for every real path.
validation_details: is_solution_valid()'s per-robot {"robot_N": {...}}
entries. Only present for robots is_solution_valid actually
reached (it returns early on the first individually-invalid
robot) β overall_valid is the fallback for any robot it didn't
get to.
"""
robot_ids = list(problem.robots.keys())
stats = {"total_robots": problem.num_robots, "robot_statistics": {}}
successful = 0
for robot_num, robot_id in enumerate(robot_ids):
robot = problem.robots[robot_id]
path = robot_paths.get(robot_id, [])
detail = validation_details.get(f"robot_{robot_num}")
robot_valid = detail["valid"] if detail is not None else overall_valid
successful += int(robot_valid)
moves_taken = max(0, len(path) - 1)
robot_stats = {
"path_length": len(path),
"moves_taken": moves_taken,
"goal_reached": robot.is_at_goal(),
"validation_passed": robot_valid,
"priority": robot.priority,
"start_time": robot.start_time,
"time_horizon": robot.T,
}
if problem.grid is not None:
optimal_length = problem.manhattan_distance(robot.start, robot.goal)
elif isinstance(robot.start, int) and isinstance(robot.goal, int):
optimal_length = _graph_shortest_distance(
problem.graph, robot.start, robot.goal
)
else:
optimal_length = problem.euclidean_distance(robot.start, robot.goal)
robot_stats["optimal_path_length"] = optimal_length
if optimal_length == 0:
robot_stats["path_efficiency"] = 1.0 # already at goal
elif moves_taken > 0:
robot_stats["path_efficiency"] = optimal_length / moves_taken
else:
robot_stats["path_efficiency"] = 0.0 # never moved but should have
stats["robot_statistics"][robot_id] = robot_stats
stats["successful_robots"] = successful
stats["success_rate"] = (
successful / problem.num_robots if problem.num_robots else 0.0
)
if problem.num_robots > 1:
lengths = [s["path_length"] for s in stats["robot_statistics"].values()]
efficiencies = [
s["path_efficiency"] for s in stats["robot_statistics"].values()
]
stats["aggregate"] = {
"total_path_length": sum(lengths),
"avg_path_length": sum(lengths) / len(lengths),
"max_path_length": max(lengths),
"min_path_length": min(lengths),
"avg_efficiency": sum(efficiencies) / len(efficiencies),
}
return stats
def _attribute_invalid_cause(validation, forced_collisions):
"""
Cross-reference a failed validation's reported vertex conflicts against
the solver's pre-processing forced-collision log (BaseSolver._flag_forced_collisions,
surfaced via solution["metadata"]["forced_collisions"]).
A run can be invalid for two very different reasons that call for
different fixes: the conflicting cell/time was already forced before
K_crash/K_swap ever ran (a pre-processing routing gap), or the QAOA/
annealing sample itself landed on a degenerate state that violates a
penalty which was genuinely present in Q (a solver-convergence issue).
Returns None if validation passed, otherwise a dict:
{"origin": "solver_sampling"} or
{"origin": "pre_processing", "matches": [{"cell", "time", "robots", "fixed_by"}, ...]}
where "fixed_by" names the mechanism(s) that forced the conflict β e.g.
"diag_fixing" when both robots were forced by the same mechanism, or
"diag_fixing + goal_lock" when one robot was still being fixed while the
other was an already-finished robot locked at its goal.
"""
if validation.get("valid", True):
return None
forced_by_cell_time = {}
for fc in forced_collisions:
forced_by_cell_time.setdefault((fc["cell"], fc["time"]), []).append(fc)
matched = []
for conflict in validation.get("details", {}).get("conflicts", []):
key = (conflict["cell"], conflict["time"])
if key in forced_by_cell_time:
matched.extend(forced_by_cell_time[key])
if not matched:
# swap_conflicts have no pre-processing equivalent today (forced_collisions
# only ever records same-cell/same-time fixes), so any swap_conflict here
# is necessarily solver-side.
return {"origin": "solver_sampling"}
matches = []
for fc in matched:
labels = {
_FIX_MECHANISM_LABELS.get(source, source) for _, source in fc["sources"]
}
matches.append(
{
"cell": fc["cell"],
"time": fc["time"],
"robots": fc["robots"],
"fixed_by": " + ".join(sorted(labels)),
}
)
return {"origin": "pre_processing", "matches": matches}
def is_solution_valid(solution, problem):
"""
Checks if the decoded path represents a valid path from start to goal.
Accepts a list of ((i, j, t), robot_num) tuples (decoded path), or a list of such paths.
Args:
solution (list): List of ((i, j, t), robot_num) tuples,
or list of such lists.
problem: Problem instance (grid or graph)
Returns:
dict: Validation result with 'valid' flag and optional error details
"""
problem_type = problem.get_format_type()
if problem_type != "grid" and problem_type != "graph":
return {
"valid": False,
"reason": "unsupported_problem_type",
"message": f"Unsupported problem type: {problem_type}",
}
result = {"valid": True, "details": {}}
# If input is a list of paths (list of lists of tuples)
if solution and isinstance(solution[0], list):
return [is_solution_valid(path, problem) for path in solution]
# If input is a single path (list of tuples)
positions = list(solution)
if not positions:
result["valid"] = False
result["reason"] = "empty_path"
result["message"] = "β No path positions found. Invalid sample"
return result
result["details"]["path"] = positions
# Group positions by robot
robot_positions = {}
for (i, j, t), robot_num in positions:
if robot_num not in robot_positions:
robot_positions[robot_num] = []
robot_positions[robot_num].append((i, j, t))
# Validate each robot's path using unified validation
for robot_num, robot_path in robot_positions.items():
robot_id = list(problem.robots.keys())[robot_num]
robot_result = _validate_single_robot_path_unified(
robot_path, problem, robot_id, robot_num
)
if not robot_result["valid"]:
result["valid"] = False
result["reason"] = f"robot_{robot_num}_invalid"
result["message"] = f"β Robot {robot_num}: {robot_result['message']}"
result["details"][f"robot_{robot_num}"] = robot_result
return result
result["details"][f"robot_{robot_num}"] = robot_result
# multi-robot vertex conflict check (same cell at same time)
occupancy = {} # (i, j, t) -> [robot_num, ...]
for robot_num, robot_path in robot_positions.items():
for i, j, t in robot_path:
key = (i, j, t)
occupancy.setdefault(key, []).append(robot_num)
# Find collisions where two or more robots occupy same cell at same time
conflicts = []
for (i, j, t), robots in occupancy.items():
if len(robots) > 1:
conflicts.append({"cell": (i, j), "time": t, "robots": sorted(robots)})
# multi-robot swap (edge) conflict check: two robots exchange cells
# between consecutive timesteps (robot A: X@t -> Y@t+1, robot B: Y@t -> X@t+1).
# This is invisible to the vertex check above since neither robot ever
# shares a cell at the same time β they cross mid-edge instead.
position_by_time = {
robot_num: {t: (i, j) for (i, j, t) in robot_path}
for robot_num, robot_path in robot_positions.items()
}
robot_nums_sorted = sorted(position_by_time.keys())
swap_conflicts = []
for idx, r1 in enumerate(robot_nums_sorted):
for r2 in robot_nums_sorted[idx + 1 :]:
times_r1 = position_by_time[r1]
times_r2 = position_by_time[r2]
for t in set(times_r1) & set(times_r2):
if (t + 1) not in times_r1 or (t + 1) not in times_r2:
continue
if times_r1[t] == times_r2[t + 1] and times_r2[t] == times_r1[t + 1]:
swap_conflicts.append(
{
"cells": (times_r1[t], times_r2[t]),
"time": (t, t + 1),
"robots": [r1, r2],
}
)
if conflicts or swap_conflicts:
conflicts.sort(key=lambda c: (c["time"], c["cell"]))
swap_conflicts.sort(key=lambda c: (c["time"], c["robots"]))
result["valid"] = False
reasons = []
messages = []
if conflicts:
reasons.append("vertex_conflict")
messages.append(f"{len(conflicts)} vertex collision(s)")
result["details"]["conflicts"] = conflicts
if swap_conflicts:
reasons.append("swap_conflict")
messages.append(f"{len(swap_conflicts)} swap collision(s)")
result["details"]["swap_conflicts"] = swap_conflicts
result["reason"] = "+".join(reasons)
result["message"] = f"β Multi-robot conflict detected: {', '.join(messages)}"
return result
result["valid"] = True
result["message"] = "β
Solution is valid"
return result
def _validate_single_robot_path_unified(positions, problem, robot_id, robot_num):
"""
Unified validation function for single robot paths in both grid and graph
problems.
Args:
positions: List of (i, j, t) tuples for one robot
problem: Problem instance (grid or graph)
robot_id: Robot identifier/name
robot_num: Robot number for error messages
Returns:
dict: Validation result
"""
result = {"valid": True, "details": {}}
# Get notation abstraction based on problem type
notation = _get_notation_abstraction(problem)
# Get robot's individual timeline end
robot = problem.robots[robot_id]
robot_end_time = robot.T + robot.start_time
if not positions:
result["valid"] = False
result["reason"] = "empty_path"
result["message"] = f"β Robot {robot_num}: No path positions found"
return result
# 1. Sort by time step
positions.sort(key=lambda x: x[2])
# 2. Get expected time range and check all time steps are present
expected_times = notation.get_expected_time_range(problem, robot_id, robot_end_time)
expected_goal = notation.get_goal_position(problem, robot_id)
# Early stop support: Check if robot reached goal early
goal_times = [
t
for i, j, t in positions
if get_position_representation(problem, (i, j)) == expected_goal
]
if goal_times:
first_goal_time = min(goal_times)
# Only require timesteps up to when goal was first reached
expected_times = set(t for t in expected_times if t <= first_goal_time)
# Verify robot stayed at goal if it has positions after reaching it
later_positions = [(i, j, t) for i, j, t in positions if t > first_goal_time]
for i, j, t in later_positions:
if get_position_representation(problem, (i, j)) != expected_goal:
result["valid"] = False
result["reason"] = "left_goal_after_reaching"
result["message"] = (
f"β Robot {robot_num}: Left goal position at time {t} "
f"after reaching it at time {first_goal_time}"
)
result["details"]["first_goal_time"] = first_goal_time
result["details"]["left_at_time"] = t
return result
all_times = set(t for _, _, t in positions)
missing_times = expected_times - all_times
# Only flag as extra if timesteps exceed the robot's full timeline range
# Robot can have timesteps from start_time to (start_time + T - 1)
# Extra timesteps are those >= (start_time + T)
extra_times = set(t for t in all_times if t >= robot_end_time)
if missing_times:
result["valid"] = False
result["reason"] = "missing_time_steps"
result["message"] = f"β Robot {robot_num}: Missing time steps: {missing_times}"
result["details"]["missing_times"] = list(missing_times)
return result
if extra_times:
result["valid"] = False
result["reason"] = "extra_time_steps"
result["message"] = (
f"β Robot {robot_num}: Timesteps exceed robot's timeline "
f"[{robot.start_time}, {robot_end_time - 1}]: {extra_times}"
)
result["details"]["extra_times"] = list(extra_times)
result["details"]["robot_timeline"] = (
f"[{robot.start_time}, {robot_end_time - 1}]"
)
return result
# 3. One-hot constraint per time step
time_to_positions = {}
for i, j, t in positions:
time_to_positions.setdefault(t, []).append((i, j))
for t, cells in time_to_positions.items():
if len(cells) > 1:
result["valid"] = False
result["reason"] = "multiple_positions_per_time"
result["message"] = (
f"β Robot {robot_num}: Multiple positions at time {t}: {cells}"
)
result["details"]["conflicts"] = {t: cells}
return result
# 4. Start position check
start_time = notation.get_start_time(problem, robot_id)
start_positions = [(i, j) for i, j, t in positions if t == start_time]
if not start_positions:
result["valid"] = False
result["reason"] = "wrong_start"
result["message"] = (
f"β Robot {robot_num}: No start position found at time {start_time}"
)
return result
expected_start = notation.get_start_position(problem, robot_id)
actual_start = get_position_representation(problem, start_positions[0])
if actual_start != expected_start:
result["valid"] = False
result["reason"] = "wrong_start"
result["message"] = (
f"β Robot {robot_num}: Wrong start position. Expected {expected_start}, got {actual_start}"
)
result["details"]["expected_start"] = expected_start
result["details"]["actual_start"] = actual_start
return result
# 5. Goal position check (already verified in early stop logic above)
# If goal_times is empty, goal was never reached
if not goal_times:
result["valid"] = False
result["reason"] = "goal_not_reached"
result["message"] = (
f"β Robot {robot_num}: Goal position {expected_goal} never reached"
)
result["details"]["goal"] = expected_goal
return result
# 6. Movement must be valid (adjacent positions only)
last_pos = None
for i, j, t in positions:
current_pos = (i, j)
# Check for obstacles (grid only)
if notation.has_obstacle_check():
obstacles = notation.get_obstacles(problem)
if current_pos in obstacles:
result["valid"] = False
result["reason"] = "obstacle_collision"
result["message"] = (
f"β Robot {robot_num}: Path goes through obstacle at {current_pos} at time {t}"
)
result["details"]["collisions"] = [(*current_pos, t)]
return result
if last_pos is not None:
last_i, last_j, last_t = last_pos
# Goal-lock bypass: if both current and previous are goal, allow staying
if (
get_position_representation(problem, (last_i, last_j)) == expected_goal
and get_position_representation(problem, current_pos) == expected_goal
):
last_pos = (i, j, t)
continue
# Valid move check using shared validation utility
if not is_valid_move(problem, (last_i, last_j), current_pos):
result["valid"] = False
result["reason"] = "invalid_move"
result["message"] = (
f"β Robot {robot_num}: Invalid move from ({last_i}, {last_j}, {last_t}) to ({i}, {j}, {t})"
)
result["details"]["invalid_moves"] = [(last_i, last_j, last_t, i, j, t)]
return result
last_pos = (i, j, t)
# 7. Final check: Goal times
goal_times = [
t
for i, j, t in positions
if get_position_representation(problem, (i, j)) == expected_goal
]
result["details"]["goal_times"] = goal_times
result["valid"] = True
result["reason"] = "valid_path"
result["message"] = f"β
Robot {robot_num}: Solution is valid"
return result
def _get_notation_abstraction(problem):
"""Get the appropriate notation abstraction for the problem type."""
problem_type = problem.get_format_type()
if problem_type == "grid":
return GridNotation()
elif problem_type in ["graph", "both"]:
return GraphNotation()
else:
raise ValueError(f"Unsupported problem type: {problem_type}")
class BaseNotation:
"""Base notation abstraction with common methods."""
def get_expected_time_range(self, problem, robot_id, T):
"""Get expected time range for robot."""
robot = problem.robots[robot_id]
return set(range(robot.start_time, T))
def get_start_time(self, problem, robot_id):
"""Get start time for robot."""
robot = problem.robots[robot_id]
return robot.start_time
class GridNotation(BaseNotation):
"""Notation abstraction for grid problems."""
def get_start_position(self, problem, robot_id):
"""Get start position for robot."""
robot = problem.robots[robot_id]
return robot.start
def get_goal_position(self, problem, robot_id):
"""Get goal position for robot."""
robot = problem.robots[robot_id]
return robot.goal
def has_obstacle_check(self):
"""Whether this notation checks for obstacles."""
return True
def get_obstacles(self, problem):
"""Get obstacles for the problem."""
return problem.grid.obstacles
class GraphNotation(BaseNotation):
"""Notation abstraction for graph problems."""
def get_start_position(self, problem, robot_id):
"""Get start node for robot."""
robot = problem.robots[robot_id]
start_node = (
robot.start
if isinstance(robot.start, int)
else problem.graph.get_node_from_position(robot.start)
)
return start_node
def get_goal_position(self, problem, robot_id):
"""Get goal node for robot."""
_, goal_node = problem.get_graph_robot_current_goal(robot_id)
return goal_node
def has_obstacle_check(self):
"""Whether this notation checks for obstacles (graphs don't)."""
return False
def get_obstacles(self, problem):
"""Get obstacles for the problem (not used for graphs)."""
return set()
def convert_tuple_keys_to_str(obj):
"""
Recursively convert tuple keys in dictionaries to strings.
This is useful for serializing dictionaries with tuple keys to JSON.
"""
if isinstance(obj, dict):
return {
str(k) if isinstance(k, tuple) else k: convert_tuple_keys_to_str(v)
for k, v in obj.items()
}
elif isinstance(obj, list):
return [convert_tuple_keys_to_str(i) for i in obj]
else:
return obj
|