Compare commits

..

No commits in common. "2049353ee9e7bcc0a8349d36411ef447f75ae510" and "725980e694980c6cccabccc50f3c9e6a6f3bd978" have entirely different histories.

38 changed files with 346 additions and 13878 deletions

99
DOCS.md
View file

@ -128,65 +128,8 @@ Use `RoutingProblem.initial_paths` to provide semantic per-net seeds. Seeds are
| Field | Default | Description | | Field | Default | Description |
| :-- | :-- | :-- | | :-- | :-- | :-- |
| `capture_expanded` | `False` | Record expanded nodes for diagnostics and visualization. | | `capture_expanded` | `False` | Record expanded nodes for diagnostics and visualization. |
| `capture_conflict_trace` | `False` | Capture authoritative post-reverify conflict trace entries for debugging negotiated-congestion failures. |
| `capture_frontier_trace` | `False` | Run an analysis-only reroute for reached-but-colliding nets and capture prune causes near their final conflict hotspots. |
## 7. Conflict Trace ## 7. RouteMetrics
`RoutingRunResult.conflict_trace` is an immutable tuple of post-reverify conflict snapshots. It is empty unless `RoutingOptions.diagnostics.capture_conflict_trace=True`.
Trace types:
- `ConflictTraceEntry`
- `stage`: `"iteration"`, `"restored_best"`, or `"final"`
- `iteration`: Iteration index for `"iteration"` entries, otherwise `None`
- `completed_net_ids`: Nets with collision-free reached-target paths at that stage
- `conflict_edges`: Undirected dynamic-conflict net pairs seen after full reverify
- `nets`: Per-net trace payloads in routing-order order
- `NetConflictTrace`
- `net_id`
- `outcome`
- `reached_target`
- `report`
- `conflicting_net_ids`: Dynamic conflicting nets for that stage
- `component_conflicts`: Dynamic component-pair overlaps for that stage
- `ComponentConflictTrace`
- `other_net_id`
- `self_component_index`
- `other_component_index`
The conflict trace only records dynamic net-vs-net component overlaps. Static-obstacle and self-collision details remain count-only in `RoutingReport`.
Use `scripts/record_conflict_trace.py` to capture JSON and Markdown trace artifacts for the built-in trace scenarios. The default target is `example_07_large_scale_routing_no_warm_start`.
## 8. Frontier Trace
`RoutingRunResult.frontier_trace` is an immutable tuple of per-net post-run frontier analyses. It is empty unless `RoutingOptions.diagnostics.capture_frontier_trace=True`.
Trace types:
- `NetFrontierTrace`
- `net_id`
- `hotspot_bounds`: Buffered bounds around the net's final dynamic component-overlap hotspots
- `pruned_closed_set`
- `pruned_hard_collision`
- `pruned_self_collision`
- `pruned_cost`
- `samples`: First traced prune events near those hotspots
- `FrontierPruneSample`
- `reason`: `"closed_set"`, `"hard_collision"`, `"self_collision"`, or `"cost"`
- `move_type`
- `hotspot_index`
- `parent_state`
- `end_state`
The frontier trace is observational only. It reruns only the final reached-but-colliding nets in analysis mode, with scratch metrics, after the routed result is already fixed.
Use `scripts/record_frontier_trace.py` to capture JSON and Markdown frontier-prune artifacts for the built-in trace scenarios. The default target is `example_07_large_scale_routing_no_warm_start`.
Separately from the observational trace tooling, the router may run a bounded post-loop pair-local scratch reroute before refinement when the restored best snapshot ends with final two-net reached-target dynamic conflicts. That repair phase is part of normal routing behavior and is reported through the `pair_local_search_*` counters below.
## 9. RouteMetrics
`RoutingRunResult.metrics` is an immutable per-run snapshot. `RoutingRunResult.metrics` is an immutable per-run snapshot.
@ -205,20 +148,11 @@ Separately from the observational trace tooling, the router may run a bounded po
- `warm_start_paths_used`: Number of routing attempts satisfied directly from an initial or warm-start path. - `warm_start_paths_used`: Number of routing attempts satisfied directly from an initial or warm-start path.
- `refine_path_calls`: Number of completed paths passed through the post-route refiner. - `refine_path_calls`: Number of completed paths passed through the post-route refiner.
- `timeout_events`: Number of timeout exits encountered during the run. - `timeout_events`: Number of timeout exits encountered during the run.
- `iteration_reverify_calls`: Number of end-of-iteration full reverify passes against the final installed dynamic geometry.
- `iteration_reverified_nets`: Number of reached-target nets reverified at iteration boundaries.
- `iteration_conflicting_nets`: Total unique nets found in end-of-iteration dynamic conflicts.
- `iteration_conflict_edges`: Total undirected dynamic-conflict edges observed at iteration boundaries.
- `nets_carried_forward`: Number of nets retained unchanged between iterations.
### Cache Counters ### Cache Counters
- `move_cache_abs_hits` / `move_cache_abs_misses`: Absolute move-geometry cache activity. - `move_cache_abs_hits` / `move_cache_abs_misses`: Absolute move-geometry cache activity.
- `move_cache_rel_hits` / `move_cache_rel_misses`: Relative move-geometry cache activity. - `move_cache_rel_hits` / `move_cache_rel_misses`: Relative move-geometry cache activity.
- `guidance_match_moves`: Number of moves that matched the reroute guidance seed and received the guidance bonus.
- `guidance_match_moves_straight`, `guidance_match_moves_bend90`, `guidance_match_moves_sbend`: Guidance-match counts split by move type.
- `guidance_bonus_applied`: Total reroute-guidance bonus subtracted from move costs across the run.
- `guidance_bonus_applied_straight`, `guidance_bonus_applied_bend90`, `guidance_bonus_applied_sbend`: Guidance bonus totals split by move type.
- `static_safe_cache_hits`: Reuse count for the static-safe admission cache. - `static_safe_cache_hits`: Reuse count for the static-safe admission cache.
- `hard_collision_cache_hits`: Reuse count for the hard-collision cache. - `hard_collision_cache_hits`: Reuse count for the hard-collision cache.
- `congestion_cache_hits` / `congestion_cache_misses`: Per-search congestion-cache activity. - `congestion_cache_hits` / `congestion_cache_misses`: Per-search congestion-cache activity.
@ -231,50 +165,29 @@ Separately from the observational trace tooling, the router may run a bounded po
- `static_tree_rebuilds`: Number of static dilated-obstacle STRtree rebuilds. - `static_tree_rebuilds`: Number of static dilated-obstacle STRtree rebuilds.
- `static_raw_tree_rebuilds`: Number of raw static-obstacle STRtree rebuilds used for verification. - `static_raw_tree_rebuilds`: Number of raw static-obstacle STRtree rebuilds used for verification.
- `static_net_tree_rebuilds`: Number of net-width-specific static STRtree rebuilds. - `static_net_tree_rebuilds`: Number of net-width-specific static STRtree rebuilds.
- `visibility_corner_index_builds`: Number of lazy corner-index rebuilds. - `visibility_builds`: Number of static visibility-graph rebuilds.
- `visibility_builds`: Number of exact corner-visibility graph rebuilds. - `visibility_corner_pairs_checked`: Number of corner-pair visibility probes considered while building that graph.
- `visibility_corner_pairs_checked`: Number of corner-pair visibility probes considered while building the exact graph. - `visibility_corner_queries` / `visibility_corner_hits`: Precomputed-corner visibility query activity.
- `visibility_corner_queries_exact` / `visibility_corner_hits_exact`: Exact-corner visibility query activity.
- `visibility_point_queries`, `visibility_point_cache_hits`, `visibility_point_cache_misses`: Arbitrary-point visibility query and cache activity. - `visibility_point_queries`, `visibility_point_cache_hits`, `visibility_point_cache_misses`: Arbitrary-point visibility query and cache activity.
- `ray_cast_calls`: Number of ray-cast queries issued against static obstacles. - `ray_cast_calls`: Number of ray-cast queries issued against static obstacles.
- `ray_cast_candidate_bounds`: Total broad-phase candidate bounds considered by ray casts. - `ray_cast_candidate_bounds`: Total broad-phase candidate bounds considered by ray casts.
- `ray_cast_exact_geometry_checks`: Total exact non-rectangular geometry checks performed by ray casts. - `ray_cast_exact_geometry_checks`: Total exact non-rectangular geometry checks performed by ray casts.
- `congestion_check_calls`: Number of congestion broad-phase checks requested by search. - `congestion_check_calls`: Number of congestion broad-phase checks requested by search.
- `congestion_presence_cache_hits` / `congestion_presence_cache_misses`: Reuse of cached per-span booleans indicating whether a move polygon could overlap any other routed net at all.
- `congestion_presence_skips`: Number of moves that bypassed full congestion evaluation because the presence precheck found no other routed nets in any covered dynamic-grid span.
- `congestion_candidate_precheck_hits` / `congestion_candidate_precheck_misses`: Reuse of cached conservative per-span booleans indicating whether any candidate nets survive the net-envelope and grid-net broad phases.
- `congestion_candidate_precheck_skips`: Number of moves that bypassed full congestion evaluation because the candidate-net precheck found no surviving candidate nets after those broad phases.
- `congestion_candidate_nets`: Total candidate net ids returned by the dynamic net-envelope broad phase during routing.
- `congestion_net_envelope_cache_hits` / `congestion_net_envelope_cache_misses`: Reuse of cached dynamic net-envelope candidate sets keyed by the queried grid-cell span.
- `congestion_grid_net_cache_hits` / `congestion_grid_net_cache_misses`: Reuse of cached per-span candidate net ids gathered from dynamic grid occupancy.
- `congestion_grid_span_cache_hits` / `congestion_grid_span_cache_misses`: Reuse of cached dynamic-path candidate unions keyed by the queried grid-cell span.
- `congestion_lazy_resolutions`: Number of popped nodes whose pending congestion was resolved lazily.
- `congestion_lazy_requeues`: Number of lazily resolved nodes requeued after a positive congestion penalty was applied.
- `congestion_candidate_ids`: Total dynamic-path object ids returned by the congestion broad phase before exact confirmation.
- `congestion_exact_pair_checks`: Number of exact geometry-pair checks performed while confirming congestion hits. - `congestion_exact_pair_checks`: Number of exact geometry-pair checks performed while confirming congestion hits.
### Verification Counters ### Verification Counters
- `verify_path_report_calls`: Number of full path-verification passes. - `verify_path_report_calls`: Number of full path-verification passes.
- `verify_static_buffer_ops`: Number of static-verification `buffer()` operations. - `verify_static_buffer_ops`: Number of static-verification `buffer()` operations.
- `verify_dynamic_candidate_nets`: Total candidate net ids returned by the dynamic net-envelope broad phase during final verification.
- `verify_dynamic_exact_pair_checks`: Number of exact geometry-pair checks performed during dynamic-path verification. - `verify_dynamic_exact_pair_checks`: Number of exact geometry-pair checks performed during dynamic-path verification.
### Local Search Counters ## 8. Internal Modules
- `pair_local_search_pairs_considered`: Number of final reached-target conflict pairs considered by the bounded post-loop pair-local-search phase.
- `pair_local_search_attempts`: Number of pair-local-search reroute attempts executed across all considered pairs.
- `pair_local_search_accepts`: Number of pair-local-search attempts accepted into the whole routed result set.
- `pair_local_search_nodes_expanded`: Total A* node expansions spent inside pair-local-search attempts.
## 10. Internal Modules
Lower-level search and collision modules are semi-private implementation details. They remain accessible through deep imports for advanced use, but they are unstable and may change without notice. The stable supported entrypoint is `route(problem, options=...)`. Lower-level search and collision modules are semi-private implementation details. They remain accessible through deep imports for advanced use, but they are unstable and may change without notice. The stable supported entrypoint is `route(problem, options=...)`.
The current implementation structure is summarized in **[docs/architecture.md](docs/architecture.md)**. The committed example-corpus counter baseline is tracked in **[docs/performance.md](docs/performance.md)**. The current implementation structure is summarized in **[docs/architecture.md](docs/architecture.md)**. The committed example-corpus counter baseline is tracked in **[docs/performance.md](docs/performance.md)**.
Use `scripts/diff_performance_baseline.py` to compare a fresh local run against that baseline. Use `scripts/record_conflict_trace.py` for opt-in conflict-hotspot traces, `scripts/record_frontier_trace.py` for hotspot-adjacent prune traces, and `scripts/characterize_pair_local_search.py` to sweep example_07-style no-warm runs for pair-local repair behavior. The counter baseline is currently observational and is not enforced as a CI gate.
## 11. Tuning Notes ## 9. Tuning Notes
### Speed vs. optimality ### Speed vs. optimality

View file

@ -16,7 +16,7 @@
- `inire/geometry/primitives.py`: Integer Manhattan ports and small transform helpers. - `inire/geometry/primitives.py`: Integer Manhattan ports and small transform helpers.
- `inire/geometry/components.py`: `Straight`, `Bend90`, and `SBend` geometry generation. - `inire/geometry/components.py`: `Straight`, `Bend90`, and `SBend` geometry generation.
- `inire/geometry/collision.py`: Routing-world collision, congestion, ray-cast, and path-verification logic. - `inire/geometry/collision.py`: Routing-world collision, congestion, ray-cast, and path-verification logic.
- `inire/geometry/static_obstacle_index.py` and `inire/geometry/dynamic_path_index.py`: Spatial-index management for static obstacles and routed paths, including dynamic per-object indices, per-net grid occupancy, congestion grid membership, and per-net dynamic envelopes. - `inire/geometry/static_obstacle_index.py` and `inire/geometry/dynamic_path_index.py`: Spatial-index management for static obstacles and routed paths.
- `inire/router/_search.py`, `_astar_moves.py`, `_astar_admission.py`, `_astar_types.py`: The state-lattice A* search loop and move admission pipeline. - `inire/router/_search.py`, `_astar_moves.py`, `_astar_admission.py`, `_astar_types.py`: The state-lattice A* search loop and move admission pipeline.
- `inire/router/_router.py`: The negotiated-congestion driver and refinement orchestration. - `inire/router/_router.py`: The negotiated-congestion driver and refinement orchestration.
- `inire/router/refiner.py`: Post-route path simplification for completed paths. - `inire/router/refiner.py`: Post-route path simplification for completed paths.
@ -39,10 +39,7 @@ The search state is a snapped Manhattan `(x, y, r)` port. From each state the ro
- Static obstacles and routed paths are treated as single-layer geometry; automatic crossings are not supported. - Static obstacles and routed paths are treated as single-layer geometry; automatic crossings are not supported.
- The danger-map implementation uses sampled obstacle-boundary points and a KD-tree, not a dense distance-transform grid. - The danger-map implementation uses sampled obstacle-boundary points and a KD-tree, not a dense distance-transform grid.
- The visibility subsystem keeps a lazy static corner index for default `tangent_corner` guidance and only builds the exact corner-to-corner graph on demand for `exact_corner` queries.
- `use_tiered_strategy` can swap in a cheaper bend proxy on the first congestion iteration. - `use_tiered_strategy` can swap in a cheaper bend proxy on the first congestion iteration.
- Negotiated congestion now re-verifies every reached-target path at the end of each iteration against the final installed dynamic geometry, and it stops early if the conflict graph stalls for consecutive iterations.
- After best-snapshot restoration, the router runs a bounded pair-local scratch reroute on final two-net reached-target conflict pairs. That repair phase clones static obstacles from the live collision world, treats all outside-pair geometry as fixed blockers, tries both pair orders, and only keeps the result if whole-set reverify improves.
- Final `RoutingResult` validity is determined by explicit post-route verification, not only by search-time pruning. - Final `RoutingResult` validity is determined by explicit post-route verification, not only by search-time pruning.
## Performance Visibility ## Performance Visibility

File diff suppressed because it is too large Load diff

View file

@ -1,57 +0,0 @@
# Conflict Trace
Generated at 2026-04-02T14:24:39-07:00 by `scripts/record_conflict_trace.py`.
## example_07_large_scale_routing_no_warm_start
Results: 10 valid / 10 reached / 10 total.
| Stage | Iteration | Conflicting Nets | Conflict Edges | Completed Nets |
| :-- | --: | --: | --: | --: |
| iteration | 0 | 9 | 16 | 1 |
| iteration | 1 | 8 | 12 | 2 |
| iteration | 2 | 6 | 5 | 4 |
| iteration | 3 | 4 | 2 | 6 |
| iteration | 4 | 4 | 2 | 6 |
| iteration | 5 | 4 | 2 | 6 |
| restored_best | | 4 | 2 | 6 |
| final | | 0 | 0 | 10 |
Top nets by traced dynamic-collision stages:
- `net_06`: 7
- `net_07`: 7
- `net_01`: 6
- `net_00`: 5
- `net_02`: 5
- `net_03`: 4
- `net_08`: 2
- `net_09`: 2
- `net_05`: 1
Top net pairs by frequency:
- `net_06` <-> `net_07`: 7
- `net_00` <-> `net_01`: 5
- `net_01` <-> `net_02`: 4
- `net_00` <-> `net_02`: 3
- `net_00` <-> `net_03`: 3
- `net_02` <-> `net_03`: 3
- `net_01` <-> `net_03`: 2
- `net_06` <-> `net_08`: 2
- `net_06` <-> `net_09`: 2
- `net_07` <-> `net_08`: 2
Top component pairs by frequency:
- `net_06[2]` <-> `net_07[2]`: 6
- `net_06[3]` <-> `net_07[2]`: 6
- `net_06[1]` <-> `net_07[1]`: 6
- `net_06[2]` <-> `net_07[1]`: 5
- `net_00[2]` <-> `net_01[3]`: 4
- `net_01[2]` <-> `net_02[2]`: 3
- `net_01[2]` <-> `net_02[3]`: 3
- `net_00[2]` <-> `net_01[2]`: 3
- `net_07[3]` <-> `net_08[2]`: 2
- `net_02[1]` <-> `net_03[1]`: 2

View file

@ -1,120 +0,0 @@
{
"generated_at": "2026-04-02T14:24:39-07:00",
"generator": "scripts/record_frontier_trace.py",
"scenarios": [
{
"frontier_trace": [],
"metrics": {
"congestion_cache_hits": 31,
"congestion_cache_misses": 4625,
"congestion_candidate_ids": 9924,
"congestion_candidate_nets": 9979,
"congestion_candidate_precheck_hits": 2562,
"congestion_candidate_precheck_misses": 2165,
"congestion_candidate_precheck_skips": 71,
"congestion_check_calls": 4625,
"congestion_exact_pair_checks": 8122,
"congestion_grid_net_cache_hits": 2457,
"congestion_grid_net_cache_misses": 3942,
"congestion_grid_span_cache_hits": 2283,
"congestion_grid_span_cache_misses": 1948,
"congestion_lazy_requeues": 0,
"congestion_lazy_resolutions": 0,
"congestion_net_envelope_cache_hits": 2673,
"congestion_net_envelope_cache_misses": 4139,
"congestion_presence_cache_hits": 2858,
"congestion_presence_cache_misses": 2556,
"congestion_presence_skips": 687,
"danger_map_cache_hits": 16878,
"danger_map_cache_misses": 7425,
"danger_map_lookup_calls": 24303,
"danger_map_query_calls": 7425,
"danger_map_total_ns": 212814061,
"dynamic_grid_rebuilds": 0,
"dynamic_path_objects_added": 471,
"dynamic_path_objects_removed": 423,
"dynamic_tree_rebuilds": 0,
"guidance_bonus_applied": 11000.0,
"guidance_bonus_applied_bend90": 3500.0,
"guidance_bonus_applied_sbend": 625.0,
"guidance_bonus_applied_straight": 6875.0,
"guidance_match_moves": 176,
"guidance_match_moves_bend90": 56,
"guidance_match_moves_sbend": 10,
"guidance_match_moves_straight": 110,
"hard_collision_cache_hits": 0,
"iteration_conflict_edges": 39,
"iteration_conflicting_nets": 36,
"iteration_reverified_nets": 60,
"iteration_reverify_calls": 6,
"move_cache_abs_hits": 2559,
"move_cache_abs_misses": 6494,
"move_cache_rel_hits": 5872,
"move_cache_rel_misses": 622,
"moves_added": 8081,
"moves_generated": 9053,
"nets_carried_forward": 0,
"nets_reached_target": 60,
"nets_routed": 60,
"nodes_expanded": 1764,
"pair_local_search_accepts": 2,
"pair_local_search_attempts": 2,
"pair_local_search_nodes_expanded": 68,
"pair_local_search_pairs_considered": 2,
"path_cost_calls": 0,
"pruned_closed_set": 439,
"pruned_cost": 533,
"pruned_hard_collision": 0,
"ray_cast_calls": 5477,
"ray_cast_calls_expand_forward": 1704,
"ray_cast_calls_expand_snap": 46,
"ray_cast_calls_other": 0,
"ray_cast_calls_straight_static": 3721,
"ray_cast_calls_visibility_build": 0,
"ray_cast_calls_visibility_query": 0,
"ray_cast_calls_visibility_tangent": 6,
"ray_cast_candidate_bounds": 305,
"ray_cast_exact_geometry_checks": 0,
"refine_path_calls": 10,
"refinement_candidate_side_extents": 0,
"refinement_candidates_accepted": 0,
"refinement_candidates_built": 0,
"refinement_candidates_verified": 0,
"refinement_dynamic_bounds_checked": 0,
"refinement_static_bounds_checked": 0,
"refinement_windows_considered": 0,
"route_iterations": 6,
"score_component_calls": 8634,
"score_component_total_ns": 241025335,
"static_net_tree_rebuilds": 1,
"static_raw_tree_rebuilds": 1,
"static_safe_cache_hits": 2482,
"static_tree_rebuilds": 1,
"timeout_events": 0,
"verify_dynamic_candidate_nets": 2106,
"verify_dynamic_exact_pair_checks": 558,
"verify_path_report_calls": 190,
"verify_static_buffer_ops": 895,
"visibility_builds": 0,
"visibility_corner_hits_exact": 0,
"visibility_corner_index_builds": 1,
"visibility_corner_pairs_checked": 0,
"visibility_corner_queries_exact": 0,
"visibility_point_cache_hits": 0,
"visibility_point_cache_misses": 0,
"visibility_point_queries": 0,
"visibility_tangent_candidate_corner_checks": 6,
"visibility_tangent_candidate_ray_tests": 6,
"visibility_tangent_candidate_scans": 1704,
"warm_start_paths_built": 0,
"warm_start_paths_used": 0
},
"name": "example_07_large_scale_routing_no_warm_start",
"summary": {
"reached_targets": 10,
"total_results": 10,
"valid_results": 10
}
}
]
}

View file

@ -1,23 +0,0 @@
# Frontier Trace
Generated at 2026-04-02T14:24:39-07:00 by `scripts/record_frontier_trace.py`.
## example_07_large_scale_routing_no_warm_start
Results: 10 valid / 10 reached / 10 total.
| Net | Hotspots | Closed-Set | Hard Collision | Self Collision | Cost | Samples |
| :-- | --: | --: | --: | --: | --: | --: |
Prune totals by reason:
- None
Top traced hotspots by sample count:
- None
Per-net sampled reason/move breakdown:
- None

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,30 +0,0 @@
# Pair-Local Search Characterization
Generated at 2026-04-02T15:53:29-07:00 by `scripts/characterize_pair_local_search.py`.
Grid: `num_nets=[6, 8, 10]`, `seed=[41, 42, 43]`, repeats=2.
| Nets | Seed | Repeat | Duration (s) | Valid | Reached | Pair Pairs | Pair Accepts | Pair Nodes | Nodes | Checks |
| :-- | :-- | :-- | --: | --: | --: | --: | --: | --: | --: | --: |
| 6 | 41 | 0 | 0.5462 | 1 | 6 | 0 | 0 | 0 | 500 | 674 |
| 6 | 41 | 1 | 0.5256 | 1 | 6 | 0 | 0 | 0 | 500 | 674 |
| 6 | 42 | 0 | 0.5241 | 1 | 6 | 0 | 0 | 0 | 503 | 683 |
| 6 | 42 | 1 | 0.5477 | 1 | 6 | 0 | 0 | 0 | 503 | 683 |
| 6 | 43 | 0 | 0.5199 | 1 | 6 | 0 | 0 | 0 | 493 | 654 |
| 6 | 43 | 1 | 0.5160 | 1 | 6 | 0 | 0 | 0 | 493 | 654 |
| 8 | 41 | 0 | 1.8818 | 8 | 8 | 2 | 2 | 38 | 1558 | 4313 |
| 8 | 41 | 1 | 1.8618 | 8 | 8 | 2 | 2 | 38 | 1558 | 4313 |
| 8 | 42 | 0 | 1.4850 | 8 | 8 | 1 | 1 | 19 | 1440 | 3799 |
| 8 | 42 | 1 | 1.4636 | 8 | 8 | 1 | 1 | 19 | 1440 | 3799 |
| 8 | 43 | 0 | 1.0652 | 8 | 8 | 0 | 0 | 0 | 939 | 1844 |
| 8 | 43 | 1 | 1.0502 | 8 | 8 | 0 | 0 | 0 | 939 | 1844 |
| 10 | 41 | 0 | 2.8617 | 8 | 10 | 2 | 2 | 41 | 2223 | 6208 |
| 10 | 41 | 1 | 2.8282 | 8 | 10 | 2 | 2 | 41 | 2223 | 6208 |
| 10 | 42 | 0 | 2.0356 | 10 | 10 | 2 | 2 | 68 | 1764 | 4625 |
| 10 | 42 | 1 | 2.0052 | 10 | 10 | 2 | 2 | 68 | 1764 | 4625 |
| 10 | 43 | 0 | 50.1863 | 10 | 10 | 2 | 2 | 38 | 61259 | 165223 |
| 10 | 43 | 1 | 50.4019 | 10 | 10 | 2 | 2 | 38 | 61259 | 165223 |
## Recommendation
No smaller stable pair-local smoke scenario satisfied the rule `valid_results == total_results`, `pair_local_search_accepts >= 1`, and `duration_s <= 1.0` across all repeats.

View file

@ -1,41 +1,25 @@
# Performance Baseline # Performance Baseline
Generated on 2026-04-02 by `scripts/record_performance_baseline.py`. Generated on 2026-03-31 by `scripts/record_performance_baseline.py`.
The full machine-readable snapshot lives in `docs/performance_baseline.json`. The full machine-readable snapshot lives in `docs/performance_baseline.json`.
Use `scripts/diff_performance_baseline.py` to compare a fresh run against that snapshot.
The default baseline table below covers the standard example corpus only. The heavier `example_07_large_scale_routing_no_warm_start` canary remains performance-only and is tracked through targeted diffs plus the conflict/frontier trace artifacts.
Use `scripts/characterize_pair_local_search.py` when you want a small parameter sweep over example_07-style no-warm runs instead of a single canary reading.
The current tracked sweep output lives in `docs/pair_local_characterization.json` and `docs/pair_local_characterization.md`.
| Scenario | Duration (s) | Total | Valid | Reached | Iter | Nets Routed | Nodes | Ray Casts | Moves Gen | Moves Added | Dyn Tree | Visibility Builds | Congestion Checks | Verify Calls | | Scenario | Duration (s) | Total | Valid | Reached | Iter | Nets Routed | Nodes | Ray Casts | Moves Gen | Moves Added | Dyn Tree | Visibility Builds | Congestion Checks | Verify Calls |
| :-- | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | | :-- | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: |
| example_01_simple_route | 0.0040 | 1 | 1 | 1 | 1 | 1 | 2 | 10 | 11 | 7 | 0 | 0 | 0 | 4 | | example_01_simple_route | 0.0042 | 1 | 1 | 1 | 1 | 1 | 2 | 22 | 11 | 7 | 2 | 2 | 0 | 3 |
| example_02_congestion_resolution | 0.3378 | 3 | 3 | 3 | 1 | 3 | 366 | 1164 | 1413 | 668 | 0 | 0 | 0 | 38 | | example_02_congestion_resolution | 0.3335 | 3 | 3 | 3 | 1 | 3 | 366 | 1176 | 1413 | 668 | 8 | 4 | 0 | 35 |
| example_03_locked_paths | 0.1929 | 2 | 2 | 2 | 2 | 2 | 191 | 657 | 904 | 307 | 0 | 0 | 0 | 16 | | example_03_locked_paths | 0.1810 | 2 | 2 | 2 | 2 | 2 | 191 | 681 | 904 | 307 | 5 | 4 | 0 | 14 |
| example_04_sbends_and_radii | 0.0279 | 2 | 2 | 2 | 1 | 2 | 15 | 70 | 123 | 65 | 0 | 0 | 0 | 8 | | example_04_sbends_and_radii | 2.0151 | 2 | 2 | 2 | 1 | 2 | 15 | 18218 | 123 | 65 | 4 | 3 | 0 | 6 |
| example_05_orientation_stress | 0.2367 | 3 | 3 | 3 | 2 | 6 | 299 | 1284 | 1691 | 696 | 0 | 0 | 149 | 18 | | example_05_orientation_stress | 0.2438 | 3 | 3 | 3 | 2 | 6 | 286 | 1243 | 1624 | 681 | 12 | 3 | 412 | 12 |
| example_06_bend_collision_models | 0.1998 | 3 | 3 | 3 | 3 | 3 | 240 | 682 | 1026 | 629 | 0 | 0 | 0 | 12 | | example_06_bend_collision_models | 4.1636 | 3 | 3 | 3 | 3 | 3 | 240 | 40530 | 1026 | 629 | 6 | 6 | 0 | 9 |
| example_07_large_scale_routing | 0.2005 | 10 | 10 | 10 | 1 | 10 | 78 | 383 | 372 | 227 | 0 | 0 | 0 | 40 | | example_07_large_scale_routing | 1.3759 | 10 | 10 | 10 | 1 | 10 | 78 | 11151 | 372 | 227 | 20 | 11 | 0 | 30 |
| example_08_custom_bend_geometry | 0.0176 | 2 | 2 | 2 | 2 | 2 | 18 | 56 | 78 | 56 | 0 | 0 | 0 | 8 | | example_08_custom_bend_geometry | 0.2437 | 2 | 2 | 2 | 2 | 2 | 18 | 2308 | 78 | 56 | 4 | 4 | 0 | 6 |
| example_09_unroutable_best_effort | 0.0058 | 1 | 0 | 0 | 1 | 1 | 3 | 13 | 16 | 10 | 0 | 0 | 0 | 1 | | example_09_unroutable_best_effort | 0.0052 | 1 | 0 | 0 | 1 | 1 | 3 | 13 | 16 | 10 | 1 | 0 | 0 | 1 |
## Full Counter Set ## Full Counter Set
Each scenario entry in `docs/performance_baseline.json` records the full `RouteMetrics` snapshot, including cache, index, congestion, and verification counters. Each scenario entry in `docs/performance_baseline.json` records the full `RouteMetrics` snapshot, including cache, index, congestion, and verification counters.
These counters are currently observational only and are not enforced as CI regression gates.
For the current accepted branch, the most important performance-only canary is `example_07_large_scale_routing_no_warm_start`, which now finishes `10/10/10` after a bounded post-loop pair-local scratch reroute. The relevant counters for that phase are:
- `pair_local_search_pairs_considered`
- `pair_local_search_attempts`
- `pair_local_search_accepts`
- `pair_local_search_nodes_expanded`
The latest tracked characterization sweep confirms there is no smaller stable pair-local smoke case under the `<=1.0s` rule, so the 10-net no-warm-start canary remains the primary regression target for this behavior.
Tracked metric keys: Tracked metric keys:
nodes_expanded, moves_generated, moves_added, pruned_closed_set, pruned_hard_collision, pruned_cost, route_iterations, nets_routed, nets_reached_target, warm_start_paths_built, warm_start_paths_used, refine_path_calls, timeout_events, iteration_reverify_calls, iteration_reverified_nets, iteration_conflicting_nets, iteration_conflict_edges, nets_carried_forward, score_component_calls, score_component_total_ns, path_cost_calls, danger_map_lookup_calls, danger_map_cache_hits, danger_map_cache_misses, danger_map_query_calls, danger_map_total_ns, move_cache_abs_hits, move_cache_abs_misses, move_cache_rel_hits, move_cache_rel_misses, guidance_match_moves, guidance_match_moves_straight, guidance_match_moves_bend90, guidance_match_moves_sbend, guidance_bonus_applied, guidance_bonus_applied_straight, guidance_bonus_applied_bend90, guidance_bonus_applied_sbend, static_safe_cache_hits, hard_collision_cache_hits, congestion_cache_hits, congestion_cache_misses, congestion_presence_cache_hits, congestion_presence_cache_misses, congestion_presence_skips, congestion_candidate_precheck_hits, congestion_candidate_precheck_misses, congestion_candidate_precheck_skips, congestion_grid_net_cache_hits, congestion_grid_net_cache_misses, congestion_grid_span_cache_hits, congestion_grid_span_cache_misses, congestion_candidate_nets, congestion_net_envelope_cache_hits, congestion_net_envelope_cache_misses, dynamic_path_objects_added, dynamic_path_objects_removed, dynamic_tree_rebuilds, dynamic_grid_rebuilds, static_tree_rebuilds, static_raw_tree_rebuilds, static_net_tree_rebuilds, visibility_corner_index_builds, visibility_builds, visibility_corner_pairs_checked, visibility_corner_queries_exact, visibility_corner_hits_exact, visibility_point_queries, visibility_point_cache_hits, visibility_point_cache_misses, visibility_tangent_candidate_scans, visibility_tangent_candidate_corner_checks, visibility_tangent_candidate_ray_tests, ray_cast_calls, ray_cast_calls_straight_static, ray_cast_calls_expand_snap, ray_cast_calls_expand_forward, ray_cast_calls_visibility_build, ray_cast_calls_visibility_query, ray_cast_calls_visibility_tangent, ray_cast_calls_other, ray_cast_candidate_bounds, ray_cast_exact_geometry_checks, congestion_check_calls, congestion_lazy_resolutions, congestion_lazy_requeues, congestion_candidate_ids, congestion_exact_pair_checks, verify_path_report_calls, verify_static_buffer_ops, verify_dynamic_candidate_nets, verify_dynamic_exact_pair_checks, refinement_windows_considered, refinement_static_bounds_checked, refinement_dynamic_bounds_checked, refinement_candidate_side_extents, refinement_candidates_built, refinement_candidates_verified, refinement_candidates_accepted, pair_local_search_pairs_considered, pair_local_search_attempts, pair_local_search_accepts, pair_local_search_nodes_expanded nodes_expanded, moves_generated, moves_added, pruned_closed_set, pruned_hard_collision, pruned_cost, route_iterations, nets_routed, nets_reached_target, warm_start_paths_built, warm_start_paths_used, refine_path_calls, timeout_events, move_cache_abs_hits, move_cache_abs_misses, move_cache_rel_hits, move_cache_rel_misses, static_safe_cache_hits, hard_collision_cache_hits, congestion_cache_hits, congestion_cache_misses, dynamic_path_objects_added, dynamic_path_objects_removed, dynamic_tree_rebuilds, dynamic_grid_rebuilds, static_tree_rebuilds, static_raw_tree_rebuilds, static_net_tree_rebuilds, visibility_builds, visibility_corner_pairs_checked, visibility_corner_queries, visibility_corner_hits, visibility_point_queries, visibility_point_cache_hits, visibility_point_cache_misses, ray_cast_calls, ray_cast_candidate_bounds, ray_cast_exact_geometry_checks, congestion_check_calls, congestion_exact_pair_checks, verify_path_report_calls, verify_static_buffer_ops, verify_dynamic_exact_pair_checks

