Compare commits

..

4 commits

38 changed files with 13878 additions and 346 deletions

99
DOCS.md
View file

@ -128,8 +128,65 @@ Use `RoutingProblem.initial_paths` to provide semantic per-net seeds. Seeds are
| Field | Default | Description |
| :-- | :-- | :-- |
| `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. RouteMetrics
## 7. Conflict Trace
`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.
@ -148,11 +205,20 @@ Use `RoutingProblem.initial_paths` to provide semantic per-net seeds. Seeds are
- `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.
- `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
- `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.
- `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.
- `hard_collision_cache_hits`: Reuse count for the hard-collision cache.
- `congestion_cache_hits` / `congestion_cache_misses`: Per-search congestion-cache activity.
@ -165,29 +231,50 @@ Use `RoutingProblem.initial_paths` to provide semantic per-net seeds. Seeds are
- `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_net_tree_rebuilds`: Number of net-width-specific static STRtree rebuilds.
- `visibility_builds`: Number of static visibility-graph rebuilds.
- `visibility_corner_pairs_checked`: Number of corner-pair visibility probes considered while building that graph.
- `visibility_corner_queries` / `visibility_corner_hits`: Precomputed-corner visibility query activity.
- `visibility_corner_index_builds`: Number of lazy corner-index rebuilds.
- `visibility_builds`: Number of exact corner-visibility graph rebuilds.
- `visibility_corner_pairs_checked`: Number of corner-pair visibility probes considered while building the exact graph.
- `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.
- `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_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_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.
### Verification Counters
- `verify_path_report_calls`: Number of full path-verification passes.
- `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.
## 8. Internal Modules
### Local Search Counters
- `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=...)`.
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.
## 9. Tuning Notes
## 11. Tuning Notes
### Speed vs. optimality

View file

@ -16,7 +16,7 @@
- `inire/geometry/primitives.py`: Integer Manhattan ports and small transform helpers.
- `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/static_obstacle_index.py` and `inire/geometry/dynamic_path_index.py`: Spatial-index management for static obstacles and routed paths.
- `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/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/refiner.py`: Post-route path simplification for completed paths.
@ -39,7 +39,10 @@ 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.
- 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.
- 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.
## Performance Visibility

2533
docs/conflict_trace.json Normal file

File diff suppressed because it is too large Load diff

57
docs/conflict_trace.md Normal file
View file

@ -0,0 +1,57 @@
# 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

120
docs/frontier_trace.json Normal file
View file

@ -0,0 +1,120 @@
{
"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
}
}
]
}

23
docs/frontier_trace.md Normal file
View file

@ -0,0 +1,23 @@
# 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

@ -0,0 +1,30 @@
# 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,25 +1,41 @@
# Performance Baseline
Generated on 2026-03-31 by `scripts/record_performance_baseline.py`.
Generated on 2026-04-02 by `scripts/record_performance_baseline.py`.
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 |
| :-- | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: |
| example_01_simple_route | 0.0042 | 1 | 1 | 1 | 1 | 1 | 2 | 22 | 11 | 7 | 2 | 2 | 0 | 3 |
| example_02_congestion_resolution | 0.3335 | 3 | 3 | 3 | 1 | 3 | 366 | 1176 | 1413 | 668 | 8 | 4 | 0 | 35 |
| example_03_locked_paths | 0.1810 | 2 | 2 | 2 | 2 | 2 | 191 | 681 | 904 | 307 | 5 | 4 | 0 | 14 |
| 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.2438 | 3 | 3 | 3 | 2 | 6 | 286 | 1243 | 1624 | 681 | 12 | 3 | 412 | 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 | 1.3759 | 10 | 10 | 10 | 1 | 10 | 78 | 11151 | 372 | 227 | 20 | 11 | 0 | 30 |
| 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.0052 | 1 | 0 | 0 | 1 | 1 | 3 | 13 | 16 | 10 | 1 | 0 | 0 | 1 |
| example_01_simple_route | 0.0040 | 1 | 1 | 1 | 1 | 1 | 2 | 10 | 11 | 7 | 0 | 0 | 0 | 4 |
| example_02_congestion_resolution | 0.3378 | 3 | 3 | 3 | 1 | 3 | 366 | 1164 | 1413 | 668 | 0 | 0 | 0 | 38 |
| example_03_locked_paths | 0.1929 | 2 | 2 | 2 | 2 | 2 | 191 | 657 | 904 | 307 | 0 | 0 | 0 | 16 |
| example_04_sbends_and_radii | 0.0279 | 2 | 2 | 2 | 1 | 2 | 15 | 70 | 123 | 65 | 0 | 0 | 0 | 8 |
| example_05_orientation_stress | 0.2367 | 3 | 3 | 3 | 2 | 6 | 299 | 1284 | 1691 | 696 | 0 | 0 | 149 | 18 |
| example_06_bend_collision_models | 0.1998 | 3 | 3 | 3 | 3 | 3 | 240 | 682 | 1026 | 629 | 0 | 0 | 0 | 12 |
| example_07_large_scale_routing | 0.2005 | 10 | 10 | 10 | 1 | 10 | 78 | 383 | 372 | 227 | 0 | 0 | 0 | 40 |
| example_08_custom_bend_geometry | 0.0176 | 2 | 2 | 2 | 2 | 2 | 18 | 56 | 78 | 56 | 0 | 0 | 0 | 8 |
| example_09_unroutable_best_effort | 0.0058 | 1 | 0 | 0 | 1 | 1 | 3 | 13 | 16 | 10 | 0 | 0 | 0 | 1 |
## Full Counter Set
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:
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
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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
import numpy
@ -28,6 +29,50 @@ 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))
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:
"""
Internal spatial state for collision detection, congestion, and verification.
@ -96,14 +141,34 @@ class RoutingWorld:
def _ensure_dynamic_grid(self) -> None:
self._dynamic_paths.ensure_grid()
def add_path(self, net_id: str, geometry: Sequence[Polygon], dilated_geometry: Sequence[Polygon]) -> None:
self._dynamic_paths.add_path(net_id, geometry, dilated_geometry=dilated_geometry)
def add_path(
self,
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:
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:
reach = self.ray_cast(start_port, start_port.r, max_dist=length + 0.01, net_width=net_width)
reach = self.ray_cast(
start_port,
start_port.r,
max_dist=length + 0.01,
net_width=net_width,
caller="straight_static",
)
return reach < length - 0.001
def _is_in_safety_zone_fast(self, idx: int, start_port: Port | None, end_port: Port | None) -> bool:
@ -229,105 +294,390 @@ class RoutingWorld:
return False
def _check_real_congestion(self, result: ComponentResult, net_id: str) -> int:
def _check_real_congestion(
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
self._ensure_dynamic_tree()
if dynamic_paths.tree is None:
return 0
total_bounds = result.total_dilated_bounds
dynamic_bounds = dynamic_paths.bounds_array
possible_total = (
(total_bounds[0] < dynamic_bounds[:, 2])
& (total_bounds[2] > dynamic_bounds[:, 0])
& (total_bounds[1] < dynamic_bounds[:, 3])
& (total_bounds[3] > dynamic_bounds[:, 1])
)
valid_hits_mask = dynamic_paths.net_ids_array != net_id
if not numpy.any(possible_total & valid_hits_mask):
return 0
geometries_to_test = result.dilated_collision_geometry
res_indices, tree_indices = dynamic_paths.tree.query(geometries_to_test, predicate="intersects")
if tree_indices.size == 0:
return 0
hit_net_ids = numpy.take(dynamic_paths.net_ids_array, tree_indices)
unique_other_nets = numpy.unique(hit_net_ids[hit_net_ids != net_id])
if unique_other_nets.size == 0:
return 0
tree_geometries = dynamic_paths.tree.geometries
real_hits_count = 0
for other_net_id in unique_other_nets:
other_mask = hit_net_ids == other_net_id
sub_tree_indices = tree_indices[other_mask]
sub_res_indices = res_indices[other_mask]
for other_net_id, other_obj_ids in candidates_by_net.items():
found_real = False
for index in range(len(sub_tree_indices)):
for obj_id, test_geometry_indexes in other_obj_ids.items():
tree_geometry = dynamic_paths.dilated[obj_id]
for test_geometry_index in test_geometry_indexes:
test_geometry = geometries_to_test[test_geometry_index]
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:
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 real_hits_count
return DynamicCongestionDetail(soft_overlap_count=real_hits_count)
def check_move_congestion(self, result: ComponentResult, net_id: str) -> int:
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(
self,
bounds: tuple[float, float, float, float],
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(
self,
bounds: tuple[float, float, float, float],
other_net_id: str,
) -> 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(
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,
) -> 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(
self,
result: ComponentResult,
net_id: str,
*,
frozen_net_ids: frozenset[str] = frozenset(),
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,
) -> DynamicCongestionDetail:
if self.metrics is not None:
self.metrics.total_congestion_check_calls += 1
dynamic_paths = self._dynamic_paths
if not dynamic_paths.geometries:
return 0
return DynamicCongestionDetail()
total_bounds = result.total_dilated_bounds
self._ensure_dynamic_grid()
dynamic_grid = dynamic_paths.grid
if not dynamic_grid:
return 0
candidates_by_net = self._collect_congestion_candidates(
result,
net_id,
net_envelope_cache,
grid_net_cache,
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,
)
gx_min, gy_min, gx_max, gy_max = grid_cell_span(total_bounds, self.grid_cell_size)
if gx_min == gx_max and gy_min == gy_max:
cell = (gx_min, gy_min)
if cell in dynamic_grid:
for obj_id in dynamic_grid[cell]:
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:
def verify_path_details(
self,
net_id: str,
components: Sequence[ComponentResult],
*,
capture_component_conflicts: bool = False,
) -> PathVerificationDetail:
if self.metrics is not None:
self.metrics.total_verify_path_report_calls += 1
static_collision_count = 0
dynamic_collision_count = 0
self_collision_count = 0
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
dynamic_paths = self._dynamic_paths
@ -350,52 +700,78 @@ class RoutingWorld:
if not self._is_in_safety_zone(polygon, obj_id, None, None):
static_collision_count += 1
self._ensure_dynamic_tree()
if dynamic_paths.tree is not None:
tree_geometries = dynamic_paths.tree.geometries
for component in components:
if dynamic_paths.dilated:
for component_index, component in enumerate(components):
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 = []
for index in range(len(tree_indices)):
if hit_net_ids[index] == str(net_id):
for new_geometry in test_geometries:
for hit_net_id in self._get_verify_net_envelope_candidates(new_geometry.bounds, str(net_id)):
for obj_id in self._get_verify_grid_span_obj_ids(new_geometry.bounds, hit_net_id):
if not _bounds_overlap(new_geometry.bounds, dynamic_paths.dilated_bounds[obj_id]):
continue
if self.metrics is not None:
self.metrics.total_verify_dynamic_exact_pair_checks += 1
new_geometry = test_geometries[res_indices[index]]
tree_geometry = tree_geometries[tree_indices[index]]
if not new_geometry.touches(tree_geometry) and new_geometry.intersection(tree_geometry).area > 1e-7:
component_hits.append(hit_net_ids[index])
tree_geometry = dynamic_paths.dilated[obj_id]
if _has_non_touching_overlap(new_geometry, tree_geometry):
component_hits.append(hit_net_id)
if capture_component_conflicts:
component_conflicts.add(
(
component_index,
hit_net_id,
dynamic_paths.component_indexes[obj_id],
)
)
break
if component_hits:
dynamic_collision_count += len(numpy.unique(component_hits))
unique_hits = tuple(sorted(set(component_hits)))
dynamic_collision_count += len(unique_hits)
conflicting_net_ids.update(unique_hits)
for index, component in enumerate(components):
for other_index in range(index + 2, len(components)):
if components_overlap(component, components[other_index], prefer_actual=True):
self_collision_count += 1
return RoutingReport(
return PathVerificationDetail(
report=RoutingReport(
static_collision_count=static_collision_count,
dynamic_collision_count=dynamic_collision_count,
self_collision_count=self_collision_count,
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(
self,
origin: Port,
angle_deg: float,
max_dist: float = 2000.0,
net_width: float | None = None,
caller: str = "other",
) -> float:
if self.metrics is not None:
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
tree: STRtree | None
is_rect_array: numpy.ndarray | None

View file

@ -21,10 +21,20 @@ class DynamicPathIndex:
"engine",
"index",
"geometries",
"component_indexes",
"dilated",
"dilated_bounds",
"net_envelope_index",
"net_envelopes",
"net_envelope_obj_ids",
"net_envelope_obj_to_net",
"tree",
"obj_ids",
"grid",
"grid_net_obj_ids",
"grid_net_counts",
"obj_cells",
"net_to_obj_ids",
"id_counter",
"net_ids_array",
"bounds_array",
@ -34,17 +44,71 @@ class DynamicPathIndex:
self.engine = engine
self.index = rtree.index.Index()
self.geometries: dict[int, tuple[str, Polygon]] = {}
self.component_indexes: dict[int, int] = {}
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.obj_ids: numpy.ndarray = numpy.array([], dtype=numpy.int32)
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.net_ids_array = numpy.array([], dtype=object)
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:
self.tree = None
self.grid = {}
self.grid_net_obj_ids = {}
self.grid_net_counts = {}
self.obj_cells = {}
def ensure_tree(self) -> None:
if self.tree is None and self.dilated:
@ -65,33 +129,105 @@ class DynamicPathIndex:
self.engine.metrics.total_dynamic_grid_rebuilds += 1
cell_size = self.engine.grid_cell_size
for obj_id, polygon in self.dilated.items():
for cell in iter_grid_cells(polygon.bounds, cell_size):
self.grid.setdefault(cell, []).append(obj_id)
self._register_grid_membership(obj_id, self.geometries[obj_id][0], polygon.bounds, cell_size=cell_size)
def add_path(self, net_id: str, geometry: Sequence[Polygon], dilated_geometry: Sequence[Polygon]) -> None:
self.invalidate_queries()
def _register_grid_membership(
self,
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:
self.engine.metrics.total_dynamic_path_objects_added += len(geometry)
cell_size = self.engine.grid_cell_size
for index, polygon in enumerate(geometry):
obj_id = self.id_counter
self.id_counter += 1
dilated = dilated_geometry[index]
dilated_bounds = dilated.bounds
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.index.insert(obj_id, dilated.bounds)
self.dilated_bounds[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:
to_remove = [obj_id for obj_id, (existing_net_id, _) in self.geometries.items() if existing_net_id == net_id]
to_remove = list(self.net_to_obj_ids.get(net_id, ()))
self.remove_obj_ids(to_remove)
def remove_obj_ids(self, obj_ids: list[int]) -> None:
if not obj_ids:
return
self.invalidate_queries()
if self.engine.metrics is not None:
self.engine.metrics.total_dynamic_path_objects_removed += len(obj_ids)
affected_nets: set[str] = set()
for obj_id in obj_ids:
self.index.delete(obj_id, self.dilated[obj_id].bounds)
net_id, _ = self.geometries[obj_id]
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.component_indexes[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,6 +105,8 @@ class RefinementOptions:
@dataclass(frozen=True, slots=True)
class DiagnosticsOptions:
capture_expanded: bool = False
capture_conflict_trace: bool = False
capture_frontier_trace: bool = False
@dataclass(frozen=True, slots=True)

View file

@ -12,6 +12,8 @@ if TYPE_CHECKING:
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)
@ -30,6 +32,52 @@ class RoutingReport:
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)
class RouteMetrics:
nodes_expanded: int
@ -45,14 +93,48 @@ class RouteMetrics:
warm_start_paths_used: int
refine_path_calls: 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_misses: int
move_cache_rel_hits: 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
hard_collision_cache_hits: int
congestion_cache_hits: 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_removed: int
dynamic_tree_rebuilds: int
@ -60,21 +142,47 @@ class RouteMetrics:
static_tree_rebuilds: int
static_raw_tree_rebuilds: int
static_net_tree_rebuilds: int
visibility_corner_index_builds: int
visibility_builds: int
visibility_corner_pairs_checked: int
visibility_corner_queries: int
visibility_corner_hits: int
visibility_corner_queries_exact: int
visibility_corner_hits_exact: int
visibility_point_queries: int
visibility_point_cache_hits: 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_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_exact_geometry_checks: int
congestion_check_calls: int
congestion_lazy_resolutions: int
congestion_lazy_requeues: int
congestion_candidate_ids: int
congestion_exact_pair_checks: int
verify_path_report_calls: int
verify_static_buffer_ops: int
verify_dynamic_candidate_nets: 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)
@ -121,3 +229,5 @@ class RoutingRunResult:
results_by_net: dict[str, RoutingResult]
metrics: RouteMetrics
expanded_nodes: tuple[tuple[int, int, int], ...] = ()
conflict_trace: tuple[ConflictTraceEntry, ...] = ()
frontier_trace: tuple[NetFrontierTrace, ...] = ()

View file

@ -26,6 +26,11 @@ def process_move(
context: AStarContext,
metrics: AStarMetrics,
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,
move_class: MoveKind,
params: tuple,
@ -109,6 +114,11 @@ def process_move(
context,
metrics,
congestion_cache,
congestion_presence_cache,
congestion_candidate_precheck_cache,
congestion_net_envelope_cache,
congestion_grid_net_cache,
congestion_grid_span_cache,
config,
move_class,
abs_key,
@ -126,15 +136,23 @@ def add_node(
context: AStarContext,
metrics: AStarMetrics,
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,
move_type: MoveKind,
cache_key: tuple,
) -> None:
frontier_trace = config.frontier_trace
metrics.moves_generated += 1
metrics.total_moves_generated += 1
state = result.end_port.as_tuple()
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 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
@ -143,6 +161,8 @@ def add_node(
end_p = result.end_port
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
metrics.pruned_hard_collision += 1
metrics.total_pruned_hard_collision += 1
@ -159,41 +179,100 @@ def add_node(
collision_found = ce.check_move_static(result, start_port=parent_p, end_port=end_p)
if collision_found:
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.total_pruned_hard_collision += 1
return
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 frontier_trace is not None:
frontier_trace.record("self_collision", move_type, parent.port.as_tuple(), state, result.total_dilated_bounds)
return
move_cost = context.cost_evaluator.score_component(
result,
start_port=parent_p,
)
move_cost += total_overlaps * context.congestion_penalty
next_seed_index = None
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 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.total_pruned_cost += 1
return
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.total_pruned_cost += 1
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
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.total_pruned_closed_set += 1
return
@ -203,6 +282,16 @@ def add_node(
target,
min_bend_radius=context.min_bend_radius,
)
heapq.heappush(open_set, AStarNode(result.end_port, g_cost, h_cost, parent, result))
heapq.heappush(
open_set,
AStarNode(
result.end_port,
g_cost,
h_cost,
parent,
result,
seed_index=next_seed_index,
),
)
metrics.moves_added += 1
metrics.total_moves_added += 1

View file

@ -18,6 +18,27 @@ def _quantized_lengths(values: list[float], max_reach: float) -> list[int]:
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:
abs_offset = abs(offset)
if abs_offset <= TOLERANCE_LINEAR or radius <= 0 or abs_offset >= 2.0 * radius:
@ -63,19 +84,25 @@ def _visible_straight_candidates(
return []
visibility_manager = context.visibility_manager
visibility_manager._ensure_current()
visibility_manager.ensure_corner_index_current()
context.metrics.total_visibility_tangent_candidate_scans += 1
max_bend_radius = max(search_options.bend_radii, default=0.0)
if max_bend_radius <= 0 or not visibility_manager.corners:
return []
reach = max_reach + max_bend_radius
bounds = (current.x - reach, current.y - reach, current.x + reach, current.y + reach)
candidate_ids = list(visibility_manager.corner_index.intersection(bounds))
candidate_ids = visibility_manager.get_tangent_corner_candidates(
current,
min_forward=search_options.min_straight_length,
max_forward=reach,
radii=search_options.bend_radii,
)
if not candidate_ids:
return []
scored: list[tuple[float, float, float, float, float]] = []
for idx in candidate_ids:
context.metrics.total_visibility_tangent_candidate_corner_checks += 1
cx, cy = visibility_manager.corners[idx]
dx = cx - current.x
dy = cy - current.y
@ -101,8 +128,15 @@ def _visible_straight_candidates(
collision_engine = context.cost_evaluator.collision_engine
tangent_candidates: set[int] = set()
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))
corner_reach = collision_engine.ray_cast(current, angle, max_dist=dist + 0.05, net_width=net_width)
corner_reach = collision_engine.ray_cast(
current,
angle,
max_dist=dist + 0.05,
net_width=net_width,
caller="visibility_tangent",
)
if corner_reach < dist - 0.01:
continue
qlen = int(round(length))
@ -132,6 +166,11 @@ def expand_moves(
context: AStarContext,
metrics: AStarMetrics,
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,
) -> None:
search_options = context.options.search
@ -156,7 +195,13 @@ def expand_moves(
dy_local = perp_t
if proj_t > 0 and abs(perp_t) < 1e-6 and cp.r == target.r:
max_reach = context.cost_evaluator.collision_engine.ray_cast(cp, cp.r, proj_t + 1.0, net_width=net_width)
max_reach = context.cost_evaluator.collision_engine.ray_cast(
cp,
cp.r,
proj_t + 1.0,
net_width=net_width,
caller="expand_snap",
)
if max_reach >= proj_t - 0.01 and (
prev_straight_length is None or proj_t < prev_straight_length - TOLERANCE_LINEAR
):
@ -170,12 +215,25 @@ def expand_moves(
context,
metrics,
congestion_cache,
congestion_presence_cache,
congestion_candidate_precheck_cache,
congestion_net_envelope_cache,
congestion_grid_net_cache,
congestion_grid_span_cache,
config,
"straight",
(int(round(proj_t)),),
)
max_reach = context.cost_evaluator.collision_engine.ray_cast(cp, cp.r, search_options.max_straight_length, net_width=net_width)
max_reach = context.cost_evaluator.collision_engine.ray_cast(
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 = [
search_options.min_straight_length,
max_reach,
@ -221,6 +279,11 @@ def expand_moves(
context,
metrics,
congestion_cache,
congestion_presence_cache,
congestion_candidate_precheck_cache,
congestion_net_envelope_cache,
congestion_grid_net_cache,
congestion_grid_span_cache,
config,
"straight",
(length,),
@ -249,6 +312,11 @@ def expand_moves(
context,
metrics,
congestion_cache,
congestion_presence_cache,
congestion_candidate_precheck_cache,
congestion_net_envelope_cache,
congestion_grid_net_cache,
congestion_grid_span_cache,
config,
"bend90",
(radius, direction),
@ -283,6 +351,11 @@ def expand_moves(
context,
metrics,
congestion_cache,
congestion_presence_cache,
congestion_candidate_precheck_cache,
congestion_net_envelope_cache,
congestion_grid_net_cache,
congestion_grid_span_cache,
config,
"sbend",
(offset, radius),

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from inire.model import resolve_bend_geometry
@ -12,6 +12,51 @@ if TYPE_CHECKING:
from inire.geometry.primitives import Port
from inire.model import RoutingOptions, RoutingProblem
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)
@ -20,6 +65,9 @@ class SearchRunConfig:
bend_physical_geometry: BendPhysicalGeometry
bend_clip_margin: float | None
node_limit: int
guidance_seed: tuple[PathSegmentSeed, ...] | None = None
guidance_bonus: float = 0.0
frontier_trace: FrontierTraceCollector | None = None
return_partial: bool = False
store_expanded: bool = False
skip_congestion: bool = False
@ -33,6 +81,9 @@ class SearchRunConfig:
*,
bend_collision_type: BendCollisionModel | 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,
store_expanded: bool = False,
skip_congestion: bool = False,
@ -49,6 +100,9 @@ class SearchRunConfig:
bend_physical_geometry=bend_physical_geometry,
bend_clip_margin=search.bend_clip_margin,
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,
store_expanded=store_expanded,
skip_congestion=skip_congestion,
@ -58,7 +112,18 @@ class SearchRunConfig:
class AStarNode:
__slots__ = ("port", "g_cost", "h_cost", "fh_cost", "parent", "component_result")
__slots__ = (
"port",
"g_cost",
"h_cost",
"fh_cost",
"parent",
"component_result",
"base_move_cost",
"cache_key",
"seed_index",
"congestion_resolved",
)
def __init__(
self,
@ -67,6 +132,11 @@ class AStarNode:
h_cost: float,
parent: AStarNode | 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:
self.port = port
self.g_cost = g_cost
@ -74,6 +144,10 @@ class AStarNode:
self.fh_cost = (g_cost + h_cost, h_cost)
self.parent = parent
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:
return self.fh_cost < other.fh_cost
@ -94,14 +168,48 @@ class AStarMetrics:
"total_warm_start_paths_used",
"total_refine_path_calls",
"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_misses",
"total_move_cache_rel_hits",
"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_hard_collision_cache_hits",
"total_congestion_cache_hits",
"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_removed",
"total_dynamic_tree_rebuilds",
@ -109,21 +217,47 @@ class AStarMetrics:
"total_static_tree_rebuilds",
"total_static_raw_tree_rebuilds",
"total_static_net_tree_rebuilds",
"total_visibility_corner_index_builds",
"total_visibility_builds",
"total_visibility_corner_pairs_checked",
"total_visibility_corner_queries",
"total_visibility_corner_hits",
"total_visibility_corner_queries_exact",
"total_visibility_corner_hits_exact",
"total_visibility_point_queries",
"total_visibility_point_cache_hits",
"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_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_exact_geometry_checks",
"total_congestion_check_calls",
"total_congestion_lazy_resolutions",
"total_congestion_lazy_requeues",
"total_congestion_candidate_ids",
"total_congestion_exact_pair_checks",
"total_verify_path_report_calls",
"total_verify_static_buffer_ops",
"total_verify_dynamic_candidate_nets",
"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",
"nodes_expanded",
"moves_generated",
@ -147,14 +281,48 @@ class AStarMetrics:
self.total_warm_start_paths_used = 0
self.total_refine_path_calls = 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_misses = 0
self.total_move_cache_rel_hits = 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_hard_collision_cache_hits = 0
self.total_congestion_cache_hits = 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_removed = 0
self.total_dynamic_tree_rebuilds = 0
@ -162,21 +330,47 @@ class AStarMetrics:
self.total_static_tree_rebuilds = 0
self.total_static_raw_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_corner_pairs_checked = 0
self.total_visibility_corner_queries = 0
self.total_visibility_corner_hits = 0
self.total_visibility_corner_queries_exact = 0
self.total_visibility_corner_hits_exact = 0
self.total_visibility_point_queries = 0
self.total_visibility_point_cache_hits = 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_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_exact_geometry_checks = 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_verify_path_report_calls = 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_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.nodes_expanded = 0
self.moves_generated = 0
@ -199,14 +393,48 @@ class AStarMetrics:
self.total_warm_start_paths_used = 0
self.total_refine_path_calls = 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_misses = 0
self.total_move_cache_rel_hits = 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_hard_collision_cache_hits = 0
self.total_congestion_cache_hits = 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_removed = 0
self.total_dynamic_tree_rebuilds = 0
@ -214,21 +442,47 @@ class AStarMetrics:
self.total_static_tree_rebuilds = 0
self.total_static_raw_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_corner_pairs_checked = 0
self.total_visibility_corner_queries = 0
self.total_visibility_corner_hits = 0
self.total_visibility_corner_queries_exact = 0
self.total_visibility_corner_hits_exact = 0
self.total_visibility_point_queries = 0
self.total_visibility_point_cache_hits = 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_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_exact_geometry_checks = 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_verify_path_report_calls = 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_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:
self.nodes_expanded = 0
@ -254,14 +508,48 @@ class AStarMetrics:
warm_start_paths_used=self.total_warm_start_paths_used,
refine_path_calls=self.total_refine_path_calls,
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_misses=self.total_move_cache_abs_misses,
move_cache_rel_hits=self.total_move_cache_rel_hits,
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,
hard_collision_cache_hits=self.total_hard_collision_cache_hits,
congestion_cache_hits=self.total_congestion_cache_hits,
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_removed=self.total_dynamic_path_objects_removed,
dynamic_tree_rebuilds=self.total_dynamic_tree_rebuilds,
@ -269,21 +557,47 @@ class AStarMetrics:
static_tree_rebuilds=self.total_static_tree_rebuilds,
static_raw_tree_rebuilds=self.total_static_raw_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_corner_pairs_checked=self.total_visibility_corner_pairs_checked,
visibility_corner_queries=self.total_visibility_corner_queries,
visibility_corner_hits=self.total_visibility_corner_hits,
visibility_corner_queries_exact=self.total_visibility_corner_queries_exact,
visibility_corner_hits_exact=self.total_visibility_corner_hits_exact,
visibility_point_queries=self.total_visibility_point_queries,
visibility_point_cache_hits=self.total_visibility_point_cache_hits,
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_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_exact_geometry_checks=self.total_ray_cast_exact_geometry_checks,
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,
verify_path_report_calls=self.total_verify_path_report_calls,
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,
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,11 +5,23 @@ import time
from dataclasses import dataclass
from typing import TYPE_CHECKING
from inire.model import NetOrder, NetSpec, resolve_bend_geometry
from inire.results import RoutingOutcome, RoutingReport, RoutingResult
from inire.router._astar_types import AStarContext, AStarMetrics, SearchRunConfig
from inire.geometry.collision import RoutingWorld
from inire.model import NetOrder, NetSpec, RoutingProblem, resolve_bend_geometry
from inire.results import (
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._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
if TYPE_CHECKING:
@ -17,6 +29,7 @@ if TYPE_CHECKING:
from shapely.geometry import Polygon
from inire.geometry.collision import PathVerificationDetail
from inire.geometry.components import ComponentResult
@ -30,6 +43,27 @@ class _RoutingState:
timeout_s: float
initial_paths: dict[str, tuple[ComponentResult, ...]] | None
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:
__slots__ = (
@ -37,6 +71,8 @@ class PathFinder:
"metrics",
"refiner",
"accumulated_expanded_nodes",
"conflict_trace",
"frontier_trace",
)
def __init__(
@ -48,16 +84,27 @@ class PathFinder:
self.metrics = self.context.metrics if metrics is None else metrics
self.context.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.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:
all_geoms: list[Polygon] = []
all_dilated: list[Polygon] = []
for result in path:
component_indexes: list[int] = []
for component_index, result in enumerate(path):
all_geoms.extend(result.collision_geometry)
all_dilated.extend(result.dilated_collision_geometry)
self.context.cost_evaluator.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated)
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,
component_indexes=component_indexes,
)
def _routing_order(
self,
@ -134,6 +181,13 @@ class PathFinder:
timeout_s=max(60.0, 10.0 * num_nets * congestion.max_iterations),
initial_paths=initial_paths,
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:
state.initial_paths = self._build_greedy_warm_start_paths(net_specs, congestion.net_order)
@ -163,6 +217,533 @@ class PathFinder:
)
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(
self,
state: _RoutingState,
@ -182,16 +763,25 @@ class PathFinder:
else:
coll_model, _ = resolve_bend_geometry(search)
skip_congestion = False
guidance_seed = None
guidance_bonus = 0.0
if congestion.use_tiered_strategy and iteration == 0:
skip_congestion = True
if coll_model == "arc":
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(
self.context.options,
bend_collision_type=coll_model,
return_partial=True,
store_expanded=diagnostics.capture_expanded,
guidance_seed=guidance_seed,
guidance_bonus=guidance_bonus,
skip_congestion=skip_congestion,
self_collision_check=(net_id in state.needs_self_collision_check),
node_limit=search.node_limit,
@ -233,29 +823,49 @@ class PathFinder:
self,
state: _RoutingState,
iteration: int,
reroute_net_ids: set[str],
iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None,
) -> dict[str, RoutingOutcome] | None:
outcomes: dict[str, RoutingOutcome] = {}
) -> _IterationReview | None:
congestion = self.context.options.congestion
self.metrics.total_route_iterations += 1
self.metrics.reset_per_route()
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
random.Random(iteration_seed).shuffle(state.ordered_net_ids)
for net_id in state.ordered_net_ids:
routed_net_ids = [net_id for net_id in state.ordered_net_ids if net_id in reroute_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:
self.metrics.total_timeout_events += 1
return None
result = self._route_net_once(state, iteration, net_id)
state.results[net_id] = result
outcomes[net_id] = result.outcome
review = self._reverify_iteration_results(state)
if iteration_callback:
iteration_callback(iteration, state.results)
return outcomes
return review
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(
self,
@ -264,10 +874,33 @@ class PathFinder:
) -> bool:
congestion = self.context.options.congestion
for iteration in range(congestion.max_iterations):
outcomes = self._run_iteration(state, iteration, iteration_callback)
if outcomes is None:
review = self._run_iteration(
state,
iteration,
set(state.ordered_net_ids),
iteration_callback,
)
if review is None:
return True
if not any(outcome in {"colliding", "partial", "unroutable"} for outcome in outcomes.values()):
self._update_best_iteration(state, review)
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
self.context.congestion_penalty *= congestion.multiplier
return False
@ -285,27 +918,49 @@ class PathFinder:
self.context.cost_evaluator.collision_engine.remove_path(net_id)
refined_path = self.refiner.refine_path(net_id, net.start, net.width, result.path)
self._install_path(net_id, refined_path)
report = self.context.cost_evaluator.collision_engine.verify_path_report(net_id, refined_path)
# Defer full verification until _verify_results() so we do not
# verify the same refined path twice in one route_all() call.
state.results[net_id] = RoutingResult(
net_id=net_id,
path=tuple(refined_path),
reached_target=result.reached_target,
report=report,
report=result.report,
)
def _verify_results(self, state: _RoutingState) -> dict[str, RoutingResult]:
final_results: dict[str, RoutingResult] = {}
details_by_net: dict[str, PathVerificationDetail] = {}
for net in self.context.problem.nets:
result = state.results.get(net.net_id)
if not result or not result.path:
final_results[net.net_id] = RoutingResult(net_id=net.net_id, path=(), reached_target=False)
continue
report = self.context.cost_evaluator.collision_engine.verify_path_report(net.net_id, result.path)
detail = self.context.cost_evaluator.collision_engine.verify_path_details(
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(
net_id=net.net_id,
path=result.path,
reached_target=result.reached_target,
report=report,
report=detail.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
@ -316,15 +971,38 @@ class PathFinder:
) -> dict[str, RoutingResult]:
self.context.congestion_penalty = self.context.options.congestion.base_penalty
self.accumulated_expanded_nodes = []
self.conflict_trace = []
self.frontier_trace = []
self.metrics.reset_totals()
self.metrics.reset_per_route()
state = self._prepare_state()
timed_out = self._run_iterations(state, iteration_callback)
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:
return self._verify_results(state)
final_results = self._verify_results(state)
self._capture_frontier_trace(state, final_results)
return final_results
self._run_pair_local_search(state)
self._refine_results(state)
return self._verify_results(state)
final_results = self._verify_results(state)
self._capture_frontier_trace(state, final_results)
return final_results

View file

@ -41,11 +41,17 @@ def route_astar(
open_set: list[_AStarNode] = []
closed_set: dict[tuple[int, int, int], float] = {}
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,
0.0,
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)
best_node = start_node
@ -89,6 +95,11 @@ def route_astar(
context,
metrics,
congestion_cache,
congestion_presence_cache,
congestion_candidate_precheck_cache,
congestion_net_envelope_cache,
congestion_grid_net_cache,
congestion_grid_span_cache,
config=config,
)

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from time import perf_counter_ns
from typing import TYPE_CHECKING
import numpy as np
@ -130,10 +131,16 @@ class CostEvaluator:
start_port: Port | None = None,
weights: ObjectiveWeights | None = None,
) -> 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)
danger_map = self.danger_map
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 metrics is not None:
metrics.total_score_component_total_ns += perf_counter_ns() - start_ns
return 1e15
move_radius = None
@ -145,7 +152,8 @@ class CostEvaluator:
weights=active_weights,
)
if danger_map is not None and active_weights.danger_weight:
# 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 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_e = danger_map.get_cost(end_port.x, end_port.y)
if start_port:
@ -155,6 +163,8 @@ class CostEvaluator:
total_cost += component.length * active_weights.danger_weight * (cost_s + cost_m + cost_e) / 3.0
else:
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
def component_penalty(
@ -181,6 +191,9 @@ class CostEvaluator:
*,
weights: ObjectiveWeights | None = None,
) -> float:
metrics = self.collision_engine.metrics
if metrics is not None:
metrics.total_path_cost_calls += 1
active_weights = self._resolve_weights(weights)
total = 0.0
current_port = start_port

View file

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

View file

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

View file

@ -16,7 +16,15 @@ class VisibilityManager:
"""
Manages corners of static obstacles for sparse A* / Visibility Graph jumps.
"""
__slots__ = ("collision_engine", "corners", "corner_index", "_corner_graph", "_point_visibility_cache", "_built_static_version")
__slots__ = (
"collision_engine",
"corners",
"corner_index",
"_corner_graph",
"_point_visibility_cache",
"_corner_index_version",
"_corner_graph_version",
)
def __init__(self, collision_engine: RoutingWorld) -> None:
self.collision_engine = collision_engine
@ -24,8 +32,8 @@ class VisibilityManager:
self.corner_index = rtree.index.Index()
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._built_static_version = -1
self._build()
self._corner_index_version = -1
self._corner_graph_version = -1
def clear_cache(self) -> None:
"""
@ -35,19 +43,31 @@ class VisibilityManager:
self.corner_index = rtree.index.Index()
self._corner_graph = {}
self._point_visibility_cache = {}
self._build()
self._corner_index_version = -1
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:
if self._built_static_version != self.collision_engine.get_static_version():
self.clear_cache()
self.ensure_corner_graph_current()
def _build(self) -> None:
"""
Extract corners and pre-compute corner-to-corner visibility.
"""
def _build_corner_index(self) -> None:
if self.collision_engine.metrics is not None:
self.collision_engine.metrics.total_visibility_builds += 1
self._built_static_version = self.collision_engine.get_static_version()
self.collision_engine.metrics.total_visibility_corner_index_builds += 1
self.corners = []
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 = []
for poly in self.collision_engine.iter_static_dilated_geometries():
coords = list(poly.exterior.coords)
@ -63,7 +83,6 @@ class VisibilityManager:
if not raw_corners:
return
# Deduplicate repeated corner coordinates
seen = set()
for x, y in raw_corners:
sx, sy = round(x, 3), round(y, 3)
@ -71,10 +90,22 @@ class VisibilityManager:
seen.add((sx, sy))
self.corners.append((sx, sy))
# Build spatial index for corners
for i, (x, y) in enumerate(self.corners):
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
num_corners = len(self.corners)
if num_corners > 200:
@ -93,11 +124,12 @@ class VisibilityManager:
dx, dy = cx - p1.x, cy - p1.y
dist = numpy.sqrt(dx**2 + dy**2)
angle = numpy.degrees(numpy.arctan2(dy, dx))
reach = self.collision_engine.ray_cast(p1, angle, max_dist=dist + 0.05)
reach = self.collision_engine.ray_cast(p1, angle, max_dist=dist + 0.05, caller="visibility_build")
if reach >= dist - 0.01:
self._corner_graph[i].append((cx, cy, dist))
def _corner_idx_at(self, origin: Port) -> int | None:
self.ensure_corner_index_current()
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)))
for idx in nearby:
@ -106,6 +138,49 @@ class VisibilityManager:
return idx
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]]:
"""
Find visible corners from an arbitrary point.
@ -113,11 +188,13 @@ class VisibilityManager:
"""
if self.collision_engine.metrics is not None:
self.collision_engine.metrics.total_visibility_point_queries += 1
self._ensure_current()
self.ensure_corner_index_current()
if max_dist < 0:
return []
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:
return [corner for corner in self._corner_graph[corner_idx] if corner[2] <= max_dist]
@ -143,7 +220,7 @@ class VisibilityManager:
continue
angle = numpy.degrees(numpy.arctan2(dy, dx))
reach = self.collision_engine.ray_cast(origin, angle, max_dist=dist + 0.05)
reach = self.collision_engine.ray_cast(origin, angle, max_dist=dist + 0.05, caller="visibility_query")
if reach >= dist - 0.01:
visible.append((cx, cy, dist))
@ -156,14 +233,14 @@ class VisibilityManager:
This avoids the expensive arbitrary-point visibility scan in hot search paths.
"""
if self.collision_engine.metrics is not None:
self.collision_engine.metrics.total_visibility_corner_queries += 1
self._ensure_current()
self.collision_engine.metrics.total_visibility_corner_queries_exact += 1
self.ensure_corner_graph_current()
if max_dist < 0:
return []
corner_idx = self._corner_idx_at(origin)
if corner_idx is not None and corner_idx in self._corner_graph:
if self.collision_engine.metrics is not None:
self.collision_engine.metrics.total_visibility_corner_hits += 1
self.collision_engine.metrics.total_visibility_corner_hits_exact += 1
return [corner for corner in self._corner_graph[corner_idx] if corner[2] <= max_dist]
return []

View file

@ -15,6 +15,7 @@ from inire import (
RoutingOptions,
RoutingProblem,
RoutingResult,
RoutingRunResult,
SearchOptions,
)
from inire.geometry.collision import RoutingWorld
@ -34,6 +35,7 @@ _OBJECTIVE_FIELDS = set(ObjectiveWeights.__dataclass_fields__)
ScenarioOutcome = tuple[float, int, int, int]
ScenarioRun = Callable[[], ScenarioOutcome]
ScenarioSnapshotRun = Callable[[], "ScenarioSnapshot"]
TraceScenarioRun = Callable[[], RoutingRunResult]
@dataclass(frozen=True, slots=True)
@ -79,6 +81,19 @@ 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:
metric_names = RouteMetrics.__dataclass_fields__
return RouteMetrics(
@ -318,6 +333,24 @@ def run_example_05() -> ScenarioOutcome:
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:
bounds = (-20, -20, 170, 170)
obstacles = [
@ -378,12 +411,40 @@ def run_example_06() -> ScenarioOutcome:
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)
obstacles = [
box(450, 0, 550, 400),
box(450, 600, 550, 1000),
]
num_nets = 10
start_x = 50
start_y_base = 500 - (num_nets * 10.0) / 2.0
end_x = 950
@ -418,10 +479,31 @@ def snapshot_example_07() -> ScenarioSnapshot:
"multiplier": 1.4,
"net_order": "shortest",
"capture_expanded": True,
"capture_conflict_trace": capture_conflict_trace,
"capture_frontier_trace": capture_frontier_trace,
"shuffle_nets": True,
"seed": 42,
"seed": seed,
"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:
_ = current_results
@ -429,10 +511,36 @@ def snapshot_example_07() -> ScenarioSnapshot:
evaluator.greedy_h_weight = new_greedy
metrics.reset_per_route()
t0 = perf_counter()
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()
run = _run_example_07_variant(
num_nets=10,
seed=42,
warm_start_enabled=warm_start_enabled,
)
t1 = perf_counter()
return _make_snapshot("example_07_large_scale_routing", results, t1 - t0, pathfinder.metrics.snapshot())
return _make_snapshot(name, run.results_by_net, t1 - t0, run.metrics)
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:
@ -534,6 +642,19 @@ SCENARIO_SNAPSHOTS: tuple[tuple[str, ScenarioSnapshotRun], ...] = (
("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, ...]:
return tuple(run() for _, run in SCENARIO_SNAPSHOTS)

View file

@ -16,8 +16,12 @@ from inire import (
route,
)
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:
import inire
@ -48,6 +52,8 @@ def test_route_problem_smoke() -> None:
assert set(run.results_by_net) == {"net1"}
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:
@ -77,14 +83,353 @@ def test_route_problem_supports_configs_and_debug_data() -> None:
assert run.expanded_nodes
assert run.metrics.nodes_expanded > 0
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.move_cache_abs_misses >= 0
assert run.metrics.ray_cast_calls >= 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.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.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:
locked = (Straight.generate(Port(10, 50, 0), 80.0, 2.0, dilation=1.0),)
problem = RoutingProblem(

View file

@ -1,16 +1,22 @@
import math
import pytest
from shapely.geometry import Polygon
from inire import RoutingProblem, RoutingOptions, RoutingResult, SearchOptions
from inire import CongestionOptions, NetSpec, RoutingProblem, RoutingOptions, RoutingResult, SearchOptions
from inire.geometry.components import Bend90, Straight
from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port
from inire.router._astar_types import AStarContext, SearchRunConfig
from inire.router._astar_types import AStarContext, AStarNode, 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.cost import CostEvaluator
from inire.router.danger_map import DangerMap
from inire.seeds import StraightSeed
BOUNDS = (0, -50, 150, 150)
@ -214,6 +220,84 @@ 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
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:
context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), bend_collision_type="arc")
@ -301,6 +385,27 @@ def test_route_astar_supports_all_visibility_guidance_modes(
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:
context = AStarContext(
basic_evaluator,
@ -318,3 +423,244 @@ def test_route_astar_repeated_searches_succeed_with_small_cache_limit(basic_eval
path = _route(context, start, target)
assert path is not None
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,6 +1,11 @@
from shapely.geometry import box
from inire.geometry.collision import RoutingWorld
from inire.geometry.components import ComponentResult
from inire.geometry.components import Straight
from inire.geometry.primitives import Port
from inire.router._astar_types import AStarMetrics
from inire.seeds import StraightSeed
def _install_static_straight(
@ -82,6 +87,7 @@ def test_check_move_static_clearance() -> None:
def test_verify_path_report_preserves_long_net_id() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
net_id = "net_abcdefghijklmnopqrstuvwxyz_0123456789"
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]
@ -91,10 +97,12 @@ def test_verify_path_report_preserves_long_net_id() -> None:
report = engine.verify_path_report(net_id, path)
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:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
shared_prefix = "net_shared_prefix_abcdefghijklmnopqrstuvwxyz_"
net_a = f"{shared_prefix}A"
net_b = f"{shared_prefix}B"
@ -115,6 +123,111 @@ def test_verify_path_report_distinguishes_long_net_ids_with_shared_prefix() -> N
report = engine.verify_path_report(net_a, path_a)
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:
@ -129,3 +242,247 @@ def test_remove_path_clears_dynamic_path() -> None:
engine.remove_path("netA")
assert list(engine._dynamic_paths.geometries.values()) == []
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
from inire.tests.example_scenarios import SCENARIOS, ScenarioOutcome
from inire.tests.example_scenarios import SCENARIOS, ScenarioOutcome, snapshot_example_07_no_warm_start
if TYPE_CHECKING:
from collections.abc import Callable
@ -15,6 +15,7 @@ if TYPE_CHECKING:
RUN_PERFORMANCE = os.environ.get("INIRE_RUN_PERFORMANCE") == "1"
PERFORMANCE_REPEATS = 3
REGRESSION_FACTOR = 1.5
NO_WARM_START_REGRESSION_SECONDS = 15.0
# Baselines are measured from clean 6a28dcf-style runs without plotting.
BASELINE_SECONDS = {
@ -67,3 +68,20 @@ def test_example_like_runtime_regression(scenario: tuple[str, Callable[[], Scena
f"{REGRESSION_FACTOR:.1f}x baseline {BASELINE_SECONDS[name]:.4f}s "
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,7 +14,16 @@ from inire import (
)
from inire.router._stack import build_routing_stack
from inire.seeds import Bend90Seed, PathSeed, StraightSeed
from inire.tests.example_scenarios import SCENARIOS, _build_evaluator, _build_pathfinder, _net_specs, AStarMetrics
from inire.tests.example_scenarios import (
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 = {
@ -36,6 +45,43 @@ def test_examples_match_legacy_expected_outcomes(name: str, run) -> None:
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:
bounds = (-20, -20, 170, 170)
obstacles = (

View file

@ -16,10 +16,38 @@ def test_snapshot_example_01_exposes_metrics() -> None:
assert snapshot.metrics.route_iterations >= 1
assert snapshot.metrics.nets_routed >= 1
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.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_expand_forward >= 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.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:
@ -43,3 +71,236 @@ def test_record_performance_baseline_script_writes_selected_scenario(tmp_path: P
assert payload["generator"] == "scripts/record_performance_baseline.py"
assert [entry["name"] for entry in payload["scenarios"]] == ["example_01_simple_route"]
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,6 +2,7 @@ from shapely.geometry import box
from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port
from inire.router._astar_types import AStarMetrics
from inire.router.visibility import VisibilityManager
@ -18,3 +19,111 @@ def test_point_visibility_cache_respects_max_distance() -> None:
assert len(near_corners) == 3
assert len(far_corners) > len(near_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

@ -0,0 +1,177 @@
#!/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

@ -0,0 +1,237 @@
#!/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

@ -0,0 +1,228 @@
#!/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

@ -0,0 +1,205 @@
#!/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 pathlib import Path
from inire.tests.example_scenarios import SCENARIO_SNAPSHOTS
from inire.tests.example_scenarios import PERFORMANCE_SCENARIO_SNAPSHOTS, SCENARIO_SNAPSHOTS
SUMMARY_METRICS = (
@ -24,10 +24,20 @@ SUMMARY_METRICS = (
)
def _build_payload(selected_scenarios: tuple[str, ...] | None = None) -> dict[str, object]:
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 _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)
snapshots = []
for name, run in SCENARIO_SNAPSHOTS:
for name, run in _snapshot_registry(include_performance_only):
if allowed is not None and name not in allowed:
continue
snapshots.append(run())
@ -46,6 +56,7 @@ def _render_markdown(payload: dict[str, object]) -> str:
f"Generated on {payload['generated_on']} by `{payload['generator']}`.",
"",
"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 |",
"| :-- | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: |",
@ -77,6 +88,7 @@ def _render_markdown(payload: dict[str, object]) -> str:
"## Full Counter Set",
"",
"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:",
"",
@ -101,6 +113,11 @@ def main() -> None:
default=[],
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()
repo_root = Path(__file__).resolve().parents[1]
@ -108,7 +125,7 @@ def main() -> None:
docs_dir.mkdir(exist_ok=True)
selected = tuple(args.scenarios) if args.scenarios else None
payload = _build_payload(selected)
payload = _build_payload(selected, include_performance_only=args.include_performance_only)
json_path = docs_dir / "performance_baseline.json"
markdown_path = docs_dir / "performance.md"