File diff suppressed because it is too large Load diff

View file

@ -14,15 +14,7 @@ from .model import (
RoutingProblem as RoutingProblem, RoutingProblem as RoutingProblem,
SearchOptions as SearchOptions, SearchOptions as SearchOptions,
) # noqa: PLC0414 ) # noqa: PLC0414
from .results import ( # noqa: PLC0414 from .results import RoutingResult as RoutingResult, RoutingRunResult as RoutingRunResult # noqa: PLC0414
ComponentConflictTrace as ComponentConflictTrace,
ConflictTraceEntry as ConflictTraceEntry,
FrontierPruneSample as FrontierPruneSample,
NetConflictTrace as NetConflictTrace,
NetFrontierTrace as NetFrontierTrace,
RoutingResult as RoutingResult,
RoutingRunResult as RoutingRunResult,
)
from .seeds import Bend90Seed as Bend90Seed, PathSeed as PathSeed, SBendSeed as SBendSeed, StraightSeed as StraightSeed # noqa: PLC0414 from .seeds import Bend90Seed as Bend90Seed, PathSeed as PathSeed, SBendSeed as SBendSeed, StraightSeed as StraightSeed # noqa: PLC0414
__author__ = 'Jan Petykiewicz' __author__ = 'Jan Petykiewicz'
@ -45,23 +37,16 @@ def route(
results_by_net=results, results_by_net=results,
metrics=finder.metrics.snapshot(), metrics=finder.metrics.snapshot(),
expanded_nodes=tuple(finder.accumulated_expanded_nodes), expanded_nodes=tuple(finder.accumulated_expanded_nodes),
conflict_trace=tuple(finder.conflict_trace),
frontier_trace=tuple(finder.frontier_trace),
) )
__all__ = [ __all__ = [
"Bend90Seed", "Bend90Seed",
"CongestionOptions", "CongestionOptions",
"ComponentConflictTrace",
"ConflictTraceEntry",
"DiagnosticsOptions", "DiagnosticsOptions",
"NetSpec", "NetSpec",
"NetConflictTrace",
"NetFrontierTrace",
"ObjectiveWeights", "ObjectiveWeights",
"PathSeed", "PathSeed",
"Port", "Port",
"FrontierPruneSample",
"RefinementOptions", "RefinementOptions",
"RoutingOptions", "RoutingOptions",
"RoutingProblem", "RoutingProblem",

View file

@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import numpy import numpy
@ -29,50 +28,6 @@ def _intersection_distance(origin: Port, geometry: BaseGeometry) -> float:
return float(numpy.sqrt((geometry.coords[0][0] - origin.x) ** 2 + (geometry.coords[0][1] - origin.y) ** 2)) return float(numpy.sqrt((geometry.coords[0][0] - origin.x) ** 2 + (geometry.coords[0][1] - origin.y) ** 2))
def _bounds_overlap(
left: tuple[float, float, float, float],
right: tuple[float, float, float, float],
) -> bool:
return (
left[0] < right[2]
and left[2] > right[0]
and left[1] < right[3]
and left[3] > right[1]
)
def _has_non_touching_overlap(left: BaseGeometry, right: BaseGeometry) -> bool:
return left.intersects(right) and not left.touches(right)
def _span_to_bounds(
gx_min: int,
gy_min: int,
gx_max: int,
gy_max: int,
cell_size: float,
) -> tuple[float, float, float, float]:
return (
gx_min * cell_size,
gy_min * cell_size,
(gx_max + 1) * cell_size,
(gy_max + 1) * cell_size,
)
@dataclass(frozen=True, slots=True)
class PathVerificationDetail:
report: RoutingReport
conflicting_net_ids: tuple[str, ...] = ()
component_conflicts: tuple[tuple[int, str, int], ...] = ()
@dataclass(frozen=True, slots=True)
class DynamicCongestionDetail:
soft_overlap_count: int = 0
hits_frozen_net: bool = False
class RoutingWorld: class RoutingWorld:
""" """
Internal spatial state for collision detection, congestion, and verification. Internal spatial state for collision detection, congestion, and verification.
@ -141,34 +96,14 @@ class RoutingWorld:
def _ensure_dynamic_grid(self) -> None: def _ensure_dynamic_grid(self) -> None:
self._dynamic_paths.ensure_grid() self._dynamic_paths.ensure_grid()
def add_path( def add_path(self, net_id: str, geometry: Sequence[Polygon], dilated_geometry: Sequence[Polygon]) -> None:
self, self._dynamic_paths.add_path(net_id, geometry, dilated_geometry=dilated_geometry)
net_id: str,
geometry: Sequence[Polygon],
dilated_geometry: Sequence[Polygon],
component_indexes: Sequence[int] | None = None,
) -> None:
self._dynamic_paths.add_path(
net_id,
geometry,
dilated_geometry=dilated_geometry,
component_indexes=component_indexes,
)
def remove_path(self, net_id: str) -> None: def remove_path(self, net_id: str) -> None:
self._dynamic_paths.remove_path(net_id) self._dynamic_paths.remove_path(net_id)
def has_dynamic_paths(self) -> bool:
return bool(self._dynamic_paths.geometries)
def check_move_straight_static(self, start_port: Port, length: float, net_width: float) -> bool: def check_move_straight_static(self, start_port: Port, length: float, net_width: float) -> bool:
reach = self.ray_cast( reach = self.ray_cast(start_port, start_port.r, max_dist=length + 0.01, net_width=net_width)
start_port,
start_port.r,
max_dist=length + 0.01,
net_width=net_width,
caller="straight_static",
)
return reach < length - 0.001 return reach < length - 0.001
def _is_in_safety_zone_fast(self, idx: int, start_port: Port | None, end_port: Port | None) -> bool: def _is_in_safety_zone_fast(self, idx: int, start_port: Port | None, end_port: Port | None) -> bool:
@ -294,390 +229,105 @@ class RoutingWorld:
return False return False
def _check_real_congestion( def _check_real_congestion(self, result: ComponentResult, net_id: str) -> int:
self,
result: ComponentResult,
candidates_by_net: dict[str, dict[int, tuple[int, ...]]],
frozen_net_ids: frozenset[str] = frozenset(),
) -> DynamicCongestionDetail:
if not candidates_by_net:
return DynamicCongestionDetail()
dynamic_paths = self._dynamic_paths dynamic_paths = self._dynamic_paths
geometries_to_test = result.dilated_collision_geometry self._ensure_dynamic_tree()
if dynamic_paths.tree is None:
return 0
real_hits_count = 0 total_bounds = result.total_dilated_bounds
for other_net_id, other_obj_ids in candidates_by_net.items(): dynamic_bounds = dynamic_paths.bounds_array
found_real = False possible_total = (
for obj_id, test_geometry_indexes in other_obj_ids.items(): (total_bounds[0] < dynamic_bounds[:, 2])
tree_geometry = dynamic_paths.dilated[obj_id] & (total_bounds[2] > dynamic_bounds[:, 0])
for test_geometry_index in test_geometry_indexes: & (total_bounds[1] < dynamic_bounds[:, 3])
test_geometry = geometries_to_test[test_geometry_index] & (total_bounds[3] > dynamic_bounds[:, 1])
if self.metrics is not None:
self.metrics.total_congestion_exact_pair_checks += 1
if _has_non_touching_overlap(test_geometry, tree_geometry):
found_real = True
break
if found_real:
break
if found_real:
if other_net_id in frozen_net_ids:
return DynamicCongestionDetail(
soft_overlap_count=real_hits_count,
hits_frozen_net=True,
)
real_hits_count += 1
return DynamicCongestionDetail(soft_overlap_count=real_hits_count)
def _collect_congestion_candidates(
self,
result: ComponentResult,
net_id: str,
net_envelope_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] | None = None,
grid_net_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] | None = None,
broad_phase_cache: dict[tuple[str, int, int, int, int], dict[str, tuple[int, ...]]] | None = None,
) -> dict[str, dict[int, tuple[int, ...]]]:
dynamic_paths = self._dynamic_paths
if not dynamic_paths.dilated:
return {}
self._ensure_dynamic_grid()
if not dynamic_paths.grid:
return {}
candidates_by_net: dict[str, dict[int, set[int]]] = {}
for test_geometry_index, test_bounds in enumerate(result.dilated_bounds):
if not self._has_possible_congestion_in_grid(test_bounds, net_id):
continue
envelope_net_ids = self._get_net_envelope_candidates(
test_bounds,
net_id,
net_envelope_cache,
)
if not envelope_net_ids:
continue
grid_net_ids = self._get_grid_span_net_candidates(
test_bounds,
net_id,
grid_net_cache,
)
if not grid_net_ids:
continue
candidate_net_ids = tuple(sorted(set(envelope_net_ids) & set(grid_net_ids)))
if not candidate_net_ids:
continue
grid_candidates = self._get_grid_span_candidates(
test_bounds,
net_id,
broad_phase_cache,
)
for other_net_id in candidate_net_ids:
if self.metrics is not None:
self.metrics.total_congestion_candidate_nets += 1
obj_ids = grid_candidates.get(other_net_id)
if not obj_ids:
continue
for obj_id in obj_ids:
if not _bounds_overlap(test_bounds, dynamic_paths.dilated_bounds[obj_id]):
continue
if self.metrics is not None:
self.metrics.total_congestion_candidate_ids += 1
candidate_indexes = candidates_by_net.setdefault(other_net_id, {}).setdefault(obj_id, set())
candidate_indexes.add(test_geometry_index)
return {
other_net_id: {
obj_id: tuple(sorted(test_geometry_indexes))
for obj_id, test_geometry_indexes in sorted(obj_ids.items())
}
for other_net_id, obj_ids in candidates_by_net.items()
}
def _has_possible_congestion_in_grid(
self,
bounds: tuple[float, float, float, float],
net_id: str,
) -> bool:
gx_min, gy_min, gx_max, gy_max = grid_cell_span(bounds, self.grid_cell_size)
dynamic_paths = self._dynamic_paths
if gx_min == gx_max and gy_min == gy_max:
net_counts = dynamic_paths.grid_net_counts.get((gx_min, gy_min))
return bool(net_counts and (len(net_counts) > 1 or net_id not in net_counts))
for gx in range(gx_min, gx_max + 1):
for gy in range(gy_min, gy_max + 1):
net_counts = dynamic_paths.grid_net_counts.get((gx, gy))
if net_counts and (len(net_counts) > 1 or net_id not in net_counts):
return True
return False
def has_possible_move_congestion(
self,
result: ComponentResult,
net_id: str,
presence_cache: dict[tuple[str, int, int, int, int], bool] | None = None,
) -> bool:
dynamic_paths = self._dynamic_paths
if not dynamic_paths.dilated:
return False
self._ensure_dynamic_grid()
if not dynamic_paths.grid:
return False
for bounds in result.dilated_bounds:
gx_min, gy_min, gx_max, gy_max = grid_cell_span(bounds, self.grid_cell_size)
cache_key = (net_id, gx_min, gy_min, gx_max, gy_max)
if presence_cache is not None and cache_key in presence_cache:
if self.metrics is not None:
self.metrics.total_congestion_presence_cache_hits += 1
has_possible = presence_cache[cache_key]
else:
if self.metrics is not None:
self.metrics.total_congestion_presence_cache_misses += 1
has_possible = self._has_possible_congestion_in_grid(bounds, net_id)
if presence_cache is not None:
presence_cache[cache_key] = has_possible
if has_possible:
return True
return False
def has_candidate_move_congestion(
self,
result: ComponentResult,
net_id: str,
candidate_precheck_cache: dict[tuple[str, int, int, int, int], bool] | None = None,
net_envelope_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] | None = None,
grid_net_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] | None = None,
) -> bool:
dynamic_paths = self._dynamic_paths
if not dynamic_paths.dilated:
return False
for bounds in result.dilated_bounds:
gx_min, gy_min, gx_max, gy_max = grid_cell_span(bounds, self.grid_cell_size)
cache_key = (net_id, gx_min, gy_min, gx_max, gy_max)
if candidate_precheck_cache is not None and cache_key in candidate_precheck_cache:
if self.metrics is not None:
self.metrics.total_congestion_candidate_precheck_hits += 1
has_candidates = candidate_precheck_cache[cache_key]
else:
if self.metrics is not None:
self.metrics.total_congestion_candidate_precheck_misses += 1
span_bounds = _span_to_bounds(gx_min, gy_min, gx_max, gy_max, self.grid_cell_size)
envelope_net_ids = self._get_net_envelope_candidates(
span_bounds,
net_id,
net_envelope_cache,
)
if not envelope_net_ids:
has_candidates = False
else:
grid_net_ids = self._get_grid_span_net_candidates(
span_bounds,
net_id,
grid_net_cache,
)
if not grid_net_ids:
has_candidates = False
else:
has_candidates = bool(set(envelope_net_ids) & set(grid_net_ids))
if candidate_precheck_cache is not None:
candidate_precheck_cache[cache_key] = has_candidates
if has_candidates:
return True
return False
def _get_grid_span_candidates(
self,
bounds: tuple[float, float, float, float],
net_id: str,
broad_phase_cache: dict[tuple[str, int, int, int, int], dict[str, tuple[int, ...]]] | None,
) -> dict[str, tuple[int, ...]]:
gx_min, gy_min, gx_max, gy_max = grid_cell_span(bounds, self.grid_cell_size)
cache_key = (net_id, gx_min, gy_min, gx_max, gy_max)
if broad_phase_cache is not None and cache_key in broad_phase_cache:
if self.metrics is not None:
self.metrics.total_congestion_grid_span_cache_hits += 1
return broad_phase_cache[cache_key]
if self.metrics is not None:
self.metrics.total_congestion_grid_span_cache_misses += 1
dynamic_paths = self._dynamic_paths
candidates_by_net: dict[str, set[int]] = {}
for gx in range(gx_min, gx_max + 1):
for gy in range(gy_min, gy_max + 1):
for other_net_id, obj_ids in dynamic_paths.grid_net_obj_ids.get((gx, gy), {}).items():
if other_net_id == net_id:
continue
candidates_by_net.setdefault(other_net_id, set()).update(obj_ids)
frozen = {
other_net_id: tuple(sorted(obj_ids))
for other_net_id, obj_ids in sorted(candidates_by_net.items())
}
if broad_phase_cache is not None:
broad_phase_cache[cache_key] = frozen
return frozen
def _get_grid_span_net_candidates(
self,
bounds: tuple[float, float, float, float],
net_id: str,
grid_net_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] | None,
) -> tuple[str, ...]:
gx_min, gy_min, gx_max, gy_max = grid_cell_span(bounds, self.grid_cell_size)
cache_key = (net_id, gx_min, gy_min, gx_max, gy_max)
if grid_net_cache is not None and cache_key in grid_net_cache:
if self.metrics is not None:
self.metrics.total_congestion_grid_net_cache_hits += 1
return grid_net_cache[cache_key]
if self.metrics is not None:
self.metrics.total_congestion_grid_net_cache_misses += 1
dynamic_paths = self._dynamic_paths
candidate_net_ids: set[str] = set()
for gx in range(gx_min, gx_max + 1):
for gy in range(gy_min, gy_max + 1):
for other_net_id in dynamic_paths.grid_net_obj_ids.get((gx, gy), {}):
if other_net_id != net_id:
candidate_net_ids.add(other_net_id)
frozen = tuple(sorted(candidate_net_ids))
if grid_net_cache is not None:
grid_net_cache[cache_key] = frozen
return frozen
def _get_net_envelope_candidates(
self,
bounds: tuple[float, float, float, float],
net_id: str,
net_envelope_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] | None,
) -> tuple[str, ...]:
dynamic_paths = self._dynamic_paths
gx_min, gy_min, gx_max, gy_max = grid_cell_span(bounds, self.grid_cell_size)
cache_key = (net_id, gx_min, gy_min, gx_max, gy_max)
if net_envelope_cache is not None and cache_key in net_envelope_cache:
if self.metrics is not None:
self.metrics.total_congestion_net_envelope_cache_hits += 1
cached_net_ids = net_envelope_cache[cache_key]
else:
if self.metrics is not None:
self.metrics.total_congestion_net_envelope_cache_misses += 1
span_bounds = _span_to_bounds(gx_min, gy_min, gx_max, gy_max, self.grid_cell_size)
cached_net_ids = tuple(
sorted(
dynamic_paths.net_envelope_obj_to_net[obj_id]
for obj_id in dynamic_paths.net_envelope_index.intersection(span_bounds)
if dynamic_paths.net_envelope_obj_to_net[obj_id] != net_id
)
)
if net_envelope_cache is not None:
net_envelope_cache[cache_key] = cached_net_ids
return tuple(
other_net_id
for other_net_id in cached_net_ids
if _bounds_overlap(bounds, dynamic_paths.net_envelopes[other_net_id])
) )
def _get_verify_net_envelope_candidates( valid_hits_mask = dynamic_paths.net_ids_array != net_id
self, if not numpy.any(possible_total & valid_hits_mask):
bounds: tuple[float, float, float, float], return 0
net_id: str,
) -> tuple[str, ...]:
dynamic_paths = self._dynamic_paths
candidate_net_ids: list[str] = []
for obj_id in dynamic_paths.net_envelope_index.intersection(bounds):
other_net_id = dynamic_paths.net_envelope_obj_to_net[obj_id]
if other_net_id == net_id:
continue
if not _bounds_overlap(bounds, dynamic_paths.net_envelopes[other_net_id]):
continue
candidate_net_ids.append(other_net_id)
if self.metrics is not None:
self.metrics.total_verify_dynamic_candidate_nets += len(candidate_net_ids)
return tuple(candidate_net_ids)
def _get_verify_grid_span_obj_ids( geometries_to_test = result.dilated_collision_geometry
self, res_indices, tree_indices = dynamic_paths.tree.query(geometries_to_test, predicate="intersects")
bounds: tuple[float, float, float, float], if tree_indices.size == 0:
other_net_id: str, return 0
) -> tuple[int, ...]:
dynamic_paths = self._dynamic_paths
gx_min, gy_min, gx_max, gy_max = grid_cell_span(bounds, self.grid_cell_size)
obj_ids: set[int] = set()
for gx in range(gx_min, gx_max + 1):
for gy in range(gy_min, gy_max + 1):
obj_ids.update(dynamic_paths.grid_net_obj_ids.get((gx, gy), {}).get(other_net_id, ()))
return tuple(sorted(obj_ids))
def check_move_congestion( hit_net_ids = numpy.take(dynamic_paths.net_ids_array, tree_indices)
self, unique_other_nets = numpy.unique(hit_net_ids[hit_net_ids != net_id])
result: ComponentResult, if unique_other_nets.size == 0:
net_id: str, return 0
net_envelope_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] | None = None,
grid_net_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] | None = None,
broad_phase_cache: dict[tuple[str, int, int, int, int], dict[str, tuple[int, ...]]] | None = None,
) -> int:
return self.check_move_congestion_detail(
result,
net_id,
net_envelope_cache=net_envelope_cache,
grid_net_cache=grid_net_cache,
broad_phase_cache=broad_phase_cache,
).soft_overlap_count
def check_move_congestion_detail( tree_geometries = dynamic_paths.tree.geometries
self, real_hits_count = 0
result: ComponentResult, for other_net_id in unique_other_nets:
net_id: str, other_mask = hit_net_ids == other_net_id
*, sub_tree_indices = tree_indices[other_mask]
frozen_net_ids: frozenset[str] = frozenset(), sub_res_indices = res_indices[other_mask]
net_envelope_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] | None = None,
grid_net_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] | None = None, found_real = False
broad_phase_cache: dict[tuple[str, int, int, int, int], dict[str, tuple[int, ...]]] | None = None, for index in range(len(sub_tree_indices)):
) -> DynamicCongestionDetail: if self.metrics is not None:
self.metrics.total_congestion_exact_pair_checks += 1
test_geometry = geometries_to_test[sub_res_indices[index]]
tree_geometry = tree_geometries[sub_tree_indices[index]]
if not test_geometry.touches(tree_geometry) and test_geometry.intersection(tree_geometry).area > 1e-7:
found_real = True
break
if found_real:
real_hits_count += 1
return real_hits_count
def check_move_congestion(self, result: ComponentResult, net_id: str) -> int:
if self.metrics is not None: if self.metrics is not None:
self.metrics.total_congestion_check_calls += 1 self.metrics.total_congestion_check_calls += 1
dynamic_paths = self._dynamic_paths dynamic_paths = self._dynamic_paths
if not dynamic_paths.geometries: if not dynamic_paths.geometries:
return DynamicCongestionDetail() return 0
candidates_by_net = self._collect_congestion_candidates( total_bounds = result.total_dilated_bounds
result, self._ensure_dynamic_grid()
net_id, dynamic_grid = dynamic_paths.grid
net_envelope_cache, if not dynamic_grid:
grid_net_cache, return 0
broad_phase_cache,
)
if not candidates_by_net:
return DynamicCongestionDetail()
return self._check_real_congestion(
result,
candidates_by_net,
frozen_net_ids=frozen_net_ids,
)
def verify_path_details( gx_min, gy_min, gx_max, gy_max = grid_cell_span(total_bounds, self.grid_cell_size)
self,
net_id: str, if gx_min == gx_max and gy_min == gy_max:
components: Sequence[ComponentResult], cell = (gx_min, gy_min)
*, if cell in dynamic_grid:
capture_component_conflicts: bool = False, for obj_id in dynamic_grid[cell]:
) -> PathVerificationDetail: if dynamic_paths.geometries[obj_id][0] != net_id:
return self._check_real_congestion(result, net_id)
return 0
any_possible = False
for gx in range(gx_min, gx_max + 1):
for gy in range(gy_min, gy_max + 1):
cell = (gx, gy)
if cell in dynamic_grid:
for obj_id in dynamic_grid[cell]:
if dynamic_paths.geometries[obj_id][0] != net_id:
any_possible = True
break
if any_possible:
break
if any_possible:
break
if not any_possible:
return 0
return self._check_real_congestion(result, net_id)
def verify_path_report(self, net_id: str, components: Sequence[ComponentResult]) -> RoutingReport:
if self.metrics is not None: if self.metrics is not None:
self.metrics.total_verify_path_report_calls += 1 self.metrics.total_verify_path_report_calls += 1
static_collision_count = 0 static_collision_count = 0
dynamic_collision_count = 0 dynamic_collision_count = 0
self_collision_count = 0 self_collision_count = 0
total_length = sum(component.length for component in components) total_length = sum(component.length for component in components)
conflicting_net_ids: set[str] = set()
component_conflicts: set[tuple[int, str, int]] = set()
static_obstacles = self._static_obstacles static_obstacles = self._static_obstacles
dynamic_paths = self._dynamic_paths dynamic_paths = self._dynamic_paths
@ -700,78 +350,52 @@ class RoutingWorld:
if not self._is_in_safety_zone(polygon, obj_id, None, None): if not self._is_in_safety_zone(polygon, obj_id, None, None):
static_collision_count += 1 static_collision_count += 1
if dynamic_paths.dilated: self._ensure_dynamic_tree()
for component_index, component in enumerate(components): if dynamic_paths.tree is not None:
tree_geometries = dynamic_paths.tree.geometries
for component in components:
test_geometries = component.dilated_physical_geometry test_geometries = component.dilated_physical_geometry
res_indices, tree_indices = dynamic_paths.tree.query(test_geometries, predicate="intersects")
if tree_indices.size == 0:
continue
hit_net_ids = numpy.take(dynamic_paths.net_ids_array, tree_indices)
component_hits = [] component_hits = []
for new_geometry in test_geometries: for index in range(len(tree_indices)):
for hit_net_id in self._get_verify_net_envelope_candidates(new_geometry.bounds, str(net_id)): if hit_net_ids[index] == str(net_id):
for obj_id in self._get_verify_grid_span_obj_ids(new_geometry.bounds, hit_net_id): continue
if not _bounds_overlap(new_geometry.bounds, dynamic_paths.dilated_bounds[obj_id]):
continue if self.metrics is not None:
if self.metrics is not None: self.metrics.total_verify_dynamic_exact_pair_checks += 1
self.metrics.total_verify_dynamic_exact_pair_checks += 1 new_geometry = test_geometries[res_indices[index]]
tree_geometry = dynamic_paths.dilated[obj_id] tree_geometry = tree_geometries[tree_indices[index]]
if _has_non_touching_overlap(new_geometry, tree_geometry): if not new_geometry.touches(tree_geometry) and new_geometry.intersection(tree_geometry).area > 1e-7:
component_hits.append(hit_net_id) component_hits.append(hit_net_ids[index])
if capture_component_conflicts:
component_conflicts.add(
(
component_index,
hit_net_id,
dynamic_paths.component_indexes[obj_id],
)
)
break
if component_hits: if component_hits:
unique_hits = tuple(sorted(set(component_hits))) dynamic_collision_count += len(numpy.unique(component_hits))
dynamic_collision_count += len(unique_hits)
conflicting_net_ids.update(unique_hits)
for index, component in enumerate(components): for index, component in enumerate(components):
for other_index in range(index + 2, len(components)): for other_index in range(index + 2, len(components)):
if components_overlap(component, components[other_index], prefer_actual=True): if components_overlap(component, components[other_index], prefer_actual=True):
self_collision_count += 1 self_collision_count += 1
return PathVerificationDetail( return RoutingReport(
report=RoutingReport( static_collision_count=static_collision_count,
static_collision_count=static_collision_count, dynamic_collision_count=dynamic_collision_count,
dynamic_collision_count=dynamic_collision_count, self_collision_count=self_collision_count,
self_collision_count=self_collision_count, total_length=total_length,
total_length=total_length,
),
conflicting_net_ids=tuple(sorted(conflicting_net_ids)),
component_conflicts=tuple(sorted(component_conflicts)),
) )
def verify_path_report(self, net_id: str, components: Sequence[ComponentResult]) -> RoutingReport:
return self.verify_path_details(net_id, components).report
def ray_cast( def ray_cast(
self, self,
origin: Port, origin: Port,
angle_deg: float, angle_deg: float,
max_dist: float = 2000.0, max_dist: float = 2000.0,
net_width: float | None = None, net_width: float | None = None,
caller: str = "other",
) -> float: ) -> float:
if self.metrics is not None: if self.metrics is not None:
self.metrics.total_ray_cast_calls += 1 self.metrics.total_ray_cast_calls += 1
if caller == "straight_static":
self.metrics.total_ray_cast_calls_straight_static += 1
elif caller == "expand_snap":
self.metrics.total_ray_cast_calls_expand_snap += 1
elif caller == "expand_forward":
self.metrics.total_ray_cast_calls_expand_forward += 1
elif caller == "visibility_build":
self.metrics.total_ray_cast_calls_visibility_build += 1
elif caller == "visibility_query":
self.metrics.total_ray_cast_calls_visibility_query += 1
elif caller == "visibility_tangent":
self.metrics.total_ray_cast_calls_visibility_tangent += 1
else:
self.metrics.total_ray_cast_calls_other += 1
static_obstacles = self._static_obstacles static_obstacles = self._static_obstacles
tree: STRtree | None tree: STRtree | None
is_rect_array: numpy.ndarray | None is_rect_array: numpy.ndarray | None

View file

@ -21,20 +21,10 @@ class DynamicPathIndex:
"engine", "engine",
"index", "index",
"geometries", "geometries",
"component_indexes",
"dilated", "dilated",
"dilated_bounds",
"net_envelope_index",
"net_envelopes",
"net_envelope_obj_ids",
"net_envelope_obj_to_net",
"tree", "tree",
"obj_ids", "obj_ids",
"grid", "grid",
"grid_net_obj_ids",
"grid_net_counts",
"obj_cells",
"net_to_obj_ids",
"id_counter", "id_counter",
"net_ids_array", "net_ids_array",
"bounds_array", "bounds_array",
@ -44,71 +34,17 @@ class DynamicPathIndex:
self.engine = engine self.engine = engine
self.index = rtree.index.Index() self.index = rtree.index.Index()
self.geometries: dict[int, tuple[str, Polygon]] = {} self.geometries: dict[int, tuple[str, Polygon]] = {}
self.component_indexes: dict[int, int] = {}
self.dilated: dict[int, Polygon] = {} self.dilated: dict[int, Polygon] = {}
self.dilated_bounds: dict[int, tuple[float, float, float, float]] = {}
self.net_envelope_index = rtree.index.Index()
self.net_envelopes: dict[str, tuple[float, float, float, float]] = {}
self.net_envelope_obj_ids: dict[str, int] = {}
self.net_envelope_obj_to_net: dict[int, str] = {}
self.tree: STRtree | None = None self.tree: STRtree | None = None
self.obj_ids: numpy.ndarray = numpy.array([], dtype=numpy.int32) self.obj_ids: numpy.ndarray = numpy.array([], dtype=numpy.int32)
self.grid: dict[tuple[int, int], list[int]] = {} self.grid: dict[tuple[int, int], list[int]] = {}
self.grid_net_obj_ids: dict[tuple[int, int], dict[str, set[int]]] = {}
self.grid_net_counts: dict[tuple[int, int], dict[str, int]] = {}
self.obj_cells: dict[int, tuple[tuple[int, int], ...]] = {}
self.net_to_obj_ids: dict[str, set[int]] = {}
self.id_counter = 0 self.id_counter = 0
self.net_ids_array = numpy.array([], dtype=object) self.net_ids_array = numpy.array([], dtype=object)
self.bounds_array = numpy.array([], dtype=numpy.float64).reshape(0, 4) self.bounds_array = numpy.array([], dtype=numpy.float64).reshape(0, 4)
def _combine_net_bounds(self, obj_ids: set[int]) -> tuple[float, float, float, float]:
first_obj_id = next(iter(obj_ids))
minx, miny, maxx, maxy = self.dilated_bounds[first_obj_id]
for obj_id in obj_ids:
bounds = self.dilated_bounds[obj_id]
minx = min(minx, bounds[0])
miny = min(miny, bounds[1])
maxx = max(maxx, bounds[2])
maxy = max(maxy, bounds[3])
return (minx, miny, maxx, maxy)
def _set_net_envelope(self, net_id: str, bounds: tuple[float, float, float, float]) -> None:
old_bounds = self.net_envelopes.get(net_id)
if old_bounds is not None:
obj_id = self.net_envelope_obj_ids[net_id]
self.net_envelope_index.delete(obj_id, old_bounds)
else:
obj_id = len(self.net_envelope_obj_ids)
while obj_id in self.net_envelope_obj_to_net:
obj_id += 1
self.net_envelope_obj_ids[net_id] = obj_id
self.net_envelope_obj_to_net[obj_id] = net_id
self.net_envelopes[net_id] = bounds
self.net_envelope_index.insert(self.net_envelope_obj_ids[net_id], bounds)
def _clear_net_envelope(self, net_id: str) -> None:
old_bounds = self.net_envelopes.pop(net_id, None)
obj_id = self.net_envelope_obj_ids.pop(net_id, None)
if old_bounds is None or obj_id is None:
return
self.net_envelope_index.delete(obj_id, old_bounds)
self.net_envelope_obj_to_net.pop(obj_id, None)
def _refresh_net_envelope(self, net_id: str) -> None:
obj_ids = self.net_to_obj_ids.get(net_id)
if not obj_ids:
self._clear_net_envelope(net_id)
return
self._set_net_envelope(net_id, self._combine_net_bounds(obj_ids))
def invalidate_queries(self) -> None: def invalidate_queries(self) -> None:
self.tree = None self.tree = None
self.grid = {} self.grid = {}
self.grid_net_obj_ids = {}
self.grid_net_counts = {}
self.obj_cells = {}
def ensure_tree(self) -> None: def ensure_tree(self) -> None:
if self.tree is None and self.dilated: if self.tree is None and self.dilated:
@ -129,105 +65,33 @@ class DynamicPathIndex:
self.engine.metrics.total_dynamic_grid_rebuilds += 1 self.engine.metrics.total_dynamic_grid_rebuilds += 1
cell_size = self.engine.grid_cell_size cell_size = self.engine.grid_cell_size
for obj_id, polygon in self.dilated.items(): for obj_id, polygon in self.dilated.items():
self._register_grid_membership(obj_id, self.geometries[obj_id][0], polygon.bounds, cell_size=cell_size) for cell in iter_grid_cells(polygon.bounds, cell_size):
self.grid.setdefault(cell, []).append(obj_id)
def _register_grid_membership( def add_path(self, net_id: str, geometry: Sequence[Polygon], dilated_geometry: Sequence[Polygon]) -> None:
self, self.invalidate_queries()
obj_id: int,
net_id: str,
bounds: tuple[float, float, float, float],
*,
cell_size: float,
) -> None:
cells = tuple(iter_grid_cells(bounds, cell_size))
self.obj_cells[obj_id] = cells
for cell in cells:
self.grid.setdefault(cell, []).append(obj_id)
net_obj_ids = self.grid_net_obj_ids.setdefault(cell, {})
net_obj_ids.setdefault(net_id, set()).add(obj_id)
net_counts = self.grid_net_counts.setdefault(cell, {})
net_counts[net_id] = net_counts.get(net_id, 0) + 1
def _unregister_grid_membership(self, obj_id: int, net_id: str) -> None:
cells = self.obj_cells.pop(obj_id, ())
for cell in cells:
obj_ids = self.grid.get(cell)
if obj_ids is not None:
try:
obj_ids.remove(obj_id)
except ValueError:
pass
if not obj_ids:
self.grid.pop(cell, None)
net_obj_ids = self.grid_net_obj_ids.get(cell)
if net_obj_ids is not None:
member_ids = net_obj_ids.get(net_id)
if member_ids is not None:
member_ids.discard(obj_id)
if not member_ids:
net_obj_ids.pop(net_id, None)
if not net_obj_ids:
self.grid_net_obj_ids.pop(cell, None)
net_counts = self.grid_net_counts.get(cell)
if net_counts is not None:
remaining = net_counts.get(net_id, 0) - 1
if remaining > 0:
net_counts[net_id] = remaining
else:
net_counts.pop(net_id, None)
if not net_counts:
self.grid_net_counts.pop(cell, None)
def add_path(
self,
net_id: str,
geometry: Sequence[Polygon],
dilated_geometry: Sequence[Polygon],
component_indexes: Sequence[int] | None = None,
) -> None:
if self.engine.metrics is not None: if self.engine.metrics is not None:
self.engine.metrics.total_dynamic_path_objects_added += len(geometry) self.engine.metrics.total_dynamic_path_objects_added += len(geometry)
cell_size = self.engine.grid_cell_size
for index, polygon in enumerate(geometry): for index, polygon in enumerate(geometry):
obj_id = self.id_counter obj_id = self.id_counter
self.id_counter += 1 self.id_counter += 1
dilated = dilated_geometry[index] dilated = dilated_geometry[index]
dilated_bounds = dilated.bounds
self.geometries[obj_id] = (net_id, polygon) self.geometries[obj_id] = (net_id, polygon)
self.component_indexes[obj_id] = index if component_indexes is None else component_indexes[index]
self.dilated[obj_id] = dilated self.dilated[obj_id] = dilated
self.dilated_bounds[obj_id] = dilated_bounds self.index.insert(obj_id, dilated.bounds)
self.index.insert(obj_id, dilated_bounds)
self.net_to_obj_ids.setdefault(net_id, set()).add(obj_id)
self._register_grid_membership(obj_id, net_id, dilated_bounds, cell_size=cell_size)
self._refresh_net_envelope(net_id)
self.tree = None
def remove_path(self, net_id: str) -> None: def remove_path(self, net_id: str) -> None:
to_remove = list(self.net_to_obj_ids.get(net_id, ())) to_remove = [obj_id for obj_id, (existing_net_id, _) in self.geometries.items() if existing_net_id == net_id]
self.remove_obj_ids(to_remove) self.remove_obj_ids(to_remove)
def remove_obj_ids(self, obj_ids: list[int]) -> None: def remove_obj_ids(self, obj_ids: list[int]) -> None:
if not obj_ids: if not obj_ids:
return return
self.invalidate_queries()
if self.engine.metrics is not None: if self.engine.metrics is not None:
self.engine.metrics.total_dynamic_path_objects_removed += len(obj_ids) self.engine.metrics.total_dynamic_path_objects_removed += len(obj_ids)
affected_nets: set[str] = set()
for obj_id in obj_ids: for obj_id in obj_ids:
net_id, _ = self.geometries[obj_id] self.index.delete(obj_id, self.dilated[obj_id].bounds)
affected_nets.add(net_id)
self._unregister_grid_membership(obj_id, net_id)
self.index.delete(obj_id, self.dilated_bounds[obj_id])
del self.geometries[obj_id] del self.geometries[obj_id]
del self.component_indexes[obj_id]
del self.dilated[obj_id] del self.dilated[obj_id]
del self.dilated_bounds[obj_id]
obj_id_set = self.net_to_obj_ids.get(net_id)
if obj_id_set is not None:
obj_id_set.discard(obj_id)
if not obj_id_set:
self.net_to_obj_ids.pop(net_id, None)
for net_id in affected_nets:
self._refresh_net_envelope(net_id)
self.tree = None

View file

@ -105,8 +105,6 @@ class RefinementOptions:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class DiagnosticsOptions: class DiagnosticsOptions:
capture_expanded: bool = False capture_expanded: bool = False
capture_conflict_trace: bool = False
capture_frontier_trace: bool = False
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)

View file

@ -12,8 +12,6 @@ if TYPE_CHECKING:
RoutingOutcome = Literal["completed", "colliding", "partial", "unroutable"] RoutingOutcome = Literal["completed", "colliding", "partial", "unroutable"]
ConflictTraceStage = Literal["iteration", "restored_best", "final"]
FrontierTraceReason = Literal["closed_set", "hard_collision", "self_collision", "cost"]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -32,52 +30,6 @@ class RoutingReport:
return self.collision_count == 0 return self.collision_count == 0
@dataclass(frozen=True, slots=True)
class ComponentConflictTrace:
other_net_id: str
self_component_index: int
other_component_index: int
@dataclass(frozen=True, slots=True)
class NetConflictTrace:
net_id: str
outcome: RoutingOutcome
reached_target: bool
report: RoutingReport
conflicting_net_ids: tuple[str, ...] = ()
component_conflicts: tuple[ComponentConflictTrace, ...] = ()
@dataclass(frozen=True, slots=True)
class ConflictTraceEntry:
stage: ConflictTraceStage
iteration: int | None
completed_net_ids: tuple[str, ...]
conflict_edges: tuple[tuple[str, str], ...]
nets: tuple[NetConflictTrace, ...]
@dataclass(frozen=True, slots=True)
class FrontierPruneSample:
reason: FrontierTraceReason
move_type: str
hotspot_index: int
parent_state: tuple[int, int, int]
end_state: tuple[int, int, int]
@dataclass(frozen=True, slots=True)
class NetFrontierTrace:
net_id: str
hotspot_bounds: tuple[tuple[float, float, float, float], ...]
pruned_closed_set: int
pruned_hard_collision: int
pruned_self_collision: int
pruned_cost: int
samples: tuple[FrontierPruneSample, ...] = ()
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class RouteMetrics: class RouteMetrics:
nodes_expanded: int nodes_expanded: int
@ -93,48 +45,14 @@ class RouteMetrics:
warm_start_paths_used: int warm_start_paths_used: int
refine_path_calls: int refine_path_calls: int
timeout_events: int timeout_events: int
iteration_reverify_calls: int
iteration_reverified_nets: int
iteration_conflicting_nets: int
iteration_conflict_edges: int
nets_carried_forward: int
score_component_calls: int
score_component_total_ns: int
path_cost_calls: int
danger_map_lookup_calls: int
danger_map_cache_hits: int
danger_map_cache_misses: int
danger_map_query_calls: int
danger_map_total_ns: int
move_cache_abs_hits: int move_cache_abs_hits: int
move_cache_abs_misses: int move_cache_abs_misses: int
move_cache_rel_hits: int move_cache_rel_hits: int
move_cache_rel_misses: int move_cache_rel_misses: int
guidance_match_moves: int
guidance_match_moves_straight: int
guidance_match_moves_bend90: int
guidance_match_moves_sbend: int
guidance_bonus_applied: float
guidance_bonus_applied_straight: float
guidance_bonus_applied_bend90: float
guidance_bonus_applied_sbend: float
static_safe_cache_hits: int static_safe_cache_hits: int
hard_collision_cache_hits: int hard_collision_cache_hits: int
congestion_cache_hits: int congestion_cache_hits: int
congestion_cache_misses: int congestion_cache_misses: int
congestion_presence_cache_hits: int
congestion_presence_cache_misses: int
congestion_presence_skips: int
congestion_candidate_precheck_hits: int
congestion_candidate_precheck_misses: int
congestion_candidate_precheck_skips: int
congestion_grid_net_cache_hits: int
congestion_grid_net_cache_misses: int
congestion_grid_span_cache_hits: int
congestion_grid_span_cache_misses: int
congestion_candidate_nets: int
congestion_net_envelope_cache_hits: int
congestion_net_envelope_cache_misses: int
dynamic_path_objects_added: int dynamic_path_objects_added: int
dynamic_path_objects_removed: int dynamic_path_objects_removed: int
dynamic_tree_rebuilds: int dynamic_tree_rebuilds: int
@ -142,47 +60,21 @@ class RouteMetrics:
static_tree_rebuilds: int static_tree_rebuilds: int
static_raw_tree_rebuilds: int static_raw_tree_rebuilds: int
static_net_tree_rebuilds: int static_net_tree_rebuilds: int
visibility_corner_index_builds: int
visibility_builds: int visibility_builds: int
visibility_corner_pairs_checked: int visibility_corner_pairs_checked: int
visibility_corner_queries_exact: int visibility_corner_queries: int
visibility_corner_hits_exact: int visibility_corner_hits: int
visibility_point_queries: int visibility_point_queries: int
visibility_point_cache_hits: int visibility_point_cache_hits: int
visibility_point_cache_misses: int visibility_point_cache_misses: int
visibility_tangent_candidate_scans: int
visibility_tangent_candidate_corner_checks: int
visibility_tangent_candidate_ray_tests: int
ray_cast_calls: int ray_cast_calls: int
ray_cast_calls_straight_static: int
ray_cast_calls_expand_snap: int
ray_cast_calls_expand_forward: int
ray_cast_calls_visibility_build: int
ray_cast_calls_visibility_query: int
ray_cast_calls_visibility_tangent: int
ray_cast_calls_other: int
ray_cast_candidate_bounds: int ray_cast_candidate_bounds: int
ray_cast_exact_geometry_checks: int ray_cast_exact_geometry_checks: int
congestion_check_calls: int congestion_check_calls: int
congestion_lazy_resolutions: int
congestion_lazy_requeues: int
congestion_candidate_ids: int
congestion_exact_pair_checks: int congestion_exact_pair_checks: int
verify_path_report_calls: int verify_path_report_calls: int
verify_static_buffer_ops: int verify_static_buffer_ops: int
verify_dynamic_candidate_nets: int
verify_dynamic_exact_pair_checks: int verify_dynamic_exact_pair_checks: int
refinement_windows_considered: int
refinement_static_bounds_checked: int
refinement_dynamic_bounds_checked: int
refinement_candidate_side_extents: int
refinement_candidates_built: int
refinement_candidates_verified: int
refinement_candidates_accepted: int
pair_local_search_pairs_considered: int
pair_local_search_attempts: int
pair_local_search_accepts: int
pair_local_search_nodes_expanded: int
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -229,5 +121,3 @@ class RoutingRunResult:
results_by_net: dict[str, RoutingResult] results_by_net: dict[str, RoutingResult]
metrics: RouteMetrics metrics: RouteMetrics
expanded_nodes: tuple[tuple[int, int, int], ...] = () expanded_nodes: tuple[tuple[int, int, int], ...] = ()
conflict_trace: tuple[ConflictTraceEntry, ...] = ()
frontier_trace: tuple[NetFrontierTrace, ...] = ()

View file

@ -26,11 +26,6 @@ def process_move(
context: AStarContext, context: AStarContext,
metrics: AStarMetrics, metrics: AStarMetrics,
congestion_cache: dict[tuple, int], congestion_cache: dict[tuple, int],
congestion_presence_cache: dict[tuple[str, int, int, int, int], bool],
congestion_candidate_precheck_cache: dict[tuple[str, int, int, int, int], bool],
congestion_net_envelope_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]],
congestion_grid_net_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]],
congestion_grid_span_cache: dict[tuple[str, int, int, int, int], dict[str, tuple[int, ...]]],
config: SearchRunConfig, config: SearchRunConfig,
move_class: MoveKind, move_class: MoveKind,
params: tuple, params: tuple,
@ -114,11 +109,6 @@ def process_move(
context, context,
metrics, metrics,
congestion_cache, congestion_cache,
congestion_presence_cache,
congestion_candidate_precheck_cache,
congestion_net_envelope_cache,
congestion_grid_net_cache,
congestion_grid_span_cache,
config, config,
move_class, move_class,
abs_key, abs_key,
@ -136,23 +126,15 @@ def add_node(
context: AStarContext, context: AStarContext,
metrics: AStarMetrics, metrics: AStarMetrics,
congestion_cache: dict[tuple, int], congestion_cache: dict[tuple, int],
congestion_presence_cache: dict[tuple[str, int, int, int, int], bool],
congestion_candidate_precheck_cache: dict[tuple[str, int, int, int, int], bool],
congestion_net_envelope_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]],
congestion_grid_net_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]],
congestion_grid_span_cache: dict[tuple[str, int, int, int, int], dict[str, tuple[int, ...]]],
config: SearchRunConfig, config: SearchRunConfig,
move_type: MoveKind, move_type: MoveKind,
cache_key: tuple, cache_key: tuple,
) -> None: ) -> None:
frontier_trace = config.frontier_trace
metrics.moves_generated += 1 metrics.moves_generated += 1
metrics.total_moves_generated += 1 metrics.total_moves_generated += 1
state = result.end_port.as_tuple() state = result.end_port.as_tuple()
new_lower_bound_g = parent.g_cost + result.length new_lower_bound_g = parent.g_cost + result.length
if state in closed_set and closed_set[state] <= new_lower_bound_g + TOLERANCE_LINEAR: if state in closed_set and closed_set[state] <= new_lower_bound_g + TOLERANCE_LINEAR:
if frontier_trace is not None:
frontier_trace.record("closed_set", move_type, parent.port.as_tuple(), state, result.total_dilated_bounds)
metrics.pruned_closed_set += 1 metrics.pruned_closed_set += 1
metrics.total_pruned_closed_set += 1 metrics.total_pruned_closed_set += 1
return return
@ -161,8 +143,6 @@ def add_node(
end_p = result.end_port end_p = result.end_port
if cache_key in context.hard_collision_set: if cache_key in context.hard_collision_set:
if frontier_trace is not None:
frontier_trace.record("hard_collision", move_type, parent.port.as_tuple(), state, result.total_dilated_bounds)
context.metrics.total_hard_collision_cache_hits += 1 context.metrics.total_hard_collision_cache_hits += 1
metrics.pruned_hard_collision += 1 metrics.pruned_hard_collision += 1
metrics.total_pruned_hard_collision += 1 metrics.total_pruned_hard_collision += 1
@ -179,100 +159,41 @@ def add_node(
collision_found = ce.check_move_static(result, start_port=parent_p, end_port=end_p) collision_found = ce.check_move_static(result, start_port=parent_p, end_port=end_p)
if collision_found: if collision_found:
context.hard_collision_set.add(cache_key) context.hard_collision_set.add(cache_key)
if frontier_trace is not None:
frontier_trace.record("hard_collision", move_type, parent.port.as_tuple(), state, result.total_dilated_bounds)
metrics.pruned_hard_collision += 1 metrics.pruned_hard_collision += 1
metrics.total_pruned_hard_collision += 1 metrics.total_pruned_hard_collision += 1
return return
context.static_safe_cache.add(cache_key) context.static_safe_cache.add(cache_key)
total_overlaps = 0
if not config.skip_congestion:
if cache_key in congestion_cache:
context.metrics.total_congestion_cache_hits += 1
total_overlaps = congestion_cache[cache_key]
else:
context.metrics.total_congestion_cache_misses += 1
total_overlaps = context.cost_evaluator.collision_engine.check_move_congestion(result, net_id)
congestion_cache[cache_key] = total_overlaps
if config.self_collision_check and component_hits_ancestor_chain(result, parent): if config.self_collision_check and component_hits_ancestor_chain(result, parent):
if frontier_trace is not None:
frontier_trace.record("self_collision", move_type, parent.port.as_tuple(), state, result.total_dilated_bounds)
return return
move_cost = context.cost_evaluator.score_component( move_cost = context.cost_evaluator.score_component(
result, result,
start_port=parent_p, start_port=parent_p,
) )
next_seed_index = None move_cost += total_overlaps * context.congestion_penalty
if (
config.guidance_seed is not None
and parent.seed_index is not None
and parent.seed_index < len(config.guidance_seed)
and result.move_spec == config.guidance_seed[parent.seed_index]
):
context.metrics.total_guidance_match_moves += 1
if result.move_type == "straight":
context.metrics.total_guidance_match_moves_straight += 1
applied_bonus = config.guidance_bonus
context.metrics.total_guidance_bonus_applied_straight += applied_bonus
elif result.move_type == "bend90":
context.metrics.total_guidance_match_moves_bend90 += 1
applied_bonus = config.guidance_bonus
context.metrics.total_guidance_bonus_applied_bend90 += applied_bonus
else:
context.metrics.total_guidance_match_moves_sbend += 1
applied_bonus = config.guidance_bonus
context.metrics.total_guidance_bonus_applied_sbend += applied_bonus
context.metrics.total_guidance_bonus_applied += applied_bonus
move_cost = max(0.001, move_cost - applied_bonus)
next_seed_index = parent.seed_index + 1
if config.max_cost is not None and parent.g_cost + move_cost > config.max_cost: if config.max_cost is not None and parent.g_cost + move_cost > config.max_cost:
if frontier_trace is not None:
frontier_trace.record("cost", move_type, parent.port.as_tuple(), state, result.total_dilated_bounds)
metrics.pruned_cost += 1 metrics.pruned_cost += 1
metrics.total_pruned_cost += 1 metrics.total_pruned_cost += 1
return return
if move_cost > 1e12: if move_cost > 1e12:
if frontier_trace is not None:
frontier_trace.record("cost", move_type, parent.port.as_tuple(), state, result.total_dilated_bounds)
metrics.pruned_cost += 1 metrics.pruned_cost += 1
metrics.total_pruned_cost += 1 metrics.total_pruned_cost += 1
return return
if state in closed_set and closed_set[state] <= parent.g_cost + move_cost + TOLERANCE_LINEAR:
if frontier_trace is not None:
frontier_trace.record("closed_set", move_type, parent.port.as_tuple(), state, result.total_dilated_bounds)
metrics.pruned_closed_set += 1
metrics.total_pruned_closed_set += 1
return
total_overlaps = 0
if not config.skip_congestion and context.cost_evaluator.collision_engine.has_dynamic_paths():
ce = context.cost_evaluator.collision_engine
if ce.has_possible_move_congestion(result, net_id, congestion_presence_cache):
if ce.has_candidate_move_congestion(
result,
net_id,
congestion_candidate_precheck_cache,
congestion_net_envelope_cache,
congestion_grid_net_cache,
):
if cache_key in congestion_cache:
context.metrics.total_congestion_cache_hits += 1
total_overlaps = congestion_cache[cache_key]
else:
context.metrics.total_congestion_cache_misses += 1
total_overlaps = ce.check_move_congestion(
result,
net_id,
net_envelope_cache=congestion_net_envelope_cache,
grid_net_cache=congestion_grid_net_cache,
broad_phase_cache=congestion_grid_span_cache,
)
congestion_cache[cache_key] = total_overlaps
else:
context.metrics.total_congestion_candidate_precheck_skips += 1
else:
context.metrics.total_congestion_presence_skips += 1
move_cost += total_overlaps * context.congestion_penalty
g_cost = parent.g_cost + move_cost g_cost = parent.g_cost + move_cost
if state in closed_set and closed_set[state] <= g_cost + TOLERANCE_LINEAR: if state in closed_set and closed_set[state] <= g_cost + TOLERANCE_LINEAR:
if frontier_trace is not None:
frontier_trace.record("closed_set", move_type, parent.port.as_tuple(), state, result.total_dilated_bounds)
metrics.pruned_closed_set += 1 metrics.pruned_closed_set += 1
metrics.total_pruned_closed_set += 1 metrics.total_pruned_closed_set += 1
return return
@ -282,16 +203,6 @@ def add_node(
target, target,
min_bend_radius=context.min_bend_radius, min_bend_radius=context.min_bend_radius,
) )
heapq.heappush( heapq.heappush(open_set, AStarNode(result.end_port, g_cost, h_cost, parent, result))
open_set,
AStarNode(
result.end_port,
g_cost,
h_cost,
parent,
result,
seed_index=next_seed_index,
),
)
metrics.moves_added += 1 metrics.moves_added += 1
metrics.total_moves_added += 1 metrics.total_moves_added += 1

View file

@ -18,27 +18,6 @@ def _quantized_lengths(values: list[float], max_reach: float) -> list[int]:
return sorted((v for v in out if v > 0), reverse=True) return sorted((v for v in out if v > 0), reverse=True)
def _distance_to_bounds_in_heading(
current: Port,
bounds: tuple[float, float, float, float],
) -> float:
min_x, min_y, max_x, max_y = bounds
if current.r == 0:
return max(0.0, max_x - current.x)
if current.r == 90:
return max(0.0, max_y - current.y)
if current.r == 180:
return max(0.0, current.x - min_x)
return max(0.0, current.y - min_y)
def _should_cap_straights_to_bounds(context: AStarContext) -> bool:
return (
not context.options.congestion.warm_start_enabled
and len(context.problem.nets) >= 8
)
def _sbend_forward_span(offset: float, radius: float) -> float | None: def _sbend_forward_span(offset: float, radius: float) -> float | None:
abs_offset = abs(offset) abs_offset = abs(offset)
if abs_offset <= TOLERANCE_LINEAR or radius <= 0 or abs_offset >= 2.0 * radius: if abs_offset <= TOLERANCE_LINEAR or radius <= 0 or abs_offset >= 2.0 * radius:
@ -84,25 +63,19 @@ def _visible_straight_candidates(
return [] return []
visibility_manager = context.visibility_manager visibility_manager = context.visibility_manager
visibility_manager.ensure_corner_index_current() visibility_manager._ensure_current()
context.metrics.total_visibility_tangent_candidate_scans += 1
max_bend_radius = max(search_options.bend_radii, default=0.0) max_bend_radius = max(search_options.bend_radii, default=0.0)
if max_bend_radius <= 0 or not visibility_manager.corners: if max_bend_radius <= 0 or not visibility_manager.corners:
return [] return []
reach = max_reach + max_bend_radius reach = max_reach + max_bend_radius
candidate_ids = visibility_manager.get_tangent_corner_candidates( bounds = (current.x - reach, current.y - reach, current.x + reach, current.y + reach)
current, candidate_ids = list(visibility_manager.corner_index.intersection(bounds))
min_forward=search_options.min_straight_length,
max_forward=reach,
radii=search_options.bend_radii,
)
if not candidate_ids: if not candidate_ids:
return [] return []
scored: list[tuple[float, float, float, float, float]] = [] scored: list[tuple[float, float, float, float, float]] = []
for idx in candidate_ids: for idx in candidate_ids:
context.metrics.total_visibility_tangent_candidate_corner_checks += 1
cx, cy = visibility_manager.corners[idx] cx, cy = visibility_manager.corners[idx]
dx = cx - current.x dx = cx - current.x
dy = cy - current.y dy = cy - current.y
@ -128,15 +101,8 @@ def _visible_straight_candidates(
collision_engine = context.cost_evaluator.collision_engine collision_engine = context.cost_evaluator.collision_engine
tangent_candidates: set[int] = set() tangent_candidates: set[int] = set()
for _, dist, length, dx, dy in sorted(scored)[:4]: for _, dist, length, dx, dy in sorted(scored)[:4]:
context.metrics.total_visibility_tangent_candidate_ray_tests += 1
angle = math.degrees(math.atan2(dy, dx)) angle = math.degrees(math.atan2(dy, dx))
corner_reach = collision_engine.ray_cast( corner_reach = collision_engine.ray_cast(current, angle, max_dist=dist + 0.05, net_width=net_width)
current,
angle,
max_dist=dist + 0.05,
net_width=net_width,
caller="visibility_tangent",
)
if corner_reach < dist - 0.01: if corner_reach < dist - 0.01:
continue continue
qlen = int(round(length)) qlen = int(round(length))
@ -166,11 +132,6 @@ def expand_moves(
context: AStarContext, context: AStarContext,
metrics: AStarMetrics, metrics: AStarMetrics,
congestion_cache: dict[tuple, int], congestion_cache: dict[tuple, int],
congestion_presence_cache: dict[tuple[str, int, int, int, int], bool],
congestion_candidate_precheck_cache: dict[tuple[str, int, int, int, int], bool],
congestion_net_envelope_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]],
congestion_grid_net_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]],
congestion_grid_span_cache: dict[tuple[str, int, int, int, int], dict[str, tuple[int, ...]]],
config: SearchRunConfig, config: SearchRunConfig,
) -> None: ) -> None:
search_options = context.options.search search_options = context.options.search
@ -195,13 +156,7 @@ def expand_moves(
dy_local = perp_t dy_local = perp_t
if proj_t > 0 and abs(perp_t) < 1e-6 and cp.r == target.r: if proj_t > 0 and abs(perp_t) < 1e-6 and cp.r == target.r:
max_reach = context.cost_evaluator.collision_engine.ray_cast( max_reach = context.cost_evaluator.collision_engine.ray_cast(cp, cp.r, proj_t + 1.0, net_width=net_width)
cp,
cp.r,
proj_t + 1.0,
net_width=net_width,
caller="expand_snap",
)
if max_reach >= proj_t - 0.01 and ( if max_reach >= proj_t - 0.01 and (
prev_straight_length is None or proj_t < prev_straight_length - TOLERANCE_LINEAR prev_straight_length is None or proj_t < prev_straight_length - TOLERANCE_LINEAR
): ):
@ -215,25 +170,12 @@ def expand_moves(
context, context,
metrics, metrics,
congestion_cache, congestion_cache,
congestion_presence_cache,
congestion_candidate_precheck_cache,
congestion_net_envelope_cache,
congestion_grid_net_cache,
congestion_grid_span_cache,
config, config,
"straight", "straight",
(int(round(proj_t)),), (int(round(proj_t)),),
) )
max_reach = context.cost_evaluator.collision_engine.ray_cast( max_reach = context.cost_evaluator.collision_engine.ray_cast(cp, cp.r, search_options.max_straight_length, net_width=net_width)
cp,
cp.r,
search_options.max_straight_length,
net_width=net_width,
caller="expand_forward",
)
if _should_cap_straights_to_bounds(context):
max_reach = min(max_reach, _distance_to_bounds_in_heading(cp, context.problem.bounds))
candidate_lengths = [ candidate_lengths = [
search_options.min_straight_length, search_options.min_straight_length,
max_reach, max_reach,
@ -279,11 +221,6 @@ def expand_moves(
context, context,
metrics, metrics,
congestion_cache, congestion_cache,
congestion_presence_cache,
congestion_candidate_precheck_cache,
congestion_net_envelope_cache,
congestion_grid_net_cache,
congestion_grid_span_cache,
config, config,
"straight", "straight",
(length,), (length,),
@ -312,11 +249,6 @@ def expand_moves(
context, context,
metrics, metrics,
congestion_cache, congestion_cache,
congestion_presence_cache,
congestion_candidate_precheck_cache,
congestion_net_envelope_cache,
congestion_grid_net_cache,
congestion_grid_span_cache,
config, config,
"bend90", "bend90",
(radius, direction), (radius, direction),
@ -345,17 +277,12 @@ def expand_moves(
current, current,
target, target,
net_width, net_width,
net_id, net_id,
open_set, open_set,
closed_set, closed_set,
context, context,
metrics, metrics,
congestion_cache, congestion_cache,
congestion_presence_cache,
congestion_candidate_precheck_cache,
congestion_net_envelope_cache,
congestion_grid_net_cache,
congestion_grid_span_cache,
config, config,
"sbend", "sbend",
(offset, radius), (offset, radius),

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from inire.model import resolve_bend_geometry from inire.model import resolve_bend_geometry
@ -12,51 +12,6 @@ if TYPE_CHECKING:
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
from inire.model import RoutingOptions, RoutingProblem from inire.model import RoutingOptions, RoutingProblem
from inire.router.cost import CostEvaluator from inire.router.cost import CostEvaluator
from inire.seeds import PathSegmentSeed
@dataclass(slots=True)
class FrontierTraceCollector:
hotspot_bounds: tuple[tuple[float, float, float, float], ...]
sample_limit: int = 64
pruned_closed_set: int = 0
pruned_hard_collision: int = 0
pruned_self_collision: int = 0
pruned_cost: int = 0
samples: list[tuple[str, str, int, tuple[int, int, int], tuple[int, int, int]]] = field(default_factory=list)
def _matching_hotspot_index(self, bounds: tuple[float, float, float, float]) -> int | None:
for idx, hotspot_bounds in enumerate(self.hotspot_bounds):
if (
bounds[0] < hotspot_bounds[2]
and bounds[2] > hotspot_bounds[0]
and bounds[1] < hotspot_bounds[3]
and bounds[3] > hotspot_bounds[1]
):
return idx
return None
def record(
self,
reason: str,
move_type: str,
parent_state: tuple[int, int, int],
end_state: tuple[int, int, int],
bounds: tuple[float, float, float, float],
) -> None:
hotspot_index = self._matching_hotspot_index(bounds)
if hotspot_index is None:
return
if reason == "closed_set":
self.pruned_closed_set += 1
elif reason == "hard_collision":
self.pruned_hard_collision += 1
elif reason == "self_collision":
self.pruned_self_collision += 1
else:
self.pruned_cost += 1
if len(self.samples) < self.sample_limit:
self.samples.append((reason, move_type, hotspot_index, parent_state, end_state))
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -65,9 +20,6 @@ class SearchRunConfig:
bend_physical_geometry: BendPhysicalGeometry bend_physical_geometry: BendPhysicalGeometry
bend_clip_margin: float | None bend_clip_margin: float | None
node_limit: int node_limit: int
guidance_seed: tuple[PathSegmentSeed, ...] | None = None
guidance_bonus: float = 0.0
frontier_trace: FrontierTraceCollector | None = None
return_partial: bool = False return_partial: bool = False
store_expanded: bool = False store_expanded: bool = False
skip_congestion: bool = False skip_congestion: bool = False
@ -81,9 +33,6 @@ class SearchRunConfig:
*, *,
bend_collision_type: BendCollisionModel | None = None, bend_collision_type: BendCollisionModel | None = None,
node_limit: int | None = None, node_limit: int | None = None,
guidance_seed: tuple[PathSegmentSeed, ...] | None = None,
guidance_bonus: float = 0.0,
frontier_trace: FrontierTraceCollector | None = None,
return_partial: bool = False, return_partial: bool = False,
store_expanded: bool = False, store_expanded: bool = False,
skip_congestion: bool = False, skip_congestion: bool = False,
@ -100,9 +49,6 @@ class SearchRunConfig:
bend_physical_geometry=bend_physical_geometry, bend_physical_geometry=bend_physical_geometry,
bend_clip_margin=search.bend_clip_margin, bend_clip_margin=search.bend_clip_margin,
node_limit=search.node_limit if node_limit is None else node_limit, node_limit=search.node_limit if node_limit is None else node_limit,
guidance_seed=guidance_seed,
guidance_bonus=float(guidance_bonus),
frontier_trace=frontier_trace,
return_partial=return_partial, return_partial=return_partial,
store_expanded=store_expanded, store_expanded=store_expanded,
skip_congestion=skip_congestion, skip_congestion=skip_congestion,
@ -112,18 +58,7 @@ class SearchRunConfig:
class AStarNode: class AStarNode:
__slots__ = ( __slots__ = ("port", "g_cost", "h_cost", "fh_cost", "parent", "component_result")
"port",
"g_cost",
"h_cost",
"fh_cost",
"parent",
"component_result",
"base_move_cost",
"cache_key",
"seed_index",
"congestion_resolved",
)
def __init__( def __init__(
self, self,
@ -132,11 +67,6 @@ class AStarNode:
h_cost: float, h_cost: float,
parent: AStarNode | None = None, parent: AStarNode | None = None,
component_result: ComponentResult | None = None, component_result: ComponentResult | None = None,
*,
base_move_cost: float = 0.0,
cache_key: tuple | None = None,
seed_index: int | None = None,
congestion_resolved: bool = True,
) -> None: ) -> None:
self.port = port self.port = port
self.g_cost = g_cost self.g_cost = g_cost
@ -144,10 +74,6 @@ class AStarNode:
self.fh_cost = (g_cost + h_cost, h_cost) self.fh_cost = (g_cost + h_cost, h_cost)
self.parent = parent self.parent = parent
self.component_result = component_result self.component_result = component_result
self.base_move_cost = base_move_cost
self.cache_key = cache_key
self.seed_index = seed_index
self.congestion_resolved = congestion_resolved
def __lt__(self, other: AStarNode) -> bool: def __lt__(self, other: AStarNode) -> bool:
return self.fh_cost < other.fh_cost return self.fh_cost < other.fh_cost
@ -168,48 +94,14 @@ class AStarMetrics:
"total_warm_start_paths_used", "total_warm_start_paths_used",
"total_refine_path_calls", "total_refine_path_calls",
"total_timeout_events", "total_timeout_events",
"total_iteration_reverify_calls",
"total_iteration_reverified_nets",
"total_iteration_conflicting_nets",
"total_iteration_conflict_edges",
"total_nets_carried_forward",
"total_score_component_calls",
"total_score_component_total_ns",
"total_path_cost_calls",
"total_danger_map_lookup_calls",
"total_danger_map_cache_hits",
"total_danger_map_cache_misses",
"total_danger_map_query_calls",
"total_danger_map_total_ns",
"total_move_cache_abs_hits", "total_move_cache_abs_hits",
"total_move_cache_abs_misses", "total_move_cache_abs_misses",
"total_move_cache_rel_hits", "total_move_cache_rel_hits",
"total_move_cache_rel_misses", "total_move_cache_rel_misses",
"total_guidance_match_moves",
"total_guidance_match_moves_straight",
"total_guidance_match_moves_bend90",
"total_guidance_match_moves_sbend",
"total_guidance_bonus_applied",
"total_guidance_bonus_applied_straight",
"total_guidance_bonus_applied_bend90",
"total_guidance_bonus_applied_sbend",
"total_static_safe_cache_hits", "total_static_safe_cache_hits",
"total_hard_collision_cache_hits", "total_hard_collision_cache_hits",
"total_congestion_cache_hits", "total_congestion_cache_hits",
"total_congestion_cache_misses", "total_congestion_cache_misses",
"total_congestion_presence_cache_hits",
"total_congestion_presence_cache_misses",
"total_congestion_presence_skips",
"total_congestion_candidate_precheck_hits",
"total_congestion_candidate_precheck_misses",
"total_congestion_candidate_precheck_skips",
"total_congestion_grid_net_cache_hits",
"total_congestion_grid_net_cache_misses",
"total_congestion_grid_span_cache_hits",
"total_congestion_grid_span_cache_misses",
"total_congestion_candidate_nets",
"total_congestion_net_envelope_cache_hits",
"total_congestion_net_envelope_cache_misses",
"total_dynamic_path_objects_added", "total_dynamic_path_objects_added",
"total_dynamic_path_objects_removed", "total_dynamic_path_objects_removed",
"total_dynamic_tree_rebuilds", "total_dynamic_tree_rebuilds",
@ -217,47 +109,21 @@ class AStarMetrics:
"total_static_tree_rebuilds", "total_static_tree_rebuilds",
"total_static_raw_tree_rebuilds", "total_static_raw_tree_rebuilds",
"total_static_net_tree_rebuilds", "total_static_net_tree_rebuilds",
"total_visibility_corner_index_builds",
"total_visibility_builds", "total_visibility_builds",
"total_visibility_corner_pairs_checked", "total_visibility_corner_pairs_checked",
"total_visibility_corner_queries_exact", "total_visibility_corner_queries",
"total_visibility_corner_hits_exact", "total_visibility_corner_hits",
"total_visibility_point_queries", "total_visibility_point_queries",
"total_visibility_point_cache_hits", "total_visibility_point_cache_hits",
"total_visibility_point_cache_misses", "total_visibility_point_cache_misses",
"total_visibility_tangent_candidate_scans",
"total_visibility_tangent_candidate_corner_checks",
"total_visibility_tangent_candidate_ray_tests",
"total_ray_cast_calls", "total_ray_cast_calls",
"total_ray_cast_calls_straight_static",
"total_ray_cast_calls_expand_snap",
"total_ray_cast_calls_expand_forward",
"total_ray_cast_calls_visibility_build",
"total_ray_cast_calls_visibility_query",
"total_ray_cast_calls_visibility_tangent",
"total_ray_cast_calls_other",
"total_ray_cast_candidate_bounds", "total_ray_cast_candidate_bounds",
"total_ray_cast_exact_geometry_checks", "total_ray_cast_exact_geometry_checks",
"total_congestion_check_calls", "total_congestion_check_calls",
"total_congestion_lazy_resolutions",
"total_congestion_lazy_requeues",
"total_congestion_candidate_ids",
"total_congestion_exact_pair_checks", "total_congestion_exact_pair_checks",
"total_verify_path_report_calls", "total_verify_path_report_calls",
"total_verify_static_buffer_ops", "total_verify_static_buffer_ops",
"total_verify_dynamic_candidate_nets",
"total_verify_dynamic_exact_pair_checks", "total_verify_dynamic_exact_pair_checks",
"total_refinement_windows_considered",
"total_refinement_static_bounds_checked",
"total_refinement_dynamic_bounds_checked",
"total_refinement_candidate_side_extents",
"total_refinement_candidates_built",
"total_refinement_candidates_verified",
"total_refinement_candidates_accepted",
"total_pair_local_search_pairs_considered",
"total_pair_local_search_attempts",
"total_pair_local_search_accepts",
"total_pair_local_search_nodes_expanded",
"last_expanded_nodes", "last_expanded_nodes",
"nodes_expanded", "nodes_expanded",
"moves_generated", "moves_generated",
@ -281,48 +147,14 @@ class AStarMetrics:
self.total_warm_start_paths_used = 0 self.total_warm_start_paths_used = 0
self.total_refine_path_calls = 0 self.total_refine_path_calls = 0
self.total_timeout_events = 0 self.total_timeout_events = 0
self.total_iteration_reverify_calls = 0
self.total_iteration_reverified_nets = 0
self.total_iteration_conflicting_nets = 0
self.total_iteration_conflict_edges = 0
self.total_nets_carried_forward = 0
self.total_score_component_calls = 0
self.total_score_component_total_ns = 0
self.total_path_cost_calls = 0
self.total_danger_map_lookup_calls = 0
self.total_danger_map_cache_hits = 0
self.total_danger_map_cache_misses = 0
self.total_danger_map_query_calls = 0
self.total_danger_map_total_ns = 0
self.total_move_cache_abs_hits = 0 self.total_move_cache_abs_hits = 0
self.total_move_cache_abs_misses = 0 self.total_move_cache_abs_misses = 0
self.total_move_cache_rel_hits = 0 self.total_move_cache_rel_hits = 0
self.total_move_cache_rel_misses = 0 self.total_move_cache_rel_misses = 0
self.total_guidance_match_moves = 0
self.total_guidance_match_moves_straight = 0
self.total_guidance_match_moves_bend90 = 0
self.total_guidance_match_moves_sbend = 0
self.total_guidance_bonus_applied = 0.0
self.total_guidance_bonus_applied_straight = 0.0
self.total_guidance_bonus_applied_bend90 = 0.0
self.total_guidance_bonus_applied_sbend = 0.0
self.total_static_safe_cache_hits = 0 self.total_static_safe_cache_hits = 0
self.total_hard_collision_cache_hits = 0 self.total_hard_collision_cache_hits = 0
self.total_congestion_cache_hits = 0 self.total_congestion_cache_hits = 0
self.total_congestion_cache_misses = 0 self.total_congestion_cache_misses = 0
self.total_congestion_presence_cache_hits = 0
self.total_congestion_presence_cache_misses = 0
self.total_congestion_presence_skips = 0
self.total_congestion_candidate_precheck_hits = 0
self.total_congestion_candidate_precheck_misses = 0
self.total_congestion_candidate_precheck_skips = 0
self.total_congestion_grid_net_cache_hits = 0
self.total_congestion_grid_net_cache_misses = 0
self.total_congestion_grid_span_cache_hits = 0
self.total_congestion_grid_span_cache_misses = 0
self.total_congestion_candidate_nets = 0
self.total_congestion_net_envelope_cache_hits = 0
self.total_congestion_net_envelope_cache_misses = 0
self.total_dynamic_path_objects_added = 0 self.total_dynamic_path_objects_added = 0
self.total_dynamic_path_objects_removed = 0 self.total_dynamic_path_objects_removed = 0
self.total_dynamic_tree_rebuilds = 0 self.total_dynamic_tree_rebuilds = 0
@ -330,47 +162,21 @@ class AStarMetrics:
self.total_static_tree_rebuilds = 0 self.total_static_tree_rebuilds = 0
self.total_static_raw_tree_rebuilds = 0 self.total_static_raw_tree_rebuilds = 0
self.total_static_net_tree_rebuilds = 0 self.total_static_net_tree_rebuilds = 0
self.total_visibility_corner_index_builds = 0
self.total_visibility_builds = 0 self.total_visibility_builds = 0
self.total_visibility_corner_pairs_checked = 0 self.total_visibility_corner_pairs_checked = 0
self.total_visibility_corner_queries_exact = 0 self.total_visibility_corner_queries = 0
self.total_visibility_corner_hits_exact = 0 self.total_visibility_corner_hits = 0
self.total_visibility_point_queries = 0 self.total_visibility_point_queries = 0
self.total_visibility_point_cache_hits = 0 self.total_visibility_point_cache_hits = 0
self.total_visibility_point_cache_misses = 0 self.total_visibility_point_cache_misses = 0
self.total_visibility_tangent_candidate_scans = 0
self.total_visibility_tangent_candidate_corner_checks = 0
self.total_visibility_tangent_candidate_ray_tests = 0
self.total_ray_cast_calls = 0 self.total_ray_cast_calls = 0
self.total_ray_cast_calls_straight_static = 0
self.total_ray_cast_calls_expand_snap = 0
self.total_ray_cast_calls_expand_forward = 0
self.total_ray_cast_calls_visibility_build = 0
self.total_ray_cast_calls_visibility_query = 0
self.total_ray_cast_calls_visibility_tangent = 0
self.total_ray_cast_calls_other = 0
self.total_ray_cast_candidate_bounds = 0 self.total_ray_cast_candidate_bounds = 0
self.total_ray_cast_exact_geometry_checks = 0 self.total_ray_cast_exact_geometry_checks = 0
self.total_congestion_check_calls = 0 self.total_congestion_check_calls = 0
self.total_congestion_lazy_resolutions = 0
self.total_congestion_lazy_requeues = 0
self.total_congestion_candidate_ids = 0
self.total_congestion_exact_pair_checks = 0 self.total_congestion_exact_pair_checks = 0
self.total_verify_path_report_calls = 0 self.total_verify_path_report_calls = 0
self.total_verify_static_buffer_ops = 0 self.total_verify_static_buffer_ops = 0
self.total_verify_dynamic_candidate_nets = 0
self.total_verify_dynamic_exact_pair_checks = 0 self.total_verify_dynamic_exact_pair_checks = 0
self.total_refinement_windows_considered = 0
self.total_refinement_static_bounds_checked = 0
self.total_refinement_dynamic_bounds_checked = 0
self.total_refinement_candidate_side_extents = 0
self.total_refinement_candidates_built = 0
self.total_refinement_candidates_verified = 0
self.total_refinement_candidates_accepted = 0
self.total_pair_local_search_pairs_considered = 0
self.total_pair_local_search_attempts = 0
self.total_pair_local_search_accepts = 0
self.total_pair_local_search_nodes_expanded = 0
self.last_expanded_nodes: list[tuple[int, int, int]] = [] self.last_expanded_nodes: list[tuple[int, int, int]] = []
self.nodes_expanded = 0 self.nodes_expanded = 0
self.moves_generated = 0 self.moves_generated = 0
@ -393,48 +199,14 @@ class AStarMetrics:
self.total_warm_start_paths_used = 0 self.total_warm_start_paths_used = 0
self.total_refine_path_calls = 0 self.total_refine_path_calls = 0
self.total_timeout_events = 0 self.total_timeout_events = 0
self.total_iteration_reverify_calls = 0
self.total_iteration_reverified_nets = 0
self.total_iteration_conflicting_nets = 0
self.total_iteration_conflict_edges = 0
self.total_nets_carried_forward = 0
self.total_score_component_calls = 0
self.total_score_component_total_ns = 0
self.total_path_cost_calls = 0
self.total_danger_map_lookup_calls = 0
self.total_danger_map_cache_hits = 0
self.total_danger_map_cache_misses = 0
self.total_danger_map_query_calls = 0
self.total_danger_map_total_ns = 0
self.total_move_cache_abs_hits = 0 self.total_move_cache_abs_hits = 0
self.total_move_cache_abs_misses = 0 self.total_move_cache_abs_misses = 0
self.total_move_cache_rel_hits = 0 self.total_move_cache_rel_hits = 0
self.total_move_cache_rel_misses = 0 self.total_move_cache_rel_misses = 0
self.total_guidance_match_moves = 0
self.total_guidance_match_moves_straight = 0
self.total_guidance_match_moves_bend90 = 0
self.total_guidance_match_moves_sbend = 0
self.total_guidance_bonus_applied = 0.0
self.total_guidance_bonus_applied_straight = 0.0
self.total_guidance_bonus_applied_bend90 = 0.0
self.total_guidance_bonus_applied_sbend = 0.0
self.total_static_safe_cache_hits = 0 self.total_static_safe_cache_hits = 0
self.total_hard_collision_cache_hits = 0 self.total_hard_collision_cache_hits = 0
self.total_congestion_cache_hits = 0 self.total_congestion_cache_hits = 0
self.total_congestion_cache_misses = 0 self.total_congestion_cache_misses = 0
self.total_congestion_presence_cache_hits = 0
self.total_congestion_presence_cache_misses = 0
self.total_congestion_presence_skips = 0
self.total_congestion_candidate_precheck_hits = 0
self.total_congestion_candidate_precheck_misses = 0
self.total_congestion_candidate_precheck_skips = 0
self.total_congestion_grid_net_cache_hits = 0
self.total_congestion_grid_net_cache_misses = 0
self.total_congestion_grid_span_cache_hits = 0
self.total_congestion_grid_span_cache_misses = 0
self.total_congestion_candidate_nets = 0
self.total_congestion_net_envelope_cache_hits = 0
self.total_congestion_net_envelope_cache_misses = 0
self.total_dynamic_path_objects_added = 0 self.total_dynamic_path_objects_added = 0
self.total_dynamic_path_objects_removed = 0 self.total_dynamic_path_objects_removed = 0
self.total_dynamic_tree_rebuilds = 0 self.total_dynamic_tree_rebuilds = 0
@ -442,47 +214,21 @@ class AStarMetrics:
self.total_static_tree_rebuilds = 0 self.total_static_tree_rebuilds = 0
self.total_static_raw_tree_rebuilds = 0 self.total_static_raw_tree_rebuilds = 0
self.total_static_net_tree_rebuilds = 0 self.total_static_net_tree_rebuilds = 0
self.total_visibility_corner_index_builds = 0
self.total_visibility_builds = 0 self.total_visibility_builds = 0
self.total_visibility_corner_pairs_checked = 0 self.total_visibility_corner_pairs_checked = 0
self.total_visibility_corner_queries_exact = 0 self.total_visibility_corner_queries = 0
self.total_visibility_corner_hits_exact = 0 self.total_visibility_corner_hits = 0
self.total_visibility_point_queries = 0 self.total_visibility_point_queries = 0
self.total_visibility_point_cache_hits = 0 self.total_visibility_point_cache_hits = 0
self.total_visibility_point_cache_misses = 0 self.total_visibility_point_cache_misses = 0
self.total_visibility_tangent_candidate_scans = 0
self.total_visibility_tangent_candidate_corner_checks = 0
self.total_visibility_tangent_candidate_ray_tests = 0
self.total_ray_cast_calls = 0 self.total_ray_cast_calls = 0
self.total_ray_cast_calls_straight_static = 0
self.total_ray_cast_calls_expand_snap = 0
self.total_ray_cast_calls_expand_forward = 0
self.total_ray_cast_calls_visibility_build = 0
self.total_ray_cast_calls_visibility_query = 0
self.total_ray_cast_calls_visibility_tangent = 0
self.total_ray_cast_calls_other = 0
self.total_ray_cast_candidate_bounds = 0 self.total_ray_cast_candidate_bounds = 0
self.total_ray_cast_exact_geometry_checks = 0 self.total_ray_cast_exact_geometry_checks = 0
self.total_congestion_check_calls = 0 self.total_congestion_check_calls = 0
self.total_congestion_lazy_resolutions = 0
self.total_congestion_lazy_requeues = 0
self.total_congestion_candidate_ids = 0
self.total_congestion_exact_pair_checks = 0 self.total_congestion_exact_pair_checks = 0
self.total_verify_path_report_calls = 0 self.total_verify_path_report_calls = 0
self.total_verify_static_buffer_ops = 0 self.total_verify_static_buffer_ops = 0
self.total_verify_dynamic_candidate_nets = 0
self.total_verify_dynamic_exact_pair_checks = 0 self.total_verify_dynamic_exact_pair_checks = 0
self.total_refinement_windows_considered = 0
self.total_refinement_static_bounds_checked = 0
self.total_refinement_dynamic_bounds_checked = 0
self.total_refinement_candidate_side_extents = 0
self.total_refinement_candidates_built = 0
self.total_refinement_candidates_verified = 0
self.total_refinement_candidates_accepted = 0
self.total_pair_local_search_pairs_considered = 0
self.total_pair_local_search_attempts = 0
self.total_pair_local_search_accepts = 0
self.total_pair_local_search_nodes_expanded = 0
def reset_per_route(self) -> None: def reset_per_route(self) -> None:
self.nodes_expanded = 0 self.nodes_expanded = 0
@ -508,48 +254,14 @@ class AStarMetrics:
warm_start_paths_used=self.total_warm_start_paths_used, warm_start_paths_used=self.total_warm_start_paths_used,
refine_path_calls=self.total_refine_path_calls, refine_path_calls=self.total_refine_path_calls,
timeout_events=self.total_timeout_events, timeout_events=self.total_timeout_events,
iteration_reverify_calls=self.total_iteration_reverify_calls,
iteration_reverified_nets=self.total_iteration_reverified_nets,
iteration_conflicting_nets=self.total_iteration_conflicting_nets,
iteration_conflict_edges=self.total_iteration_conflict_edges,
nets_carried_forward=self.total_nets_carried_forward,
score_component_calls=self.total_score_component_calls,
score_component_total_ns=self.total_score_component_total_ns,
path_cost_calls=self.total_path_cost_calls,
danger_map_lookup_calls=self.total_danger_map_lookup_calls,
danger_map_cache_hits=self.total_danger_map_cache_hits,
danger_map_cache_misses=self.total_danger_map_cache_misses,
danger_map_query_calls=self.total_danger_map_query_calls,
danger_map_total_ns=self.total_danger_map_total_ns,
move_cache_abs_hits=self.total_move_cache_abs_hits, move_cache_abs_hits=self.total_move_cache_abs_hits,
move_cache_abs_misses=self.total_move_cache_abs_misses, move_cache_abs_misses=self.total_move_cache_abs_misses,
move_cache_rel_hits=self.total_move_cache_rel_hits, move_cache_rel_hits=self.total_move_cache_rel_hits,
move_cache_rel_misses=self.total_move_cache_rel_misses, move_cache_rel_misses=self.total_move_cache_rel_misses,
guidance_match_moves=self.total_guidance_match_moves,
guidance_match_moves_straight=self.total_guidance_match_moves_straight,
guidance_match_moves_bend90=self.total_guidance_match_moves_bend90,
guidance_match_moves_sbend=self.total_guidance_match_moves_sbend,
guidance_bonus_applied=self.total_guidance_bonus_applied,
guidance_bonus_applied_straight=self.total_guidance_bonus_applied_straight,
guidance_bonus_applied_bend90=self.total_guidance_bonus_applied_bend90,
guidance_bonus_applied_sbend=self.total_guidance_bonus_applied_sbend,
static_safe_cache_hits=self.total_static_safe_cache_hits, static_safe_cache_hits=self.total_static_safe_cache_hits,
hard_collision_cache_hits=self.total_hard_collision_cache_hits, hard_collision_cache_hits=self.total_hard_collision_cache_hits,
congestion_cache_hits=self.total_congestion_cache_hits, congestion_cache_hits=self.total_congestion_cache_hits,
congestion_cache_misses=self.total_congestion_cache_misses, congestion_cache_misses=self.total_congestion_cache_misses,
congestion_presence_cache_hits=self.total_congestion_presence_cache_hits,
congestion_presence_cache_misses=self.total_congestion_presence_cache_misses,
congestion_presence_skips=self.total_congestion_presence_skips,
congestion_candidate_precheck_hits=self.total_congestion_candidate_precheck_hits,
congestion_candidate_precheck_misses=self.total_congestion_candidate_precheck_misses,
congestion_candidate_precheck_skips=self.total_congestion_candidate_precheck_skips,
congestion_grid_net_cache_hits=self.total_congestion_grid_net_cache_hits,
congestion_grid_net_cache_misses=self.total_congestion_grid_net_cache_misses,
congestion_grid_span_cache_hits=self.total_congestion_grid_span_cache_hits,
congestion_grid_span_cache_misses=self.total_congestion_grid_span_cache_misses,
congestion_candidate_nets=self.total_congestion_candidate_nets,
congestion_net_envelope_cache_hits=self.total_congestion_net_envelope_cache_hits,
congestion_net_envelope_cache_misses=self.total_congestion_net_envelope_cache_misses,
dynamic_path_objects_added=self.total_dynamic_path_objects_added, dynamic_path_objects_added=self.total_dynamic_path_objects_added,
dynamic_path_objects_removed=self.total_dynamic_path_objects_removed, dynamic_path_objects_removed=self.total_dynamic_path_objects_removed,
dynamic_tree_rebuilds=self.total_dynamic_tree_rebuilds, dynamic_tree_rebuilds=self.total_dynamic_tree_rebuilds,
@ -557,47 +269,21 @@ class AStarMetrics:
static_tree_rebuilds=self.total_static_tree_rebuilds, static_tree_rebuilds=self.total_static_tree_rebuilds,
static_raw_tree_rebuilds=self.total_static_raw_tree_rebuilds, static_raw_tree_rebuilds=self.total_static_raw_tree_rebuilds,
static_net_tree_rebuilds=self.total_static_net_tree_rebuilds, static_net_tree_rebuilds=self.total_static_net_tree_rebuilds,
visibility_corner_index_builds=self.total_visibility_corner_index_builds,
visibility_builds=self.total_visibility_builds, visibility_builds=self.total_visibility_builds,
visibility_corner_pairs_checked=self.total_visibility_corner_pairs_checked, visibility_corner_pairs_checked=self.total_visibility_corner_pairs_checked,
visibility_corner_queries_exact=self.total_visibility_corner_queries_exact, visibility_corner_queries=self.total_visibility_corner_queries,
visibility_corner_hits_exact=self.total_visibility_corner_hits_exact, visibility_corner_hits=self.total_visibility_corner_hits,
visibility_point_queries=self.total_visibility_point_queries, visibility_point_queries=self.total_visibility_point_queries,
visibility_point_cache_hits=self.total_visibility_point_cache_hits, visibility_point_cache_hits=self.total_visibility_point_cache_hits,
visibility_point_cache_misses=self.total_visibility_point_cache_misses, visibility_point_cache_misses=self.total_visibility_point_cache_misses,
visibility_tangent_candidate_scans=self.total_visibility_tangent_candidate_scans,
visibility_tangent_candidate_corner_checks=self.total_visibility_tangent_candidate_corner_checks,
visibility_tangent_candidate_ray_tests=self.total_visibility_tangent_candidate_ray_tests,
ray_cast_calls=self.total_ray_cast_calls, ray_cast_calls=self.total_ray_cast_calls,
ray_cast_calls_straight_static=self.total_ray_cast_calls_straight_static,
ray_cast_calls_expand_snap=self.total_ray_cast_calls_expand_snap,
ray_cast_calls_expand_forward=self.total_ray_cast_calls_expand_forward,
ray_cast_calls_visibility_build=self.total_ray_cast_calls_visibility_build,
ray_cast_calls_visibility_query=self.total_ray_cast_calls_visibility_query,
ray_cast_calls_visibility_tangent=self.total_ray_cast_calls_visibility_tangent,
ray_cast_calls_other=self.total_ray_cast_calls_other,
ray_cast_candidate_bounds=self.total_ray_cast_candidate_bounds, ray_cast_candidate_bounds=self.total_ray_cast_candidate_bounds,
ray_cast_exact_geometry_checks=self.total_ray_cast_exact_geometry_checks, ray_cast_exact_geometry_checks=self.total_ray_cast_exact_geometry_checks,
congestion_check_calls=self.total_congestion_check_calls, congestion_check_calls=self.total_congestion_check_calls,
congestion_lazy_resolutions=self.total_congestion_lazy_resolutions,
congestion_lazy_requeues=self.total_congestion_lazy_requeues,
congestion_candidate_ids=self.total_congestion_candidate_ids,
congestion_exact_pair_checks=self.total_congestion_exact_pair_checks, congestion_exact_pair_checks=self.total_congestion_exact_pair_checks,
verify_path_report_calls=self.total_verify_path_report_calls, verify_path_report_calls=self.total_verify_path_report_calls,
verify_static_buffer_ops=self.total_verify_static_buffer_ops, verify_static_buffer_ops=self.total_verify_static_buffer_ops,
verify_dynamic_candidate_nets=self.total_verify_dynamic_candidate_nets,
verify_dynamic_exact_pair_checks=self.total_verify_dynamic_exact_pair_checks, verify_dynamic_exact_pair_checks=self.total_verify_dynamic_exact_pair_checks,
refinement_windows_considered=self.total_refinement_windows_considered,
refinement_static_bounds_checked=self.total_refinement_static_bounds_checked,
refinement_dynamic_bounds_checked=self.total_refinement_dynamic_bounds_checked,
refinement_candidate_side_extents=self.total_refinement_candidate_side_extents,
refinement_candidates_built=self.total_refinement_candidates_built,
refinement_candidates_verified=self.total_refinement_candidates_verified,
refinement_candidates_accepted=self.total_refinement_candidates_accepted,
pair_local_search_pairs_considered=self.total_pair_local_search_pairs_considered,
pair_local_search_attempts=self.total_pair_local_search_attempts,
pair_local_search_accepts=self.total_pair_local_search_accepts,
pair_local_search_nodes_expanded=self.total_pair_local_search_nodes_expanded,
) )

View file

@ -5,23 +5,11 @@ import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from inire.geometry.collision import RoutingWorld from inire.model import NetOrder, NetSpec, resolve_bend_geometry
from inire.model import NetOrder, NetSpec, RoutingProblem, resolve_bend_geometry from inire.results import RoutingOutcome, RoutingReport, RoutingResult
from inire.results import ( from inire.router._astar_types import AStarContext, AStarMetrics, SearchRunConfig
ComponentConflictTrace,
ConflictTraceEntry,
FrontierPruneSample,
NetConflictTrace,
NetFrontierTrace,
RoutingOutcome,
RoutingReport,
RoutingResult,
)
from inire.router._astar_types import AStarContext, AStarMetrics, FrontierTraceCollector, SearchRunConfig
from inire.router._search import route_astar from inire.router._search import route_astar
from inire.router._seed_materialization import materialize_path_seed from inire.router._seed_materialization import materialize_path_seed
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
from inire.router.refiner import PathRefiner from inire.router.refiner import PathRefiner
if TYPE_CHECKING: if TYPE_CHECKING:
@ -29,7 +17,6 @@ if TYPE_CHECKING:
from shapely.geometry import Polygon from shapely.geometry import Polygon
from inire.geometry.collision import PathVerificationDetail
from inire.geometry.components import ComponentResult from inire.geometry.components import ComponentResult
@ -43,27 +30,6 @@ class _RoutingState:
timeout_s: float timeout_s: float
initial_paths: dict[str, tuple[ComponentResult, ...]] | None initial_paths: dict[str, tuple[ComponentResult, ...]] | None
accumulated_expanded_nodes: list[tuple[int, int, int]] accumulated_expanded_nodes: list[tuple[int, int, int]]
best_results: dict[str, RoutingResult]
best_completed_nets: int
best_conflict_edges: int
best_dynamic_collisions: int
last_conflict_signature: tuple[tuple[str, str], ...]
last_conflict_edge_count: int
repeated_conflict_count: int
@dataclass(slots=True)
class _IterationReview:
conflicting_nets: set[str]
conflict_edges: set[tuple[str, str]]
completed_net_ids: set[str]
total_dynamic_collisions: int
@dataclass(frozen=True, slots=True)
class _PairLocalTarget:
net_ids: tuple[str, str]
class PathFinder: class PathFinder:
__slots__ = ( __slots__ = (
@ -71,8 +37,6 @@ class PathFinder:
"metrics", "metrics",
"refiner", "refiner",
"accumulated_expanded_nodes", "accumulated_expanded_nodes",
"conflict_trace",
"frontier_trace",
) )
def __init__( def __init__(
@ -84,27 +48,16 @@ class PathFinder:
self.metrics = self.context.metrics if metrics is None else metrics self.metrics = self.context.metrics if metrics is None else metrics
self.context.metrics = self.metrics self.context.metrics = self.metrics
self.context.cost_evaluator.collision_engine.metrics = self.metrics self.context.cost_evaluator.collision_engine.metrics = self.metrics
if self.context.cost_evaluator.danger_map is not None:
self.context.cost_evaluator.danger_map.metrics = self.metrics
self.refiner = PathRefiner(self.context) self.refiner = PathRefiner(self.context)
self.accumulated_expanded_nodes: list[tuple[int, int, int]] = [] self.accumulated_expanded_nodes: list[tuple[int, int, int]] = []
self.conflict_trace: list[ConflictTraceEntry] = []
self.frontier_trace: list[NetFrontierTrace] = []
def _install_path(self, net_id: str, path: Sequence[ComponentResult]) -> None: def _install_path(self, net_id: str, path: Sequence[ComponentResult]) -> None:
all_geoms: list[Polygon] = [] all_geoms: list[Polygon] = []
all_dilated: list[Polygon] = [] all_dilated: list[Polygon] = []
component_indexes: list[int] = [] for result in path:
for component_index, result in enumerate(path):
all_geoms.extend(result.collision_geometry) all_geoms.extend(result.collision_geometry)
all_dilated.extend(result.dilated_collision_geometry) all_dilated.extend(result.dilated_collision_geometry)
component_indexes.extend([component_index] * len(result.collision_geometry)) self.context.cost_evaluator.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated)
self.context.cost_evaluator.collision_engine.add_path(
net_id,
all_geoms,
dilated_geometry=all_dilated,
component_indexes=component_indexes,
)
def _routing_order( def _routing_order(
self, self,
@ -181,13 +134,6 @@ class PathFinder:
timeout_s=max(60.0, 10.0 * num_nets * congestion.max_iterations), timeout_s=max(60.0, 10.0 * num_nets * congestion.max_iterations),
initial_paths=initial_paths, initial_paths=initial_paths,
accumulated_expanded_nodes=[], accumulated_expanded_nodes=[],
best_results={},
best_completed_nets=-1,
best_conflict_edges=10**9,
best_dynamic_collisions=10**9,
last_conflict_signature=(),
last_conflict_edge_count=0,
repeated_conflict_count=0,
) )
if state.initial_paths is None and congestion.warm_start_enabled: if state.initial_paths is None and congestion.warm_start_enabled:
state.initial_paths = self._build_greedy_warm_start_paths(net_specs, congestion.net_order) state.initial_paths = self._build_greedy_warm_start_paths(net_specs, congestion.net_order)
@ -214,536 +160,9 @@ class PathFinder:
net_width=net.width, net_width=net.width,
search=search, search=search,
clearance=self.context.cost_evaluator.collision_engine.clearance, clearance=self.context.cost_evaluator.collision_engine.clearance,
) )
return initial_paths return initial_paths
def _replace_installed_paths(self, state: _RoutingState, results: dict[str, RoutingResult]) -> None:
for net_id in state.ordered_net_ids:
self.context.cost_evaluator.collision_engine.remove_path(net_id)
for net_id in state.ordered_net_ids:
result = results.get(net_id)
if result and result.path:
self._install_path(net_id, result.path)
def _update_best_iteration(self, state: _RoutingState, review: _IterationReview) -> bool:
completed_nets = len(review.completed_net_ids)
conflict_edges = len(review.conflict_edges)
dynamic_collisions = review.total_dynamic_collisions
is_better = (
completed_nets > state.best_completed_nets
or (
completed_nets == state.best_completed_nets
and (
conflict_edges < state.best_conflict_edges
or (
conflict_edges == state.best_conflict_edges
and dynamic_collisions < state.best_dynamic_collisions
)
)
)
)
if not is_better:
return False
state.best_results = dict(state.results)
state.best_completed_nets = completed_nets
state.best_conflict_edges = conflict_edges
state.best_dynamic_collisions = dynamic_collisions
return True
def _restore_best_iteration(self, state: _RoutingState) -> None:
if not state.best_results:
return
state.results = dict(state.best_results)
self._replace_installed_paths(state, state.results)
def _capture_conflict_trace_entry(
self,
state: _RoutingState,
*,
stage: str,
iteration: int | None,
results: dict[str, RoutingResult],
details_by_net: dict[str, PathVerificationDetail],
review: _IterationReview,
) -> None:
if not self.context.options.diagnostics.capture_conflict_trace:
return
nets = []
for net_id in state.ordered_net_ids:
result = results.get(net_id)
if result is None:
result = RoutingResult(net_id=net_id, path=(), reached_target=False)
detail = details_by_net.get(net_id)
component_conflicts = ()
conflicting_net_ids = ()
if detail is not None:
conflicting_net_ids = detail.conflicting_net_ids
component_conflicts = tuple(
ComponentConflictTrace(
other_net_id=other_net_id,
self_component_index=self_component_index,
other_component_index=other_component_index,
)
for self_component_index, other_net_id, other_component_index in detail.component_conflicts
)
nets.append(
NetConflictTrace(
net_id=net_id,
outcome=result.outcome,
reached_target=result.reached_target,
report=result.report,
conflicting_net_ids=tuple(conflicting_net_ids),
component_conflicts=component_conflicts,
)
)
self.conflict_trace.append(
ConflictTraceEntry(
stage=stage, # type: ignore[arg-type]
iteration=iteration,
completed_net_ids=tuple(sorted(review.completed_net_ids)),
conflict_edges=tuple(sorted(review.conflict_edges)),
nets=tuple(nets),
)
)
def _build_frontier_hotspot_bounds(
self,
state: _RoutingState,
net_id: str,
details_by_net: dict[str, PathVerificationDetail],
) -> tuple[tuple[float, float, float, float], ...]:
result = state.results.get(net_id)
detail = details_by_net.get(net_id)
if result is None or detail is None or not result.path:
return ()
hotspot_bounds: list[tuple[float, float, float, float]] = []
seen: set[tuple[float, float, float, float]] = set()
margin = max(5.0, self.context.cost_evaluator.collision_engine.clearance * 2.0)
for self_component_index, other_net_id, other_component_index in detail.component_conflicts:
other_result = state.results.get(other_net_id)
if other_result is None or not other_result.path:
continue
if self_component_index >= len(result.path) or other_component_index >= len(other_result.path):
continue
left_component = result.path[self_component_index]
right_component = other_result.path[other_component_index]
overlap_found = False
for left_poly in left_component.dilated_physical_geometry:
for right_poly in right_component.dilated_physical_geometry:
if not left_poly.intersects(right_poly) or left_poly.touches(right_poly):
continue
overlap = left_poly.intersection(right_poly)
if overlap.is_empty:
continue
buffered = overlap.buffer(margin, join_style="mitre").bounds
if buffered not in seen:
seen.add(buffered)
hotspot_bounds.append(buffered)
overlap_found = True
if overlap_found:
continue
left_bounds = left_component.total_dilated_bounds
right_bounds = right_component.total_dilated_bounds
if (
left_bounds[0] < right_bounds[2]
and left_bounds[2] > right_bounds[0]
and left_bounds[1] < right_bounds[3]
and left_bounds[3] > right_bounds[1]
):
buffered = (
max(left_bounds[0], right_bounds[0]) - margin,
max(left_bounds[1], right_bounds[1]) - margin,
min(left_bounds[2], right_bounds[2]) + margin,
min(left_bounds[3], right_bounds[3]) + margin,
)
if buffered not in seen:
seen.add(buffered)
hotspot_bounds.append(buffered)
return tuple(hotspot_bounds)
def _analyze_results(
self,
ordered_net_ids: Sequence[str],
results: dict[str, RoutingResult],
*,
capture_component_conflicts: bool,
count_iteration_metrics: bool,
) -> tuple[dict[str, RoutingResult], dict[str, PathVerificationDetail], _IterationReview]:
if count_iteration_metrics:
self.metrics.total_iteration_reverify_calls += 1
conflict_edges: set[tuple[str, str]] = set()
conflicting_nets: set[str] = set()
completed_net_ids: set[str] = set()
total_dynamic_collisions = 0
analyzed_results = dict(results)
details_by_net: dict[str, PathVerificationDetail] = {}
for net_id in ordered_net_ids:
result = results.get(net_id)
if not result or not result.path or not result.reached_target:
continue
if count_iteration_metrics:
self.metrics.total_iteration_reverified_nets += 1
detail = self.context.cost_evaluator.collision_engine.verify_path_details(
net_id,
result.path,
capture_component_conflicts=capture_component_conflicts,
)
details_by_net[net_id] = detail
analyzed_results[net_id] = RoutingResult(
net_id=net_id,
path=result.path,
reached_target=result.reached_target,
report=detail.report,
)
total_dynamic_collisions += detail.report.dynamic_collision_count
if analyzed_results[net_id].outcome == "completed":
completed_net_ids.add(net_id)
if not detail.conflicting_net_ids:
continue
conflicting_nets.add(net_id)
for other_net_id in detail.conflicting_net_ids:
conflicting_nets.add(other_net_id)
if other_net_id == net_id:
continue
conflict_edges.add(tuple(sorted((net_id, other_net_id))))
if count_iteration_metrics:
self.metrics.total_iteration_conflicting_nets += len(conflicting_nets)
self.metrics.total_iteration_conflict_edges += len(conflict_edges)
return (
analyzed_results,
details_by_net,
_IterationReview(
conflicting_nets=conflicting_nets,
conflict_edges=conflict_edges,
completed_net_ids=completed_net_ids,
total_dynamic_collisions=total_dynamic_collisions,
),
)
def _capture_frontier_trace(
self,
state: _RoutingState,
final_results: dict[str, RoutingResult],
) -> None:
if not self.context.options.diagnostics.capture_frontier_trace:
return
self.frontier_trace = []
state.results = dict(final_results)
state.results, details_by_net, _ = self._analyze_results(
state.ordered_net_ids,
state.results,
capture_component_conflicts=True,
count_iteration_metrics=False,
)
original_metrics = self.metrics
original_context_metrics = self.context.metrics
original_engine_metrics = self.context.cost_evaluator.collision_engine.metrics
original_danger_metrics = None
if self.context.cost_evaluator.danger_map is not None:
original_danger_metrics = self.context.cost_evaluator.danger_map.metrics
try:
for net_id in state.ordered_net_ids:
result = state.results.get(net_id)
detail = details_by_net.get(net_id)
if result is None or detail is None or not result.reached_target:
continue
if detail.report.dynamic_collision_count == 0 or not detail.component_conflicts:
continue
hotspot_bounds = self._build_frontier_hotspot_bounds(state, net_id, details_by_net)
if not hotspot_bounds:
continue
scratch_metrics = AStarMetrics()
self.context.metrics = scratch_metrics
self.context.cost_evaluator.collision_engine.metrics = scratch_metrics
if self.context.cost_evaluator.danger_map is not None:
self.context.cost_evaluator.danger_map.metrics = scratch_metrics
guidance_seed = result.as_seed().segments if result.path else None
guidance_bonus = 0.0
if guidance_seed:
guidance_bonus = max(10.0, self.context.options.objective.bend_penalty * 0.25)
collector = FrontierTraceCollector(hotspot_bounds=hotspot_bounds)
run_config = SearchRunConfig.from_options(
self.context.options,
return_partial=True,
store_expanded=False,
guidance_seed=guidance_seed,
guidance_bonus=guidance_bonus,
frontier_trace=collector,
self_collision_check=(net_id in state.needs_self_collision_check),
node_limit=self.context.options.search.node_limit,
)
self.context.cost_evaluator.collision_engine.remove_path(net_id)
try:
route_astar(
state.net_specs[net_id].start,
state.net_specs[net_id].target,
state.net_specs[net_id].width,
context=self.context,
metrics=scratch_metrics,
net_id=net_id,
config=run_config,
)
finally:
if result.path:
self._install_path(net_id, result.path)
self.frontier_trace.append(
NetFrontierTrace(
net_id=net_id,
hotspot_bounds=hotspot_bounds,
pruned_closed_set=collector.pruned_closed_set,
pruned_hard_collision=collector.pruned_hard_collision,
pruned_self_collision=collector.pruned_self_collision,
pruned_cost=collector.pruned_cost,
samples=tuple(
FrontierPruneSample(
reason=reason, # type: ignore[arg-type]
move_type=move_type,
hotspot_index=hotspot_index,
parent_state=parent_state,
end_state=end_state,
)
for reason, move_type, hotspot_index, parent_state, end_state in collector.samples
),
)
)
finally:
self.metrics = original_metrics
self.context.metrics = original_context_metrics
self.context.cost_evaluator.collision_engine.metrics = original_engine_metrics
if self.context.cost_evaluator.danger_map is not None:
self.context.cost_evaluator.danger_map.metrics = original_danger_metrics
def _whole_set_is_better(
self,
candidate_results: dict[str, RoutingResult],
candidate_review: _IterationReview,
incumbent_results: dict[str, RoutingResult],
incumbent_review: _IterationReview,
) -> bool:
candidate_completed = len(candidate_review.completed_net_ids)
incumbent_completed = len(incumbent_review.completed_net_ids)
if candidate_completed != incumbent_completed:
return candidate_completed > incumbent_completed
candidate_edges = len(candidate_review.conflict_edges)
incumbent_edges = len(incumbent_review.conflict_edges)
if candidate_edges != incumbent_edges:
return candidate_edges < incumbent_edges
if candidate_review.total_dynamic_collisions != incumbent_review.total_dynamic_collisions:
return candidate_review.total_dynamic_collisions < incumbent_review.total_dynamic_collisions
candidate_length = sum(
result.report.total_length
for result in candidate_results.values()
if result.reached_target
)
incumbent_length = sum(
result.report.total_length
for result in incumbent_results.values()
if result.reached_target
)
if abs(candidate_length - incumbent_length) > 1e-6:
return candidate_length < incumbent_length
return False
def _collect_pair_local_targets(
self,
state: _RoutingState,
results: dict[str, RoutingResult],
review: _IterationReview,
) -> list[_PairLocalTarget]:
if not review.conflict_edges:
return []
order_index = {net_id: idx for idx, net_id in enumerate(state.ordered_net_ids)}
seen_net_ids: set[str] = set()
targets: list[_PairLocalTarget] = []
for left_net_id, right_net_id in sorted(review.conflict_edges):
if left_net_id in seen_net_ids or right_net_id in seen_net_ids:
return []
left_result = results.get(left_net_id)
right_result = results.get(right_net_id)
if (
left_result is None
or right_result is None
or not left_result.reached_target
or not right_result.reached_target
):
continue
seen_net_ids.update((left_net_id, right_net_id))
targets.append(_PairLocalTarget(net_ids=(left_net_id, right_net_id)))
targets.sort(key=lambda target: min(order_index[target.net_ids[0]], order_index[target.net_ids[1]]))
return targets
def _build_pair_local_context(
self,
state: _RoutingState,
incumbent_results: dict[str, RoutingResult],
pair_net_ids: tuple[str, str],
) -> AStarContext:
problem = self.context.problem
objective = self.context.options.objective
static_obstacles = tuple(self.context.cost_evaluator.collision_engine._static_obstacles.geometries.values())
engine = RoutingWorld(
clearance=self.context.cost_evaluator.collision_engine.clearance,
safety_zone_radius=self.context.cost_evaluator.collision_engine.safety_zone_radius,
)
for obstacle in static_obstacles:
engine.add_static_obstacle(obstacle)
for net_id in state.ordered_net_ids:
if net_id in pair_net_ids:
continue
result = incumbent_results.get(net_id)
if result is None or not result.path:
continue
for component in result.path:
for polygon in component.physical_geometry:
engine.add_static_obstacle(polygon)
danger_map = DangerMap(bounds=problem.bounds)
danger_map.precompute(list(static_obstacles))
evaluator = CostEvaluator(
engine,
danger_map,
unit_length_cost=objective.unit_length_cost,
greedy_h_weight=self.context.cost_evaluator.greedy_h_weight,
bend_penalty=objective.bend_penalty,
sbend_penalty=objective.sbend_penalty,
danger_weight=objective.danger_weight,
)
return AStarContext(
evaluator,
RoutingProblem(
bounds=problem.bounds,
nets=tuple(state.net_specs[net_id] for net_id in state.ordered_net_ids),
static_obstacles=static_obstacles,
clearance=problem.clearance,
safety_zone_radius=problem.safety_zone_radius,
),
self.context.options,
metrics=AStarMetrics(),
)
def _run_pair_local_attempt(
self,
state: _RoutingState,
incumbent_results: dict[str, RoutingResult],
pair_order: tuple[str, str],
) -> tuple[dict[str, RoutingResult], int] | None:
local_context = self._build_pair_local_context(state, incumbent_results, pair_order)
local_results = dict(incumbent_results)
for net_id in pair_order:
net = state.net_specs[net_id]
guidance_result = incumbent_results.get(net_id)
guidance_seed = None
guidance_bonus = 0.0
if guidance_result and guidance_result.reached_target and guidance_result.path:
guidance_seed = guidance_result.as_seed().segments
guidance_bonus = max(10.0, self.context.options.objective.bend_penalty * 0.25)
run_config = SearchRunConfig.from_options(
self.context.options,
return_partial=False,
skip_congestion=True,
self_collision_check=(net_id in state.needs_self_collision_check),
guidance_seed=guidance_seed,
guidance_bonus=guidance_bonus,
node_limit=self.context.options.search.node_limit,
)
path = route_astar(
net.start,
net.target,
net.width,
context=local_context,
metrics=local_context.metrics,
net_id=net_id,
config=run_config,
)
if not path or path[-1].end_port != net.target:
return None
report = local_context.cost_evaluator.collision_engine.verify_path_report(net_id, path)
if not report.is_valid:
return None
local_results[net_id] = RoutingResult(
net_id=net_id,
path=tuple(path),
reached_target=True,
report=report,
)
for component in path:
for polygon in component.physical_geometry:
local_context.cost_evaluator.collision_engine.add_static_obstacle(polygon)
local_context.clear_static_caches()
return local_results, local_context.metrics.total_nodes_expanded
def _run_pair_local_search(self, state: _RoutingState) -> None:
state.results, _details_by_net, review = self._analyze_results(
state.ordered_net_ids,
state.results,
capture_component_conflicts=True,
count_iteration_metrics=False,
)
targets = self._collect_pair_local_targets(state, state.results, review)
if not targets:
return
for target in targets[:2]:
self.metrics.total_pair_local_search_pairs_considered += 1
incumbent_results = dict(state.results)
incumbent_review = review
accepted = False
for pair_order in (target.net_ids, target.net_ids[::-1]):
self.metrics.total_pair_local_search_attempts += 1
candidate = self._run_pair_local_attempt(state, incumbent_results, pair_order)
if candidate is None:
continue
candidate_results, nodes_expanded = candidate
self.metrics.total_pair_local_search_nodes_expanded += nodes_expanded
self._replace_installed_paths(state, candidate_results)
candidate_results, _candidate_details_by_net, candidate_review = self._analyze_results(
state.ordered_net_ids,
candidate_results,
capture_component_conflicts=True,
count_iteration_metrics=False,
)
if self._whole_set_is_better(
candidate_results,
candidate_review,
incumbent_results,
incumbent_review,
):
self.metrics.total_pair_local_search_accepts += 1
state.results = candidate_results
review = candidate_review
accepted = True
break
self._replace_installed_paths(state, incumbent_results)
if not accepted:
state.results = incumbent_results
self._replace_installed_paths(state, incumbent_results)
def _route_net_once( def _route_net_once(
self, self,
state: _RoutingState, state: _RoutingState,
@ -763,25 +182,16 @@ class PathFinder:
else: else:
coll_model, _ = resolve_bend_geometry(search) coll_model, _ = resolve_bend_geometry(search)
skip_congestion = False skip_congestion = False
guidance_seed = None
guidance_bonus = 0.0
if congestion.use_tiered_strategy and iteration == 0: if congestion.use_tiered_strategy and iteration == 0:
skip_congestion = True skip_congestion = True
if coll_model == "arc": if coll_model == "arc":
coll_model = "clipped_bbox" coll_model = "clipped_bbox"
elif iteration > 0:
guidance_result = state.results.get(net_id)
if guidance_result and guidance_result.reached_target and guidance_result.path:
guidance_seed = guidance_result.as_seed().segments
guidance_bonus = max(10.0, self.context.options.objective.bend_penalty * 0.25)
run_config = SearchRunConfig.from_options( run_config = SearchRunConfig.from_options(
self.context.options, self.context.options,
bend_collision_type=coll_model, bend_collision_type=coll_model,
return_partial=True, return_partial=True,
store_expanded=diagnostics.capture_expanded, store_expanded=diagnostics.capture_expanded,
guidance_seed=guidance_seed,
guidance_bonus=guidance_bonus,
skip_congestion=skip_congestion, skip_congestion=skip_congestion,
self_collision_check=(net_id in state.needs_self_collision_check), self_collision_check=(net_id in state.needs_self_collision_check),
node_limit=search.node_limit, node_limit=search.node_limit,
@ -823,49 +233,29 @@ class PathFinder:
self, self,
state: _RoutingState, state: _RoutingState,
iteration: int, iteration: int,
reroute_net_ids: set[str],
iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None, iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None,
) -> _IterationReview | None: ) -> dict[str, RoutingOutcome] | None:
outcomes: dict[str, RoutingOutcome] = {}
congestion = self.context.options.congestion congestion = self.context.options.congestion
self.metrics.total_route_iterations += 1 self.metrics.total_route_iterations += 1
self.metrics.reset_per_route() self.metrics.reset_per_route()
if congestion.shuffle_nets and (iteration > 0 or state.initial_paths is None): if congestion.shuffle_nets and (iteration > 0 or state.initial_paths is None):
iteration_seed = (congestion.seed + iteration) if congestion.seed is not None else None iteration_seed = (congestion.seed + iteration) if congestion.seed is not None else None
random.Random(iteration_seed).shuffle(state.ordered_net_ids) random.Random(iteration_seed).shuffle(state.ordered_net_ids)
routed_net_ids = [net_id for net_id in state.ordered_net_ids if net_id in reroute_net_ids] for net_id in state.ordered_net_ids:
self.metrics.total_nets_carried_forward += len(state.ordered_net_ids) - len(routed_net_ids)
for net_id in routed_net_ids:
if time.monotonic() - state.start_time > state.timeout_s: if time.monotonic() - state.start_time > state.timeout_s:
self.metrics.total_timeout_events += 1 self.metrics.total_timeout_events += 1
return None return None
result = self._route_net_once(state, iteration, net_id) result = self._route_net_once(state, iteration, net_id)
state.results[net_id] = result state.results[net_id] = result
outcomes[net_id] = result.outcome
review = self._reverify_iteration_results(state)
if iteration_callback: if iteration_callback:
iteration_callback(iteration, state.results) iteration_callback(iteration, state.results)
return review return outcomes
def _reverify_iteration_results(self, state: _RoutingState) -> _IterationReview:
state.results, details_by_net, review = self._analyze_results(
state.ordered_net_ids,
state.results,
capture_component_conflicts=self.context.options.diagnostics.capture_conflict_trace,
count_iteration_metrics=True,
)
self._capture_conflict_trace_entry(
state,
stage="iteration",
iteration=self.metrics.total_route_iterations - 1,
results=state.results,
details_by_net=details_by_net,
review=review,
)
return review
def _run_iterations( def _run_iterations(
self, self,
@ -874,33 +264,10 @@ class PathFinder:
) -> bool: ) -> bool:
congestion = self.context.options.congestion congestion = self.context.options.congestion
for iteration in range(congestion.max_iterations): for iteration in range(congestion.max_iterations):
review = self._run_iteration( outcomes = self._run_iteration(state, iteration, iteration_callback)
state, if outcomes is None:
iteration,
set(state.ordered_net_ids),
iteration_callback,
)
if review is None:
return True return True
self._update_best_iteration(state, review) if not any(outcome in {"colliding", "partial", "unroutable"} for outcome in outcomes.values()):
if not any(
result.outcome in {"colliding", "partial", "unroutable"}
for result in state.results.values()
):
return False
current_signature = tuple(sorted(review.conflict_edges))
repeated = (
bool(current_signature)
and (
current_signature == state.last_conflict_signature
or len(current_signature) == state.last_conflict_edge_count
)
)
state.repeated_conflict_count = state.repeated_conflict_count + 1 if repeated else 0
state.last_conflict_signature = current_signature
state.last_conflict_edge_count = len(current_signature)
if state.repeated_conflict_count >= 2:
return False return False
self.context.congestion_penalty *= congestion.multiplier self.context.congestion_penalty *= congestion.multiplier
return False return False
@ -918,49 +285,27 @@ class PathFinder:
self.context.cost_evaluator.collision_engine.remove_path(net_id) self.context.cost_evaluator.collision_engine.remove_path(net_id)
refined_path = self.refiner.refine_path(net_id, net.start, net.width, result.path) refined_path = self.refiner.refine_path(net_id, net.start, net.width, result.path)
self._install_path(net_id, refined_path) self._install_path(net_id, refined_path)
# Defer full verification until _verify_results() so we do not report = self.context.cost_evaluator.collision_engine.verify_path_report(net_id, refined_path)
# verify the same refined path twice in one route_all() call.
state.results[net_id] = RoutingResult( state.results[net_id] = RoutingResult(
net_id=net_id, net_id=net_id,
path=tuple(refined_path), path=tuple(refined_path),
reached_target=result.reached_target, reached_target=result.reached_target,
report=result.report, report=report,
) )
def _verify_results(self, state: _RoutingState) -> dict[str, RoutingResult]: def _verify_results(self, state: _RoutingState) -> dict[str, RoutingResult]:
final_results: dict[str, RoutingResult] = {} final_results: dict[str, RoutingResult] = {}
details_by_net: dict[str, PathVerificationDetail] = {}
for net in self.context.problem.nets: for net in self.context.problem.nets:
result = state.results.get(net.net_id) result = state.results.get(net.net_id)
if not result or not result.path: if not result or not result.path:
final_results[net.net_id] = RoutingResult(net_id=net.net_id, path=(), reached_target=False) final_results[net.net_id] = RoutingResult(net_id=net.net_id, path=(), reached_target=False)
continue continue
detail = self.context.cost_evaluator.collision_engine.verify_path_details( report = self.context.cost_evaluator.collision_engine.verify_path_report(net.net_id, result.path)
net.net_id,
result.path,
capture_component_conflicts=self.context.options.diagnostics.capture_conflict_trace,
)
details_by_net[net.net_id] = detail
final_results[net.net_id] = RoutingResult( final_results[net.net_id] = RoutingResult(
net_id=net.net_id, net_id=net.net_id,
path=result.path, path=result.path,
reached_target=result.reached_target, reached_target=result.reached_target,
report=detail.report, report=report,
)
if self.context.options.diagnostics.capture_conflict_trace:
_, _, review = self._analyze_results(
state.ordered_net_ids,
final_results,
capture_component_conflicts=True,
count_iteration_metrics=False,
)
self._capture_conflict_trace_entry(
state,
stage="final",
iteration=None,
results=final_results,
details_by_net=details_by_net,
review=review,
) )
return final_results return final_results
@ -971,38 +316,15 @@ class PathFinder:
) -> dict[str, RoutingResult]: ) -> dict[str, RoutingResult]:
self.context.congestion_penalty = self.context.options.congestion.base_penalty self.context.congestion_penalty = self.context.options.congestion.base_penalty
self.accumulated_expanded_nodes = [] self.accumulated_expanded_nodes = []
self.conflict_trace = []
self.frontier_trace = []
self.metrics.reset_totals() self.metrics.reset_totals()
self.metrics.reset_per_route() self.metrics.reset_per_route()
state = self._prepare_state() state = self._prepare_state()
timed_out = self._run_iterations(state, iteration_callback) timed_out = self._run_iterations(state, iteration_callback)
self.accumulated_expanded_nodes = list(state.accumulated_expanded_nodes) self.accumulated_expanded_nodes = list(state.accumulated_expanded_nodes)
self._restore_best_iteration(state)
if self.context.options.diagnostics.capture_conflict_trace:
state.results, details_by_net, review = self._analyze_results(
state.ordered_net_ids,
state.results,
capture_component_conflicts=True,
count_iteration_metrics=False,
)
self._capture_conflict_trace_entry(
state,
stage="restored_best",
iteration=None,
results=state.results,
details_by_net=details_by_net,
review=review,
)
if timed_out: if timed_out:
final_results = self._verify_results(state) return self._verify_results(state)
self._capture_frontier_trace(state, final_results)
return final_results
self._run_pair_local_search(state)
self._refine_results(state) self._refine_results(state)
final_results = self._verify_results(state) return self._verify_results(state)
self._capture_frontier_trace(state, final_results)
return final_results

View file

@ -41,17 +41,11 @@ def route_astar(
open_set: list[_AStarNode] = [] open_set: list[_AStarNode] = []
closed_set: dict[tuple[int, int, int], float] = {} closed_set: dict[tuple[int, int, int], float] = {}
congestion_cache: dict[tuple, int] = {} congestion_cache: dict[tuple, int] = {}
congestion_presence_cache: dict[tuple[str, int, int, int, int], bool] = {}
congestion_candidate_precheck_cache: dict[tuple[str, int, int, int, int], bool] = {}
congestion_net_envelope_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] = {}
congestion_grid_net_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] = {}
congestion_grid_span_cache: dict[tuple[str, int, int, int, int], dict[str, tuple[int, ...]]] = {}
start_node = _AStarNode( start_node = _AStarNode(
start, start,
0.0, 0.0,
context.cost_evaluator.h_manhattan(start, target, min_bend_radius=context.min_bend_radius), context.cost_evaluator.h_manhattan(start, target, min_bend_radius=context.min_bend_radius),
seed_index=0 if config.guidance_seed else None,
) )
heapq.heappush(open_set, start_node) heapq.heappush(open_set, start_node)
best_node = start_node best_node = start_node
@ -95,11 +89,6 @@ def route_astar(
context, context,
metrics, metrics,
congestion_cache, congestion_cache,
congestion_presence_cache,
congestion_candidate_precheck_cache,
congestion_net_envelope_cache,
congestion_grid_net_cache,
congestion_grid_span_cache,
config=config, config=config,
) )

View file

@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
from time import perf_counter_ns
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import numpy as np import numpy as np
@ -131,16 +130,10 @@ class CostEvaluator:
start_port: Port | None = None, start_port: Port | None = None,
weights: ObjectiveWeights | None = None, weights: ObjectiveWeights | None = None,
) -> float: ) -> float:
metrics = self.collision_engine.metrics
if metrics is not None:
metrics.total_score_component_calls += 1
start_ns = perf_counter_ns()
active_weights = self._resolve_weights(weights) active_weights = self._resolve_weights(weights)
danger_map = self.danger_map danger_map = self.danger_map
end_port = component.end_port end_port = component.end_port
if danger_map is not None and not danger_map.is_within_bounds(end_port.x, end_port.y): if danger_map is not None and not danger_map.is_within_bounds(end_port.x, end_port.y):
if metrics is not None:
metrics.total_score_component_total_ns += perf_counter_ns() - start_ns
return 1e15 return 1e15
move_radius = None move_radius = None
@ -152,8 +145,7 @@ class CostEvaluator:
weights=active_weights, weights=active_weights,
) )
# Skip danger sampling entirely when there are no static obstacles in the KD-tree. if danger_map is not None and active_weights.danger_weight:
if danger_map is not None and active_weights.danger_weight and danger_map.tree is not None:
cost_s = danger_map.get_cost(start_port.x, start_port.y) if start_port else 0.0 cost_s = danger_map.get_cost(start_port.x, start_port.y) if start_port else 0.0
cost_e = danger_map.get_cost(end_port.x, end_port.y) cost_e = danger_map.get_cost(end_port.x, end_port.y)
if start_port: if start_port:
@ -163,8 +155,6 @@ class CostEvaluator:
total_cost += component.length * active_weights.danger_weight * (cost_s + cost_m + cost_e) / 3.0 total_cost += component.length * active_weights.danger_weight * (cost_s + cost_m + cost_e) / 3.0
else: else:
total_cost += component.length * active_weights.danger_weight * cost_e total_cost += component.length * active_weights.danger_weight * cost_e
if metrics is not None:
metrics.total_score_component_total_ns += perf_counter_ns() - start_ns
return total_cost return total_cost
def component_penalty( def component_penalty(
@ -191,9 +181,6 @@ class CostEvaluator:
*, *,
weights: ObjectiveWeights | None = None, weights: ObjectiveWeights | None = None,
) -> float: ) -> float:
metrics = self.collision_engine.metrics
if metrics is not None:
metrics.total_path_cost_calls += 1
active_weights = self._resolve_weights(weights) active_weights = self._resolve_weights(weights)
total = 0.0 total = 0.0
current_port = start_port current_port = start_port

View file

@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections import OrderedDict from collections import OrderedDict
from time import perf_counter_ns
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import numpy import numpy
@ -9,7 +8,6 @@ from scipy.spatial import cKDTree
if TYPE_CHECKING: if TYPE_CHECKING:
from shapely.geometry import Polygon from shapely.geometry import Polygon
from inire.router._astar_types import AStarMetrics
_COST_CACHE_SIZE = 100000 _COST_CACHE_SIZE = 100000
@ -20,7 +18,7 @@ class DangerMap:
A proximity cost evaluator using a KD-Tree of obstacle boundary points. A proximity cost evaluator using a KD-Tree of obstacle boundary points.
Scales with obstacle perimeter rather than design area. Scales with obstacle perimeter rather than design area.
""" """
__slots__ = ('minx', 'miny', 'maxx', 'maxy', 'resolution', 'safety_threshold', 'k', 'tree', '_cost_cache', 'metrics') __slots__ = ('minx', 'miny', 'maxx', 'maxy', 'resolution', 'safety_threshold', 'k', 'tree', '_cost_cache')
def __init__( def __init__(
self, self,
@ -44,7 +42,6 @@ class DangerMap:
self.k = k self.k = k
self.tree: cKDTree | None = None self.tree: cKDTree | None = None
self._cost_cache: OrderedDict[tuple[int, int], float] = OrderedDict() self._cost_cache: OrderedDict[tuple[int, int], float] = OrderedDict()
self.metrics: AStarMetrics | None = None
def precompute(self, obstacles: list[Polygon]) -> None: def precompute(self, obstacles: list[Polygon]) -> None:
""" """
@ -85,28 +82,17 @@ class DangerMap:
Get the proximity cost at a specific coordinate using the KD-Tree. Get the proximity cost at a specific coordinate using the KD-Tree.
Coordinates are quantized to 1nm to improve cache performance. Coordinates are quantized to 1nm to improve cache performance.
""" """
metrics = self.metrics
if metrics is not None:
metrics.total_danger_map_lookup_calls += 1
start_ns = perf_counter_ns()
qx_milli = int(round(x * 1000)) qx_milli = int(round(x * 1000))
qy_milli = int(round(y * 1000)) qy_milli = int(round(y * 1000))
key = (qx_milli, qy_milli) key = (qx_milli, qy_milli)
if key in self._cost_cache: if key in self._cost_cache:
if metrics is not None:
metrics.total_danger_map_cache_hits += 1
metrics.total_danger_map_total_ns += perf_counter_ns() - start_ns
self._cost_cache.move_to_end(key) self._cost_cache.move_to_end(key)
return self._cost_cache[key] return self._cost_cache[key]
if metrics is not None:
metrics.total_danger_map_cache_misses += 1
cost = self._compute_cost_quantized(qx_milli, qy_milli) cost = self._compute_cost_quantized(qx_milli, qy_milli)
self._cost_cache[key] = cost self._cost_cache[key] = cost
if len(self._cost_cache) > _COST_CACHE_SIZE: if len(self._cost_cache) > _COST_CACHE_SIZE:
self._cost_cache.popitem(last=False) self._cost_cache.popitem(last=False)
if metrics is not None:
metrics.total_danger_map_total_ns += perf_counter_ns() - start_ns
return cost return cost
def _compute_cost_quantized(self, qx_milli: int, qy_milli: int) -> float: def _compute_cost_quantized(self, qx_milli: int, qy_milli: int) -> float:
@ -116,8 +102,6 @@ class DangerMap:
return 1e15 return 1e15
if self.tree is None: if self.tree is None:
return 0.0 return 0.0
if self.metrics is not None:
self.metrics.total_danger_map_query_calls += 1
dist, _ = self.tree.query([qx, qy], distance_upper_bound=self.safety_threshold) dist, _ = self.tree.query([qx, qy], distance_upper_bound=self.safety_threshold)
if dist >= self.safety_threshold: if dist >= self.safety_threshold:
return 0.0 return 0.0

View file

@ -128,7 +128,6 @@ class PathRefiner:
x_max = max(0.0, float(local_dx)) + 0.01 x_max = max(0.0, float(local_dx)) + 0.01
for bounds in self.collision_engine.iter_static_obstacle_bounds(query_bounds): for bounds in self.collision_engine.iter_static_obstacle_bounds(query_bounds):
self.context.metrics.total_refinement_static_bounds_checked += 1
local_corners = ( local_corners = (
self._to_local_xy(start, bounds[0], bounds[1]), self._to_local_xy(start, bounds[0], bounds[1]),
self._to_local_xy(start, bounds[0], bounds[3]), self._to_local_xy(start, bounds[0], bounds[3]),
@ -145,7 +144,6 @@ class PathRefiner:
negative_anchors.add(obs_min_y) negative_anchors.add(obs_min_y)
for bounds in self.collision_engine.iter_dynamic_path_bounds(query_bounds): for bounds in self.collision_engine.iter_dynamic_path_bounds(query_bounds):
self.context.metrics.total_refinement_dynamic_bounds_checked += 1
local_corners = ( local_corners = (
self._to_local_xy(start, bounds[0], bounds[1]), self._to_local_xy(start, bounds[0], bounds[1]),
self._to_local_xy(start, bounds[0], bounds[3]), self._to_local_xy(start, bounds[0], bounds[3]),
@ -168,7 +166,6 @@ class PathRefiner:
if anchor < min(0.0, float(local_dy)) + 0.01: if anchor < min(0.0, float(local_dy)) + 0.01:
direct_extents.add(anchor - pad) direct_extents.add(anchor - pad)
self.context.metrics.total_refinement_candidate_side_extents += len(direct_extents)
return sorted(direct_extents, key=lambda value: (abs(value), value)) return sorted(direct_extents, key=lambda value: (abs(value), value))
def _build_same_orientation_dogleg( def _build_same_orientation_dogleg(
@ -246,7 +243,6 @@ class PathRefiner:
local_dx, _ = self._to_local(window_start, window_end) local_dx, _ = self._to_local(window_start, window_end)
if local_dx < 4.0 * min_radius - 0.01: if local_dx < 4.0 * min_radius - 0.01:
continue continue
self.context.metrics.total_refinement_windows_considered += 1
windows.append((start_idx, end_idx)) windows.append((start_idx, end_idx))
return windows return windows
@ -274,15 +270,12 @@ class PathRefiner:
replacement = self._build_same_orientation_dogleg(window_start, window_end, net_width, radius, side_extent) replacement = self._build_same_orientation_dogleg(window_start, window_end, net_width, radius, side_extent)
if replacement is None: if replacement is None:
continue continue
self.context.metrics.total_refinement_candidates_built += 1
candidate_path = path[:start_idx] + replacement + path[end_idx:] candidate_path = path[:start_idx] + replacement + path[end_idx:]
self.context.metrics.total_refinement_candidates_verified += 1
report = self.collision_engine.verify_path_report(net_id, candidate_path) report = self.collision_engine.verify_path_report(net_id, candidate_path)
if not report.is_valid: if not report.is_valid:
continue continue
candidate_cost = self.path_cost(candidate_path) candidate_cost = self.path_cost(candidate_path)
if candidate_cost + 1e-6 < best_candidate_cost: if candidate_cost + 1e-6 < best_candidate_cost:
self.context.metrics.total_refinement_candidates_accepted += 1
best_candidate_cost = candidate_cost best_candidate_cost = candidate_cost
best_path = candidate_path best_path = candidate_path

View file

@ -16,15 +16,7 @@ class VisibilityManager:
""" """
Manages corners of static obstacles for sparse A* / Visibility Graph jumps. Manages corners of static obstacles for sparse A* / Visibility Graph jumps.
""" """
__slots__ = ( __slots__ = ("collision_engine", "corners", "corner_index", "_corner_graph", "_point_visibility_cache", "_built_static_version")
"collision_engine",
"corners",
"corner_index",
"_corner_graph",
"_point_visibility_cache",
"_corner_index_version",
"_corner_graph_version",
)
def __init__(self, collision_engine: RoutingWorld) -> None: def __init__(self, collision_engine: RoutingWorld) -> None:
self.collision_engine = collision_engine self.collision_engine = collision_engine
@ -32,8 +24,8 @@ class VisibilityManager:
self.corner_index = rtree.index.Index() self.corner_index = rtree.index.Index()
self._corner_graph: dict[int, list[tuple[float, float, float]]] = {} self._corner_graph: dict[int, list[tuple[float, float, float]]] = {}
self._point_visibility_cache: dict[tuple[int, int, int], list[tuple[float, float, float]]] = {} self._point_visibility_cache: dict[tuple[int, int, int], list[tuple[float, float, float]]] = {}
self._corner_index_version = -1 self._built_static_version = -1
self._corner_graph_version = -1 self._build()
def clear_cache(self) -> None: def clear_cache(self) -> None:
""" """
@ -43,31 +35,19 @@ class VisibilityManager:
self.corner_index = rtree.index.Index() self.corner_index = rtree.index.Index()
self._corner_graph = {} self._corner_graph = {}
self._point_visibility_cache = {} self._point_visibility_cache = {}
self._corner_index_version = -1 self._build()
self._corner_graph_version = -1
def ensure_corner_index_current(self) -> None:
if self._corner_index_version != self.collision_engine.get_static_version():
self._build_corner_index()
def ensure_corner_graph_current(self) -> None:
self.ensure_corner_index_current()
static_version = self.collision_engine.get_static_version()
if self._corner_graph_version != static_version:
self._build_corner_graph()
def _ensure_current(self) -> None: def _ensure_current(self) -> None:
self.ensure_corner_graph_current() if self._built_static_version != self.collision_engine.get_static_version():
self.clear_cache()
def _build_corner_index(self) -> None: def _build(self) -> None:
"""
Extract corners and pre-compute corner-to-corner visibility.
"""
if self.collision_engine.metrics is not None: if self.collision_engine.metrics is not None:
self.collision_engine.metrics.total_visibility_corner_index_builds += 1 self.collision_engine.metrics.total_visibility_builds += 1
self.corners = [] self._built_static_version = self.collision_engine.get_static_version()
self.corner_index = rtree.index.Index()
self._corner_graph = {}
self._point_visibility_cache = {}
self._corner_graph_version = -1
self._corner_index_version = self.collision_engine.get_static_version()
raw_corners = [] raw_corners = []
for poly in self.collision_engine.iter_static_dilated_geometries(): for poly in self.collision_engine.iter_static_dilated_geometries():
coords = list(poly.exterior.coords) coords = list(poly.exterior.coords)
@ -83,6 +63,7 @@ class VisibilityManager:
if not raw_corners: if not raw_corners:
return return
# Deduplicate repeated corner coordinates
seen = set() seen = set()
for x, y in raw_corners: for x, y in raw_corners:
sx, sy = round(x, 3), round(y, 3) sx, sy = round(x, 3), round(y, 3)
@ -90,27 +71,15 @@ class VisibilityManager:
seen.add((sx, sy)) seen.add((sx, sy))
self.corners.append((sx, sy)) self.corners.append((sx, sy))
# Build spatial index for corners
for i, (x, y) in enumerate(self.corners): for i, (x, y) in enumerate(self.corners):
self.corner_index.insert(i, (x, y, x, y)) self.corner_index.insert(i, (x, y, x, y))
def _build_corner_graph(self) -> None:
"""
Pre-compute corner-to-corner visibility from the current corner index.
"""
self.ensure_corner_index_current()
if self.collision_engine.metrics is not None:
self.collision_engine.metrics.total_visibility_builds += 1
self._corner_graph = {}
self._corner_graph_version = self.collision_engine.get_static_version()
if not self.corners:
return
# Pre-compute visibility graph between corners # Pre-compute visibility graph between corners
num_corners = len(self.corners) num_corners = len(self.corners)
if num_corners > 200: if num_corners > 200:
# Limit pre-computation if too many corners # Limit pre-computation if too many corners
return return
for i in range(num_corners): for i in range(num_corners):
self._corner_graph[i] = [] self._corner_graph[i] = []
@ -124,12 +93,11 @@ class VisibilityManager:
dx, dy = cx - p1.x, cy - p1.y dx, dy = cx - p1.x, cy - p1.y
dist = numpy.sqrt(dx**2 + dy**2) dist = numpy.sqrt(dx**2 + dy**2)
angle = numpy.degrees(numpy.arctan2(dy, dx)) angle = numpy.degrees(numpy.arctan2(dy, dx))
reach = self.collision_engine.ray_cast(p1, angle, max_dist=dist + 0.05, caller="visibility_build") reach = self.collision_engine.ray_cast(p1, angle, max_dist=dist + 0.05)
if reach >= dist - 0.01: if reach >= dist - 0.01:
self._corner_graph[i].append((cx, cy, dist)) self._corner_graph[i].append((cx, cy, dist))
def _corner_idx_at(self, origin: Port) -> int | None: def _corner_idx_at(self, origin: Port) -> int | None:
self.ensure_corner_index_current()
ox, oy = round(origin.x, 3), round(origin.y, 3) ox, oy = round(origin.x, 3), round(origin.y, 3)
nearby = list(self.corner_index.intersection((ox - 0.001, oy - 0.001, ox + 0.001, oy + 0.001))) nearby = list(self.corner_index.intersection((ox - 0.001, oy - 0.001, ox + 0.001, oy + 0.001)))
for idx in nearby: for idx in nearby:
@ -138,49 +106,6 @@ class VisibilityManager:
return idx return idx
return None return None
def get_tangent_corner_candidates(
self,
origin: Port,
*,
min_forward: float,
max_forward: float,
radii: tuple[float, ...],
tolerance: float = 2.0,
) -> list[int]:
self.ensure_corner_index_current()
if max_forward <= min_forward or not radii or not self.corners:
return []
candidate_ids: set[int] = set()
x0 = float(origin.x)
y0 = float(origin.y)
def _add_hits(bounds: tuple[float, float, float, float]) -> None:
min_x, min_y, max_x, max_y = bounds
if min_x > max_x or min_y > max_y:
return
candidate_ids.update(self.corner_index.intersection(bounds))
for radius in radii:
if origin.r == 0:
x_bounds = (x0 + min_forward, x0 + max_forward)
_add_hits((x_bounds[0], y0 + radius - tolerance, x_bounds[1], y0 + radius + tolerance))
_add_hits((x_bounds[0], y0 - radius - tolerance, x_bounds[1], y0 - radius + tolerance))
elif origin.r == 180:
x_bounds = (x0 - max_forward, x0 - min_forward)
_add_hits((x_bounds[0], y0 + radius - tolerance, x_bounds[1], y0 + radius + tolerance))
_add_hits((x_bounds[0], y0 - radius - tolerance, x_bounds[1], y0 - radius + tolerance))
elif origin.r == 90:
y_bounds = (y0 + min_forward, y0 + max_forward)
_add_hits((x0 + radius - tolerance, y_bounds[0], x0 + radius + tolerance, y_bounds[1]))
_add_hits((x0 - radius - tolerance, y_bounds[0], x0 - radius + tolerance, y_bounds[1]))
else:
y_bounds = (y0 - max_forward, y0 - min_forward)
_add_hits((x0 + radius - tolerance, y_bounds[0], x0 + radius + tolerance, y_bounds[1]))
_add_hits((x0 - radius - tolerance, y_bounds[0], x0 - radius + tolerance, y_bounds[1]))
return sorted(candidate_ids)
def get_point_visibility(self, origin: Port, max_dist: float = 1000.0) -> list[tuple[float, float, float]]: def get_point_visibility(self, origin: Port, max_dist: float = 1000.0) -> list[tuple[float, float, float]]:
""" """
Find visible corners from an arbitrary point. Find visible corners from an arbitrary point.
@ -188,13 +113,11 @@ class VisibilityManager:
""" """
if self.collision_engine.metrics is not None: if self.collision_engine.metrics is not None:
self.collision_engine.metrics.total_visibility_point_queries += 1 self.collision_engine.metrics.total_visibility_point_queries += 1
self.ensure_corner_index_current() self._ensure_current()
if max_dist < 0: if max_dist < 0:
return [] return []
corner_idx = self._corner_idx_at(origin) corner_idx = self._corner_idx_at(origin)
if corner_idx is not None:
self.ensure_corner_graph_current()
if corner_idx is not None and corner_idx in self._corner_graph: if corner_idx is not None and corner_idx in self._corner_graph:
return [corner for corner in self._corner_graph[corner_idx] if corner[2] <= max_dist] return [corner for corner in self._corner_graph[corner_idx] if corner[2] <= max_dist]
@ -220,7 +143,7 @@ class VisibilityManager:
continue continue
angle = numpy.degrees(numpy.arctan2(dy, dx)) angle = numpy.degrees(numpy.arctan2(dy, dx))
reach = self.collision_engine.ray_cast(origin, angle, max_dist=dist + 0.05, caller="visibility_query") reach = self.collision_engine.ray_cast(origin, angle, max_dist=dist + 0.05)
if reach >= dist - 0.01: if reach >= dist - 0.01:
visible.append((cx, cy, dist)) visible.append((cx, cy, dist))
@ -233,14 +156,14 @@ class VisibilityManager:
This avoids the expensive arbitrary-point visibility scan in hot search paths. This avoids the expensive arbitrary-point visibility scan in hot search paths.
""" """
if self.collision_engine.metrics is not None: if self.collision_engine.metrics is not None:
self.collision_engine.metrics.total_visibility_corner_queries_exact += 1 self.collision_engine.metrics.total_visibility_corner_queries += 1
self.ensure_corner_graph_current() self._ensure_current()
if max_dist < 0: if max_dist < 0:
return [] return []
corner_idx = self._corner_idx_at(origin) corner_idx = self._corner_idx_at(origin)
if corner_idx is not None and corner_idx in self._corner_graph: if corner_idx is not None and corner_idx in self._corner_graph:
if self.collision_engine.metrics is not None: if self.collision_engine.metrics is not None:
self.collision_engine.metrics.total_visibility_corner_hits_exact += 1 self.collision_engine.metrics.total_visibility_corner_hits += 1
return [corner for corner in self._corner_graph[corner_idx] if corner[2] <= max_dist] return [corner for corner in self._corner_graph[corner_idx] if corner[2] <= max_dist]
return [] return []

View file

@ -15,7 +15,6 @@ from inire import (
RoutingOptions, RoutingOptions,
RoutingProblem, RoutingProblem,
RoutingResult, RoutingResult,
RoutingRunResult,
SearchOptions, SearchOptions,
) )
from inire.geometry.collision import RoutingWorld from inire.geometry.collision import RoutingWorld
@ -35,7 +34,6 @@ _OBJECTIVE_FIELDS = set(ObjectiveWeights.__dataclass_fields__)
ScenarioOutcome = tuple[float, int, int, int] ScenarioOutcome = tuple[float, int, int, int]
ScenarioRun = Callable[[], ScenarioOutcome] ScenarioRun = Callable[[], ScenarioOutcome]
ScenarioSnapshotRun = Callable[[], "ScenarioSnapshot"] ScenarioSnapshotRun = Callable[[], "ScenarioSnapshot"]
TraceScenarioRun = Callable[[], RoutingRunResult]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -81,19 +79,6 @@ def _make_snapshot(
) )
def _make_run_result(
results: dict[str, RoutingResult],
pathfinder: PathFinder,
) -> RoutingRunResult:
return RoutingRunResult(
results_by_net=results,
metrics=pathfinder.metrics.snapshot(),
expanded_nodes=tuple(pathfinder.accumulated_expanded_nodes),
conflict_trace=tuple(pathfinder.conflict_trace),
frontier_trace=tuple(pathfinder.frontier_trace),
)
def _sum_metrics(metrics_list: tuple[RouteMetrics, ...]) -> RouteMetrics: def _sum_metrics(metrics_list: tuple[RouteMetrics, ...]) -> RouteMetrics:
metric_names = RouteMetrics.__dataclass_fields__ metric_names = RouteMetrics.__dataclass_fields__
return RouteMetrics( return RouteMetrics(
@ -333,24 +318,6 @@ def run_example_05() -> ScenarioOutcome:
return snapshot_example_05().as_outcome() return snapshot_example_05().as_outcome()
def trace_example_05() -> RoutingRunResult:
netlist = {
"u_turn": (Port(50, 50, 0), Port(50, 70, 180)),
"loop": (Port(100, 100, 90), Port(100, 80, 270)),
"zig_zag": (Port(20, 150, 0), Port(180, 150, 0)),
}
widths = dict.fromkeys(netlist, 2.0)
_, _, _, pathfinder = _build_routing_stack(
bounds=(0, 0, 200, 200),
netlist=netlist,
widths=widths,
evaluator_kwargs={"bend_penalty": 50.0},
request_kwargs={"bend_radii": [20.0], "capture_conflict_trace": True, "capture_frontier_trace": True},
)
results = pathfinder.route_all()
return _make_run_result(results, pathfinder)
def snapshot_example_06() -> ScenarioSnapshot: def snapshot_example_06() -> ScenarioSnapshot:
bounds = (-20, -20, 170, 170) bounds = (-20, -20, 170, 170)
obstacles = [ obstacles = [
@ -411,40 +378,12 @@ def run_example_06() -> ScenarioOutcome:
def snapshot_example_07() -> ScenarioSnapshot: def snapshot_example_07() -> ScenarioSnapshot:
return _snapshot_example_07_variant(
"example_07_large_scale_routing",
warm_start_enabled=True,
)
def snapshot_example_07_no_warm_start() -> ScenarioSnapshot:
return _snapshot_example_07_variant(
"example_07_large_scale_routing_no_warm_start",
warm_start_enabled=False,
)
def trace_example_07() -> RoutingRunResult:
return _trace_example_07_variant(warm_start_enabled=True)
def trace_example_07_no_warm_start() -> RoutingRunResult:
return _trace_example_07_variant(warm_start_enabled=False)
def _build_example_07_variant_stack(
*,
num_nets: int,
seed: int,
warm_start_enabled: bool,
capture_conflict_trace: bool = False,
capture_frontier_trace: bool = False,
) -> tuple[CostEvaluator, AStarMetrics, PathFinder]:
bounds = (0, 0, 1000, 1000) bounds = (0, 0, 1000, 1000)
obstacles = [ obstacles = [
box(450, 0, 550, 400), box(450, 0, 550, 400),
box(450, 600, 550, 1000), box(450, 600, 550, 1000),
] ]
num_nets = 10
start_x = 50 start_x = 50
start_y_base = 500 - (num_nets * 10.0) / 2.0 start_y_base = 500 - (num_nets * 10.0) / 2.0
end_x = 950 end_x = 950
@ -479,31 +418,10 @@ def _build_example_07_variant_stack(
"multiplier": 1.4, "multiplier": 1.4,
"net_order": "shortest", "net_order": "shortest",
"capture_expanded": True, "capture_expanded": True,
"capture_conflict_trace": capture_conflict_trace,
"capture_frontier_trace": capture_frontier_trace,
"shuffle_nets": True, "shuffle_nets": True,
"seed": seed, "seed": 42,
"warm_start_enabled": warm_start_enabled,
}, },
) )
return evaluator, metrics, pathfinder
def _run_example_07_variant(
*,
num_nets: int,
seed: int,
warm_start_enabled: bool,
capture_conflict_trace: bool = False,
capture_frontier_trace: bool = False,
) -> RoutingRunResult:
evaluator, metrics, pathfinder = _build_example_07_variant_stack(
num_nets=num_nets,
seed=seed,
warm_start_enabled=warm_start_enabled,
capture_conflict_trace=capture_conflict_trace,
capture_frontier_trace=capture_frontier_trace,
)
def iteration_callback(idx: int, current_results: dict[str, RoutingResult]) -> None: def iteration_callback(idx: int, current_results: dict[str, RoutingResult]) -> None:
_ = current_results _ = current_results
@ -511,36 +429,10 @@ def _run_example_07_variant(
evaluator.greedy_h_weight = new_greedy evaluator.greedy_h_weight = new_greedy
metrics.reset_per_route() metrics.reset_per_route()
results = pathfinder.route_all(iteration_callback=iteration_callback)
return _make_run_result(results, pathfinder)
def _snapshot_example_07_variant(
name: str,
*,
warm_start_enabled: bool,
) -> ScenarioSnapshot:
t0 = perf_counter() t0 = perf_counter()
run = _run_example_07_variant( results = pathfinder.route_all(iteration_callback=iteration_callback)
num_nets=10,
seed=42,
warm_start_enabled=warm_start_enabled,
)
t1 = perf_counter() t1 = perf_counter()
return _make_snapshot(name, run.results_by_net, t1 - t0, run.metrics) return _make_snapshot("example_07_large_scale_routing", results, t1 - t0, pathfinder.metrics.snapshot())
def _trace_example_07_variant(
*,
warm_start_enabled: bool,
) -> RoutingRunResult:
return _run_example_07_variant(
num_nets=10,
seed=42,
warm_start_enabled=warm_start_enabled,
capture_conflict_trace=True,
capture_frontier_trace=True,
)
def run_example_07() -> ScenarioOutcome: def run_example_07() -> ScenarioOutcome:
@ -642,19 +534,6 @@ SCENARIO_SNAPSHOTS: tuple[tuple[str, ScenarioSnapshotRun], ...] = (
("example_09_unroutable_best_effort", snapshot_example_09), ("example_09_unroutable_best_effort", snapshot_example_09),
) )
PERFORMANCE_SCENARIO_SNAPSHOTS: tuple[tuple[str, ScenarioSnapshotRun], ...] = (
("example_07_large_scale_routing_no_warm_start", snapshot_example_07_no_warm_start),
)
TRACE_SCENARIO_RUNS: tuple[tuple[str, TraceScenarioRun], ...] = (
("example_05_orientation_stress", trace_example_05),
("example_07_large_scale_routing", trace_example_07),
)
TRACE_PERFORMANCE_SCENARIO_RUNS: tuple[tuple[str, TraceScenarioRun], ...] = (
("example_07_large_scale_routing_no_warm_start", trace_example_07_no_warm_start),
)
def capture_all_scenario_snapshots() -> tuple[ScenarioSnapshot, ...]: def capture_all_scenario_snapshots() -> tuple[ScenarioSnapshot, ...]:
return tuple(run() for _, run in SCENARIO_SNAPSHOTS) return tuple(run() for _, run in SCENARIO_SNAPSHOTS)

View file

@ -16,12 +16,8 @@ from inire import (
route, route,
) )
from inire.geometry.components import Straight from inire.geometry.components import Straight
from inire.geometry.collision import RoutingWorld
from inire.results import RoutingReport, RoutingResult
from inire.router._astar_types import AStarContext
from inire.router._router import PathFinder, _IterationReview
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
def test_root_module_exports_only_stable_surface() -> None: def test_root_module_exports_only_stable_surface() -> None:
import inire import inire
@ -52,8 +48,6 @@ def test_route_problem_smoke() -> None:
assert set(run.results_by_net) == {"net1"} assert set(run.results_by_net) == {"net1"}
assert run.results_by_net["net1"].is_valid assert run.results_by_net["net1"].is_valid
assert run.conflict_trace == ()
assert run.frontier_trace == ()
def test_route_problem_supports_configs_and_debug_data() -> None: def test_route_problem_supports_configs_and_debug_data() -> None:
@ -83,353 +77,14 @@ def test_route_problem_supports_configs_and_debug_data() -> None:
assert run.expanded_nodes assert run.expanded_nodes
assert run.metrics.nodes_expanded > 0 assert run.metrics.nodes_expanded > 0
assert run.metrics.route_iterations >= 1 assert run.metrics.route_iterations >= 1
assert run.metrics.iteration_reverify_calls >= 1
assert run.metrics.iteration_reverified_nets >= 0
assert run.metrics.iteration_conflicting_nets >= 0
assert run.metrics.iteration_conflict_edges >= 0
assert run.metrics.nets_carried_forward >= 0
assert run.metrics.nets_routed >= 1 assert run.metrics.nets_routed >= 1
assert run.metrics.move_cache_abs_misses >= 0 assert run.metrics.move_cache_abs_misses >= 0
assert run.metrics.ray_cast_calls >= 0 assert run.metrics.ray_cast_calls >= 0
assert run.metrics.dynamic_tree_rebuilds >= 0 assert run.metrics.dynamic_tree_rebuilds >= 0
assert run.metrics.visibility_corner_index_builds >= 0
assert run.metrics.visibility_builds >= 0 assert run.metrics.visibility_builds >= 0
assert run.metrics.guidance_match_moves >= 0
assert run.metrics.guidance_match_moves_straight >= 0
assert run.metrics.guidance_match_moves_bend90 >= 0
assert run.metrics.guidance_match_moves_sbend >= 0
assert run.metrics.guidance_bonus_applied >= 0.0
assert run.metrics.guidance_bonus_applied_straight >= 0.0
assert run.metrics.guidance_bonus_applied_bend90 >= 0.0
assert run.metrics.guidance_bonus_applied_sbend >= 0.0
assert run.metrics.congestion_grid_span_cache_hits >= 0
assert run.metrics.congestion_grid_span_cache_misses >= 0
assert run.metrics.congestion_presence_cache_hits >= 0
assert run.metrics.congestion_presence_cache_misses >= 0
assert run.metrics.congestion_presence_skips >= 0
assert run.metrics.congestion_candidate_precheck_hits >= 0
assert run.metrics.congestion_candidate_precheck_misses >= 0
assert run.metrics.congestion_candidate_precheck_skips >= 0
assert run.metrics.congestion_candidate_nets >= 0
assert run.metrics.congestion_net_envelope_cache_hits >= 0
assert run.metrics.congestion_net_envelope_cache_misses >= 0
assert run.metrics.congestion_grid_net_cache_hits >= 0
assert run.metrics.congestion_grid_net_cache_misses >= 0
assert run.metrics.congestion_lazy_resolutions >= 0
assert run.metrics.congestion_lazy_requeues >= 0
assert run.metrics.congestion_candidate_ids >= 0
assert run.metrics.verify_dynamic_candidate_nets >= 0
assert run.metrics.verify_path_report_calls >= 0 assert run.metrics.verify_path_report_calls >= 0
assert run.metrics.pair_local_search_pairs_considered >= 0
assert run.metrics.pair_local_search_attempts >= 0
assert run.metrics.pair_local_search_accepts >= 0
assert run.metrics.pair_local_search_nodes_expanded >= 0
def test_iteration_callback_observes_reverified_conflicts() -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(
NetSpec("horizontal", Port(10, 50, 0), Port(90, 50, 0), width=2.0),
NetSpec("vertical", Port(50, 10, 90), Port(50, 90, 90), width=2.0),
),
)
options = RoutingOptions(
congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False),
refinement=RefinementOptions(enabled=False),
)
evaluator = CostEvaluator(RoutingWorld(clearance=2.0), DangerMap(bounds=problem.bounds))
pathfinder = PathFinder(AStarContext(evaluator, problem, options))
snapshots: list[dict[str, str]] = []
def callback(iteration: int, current_results: dict[str, object]) -> None:
_ = iteration
snapshots.append({net_id: result.outcome for net_id, result in current_results.items()})
results = pathfinder.route_all(iteration_callback=callback)
assert snapshots == [{"horizontal": "colliding", "vertical": "colliding"}]
assert results["horizontal"].outcome == "colliding"
assert results["vertical"].outcome == "colliding"
def test_capture_conflict_trace_preserves_route_outputs() -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(
NetSpec("horizontal", Port(10, 50, 0), Port(90, 50, 0), width=2.0),
NetSpec("vertical", Port(50, 10, 90), Port(50, 90, 90), width=2.0),
),
)
base_options = RoutingOptions(
congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False),
refinement=RefinementOptions(enabled=False),
)
run_without_trace = route(problem, options=base_options)
run_with_trace = route(
problem,
options=RoutingOptions(
congestion=base_options.congestion,
refinement=base_options.refinement,
diagnostics=DiagnosticsOptions(capture_conflict_trace=True),
),
)
assert {net_id: result.outcome for net_id, result in run_without_trace.results_by_net.items()} == {
net_id: result.outcome for net_id, result in run_with_trace.results_by_net.items()
}
assert [entry.stage for entry in run_with_trace.conflict_trace] == ["iteration", "restored_best", "final"]
def test_capture_conflict_trace_records_component_pairs() -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(
NetSpec("horizontal", Port(10, 50, 0), Port(90, 50, 0), width=2.0),
NetSpec("vertical", Port(50, 10, 90), Port(50, 90, 90), width=2.0),
),
)
options = RoutingOptions(
congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False),
refinement=RefinementOptions(enabled=False),
diagnostics=DiagnosticsOptions(capture_conflict_trace=True),
)
run = route(problem, options=options)
final_entry = run.conflict_trace[-1]
trace_by_net = {net.net_id: net for net in final_entry.nets}
assert final_entry.stage == "final"
assert final_entry.conflict_edges == (("horizontal", "vertical"),)
assert trace_by_net["horizontal"].component_conflicts[0].other_net_id == "vertical"
assert trace_by_net["horizontal"].component_conflicts[0].self_component_index == 0
assert trace_by_net["horizontal"].component_conflicts[0].other_component_index == 0
assert trace_by_net["vertical"].component_conflicts[0].other_net_id == "horizontal"
assert trace_by_net["vertical"].component_conflicts[0].self_component_index == 0
assert trace_by_net["vertical"].component_conflicts[0].other_component_index == 0
def test_capture_frontier_trace_preserves_route_outputs() -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(
NetSpec("horizontal", Port(10, 50, 0), Port(90, 50, 0), width=2.0),
NetSpec("vertical", Port(50, 10, 90), Port(50, 90, 90), width=2.0),
),
)
base_options = RoutingOptions(
congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False),
refinement=RefinementOptions(enabled=False),
)
run_without_trace = route(problem, options=base_options)
run_with_trace = route(
problem,
options=RoutingOptions(
congestion=base_options.congestion,
refinement=base_options.refinement,
diagnostics=DiagnosticsOptions(capture_frontier_trace=True),
),
)
assert {net_id: result.outcome for net_id, result in run_without_trace.results_by_net.items()} == {
net_id: result.outcome for net_id, result in run_with_trace.results_by_net.items()
}
assert {trace.net_id for trace in run_with_trace.frontier_trace} == {"horizontal", "vertical"}
def test_capture_frontier_trace_records_prune_reasons() -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(
NetSpec("horizontal", Port(10, 50, 0), Port(90, 50, 0), width=2.0),
NetSpec("vertical", Port(50, 10, 90), Port(50, 90, 90), width=2.0),
),
)
run = route(
problem,
options=RoutingOptions(
congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False),
refinement=RefinementOptions(enabled=False),
diagnostics=DiagnosticsOptions(capture_frontier_trace=True),
),
)
trace_by_net = {entry.net_id: entry for entry in run.frontier_trace}
assert trace_by_net["horizontal"].hotspot_bounds
assert (
trace_by_net["horizontal"].pruned_closed_set
+ trace_by_net["horizontal"].pruned_hard_collision
+ trace_by_net["horizontal"].pruned_self_collision
+ trace_by_net["horizontal"].pruned_cost
) > 0
assert trace_by_net["horizontal"].samples
def test_reverify_iterations_stop_early_on_stalled_conflict_graph() -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(
NetSpec("horizontal", Port(10, 50, 0), Port(90, 50, 0), width=2.0),
NetSpec("vertical", Port(50, 10, 90), Port(50, 90, 90), width=2.0),
),
)
options = RoutingOptions(
congestion=CongestionOptions(max_iterations=10, warm_start_enabled=False),
refinement=RefinementOptions(enabled=False),
)
run = route(problem, options=options)
assert run.metrics.route_iterations < 10
def test_route_all_restores_best_iteration_snapshot(monkeypatch: pytest.MonkeyPatch) -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(
NetSpec("netA", Port(10, 50, 0), Port(90, 50, 0), width=2.0),
NetSpec("netB", Port(50, 10, 90), Port(50, 90, 90), width=2.0),
),
)
options = RoutingOptions(
congestion=CongestionOptions(max_iterations=2, warm_start_enabled=False),
refinement=RefinementOptions(enabled=False),
)
evaluator = CostEvaluator(RoutingWorld(clearance=2.0), DangerMap(bounds=problem.bounds))
pathfinder = PathFinder(AStarContext(evaluator, problem, options))
best_result = RoutingResult(
net_id="netA",
path=(Straight.generate(Port(10, 50, 0), 80.0, 2.0, dilation=1.0),),
reached_target=True,
report=RoutingReport(),
)
missing_result = RoutingResult(net_id="netA", path=(), reached_target=False)
unroutable_b = RoutingResult(net_id="netB", path=(), reached_target=False)
def fake_run_iteration(self, state, iteration, reroute_net_ids, iteration_callback):
_ = self
_ = reroute_net_ids
_ = iteration_callback
if iteration == 0:
state.results = {"netA": best_result, "netB": unroutable_b}
return _IterationReview(
conflicting_nets={"netA", "netB"},
conflict_edges={("netA", "netB")},
completed_net_ids={"netA"},
total_dynamic_collisions=1,
)
state.results = {"netA": missing_result, "netB": unroutable_b}
return _IterationReview(
conflicting_nets={"netA", "netB"},
conflict_edges={("netA", "netB")},
completed_net_ids=set(),
total_dynamic_collisions=2,
)
monkeypatch.setattr(PathFinder, "_run_iteration", fake_run_iteration)
monkeypatch.setattr(PathFinder, "_verify_results", lambda self, state: dict(state.results))
results = pathfinder.route_all()
assert results["netA"].outcome == "completed"
assert results["netB"].outcome == "unroutable"
def test_route_all_restores_best_iteration_snapshot_on_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(NetSpec("netA", Port(10, 50, 0), Port(90, 50, 0), width=2.0),),
)
options = RoutingOptions(
congestion=CongestionOptions(max_iterations=2, warm_start_enabled=False),
refinement=RefinementOptions(enabled=False),
)
evaluator = CostEvaluator(RoutingWorld(clearance=2.0), DangerMap(bounds=problem.bounds))
pathfinder = PathFinder(AStarContext(evaluator, problem, options))
best_result = RoutingResult(
net_id="netA",
path=(Straight.generate(Port(10, 50, 0), 80.0, 2.0, dilation=1.0),),
reached_target=True,
report=RoutingReport(),
)
worse_result = RoutingResult(net_id="netA", path=(), reached_target=False)
def fake_run_iterations(self, state, iteration_callback):
_ = iteration_callback
_ = self
state.results = {"netA": best_result}
pathfinder._update_best_iteration(
state,
_IterationReview(
conflicting_nets=set(),
conflict_edges=set(),
completed_net_ids={"netA"},
total_dynamic_collisions=0,
),
)
state.results = {"netA": worse_result}
return True
monkeypatch.setattr(PathFinder, "_run_iterations", fake_run_iterations)
monkeypatch.setattr(PathFinder, "_verify_results", lambda self, state: dict(state.results))
results = pathfinder.route_all()
assert results["netA"].outcome == "completed"
def test_capture_conflict_trace_records_restored_best_stage(monkeypatch: pytest.MonkeyPatch) -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(NetSpec("netA", Port(10, 50, 0), Port(90, 50, 0), width=2.0),),
)
options = RoutingOptions(
congestion=CongestionOptions(max_iterations=2, warm_start_enabled=False),
refinement=RefinementOptions(enabled=False),
diagnostics=DiagnosticsOptions(capture_conflict_trace=True),
)
evaluator = CostEvaluator(RoutingWorld(clearance=2.0), DangerMap(bounds=problem.bounds))
pathfinder = PathFinder(AStarContext(evaluator, problem, options))
best_result = RoutingResult(
net_id="netA",
path=(Straight.generate(Port(10, 50, 0), 80.0, 2.0, dilation=1.0),),
reached_target=True,
report=RoutingReport(),
)
worse_result = RoutingResult(net_id="netA", path=(), reached_target=False)
def fake_run_iteration(self, state, iteration, reroute_net_ids, iteration_callback):
_ = self
_ = reroute_net_ids
_ = iteration_callback
if iteration == 0:
state.results = {"netA": best_result}
return _IterationReview(
conflicting_nets=set(),
conflict_edges=set(),
completed_net_ids={"netA"},
total_dynamic_collisions=0,
)
state.results = {"netA": worse_result}
return _IterationReview(
conflicting_nets=set(),
conflict_edges=set(),
completed_net_ids=set(),
total_dynamic_collisions=0,
)
monkeypatch.setattr(PathFinder, "_run_iteration", fake_run_iteration)
pathfinder.route_all()
assert [entry.stage for entry in pathfinder.conflict_trace] == [
"restored_best",
"final",
]
restored_entry = pathfinder.conflict_trace[0]
assert restored_entry.nets[0].outcome == "completed"
def test_route_problem_locked_routes_become_static_obstacles() -> None: def test_route_problem_locked_routes_become_static_obstacles() -> None:
locked = (Straight.generate(Port(10, 50, 0), 80.0, 2.0, dilation=1.0),) locked = (Straight.generate(Port(10, 50, 0), 80.0, 2.0, dilation=1.0),)
problem = RoutingProblem( problem = RoutingProblem(

View file

@ -1,22 +1,16 @@
import math import math
import pytest import pytest
from shapely.geometry import Polygon from shapely.geometry import Polygon
from inire import CongestionOptions, NetSpec, RoutingProblem, RoutingOptions, RoutingResult, SearchOptions from inire import RoutingProblem, RoutingOptions, RoutingResult, SearchOptions
from inire.geometry.components import Bend90, Straight from inire.geometry.components import Bend90, Straight
from inire.geometry.collision import RoutingWorld from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
from inire.router._astar_types import AStarContext, AStarNode, SearchRunConfig from inire.router._astar_types import AStarContext, SearchRunConfig
from inire.router._astar_admission import add_node
from inire.router._astar_moves import (
_distance_to_bounds_in_heading,
_should_cap_straights_to_bounds,
)
from inire.router._router import PathFinder, _RoutingState
from inire.router._search import route_astar from inire.router._search import route_astar
from inire.router.cost import CostEvaluator from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap from inire.router.danger_map import DangerMap
from inire.seeds import StraightSeed
BOUNDS = (0, -50, 150, 150) BOUNDS = (0, -50, 150, 150)
@ -220,84 +214,6 @@ def test_astar_context_keeps_evaluator_weights_separate(basic_evaluator: CostEva
assert basic_evaluator.h_manhattan(Port(0, 0, 0), Port(10, 10, 0)) > 0.0 assert basic_evaluator.h_manhattan(Port(0, 0, 0), Port(10, 10, 0)) > 0.0
def test_distance_to_bounds_in_heading_is_directional() -> None:
bounds = (0, 0, 100, 200)
assert _distance_to_bounds_in_heading(Port(20, 30, 0), bounds) == pytest.approx(80.0)
assert _distance_to_bounds_in_heading(Port(20, 30, 90), bounds) == pytest.approx(170.0)
assert _distance_to_bounds_in_heading(Port(20, 30, 180), bounds) == pytest.approx(20.0)
assert _distance_to_bounds_in_heading(Port(20, 30, 270), bounds) == pytest.approx(30.0)
def test_should_cap_straights_to_bounds_only_for_large_no_warm_runs(basic_evaluator: CostEvaluator) -> None:
large_context = AStarContext(
basic_evaluator,
RoutingProblem(
bounds=(0, 0, 1000, 1000),
nets=tuple(
NetSpec(f"net{i}", Port(0, i * 10, 0), Port(10, i * 10, 0), width=2.0)
for i in range(8)
),
),
RoutingOptions(
congestion=CongestionOptions(warm_start_enabled=False),
),
)
small_context = _build_context(basic_evaluator, bounds=BOUNDS)
assert _should_cap_straights_to_bounds(large_context)
assert not _should_cap_straights_to_bounds(small_context)
def test_pair_local_context_clones_live_static_obstacles() -> None:
obstacle = Polygon([(20, -20), (40, -20), (40, 20), (20, 20)])
engine = RoutingWorld(clearance=2.0)
engine.add_static_obstacle(obstacle)
danger_map = DangerMap(bounds=BOUNDS)
danger_map.precompute([obstacle])
evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
finder = PathFinder(
AStarContext(
evaluator,
RoutingProblem(
bounds=BOUNDS,
nets=(
NetSpec("pair_a", Port(0, 0, 0), Port(60, 0, 0), width=2.0),
NetSpec("pair_b", Port(0, 10, 0), Port(60, 10, 0), width=2.0),
),
),
RoutingOptions(),
)
)
state = _RoutingState(
net_specs={
"pair_a": NetSpec("pair_a", Port(0, 0, 0), Port(60, 0, 0), width=2.0),
"pair_b": NetSpec("pair_b", Port(0, 10, 0), Port(60, 10, 0), width=2.0),
},
ordered_net_ids=["pair_a", "pair_b"],
results={},
needs_self_collision_check=set(),
start_time=0.0,
timeout_s=1.0,
initial_paths=None,
accumulated_expanded_nodes=[],
best_results={},
best_completed_nets=-1,
best_conflict_edges=10**9,
best_dynamic_collisions=10**9,
last_conflict_signature=(),
last_conflict_edge_count=0,
repeated_conflict_count=0,
)
local_context = finder._build_pair_local_context(state, {}, ("pair_a", "pair_b"))
assert finder.context.problem.static_obstacles == ()
assert len(local_context.problem.static_obstacles) == 1
assert len(local_context.cost_evaluator.collision_engine._static_obstacles.geometries) == 1
assert next(iter(local_context.problem.static_obstacles)).equals(obstacle)
def test_route_astar_bend_collision_override_does_not_persist(basic_evaluator: CostEvaluator) -> None: def test_route_astar_bend_collision_override_does_not_persist(basic_evaluator: CostEvaluator) -> None:
context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), bend_collision_type="arc") context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), bend_collision_type="arc")
@ -385,27 +301,6 @@ def test_route_astar_supports_all_visibility_guidance_modes(
assert validation["connectivity_ok"] assert validation["connectivity_ok"]
def test_tangent_corner_mode_avoids_exact_visibility_graph_builds(basic_evaluator: CostEvaluator) -> None:
obstacle = Polygon([(30, 10), (50, 10), (50, 40), (30, 40)])
basic_evaluator.collision_engine.add_static_obstacle(obstacle)
basic_evaluator.danger_map.precompute([obstacle])
context = _build_context(
basic_evaluator,
bounds=BOUNDS,
bend_radii=(10.0,),
sbend_radii=(),
max_straight_length=150.0,
visibility_guidance="tangent_corner",
)
path = _route(context, Port(0, 0, 0), Port(80, 50, 0))
assert path is not None
assert context.metrics.total_visibility_builds == 0
assert context.metrics.total_visibility_corner_pairs_checked == 0
assert context.metrics.total_ray_cast_calls_visibility_build == 0
def test_route_astar_repeated_searches_succeed_with_small_cache_limit(basic_evaluator: CostEvaluator) -> None: def test_route_astar_repeated_searches_succeed_with_small_cache_limit(basic_evaluator: CostEvaluator) -> None:
context = AStarContext( context = AStarContext(
basic_evaluator, basic_evaluator,
@ -423,244 +318,3 @@ def test_route_astar_repeated_searches_succeed_with_small_cache_limit(basic_eval
path = _route(context, start, target) path = _route(context, start, target)
assert path is not None assert path is not None
assert path[-1].end_port == target assert path[-1].end_port == target
def test_self_collision_prunes_before_congestion_check(basic_evaluator: CostEvaluator) -> None:
context = _build_context(basic_evaluator, bounds=BOUNDS)
root = AStarNode(Port(0, 0, 0), 0.0, 0.0)
parent_result = Straight.generate(Port(0, 0, 0), 10.0, width=2.0, dilation=1.0)
parent = AStarNode(parent_result.end_port, g_cost=10.0, h_cost=0.0, parent=root, component_result=parent_result)
open_set: list[AStarNode] = []
closed_set: dict[tuple[int, int, int], float] = {}
add_node(
parent,
parent_result,
target=Port(20, 0, 0),
net_width=2.0,
net_id="netA",
open_set=open_set,
closed_set=closed_set,
context=context,
metrics=context.metrics,
congestion_cache={},
congestion_presence_cache={},
congestion_candidate_precheck_cache={},
congestion_net_envelope_cache={},
congestion_grid_net_cache={},
congestion_grid_span_cache={},
config=SearchRunConfig.from_options(context.options, self_collision_check=True),
move_type="straight",
cache_key=("overlap",),
)
assert not open_set
assert context.metrics.total_congestion_check_calls == 0
assert context.metrics.total_congestion_cache_misses == 0
def test_closed_set_dominance_prunes_before_congestion_check(basic_evaluator: CostEvaluator) -> None:
context = _build_context(basic_evaluator, bounds=BOUNDS)
root = AStarNode(Port(0, 0, 0), 0.0, 0.0)
result = Straight.generate(Port(0, 0, 0), 10.0, width=2.0, dilation=1.0)
open_set: list[AStarNode] = []
closed_set = {result.end_port.as_tuple(): context.cost_evaluator.score_component(result, start_port=root.port)}
add_node(
root,
result,
target=Port(20, 0, 0),
net_width=2.0,
net_id="netA",
open_set=open_set,
closed_set=closed_set,
context=context,
metrics=context.metrics,
congestion_cache={},
congestion_presence_cache={},
congestion_candidate_precheck_cache={},
congestion_net_envelope_cache={},
congestion_grid_net_cache={},
congestion_grid_span_cache={},
config=SearchRunConfig.from_options(context.options),
move_type="straight",
cache_key=("dominated",),
)
assert not open_set
assert context.metrics.total_congestion_check_calls == 0
assert context.metrics.total_congestion_cache_misses == 0
def test_no_dynamic_paths_skips_congestion_check(basic_evaluator: CostEvaluator) -> None:
context = _build_context(basic_evaluator, bounds=BOUNDS)
root = AStarNode(Port(0, 0, 0), 0.0, 0.0)
result = Straight.generate(Port(0, 0, 0), 10.0, width=2.0, dilation=1.0)
open_set: list[AStarNode] = []
closed_set: dict[tuple[int, int, int], float] = {}
add_node(
root,
result,
target=Port(20, 0, 0),
net_width=2.0,
net_id="netA",
open_set=open_set,
closed_set=closed_set,
context=context,
metrics=context.metrics,
congestion_cache={},
congestion_presence_cache={},
congestion_candidate_precheck_cache={},
congestion_net_envelope_cache={},
congestion_grid_net_cache={},
congestion_grid_span_cache={},
config=SearchRunConfig.from_options(context.options),
move_type="straight",
cache_key=("no-dynamic",),
)
assert open_set
assert context.metrics.total_congestion_check_calls == 0
assert context.metrics.total_congestion_cache_misses == 0
def test_guidance_seed_matching_move_reduces_cost_and_advances_seed_index(
basic_evaluator: CostEvaluator,
) -> None:
context = _build_context(basic_evaluator, bounds=BOUNDS)
root = AStarNode(Port(0, 0, 0), 0.0, 0.0, seed_index=0)
result = Straight.generate(Port(0, 0, 0), 10.0, width=2.0, dilation=1.0)
open_set: list[AStarNode] = []
unguided_open_set: list[AStarNode] = []
closed_set: dict[tuple[int, int, int], float] = {}
add_node(
root,
result,
target=Port(20, 0, 0),
net_width=2.0,
net_id="netA",
open_set=open_set,
closed_set=closed_set,
context=context,
metrics=context.metrics,
congestion_cache={},
congestion_presence_cache={},
congestion_candidate_precheck_cache={},
congestion_net_envelope_cache={},
congestion_grid_net_cache={},
congestion_grid_span_cache={},
config=SearchRunConfig.from_options(
context.options,
guidance_seed=(StraightSeed(length=10.0),),
guidance_bonus=5.0,
),
move_type="straight",
cache_key=("guided",),
)
add_node(
AStarNode(Port(0, 0, 0), 0.0, 0.0),
result,
target=Port(20, 0, 0),
net_width=2.0,
net_id="netA",
open_set=unguided_open_set,
closed_set={},
context=context,
metrics=context.metrics,
congestion_cache={},
congestion_presence_cache={},
congestion_candidate_precheck_cache={},
congestion_net_envelope_cache={},
congestion_grid_net_cache={},
congestion_grid_span_cache={},
config=SearchRunConfig.from_options(context.options),
move_type="straight",
cache_key=("unguided",),
)
assert open_set
assert unguided_open_set
guided_node = open_set[0]
unguided_node = unguided_open_set[0]
assert guided_node.seed_index == 1
assert guided_node.g_cost < unguided_node.g_cost
assert context.metrics.total_guidance_match_moves == 1
assert context.metrics.total_guidance_match_moves_straight == 1
assert context.metrics.total_guidance_match_moves_bend90 == 0
assert context.metrics.total_guidance_match_moves_sbend == 0
assert context.metrics.total_guidance_bonus_applied == pytest.approx(5.0)
assert context.metrics.total_guidance_bonus_applied_straight == pytest.approx(5.0)
assert context.metrics.total_guidance_bonus_applied_bend90 == pytest.approx(0.0)
assert context.metrics.total_guidance_bonus_applied_sbend == pytest.approx(0.0)
def test_guidance_seed_bend90_keeps_full_bonus(
basic_evaluator: CostEvaluator,
) -> None:
context = _build_context(basic_evaluator, bounds=BOUNDS)
root = AStarNode(Port(0, 0, 0), 0.0, 0.0, seed_index=0)
result = Bend90.generate(Port(0, 0, 0), 10.0, width=2.0, direction="CCW", dilation=1.0)
open_set: list[AStarNode] = []
unguided_open_set: list[AStarNode] = []
add_node(
root,
result,
target=Port(10, 10, 90),
net_width=2.0,
net_id="netA",
open_set=open_set,
closed_set={},
context=context,
metrics=context.metrics,
congestion_cache={},
congestion_presence_cache={},
congestion_candidate_precheck_cache={},
congestion_net_envelope_cache={},
congestion_grid_net_cache={},
congestion_grid_span_cache={},
config=SearchRunConfig.from_options(
context.options,
guidance_seed=(result.move_spec,),
guidance_bonus=5.0,
),
move_type="bend90",
cache_key=("guided-bend90",),
)
add_node(
AStarNode(Port(0, 0, 0), 0.0, 0.0),
result,
target=Port(10, 10, 90),
net_width=2.0,
net_id="netA",
open_set=unguided_open_set,
closed_set={},
context=context,
metrics=context.metrics,
congestion_cache={},
congestion_presence_cache={},
congestion_candidate_precheck_cache={},
congestion_net_envelope_cache={},
congestion_grid_net_cache={},
congestion_grid_span_cache={},
config=SearchRunConfig.from_options(context.options),
move_type="bend90",
cache_key=("unguided-bend90",),
)
assert open_set
assert unguided_open_set
guided_node = open_set[0]
unguided_node = unguided_open_set[0]
assert guided_node.seed_index == 1
assert unguided_node.g_cost - guided_node.g_cost == pytest.approx(5.0)
assert context.metrics.total_guidance_match_moves == 1
assert context.metrics.total_guidance_match_moves_straight == 0
assert context.metrics.total_guidance_match_moves_bend90 == 1
assert context.metrics.total_guidance_match_moves_sbend == 0
assert context.metrics.total_guidance_bonus_applied == pytest.approx(5.0)
assert context.metrics.total_guidance_bonus_applied_straight == pytest.approx(0.0)
assert context.metrics.total_guidance_bonus_applied_bend90 == pytest.approx(5.0)
assert context.metrics.total_guidance_bonus_applied_sbend == pytest.approx(0.0)

View file

@ -1,11 +1,6 @@
from shapely.geometry import box
from inire.geometry.collision import RoutingWorld from inire.geometry.collision import RoutingWorld
from inire.geometry.components import ComponentResult
from inire.geometry.components import Straight from inire.geometry.components import Straight
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
from inire.router._astar_types import AStarMetrics
from inire.seeds import StraightSeed
def _install_static_straight( def _install_static_straight(
@ -87,7 +82,6 @@ def test_check_move_static_clearance() -> None:
def test_verify_path_report_preserves_long_net_id() -> None: def test_verify_path_report_preserves_long_net_id() -> None:
engine = RoutingWorld(clearance=2.0) engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
net_id = "net_abcdefghijklmnopqrstuvwxyz_0123456789" net_id = "net_abcdefghijklmnopqrstuvwxyz_0123456789"
path = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)] path = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)]
geoms = [poly for component in path for poly in component.collision_geometry] geoms = [poly for component in path for poly in component.collision_geometry]
@ -97,12 +91,10 @@ def test_verify_path_report_preserves_long_net_id() -> None:
report = engine.verify_path_report(net_id, path) report = engine.verify_path_report(net_id, path)
assert report.dynamic_collision_count == 0 assert report.dynamic_collision_count == 0
assert engine.metrics.total_verify_dynamic_candidate_nets == 0
def test_verify_path_report_distinguishes_long_net_ids_with_shared_prefix() -> None: def test_verify_path_report_distinguishes_long_net_ids_with_shared_prefix() -> None:
engine = RoutingWorld(clearance=2.0) engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
shared_prefix = "net_shared_prefix_abcdefghijklmnopqrstuvwxyz_" shared_prefix = "net_shared_prefix_abcdefghijklmnopqrstuvwxyz_"
net_a = f"{shared_prefix}A" net_a = f"{shared_prefix}A"
net_b = f"{shared_prefix}B" net_b = f"{shared_prefix}B"
@ -123,111 +115,6 @@ def test_verify_path_report_distinguishes_long_net_ids_with_shared_prefix() -> N
report = engine.verify_path_report(net_a, path_a) report = engine.verify_path_report(net_a, path_a)
assert report.dynamic_collision_count == 1 assert report.dynamic_collision_count == 1
assert engine.metrics.total_verify_dynamic_candidate_nets == 1
def test_verify_path_report_uses_net_envelopes_before_dynamic_object_scan() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
path_a = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)]
path_b = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)]
path_far = [Straight.generate(Port(100, 100, 0), 20.0, width=2.0, dilation=1.0)]
engine.add_path(
"netB",
[poly for component in path_b for poly in component.collision_geometry],
dilated_geometry=[poly for component in path_b for poly in component.dilated_collision_geometry],
)
engine.add_path(
"netFar",
[poly for component in path_far for poly in component.collision_geometry],
dilated_geometry=[poly for component in path_far for poly in component.dilated_collision_geometry],
)
report = engine.verify_path_report("netA", path_a)
assert report.dynamic_collision_count == 1
assert engine.metrics.total_verify_dynamic_candidate_nets == 1
assert engine.metrics.total_verify_dynamic_exact_pair_checks == 1
def test_verify_path_details_returns_conflicting_net_ids() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
path_a = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)]
path_b = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)]
engine.add_path(
"netB",
[poly for component in path_b for poly in component.collision_geometry],
dilated_geometry=[poly for component in path_b for poly in component.dilated_collision_geometry],
)
detail = engine.verify_path_details("netA", path_a)
assert detail.report.dynamic_collision_count == 1
assert detail.conflicting_net_ids == ("netB",)
def test_verify_path_details_reports_component_conflicts() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
path_a = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)]
path_b = [
Straight.generate(Port(100, 0, 0), 10.0, width=2.0, dilation=1.0),
Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0),
]
engine.add_path(
"netB",
[poly for component in path_b for poly in component.collision_geometry],
dilated_geometry=[poly for component in path_b for poly in component.dilated_collision_geometry],
component_indexes=[0] * len(path_b[0].collision_geometry) + [1] * len(path_b[1].collision_geometry),
)
detail = engine.verify_path_details("netA", path_a, capture_component_conflicts=True)
assert detail.conflicting_net_ids == ("netB",)
assert detail.component_conflicts == ((0, "netB", 1),)
def test_verify_path_details_deduplicates_component_conflicts() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
query_component = ComponentResult(
start_port=Port(0, 0, 0),
collision_geometry=[box(0, 0, 10, 10), box(12, 0, 22, 10)],
end_port=Port(22, 0, 0),
length=22.0,
move_type="straight",
move_spec=StraightSeed(22.0),
physical_geometry=[box(0, 0, 10, 10), box(12, 0, 22, 10)],
dilated_collision_geometry=[box(0, 0, 10, 10), box(12, 0, 22, 10)],
dilated_physical_geometry=[box(0, 0, 10, 10), box(12, 0, 22, 10)],
)
blocker_component = ComponentResult(
start_port=Port(0, 0, 0),
collision_geometry=[box(5, 0, 17, 10)],
end_port=Port(17, 0, 0),
length=12.0,
move_type="straight",
move_spec=StraightSeed(12.0),
physical_geometry=[box(5, 0, 17, 10)],
dilated_collision_geometry=[box(5, 0, 17, 10)],
dilated_physical_geometry=[box(5, 0, 17, 10)],
)
engine.add_path(
"netB",
blocker_component.collision_geometry,
dilated_geometry=blocker_component.dilated_collision_geometry,
component_indexes=[0],
)
detail = engine.verify_path_details("netA", [query_component], capture_component_conflicts=True)
assert detail.conflicting_net_ids == ("netB",)
assert detail.component_conflicts == ((0, "netB", 0),)
def test_remove_path_clears_dynamic_path() -> None: def test_remove_path_clears_dynamic_path() -> None:
@ -242,247 +129,3 @@ def test_remove_path_clears_dynamic_path() -> None:
engine.remove_path("netA") engine.remove_path("netA")
assert list(engine._dynamic_paths.geometries.values()) == [] assert list(engine._dynamic_paths.geometries.values()) == []
assert len(engine._static_obstacles.geometries) == 0 assert len(engine._static_obstacles.geometries) == 0
def test_dynamic_grid_updates_incrementally_on_add_and_remove() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
path_a = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)]
path_b = [Straight.generate(Port(0, 4, 0), 20.0, width=2.0, dilation=1.0)]
engine.add_path(
"netA",
[poly for component in path_a for poly in component.collision_geometry],
dilated_geometry=[poly for component in path_a for poly in component.dilated_collision_geometry],
)
engine.add_path(
"netB",
[poly for component in path_b for poly in component.collision_geometry],
dilated_geometry=[poly for component in path_b for poly in component.dilated_collision_geometry],
)
dynamic_paths = engine._dynamic_paths
assert dynamic_paths.net_to_obj_ids["netA"]
assert dynamic_paths.net_to_obj_ids["netB"]
assert dynamic_paths.grid
assert engine.metrics.total_dynamic_grid_rebuilds == 0
engine.remove_path("netA")
assert "netA" not in dynamic_paths.net_to_obj_ids
assert "netB" in dynamic_paths.net_to_obj_ids
assert engine.metrics.total_dynamic_grid_rebuilds == 0
assert "netA" not in dynamic_paths.grid_net_obj_ids.get((0, -1), {})
assert "netB" in dynamic_paths.grid_net_obj_ids.get((0, 0), {})
def test_dynamic_net_envelopes_update_incrementally_on_add_and_remove() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
path_a = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)]
path_b = [Straight.generate(Port(0, 40, 0), 10.0, width=2.0, dilation=1.0)]
engine.add_path(
"netA",
[poly for component in path_a for poly in component.collision_geometry],
dilated_geometry=[poly for component in path_a for poly in component.dilated_collision_geometry],
)
engine.add_path(
"netB",
[poly for component in path_b for poly in component.collision_geometry],
dilated_geometry=[poly for component in path_b for poly in component.dilated_collision_geometry],
)
dynamic_paths = engine._dynamic_paths
assert set(dynamic_paths.net_envelopes) == {"netA", "netB"}
assert dynamic_paths.net_envelopes["netA"] == (-1.0, -2.0, 21.0, 2.0)
assert dynamic_paths.net_envelopes["netB"] == (-1.0, 38.0, 11.0, 42.0)
assert engine.metrics.total_dynamic_tree_rebuilds == 0
net_b_envelope_obj_id = dynamic_paths.net_envelope_obj_ids["netB"]
assert list(dynamic_paths.net_envelope_index.intersection((-5.0, 35.0, 15.0, 45.0))) == [net_b_envelope_obj_id]
engine.remove_path("netA")
assert "netA" not in dynamic_paths.net_envelopes
assert "netA" not in dynamic_paths.net_envelope_obj_ids
assert "netB" in dynamic_paths.net_envelopes
assert engine.metrics.total_dynamic_tree_rebuilds == 0
def test_congestion_query_uses_per_polygon_bounds() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
blocker = Straight.generate(Port(40, 4, 90), 2.0, width=2.0, dilation=1.0)
engine.add_path(
"netB",
[poly for poly in blocker.collision_geometry],
dilated_geometry=[poly for poly in blocker.dilated_collision_geometry],
)
move = ComponentResult(
start_port=Port(0, 0, 0),
collision_geometry=[box(0, 0, 10, 10), box(90, 0, 100, 10)],
end_port=Port(100, 0, 0),
length=100.0,
move_type="straight",
move_spec=StraightSeed(100.0),
physical_geometry=[box(0, 0, 10, 10), box(90, 0, 100, 10)],
dilated_collision_geometry=[box(0, 0, 10, 10), box(90, 0, 100, 10)],
dilated_physical_geometry=[box(0, 0, 10, 10), box(90, 0, 100, 10)],
)
assert engine.check_move_congestion(move, "netA") == 0
assert engine.metrics.total_congestion_candidate_nets == 0
assert engine.metrics.total_congestion_candidate_ids == 0
assert engine.metrics.total_congestion_exact_pair_checks == 0
def test_congestion_touching_geometries_do_not_count_as_overlap() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
existing = Straight.generate(Port(0, 0, 0), 10.0, width=2.0, dilation=1.0)
touching = Straight.generate(Port(12, 0, 0), 10.0, width=2.0, dilation=1.0)
engine.add_path(
"netB",
[poly for poly in existing.collision_geometry],
dilated_geometry=[poly for poly in existing.dilated_collision_geometry],
)
assert engine.check_move_congestion(touching, "netA") == 0
def test_congestion_exact_checks_only_touch_relevant_move_polygons() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
blocker = Straight.generate(Port(0, 0, 0), 10.0, width=2.0, dilation=1.0)
engine.add_path(
"netB",
[poly for poly in blocker.collision_geometry],
dilated_geometry=[poly for poly in blocker.dilated_collision_geometry],
)
move = ComponentResult(
start_port=Port(0, 0, 0),
collision_geometry=[box(0, -2, 10, 2), box(90, -2, 100, 2)],
end_port=Port(100, 0, 0),
length=100.0,
move_type="straight",
move_spec=StraightSeed(100.0),
physical_geometry=[box(0, -2, 10, 2), box(90, -2, 100, 2)],
dilated_collision_geometry=[box(0, -2, 10, 2), box(90, -2, 100, 2)],
dilated_physical_geometry=[box(0, -2, 10, 2), box(90, -2, 100, 2)],
)
assert engine.check_move_congestion(move, "netA") == 1
assert engine.metrics.total_congestion_candidate_nets == 1
assert engine.metrics.total_congestion_candidate_ids == 1
assert engine.metrics.total_congestion_exact_pair_checks == 1
def test_congestion_grid_span_cache_reuses_broad_phase_candidates() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
net_envelope_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] = {}
grid_net_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] = {}
cache: dict[tuple[str, int, int, int, int], dict[str, tuple[int, ...]]] = {}
blocker = Straight.generate(Port(15, 0, 0), 20.0, width=2.0, dilation=1.0)
engine.add_path(
"netB",
[poly for poly in blocker.collision_geometry],
dilated_geometry=[poly for poly in blocker.dilated_collision_geometry],
)
move_a = Straight.generate(Port(5, 0, 0), 20.0, width=2.0, dilation=1.0)
move_b = Straight.generate(Port(7, 0, 0), 20.0, width=2.0, dilation=1.0)
assert engine.check_move_congestion(
move_a,
"netA",
net_envelope_cache=net_envelope_cache,
grid_net_cache=grid_net_cache,
broad_phase_cache=cache,
) == 1
assert engine.check_move_congestion(
move_b,
"netA",
net_envelope_cache=net_envelope_cache,
grid_net_cache=grid_net_cache,
broad_phase_cache=cache,
) == 1
assert engine.metrics.total_congestion_candidate_nets == 2
assert engine.metrics.total_congestion_net_envelope_cache_misses == 1
assert engine.metrics.total_congestion_net_envelope_cache_hits == 1
assert engine.metrics.total_congestion_grid_net_cache_misses == 1
assert engine.metrics.total_congestion_grid_net_cache_hits == 1
assert engine.metrics.total_congestion_grid_span_cache_misses == 1
assert engine.metrics.total_congestion_grid_span_cache_hits == 1
def test_has_possible_move_congestion_uses_presence_cache_and_skips_empty_spans() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
presence_cache: dict[tuple[str, int, int, int, int], bool] = {}
blocker = Straight.generate(Port(10, 0, 0), 20.0, width=2.0, dilation=1.0)
engine.add_path(
"netB",
[poly for poly in blocker.collision_geometry],
dilated_geometry=[poly for poly in blocker.dilated_collision_geometry],
)
empty_move = Straight.generate(Port(200, 0, 0), 20.0, width=2.0, dilation=1.0)
assert not engine.has_possible_move_congestion(empty_move, "netA", presence_cache)
assert not engine.has_possible_move_congestion(empty_move, "netA", presence_cache)
assert engine.metrics.total_congestion_presence_cache_misses == 1
assert engine.metrics.total_congestion_presence_cache_hits == 1
occupied_move = Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)
assert engine.has_possible_move_congestion(occupied_move, "netA")
def test_has_candidate_move_congestion_uses_candidate_precheck_cache() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
candidate_precheck_cache: dict[tuple[str, int, int, int, int], bool] = {}
net_envelope_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] = {}
grid_net_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] = {}
blocker = Straight.generate(Port(10, 0, 0), 20.0, width=2.0, dilation=1.0)
engine.add_path(
"netB",
[poly for poly in blocker.collision_geometry],
dilated_geometry=[poly for poly in blocker.dilated_collision_geometry],
)
empty_move = Straight.generate(Port(200, 0, 0), 20.0, width=2.0, dilation=1.0)
occupied_move = Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)
assert not engine.has_candidate_move_congestion(
empty_move,
"netA",
candidate_precheck_cache,
net_envelope_cache,
grid_net_cache,
)
assert not engine.has_candidate_move_congestion(
empty_move,
"netA",
candidate_precheck_cache,
net_envelope_cache,
grid_net_cache,
)
assert engine.has_candidate_move_congestion(
occupied_move,
"netA",
candidate_precheck_cache,
net_envelope_cache,
grid_net_cache,
)
assert engine.metrics.total_congestion_candidate_precheck_misses >= 2
assert engine.metrics.total_congestion_candidate_precheck_hits >= 1

View file

@ -6,7 +6,7 @@ from typing import TYPE_CHECKING
import pytest import pytest
from inire.tests.example_scenarios import SCENARIOS, ScenarioOutcome, snapshot_example_07_no_warm_start from inire.tests.example_scenarios import SCENARIOS, ScenarioOutcome
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
@ -15,7 +15,6 @@ if TYPE_CHECKING:
RUN_PERFORMANCE = os.environ.get("INIRE_RUN_PERFORMANCE") == "1" RUN_PERFORMANCE = os.environ.get("INIRE_RUN_PERFORMANCE") == "1"
PERFORMANCE_REPEATS = 3 PERFORMANCE_REPEATS = 3
REGRESSION_FACTOR = 1.5 REGRESSION_FACTOR = 1.5
NO_WARM_START_REGRESSION_SECONDS = 15.0
# Baselines are measured from clean 6a28dcf-style runs without plotting. # Baselines are measured from clean 6a28dcf-style runs without plotting.
BASELINE_SECONDS = { BASELINE_SECONDS = {
@ -68,20 +67,3 @@ def test_example_like_runtime_regression(scenario: tuple[str, Callable[[], Scena
f"{REGRESSION_FACTOR:.1f}x baseline {BASELINE_SECONDS[name]:.4f}s " f"{REGRESSION_FACTOR:.1f}x baseline {BASELINE_SECONDS[name]:.4f}s "
f"from timings {timings!r}" f"from timings {timings!r}"
) )
@pytest.mark.performance
@pytest.mark.skipif(not RUN_PERFORMANCE, reason="set INIRE_RUN_PERFORMANCE=1 to run runtime regression checks")
def test_example_07_no_warm_start_runtime_regression() -> None:
snapshot = snapshot_example_07_no_warm_start()
assert snapshot.total_results == 10
assert snapshot.valid_results == 10
assert snapshot.reached_targets == 10
assert snapshot.metrics.warm_start_paths_built == 0
assert snapshot.metrics.warm_start_paths_used == 0
assert snapshot.duration_s <= NO_WARM_START_REGRESSION_SECONDS, (
"example_07_large_scale_routing_no_warm_start runtime "
f"{snapshot.duration_s:.4f}s exceeded guardrail "
f"{NO_WARM_START_REGRESSION_SECONDS:.1f}s"
)

View file

@ -14,16 +14,7 @@ from inire import (
) )
from inire.router._stack import build_routing_stack from inire.router._stack import build_routing_stack
from inire.seeds import Bend90Seed, PathSeed, StraightSeed from inire.seeds import Bend90Seed, PathSeed, StraightSeed
from inire.tests.example_scenarios import ( from inire.tests.example_scenarios import SCENARIOS, _build_evaluator, _build_pathfinder, _net_specs, AStarMetrics
SCENARIOS,
_build_evaluator,
_build_pathfinder,
_net_specs,
AStarMetrics,
snapshot_example_05,
snapshot_example_07_no_warm_start,
trace_example_07_no_warm_start,
)
EXPECTED_OUTCOMES = { EXPECTED_OUTCOMES = {
@ -45,43 +36,6 @@ def test_examples_match_legacy_expected_outcomes(name: str, run) -> None:
assert outcome[1:] == EXPECTED_OUTCOMES[name] assert outcome[1:] == EXPECTED_OUTCOMES[name]
def test_example_05_avoids_dynamic_tree_rebuilds() -> None:
snapshot = snapshot_example_05()
assert snapshot.valid_results == 3
assert snapshot.metrics.dynamic_tree_rebuilds == 0
def test_example_07_no_warm_start_canary_improves_validity() -> None:
snapshot = snapshot_example_07_no_warm_start()
assert snapshot.total_results == 10
assert snapshot.reached_targets == 10
assert snapshot.valid_results == 10
assert snapshot.metrics.warm_start_paths_built == 0
assert snapshot.metrics.warm_start_paths_used == 0
assert snapshot.metrics.pair_local_search_pairs_considered >= 1
assert snapshot.metrics.pair_local_search_accepts >= 1
assert snapshot.metrics.pair_local_search_nodes_expanded <= 128
assert snapshot.metrics.nodes_expanded <= 2500
assert snapshot.metrics.congestion_check_calls <= 6000
def test_example_07_no_warm_start_trace_finishes_without_conflict_edges() -> None:
run = trace_example_07_no_warm_start()
assert len(run.results_by_net) == 10
assert sum(result.is_valid for result in run.results_by_net.values()) == 10
assert sum(result.reached_target for result in run.results_by_net.values()) == 10
assert run.metrics.pair_local_search_pairs_considered >= 1
assert run.metrics.pair_local_search_accepts >= 1
final_entry = run.conflict_trace[-1]
assert final_entry.stage == "final"
assert len(final_entry.completed_net_ids) == 10
assert final_entry.conflict_edges == ()
def test_example_06_clipped_bbox_margin_restores_legacy_seed() -> None: def test_example_06_clipped_bbox_margin_restores_legacy_seed() -> None:
bounds = (-20, -20, 170, 170) bounds = (-20, -20, 170, 170)
obstacles = ( obstacles = (

View file

@ -16,38 +16,10 @@ def test_snapshot_example_01_exposes_metrics() -> None:
assert snapshot.metrics.route_iterations >= 1 assert snapshot.metrics.route_iterations >= 1
assert snapshot.metrics.nets_routed >= 1 assert snapshot.metrics.nets_routed >= 1
assert snapshot.metrics.nodes_expanded > 0 assert snapshot.metrics.nodes_expanded > 0
assert snapshot.metrics.score_component_calls >= 0
assert snapshot.metrics.danger_map_lookup_calls >= 0
assert snapshot.metrics.move_cache_abs_misses >= 0 assert snapshot.metrics.move_cache_abs_misses >= 0
assert snapshot.metrics.guidance_match_moves >= 0
assert snapshot.metrics.guidance_match_moves_straight >= 0
assert snapshot.metrics.guidance_match_moves_bend90 >= 0
assert snapshot.metrics.guidance_match_moves_sbend >= 0
assert snapshot.metrics.guidance_bonus_applied >= 0.0
assert snapshot.metrics.guidance_bonus_applied_straight >= 0.0
assert snapshot.metrics.guidance_bonus_applied_bend90 >= 0.0
assert snapshot.metrics.guidance_bonus_applied_sbend >= 0.0
assert snapshot.metrics.ray_cast_calls >= 0 assert snapshot.metrics.ray_cast_calls >= 0
assert snapshot.metrics.ray_cast_calls_expand_forward >= 0
assert snapshot.metrics.dynamic_tree_rebuilds >= 0 assert snapshot.metrics.dynamic_tree_rebuilds >= 0
assert snapshot.metrics.visibility_corner_index_builds >= 0
assert snapshot.metrics.visibility_builds >= 0 assert snapshot.metrics.visibility_builds >= 0
assert snapshot.metrics.congestion_grid_span_cache_hits >= 0
assert snapshot.metrics.congestion_grid_span_cache_misses >= 0
assert snapshot.metrics.congestion_candidate_nets >= 0
assert snapshot.metrics.congestion_net_envelope_cache_hits >= 0
assert snapshot.metrics.congestion_net_envelope_cache_misses >= 0
assert snapshot.metrics.congestion_grid_net_cache_hits >= 0
assert snapshot.metrics.congestion_grid_net_cache_misses >= 0
assert snapshot.metrics.congestion_lazy_resolutions >= 0
assert snapshot.metrics.congestion_lazy_requeues >= 0
assert snapshot.metrics.congestion_candidate_ids >= 0
assert snapshot.metrics.verify_dynamic_candidate_nets >= 0
assert snapshot.metrics.refinement_candidates_verified >= 0
assert snapshot.metrics.pair_local_search_pairs_considered >= 0
assert snapshot.metrics.pair_local_search_attempts >= 0
assert snapshot.metrics.pair_local_search_accepts >= 0
assert snapshot.metrics.pair_local_search_nodes_expanded >= 0
def test_record_performance_baseline_script_writes_selected_scenario(tmp_path: Path) -> None: def test_record_performance_baseline_script_writes_selected_scenario(tmp_path: Path) -> None:
@ -71,236 +43,3 @@ def test_record_performance_baseline_script_writes_selected_scenario(tmp_path: P
assert payload["generator"] == "scripts/record_performance_baseline.py" assert payload["generator"] == "scripts/record_performance_baseline.py"
assert [entry["name"] for entry in payload["scenarios"]] == ["example_01_simple_route"] assert [entry["name"] for entry in payload["scenarios"]] == ["example_01_simple_route"]
assert (tmp_path / "performance.md").exists() assert (tmp_path / "performance.md").exists()
def test_diff_performance_baseline_script_writes_selected_scenario(tmp_path: Path) -> None:
repo_root = Path(__file__).resolve().parents[2]
record_script = repo_root / "scripts" / "record_performance_baseline.py"
diff_script = repo_root / "scripts" / "diff_performance_baseline.py"
baseline_dir = tmp_path / "baseline"
baseline_dir.mkdir()
output_path = tmp_path / "diff.md"
subprocess.run(
[
sys.executable,
str(record_script),
"--output-dir",
str(baseline_dir),
"--scenario",
"example_01_simple_route",
],
check=True,
)
subprocess.run(
[
sys.executable,
str(diff_script),
"--baseline",
str(baseline_dir / "performance_baseline.json"),
"--include-performance-only",
"--scenario",
"example_01_simple_route",
"--output",
str(output_path),
],
check=True,
)
report = output_path.read_text()
assert "Performance Baseline Diff" in report
assert "example_01_simple_route" in report
def test_diff_performance_baseline_script_can_append_measurement_log(tmp_path: Path) -> None:
repo_root = Path(__file__).resolve().parents[2]
record_script = repo_root / "scripts" / "record_performance_baseline.py"
diff_script = repo_root / "scripts" / "diff_performance_baseline.py"
baseline_dir = tmp_path / "baseline"
baseline_dir.mkdir()
log_path = tmp_path / "optimization.md"
subprocess.run(
[
sys.executable,
str(record_script),
"--output-dir",
str(baseline_dir),
"--scenario",
"example_01_simple_route",
],
check=True,
)
subprocess.run(
[
sys.executable,
str(diff_script),
"--baseline",
str(baseline_dir / "performance_baseline.json"),
"--include-performance-only",
"--scenario",
"example_01_simple_route",
"--metric",
"duration_s",
"--metric",
"valid_results",
"--metric",
"nodes_expanded",
"--metric",
"visibility_corner_index_builds",
"--label",
"Step 0 - Baseline",
"--notes",
"Tooling smoke test.",
"--log",
str(log_path),
],
check=True,
)
report = log_path.read_text()
assert "Step 0 - Baseline" in report
assert "Tooling smoke test." in report
assert "| example_01_simple_route | duration_s |" in report
assert "| example_01_simple_route | valid_results |" in report
assert "| example_01_simple_route | visibility_corner_index_builds |" in report
def test_diff_performance_baseline_script_renders_current_metrics_for_added_scenario(tmp_path: Path) -> None:
repo_root = Path(__file__).resolve().parents[2]
record_script = repo_root / "scripts" / "record_performance_baseline.py"
diff_script = repo_root / "scripts" / "diff_performance_baseline.py"
baseline_dir = tmp_path / "baseline"
baseline_dir.mkdir()
output_path = tmp_path / "diff_added.md"
subprocess.run(
[
sys.executable,
str(record_script),
"--output-dir",
str(baseline_dir),
"--scenario",
"example_01_simple_route",
],
check=True,
)
subprocess.run(
[
sys.executable,
str(diff_script),
"--baseline",
str(baseline_dir / "performance_baseline.json"),
"--include-performance-only",
"--scenario",
"example_07_large_scale_routing_no_warm_start",
"--metric",
"duration_s",
"--metric",
"nodes_expanded",
"--output",
str(output_path),
],
check=True,
)
report = output_path.read_text()
assert "| example_07_large_scale_routing_no_warm_start | duration_s | - |" in report
assert "| example_07_large_scale_routing_no_warm_start | nodes_expanded | - |" in report
def test_record_conflict_trace_script_writes_selected_scenario(tmp_path: Path) -> None:
repo_root = Path(__file__).resolve().parents[2]
script_path = repo_root / "scripts" / "record_conflict_trace.py"
subprocess.run(
[
sys.executable,
str(script_path),
"--output-dir",
str(tmp_path),
"--scenario",
"example_05_orientation_stress",
],
check=True,
)
payload = json.loads((tmp_path / "conflict_trace.json").read_text())
assert payload["generated_at"]
assert payload["generator"] == "scripts/record_conflict_trace.py"
assert [entry["name"] for entry in payload["scenarios"]] == ["example_05_orientation_stress"]
assert (tmp_path / "conflict_trace.md").exists()
def test_record_conflict_trace_script_supports_performance_only_scenario(tmp_path: Path) -> None:
repo_root = Path(__file__).resolve().parents[2]
script_path = repo_root / "scripts" / "record_conflict_trace.py"
subprocess.run(
[
sys.executable,
str(script_path),
"--output-dir",
str(tmp_path),
"--include-performance-only",
"--scenario",
"example_07_large_scale_routing_no_warm_start",
],
check=True,
)
payload = json.loads((tmp_path / "conflict_trace.json").read_text())
assert [entry["name"] for entry in payload["scenarios"]] == ["example_07_large_scale_routing_no_warm_start"]
def test_record_frontier_trace_script_writes_selected_scenario(tmp_path: Path) -> None:
repo_root = Path(__file__).resolve().parents[2]
script_path = repo_root / "scripts" / "record_frontier_trace.py"
subprocess.run(
[
sys.executable,
str(script_path),
"--output-dir",
str(tmp_path),
"--scenario",
"example_05_orientation_stress",
],
check=True,
)
payload = json.loads((tmp_path / "frontier_trace.json").read_text())
assert payload["generated_at"]
assert payload["generator"] == "scripts/record_frontier_trace.py"
assert [entry["name"] for entry in payload["scenarios"]] == ["example_05_orientation_stress"]
assert (tmp_path / "frontier_trace.md").exists()
def test_characterize_pair_local_search_script_writes_outputs(tmp_path: Path) -> None:
repo_root = Path(__file__).resolve().parents[2]
script_path = repo_root / "scripts" / "characterize_pair_local_search.py"
subprocess.run(
[
sys.executable,
str(script_path),
"--output-dir",
str(tmp_path),
"--num-nets",
"6",
"--seeds",
"41",
"--repeats",
"1",
],
check=True,
)
payload = json.loads((tmp_path / "pair_local_characterization.json").read_text())
assert payload["generated_at"]
assert payload["generator"] == "scripts/characterize_pair_local_search.py"
assert payload["grid"]["num_nets"] == [6]
assert payload["grid"]["seeds"] == [41]
assert payload["grid"]["repeats"] == 1
assert len(payload["cases"]) == 1
assert (tmp_path / "pair_local_characterization.md").exists()

View file

@ -2,7 +2,6 @@ from shapely.geometry import box
from inire.geometry.collision import RoutingWorld from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
from inire.router._astar_types import AStarMetrics
from inire.router.visibility import VisibilityManager from inire.router.visibility import VisibilityManager
@ -19,111 +18,3 @@ def test_point_visibility_cache_respects_max_distance() -> None:
assert len(near_corners) == 3 assert len(near_corners) == 3
assert len(far_corners) > len(near_corners) assert len(far_corners) > len(near_corners)
assert any(corner[0] >= 100.0 for corner in far_corners) assert any(corner[0] >= 100.0 for corner in far_corners)
def test_visibility_manager_is_lazy_until_queried() -> None:
engine = RoutingWorld(clearance=0.0)
engine.metrics = AStarMetrics()
engine.add_static_obstacle(box(10, 20, 20, 30))
visibility = VisibilityManager(engine)
assert visibility.corners == []
assert engine.metrics.total_visibility_corner_index_builds == 0
assert engine.metrics.total_visibility_builds == 0
visibility.ensure_corner_index_current()
assert visibility.corners
assert engine.metrics.total_visibility_corner_index_builds == 1
assert engine.metrics.total_visibility_builds == 0
def test_exact_corner_visibility_builds_graph_once_per_static_version() -> None:
engine = RoutingWorld(clearance=0.0)
engine.metrics = AStarMetrics()
engine.add_static_obstacle(box(10, 20, 20, 30))
visibility = VisibilityManager(engine)
origin = Port(10, 20, 0)
first = visibility.get_corner_visibility(origin, max_dist=100.0)
second = visibility.get_corner_visibility(origin, max_dist=100.0)
assert second == first
assert engine.metrics.total_visibility_corner_index_builds == 1
assert engine.metrics.total_visibility_builds == 1
def test_clear_cache_invalidates_without_rebuilding_and_static_change_rebuilds_lazily() -> None:
engine = RoutingWorld(clearance=0.0)
engine.metrics = AStarMetrics()
engine.add_static_obstacle(box(10, 20, 20, 30))
visibility = VisibilityManager(engine)
visibility.get_corner_visibility(Port(10, 20, 0), max_dist=100.0)
assert engine.metrics.total_visibility_corner_index_builds == 1
assert engine.metrics.total_visibility_builds == 1
visibility.clear_cache()
assert visibility.corners == []
assert engine.metrics.total_visibility_corner_index_builds == 1
assert engine.metrics.total_visibility_builds == 1
engine.add_static_obstacle(box(40, 20, 50, 30))
visible = visibility.get_corner_visibility(Port(10, 20, 0), max_dist=100.0)
assert visible == []
assert engine.metrics.total_visibility_corner_index_builds == 2
assert engine.metrics.total_visibility_builds == 2
def test_tangent_corner_candidate_query_matches_bruteforce_filter() -> None:
engine = RoutingWorld(clearance=0.0)
engine.add_static_obstacle(box(10, 20, 20, 30))
engine.add_static_obstacle(box(-35, -15, -25, -5))
engine.add_static_obstacle(box(35, -40, 45, -30))
visibility = VisibilityManager(engine)
radii = (10.0, 20.0)
min_forward = 5.0
max_forward = 60.0
tolerance = 2.0
for origin in (
Port(0, 0, 0),
Port(0, 0, 90),
Port(0, 0, 180),
Port(0, 0, 270),
):
candidate_ids = set(
visibility.get_tangent_corner_candidates(
origin,
min_forward=min_forward,
max_forward=max_forward,
radii=radii,
tolerance=tolerance,
)
)
expected_ids: set[int] = set()
if origin.r == 0:
cos_v, sin_v = 1.0, 0.0
elif origin.r == 90:
cos_v, sin_v = 0.0, 1.0
elif origin.r == 180:
cos_v, sin_v = -1.0, 0.0
else:
cos_v, sin_v = 0.0, -1.0
for idx, (cx, cy) in enumerate(visibility.corners):
dx = cx - origin.x
dy = cy - origin.y
local_x = dx * cos_v + dy * sin_v
local_y = -dx * sin_v + dy * cos_v
if local_x <= min_forward or local_x > max_forward + 0.01:
continue
nearest_radius = min(radii, key=lambda radius: abs(abs(local_y) - radius))
if abs(abs(local_y) - nearest_radius) <= tolerance:
expected_ids.add(idx)
assert candidate_ids == expected_ids

View file

@ -1,177 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
from time import perf_counter
from inire.tests.example_scenarios import _run_example_07_variant
def _parse_csv_ints(raw: str) -> tuple[int, ...]:
return tuple(int(part) for part in raw.split(",") if part.strip())
def _run_case(num_nets: int, seed: int) -> dict[str, object]:
t0 = perf_counter()
run = _run_example_07_variant(
num_nets=num_nets,
seed=seed,
warm_start_enabled=False,
)
duration_s = perf_counter() - t0
return {
"duration_s": duration_s,
"summary": {
"total_results": len(run.results_by_net),
"valid_results": sum(1 for result in run.results_by_net.values() if result.is_valid),
"reached_targets": sum(1 for result in run.results_by_net.values() if result.reached_target),
},
"metrics": asdict(run.metrics),
}
def _is_smoke_candidate(entry: dict[str, object]) -> bool:
summary = entry["summary"]
metrics = entry["metrics"]
return (
summary["valid_results"] == summary["total_results"]
and metrics["pair_local_search_accepts"] >= 1
and entry["duration_s"] <= 1.0
)
def _select_smoke_case(cases: list[dict[str, object]]) -> dict[str, object] | None:
grouped: dict[tuple[int, int], list[dict[str, object]]] = {}
for case in cases:
key = (case["num_nets"], case["seed"])
grouped.setdefault(key, []).append(case)
candidates = []
for (num_nets, seed), repeats in grouped.items():
if repeats and all(_is_smoke_candidate(repeat) for repeat in repeats):
candidates.append({"num_nets": num_nets, "seed": seed})
if not candidates:
return None
candidates.sort(key=lambda item: (item["num_nets"], item["seed"]))
return candidates[0]
def _render_markdown(payload: dict[str, object]) -> str:
lines = [
"# Pair-Local Search Characterization",
"",
f"Generated at {payload['generated_at']} by `{payload['generator']}`.",
"",
f"Grid: `num_nets={payload['grid']['num_nets']}`, `seed={payload['grid']['seeds']}`, repeats={payload['grid']['repeats']}.",
"",
"| Nets | Seed | Repeat | Duration (s) | Valid | Reached | Pair Pairs | Pair Accepts | Pair Nodes | Nodes | Checks |",
"| :-- | :-- | :-- | --: | --: | --: | --: | --: | --: | --: | --: |",
]
for case in payload["cases"]:
summary = case["summary"]
metrics = case["metrics"]
lines.append(
"| "
f"{case['num_nets']} | "
f"{case['seed']} | "
f"{case['repeat']} | "
f"{case['duration_s']:.4f} | "
f"{summary['valid_results']} | "
f"{summary['reached_targets']} | "
f"{metrics['pair_local_search_pairs_considered']} | "
f"{metrics['pair_local_search_accepts']} | "
f"{metrics['pair_local_search_nodes_expanded']} | "
f"{metrics['nodes_expanded']} | "
f"{metrics['congestion_check_calls']} |"
)
lines.extend(["", "## Recommendation", ""])
recommended = payload["recommended_smoke_scenario"]
if recommended is None:
lines.append(
"No smaller stable pair-local smoke scenario satisfied the rule "
"`valid_results == total_results`, `pair_local_search_accepts >= 1`, and `duration_s <= 1.0` across all repeats."
)
else:
lines.append(
f"Recommended smoke scenario: `num_nets={recommended['num_nets']}`, `seed={recommended['seed']}`."
)
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser(description="Characterize pair-local search across example_07-style no-warm runs.")
parser.add_argument(
"--num-nets",
default="6,8,10",
help="Comma-separated num_nets values to sweep. Default: 6,8,10.",
)
parser.add_argument(
"--seeds",
default="41,42,43",
help="Comma-separated seed values to sweep. Default: 41,42,43.",
)
parser.add_argument(
"--repeats",
type=int,
default=2,
help="Number of repeated runs per (num_nets, seed). Default: 2.",
)
parser.add_argument(
"--output-dir",
type=Path,
default=None,
help="Directory to write pair_local_characterization.json and .md into. Defaults to <repo>/docs.",
)
args = parser.parse_args()
repo_root = Path(__file__).resolve().parents[1]
output_dir = repo_root / "docs" if args.output_dir is None else args.output_dir.resolve()
output_dir.mkdir(exist_ok=True)
num_nets_values = _parse_csv_ints(args.num_nets)
seed_values = _parse_csv_ints(args.seeds)
cases: list[dict[str, object]] = []
for num_nets in num_nets_values:
for seed in seed_values:
for repeat in range(args.repeats):
case = _run_case(num_nets, seed)
case["num_nets"] = num_nets
case["seed"] = seed
case["repeat"] = repeat
cases.append(case)
payload = {
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"generator": "scripts/characterize_pair_local_search.py",
"grid": {
"num_nets": list(num_nets_values),
"seeds": list(seed_values),
"repeats": args.repeats,
},
"cases": cases,
"recommended_smoke_scenario": _select_smoke_case(cases),
}
json_path = output_dir / "pair_local_characterization.json"
markdown_path = output_dir / "pair_local_characterization.md"
json_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
markdown_path.write_text(_render_markdown(payload) + "\n")
if json_path.is_relative_to(repo_root):
print(f"Wrote {json_path.relative_to(repo_root)}")
else:
print(f"Wrote {json_path}")
if markdown_path.is_relative_to(repo_root):
print(f"Wrote {markdown_path.relative_to(repo_root)}")
else:
print(f"Wrote {markdown_path}")
if __name__ == "__main__":
main()

View file

@ -1,237 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
from inire.tests.example_scenarios import PERFORMANCE_SCENARIO_SNAPSHOTS, SCENARIO_SNAPSHOTS
from inire.results import RouteMetrics
SUMMARY_KEYS = (
"duration_s",
"valid_results",
"reached_targets",
"route_iterations",
"nets_routed",
"nodes_expanded",
"ray_cast_calls",
"moves_generated",
"moves_added",
"congestion_check_calls",
"verify_path_report_calls",
)
def _snapshot_registry(include_performance_only: bool) -> tuple[tuple[str, object], ...]:
if not include_performance_only:
return SCENARIO_SNAPSHOTS
return SCENARIO_SNAPSHOTS + PERFORMANCE_SCENARIO_SNAPSHOTS
def _available_metric_names() -> tuple[str, ...]:
return (
"duration_s",
"total_results",
"valid_results",
"reached_targets",
*RouteMetrics.__dataclass_fields__.keys(),
)
def _current_snapshots(
selected_scenarios: tuple[str, ...] | None,
*,
include_performance_only: bool,
) -> dict[str, dict[str, object]]:
allowed = None if selected_scenarios is None else set(selected_scenarios)
snapshots: dict[str, dict[str, object]] = {}
for name, run in _snapshot_registry(include_performance_only):
if allowed is not None and name not in allowed:
continue
snapshots[name] = asdict(run())
return snapshots
def _load_baseline(path: Path, selected_scenarios: tuple[str, ...] | None) -> dict[str, dict[str, object]]:
payload = json.loads(path.read_text())
allowed = None if selected_scenarios is None else set(selected_scenarios)
return {
entry["name"]: entry
for entry in payload["scenarios"]
if allowed is None or entry["name"] in allowed
}
def _metric_value(snapshot: dict[str, object], key: str) -> float | None:
if key in {"duration_s", "total_results", "valid_results", "reached_targets"}:
return float(snapshot[key])
if key not in snapshot["metrics"]:
return None
return float(snapshot["metrics"][key])
def _validate_metrics(metric_names: tuple[str, ...]) -> None:
valid_names = set(_available_metric_names())
unknown = [name for name in metric_names if name not in valid_names]
if unknown:
raise SystemExit(
f"Unknown metric name(s): {', '.join(sorted(unknown))}. "
f"Valid names are: {', '.join(_available_metric_names())}"
)
def _render_report(
baseline: dict[str, dict[str, object]],
current: dict[str, dict[str, object]],
metric_names: tuple[str, ...],
) -> str:
scenario_names = sorted(set(baseline) | set(current))
lines = [
"# Performance Baseline Diff",
"",
"| Scenario | Metric | Baseline | Current | Delta |",
"| :-- | :-- | --: | --: | --: |",
]
for scenario in scenario_names:
base_snapshot = baseline.get(scenario)
curr_snapshot = current.get(scenario)
if base_snapshot is None:
if curr_snapshot is None:
lines.append(f"| {scenario} | added | - | - | - |")
continue
for key in metric_names:
curr_value = _metric_value(curr_snapshot, key)
if curr_value is None:
lines.append(f"| {scenario} | {key} | - | - | - |")
continue
lines.append(f"| {scenario} | {key} | - | {curr_value:.4f} | - |")
continue
if curr_snapshot is None:
lines.append(f"| {scenario} | missing | - | - | - |")
continue
for key in metric_names:
base_value = _metric_value(base_snapshot, key)
curr_value = _metric_value(curr_snapshot, key)
if base_value is None:
lines.append(
f"| {scenario} | {key} | - | {curr_value:.4f} | - |"
)
continue
if curr_value is None:
lines.append(
f"| {scenario} | {key} | {base_value:.4f} | - | - |"
)
continue
lines.append(
f"| {scenario} | {key} | {base_value:.4f} | {curr_value:.4f} | {curr_value - base_value:+.4f} |"
)
return "\n".join(lines) + "\n"
def _render_log_entry(
*,
baseline_path: Path,
label: str,
notes: tuple[str, ...],
report: str,
) -> str:
lines = [
f"## {label}",
"",
f"Measured on {datetime.now().astimezone().isoformat(timespec='seconds')}.",
f"Baseline: `{baseline_path}`.",
"",
]
if notes:
lines.extend(["Findings:", ""])
lines.extend(f"- {note}" for note in notes)
lines.append("")
lines.append(report.rstrip())
lines.append("")
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser(description="Diff the committed performance baseline against a fresh run.")
parser.add_argument(
"--baseline",
type=Path,
default=Path("docs/performance_baseline.json"),
help="Baseline JSON to compare against.",
)
parser.add_argument(
"--output",
type=Path,
default=None,
help="Optional file to write the report to. Defaults to stdout.",
)
parser.add_argument(
"--scenario",
action="append",
dest="scenarios",
default=[],
help="Optional scenario name to include. May be passed more than once.",
)
parser.add_argument(
"--metric",
action="append",
dest="metrics",
default=[],
help="Optional metric to include. May be passed more than once. Defaults to the summary metric set.",
)
parser.add_argument(
"--label",
default="Measurement",
help="Section label to use when appending to a log file.",
)
parser.add_argument(
"--notes",
action="append",
dest="notes",
default=[],
help="Optional short finding to append under the measurement section. May be passed more than once.",
)
parser.add_argument(
"--log",
type=Path,
default=None,
help="Optional Markdown log file to append the rendered report to.",
)
parser.add_argument(
"--include-performance-only",
action="store_true",
help="Include performance-only snapshot scenarios that are excluded from the default baseline corpus.",
)
args = parser.parse_args()
selected = tuple(args.scenarios) if args.scenarios else None
metrics = tuple(args.metrics) if args.metrics else SUMMARY_KEYS
_validate_metrics(metrics)
baseline = _load_baseline(args.baseline, selected)
current = _current_snapshots(selected, include_performance_only=args.include_performance_only)
report = _render_report(baseline, current, metrics)
if args.output is not None:
args.output.write_text(report)
print(f"Wrote {args.output}")
elif args.log is None:
print(report, end="")
if args.log is not None:
entry = _render_log_entry(
baseline_path=args.baseline,
label=args.label,
notes=tuple(args.notes),
report=report,
)
with args.log.open("a", encoding="utf-8") as handle:
handle.write(entry)
print(f"Appended {args.log}")
if __name__ == "__main__":
main()

View file

@ -1,228 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from collections import Counter
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
from inire.results import RoutingRunResult
from inire.tests.example_scenarios import TRACE_PERFORMANCE_SCENARIO_RUNS, TRACE_SCENARIO_RUNS
def _trace_registry(include_performance_only: bool) -> tuple[tuple[str, object], ...]:
if include_performance_only:
return TRACE_SCENARIO_RUNS + TRACE_PERFORMANCE_SCENARIO_RUNS
return TRACE_SCENARIO_RUNS
def _selected_runs(
selected_scenarios: tuple[str, ...] | None,
*,
include_performance_only: bool,
) -> tuple[tuple[str, object], ...]:
if selected_scenarios is None:
return (("example_07_large_scale_routing_no_warm_start", dict(TRACE_PERFORMANCE_SCENARIO_RUNS)["example_07_large_scale_routing_no_warm_start"]),)
registry = dict(TRACE_SCENARIO_RUNS + TRACE_PERFORMANCE_SCENARIO_RUNS)
allowed_standard = dict(_trace_registry(include_performance_only))
runs = []
for name in selected_scenarios:
if name in allowed_standard:
runs.append((name, allowed_standard[name]))
continue
if name in registry:
runs.append((name, registry[name]))
continue
valid = ", ".join(sorted(registry))
raise SystemExit(f"Unknown trace scenario: {name}. Valid scenarios: {valid}")
return tuple(runs)
def _result_summary(run: RoutingRunResult) -> dict[str, object]:
return {
"total_results": len(run.results_by_net),
"valid_results": sum(1 for result in run.results_by_net.values() if result.is_valid),
"reached_targets": sum(1 for result in run.results_by_net.values() if result.reached_target),
"results_by_net": {
net_id: {
"outcome": result.outcome,
"reached_target": result.reached_target,
"report": asdict(result.report),
}
for net_id, result in run.results_by_net.items()
},
}
def _build_payload(
selected_scenarios: tuple[str, ...] | None,
*,
include_performance_only: bool,
) -> dict[str, object]:
scenarios = []
for name, run in _selected_runs(selected_scenarios, include_performance_only=include_performance_only):
result = run()
scenarios.append(
{
"name": name,
"summary": _result_summary(result),
"metrics": asdict(result.metrics),
"conflict_trace": [asdict(entry) for entry in result.conflict_trace],
}
)
return {
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"generator": "scripts/record_conflict_trace.py",
"scenarios": scenarios,
}
def _count_stage_nets(entry: dict[str, object]) -> int:
return sum(
1
for net in entry["nets"]
if net["report"]["dynamic_collision_count"] > 0
)
def _canonical_component_pair(
net_id: str,
self_component_index: int,
other_net_id: str,
other_component_index: int,
) -> tuple[tuple[str, int], tuple[str, int]]:
left = (net_id, self_component_index)
right = (other_net_id, other_component_index)
if left <= right:
return (left, right)
return (right, left)
def _render_markdown(payload: dict[str, object]) -> str:
lines = [
"# Conflict Trace",
"",
f"Generated at {payload['generated_at']} by `{payload['generator']}`.",
"",
]
for scenario in payload["scenarios"]:
lines.extend(
[
f"## {scenario['name']}",
"",
f"Results: {scenario['summary']['valid_results']} valid / "
f"{scenario['summary']['reached_targets']} reached / "
f"{scenario['summary']['total_results']} total.",
"",
"| Stage | Iteration | Conflicting Nets | Conflict Edges | Completed Nets |",
"| :-- | --: | --: | --: | --: |",
]
)
net_stage_counts: Counter[str] = Counter()
edge_counts: Counter[tuple[str, str]] = Counter()
component_pair_counts: Counter[tuple[tuple[str, int], tuple[str, int]]] = Counter()
trace_entries = scenario["conflict_trace"]
for entry in trace_entries:
lines.append(
"| "
f"{entry['stage']} | "
f"{'' if entry['iteration'] is None else entry['iteration']} | "
f"{_count_stage_nets(entry)} | "
f"{len(entry['conflict_edges'])} | "
f"{len(entry['completed_net_ids'])} |"
)
seen_component_pairs: set[tuple[tuple[str, int], tuple[str, int]]] = set()
for edge in entry["conflict_edges"]:
edge_counts[tuple(edge)] += 1
for net in entry["nets"]:
if net["report"]["dynamic_collision_count"] > 0:
net_stage_counts[net["net_id"]] += 1
for component_conflict in net["component_conflicts"]:
pair = _canonical_component_pair(
net["net_id"],
component_conflict["self_component_index"],
component_conflict["other_net_id"],
component_conflict["other_component_index"],
)
seen_component_pairs.add(pair)
for pair in seen_component_pairs:
component_pair_counts[pair] += 1
lines.extend(["", "Top nets by traced dynamic-collision stages:", ""])
if net_stage_counts:
for net_id, count in net_stage_counts.most_common(10):
lines.append(f"- `{net_id}`: {count}")
else:
lines.append("- None")
lines.extend(["", "Top net pairs by frequency:", ""])
if edge_counts:
for (left, right), count in edge_counts.most_common(10):
lines.append(f"- `{left}` <-> `{right}`: {count}")
else:
lines.append("- None")
lines.extend(["", "Top component pairs by frequency:", ""])
if component_pair_counts:
for pair, count in component_pair_counts.most_common(10):
(left_net, left_index), (right_net, right_index) = pair
lines.append(f"- `{left_net}[{left_index}]` <-> `{right_net}[{right_index}]`: {count}")
else:
lines.append("- None")
lines.append("")
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser(description="Record conflict-trace artifacts for selected trace scenarios.")
parser.add_argument(
"--scenario",
action="append",
dest="scenarios",
default=[],
help="Optional trace scenario name to include. May be passed more than once.",
)
parser.add_argument(
"--include-performance-only",
action="store_true",
help="Include performance-only trace scenarios when selecting from the standard registry.",
)
parser.add_argument(
"--output-dir",
type=Path,
default=None,
help="Directory to write conflict_trace.json and conflict_trace.md into. Defaults to <repo>/docs.",
)
args = parser.parse_args()
repo_root = Path(__file__).resolve().parents[1]
output_dir = repo_root / "docs" if args.output_dir is None else args.output_dir.resolve()
output_dir.mkdir(exist_ok=True)
selected = tuple(args.scenarios) if args.scenarios else None
payload = _build_payload(selected, include_performance_only=args.include_performance_only)
json_path = output_dir / "conflict_trace.json"
markdown_path = output_dir / "conflict_trace.md"
json_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
markdown_path.write_text(_render_markdown(payload) + "\n")
if json_path.is_relative_to(repo_root):
print(f"Wrote {json_path.relative_to(repo_root)}")
else:
print(f"Wrote {json_path}")
if markdown_path.is_relative_to(repo_root):
print(f"Wrote {markdown_path.relative_to(repo_root)}")
else:
print(f"Wrote {markdown_path}")
if __name__ == "__main__":
main()

View file

@ -1,205 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from collections import Counter
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
from inire.tests.example_scenarios import TRACE_PERFORMANCE_SCENARIO_RUNS, TRACE_SCENARIO_RUNS
def _trace_registry(include_performance_only: bool) -> tuple[tuple[str, object], ...]:
if include_performance_only:
return TRACE_SCENARIO_RUNS + TRACE_PERFORMANCE_SCENARIO_RUNS
return TRACE_SCENARIO_RUNS
def _selected_runs(
selected_scenarios: tuple[str, ...] | None,
*,
include_performance_only: bool,
) -> tuple[tuple[str, object], ...]:
if selected_scenarios is None:
default_registry = dict(TRACE_PERFORMANCE_SCENARIO_RUNS)
return (("example_07_large_scale_routing_no_warm_start", default_registry["example_07_large_scale_routing_no_warm_start"]),)
registry = dict(TRACE_SCENARIO_RUNS + TRACE_PERFORMANCE_SCENARIO_RUNS)
allowed_standard = dict(_trace_registry(include_performance_only))
runs = []
for name in selected_scenarios:
if name in allowed_standard:
runs.append((name, allowed_standard[name]))
continue
if name in registry:
runs.append((name, registry[name]))
continue
valid = ", ".join(sorted(registry))
raise SystemExit(f"Unknown trace scenario: {name}. Valid scenarios: {valid}")
return tuple(runs)
def _build_payload(
selected_scenarios: tuple[str, ...] | None,
*,
include_performance_only: bool,
) -> dict[str, object]:
scenarios = []
for name, run in _selected_runs(selected_scenarios, include_performance_only=include_performance_only):
result = run()
scenarios.append(
{
"name": name,
"summary": {
"total_results": len(result.results_by_net),
"valid_results": sum(1 for entry in result.results_by_net.values() if entry.is_valid),
"reached_targets": sum(1 for entry in result.results_by_net.values() if entry.reached_target),
},
"metrics": asdict(result.metrics),
"frontier_trace": [asdict(entry) for entry in result.frontier_trace],
}
)
return {
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"generator": "scripts/record_frontier_trace.py",
"scenarios": scenarios,
}
def _render_markdown(payload: dict[str, object]) -> str:
lines = [
"# Frontier Trace",
"",
f"Generated at {payload['generated_at']} by `{payload['generator']}`.",
"",
]
for scenario in payload["scenarios"]:
lines.extend(
[
f"## {scenario['name']}",
"",
f"Results: {scenario['summary']['valid_results']} valid / "
f"{scenario['summary']['reached_targets']} reached / "
f"{scenario['summary']['total_results']} total.",
"",
"| Net | Hotspots | Closed-Set | Hard Collision | Self Collision | Cost | Samples |",
"| :-- | --: | --: | --: | --: | --: | --: |",
]
)
reason_counts: Counter[str] = Counter()
hotspot_counts: Counter[tuple[str, int]] = Counter()
for net_trace in scenario["frontier_trace"]:
sample_count = len(net_trace["samples"])
lines.append(
"| "
f"{net_trace['net_id']} | "
f"{len(net_trace['hotspot_bounds'])} | "
f"{net_trace['pruned_closed_set']} | "
f"{net_trace['pruned_hard_collision']} | "
f"{net_trace['pruned_self_collision']} | "
f"{net_trace['pruned_cost']} | "
f"{sample_count} |"
)
reason_counts["closed_set"] += net_trace["pruned_closed_set"]
reason_counts["hard_collision"] += net_trace["pruned_hard_collision"]
reason_counts["self_collision"] += net_trace["pruned_self_collision"]
reason_counts["cost"] += net_trace["pruned_cost"]
for sample in net_trace["samples"]:
hotspot_counts[(net_trace["net_id"], sample["hotspot_index"])] += 1
lines.extend(["", "Prune totals by reason:", ""])
if reason_counts:
for reason, count in reason_counts.most_common():
lines.append(f"- `{reason}`: {count}")
else:
lines.append("- None")
lines.extend(["", "Top traced hotspots by sample count:", ""])
if hotspot_counts:
for (net_id, hotspot_index), count in hotspot_counts.most_common(10):
lines.append(f"- `{net_id}` hotspot `{hotspot_index}`: {count}")
else:
lines.append("- None")
lines.extend(["", "Per-net sampled reason/move breakdown:", ""])
if scenario["frontier_trace"]:
for net_trace in scenario["frontier_trace"]:
reason_move_counts: Counter[tuple[str, str]] = Counter()
hotspot_sample_counts: Counter[int] = Counter()
for sample in net_trace["samples"]:
reason_move_counts[(sample["reason"], sample["move_type"])] += 1
hotspot_sample_counts[sample["hotspot_index"]] += 1
lines.append(f"- `{net_trace['net_id']}`")
if reason_move_counts:
top_pairs = ", ".join(
f"`{reason}` x `{move}` = {count}"
for (reason, move), count in reason_move_counts.most_common(3)
)
lines.append(f" sampled reasons: {top_pairs}")
else:
lines.append(" sampled reasons: none")
if hotspot_sample_counts:
top_hotspots = ", ".join(
f"`{hotspot}` = {count}" for hotspot, count in hotspot_sample_counts.most_common(3)
)
lines.append(f" hotspot samples: {top_hotspots}")
else:
lines.append(" hotspot samples: none")
else:
lines.append("- None")
lines.append("")
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser(description="Record frontier-trace artifacts for selected trace scenarios.")
parser.add_argument(
"--scenario",
action="append",
dest="scenarios",
default=[],
help="Optional trace scenario name to include. May be passed more than once.",
)
parser.add_argument(
"--include-performance-only",
action="store_true",
help="Include performance-only trace scenarios when selecting from the standard registry.",
)
parser.add_argument(
"--output-dir",
type=Path,
default=None,
help="Directory to write frontier_trace.json and frontier_trace.md into. Defaults to <repo>/docs.",
)
args = parser.parse_args()
repo_root = Path(__file__).resolve().parents[1]
output_dir = repo_root / "docs" if args.output_dir is None else args.output_dir.resolve()
output_dir.mkdir(exist_ok=True)
selected = tuple(args.scenarios) if args.scenarios else None
payload = _build_payload(selected, include_performance_only=args.include_performance_only)
json_path = output_dir / "frontier_trace.json"
markdown_path = output_dir / "frontier_trace.md"
json_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
markdown_path.write_text(_render_markdown(payload) + "\n")
if json_path.is_relative_to(repo_root):
print(f"Wrote {json_path.relative_to(repo_root)}")
else:
print(f"Wrote {json_path}")
if markdown_path.is_relative_to(repo_root):
print(f"Wrote {markdown_path.relative_to(repo_root)}")
else:
print(f"Wrote {markdown_path}")
if __name__ == "__main__":
main()

View file

@ -7,7 +7,7 @@ from dataclasses import asdict
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
from inire.tests.example_scenarios import PERFORMANCE_SCENARIO_SNAPSHOTS, SCENARIO_SNAPSHOTS from inire.tests.example_scenarios import SCENARIO_SNAPSHOTS
SUMMARY_METRICS = ( SUMMARY_METRICS = (
@ -24,20 +24,10 @@ SUMMARY_METRICS = (
) )
def _snapshot_registry(include_performance_only: bool) -> tuple[tuple[str, object], ...]: def _build_payload(selected_scenarios: tuple[str, ...] | None = None) -> dict[str, object]:
if not include_performance_only:
return SCENARIO_SNAPSHOTS
return SCENARIO_SNAPSHOTS + PERFORMANCE_SCENARIO_SNAPSHOTS
def _build_payload(
selected_scenarios: tuple[str, ...] | None = None,
*,
include_performance_only: bool = False,
) -> dict[str, object]:
allowed = None if selected_scenarios is None else set(selected_scenarios) allowed = None if selected_scenarios is None else set(selected_scenarios)
snapshots = [] snapshots = []
for name, run in _snapshot_registry(include_performance_only): for name, run in SCENARIO_SNAPSHOTS:
if allowed is not None and name not in allowed: if allowed is not None and name not in allowed:
continue continue
snapshots.append(run()) snapshots.append(run())
@ -56,7 +46,6 @@ def _render_markdown(payload: dict[str, object]) -> str:
f"Generated on {payload['generated_on']} by `{payload['generator']}`.", f"Generated on {payload['generated_on']} by `{payload['generator']}`.",
"", "",
"The full machine-readable snapshot lives in `docs/performance_baseline.json`.", "The full machine-readable snapshot lives in `docs/performance_baseline.json`.",
"Use `scripts/diff_performance_baseline.py` to compare a fresh run against that snapshot.",
"", "",
"| Scenario | Duration (s) | Total | Valid | Reached | Iter | Nets Routed | Nodes | Ray Casts | Moves Gen | Moves Added | Dyn Tree | Visibility Builds | Congestion Checks | Verify Calls |", "| Scenario | Duration (s) | Total | Valid | Reached | Iter | Nets Routed | Nodes | Ray Casts | Moves Gen | Moves Added | Dyn Tree | Visibility Builds | Congestion Checks | Verify Calls |",
"| :-- | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: |", "| :-- | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: |",
@ -88,7 +77,6 @@ def _render_markdown(payload: dict[str, object]) -> str:
"## Full Counter Set", "## Full Counter Set",
"", "",
"Each scenario entry in `docs/performance_baseline.json` records the full `RouteMetrics` snapshot, including cache, index, congestion, and verification counters.", "Each scenario entry in `docs/performance_baseline.json` records the full `RouteMetrics` snapshot, including cache, index, congestion, and verification counters.",
"These counters are currently observational only and are not enforced as CI regression gates.",
"", "",
"Tracked metric keys:", "Tracked metric keys:",
"", "",
@ -113,11 +101,6 @@ def main() -> None:
default=[], default=[],
help="Optional scenario name to include. May be passed more than once.", help="Optional scenario name to include. May be passed more than once.",
) )
parser.add_argument(
"--include-performance-only",
action="store_true",
help="Include performance-only snapshot scenarios that are excluded from the default baseline corpus.",
)
args = parser.parse_args() args = parser.parse_args()
repo_root = Path(__file__).resolve().parents[1] repo_root = Path(__file__).resolve().parents[1]
@ -125,7 +108,7 @@ def main() -> None:
docs_dir.mkdir(exist_ok=True) docs_dir.mkdir(exist_ok=True)
selected = tuple(args.scenarios) if args.scenarios else None selected = tuple(args.scenarios) if args.scenarios else None
payload = _build_payload(selected, include_performance_only=args.include_performance_only) payload = _build_payload(selected)
json_path = docs_dir / "performance_baseline.json" json_path = docs_dir / "performance_baseline.json"
markdown_path = docs_dir / "performance.md" markdown_path = docs_dir / "performance.md"